use std::collections::HashMap; use serde::Deserialize; /// Parsed TOML frontmatter from a markdown document. #[derive(Debug, Clone, Default, Deserialize)] pub struct Frontmatter { pub title: Option, pub date: Option, pub tags: Option>, pub section: Option, pub draft: Option, #[serde(flatten)] pub extra: HashMap, } /// Parse TOML frontmatter delimited by `+++` from the beginning of a document. /// /// Returns the parsed frontmatter (if present) and the remaining markdown /// content. pub fn parse_frontmatter(input: &str) -> (Option, &str) { let trimmed = input.trim_start(); if !trimmed.starts_with("+++") { return (None, input); } // Find the closing +++ let after_opening = &trimmed[3..]; let after_opening = after_opening.strip_prefix('\n').unwrap_or(after_opening); if let Some(end_pos) = after_opening.find("\n+++") { let toml_content = &after_opening[..end_pos]; let rest_start = end_pos + 4; // skip \n+++ let rest = &after_opening[rest_start..]; let rest = rest.strip_prefix('\n').unwrap_or(rest); // Calculate the actual offset into the original input let rest_offset = input.len() - rest.len(); let rest_slice = &input[rest_offset..]; match toml::from_str::(toml_content) { Ok(fm) => (Some(fm), rest_slice), Err(e) => { tracing::warn!(error = %e, "Failed to parse TOML frontmatter"); (None, input) } } } else { (None, input) } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_basic_frontmatter() { let input = "+++\ntitle = \"Hello\"\ndate = \"2026-01-01\"\n+++\n\n# Body"; let (fm, rest) = parse_frontmatter(input); let fm = fm.unwrap(); assert_eq!(fm.title.as_deref(), Some("Hello")); assert_eq!(fm.date.as_deref(), Some("2026-01-01")); assert!(rest.contains("# Body")); } #[test] fn parse_with_tags() { let input = "+++\ntitle = \"Post\"\ntags = [\"rust\", \"web\"]\n+++\nContent"; let (fm, _rest) = parse_frontmatter(input); let fm = fm.unwrap(); assert_eq!(fm.tags.as_deref(), Some(&["rust".to_string(), "web".to_string()][..])); } #[test] fn parse_with_draft() { let input = "+++\ndraft = true\n+++\nContent"; let (fm, _rest) = parse_frontmatter(input); assert_eq!(fm.unwrap().draft, Some(true)); } #[test] fn parse_with_extra_fields() { let input = "+++\ntitle = \"Test\"\ncustom_key = \"custom_value\"\n+++\nBody"; let (fm, _) = parse_frontmatter(input); let fm = fm.unwrap(); assert_eq!(fm.title.as_deref(), Some("Test")); assert_eq!( fm.extra.get("custom_key").and_then(|v| v.as_str()), Some("custom_value") ); } #[test] fn no_frontmatter() { let input = "# Just Markdown\n\nBody text"; let (fm, rest) = parse_frontmatter(input); assert!(fm.is_none()); assert_eq!(rest, input); } #[test] fn unclosed_frontmatter() { let input = "+++\ntitle = \"Oops\"\nNo closing delimiter"; let (fm, rest) = parse_frontmatter(input); assert!(fm.is_none()); assert_eq!(rest, input); } #[test] fn invalid_toml_returns_none() { let input = "+++\nnot valid toml {{{\n+++\nBody"; let (fm, rest) = parse_frontmatter(input); assert!(fm.is_none()); assert_eq!(rest, input); } #[test] fn empty_frontmatter() { let input = "+++\n\n+++\nBody"; let (fm, rest) = parse_frontmatter(input); let fm = fm.unwrap(); assert!(fm.title.is_none()); assert!(rest.contains("Body")); } #[test] fn frontmatter_with_section() { let input = "+++\nsection = \"guide\"\n+++\nContent"; let (fm, _) = parse_frontmatter(input); assert_eq!(fm.unwrap().section.as_deref(), Some("guide")); } }