Skip to main content

opys_engine/
frontmatter.rs

1//! Frontmatter parsing and canonical serialization.
2//!
3//! Files are `---`-fenced YAML followed by a markdown body. Unlike the
4//! original Python tool — which hand-parsed a "constrained" flat YAML — this
5//! uses a real YAML parser (`serde_norway`), so custom fields may carry nested
6//! mappings, sequences, and multiline/block scalars. The serializer still
7//! emits canonical, minimal frontmatter (core fields first, then remaining
8//! keys alphabetically), formatting flat scalars and scalar lists inline and
9//! falling back to block YAML for complex custom values.
10
11use serde_norway::{Mapping, Value};
12
13/// Feature field keys with first-class meaning; everything else is a declared
14/// custom field (or rejected by `verify`). `references` is the uniform
15/// ID->title map linking to other features and work items; `blocked_by` /
16/// `blocks` are the directional blocker-relation maps (see `refs.rs`).
17pub 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
30/// Work-item reserved field keys. Work items share `id`/`status`/`tags`/
31/// `created`/`updated`/`references`/`blocked_by`/`blocks` with features and add
32/// `blocked_reason`.
33pub 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
45/// Core fields emitted first, in this order, before the remaining keys
46/// (alphabetical). `created`/`updated` are auto-maintained RFC3339 datetimes.
47const ORDER: [&str; 5] = ["id", "status", "tags", "created", "updated"];
48
49/// Parsed frontmatter, retaining the full YAML mapping so `verify` can inspect
50/// wrong-typed values (rather than failing to parse them).
51#[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    /// String value of `key`, only if it is actually a YAML string.
76    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    /// `tags` as a list of strings, when it is a sequence whose elements are
88    /// all strings. Returns `None` if absent or wrong-shaped (verify reports).
89    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    /// True when `tags` is present and is a non-empty sequence.
97    pub fn tags_is_nonempty_list(&self) -> bool {
98        matches!(self.get("tags"), Some(Value::Sequence(s)) if !s.is_empty())
99    }
100
101    /// True when the document carries `query` as a tag — either an exact tag, or
102    /// any tag whose key (the head before `:` or `=`) equals `query`. So `area`
103    /// matches `area`, `area:parsing`, and `area=high`.
104    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/// A frontmatter parse failure, carrying a human-readable message.
132#[derive(Debug)]
133pub struct ParseError(pub String);
134
135/// Split a file into (frontmatter, body), then parse the frontmatter as YAML.
136pub 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
164/// Build a frontmatter parse-error message, adding a targeted hint for the
165/// most common footgun: an unquoted scalar containing a colon followed by a
166/// space, which YAML reads as a nested mapping. (`opys`'s own serializer
167/// quotes these, but files written by hand or by a script can trip it.)
168fn 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
179/// Serialize frontmatter + body back into a complete file.
180pub 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
202/// Render one `key: value` frontmatter entry (possibly multi-line).
203fn 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    // Complex value (nested mapping, or sequence with non-scalar elements):
213    // emit it as block YAML under the key.
214    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
222/// Inline representation of a flat scalar, or `None` if `value` is composite.
223fn 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
233/// Quote a string only when needed for unambiguous YAML round-tripping.
234fn 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    // Would parse back as a non-string scalar.
247    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    // YAML indicator characters that make a plain scalar ambiguous.
254    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        // One leading newline is retained (matching the original tool); the
293        // serializer trims it back out.
294        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        // RFC3339 strings contain colons, so the conservative serializer quotes
320        // them — valid YAML that round-trips and parses back to the same string.
321        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}