1use std::path::Path;
10
11use chrono::{DateTime, NaiveDate, Utc};
12use serde::{Deserialize, Serialize};
13
14use crate::evidence::manifest::RunOutcome;
15use crate::model::{Cadence, LoadedRegistry, RunStatus};
16use crate::registry::coverage::{self, PeriodCoverage, PeriodStatus};
17use crate::registry::period;
18use crate::registry::resolver;
19use crate::risks::{self, EventData, RiskState, Severity, Status};
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ReportData {
25 pub schema_version: u32,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub org: Option<String>,
29 pub period: ReportPeriod,
30 pub generated_on: NaiveDate,
33 pub controls: Vec<ControlActivity>,
34 pub totals: Totals,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub manifest_errors: Vec<ManifestError>,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub overdue: Vec<OverdueControl>,
52 pub risks: RiskSummary,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub upcoming: Vec<UpcomingControl>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ReportPeriod {
62 pub label: String,
64 pub cadence: Cadence,
66 pub start: NaiveDate,
67 pub end: NaiveDate,
68 pub horizon: NaiveDate,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ControlActivity {
77 pub id: String,
78 pub title: String,
79 pub owner: String,
80 pub cadence: Cadence,
81 pub periods: Vec<PeriodCoverage>,
85 pub counts: PeriodCounts,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub runs: Vec<RunSummary>,
89}
90
91#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
92pub struct PeriodCounts {
93 pub satisfied: usize,
94 pub late: usize,
96 pub failed: usize,
97 pub gaps: usize,
98 pub open: usize,
99 pub skipped: usize,
100 pub future: usize,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct RunSummary {
105 pub run_id: String,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub period_id: Option<String>,
108 pub completed_at: DateTime<Utc>,
109 pub status: RunOutcome,
110 pub draft_risks: usize,
111 pub draft_issues: usize,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub external_links: Vec<serde_json::Value>,
114 pub path: String,
116}
117
118#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
119pub struct Totals {
120 pub controls: usize,
121 pub runs: usize,
129 pub satisfied: usize,
130 pub late: usize,
131 pub failed: usize,
132 pub gaps: usize,
133 pub open: usize,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct OverdueControl {
138 pub id: String,
139 pub title: String,
140 pub owner: String,
141 pub next_due: NaiveDate,
142 pub days_overdue: i64,
143 pub last_status: RunStatus,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct UpcomingControl {
148 pub id: String,
149 pub title: String,
150 pub owner: String,
151 pub cadence: Cadence,
152 pub next_due: NaiveDate,
153}
154
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct RiskSummary {
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub open: Vec<OpenRisk>,
160 pub opened_in_period: usize,
162 pub reopened_in_period: usize,
165 pub closed_in_period: usize,
169 pub past_sla: usize,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub lapsed_exceptions: Vec<LapsedException>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 pub register_errors: Vec<RegisterError>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct RegisterError {
188 pub risk_id: String,
189 pub error: String,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct LapsedException {
194 pub risk_id: String,
195 pub title: String,
196 pub severity: Severity,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub owner: Option<String>,
199 pub expired_on: NaiveDate,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ManifestError {
204 pub control_id: String,
205 pub path: String,
207 pub error: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct OpenRisk {
212 pub risk_id: String,
213 pub title: String,
214 pub severity: Severity,
215 pub status: Status,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub owner: Option<String>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub due_at: Option<NaiveDate>,
220 pub past_sla: bool,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub source_control: Option<String>,
223}
224
225pub fn assemble(
234 reg: &LoadedRegistry,
235 label: &str,
236 window_cadence: Cadence,
237 start: NaiveDate,
238 end: NaiveDate,
239 today: NaiveDate,
240) -> anyhow::Result<ReportData> {
241 let mut controls: Vec<ControlActivity> = Vec::new();
242 let mut totals = Totals::default();
243
244 let walk = coverage::sealed_runs_by_control(®.root)?;
249 let mut runs_by_control = walk.runs;
250 let manifest_errors: Vec<ManifestError> = walk
251 .errors
252 .into_iter()
253 .map(|e| ManifestError {
254 control_id: e.control_id,
255 path: e
256 .path
257 .strip_prefix(®.root)
258 .unwrap_or(&e.path)
259 .to_string_lossy()
260 .into_owned(),
261 error: e.error,
262 })
263 .collect();
264
265 for control in reg.controls.values() {
266 let sealed = runs_by_control.remove(&control.id).unwrap_or_default();
267 let cov = if matches!(control.cadence, Cadence::Continuous) {
268 None
269 } else {
270 Some(coverage::coverage_over_runs(
271 reg,
272 &control.id,
273 &sealed,
274 start,
275 end,
276 today,
277 )?)
278 };
279 let periods = cov.map(|c| c.periods).unwrap_or_default();
280 let runs = runs_in_window(®.root, &sealed, control.cadence, &periods, start, end);
281
282 if periods.is_empty() && runs.is_empty() {
285 continue;
286 }
287
288 let mut counts = PeriodCounts::default();
289 for p in &periods {
290 match p.status {
291 PeriodStatus::Satisfied => {
292 counts.satisfied += 1;
293 if p.late {
294 counts.late += 1;
295 }
296 }
297 PeriodStatus::Failed => counts.failed += 1,
298 PeriodStatus::Gap => counts.gaps += 1,
299 PeriodStatus::Open => counts.open += 1,
300 PeriodStatus::Skipped => counts.skipped += 1,
301 PeriodStatus::Future => counts.future += 1,
302 }
303 }
304 totals.controls += 1;
305 totals.runs += runs.len();
306 totals.satisfied += counts.satisfied;
307 totals.late += counts.late;
308 totals.failed += counts.failed;
309 totals.gaps += counts.gaps;
310 totals.open += counts.open;
311
312 controls.push(ControlActivity {
313 id: control.id.clone(),
314 title: control.title.clone(),
315 owner: control.owner.clone(),
316 cadence: control.cadence,
317 periods,
318 counts,
319 runs,
320 });
321 }
322
323 let due_rows = resolver::due_rows(reg, today);
325 let horizon = upcoming_horizon(window_cadence, start, end, today);
326 let overdue = overdue_controls(reg, &due_rows, today);
327 let upcoming = upcoming_controls(reg, &due_rows, horizon);
328 let risks = risk_summary(®.root, start, end, today)?;
329
330 Ok(ReportData {
331 schema_version: crate::SCHEMA_VERSION,
332 org: reg.config.org.as_ref().and_then(|o| o.name.clone()),
333 period: ReportPeriod {
334 label: label.to_string(),
335 cadence: window_cadence,
336 start,
337 end,
338 horizon,
339 },
340 generated_on: today,
341 controls,
342 totals,
343 manifest_errors,
344 overdue,
345 risks,
346 upcoming,
347 })
348}
349
350fn runs_in_window(
356 root: &Path,
357 sealed: &[coverage::SealedRun],
358 cadence: Cadence,
359 periods: &[PeriodCoverage],
360 start: NaiveDate,
361 end: NaiveDate,
362) -> Vec<RunSummary> {
363 let mut out: Vec<RunSummary> = Vec::new();
364 for run in sealed {
365 let manifest = &run.manifest;
366 let completed_in_window = {
367 let d = manifest.completed_at.date_naive();
368 d >= start && d <= end
369 };
370 let in_window = completed_in_window
371 || match &manifest.period_id {
372 Some(pid) => {
373 periods.iter().any(|p| &p.period_id == pid)
374 || period::bounds(cadence, pid)
375 .is_some_and(|(ps, pe)| ps <= end && pe >= start)
376 }
377 None => false,
378 };
379 if !in_window {
380 continue;
381 }
382 let rel = run
383 .path
384 .strip_prefix(root)
385 .unwrap_or(&run.path)
386 .to_string_lossy()
387 .into_owned();
388 out.push(RunSummary {
389 run_id: manifest.run_id.clone(),
390 period_id: manifest.period_id.clone(),
391 completed_at: manifest.completed_at,
392 status: manifest.status,
393 draft_risks: manifest.draft_risks.len(),
394 draft_issues: manifest.draft_issues.len(),
395 external_links: manifest.external_links.clone(),
396 path: rel,
397 });
398 }
399 out.sort_by_key(|r| r.completed_at);
400 out
401}
402
403fn overdue_controls(
407 reg: &LoadedRegistry,
408 due_rows: &[resolver::DueRow],
409 today: NaiveDate,
410) -> Vec<OverdueControl> {
411 let mut out: Vec<OverdueControl> = Vec::new();
412 for row in due_rows {
413 if !row.overdue {
414 continue;
415 }
416 let Some(next_due) = row.next_due else {
417 continue;
418 };
419 let Some(control) = reg.controls.get(&row.control_id) else {
420 continue;
421 };
422 let last_status = reg
423 .state
424 .controls
425 .get(&row.control_id)
426 .map(|e| e.last_status)
427 .unwrap_or(RunStatus::NeverRun);
428 out.push(OverdueControl {
429 id: control.id.clone(),
430 title: control.title.clone(),
431 owner: control.owner.clone(),
432 next_due,
433 days_overdue: (today - next_due).num_days(),
434 last_status,
435 });
436 }
437 out.sort_by_key(|o| std::cmp::Reverse(o.days_overdue));
438 out
439}
440
441fn upcoming_horizon(
446 window_cadence: Cadence,
447 start: NaiveDate,
448 end: NaiveDate,
449 today: NaiveDate,
450) -> NaiveDate {
451 let anchor = (end + chrono::Duration::days(1)).max(today);
452 period::derive(window_cadence, anchor)
453 .and_then(|pid| period::bounds(window_cadence, &pid))
454 .map(|(_, pe)| pe)
455 .unwrap_or(anchor + chrono::Duration::days((end - start).num_days()))
457}
458
459fn upcoming_controls(
464 reg: &LoadedRegistry,
465 due_rows: &[resolver::DueRow],
466 horizon: NaiveDate,
467) -> Vec<UpcomingControl> {
468 let mut out: Vec<UpcomingControl> = Vec::new();
469 for row in due_rows {
470 if row.overdue {
471 continue;
472 }
473 let Some(next_due) = row.next_due else {
474 continue;
475 };
476 if next_due > horizon {
477 continue;
478 }
479 let Some(control) = reg.controls.get(&row.control_id) else {
480 continue;
481 };
482 out.push(UpcomingControl {
483 id: control.id.clone(),
484 title: control.title.clone(),
485 owner: control.owner.clone(),
486 cadence: control.cadence,
487 next_due,
488 });
489 }
490 out.sort_by(|a, b| a.next_due.cmp(&b.next_due).then(a.id.cmp(&b.id)));
491 out
492}
493
494fn risk_summary(
501 root: &Path,
502 start: NaiveDate,
503 end: NaiveDate,
504 today: NaiveDate,
505) -> anyhow::Result<RiskSummary> {
506 let mut summary = RiskSummary::default();
507 let register = risks::load_register(root)?;
513 summary.register_errors = register
514 .errors
515 .into_iter()
516 .map(|(risk_id, error)| RegisterError {
517 risk_id,
518 error: error.replace(&format!("{}/", root.display()), ""),
519 })
520 .collect();
521 for (risk_id, events) in register.risks {
522 let mut opened = false;
527 let mut reopened = false;
528 let mut closing_event = false;
529 for ev in &events {
530 let d = ev.ts.date_naive();
531 if d < start || d > end {
532 continue;
533 }
534 match &ev.data {
535 EventData::Opened { .. } => opened = true,
536 EventData::Reopened { .. } => reopened = true,
537 EventData::Remediated { .. } | EventData::ExceptionDocumented { .. } => {
538 closing_event = true
539 }
540 EventData::StatusChanged { to, .. } => {
541 if to.is_closed() {
542 closing_event = true;
543 } else if matches!(to, Status::Reopened) {
544 reopened = true;
545 }
546 }
547 _ => {}
548 }
549 }
550 if opened {
551 summary.opened_in_period += 1;
552 }
553 if reopened {
554 summary.reopened_in_period += 1;
555 }
556 if closing_event {
557 let closed_at_end = risks::fold_at(&events, end).is_some_and(|s| s.status.is_closed());
558 if closed_at_end {
559 summary.closed_in_period += 1;
560 }
561 }
562
563 let state: RiskState = risks::fold(&events);
564 if state.status.is_open() {
565 let past_sla = state.due_at.is_some_and(|d| d < today);
566 if past_sla {
567 summary.past_sla += 1;
568 }
569 summary.open.push(OpenRisk {
570 risk_id,
571 title: state.title.clone(),
572 severity: state.severity,
573 status: state.status,
574 owner: state.owner.clone(),
575 due_at: state.due_at,
576 past_sla,
577 source_control: state.source_control().map(str::to_string),
578 });
579 } else if matches!(state.status, Status::AcceptedException) {
580 let expired_on = state.exception_expires_at.or(state.due_at);
584 if let Some(expired_on) = expired_on.filter(|d| *d < today) {
585 summary.lapsed_exceptions.push(LapsedException {
586 risk_id,
587 title: state.title.clone(),
588 severity: state.severity,
589 owner: state.owner.clone(),
590 expired_on,
591 });
592 }
593 }
594 }
595 summary
596 .open
597 .sort_by(|a, b| a.severity.cmp(&b.severity).then(a.risk_id.cmp(&b.risk_id)));
598 Ok(summary)
599}