use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedDocument {
pub frontmatter: HashMap<String, serde_yaml::Value>,
pub body: String,
pub frontmatter_range: Option<(usize, usize)>,
#[serde(default)]
pub frontmatter_error: Option<String>,
}
impl ParsedDocument {
#[allow(clippy::string_slice)]
pub fn render_body(&self) -> &str {
match (self.frontmatter_error.as_ref(), self.frontmatter_range) {
(Some(_), Some((_, fm_end))) => &self.body[fm_end..],
_ => &self.body,
}
}
}
pub fn parse(content: &str) -> ParsedDocument {
let owned;
let content = if content.contains("\r\n") {
owned = content.replace("\r\n", "\n");
owned.as_str()
} else {
content
};
if !content.starts_with("---") {
return ParsedDocument {
frontmatter: HashMap::new(),
body: content.to_string(),
frontmatter_range: None,
frontmatter_error: None,
};
}
let after_opening = match content.find('\n') {
Some(pos) => pos + 1,
None => {
return ParsedDocument {
frontmatter: HashMap::new(),
body: content.to_string(),
frontmatter_range: None,
frontmatter_error: None,
};
}
};
#[allow(clippy::string_slice)]
let rest = &content[after_opening..];
let mut offset = 0;
for line in rest.lines() {
if line.trim() == "---" {
let close_line_start = after_opening + offset;
let close_line_end = close_line_start + line.len();
let fm_end = if close_line_end < content.len()
&& content.as_bytes()[close_line_end] == b'\n'
{
close_line_end + 1
} else {
close_line_end
};
#[allow(clippy::string_slice)]
let yaml_text = &content[after_opening..close_line_start];
let frontmatter: HashMap<String, serde_yaml::Value> =
match serde_yaml::from_str(yaml_text) {
Ok(map) => map,
Err(e) => {
return ParsedDocument {
frontmatter: HashMap::new(),
body: content.to_string(),
frontmatter_range: Some((0, fm_end)),
frontmatter_error: Some(e.to_string()),
};
}
};
#[allow(clippy::string_slice)]
let body = &content[fm_end..];
return ParsedDocument {
frontmatter,
body: body.to_string(),
frontmatter_range: Some((0, fm_end)),
frontmatter_error: None,
};
}
offset += line.len() + 1; }
ParsedDocument {
frontmatter: HashMap::new(),
body: content.to_string(),
frontmatter_range: None,
frontmatter_error: None,
}
}
pub fn serialize(
frontmatter: &HashMap<String, serde_yaml::Value>,
body: &str,
) -> Result<String, String> {
if frontmatter.is_empty() {
return Ok(body.to_string());
}
let safe_fm: HashMap<String, serde_yaml::Value> = frontmatter
.iter()
.map(|(k, v)| (k.clone(), ensure_strings_quoted(&strip_control_chars(v))))
.collect();
let yaml =
serde_yaml::to_string(&safe_fm).map_err(|e| format!("YAML serialize error: {}", e))?;
Ok(format!("---\n{}---\n{}", yaml, body))
}
fn strip_control_chars(value: &serde_yaml::Value) -> serde_yaml::Value {
match value {
serde_yaml::Value::String(s) => serde_yaml::Value::String(strip_control_chars_str(s)),
serde_yaml::Value::Sequence(seq) => {
serde_yaml::Value::Sequence(seq.iter().map(strip_control_chars).collect())
}
serde_yaml::Value::Mapping(map) => {
let mut new_map = serde_yaml::Mapping::new();
for (k, v) in map {
new_map.insert(k.clone(), strip_control_chars(v));
}
serde_yaml::Value::Mapping(new_map)
}
other => other.clone(),
}
}
fn is_stray_control_char(c: char) -> bool {
matches!(c as u32,
0x00..=0x08 | 0x0b..=0x0c | 0x0e..=0x1f | 0x7f..=0x9f
)
}
pub fn strip_control_chars_str(s: &str) -> String {
s.chars().filter(|c| !is_stray_control_char(*c)).collect()
}
fn ensure_strings_quoted(value: &serde_yaml::Value) -> serde_yaml::Value {
match value {
serde_yaml::Value::Sequence(seq) => {
serde_yaml::Value::Sequence(seq.iter().map(ensure_strings_quoted).collect())
}
serde_yaml::Value::Mapping(map) => {
let mut new_map = serde_yaml::Mapping::new();
for (k, v) in map {
new_map.insert(k.clone(), ensure_strings_quoted(v));
}
serde_yaml::Value::Mapping(new_map)
}
other => other.clone(),
}
}
pub fn value_as_string(value: &serde_yaml::Value) -> Option<String> {
match value {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(format!("{}", n)),
serde_yaml::Value::Bool(b) => Some(format!("{}", b)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_with_frontmatter() {
let input = "---\ntitle: Hello World\ndate: 2024-01-15\n---\nBody content here.";
let doc = parse(input);
assert_eq!(doc.frontmatter.len(), 2);
assert_eq!(
doc.frontmatter.get("title").and_then(|v| v.as_str()),
Some("Hello World")
);
assert_eq!(
doc.frontmatter.get("date").and_then(|v| v.as_str()),
Some("2024-01-15")
);
assert_eq!(doc.body, "Body content here.");
assert!(doc.frontmatter_range.is_some());
assert!(doc.frontmatter_error.is_none(), "valid YAML reports no error");
}
#[test]
fn test_parse_no_frontmatter() {
let input = "Just body content.";
let doc = parse(input);
assert!(doc.frontmatter.is_empty());
assert_eq!(doc.body, "Just body content.");
assert!(doc.frontmatter_range.is_none());
assert!(doc.frontmatter_error.is_none(), "no block → no YAML error");
}
#[test]
fn test_parse_empty_frontmatter() {
let input = "---\n---\nBody after empty frontmatter.";
let doc = parse(input);
assert_eq!(doc.body, "Body after empty frontmatter.");
}
#[test]
fn test_parse_no_closing_delimiter() {
let input = "---\ntitle: Hello\nno closing";
let doc = parse(input);
assert!(doc.frontmatter.is_empty());
assert_eq!(doc.body, input);
assert!(doc.frontmatter_range.is_none());
assert!(
doc.frontmatter_error.is_none(),
"unterminated block is not a YAML parse error; body stays whole"
);
}
#[test]
fn test_parse_yaml_arrays() {
let input = "---\ntags:\n - rust\n - wasm\n---\nBody.";
let doc = parse(input);
let tags = doc.frontmatter.get("tags").expect("tags field");
let seq = tags.as_sequence().expect("should be sequence");
assert_eq!(seq.len(), 2);
assert_eq!(seq[0].as_str(), Some("rust"));
assert_eq!(seq[1].as_str(), Some("wasm"));
}
#[test]
fn test_parse_boolean_values() {
let input = "---\ndraft: true\n---\nContent.";
let doc = parse(input);
assert_eq!(
doc.frontmatter.get("draft").and_then(|v| v.as_bool()),
Some(true)
);
}
#[test]
fn test_parse_numeric_values() {
let input = "---\nweight: 42\nrating: 3.5\n---\nContent.";
let doc = parse(input);
assert_eq!(
doc.frontmatter.get("weight").and_then(|v| v.as_u64()),
Some(42)
);
assert_eq!(
doc.frontmatter.get("rating").and_then(|v| v.as_f64()),
Some(3.5)
);
}
#[test]
fn test_parse_preserves_body_exactly() {
let body = "Line 1\n\nLine 3 with **bold**\n\n- list item\n";
let input = format!("---\ntitle: Test\n---\n{}", body);
let doc = parse(&input);
assert_eq!(doc.body, body);
}
#[test]
fn test_frontmatter_range_byte_offsets() {
let input = "---\ntitle: Hi\n---\nBody.";
let doc = parse(input);
let (start, end) = doc.frontmatter_range.expect("range");
assert_eq!(start, 0);
#[allow(clippy::string_slice)] {
assert_eq!(&input[start..end], "---\ntitle: Hi\n---\n");
assert_eq!(&input[end..], "Body.");
}
}
#[test]
fn test_serialize_with_frontmatter() {
let mut fm = HashMap::new();
fm.insert(
"title".to_string(),
serde_yaml::Value::String("Hello".to_string()),
);
let result = serialize(&fm, "Body content.").expect("serialize");
assert!(result.starts_with("---\n"));
assert!(result.contains("title: Hello"));
assert!(result.contains("---\nBody content."));
}
#[test]
fn test_serialize_empty_frontmatter() {
let fm = HashMap::new();
let result = serialize(&fm, "Just body.").expect("serialize");
assert_eq!(result, "Just body.");
}
#[test]
fn test_parse_invalid_yaml() {
let input = "---\n: invalid: yaml: [unclosed\n---\nBody.";
let doc = parse(input);
assert!(doc.frontmatter.is_empty());
assert_eq!(doc.body, input, "body preserved whole on YAML error");
assert!(doc.frontmatter_error.is_some());
assert!(doc.frontmatter_range.is_some());
assert_eq!(doc.render_body(), "Body.", "render view excludes the bad block");
}
#[test]
fn test_parse_frontmatter_with_trailing_whitespace_on_delimiter() {
let input = "---\ntitle: Test\n--- \nBody.";
let doc = parse(input);
assert_eq!(
doc.frontmatter.get("title").and_then(|v| v.as_str()),
Some("Test")
);
assert_eq!(doc.body, "Body.");
}
#[test]
fn test_parse_content_starts_with_dashes_but_not_frontmatter() {
let input = "---- Not frontmatter\nJust text.";
let doc = parse(input);
assert!(doc.frontmatter.is_empty());
assert_eq!(doc.body, input);
}
#[test]
fn test_roundtrip() {
let input = "---\ntitle: Round Trip\n---\nBody stays the same.";
let doc = parse(input);
let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
let doc2 = parse(&output);
assert_eq!(
doc.frontmatter.get("title"),
doc2.frontmatter.get("title")
);
assert_eq!(doc.body, doc2.body);
}
#[test]
fn test_parse_multiline_body() {
let input = "---\ntitle: Test\n---\nParagraph 1.\n\nParagraph 2.\n\n> Quote\n";
let doc = parse(input);
assert_eq!(doc.body, "Paragraph 1.\n\nParagraph 2.\n\n> Quote\n");
}
#[test]
fn test_parse_only_dashes() {
let input = "---";
let doc = parse(input);
assert!(doc.frontmatter.is_empty());
assert_eq!(doc.body, "---");
}
#[test]
fn test_parse_crlf_content() {
let input = "---\r\ntitle: Hello World\r\ndate: 2024-01-15\r\n---\r\nBody content here.";
let doc = parse(input);
assert_eq!(doc.frontmatter.len(), 2);
assert_eq!(
doc.frontmatter.get("title").and_then(|v| v.as_str()),
Some("Hello World")
);
assert_eq!(
doc.frontmatter.get("date").and_then(|v| v.as_str()),
Some("2024-01-15")
);
assert_eq!(doc.body, "Body content here.");
assert!(doc.frontmatter_range.is_some());
}
#[test]
fn test_parse_crlf_byte_offsets() {
let input = "---\r\ntitle: Hi\r\n---\r\nBody.";
let doc = parse(input);
let (start, end) = doc.frontmatter_range.expect("range");
assert_eq!(start, 0);
assert_eq!(end, 18);
}
#[test]
fn test_parse_crlf_preserves_body() {
let body = "Line 1\nLine 2\n";
let input = format!("---\r\ntitle: Test\r\n---\r\n{}", body.replace('\n', "\r\n"));
let doc = parse(&input);
assert_eq!(
doc.frontmatter.get("title").and_then(|v| v.as_str()),
Some("Test")
);
assert_eq!(doc.body, body);
}
#[test]
fn test_parse_crlf_yaml_arrays() {
let input = "---\r\ntags:\r\n - rust\r\n - wasm\r\n---\r\nBody.";
let doc = parse(input);
let tags = doc.frontmatter.get("tags").expect("tags field");
let seq = tags.as_sequence().expect("should be sequence");
assert_eq!(seq.len(), 2);
assert_eq!(seq[0].as_str(), Some("rust"));
assert_eq!(seq[1].as_str(), Some("wasm"));
}
#[test]
fn test_uid_scientific_notation_roundtrip() {
let input = "---\ntitle: Test\nuid: \"753659e7\"\n---\nBody.";
let doc = parse(input);
let uid_val = doc.frontmatter.get("uid").expect("uid field");
assert_eq!(uid_val.as_str(), Some("753659e7"));
let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
let doc2 = parse(&output);
let uid2 = doc2.frontmatter.get("uid").expect("uid field after roundtrip");
assert_eq!(uid2.as_str(), Some("753659e7"));
}
#[test]
fn test_value_as_string_handles_numbers() {
let num_val = serde_yaml::Value::Number(serde_yaml::Number::from(75365900));
assert!(value_as_string(&num_val).is_some());
let str_val = serde_yaml::Value::String("753659e7".to_string());
assert_eq!(value_as_string(&str_val), Some("753659e7".to_string()));
}
#[test]
fn test_unquoted_uid_parsed_as_number() {
let input = "---\ntitle: Test\nuid: 753659e7\n---\nBody.";
let doc = parse(input);
let uid_val = doc.frontmatter.get("uid").expect("uid field");
assert!(
uid_val.as_str().is_none(),
"Unquoted 753659e7 should NOT parse as string (it's a YAML number)"
);
assert!(value_as_string(uid_val).is_some());
}
#[test]
fn test_serialize_strips_stray_control_chars() {
let corrupted = format!("websites.{}", "\u{1D}".repeat(8));
let mut fm = HashMap::new();
fm.insert(
"description".to_string(),
serde_yaml::Value::String(corrupted),
);
let output = serialize(&fm, "Body.").expect("serialize");
let doc = parse(&output);
assert_eq!(
doc.frontmatter.get("description").and_then(|v| v.as_str()),
Some("websites."),
"control chars must be stripped from the written value"
);
}
#[test]
fn test_parse_invalid_yaml_preserves_body_no_data_loss() {
let input =
"---\nchildren_style: grid\nseries: true\nweight: 10\nuid: blk-europecover: \"006.jpg\"\n---\n\n\ngh\n![[x.jpg]]\n";
let doc = parse(input);
assert!(doc.frontmatter.is_empty(), "malformed YAML yields no fields");
assert!(
doc.frontmatter_error.is_some(),
"the serde_yaml error must be surfaced, not swallowed"
);
let (start, fm_end) = doc.frontmatter_range.expect("range on malformed block");
assert_eq!(start, 0);
assert_eq!(doc.body, input, "body must be the whole document — no data loss");
#[allow(clippy::string_slice)] {
assert!(
input[0..fm_end].starts_with("---\n") && input[0..fm_end].ends_with("---\n"),
"frontmatter_range must bound the `---...---\\n` block"
);
}
}
#[test]
fn test_render_body_excludes_failed_block() {
let input =
"---\nchildren_style: grid\nseries: true\nweight: 10\nuid: blk-europecover: \"006.jpg\"\n---\n\n\ngh\n![[x.jpg]]\n";
let doc = parse(input);
let rendered = doc.render_body();
assert!(!rendered.contains("---"), "delimiters must not leak: {rendered:?}");
assert!(!rendered.contains("uid:"), "raw YAML must not leak: {rendered:?}");
assert_eq!(
rendered, "\n\ngh\n![[x.jpg]]\n",
"render_body is exactly the content after the closing delimiter"
);
}
#[test]
fn test_render_body_equals_body_on_success() {
let ok = parse("---\ntitle: Hi\n---\nBody.");
assert!(ok.frontmatter_error.is_none());
assert_eq!(ok.render_body(), ok.body);
assert_eq!(ok.render_body(), "Body.");
let none = parse("No frontmatter here.");
assert!(none.frontmatter_error.is_none());
assert_eq!(none.render_body(), none.body);
}
#[test]
fn test_strip_control_chars_str_keeps_tab_lf_cr() {
let input = "a\tb\nc\rd\u{00}\u{7f}\u{85}e";
assert_eq!(strip_control_chars_str(input), "a\tb\nc\rde");
}
}