1use serde_norway::{Mapping, Value};
12
13pub const RESERVED_FIELDS: [&str; 10] = [
18 "id",
19 "status",
20 "tags",
21 "created",
22 "updated",
23 "spec",
24 "wontfix_reason",
25 "references",
26 "blocked_by",
27 "blocks",
28];
29
30pub const WI_RESERVED_FIELDS: [&str; 9] = [
34 "id",
35 "status",
36 "tags",
37 "created",
38 "updated",
39 "references",
40 "blocked_by",
41 "blocks",
42 "blocked_reason",
43];
44
45const ORDER: [&str; 5] = ["id", "status", "tags", "created", "updated"];
48
49#[derive(Debug, Clone, Default)]
52pub struct Frontmatter {
53 pub map: Mapping,
54}
55
56impl Frontmatter {
57 pub fn new() -> Self {
58 Frontmatter {
59 map: Mapping::new(),
60 }
61 }
62
63 pub fn get(&self, key: &str) -> Option<&Value> {
64 self.map.get(Value::String(key.to_string()))
65 }
66
67 pub fn contains_key(&self, key: &str) -> bool {
68 self.get(key).is_some()
69 }
70
71 pub fn keys(&self) -> impl Iterator<Item = &str> {
72 self.map.keys().filter_map(|k| k.as_str())
73 }
74
75 pub fn get_str(&self, key: &str) -> Option<&str> {
77 self.get(key).and_then(|v| v.as_str())
78 }
79
80 pub fn id(&self) -> Option<&str> {
81 self.get_str("id")
82 }
83 pub fn status(&self) -> Option<&str> {
84 self.get_str("status")
85 }
86
87 pub fn tags(&self) -> Option<Vec<String>> {
90 match self.get("tags")? {
91 Value::Sequence(seq) => seq.iter().map(|v| v.as_str().map(str::to_string)).collect(),
92 _ => None,
93 }
94 }
95
96 pub fn tags_is_nonempty_list(&self) -> bool {
98 matches!(self.get("tags"), Some(Value::Sequence(s)) if !s.is_empty())
99 }
100
101 pub fn has_tag(&self, query: &str) -> bool {
105 self.tags().is_some_and(|ts| {
106 ts.iter()
107 .any(|t| t == query || crate::commands::tag_key(t) == query)
108 })
109 }
110
111 pub fn set_str(&mut self, key: &str, value: impl Into<String>) {
112 self.map
113 .insert(Value::String(key.to_string()), Value::String(value.into()));
114 }
115
116 pub fn set_tags(&mut self, tags: &[String]) {
117 let seq = tags.iter().cloned().map(Value::String).collect();
118 self.map
119 .insert(Value::String("tags".to_string()), Value::Sequence(seq));
120 }
121
122 pub fn insert(&mut self, key: &str, value: Value) {
123 self.map.insert(Value::String(key.to_string()), value);
124 }
125
126 pub fn remove(&mut self, key: &str) -> Option<Value> {
127 self.map.remove(Value::String(key.to_string()))
128 }
129}
130
131#[derive(Debug)]
133pub struct ParseError(pub String);
134
135pub fn parse(text: &str, path_display: &str) -> Result<(Frontmatter, String), ParseError> {
137 if !text.starts_with("---\n") {
138 return Err(ParseError(format!(
139 "{path_display}: missing frontmatter opening '---'"
140 )));
141 }
142 let end = match text.get(4..).and_then(|s| s.find("\n---")) {
143 Some(i) => i + 4,
144 None => {
145 return Err(ParseError(format!(
146 "{path_display}: unterminated frontmatter"
147 )))
148 }
149 };
150 let yaml = &text[4..end];
151 let mut body = &text[end + 4..];
152 if let Some(stripped) = body.strip_prefix('\n') {
153 body = stripped;
154 }
155
156 let map: Mapping = if yaml.trim().is_empty() {
157 Mapping::new()
158 } else {
159 serde_norway::from_str(yaml).map_err(|e| ParseError(yaml_error(path_display, &e)))?
160 };
161 Ok((Frontmatter { map }, body.to_string()))
162}
163
164fn yaml_error(path_display: &str, e: &serde_norway::Error) -> String {
169 let mut msg = format!("{path_display}: invalid frontmatter YAML: {e}");
170 if e.to_string().contains("mapping values are not allowed") {
171 msg.push_str(
172 " (hint: a value containing a colon followed by a space is read as nested \
173 YAML — quote the whole value, e.g. key: \"text: more text\")",
174 );
175 }
176 msg
177}
178
179pub fn serialize(fm: &Frontmatter, body: &str) -> String {
181 let mut keys: Vec<&str> = Vec::new();
182 for k in ORDER {
183 if fm.contains_key(k) {
184 keys.push(k);
185 }
186 }
187 let mut rest: Vec<&str> = fm.keys().filter(|k| !ORDER.contains(k)).collect();
188 rest.sort_unstable();
189 keys.extend(rest);
190
191 let mut lines = String::from("---\n");
192 for k in keys {
193 let v = fm.get(k).expect("key came from the map");
194 lines.push_str(&render_entry(k, v));
195 lines.push('\n');
196 }
197 lines.push_str("---\n\n");
198 lines.push_str(body.trim_start_matches('\n'));
199 lines
200}
201
202fn render_entry(key: &str, value: &Value) -> String {
204 if let Some(scalar) = inline_scalar(value) {
205 return format!("{key}: {scalar}");
206 }
207 if let Value::Sequence(seq) = value {
208 if let Some(items) = seq.iter().map(inline_scalar).collect::<Option<Vec<_>>>() {
209 return format!("{key}: [{}]", items.join(", "));
210 }
211 }
212 let mut single = Mapping::new();
215 single.insert(Value::String(key.to_string()), value.clone());
216 serde_norway::to_string(&single)
217 .unwrap_or_else(|_| format!("{key}: ~"))
218 .trim_end_matches('\n')
219 .to_string()
220}
221
222fn inline_scalar(value: &Value) -> Option<String> {
224 match value {
225 Value::Null => Some("null".to_string()),
226 Value::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
227 Value::Number(n) => Some(n.to_string()),
228 Value::String(s) => Some(format_string(s)),
229 _ => None,
230 }
231}
232
233fn format_string(s: &str) -> String {
235 if needs_quote(s) {
236 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
237 } else {
238 s.to_string()
239 }
240}
241
242fn needs_quote(s: &str) -> bool {
243 if s.is_empty() || s != s.trim() {
244 return true;
245 }
246 if matches!(s, "true" | "false" | "null" | "~" | "yes" | "no") {
248 return true;
249 }
250 if s.parse::<i64>().is_ok() || s.parse::<f64>().is_ok() {
251 return true;
252 }
253 const SPECIAL: &str = ":#[]{}\"',&*!|>%@`";
255 if s.chars().any(|c| SPECIAL.contains(c)) {
256 return true;
257 }
258 matches!(s.chars().next(), Some('-') | Some('?'))
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn missing_opening_fence() {
267 assert!(parse("no fence here", "x.md").is_err());
268 }
269
270 #[test]
271 fn unterminated() {
272 assert!(parse("---\nid: A\n", "x.md").is_err());
273 }
274
275 #[test]
276 fn unquoted_colon_space_value_gets_a_hint() {
277 let text =
278 "---\nid: A\nstatus: planned\ntags: [a]\nreason: MVP scope: containers\n---\n\n# T\n";
279 let err = parse(text, "x.md").unwrap_err();
280 assert!(err.0.contains("mapping values are not allowed"));
281 assert!(err.0.contains("quote the whole value"));
282 }
283
284 #[test]
285 fn parses_core_fields_and_body() {
286 let text =
287 "---\nid: VIK-0001\nstatus: planned\ntags: [osc, tabs]\n---\n\n# Title\n\nProse.\n";
288 let (fm, body) = parse(text, "x.md").unwrap();
289 assert_eq!(fm.id(), Some("VIK-0001"));
290 assert_eq!(fm.status(), Some("planned"));
291 assert_eq!(fm.tags(), Some(vec!["osc".into(), "tabs".into()]));
292 assert_eq!(body, "\n# Title\n\nProse.\n");
295 assert_eq!(crate::body::title(&body), "Title");
296 }
297
298 #[test]
299 fn round_trip_key_order() {
300 let mut fm = Frontmatter::new();
301 fm.set_str("status", "planned");
302 fm.set_str("id", "VIK-0001");
303 fm.set_tags(&["osc".into(), "tabs".into()]);
304 fm.set_str("ptyxis_ref", "src/x.c");
305 let out = serialize(&fm, "# Title\n");
306 let expected = "---\nid: VIK-0001\nstatus: planned\ntags: [osc, tabs]\nptyxis_ref: src/x.c\n---\n\n# Title\n";
307 assert_eq!(out, expected);
308 }
309
310 #[test]
311 fn timestamps_order_after_tags_before_custom() {
312 let mut fm = Frontmatter::new();
313 fm.set_str("custom_field", "x");
314 fm.set_str("id", "VIK-0001");
315 fm.set_str("updated", "2026-06-16T14:30:00Z");
316 fm.set_str("status", "planned");
317 fm.set_str("created", "2026-06-01T09:00:00Z");
318 fm.set_tags(&["osc".into()]);
319 let out = serialize(&fm, "# Title\n");
322 let expected = "---\nid: VIK-0001\nstatus: planned\ntags: [osc]\ncreated: \"2026-06-01T09:00:00Z\"\nupdated: \"2026-06-16T14:30:00Z\"\ncustom_field: x\n---\n\n# Title\n";
323 assert_eq!(out, expected);
324 }
325
326 #[test]
327 fn quotes_ambiguous_strings() {
328 let mut fm = Frontmatter::new();
329 fm.set_str("id", "A-1");
330 fm.set_str("ref", "set_title: handler");
331 fm.set_str("numlike", "123");
332 let out = serialize(&fm, "# T\n");
333 assert!(out.contains("ref: \"set_title: handler\""));
334 assert!(out.contains("numlike: \"123\""));
335 }
336
337 #[test]
338 fn nested_custom_field_round_trips() {
339 let text = "---\nid: VIK-0001\nstatus: planned\ntags: [a]\nlinks:\n- url: http://x\n label: x\n---\n\n# T\n";
340 let (fm, body) = parse(text, "x.md").unwrap();
341 let out = serialize(&fm, &body);
342 let (fm2, _) = parse(&out, "x.md").unwrap();
343 assert_eq!(fm.get("links"), fm2.get("links"));
344 }
345
346 #[test]
347 fn modernization_allows_block_scalar() {
348 let text =
349 "---\nid: VIK-0001\nstatus: planned\ntags: [a]\nnote: |\n line one\n line two\n---\n\n# T\n";
350 let (fm, _) = parse(text, "x.md").unwrap();
351 assert_eq!(fm.get_str("note"), Some("line one\nline two"));
352 }
353}