Skip to main content

apm_core/ticket/
ticket_fmt.rs

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