diff --git a/Cargo.lock b/Cargo.lock index 8ace39a..7631ec6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 42f362d..be49795 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [ diff --git a/src/experts/mod.rs b/src/experts/mod.rs index ed3a816..8b0ae5a 100644 --- a/src/experts/mod.rs +++ b/src/experts/mod.rs @@ -795,11 +795,14 @@ fn load_experts_from_root(root: &Path, source: ExpertSource) -> Vec { fn parse_expert_file(path: &Path, source: ExpertSource) -> Result { 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::(&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 Option<(&str, &str)> { - // 兼容 CRLF(Windows)和 LF(Unix)行尾符 - 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] diff --git a/src/frontmatter.rs b/src/frontmatter.rs new file mode 100644 index 0000000..763b8f6 --- /dev/null +++ b/src/frontmatter.rs @@ -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(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::::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, + } + + #[test] + fn parses_lf_endings() { + let input = "---\ndescription: demo\n---\nbody text"; + let (fm, body) = parse::(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::(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::(input).unwrap(); + assert_eq!(fm.description, "demo"); + assert_eq!(body, "body text"); + } + + #[test] + fn missing_block_is_rejected() { + let err = parse::("no front matter here").unwrap_err(); + assert_eq!(err, "missing YAML frontmatter block"); + } + + #[test] + fn invalid_yaml_is_rejected() { + let err = parse::("---\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::(input).unwrap(); + assert_eq!(body, "excerpt\n---\nmore content"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3befbd0..ae8ec53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 4cc1243..62f0766 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -867,11 +867,14 @@ struct SkillFrontmatter { fn parse_skill_file(path: &Path, source: SkillSource) -> Result { 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::(&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 { }) } -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] diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 2daae49..68f6674 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -1467,11 +1467,14 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result(&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 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]