1use anyhow::{bail, Context, Result};
2use chrono::{DateTime, Utc};
3use indexmap::IndexMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9fn deserialize_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
10 use serde::de::{self, Visitor};
11 struct IdVisitor;
12 impl<'de> Visitor<'de> for IdVisitor {
13 type Value = String;
14 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
15 f.write_str("an integer or hex string")
16 }
17 fn visit_u64<E: de::Error>(self, v: u64) -> Result<String, E> {
18 Ok(format!("{v:04}"))
19 }
20 fn visit_i64<E: de::Error>(self, v: i64) -> Result<String, E> {
21 Ok(format!("{v:04}"))
22 }
23 fn visit_str<E: de::Error>(self, v: &str) -> Result<String, E> {
24 Ok(v.to_string())
25 }
26 fn visit_string<E: de::Error>(self, v: String) -> Result<String, E> {
27 Ok(v)
28 }
29 }
30 d.deserialize_any(IdVisitor)
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
34pub struct Frontmatter {
35 #[serde(deserialize_with = "deserialize_id")]
36 #[schemars(with = "String")]
37 pub id: String,
38 pub title: String,
39 pub state: String,
40 #[serde(default)]
41 pub priority: u8,
42 #[serde(default)]
43 pub effort: u8,
44 #[serde(default)]
45 pub risk: u8,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub author: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub owner: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub branch: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub created_at: Option<DateTime<Utc>>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub updated_at: Option<DateTime<Utc>>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub focus_section: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub epic: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub target_branch: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub depends_on: Option<Vec<String>>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub agent: Option<String>,
66 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
67 pub agent_overrides: HashMap<String, String>,
68}
69
70#[derive(Debug, Clone)]
71pub struct Ticket {
72 pub frontmatter: Frontmatter,
73 pub body: String,
74 pub path: PathBuf,
75}
76
77impl Ticket {
78 pub fn load(path: &Path) -> Result<Self> {
79 let raw = std::fs::read_to_string(path)
80 .with_context(|| format!("cannot read {}", path.display()))?;
81 Self::parse(path, &raw)
82 }
83
84 pub fn parse(path: &Path, raw: &str) -> Result<Self> {
85 let Some(rest) = raw.strip_prefix("+++\n") else {
86 bail!("missing frontmatter in {}", path.display());
87 };
88 let Some(end) = rest.find("\n+++") else {
89 bail!("unclosed frontmatter in {}", path.display());
90 };
91 let toml_src = &rest[..end];
92 let body = rest[end + 4..].trim_start_matches('\n').to_string();
93 let frontmatter: Frontmatter = toml::from_str(toml_src)
94 .with_context(|| format!("cannot parse frontmatter in {}", path.display()))?;
95 Ok(Self { frontmatter, body, path: path.to_owned() })
96 }
97
98 pub fn serialize(&self) -> Result<String> {
99 let fm = toml::to_string(&self.frontmatter)
100 .context("cannot serialize frontmatter")?;
101 Ok(format!("+++\n{}+++\n\n{}", fm, self.body))
102 }
103
104 pub fn save(&self) -> Result<()> {
105 let content = self.serialize()?;
106 std::fs::write(&self.path, content)
107 .with_context(|| format!("cannot write {}", self.path.display()))
108 }
109
110 pub fn document(&self) -> Result<TicketDocument> {
111 TicketDocument::parse(&self.body)
112 }
113}
114
115pub fn slugify(s: &str) -> String {
116 s.chars()
117 .map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
118 .collect::<String>()
119 .split('-')
120 .filter(|p| !p.is_empty())
121 .collect::<Vec<_>>()
122 .join("-")
123 .chars()
124 .take(40)
125 .collect()
126}
127
128#[derive(Debug, Clone)]
131pub struct ChecklistItem {
132 pub checked: bool,
133 pub text: String,
134}
135
136#[derive(Debug, Clone)]
137pub enum ValidationError {
138 EmptySection(String),
139 NoAcceptanceCriteria,
140}
141
142impl std::fmt::Display for ValidationError {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 match self {
145 Self::EmptySection(s) => write!(f, "### {s} section is empty"),
146 Self::NoAcceptanceCriteria => write!(f, "### Acceptance criteria has no checklist items"),
147 }
148 }
149}
150
151#[derive(Debug, Clone)]
152pub struct TicketDocument {
153 pub sections: IndexMap<String, String>,
154 pub(crate) raw_history: String,
155}
156
157pub(crate) fn parse_checklist(text: &str) -> Vec<ChecklistItem> {
158 text.lines()
159 .filter_map(|line| {
160 let l = line.trim();
161 if let Some(s) = l.strip_prefix("- [ ] ") {
162 Some(ChecklistItem { checked: false, text: s.to_string() })
163 } else if let Some(s) = l.strip_prefix("- [x] ") {
164 Some(ChecklistItem { checked: true, text: s.to_string() })
165 } else {
166 l.strip_prefix("- [X] ").map(|s| ChecklistItem { checked: true, text: s.to_string() })
167 }
168 })
169 .collect()
170}
171
172pub(crate) fn serialize_checklist(items: &[ChecklistItem]) -> String {
173 items.iter()
174 .map(|i| format!("- [{}] {}", if i.checked { "x" } else { " " }, i.text))
175 .collect::<Vec<_>>()
176 .join("\n")
177}
178
179impl TicketDocument {
180 pub fn parse(body: &str) -> Result<Self> {
181 let (spec_part, raw_history) = if let Some(pos) = body.find("\n## History") {
182 (&body[..pos], body[pos + 1..].to_string())
183 } else {
184 (body, String::new())
185 };
186
187 let mut sections = IndexMap::new();
188 let mut current_name: Option<String> = None;
189 let mut current_lines: Vec<&str> = Vec::new();
190
191 for line in spec_part.lines() {
192 if let Some(name) = line.strip_prefix("### ") {
193 if let Some(prev) = current_name.take() {
194 sections.insert(prev, current_lines.join("\n").trim().to_string());
195 }
196 current_name = Some(name.trim().to_string());
197 current_lines.clear();
198 } else if line.starts_with("## ") {
199 if let Some(prev) = current_name.take() {
200 sections.insert(prev, current_lines.join("\n").trim().to_string());
201 }
202 current_lines.clear();
203 } else if current_name.is_some() {
204 current_lines.push(line);
205 }
206 }
207 if let Some(name) = current_name {
208 sections.insert(name, current_lines.join("\n").trim().to_string());
209 }
210
211 Ok(Self { sections, raw_history })
212 }
213
214 pub fn serialize(&self) -> String {
215 let mut out = String::from("## Spec\n");
216
217 for (name, value) in &self.sections {
218 out.push_str(&format!("\n### {}\n\n", name));
219 if !value.is_empty() {
220 out.push_str(value);
221 out.push('\n');
222 }
223 }
224
225 if !self.raw_history.is_empty() {
226 out.push('\n');
227 out.push_str(&self.raw_history);
228 }
229
230 out
231 }
232
233 pub fn validate(&self, config_sections: &[crate::config::TicketSection]) -> Vec<ValidationError> {
234 use crate::config::SectionType;
235 let mut errors = Vec::new();
236 for sec in config_sections {
237 if !sec.required {
238 continue;
239 }
240 let val = self.sections.get(&sec.name).map(|s| s.as_str()).unwrap_or("");
241 if val.is_empty() {
242 if sec.type_ == SectionType::Tasks {
243 errors.push(ValidationError::NoAcceptanceCriteria);
244 } else {
245 errors.push(ValidationError::EmptySection(sec.name.clone()));
246 }
247 continue;
248 }
249 if sec.type_ == SectionType::Tasks && parse_checklist(val).is_empty() {
250 errors.push(ValidationError::NoAcceptanceCriteria);
251 }
252 }
253 errors
254 }
255}
256
257pub fn normalize_id_arg(arg: &str) -> Result<String> {
260 if (4..=8).contains(&arg.len()) && arg.chars().all(|c| c.is_ascii_hexdigit()) {
263 return Ok(arg.to_lowercase());
264 }
265 if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_digit()) {
266 let n: u64 = arg.parse().context("invalid integer ID")?;
267 return Ok(format!("{n:04}"));
268 }
269 bail!("invalid ticket ID {:?}: use 4–8 hex chars or a plain integer", arg);
270}
271
272pub fn id_arg_prefixes(arg: &str) -> Result<Vec<String>> {
278 let canonical = normalize_id_arg(arg)?;
279 if arg.chars().all(|c| c.is_ascii_digit()) && arg.len() < 4 {
280 Ok(vec![canonical, arg.to_string()])
281 } else {
282 Ok(vec![canonical])
283 }
284}
285
286pub fn resolve_id_in_slice(tickets: &[Ticket], arg: &str) -> Result<String> {
288 let prefixes = id_arg_prefixes(arg)?;
289 let mut seen = std::collections::HashSet::new();
290 let matches: Vec<&Ticket> = tickets.iter()
291 .filter(|t| {
292 let id = &t.frontmatter.id;
293 prefixes.iter().any(|p| id.starts_with(p.as_str())) && seen.insert(id.clone())
294 })
295 .collect();
296 match matches.len() {
297 0 => bail!("no ticket matches '{arg}'"),
298 1 => Ok(matches[0].frontmatter.id.clone()),
299 _ => {
300 let mut msg = format!("error: prefix '{arg}' is ambiguous");
301 for t in &matches {
302 msg.push_str(&format!("\n {} {}", t.frontmatter.id, t.frontmatter.title));
303 }
304 bail!("{msg}")
305 }
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use std::path::Path;
313
314 fn dummy_path() -> &'static Path {
315 Path::new("test.md")
316 }
317
318 fn minimal_raw(extra_fm: &str, body: &str) -> String {
319 format!(
320 "+++\nid = \"0001\"\ntitle = \"Test\"\nstate = \"new\"\n{extra_fm}+++\n\n{body}"
321 )
322 }
323
324 fn minimal_raw_int(extra_fm: &str, body: &str) -> String {
325 format!(
326 "+++\nid = 1\ntitle = \"Test\"\nstate = \"new\"\n{extra_fm}+++\n\n{body}"
327 )
328 }
329
330 #[test]
333 fn parse_well_formed() {
334 let raw = minimal_raw("priority = 5\n", "## Spec\n\nHello\n");
335 let t = Ticket::parse(dummy_path(), &raw).unwrap();
336 assert_eq!(t.frontmatter.id, "0001");
337 assert_eq!(t.frontmatter.title, "Test");
338 assert_eq!(t.frontmatter.state, "new");
339 assert_eq!(t.frontmatter.priority, 5);
340 assert_eq!(t.body, "## Spec\n\nHello\n");
341 }
342
343 #[test]
344 fn parse_integer_id_is_zero_padded() {
345 let raw = minimal_raw_int("", "");
346 let t = Ticket::parse(dummy_path(), &raw).unwrap();
347 assert_eq!(t.frontmatter.id, "0001");
348 }
349
350 #[test]
351 fn parse_optional_fields_default() {
352 let raw = minimal_raw("", "");
353 let t = Ticket::parse(dummy_path(), &raw).unwrap();
354 assert_eq!(t.frontmatter.priority, 0);
355 assert_eq!(t.frontmatter.effort, 0);
356 assert_eq!(t.frontmatter.risk, 0);
357 assert!(t.frontmatter.branch.is_none());
358 }
359
360 #[test]
361 fn parse_epic_field() {
362 let raw = minimal_raw("epic = \"ab12cd34\"\n", "");
363 let t = Ticket::parse(dummy_path(), &raw).unwrap();
364 assert_eq!(t.frontmatter.epic, Some("ab12cd34".to_string()));
365 }
366
367 #[test]
368 fn parse_target_branch_field() {
369 let raw = minimal_raw("target_branch = \"epic/ab12cd34-user-auth\"\n", "");
370 let t = Ticket::parse(dummy_path(), &raw).unwrap();
371 assert_eq!(t.frontmatter.target_branch, Some("epic/ab12cd34-user-auth".to_string()));
372 }
373
374 #[test]
375 fn parse_depends_on_field() {
376 let raw = minimal_raw("depends_on = [\"cd56ef78\", \"12ab34cd\"]\n", "");
377 let t = Ticket::parse(dummy_path(), &raw).unwrap();
378 assert_eq!(t.frontmatter.depends_on, Some(vec!["cd56ef78".to_string(), "12ab34cd".to_string()]));
379 }
380
381 #[test]
382 fn parse_omits_new_fields() {
383 let raw = minimal_raw("", "");
384 let t = Ticket::parse(dummy_path(), &raw).unwrap();
385 assert!(t.frontmatter.epic.is_none());
386 assert!(t.frontmatter.target_branch.is_none());
387 assert!(t.frontmatter.depends_on.is_none());
388 }
389
390 #[test]
391 fn serialize_omits_absent_fields() {
392 let raw = minimal_raw("", "## Spec\n\ncontent\n");
393 let t = Ticket::parse(dummy_path(), &raw).unwrap();
394 let serialized = t.serialize().unwrap();
395 assert!(!serialized.contains("epic"));
396 assert!(!serialized.contains("target_branch"));
397 assert!(!serialized.contains("depends_on"));
398 }
399
400 #[test]
401 fn parse_missing_opening_delimiter() {
402 let raw = "id = \"0001\"\ntitle = \"Test\"\nstate = \"new\"\n+++\n\nbody\n";
403 let err = Ticket::parse(dummy_path(), raw).unwrap_err();
404 assert!(err.to_string().contains("missing frontmatter"));
405 }
406
407 #[test]
408 fn parse_unclosed_frontmatter() {
409 let raw = "+++\nid = \"0001\"\ntitle = \"Test\"\nstate = \"new\"\n\nbody\n";
410 let err = Ticket::parse(dummy_path(), raw).unwrap_err();
411 assert!(err.to_string().contains("unclosed frontmatter"));
412 }
413
414 #[test]
415 fn parse_invalid_toml() {
416 let raw = "+++\nid = not_a_number\n+++\n\nbody\n";
417 let err = Ticket::parse(dummy_path(), raw).unwrap_err();
418 assert!(err.to_string().contains("cannot parse frontmatter"));
419 }
420
421 #[test]
422 fn epic_and_depends_on_round_trip() {
423 let raw = minimal_raw(
424 "epic = \"ab12cd34\"\ndepends_on = [\"cd56ef78\", \"12ab34cd\"]\n",
425 "## Spec\n\ncontent\n",
426 );
427 let t = Ticket::parse(dummy_path(), &raw).unwrap();
428 assert_eq!(t.frontmatter.epic, Some("ab12cd34".to_string()));
429 assert_eq!(
430 t.frontmatter.depends_on,
431 Some(vec!["cd56ef78".to_string(), "12ab34cd".to_string()])
432 );
433 let serialized = t.serialize().unwrap();
434 assert!(serialized.contains("epic = \"ab12cd34\""));
435 assert!(serialized.contains("depends_on = [\"cd56ef78\", \"12ab34cd\"]"));
436 let t2 = Ticket::parse(dummy_path(), &serialized).unwrap();
437 assert_eq!(t2.frontmatter.epic, Some("ab12cd34".to_string()));
438 assert_eq!(
439 t2.frontmatter.depends_on,
440 Some(vec!["cd56ef78".to_string(), "12ab34cd".to_string()])
441 );
442 }
443
444 #[test]
445 fn target_branch_round_trips() {
446 let raw = minimal_raw("target_branch = \"epic/abc\"\n", "## Spec\n\ncontent\n");
447 let t = Ticket::parse(dummy_path(), &raw).unwrap();
448 let serialized = t.serialize().unwrap();
449 assert!(serialized.contains("target_branch = \"epic/abc\""));
450 let t2 = Ticket::parse(dummy_path(), &serialized).unwrap();
451 assert_eq!(t2.frontmatter.target_branch, Some("epic/abc".to_string()));
452 }
453
454 #[test]
455 fn target_branch_absent_not_added_on_round_trip() {
456 let raw = minimal_raw("", "## Spec\n\ncontent\n");
457 let t = Ticket::parse(dummy_path(), &raw).unwrap();
458 let serialized = t.serialize().unwrap();
459 assert!(!serialized.contains("target_branch"));
460 let t2 = Ticket::parse(dummy_path(), &serialized).unwrap();
461 assert!(t2.frontmatter.target_branch.is_none());
462 }
463
464 #[test]
467 fn serialize_round_trips() {
468 let raw = minimal_raw("effort = 3\nrisk = 1\n", "## Spec\n\ncontent\n");
469 let t = Ticket::parse(dummy_path(), &raw).unwrap();
470 let serialized = t.serialize().unwrap();
471 let t2 = Ticket::parse(dummy_path(), &serialized).unwrap();
472 assert_eq!(t2.frontmatter.id, t.frontmatter.id);
473 assert_eq!(t2.frontmatter.title, t.frontmatter.title);
474 assert_eq!(t2.frontmatter.state, t.frontmatter.state);
475 assert_eq!(t2.frontmatter.effort, t.frontmatter.effort);
476 assert_eq!(t2.frontmatter.risk, t.frontmatter.risk);
477 assert_eq!(t2.body, t.body);
478 }
479
480 #[test]
483 fn slugify_basic() {
484 assert_eq!(slugify("Hello World"), "hello-world");
485 }
486
487 #[test]
488 fn slugify_special_chars() {
489 assert_eq!(slugify("Add apm init --hooks (install git hooks)"), "add-apm-init-hooks-install-git-hooks");
490 }
491
492 #[test]
493 fn slugify_truncates_at_40() {
494 let long = "a".repeat(50);
495 assert_eq!(slugify(&long).len(), 40);
496 }
497
498 #[test]
499 fn slugify_collapses_separators() {
500 assert_eq!(slugify("foo -- bar"), "foo-bar");
501 }
502
503 #[test]
506 fn normalize_integer_pads_to_four() {
507 assert_eq!(normalize_id_arg("35").unwrap(), "0035");
508 assert_eq!(normalize_id_arg("1").unwrap(), "0001");
509 assert_eq!(normalize_id_arg("9999").unwrap(), "9999");
510 }
511
512 #[test]
513 fn normalize_hex_passthrough() {
514 assert_eq!(normalize_id_arg("a3f9b2c1").unwrap(), "a3f9b2c1");
515 assert_eq!(normalize_id_arg("a3f9").unwrap(), "a3f9");
516 }
517
518 #[test]
519 fn normalize_too_short_errors() {
520 assert!(normalize_id_arg("abc").is_err());
521 }
522
523 #[test]
524 fn normalize_non_hex_errors() {
525 assert!(normalize_id_arg("gggg").is_err());
526 }
527
528 #[test]
531 fn prefixes_short_digit_returns_two() {
532 let p = id_arg_prefixes("314").unwrap();
533 assert_eq!(p, vec!["0314", "314"]);
534 }
535
536 #[test]
537 fn prefixes_four_digit_returns_one() {
538 let p = id_arg_prefixes("3142").unwrap();
539 assert_eq!(p, vec!["3142"]);
540 }
541
542 #[test]
543 fn prefixes_hex_returns_one() {
544 let p = id_arg_prefixes("a3f9").unwrap();
545 assert_eq!(p, vec!["a3f9"]);
546 }
547
548 fn make_ticket_with_title(id: &str, title: &str) -> Ticket {
551 let raw = format!(
552 "+++\nid = \"{id}\"\ntitle = \"{title}\"\nstate = \"new\"\n+++\n\nbody\n"
553 );
554 let path = std::path::PathBuf::from(format!("tickets/{id}.md"));
555 Ticket::parse(&path, &raw).unwrap()
556 }
557
558 #[test]
559 fn resolve_short_digit_prefix_unique() {
560 let tickets = vec![make_ticket_with_title("314abcde", "Alpha")];
561 assert_eq!(resolve_id_in_slice(&tickets, "314").unwrap(), "314abcde");
562 }
563
564 #[test]
565 fn resolve_integer_one_matches_0001() {
566 let tickets = vec![make_ticket_with_title("0001", "One")];
567 assert_eq!(resolve_id_in_slice(&tickets, "1").unwrap(), "0001");
568 }
569
570 #[test]
571 fn resolve_four_digit_prefix() {
572 let tickets = vec![make_ticket_with_title("3142abcd", "Beta")];
573 assert_eq!(resolve_id_in_slice(&tickets, "3142").unwrap(), "3142abcd");
574 }
575
576 #[test]
577 fn resolve_ambiguous_prefix_lists_candidates() {
578 let tickets = vec![
579 make_ticket_with_title("314abcde", "Alpha"),
580 make_ticket_with_title("3142xxxx", "Beta"),
581 ];
582 let err = resolve_id_in_slice(&tickets, "314").unwrap_err().to_string();
583 assert!(err.contains("ambiguous"), "expected 'ambiguous' in: {err}");
584 assert!(err.contains("314abcde"), "expected first id in: {err}");
585 assert!(err.contains("3142xxxx"), "expected second id in: {err}");
586 }
587
588 fn full_body(ac: &str) -> String {
591 format!(
592 "## Spec\n\n### Problem\n\nSome problem.\n\n### Acceptance criteria\n\n{ac}\n\n### Out of scope\n\nNothing.\n\n### Approach\n\nDo it.\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
593 )
594 }
595
596 fn minimal_ticket_sections() -> Vec<crate::config::TicketSection> {
597 use crate::config::{SectionType, TicketSection};
598 vec![
599 TicketSection { name: "Problem".into(), type_: SectionType::Free, required: true, placeholder: None },
600 TicketSection { name: "Acceptance criteria".into(), type_: SectionType::Tasks, required: true, placeholder: None },
601 TicketSection { name: "Out of scope".into(), type_: SectionType::Free, required: true, placeholder: None },
602 TicketSection { name: "Approach".into(), type_: SectionType::Free, required: true, placeholder: None },
603 ]
604 }
605
606 #[test]
607 fn document_parse_required_sections() {
608 let body = full_body("- [ ] item one\n- [x] item two");
609 let doc = TicketDocument::parse(&body).unwrap();
610 assert_eq!(doc.sections.get("Problem").map(|s| s.as_str()), Some("Some problem."));
611 let ac = doc.sections.get("Acceptance criteria").unwrap();
612 assert!(ac.contains("- [ ] item one"));
613 assert!(ac.contains("- [x] item two"));
614 assert_eq!(doc.sections.get("Out of scope").map(|s| s.as_str()), Some("Nothing."));
615 assert_eq!(doc.sections.get("Approach").map(|s| s.as_str()), Some("Do it."));
616 }
617
618 #[test]
619 fn document_parse_missing_section_fails_validate() {
620 let body = "## Spec\n\n### Problem\n\nSome problem.\n\n## History\n\n";
621 let doc = TicketDocument::parse(body).unwrap();
622 let errs = doc.validate(&minimal_ticket_sections());
623 assert!(!errs.is_empty(), "expected validation errors for missing required sections");
624 }
625
626 #[test]
627 fn document_parse_unknown_section_preserved() {
628 let body = "## Spec\n\n### Problem\n\nfoo\n\n### Acceptance criteria\n\n- [x] done\n\n### Out of scope\n\nbar\n\n### Approach\n\nbaz\n\n### Foo\n\nsome custom content\n\n## History\n\n";
629 let doc = TicketDocument::parse(body).unwrap();
630 assert_eq!(doc.sections.get("Foo").map(|s| s.as_str()), Some("some custom content"));
631 let s = doc.serialize();
632 assert!(s.contains("### Foo"), "unknown section should be preserved in serialization");
633 assert!(s.contains("some custom content"));
634 }
635
636 #[test]
637 fn document_parse_code_review_preserved() {
638 let body = "## Spec\n\n### Problem\n\nfoo\n\n### Acceptance criteria\n\n- [x] done\n\n### Out of scope\n\nbar\n\n### Approach\n\nbaz\n\n### Code review\n\n- [ ] Check tests\n\n## History\n\n";
639 let doc = TicketDocument::parse(body).unwrap();
640 let s = doc.serialize();
641 assert!(s.contains("### Code review"), "Code review section should survive round-trip");
642 assert!(s.contains("- [ ] Check tests"));
643 }
644
645 #[test]
646 fn document_round_trip() {
647 let body = full_body("- [ ] criterion A\n- [x] criterion B");
648 let doc = TicketDocument::parse(&body).unwrap();
649 let serialized = doc.serialize();
650 let doc2 = TicketDocument::parse(&serialized).unwrap();
651 assert_eq!(doc2.sections.get("Problem"), doc.sections.get("Problem"));
652 assert_eq!(doc2.sections.get("Acceptance criteria"), doc.sections.get("Acceptance criteria"));
653 assert_eq!(doc2.sections.get("Out of scope"), doc.sections.get("Out of scope"));
654 assert_eq!(doc2.sections.get("Approach"), doc.sections.get("Approach"));
655 }
656
657 #[test]
658 fn document_validate_empty_sections() {
659 let body = "## Spec\n\n### Problem\n\n\n### Acceptance criteria\n\n- [ ] x\n\n### Out of scope\n\n\n### Approach\n\ncontent\n";
660 let doc = TicketDocument::parse(body).unwrap();
661 let errs = doc.validate(&minimal_ticket_sections());
662 let msgs: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
663 assert!(msgs.iter().any(|m| m.contains("Problem")));
664 assert!(msgs.iter().any(|m| m.contains("Out of scope")));
665 assert!(!msgs.iter().any(|m| m.contains("Approach")));
666 }
667
668 #[test]
669 fn document_validate_no_criteria() {
670 let body = "## Spec\n\n### Problem\n\nfoo\n\n### Acceptance criteria\n\n\n### Out of scope\n\nbar\n\n### Approach\n\nbaz\n";
671 let doc = TicketDocument::parse(body).unwrap();
672 let errs = doc.validate(&minimal_ticket_sections());
673 assert!(errs.iter().any(|e| matches!(e, ValidationError::NoAcceptanceCriteria)));
674 }
675
676 #[test]
677 fn document_validate_required_from_config() {
678 use crate::config::{SectionType, TicketSection};
679 let body = "## Spec\n\n### Problem\n\nfoo\n\n";
680 let doc = TicketDocument::parse(body).unwrap();
681 let sections = vec![
682 TicketSection { name: "Problem".into(), type_: SectionType::Free, required: true, placeholder: None },
683 TicketSection { name: "Context".into(), type_: SectionType::Free, required: true, placeholder: None },
684 ];
685 let errs = doc.validate(§ions);
686 let msgs: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
687 assert!(msgs.iter().any(|m| m.contains("Context")), "required config section should be validated");
688 assert!(!msgs.iter().any(|m| m.contains("Problem")), "present section should not error");
689 }
690
691 #[test]
692 fn frontmatter_agent_round_trip() {
693 let raw = minimal_raw(
694 "agent = \"mock-happy\"\n\n[agent_overrides]\nspec_agent = \"claude\"\nimpl_agent = \"mock-sad\"\n",
695 "## Spec\n\ncontent\n",
696 );
697 let t = Ticket::parse(dummy_path(), &raw).unwrap();
698 assert_eq!(t.frontmatter.agent, Some("mock-happy".to_string()));
699 assert_eq!(t.frontmatter.agent_overrides.get("spec_agent").map(|s| s.as_str()), Some("claude"));
700 assert_eq!(t.frontmatter.agent_overrides.get("impl_agent").map(|s| s.as_str()), Some("mock-sad"));
701
702 let serialized = t.serialize().unwrap();
703 let t2 = Ticket::parse(dummy_path(), &serialized).unwrap();
704 assert_eq!(t2.frontmatter.agent, Some("mock-happy".to_string()));
705 assert_eq!(t2.frontmatter.agent_overrides.get("spec_agent").map(|s| s.as_str()), Some("claude"));
706 assert_eq!(t2.frontmatter.agent_overrides.get("impl_agent").map(|s| s.as_str()), Some("mock-sad"));
707 }
708
709 #[test]
710 fn frontmatter_agent_omitted_when_unset() {
711 let raw = minimal_raw("", "## Spec\n\ncontent\n");
712 let t = Ticket::parse(dummy_path(), &raw).unwrap();
713 assert!(t.frontmatter.agent.is_none());
714 assert!(t.frontmatter.agent_overrides.is_empty());
715
716 let serialized = t.serialize().unwrap();
717 assert!(!serialized.contains("agent"), "agent field must not appear in serialized output");
718 assert!(!serialized.contains("agent_overrides"), "agent_overrides must not appear in serialized output");
719 }
720
721 #[test]
722 fn document_history_preserved() {
723 let body = full_body("- [x] done");
724 let doc = TicketDocument::parse(&body).unwrap();
725 let s = doc.serialize();
726 assert!(s.contains("## History"));
727 assert!(s.contains("| When |"));
728 }
729}
730
731pub fn gen_hex_id() -> String {
735 use std::time::{SystemTime, UNIX_EPOCH};
736 let dur = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
737 let secs = dur.as_secs();
738 let nanos = dur.subsec_nanos() as u64;
739 let pid = std::process::id() as u64;
740 let a = secs.wrapping_mul(0x9e3779b97f4a7c15).wrapping_add(nanos);
742 let b = (a ^ (a >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
743 let c = (b ^ (b >> 27)).wrapping_mul(0x94d049bb133111eb);
744 let result = (c ^ (c >> 31)) ^ pid.wrapping_mul(0x6c62272e07bb0142);
745 format!("{:016x}", result)[..8].to_string()
746}
747
748pub fn resolve_ticket_branch(branches: &[String], arg: &str) -> Result<String> {
751 let prefixes = id_arg_prefixes(arg)?;
752 let mut seen = std::collections::HashSet::new();
753 let matches: Vec<&String> = branches.iter()
754 .filter(|b| {
755 let id = b.strip_prefix("ticket/")
756 .and_then(|s| s.split('-').next())
757 .unwrap_or("");
758 prefixes.iter().any(|p| id.starts_with(p.as_str())) && seen.insert(id.to_string())
759 })
760 .collect();
761 match matches.len() {
762 0 => bail!("no ticket matches '{arg}'"),
763 1 => Ok(matches[0].clone()),
764 _ => {
765 let mut msg = format!("error: prefix '{arg}' is ambiguous");
766 for b in &matches {
767 let id = b.strip_prefix("ticket/")
768 .and_then(|s| s.split('-').next())
769 .unwrap_or(b.as_str());
770 msg.push_str(&format!("\n {id} ({})", b));
771 }
772 bail!("{msg}")
773 }
774 }
775}
776
777pub fn branch_name_from_path(path: &Path) -> Option<String> {
780 let stem = path.file_stem()?.to_str()?;
781 Some(format!("ticket/{stem}"))
782}