adrscope/infrastructure/parser/
frontmatter.rs

1//! YAML frontmatter parsing.
2//!
3//! Extracts and parses the YAML frontmatter block from ADR files.
4
5use std::path::Path;
6
7use crate::domain::Frontmatter;
8use crate::error::{Error, Result};
9
10/// Parser for YAML frontmatter in ADR files.
11#[derive(Debug, Clone, Default)]
12pub struct FrontmatterParser;
13
14impl FrontmatterParser {
15    /// Creates a new frontmatter parser.
16    #[must_use]
17    pub const fn new() -> Self {
18        Self
19    }
20
21    /// Parses frontmatter from file content, returning the frontmatter and remaining body.
22    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        // Validate required fields
36        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
47/// Extracts the YAML frontmatter block and remaining body from content.
48///
49/// Returns `None` if the content doesn't start with `---` or doesn't have
50/// a closing `---` delimiter.
51fn extract_frontmatter(content: &str) -> Option<(&str, &str)> {
52    // Content must start with "---"
53    let content = content.strip_prefix("---")?;
54
55    // Find the closing "---"
56    // We need to handle both "---\n" and just "---" at end
57    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
65/// Finds the position of the closing `---` delimiter.
66///
67/// The closing delimiter must be at the start of a line (after a newline).
68fn find_closing_delimiter(content: &str) -> Option<usize> {
69    // Look for "\n---" to find a delimiter at the start of a line
70    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        // serde_yaml returns YamlParse error for missing required fields
168        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        // Empty title is caught by our validation check
185        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}