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> {
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
49pub 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 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
119pub 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
127pub 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
158pub 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)?;
186 Ok(Some(stamped))
187}
188
189struct RawBlock<'a> {
190 yaml: &'a str,
191 close_at: usize,
193 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 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 InvalidFieldType {
267 key: String,
268 },
269 InvalidBlockedBy,
270 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 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 #[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 assert!(parse("---\nid: T1\n---\nbody\rwith\u{0085}breaks\n").is_ok());
493 }
494
495 #[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 #[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}