Skip to main content

secunit_core/registry/
coverage.rs

1//! Coverage reports tying expected periods to the runs that satisfied them.
2//!
3//! Two halves: [`expected_periods`] enumerates the periods a control owes
4//! over a window (respecting `schedule.yaml` skip directives), and
5//! [`coverage`] walks `evidence/<control>/` to find the complete runs that
6//! claimed each period. The resulting [`CoverageReport`] is the
7//! auditor-shaped answer: every period is `Satisfied`, `Gap`, `Skipped`, or
8//! `Future`, and any unclaimed/legacy evidence is surfaced separately.
9
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14use chrono::{DateTime, Datelike, NaiveDate, Utc};
15use serde::{Deserialize, Serialize};
16
17use super::period;
18use crate::evidence::manifest::{Manifest, RunOutcome};
19use crate::model::{Cadence, Control, LoadedRegistry, Schedule};
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct RunRef {
23    pub run_id: String,
24    pub completed_at: DateTime<Utc>,
25    pub status: RunOutcome,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "kebab-case")]
30pub enum PeriodStatus {
31    /// At least one `complete` run claims this period.
32    Satisfied,
33    /// A `failed` run sealed in this period and no `complete` run
34    /// supersedes it. Terminal: the activity ran to a verdict, the
35    /// verdict was negative, and remediation moves to findings rather
36    /// than a retry of this period.
37    Failed,
38    /// Period has ended without a satisfying run.
39    Gap,
40    /// `schedule.yaml` skip directive removed this period.
41    Skipped,
42    /// Period hasn't started yet relative to `today`.
43    Future,
44    /// Period is open: it's in progress and not yet satisfied.
45    Open,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct PeriodCoverage {
50    pub period_id: String,
51    pub period_start: NaiveDate,
52    pub period_end: NaiveDate,
53    pub status: PeriodStatus,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub satisfied_by: Option<RunRef>,
56    /// Set when `satisfied_by.completed_at` falls past `period_end`.
57    #[serde(default)]
58    pub late: bool,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub skipped_reason: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct UnclassifiedRun {
65    pub run_id: String,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub period_id: Option<String>,
68    pub completed_at: DateTime<Utc>,
69    pub status: RunOutcome,
70    /// Why this run isn't bucketed into the report's expected periods —
71    /// e.g. legacy run with no `period_id`, or claims a period outside
72    /// the requested window.
73    pub reason: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct CoverageReport {
78    pub control_id: String,
79    pub window_start: NaiveDate,
80    pub window_end: NaiveDate,
81    pub periods: Vec<PeriodCoverage>,
82    #[serde(default)]
83    pub unclassified_runs: Vec<UnclassifiedRun>,
84}
85
86/// Periods a control is expected to cover within `[window_start, window_end]`.
87///
88/// Returns `(period_id, period_start, period_end)` in chronological order,
89/// excluding any periods removed by `schedule.yaml` skip directives.
90/// Continuous cadence has no periods.
91pub fn expected_periods(
92    control: &Control,
93    schedule: &Schedule,
94    window_start: NaiveDate,
95    window_end: NaiveDate,
96) -> Vec<(String, NaiveDate, NaiveDate)> {
97    if matches!(control.cadence, Cadence::Continuous) || window_start > window_end {
98        return Vec::new();
99    }
100    let mut out: Vec<(String, NaiveDate, NaiveDate)> = Vec::new();
101    let mut cursor = window_start;
102    while let Some(pid) = period::derive(control.cadence, cursor) {
103        let Some((start, end)) = period::bounds(control.cadence, &pid) else {
104            break;
105        };
106        // Defensive guard against pathological bounds that don't move
107        // forward (would otherwise infinite-loop).
108        if out.last().is_none_or(|(p, _, _)| p != &pid) {
109            out.push((pid, start, end));
110        }
111        let Some(next) = end.succ_opt() else { break };
112        if next > window_end {
113            break;
114        }
115        cursor = next;
116    }
117    out.into_iter()
118        .filter(|(_, start, _)| !is_skipped(control, schedule, *start).0)
119        .collect()
120}
121
122/// Build a coverage report for `control_id` over `[window_start, window_end]`.
123///
124/// `today` separates `Future` from `Open`/`Gap` — periods starting after
125/// today are `Future`; current/past periods are `Open` (current,
126/// unsatisfied) or `Gap` (past, unsatisfied) when no run claims them.
127pub fn coverage(
128    reg: &LoadedRegistry,
129    control_id: &str,
130    window_start: NaiveDate,
131    window_end: NaiveDate,
132    today: NaiveDate,
133) -> anyhow::Result<CoverageReport> {
134    let runs = sealed_runs_for_control(&reg.root, control_id)?;
135    coverage_over_runs(reg, control_id, &runs, window_start, window_end, today)
136}
137
138/// [`coverage`] over runs the caller already walked — so a caller that
139/// also needs the manifests (report assembly) touches the evidence tree
140/// once instead of twice.
141pub fn coverage_over_runs(
142    reg: &LoadedRegistry,
143    control_id: &str,
144    runs: &[SealedRun],
145    window_start: NaiveDate,
146    window_end: NaiveDate,
147    today: NaiveDate,
148) -> anyhow::Result<CoverageReport> {
149    let control = reg
150        .controls
151        .get(control_id)
152        .ok_or_else(|| anyhow::anyhow!("control `{control_id}` not found"))?;
153
154    let expected = expected_periods(control, &reg.schedule, window_start, window_end);
155
156    // Bucket runs by claimed period_id. Multiple runs claiming the same
157    // period coexist; we pick the earliest `complete` run as the
158    // satisfier, and surface the rest as additional context if needed
159    // (PR-2 keeps it minimal — first complete wins).
160    let mut by_period: HashMap<String, Vec<RunRef>> = HashMap::new();
161    let mut legacy: Vec<UnclassifiedRun> = Vec::new();
162    for r in runs {
163        let m = &r.manifest;
164        match &m.period_id {
165            Some(pid) => by_period.entry(pid.clone()).or_default().push(RunRef {
166                run_id: m.run_id.clone(),
167                completed_at: m.completed_at,
168                status: m.status,
169            }),
170            None => legacy.push(UnclassifiedRun {
171                run_id: m.run_id.clone(),
172                period_id: None,
173                completed_at: m.completed_at,
174                status: m.status,
175                reason: "legacy run sealed before period_id was introduced".into(),
176            }),
177        }
178    }
179
180    let expected_ids: std::collections::HashSet<&str> =
181        expected.iter().map(|(p, _, _)| p.as_str()).collect();
182    let mut periods: Vec<PeriodCoverage> = Vec::with_capacity(expected.len());
183    for (pid, start, end) in &expected {
184        let runs_here = by_period.get(pid);
185        let satisfier = runs_here.and_then(|rs| {
186            rs.iter()
187                .filter(|r| matches!(r.status, RunOutcome::Complete))
188                .min_by_key(|r| r.completed_at)
189                .cloned()
190        });
191        // No complete run, but a sealed failed run is a terminal verdict
192        // for the period — Failed, not Open/Gap. Partial runs stay
193        // non-terminal: the operator is expected to follow up.
194        let failed = if satisfier.is_none() {
195            runs_here.and_then(|rs| {
196                rs.iter()
197                    .filter(|r| matches!(r.status, RunOutcome::Failed))
198                    .min_by_key(|r| r.completed_at)
199                    .cloned()
200            })
201        } else {
202            None
203        };
204
205        let (status, late) = match (&satisfier, &failed) {
206            (Some(r), _) => (PeriodStatus::Satisfied, r.completed_at.date_naive() > *end),
207            (None, Some(_)) => (PeriodStatus::Failed, false),
208            (None, None) => {
209                if today < *start {
210                    (PeriodStatus::Future, false)
211                } else if today >= *start && today <= *end {
212                    (PeriodStatus::Open, false)
213                } else {
214                    (PeriodStatus::Gap, false)
215                }
216            }
217        };
218
219        periods.push(PeriodCoverage {
220            period_id: pid.clone(),
221            period_start: *start,
222            period_end: *end,
223            status,
224            satisfied_by: satisfier,
225            late,
226            skipped_reason: None,
227        });
228    }
229
230    // Add explicitly-skipped periods so the report shows them as such.
231    let mut cursor = window_start;
232    while cursor <= window_end {
233        if let Some(pid) = period::derive(control.cadence, cursor) {
234            if let Some((start, end)) = period::bounds(control.cadence, &pid) {
235                let (skipped, reason) = is_skipped(control, &reg.schedule, start);
236                if skipped && !periods.iter().any(|p| p.period_id == pid) {
237                    periods.push(PeriodCoverage {
238                        period_id: pid.clone(),
239                        period_start: start,
240                        period_end: end,
241                        status: PeriodStatus::Skipped,
242                        satisfied_by: None,
243                        late: false,
244                        skipped_reason: reason,
245                    });
246                }
247                cursor = end.succ_opt().unwrap_or(end);
248                if cursor <= end {
249                    break;
250                }
251                continue;
252            }
253        }
254        break;
255    }
256    periods.sort_by_key(|p| p.period_start);
257
258    // Runs whose claimed period falls outside the window: report as
259    // out-of-window unclassified so auditors can see them.
260    for (pid, rs) in by_period {
261        if !expected_ids.contains(pid.as_str()) {
262            for r in rs {
263                legacy.push(UnclassifiedRun {
264                    run_id: r.run_id,
265                    period_id: Some(pid.clone()),
266                    completed_at: r.completed_at,
267                    status: r.status,
268                    reason: format!("claims period `{pid}` outside requested window"),
269                });
270            }
271        }
272    }
273    legacy.sort_by_key(|u| u.completed_at);
274
275    Ok(CoverageReport {
276        control_id: control_id.to_string(),
277        window_start,
278        window_end,
279        periods,
280        unclassified_runs: legacy,
281    })
282}
283
284/// Test if a period whose start falls on `period_start` is removed by
285/// any `schedule.yaml` skip directive for this control. Returns the
286/// skip reason when matched.
287fn is_skipped(
288    control: &Control,
289    schedule: &Schedule,
290    period_start: NaiveDate,
291) -> (bool, Option<String>) {
292    for entry in schedule
293        .overrides
294        .iter()
295        .filter(|o| o.control_id == control.id)
296    {
297        let Some(skip) = &entry.skip else {
298            continue;
299        };
300        if let Some(q) = &skip.quarter {
301            let pq = format!(
302                "{:04}-q{}",
303                period_start.year(),
304                quarter_of_month(period_start.month())
305            );
306            if &pq == q {
307                return (true, skip.reason.clone().or_else(|| entry.reason.clone()));
308            }
309        }
310        if let Some(y) = skip.year {
311            if period_start.year() == y {
312                return (true, skip.reason.clone().or_else(|| entry.reason.clone()));
313            }
314        }
315    }
316    (false, None)
317}
318
319fn quarter_of_month(month: u32) -> u32 {
320    (month - 1) / 3 + 1
321}
322
323/// One sealed run on disk: its run directory and parsed manifest. The
324/// single walk result both coverage and report assembly consume, so the
325/// evidence layout and the lenient corrupt-manifest policy live in
326/// exactly one walker.
327#[derive(Debug, Clone)]
328pub struct SealedRun {
329    pub path: std::path::PathBuf,
330    pub manifest: Manifest,
331}
332
333/// Walk `<root>/evidence/*/*/control_id/*/manifest.json` and return one
334/// row per sealed manifest, sorted by path. Skips pending runs (no
335/// manifest yet) and silently skips manifests that fail to parse —
336/// coverage queries shouldn't take down the whole report on one corrupt
337/// manifest. Callers who want stricter checking should use
338/// [`crate::evidence::verifier`].
339pub fn sealed_runs_for_control(root: &Path, control_id: &str) -> anyhow::Result<Vec<SealedRun>> {
340    let mut out: Vec<SealedRun> = Vec::new();
341    let evidence = root.join("evidence");
342    if !evidence.is_dir() {
343        return Ok(out);
344    }
345    for year in dir_children(&evidence)? {
346        for quarter in dir_children(&year)? {
347            let ctrl_dir = quarter.join(control_id);
348            if !ctrl_dir.is_dir() {
349                continue;
350            }
351            collect_runs(&ctrl_dir, &mut out, &mut |_, _| {})?;
352        }
353    }
354    Ok(out)
355}
356
357/// A manifest that exists but could not be read or parsed — evidence
358/// that is present yet unusable. Walkers that skip these must give the
359/// caller the list, so a report can say so in-band instead of silently
360/// reading the period as a gap.
361#[derive(Debug, Clone)]
362pub struct EvidenceError {
363    pub control_id: String,
364    /// The run dir, as walked (absolute; callers relativize for output).
365    pub path: std::path::PathBuf,
366    pub error: String,
367}
368
369/// [`sealed_runs_by_control`]'s result: runs bucketed by control-dir
370/// name, plus every manifest the walk had to skip.
371#[derive(Debug, Clone, Default)]
372pub struct EvidenceWalk {
373    pub runs: std::collections::BTreeMap<String, Vec<SealedRun>>,
374    pub errors: Vec<EvidenceError>,
375}
376
377/// One walk of the whole evidence tree, bucketing sealed runs by the
378/// control-dir name. Per-control sequences are identical to what
379/// [`sealed_runs_for_control`] returns — registry-wide callers (report
380/// assembly) use this to avoid re-enumerating the year/quarter dirs once
381/// per control, and to receive the corrupt-manifest list the per-control
382/// walker silently skips.
383pub fn sealed_runs_by_control(root: &Path) -> anyhow::Result<EvidenceWalk> {
384    let mut runs: std::collections::BTreeMap<String, Vec<SealedRun>> = Default::default();
385    let mut errors: Vec<EvidenceError> = Vec::new();
386    let evidence = root.join("evidence");
387    if evidence.is_dir() {
388        for year in dir_children(&evidence)? {
389            for quarter in dir_children(&year)? {
390                for ctrl_dir in dir_children(&quarter)? {
391                    let Some(control_id) = ctrl_dir.file_name().and_then(|n| n.to_str()) else {
392                        continue;
393                    };
394                    let control_id = control_id.to_string();
395                    let bucket = runs.entry(control_id.clone()).or_default();
396                    collect_runs(&ctrl_dir, bucket, &mut |path, error| {
397                        errors.push(EvidenceError {
398                            control_id: control_id.clone(),
399                            path,
400                            error,
401                        })
402                    })?;
403                }
404            }
405        }
406    }
407    Ok(EvidenceWalk { runs, errors })
408}
409
410/// Append every sealed run under one `<quarter>/<control>/` dir. Pending
411/// runs (no manifest yet) are skipped outright; unreadable or unparseable
412/// manifests are reported through `on_error` — the per-control walker
413/// drops them (its documented lenient policy), the registry-wide walker
414/// surfaces them.
415fn collect_runs(
416    ctrl_dir: &Path,
417    out: &mut Vec<SealedRun>,
418    on_error: &mut dyn FnMut(std::path::PathBuf, String),
419) -> anyhow::Result<()> {
420    for run in dir_children(ctrl_dir)? {
421        let mpath = run.join("manifest.json");
422        if !mpath.is_file() {
423            continue;
424        }
425        let bytes = match fs::read(&mpath) {
426            Ok(b) => b,
427            Err(e) => {
428                on_error(run, format!("read manifest.json: {e}"));
429                continue;
430            }
431        };
432        let manifest: Manifest = match serde_json::from_slice(&bytes) {
433            Ok(m) => m,
434            Err(e) => {
435                on_error(run, format!("parse manifest.json: {e}"));
436                continue;
437            }
438        };
439        out.push(SealedRun {
440            path: run,
441            manifest,
442        });
443    }
444    Ok(())
445}
446
447fn dir_children(p: &Path) -> anyhow::Result<Vec<std::path::PathBuf>> {
448    let mut v = Vec::new();
449    for entry in fs::read_dir(p)? {
450        let entry = entry?;
451        if entry.file_type()?.is_dir() {
452            v.push(entry.path());
453        }
454    }
455    // Deterministic walk order regardless of filesystem readdir order.
456    v.sort();
457    Ok(v)
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::model::{Cadence, Control, Schedule, ScheduleEntry, ScheduleSkip};
464
465    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
466        NaiveDate::from_ymd_opt(y, m, day).unwrap()
467    }
468
469    fn make_control(id: &str, cadence: Cadence) -> Control {
470        Control {
471            id: id.into(),
472            title: "t".into(),
473            policy: "p".into(),
474            nist: Vec::new(),
475            owner: "o".into(),
476            cadence,
477            weekday: None,
478            due_by: None,
479            skill: "s".into(),
480            skill_args: None,
481            scope: None,
482            evidence_required: Vec::new(),
483            remediation_thresholds: Default::default(),
484            outputs: None,
485            references: Vec::new(),
486        }
487    }
488
489    fn weekly_control(id: &str) -> Control {
490        make_control(id, Cadence::Weekly)
491    }
492
493    fn quarterly_control(id: &str) -> Control {
494        make_control(id, Cadence::Quarterly)
495    }
496
497    fn continuous_control(id: &str) -> Control {
498        make_control(id, Cadence::Continuous)
499    }
500
501    #[test]
502    fn weekly_window_covers_iso_weeks_in_range() {
503        let c = weekly_control("c1");
504        let s = Schedule::default();
505        // Apr 27 (Mon, W18) through May 17 (Sun, W20): expect W18, W19, W20.
506        let got = expected_periods(&c, &s, d(2026, 4, 27), d(2026, 5, 17));
507        let ids: Vec<&str> = got.iter().map(|(p, _, _)| p.as_str()).collect();
508        assert_eq!(ids, ["2026-W18", "2026-W19", "2026-W20"]);
509    }
510
511    #[test]
512    fn quarterly_window_covers_quarters() {
513        let c = quarterly_control("c1");
514        let s = Schedule::default();
515        let got = expected_periods(&c, &s, d(2026, 1, 1), d(2026, 12, 31));
516        let ids: Vec<&str> = got.iter().map(|(p, _, _)| p.as_str()).collect();
517        assert_eq!(ids, ["2026-q1", "2026-q2", "2026-q3", "2026-q4"]);
518    }
519
520    #[test]
521    fn continuous_returns_no_periods() {
522        let c = continuous_control("c1");
523        let s = Schedule::default();
524        assert!(expected_periods(&c, &s, d(2026, 1, 1), d(2026, 12, 31)).is_empty());
525    }
526
527    #[test]
528    fn skip_quarter_directive_excludes_periods_in_that_quarter() {
529        let c = weekly_control("c1");
530        let s = Schedule {
531            overrides: vec![ScheduleEntry {
532                control_id: "c1".into(),
533                due: None,
534                weekday: None,
535                note: None,
536                reason: None,
537                skip: Some(ScheduleSkip {
538                    quarter: Some("2026-q2".into()),
539                    year: None,
540                    reason: Some("audit prep".into()),
541                }),
542                insert: None,
543            }],
544        };
545        // Window covers Q1 end and Q2 start. Q2 weeks should be excluded.
546        let got = expected_periods(&c, &s, d(2026, 3, 23), d(2026, 4, 12));
547        let ids: Vec<&str> = got.iter().map(|(p, _, _)| p.as_str()).collect();
548        // ISO weeks: Mar 23-29 = W13, Mar 30-Apr 5 = W14 (starts in Q1, but
549        // its start date Mar 30 is in Q1 → kept). Apr 6-12 = W15 (Q2 → skipped).
550        assert!(ids.contains(&"2026-W13"));
551        assert!(!ids.contains(&"2026-W15"));
552    }
553
554    #[test]
555    fn empty_window_returns_empty() {
556        let c = weekly_control("c1");
557        let s = Schedule::default();
558        assert!(expected_periods(&c, &s, d(2026, 5, 10), d(2026, 5, 4)).is_empty());
559    }
560}