sara_core/parser/
frontmatter.rs

1//! YAML frontmatter extraction from Markdown files.
2
3use std::path::Path;
4
5use crate::error::ParseError;
6
7/// Represents extracted frontmatter content.
8#[derive(Debug, Clone)]
9pub struct ExtractedFrontmatter {
10    /// The raw YAML content between the `---` delimiters.
11    pub yaml: String,
12    /// Line number where the frontmatter starts (1-indexed, at the opening `---`).
13    pub start_line: usize,
14    /// Line number where the frontmatter ends (at the closing `---`).
15    pub end_line: usize,
16    /// The remaining Markdown content after the frontmatter.
17    pub body: String,
18}
19
20/// Extracts YAML frontmatter from Markdown content.
21///
22/// Frontmatter must be at the start of the file, enclosed by `---` delimiters.
23///
24/// # Example
25/// ```text
26/// ---
27/// id: "SOL-001"
28/// type: solution
29/// name: "My Solution"
30/// ---
31/// # Markdown content here
32/// ```
33pub fn extract_frontmatter(content: &str, file: &Path) -> Result<ExtractedFrontmatter, ParseError> {
34    let lines: Vec<&str> = content.lines().collect();
35
36    if lines.is_empty() {
37        return Err(ParseError::MissingFrontmatter {
38            file: file.to_path_buf(),
39        });
40    }
41
42    // Check for opening delimiter
43    if lines[0].trim() != "---" {
44        return Err(ParseError::MissingFrontmatter {
45            file: file.to_path_buf(),
46        });
47    }
48
49    // Find closing delimiter
50    let mut end_idx = None;
51    for (i, line) in lines.iter().enumerate().skip(1) {
52        if line.trim() == "---" {
53            end_idx = Some(i);
54            break;
55        }
56    }
57
58    let end_idx = end_idx.ok_or_else(|| ParseError::InvalidFrontmatter {
59        file: file.to_path_buf(),
60        line: 1,
61        reason: "Missing closing `---` delimiter".to_string(),
62    })?;
63
64    // Extract YAML content (lines between delimiters)
65    let yaml_lines: Vec<&str> = lines[1..end_idx].to_vec();
66    let yaml = yaml_lines.join("\n");
67
68    // Extract body (everything after closing delimiter)
69    let body_lines: Vec<&str> = if end_idx + 1 < lines.len() {
70        lines[end_idx + 1..].to_vec()
71    } else {
72        Vec::new()
73    };
74    let body = body_lines.join("\n");
75
76    Ok(ExtractedFrontmatter {
77        yaml,
78        start_line: 1,
79        end_line: end_idx + 1, // 1-indexed
80        body,
81    })
82}
83
84/// Checks if content has frontmatter (starts with `---`).
85pub fn has_frontmatter(content: &str) -> bool {
86    content.trim_start().starts_with("---")
87}
88
89/// Extracts just the body content after the frontmatter (FR-064).
90///
91/// Returns the body content without the frontmatter delimiters.
92/// If no frontmatter is present, returns the original content.
93pub fn extract_body(content: &str) -> String {
94    let lines: Vec<&str> = content.lines().collect();
95
96    if lines.is_empty() || lines[0].trim() != "---" {
97        // No frontmatter, return original content
98        return content.to_string();
99    }
100
101    // Find closing delimiter
102    for (i, line) in lines.iter().enumerate().skip(1) {
103        if line.trim() == "---" {
104            // Return everything after the closing delimiter
105            if i + 1 < lines.len() {
106                return lines[i + 1..].join("\n");
107            } else {
108                return String::new();
109            }
110        }
111    }
112
113    // No closing delimiter found, return original
114    content.to_string()
115}
116
117/// Updates the YAML frontmatter in content while preserving the body (FR-064).
118///
119/// The new_yaml should NOT include the `---` delimiters.
120/// Returns the updated content with new frontmatter and preserved body.
121pub fn update_frontmatter(content: &str, new_yaml: &str) -> String {
122    let body = extract_body(content);
123
124    // Ensure trailing newline in YAML
125    let yaml_trimmed = new_yaml.trim_end();
126
127    if body.is_empty() {
128        format!("---\n{}\n---\n", yaml_trimmed)
129    } else {
130        format!("---\n{}\n---\n{}", yaml_trimmed, body)
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use std::path::PathBuf;
138
139    #[test]
140    fn test_extract_frontmatter_valid() {
141        let content = r#"---
142id: "SOL-001"
143type: solution
144name: "Test"
145---
146# Body content"#;
147
148        let result = extract_frontmatter(content, &PathBuf::from("test.md")).unwrap();
149        assert!(result.yaml.contains("id: \"SOL-001\""));
150        assert!(result.yaml.contains("type: solution"));
151        assert_eq!(result.start_line, 1);
152        assert_eq!(result.end_line, 5);
153        assert_eq!(result.body.trim(), "# Body content");
154    }
155
156    #[test]
157    fn test_extract_frontmatter_no_body() {
158        let content = r#"---
159id: "SOL-001"
160---"#;
161
162        let result = extract_frontmatter(content, &PathBuf::from("test.md")).unwrap();
163        assert!(result.yaml.contains("id: \"SOL-001\""));
164        assert!(result.body.is_empty());
165    }
166
167    #[test]
168    fn test_extract_frontmatter_missing() {
169        let content = "# Just markdown";
170        let result = extract_frontmatter(content, &PathBuf::from("test.md"));
171        assert!(result.is_err());
172    }
173
174    #[test]
175    fn test_extract_frontmatter_unclosed() {
176        let content = r#"---
177id: "SOL-001"
178# No closing delimiter"#;
179
180        let result = extract_frontmatter(content, &PathBuf::from("test.md"));
181        assert!(result.is_err());
182    }
183
184    #[test]
185    fn test_has_frontmatter() {
186        assert!(has_frontmatter("---\nid: test\n---"));
187        assert!(has_frontmatter("  ---\nid: test\n---"));
188        assert!(!has_frontmatter("# No frontmatter"));
189    }
190
191    #[test]
192    fn test_extract_frontmatter_empty() {
193        let content = "";
194        let result = extract_frontmatter(content, &PathBuf::from("test.md"));
195        assert!(result.is_err());
196    }
197
198    #[test]
199    fn test_extract_body_with_frontmatter() {
200        let content = r#"---
201id: "SOL-001"
202type: solution
203---
204# Body Content
205
206Some markdown here."#;
207
208        let body = extract_body(content);
209        assert_eq!(body, "# Body Content\n\nSome markdown here.");
210    }
211
212    #[test]
213    fn test_extract_body_no_frontmatter() {
214        let content = "# Just markdown\n\nNo frontmatter here.";
215        let body = extract_body(content);
216        assert_eq!(body, content);
217    }
218
219    #[test]
220    fn test_extract_body_empty_body() {
221        let content = "---\nid: test\n---";
222        let body = extract_body(content);
223        assert!(body.is_empty());
224    }
225
226    #[test]
227    fn test_update_frontmatter() {
228        let content = r#"---
229id: "SOL-001"
230type: solution
231name: "Old Name"
232---
233# Body Content
234
235Some markdown here."#;
236
237        let new_yaml = r#"id: "SOL-001"
238type: solution
239name: "New Name""#;
240
241        let updated = update_frontmatter(content, new_yaml);
242
243        assert!(updated.starts_with("---\n"));
244        assert!(updated.contains("name: \"New Name\""));
245        assert!(updated.contains("# Body Content"));
246        assert!(updated.contains("Some markdown here."));
247    }
248
249    #[test]
250    fn test_update_frontmatter_no_body() {
251        let content = "---\nid: test\n---";
252        let new_yaml = "id: test\nname: Updated";
253
254        let updated = update_frontmatter(content, new_yaml);
255
256        assert_eq!(updated, "---\nid: test\nname: Updated\n---\n");
257    }
258}