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