1use std::collections::HashSet;
9
10use chrono::{Datelike, Duration, NaiveDate};
11use serde::{Deserialize, Serialize};
12
13use crate::model::{
14 Cadence, Control, Inventory, LoadedRegistry, ResolvedSystem, Schedule, Scope, StateEntry,
15 Weekday,
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum DueReason {
26 Cadence,
28 OverrideDue,
30 OverrideInsert,
32 OverrideWeekday,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct DueResolution {
42 pub date: NaiveDate,
43 pub reason: DueReason,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub note: Option<String>,
46}
47
48pub fn next_due(
57 control: &Control,
58 schedule: &Schedule,
59 state: Option<&StateEntry>,
60 today: NaiveDate,
61 config_default_weekday: Option<Weekday>,
62) -> Option<NaiveDate> {
63 next_due_with_reason(control, schedule, state, today, config_default_weekday).map(|r| r.date)
64}
65
66pub fn next_due_with_reason(
84 control: &Control,
85 schedule: &Schedule,
86 state: Option<&StateEntry>,
87 today: NaiveDate,
88 config_default_weekday: Option<Weekday>,
89) -> Option<DueResolution> {
90 let weekday_override_entry = schedule
94 .overrides
95 .iter()
96 .find(|o| o.control_id == control.id && o.weekday.is_some());
97 let weekday_override = weekday_override_entry.and_then(|o| o.weekday);
98 let weekday_note =
99 weekday_override_entry.and_then(|o| o.note.clone().or_else(|| o.reason.clone()));
100 let effective_weekday = effective_weekday(control, schedule, config_default_weekday);
101
102 let pins: Vec<(NaiveDate, Option<String>)> = schedule
105 .overrides
106 .iter()
107 .filter(|o| o.control_id == control.id)
108 .filter_map(|o| {
109 o.due
110 .map(|d| (d, o.note.clone().or_else(|| o.reason.clone())))
111 })
112 .collect();
113
114 let pin_defers = |from: NaiveDate, p: NaiveDate| -> bool {
118 from <= p
119 && match nominal_firing_after(control, effective_weekday, from) {
120 Some(nf) => nf > p,
121 None => true,
122 }
123 };
124
125 if let Some(stale) = state.and_then(|s| s.next_due) {
134 if stale < today && !skip_covers(control, schedule, stale) {
135 let rescheduled = pins
136 .iter()
137 .any(|(p, _)| *p >= today && pin_defers(stale, *p));
138 if !rescheduled {
139 let missed_pin = pins
144 .iter()
145 .filter(|(p, _)| *p < today && pin_defers(stale, *p))
146 .min_by_key(|(p, _)| *p);
147 return Some(match missed_pin {
148 Some((p, note)) => DueResolution {
149 date: *p,
150 reason: DueReason::OverrideDue,
151 note: note
152 .clone()
153 .or_else(|| Some("due date passed without a completed run".into())),
154 },
155 None => DueResolution {
156 date: stale,
157 reason: DueReason::Cadence,
158 note: Some("due date passed without a completed run".into()),
159 },
160 });
161 }
162 }
163 }
164
165 let skip_today = skip_covers(control, schedule, today);
167
168 let mut candidates: Vec<DatedCandidate> = Vec::new();
170
171 for ov in schedule
176 .overrides
177 .iter()
178 .filter(|o| o.control_id == control.id)
179 {
180 if let Some(insert) = &ov.insert {
181 if insert.run_at >= today {
182 candidates.push(DatedCandidate {
183 date: insert.run_at,
184 reason: DueReason::OverrideInsert,
185 note: ov
186 .note
187 .clone()
188 .or_else(|| insert.reason.clone())
189 .or_else(|| ov.reason.clone()),
190 precedence: 0,
191 });
192 }
193 }
194 }
195
196 for (p, note) in &pins {
198 if *p >= today {
199 candidates.push(DatedCandidate {
200 date: *p,
201 reason: DueReason::OverrideDue,
202 note: note.clone(),
203 precedence: 1,
204 });
205 }
206 }
207
208 let cached_next = state.and_then(|s| s.next_due).filter(|d| *d >= today);
217 let cadence_due = match control.cadence {
218 Cadence::Continuous => None,
219 _ => cached_next.or_else(|| {
220 Some(match control.cadence {
221 Cadence::Continuous => unreachable!("handled above"),
222 Cadence::Weekly => next_weekly(today, effective_weekday),
223 Cadence::Monthly => next_business_day(today, monthly_anchor(today)),
224 Cadence::Quarterly => next_business_day(today, quarterly_anchor(today)),
225 Cadence::SemiAnnual => next_business_day(today, semiannual_anchor(today)),
226 Cadence::Annual => next_annual(today, control.due_by.as_deref()),
227 })
228 }),
229 };
230
231 let deferred = |d: NaiveDate| pins.iter().any(|(p, _)| *p >= today && pin_defers(d, *p));
238
239 if let Some(d) = cadence_due.filter(|d| !deferred(*d)) {
240 let weekday_active =
241 matches!(control.cadence, Cadence::Weekly) && weekday_override.is_some();
242 let (reason, note, precedence) = if weekday_active {
243 (DueReason::OverrideWeekday, weekday_note.clone(), 2u8)
244 } else {
245 (DueReason::Cadence, None, 3u8)
246 };
247 candidates.push(DatedCandidate {
248 date: d,
249 reason,
250 note,
251 precedence,
252 });
253 }
254
255 let winner = candidates
258 .iter()
259 .min_by(|a, b| a.date.cmp(&b.date).then(a.precedence.cmp(&b.precedence)))
260 .cloned();
261
262 let winner = winner?;
263
264 if skip_today && winner.reason == DueReason::Cadence {
265 return candidates
269 .into_iter()
270 .filter(|c| c.reason == DueReason::OverrideInsert)
271 .min_by_key(|c| c.date)
272 .map(Into::into);
273 }
274
275 Some(winner.into())
276}
277
278#[derive(Debug, Clone)]
279struct DatedCandidate {
280 date: NaiveDate,
281 reason: DueReason,
282 note: Option<String>,
283 precedence: u8,
285}
286
287impl From<DatedCandidate> for DueResolution {
288 fn from(c: DatedCandidate) -> Self {
289 DueResolution {
290 date: c.date,
291 reason: c.reason,
292 note: c.note,
293 }
294 }
295}
296
297fn skip_covers(control: &Control, schedule: &Schedule, date: NaiveDate) -> bool {
299 schedule
300 .overrides
301 .iter()
302 .filter(|o| o.control_id == control.id)
303 .any(|o| {
304 if let Some(skip) = &o.skip {
305 if let Some(q) = &skip.quarter {
306 return quarter_string(date) == *q;
307 }
308 if let Some(y) = skip.year {
309 return date.year() == y;
310 }
311 }
312 false
313 })
314}
315
316pub fn is_overdue(control: &Control, due: NaiveDate, today: NaiveDate) -> bool {
318 today > due + grace(control.cadence)
319}
320
321pub fn grace(cadence: Cadence) -> Duration {
323 match cadence {
324 Cadence::Continuous => Duration::days(0),
325 Cadence::Weekly => Duration::days(3),
326 Cadence::Monthly => Duration::days(7),
327 Cadence::Quarterly => Duration::days(14),
328 Cadence::SemiAnnual => Duration::days(21),
329 Cadence::Annual => Duration::days(30),
330 }
331}
332
333pub fn next_firing_after(
343 control: &Control,
344 schedule: &Schedule,
345 after: NaiveDate,
346 config_default_weekday: Option<Weekday>,
347) -> Option<NaiveDate> {
348 let effective_weekday = effective_weekday(control, schedule, config_default_weekday);
349 let mut d = nominal_firing_after(control, effective_weekday, after)?;
350 for _ in 0..256 {
354 if !skip_covers(control, schedule, d) {
355 return Some(d);
356 }
357 d = nominal_firing_after(control, effective_weekday, d)?;
358 }
359 Some(d)
360}
361
362fn effective_weekday(
366 control: &Control,
367 schedule: &Schedule,
368 config_default_weekday: Option<Weekday>,
369) -> Weekday {
370 schedule
371 .overrides
372 .iter()
373 .find(|o| o.control_id == control.id && o.weekday.is_some())
374 .and_then(|o| o.weekday)
375 .or(control.weekday)
376 .or(config_default_weekday)
377 .unwrap_or(Weekday::Monday)
378}
379
380fn nominal_firing_after(control: &Control, weekday: Weekday, d: NaiveDate) -> Option<NaiveDate> {
386 let after = d + Duration::days(1);
387 match control.cadence {
388 Cadence::Continuous => None,
389 Cadence::Weekly => Some(next_weekly(after, weekday)),
390 Cadence::Monthly => {
391 let this = first_business_day(monthly_anchor(after));
392 Some(if this > d {
393 this
394 } else {
395 let (y, m) = if after.month() == 12 {
396 (after.year() + 1, 1)
397 } else {
398 (after.year(), after.month() + 1)
399 };
400 first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
401 })
402 }
403 Cadence::Quarterly => {
404 let this = first_business_day(quarterly_anchor(after));
405 Some(if this > d {
406 this
407 } else {
408 let anchor = quarterly_anchor(after);
409 let (y, m) = if anchor.month() == 10 {
410 (anchor.year() + 1, 1)
411 } else {
412 (anchor.year(), anchor.month() + 3)
413 };
414 first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
415 })
416 }
417 Cadence::SemiAnnual => {
418 let this = first_business_day(semiannual_anchor(after));
419 Some(if this > d {
420 this
421 } else {
422 let anchor = semiannual_anchor(after);
423 let (y, m) = if anchor.month() == 7 {
424 (anchor.year() + 1, 1)
425 } else {
426 (anchor.year(), 7)
427 };
428 first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
429 })
430 }
431 Cadence::Annual => Some(next_annual(after, control.due_by.as_deref())),
432 }
433}
434
435fn first_business_day(anchor: NaiveDate) -> NaiveDate {
436 let mut d = anchor;
437 while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
438 d += Duration::days(1);
439 }
440 d
441}
442
443fn next_weekly(today: NaiveDate, weekday: Weekday) -> NaiveDate {
444 let target = weekday.to_chrono().num_days_from_monday() as i64;
445 let cur = today.weekday().num_days_from_monday() as i64;
446 let mut delta = target - cur;
447 if delta < 0 {
448 delta += 7;
449 }
450 today + Duration::days(delta)
451}
452
453fn monthly_anchor(today: NaiveDate) -> NaiveDate {
454 NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap()
455}
456
457fn quarterly_anchor(today: NaiveDate) -> NaiveDate {
458 let q_first = match today.month() {
459 1..=3 => 1,
460 4..=6 => 4,
461 7..=9 => 7,
462 _ => 10,
463 };
464 NaiveDate::from_ymd_opt(today.year(), q_first, 1).unwrap()
465}
466
467fn semiannual_anchor(today: NaiveDate) -> NaiveDate {
468 let m = if today.month() <= 6 { 1 } else { 7 };
469 NaiveDate::from_ymd_opt(today.year(), m, 1).unwrap()
470}
471
472fn next_annual(today: NaiveDate, due_by: Option<&str>) -> NaiveDate {
473 if let Some(due) = due_by {
474 if let Some(d) = parse_due_by(due, today.year()) {
475 if d >= today {
476 return d;
477 }
478 return parse_due_by(due, today.year() + 1).unwrap_or(d);
479 }
480 }
481 NaiveDate::from_ymd_opt(today.year(), 12, 31).unwrap_or(today)
482}
483
484fn parse_due_by(s: &str, year: i32) -> Option<NaiveDate> {
485 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
486 return Some(d);
487 }
488 let mut parts = s.splitn(2, '-');
489 let month = parts.next()?;
490 let day: u32 = parts.next()?.parse().ok()?;
491 let m = match month.to_lowercase().as_str() {
492 "january" | "jan" => 1,
493 "february" | "feb" => 2,
494 "march" | "mar" => 3,
495 "april" | "apr" => 4,
496 "may" => 5,
497 "june" | "jun" => 6,
498 "july" | "jul" => 7,
499 "august" | "aug" => 8,
500 "september" | "sep" => 9,
501 "october" | "oct" => 10,
502 "november" | "nov" => 11,
503 "december" | "dec" => 12,
504 _ => return None,
505 };
506 NaiveDate::from_ymd_opt(year, m, day)
507}
508
509fn next_business_day(today: NaiveDate, anchor: NaiveDate) -> NaiveDate {
510 let mut d = anchor.max(today);
511 while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
512 d += Duration::days(1);
513 }
514 if d < today {
515 return today;
519 }
520 d
521}
522
523fn quarter_string(date: NaiveDate) -> String {
524 let q = (date.month() - 1) / 3 + 1;
525 format!("{:04}-q{}", date.year(), q)
526}
527
528pub fn resolve_scope(
532 control: &Control,
533 inventory: &Inventory,
534 run_date: NaiveDate,
535) -> Vec<ResolvedSystem> {
536 match &control.scope {
537 None => Vec::new(),
538 Some(Scope::Inline(inline)) => inline
539 .inline
540 .iter()
541 .map(|e| ResolvedSystem {
542 name: e.name.clone(),
543 kind: e.kind.clone(),
544 tags: e.tags.clone(),
545 extras: Default::default(),
546 })
547 .collect(),
548 Some(Scope::Inventory(spec)) => {
549 let entries = inventory.entries(&spec.kind);
550 let want_tags: HashSet<&str> = spec.has_tags.iter().map(String::as_str).collect();
551 let control_excludes: HashSet<&str> =
552 spec.excludes.iter().map(String::as_str).collect();
553 let all = spec.all.unwrap_or(false);
554
555 let mut out: Vec<ResolvedSystem> = entries
556 .iter()
557 .filter(|e| e.is_active_on(run_date))
558 .filter(|e| {
559 if all {
560 true
561 } else {
562 let entry_tags: HashSet<&str> = e.tags.iter().map(String::as_str).collect();
563 want_tags.iter().all(|t| entry_tags.contains(t))
564 }
565 })
566 .filter(|e| !control_excludes.contains(e.name.as_str()))
567 .filter(|e| !e.excludes.iter().any(|s| s == &control.skill))
568 .map(|e| ResolvedSystem {
569 name: e.name.clone(),
570 kind: spec.kind.clone(),
571 tags: e.tags.clone(),
572 extras: e.extras.clone(),
573 })
574 .collect();
575 out.sort_by(|a, b| a.name.cmp(&b.name));
576 out
577 }
578 }
579}
580
581#[derive(Debug, Clone)]
584pub struct DueRow {
585 pub control_id: String,
586 pub cadence: Cadence,
587 pub next_due: Option<NaiveDate>,
588 pub overdue: bool,
589}
590
591pub fn due_rows(reg: &LoadedRegistry, today: NaiveDate) -> Vec<DueRow> {
595 let mut rows: Vec<DueRow> = reg
596 .controls
597 .values()
598 .map(|c| {
599 let state = reg.state.controls.get(&c.id);
600 let next = next_due(
601 c,
602 ®.schedule,
603 state,
604 today,
605 reg.config.weekly_default_weekday,
606 );
607 let overdue = next.map(|d| is_overdue(c, d, today)).unwrap_or(false);
608 DueRow {
609 control_id: c.id.clone(),
610 cadence: c.cadence,
611 next_due: next,
612 overdue,
613 }
614 })
615 .collect();
616 rows.sort_by(|a, b| match (a.next_due, b.next_due) {
617 (Some(x), Some(y)) => (x, &a.control_id).cmp(&(y, &b.control_id)),
618 (Some(_), None) => std::cmp::Ordering::Less,
619 (None, Some(_)) => std::cmp::Ordering::Greater,
620 (None, None) => a.control_id.cmp(&b.control_id),
621 });
622 rows
623}
624
625pub fn due_within(reg: &LoadedRegistry, today: NaiveDate, window_days: i64) -> Vec<DueRow> {
627 let cutoff = today + Duration::days(window_days);
628 due_rows(reg, today)
629 .into_iter()
630 .filter(|r| match r.next_due {
631 Some(d) => d <= cutoff,
632 None => false,
633 })
634 .collect()
635}