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    parse(&stamped)?;
182    Ok(Some(stamped))
183}
184
185struct RawBlock<'a> {
186    yaml: &'a str,
187    /// Byte offset of the closing `---` line, where new keys are inserted.
188    close_at: usize,
189    /// Byte offset immediately after the closing `---` line.
190    body_at: usize,
191}
192
193fn split(content: &str) -> Result<Option<RawBlock<'_>>, FrontmatterError> {
194    let Some(after_open) = content.strip_prefix("---\n") else {
195        return Ok(None);
196    };
197    let yaml_start = "---\n".len();
198    let mut offset = 0;
199    for line in after_open.split_inclusive('\n') {
200        if line == "---\n" || line == "---" {
201            let yaml = &after_open[..offset];
202            // This module reads the block as LF-separated lines, but YAML
203            // also breaks lines on CR, NEL, LS, and PS. A block containing
204            // one would make `parse` see keys at positions `stamp`'s byte
205            // model does not, so stamping could write a duplicate key and
206            // corrupt the file. Reject the ambiguity instead.
207            if yaml.contains(['\r', '\u{0085}', '\u{2028}', '\u{2029}']) {
208                return Err(FrontmatterError::ForeignLineBreak);
209            }
210            return Ok(Some(RawBlock {
211                yaml,
212                close_at: yaml_start + offset,
213                body_at: yaml_start + offset + line.len(),
214            }));
215        }
216        offset += line.len();
217    }
218    Err(FrontmatterError::Unterminated)
219}
220
221fn string_list_field(
222    mapping: &serde_yaml::Mapping,
223    key: &str,
224) -> Result<(Vec<String>, bool), FrontmatterError> {
225    let Some(value) = mapping.get(key) else {
226        return Ok((Vec::new(), false));
227    };
228    let Some(values) = value.as_sequence() else {
229        return Err(FrontmatterError::InvalidBlockedBy);
230    };
231    let values = values
232        .iter()
233        .map(|value| {
234            value
235                .as_str()
236                .map(str::to_owned)
237                .ok_or(FrontmatterError::InvalidBlockedBy)
238        })
239        .collect::<Result<Vec<_>, _>>()?;
240    Ok((values, true))
241}
242
243fn string_field(
244    mapping: &serde_yaml::Mapping,
245    key: &str,
246) -> Result<Option<String>, FrontmatterError> {
247    match mapping.get(key) {
248        None | Some(serde_yaml::Value::Null) => Ok(None),
249        Some(serde_yaml::Value::String(value)) => Ok(Some(value.clone())),
250        Some(serde_yaml::Value::Number(value)) => Ok(Some(value.to_string())),
251        Some(_) => Err(FrontmatterError::InvalidFieldType {
252            key: key.to_owned(),
253        }),
254    }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum FrontmatterError {
259    Unterminated,
260    InvalidYaml(String),
261    /// A known scalar field holds a sequence or mapping.
262    InvalidFieldType {
263        key: String,
264    },
265    InvalidBlockedBy,
266    /// The block contains a line break other than LF (CR, NEL, LS, or PS).
267    ForeignLineBreak,
268}
269
270impl fmt::Display for FrontmatterError {
271    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
272        match self {
273            Self::Unterminated => formatter.write_str("frontmatter block is not terminated"),
274            Self::InvalidYaml(message) => write!(formatter, "invalid frontmatter: {message}"),
275            Self::InvalidFieldType { key } => write!(
276                formatter,
277                "invalid frontmatter: frontmatter field `{key}` must be a scalar"
278            ),
279            Self::InvalidBlockedBy => {
280                formatter.write_str("frontmatter field `blocked_by` must be a YAML list of strings")
281            }
282            Self::ForeignLineBreak => formatter.write_str(
283                "frontmatter block contains a line break other than LF \
284                 (carriage return, NEL, LS, or PS); use Unix line endings",
285            ),
286        }
287    }
288}
289
290impl std::error::Error for FrontmatterError {}
291
292#[cfg(test)]
293mod tests {
294    use super::{FrontmatterError, parse, parse_collecting, stamp, stamp_id};
295
296    #[test]
297    fn every_bad_field_is_collected_and_parse_reports_the_first() {
298        let content = "---\nblocked_by: T1\nname: [a]\ntarget: claude\n---\nbody\n";
299        let (frontmatter, problems) = parse_collecting(content).unwrap();
300        assert_eq!(
301            problems,
302            [
303                FrontmatterError::InvalidBlockedBy,
304                FrontmatterError::InvalidFieldType { key: "name".into() },
305            ]
306        );
307        assert_eq!(frontmatter.name, "");
308        assert!(!frontmatter.has_blocked_by());
309        assert_eq!(frontmatter.target.as_deref(), Some("claude"));
310        assert_eq!(parse(content), Err(FrontmatterError::InvalidBlockedBy));
311    }
312
313    #[test]
314    fn a_block_that_cannot_be_read_is_fatal_rather_than_collected() {
315        for content in ["---\nname: [oops\n---\nbody\n", "---\nid: T1\n"] {
316            assert!(parse_collecting(content).is_err(), "{content:?}");
317        }
318    }
319
320    #[test]
321    fn a_file_without_frontmatter_parses_to_empty_fields() {
322        let frontmatter = parse("# Title\nbody\n").unwrap();
323        assert_eq!(frontmatter.id, None);
324        assert_eq!(frontmatter.project, None);
325    }
326
327    #[test]
328    fn known_fields_are_extracted_and_unknown_fields_are_ignored() {
329        let frontmatter =
330            parse(
331                 "---\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",
332            )
333            .unwrap();
334        assert_eq!(frontmatter.id.as_deref(), Some("T1"));
335        assert_eq!(frontmatter.project.as_deref(), Some("default"));
336        assert_eq!(frontmatter.name, "Work");
337        assert_eq!(frontmatter.blocked_by, ["T0"]);
338        assert!(frontmatter.has_blocked_by());
339        assert_eq!(frontmatter.worktree.as_deref(), Some("topic/t1"));
340        assert_eq!(frontmatter.target.as_deref(), Some("claude"));
341        assert_eq!(frontmatter.model.as_deref(), Some("sonnet"));
342        assert_eq!(frontmatter.effort.as_deref(), Some("medium"));
343        assert_eq!(frontmatter.flow.as_deref(), Some("release"));
344    }
345
346    #[test]
347    fn stamping_a_bare_file_prepends_a_complete_block() {
348        let stamped = stamp(
349            "# Persist cooldowns\n",
350            "cooldown",
351            "default",
352            "sloop/cooldown",
353            "default",
354        )
355        .unwrap()
356        .unwrap();
357        assert_eq!(
358            stamped,
359            "---\nid: cooldown\nproject: default\nworktree: sloop/cooldown\nflow: default\n---\n# Persist cooldowns\n"
360        );
361    }
362
363    #[test]
364    fn stamping_preserves_existing_keys_and_body_bytes() {
365        let content = "---\ntitle: Cooldowns\nid: T9\n---\nbody stays   untouched\n";
366        let stamped = stamp(content, "ignored", "default", "sloop/T9", "default")
367            .unwrap()
368            .unwrap();
369        assert_eq!(
370            stamped,
371            "---\ntitle: Cooldowns\nid: T9\nproject: default\nworktree: sloop/T9\nflow: default\n---\nbody stays   untouched\n"
372        );
373    }
374
375    #[test]
376    fn a_fully_stamped_file_needs_no_rewrite() {
377        let content = "---\nid: T1\nproject: default\nworktree: topic/t1\nflow: default\n---\n";
378        assert_eq!(
379            stamp(content, "T1", "default", "sloop/T1", "default").unwrap(),
380            None
381        );
382    }
383
384    #[test]
385    fn blocked_by_list_and_empty_list_round_trip_without_rewriting() {
386        for (content, expected) in [
387            (
388                "---\nblocked_by:\n  - T1\n  - T2\nworktree: topic/t3\nid: T3\nproject: default\nflow: default\n---\nbody\n",
389                &["T1", "T2"][..],
390            ),
391            (
392                "---\nblocked_by: []\nworktree: topic/t3\nid: T3\nproject: default\nflow: default\n---\nbody\n",
393                &[][..],
394            ),
395        ] {
396            let parsed = parse(content).unwrap();
397            assert!(parsed.has_blocked_by());
398            assert_eq!(parsed.blocked_by, expected);
399            assert_eq!(
400                stamp(content, "T3", "default", "sloop/T3", "default").unwrap(),
401                None
402            );
403        }
404    }
405
406    #[test]
407    fn scalar_blocked_by_is_rejected() {
408        assert_eq!(
409            parse("---\nblocked_by: T1\n---\n"),
410            Err(FrontmatterError::InvalidBlockedBy)
411        );
412    }
413
414    #[test]
415    fn explicit_worktree_is_left_byte_for_byte_untouched() {
416        let content = "---\nid: T1\nproject: default\nworktree: releases/T1\nflow: default\n---\nbody stays untouched\n";
417        assert_eq!(
418            stamp(content, "T1", "default", "sloop/T1", "default").unwrap(),
419            None
420        );
421    }
422
423    #[test]
424    fn an_explicit_flow_is_left_byte_for_byte_untouched() {
425        let content =
426            "---\nid: T1\nproject: default\nworktree: sloop/T1\nflow: release\n---\nbody\n";
427        assert_eq!(
428            stamp(content, "T1", "default", "sloop/T1", "default").unwrap(),
429            None
430        );
431    }
432
433    #[test]
434    fn a_missing_flow_is_stamped_with_the_default() {
435        let content = "---\nid: T1\nproject: default\nworktree: sloop/T1\n---\nbody\n";
436        let stamped = stamp(content, "T1", "default", "sloop/T1", "release")
437            .unwrap()
438            .unwrap();
439        assert_eq!(
440            stamped,
441            "---\nid: T1\nproject: default\nworktree: sloop/T1\nflow: release\n---\nbody\n"
442        );
443    }
444
445    #[test]
446    fn project_stamping_writes_only_an_id_and_preserves_every_other_byte() {
447        let content = "---\ntitle: Agent team\ncolor: blue\n---\nbody stays   untouched\n";
448        let stamped = stamp_id(content, "PROJ-1").unwrap().unwrap();
449        assert_eq!(
450            stamped,
451            "---\ntitle: Agent team\ncolor: blue\nid: PROJ-1\n---\nbody stays   untouched\n"
452        );
453        assert!(!stamped.contains("project:"));
454    }
455
456    #[test]
457    fn a_project_with_an_id_needs_no_rewrite() {
458        let content = "---\nid: explicit\ntitle: Existing\n---\n";
459        assert_eq!(stamp_id(content, "PROJ-1").unwrap(), None);
460    }
461
462    #[test]
463    fn an_unterminated_block_is_rejected() {
464        assert_eq!(parse("---\nid: T1\n"), Err(FrontmatterError::Unterminated));
465    }
466
467    /// Found by fuzzing: YAML breaks lines on CR/NEL/LS/PS where this module
468    /// only breaks on LF, so YAML saw an empty `id:` here while stamping's
469    /// byte model did not — and stamping then wrote a second `id`, corrupting
470    /// the file. Such blocks are rejected outright.
471    #[test]
472    fn non_lf_line_breaks_in_the_block_are_rejected() {
473        for content in [
474            "---\nid:\r¡title: Fix the flaky test\n---\nbody\n",
475            "---\nid:\r\ntitle: t\n---\n",
476            "---\nid:\u{0085}title: t\n---\n",
477            "---\nid:\u{2028}title: t\n---\n",
478            "---\nid:\u{2029}title: t\n---\n",
479        ] {
480            assert_eq!(parse(content), Err(FrontmatterError::ForeignLineBreak));
481            assert_eq!(
482                stamp(content, "id-1", "default", "sloop/t", "default"),
483                Err(FrontmatterError::ForeignLineBreak)
484            );
485        }
486        assert!(parse("---\nid: T1\n---\nbody\rwith\u{0085}breaks\n").is_ok());
487    }
488
489    /// Found by fuzzing: `~` is YAML for null, which the empty-block shortcut
490    /// accepted — and stamping keys after a null scalar corrupts the file.
491    /// Null now only passes for genuinely blank or comment-only blocks.
492    #[test]
493    fn explicit_null_content_is_rejected_but_blank_blocks_are_not() {
494        for content in ["---\n~\n---\n", "---\nnull\n---\n"] {
495            assert!(matches!(
496                parse(content),
497                Err(FrontmatterError::InvalidYaml(_))
498            ));
499        }
500        for content in ["---\n---\n", "---\n\n---\n", "---\n# note\n---\nbody\n"] {
501            assert!(parse(content).is_ok(), "blank-ish block: {content:?}");
502            let stamped = stamp(content, "id-1", "default", "sloop/t", "default")
503                .unwrap()
504                .unwrap();
505            assert_eq!(parse(&stamped).unwrap().id.as_deref(), Some("id-1"));
506        }
507    }
508
509    /// A flow-style mapping parses but cannot take line-inserted keys;
510    /// stamping must refuse with an error rather than corrupt the file.
511    #[test]
512    fn unstampable_blocks_error_instead_of_corrupting() {
513        let content = "---\n{title: Flow style}\n---\nbody\n";
514        assert!(parse(content).is_ok());
515        assert!(stamp(content, "id-1", "default", "sloop/t", "default").is_err());
516    }
517}