1use std::fmt;
2
3#[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
35pub 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 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
81pub 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
89pub 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
120pub 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 parse(&stamped)?;
148 Ok(Some(stamped))
149}
150
151struct RawBlock<'a> {
152 yaml: &'a str,
153 close_at: usize,
155 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 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 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 #[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 assert!(parse("---\nid: T1\n---\nbody\rwith\u{0085}breaks\n").is_ok());
422 }
423
424 #[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 #[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}