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///   * Earliest date wins; on a tie, override sources beat cadence:
71///     insert > dated > weekday > cadence.
72///   * A dated override (`due:`) earlier than the cadence date therefore
73///     wins outright. One later than the cadence date is a **bounded
74///     postponement**: it defers the upcoming firing, but only as far as
75///     the next firing after it — a pin can never swallow more than one
76///     cadence firing. A pin further out than that leaves the nearer
77///     firings untouched and only postpones the firing immediately
78///     before it, once its turn comes. Use `skip:` to remove whole
79///     windows; change the control's `due_by`/cadence for a recurring
80///     shift.
81///   * A skip override removes the cadence firing for the matching
82///     window; the next-earliest insert (if any) takes its place.
83pub 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    // Weekday override only changes the cadence-derived date for
91    // weekly controls — capture the note so the cadence candidate can
92    // pick it up if it ends up labelled `OverrideWeekday`.
93    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 = effective_weekday(control, schedule, config_default_weekday);
101
102    // Dated overrides (`due:` pins) for this control, collected once —
103    // the stale-miss guard and the candidate race both consult them.
104    let pins: Vec<(NaiveDate, Option<String>)> = schedule
105        .overrides
106        .iter()
107        .filter(|o| o.control_id == control.id)
108        .filter_map(|o| {
109            o.due
110                .map(|d| (d, o.note.clone().or_else(|| o.reason.clone())))
111        })
112        .collect();
113
114    // Does pin `p` legally postpone an obligation at `from`? Only if no
115    // other cadence firing falls in between — a pin defers exactly one
116    // firing, never a stretch of them.
117    let pin_defers = |from: NaiveDate, p: NaiveDate| -> bool {
118        from <= p
119            && match nominal_firing_after(control, effective_weekday, from) {
120                Some(nf) => nf > p,
121                None => true,
122            }
123    };
124
125    // A cached next_due in the past means the obligation came due and no
126    // finalize advanced it — the control is still due on that date, and
127    // once past its grace window it is overdue. Rolling forward here
128    // would silently forgive every miss and leave `is_overdue` (and the
129    // grace table) unreachable for cadence-driven controls. Two schedule
130    // directives sanction the miss and fall through to the normal
131    // forward computation: a skip covering the missed date, or a pending
132    // dated override that postpones that same obligation.
133    if let Some(stale) = state.and_then(|s| s.next_due) {
134        if stale < today && !skip_covers(control, schedule, stale) {
135            let rescheduled = pins
136                .iter()
137                .any(|(p, _)| *p >= today && pin_defers(stale, *p));
138            if !rescheduled {
139                // If the obligation was postponed and the new date has
140                // also passed, the pinned date is the missed obligation —
141                // report it with override provenance and the operator's
142                // note, not the older cadence date.
143                let missed_pin = pins
144                    .iter()
145                    .filter(|(p, _)| *p < today && pin_defers(stale, *p))
146                    .min_by_key(|(p, _)| *p);
147                return Some(match missed_pin {
148                    Some((p, note)) => DueResolution {
149                        date: *p,
150                        reason: DueReason::OverrideDue,
151                        note: note
152                            .clone()
153                            .or_else(|| Some("due date passed without a completed run".into())),
154                    },
155                    None => DueResolution {
156                        date: stale,
157                        reason: DueReason::Cadence,
158                        note: Some("due date passed without a completed run".into()),
159                    },
160                });
161            }
162        }
163    }
164
165    // Skip a single firing window if `schedule.yaml` says so.
166    let skip_today = skip_covers(control, schedule, today);
167
168    // Candidate buckets, each carrying provenance for the reason field.
169    let mut candidates: Vec<DatedCandidate> = Vec::new();
170
171    // Inserts — one-off extra firings. Note precedence: explicit
172    // entry note → insert's own reason → entry-level reason. This
173    // covers both the YAML shape `entry.note: "x"` and the more
174    // common `insert: { run_at, reason: "x" }`.
175    for ov in schedule
176        .overrides
177        .iter()
178        .filter(|o| o.control_id == control.id)
179    {
180        if let Some(insert) = &ov.insert {
181            if insert.run_at >= today {
182                candidates.push(DatedCandidate {
183                    date: insert.run_at,
184                    reason: DueReason::OverrideInsert,
185                    note: ov
186                        .note
187                        .clone()
188                        .or_else(|| insert.reason.clone())
189                        .or_else(|| ov.reason.clone()),
190                    precedence: 0,
191                });
192            }
193        }
194    }
195
196    // Dated overrides — pinned `due:` dates still ahead of us.
197    for (p, note) in &pins {
198        if *p >= today {
199            candidates.push(DatedCandidate {
200                date: *p,
201                reason: DueReason::OverrideDue,
202                note: note.clone(),
203                precedence: 1,
204            });
205        }
206    }
207
208    // Cadence-derived date. A cached future `state.next_due` is
209    // authoritative for every cadence: finalize wrote it as the next
210    // nominal obligation after the sealed run, and recomputing from
211    // `today` instead would re-arm the period the run just satisfied
212    // (the month-anchored arms below can only say "when is the current
213    // period due", never "when is the next firing"). Without a fresh
214    // cache, fall back to the current period's target, accounting for
215    // any weekday override that applies to a weekly cadence.
216    let cached_next = state.and_then(|s| s.next_due).filter(|d| *d >= today);
217    let cadence_due = match control.cadence {
218        Cadence::Continuous => None,
219        _ => cached_next.or_else(|| {
220            Some(match control.cadence {
221                Cadence::Continuous => unreachable!("handled above"),
222                Cadence::Weekly => next_weekly(today, effective_weekday),
223                Cadence::Monthly => next_business_day(today, monthly_anchor(today)),
224                Cadence::Quarterly => next_business_day(today, quarterly_anchor(today)),
225                Cadence::SemiAnnual => next_business_day(today, semiannual_anchor(today)),
226                Cadence::Annual => next_annual(today, control.due_by.as_deref()),
227            })
228        }),
229    };
230
231    // A pending pin that legally postpones the upcoming cadence firing
232    // suppresses it — that is the deferral. A pin further out (with
233    // another firing in between) leaves the cadence candidate in the
234    // race, so nearer firings are never silently swallowed. A pin
235    // earlier than the cadence date needs no suppression: it wins the
236    // earliest-date race on its own.
237    let deferred = |d: NaiveDate| pins.iter().any(|(p, _)| *p >= today && pin_defers(d, *p));
238
239    if let Some(d) = cadence_due.filter(|d| !deferred(*d)) {
240        let weekday_active =
241            matches!(control.cadence, Cadence::Weekly) && weekday_override.is_some();
242        let (reason, note, precedence) = if weekday_active {
243            (DueReason::OverrideWeekday, weekday_note.clone(), 2u8)
244        } else {
245            (DueReason::Cadence, None, 3u8)
246        };
247        candidates.push(DatedCandidate {
248            date: d,
249            reason,
250            note,
251            precedence,
252        });
253    }
254
255    // Pick the earliest date; on ties, lower precedence index wins
256    // (insert > dated > weekday > cadence).
257    let winner = candidates
258        .iter()
259        .min_by(|a, b| a.date.cmp(&b.date).then(a.precedence.cmp(&b.precedence)))
260        .cloned();
261
262    let winner = winner?;
263
264    if skip_today && winner.reason == DueReason::Cadence {
265        // Cadence firing is skipped — fall back to the earliest insert
266        // (if any). Dated overrides survive a skip; only the cadence
267        // window is removed, per the spec's `skip` semantics.
268        return candidates
269            .into_iter()
270            .filter(|c| c.reason == DueReason::OverrideInsert)
271            .min_by_key(|c| c.date)
272            .map(Into::into);
273    }
274
275    Some(winner.into())
276}
277
278#[derive(Debug, Clone)]
279struct DatedCandidate {
280    date: NaiveDate,
281    reason: DueReason,
282    note: Option<String>,
283    /// Lower wins when dates tie. 0=insert, 1=dated, 2=weekday, 3=cadence.
284    precedence: u8,
285}
286
287impl From<DatedCandidate> for DueResolution {
288    fn from(c: DatedCandidate) -> Self {
289        DueResolution {
290            date: c.date,
291            reason: c.reason,
292            note: c.note,
293        }
294    }
295}
296
297/// Does any `schedule.yaml` skip directive for this control cover `date`?
298fn skip_covers(control: &Control, schedule: &Schedule, date: NaiveDate) -> bool {
299    schedule
300        .overrides
301        .iter()
302        .filter(|o| o.control_id == control.id)
303        .any(|o| {
304            if let Some(skip) = &o.skip {
305                if let Some(q) = &skip.quarter {
306                    return quarter_string(date) == *q;
307                }
308                if let Some(y) = skip.year {
309                    return date.year() == y;
310                }
311            }
312            false
313        })
314}
315
316/// Has the control passed its grace window?
317pub fn is_overdue(control: &Control, due: NaiveDate, today: NaiveDate) -> bool {
318    today > due + grace(control.cadence)
319}
320
321/// Per-cadence grace period after which a due control is overdue.
322pub fn grace(cadence: Cadence) -> Duration {
323    match cadence {
324        Cadence::Continuous => Duration::days(0),
325        Cadence::Weekly => Duration::days(3),
326        Cadence::Monthly => Duration::days(7),
327        Cadence::Quarterly => Duration::days(14),
328        Cadence::SemiAnnual => Duration::days(21),
329        Cadence::Annual => Duration::days(30),
330    }
331}
332
333/// The next nominal cadence firing strictly after `after`, resolving the
334/// effective weekday (schedule override > control > config default) and
335/// rolling past firings whose window a `skip:` directive removes.
336///
337/// This is the date finalize caches as `state.next_due` once a run has
338/// satisfied the current obligation. Pins and inserts are deliberately
339/// NOT baked in — the read-side candidate race in
340/// [`next_due_with_reason`] applies overrides dynamically, so the cache
341/// stays a pure cadence fact. `None` for continuous cadence.
342pub fn next_firing_after(
343    control: &Control,
344    schedule: &Schedule,
345    after: NaiveDate,
346    config_default_weekday: Option<Weekday>,
347) -> Option<NaiveDate> {
348    let effective_weekday = effective_weekday(control, schedule, config_default_weekday);
349    let mut d = nominal_firing_after(control, effective_weekday, after)?;
350    // Roll past skip-removed windows. Bounded: a year-wide skip on a
351    // weekly control is ~52 firings; past the bound, return the last
352    // computed date rather than loop forever on a pathological schedule.
353    for _ in 0..256 {
354        if !skip_covers(control, schedule, d) {
355            return Some(d);
356        }
357        d = nominal_firing_after(control, effective_weekday, d)?;
358    }
359    Some(d)
360}
361
362/// The weekday a weekly control fires on: schedule override > control's
363/// own `weekday` > config default > Monday. Single source of the chain
364/// so [`next_due_with_reason`] and [`next_firing_after`] cannot drift.
365fn effective_weekday(
366    control: &Control,
367    schedule: &Schedule,
368    config_default_weekday: Option<Weekday>,
369) -> Weekday {
370    schedule
371        .overrides
372        .iter()
373        .find(|o| o.control_id == control.id && o.weekday.is_some())
374        .and_then(|o| o.weekday)
375        .or(control.weekday)
376        .or(config_default_weekday)
377        .unwrap_or(Weekday::Monday)
378}
379
380/// The first nominal cadence firing strictly after `d`, ignoring state
381/// and overrides. `None` for continuous cadence, which never fires.
382///
383/// This is the yardstick for bounded postponement: a `due:` pin may
384/// defer an obligation only up to (not past) the firing that follows it.
385fn nominal_firing_after(control: &Control, weekday: Weekday, d: NaiveDate) -> Option<NaiveDate> {
386    let after = d + Duration::days(1);
387    match control.cadence {
388        Cadence::Continuous => None,
389        Cadence::Weekly => Some(next_weekly(after, weekday)),
390        Cadence::Monthly => {
391            let this = first_business_day(monthly_anchor(after));
392            Some(if this > d {
393                this
394            } else {
395                let (y, m) = if after.month() == 12 {
396                    (after.year() + 1, 1)
397                } else {
398                    (after.year(), after.month() + 1)
399                };
400                first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
401            })
402        }
403        Cadence::Quarterly => {
404            let this = first_business_day(quarterly_anchor(after));
405            Some(if this > d {
406                this
407            } else {
408                let anchor = quarterly_anchor(after);
409                let (y, m) = if anchor.month() == 10 {
410                    (anchor.year() + 1, 1)
411                } else {
412                    (anchor.year(), anchor.month() + 3)
413                };
414                first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
415            })
416        }
417        Cadence::SemiAnnual => {
418            let this = first_business_day(semiannual_anchor(after));
419            Some(if this > d {
420                this
421            } else {
422                let anchor = semiannual_anchor(after);
423                let (y, m) = if anchor.month() == 7 {
424                    (anchor.year() + 1, 1)
425                } else {
426                    (anchor.year(), 7)
427                };
428                first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
429            })
430        }
431        Cadence::Annual => Some(next_annual(after, control.due_by.as_deref())),
432    }
433}
434
435fn first_business_day(anchor: NaiveDate) -> NaiveDate {
436    let mut d = anchor;
437    while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
438        d += Duration::days(1);
439    }
440    d
441}
442
443fn next_weekly(today: NaiveDate, weekday: Weekday) -> NaiveDate {
444    let target = weekday.to_chrono().num_days_from_monday() as i64;
445    let cur = today.weekday().num_days_from_monday() as i64;
446    let mut delta = target - cur;
447    if delta < 0 {
448        delta += 7;
449    }
450    today + Duration::days(delta)
451}
452
453fn monthly_anchor(today: NaiveDate) -> NaiveDate {
454    NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap()
455}
456
457fn quarterly_anchor(today: NaiveDate) -> NaiveDate {
458    let q_first = match today.month() {
459        1..=3 => 1,
460        4..=6 => 4,
461        7..=9 => 7,
462        _ => 10,
463    };
464    NaiveDate::from_ymd_opt(today.year(), q_first, 1).unwrap()
465}
466
467fn semiannual_anchor(today: NaiveDate) -> NaiveDate {
468    let m = if today.month() <= 6 { 1 } else { 7 };
469    NaiveDate::from_ymd_opt(today.year(), m, 1).unwrap()
470}
471
472fn next_annual(today: NaiveDate, due_by: Option<&str>) -> NaiveDate {
473    if let Some(due) = due_by {
474        if let Some(d) = parse_due_by(due, today.year()) {
475            if d >= today {
476                return d;
477            }
478            return parse_due_by(due, today.year() + 1).unwrap_or(d);
479        }
480    }
481    NaiveDate::from_ymd_opt(today.year(), 12, 31).unwrap_or(today)
482}
483
484fn parse_due_by(s: &str, year: i32) -> Option<NaiveDate> {
485    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
486        return Some(d);
487    }
488    let mut parts = s.splitn(2, '-');
489    let month = parts.next()?;
490    let day: u32 = parts.next()?.parse().ok()?;
491    let m = match month.to_lowercase().as_str() {
492        "january" | "jan" => 1,
493        "february" | "feb" => 2,
494        "march" | "mar" => 3,
495        "april" | "apr" => 4,
496        "may" => 5,
497        "june" | "jun" => 6,
498        "july" | "jul" => 7,
499        "august" | "aug" => 8,
500        "september" | "sep" => 9,
501        "october" | "oct" => 10,
502        "november" | "nov" => 11,
503        "december" | "dec" => 12,
504        _ => return None,
505    };
506    NaiveDate::from_ymd_opt(year, m, day)
507}
508
509fn next_business_day(today: NaiveDate, anchor: NaiveDate) -> NaiveDate {
510    let mut d = anchor.max(today);
511    while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
512        d += Duration::days(1);
513    }
514    if d < today {
515        // Anchor is in the past — push to next month/quarter window.
516        // Caller can re-anchor; here we just bump by a month as a safe
517        // default.
518        return today;
519    }
520    d
521}
522
523fn quarter_string(date: NaiveDate) -> String {
524    let q = (date.month() - 1) / 3 + 1;
525    format!("{:04}-q{}", date.year(), q)
526}
527
528// ---------- scope -----------------------------------------------------------
529
530/// Expand a control's scope against the inventory on the given run date.
531pub fn resolve_scope(
532    control: &Control,
533    inventory: &Inventory,
534    run_date: NaiveDate,
535) -> Vec<ResolvedSystem> {
536    match &control.scope {
537        None => Vec::new(),
538        Some(Scope::Inline(inline)) => inline
539            .inline
540            .iter()
541            .map(|e| ResolvedSystem {
542                name: e.name.clone(),
543                kind: e.kind.clone(),
544                tags: e.tags.clone(),
545                extras: Default::default(),
546            })
547            .collect(),
548        Some(Scope::Inventory(spec)) => {
549            let entries = inventory.entries(&spec.kind);
550            let want_tags: HashSet<&str> = spec.has_tags.iter().map(String::as_str).collect();
551            let control_excludes: HashSet<&str> =
552                spec.excludes.iter().map(String::as_str).collect();
553            let all = spec.all.unwrap_or(false);
554
555            let mut out: Vec<ResolvedSystem> = entries
556                .iter()
557                .filter(|e| e.is_active_on(run_date))
558                .filter(|e| {
559                    if all {
560                        true
561                    } else {
562                        let entry_tags: HashSet<&str> = e.tags.iter().map(String::as_str).collect();
563                        want_tags.iter().all(|t| entry_tags.contains(t))
564                    }
565                })
566                .filter(|e| !control_excludes.contains(e.name.as_str()))
567                .filter(|e| !e.excludes.iter().any(|s| s == &control.skill))
568                .map(|e| ResolvedSystem {
569                    name: e.name.clone(),
570                    kind: spec.kind.clone(),
571                    tags: e.tags.clone(),
572                    extras: e.extras.clone(),
573                })
574                .collect();
575            out.sort_by(|a, b| a.name.cmp(&b.name));
576            out
577        }
578    }
579}
580
581// ---------- registry-wide helpers ------------------------------------------
582
583#[derive(Debug, Clone)]
584pub struct DueRow {
585    pub control_id: String,
586    pub cadence: Cadence,
587    pub next_due: Option<NaiveDate>,
588    pub overdue: bool,
589}
590
591/// Compute next-due rows for every control in `reg` as of `today`. Sorted
592/// by `(next_due ascending, control_id)`; controls without a computable
593/// firing date come last.
594pub fn due_rows(reg: &LoadedRegistry, today: NaiveDate) -> Vec<DueRow> {
595    let mut rows: Vec<DueRow> = reg
596        .controls
597        .values()
598        .map(|c| {
599            let state = reg.state.controls.get(&c.id);
600            let next = next_due(
601                c,
602                &reg.schedule,
603                state,
604                today,
605                reg.config.weekly_default_weekday,
606            );
607            let overdue = next.map(|d| is_overdue(c, d, today)).unwrap_or(false);
608            DueRow {
609                control_id: c.id.clone(),
610                cadence: c.cadence,
611                next_due: next,
612                overdue,
613            }
614        })
615        .collect();
616    rows.sort_by(|a, b| match (a.next_due, b.next_due) {
617        (Some(x), Some(y)) => (x, &a.control_id).cmp(&(y, &b.control_id)),
618        (Some(_), None) => std::cmp::Ordering::Less,
619        (None, Some(_)) => std::cmp::Ordering::Greater,
620        (None, None) => a.control_id.cmp(&b.control_id),
621    });
622    rows
623}
624
625/// Return controls due within `window` days of `today` (inclusive).
626pub fn due_within(reg: &LoadedRegistry, today: NaiveDate, window_days: i64) -> Vec<DueRow> {
627    let cutoff = today + Duration::days(window_days);
628    due_rows(reg, today)
629        .into_iter()
630        .filter(|r| match r.next_due {
631            Some(d) => d <= cutoff,
632            None => false,
633        })
634        .collect()
635}