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 control = reg
135        .controls
136        .get(control_id)
137        .ok_or_else(|| anyhow::anyhow!("control `{control_id}` not found"))?;
138
139    let expected = expected_periods(control, &reg.schedule, window_start, window_end);
140    let runs = walk_runs_for_control(&reg.root, control_id)?;
141
142    // Bucket runs by claimed period_id. Multiple runs claiming the same
143    // period coexist; we pick the earliest `complete` run as the
144    // satisfier, and surface the rest as additional context if needed
145    // (PR-2 keeps it minimal — first complete wins).
146    let mut by_period: HashMap<String, Vec<RunRef>> = HashMap::new();
147    let mut legacy: Vec<UnclassifiedRun> = Vec::new();
148    for r in &runs {
149        match &r.period_id {
150            Some(pid) => by_period.entry(pid.clone()).or_default().push(RunRef {
151                run_id: r.run_id.clone(),
152                completed_at: r.completed_at,
153                status: r.status,
154            }),
155            None => legacy.push(UnclassifiedRun {
156                run_id: r.run_id.clone(),
157                period_id: None,
158                completed_at: r.completed_at,
159                status: r.status,
160                reason: "legacy run sealed before period_id was introduced".into(),
161            }),
162        }
163    }
164
165    let expected_ids: std::collections::HashSet<&str> =
166        expected.iter().map(|(p, _, _)| p.as_str()).collect();
167    let mut periods: Vec<PeriodCoverage> = Vec::with_capacity(expected.len());
168    for (pid, start, end) in &expected {
169        let runs_here = by_period.get(pid);
170        let satisfier = runs_here.and_then(|rs| {
171            rs.iter()
172                .filter(|r| matches!(r.status, RunOutcome::Complete))
173                .min_by_key(|r| r.completed_at)
174                .cloned()
175        });
176        // No complete run, but a sealed failed run is a terminal verdict
177        // for the period — Failed, not Open/Gap. Partial runs stay
178        // non-terminal: the operator is expected to follow up.
179        let failed = if satisfier.is_none() {
180            runs_here.and_then(|rs| {
181                rs.iter()
182                    .filter(|r| matches!(r.status, RunOutcome::Failed))
183                    .min_by_key(|r| r.completed_at)
184                    .cloned()
185            })
186        } else {
187            None
188        };
189
190        let (status, late) = match (&satisfier, &failed) {
191            (Some(r), _) => (PeriodStatus::Satisfied, r.completed_at.date_naive() > *end),
192            (None, Some(_)) => (PeriodStatus::Failed, false),
193            (None, None) => {
194                if today < *start {
195                    (PeriodStatus::Future, false)
196                } else if today >= *start && today <= *end {
197                    (PeriodStatus::Open, false)
198                } else {
199                    (PeriodStatus::Gap, false)
200                }
201            }
202        };
203
204        periods.push(PeriodCoverage {
205            period_id: pid.clone(),
206            period_start: *start,
207            period_end: *end,
208            status,
209            satisfied_by: satisfier,
210            late,
211            skipped_reason: None,
212        });
213    }
214
215    // Add explicitly-skipped periods so the report shows them as such.
216    let mut cursor = window_start;
217    while cursor <= window_end {
218        if let Some(pid) = period::derive(control.cadence, cursor) {
219            if let Some((start, end)) = period::bounds(control.cadence, &pid) {
220                let (skipped, reason) = is_skipped(control, &reg.schedule, start);
221                if skipped && !periods.iter().any(|p| p.period_id == pid) {
222                    periods.push(PeriodCoverage {
223                        period_id: pid.clone(),
224                        period_start: start,
225                        period_end: end,
226                        status: PeriodStatus::Skipped,
227                        satisfied_by: None,
228                        late: false,
229                        skipped_reason: reason,
230                    });
231                }
232                cursor = end.succ_opt().unwrap_or(end);
233                if cursor <= end {
234                    break;
235                }
236                continue;
237            }
238        }
239        break;
240    }
241    periods.sort_by_key(|p| p.period_start);
242
243    // Runs whose claimed period falls outside the window: report as
244    // out-of-window unclassified so auditors can see them.
245    for (pid, rs) in by_period {
246        if !expected_ids.contains(pid.as_str()) {
247            for r in rs {
248                legacy.push(UnclassifiedRun {
249                    run_id: r.run_id,
250                    period_id: Some(pid.clone()),
251                    completed_at: r.completed_at,
252                    status: r.status,
253                    reason: format!("claims period `{pid}` outside requested window"),
254                });
255            }
256        }
257    }
258    legacy.sort_by_key(|u| u.completed_at);
259
260    Ok(CoverageReport {
261        control_id: control_id.to_string(),
262        window_start,
263        window_end,
264        periods,
265        unclassified_runs: legacy,
266    })
267}
268
269/// Test if a period whose start falls on `period_start` is removed by
270/// any `schedule.yaml` skip directive for this control. Returns the
271/// skip reason when matched.
272fn is_skipped(
273    control: &Control,
274    schedule: &Schedule,
275    period_start: NaiveDate,
276) -> (bool, Option<String>) {
277    for entry in schedule
278        .overrides
279        .iter()
280        .filter(|o| o.control_id == control.id)
281    {
282        let Some(skip) = &entry.skip else {
283            continue;
284        };
285        if let Some(q) = &skip.quarter {
286            let pq = format!(
287                "{:04}-q{}",
288                period_start.year(),
289                quarter_of_month(period_start.month())
290            );
291            if &pq == q {
292                return (true, skip.reason.clone().or_else(|| entry.reason.clone()));
293            }
294        }
295        if let Some(y) = skip.year {
296            if period_start.year() == y {
297                return (true, skip.reason.clone().or_else(|| entry.reason.clone()));
298            }
299        }
300    }
301    (false, None)
302}
303
304fn quarter_of_month(month: u32) -> u32 {
305    (month - 1) / 3 + 1
306}
307
308#[derive(Debug, Clone)]
309struct WalkedRun {
310    run_id: String,
311    completed_at: DateTime<Utc>,
312    status: RunOutcome,
313    period_id: Option<String>,
314}
315
316/// Walk `<root>/evidence/*/*/control_id/*/manifest.json` and return one
317/// row per sealed manifest. Skips pending runs (no manifest yet) and
318/// silently skips manifests that fail to parse — coverage queries
319/// shouldn't take down the whole report on one corrupt manifest. Callers
320/// who want stricter checking should use [`crate::evidence::verifier`].
321fn walk_runs_for_control(root: &Path, control_id: &str) -> anyhow::Result<Vec<WalkedRun>> {
322    let mut out: Vec<WalkedRun> = Vec::new();
323    let evidence = root.join("evidence");
324    if !evidence.is_dir() {
325        return Ok(out);
326    }
327    for year in dir_children(&evidence)? {
328        for quarter in dir_children(&year)? {
329            let ctrl_dir = quarter.join(control_id);
330            if !ctrl_dir.is_dir() {
331                continue;
332            }
333            for run in dir_children(&ctrl_dir)? {
334                let mpath = run.join("manifest.json");
335                if !mpath.is_file() {
336                    continue;
337                }
338                let bytes = match fs::read(&mpath) {
339                    Ok(b) => b,
340                    Err(_) => continue,
341                };
342                let manifest: Manifest = match serde_json::from_slice(&bytes) {
343                    Ok(m) => m,
344                    Err(_) => continue,
345                };
346                out.push(WalkedRun {
347                    run_id: manifest.run_id,
348                    completed_at: manifest.completed_at,
349                    status: manifest.status,
350                    period_id: manifest.period_id,
351                });
352            }
353        }
354    }
355    Ok(out)
356}
357
358fn dir_children(p: &Path) -> anyhow::Result<Vec<std::path::PathBuf>> {
359    let mut v = Vec::new();
360    for entry in fs::read_dir(p)? {
361        let entry = entry?;
362        if entry.file_type()?.is_dir() {
363            v.push(entry.path());
364        }
365    }
366    Ok(v)
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::model::{Cadence, Control, Schedule, ScheduleEntry, ScheduleSkip};
373
374    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
375        NaiveDate::from_ymd_opt(y, m, day).unwrap()
376    }
377
378    fn make_control(id: &str, cadence: Cadence) -> Control {
379        Control {
380            id: id.into(),
381            title: "t".into(),
382            policy: "p".into(),
383            nist: Vec::new(),
384            owner: "o".into(),
385            cadence,
386            weekday: None,
387            due_by: None,
388            skill: "s".into(),
389            skill_args: None,
390            scope: None,
391            evidence_required: Vec::new(),
392            remediation_thresholds: Default::default(),
393            outputs: None,
394            references: Vec::new(),
395        }
396    }
397
398    fn weekly_control(id: &str) -> Control {
399        make_control(id, Cadence::Weekly)
400    }
401
402    fn quarterly_control(id: &str) -> Control {
403        make_control(id, Cadence::Quarterly)
404    }
405
406    fn continuous_control(id: &str) -> Control {
407        make_control(id, Cadence::Continuous)
408    }
409
410    #[test]
411    fn weekly_window_covers_iso_weeks_in_range() {
412        let c = weekly_control("c1");
413        let s = Schedule::default();
414        // Apr 27 (Mon, W18) through May 17 (Sun, W20): expect W18, W19, W20.
415        let got = expected_periods(&c, &s, d(2026, 4, 27), d(2026, 5, 17));
416        let ids: Vec<&str> = got.iter().map(|(p, _, _)| p.as_str()).collect();
417        assert_eq!(ids, ["2026-W18", "2026-W19", "2026-W20"]);
418    }
419
420    #[test]
421    fn quarterly_window_covers_quarters() {
422        let c = quarterly_control("c1");
423        let s = Schedule::default();
424        let got = expected_periods(&c, &s, d(2026, 1, 1), d(2026, 12, 31));
425        let ids: Vec<&str> = got.iter().map(|(p, _, _)| p.as_str()).collect();
426        assert_eq!(ids, ["2026-q1", "2026-q2", "2026-q3", "2026-q4"]);
427    }
428
429    #[test]
430    fn continuous_returns_no_periods() {
431        let c = continuous_control("c1");
432        let s = Schedule::default();
433        assert!(expected_periods(&c, &s, d(2026, 1, 1), d(2026, 12, 31)).is_empty());
434    }
435
436    #[test]
437    fn skip_quarter_directive_excludes_periods_in_that_quarter() {
438        let c = weekly_control("c1");
439        let s = Schedule {
440            overrides: vec![ScheduleEntry {
441                control_id: "c1".into(),
442                due: None,
443                weekday: None,
444                note: None,
445                reason: None,
446                skip: Some(ScheduleSkip {
447                    quarter: Some("2026-q2".into()),
448                    year: None,
449                    reason: Some("audit prep".into()),
450                }),
451                insert: None,
452            }],
453        };
454        // Window covers Q1 end and Q2 start. Q2 weeks should be excluded.
455        let got = expected_periods(&c, &s, d(2026, 3, 23), d(2026, 4, 12));
456        let ids: Vec<&str> = got.iter().map(|(p, _, _)| p.as_str()).collect();
457        // ISO weeks: Mar 23-29 = W13, Mar 30-Apr 5 = W14 (starts in Q1, but
458        // its start date Mar 30 is in Q1 → kept). Apr 6-12 = W15 (Q2 → skipped).
459        assert!(ids.contains(&"2026-W13"));
460        assert!(!ids.contains(&"2026-W15"));
461    }
462
463    #[test]
464    fn empty_window_returns_empty() {
465        let c = weekly_control("c1");
466        let s = Schedule::default();
467        assert!(expected_periods(&c, &s, d(2026, 5, 10), d(2026, 5, 4)).is_empty());
468    }
469}