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