Skip to main content

cc_audit/parser/
frontmatter.rs

1//! YAML frontmatter parser for markdown files.
2
3/// Parses YAML frontmatter from markdown files.
4pub struct FrontmatterParser;
5
6impl FrontmatterParser {
7    /// Extract frontmatter content from a markdown file.
8    ///
9    /// Frontmatter is delimited by `---` at the start and end.
10    /// Returns None if no valid frontmatter is found.
11    ///
12    /// # Example
13    /// ```
14    /// use cc_audit::parser::FrontmatterParser;
15    /// let content = "---\nname: test\n---\n# Content";
16    /// let frontmatter = FrontmatterParser::extract(content);
17    /// assert_eq!(frontmatter, Some("\nname: test\n"));
18    /// ```
19    pub fn extract(content: &str) -> Option<&str> {
20        // The opening delimiter must be a line consisting solely of `---`.
21        // Requiring a line break right after `---` rejects `----`, `---x`, and a
22        // top-of-file `------` thematic break (issue #131).
23        let after_open = content.strip_prefix("---")?;
24        if !after_open.starts_with('\n') && !after_open.starts_with("\r\n") {
25            return None;
26        }
27
28        // The closing delimiter must also be a line that is solely `---`
29        // (trailing whitespace allowed). A raw substring search would match a
30        // `---` inside a quoted value and truncate the frontmatter early,
31        // pushing later lines (e.g. `allowed-tools: *`) out of the scanned
32        // region and evading OP-001.
33        let mut offset = 0;
34        for line in after_open.split_inclusive('\n') {
35            if line.trim_end_matches(['\r', '\n']).trim_end() == "---" {
36                return Some(&after_open[..offset]);
37            }
38            offset += line.len();
39        }
40
41        None
42    }
43
44    /// Parse frontmatter as a YAML value.
45    ///
46    /// Returns None if no frontmatter exists or parsing fails.
47    pub fn parse_yaml(content: &str) -> Option<serde_norway::Value> {
48        Self::extract(content).and_then(|fm| serde_norway::from_str(fm.trim()).ok())
49    }
50
51    /// Parse frontmatter as a JSON value.
52    ///
53    /// Returns None if no frontmatter exists or parsing fails.
54    pub fn parse_json(content: &str) -> Option<serde_json::Value> {
55        Self::extract(content)
56            .and_then(|fm| serde_norway::from_str::<serde_json::Value>(fm.trim()).ok())
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn test_valid_frontmatter() {
66        let content = "---\nname: test\ndescription: A test\n---\n# Content";
67        let result = FrontmatterParser::extract(content);
68        assert_eq!(result, Some("\nname: test\ndescription: A test\n"));
69    }
70
71    #[test]
72    fn test_frontmatter_with_allowed_tools() {
73        let content = "---\nname: skill\nallowed-tools: Read, Write\n---\n# Skill";
74        let result = FrontmatterParser::extract(content);
75        assert!(result.is_some());
76        assert!(result.unwrap().contains("allowed-tools"));
77    }
78
79    #[test]
80    fn test_no_frontmatter() {
81        let content = "# Just Markdown\nNo frontmatter here.";
82        assert!(FrontmatterParser::extract(content).is_none());
83    }
84
85    #[test]
86    fn test_incomplete_frontmatter() {
87        let content = "---\nname: test\nNo closing dashes";
88        assert!(FrontmatterParser::extract(content).is_none());
89    }
90
91    #[test]
92    fn test_thematic_break_is_not_empty_frontmatter() {
93        // `------` (a markdown thematic break) is NOT an opening `---` delimiter
94        // line, so it must not be parsed as empty frontmatter (issue #131).
95        let content = "------\n# Content";
96        assert!(FrontmatterParser::extract(content).is_none());
97    }
98
99    #[test]
100    fn test_inline_dashes_in_value_do_not_truncate() {
101        // A `---` inside a quoted value must NOT be treated as the closing
102        // delimiter; the real closing `---` is on its own line (issue #131).
103        let content = "---\ndescription: \"harmless a---b\"\nallowed-tools: *\n---\n# Body";
104        let result = FrontmatterParser::extract(content);
105        assert_eq!(
106            result,
107            Some("\ndescription: \"harmless a---b\"\nallowed-tools: *\n")
108        );
109    }
110
111    #[test]
112    fn test_closing_delimiter_must_be_own_line() {
113        // A `---` that is part of a longer token on a line is not a closing
114        // delimiter; without a real closing line, there is no frontmatter.
115        let content = "---\nname: test\nvalue: a---b\nno closing line";
116        assert!(FrontmatterParser::extract(content).is_none());
117    }
118
119    #[test]
120    fn test_content_not_starting_with_dashes() {
121        let content = "# Title\n---\nname: test\n---";
122        assert!(FrontmatterParser::extract(content).is_none());
123    }
124
125    #[test]
126    fn test_parse_yaml() {
127        let content = "---\nname: test\nversion: 1.0\n---\n# Content";
128        let yaml = FrontmatterParser::parse_yaml(content);
129        assert!(yaml.is_some());
130        let yaml = yaml.unwrap();
131        assert_eq!(yaml["name"].as_str(), Some("test"));
132    }
133
134    #[test]
135    fn test_parse_json() {
136        let content = "---\nname: test\nversion: 1.0\n---\n# Content";
137        let json = FrontmatterParser::parse_json(content);
138        assert!(json.is_some());
139        let json = json.unwrap();
140        assert_eq!(json["name"].as_str(), Some("test"));
141    }
142}