Skip to main content

max / makenotwork

4.3 KB · 137 lines History Blame Raw
1 use std::collections::HashMap;
2
3 use serde::Deserialize;
4
5 /// Parsed TOML frontmatter from a markdown document.
6 #[derive(Debug, Clone, Default, Deserialize)]
7 pub struct Frontmatter {
8 pub title: Option<String>,
9 pub date: Option<String>,
10 pub tags: Option<Vec<String>>,
11 pub section: Option<String>,
12 pub draft: Option<bool>,
13 #[serde(flatten)]
14 pub extra: HashMap<String, toml::Value>,
15 }
16
17 /// Parse TOML frontmatter delimited by `+++` from the beginning of a document.
18 ///
19 /// Returns the parsed frontmatter (if present) and the remaining markdown
20 /// content.
21 pub fn parse_frontmatter(input: &str) -> (Option<Frontmatter>, &str) {
22 let trimmed = input.trim_start();
23 if !trimmed.starts_with("+++") {
24 return (None, input);
25 }
26
27 // Find the closing +++
28 let after_opening = &trimmed[3..];
29 let after_opening = after_opening.strip_prefix('\n').unwrap_or(after_opening);
30
31 if let Some(end_pos) = after_opening.find("\n+++") {
32 let toml_content = &after_opening[..end_pos];
33 let rest_start = end_pos + 4; // skip \n+++
34 let rest = &after_opening[rest_start..];
35 let rest = rest.strip_prefix('\n').unwrap_or(rest);
36
37 // Calculate the actual offset into the original input
38 let rest_offset = input.len() - rest.len();
39 let rest_slice = &input[rest_offset..];
40
41 match toml::from_str::<Frontmatter>(toml_content) {
42 Ok(fm) => (Some(fm), rest_slice),
43 Err(e) => {
44 tracing::warn!(error = %e, "Failed to parse TOML frontmatter");
45 (None, input)
46 }
47 }
48 } else {
49 (None, input)
50 }
51 }
52
53 #[cfg(test)]
54 mod tests {
55 use super::*;
56
57 #[test]
58 fn parse_basic_frontmatter() {
59 let input = "+++\ntitle = \"Hello\"\ndate = \"2026-01-01\"\n+++\n\n# Body";
60 let (fm, rest) = parse_frontmatter(input);
61 let fm = fm.unwrap();
62 assert_eq!(fm.title.as_deref(), Some("Hello"));
63 assert_eq!(fm.date.as_deref(), Some("2026-01-01"));
64 // Exact match — `rest.contains("# Body")` would pass even if rest were
65 // the entire input, so it's too loose to catch L38 arithmetic mutations
66 // on `rest_offset`. Pinning the exact slice tightens the boundary.
67 assert_eq!(rest, "\n# Body");
68 }
69
70 #[test]
71 fn parse_with_tags() {
72 let input = "+++\ntitle = \"Post\"\ntags = [\"rust\", \"web\"]\n+++\nContent";
73 let (fm, _rest) = parse_frontmatter(input);
74 let fm = fm.unwrap();
75 assert_eq!(fm.tags.as_deref(), Some(&["rust".to_string(), "web".to_string()][..]));
76 }
77
78 #[test]
79 fn parse_with_draft() {
80 let input = "+++\ndraft = true\n+++\nContent";
81 let (fm, _rest) = parse_frontmatter(input);
82 assert_eq!(fm.unwrap().draft, Some(true));
83 }
84
85 #[test]
86 fn parse_with_extra_fields() {
87 let input = "+++\ntitle = \"Test\"\ncustom_key = \"custom_value\"\n+++\nBody";
88 let (fm, _) = parse_frontmatter(input);
89 let fm = fm.unwrap();
90 assert_eq!(fm.title.as_deref(), Some("Test"));
91 assert_eq!(
92 fm.extra.get("custom_key").and_then(|v| v.as_str()),
93 Some("custom_value")
94 );
95 }
96
97 #[test]
98 fn no_frontmatter() {
99 let input = "# Just Markdown\n\nBody text";
100 let (fm, rest) = parse_frontmatter(input);
101 assert!(fm.is_none());
102 assert_eq!(rest, input);
103 }
104
105 #[test]
106 fn unclosed_frontmatter() {
107 let input = "+++\ntitle = \"Oops\"\nNo closing delimiter";
108 let (fm, rest) = parse_frontmatter(input);
109 assert!(fm.is_none());
110 assert_eq!(rest, input);
111 }
112
113 #[test]
114 fn invalid_toml_returns_none() {
115 let input = "+++\nnot valid toml {{{\n+++\nBody";
116 let (fm, rest) = parse_frontmatter(input);
117 assert!(fm.is_none());
118 assert_eq!(rest, input);
119 }
120
121 #[test]
122 fn empty_frontmatter() {
123 let input = "+++\n\n+++\nBody";
124 let (fm, rest) = parse_frontmatter(input);
125 let fm = fm.unwrap();
126 assert!(fm.title.is_none());
127 assert!(rest.contains("Body"));
128 }
129
130 #[test]
131 fn frontmatter_with_section() {
132 let input = "+++\nsection = \"guide\"\n+++\nContent";
133 let (fm, _) = parse_frontmatter(input);
134 assert_eq!(fm.unwrap().section.as_deref(), Some("guide"));
135 }
136 }
137