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
26/// Parses the leading `---` frontmatter block. A file without a block parses
27/// to an empty `Frontmatter`; a malformed block is an error so a typo never
28/// silently registers a ticket under the wrong identity.
29pub fn parse(content: &str) -> Result<Frontmatter, FrontmatterError> {
30    let Some(block) = split(content)? else {
31        return Ok(Frontmatter::default());
32    };
33
34    let mapping: serde_yaml::Value = serde_yaml::from_str(block.yaml)
35        .map_err(|error| FrontmatterError::InvalidYaml(error.to_string()))?;
36    if mapping.is_null() {
37        return Ok(Frontmatter::default());
38    }
39    let mapping = mapping
40        .as_mapping()
41        .ok_or_else(|| FrontmatterError::InvalidYaml("frontmatter must be a mapping".into()))?;
42
43    let (blocked_by, blocked_by_present) = string_list_field(mapping, "blocked_by")?;
44    Ok(Frontmatter {
45        id: string_field(mapping, "id")?,
46        project: string_field(mapping, "project")?,
47        title: string_field(mapping, "title")?,
48        name: string_field(mapping, "name")?.unwrap_or_default(),
49        blocked_by,
50        worktree: string_field(mapping, "worktree")?,
51        target: string_field(mapping, "target")?,
52        model: string_field(mapping, "model")?,
53        effort: string_field(mapping, "effort")?,
54        flow: string_field(mapping, "flow")?,
55        blocked_by_present,
56    })
57}
58
59/// Returns the Markdown body after the leading frontmatter block.
60pub fn body(content: &str) -> Result<&str, FrontmatterError> {
61    Ok(match split(content)? {
62        Some(block) => &content[block.body_at..],
63        None => content,
64    })
65}
66
67/// Writes `id`, `project`, `worktree`, and `flow` into the frontmatter
68/// without disturbing any other byte of the file. Returns `None` when the
69/// file already carries all four values, so callers can skip the write
70/// entirely.
71///
72/// Callers must resolve conflicts first: stamping never overwrites an
73/// existing `id`, `project`, or `flow` value.
74pub fn stamp(
75    content: &str,
76    id: &str,
77    project: &str,
78    worktree: &str,
79    flow: &str,
80) -> Result<Option<String>, FrontmatterError> {
81    let current = parse(content)?;
82    let mut lines = String::new();
83    if current.id.is_none() {
84        lines.push_str(&format!("id: {id}\n"));
85    }
86    if current.project.is_none() {
87        lines.push_str(&format!("project: {project}\n"));
88    }
89    if current.worktree.is_none() {
90        lines.push_str(&format!("worktree: {worktree}\n"));
91    }
92    if current.flow.is_none() {
93        lines.push_str(&format!("flow: {flow}\n"));
94    }
95    insert_lines(content, lines)
96}
97
98/// Writes only `id`, for project files. Existing IDs return `None` so startup
99/// can leave an already identified project byte-for-byte untouched.
100pub fn stamp_id(content: &str, id: &str) -> Result<Option<String>, FrontmatterError> {
101    if parse(content)?.id.is_some() {
102        return Ok(None);
103    }
104    insert_lines(content, format!("id: {id}\n"))
105}
106
107fn insert_lines(content: &str, lines: String) -> Result<Option<String>, FrontmatterError> {
108    if lines.is_empty() {
109        return Ok(None);
110    }
111    let stamped = match split(content)? {
112        Some(block) => {
113            let mut stamped = String::with_capacity(content.len() + lines.len());
114            stamped.push_str(&content[..block.close_at]);
115            stamped.push_str(&lines);
116            stamped.push_str(&content[block.close_at..]);
117            stamped
118        }
119        None => format!("---\n{lines}---\n{content}"),
120    };
121    Ok(Some(stamped))
122}
123
124struct RawBlock<'a> {
125    yaml: &'a str,
126    /// Byte offset of the closing `---` line, where new keys are inserted.
127    close_at: usize,
128    /// Byte offset immediately after the closing `---` line.
129    body_at: usize,
130}
131
132fn split(content: &str) -> Result<Option<RawBlock<'_>>, FrontmatterError> {
133    let Some(after_open) = content.strip_prefix("---\n") else {
134        return Ok(None);
135    };
136    let yaml_start = "---\n".len();
137    let mut offset = 0;
138    for line in after_open.split_inclusive('\n') {
139        if line == "---\n" || line == "---" {
140            return Ok(Some(RawBlock {
141                yaml: &after_open[..offset],
142                close_at: yaml_start + offset,
143                body_at: yaml_start + offset + line.len(),
144            }));
145        }
146        offset += line.len();
147    }
148    Err(FrontmatterError::Unterminated)
149}
150
151fn string_list_field(
152    mapping: &serde_yaml::Mapping,
153    key: &str,
154) -> Result<(Vec<String>, bool), FrontmatterError> {
155    let Some(value) = mapping.get(key) else {
156        return Ok((Vec::new(), false));
157    };
158    let Some(values) = value.as_sequence() else {
159        return Err(FrontmatterError::InvalidBlockedBy);
160    };
161    let values = values
162        .iter()
163        .map(|value| {
164            value
165                .as_str()
166                .map(str::to_owned)
167                .ok_or(FrontmatterError::InvalidBlockedBy)
168        })
169        .collect::<Result<Vec<_>, _>>()?;
170    Ok((values, true))
171}
172
173fn string_field(
174    mapping: &serde_yaml::Mapping,
175    key: &str,
176) -> Result<Option<String>, FrontmatterError> {
177    match mapping.get(key) {
178        None | Some(serde_yaml::Value::Null) => Ok(None),
179        Some(serde_yaml::Value::String(value)) => Ok(Some(value.clone())),
180        Some(serde_yaml::Value::Number(value)) => Ok(Some(value.to_string())),
181        Some(_) => Err(FrontmatterError::InvalidYaml(format!(
182            "frontmatter field `{key}` must be a scalar"
183        ))),
184    }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum FrontmatterError {
189    Unterminated,
190    InvalidYaml(String),
191    InvalidBlockedBy,
192}
193
194impl fmt::Display for FrontmatterError {
195    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196        match self {
197            Self::Unterminated => formatter.write_str("frontmatter block is not terminated"),
198            Self::InvalidYaml(message) => write!(formatter, "invalid frontmatter: {message}"),
199            Self::InvalidBlockedBy => {
200                formatter.write_str("frontmatter field `blocked_by` must be a YAML list of strings")
201            }
202        }
203    }
204}
205
206impl std::error::Error for FrontmatterError {}
207
208#[cfg(test)]
209mod tests {
210    use super::{FrontmatterError, parse, stamp, stamp_id};
211
212    #[test]
213    fn a_file_without_frontmatter_parses_to_empty_fields() {
214        let frontmatter = parse("# Title\nbody\n").unwrap();
215        assert_eq!(frontmatter.id, None);
216        assert_eq!(frontmatter.project, None);
217    }
218
219    #[test]
220    fn known_fields_are_extracted_and_unknown_fields_are_ignored() {
221        let frontmatter =
222            parse(
223                 "---\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",
224            )
225            .unwrap();
226        assert_eq!(frontmatter.id.as_deref(), Some("T1"));
227        assert_eq!(frontmatter.project.as_deref(), Some("default"));
228        assert_eq!(frontmatter.name, "Work");
229        assert_eq!(frontmatter.blocked_by, ["T0"]);
230        assert!(frontmatter.has_blocked_by());
231        assert_eq!(frontmatter.worktree.as_deref(), Some("topic/t1"));
232        assert_eq!(frontmatter.target.as_deref(), Some("claude"));
233        assert_eq!(frontmatter.model.as_deref(), Some("sonnet"));
234        assert_eq!(frontmatter.effort.as_deref(), Some("medium"));
235        assert_eq!(frontmatter.flow.as_deref(), Some("release"));
236    }
237
238    #[test]
239    fn stamping_a_bare_file_prepends_a_complete_block() {
240        let stamped = stamp(
241            "# Persist cooldowns\n",
242            "cooldown",
243            "default",
244            "sloop/cooldown",
245            "default",
246        )
247        .unwrap()
248        .unwrap();
249        assert_eq!(
250            stamped,
251            "---\nid: cooldown\nproject: default\nworktree: sloop/cooldown\nflow: default\n---\n# Persist cooldowns\n"
252        );
253    }
254
255    #[test]
256    fn stamping_preserves_existing_keys_and_body_bytes() {
257        let content = "---\ntitle: Cooldowns\nid: T9\n---\nbody stays   untouched\n";
258        let stamped = stamp(content, "ignored", "default", "sloop/T9", "default")
259            .unwrap()
260            .unwrap();
261        assert_eq!(
262            stamped,
263            "---\ntitle: Cooldowns\nid: T9\nproject: default\nworktree: sloop/T9\nflow: default\n---\nbody stays   untouched\n"
264        );
265    }
266
267    #[test]
268    fn a_fully_stamped_file_needs_no_rewrite() {
269        let content = "---\nid: T1\nproject: default\nworktree: topic/t1\nflow: default\n---\n";
270        assert_eq!(
271            stamp(content, "T1", "default", "sloop/T1", "default").unwrap(),
272            None
273        );
274    }
275
276    #[test]
277    fn blocked_by_list_and_empty_list_round_trip_without_rewriting() {
278        for (content, expected) in [
279            (
280                "---\nblocked_by:\n  - T1\n  - T2\nworktree: topic/t3\nid: T3\nproject: default\nflow: default\n---\nbody\n",
281                &["T1", "T2"][..],
282            ),
283            (
284                "---\nblocked_by: []\nworktree: topic/t3\nid: T3\nproject: default\nflow: default\n---\nbody\n",
285                &[][..],
286            ),
287        ] {
288            let parsed = parse(content).unwrap();
289            assert!(parsed.has_blocked_by());
290            assert_eq!(parsed.blocked_by, expected);
291            assert_eq!(
292                stamp(content, "T3", "default", "sloop/T3", "default").unwrap(),
293                None
294            );
295        }
296    }
297
298    #[test]
299    fn scalar_blocked_by_is_rejected() {
300        assert_eq!(
301            parse("---\nblocked_by: T1\n---\n"),
302            Err(FrontmatterError::InvalidBlockedBy)
303        );
304    }
305
306    #[test]
307    fn explicit_worktree_is_left_byte_for_byte_untouched() {
308        let content = "---\nid: T1\nproject: default\nworktree: releases/T1\nflow: default\n---\nbody stays untouched\n";
309        assert_eq!(
310            stamp(content, "T1", "default", "sloop/T1", "default").unwrap(),
311            None
312        );
313    }
314
315    #[test]
316    fn an_explicit_flow_is_left_byte_for_byte_untouched() {
317        let content =
318            "---\nid: T1\nproject: default\nworktree: sloop/T1\nflow: release\n---\nbody\n";
319        assert_eq!(
320            stamp(content, "T1", "default", "sloop/T1", "default").unwrap(),
321            None
322        );
323    }
324
325    #[test]
326    fn a_missing_flow_is_stamped_with_the_default() {
327        let content = "---\nid: T1\nproject: default\nworktree: sloop/T1\n---\nbody\n";
328        let stamped = stamp(content, "T1", "default", "sloop/T1", "release")
329            .unwrap()
330            .unwrap();
331        assert_eq!(
332            stamped,
333            "---\nid: T1\nproject: default\nworktree: sloop/T1\nflow: release\n---\nbody\n"
334        );
335    }
336
337    #[test]
338    fn project_stamping_writes_only_an_id_and_preserves_every_other_byte() {
339        let content = "---\ntitle: Agent team\ncolor: blue\n---\nbody stays   untouched\n";
340        let stamped = stamp_id(content, "PROJ-1").unwrap().unwrap();
341        assert_eq!(
342            stamped,
343            "---\ntitle: Agent team\ncolor: blue\nid: PROJ-1\n---\nbody stays   untouched\n"
344        );
345        assert!(!stamped.contains("project:"));
346    }
347
348    #[test]
349    fn a_project_with_an_id_needs_no_rewrite() {
350        let content = "---\nid: explicit\ntitle: Existing\n---\n";
351        assert_eq!(stamp_id(content, "PROJ-1").unwrap(), None);
352    }
353
354    #[test]
355    fn an_unterminated_block_is_rejected() {
356        assert_eq!(parse("---\nid: T1\n"), Err(FrontmatterError::Unterminated));
357    }
358}