1use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ParsedDocument {
16 pub frontmatter: HashMap<String, serde_yaml::Value>,
18 pub body: String,
20 pub frontmatter_range: Option<(usize, usize)>,
23 #[serde(default)]
32 pub frontmatter_error: Option<String>,
33}
34
35impl ParsedDocument {
36 #[allow(clippy::string_slice)]
44 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
55pub fn parse(content: &str) -> ParsedDocument {
63 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 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 let after_opening = match content.find('\n') {
84 Some(pos) => pos + 1,
85 None => {
86 return ParsedDocument {
88 frontmatter: HashMap::new(),
89 body: content.to_string(),
90 frontmatter_range: None,
91 frontmatter_error: None,
92 };
93 }
94 };
95
96 #[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 let close_line_start = after_opening + offset;
106 let close_line_end = close_line_start + line.len();
107
108 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 #[allow(clippy::string_slice)]
123 let yaml_text = &content[after_opening..close_line_start];
124
125 let frontmatter: HashMap<String, serde_yaml::Value> =
127 match serde_yaml::from_str(yaml_text) {
128 Ok(map) => map,
129 Err(e) => {
130 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 #[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; }
162
163 ParsedDocument {
165 frontmatter: HashMap::new(),
166 body: content.to_string(),
167 frontmatter_range: None,
168 frontmatter_error: None,
169 }
170}
171
172pub 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 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 Ok(format!("---\n{}---\n{}", yaml, body))
202}
203
204fn 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 other => other.clone(),
241 }
242}
243
244fn is_stray_control_char(c: char) -> bool {
247 matches!(c as u32,
248 0x00..=0x08 | 0x0b..=0x0c | 0x0e..=0x1f | 0x7f..=0x9f
249 )
250}
251
252pub fn strip_control_chars_str(s: &str) -> String {
261 s.chars().filter(|c| !is_stray_control_char(*c)).collect()
262}
263
264fn 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 other => other.clone(),
287 }
288}
289
290pub 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#[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 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 #[allow(clippy::string_slice)] {
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 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 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 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 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 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 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 let input = "---\ntitle: Test\nuid: \"753659e7\"\n---\nBody.";
586 let doc = parse(input);
587
588 let uid_val = doc.frontmatter.get("uid").expect("uid field");
590 assert_eq!(uid_val.as_str(), Some("753659e7"));
591
592 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 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 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 assert!(
619 uid_val.as_str().is_none(),
620 "Unquoted 753659e7 should NOT parse as string (it's a YAML number)"
621 );
622
623 assert!(value_as_string(uid_val).is_some());
625 }
626
627 #[test]
628 fn test_serialize_strips_stray_control_chars() {
629 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 #[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 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)] {
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 #[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 #[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 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}