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#[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
250pub fn normalize_id_arg(arg: &str) -> Result<String> {
253 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
265pub 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
279pub 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 #[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 #[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 #[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 #[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 #[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 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 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(§ions);
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
694pub 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 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
711pub 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
740pub fn branch_name_from_path(path: &Path) -> Option<String> {
743 let stem = path.file_stem()?.to_str()?;
744 Some(format!("ticket/{stem}"))
745}