Skip to main content

nova_schema/
frontmatter.rs

1//! Frontmatter parsing for document metadata.
2//!
3//! Supports YAML and TOML frontmatter formats:
4//!
5//! ```text
6//! ---
7//! title: My Document
8//! author: John Doe
9//! ---
10//! ```
11
12use crate::error::{Error, Result};
13use serde::de::DeserializeOwned;
14
15/// Parser for document frontmatter.
16#[derive(Debug, Clone, Default)]
17pub struct FrontmatterParser {
18    /// Strict mode fails on unknown fields.
19    strict: bool,
20}
21
22impl FrontmatterParser {
23    /// Create a new frontmatter parser.
24    #[must_use]
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Enable strict validation mode.
30    #[must_use]
31    pub fn strict(mut self, strict: bool) -> Self {
32        self.strict = strict;
33        self
34    }
35
36    /// Parse frontmatter from document content.
37    ///
38    /// Detects the frontmatter format (YAML or TOML) and parses it.
39    ///
40    /// # Returns
41    ///
42    /// A tuple of (parsed metadata, remaining content).
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if parsing fails.
47    pub fn parse<'a, T: DeserializeOwned>(&self, content: &'a str) -> Result<(Option<T>, &'a str)> {
48        let trimmed = content.trim_start();
49        let offset = content.len() - trimmed.len();
50
51        // Check for YAML frontmatter (---)
52        if trimmed.starts_with("---") {
53            return self.parse_yaml(&content[offset..]);
54        }
55
56        // Check for TOML frontmatter (+++)
57        if trimmed.starts_with("+++") {
58            return self.parse_toml(&content[offset..]);
59        }
60
61        // No frontmatter
62        Ok((None, &content[offset..]))
63    }
64
65    /// Parse YAML frontmatter.
66    fn parse_yaml<'a, T: DeserializeOwned>(
67        &self,
68        content: &'a str,
69    ) -> Result<(Option<T>, &'a str)> {
70        let inner = &content[3..]; // Skip opening ---
71
72        let end = inner.find("\n---").ok_or_else(|| Error::FrontmatterParse {
73            message: "unclosed YAML frontmatter".to_string(),
74            line: None,
75        })?;
76
77        let yaml_content = inner[..end].trim();
78        let remaining_start = 3 + end + 4; // Skip "---" + content + "\n---"
79        let remaining = &content[remaining_start..];
80
81        if yaml_content.is_empty() {
82            let trimmed = remaining.trim_start();
83            let offset = remaining.len() - trimmed.len();
84            return Ok((None, &remaining[offset..]));
85        }
86
87        let parsed: T = serde_yaml::from_str(yaml_content)?;
88        let trimmed = remaining.trim_start();
89        let offset = remaining.len() - trimmed.len();
90        Ok((Some(parsed), &remaining[offset..]))
91    }
92
93    /// Parse TOML frontmatter.
94    fn parse_toml<'a, T: DeserializeOwned>(
95        &self,
96        content: &'a str,
97    ) -> Result<(Option<T>, &'a str)> {
98        let inner = &content[3..]; // Skip opening +++
99
100        let end = inner.find("\n+++").ok_or_else(|| Error::FrontmatterParse {
101            message: "unclosed TOML frontmatter".to_string(),
102            line: None,
103        })?;
104
105        let toml_content = inner[..end].trim();
106        let remaining_start = 3 + end + 4; // Skip "+++" + content + "\n+++"
107        let remaining = &content[remaining_start..];
108
109        if toml_content.is_empty() {
110            let trimmed = remaining.trim_start();
111            let offset = remaining.len() - trimmed.len();
112            return Ok((None, &remaining[offset..]));
113        }
114
115        let parsed: T = toml::from_str(toml_content)?;
116        let trimmed = remaining.trim_start();
117        let offset = remaining.len() - trimmed.len();
118        Ok((Some(parsed), &remaining[offset..]))
119    }
120
121    /// Extract raw frontmatter string without parsing.
122    ///
123    /// Useful for validation before full parsing.
124    #[must_use]
125    pub fn extract_raw<'a>(&self, content: &'a str) -> Option<(&'a str, FrontmatterFormat)> {
126        let content = content.trim_start();
127
128        if let Some(content) = content.strip_prefix("---") {
129            if let Some(end) = content.find("\n---") {
130                return Some((&content[..end], FrontmatterFormat::Yaml));
131            }
132        }
133
134        if let Some(content) = content.strip_prefix("+++") {
135            if let Some(end) = content.find("\n+++") {
136                return Some((&content[..end], FrontmatterFormat::Toml));
137            }
138        }
139
140        None
141    }
142}
143
144/// Frontmatter format.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum FrontmatterFormat {
147    /// YAML format (---).
148    Yaml,
149    /// TOML format (+++).
150    Toml,
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use serde::Deserialize;
157
158    #[derive(Debug, Deserialize, PartialEq)]
159    struct TestMeta {
160        title: String,
161        author: Option<String>,
162    }
163
164    #[test]
165    fn parse_yaml_frontmatter() {
166        let content = r#"---
167title: Test Document
168author: John Doe
169---
170Hello, world!"#;
171
172        let parser = FrontmatterParser::new();
173        let (meta, remaining): (Option<TestMeta>, _) = parser.parse(content).unwrap();
174
175        let meta = meta.expect("should have metadata");
176        assert_eq!(meta.title, "Test Document");
177        assert_eq!(meta.author, Some("John Doe".to_string()));
178        assert_eq!(remaining, "Hello, world!");
179    }
180
181    #[test]
182    fn parse_toml_frontmatter() {
183        let content = r#"+++
184title = "Test Document"
185author = "Jane Doe"
186+++
187Content here"#;
188
189        let parser = FrontmatterParser::new();
190        let (meta, remaining): (Option<TestMeta>, _) = parser.parse(content).unwrap();
191
192        let meta = meta.expect("should have metadata");
193        assert_eq!(meta.title, "Test Document");
194        assert_eq!(meta.author, Some("Jane Doe".to_string()));
195        assert_eq!(remaining, "Content here");
196    }
197
198    #[test]
199    fn parse_no_frontmatter() {
200        let content = "Just regular content";
201
202        let parser = FrontmatterParser::new();
203        let (meta, remaining): (Option<TestMeta>, _) = parser.parse(content).unwrap();
204
205        assert!(meta.is_none());
206        assert_eq!(remaining, "Just regular content");
207    }
208
209    #[test]
210    fn parse_unclosed_frontmatter() {
211        let content = "---\ntitle: Test\nNo closing delimiter";
212
213        let parser = FrontmatterParser::new();
214        let result: Result<(Option<TestMeta>, _)> = parser.parse(content);
215
216        assert!(matches!(result, Err(Error::FrontmatterParse { .. })));
217    }
218
219    #[test]
220    fn extract_raw_yaml() {
221        let content = "---\ntitle: Test\n---\nContent";
222        let parser = FrontmatterParser::new();
223
224        let (raw, format) = parser.extract_raw(content).unwrap();
225        assert_eq!(raw.trim(), "title: Test");
226        assert_eq!(format, FrontmatterFormat::Yaml);
227    }
228
229    #[test]
230    fn extract_raw_toml() {
231        let content = "+++\ntitle = \"Test\"\n+++\nContent";
232        let parser = FrontmatterParser::new();
233
234        let (raw, format) = parser.extract_raw(content).unwrap();
235        assert_eq!(raw.trim(), "title = \"Test\"");
236        assert_eq!(format, FrontmatterFormat::Toml);
237    }
238}