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