1use 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 Satisfied,
33 Failed,
38 Gap,
40 Skipped,
42 Future,
44 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 #[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 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
86pub 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 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
122pub 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(®.root, control_id)?;
135 coverage_over_runs(reg, control_id, &runs, window_start, window_end, today)
136}
137
138pub 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, ®.schedule, window_start, window_end);
155
156 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 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 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, ®.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 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
284fn 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#[derive(Debug, Clone)]
328pub struct SealedRun {
329 pub path: std::path::PathBuf,
330 pub manifest: Manifest,
331}
332
333pub 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#[derive(Debug, Clone)]
362pub struct EvidenceError {
363 pub control_id: String,
364 pub path: std::path::PathBuf,
366 pub error: String,
367}
368
369#[derive(Debug, Clone, Default)]
372pub struct EvidenceWalk {
373 pub runs: std::collections::BTreeMap<String, Vec<SealedRun>>,
374 pub errors: Vec<EvidenceError>,
375}
376
377pub 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
410fn 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 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 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 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 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}