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    /// serde_yaml error message when a delimited `---...---` block failed to
24    /// parse as YAML. `None` when the block parsed cleanly or there was no
25    /// delimited block. When `Some`, `frontmatter` is empty and `body` still
26    /// holds the WHOLE document (so the editor can show/repair the bad block);
27    /// use `render_body()` for the HTML-render view that excludes it.
28    ///
29    /// `#[serde(default)]` is defensive: the type derives Deserialize but is
30    /// transient (no code deserializes INTO it).
31    #[serde(default)]
32    pub frontmatter_error: Option<String>,
33}
34
35impl ParsedDocument {
36    /// Body suitable for RENDERING to HTML (build pipeline): excludes a
37    /// delimited frontmatter block that FAILED to parse, so malformed YAML never
38    /// leaks verbatim into output. Equal to `body` when the frontmatter parsed
39    /// cleanly or there was no block.
40    ///
41    /// The EDITOR must NOT use this — it needs the raw `body` so the author can
42    /// see/repair the bad block and a full-reserialize save preserves it.
43    #[allow(clippy::string_slice)]
44    // `fm_end` is a line-boundary offset (the `frontmatter_range` contract); on
45    // the error path `body` == the CRLF-normalized content that `fm_end` indexes,
46    // so the slice is char-aligned and CRLF-safe.
47    pub fn render_body(&self) -> &str {
48        match (self.frontmatter_error.as_ref(), self.frontmatter_range) {
49            (Some(_), Some((_, fm_end))) => &self.body[fm_end..],
50            _ => &self.body,
51        }
52    }
53}
54
55/// Parse a markdown document, extracting frontmatter and body.
56///
57/// If the content starts with `---\n`, the YAML frontmatter is extracted
58/// and deserialized into a `HashMap`. The body is everything after the
59/// closing `---` delimiter (preserved byte-for-byte).
60///
61/// If no frontmatter is found, returns an empty map with the full content as body.
62pub fn parse(content: &str) -> ParsedDocument {
63    // Normalize CRLF → LF so byte-offset arithmetic can assume single-byte newlines.
64    let owned;
65    let content = if content.contains("\r\n") {
66        owned = content.replace("\r\n", "\n");
67        owned.as_str()
68    } else {
69        content
70    };
71
72    // Must start with `---` followed by newline (or just `---` at end of content).
73    if !content.starts_with("---") {
74        return ParsedDocument {
75            frontmatter: HashMap::new(),
76            body: content.to_string(),
77            frontmatter_range: None,
78            frontmatter_error: None,
79        };
80    }
81
82    // Find end of opening `---` line.
83    let after_opening = match content.find('\n') {
84        Some(pos) => pos + 1,
85        None => {
86            // Content is just "---" with no newline — no valid frontmatter.
87            return ParsedDocument {
88                frontmatter: HashMap::new(),
89                body: content.to_string(),
90                frontmatter_range: None,
91                frontmatter_error: None,
92            };
93        }
94    };
95
96    // Search for closing `---` line in the remainder.
97    // Char-aligned: `after_opening = pos + 1` where `pos = content.find('\n')`,
98    // and '\n' is a single ASCII byte, so the index lands on a char boundary.
99    #[allow(clippy::string_slice)]
100    let rest = &content[after_opening..];
101    let mut offset = 0;
102    for line in rest.lines() {
103        if line.trim() == "---" {
104            // Found closing delimiter.
105            let close_line_start = after_opening + offset;
106            let close_line_end = close_line_start + line.len();
107
108            // Include the newline after the closing `---` if present.
109            let fm_end = if close_line_end < content.len()
110                && content.as_bytes()[close_line_end] == b'\n'
111            {
112                close_line_end + 1
113            } else {
114                close_line_end
115            };
116
117            // The YAML text is between the opening and closing delimiters.
118            // Char-aligned: `after_opening` follows '\n' (ASCII), and
119            // `close_line_start = after_opening + offset` where `offset`
120            // accumulates `line.len() + 1` per line returned by `lines()`
121            // (each line is a complete-char slice and '\n' is one byte).
122            #[allow(clippy::string_slice)]
123            let yaml_text = &content[after_opening..close_line_start];
124
125            // Parse the YAML.
126            let frontmatter: HashMap<String, serde_yaml::Value> =
127                match serde_yaml::from_str(yaml_text) {
128                    Ok(map) => map,
129                    Err(e) => {
130                        // Invalid YAML. Record the block range + surface the
131                        // error instead of silently swallowing it (which used to
132                        // dump the raw `---...---` block into `body`, leaking it
133                        // verbatim into rendered HTML with no warning — the
134                        // "Europe - A Prophecy.md" bug). `body` stays the WHOLE
135                        // document so the editor can still show/repair the block
136                        // and a re-serialize save preserves the file; the build
137                        // renders `render_body()` (block-excluded) so nothing
138                        // leaks. See ADR-020.
139                        return ParsedDocument {
140                            frontmatter: HashMap::new(),
141                            body: content.to_string(),
142                            frontmatter_range: Some((0, fm_end)),
143                            frontmatter_error: Some(e.to_string()),
144                        };
145                    }
146                };
147
148            // Char-aligned: `fm_end` is `close_line_end` (= line-aligned via `lines()`
149            // + ASCII '---') optionally + 1 for an ASCII '\n'.
150            #[allow(clippy::string_slice)]
151            let body = &content[fm_end..];
152
153            return ParsedDocument {
154                frontmatter,
155                body: body.to_string(),
156                frontmatter_range: Some((0, fm_end)),
157                frontmatter_error: None,
158            };
159        }
160        offset += line.len() + 1; // +1 for '\n'
161    }
162
163    // No closing delimiter found — no valid frontmatter.
164    ParsedDocument {
165        frontmatter: HashMap::new(),
166        body: content.to_string(),
167        frontmatter_range: None,
168        frontmatter_error: None,
169    }
170}
171
172/// Serialize frontmatter and body back into a markdown document.
173///
174/// Produces `---\n{yaml}\n---\n{body}`. If frontmatter is empty,
175/// returns just the body.
176///
177/// String values that look like YAML numbers (integers, floats, scientific
178/// notation like `753659e7`) are forced to `serde_yaml::Value::String` before
179/// serialization so that serde_yaml quotes them. This prevents silent data
180/// corruption on the next parse.
181pub fn serialize(
182    frontmatter: &HashMap<String, serde_yaml::Value>,
183    body: &str,
184) -> Result<String, String> {
185    if frontmatter.is_empty() {
186        return Ok(body.to_string());
187    }
188
189    // Ensure string values that look numeric are serialized as quoted strings.
190    // Also strip stray control characters (defense-in-depth mirror of the
191    // frontend `beforeinput` guard, see below) before either transform.
192    let safe_fm: HashMap<String, serde_yaml::Value> = frontmatter
193        .iter()
194        .map(|(k, v)| (k.clone(), ensure_strings_quoted(&strip_control_chars(v))))
195        .collect();
196
197    let yaml =
198        serde_yaml::to_string(&safe_fm).map_err(|e| format!("YAML serialize error: {}", e))?;
199
200    // serde_yaml adds a trailing newline; no need to add another.
201    Ok(format!("---\n{}---\n{}", yaml, body))
202}
203
204/// Recursively strip stray C0/C1 control characters from `serde_yaml::Value`
205/// strings (write-boundary defense-in-depth).
206///
207/// ── Why this exists (root cause) ─────────────────────────────────────────
208/// On macOS, Tauri v2's multiwebview path (moss enables the `unstable`
209/// feature and creates the editor as a child webview via `window.add_child`)
210/// hits an unfixed wry bug: arrow keys forward into AppKit's
211/// `interpretKeyEvents:` -> `insertText:`, which types the arrow key's
212/// legacy control code (Left 0x1C, Right 0x1D, Up 0x1E, Down 0x1F) into a
213/// plain `<input>`/`<textarea>` instead of only moving the caret. See
214/// `tauri-apps/tauri#10194` (open upstream issue).
215///
216/// The frontend guards this at the DOM `beforeinput` boundary (see
217/// `frontend/app/ui/control-char-guard.ts`), but this Rust strip mirrors it
218/// at the write boundary as defense-in-depth — any control char that reaches
219/// this point (e.g. a value set before the guard was installed, or via a
220/// path that bypasses the DOM entirely) is stripped before it is ever
221/// persisted to disk.
222///
223/// Removes C0 controls (0x00-0x1F) EXCEPT TAB (0x09), LF (0x0A), CR (0x0D);
224/// DEL (0x7F); and C1 controls (0x80-0x9F). This numeric-range approach
225/// mirrors the frontend guard's `CONTROL_RANGES` table exactly.
226fn strip_control_chars(value: &serde_yaml::Value) -> serde_yaml::Value {
227    match value {
228        serde_yaml::Value::String(s) => serde_yaml::Value::String(strip_control_chars_str(s)),
229        serde_yaml::Value::Sequence(seq) => {
230            serde_yaml::Value::Sequence(seq.iter().map(strip_control_chars).collect())
231        }
232        serde_yaml::Value::Mapping(map) => {
233            let mut new_map = serde_yaml::Mapping::new();
234            for (k, v) in map {
235                new_map.insert(k.clone(), strip_control_chars(v));
236            }
237            serde_yaml::Value::Mapping(new_map)
238        }
239        // Leave other types as-is
240        other => other.clone(),
241    }
242}
243
244/// True if `c` is a C0/C1 control character that must never survive into
245/// saved frontmatter (excludes TAB/LF/CR, which are legitimate whitespace).
246fn is_stray_control_char(c: char) -> bool {
247    matches!(c as u32,
248        0x00..=0x08 | 0x0b..=0x0c | 0x0e..=0x1f | 0x7f..=0x9f
249    )
250}
251
252/// Remove all stray C0/C1 control characters (excluding TAB/LF/CR) from `s`.
253///
254/// The shared control-char stripper: this is the same string-level primitive
255/// `strip_control_chars` (above) applies recursively to `serde_yaml::Value`
256/// trees. It is `pub` so other crates (e.g. `src-tauri`'s scrape/email write
257/// paths) can apply the identical defense-in-depth strip at their own
258/// hand-rolled or `serde_yaml`-based frontmatter funnels — see
259/// `tauri-apps/tauri#10194`.
260pub fn strip_control_chars_str(s: &str) -> String {
261    s.chars().filter(|c| !is_stray_control_char(*c)).collect()
262}
263
264/// Recursively ensure that `serde_yaml::Value::Number` values that were
265/// originally strings (e.g., UIDs like "753659e7") remain as strings.
266///
267/// This is a defensive measure: if a value is already a `String`, leave it.
268/// If it's a `Number`, convert it to `String` representation so serde_yaml
269/// will quote it. This handles the case where a previous parse already
270/// corrupted a hex-like UID into a float.
271///
272/// For sequences and mappings, recurse.
273fn ensure_strings_quoted(value: &serde_yaml::Value) -> serde_yaml::Value {
274    match value {
275        serde_yaml::Value::Sequence(seq) => {
276            serde_yaml::Value::Sequence(seq.iter().map(ensure_strings_quoted).collect())
277        }
278        serde_yaml::Value::Mapping(map) => {
279            let mut new_map = serde_yaml::Mapping::new();
280            for (k, v) in map {
281                new_map.insert(k.clone(), ensure_strings_quoted(v));
282            }
283            serde_yaml::Value::Mapping(new_map)
284        }
285        // Leave other types as-is
286        other => other.clone(),
287    }
288}
289
290/// Extract a frontmatter value as a string, handling the case where YAML
291/// parsed a hex-like string (e.g., `753659e7`) as a number.
292///
293/// Returns `Some(string)` if the value is a String or a Number that can be
294/// converted to string. Returns `None` for other types.
295pub fn value_as_string(value: &serde_yaml::Value) -> Option<String> {
296    match value {
297        serde_yaml::Value::String(s) => Some(s.clone()),
298        serde_yaml::Value::Number(n) => Some(format!("{}", n)),
299        serde_yaml::Value::Bool(b) => Some(format!("{}", b)),
300        _ => None,
301    }
302}
303
304// ---------------------------------------------------------------------------
305// Tests
306// ---------------------------------------------------------------------------
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_parse_with_frontmatter() {
314        let input = "---\ntitle: Hello World\ndate: 2024-01-15\n---\nBody content here.";
315        let doc = parse(input);
316
317        assert_eq!(doc.frontmatter.len(), 2);
318        assert_eq!(
319            doc.frontmatter.get("title").and_then(|v| v.as_str()),
320            Some("Hello World")
321        );
322        assert_eq!(
323            doc.frontmatter.get("date").and_then(|v| v.as_str()),
324            Some("2024-01-15")
325        );
326        assert_eq!(doc.body, "Body content here.");
327        assert!(doc.frontmatter_range.is_some());
328        assert!(doc.frontmatter_error.is_none(), "valid YAML reports no error");
329    }
330
331    #[test]
332    fn test_parse_no_frontmatter() {
333        let input = "Just body content.";
334        let doc = parse(input);
335
336        assert!(doc.frontmatter.is_empty());
337        assert_eq!(doc.body, "Just body content.");
338        assert!(doc.frontmatter_range.is_none());
339        assert!(doc.frontmatter_error.is_none(), "no block → no YAML error");
340    }
341
342    #[test]
343    fn test_parse_empty_frontmatter() {
344        let input = "---\n---\nBody after empty frontmatter.";
345        let doc = parse(input);
346
347        // serde_yaml::from_str("") returns Err for empty input, so this
348        // should be treated as no-frontmatter (invalid YAML).
349        // Actually, empty string can produce Null rather than a map.
350        // Either way the behavior is graceful.
351        assert_eq!(doc.body, "Body after empty frontmatter.");
352    }
353
354    #[test]
355    fn test_parse_no_closing_delimiter() {
356        let input = "---\ntitle: Hello\nno closing";
357        let doc = parse(input);
358
359        assert!(doc.frontmatter.is_empty());
360        assert_eq!(doc.body, input);
361        assert!(doc.frontmatter_range.is_none());
362        assert!(
363            doc.frontmatter_error.is_none(),
364            "unterminated block is not a YAML parse error; body stays whole"
365        );
366    }
367
368    #[test]
369    fn test_parse_yaml_arrays() {
370        let input = "---\ntags:\n  - rust\n  - wasm\n---\nBody.";
371        let doc = parse(input);
372
373        let tags = doc.frontmatter.get("tags").expect("tags field");
374        let seq = tags.as_sequence().expect("should be sequence");
375        assert_eq!(seq.len(), 2);
376        assert_eq!(seq[0].as_str(), Some("rust"));
377        assert_eq!(seq[1].as_str(), Some("wasm"));
378    }
379
380    #[test]
381    fn test_parse_boolean_values() {
382        let input = "---\ndraft: true\n---\nContent.";
383        let doc = parse(input);
384
385        assert_eq!(
386            doc.frontmatter.get("draft").and_then(|v| v.as_bool()),
387            Some(true)
388        );
389    }
390
391    #[test]
392    fn test_parse_numeric_values() {
393        let input = "---\nweight: 42\nrating: 3.5\n---\nContent.";
394        let doc = parse(input);
395
396        assert_eq!(
397            doc.frontmatter.get("weight").and_then(|v| v.as_u64()),
398            Some(42)
399        );
400        assert_eq!(
401            doc.frontmatter.get("rating").and_then(|v| v.as_f64()),
402            Some(3.5)
403        );
404    }
405
406    #[test]
407    fn test_parse_preserves_body_exactly() {
408        let body = "Line 1\n\nLine 3 with **bold**\n\n- list item\n";
409        let input = format!("---\ntitle: Test\n---\n{}", body);
410        let doc = parse(&input);
411
412        assert_eq!(doc.body, body);
413    }
414
415    #[test]
416    fn test_frontmatter_range_byte_offsets() {
417        let input = "---\ntitle: Hi\n---\nBody.";
418        let doc = parse(input);
419
420        let (start, end) = doc.frontmatter_range.expect("range");
421        assert_eq!(start, 0);
422        // "---\ntitle: Hi\n---\n" = 18 bytes. The slices below assert the
423        // byte-offset contract of `frontmatter_range`: each offset lands on a
424        // line boundary (after `\n`), which is ASCII and therefore char-aligned.
425        #[allow(clippy::string_slice)] // char-aligned: range returns line-boundary byte offsets
426        {
427            assert_eq!(&input[start..end], "---\ntitle: Hi\n---\n");
428            assert_eq!(&input[end..], "Body.");
429        }
430    }
431
432    #[test]
433    fn test_serialize_with_frontmatter() {
434        let mut fm = HashMap::new();
435        fm.insert(
436            "title".to_string(),
437            serde_yaml::Value::String("Hello".to_string()),
438        );
439
440        let result = serialize(&fm, "Body content.").expect("serialize");
441
442        assert!(result.starts_with("---\n"));
443        assert!(result.contains("title: Hello"));
444        assert!(result.contains("---\nBody content."));
445    }
446
447    #[test]
448    fn test_serialize_empty_frontmatter() {
449        let fm = HashMap::new();
450        let result = serialize(&fm, "Just body.").expect("serialize");
451        assert_eq!(result, "Just body.");
452    }
453
454    #[test]
455    fn test_parse_invalid_yaml() {
456        let input = "---\n: invalid: yaml: [unclosed\n---\nBody.";
457        let doc = parse(input);
458
459        // Invalid YAML should fall back to no-frontmatter, but now the block is
460        // recorded, the error surfaced, and the body kept whole (no data loss).
461        assert!(doc.frontmatter.is_empty());
462        assert_eq!(doc.body, input, "body preserved whole on YAML error");
463        assert!(doc.frontmatter_error.is_some());
464        assert!(doc.frontmatter_range.is_some());
465        assert_eq!(doc.render_body(), "Body.", "render view excludes the bad block");
466    }
467
468    #[test]
469    fn test_parse_frontmatter_with_trailing_whitespace_on_delimiter() {
470        let input = "---\ntitle: Test\n---  \nBody.";
471        let doc = parse(input);
472
473        // The closing delimiter has trailing spaces — `line.trim() == "---"` should match.
474        assert_eq!(
475            doc.frontmatter.get("title").and_then(|v| v.as_str()),
476            Some("Test")
477        );
478        assert_eq!(doc.body, "Body.");
479    }
480
481    #[test]
482    fn test_parse_content_starts_with_dashes_but_not_frontmatter() {
483        let input = "---- Not frontmatter\nJust text.";
484        let doc = parse(input);
485
486        // Starts with "----" (4 dashes), which starts_with("---") is true.
487        // But after the first line, there's no closing `---`.
488        assert!(doc.frontmatter.is_empty());
489        assert_eq!(doc.body, input);
490    }
491
492    #[test]
493    fn test_roundtrip() {
494        let input = "---\ntitle: Round Trip\n---\nBody stays the same.";
495        let doc = parse(input);
496
497        let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
498
499        // Re-parse and verify
500        let doc2 = parse(&output);
501        assert_eq!(
502            doc.frontmatter.get("title"),
503            doc2.frontmatter.get("title")
504        );
505        assert_eq!(doc.body, doc2.body);
506    }
507
508    #[test]
509    fn test_parse_multiline_body() {
510        let input = "---\ntitle: Test\n---\nParagraph 1.\n\nParagraph 2.\n\n> Quote\n";
511        let doc = parse(input);
512
513        assert_eq!(doc.body, "Paragraph 1.\n\nParagraph 2.\n\n> Quote\n");
514    }
515
516    #[test]
517    fn test_parse_only_dashes() {
518        let input = "---";
519        let doc = parse(input);
520
521        assert!(doc.frontmatter.is_empty());
522        assert_eq!(doc.body, "---");
523    }
524
525    #[test]
526    fn test_parse_crlf_content() {
527        let input = "---\r\ntitle: Hello World\r\ndate: 2024-01-15\r\n---\r\nBody content here.";
528        let doc = parse(input);
529
530        assert_eq!(doc.frontmatter.len(), 2);
531        assert_eq!(
532            doc.frontmatter.get("title").and_then(|v| v.as_str()),
533            Some("Hello World")
534        );
535        assert_eq!(
536            doc.frontmatter.get("date").and_then(|v| v.as_str()),
537            Some("2024-01-15")
538        );
539        assert_eq!(doc.body, "Body content here.");
540        assert!(doc.frontmatter_range.is_some());
541    }
542
543    #[test]
544    fn test_parse_crlf_byte_offsets() {
545        let input = "---\r\ntitle: Hi\r\n---\r\nBody.";
546        let doc = parse(input);
547
548        let (start, end) = doc.frontmatter_range.expect("range");
549        assert_eq!(start, 0);
550        // After CRLF normalization, offsets are relative to the normalized string.
551        // "---\ntitle: Hi\n---\n" = 18 bytes
552        assert_eq!(end, 18);
553    }
554
555    #[test]
556    fn test_parse_crlf_preserves_body() {
557        let body = "Line 1\nLine 2\n";
558        let input = format!("---\r\ntitle: Test\r\n---\r\n{}", body.replace('\n', "\r\n"));
559        let doc = parse(&input);
560
561        assert_eq!(
562            doc.frontmatter.get("title").and_then(|v| v.as_str()),
563            Some("Test")
564        );
565        // Body CRLF is also normalized to LF.
566        assert_eq!(doc.body, body);
567    }
568
569    #[test]
570    fn test_parse_crlf_yaml_arrays() {
571        let input = "---\r\ntags:\r\n  - rust\r\n  - wasm\r\n---\r\nBody.";
572        let doc = parse(input);
573
574        let tags = doc.frontmatter.get("tags").expect("tags field");
575        let seq = tags.as_sequence().expect("should be sequence");
576        assert_eq!(seq.len(), 2);
577        assert_eq!(seq[0].as_str(), Some("rust"));
578        assert_eq!(seq[1].as_str(), Some("wasm"));
579    }
580
581    #[test]
582    fn test_uid_scientific_notation_roundtrip() {
583        // Regression test: UIDs like "753659e7" look like YAML scientific
584        // notation and get parsed as floats. The serialize path must quote them.
585        let input = "---\ntitle: Test\nuid: \"753659e7\"\n---\nBody.";
586        let doc = parse(input);
587
588        // When properly quoted, uid is parsed as a string
589        let uid_val = doc.frontmatter.get("uid").expect("uid field");
590        assert_eq!(uid_val.as_str(), Some("753659e7"));
591
592        // Round-trip: serialize and re-parse
593        let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
594        let doc2 = parse(&output);
595        let uid2 = doc2.frontmatter.get("uid").expect("uid field after roundtrip");
596        assert_eq!(uid2.as_str(), Some("753659e7"));
597    }
598
599    #[test]
600    fn test_value_as_string_handles_numbers() {
601        // If a uid was already corrupted to a number by YAML parsing,
602        // value_as_string should still extract a usable string.
603        let num_val = serde_yaml::Value::Number(serde_yaml::Number::from(75365900));
604        assert!(value_as_string(&num_val).is_some());
605
606        let str_val = serde_yaml::Value::String("753659e7".to_string());
607        assert_eq!(value_as_string(&str_val), Some("753659e7".to_string()));
608    }
609
610    #[test]
611    fn test_unquoted_uid_parsed_as_number() {
612        // Demonstrates the bug: unquoted hex-like UIDs are parsed as numbers
613        let input = "---\ntitle: Test\nuid: 753659e7\n---\nBody.";
614        let doc = parse(input);
615
616        let uid_val = doc.frontmatter.get("uid").expect("uid field");
617        // serde_yaml parses this as a number, not a string
618        assert!(
619            uid_val.as_str().is_none(),
620            "Unquoted 753659e7 should NOT parse as string (it's a YAML number)"
621        );
622
623        // But value_as_string can still extract it
624        assert!(value_as_string(uid_val).is_some());
625    }
626
627    #[test]
628    fn test_serialize_strips_stray_control_chars() {
629        // Regression test for the macOS Tauri multiwebview arrow-key bug
630        // (tauri-apps/tauri#10194): a child webview's beforeinput/keyDown
631        // path can insert the arrow key's legacy control code (Right =
632        // U+001D GROUP SEPARATOR) into a plain input instead of just moving
633        // the caret. The frontend guards this at `beforeinput`
634        // (frontend/app/ui/control-char-guard.ts); this write-boundary strip
635        // is the defense-in-depth backstop so a corrupted value can never
636        // reach disk even if it slips past the DOM guard.
637        let corrupted = format!("websites.{}", "\u{1D}".repeat(8));
638
639        let mut fm = HashMap::new();
640        fm.insert(
641            "description".to_string(),
642            serde_yaml::Value::String(corrupted),
643        );
644
645        let output = serialize(&fm, "Body.").expect("serialize");
646        let doc = parse(&output);
647
648        assert_eq!(
649            doc.frontmatter.get("description").and_then(|v| v.as_str()),
650            Some("websites."),
651            "control chars must be stripped from the written value"
652        );
653    }
654
655    /// THE anti-regression test for the "malformed frontmatter leaks verbatim"
656    /// bug (William Blake "Europe - A Prophecy.md"). Two YAML keys collapsed onto
657    /// one line (`uid: blk-europecover: "006.jpg"`) make serde_yaml fail. The
658    /// parser must (a) report the error, (b) keep the WHOLE document as `body`
659    /// (no data loss — the editor must still see and be able to repair the block),
660    /// and (c) record the block's byte range.
661    #[test]
662    fn test_parse_invalid_yaml_preserves_body_no_data_loss() {
663        let input =
664            "---\nchildren_style: grid\nseries: true\nweight: 10\nuid: blk-europecover: \"006.jpg\"\n---\n\n\ngh\n![[x.jpg]]\n";
665        let doc = parse(input);
666
667        assert!(doc.frontmatter.is_empty(), "malformed YAML yields no fields");
668        assert!(
669            doc.frontmatter_error.is_some(),
670            "the serde_yaml error must be surfaced, not swallowed"
671        );
672        // Range covers the delimited block; body is the WHOLE document (block NOT
673        // trimmed) so the editor can still show/repair it and a re-serialize save
674        // preserves the file.
675        let (start, fm_end) = doc.frontmatter_range.expect("range on malformed block");
676        assert_eq!(start, 0);
677        assert_eq!(doc.body, input, "body must be the whole document — no data loss");
678        #[allow(clippy::string_slice)] // line-boundary offsets, char-aligned
679        {
680            assert!(
681                input[0..fm_end].starts_with("---\n") && input[0..fm_end].ends_with("---\n"),
682                "frontmatter_range must bound the `---...---\\n` block"
683            );
684        }
685    }
686
687    /// The build-facing render view excludes a failed block so malformed YAML
688    /// never leaks verbatim into published HTML.
689    #[test]
690    fn test_render_body_excludes_failed_block() {
691        let input =
692            "---\nchildren_style: grid\nseries: true\nweight: 10\nuid: blk-europecover: \"006.jpg\"\n---\n\n\ngh\n![[x.jpg]]\n";
693        let doc = parse(input);
694
695        let rendered = doc.render_body();
696        assert!(!rendered.contains("---"), "delimiters must not leak: {rendered:?}");
697        assert!(!rendered.contains("uid:"), "raw YAML must not leak: {rendered:?}");
698        assert_eq!(
699            rendered, "\n\ngh\n![[x.jpg]]\n",
700            "render_body is exactly the content after the closing delimiter"
701        );
702    }
703
704    /// On success (and no-frontmatter), render_body is a no-op equal to body.
705    #[test]
706    fn test_render_body_equals_body_on_success() {
707        let ok = parse("---\ntitle: Hi\n---\nBody.");
708        assert!(ok.frontmatter_error.is_none());
709        assert_eq!(ok.render_body(), ok.body);
710        assert_eq!(ok.render_body(), "Body.");
711
712        let none = parse("No frontmatter here.");
713        assert!(none.frontmatter_error.is_none());
714        assert_eq!(none.render_body(), none.body);
715    }
716
717    #[test]
718    fn test_strip_control_chars_str_keeps_tab_lf_cr() {
719        // TAB/LF/CR are legitimate whitespace and must survive the strip —
720        // mirrors the frontend guard's CONTROL_RANGES exclusions.
721        let input = "a\tb\nc\rd\u{00}\u{7f}\u{85}e";
722        assert_eq!(strip_control_chars_str(input), "a\tb\nc\rde");
723    }
724}