Skip to main content

ai_agent/utils/plugins/
frontmatter_parser.rs

1// Source: ~/claudecode/openclaudecode/src/utils/plugins/frontmatterParser.ts
2//! Frontmatter parser for plugin markdown files.
3//!
4//! Parses YAML frontmatter (delimited by `---` markers) from markdown content
5//! and returns the parsed frontmatter as a JSON object along with the remaining
6//! markdown body.
7
8use serde_yaml;
9
10/// Result of parsing frontmatter: (frontmatter map, remaining markdown content)
11pub fn parse_frontmatter<'a>(content: &'a str, path: &str) -> (serde_json::Map<String, serde_json::Value>, &'a str) {
12    let content = content.trim_start();
13
14    // Content must start with the frontmatter delimiter
15    if !content.starts_with("---") {
16        return (serde_json::Map::new(), content);
17    }
18
19    // Find the closing delimiter
20    let rest = &content[3..]; // skip opening "---"
21    if let Some(end_pos) = rest.find("\n---") {
22        let frontmatter_yaml = &rest[..end_pos];
23        let markdown_body = &rest[end_pos + 4..]; // skip "\n---" and newline after
24
25        // Parse the YAML frontmatter
26        match serde_yaml::from_str::<serde_yaml::Value>(frontmatter_yaml) {
27            Ok(yaml_value) => {
28                let fm = yaml_to_json_map(&yaml_value);
29                (fm, markdown_body.trim_start())
30            }
31            Err(e) => {
32                log::warn!(
33                    "[frontmatter] Failed to parse frontmatter in {}: {}",
34                    path,
35                    e
36                );
37                // Return empty frontmatter with full content
38                (serde_json::Map::new(), content)
39            }
40        }
41    } else {
42        // No closing delimiter found - treat entire content as markdown
43        (serde_json::Map::new(), content)
44    }
45}
46
47/// Convert a serde_yaml::Value to a serde_json::Map<String, serde_json::Value>.
48fn yaml_to_json_map(value: &serde_yaml::Value) -> serde_json::Map<String, serde_json::Value> {
49    match value {
50        serde_yaml::Value::Mapping(map) => {
51            let mut result = serde_json::Map::new();
52            for (k, v) in map {
53                if let Some(key) = k.as_str() {
54                    result.insert(key.to_string(), yaml_value_to_json(v));
55                }
56            }
57            result
58        }
59        _ => serde_json::Map::new(),
60    }
61}
62
63/// Convert a single serde_yaml::Value to serde_json::Value.
64fn yaml_value_to_json(value: &serde_yaml::Value) -> serde_json::Value {
65    match value {
66        serde_yaml::Value::Null => serde_json::Value::Null,
67        serde_yaml::Value::Bool(b) => serde_json::Value::Bool(*b),
68        serde_yaml::Value::Number(n) => {
69            if let Some(i) = n.as_i64() {
70                serde_json::Value::Number(serde_json::Number::from(i))
71            } else if let Some(f) = n.as_f64() {
72                serde_json::Number::from_f64(f)
73                    .map(serde_json::Value::Number)
74                    .unwrap_or(serde_json::Value::Null)
75            } else {
76                serde_json::Value::Null
77            }
78        }
79        serde_yaml::Value::String(s) => serde_json::Value::String(s.clone()),
80        serde_yaml::Value::Sequence(seq) => {
81            serde_json::Value::Array(seq.iter().map(yaml_value_to_json).collect())
82        }
83        serde_yaml::Value::Mapping(map) => {
84            let mut obj = serde_json::Map::new();
85            for (k, v) in map {
86                if let Some(key) = k.as_str() {
87                    obj.insert(key.to_string(), yaml_value_to_json(v));
88                }
89            }
90            serde_json::Value::Object(obj)
91        }
92        serde_yaml::Value::Tagged(tagged) => {
93            // Handle tagged values (e.g., !!str "value") - unwrap the inner value
94            yaml_value_to_json(&tagged.value)
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn test_parse_frontmatter_basic() {
105        let content = "---\nname: My Agent\ndescription: A test agent\n---\n\n# Agent body";
106        let (fm, body) = parse_frontmatter(content, "test.md");
107        assert_eq!(fm.get("name").and_then(|v| v.as_str()), Some("My Agent"));
108        assert_eq!(fm.get("description").and_then(|v| v.as_str()), Some("A test agent"));
109        assert_eq!(body, "# Agent body");
110    }
111
112    #[test]
113    fn test_parse_frontmatter_no_frontmatter() {
114        let content = "# Just markdown";
115        let (fm, body) = parse_frontmatter(content, "test.md");
116        assert!(fm.is_empty());
117        assert_eq!(body, "# Just markdown");
118    }
119
120    #[test]
121    fn test_parse_frontmatter_no_closing_delimiter() {
122        let content = "---\nname: unclosed";
123        let (fm, body) = parse_frontmatter(content, "test.md");
124        assert!(fm.is_empty());
125        assert_eq!(body, "---\nname: unclosed");
126    }
127
128    #[test]
129    fn test_parse_frontmatter_array_value() {
130        let content = "---\nname: Test\ntools:\n  - Read\n  - Bash\n---\n\nBody";
131        let (fm, _body) = parse_frontmatter(content, "test.md");
132        let tools = fm.get("tools").and_then(|v| v.as_array());
133        assert!(tools.is_some());
134        let tools = tools.unwrap();
135        assert_eq!(tools.len(), 2);
136        assert_eq!(tools[0].as_str(), Some("Read"));
137        assert_eq!(tools[1].as_str(), Some("Bash"));
138    }
139
140    #[test]
141    fn test_parse_frontmatter_empty() {
142        let content = "";
143        let (fm, body) = parse_frontmatter(content, "test.md");
144        assert!(fm.is_empty());
145        assert_eq!(body, "");
146    }
147
148    #[test]
149    fn test_parse_frontmatter_leading_whitespace() {
150        let content = "  \n---\nname: Test\n---\nBody";
151        let (fm, body) = parse_frontmatter(content, "test.md");
152        assert_eq!(fm.get("name").and_then(|v| v.as_str()), Some("Test"));
153        assert_eq!(body, "Body");
154    }
155
156    #[test]
157    fn test_parse_frontmatter_boolean_value() {
158        let content = "---\nname: Test\nbackground: true\n---\n\nBody";
159        let (fm, _body) = parse_frontmatter(content, "test.md");
160        assert_eq!(fm.get("background").and_then(|v| v.as_bool()), Some(true));
161    }
162
163    #[test]
164    fn test_parse_frontmatter_nested_object() {
165        let content = "---\nname: Test\nconfig:\n  key: value\n---\n\nBody";
166        let (fm, _body) = parse_frontmatter(content, "test.md");
167        let config = fm.get("config").and_then(|v| v.as_object());
168        assert!(config.is_some());
169        let config = config.unwrap();
170        assert_eq!(config.get("key").and_then(|v| v.as_str()), Some("value"));
171    }
172}