Skip to main content

batuta/bug_hunter/
ticket.rs

1//! PMAT Work Ticket Integration (BH-12)
2//!
3//! Parses PMAT work tickets to scope bug hunting to active development areas.
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10/// A parsed PMAT work ticket.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct PmatTicket {
13    /// Ticket identifier (e.g., "PMAT-1234")
14    pub id: String,
15    /// Ticket title
16    pub title: String,
17    /// Description
18    pub description: String,
19    /// Affected files/paths
20    pub affected_paths: Vec<PathBuf>,
21    /// Expected behavior description
22    pub expected_behavior: Option<String>,
23    /// Acceptance criteria
24    pub acceptance_criteria: Vec<String>,
25    /// Priority
26    pub priority: TicketPriority,
27}
28
29/// Ticket priority levels.
30#[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    /// Parse a ticket from file path or ID.
41    pub fn parse(ticket_ref: &str, project_path: &Path) -> Result<Self, String> {
42        // Check if it's a file path
43        let ticket_path = if ticket_ref.ends_with(".md") || ticket_ref.ends_with(".yaml") {
44            PathBuf::from(ticket_ref)
45        } else {
46            // Try to find ticket by ID in .pmat/tickets/
47            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                // Try GitHub issue
57                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    /// Parse from YAML file.
69    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    /// Parse from Markdown file.
77    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    /// Parse from GitHub issue (placeholder - would use gh CLI).
85    fn from_github_issue(issue_ref: &str) -> Result<Self, String> {
86        // Extract issue number
87        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        // For now, return a placeholder - in production would call `gh issue view`
94        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    /// Get target paths for scoped analysis.
106    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
115/// Parse a markdown ticket file.
116fn 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        // Parse title from first # header
137        if trimmed.starts_with("# ") && ticket.title.is_empty() {
138            ticket.title = trimmed.get(2..).unwrap_or("").to_string();
139            continue;
140        }
141
142        // Track sections
143        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); // defaults to Medium
440    }
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    // ========================================================================
481    // Coverage: PmatTicket::parse() — file path dispatch and lookup branches
482    // ========================================================================
483
484    #[test]
485    fn test_parse_md_extension_routes_to_from_markdown() {
486        // Passing a .md path that doesn't exist should return a read error
487        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        // Passing a .yaml path that doesn't exist should return a read error
495        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        // When neither .md nor .yaml exists in .pmat/tickets/, falls through to from_github_issue
503        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        // Invalid issue ref that can't parse as u32
512        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        // Create a temp directory with .pmat/tickets/<id>.md
520        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        // Cleanup
533        let _ = fs::remove_dir_all(&tmp);
534    }
535
536    #[test]
537    fn test_parse_finds_yaml_ticket_in_pmat_dir() {
538        // Create a temp directory with .pmat/tickets/<id>.yaml
539        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        // Cleanup
560        let _ = fs::remove_dir_all(&tmp);
561    }
562
563    // ========================================================================
564    // Coverage: from_yaml() and from_markdown() direct calls
565    // ========================================================================
566
567    #[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        // Test the parse() path where extension is .yaml and file exists
637        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        // Test the parse() path where extension is .md and file exists
660        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        // Path with no file stem (edge case)
676        let content = "Just content";
677        let ticket = parse_markdown_ticket(content, Path::new("")).expect("unexpected failure");
678        // Empty path -> file_stem returns None -> defaults to "UNKNOWN"
679        assert_eq!(ticket.id, "UNKNOWN");
680    }
681}