use std::collections::{HashMap, HashSet};
use serde::Serialize;
use datasynth_audit_fsm::schema::AuditBlueprint;
#[derive(Debug, Clone, Serialize)]
pub struct CoverageReport {
pub standards_coverage: f64,
pub standards_covered: Vec<String>,
pub standards_uncovered: Vec<String>,
pub risk_coverage: HashMap<String, f64>,
pub total_procedures: usize,
pub included_procedures: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ImpactReport {
pub removed_procedure: String,
pub standards_lost: Vec<String>,
pub standards_coverage_delta: f64,
pub risk_coverage_delta: HashMap<String, f64>,
pub dependent_procedures_affected: Vec<String>,
}
pub fn analyze_coverage(
blueprint: &AuditBlueprint,
included_procedures: &[String],
) -> CoverageReport {
let included_set: HashSet<&str> = included_procedures.iter().map(|s| s.as_str()).collect();
let all_proc_ids: Vec<&str> = blueprint
.phases
.iter()
.flat_map(|ph| ph.procedures.iter())
.map(|p| p.id.as_str())
.collect();
let total_procedures = all_proc_ids.len();
let included_count = all_proc_ids
.iter()
.filter(|id| included_set.contains(**id))
.count();
let mut total_standards: HashSet<String> = HashSet::new();
let mut covered_standards: HashSet<String> = HashSet::new();
for phase in &blueprint.phases {
for proc in &phase.procedures {
for step in &proc.steps {
for std_ref in &step.standards {
total_standards.insert(std_ref.ref_id.clone());
if included_set.contains(proc.id.as_str()) {
covered_standards.insert(std_ref.ref_id.clone());
}
}
}
}
}
let standards_coverage = if total_standards.is_empty() {
1.0
} else {
covered_standards.len() as f64 / total_standards.len() as f64
};
let mut standards_covered: Vec<String> = covered_standards.iter().cloned().collect();
standards_covered.sort();
let mut standards_uncovered: Vec<String> = total_standards
.difference(&covered_standards)
.cloned()
.collect();
standards_uncovered.sort();
let mut cat_total: HashMap<String, HashSet<String>> = HashMap::new();
let mut cat_included: HashMap<String, HashSet<String>> = HashMap::new();
for phase in &blueprint.phases {
for proc in &phase.procedures {
for (cat, vals) in &proc.discriminators {
let total_entry = cat_total.entry(cat.clone()).or_default();
let inc_entry = cat_included.entry(cat.clone()).or_default();
for v in vals {
total_entry.insert(v.clone());
if included_set.contains(proc.id.as_str()) {
inc_entry.insert(v.clone());
}
}
}
}
}
let mut risk_coverage: HashMap<String, f64> = HashMap::new();
for (cat, total_vals) in &cat_total {
let inc_vals = cat_included.get(cat).map(|s| s.len()).unwrap_or(0);
let frac = if total_vals.is_empty() {
1.0
} else {
inc_vals as f64 / total_vals.len() as f64
};
risk_coverage.insert(cat.clone(), frac);
}
CoverageReport {
standards_coverage,
standards_covered,
standards_uncovered,
risk_coverage,
total_procedures,
included_procedures: included_count,
}
}
pub fn impact_of_removing(
blueprint: &AuditBlueprint,
preconditions: &HashMap<String, Vec<String>>,
current_plan: &[String],
remove_procedure: &str,
) -> ImpactReport {
let before = analyze_coverage(blueprint, current_plan);
let after_plan: Vec<String> = current_plan
.iter()
.filter(|id| id.as_str() != remove_procedure)
.cloned()
.collect();
let after = analyze_coverage(blueprint, &after_plan);
let after_covered: HashSet<&str> = after.standards_covered.iter().map(|s| s.as_str()).collect();
let mut standards_lost: Vec<String> = before
.standards_covered
.iter()
.filter(|s| !after_covered.contains(s.as_str()))
.cloned()
.collect();
standards_lost.sort();
let standards_coverage_delta = after.standards_coverage - before.standards_coverage;
let mut risk_coverage_delta: HashMap<String, f64> = HashMap::new();
for (cat, before_val) in &before.risk_coverage {
let after_val = after.risk_coverage.get(cat).copied().unwrap_or(0.0);
risk_coverage_delta.insert(cat.clone(), after_val - before_val);
}
for (cat, after_val) in &after.risk_coverage {
risk_coverage_delta
.entry(cat.clone())
.or_insert_with(|| after_val - 0.0);
}
let mut dependent_procedures_affected: Vec<String> = preconditions
.iter()
.filter(|(proc_id, deps)| {
current_plan.contains(proc_id) && deps.iter().any(|d| d == remove_procedure)
})
.map(|(proc_id, _)| proc_id.clone())
.collect();
dependent_procedures_affected.sort();
ImpactReport {
removed_procedure: remove_procedure.to_string(),
standards_lost,
standards_coverage_delta,
risk_coverage_delta,
dependent_procedures_affected,
}
}
#[cfg(test)]
mod tests {
use super::*;
use datasynth_audit_fsm::loader::BlueprintWithPreconditions;
fn load_fsa() -> BlueprintWithPreconditions {
BlueprintWithPreconditions::load_builtin_fsa().expect("builtin FSA blueprint should load")
}
#[test]
fn test_full_scope_100_percent() {
let bwp = load_fsa();
let all_procs: Vec<String> = bwp
.blueprint
.phases
.iter()
.flat_map(|ph| ph.procedures.iter())
.map(|p| p.id.clone())
.collect();
let report = analyze_coverage(&bwp.blueprint, &all_procs);
assert!(
(report.standards_coverage - 1.0).abs() < f64::EPSILON,
"full scope should give 100% standards coverage, got {}",
report.standards_coverage
);
assert!(
report.standards_uncovered.is_empty(),
"full scope should have no uncovered standards"
);
assert_eq!(report.included_procedures, report.total_procedures);
}
#[test]
fn test_empty_scope_zero_percent() {
let bwp = load_fsa();
let report = analyze_coverage(&bwp.blueprint, &[]);
assert!(
report.standards_coverage.abs() < f64::EPSILON,
"empty scope should give 0% standards coverage, got {}",
report.standards_coverage
);
assert!(
report.standards_covered.is_empty(),
"empty scope should cover no standards"
);
assert_eq!(report.included_procedures, 0);
}
#[test]
fn test_partial_scope_coverage() {
let bwp = load_fsa();
let first_proc = bwp.blueprint.phases[0].procedures[0].id.clone();
let report = analyze_coverage(&bwp.blueprint, &[first_proc]);
assert!(
report.standards_coverage > 0.0,
"partial scope should have > 0% coverage"
);
assert!(
report.standards_coverage < 1.0,
"partial scope should have < 100% coverage"
);
assert_eq!(report.included_procedures, 1);
}
#[test]
fn test_removal_impact_reports_dependents() {
let bwp = load_fsa();
let all_procs: Vec<String> = bwp
.blueprint
.phases
.iter()
.flat_map(|ph| ph.procedures.iter())
.map(|p| p.id.clone())
.collect();
let impact = impact_of_removing(
&bwp.blueprint,
&bwp.preconditions,
&all_procs,
"substantive_testing",
);
assert_eq!(impact.removed_procedure, "substantive_testing");
assert!(
impact
.dependent_procedures_affected
.contains(&"going_concern".to_string()),
"going_concern depends on substantive_testing"
);
assert!(
impact
.dependent_procedures_affected
.contains(&"subsequent_events".to_string()),
"subsequent_events depends on substantive_testing"
);
}
#[test]
fn test_reports_serialize() {
let bwp = load_fsa();
let all_procs: Vec<String> = bwp
.blueprint
.phases
.iter()
.flat_map(|ph| ph.procedures.iter())
.map(|p| p.id.clone())
.collect();
let coverage = analyze_coverage(&bwp.blueprint, &all_procs);
let json = serde_json::to_string(&coverage).expect("CoverageReport should serialize");
assert!(json.contains("standards_coverage"));
assert!(json.contains("risk_coverage"));
let impact = impact_of_removing(
&bwp.blueprint,
&bwp.preconditions,
&all_procs,
"accept_engagement",
);
let json = serde_json::to_string(&impact).expect("ImpactReport should serialize");
assert!(json.contains("removed_procedure"));
assert!(json.contains("standards_lost"));
assert!(json.contains("dependent_procedures_affected"));
}
}