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#[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
252pub fn normalize_id_arg(arg: &str) -> Result<String> {
255 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
267pub 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
281pub 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 #[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 #[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 #[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 #[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 #[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 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 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(§ions);
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
696pub 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 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
713pub 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
742pub fn branch_name_from_path(path: &Path) -> Option<String> {
745 let stem = path.file_stem()?.to_str()?;
746 Some(format!("ticket/{stem}"))
747}