sara_core/parser/
frontmatter.rs1use std::path::Path;
4
5use crate::error::ParseError;
6
7#[derive(Debug, Clone)]
9pub struct ExtractedFrontmatter {
10 pub yaml: String,
12 pub start_line: usize,
14 pub end_line: usize,
16 pub body: String,
18}
19
20pub 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 if lines[0].trim() != "---" {
44 return Err(ParseError::MissingFrontmatter {
45 file: file.to_path_buf(),
46 });
47 }
48
49 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 let yaml_lines: Vec<&str> = lines[1..end_idx].to_vec();
66 let yaml = yaml_lines.join("\n");
67
68 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, body,
81 })
82}
83
84pub fn has_frontmatter(content: &str) -> bool {
86 content.trim_start().starts_with("---")
87}
88
89pub fn extract_body(content: &str) -> String {
94 let lines: Vec<&str> = content.lines().collect();
95
96 if lines.is_empty() || lines[0].trim() != "---" {
97 return content.to_string();
99 }
100
101 for (i, line) in lines.iter().enumerate().skip(1) {
103 if line.trim() == "---" {
104 if i + 1 < lines.len() {
106 return lines[i + 1..].join("\n");
107 } else {
108 return String::new();
109 }
110 }
111 }
112
113 content.to_string()
115}
116
117pub fn update_frontmatter(content: &str, new_yaml: &str) -> String {
122 let body = extract_body(content);
123
124 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}