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 = weekday_override
101 .or(control.weekday)
102 .or(config_default_weekday)
103 .unwrap_or(Weekday::Monday);
104
105 let pins: Vec<(NaiveDate, Option<String>)> = schedule
108 .overrides
109 .iter()
110 .filter(|o| o.control_id == control.id)
111 .filter_map(|o| {
112 o.due
113 .map(|d| (d, o.note.clone().or_else(|| o.reason.clone())))
114 })
115 .collect();
116
117 let pin_defers = |from: NaiveDate, p: NaiveDate| -> bool {
121 from <= p
122 && match next_firing_after(control, effective_weekday, from) {
123 Some(nf) => nf > p,
124 None => true,
125 }
126 };
127
128 if let Some(stale) = state.and_then(|s| s.next_due) {
137 if stale < today && !skip_covers(control, schedule, stale) {
138 let rescheduled = pins
139 .iter()
140 .any(|(p, _)| *p >= today && pin_defers(stale, *p));
141 if !rescheduled {
142 let missed_pin = pins
147 .iter()
148 .filter(|(p, _)| *p < today && pin_defers(stale, *p))
149 .min_by_key(|(p, _)| *p);
150 return Some(match missed_pin {
151 Some((p, note)) => DueResolution {
152 date: *p,
153 reason: DueReason::OverrideDue,
154 note: note
155 .clone()
156 .or_else(|| Some("due date passed without a completed run".into())),
157 },
158 None => DueResolution {
159 date: stale,
160 reason: DueReason::Cadence,
161 note: Some("due date passed without a completed run".into()),
162 },
163 });
164 }
165 }
166 }
167
168 let skip_today = skip_covers(control, schedule, today);
170
171 let mut candidates: Vec<DatedCandidate> = Vec::new();
173
174 for ov in schedule
179 .overrides
180 .iter()
181 .filter(|o| o.control_id == control.id)
182 {
183 if let Some(insert) = &ov.insert {
184 if insert.run_at >= today {
185 candidates.push(DatedCandidate {
186 date: insert.run_at,
187 reason: DueReason::OverrideInsert,
188 note: ov
189 .note
190 .clone()
191 .or_else(|| insert.reason.clone())
192 .or_else(|| ov.reason.clone()),
193 precedence: 0,
194 });
195 }
196 }
197 }
198
199 for (p, note) in &pins {
201 if *p >= today {
202 candidates.push(DatedCandidate {
203 date: *p,
204 reason: DueReason::OverrideDue,
205 note: note.clone(),
206 precedence: 1,
207 });
208 }
209 }
210
211 let cadence_due = match control.cadence {
214 Cadence::Continuous => None,
215 Cadence::Weekly => Some(next_weekly(
216 today,
217 effective_weekday,
218 state.and_then(|s| s.next_due),
219 )),
220 Cadence::Monthly => Some(next_business_day(today, monthly_anchor(today))),
221 Cadence::Quarterly => Some(next_business_day(today, quarterly_anchor(today))),
222 Cadence::SemiAnnual => Some(next_business_day(today, semiannual_anchor(today))),
223 Cadence::Annual => Some(next_annual(today, control.due_by.as_deref())),
224 };
225
226 let deferred = |d: NaiveDate| pins.iter().any(|(p, _)| *p >= today && pin_defers(d, *p));
233
234 if let Some(d) = cadence_due.filter(|d| !deferred(*d)) {
235 let weekday_active =
236 matches!(control.cadence, Cadence::Weekly) && weekday_override.is_some();
237 let (reason, note, precedence) = if weekday_active {
238 (DueReason::OverrideWeekday, weekday_note.clone(), 2u8)
239 } else {
240 (DueReason::Cadence, None, 3u8)
241 };
242 candidates.push(DatedCandidate {
243 date: d,
244 reason,
245 note,
246 precedence,
247 });
248 }
249
250 let winner = candidates
253 .iter()
254 .min_by(|a, b| a.date.cmp(&b.date).then(a.precedence.cmp(&b.precedence)))
255 .cloned();
256
257 let winner = winner?;
258
259 if skip_today && winner.reason == DueReason::Cadence {
260 return candidates
264 .into_iter()
265 .filter(|c| c.reason == DueReason::OverrideInsert)
266 .min_by_key(|c| c.date)
267 .map(Into::into);
268 }
269
270 Some(winner.into())
271}
272
273#[derive(Debug, Clone)]
274struct DatedCandidate {
275 date: NaiveDate,
276 reason: DueReason,
277 note: Option<String>,
278 precedence: u8,
280}
281
282impl From<DatedCandidate> for DueResolution {
283 fn from(c: DatedCandidate) -> Self {
284 DueResolution {
285 date: c.date,
286 reason: c.reason,
287 note: c.note,
288 }
289 }
290}
291
292fn skip_covers(control: &Control, schedule: &Schedule, date: NaiveDate) -> bool {
294 schedule
295 .overrides
296 .iter()
297 .filter(|o| o.control_id == control.id)
298 .any(|o| {
299 if let Some(skip) = &o.skip {
300 if let Some(q) = &skip.quarter {
301 return quarter_string(date) == *q;
302 }
303 if let Some(y) = skip.year {
304 return date.year() == y;
305 }
306 }
307 false
308 })
309}
310
311pub fn is_overdue(control: &Control, due: NaiveDate, today: NaiveDate) -> bool {
313 today > due + grace(control.cadence)
314}
315
316pub fn grace(cadence: Cadence) -> Duration {
318 match cadence {
319 Cadence::Continuous => Duration::days(0),
320 Cadence::Weekly => Duration::days(3),
321 Cadence::Monthly => Duration::days(7),
322 Cadence::Quarterly => Duration::days(14),
323 Cadence::SemiAnnual => Duration::days(21),
324 Cadence::Annual => Duration::days(30),
325 }
326}
327
328fn next_firing_after(control: &Control, weekday: Weekday, d: NaiveDate) -> Option<NaiveDate> {
334 let after = d + Duration::days(1);
335 match control.cadence {
336 Cadence::Continuous => None,
337 Cadence::Weekly => Some(next_weekly(after, weekday, None)),
338 Cadence::Monthly => {
339 let this = first_business_day(monthly_anchor(after));
340 Some(if this > d {
341 this
342 } else {
343 let (y, m) = if after.month() == 12 {
344 (after.year() + 1, 1)
345 } else {
346 (after.year(), after.month() + 1)
347 };
348 first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
349 })
350 }
351 Cadence::Quarterly => {
352 let this = first_business_day(quarterly_anchor(after));
353 Some(if this > d {
354 this
355 } else {
356 let anchor = quarterly_anchor(after);
357 let (y, m) = if anchor.month() == 10 {
358 (anchor.year() + 1, 1)
359 } else {
360 (anchor.year(), anchor.month() + 3)
361 };
362 first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
363 })
364 }
365 Cadence::SemiAnnual => {
366 let this = first_business_day(semiannual_anchor(after));
367 Some(if this > d {
368 this
369 } else {
370 let anchor = semiannual_anchor(after);
371 let (y, m) = if anchor.month() == 7 {
372 (anchor.year() + 1, 1)
373 } else {
374 (anchor.year(), 7)
375 };
376 first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
377 })
378 }
379 Cadence::Annual => Some(next_annual(after, control.due_by.as_deref())),
380 }
381}
382
383fn first_business_day(anchor: NaiveDate) -> NaiveDate {
384 let mut d = anchor;
385 while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
386 d += Duration::days(1);
387 }
388 d
389}
390
391fn next_weekly(today: NaiveDate, weekday: Weekday, last_next_due: Option<NaiveDate>) -> NaiveDate {
392 if let Some(d) = last_next_due {
395 if d >= today {
396 return d;
397 }
398 }
399 let target = weekday.to_chrono().num_days_from_monday() as i64;
400 let cur = today.weekday().num_days_from_monday() as i64;
401 let mut delta = target - cur;
402 if delta < 0 {
403 delta += 7;
404 }
405 today + Duration::days(delta)
406}
407
408fn monthly_anchor(today: NaiveDate) -> NaiveDate {
409 NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap()
410}
411
412fn quarterly_anchor(today: NaiveDate) -> NaiveDate {
413 let q_first = match today.month() {
414 1..=3 => 1,
415 4..=6 => 4,
416 7..=9 => 7,
417 _ => 10,
418 };
419 NaiveDate::from_ymd_opt(today.year(), q_first, 1).unwrap()
420}
421
422fn semiannual_anchor(today: NaiveDate) -> NaiveDate {
423 let m = if today.month() <= 6 { 1 } else { 7 };
424 NaiveDate::from_ymd_opt(today.year(), m, 1).unwrap()
425}
426
427fn next_annual(today: NaiveDate, due_by: Option<&str>) -> NaiveDate {
428 if let Some(due) = due_by {
429 if let Some(d) = parse_due_by(due, today.year()) {
430 if d >= today {
431 return d;
432 }
433 return parse_due_by(due, today.year() + 1).unwrap_or(d);
434 }
435 }
436 NaiveDate::from_ymd_opt(today.year(), 12, 31).unwrap_or(today)
437}
438
439fn parse_due_by(s: &str, year: i32) -> Option<NaiveDate> {
440 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
441 return Some(d);
442 }
443 let mut parts = s.splitn(2, '-');
444 let month = parts.next()?;
445 let day: u32 = parts.next()?.parse().ok()?;
446 let m = match month.to_lowercase().as_str() {
447 "january" | "jan" => 1,
448 "february" | "feb" => 2,
449 "march" | "mar" => 3,
450 "april" | "apr" => 4,
451 "may" => 5,
452 "june" | "jun" => 6,
453 "july" | "jul" => 7,
454 "august" | "aug" => 8,
455 "september" | "sep" => 9,
456 "october" | "oct" => 10,
457 "november" | "nov" => 11,
458 "december" | "dec" => 12,
459 _ => return None,
460 };
461 NaiveDate::from_ymd_opt(year, m, day)
462}
463
464fn next_business_day(today: NaiveDate, anchor: NaiveDate) -> NaiveDate {
465 let mut d = anchor.max(today);
466 while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
467 d += Duration::days(1);
468 }
469 if d < today {
470 return today;
474 }
475 d
476}
477
478fn quarter_string(date: NaiveDate) -> String {
479 let q = (date.month() - 1) / 3 + 1;
480 format!("{:04}-q{}", date.year(), q)
481}
482
483pub fn resolve_scope(
487 control: &Control,
488 inventory: &Inventory,
489 run_date: NaiveDate,
490) -> Vec<ResolvedSystem> {
491 match &control.scope {
492 None => Vec::new(),
493 Some(Scope::Inline(inline)) => inline
494 .inline
495 .iter()
496 .map(|e| ResolvedSystem {
497 name: e.name.clone(),
498 kind: e.kind.clone(),
499 tags: e.tags.clone(),
500 extras: Default::default(),
501 })
502 .collect(),
503 Some(Scope::Inventory(spec)) => {
504 let entries = inventory.entries(&spec.kind);
505 let want_tags: HashSet<&str> = spec.has_tags.iter().map(String::as_str).collect();
506 let control_excludes: HashSet<&str> =
507 spec.excludes.iter().map(String::as_str).collect();
508 let all = spec.all.unwrap_or(false);
509
510 let mut out: Vec<ResolvedSystem> = entries
511 .iter()
512 .filter(|e| e.is_active_on(run_date))
513 .filter(|e| {
514 if all {
515 true
516 } else {
517 let entry_tags: HashSet<&str> = e.tags.iter().map(String::as_str).collect();
518 want_tags.iter().all(|t| entry_tags.contains(t))
519 }
520 })
521 .filter(|e| !control_excludes.contains(e.name.as_str()))
522 .filter(|e| !e.excludes.iter().any(|s| s == &control.skill))
523 .map(|e| ResolvedSystem {
524 name: e.name.clone(),
525 kind: spec.kind.clone(),
526 tags: e.tags.clone(),
527 extras: e.extras.clone(),
528 })
529 .collect();
530 out.sort_by(|a, b| a.name.cmp(&b.name));
531 out
532 }
533 }
534}
535
536#[derive(Debug, Clone)]
539pub struct DueRow {
540 pub control_id: String,
541 pub cadence: Cadence,
542 pub next_due: Option<NaiveDate>,
543 pub overdue: bool,
544}
545
546pub fn due_rows(reg: &LoadedRegistry, today: NaiveDate) -> Vec<DueRow> {
550 let mut rows: Vec<DueRow> = reg
551 .controls
552 .values()
553 .map(|c| {
554 let state = reg.state.controls.get(&c.id);
555 let next = next_due(
556 c,
557 ®.schedule,
558 state,
559 today,
560 reg.config.weekly_default_weekday,
561 );
562 let overdue = next.map(|d| is_overdue(c, d, today)).unwrap_or(false);
563 DueRow {
564 control_id: c.id.clone(),
565 cadence: c.cadence,
566 next_due: next,
567 overdue,
568 }
569 })
570 .collect();
571 rows.sort_by(|a, b| match (a.next_due, b.next_due) {
572 (Some(x), Some(y)) => (x, &a.control_id).cmp(&(y, &b.control_id)),
573 (Some(_), None) => std::cmp::Ordering::Less,
574 (None, Some(_)) => std::cmp::Ordering::Greater,
575 (None, None) => a.control_id.cmp(&b.control_id),
576 });
577 rows
578}
579
580pub fn due_within(reg: &LoadedRegistry, today: NaiveDate, window_days: i64) -> Vec<DueRow> {
582 let cutoff = today + Duration::days(window_days);
583 due_rows(reg, today)
584 .into_iter()
585 .filter(|r| match r.next_due {
586 Some(d) => d <= cutoff,
587 None => false,
588 })
589 .collect()
590}