use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PmatTicket {
pub id: String,
pub title: String,
pub description: String,
pub affected_paths: Vec<PathBuf>,
pub expected_behavior: Option<String>,
pub acceptance_criteria: Vec<String>,
pub priority: TicketPriority,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TicketPriority {
Critical,
High,
#[default]
Medium,
Low,
}
impl PmatTicket {
pub fn parse(ticket_ref: &str, project_path: &Path) -> Result<Self, String> {
let ticket_path = if ticket_ref.ends_with(".md") || ticket_ref.ends_with(".yaml") {
PathBuf::from(ticket_ref)
} else {
let pmat_dir = project_path.join(".pmat/tickets");
let md_path = pmat_dir.join(format!("{}.md", ticket_ref));
let yaml_path = pmat_dir.join(format!("{}.yaml", ticket_ref));
if md_path.exists() {
md_path
} else if yaml_path.exists() {
yaml_path
} else {
return Self::from_github_issue(ticket_ref);
}
};
if ticket_path.extension().map(|e| e == "yaml").unwrap_or(false) {
Self::from_yaml(&ticket_path)
} else {
Self::from_markdown(&ticket_path)
}
}
fn from_yaml(path: &Path) -> Result<Self, String> {
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read ticket: {}", e))?;
serde_yaml_ng::from_str(&content).map_err(|e| format!("Failed to parse YAML ticket: {}", e))
}
fn from_markdown(path: &Path) -> Result<Self, String> {
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read ticket: {}", e))?;
parse_markdown_ticket(&content, path)
}
fn from_github_issue(issue_ref: &str) -> Result<Self, String> {
let issue_num: u32 = issue_ref
.trim_start_matches("PMAT-")
.trim_start_matches('#')
.parse()
.map_err(|_| format!("Invalid issue reference: {}", issue_ref))?;
Ok(Self {
id: format!("PMAT-{}", issue_num),
title: format!("GitHub Issue #{}", issue_num),
description: "Loaded from GitHub".to_string(),
affected_paths: vec![PathBuf::from("src")],
expected_behavior: None,
acceptance_criteria: Vec::new(),
priority: TicketPriority::Medium,
})
}
pub fn target_paths(&self) -> Vec<PathBuf> {
if self.affected_paths.is_empty() {
vec![PathBuf::from("src")]
} else {
self.affected_paths.clone()
}
}
}
fn parse_markdown_ticket(content: &str, path: &Path) -> Result<PmatTicket, String> {
let mut ticket = PmatTicket {
id: path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "UNKNOWN".to_string()),
title: String::new(),
description: String::new(),
affected_paths: Vec::new(),
expected_behavior: None,
acceptance_criteria: Vec::new(),
priority: TicketPriority::Medium,
};
let mut current_section = "";
let mut description_lines: Vec<&str> = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("# ") && ticket.title.is_empty() {
ticket.title = trimmed.get(2..).unwrap_or("").to_string();
continue;
}
if let Some(section) = trimmed.strip_prefix("## ") {
current_section = section.trim();
continue;
}
parse_section_line(current_section, trimmed, &mut ticket, &mut description_lines);
}
ticket.description = description_lines.join(" ");
Ok(ticket)
}
fn parse_section_line<'a>(
section: &str,
trimmed: &'a str,
ticket: &mut PmatTicket,
description_lines: &mut Vec<&'a str>,
) {
fn strip_list_marker(s: &str) -> &str {
s.get(2..).unwrap_or("")
}
match section.to_lowercase().as_str() {
"description" | "summary" => {
if !trimmed.is_empty() {
description_lines.push(trimmed);
}
}
"affected files" | "files" | "paths" | "scope" => {
if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
let path_str = strip_list_marker(trimmed).trim().trim_matches('`');
ticket.affected_paths.push(PathBuf::from(path_str));
}
}
"expected behavior" | "expected" => {
if !trimmed.is_empty() {
ticket.expected_behavior = Some(trimmed.to_string());
}
}
"acceptance criteria" | "criteria" => {
if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
ticket.acceptance_criteria.push(strip_list_marker(trimmed).to_string());
}
}
"priority" => {
ticket.priority = parse_priority(trimmed);
}
_ => {}
}
}
fn parse_priority(s: &str) -> TicketPriority {
match s.to_lowercase().as_str() {
"critical" => TicketPriority::Critical,
"high" => TicketPriority::High,
"medium" => TicketPriority::Medium,
"low" => TicketPriority::Low,
_ => TicketPriority::Medium,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_markdown_ticket() {
let content = r#"
# Fix Authentication Bug
## Description
Users cannot login when using special characters.
## Affected Files
- `src/auth/login.rs`
- `src/auth/token.rs`
## Expected Behavior
Login should work with any valid password.
## Acceptance Criteria
- [ ] Special characters handled correctly
- [ ] No regression in existing tests
## Priority
High
"#;
let ticket =
parse_markdown_ticket(content, Path::new("PMAT-123.md")).expect("unexpected failure");
assert_eq!(ticket.id, "PMAT-123");
assert_eq!(ticket.title, "Fix Authentication Bug");
assert_eq!(ticket.affected_paths.len(), 2);
assert_eq!(ticket.priority, TicketPriority::High);
}
#[test]
fn test_ticket_priority_default() {
assert_eq!(TicketPriority::default(), TicketPriority::Medium);
}
#[test]
fn test_target_paths_empty() {
let ticket = PmatTicket {
id: "TEST".to_string(),
title: "Test".to_string(),
description: String::new(),
affected_paths: Vec::new(),
expected_behavior: None,
acceptance_criteria: Vec::new(),
priority: TicketPriority::Medium,
};
let paths = ticket.target_paths();
assert_eq!(paths, vec![PathBuf::from("src")]);
}
#[test]
fn test_target_paths_with_paths() {
let ticket = PmatTicket {
id: "TEST".to_string(),
title: "Test".to_string(),
description: String::new(),
affected_paths: vec![PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")],
expected_behavior: None,
acceptance_criteria: Vec::new(),
priority: TicketPriority::Medium,
};
let paths = ticket.target_paths();
assert_eq!(paths.len(), 2);
assert!(paths.contains(&PathBuf::from("src/lib.rs")));
}
#[test]
fn test_from_github_issue_with_pmat_prefix() {
let ticket = PmatTicket::from_github_issue("PMAT-1234").expect("unexpected failure");
assert_eq!(ticket.id, "PMAT-1234");
assert!(ticket.description.contains("GitHub"));
assert_eq!(ticket.priority, TicketPriority::Medium);
}
#[test]
fn test_from_github_issue_with_hash() {
let ticket = PmatTicket::from_github_issue("#5678").expect("unexpected failure");
assert_eq!(ticket.id, "PMAT-5678");
}
#[test]
fn test_from_github_issue_number_only() {
let ticket = PmatTicket::from_github_issue("42").expect("unexpected failure");
assert_eq!(ticket.id, "PMAT-42");
}
#[test]
fn test_from_github_issue_invalid() {
let result = PmatTicket::from_github_issue("invalid-ref");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid issue reference"));
}
#[test]
fn test_parse_markdown_summary_section() {
let content = r#"
# Test Ticket
## Summary
This is the summary text.
"#;
let ticket =
parse_markdown_ticket(content, Path::new("TEST.md")).expect("unexpected failure");
assert_eq!(ticket.description, "This is the summary text.");
}
#[test]
fn test_parse_markdown_files_section() {
let content = r#"
# Test
## Files
* src/foo.rs
* src/bar.rs
"#;
let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
assert_eq!(ticket.affected_paths.len(), 2);
}
#[test]
fn test_parse_markdown_paths_section() {
let content = r#"
# Test
## Paths
- lib/
"#;
let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
assert_eq!(ticket.affected_paths, vec![PathBuf::from("lib/")]);
}
#[test]
fn test_parse_markdown_scope_section() {
let content = r#"
# Test
## Scope
- module/
"#;
let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
assert_eq!(ticket.affected_paths, vec![PathBuf::from("module/")]);
}
#[test]
fn test_parse_markdown_expected_section() {
let content = r#"
# Test
## Expected
It should work correctly.
"#;
let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
assert_eq!(ticket.expected_behavior, Some("It should work correctly.".to_string()));
}
#[test]
fn test_parse_markdown_criteria_section() {
let content = r#"
# Test
## Criteria
- First criterion
- Second criterion
"#;
let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
assert_eq!(ticket.acceptance_criteria.len(), 2);
assert!(ticket.acceptance_criteria.contains(&"First criterion".to_string()));
}
#[test]
fn test_parse_markdown_priority_critical() {
let content = r#"
# Test
## Priority
Critical
"#;
let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
assert_eq!(ticket.priority, TicketPriority::Critical);
}
#[test]
fn test_parse_markdown_priority_low() {
let content = r#"
# Test
## Priority
Low
"#;
let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
assert_eq!(ticket.priority, TicketPriority::Low);
}
#[test]
fn test_parse_markdown_priority_medium() {
let content = r#"
# Test
## Priority
Medium
"#;
let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
assert_eq!(ticket.priority, TicketPriority::Medium);
}
#[test]
fn test_parse_markdown_priority_invalid() {
let content = r#"
# Test
## Priority
Unknown
"#;
let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
assert_eq!(ticket.priority, TicketPriority::Medium); }
#[test]
fn test_parse_markdown_no_title() {
let content = "Just some content without a title.";
let ticket = parse_markdown_ticket(content, Path::new("T.md")).expect("unexpected failure");
assert_eq!(ticket.title, "");
}
#[test]
fn test_ticket_serialization() {
let ticket = PmatTicket {
id: "PMAT-1".to_string(),
title: "Test".to_string(),
description: "Desc".to_string(),
affected_paths: vec![PathBuf::from("src/")],
expected_behavior: Some("Works".to_string()),
acceptance_criteria: vec!["Done".to_string()],
priority: TicketPriority::High,
};
let json = serde_json::to_string(&ticket).expect("json serialize failed");
let deserialized: PmatTicket =
serde_json::from_str(&json).expect("json deserialize failed");
assert_eq!(ticket.id, deserialized.id);
assert_eq!(ticket.priority, deserialized.priority);
}
#[test]
fn test_priority_equality() {
assert_eq!(TicketPriority::Critical, TicketPriority::Critical);
assert_ne!(TicketPriority::High, TicketPriority::Low);
}
#[test]
fn test_priority_copy() {
let p = TicketPriority::High;
let p2 = p;
assert_eq!(p, p2);
}
#[test]
fn test_parse_md_extension_routes_to_from_markdown() {
let result = PmatTicket::parse("nonexistent_ticket.md", Path::new("/tmp"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to read ticket"));
}
#[test]
fn test_parse_yaml_extension_routes_to_from_yaml() {
let result = PmatTicket::parse("nonexistent_ticket.yaml", Path::new("/tmp"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to read ticket"));
}
#[test]
fn test_parse_ticket_id_no_pmat_dir_falls_to_github() {
let result = PmatTicket::parse("42", Path::new("/tmp/nonexistent_project"));
assert!(result.is_ok());
let ticket = result.expect("operation failed");
assert_eq!(ticket.id, "PMAT-42");
}
#[test]
fn test_parse_ticket_id_invalid_falls_to_github_error() {
let result = PmatTicket::parse("not-a-number", Path::new("/tmp/nonexistent_project"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid issue reference"));
}
#[test]
fn test_parse_finds_md_ticket_in_pmat_dir() {
let tmp = std::env::temp_dir().join("batuta_test_parse_md");
let tickets_dir = tmp.join(".pmat/tickets");
let _ = fs::create_dir_all(&tickets_dir);
let md_content = "# Test Ticket\n\n## Description\n\nA test.\n";
let md_path = tickets_dir.join("PMAT-999.md");
fs::write(&md_path, md_content).expect("fs write failed");
let result = PmatTicket::parse("PMAT-999", &tmp);
assert!(result.is_ok());
let ticket = result.expect("operation failed");
assert_eq!(ticket.title, "Test Ticket");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_parse_finds_yaml_ticket_in_pmat_dir() {
let tmp = std::env::temp_dir().join("batuta_test_parse_yaml");
let tickets_dir = tmp.join(".pmat/tickets");
let _ = fs::create_dir_all(&tickets_dir);
let yaml_content = r#"id: "PMAT-888"
title: "YAML Ticket"
description: "From YAML"
affected_paths:
- "src/lib.rs"
expected_behavior: null
acceptance_criteria: []
priority: High
"#;
let yaml_path = tickets_dir.join("PMAT-888.yaml");
fs::write(&yaml_path, yaml_content).expect("fs write failed");
let result = PmatTicket::parse("PMAT-888", &tmp);
assert!(result.is_ok());
let ticket = result.expect("operation failed");
assert_eq!(ticket.title, "YAML Ticket");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_from_yaml_valid_file() {
let tmp = std::env::temp_dir().join("batuta_test_from_yaml_valid");
let _ = fs::create_dir_all(&tmp);
let yaml_content = r#"id: "TK-1"
title: "YAML Direct"
description: "Direct YAML parse"
affected_paths: []
expected_behavior: "Works"
acceptance_criteria:
- "Test passes"
priority: Low
"#;
let path = tmp.join("ticket.yaml");
fs::write(&path, yaml_content).expect("fs write failed");
let ticket = PmatTicket::from_yaml(&path).expect("unexpected failure");
assert_eq!(ticket.id, "TK-1");
assert_eq!(ticket.title, "YAML Direct");
assert_eq!(ticket.priority, TicketPriority::Low);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_from_yaml_invalid_content() {
let tmp = std::env::temp_dir().join("batuta_test_from_yaml_invalid");
let _ = fs::create_dir_all(&tmp);
let path = tmp.join("bad.yaml");
fs::write(&path, "not: valid: yaml: [[[").expect("fs write failed");
let result = PmatTicket::from_yaml(&path);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to parse YAML ticket"));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_from_yaml_nonexistent_file() {
let result = PmatTicket::from_yaml(Path::new("/tmp/does_not_exist_at_all.yaml"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to read ticket"));
}
#[test]
fn test_from_markdown_valid_file() {
let tmp = std::env::temp_dir().join("batuta_test_from_md_valid");
let _ = fs::create_dir_all(&tmp);
let md_content = "# MD Direct Test\n\n## Description\n\nA direct test.\n";
let path = tmp.join("DIRECT-1.md");
fs::write(&path, md_content).expect("fs write failed");
let ticket = PmatTicket::from_markdown(&path).expect("unexpected failure");
assert_eq!(ticket.id, "DIRECT-1");
assert_eq!(ticket.title, "MD Direct Test");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_from_markdown_nonexistent_file() {
let result = PmatTicket::from_markdown(Path::new("/tmp/does_not_exist_at_all.md"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to read ticket"));
}
#[test]
fn test_parse_yaml_extension_with_real_yaml_file() {
let tmp = std::env::temp_dir().join("batuta_test_parse_yaml_ext");
let _ = fs::create_dir_all(&tmp);
let yaml_content = r#"id: "EXT-1"
title: "Extension Test"
description: "Test yaml extension detection in parse()"
affected_paths: []
expected_behavior: null
acceptance_criteria: []
priority: Medium
"#;
let path = tmp.join("ticket.yaml");
fs::write(&path, yaml_content).expect("fs write failed");
let result = PmatTicket::parse(path.to_str().expect("path to_str failed"), &tmp);
assert!(result.is_ok());
assert_eq!(result.expect("operation failed").title, "Extension Test");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_parse_md_extension_with_real_md_file() {
let tmp = std::env::temp_dir().join("batuta_test_parse_md_ext");
let _ = fs::create_dir_all(&tmp);
let md_content = "# MD Extension Test\n\n## Description\n\nParse route to markdown.\n";
let path = tmp.join("ticket.md");
fs::write(&path, md_content).expect("fs write failed");
let result = PmatTicket::parse(path.to_str().expect("path to_str failed"), &tmp);
assert!(result.is_ok());
assert_eq!(result.expect("operation failed").title, "MD Extension Test");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_parse_markdown_no_file_stem() {
let content = "Just content";
let ticket = parse_markdown_ticket(content, Path::new("")).expect("unexpected failure");
assert_eq!(ticket.id, "UNKNOWN");
}
}