use std::path::Path;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use crate::evidence::manifest::RunOutcome;
use crate::model::{Cadence, LoadedRegistry, RunStatus};
use crate::registry::coverage::{self, PeriodCoverage, PeriodStatus};
use crate::registry::period;
use crate::registry::resolver;
use crate::risks::{self, EventData, RiskState, Severity, Status};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportData {
pub schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org: Option<String>,
pub period: ReportPeriod,
pub generated_on: NaiveDate,
pub controls: Vec<ControlActivity>,
pub totals: Totals,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub manifest_errors: Vec<ManifestError>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub overdue: Vec<OverdueControl>,
pub risks: RiskSummary,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub upcoming: Vec<UpcomingControl>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportPeriod {
pub label: String,
pub cadence: Cadence,
pub start: NaiveDate,
pub end: NaiveDate,
pub horizon: NaiveDate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlActivity {
pub id: String,
pub title: String,
pub owner: String,
pub cadence: Cadence,
pub periods: Vec<PeriodCoverage>,
pub counts: PeriodCounts,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runs: Vec<RunSummary>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct PeriodCounts {
pub satisfied: usize,
pub late: usize,
pub failed: usize,
pub gaps: usize,
pub open: usize,
pub skipped: usize,
pub future: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunSummary {
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 draft_risks: usize,
pub draft_issues: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub external_links: Vec<serde_json::Value>,
pub path: String,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct Totals {
pub controls: usize,
pub runs: usize,
pub satisfied: usize,
pub late: usize,
pub failed: usize,
pub gaps: usize,
pub open: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverdueControl {
pub id: String,
pub title: String,
pub owner: String,
pub next_due: NaiveDate,
pub days_overdue: i64,
pub last_status: RunStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpcomingControl {
pub id: String,
pub title: String,
pub owner: String,
pub cadence: Cadence,
pub next_due: NaiveDate,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RiskSummary {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub open: Vec<OpenRisk>,
pub opened_in_period: usize,
pub reopened_in_period: usize,
pub closed_in_period: usize,
pub past_sla: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lapsed_exceptions: Vec<LapsedException>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub register_errors: Vec<RegisterError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterError {
pub risk_id: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LapsedException {
pub risk_id: String,
pub title: String,
pub severity: Severity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
pub expired_on: NaiveDate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestError {
pub control_id: String,
pub path: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenRisk {
pub risk_id: String,
pub title: String,
pub severity: Severity,
pub status: Status,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due_at: Option<NaiveDate>,
pub past_sla: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_control: Option<String>,
}
pub fn assemble(
reg: &LoadedRegistry,
label: &str,
window_cadence: Cadence,
start: NaiveDate,
end: NaiveDate,
today: NaiveDate,
) -> anyhow::Result<ReportData> {
let mut controls: Vec<ControlActivity> = Vec::new();
let mut totals = Totals::default();
let walk = coverage::sealed_runs_by_control(®.root)?;
let mut runs_by_control = walk.runs;
let manifest_errors: Vec<ManifestError> = walk
.errors
.into_iter()
.map(|e| ManifestError {
control_id: e.control_id,
path: e
.path
.strip_prefix(®.root)
.unwrap_or(&e.path)
.to_string_lossy()
.into_owned(),
error: e.error,
})
.collect();
for control in reg.controls.values() {
let sealed = runs_by_control.remove(&control.id).unwrap_or_default();
let cov = if matches!(control.cadence, Cadence::Continuous) {
None
} else {
Some(coverage::coverage_over_runs(
reg,
&control.id,
&sealed,
start,
end,
today,
)?)
};
let periods = cov.map(|c| c.periods).unwrap_or_default();
let runs = runs_in_window(®.root, &sealed, control.cadence, &periods, start, end);
if periods.is_empty() && runs.is_empty() {
continue;
}
let mut counts = PeriodCounts::default();
for p in &periods {
match p.status {
PeriodStatus::Satisfied => {
counts.satisfied += 1;
if p.late {
counts.late += 1;
}
}
PeriodStatus::Failed => counts.failed += 1,
PeriodStatus::Gap => counts.gaps += 1,
PeriodStatus::Open => counts.open += 1,
PeriodStatus::Skipped => counts.skipped += 1,
PeriodStatus::Future => counts.future += 1,
}
}
totals.controls += 1;
totals.runs += runs.len();
totals.satisfied += counts.satisfied;
totals.late += counts.late;
totals.failed += counts.failed;
totals.gaps += counts.gaps;
totals.open += counts.open;
controls.push(ControlActivity {
id: control.id.clone(),
title: control.title.clone(),
owner: control.owner.clone(),
cadence: control.cadence,
periods,
counts,
runs,
});
}
let due_rows = resolver::due_rows(reg, today);
let horizon = upcoming_horizon(window_cadence, start, end, today);
let overdue = overdue_controls(reg, &due_rows, today);
let upcoming = upcoming_controls(reg, &due_rows, horizon);
let risks = risk_summary(®.root, start, end, today)?;
Ok(ReportData {
schema_version: crate::SCHEMA_VERSION,
org: reg.config.org.as_ref().and_then(|o| o.name.clone()),
period: ReportPeriod {
label: label.to_string(),
cadence: window_cadence,
start,
end,
horizon,
},
generated_on: today,
controls,
totals,
manifest_errors,
overdue,
risks,
upcoming,
})
}
fn runs_in_window(
root: &Path,
sealed: &[coverage::SealedRun],
cadence: Cadence,
periods: &[PeriodCoverage],
start: NaiveDate,
end: NaiveDate,
) -> Vec<RunSummary> {
let mut out: Vec<RunSummary> = Vec::new();
for run in sealed {
let manifest = &run.manifest;
let completed_in_window = {
let d = manifest.completed_at.date_naive();
d >= start && d <= end
};
let in_window = completed_in_window
|| match &manifest.period_id {
Some(pid) => {
periods.iter().any(|p| &p.period_id == pid)
|| period::bounds(cadence, pid)
.is_some_and(|(ps, pe)| ps <= end && pe >= start)
}
None => false,
};
if !in_window {
continue;
}
let rel = run
.path
.strip_prefix(root)
.unwrap_or(&run.path)
.to_string_lossy()
.into_owned();
out.push(RunSummary {
run_id: manifest.run_id.clone(),
period_id: manifest.period_id.clone(),
completed_at: manifest.completed_at,
status: manifest.status,
draft_risks: manifest.draft_risks.len(),
draft_issues: manifest.draft_issues.len(),
external_links: manifest.external_links.clone(),
path: rel,
});
}
out.sort_by_key(|r| r.completed_at);
out
}
fn overdue_controls(
reg: &LoadedRegistry,
due_rows: &[resolver::DueRow],
today: NaiveDate,
) -> Vec<OverdueControl> {
let mut out: Vec<OverdueControl> = Vec::new();
for row in due_rows {
if !row.overdue {
continue;
}
let Some(next_due) = row.next_due else {
continue;
};
let Some(control) = reg.controls.get(&row.control_id) else {
continue;
};
let last_status = reg
.state
.controls
.get(&row.control_id)
.map(|e| e.last_status)
.unwrap_or(RunStatus::NeverRun);
out.push(OverdueControl {
id: control.id.clone(),
title: control.title.clone(),
owner: control.owner.clone(),
next_due,
days_overdue: (today - next_due).num_days(),
last_status,
});
}
out.sort_by_key(|o| std::cmp::Reverse(o.days_overdue));
out
}
fn upcoming_horizon(
window_cadence: Cadence,
start: NaiveDate,
end: NaiveDate,
today: NaiveDate,
) -> NaiveDate {
let anchor = (end + chrono::Duration::days(1)).max(today);
period::derive(window_cadence, anchor)
.and_then(|pid| period::bounds(window_cadence, &pid))
.map(|(_, pe)| pe)
.unwrap_or(anchor + chrono::Duration::days((end - start).num_days()))
}
fn upcoming_controls(
reg: &LoadedRegistry,
due_rows: &[resolver::DueRow],
horizon: NaiveDate,
) -> Vec<UpcomingControl> {
let mut out: Vec<UpcomingControl> = Vec::new();
for row in due_rows {
if row.overdue {
continue;
}
let Some(next_due) = row.next_due else {
continue;
};
if next_due > horizon {
continue;
}
let Some(control) = reg.controls.get(&row.control_id) else {
continue;
};
out.push(UpcomingControl {
id: control.id.clone(),
title: control.title.clone(),
owner: control.owner.clone(),
cadence: control.cadence,
next_due,
});
}
out.sort_by(|a, b| a.next_due.cmp(&b.next_due).then(a.id.cmp(&b.id)));
out
}
fn risk_summary(
root: &Path,
start: NaiveDate,
end: NaiveDate,
today: NaiveDate,
) -> anyhow::Result<RiskSummary> {
let mut summary = RiskSummary::default();
let register = risks::load_register(root)?;
summary.register_errors = register
.errors
.into_iter()
.map(|(risk_id, error)| RegisterError {
risk_id,
error: error.replace(&format!("{}/", root.display()), ""),
})
.collect();
for (risk_id, events) in register.risks {
let mut opened = false;
let mut reopened = false;
let mut closing_event = false;
for ev in &events {
let d = ev.ts.date_naive();
if d < start || d > end {
continue;
}
match &ev.data {
EventData::Opened { .. } => opened = true,
EventData::Reopened { .. } => reopened = true,
EventData::Remediated { .. } | EventData::ExceptionDocumented { .. } => {
closing_event = true
}
EventData::StatusChanged { to, .. } => {
if to.is_closed() {
closing_event = true;
} else if matches!(to, Status::Reopened) {
reopened = true;
}
}
_ => {}
}
}
if opened {
summary.opened_in_period += 1;
}
if reopened {
summary.reopened_in_period += 1;
}
if closing_event {
let closed_at_end = risks::fold_at(&events, end).is_some_and(|s| s.status.is_closed());
if closed_at_end {
summary.closed_in_period += 1;
}
}
let state: RiskState = risks::fold(&events);
if state.status.is_open() {
let past_sla = state.due_at.is_some_and(|d| d < today);
if past_sla {
summary.past_sla += 1;
}
summary.open.push(OpenRisk {
risk_id,
title: state.title.clone(),
severity: state.severity,
status: state.status,
owner: state.owner.clone(),
due_at: state.due_at,
past_sla,
source_control: state.source_control().map(str::to_string),
});
} else if matches!(state.status, Status::AcceptedException) {
let expired_on = state.exception_expires_at.or(state.due_at);
if let Some(expired_on) = expired_on.filter(|d| *d < today) {
summary.lapsed_exceptions.push(LapsedException {
risk_id,
title: state.title.clone(),
severity: state.severity,
owner: state.owner.clone(),
expired_on,
});
}
}
}
summary
.open
.sort_by(|a, b| a.severity.cmp(&b.severity).then(a.risk_id.cmp(&b.risk_id)));
Ok(summary)
}