#![cfg_attr(coverage_nightly, coverage(off))]
use super::roadmap::Roadmap;
use super::ticket::TicketFile;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ValidationReport {
pub timestamp: String,
pub project_name: String,
pub error_count: usize,
pub warning_count: usize,
pub missing_tickets: Vec<MissingTicket>,
pub broken_dependencies: Vec<BrokenDependency>,
pub orphaned_tickets: Vec<String>,
pub status_mismatches: Vec<StatusMismatch>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MissingTicket {
pub ticket_id: String,
pub sprint_number: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BrokenDependency {
pub ticket_id: String,
pub dependency_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusMismatch {
pub ticket_id: String,
pub ticket_status: String,
pub roadmap_completed: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ValidatorError {
#[error("Roadmap error: {0}")]
RoadmapError(#[from] super::roadmap::RoadmapError),
#[error("Ticket error: {0}")]
TicketError(#[from] super::ticket::TicketError),
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, ValidatorError>;
impl ValidationReport {
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn new(project_name: String) -> Self {
Self {
timestamp: chrono::Utc::now().to_rfc3339(),
project_name,
error_count: 0,
warning_count: 0,
missing_tickets: Vec::new(),
broken_dependencies: Vec::new(),
orphaned_tickets: Vec::new(),
status_mismatches: Vec::new(),
}
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn is_valid(&self) -> bool {
self.error_count == 0
}
fn update_counts(&mut self) {
self.error_count = self.missing_tickets.len() + self.broken_dependencies.len();
self.warning_count = self.orphaned_tickets.len() + self.status_mismatches.len();
}
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub fn validate_project(roadmap_path: &Path, tickets_dir: &Path) -> Result<ValidationReport> {
use std::collections::HashMap;
let roadmap = Roadmap::from_file(roadmap_path)?;
let ticket_files = super::ticket::list_tickets(tickets_dir)?;
let ticket_map: HashMap<_, _> = ticket_files.iter().map(|t| (t.id.clone(), t)).collect();
let mut report = ValidationReport::new("PMAT".to_string());
validate_roadmap_tickets(&roadmap, &ticket_map, &mut report);
validate_ticket_dependencies(&roadmap, &ticket_files, &ticket_map, &mut report);
report.update_counts();
Ok(report)
}
fn validate_roadmap_tickets(
roadmap: &Roadmap,
ticket_map: &std::collections::HashMap<String, &super::ticket::TicketFile>,
report: &mut ValidationReport,
) {
for sprint in &roadmap.sprints {
for ticket in &sprint.tickets {
if !ticket_map.contains_key(&ticket.id) {
report.missing_tickets.push(MissingTicket {
ticket_id: ticket.id.clone(),
sprint_number: sprint.number,
});
} else {
let ticket_file = ticket_map.get(&ticket.id).expect("internal error");
if !status_matches(ticket_file, ticket.completed) {
report.status_mismatches.push(StatusMismatch {
ticket_id: ticket.id.clone(),
ticket_status: format!("{:?}", ticket_file.status),
roadmap_completed: ticket.completed,
});
}
}
}
}
}
fn validate_ticket_dependencies(
roadmap: &Roadmap,
ticket_files: &[super::ticket::TicketFile],
ticket_map: &std::collections::HashMap<String, &super::ticket::TicketFile>,
report: &mut ValidationReport,
) {
use std::collections::HashSet;
let roadmap_ticket_ids: HashSet<_> = roadmap
.sprints
.iter()
.flat_map(|s| s.tickets.iter().map(|t| &t.id))
.collect();
for ticket_file in ticket_files {
if !roadmap_ticket_ids.contains(&ticket_file.id) {
report.orphaned_tickets.push(ticket_file.id.clone());
}
for dep in &ticket_file.dependencies {
if !ticket_map.contains_key(dep) {
report.broken_dependencies.push(BrokenDependency {
ticket_id: ticket_file.id.clone(),
dependency_id: dep.clone(),
});
}
}
}
}
fn status_matches(ticket_file: &TicketFile, roadmap_completed: bool) -> bool {
use super::ticket::TicketStatus;
if roadmap_completed {
matches!(
ticket_file.status,
TicketStatus::Green | TicketStatus::Complete
)
} else {
true
}
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn format_report(report: &ValidationReport) -> String {
let mut output = String::new();
output.push_str(&format!("# Validation Report: {}\n\n", report.project_name));
output.push_str(&format!("**Timestamp**: {}\n\n", report.timestamp));
if report.is_valid() {
output.push_str("✅ **Status**: VALID - All checks passed!\n\n");
} else {
output.push_str(&format!(
"❌ **Status**: INVALID - {} errors, {} warnings\n\n",
report.error_count, report.warning_count
));
}
if !report.missing_tickets.is_empty() {
output.push_str("## ❌ Missing Ticket Files\n\n");
for missing in &report.missing_tickets {
output.push_str(&format!(
"- `{}` (Sprint {})\n",
missing.ticket_id, missing.sprint_number
));
}
output.push('\n');
}
if !report.broken_dependencies.is_empty() {
output.push_str("## ❌ Broken Dependencies\n\n");
for broken in &report.broken_dependencies {
output.push_str(&format!(
"- `{}` depends on missing `{}`\n",
broken.ticket_id, broken.dependency_id
));
}
output.push('\n');
}
if !report.orphaned_tickets.is_empty() {
output.push_str("## ⚠️ Orphaned Tickets (not in roadmap)\n\n");
for orphaned in &report.orphaned_tickets {
output.push_str(&format!("- `{}`\n", orphaned));
}
output.push('\n');
}
if !report.status_mismatches.is_empty() {
output.push_str("## ⚠️ Status Mismatches\n\n");
for mismatch in &report.status_mismatches {
output.push_str(&format!(
"- `{}`: ticket={}, roadmap_complete={}\n",
mismatch.ticket_id, mismatch.ticket_status, mismatch.roadmap_completed
));
}
output.push('\n');
}
output
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_validation_report_creation() {
let report = ValidationReport::new("Test".to_string());
assert_eq!(report.project_name, "Test");
assert_eq!(report.error_count, 0);
assert!(report.is_valid());
}
#[test]
fn test_validation_report_with_errors() {
let mut report = ValidationReport::new("Test".to_string());
report.missing_tickets.push(MissingTicket {
ticket_id: "TICKET-PMAT-9999".into(),
sprint_number: 99,
});
report.update_counts();
assert_eq!(report.error_count, 1);
assert!(!report.is_valid());
}
#[test]
fn test_status_matches_completed() {
use super::super::ticket::{Priority, TicketFile, TicketStatus};
let ticket = TicketFile {
id: "TICKET-PMAT-0001".into(),
title: "Test".into(),
status: TicketStatus::Green,
priority: Priority::P0,
complexity: 5,
estimated_time: "1h".into(),
dependencies: vec![],
sprint: "Sprint 1".into(),
objective: "Test".into(),
success_criteria: vec!["Test".into()],
file_path: PathBuf::new(),
};
assert!(status_matches(&ticket, true));
}
#[test]
fn test_status_matches_incomplete() {
use super::super::ticket::{Priority, TicketFile, TicketStatus};
let ticket = TicketFile {
id: "TICKET-PMAT-0001".into(),
title: "Test".into(),
status: TicketStatus::Red,
priority: Priority::P0,
complexity: 5,
estimated_time: "1h".into(),
dependencies: vec![],
sprint: "Sprint 1".into(),
objective: "Test".into(),
success_criteria: vec!["Test".into()],
file_path: PathBuf::new(),
};
assert!(status_matches(&ticket, false));
}
#[test]
fn integration_validate_pmat_project() {
let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let roadmap_path = project_root.join("ROADMAP.md");
let tickets_dir = project_root.join("docs/tickets");
if !roadmap_path.exists() || !tickets_dir.exists() {
eprintln!("Skipping: ROADMAP.md or docs/tickets not found");
return;
}
let report = validate_project(&roadmap_path, &tickets_dir).expect("internal error");
println!("Validation report:\n{}", format_report(&report));
if report.error_count > 0 {
println!("Errors found: {}", report.error_count);
println!("Missing tickets: {:?}", report.missing_tickets);
println!("Broken dependencies: {:?}", report.broken_dependencies);
}
}
#[test]
fn test_format_report_valid() {
let report = ValidationReport::new("Test".to_string());
let formatted = format_report(&report);
assert!(formatted.contains("VALID"));
assert!(formatted.contains("All checks passed"));
}
#[test]
fn test_format_report_with_issues() {
let mut report = ValidationReport::new("Test".to_string());
report.missing_tickets.push(MissingTicket {
ticket_id: "TICKET-PMAT-9999".into(),
sprint_number: 99,
});
report.update_counts();
let formatted = format_report(&report);
assert!(formatted.contains("INVALID"));
assert!(formatted.contains("Missing Ticket Files"));
assert!(formatted.contains("TICKET-PMAT-9999"));
}
}