Skip to main content

moss_core/
frontmatter.rs

1//! YAML frontmatter parsing with body preservation.
2//!
3//! Uses `serde_yaml` directly (NOT `gray_matter`, whose `Pod` type
4//! doesn't properly deserialize YAML arrays — see ADR-008).
5//!
6//! The body is preserved byte-for-byte via boundary-aware splitting.
7//! `frontmatter_range` records the byte offsets of the `---` delimiters
8//! so callers can do surgical replacement without re-serializing.
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// A parsed markdown document with frontmatter separated from body.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ParsedDocument {
16    /// Parsed frontmatter key-value pairs.
17    pub frontmatter: HashMap<String, serde_yaml::Value>,
18    /// The markdown body (everything after the closing `---`).
19    pub body: String,
20    /// Byte offsets of the frontmatter block: (start_of_opening_delimiter, end_of_closing_delimiter).
21    /// `None` if no frontmatter was found.
22    pub frontmatter_range: Option<(usize, usize)>,
23}
24
25/// Parse a markdown document, extracting frontmatter and body.
26///
27/// If the content starts with `---\n`, the YAML frontmatter is extracted
28/// and deserialized into a `HashMap`. The body is everything after the
29/// closing `---` delimiter (preserved byte-for-byte).
30///
31/// If no frontmatter is found, returns an empty map with the full content as body.
32pub fn parse(content: &str) -> ParsedDocument {
33    // Normalize CRLF → LF so byte-offset arithmetic can assume single-byte newlines.
34    let owned;
35    let content = if content.contains("\r\n") {
36        owned = content.replace("\r\n", "\n");
37        owned.as_str()
38    } else {
39        content
40    };
41
42    // Must start with `---` followed by newline (or just `---` at end of content).
43    if !content.starts_with("---") {
44        return ParsedDocument {
45            frontmatter: HashMap::new(),
46            body: content.to_string(),
47            frontmatter_range: None,
48        };
49    }
50
51    // Find end of opening `---` line.
52    let after_opening = match content.find('\n') {
53        Some(pos) => pos + 1,
54        None => {
55            // Content is just "---" with no newline — no valid frontmatter.
56            return ParsedDocument {
57                frontmatter: HashMap::new(),
58                body: content.to_string(),
59                frontmatter_range: None,
60            };
61        }
62    };
63
64    // Search for closing `---` line in the remainder.
65    // Char-aligned: `after_opening = pos + 1` where `pos = content.find('\n')`,
66    // and '\n' is a single ASCII byte, so the index lands on a char boundary.
67    #[allow(clippy::string_slice)]
68    let rest = &content[after_opening..];
69    let mut offset = 0;
70    for line in rest.lines() {
71        if line.trim() == "---" {
72            // Found closing delimiter.
73            let close_line_start = after_opening + offset;
74            let close_line_end = close_line_start + line.len();
75
76            // Include the newline after the closing `---` if present.
77            let fm_end = if close_line_end < content.len()
78                && content.as_bytes()[close_line_end] == b'\n'
79            {
80                close_line_end + 1
81            } else {
82                close_line_end
83            };
84
85            // The YAML text is between the opening and closing delimiters.
86            // Char-aligned: `after_opening` follows '\n' (ASCII), and
87            // `close_line_start = after_opening + offset` where `offset`
88            // accumulates `line.len() + 1` per line returned by `lines()`
89            // (each line is a complete-char slice and '\n' is one byte).
90            #[allow(clippy::string_slice)]
91            let yaml_text = &content[after_opening..close_line_start];
92
93            // Parse the YAML.
94            let frontmatter: HashMap<String, serde_yaml::Value> =
95                match serde_yaml::from_str(yaml_text) {
96                    Ok(map) => map,
97                    Err(_) => {
98                        // Invalid YAML — treat as no frontmatter.
99                        return ParsedDocument {
100                            frontmatter: HashMap::new(),
101                            body: content.to_string(),
102                            frontmatter_range: None,
103                        };
104                    }
105                };
106
107            // Char-aligned: `fm_end` is `close_line_end` (= line-aligned via `lines()`
108            // + ASCII '---') optionally + 1 for an ASCII '\n'.
109            #[allow(clippy::string_slice)]
110            let body = &content[fm_end..];
111
112            return ParsedDocument {
113                frontmatter,
114                body: body.to_string(),
115                frontmatter_range: Some((0, fm_end)),
116            };
117        }
118        offset += line.len() + 1; // +1 for '\n'
119    }
120
121    // No closing delimiter found — no valid frontmatter.
122    ParsedDocument {
123        frontmatter: HashMap::new(),
124        body: content.to_string(),
125        frontmatter_range: None,
126    }
127}
128
129/// Serialize frontmatter and body back into a markdown document.
130///
131/// Produces `---\n{yaml}\n---\n{body}`. If frontmatter is empty,
132/// returns just the body.
133///
134/// String values that look like YAML numbers (integers, floats, scientific
135/// notation like `753659e7`) are forced to `serde_yaml::Value::String` before
136/// serialization so that serde_yaml quotes them. This prevents silent data
137/// corruption on the next parse.
138pub fn serialize(
139    frontmatter: &HashMap<String, serde_yaml::Value>,
140    body: &str,
141) -> Result<String, String> {
142    if frontmatter.is_empty() {
143        return Ok(body.to_string());
144    }
145
146    // Ensure string values that look numeric are serialized as quoted strings.
147    let safe_fm: HashMap<String, serde_yaml::Value> = frontmatter
148        .iter()
149        .map(|(k, v)| (k.clone(), ensure_strings_quoted(v)))
150        .collect();
151
152    let yaml =
153        serde_yaml::to_string(&safe_fm).map_err(|e| format!("YAML serialize error: {}", e))?;
154
155    // serde_yaml adds a trailing newline; no need to add another.
156    Ok(format!("---\n{}---\n{}", yaml, body))
157}
158
159/// Recursively ensure that `serde_yaml::Value::Number` values that were
160/// originally strings (e.g., UIDs like "753659e7") remain as strings.
161///
162/// This is a defensive measure: if a value is already a `String`, leave it.
163/// If it's a `Number`, convert it to `String` representation so serde_yaml
164/// will quote it. This handles the case where a previous parse already
165/// corrupted a hex-like UID into a float.
166///
167/// For sequences and mappings, recurse.
168fn ensure_strings_quoted(value: &serde_yaml::Value) -> serde_yaml::Value {
169    match value {
170        serde_yaml::Value::Sequence(seq) => {
171            serde_yaml::Value::Sequence(seq.iter().map(ensure_strings_quoted).collect())
172        }
173        serde_yaml::Value::Mapping(map) => {
174            let mut new_map = serde_yaml::Mapping::new();
175            for (k, v) in map {
176                new_map.insert(k.clone(), ensure_strings_quoted(v));
177            }
178            serde_yaml::Value::Mapping(new_map)
179        }
180        // Leave other types as-is
181        other => other.clone(),
182    }
183}
184
185/// Extract a frontmatter value as a string, handling the case where YAML
186/// parsed a hex-like string (e.g., `753659e7`) as a number.
187///
188/// Returns `Some(string)` if the value is a String or a Number that can be
189/// converted to string. Returns `None` for other types.
190pub fn value_as_string(value: &serde_yaml::Value) -> Option<String> {
191    match value {
192        serde_yaml::Value::String(s) => Some(s.clone()),
193        serde_yaml::Value::Number(n) => Some(format!("{}", n)),
194        serde_yaml::Value::Bool(b) => Some(format!("{}", b)),
195        _ => None,
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Tests
201// ---------------------------------------------------------------------------
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_parse_with_frontmatter() {
209        let input = "---\ntitle: Hello World\ndate: 2024-01-15\n---\nBody content here.";
210        let doc = parse(input);
211
212        assert_eq!(doc.frontmatter.len(), 2);
213        assert_eq!(
214            doc.frontmatter.get("title").and_then(|v| v.as_str()),
215            Some("Hello World")
216        );
217        assert_eq!(
218            doc.frontmatter.get("date").and_then(|v| v.as_str()),
219            Some("2024-01-15")
220        );
221        assert_eq!(doc.body, "Body content here.");
222        assert!(doc.frontmatter_range.is_some());
223    }
224
225    #[test]
226    fn test_parse_no_frontmatter() {
227        let input = "Just body content.";
228        let doc = parse(input);
229
230        assert!(doc.frontmatter.is_empty());
231        assert_eq!(doc.body, "Just body content.");
232        assert!(doc.frontmatter_range.is_none());
233    }
234
235    #[test]
236    fn test_parse_empty_frontmatter() {
237        let input = "---\n---\nBody after empty frontmatter.";
238        let doc = parse(input);
239
240        // serde_yaml::from_str("") returns Err for empty input, so this
241        // should be treated as no-frontmatter (invalid YAML).
242        // Actually, empty string can produce Null rather than a map.
243        // Either way the behavior is graceful.
244        assert_eq!(doc.body, "Body after empty frontmatter.");
245    }
246
247    #[test]
248    fn test_parse_no_closing_delimiter() {
249        let input = "---\ntitle: Hello\nno closing";
250        let doc = parse(input);
251
252        assert!(doc.frontmatter.is_empty());
253        assert_eq!(doc.body, input);
254        assert!(doc.frontmatter_range.is_none());
255    }
256
257    #[test]
258    fn test_parse_yaml_arrays() {
259        let input = "---\ntags:\n  - rust\n  - wasm\n---\nBody.";
260        let doc = parse(input);
261
262        let tags = doc.frontmatter.get("tags").expect("tags field");
263        let seq = tags.as_sequence().expect("should be sequence");
264        assert_eq!(seq.len(), 2);
265        assert_eq!(seq[0].as_str(), Some("rust"));
266        assert_eq!(seq[1].as_str(), Some("wasm"));
267    }
268
269    #[test]
270    fn test_parse_boolean_values() {
271        let input = "---\ndraft: true\n---\nContent.";
272        let doc = parse(input);
273
274        assert_eq!(
275            doc.frontmatter.get("draft").and_then(|v| v.as_bool()),
276            Some(true)
277        );
278    }
279
280    #[test]
281    fn test_parse_numeric_values() {
282        let input = "---\nweight: 42\nrating: 3.5\n---\nContent.";
283        let doc = parse(input);
284
285        assert_eq!(
286            doc.frontmatter.get("weight").and_then(|v| v.as_u64()),
287            Some(42)
288        );
289        assert_eq!(
290            doc.frontmatter.get("rating").and_then(|v| v.as_f64()),
291            Some(3.5)
292        );
293    }
294
295    #[test]
296    fn test_parse_preserves_body_exactly() {
297        let body = "Line 1\n\nLine 3 with **bold**\n\n- list item\n";
298        let input = format!("---\ntitle: Test\n---\n{}", body);
299        let doc = parse(&input);
300
301        assert_eq!(doc.body, body);
302    }
303
304    #[test]
305    fn test_frontmatter_range_byte_offsets() {
306        let input = "---\ntitle: Hi\n---\nBody.";
307        let doc = parse(input);
308
309        let (start, end) = doc.frontmatter_range.expect("range");
310        assert_eq!(start, 0);
311        // "---\ntitle: Hi\n---\n" = 18 bytes. The slices below assert the
312        // byte-offset contract of `frontmatter_range`: each offset lands on a
313        // line boundary (after `\n`), which is ASCII and therefore char-aligned.
314        #[allow(clippy::string_slice)] // char-aligned: range returns line-boundary byte offsets
315        {
316            assert_eq!(&input[start..end], "---\ntitle: Hi\n---\n");
317            assert_eq!(&input[end..], "Body.");
318        }
319    }
320
321    #[test]
322    fn test_serialize_with_frontmatter() {
323        let mut fm = HashMap::new();
324        fm.insert(
325            "title".to_string(),
326            serde_yaml::Value::String("Hello".to_string()),
327        );
328
329        let result = serialize(&fm, "Body content.").expect("serialize");
330
331        assert!(result.starts_with("---\n"));
332        assert!(result.contains("title: Hello"));
333        assert!(result.contains("---\nBody content."));
334    }
335
336    #[test]
337    fn test_serialize_empty_frontmatter() {
338        let fm = HashMap::new();
339        let result = serialize(&fm, "Just body.").expect("serialize");
340        assert_eq!(result, "Just body.");
341    }
342
343    #[test]
344    fn test_parse_invalid_yaml() {
345        let input = "---\n: invalid: yaml: [unclosed\n---\nBody.";
346        let doc = parse(input);
347
348        // Invalid YAML should fall back to no-frontmatter.
349        assert!(doc.frontmatter.is_empty());
350    }
351
352    #[test]
353    fn test_parse_frontmatter_with_trailing_whitespace_on_delimiter() {
354        let input = "---\ntitle: Test\n---  \nBody.";
355        let doc = parse(input);
356
357        // The closing delimiter has trailing spaces — `line.trim() == "---"` should match.
358        assert_eq!(
359            doc.frontmatter.get("title").and_then(|v| v.as_str()),
360            Some("Test")
361        );
362        assert_eq!(doc.body, "Body.");
363    }
364
365    #[test]
366    fn test_parse_content_starts_with_dashes_but_not_frontmatter() {
367        let input = "---- Not frontmatter\nJust text.";
368        let doc = parse(input);
369
370        // Starts with "----" (4 dashes), which starts_with("---") is true.
371        // But after the first line, there's no closing `---`.
372        assert!(doc.frontmatter.is_empty());
373        assert_eq!(doc.body, input);
374    }
375
376    #[test]
377    fn test_roundtrip() {
378        let input = "---\ntitle: Round Trip\n---\nBody stays the same.";
379        let doc = parse(input);
380
381        let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
382
383        // Re-parse and verify
384        let doc2 = parse(&output);
385        assert_eq!(
386            doc.frontmatter.get("title"),
387            doc2.frontmatter.get("title")
388        );
389        assert_eq!(doc.body, doc2.body);
390    }
391
392    #[test]
393    fn test_parse_multiline_body() {
394        let input = "---\ntitle: Test\n---\nParagraph 1.\n\nParagraph 2.\n\n> Quote\n";
395        let doc = parse(input);
396
397        assert_eq!(doc.body, "Paragraph 1.\n\nParagraph 2.\n\n> Quote\n");
398    }
399
400    #[test]
401    fn test_parse_only_dashes() {
402        let input = "---";
403        let doc = parse(input);
404
405        assert!(doc.frontmatter.is_empty());
406        assert_eq!(doc.body, "---");
407    }
408
409    #[test]
410    fn test_parse_crlf_content() {
411        let input = "---\r\ntitle: Hello World\r\ndate: 2024-01-15\r\n---\r\nBody content here.";
412        let doc = parse(input);
413
414        assert_eq!(doc.frontmatter.len(), 2);
415        assert_eq!(
416            doc.frontmatter.get("title").and_then(|v| v.as_str()),
417            Some("Hello World")
418        );
419        assert_eq!(
420            doc.frontmatter.get("date").and_then(|v| v.as_str()),
421            Some("2024-01-15")
422        );
423        assert_eq!(doc.body, "Body content here.");
424        assert!(doc.frontmatter_range.is_some());
425    }
426
427    #[test]
428    fn test_parse_crlf_byte_offsets() {
429        let input = "---\r\ntitle: Hi\r\n---\r\nBody.";
430        let doc = parse(input);
431
432        let (start, end) = doc.frontmatter_range.expect("range");
433        assert_eq!(start, 0);
434        // After CRLF normalization, offsets are relative to the normalized string.
435        // "---\ntitle: Hi\n---\n" = 18 bytes
436        assert_eq!(end, 18);
437    }
438
439    #[test]
440    fn test_parse_crlf_preserves_body() {
441        let body = "Line 1\nLine 2\n";
442        let input = format!("---\r\ntitle: Test\r\n---\r\n{}", body.replace('\n', "\r\n"));
443        let doc = parse(&input);
444
445        assert_eq!(
446            doc.frontmatter.get("title").and_then(|v| v.as_str()),
447            Some("Test")
448        );
449        // Body CRLF is also normalized to LF.
450        assert_eq!(doc.body, body);
451    }
452
453    #[test]
454    fn test_parse_crlf_yaml_arrays() {
455        let input = "---\r\ntags:\r\n  - rust\r\n  - wasm\r\n---\r\nBody.";
456        let doc = parse(input);
457
458        let tags = doc.frontmatter.get("tags").expect("tags field");
459        let seq = tags.as_sequence().expect("should be sequence");
460        assert_eq!(seq.len(), 2);
461        assert_eq!(seq[0].as_str(), Some("rust"));
462        assert_eq!(seq[1].as_str(), Some("wasm"));
463    }
464
465    #[test]
466    fn test_uid_scientific_notation_roundtrip() {
467        // Regression test: UIDs like "753659e7" look like YAML scientific
468        // notation and get parsed as floats. The serialize path must quote them.
469        let input = "---\ntitle: Test\nuid: \"753659e7\"\n---\nBody.";
470        let doc = parse(input);
471
472        // When properly quoted, uid is parsed as a string
473        let uid_val = doc.frontmatter.get("uid").expect("uid field");
474        assert_eq!(uid_val.as_str(), Some("753659e7"));
475
476        // Round-trip: serialize and re-parse
477        let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
478        let doc2 = parse(&output);
479        let uid2 = doc2.frontmatter.get("uid").expect("uid field after roundtrip");
480        assert_eq!(uid2.as_str(), Some("753659e7"));
481    }
482
483    #[test]
484    fn test_value_as_string_handles_numbers() {
485        // If a uid was already corrupted to a number by YAML parsing,
486        // value_as_string should still extract a usable string.
487        let num_val = serde_yaml::Value::Number(serde_yaml::Number::from(75365900));
488        assert!(value_as_string(&num_val).is_some());
489
490        let str_val = serde_yaml::Value::String("753659e7".to_string());
491        assert_eq!(value_as_string(&str_val), Some("753659e7".to_string()));
492    }
493
494    #[test]
495    fn test_unquoted_uid_parsed_as_number() {
496        // Demonstrates the bug: unquoted hex-like UIDs are parsed as numbers
497        let input = "---\ntitle: Test\nuid: 753659e7\n---\nBody.";
498        let doc = parse(input);
499
500        let uid_val = doc.frontmatter.get("uid").expect("uid field");
501        // serde_yaml parses this as a number, not a string
502        assert!(
503            uid_val.as_str().is_none(),
504            "Unquoted 753659e7 should NOT parse as string (it's a YAML number)"
505        );
506
507        // But value_as_string can still extract it
508        assert!(value_as_string(uid_val).is_some());
509    }
510}