nova_schema/
frontmatter.rs1use crate::error::{Error, Result};
13use serde::de::DeserializeOwned;
14
15#[derive(Debug, Clone, Default)]
17pub struct FrontmatterParser {
18 strict: bool,
20}
21
22impl FrontmatterParser {
23 #[must_use]
25 pub fn new() -> Self {
26 Self::default()
27 }
28
29 #[must_use]
31 pub fn strict(mut self, strict: bool) -> Self {
32 self.strict = strict;
33 self
34 }
35
36 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 if trimmed.starts_with("---") {
53 return self.parse_yaml(&content[offset..]);
54 }
55
56 if trimmed.starts_with("+++") {
58 return self.parse_toml(&content[offset..]);
59 }
60
61 Ok((None, &content[offset..]))
63 }
64
65 fn parse_yaml<'a, T: DeserializeOwned>(
67 &self,
68 content: &'a str,
69 ) -> Result<(Option<T>, &'a str)> {
70 let inner = &content[3..]; 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; 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 fn parse_toml<'a, T: DeserializeOwned>(
95 &self,
96 content: &'a str,
97 ) -> Result<(Option<T>, &'a str)> {
98 let inner = &content[3..]; 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; 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum FrontmatterFormat {
147 Yaml,
149 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}