Skip to main content

apm_core/ticket/
ticket_fmt.rs

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// ── TicketDocument ─────────────────────────────────────────────────────────
129
130#[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
257/// Normalize a user-supplied ID argument to a canonical prefix string.
258/// Accepts: plain integer (zero-padded to 4 chars), or 4–8 hex char string.
259pub fn normalize_id_arg(arg: &str) -> Result<String> {
260    // 4–8 hex chars: treat as hex prefix and preserve leading zeros.
261    // (Digits are also hex, so "05544285" hits this branch and is not parsed as int.)
262    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
272/// Return all candidate prefix strings for a user-supplied ID argument.
273///
274/// For all-digit inputs shorter than 4 chars, both the zero-padded form and
275/// the raw digit string are returned (the raw string is the correct hex prefix).
276/// For all other inputs a single-element vec is returned.
277pub 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
286/// Resolve a user-supplied ID argument to a unique ticket ID from a loaded list.
287pub 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    // --- parse ---
331
332    #[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    // --- serialize round-trip ---
465
466    #[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    // --- slugify ---
481
482    #[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    // --- normalize_id_arg ---
504
505    #[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    // --- id_arg_prefixes ---
529
530    #[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    // --- resolve_id_in_slice ---
549
550    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    // ── TicketDocument ────────────────────────────────────────────────────
589
590    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(&sections);
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
731/// Generate an 8-character hex ticket ID from local entropy (timestamp + PID).
732/// No network access or shared state is required. Birthday collision probability
733/// at N=1000 tickets: N²/2³² ≈ 0.023% — acceptable at this scale.
734pub 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    // splitmix64-style mixing for good bit avalanche
741    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
748/// Find a ticket branch matching a user-supplied ID argument (prefix or full hex).
749/// Normalizes plain integers (e.g. 35 → 0035) via `id_arg_prefixes`.
750pub 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
777/// Derive the ticket branch name from the ticket file path.
778/// e.g. tickets/0001-my-ticket.md → ticket/0001-my-ticket
779pub fn branch_name_from_path(path: &Path) -> Option<String> {
780    let stem = path.file_stem()?.to_str()?;
781    Some(format!("ticket/{stem}"))
782}