1use std::fs;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct PmatTicket {
13 pub id: String,
15 pub title: String,
17 pub description: String,
19 pub affected_paths: Vec<PathBuf>,
21 pub expected_behavior: Option<String>,
23 pub acceptance_criteria: Vec<String>,
25 pub priority: TicketPriority,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31pub enum TicketPriority {
32 Critical,
33 High,
34 #[default]
35 Medium,
36 Low,
37}
38
39impl PmatTicket {
40 pub fn parse(ticket_ref: &str, project_path: &Path) -> Result<Self, String> {
42 let ticket_path = if ticket_ref.ends_with(".md") || ticket_ref.ends_with(".yaml") {
44 PathBuf::from(ticket_ref)
45 } else {
46 let pmat_dir = project_path.join(".pmat/tickets");
48 let md_path = pmat_dir.join(format!("{}.md", ticket_ref));
49 let yaml_path = pmat_dir.join(format!("{}.yaml", ticket_ref));
50
51 if md_path.exists() {
52 md_path
53 } else if yaml_path.exists() {
54 yaml_path
55 } else {
56 return Self::from_github_issue(ticket_ref);
58 }
59 };
60
61 if ticket_path.extension().map(|e| e == "yaml").unwrap_or(false) {
62 Self::from_yaml(&ticket_path)
63 } else {
64 Self::from_markdown(&ticket_path)
65 }
66 }
67
68 fn from_yaml(path: &Path) -> Result<Self, String> {
70 let content =
71 fs::read_to_string(path).map_err(|e| format!("Failed to read ticket: {}", e))?;
72
73 serde_yaml_ng::from_str(&content).map_err(|e| format!("Failed to parse YAML ticket: {}", e))
74 }
75
76 fn from_markdown(path: &Path) -> Result<Self, String> {
78 let content =
79 fs::read_to_string(path).map_err(|e| format!("Failed to read ticket: {}", e))?;
80
81 parse_markdown_ticket(&content, path)
82 }
83
84 fn from_github_issue(issue_ref: &str) -> Result<Self, String> {
86 let issue_num: u32 = issue_ref
88 .trim_start_matches("PMAT-")
89 .trim_start_matches('#')
90 .parse()
91 .map_err(|_| format!("Invalid issue reference: {}", issue_ref))?;
92
93 Ok(Self {
95 id: format!("PMAT-{}", issue_num),
96 title: format!("GitHub Issue #{}", issue_num),
97 description: "Loaded from GitHub".to_string(),
98 affected_paths: vec![PathBuf::from("src")],
99 expected_behavior: None,
100 acceptance_criteria: Vec::new(),
101 priority: TicketPriority::Medium,
102 })
103 }
104
105 pub fn target_paths(&self) -> Vec<PathBuf> {
107 if self.affected_paths.is_empty() {
108 vec![PathBuf::from("src")]
109 } else {
110 self.affected_paths.clone()
111 }
112 }
113}
114
115fn parse_markdown_ticket(content: &str, path: &Path) -> Result<PmatTicket, String> {
117 let mut ticket = PmatTicket {
118 id: path
119 .file_stem()
120 .map(|s| s.to_string_lossy().to_string())
121 .unwrap_or_else(|| "UNKNOWN".to_string()),
122 title: String::new(),
123 description: String::new(),
124 affected_paths: Vec::new(),
125 expected_behavior: None,
126 acceptance_criteria: Vec::new(),
127 priority: TicketPriority::Medium,
128 };
129
130 let mut current_section = "";
131 let mut description_lines: Vec<&str> = Vec::new();
132
133 for line in content.lines() {
134 let trimmed = line.trim();
135
136 if trimmed.starts_with("# ") && ticket.title.is_empty() {
138 ticket.title = trimmed.get(2..).unwrap_or("").to_string();
139 continue;
140 }
141
142 if let Some(section) = trimmed.strip_prefix("## ") {
144 current_section = section.trim();
145 continue;
146 }
147
148 parse_section_line(current_section, trimmed, &mut ticket, &mut description_lines);
149 }
150
151 ticket.description = description_lines.join(" ");
152
153 Ok(ticket)
154}
155
156fn parse_section_line<'a>(
157 section: &str,
158 trimmed: &'a str,
159 ticket: &mut PmatTicket,
160 description_lines: &mut Vec<&'a str>,
161) {
162 fn strip_list_marker(s: &str) -> &str {
163 s.get(2..).unwrap_or("")
164 }
165
166 match section.to_lowercase().as_str() {
167 "description" | "summary" => {
168 if !trimmed.is_empty() {
169 description_lines.push(trimmed);
170 }
171 }
172 "affected files" | "files" | "paths" | "scope" => {
173 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
174 let path_str = strip_list_marker(trimmed).trim().trim_matches('`');
175 ticket.affected_paths.push(PathBuf::from(path_str));
176 }
177 }
178 "expected behavior" | "expected" => {
179 if !trimmed.is_empty() {
180 ticket.expected_behavior = Some(trimmed.to_string());
181 }
182 }
183 "acceptance criteria" | "criteria" => {
184 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
185 ticket.acceptance_criteria.push(strip_list_marker(trimmed).to_string());
186 }
187 }
188 "priority" => {
189 ticket.priority = parse_priority(trimmed);
190 }
191 _ => {}
192 }
193}
194
195fn parse_priority(s: &str) -> TicketPriority {
196 match s.to_lowercase().as_str() {
197 "critical" => TicketPriority::Critical,
198 "high" => TicketPriority::High,
199 "medium" => TicketPriority::Medium,
200 "low" => TicketPriority::Low,
201 _ => TicketPriority::Medium,
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn test_parse_markdown_ticket() {
211 let content = r#"
212# Fix Authentication Bug
213
214## Description
215
216Users cannot login when using special characters.
217
218## Affected Files
219
220- `src/auth/login.rs`
221- `src/auth/token.rs`
222
223## Expected Behavior
224
225Login should work with any valid password.
226
227## Acceptance Criteria
228
229- [ ] Special characters handled correctly
230- [ ] No regression in existing tests
231
232## Priority
233
234High
235"#;
236
237 let ticket =
238 parse_markdown_ticket(content, Path::new("PMAT-123.md")).expect("unexpected failure");
239 assert_eq!(ticket.id, "PMAT-123");
240 assert_eq!(ticket.title, "Fix Authentication Bug");
241 assert_eq!(ticket.affected_paths.len(), 2);
242 assert_eq!(ticket.priority, TicketPriority::High);
243 }
244
245 #[test]
246 fn test_ticket_priority_default() {
247 assert_eq!(TicketPriority::default(), TicketPriority::Medium);
248 }
249
250 #[test]
251 fn test_target_paths_empty() {
252 let ticket = PmatTicket {
253 id: "TEST".to_string(),
254 title: "Test".to_string(),
255 description: String::new(),
256 affected_paths: Vec::new(),
257 expected_behavior: None,
258 acceptance_criteria: Vec::new(),
259 priority: TicketPriority::Medium,
260 };
261 let paths = ticket.target_paths();
262 assert_eq!(paths, vec![PathBuf::from("src")]);
263 }
264
265 #[test]
266 fn test_target_paths_with_paths() {
267 let ticket = PmatTicket {
268 id: "TEST".to_string(),
269 title: "Test".to_string(),
270 description: String::new(),
271 affected_paths: vec![PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")],
272 expected_behavior: None,
273 acceptance_criteria: Vec::new(),
274 priority: TicketPriority::Medium,
275 };
276 let paths = ticket.target_paths();
277 assert_eq!(paths.len(), 2);
278 assert!(paths.contains(&PathBuf::from("src/lib.rs")));
279 }
280
281 #[test]
282 fn test_from_github_issue_with_pmat_prefix() {
283 let ticket = PmatTicket::from_github_issue("PMAT-1234").expect("unexpected failure");
284 assert_eq!(ticket.id, "PMAT-1234");
285 assert!(ticket.description.contains("GitHub"));
286 assert_eq!(ticket.priority, TicketPriority::Medium);
287 }
288
289 #[test]
290 fn test_from_github_issue_with_hash() {
291 let ticket = PmatTicket::from_github_issue("#5678").expect("unexpected failure");
292 assert_eq!(ticket.id, "PMAT-5678");
293 }
294
295 #[test]
296 fn test_from_github_issue_number_only() {
297 let ticket = PmatTicket::from_github_issue("42").expect("unexpected failure");
298 assert_eq!(ticket.id, "PMAT-42");
299 }
300
301 #[test]
302 fn test_from_github_issue_invalid() {
303 let result = PmatTicket::from_github_issue("invalid-ref");
304 assert!(result.is_err());
305 assert!(result.unwrap_err().contains("Invalid issue reference"));
306 }
307
308 #[test]
309 fn test_parse_markdown_summary_section() {
310 let content = r#"
311# Test Ticket
312
313## Summary
314
315This is the summary text.
316"#;
317 let ticket =
318 parse_markdown_ticket(content, Path::new("TEST.md")).expect("unexpected failure");
319 assert_eq!(ticket.description, "This is the summary text.");
320 }
321
322 #[test]
323 fn test_parse_markdown_files_section() {
324 let content = r#"
325# Test
326
327## Files
328
329* src/foo.rs
330* src/bar.rs
331"#;
332 let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
333 assert_eq!(ticket.affected_paths.len(), 2);
334 }
335
336 #[test]
337 fn test_parse_markdown_paths_section() {
338 let content = r#"
339# Test
340
341## Paths
342
343- lib/
344"#;
345 let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
346 assert_eq!(ticket.affected_paths, vec![PathBuf::from("lib/")]);
347 }
348
349 #[test]
350 fn test_parse_markdown_scope_section() {
351 let content = r#"
352# Test
353
354## Scope
355
356- module/
357"#;
358 let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
359 assert_eq!(ticket.affected_paths, vec![PathBuf::from("module/")]);
360 }
361
362 #[test]
363 fn test_parse_markdown_expected_section() {
364 let content = r#"
365# Test
366
367## Expected
368
369It should work correctly.
370"#;
371 let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
372 assert_eq!(ticket.expected_behavior, Some("It should work correctly.".to_string()));
373 }
374
375 #[test]
376 fn test_parse_markdown_criteria_section() {
377 let content = r#"
378# Test
379
380## Criteria
381
382- First criterion
383- Second criterion
384"#;
385 let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
386 assert_eq!(ticket.acceptance_criteria.len(), 2);
387 assert!(ticket.acceptance_criteria.contains(&"First criterion".to_string()));
388 }
389
390 #[test]
391 fn test_parse_markdown_priority_critical() {
392 let content = r#"
393# Test
394
395## Priority
396
397Critical
398"#;
399 let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
400 assert_eq!(ticket.priority, TicketPriority::Critical);
401 }
402
403 #[test]
404 fn test_parse_markdown_priority_low() {
405 let content = r#"
406# Test
407
408## Priority
409
410Low
411"#;
412 let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
413 assert_eq!(ticket.priority, TicketPriority::Low);
414 }
415
416 #[test]
417 fn test_parse_markdown_priority_medium() {
418 let content = r#"
419# Test
420
421## Priority
422
423Medium
424"#;
425 let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
426 assert_eq!(ticket.priority, TicketPriority::Medium);
427 }
428
429 #[test]
430 fn test_parse_markdown_priority_invalid() {
431 let content = r#"
432# Test
433
434## Priority
435
436Unknown
437"#;
438 let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
439 assert_eq!(ticket.priority, TicketPriority::Medium); }
441
442 #[test]
443 fn test_parse_markdown_no_title() {
444 let content = "Just some content without a title.";
445 let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
446 assert_eq!(ticket.title, "");
447 }
448
449 #[test]
450 fn test_ticket_serialization() {
451 let ticket = PmatTicket {
452 id: "PMAT-1".to_string(),
453 title: "Test".to_string(),
454 description: "Desc".to_string(),
455 affected_paths: vec![PathBuf::from("src/")],
456 expected_behavior: Some("Works".to_string()),
457 acceptance_criteria: vec!["Done".to_string()],
458 priority: TicketPriority::High,
459 };
460 let json = serde_json::to_string(&ticket).expect("json serialize failed");
461 let deserialized: PmatTicket =
462 serde_json::from_str(&json).expect("json deserialize failed");
463 assert_eq!(ticket.id, deserialized.id);
464 assert_eq!(ticket.priority, deserialized.priority);
465 }
466
467 #[test]
468 fn test_priority_equality() {
469 assert_eq!(TicketPriority::Critical, TicketPriority::Critical);
470 assert_ne!(TicketPriority::High, TicketPriority::Low);
471 }
472
473 #[test]
474 fn test_priority_copy() {
475 let p = TicketPriority::High;
476 let p2 = p;
477 assert_eq!(p, p2);
478 }
479
480 #[test]
485 fn test_parse_md_extension_routes_to_from_markdown() {
486 let result = PmatTicket::parse("nonexistent_ticket.md", Path::new("/tmp"));
488 assert!(result.is_err());
489 assert!(result.unwrap_err().contains("Failed to read ticket"));
490 }
491
492 #[test]
493 fn test_parse_yaml_extension_routes_to_from_yaml() {
494 let result = PmatTicket::parse("nonexistent_ticket.yaml", Path::new("/tmp"));
496 assert!(result.is_err());
497 assert!(result.unwrap_err().contains("Failed to read ticket"));
498 }
499
500 #[test]
501 fn test_parse_ticket_id_no_pmat_dir_falls_to_github() {
502 let result = PmatTicket::parse("42", Path::new("/tmp/nonexistent_project"));
504 assert!(result.is_ok());
505 let ticket = result.expect("operation failed");
506 assert_eq!(ticket.id, "PMAT-42");
507 }
508
509 #[test]
510 fn test_parse_ticket_id_invalid_falls_to_github_error() {
511 let result = PmatTicket::parse("not-a-number", Path::new("/tmp/nonexistent_project"));
513 assert!(result.is_err());
514 assert!(result.unwrap_err().contains("Invalid issue reference"));
515 }
516
517 #[test]
518 fn test_parse_finds_md_ticket_in_pmat_dir() {
519 let tmp = std::env::temp_dir().join("batuta_test_parse_md");
521 let tickets_dir = tmp.join(".pmat/tickets");
522 let _ = fs::create_dir_all(&tickets_dir);
523 let md_content = "# Test Ticket\n\n## Description\n\nA test.\n";
524 let md_path = tickets_dir.join("PMAT-999.md");
525 fs::write(&md_path, md_content).expect("fs write failed");
526
527 let result = PmatTicket::parse("PMAT-999", &tmp);
528 assert!(result.is_ok());
529 let ticket = result.expect("operation failed");
530 assert_eq!(ticket.title, "Test Ticket");
531
532 let _ = fs::remove_dir_all(&tmp);
534 }
535
536 #[test]
537 fn test_parse_finds_yaml_ticket_in_pmat_dir() {
538 let tmp = std::env::temp_dir().join("batuta_test_parse_yaml");
540 let tickets_dir = tmp.join(".pmat/tickets");
541 let _ = fs::create_dir_all(&tickets_dir);
542 let yaml_content = r#"id: "PMAT-888"
543title: "YAML Ticket"
544description: "From YAML"
545affected_paths:
546 - "src/lib.rs"
547expected_behavior: null
548acceptance_criteria: []
549priority: High
550"#;
551 let yaml_path = tickets_dir.join("PMAT-888.yaml");
552 fs::write(&yaml_path, yaml_content).expect("fs write failed");
553
554 let result = PmatTicket::parse("PMAT-888", &tmp);
555 assert!(result.is_ok());
556 let ticket = result.expect("operation failed");
557 assert_eq!(ticket.title, "YAML Ticket");
558
559 let _ = fs::remove_dir_all(&tmp);
561 }
562
563 #[test]
568 fn test_from_yaml_valid_file() {
569 let tmp = std::env::temp_dir().join("batuta_test_from_yaml_valid");
570 let _ = fs::create_dir_all(&tmp);
571 let yaml_content = r#"id: "TK-1"
572title: "YAML Direct"
573description: "Direct YAML parse"
574affected_paths: []
575expected_behavior: "Works"
576acceptance_criteria:
577 - "Test passes"
578priority: Low
579"#;
580 let path = tmp.join("ticket.yaml");
581 fs::write(&path, yaml_content).expect("fs write failed");
582
583 let ticket = PmatTicket::from_yaml(&path).expect("unexpected failure");
584 assert_eq!(ticket.id, "TK-1");
585 assert_eq!(ticket.title, "YAML Direct");
586 assert_eq!(ticket.priority, TicketPriority::Low);
587
588 let _ = fs::remove_dir_all(&tmp);
589 }
590
591 #[test]
592 fn test_from_yaml_invalid_content() {
593 let tmp = std::env::temp_dir().join("batuta_test_from_yaml_invalid");
594 let _ = fs::create_dir_all(&tmp);
595 let path = tmp.join("bad.yaml");
596 fs::write(&path, "not: valid: yaml: [[[").expect("fs write failed");
597
598 let result = PmatTicket::from_yaml(&path);
599 assert!(result.is_err());
600 assert!(result.unwrap_err().contains("Failed to parse YAML ticket"));
601
602 let _ = fs::remove_dir_all(&tmp);
603 }
604
605 #[test]
606 fn test_from_yaml_nonexistent_file() {
607 let result = PmatTicket::from_yaml(Path::new("/tmp/does_not_exist_at_all.yaml"));
608 assert!(result.is_err());
609 assert!(result.unwrap_err().contains("Failed to read ticket"));
610 }
611
612 #[test]
613 fn test_from_markdown_valid_file() {
614 let tmp = std::env::temp_dir().join("batuta_test_from_md_valid");
615 let _ = fs::create_dir_all(&tmp);
616 let md_content = "# MD Direct Test\n\n## Description\n\nA direct test.\n";
617 let path = tmp.join("DIRECT-1.md");
618 fs::write(&path, md_content).expect("fs write failed");
619
620 let ticket = PmatTicket::from_markdown(&path).expect("unexpected failure");
621 assert_eq!(ticket.id, "DIRECT-1");
622 assert_eq!(ticket.title, "MD Direct Test");
623
624 let _ = fs::remove_dir_all(&tmp);
625 }
626
627 #[test]
628 fn test_from_markdown_nonexistent_file() {
629 let result = PmatTicket::from_markdown(Path::new("/tmp/does_not_exist_at_all.md"));
630 assert!(result.is_err());
631 assert!(result.unwrap_err().contains("Failed to read ticket"));
632 }
633
634 #[test]
635 fn test_parse_yaml_extension_with_real_yaml_file() {
636 let tmp = std::env::temp_dir().join("batuta_test_parse_yaml_ext");
638 let _ = fs::create_dir_all(&tmp);
639 let yaml_content = r#"id: "EXT-1"
640title: "Extension Test"
641description: "Test yaml extension detection in parse()"
642affected_paths: []
643expected_behavior: null
644acceptance_criteria: []
645priority: Medium
646"#;
647 let path = tmp.join("ticket.yaml");
648 fs::write(&path, yaml_content).expect("fs write failed");
649
650 let result = PmatTicket::parse(path.to_str().expect("path to_str failed"), &tmp);
651 assert!(result.is_ok());
652 assert_eq!(result.expect("operation failed").title, "Extension Test");
653
654 let _ = fs::remove_dir_all(&tmp);
655 }
656
657 #[test]
658 fn test_parse_md_extension_with_real_md_file() {
659 let tmp = std::env::temp_dir().join("batuta_test_parse_md_ext");
661 let _ = fs::create_dir_all(&tmp);
662 let md_content = "# MD Extension Test\n\n## Description\n\nParse route to markdown.\n";
663 let path = tmp.join("ticket.md");
664 fs::write(&path, md_content).expect("fs write failed");
665
666 let result = PmatTicket::parse(path.to_str().expect("path to_str failed"), &tmp);
667 assert!(result.is_ok());
668 assert_eq!(result.expect("operation failed").title, "MD Extension Test");
669
670 let _ = fs::remove_dir_all(&tmp);
671 }
672
673 #[test]
674 fn test_parse_markdown_no_file_stem() {
675 let content = "Just content";
677 let ticket = parse_markdown_ticket(content, Path::new("")).expect("unexpected failure");
678 assert_eq!(ticket.id, "UNKNOWN");
680 }
681}