mdvault_core/frontmatter/
parser.rs

1//! Frontmatter parsing from markdown documents.
2
3use super::types::{Frontmatter, ParsedDocument, TemplateFrontmatter};
4use thiserror::Error;
5
6/// Errors that can occur during frontmatter parsing.
7#[derive(Debug, Error)]
8pub enum FrontmatterParseError {
9    #[error("invalid YAML frontmatter: {0}")]
10    InvalidYaml(#[from] serde_yaml::Error),
11}
12
13/// Parse frontmatter from markdown content.
14///
15/// Frontmatter is delimited by `---` at the start of the document:
16/// ```markdown
17/// ---
18/// key: value
19/// ---
20/// # Document content
21/// ```
22pub fn parse(content: &str) -> Result<ParsedDocument, FrontmatterParseError> {
23    let trimmed = content.trim_start();
24
25    // Check if document starts with frontmatter delimiter
26    if !trimmed.starts_with("---") {
27        return Ok(ParsedDocument { frontmatter: None, body: content.to_string() });
28    }
29
30    // Find the closing ---
31    let after_first = &trimmed[3..];
32
33    // Skip the newline after opening ---
34    let after_newline = after_first
35        .strip_prefix('\n')
36        .or_else(|| after_first.strip_prefix("\r\n"))
37        .unwrap_or(after_first);
38
39    // Find closing delimiter
40    if let Some(end_pos) = find_closing_delimiter(after_newline) {
41        let yaml_content = &after_newline[..end_pos];
42
43        // Calculate body start (skip closing --- and following newline)
44        let after_closing = &after_newline[end_pos + 3..];
45        let body = after_closing
46            .strip_prefix('\n')
47            .or_else(|| after_closing.strip_prefix("\r\n"))
48            .unwrap_or(after_closing)
49            .to_string();
50
51        // Parse YAML
52        let frontmatter: Frontmatter = if yaml_content.trim().is_empty() {
53            Frontmatter::default()
54        } else {
55            serde_yaml::from_str(yaml_content.trim())?
56        };
57
58        Ok(ParsedDocument { frontmatter: Some(frontmatter), body })
59    } else {
60        // No closing ---, treat as no frontmatter
61        Ok(ParsedDocument { frontmatter: None, body: content.to_string() })
62    }
63}
64
65/// Find the position of closing `---` delimiter.
66fn find_closing_delimiter(content: &str) -> Option<usize> {
67    // Look for --- at the start of a line
68    for (i, line) in content.lines().enumerate() {
69        if line.trim() == "---" {
70            // Calculate byte position
71            let pos: usize = content
72                .lines()
73                .take(i)
74                .map(|l| l.len() + 1) // +1 for newline
75                .sum();
76            return Some(pos);
77        }
78    }
79    None
80}
81
82/// Parse template-specific frontmatter.
83///
84/// Returns the parsed template frontmatter (if present) and the body content.
85pub fn parse_template_frontmatter(
86    content: &str,
87) -> Result<(Option<TemplateFrontmatter>, String), FrontmatterParseError> {
88    let parsed = parse(content)?;
89
90    if let Some(fm) = parsed.frontmatter {
91        // Convert Frontmatter to TemplateFrontmatter
92        let yaml_value = serde_yaml::to_value(&fm.fields)?;
93        let template_fm: TemplateFrontmatter = serde_yaml::from_value(yaml_value)?;
94        Ok((Some(template_fm), parsed.body))
95    } else {
96        Ok((None, parsed.body))
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn parse_no_frontmatter() {
106        let content = "# Hello\n\nSome content";
107        let result = parse(content).unwrap();
108        assert!(result.frontmatter.is_none());
109        assert_eq!(result.body, content);
110    }
111
112    #[test]
113    fn parse_simple_frontmatter() {
114        let content = "---\ntitle: Hello\n---\n# Content";
115        let result = parse(content).unwrap();
116        assert!(result.frontmatter.is_some());
117        let fm = result.frontmatter.unwrap();
118        assert_eq!(fm.fields.get("title").and_then(|v| v.as_str()), Some("Hello"));
119        assert_eq!(result.body, "# Content");
120    }
121
122    #[test]
123    fn parse_frontmatter_with_multiple_fields() {
124        let content =
125            "---\ntitle: Test\ndate: 2024-01-15\ntags:\n  - rust\n  - cli\n---\n\nBody";
126        let result = parse(content).unwrap();
127        assert!(result.frontmatter.is_some());
128        let fm = result.frontmatter.unwrap();
129        assert_eq!(fm.fields.get("title").and_then(|v| v.as_str()), Some("Test"));
130        assert!(fm.fields.contains_key("tags"));
131        assert_eq!(result.body, "\nBody");
132    }
133
134    #[test]
135    fn parse_empty_frontmatter() {
136        let content = "---\n---\n# Content";
137        let result = parse(content).unwrap();
138        assert!(result.frontmatter.is_some());
139        assert!(result.frontmatter.unwrap().fields.is_empty());
140        assert_eq!(result.body, "# Content");
141    }
142
143    #[test]
144    fn parse_template_frontmatter_with_output() {
145        let content = "---\noutput: daily/{{date}}.md\ntags: [daily]\n---\n# Daily";
146        let (fm, body) = parse_template_frontmatter(content).unwrap();
147        assert!(fm.is_some());
148        let fm = fm.unwrap();
149        assert_eq!(fm.output, Some("daily/{{date}}.md".to_string()));
150        assert!(fm.extra.contains_key("tags"));
151        assert_eq!(body, "# Daily");
152    }
153}