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
26pub 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
59pub 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
67pub 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
98pub 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 close_at: usize,
128 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}