改用gray_matter解析md头元数据,增加相应的日志
This commit is contained in:
parent
141ffda1ee
commit
56612389ae
82
Cargo.lock
generated
82
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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)> {
|
||||
// 兼容 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]
|
||||
|
||||
86
src/frontmatter.rs
Normal file
86
src/frontmatter.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user