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