adrscope/infrastructure/parser/
frontmatter.rs1use std::path::Path;
6
7use crate::domain::Frontmatter;
8use crate::error::{Error, Result};
9
10#[derive(Debug, Clone, Default)]
12pub struct FrontmatterParser;
13
14impl FrontmatterParser {
15 #[must_use]
17 pub const fn new() -> Self {
18 Self
19 }
20
21 pub fn parse<'a>(&self, path: &Path, content: &'a str) -> Result<(Frontmatter, &'a str)> {
23 let (yaml, body) =
24 extract_frontmatter(content).ok_or_else(|| Error::InvalidFrontmatter {
25 path: path.to_path_buf(),
26 message: "missing or invalid frontmatter delimiters (---)".to_string(),
27 })?;
28
29 let frontmatter: Frontmatter =
30 serde_yaml::from_str(yaml).map_err(|source| Error::YamlParse {
31 path: path.to_path_buf(),
32 source,
33 })?;
34
35 if frontmatter.title.is_empty() {
37 return Err(Error::MissingField {
38 path: path.to_path_buf(),
39 field: "title",
40 });
41 }
42
43 Ok((frontmatter, body))
44 }
45}
46
47fn extract_frontmatter(content: &str) -> Option<(&str, &str)> {
52 let content = content.strip_prefix("---")?;
54
55 let closing_pos = find_closing_delimiter(content)?;
58
59 let yaml = content[..closing_pos].trim();
60 let body = content[closing_pos + 3..].trim_start_matches(['\n', '\r']);
61
62 Some((yaml, body))
63}
64
65fn find_closing_delimiter(content: &str) -> Option<usize> {
69 content.find("\n---").map(|pos| pos + 1)
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use std::path::PathBuf;
77
78 #[test]
79 fn test_extract_frontmatter_basic() {
80 let content = r"---
81title: Test
82status: accepted
83---
84Body content here.
85";
86
87 let (yaml, body) = extract_frontmatter(content).expect("should extract");
88
89 assert!(yaml.contains("title: Test"));
90 assert!(yaml.contains("status: accepted"));
91 assert_eq!(body.trim(), "Body content here.");
92 }
93
94 #[test]
95 fn test_extract_frontmatter_multiline_body() {
96 let content = r"---
97title: Test
98---
99# Heading
100
101Paragraph 1.
102
103Paragraph 2.
104";
105
106 let (yaml, body) = extract_frontmatter(content).expect("should extract");
107
108 assert!(yaml.contains("title: Test"));
109 assert!(body.contains(" Heading"));
110 assert!(body.contains("Paragraph 1."));
111 }
112
113 #[test]
114 fn test_extract_frontmatter_no_delimiter() {
115 let content = "No frontmatter here.";
116 assert!(extract_frontmatter(content).is_none());
117 }
118
119 #[test]
120 fn test_extract_frontmatter_missing_closing() {
121 let content = r"---
122title: Test
123No closing delimiter
124";
125 assert!(extract_frontmatter(content).is_none());
126 }
127
128 #[test]
129 fn test_parse_frontmatter_success() {
130 let content = r"---
131title: Use Rust
132description: Decision to use Rust for CLI
133status: accepted
134category: technology
135tags:
136 - rust
137 - cli
138author: Team Lead
139---
140Body here.
141";
142
143 let parser = FrontmatterParser::new();
144 let path = PathBuf::from("test.md");
145 let (frontmatter, body) = parser.parse(&path, content).expect("should parse");
146
147 assert_eq!(frontmatter.title, "Use Rust");
148 assert_eq!(frontmatter.description, "Decision to use Rust for CLI");
149 assert_eq!(frontmatter.category, "technology");
150 assert_eq!(frontmatter.tags, vec!["rust", "cli"]);
151 assert_eq!(frontmatter.author, "Team Lead");
152 assert_eq!(body.trim(), "Body here.");
153 }
154
155 #[test]
156 fn test_parse_frontmatter_missing_title() {
157 let content = r"---
158description: Missing title
159---
160Body
161";
162
163 let parser = FrontmatterParser::new();
164 let path = PathBuf::from("test.md");
165 let result = parser.parse(&path, content);
166
167 assert!(result.is_err());
169 assert!(matches!(result, Err(Error::YamlParse { .. })));
170 }
171
172 #[test]
173 fn test_parse_frontmatter_empty_title() {
174 let content = r#"---
175title: ""
176---
177Body
178"#;
179
180 let parser = FrontmatterParser::new();
181 let path = PathBuf::from("test.md");
182 let result = parser.parse(&path, content);
183
184 assert!(result.is_err());
186 if let Err(Error::MissingField { field, .. }) = result {
187 assert_eq!(field, "title");
188 } else {
189 panic!("Expected MissingField error, got {:?}", result);
190 }
191 }
192
193 #[test]
194 fn test_parse_frontmatter_invalid_yaml() {
195 let content = r"---
196title: Test
197invalid: [unclosed bracket
198---
199Body
200";
201
202 let parser = FrontmatterParser::new();
203 let path = PathBuf::from("test.md");
204 let result = parser.parse(&path, content);
205
206 assert!(matches!(result, Err(Error::YamlParse { .. })));
207 }
208
209 #[test]
210 fn test_parse_frontmatter_with_dates() {
211 let content = r#"---
212title: Test with Dates
213created: "2025-01-15"
214updated: "2025-01-20"
215---
216Body
217"#;
218
219 let parser = FrontmatterParser::new();
220 let path = PathBuf::from("test.md");
221 let (frontmatter, _) = parser.parse(&path, content).expect("should parse");
222
223 assert!(frontmatter.created.is_some());
224 assert!(frontmatter.updated.is_some());
225 }
226
227 #[test]
228 fn test_parse_frontmatter_with_related() {
229 let content = r"---
230title: Related ADRs
231related:
232 - adr_0001.md
233 - adr_0005.md
234---
235Body
236";
237
238 let parser = FrontmatterParser::new();
239 let path = PathBuf::from("test.md");
240 let (frontmatter, _) = parser.parse(&path, content).expect("should parse");
241
242 assert_eq!(frontmatter.related, vec!["adr_0001.md", "adr_0005.md"]);
243 }
244}