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 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, ®.schedule, window_start, window_end);
140 let runs = walk_runs_for_control(®.root, control_id)?;
141
142 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 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 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, ®.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 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
269fn 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
316fn 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 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 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 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}