Skip to main content

sloop/
frontmatter.rs

1use std::fmt;
2
3/// The fields Sloop understands in a committed Markdown file. Unknown keys
4/// are preserved on disk and simply ignored here.
5#[derive(Debug, Clone, Default, PartialEq, Eq)]
6pub struct Frontmatter {
7    pub id: Option<String>,
8    pub project: Option<String>,
9    pub title: Option<String>,
10    pub name: String,
11    pub blocked_by: Vec<String>,
12    pub worktree: Option<String>,
13    pub target: Option<String>,
14    pub model: Option<String>,
15    pub effort: Option<String>,
16    pub flow: Option<String>,
17    blocked_by_present: bool,
18}
19
20impl Frontmatter {
21    pub fn has_blocked_by(&self) -> bool {
22        self.blocked_by_present
23    }
24
25    pub(crate) fn sourced(name: String, blocked_by: Vec<String>) -> Self {
26        Self {
27            name,
28            blocked_by,
29            blocked_by_present: true,
30            ..Self::default()
31        }
32    }
33}
34
35/// Parses the leading `---` frontmatter block. A file without a block parses
36/// to an empty `Frontmatter`; a malformed block is an error so a typo never
37/// silently registers a ticket under the wrong identity.
38///
39/// Reports only the first problem. Callers that want every problem at once
40/// use [`parse_collecting`].
41pub fn parse(content: &str) -> Result<Frontmatter, FrontmatterError> {
42    let (frontmatter, problems) = parse_collecting(content)?;
43    match problems.into_iter().next() {
44        Some(problem) => Err(problem),
45        None => Ok(frontmatter),
46    }
47}
48
49/// Parses the block, accumulating field-level problems rather than stopping
50/// at the first one.
51///
52/// The returned `Err` is reserved for failures that make further validation
53/// meaningless — no block at all, an unterminated block, YAML that does not
54/// parse, or a block that is not a mapping. Past that point every field is
55/// read independently, so a bad value for one key says nothing about the
56/// others and each problem is collected into the returned list. A field that
57/// fails takes its default value in the returned `Frontmatter`, which callers
58/// must therefore only trust when the list is empty.
59pub fn parse_collecting(
60    content: &str,
61) -> Result<(Frontmatter, Vec<FrontmatterError>), FrontmatterError> {
62    let Some(block) = split(content)? else {
63        return Ok((Frontmatter::default(), Vec::new()));
64    };
65
66    let mapping: serde_yaml::Value = serde_yaml::from_str(block.yaml)
67        .map_err(|error| FrontmatterError::InvalidYaml(error.to_string()))?;
68    if mapping.is_null() {
69        // Null covers both a genuinely empty block and explicit null content
70        // (`~`, `null`). Only the former is an empty frontmatter; the latter
71        // is content that is not a mapping, and accepting it would let
72        // stamping append keys after a scalar and corrupt the file.
73        let blank = block
74            .yaml
75            .lines()
76            .all(|line| line.trim().is_empty() || line.trim_start().starts_with('#'));
77        if !blank {
78            return Err(FrontmatterError::InvalidYaml(
79                "frontmatter must be a mapping".into(),
80            ));
81        }
82        return Ok((Frontmatter::default(), Vec::new()));
83    }
84    let mapping = mapping
85        .as_mapping()
86        .ok_or_else(|| FrontmatterError::InvalidYaml("frontmatter must be a mapping".into()))?;
87
88    let mut problems = Vec::new();
89    let (blocked_by, blocked_by_present) = match string_list_field(mapping, "blocked_by") {
90        Ok(value) => value,
91        Err(error) => {
92            problems.push(error);
93            (Vec::new(), false)
94        }
95    };
96    let mut string = |key| match string_field(mapping, key) {
97        Ok(value) => value,
98        Err(error) => {
99            problems.push(error);
100            None
101        }
102    };
103    let frontmatter = Frontmatter {
104        id: string("id"),
105        project: string("project"),
106        title: string("title"),
107        name: string("name").unwrap_or_default(),
108        blocked_by,
109        worktree: string("worktree"),
110        target: string("target"),
111        model: string("model"),
112        effort: string("effort"),
113        flow: string("flow"),
114        blocked_by_present,
115    };
116    Ok((frontmatter, problems))
117}
118
119/// Returns the Markdown body after the leading frontmatter block.
120pub fn body(content: &str) -> Result<&str, FrontmatterError> {
121    Ok(match split(content)? {
122        Some(block) => &content[block.body_at..],
123        None => content,
124    })
125}
126
127/// Writes `id`, `project`, `worktree`, and `flow` into the frontmatter
128/// without disturbing any other byte of the file. Returns `None` when the
129/// file already carries all four values, so callers can skip the write
130/// entirely.
131///
132/// Callers must resolve conflicts first: stamping never overwrites an
133/// existing `id`, `project`, or `flow` value.
134pub fn stamp(
135    content: &str,
136    id: &str,
137    project: &str,
138    worktree: &str,
139    flow: &str,
140) -> Result<Option<String>, FrontmatterError> {
141    let current = parse(content)?;
142    let mut lines = String::new();
143    if current.id.is_none() {
144        lines.push_str(&format!("id: {id}\n"));
145    }
146    if current.project.is_none() {
147        lines.push_str(&format!("project: {project}\n"));
148    }
149    if current.worktree.is_none() {
150        lines.push_str(&format!("worktree: {worktree}\n"));
151    }
152    if current.flow.is_none() {
153        lines.push_str(&format!("flow: {flow}\n"));
154    }
155    insert_lines(content, lines)
156}
157
158/// Writes only `id`, for project files. Existing IDs return `None` so startup
159/// can leave an already identified project byte-for-byte untouched.
160pub fn stamp_id(content: &str, id: &str) -> Result<Option<String>, FrontmatterError> {
161    if parse(content)?.id.is_some() {
162        return Ok(None);
163    }
164    insert_lines(content, format!("id: {id}\n"))
165}
166
167fn insert_lines(content: &str, lines: String) -> Result<Option<String>, FrontmatterError> {
168    if lines.is_empty() {
169        return Ok(None);
170    }
171    let stamped = match split(content)? {
172        Some(block) => {
173            let mut stamped = String::with_capacity(content.len() + lines.len());
174            stamped.push_str(&content[..block.close_at]);
175            stamped.push_str(&lines);
176            stamped.push_str(&content[block.close_at..]);
177            stamped
178        }
179        None => format!("---\n{lines}---\n{content}"),
180    };
181    // Line insertion assumes a block-style mapping; exotic-but-parseable
182    // blocks (a flow mapping like `{id: x}`) would end up invalid. Verify
183    // the write before handing it back: refusing with an error is always
184    // preferable to corrupting a user's committed file.
185    parse(&stamped)?;
186    Ok(Some(stamped))
187}
188
189struct RawBlock<'a> {
190    yaml: &'a str,
191    /// Byte offset of the closing `---` line, where new keys are inserted.
192    close_at: usize,
193    /// Byte offset immediately after the closing `---` line.
194    body_at: usize,
195}
196
197fn split(content: &str) -> Result<Option<RawBlock<'_>>, FrontmatterError> {
198    let Some(after_open) = content.strip_prefix("---\n") else {
199        return Ok(None);
200    };
201    let yaml_start = "---\n".len();
202    let mut offset = 0;
203    for line in after_open.split_inclusive('\n') {
204        if line == "---\n" || line == "---" {
205            let yaml = &after_open[..offset];
206            // This module reads the block as LF-separated lines, but YAML
207            // also breaks lines on CR, NEL, LS, and PS. A block containing
208            // one would make `parse` see keys at positions `stamp`'s byte
209            // model does not, so stamping could write a duplicate key and
210            // corrupt the file. Reject the ambiguity instead.
211            if yaml.contains(['\r', '\u{0085}', '\u{2028}', '\u{2029}']) {
212                return Err(FrontmatterError::ForeignLineBreak);
213            }
214            return Ok(Some(RawBlock {
215                yaml,
216                close_at: yaml_start + offset,
217                body_at: yaml_start + offset + line.len(),
218            }));
219        }
220        offset += line.len();
221    }
222    Err(FrontmatterError::Unterminated)
223}
224
225fn string_list_field(
226    mapping: &serde_yaml::Mapping,
227    key: &str,
228) -> Result<(Vec<String>, bool), FrontmatterError> {
229    let Some(value) = mapping.get(key) else {
230        return Ok((Vec::new(), false));
231    };
232    let Some(values) = value.as_sequence() else {
233        return Err(FrontmatterError::InvalidBlockedBy);
234    };
235    let values = values
236        .iter()
237        .map(|value| {
238            value
239                .as_str()
240                .map(str::to_owned)
241                .ok_or(FrontmatterError::InvalidBlockedBy)
242        })
243        .collect::<Result<Vec<_>, _>>()?;
244    Ok((values, true))
245}
246
247fn string_field(
248    mapping: &serde_yaml::Mapping,
249    key: &str,
250) -> Result<Option<String>, FrontmatterError> {
251    match mapping.get(key) {
252        None | Some(serde_yaml::Value::Null) => Ok(None),
253        Some(serde_yaml::Value::String(value)) => Ok(Some(value.clone())),
254        Some(serde_yaml::Value::Number(value)) => Ok(Some(value.to_string())),
255        Some(_) => Err(FrontmatterError::InvalidFieldType {
256            key: key.to_owned(),
257        }),
258    }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub enum FrontmatterError {
263    Unterminated,
264    InvalidYaml(String),
265    /// A known scalar field holds a sequence or mapping.
266    InvalidFieldType {
267        key: String,
268    },
269    InvalidBlockedBy,
270    /// The block contains a line break other than LF (CR, NEL, LS, or PS).
271    ForeignLineBreak,
272}
273
274impl fmt::Display for FrontmatterError {
275    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Self::Unterminated => formatter.write_str("frontmatter block is not terminated"),
278            Self::InvalidYaml(message) => write!(formatter, "invalid frontmatter: {message}"),
279            Self::InvalidFieldType { key } => write!(
280                formatter,
281                "invalid frontmatter: frontmatter field `{key}` must be a scalar"
282            ),
283            Self::InvalidBlockedBy => {
284                formatter.write_str("frontmatter field `blocked_by` must be a YAML list of strings")
285            }
286            Self::ForeignLineBreak => formatter.write_str(
287                "frontmatter block contains a line break other than LF \
288                 (carriage return, NEL, LS, or PS); use Unix line endings",
289            ),
290        }
291    }
292}
293
294impl std::error::Error for FrontmatterError {}
295
296#[cfg(test)]
297mod tests {
298    use super::{FrontmatterError, parse, parse_collecting, stamp, stamp_id};
299
300    #[test]
301    fn every_bad_field_is_collected_and_parse_reports_the_first() {
302        let content = "---\nblocked_by: T1\nname: [a]\ntarget: claude\n---\nbody\n";
303        let (frontmatter, problems) = parse_collecting(content).unwrap();
304        assert_eq!(
305            problems,
306            [
307                FrontmatterError::InvalidBlockedBy,
308                FrontmatterError::InvalidFieldType { key: "name".into() },
309            ]
310        );
311        // Failed fields fall back to defaults; readable ones still parse.
312        assert_eq!(frontmatter.name, "");
313        assert!(!frontmatter.has_blocked_by());
314        assert_eq!(frontmatter.target.as_deref(), Some("claude"));
315        assert_eq!(parse(content), Err(FrontmatterError::InvalidBlockedBy));
316    }
317
318    #[test]
319    fn a_block_that_cannot_be_read_is_fatal_rather_than_collected() {
320        for content in ["---\nname: [oops\n---\nbody\n", "---\nid: T1\n"] {
321            assert!(parse_collecting(content).is_err(), "{content:?}");
322        }
323    }
324
325    #[test]
326    fn a_file_without_frontmatter_parses_to_empty_fields() {
327        let frontmatter = parse("# Title\nbody\n").unwrap();
328        assert_eq!(frontmatter.id, None);
329        assert_eq!(frontmatter.project, None);
330    }
331
332    #[test]
333    fn known_fields_are_extracted_and_unknown_fields_are_ignored() {
334        let frontmatter =
335            parse(
336                 "---\nid: T1\nproject: default\nname: Work\nblocked_by: [T0]\nworktree: topic/t1\ntarget: claude\nmodel: sonnet\neffort: medium\nflow: release\npriority: 3\n---\n# Body\n",
337            )
338            .unwrap();
339        assert_eq!(frontmatter.id.as_deref(), Some("T1"));
340        assert_eq!(frontmatter.project.as_deref(), Some("default"));
341        assert_eq!(frontmatter.name, "Work");
342        assert_eq!(frontmatter.blocked_by, ["T0"]);
343        assert!(frontmatter.has_blocked_by());
344        assert_eq!(frontmatter.worktree.as_deref(), Some("topic/t1"));
345        assert_eq!(frontmatter.target.as_deref(), Some("claude"));
346        assert_eq!(frontmatter.model.as_deref(), Some("sonnet"));
347        assert_eq!(frontmatter.effort.as_deref(), Some("medium"));
348        assert_eq!(frontmatter.flow.as_deref(), Some("release"));
349    }
350
351    #[test]
352    fn stamping_a_bare_file_prepends_a_complete_block() {
353        let stamped = stamp(
354            "# Persist cooldowns\n",
355            "cooldown",
356            "default",
357            "sloop/cooldown",
358            "default",
359        )
360        .unwrap()
361        .unwrap();
362        assert_eq!(
363            stamped,
364            "---\nid: cooldown\nproject: default\nworktree: sloop/cooldown\nflow: default\n---\n# Persist cooldowns\n"
365        );
366    }
367
368    #[test]
369    fn stamping_preserves_existing_keys_and_body_bytes() {
370        let content = "---\ntitle: Cooldowns\nid: T9\n---\nbody stays   untouched\n";
371        let stamped = stamp(content, "ignored", "default", "sloop/T9", "default")
372            .unwrap()
373            .unwrap();
374        assert_eq!(
375            stamped,
376            "---\ntitle: Cooldowns\nid: T9\nproject: default\nworktree: sloop/T9\nflow: default\n---\nbody stays   untouched\n"
377        );
378    }
379
380    #[test]
381    fn a_fully_stamped_file_needs_no_rewrite() {
382        let content = "---\nid: T1\nproject: default\nworktree: topic/t1\nflow: default\n---\n";
383        assert_eq!(
384            stamp(content, "T1", "default", "sloop/T1", "default").unwrap(),
385            None
386        );
387    }
388
389    #[test]
390    fn blocked_by_list_and_empty_list_round_trip_without_rewriting() {
391        for (content, expected) in [
392            (
393                "---\nblocked_by:\n  - T1\n  - T2\nworktree: topic/t3\nid: T3\nproject: default\nflow: default\n---\nbody\n",
394                &["T1", "T2"][..],
395            ),
396            (
397                "---\nblocked_by: []\nworktree: topic/t3\nid: T3\nproject: default\nflow: default\n---\nbody\n",
398                &[][..],
399            ),
400        ] {
401            let parsed = parse(content).unwrap();
402            assert!(parsed.has_blocked_by());
403            assert_eq!(parsed.blocked_by, expected);
404            assert_eq!(
405                stamp(content, "T3", "default", "sloop/T3", "default").unwrap(),
406                None
407            );
408        }
409    }
410
411    #[test]
412    fn scalar_blocked_by_is_rejected() {
413        assert_eq!(
414            parse("---\nblocked_by: T1\n---\n"),
415            Err(FrontmatterError::InvalidBlockedBy)
416        );
417    }
418
419    #[test]
420    fn explicit_worktree_is_left_byte_for_byte_untouched() {
421        let content = "---\nid: T1\nproject: default\nworktree: releases/T1\nflow: default\n---\nbody stays untouched\n";
422        assert_eq!(
423            stamp(content, "T1", "default", "sloop/T1", "default").unwrap(),
424            None
425        );
426    }
427
428    #[test]
429    fn an_explicit_flow_is_left_byte_for_byte_untouched() {
430        let content =
431            "---\nid: T1\nproject: default\nworktree: sloop/T1\nflow: release\n---\nbody\n";
432        assert_eq!(
433            stamp(content, "T1", "default", "sloop/T1", "default").unwrap(),
434            None
435        );
436    }
437
438    #[test]
439    fn a_missing_flow_is_stamped_with_the_default() {
440        let content = "---\nid: T1\nproject: default\nworktree: sloop/T1\n---\nbody\n";
441        let stamped = stamp(content, "T1", "default", "sloop/T1", "release")
442            .unwrap()
443            .unwrap();
444        assert_eq!(
445            stamped,
446            "---\nid: T1\nproject: default\nworktree: sloop/T1\nflow: release\n---\nbody\n"
447        );
448    }
449
450    #[test]
451    fn project_stamping_writes_only_an_id_and_preserves_every_other_byte() {
452        let content = "---\ntitle: Agent team\ncolor: blue\n---\nbody stays   untouched\n";
453        let stamped = stamp_id(content, "PROJ-1").unwrap().unwrap();
454        assert_eq!(
455            stamped,
456            "---\ntitle: Agent team\ncolor: blue\nid: PROJ-1\n---\nbody stays   untouched\n"
457        );
458        assert!(!stamped.contains("project:"));
459    }
460
461    #[test]
462    fn a_project_with_an_id_needs_no_rewrite() {
463        let content = "---\nid: explicit\ntitle: Existing\n---\n";
464        assert_eq!(stamp_id(content, "PROJ-1").unwrap(), None);
465    }
466
467    #[test]
468    fn an_unterminated_block_is_rejected() {
469        assert_eq!(parse("---\nid: T1\n"), Err(FrontmatterError::Unterminated));
470    }
471
472    /// Found by fuzzing: YAML breaks lines on CR/NEL/LS/PS where this module
473    /// only breaks on LF, so YAML saw an empty `id:` here while stamping's
474    /// byte model did not — and stamping then wrote a second `id`, corrupting
475    /// the file. Such blocks are rejected outright.
476    #[test]
477    fn non_lf_line_breaks_in_the_block_are_rejected() {
478        for content in [
479            "---\nid:\r¡title: Fix the flaky test\n---\nbody\n",
480            "---\nid:\r\ntitle: t\n---\n",
481            "---\nid:\u{0085}title: t\n---\n",
482            "---\nid:\u{2028}title: t\n---\n",
483            "---\nid:\u{2029}title: t\n---\n",
484        ] {
485            assert_eq!(parse(content), Err(FrontmatterError::ForeignLineBreak));
486            assert_eq!(
487                stamp(content, "id-1", "default", "sloop/t", "default"),
488                Err(FrontmatterError::ForeignLineBreak)
489            );
490        }
491        // Body bytes are user Markdown and stay unrestricted.
492        assert!(parse("---\nid: T1\n---\nbody\rwith\u{0085}breaks\n").is_ok());
493    }
494
495    /// Found by fuzzing: `~` is YAML for null, which the empty-block shortcut
496    /// accepted — and stamping keys after a null scalar corrupts the file.
497    /// Null now only passes for genuinely blank or comment-only blocks.
498    #[test]
499    fn explicit_null_content_is_rejected_but_blank_blocks_are_not() {
500        for content in ["---\n~\n---\n", "---\nnull\n---\n"] {
501            assert!(matches!(
502                parse(content),
503                Err(FrontmatterError::InvalidYaml(_))
504            ));
505        }
506        for content in ["---\n---\n", "---\n\n---\n", "---\n# note\n---\nbody\n"] {
507            assert!(parse(content).is_ok(), "blank-ish block: {content:?}");
508            let stamped = stamp(content, "id-1", "default", "sloop/t", "default")
509                .unwrap()
510                .unwrap();
511            assert_eq!(parse(&stamped).unwrap().id.as_deref(), Some("id-1"));
512        }
513    }
514
515    /// A flow-style mapping parses but cannot take line-inserted keys;
516    /// stamping must refuse with an error rather than corrupt the file.
517    #[test]
518    fn unstampable_blocks_error_instead_of_corrupting() {
519        let content = "---\n{title: Flow style}\n---\nbody\n";
520        assert!(parse(content).is_ok());
521        assert!(stamp(content, "id-1", "default", "sloop/t", "default").is_err());
522    }
523}