claude_agent/common/
frontmatter.rs

1//! Frontmatter parsing utilities for Progressive Disclosure.
2//!
3//! Provides generic frontmatter parsing for all Index types.
4//! Supports YAML frontmatter delimited by `---` markers.
5
6use serde::de::DeserializeOwned;
7
8/// Parsed document containing frontmatter metadata and body content.
9pub struct ParsedDocument<F> {
10    pub frontmatter: F,
11    pub body: String,
12}
13
14/// Strip YAML frontmatter from content, returning body only.
15///
16/// This is a lightweight operation that returns a slice (no allocation).
17/// Use when you only need the body content without parsing metadata.
18///
19/// # Examples
20/// ```
21/// use claude_agent::common::strip_frontmatter;
22///
23/// let content = "---\nname: test\n---\nBody content";
24/// assert_eq!(strip_frontmatter(content), "Body content");
25///
26/// let no_frontmatter = "Just content";
27/// assert_eq!(strip_frontmatter(no_frontmatter), "Just content");
28/// ```
29pub fn strip_frontmatter(content: &str) -> &str {
30    if let Some(after_first) = content.strip_prefix("---")
31        && let Some(end_pos) = after_first.find("---")
32    {
33        return after_first[end_pos + 3..].trim_start();
34    }
35    content
36}
37
38/// Parse frontmatter from content, returning structured metadata and body.
39///
40/// Returns an error if frontmatter is missing or malformed.
41pub fn parse_frontmatter<F: DeserializeOwned>(content: &str) -> crate::Result<ParsedDocument<F>> {
42    if !content.starts_with("---") {
43        return Err(crate::Error::Config(
44            "Document must have YAML frontmatter (starting with ---)".to_string(),
45        ));
46    }
47
48    let after_first = &content[3..];
49    let end_pos = after_first.find("---").ok_or_else(|| {
50        crate::Error::Config("Frontmatter not properly terminated with ---".to_string())
51    })?;
52
53    let frontmatter_str = after_first[..end_pos].trim();
54    let body = after_first[end_pos + 3..].trim().to_string();
55
56    let frontmatter: F = serde_yaml_bw::from_str(frontmatter_str)
57        .map_err(|e| crate::Error::Config(format!("Failed to parse frontmatter: {}", e)))?;
58
59    Ok(ParsedDocument { frontmatter, body })
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use serde::Deserialize;
66
67    #[derive(Debug, Deserialize, PartialEq)]
68    struct TestFrontmatter {
69        name: String,
70        #[serde(default)]
71        description: String,
72    }
73
74    #[test]
75    fn test_parse_valid() {
76        let content = r#"---
77name: test
78description: A test
79---
80
81Body content here."#;
82
83        let doc = parse_frontmatter::<TestFrontmatter>(content).unwrap();
84        assert_eq!(doc.frontmatter.name, "test");
85        assert_eq!(doc.frontmatter.description, "A test");
86        assert_eq!(doc.body, "Body content here.");
87    }
88
89    #[test]
90    fn test_parse_no_frontmatter() {
91        let content = "Just content without frontmatter";
92        let result = parse_frontmatter::<TestFrontmatter>(content);
93        assert!(result.is_err());
94    }
95
96    #[test]
97    fn test_parse_unterminated() {
98        let content = "---\nname: test\nNo closing delimiter";
99        let result = parse_frontmatter::<TestFrontmatter>(content);
100        assert!(result.is_err());
101    }
102
103    #[test]
104    fn test_parse_empty_body() {
105        let content = r#"---
106name: minimal
107---
108"#;
109
110        let doc = parse_frontmatter::<TestFrontmatter>(content).unwrap();
111        assert_eq!(doc.frontmatter.name, "minimal");
112        assert!(doc.body.is_empty());
113    }
114}