改用gray_matter解析md头元数据,增加相应的日志

This commit is contained in:
xiaoxixi 2026-07-21 16:33:55 +08:00
parent 141ffda1ee
commit 56612389ae
7 changed files with 221 additions and 98 deletions

82
Cargo.lock generated
View File

@ -19,6 +19,18 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -28,6 +40,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
@ -93,6 +111,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "async-trait"
version = "0.1.89"
@ -545,7 +569,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -616,7 +640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -844,6 +868,27 @@ dependencies = [
"weezl",
]
[[package]]
name = "gray_matter"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8666976c40b8633f918783969b6681a3ddb205f29150348617de425d85a3e3bd"
dependencies = [
"serde",
"serde_json",
"yaml-rust2",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@ -868,6 +913,15 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hashlink"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "hashlink"
version = "0.11.1"
@ -1485,7 +1539,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -1596,6 +1650,7 @@ dependencies = [
"dotenv",
"encoding_rs",
"futures-util",
"gray_matter",
"http",
"iana-time-zone",
"image",
@ -2123,7 +2178,7 @@ dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"hashlink 0.11.1",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
@ -2188,7 +2243,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2247,7 +2302,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2656,7 +2711,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3347,7 +3402,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3657,6 +3712,17 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yaml-rust2"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8"
dependencies = [
"arraydeque",
"encoding_rs",
"hashlink 0.8.4",
]
[[package]]
name = "yoke"
version = "0.8.2"

View File

@ -41,6 +41,7 @@ rustls = { version = "0.23", features = ["ring"] }
wechatbot = { path = "vendor/wechatbot" }
encoding_rs = "0.8"
libc = "0.2"
gray_matter = { version = "0.2", default-features = false, features = ["yaml"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [

View File

@ -795,11 +795,14 @@ fn load_experts_from_root(root: &Path, source: ExpertSource) -> Vec<Expert> {
fn parse_expert_file(path: &Path, source: ExpertSource) -> Result<Expert, String> {
let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?;
let (frontmatter_raw, body) =
split_frontmatter(&content).ok_or_else(|| "missing YAML frontmatter block".to_string())?;
let frontmatter: ExpertFrontmatter = serde_yaml::from_str(frontmatter_raw)
.map_err(|e| format!("invalid YAML frontmatter: {}", e))?;
let (frontmatter, body) = match crate::frontmatter::parse::<ExpertFrontmatter>(&content) {
Ok(v) => v,
Err(err) => {
let bytes = content.len();
let crlf = content.contains('\r');
return Err(format!("{} (bytes={}, crlf={})", err, bytes, crlf));
}
};
let description = frontmatter.description.trim();
if description.is_empty() {
@ -823,26 +826,6 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result<Expert, String
})
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
// 兼容 CRLFWindows和 LFUnix行尾符
let rest = content
.strip_prefix("---\n")
.or_else(|| content.strip_prefix("---\r\n"))?;
let marker = "\n---\n";
let marker_crlf = "\n---\r\n";
if let Some(idx) = rest.find(marker) {
let frontmatter = &rest[..idx];
let body = &rest[idx + marker.len()..];
Some((frontmatter, body))
} else if let Some(idx) = rest.find(marker_crlf) {
let frontmatter = &rest[..idx];
let body = &rest[idx + marker_crlf.len()..];
Some((frontmatter, body))
} else {
None
}
}
// ========== State file I/O ==========
fn load_expert_disable_state(cwd: &Path) -> ExpertDisableState {
@ -992,11 +975,17 @@ mod tests {
}
#[test]
fn test_split_frontmatter() {
let input = "---\ndescription: demo\n---\nhello";
let (fm, body) = split_frontmatter(input).unwrap();
assert!(fm.contains("description"));
assert_eq!(body, "hello");
fn test_parse_expert_file_handles_crlf_endings() {
let dir = tempfile::tempdir().unwrap();
let expert_dir = dir.path().join("demo");
fs::create_dir_all(&expert_dir).unwrap();
let expert_md = expert_dir.join("EXPERT.md");
fs::write(&expert_md, "---\r\ndescription: demo expert\r\n---\r\nStep A\r\nStep B").unwrap();
let expert = parse_expert_file(&expert_md, ExpertSource::Project).unwrap();
assert_eq!(expert.name, "demo");
assert_eq!(expert.description, "demo expert");
assert_eq!(expert.body, "Step A\nStep B");
}
#[test]

86
src/frontmatter.rs Normal file
View File

@ -0,0 +1,86 @@
use gray_matter::engine::YAML;
use gray_matter::Matter;
use serde::de::DeserializeOwned;
/// Parse a markdown document with YAML frontmatter into `(frontmatter, body)`.
///
/// Tolerates CRLF, CR, and LF line endings. A `---` appearing inside the body
/// is not treated as a delimiter (only the leading frontmatter block is split).
///
/// - Returns `Err("missing YAML frontmatter block")` when no leading `---`
/// delimiter is present.
/// - Returns `Err("invalid YAML frontmatter: {e}")` when the block is present
/// but the YAML fails to parse or deserialize into `T`.
pub fn parse<T: DeserializeOwned>(content: &str) -> Result<(T, String), String> {
let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
if !normalized.starts_with("---\n") {
return Err("missing YAML frontmatter block".to_string());
}
let matter = Matter::<YAML>::new();
let result = matter.parse(&normalized);
let pod = result
.data
.ok_or_else(|| "invalid YAML frontmatter".to_string())?;
let data: T = pod
.deserialize()
.map_err(|e| format!("invalid YAML frontmatter: {}", e))?;
Ok((data, result.content))
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
struct FrontMatter {
description: String,
#[serde(default)]
name: Option<String>,
}
#[test]
fn parses_lf_endings() {
let input = "---\ndescription: demo\n---\nbody text";
let (fm, body) = parse::<FrontMatter>(input).unwrap();
assert_eq!(fm, FrontMatter { description: "demo".to_string(), name: None });
assert_eq!(body, "body text");
}
#[test]
fn parses_crlf_endings() {
let input = "---\r\ndescription: demo\r\n---\r\nbody text";
let (fm, body) = parse::<FrontMatter>(input).unwrap();
assert_eq!(fm.description, "demo");
assert_eq!(body, "body text");
}
#[test]
fn parses_cr_endings() {
let input = "---\rdescription: demo\r---\rbody text";
let (fm, body) = parse::<FrontMatter>(input).unwrap();
assert_eq!(fm.description, "demo");
assert_eq!(body, "body text");
}
#[test]
fn missing_block_is_rejected() {
let err = parse::<FrontMatter>("no front matter here").unwrap_err();
assert_eq!(err, "missing YAML frontmatter block");
}
#[test]
fn invalid_yaml_is_rejected() {
let err = parse::<FrontMatter>("---\n: : bad\n---\nbody").unwrap_err();
assert!(err.starts_with("invalid YAML frontmatter"));
}
#[test]
fn body_with_inner_delimiter_is_preserved() {
let input = "---\ndescription: demo\n---\nexcerpt\n---\nmore content";
let (_fm, body) = parse::<FrontMatter>(input).unwrap();
assert_eq!(body, "excerpt\n---\nmore content");
}
}

View File

@ -8,6 +8,7 @@ pub mod command;
pub mod config;
pub mod domain;
pub mod experts;
pub mod frontmatter;
pub mod gateway;
pub mod logging;
pub mod mcp;

View File

@ -867,11 +867,14 @@ struct SkillFrontmatter {
fn parse_skill_file(path: &Path, source: SkillSource) -> Result<Skill, String> {
let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?;
let (frontmatter_raw, body) =
split_frontmatter(&content).ok_or_else(|| "missing YAML frontmatter block".to_string())?;
let frontmatter: SkillFrontmatter = serde_yaml::from_str(frontmatter_raw)
.map_err(|e| format!("invalid YAML frontmatter: {}", e))?;
let (frontmatter, body) = match crate::frontmatter::parse::<SkillFrontmatter>(&content) {
Ok(v) => v,
Err(err) => {
let bytes = content.len();
let crlf = content.contains('\r');
return Err(format!("{} (bytes={}, crlf={})", err, bytes, crlf));
}
};
let description = frontmatter.description.trim();
if description.is_empty() {
@ -895,15 +898,6 @@ fn parse_skill_file(path: &Path, source: SkillSource) -> Result<Skill, String> {
})
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let rest = content.strip_prefix("---\n")?;
let marker = "\n---\n";
let idx = rest.find(marker)?;
let frontmatter = &rest[..idx];
let body = &rest[idx + marker.len()..];
Some((frontmatter, body))
}
// 使用 platform 模块提供的 xml_escape 和 path_to_uri 函数
// SkillPromptProvider 实现
@ -999,11 +993,17 @@ mod tests {
}
#[test]
fn test_split_frontmatter() {
let input = "---\ndescription: demo\n---\nhello";
let (fm, body) = split_frontmatter(input).unwrap();
assert!(fm.contains("description"));
assert_eq!(body, "hello");
fn test_parse_skill_file_handles_crlf_endings() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("demo");
fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md");
fs::write(&skill_md, "---\r\ndescription: demo skill\r\n---\r\nStep A\r\nStep B").unwrap();
let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap();
assert_eq!(skill.name, "demo");
assert_eq!(skill.description, "demo skill");
assert_eq!(skill.body, "Step A\nStep B");
}
#[test]

View File

@ -1467,11 +1467,14 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
let content = fs::read_to_string(path)
.map_err(|e| format!("failed to read file: {}", e))?;
let (frontmatter_raw, body) = split_frontmatter(&content)
.ok_or_else(|| "missing YAML frontmatter block".to_string())?;
let frontmatter: SubagentFrontmatter = serde_yaml::from_str(frontmatter_raw)
.map_err(|e| format!("invalid YAML frontmatter: {}", e))?;
let (frontmatter, body) = match crate::frontmatter::parse::<SubagentFrontmatter>(&content) {
Ok(v) => v,
Err(err) => {
let bytes = content.len();
let crlf = content.contains('\r');
return Err(format!("{} (bytes={}, crlf={})", err, bytes, crlf));
}
};
if frontmatter.description.trim().is_empty() {
return Err("description is required and cannot be empty".to_string());
@ -1501,37 +1504,6 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
})
}
/// 分割 frontmatter 和 body
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
// 跳过开头的 ---
let content = content
.strip_prefix("---")
.or_else(|| content.strip_prefix("---"))?;
// 跳过 --- 后的换行符和可能的空行
let content = content.trim_start_matches('\r').trim_start_matches('\n');
// 找结束标记(容忍不同的换行符格式和前面的空行)
// 尝试多种可能的结束标记格式
let end_markers = ["\n---\n", "\n---", "\r\n---\r\n", "\r\n---"];
let mut idx = None;
let mut marker_len = 0;
for marker in end_markers {
if let Some(pos) = content.find(marker) {
idx = Some(pos);
marker_len = marker.len();
break;
}
}
let idx = idx?;
let frontmatter = &content[..idx];
let body = &content[idx + marker_len..];
let body = body.trim_start_matches('\r').trim_start_matches('\n');
Some((frontmatter, body))
}
#[cfg(test)]
mod tests {
use super::*;
@ -1817,11 +1789,19 @@ mod tests {
// ===== frontmatter 解析denied_tools测试 =====
#[test]
fn split_frontmatter_extracts_yaml_and_body() {
let content = "---\nname: x\ndescription: y\n---\nbody text";
let (fm, body) = split_frontmatter(content).expect("should split");
assert!(fm.contains("description: y"));
assert_eq!(body, "body text");
fn parse_subagent_file_handles_crlf_endings() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("SUBAGENT.md");
std::fs::write(
&path,
"---\r\nname: demo\r\ndescription: demo subagent\r\n---\r\nStep A\r\nStep B",
)
.unwrap();
let subagent = parse_subagent_file(&path, SubagentSource::Project).unwrap();
assert_eq!(subagent.name, "demo");
assert_eq!(subagent.description, "demo subagent");
assert_eq!(subagent.body.as_deref(), Some("Step A\nStep B"));
}
#[test]