Skip to main content

secunit_core/registry/
resolver.rs

1//! Cadence resolution and scope expansion.
2//!
3//! Pure functions over the loaded model. Cadence math follows the table
4//! in `docs/storage.md`; scope follows the inventory + tag-filter rules in
5//! the same doc. Anything date-shaped enters as `chrono::NaiveDate` so
6//! tests can pin "today" deterministically.
7
8use 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// ---------- due resolution --------------------------------------------------
19
20/// Why a particular firing date won — i.e. which input to the resolver
21/// produced it. The CLI surfaces this via `secunit due --why`; the GUI
22/// renders it as a chip on the Schedule view.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum DueReason {
26    /// The cadence rules produced the date with no override in play.
27    Cadence,
28    /// A `schedule.yaml` override pinned a specific date for this control.
29    OverrideDue,
30    /// A `schedule.yaml` insert added a one-off firing.
31    OverrideInsert,
32    /// A `schedule.yaml` override changed the weekday a weekly cadence
33    /// fires on. The date is still cadence-derived; the weekday is the
34    /// operator's pick.
35    OverrideWeekday,
36}
37
38/// A firing date with provenance and (where the override carried one)
39/// the operator's note.
40#[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
48// ---------- cadence ---------------------------------------------------------
49
50/// Compute the next firing date for `control` on or after `today`,
51/// taking schedule overrides and the last-run pointer into account.
52///
53/// Thin facade over [`next_due_with_reason`]; callers that need to
54/// know *why* a date won (the GUI's Schedule view, a future
55/// `secunit due --why` flag) should call the richer version directly.
56pub 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
66/// Like [`next_due`] but returns the date together with the
67/// [`DueReason`] that produced it and the override's note (if any).
68///
69/// Precedence rules:
70///   * Earlier date always wins.
71///   * On a tie, override sources beat cadence: insert > dated > weekday > cadence.
72///   * A skip override removes the cadence firing for the matching
73///     window; the next-earliest insert (if any) takes its place.
74pub 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    // A cached next_due in the past means the obligation came due and no
82    // finalize advanced it — the control is still due on that date, and
83    // once past its grace window it is overdue. Rolling forward here
84    // would silently forgive every miss and leave `is_overdue` (and the
85    // grace table) unreachable for cadence-driven controls. A skip
86    // override covering the missed date sanctions the miss and falls
87    // through to the normal forward computation.
88    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    // Skip a single firing window if `schedule.yaml` says so.
99    let skip_today = skip_covers(control, schedule, today);
100
101    // Candidate buckets, each carrying provenance for the reason field.
102    let mut candidates: Vec<DatedCandidate> = Vec::new();
103
104    // Inserts — one-off extra firings. Note precedence: explicit
105    // entry note → insert's own reason → entry-level reason. This
106    // covers both the YAML shape `entry.note: "x"` and the more
107    // common `insert: { run_at, reason: "x" }`.
108    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    // Dated overrides — a pinned `due:` date.
130    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    // Weekday override only changes the cadence-derived date for
148    // weekly controls — capture the note so the cadence candidate can
149    // pick it up if it ends up labelled `OverrideWeekday`.
150    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    // Cadence-derived date, accounting for any weekday override that
159    // applies to a weekly cadence.
160    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    // Pick the earliest date; on ties, lower precedence index wins
192    // (insert > dated > weekday > cadence).
193    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        // Cadence firing is skipped — fall back to the earliest insert
202        // (if any). Dated overrides survive a skip; only the cadence
203        // window is removed, per the spec's `skip` semantics.
204        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    /// Lower wins when dates tie. 0=insert, 1=dated, 2=weekday, 3=cadence.
220    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
233/// Does any `schedule.yaml` skip directive for this control cover `date`?
234fn 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
252/// Has the control passed its grace window?
253pub fn is_overdue(control: &Control, due: NaiveDate, today: NaiveDate) -> bool {
254    today > due + grace(control.cadence)
255}
256
257/// Per-cadence grace period after which a due control is overdue.
258pub 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 state already says "next due Monday Y", honour it as long as it
271    // is in the future. Otherwise compute the upcoming target weekday.
272    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        // Anchor is in the past — push to next month/quarter window.
349        // Caller can re-anchor; here we just bump by a month as a safe
350        // default.
351        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
361// ---------- scope -----------------------------------------------------------
362
363/// Expand a control's scope against the inventory on the given run date.
364pub 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// ---------- registry-wide helpers ------------------------------------------
415
416#[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
424/// Compute next-due rows for every control in `reg` as of `today`. Sorted
425/// by `(next_due ascending, control_id)`; controls without a computable
426/// firing date come last.
427pub 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                &reg.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
458/// Return controls due within `window` days of `today` (inclusive).
459pub 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}