1use std::collections::BTreeMap;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct FrontMatter {
5 fields: Vec<(String, String)>,
6}
7
8impl FrontMatter {
9 pub fn new(fields: Vec<(String, String)>) -> Self {
10 Self { fields }
11 }
12
13 pub fn fields(&self) -> &[(String, String)] {
14 &self.fields
15 }
16
17 pub fn get(&self, key: &str) -> Option<&str> {
18 self.fields
19 .iter()
20 .find(|(field_key, _)| field_key == key)
21 .map(|(_, value)| value.as_str())
22 }
23
24 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
25 let key = key.into();
26 let value = value.into();
27
28 if let Some((_, current_value)) = self
29 .fields
30 .iter_mut()
31 .find(|(field_key, _)| field_key == &key)
32 {
33 *current_value = value;
34 } else {
35 self.fields.push((key, value));
36 }
37 }
38
39 pub fn remove(&mut self, key: &str) -> Option<String> {
40 let index = self
41 .fields
42 .iter()
43 .position(|(field_key, _)| field_key == key)?;
44 Some(self.fields.remove(index).1)
45 }
46
47 pub fn merge_missing_from(&mut self, other: &FrontMatter) {
48 for (key, value) in &other.fields {
49 if self.get(key).is_none() {
50 self.fields.push((key.clone(), value.clone()));
51 }
52 }
53 }
54
55 pub fn to_note_text(&self, body: &str) -> String {
56 if self.fields.is_empty() {
57 return body.to_string();
58 }
59
60 let mut output = self.to_string();
61 if !body.starts_with('\n') && !body.is_empty() {
62 output.push('\n');
63 }
64 output.push_str(body);
65 output
66 }
67
68 pub fn to_map(&self) -> BTreeMap<String, FrontMatterValue> {
69 self.fields
70 .iter()
71 .map(|(key, raw)| (key.clone(), parse_value(raw)))
72 .collect()
73 }
74}
75
76impl std::fmt::Display for FrontMatter {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 if self.fields.is_empty() {
79 return Ok(());
80 }
81
82 writeln!(f, "---")?;
83 for (key, value) in &self.fields {
84 writeln!(f, "{key}: {}", quote_if_needed(value))?;
85 }
86 write!(f, "---")
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum FrontMatterValue {
92 String(String),
93 Bool(bool),
94 Integer(i64),
95 Array(Vec<String>),
96}
97
98pub fn parse_front_matter(text: &str) -> (Option<FrontMatter>, String) {
99 let mut lines = text.lines();
100 let Some(first_line) = lines.next() else {
101 return (None, String::new());
102 };
103
104 if first_line.trim() != "---" {
105 return (None, text.to_string());
106 }
107
108 let mut fields = Vec::new();
109 let mut body_start = None;
110 let all_lines = text.lines().collect::<Vec<_>>();
111
112 for (index, line) in all_lines.iter().enumerate().skip(1) {
113 let trimmed = line.trim();
114 if trimmed == "---" {
115 body_start = Some(index + 1);
116 break;
117 }
118
119 if trimmed.is_empty() || trimmed.starts_with('#') {
120 continue;
121 }
122
123 let Some((key, value)) = line.split_once(':') else {
124 continue;
125 };
126 let key = key.trim();
127 if key.is_empty() {
128 continue;
129 }
130
131 fields.push((key.to_string(), unquote(value.trim())));
132 }
133
134 let Some(body_start) = body_start else {
135 return (None, text.to_string());
136 };
137
138 let body = all_lines[body_start..].join("\n");
139 (Some(FrontMatter::new(fields)), body)
140}
141
142fn unquote(value: &str) -> String {
143 if value.len() >= 2 {
144 let bytes = value.as_bytes();
145 if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
146 || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
147 {
148 return value[1..value.len() - 1].to_string();
149 }
150 }
151
152 value.to_string()
153}
154
155fn quote_if_needed(value: &str) -> String {
156 let looks_like_scalar = matches!(
157 parse_value(value),
158 FrontMatterValue::Bool(_) | FrontMatterValue::Integer(_) | FrontMatterValue::Array(_)
159 );
160 if looks_like_scalar {
161 return value.to_string();
162 }
163
164 let needs_quotes = value.is_empty()
165 || value.starts_with(' ')
166 || value.ends_with(' ')
167 || value.chars().any(|ch| {
168 matches!(
169 ch,
170 ':' | '#'
171 | '{'
172 | '}'
173 | '['
174 | ']'
175 | ','
176 | '&'
177 | '*'
178 | '!'
179 | '|'
180 | '>'
181 | '\''
182 | '"'
183 | '%'
184 | '@'
185 )
186 });
187
188 if needs_quotes {
189 format!("\"{}\"", value.replace('"', "\\\""))
190 } else {
191 value.to_string()
192 }
193}
194
195fn parse_value(raw: &str) -> FrontMatterValue {
196 if raw == "true" {
197 return FrontMatterValue::Bool(true);
198 }
199 if raw == "false" {
200 return FrontMatterValue::Bool(false);
201 }
202 if let Ok(value) = raw.parse::<i64>() {
203 return FrontMatterValue::Integer(value);
204 }
205 if raw.starts_with('[') && raw.ends_with(']') {
206 let items = raw[1..raw.len() - 1]
207 .split(',')
208 .map(str::trim)
209 .filter(|item| !item.is_empty())
210 .map(unquote)
211 .collect::<Vec<_>>();
212 return FrontMatterValue::Array(items);
213 }
214
215 FrontMatterValue::String(raw.to_string())
216}
217
218#[cfg(test)]
219mod tests {
220 use super::{FrontMatter, FrontMatterValue, parse_front_matter};
221
222 #[test]
223 fn returns_original_body_when_front_matter_missing() {
224 let text = "# Title\n\nBody";
225 let (front_matter, body) = parse_front_matter(text);
226
227 assert!(front_matter.is_none());
228 assert_eq!(body, text);
229 }
230
231 #[test]
232 fn parses_simple_front_matter() {
233 let text = "---\ntitle: Test\ndraft: true\n---\n# Title\n";
234 let (front_matter, body) = parse_front_matter(text);
235 let front_matter = front_matter.expect("front matter should parse");
236
237 assert_eq!(front_matter.get("title"), Some("Test"));
238 assert_eq!(front_matter.get("draft"), Some("true"));
239 assert_eq!(body, "# Title");
240 }
241
242 #[test]
243 fn parses_quoted_strings_and_arrays() {
244 let text = "---\ntitle: \"Hello: world\"\ntags: [\"rust\", bear]\n---\nBody";
245 let (front_matter, body) = parse_front_matter(text);
246 let front_matter = front_matter.expect("front matter should parse");
247
248 assert_eq!(front_matter.get("title"), Some("Hello: world"));
249 assert_eq!(body, "Body");
250
251 let map = front_matter.to_map();
252 assert_eq!(
253 map.get("tags"),
254 Some(&FrontMatterValue::Array(vec![
255 "rust".to_string(),
256 "bear".to_string()
257 ]))
258 );
259 }
260
261 #[test]
262 fn ignores_unclosed_front_matter_block() {
263 let text = "---\ntitle: Test\nbody";
264 let (front_matter, body) = parse_front_matter(text);
265
266 assert!(front_matter.is_none());
267 assert_eq!(body, text);
268 }
269
270 #[test]
271 fn serializes_front_matter_back_to_note_text() {
272 let mut front_matter = FrontMatter::new(vec![
273 ("title".to_string(), "Test".to_string()),
274 ("tags".to_string(), "[rust, bear]".to_string()),
275 ]);
276 front_matter.set("draft", "false");
277 let text = front_matter.to_note_text("# Title\n\nBody");
278
279 assert_eq!(
280 text,
281 "---\ntitle: Test\ntags: [rust, bear]\ndraft: false\n---\n# Title\n\nBody"
282 );
283 }
284
285 #[test]
286 fn preserves_field_order_when_updating() {
287 let mut front_matter = FrontMatter::new(vec![
288 ("title".to_string(), "One".to_string()),
289 ("draft".to_string(), "true".to_string()),
290 ]);
291
292 front_matter.set("title", "Two");
293 front_matter.set("tags", "bear");
294
295 assert_eq!(
296 front_matter.fields(),
297 &[
298 ("title".to_string(), "Two".to_string()),
299 ("draft".to_string(), "true".to_string()),
300 ("tags".to_string(), "bear".to_string()),
301 ]
302 );
303 }
304}