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\nunlisted: false\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        assert_eq!(
279            doc.frontmatter.get("unlisted").and_then(|v| v.as_bool()),
280            Some(false)
281        );
282    }
283
284    #[test]
285    fn test_parse_numeric_values() {
286        let input = "---\nweight: 42\nrating: 3.5\n---\nContent.";
287        let doc = parse(input);
288
289        assert_eq!(
290            doc.frontmatter.get("weight").and_then(|v| v.as_u64()),
291            Some(42)
292        );
293        assert_eq!(
294            doc.frontmatter.get("rating").and_then(|v| v.as_f64()),
295            Some(3.5)
296        );
297    }
298
299    #[test]
300    fn test_parse_preserves_body_exactly() {
301        let body = "Line 1\n\nLine 3 with **bold**\n\n- list item\n";
302        let input = format!("---\ntitle: Test\n---\n{}", body);
303        let doc = parse(&input);
304
305        assert_eq!(doc.body, body);
306    }
307
308    #[test]
309    fn test_frontmatter_range_byte_offsets() {
310        let input = "---\ntitle: Hi\n---\nBody.";
311        let doc = parse(input);
312
313        let (start, end) = doc.frontmatter_range.expect("range");
314        assert_eq!(start, 0);
315        // "---\ntitle: Hi\n---\n" = 18 bytes. The slices below assert the
316        // byte-offset contract of `frontmatter_range`: each offset lands on a
317        // line boundary (after `\n`), which is ASCII and therefore char-aligned.
318        #[allow(clippy::string_slice)] // char-aligned: range returns line-boundary byte offsets
319        {
320            assert_eq!(&input[start..end], "---\ntitle: Hi\n---\n");
321            assert_eq!(&input[end..], "Body.");
322        }
323    }
324
325    #[test]
326    fn test_serialize_with_frontmatter() {
327        let mut fm = HashMap::new();
328        fm.insert(
329            "title".to_string(),
330            serde_yaml::Value::String("Hello".to_string()),
331        );
332
333        let result = serialize(&fm, "Body content.").expect("serialize");
334
335        assert!(result.starts_with("---\n"));
336        assert!(result.contains("title: Hello"));
337        assert!(result.contains("---\nBody content."));
338    }
339
340    #[test]
341    fn test_serialize_empty_frontmatter() {
342        let fm = HashMap::new();
343        let result = serialize(&fm, "Just body.").expect("serialize");
344        assert_eq!(result, "Just body.");
345    }
346
347    #[test]
348    fn test_parse_invalid_yaml() {
349        let input = "---\n: invalid: yaml: [unclosed\n---\nBody.";
350        let doc = parse(input);
351
352        // Invalid YAML should fall back to no-frontmatter.
353        assert!(doc.frontmatter.is_empty());
354    }
355
356    #[test]
357    fn test_parse_frontmatter_with_trailing_whitespace_on_delimiter() {
358        let input = "---\ntitle: Test\n---  \nBody.";
359        let doc = parse(input);
360
361        // The closing delimiter has trailing spaces — `line.trim() == "---"` should match.
362        assert_eq!(
363            doc.frontmatter.get("title").and_then(|v| v.as_str()),
364            Some("Test")
365        );
366        assert_eq!(doc.body, "Body.");
367    }
368
369    #[test]
370    fn test_parse_content_starts_with_dashes_but_not_frontmatter() {
371        let input = "---- Not frontmatter\nJust text.";
372        let doc = parse(input);
373
374        // Starts with "----" (4 dashes), which starts_with("---") is true.
375        // But after the first line, there's no closing `---`.
376        assert!(doc.frontmatter.is_empty());
377        assert_eq!(doc.body, input);
378    }
379
380    #[test]
381    fn test_roundtrip() {
382        let input = "---\ntitle: Round Trip\n---\nBody stays the same.";
383        let doc = parse(input);
384
385        let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
386
387        // Re-parse and verify
388        let doc2 = parse(&output);
389        assert_eq!(
390            doc.frontmatter.get("title"),
391            doc2.frontmatter.get("title")
392        );
393        assert_eq!(doc.body, doc2.body);
394    }
395
396    #[test]
397    fn test_parse_multiline_body() {
398        let input = "---\ntitle: Test\n---\nParagraph 1.\n\nParagraph 2.\n\n> Quote\n";
399        let doc = parse(input);
400
401        assert_eq!(doc.body, "Paragraph 1.\n\nParagraph 2.\n\n> Quote\n");
402    }
403
404    #[test]
405    fn test_parse_only_dashes() {
406        let input = "---";
407        let doc = parse(input);
408
409        assert!(doc.frontmatter.is_empty());
410        assert_eq!(doc.body, "---");
411    }
412
413    #[test]
414    fn test_parse_crlf_content() {
415        let input = "---\r\ntitle: Hello World\r\ndate: 2024-01-15\r\n---\r\nBody content here.";
416        let doc = parse(input);
417
418        assert_eq!(doc.frontmatter.len(), 2);
419        assert_eq!(
420            doc.frontmatter.get("title").and_then(|v| v.as_str()),
421            Some("Hello World")
422        );
423        assert_eq!(
424            doc.frontmatter.get("date").and_then(|v| v.as_str()),
425            Some("2024-01-15")
426        );
427        assert_eq!(doc.body, "Body content here.");
428        assert!(doc.frontmatter_range.is_some());
429    }
430
431    #[test]
432    fn test_parse_crlf_byte_offsets() {
433        let input = "---\r\ntitle: Hi\r\n---\r\nBody.";
434        let doc = parse(input);
435
436        let (start, end) = doc.frontmatter_range.expect("range");
437        assert_eq!(start, 0);
438        // After CRLF normalization, offsets are relative to the normalized string.
439        // "---\ntitle: Hi\n---\n" = 18 bytes
440        assert_eq!(end, 18);
441    }
442
443    #[test]
444    fn test_parse_crlf_preserves_body() {
445        let body = "Line 1\nLine 2\n";
446        let input = format!("---\r\ntitle: Test\r\n---\r\n{}", body.replace('\n', "\r\n"));
447        let doc = parse(&input);
448
449        assert_eq!(
450            doc.frontmatter.get("title").and_then(|v| v.as_str()),
451            Some("Test")
452        );
453        // Body CRLF is also normalized to LF.
454        assert_eq!(doc.body, body);
455    }
456
457    #[test]
458    fn test_parse_crlf_yaml_arrays() {
459        let input = "---\r\ntags:\r\n  - rust\r\n  - wasm\r\n---\r\nBody.";
460        let doc = parse(input);
461
462        let tags = doc.frontmatter.get("tags").expect("tags field");
463        let seq = tags.as_sequence().expect("should be sequence");
464        assert_eq!(seq.len(), 2);
465        assert_eq!(seq[0].as_str(), Some("rust"));
466        assert_eq!(seq[1].as_str(), Some("wasm"));
467    }
468
469    #[test]
470    fn test_uid_scientific_notation_roundtrip() {
471        // Regression test: UIDs like "753659e7" look like YAML scientific
472        // notation and get parsed as floats. The serialize path must quote them.
473        let input = "---\ntitle: Test\nuid: \"753659e7\"\n---\nBody.";
474        let doc = parse(input);
475
476        // When properly quoted, uid is parsed as a string
477        let uid_val = doc.frontmatter.get("uid").expect("uid field");
478        assert_eq!(uid_val.as_str(), Some("753659e7"));
479
480        // Round-trip: serialize and re-parse
481        let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
482        let doc2 = parse(&output);
483        let uid2 = doc2.frontmatter.get("uid").expect("uid field after roundtrip");
484        assert_eq!(uid2.as_str(), Some("753659e7"));
485    }
486
487    #[test]
488    fn test_value_as_string_handles_numbers() {
489        // If a uid was already corrupted to a number by YAML parsing,
490        // value_as_string should still extract a usable string.
491        let num_val = serde_yaml::Value::Number(serde_yaml::Number::from(75365900));
492        assert!(value_as_string(&num_val).is_some());
493
494        let str_val = serde_yaml::Value::String("753659e7".to_string());
495        assert_eq!(value_as_string(&str_val), Some("753659e7".to_string()));
496    }
497
498    #[test]
499    fn test_unquoted_uid_parsed_as_number() {
500        // Demonstrates the bug: unquoted hex-like UIDs are parsed as numbers
501        let input = "---\ntitle: Test\nuid: 753659e7\n---\nBody.";
502        let doc = parse(input);
503
504        let uid_val = doc.frontmatter.get("uid").expect("uid field");
505        // serde_yaml parses this as a number, not a string
506        assert!(
507            uid_val.as_str().is_none(),
508            "Unquoted 753659e7 should NOT parse as string (it's a YAML number)"
509        );
510
511        // But value_as_string can still extract it
512        assert!(value_as_string(uid_val).is_some());
513    }
514}