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