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(
75 control: &Control,
76 schedule: &Schedule,
77 state: Option<&StateEntry>,
78 today: NaiveDate,
79 config_default_weekday: Option<Weekday>,
80) -> Option<DueResolution> {
81 if let Some(stale) = state.and_then(|s| s.next_due) {
89 if stale < today && !skip_covers(control, schedule, stale) {
90 return Some(DueResolution {
91 date: stale,
92 reason: DueReason::Cadence,
93 note: Some("due date passed without a completed run".into()),
94 });
95 }
96 }
97
98 let skip_today = skip_covers(control, schedule, today);
100
101 let mut candidates: Vec<DatedCandidate> = Vec::new();
103
104 for ov in schedule
109 .overrides
110 .iter()
111 .filter(|o| o.control_id == control.id)
112 {
113 if let Some(insert) = &ov.insert {
114 if insert.run_at >= today {
115 candidates.push(DatedCandidate {
116 date: insert.run_at,
117 reason: DueReason::OverrideInsert,
118 note: ov
119 .note
120 .clone()
121 .or_else(|| insert.reason.clone())
122 .or_else(|| ov.reason.clone()),
123 precedence: 0,
124 });
125 }
126 }
127 }
128
129 for ov in schedule
131 .overrides
132 .iter()
133 .filter(|o| o.control_id == control.id)
134 {
135 if let Some(d) = ov.due {
136 if d >= today {
137 candidates.push(DatedCandidate {
138 date: d,
139 reason: DueReason::OverrideDue,
140 note: ov.note.clone().or_else(|| ov.reason.clone()),
141 precedence: 1,
142 });
143 }
144 }
145 }
146
147 let weekday_override_entry = schedule
151 .overrides
152 .iter()
153 .find(|o| o.control_id == control.id && o.weekday.is_some());
154 let weekday_override = weekday_override_entry.and_then(|o| o.weekday);
155 let weekday_note =
156 weekday_override_entry.and_then(|o| o.note.clone().or_else(|| o.reason.clone()));
157
158 let cadence_due = match control.cadence {
161 Cadence::Continuous => None,
162 Cadence::Weekly => {
163 let wd = weekday_override
164 .or(control.weekday)
165 .or(config_default_weekday)
166 .unwrap_or(Weekday::Monday);
167 Some(next_weekly(today, wd, state.and_then(|s| s.next_due)))
168 }
169 Cadence::Monthly => Some(next_business_day(today, monthly_anchor(today))),
170 Cadence::Quarterly => Some(next_business_day(today, quarterly_anchor(today))),
171 Cadence::SemiAnnual => Some(next_business_day(today, semiannual_anchor(today))),
172 Cadence::Annual => Some(next_annual(today, control.due_by.as_deref())),
173 };
174
175 if let Some(d) = cadence_due {
176 let weekday_active =
177 matches!(control.cadence, Cadence::Weekly) && weekday_override.is_some();
178 let (reason, note, precedence) = if weekday_active {
179 (DueReason::OverrideWeekday, weekday_note.clone(), 2u8)
180 } else {
181 (DueReason::Cadence, None, 3u8)
182 };
183 candidates.push(DatedCandidate {
184 date: d,
185 reason,
186 note,
187 precedence,
188 });
189 }
190
191 let winner = candidates
194 .iter()
195 .min_by(|a, b| a.date.cmp(&b.date).then(a.precedence.cmp(&b.precedence)))
196 .cloned();
197
198 let winner = winner?;
199
200 if skip_today && winner.reason == DueReason::Cadence {
201 return candidates
205 .into_iter()
206 .filter(|c| c.reason == DueReason::OverrideInsert)
207 .min_by_key(|c| c.date)
208 .map(Into::into);
209 }
210
211 Some(winner.into())
212}
213
214#[derive(Debug, Clone)]
215struct DatedCandidate {
216 date: NaiveDate,
217 reason: DueReason,
218 note: Option<String>,
219 precedence: u8,
221}
222
223impl From<DatedCandidate> for DueResolution {
224 fn from(c: DatedCandidate) -> Self {
225 DueResolution {
226 date: c.date,
227 reason: c.reason,
228 note: c.note,
229 }
230 }
231}
232
233fn skip_covers(control: &Control, schedule: &Schedule, date: NaiveDate) -> bool {
235 schedule
236 .overrides
237 .iter()
238 .filter(|o| o.control_id == control.id)
239 .any(|o| {
240 if let Some(skip) = &o.skip {
241 if let Some(q) = &skip.quarter {
242 return quarter_string(date) == *q;
243 }
244 if let Some(y) = skip.year {
245 return date.year() == y;
246 }
247 }
248 false
249 })
250}
251
252pub fn is_overdue(control: &Control, due: NaiveDate, today: NaiveDate) -> bool {
254 today > due + grace(control.cadence)
255}
256
257pub fn grace(cadence: Cadence) -> Duration {
259 match cadence {
260 Cadence::Continuous => Duration::days(0),
261 Cadence::Weekly => Duration::days(3),
262 Cadence::Monthly => Duration::days(7),
263 Cadence::Quarterly => Duration::days(14),
264 Cadence::SemiAnnual => Duration::days(21),
265 Cadence::Annual => Duration::days(30),
266 }
267}
268
269fn next_weekly(today: NaiveDate, weekday: Weekday, last_next_due: Option<NaiveDate>) -> NaiveDate {
270 if let Some(d) = last_next_due {
273 if d >= today {
274 return d;
275 }
276 }
277 let target = weekday.to_chrono().num_days_from_monday() as i64;
278 let cur = today.weekday().num_days_from_monday() as i64;
279 let mut delta = target - cur;
280 if delta < 0 {
281 delta += 7;
282 }
283 today + Duration::days(delta)
284}
285
286fn monthly_anchor(today: NaiveDate) -> NaiveDate {
287 NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap()
288}
289
290fn quarterly_anchor(today: NaiveDate) -> NaiveDate {
291 let q_first = match today.month() {
292 1..=3 => 1,
293 4..=6 => 4,
294 7..=9 => 7,
295 _ => 10,
296 };
297 NaiveDate::from_ymd_opt(today.year(), q_first, 1).unwrap()
298}
299
300fn semiannual_anchor(today: NaiveDate) -> NaiveDate {
301 let m = if today.month() <= 6 { 1 } else { 7 };
302 NaiveDate::from_ymd_opt(today.year(), m, 1).unwrap()
303}
304
305fn next_annual(today: NaiveDate, due_by: Option<&str>) -> NaiveDate {
306 if let Some(due) = due_by {
307 if let Some(d) = parse_due_by(due, today.year()) {
308 if d >= today {
309 return d;
310 }
311 return parse_due_by(due, today.year() + 1).unwrap_or(d);
312 }
313 }
314 NaiveDate::from_ymd_opt(today.year(), 12, 31).unwrap_or(today)
315}
316
317fn parse_due_by(s: &str, year: i32) -> Option<NaiveDate> {
318 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
319 return Some(d);
320 }
321 let mut parts = s.splitn(2, '-');
322 let month = parts.next()?;
323 let day: u32 = parts.next()?.parse().ok()?;
324 let m = match month.to_lowercase().as_str() {
325 "january" | "jan" => 1,
326 "february" | "feb" => 2,
327 "march" | "mar" => 3,
328 "april" | "apr" => 4,
329 "may" => 5,
330 "june" | "jun" => 6,
331 "july" | "jul" => 7,
332 "august" | "aug" => 8,
333 "september" | "sep" => 9,
334 "october" | "oct" => 10,
335 "november" | "nov" => 11,
336 "december" | "dec" => 12,
337 _ => return None,
338 };
339 NaiveDate::from_ymd_opt(year, m, day)
340}
341
342fn next_business_day(today: NaiveDate, anchor: NaiveDate) -> NaiveDate {
343 let mut d = anchor.max(today);
344 while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
345 d += Duration::days(1);
346 }
347 if d < today {
348 return today;
352 }
353 d
354}
355
356fn quarter_string(date: NaiveDate) -> String {
357 let q = (date.month() - 1) / 3 + 1;
358 format!("{:04}-q{}", date.year(), q)
359}
360
361pub fn resolve_scope(
365 control: &Control,
366 inventory: &Inventory,
367 run_date: NaiveDate,
368) -> Vec<ResolvedSystem> {
369 match &control.scope {
370 None => Vec::new(),
371 Some(Scope::Inline(inline)) => inline
372 .inline
373 .iter()
374 .map(|e| ResolvedSystem {
375 name: e.name.clone(),
376 kind: e.kind.clone(),
377 tags: e.tags.clone(),
378 extras: Default::default(),
379 })
380 .collect(),
381 Some(Scope::Inventory(spec)) => {
382 let entries = inventory.entries(&spec.kind);
383 let want_tags: HashSet<&str> = spec.has_tags.iter().map(String::as_str).collect();
384 let control_excludes: HashSet<&str> =
385 spec.excludes.iter().map(String::as_str).collect();
386 let all = spec.all.unwrap_or(false);
387
388 let mut out: Vec<ResolvedSystem> = entries
389 .iter()
390 .filter(|e| e.is_active_on(run_date))
391 .filter(|e| {
392 if all {
393 true
394 } else {
395 let entry_tags: HashSet<&str> = e.tags.iter().map(String::as_str).collect();
396 want_tags.iter().all(|t| entry_tags.contains(t))
397 }
398 })
399 .filter(|e| !control_excludes.contains(e.name.as_str()))
400 .filter(|e| !e.excludes.iter().any(|s| s == &control.skill))
401 .map(|e| ResolvedSystem {
402 name: e.name.clone(),
403 kind: spec.kind.clone(),
404 tags: e.tags.clone(),
405 extras: e.extras.clone(),
406 })
407 .collect();
408 out.sort_by(|a, b| a.name.cmp(&b.name));
409 out
410 }
411 }
412}
413
414#[derive(Debug, Clone)]
417pub struct DueRow {
418 pub control_id: String,
419 pub cadence: Cadence,
420 pub next_due: Option<NaiveDate>,
421 pub overdue: bool,
422}
423
424pub fn due_rows(reg: &LoadedRegistry, today: NaiveDate) -> Vec<DueRow> {
428 let mut rows: Vec<DueRow> = reg
429 .controls
430 .values()
431 .map(|c| {
432 let state = reg.state.controls.get(&c.id);
433 let next = next_due(
434 c,
435 ®.schedule,
436 state,
437 today,
438 reg.config.weekly_default_weekday,
439 );
440 let overdue = next.map(|d| is_overdue(c, d, today)).unwrap_or(false);
441 DueRow {
442 control_id: c.id.clone(),
443 cadence: c.cadence,
444 next_due: next,
445 overdue,
446 }
447 })
448 .collect();
449 rows.sort_by(|a, b| match (a.next_due, b.next_due) {
450 (Some(x), Some(y)) => (x, &a.control_id).cmp(&(y, &b.control_id)),
451 (Some(_), None) => std::cmp::Ordering::Less,
452 (None, Some(_)) => std::cmp::Ordering::Greater,
453 (None, None) => a.control_id.cmp(&b.control_id),
454 });
455 rows
456}
457
458pub fn due_within(reg: &LoadedRegistry, today: NaiveDate, window_days: i64) -> Vec<DueRow> {
460 let cutoff = today + Duration::days(window_days);
461 due_rows(reg, today)
462 .into_iter()
463 .filter(|r| match r.next_due {
464 Some(d) => d <= cutoff,
465 None => false,
466 })
467 .collect()
468}