use std::path::Path;
use crate::domain::Frontmatter;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Default)]
pub struct FrontmatterParser;
impl FrontmatterParser {
#[must_use]
pub const fn new() -> Self {
Self
}
pub fn parse<'a>(&self, path: &Path, content: &'a str) -> Result<(Frontmatter, &'a str)> {
let (yaml, body) =
extract_frontmatter(content).ok_or_else(|| Error::InvalidFrontmatter {
path: path.to_path_buf(),
message: "missing or invalid frontmatter delimiters (---)".to_string(),
})?;
let frontmatter: Frontmatter =
serde_yaml::from_str(yaml).map_err(|source| Error::YamlParse {
path: path.to_path_buf(),
source,
})?;
if frontmatter.title.is_empty() {
return Err(Error::MissingField {
path: path.to_path_buf(),
field: "title",
});
}
Ok((frontmatter, body))
}
}
fn extract_frontmatter(content: &str) -> Option<(&str, &str)> {
let content = content.strip_prefix("---")?;
let closing_pos = find_closing_delimiter(content)?;
let yaml = content[..closing_pos].trim();
let body = content[closing_pos + 3..].trim_start_matches(['\n', '\r']);
Some((yaml, body))
}
fn find_closing_delimiter(content: &str) -> Option<usize> {
content.find("\n---").map(|pos| pos + 1)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_extract_frontmatter_basic() {
let content = r"---
title: Test
status: accepted
---
Body content here.
";
let (yaml, body) = extract_frontmatter(content).expect("should extract");
assert!(yaml.contains("title: Test"));
assert!(yaml.contains("status: accepted"));
assert_eq!(body.trim(), "Body content here.");
}
#[test]
fn test_extract_frontmatter_multiline_body() {
let content = r"---
title: Test
---
# Heading
Paragraph 1.
Paragraph 2.
";
let (yaml, body) = extract_frontmatter(content).expect("should extract");
assert!(yaml.contains("title: Test"));
assert!(body.contains(" Heading"));
assert!(body.contains("Paragraph 1."));
}
#[test]
fn test_extract_frontmatter_no_delimiter() {
let content = "No frontmatter here.";
assert!(extract_frontmatter(content).is_none());
}
#[test]
fn test_extract_frontmatter_missing_closing() {
let content = r"---
title: Test
No closing delimiter
";
assert!(extract_frontmatter(content).is_none());
}
#[test]
fn test_parse_frontmatter_success() {
let content = r"---
title: Use Rust
description: Decision to use Rust for CLI
status: accepted
category: technology
tags:
- rust
- cli
author: Team Lead
---
Body here.
";
let parser = FrontmatterParser::new();
let path = PathBuf::from("test.md");
let (frontmatter, body) = parser.parse(&path, content).expect("should parse");
assert_eq!(frontmatter.title, "Use Rust");
assert_eq!(frontmatter.description, "Decision to use Rust for CLI");
assert_eq!(frontmatter.category, "technology");
assert_eq!(frontmatter.tags, vec!["rust", "cli"]);
assert_eq!(frontmatter.author, "Team Lead");
assert_eq!(body.trim(), "Body here.");
}
#[test]
fn test_parse_frontmatter_missing_title() {
let content = r"---
description: Missing title
---
Body
";
let parser = FrontmatterParser::new();
let path = PathBuf::from("test.md");
let result = parser.parse(&path, content);
assert!(result.is_err());
assert!(matches!(result, Err(Error::YamlParse { .. })));
}
#[test]
fn test_parse_frontmatter_empty_title() {
let content = r#"---
title: ""
---
Body
"#;
let parser = FrontmatterParser::new();
let path = PathBuf::from("test.md");
let result = parser.parse(&path, content);
assert!(result.is_err());
if let Err(Error::MissingField { field, .. }) = result {
assert_eq!(field, "title");
} else {
panic!("Expected MissingField error, got {:?}", result);
}
}
#[test]
fn test_parse_frontmatter_invalid_yaml() {
let content = r"---
title: Test
invalid: [unclosed bracket
---
Body
";
let parser = FrontmatterParser::new();
let path = PathBuf::from("test.md");
let result = parser.parse(&path, content);
assert!(matches!(result, Err(Error::YamlParse { .. })));
}
#[test]
fn test_parse_frontmatter_with_dates() {
let content = r#"---
title: Test with Dates
created: "2025-01-15"
updated: "2025-01-20"
---
Body
"#;
let parser = FrontmatterParser::new();
let path = PathBuf::from("test.md");
let (frontmatter, _) = parser.parse(&path, content).expect("should parse");
assert!(frontmatter.created.is_some());
assert!(frontmatter.updated.is_some());
}
#[test]
fn test_parse_frontmatter_with_related() {
let content = r"---
title: Related ADRs
related:
- adr_0001.md
- adr_0005.md
---
Body
";
let parser = FrontmatterParser::new();
let path = PathBuf::from("test.md");
let (frontmatter, _) = parser.parse(&path, content).expect("should parse");
assert_eq!(frontmatter.related, vec!["adr_0001.md", "adr_0005.md"]);
}
}