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)?;
182 Ok(Some(stamped))
183}
184
185struct RawBlock<'a> {
186 yaml: &'a str,
187 close_at: usize,
189 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 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 InvalidFieldType {
263 key: String,
264 },
265 InvalidBlockedBy,
266 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 #[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 #[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 #[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}