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