use std::collections::HashMap;
use std::fs;
use std::path::Path;
use chrono::{DateTime, Datelike, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use super::period;
use crate::evidence::manifest::{Manifest, RunOutcome};
use crate::model::{Cadence, Control, LoadedRegistry, Schedule};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRef {
pub run_id: String,
pub completed_at: DateTime<Utc>,
pub status: RunOutcome,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PeriodStatus {
Satisfied,
Failed,
Gap,
Skipped,
Future,
Open,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeriodCoverage {
pub period_id: String,
pub period_start: NaiveDate,
pub period_end: NaiveDate,
pub status: PeriodStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub satisfied_by: Option<RunRef>,
#[serde(default)]
pub late: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skipped_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnclassifiedRun {
pub run_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_id: Option<String>,
pub completed_at: DateTime<Utc>,
pub status: RunOutcome,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageReport {
pub control_id: String,
pub window_start: NaiveDate,
pub window_end: NaiveDate,
pub periods: Vec<PeriodCoverage>,
#[serde(default)]
pub unclassified_runs: Vec<UnclassifiedRun>,
}
pub fn expected_periods(
control: &Control,
schedule: &Schedule,
window_start: NaiveDate,
window_end: NaiveDate,
) -> Vec<(String, NaiveDate, NaiveDate)> {
if matches!(control.cadence, Cadence::Continuous) || window_start > window_end {
return Vec::new();
}
let mut out: Vec<(String, NaiveDate, NaiveDate)> = Vec::new();
let mut cursor = window_start;
while let Some(pid) = period::derive(control.cadence, cursor) {
let Some((start, end)) = period::bounds(control.cadence, &pid) else {
break;
};
if out.last().is_none_or(|(p, _, _)| p != &pid) {
out.push((pid, start, end));
}
let Some(next) = end.succ_opt() else { break };
if next > window_end {
break;
}
cursor = next;
}
out.into_iter()
.filter(|(_, start, _)| !is_skipped(control, schedule, *start).0)
.collect()
}
pub fn coverage(
reg: &LoadedRegistry,
control_id: &str,
window_start: NaiveDate,
window_end: NaiveDate,
today: NaiveDate,
) -> anyhow::Result<CoverageReport> {
let runs = sealed_runs_for_control(®.root, control_id)?;
coverage_over_runs(reg, control_id, &runs, window_start, window_end, today)
}
pub fn coverage_over_runs(
reg: &LoadedRegistry,
control_id: &str,
runs: &[SealedRun],
window_start: NaiveDate,
window_end: NaiveDate,
today: NaiveDate,
) -> anyhow::Result<CoverageReport> {
let control = reg
.controls
.get(control_id)
.ok_or_else(|| anyhow::anyhow!("control `{control_id}` not found"))?;
let expected = expected_periods(control, ®.schedule, window_start, window_end);
let mut by_period: HashMap<String, Vec<RunRef>> = HashMap::new();
let mut legacy: Vec<UnclassifiedRun> = Vec::new();
for r in runs {
let m = &r.manifest;
match &m.period_id {
Some(pid) => by_period.entry(pid.clone()).or_default().push(RunRef {
run_id: m.run_id.clone(),
completed_at: m.completed_at,
status: m.status,
}),
None => legacy.push(UnclassifiedRun {
run_id: m.run_id.clone(),
period_id: None,
completed_at: m.completed_at,
status: m.status,
reason: "legacy run sealed before period_id was introduced".into(),
}),
}
}
let expected_ids: std::collections::HashSet<&str> =
expected.iter().map(|(p, _, _)| p.as_str()).collect();
let mut periods: Vec<PeriodCoverage> = Vec::with_capacity(expected.len());
for (pid, start, end) in &expected {
let runs_here = by_period.get(pid);
let satisfier = runs_here.and_then(|rs| {
rs.iter()
.filter(|r| matches!(r.status, RunOutcome::Complete))
.min_by_key(|r| r.completed_at)
.cloned()
});
let failed = if satisfier.is_none() {
runs_here.and_then(|rs| {
rs.iter()
.filter(|r| matches!(r.status, RunOutcome::Failed))
.min_by_key(|r| r.completed_at)
.cloned()
})
} else {
None
};
let (status, late) = match (&satisfier, &failed) {
(Some(r), _) => (PeriodStatus::Satisfied, r.completed_at.date_naive() > *end),
(None, Some(_)) => (PeriodStatus::Failed, false),
(None, None) => {
if today < *start {
(PeriodStatus::Future, false)
} else if today >= *start && today <= *end {
(PeriodStatus::Open, false)
} else {
(PeriodStatus::Gap, false)
}
}
};
periods.push(PeriodCoverage {
period_id: pid.clone(),
period_start: *start,
period_end: *end,
status,
satisfied_by: satisfier,
late,
skipped_reason: None,
});
}
let mut cursor = window_start;
while cursor <= window_end {
if let Some(pid) = period::derive(control.cadence, cursor) {
if let Some((start, end)) = period::bounds(control.cadence, &pid) {
let (skipped, reason) = is_skipped(control, ®.schedule, start);
if skipped && !periods.iter().any(|p| p.period_id == pid) {
periods.push(PeriodCoverage {
period_id: pid.clone(),
period_start: start,
period_end: end,
status: PeriodStatus::Skipped,
satisfied_by: None,
late: false,
skipped_reason: reason,
});
}
cursor = end.succ_opt().unwrap_or(end);
if cursor <= end {
break;
}
continue;
}
}
break;
}
periods.sort_by_key(|p| p.period_start);
for (pid, rs) in by_period {
if !expected_ids.contains(pid.as_str()) {
for r in rs {
legacy.push(UnclassifiedRun {
run_id: r.run_id,
period_id: Some(pid.clone()),
completed_at: r.completed_at,
status: r.status,
reason: format!("claims period `{pid}` outside requested window"),
});
}
}
}
legacy.sort_by_key(|u| u.completed_at);
Ok(CoverageReport {
control_id: control_id.to_string(),
window_start,
window_end,
periods,
unclassified_runs: legacy,
})
}
fn is_skipped(
control: &Control,
schedule: &Schedule,
period_start: NaiveDate,
) -> (bool, Option<String>) {
for entry in schedule
.overrides
.iter()
.filter(|o| o.control_id == control.id)
{
let Some(skip) = &entry.skip else {
continue;
};
if let Some(q) = &skip.quarter {
let pq = format!(
"{:04}-q{}",
period_start.year(),
quarter_of_month(period_start.month())
);
if &pq == q {
return (true, skip.reason.clone().or_else(|| entry.reason.clone()));
}
}
if let Some(y) = skip.year {
if period_start.year() == y {
return (true, skip.reason.clone().or_else(|| entry.reason.clone()));
}
}
}
(false, None)
}
fn quarter_of_month(month: u32) -> u32 {
(month - 1) / 3 + 1
}
#[derive(Debug, Clone)]
pub struct SealedRun {
pub path: std::path::PathBuf,
pub manifest: Manifest,
}
pub fn sealed_runs_for_control(root: &Path, control_id: &str) -> anyhow::Result<Vec<SealedRun>> {
let mut out: Vec<SealedRun> = Vec::new();
let evidence = root.join("evidence");
if !evidence.is_dir() {
return Ok(out);
}
for year in dir_children(&evidence)? {
for quarter in dir_children(&year)? {
let ctrl_dir = quarter.join(control_id);
if !ctrl_dir.is_dir() {
continue;
}
collect_runs(&ctrl_dir, &mut out, &mut |_, _| {})?;
}
}
Ok(out)
}
#[derive(Debug, Clone)]
pub struct EvidenceError {
pub control_id: String,
pub path: std::path::PathBuf,
pub error: String,
}
#[derive(Debug, Clone, Default)]
pub struct EvidenceWalk {
pub runs: std::collections::BTreeMap<String, Vec<SealedRun>>,
pub errors: Vec<EvidenceError>,
}
pub fn sealed_runs_by_control(root: &Path) -> anyhow::Result<EvidenceWalk> {
let mut runs: std::collections::BTreeMap<String, Vec<SealedRun>> = Default::default();
let mut errors: Vec<EvidenceError> = Vec::new();
let evidence = root.join("evidence");
if evidence.is_dir() {
for year in dir_children(&evidence)? {
for quarter in dir_children(&year)? {
for ctrl_dir in dir_children(&quarter)? {
let Some(control_id) = ctrl_dir.file_name().and_then(|n| n.to_str()) else {
continue;
};
let control_id = control_id.to_string();
let bucket = runs.entry(control_id.clone()).or_default();
collect_runs(&ctrl_dir, bucket, &mut |path, error| {
errors.push(EvidenceError {
control_id: control_id.clone(),
path,
error,
})
})?;
}
}
}
}
Ok(EvidenceWalk { runs, errors })
}
fn collect_runs(
ctrl_dir: &Path,
out: &mut Vec<SealedRun>,
on_error: &mut dyn FnMut(std::path::PathBuf, String),
) -> anyhow::Result<()> {
for run in dir_children(ctrl_dir)? {
let mpath = run.join("manifest.json");
if !mpath.is_file() {
continue;
}
let bytes = match fs::read(&mpath) {
Ok(b) => b,
Err(e) => {
on_error(run, format!("read manifest.json: {e}"));
continue;
}
};
let manifest: Manifest = match serde_json::from_slice(&bytes) {
Ok(m) => m,
Err(e) => {
on_error(run, format!("parse manifest.json: {e}"));
continue;
}
};
out.push(SealedRun {
path: run,
manifest,
});
}
Ok(())
}
fn dir_children(p: &Path) -> anyhow::Result<Vec<std::path::PathBuf>> {
let mut v = Vec::new();
for entry in fs::read_dir(p)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
v.push(entry.path());
}
}
v.sort();
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Cadence, Control, Schedule, ScheduleEntry, ScheduleSkip};
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn make_control(id: &str, cadence: Cadence) -> Control {
Control {
id: id.into(),
title: "t".into(),
policy: "p".into(),
nist: Vec::new(),
owner: "o".into(),
cadence,
weekday: None,
due_by: None,
skill: "s".into(),
skill_args: None,
scope: None,
evidence_required: Vec::new(),
remediation_thresholds: Default::default(),
outputs: None,
references: Vec::new(),
}
}
fn weekly_control(id: &str) -> Control {
make_control(id, Cadence::Weekly)
}
fn quarterly_control(id: &str) -> Control {
make_control(id, Cadence::Quarterly)
}
fn continuous_control(id: &str) -> Control {
make_control(id, Cadence::Continuous)
}
#[test]
fn weekly_window_covers_iso_weeks_in_range() {
let c = weekly_control("c1");
let s = Schedule::default();
let got = expected_periods(&c, &s, d(2026, 4, 27), d(2026, 5, 17));
let ids: Vec<&str> = got.iter().map(|(p, _, _)| p.as_str()).collect();
assert_eq!(ids, ["2026-W18", "2026-W19", "2026-W20"]);
}
#[test]
fn quarterly_window_covers_quarters() {
let c = quarterly_control("c1");
let s = Schedule::default();
let got = expected_periods(&c, &s, d(2026, 1, 1), d(2026, 12, 31));
let ids: Vec<&str> = got.iter().map(|(p, _, _)| p.as_str()).collect();
assert_eq!(ids, ["2026-q1", "2026-q2", "2026-q3", "2026-q4"]);
}
#[test]
fn continuous_returns_no_periods() {
let c = continuous_control("c1");
let s = Schedule::default();
assert!(expected_periods(&c, &s, d(2026, 1, 1), d(2026, 12, 31)).is_empty());
}
#[test]
fn skip_quarter_directive_excludes_periods_in_that_quarter() {
let c = weekly_control("c1");
let s = Schedule {
overrides: vec![ScheduleEntry {
control_id: "c1".into(),
due: None,
weekday: None,
note: None,
reason: None,
skip: Some(ScheduleSkip {
quarter: Some("2026-q2".into()),
year: None,
reason: Some("audit prep".into()),
}),
insert: None,
}],
};
let got = expected_periods(&c, &s, d(2026, 3, 23), d(2026, 4, 12));
let ids: Vec<&str> = got.iter().map(|(p, _, _)| p.as_str()).collect();
assert!(ids.contains(&"2026-W13"));
assert!(!ids.contains(&"2026-W15"));
}
#[test]
fn empty_window_returns_empty() {
let c = weekly_control("c1");
let s = Schedule::default();
assert!(expected_periods(&c, &s, d(2026, 5, 10), d(2026, 5, 4)).is_empty());
}
}