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    // Skip a single firing window if `schedule.yaml` says so.
82    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    // Candidate buckets, each carrying provenance for the reason field.
99    let mut candidates: Vec<DatedCandidate> = Vec::new();
100
101    // Inserts — one-off extra firings. Note precedence: explicit
102    // entry note → insert's own reason → entry-level reason. This
103    // covers both the YAML shape `entry.note: "x"` and the more
104    // common `insert: { run_at, reason: "x" }`.
105    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    // Dated overrides — a pinned `due:` date.
127    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    // Weekday override only changes the cadence-derived date for
145    // weekly controls — capture the note so the cadence candidate can
146    // pick it up if it ends up labelled `OverrideWeekday`.
147    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    // Cadence-derived date, accounting for any weekday override that
156    // applies to a weekly cadence.
157    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    // Pick the earliest date; on ties, lower precedence index wins
189    // (insert > dated > weekday > cadence).
190    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        // Cadence firing is skipped — fall back to the earliest insert
199        // (if any). Dated overrides survive a skip; only the cadence
200        // window is removed, per the spec's `skip` semantics.
201        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    /// Lower wins when dates tie. 0=insert, 1=dated, 2=weekday, 3=cadence.
217    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
230/// Has the control passed its grace window?
231pub fn is_overdue(control: &Control, due: NaiveDate, today: NaiveDate) -> bool {
232    today > due + grace(control.cadence)
233}
234
235/// Per-cadence grace period after which a due control is overdue.
236pub 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 state already says "next due Monday Y", honour it as long as it
249    // is in the future. Otherwise compute the upcoming target weekday.
250    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        // Anchor is in the past — push to next month/quarter window.
327        // Caller can re-anchor; here we just bump by a month as a safe
328        // default.
329        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
339// ---------- scope -----------------------------------------------------------
340
341/// Expand a control's scope against the inventory on the given run date.
342pub 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// ---------- registry-wide helpers ------------------------------------------
393
394#[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
402/// Compute next-due rows for every control in `reg` as of `today`. Sorted
403/// by `(next_due ascending, control_id)`; controls without a computable
404/// firing date come last.
405pub 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                &reg.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
436/// Return controls due within `window` days of `today` (inclusive).
437pub 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}