secunit-core 0.7.1

Registry, evidence, hashing, and verification primitives for secunit.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
//! Cadence resolution and scope expansion.
//!
//! Pure functions over the loaded model. Cadence math follows the table
//! in `docs/storage.md`; scope follows the inventory + tag-filter rules in
//! the same doc. Anything date-shaped enters as `chrono::NaiveDate` so
//! tests can pin "today" deterministically.

use std::collections::HashSet;

use chrono::{Datelike, Duration, NaiveDate};
use serde::{Deserialize, Serialize};

use crate::model::{
    Cadence, Control, Inventory, LoadedRegistry, ResolvedSystem, Schedule, Scope, StateEntry,
    Weekday,
};

// ---------- due resolution --------------------------------------------------

/// Why a particular firing date won — i.e. which input to the resolver
/// produced it. The CLI surfaces this via `secunit due --why`; the GUI
/// renders it as a chip on the Schedule view.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DueReason {
    /// The cadence rules produced the date with no override in play.
    Cadence,
    /// A `schedule.yaml` override pinned a specific date for this control.
    OverrideDue,
    /// A `schedule.yaml` insert added a one-off firing.
    OverrideInsert,
    /// A `schedule.yaml` override changed the weekday a weekly cadence
    /// fires on. The date is still cadence-derived; the weekday is the
    /// operator's pick.
    OverrideWeekday,
}

/// A firing date with provenance and (where the override carried one)
/// the operator's note.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DueResolution {
    pub date: NaiveDate,
    pub reason: DueReason,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,
}

// ---------- cadence ---------------------------------------------------------

/// Compute the next firing date for `control` on or after `today`,
/// taking schedule overrides and the last-run pointer into account.
///
/// Thin facade over [`next_due_with_reason`]; callers that need to
/// know *why* a date won (the GUI's Schedule view, a future
/// `secunit due --why` flag) should call the richer version directly.
pub fn next_due(
    control: &Control,
    schedule: &Schedule,
    state: Option<&StateEntry>,
    today: NaiveDate,
    config_default_weekday: Option<Weekday>,
) -> Option<NaiveDate> {
    next_due_with_reason(control, schedule, state, today, config_default_weekday).map(|r| r.date)
}

/// Like [`next_due`] but returns the date together with the
/// [`DueReason`] that produced it and the override's note (if any).
///
/// Precedence rules:
///   * Earliest date wins; on a tie, override sources beat cadence:
///     insert > dated > weekday > cadence.
///   * A dated override (`due:`) earlier than the cadence date therefore
///     wins outright. One later than the cadence date is a **bounded
///     postponement**: it defers the upcoming firing, but only as far as
///     the next firing after it — a pin can never swallow more than one
///     cadence firing. A pin further out than that leaves the nearer
///     firings untouched and only postpones the firing immediately
///     before it, once its turn comes. Use `skip:` to remove whole
///     windows; change the control's `due_by`/cadence for a recurring
///     shift.
///   * A skip override removes the cadence firing for the matching
///     window; the next-earliest insert (if any) takes its place.
pub fn next_due_with_reason(
    control: &Control,
    schedule: &Schedule,
    state: Option<&StateEntry>,
    today: NaiveDate,
    config_default_weekday: Option<Weekday>,
) -> Option<DueResolution> {
    // Weekday override only changes the cadence-derived date for
    // weekly controls — capture the note so the cadence candidate can
    // pick it up if it ends up labelled `OverrideWeekday`.
    let weekday_override_entry = schedule
        .overrides
        .iter()
        .find(|o| o.control_id == control.id && o.weekday.is_some());
    let weekday_override = weekday_override_entry.and_then(|o| o.weekday);
    let weekday_note =
        weekday_override_entry.and_then(|o| o.note.clone().or_else(|| o.reason.clone()));
    let effective_weekday = effective_weekday(control, schedule, config_default_weekday);

    // Dated overrides (`due:` pins) for this control, collected once —
    // the stale-miss guard and the candidate race both consult them.
    let pins: Vec<(NaiveDate, Option<String>)> = schedule
        .overrides
        .iter()
        .filter(|o| o.control_id == control.id)
        .filter_map(|o| {
            o.due
                .map(|d| (d, o.note.clone().or_else(|| o.reason.clone())))
        })
        .collect();

    // Does pin `p` legally postpone an obligation at `from`? Only if no
    // other cadence firing falls in between — a pin defers exactly one
    // firing, never a stretch of them.
    let pin_defers = |from: NaiveDate, p: NaiveDate| -> bool {
        from <= p
            && match nominal_firing_after(control, effective_weekday, from) {
                Some(nf) => nf > p,
                None => true,
            }
    };

    // A cached next_due in the past means the obligation came due and no
    // finalize advanced it — the control is still due on that date, and
    // once past its grace window it is overdue. Rolling forward here
    // would silently forgive every miss and leave `is_overdue` (and the
    // grace table) unreachable for cadence-driven controls. Two schedule
    // directives sanction the miss and fall through to the normal
    // forward computation: a skip covering the missed date, or a pending
    // dated override that postpones that same obligation.
    if let Some(stale) = state.and_then(|s| s.next_due) {
        if stale < today && !skip_covers(control, schedule, stale) {
            let rescheduled = pins
                .iter()
                .any(|(p, _)| *p >= today && pin_defers(stale, *p));
            if !rescheduled {
                // If the obligation was postponed and the new date has
                // also passed, the pinned date is the missed obligation —
                // report it with override provenance and the operator's
                // note, not the older cadence date.
                let missed_pin = pins
                    .iter()
                    .filter(|(p, _)| *p < today && pin_defers(stale, *p))
                    .min_by_key(|(p, _)| *p);
                return Some(match missed_pin {
                    Some((p, note)) => DueResolution {
                        date: *p,
                        reason: DueReason::OverrideDue,
                        note: note
                            .clone()
                            .or_else(|| Some("due date passed without a completed run".into())),
                    },
                    None => DueResolution {
                        date: stale,
                        reason: DueReason::Cadence,
                        note: Some("due date passed without a completed run".into()),
                    },
                });
            }
        }
    }

    // Skip a single firing window if `schedule.yaml` says so.
    let skip_today = skip_covers(control, schedule, today);

    // Candidate buckets, each carrying provenance for the reason field.
    let mut candidates: Vec<DatedCandidate> = Vec::new();

    // Inserts — one-off extra firings. Note precedence: explicit
    // entry note → insert's own reason → entry-level reason. This
    // covers both the YAML shape `entry.note: "x"` and the more
    // common `insert: { run_at, reason: "x" }`.
    for ov in schedule
        .overrides
        .iter()
        .filter(|o| o.control_id == control.id)
    {
        if let Some(insert) = &ov.insert {
            if insert.run_at >= today {
                candidates.push(DatedCandidate {
                    date: insert.run_at,
                    reason: DueReason::OverrideInsert,
                    note: ov
                        .note
                        .clone()
                        .or_else(|| insert.reason.clone())
                        .or_else(|| ov.reason.clone()),
                    precedence: 0,
                });
            }
        }
    }

    // Dated overrides — pinned `due:` dates still ahead of us.
    for (p, note) in &pins {
        if *p >= today {
            candidates.push(DatedCandidate {
                date: *p,
                reason: DueReason::OverrideDue,
                note: note.clone(),
                precedence: 1,
            });
        }
    }

    // Cadence-derived date. A cached future `state.next_due` is
    // authoritative for every cadence: finalize wrote it as the next
    // nominal obligation after the sealed run, and recomputing from
    // `today` instead would re-arm the period the run just satisfied
    // (the month-anchored arms below can only say "when is the current
    // period due", never "when is the next firing"). Without a fresh
    // cache, fall back to the current period's target, accounting for
    // any weekday override that applies to a weekly cadence.
    let cached_next = state.and_then(|s| s.next_due).filter(|d| *d >= today);
    let cadence_due = match control.cadence {
        Cadence::Continuous => None,
        _ => cached_next.or_else(|| {
            Some(match control.cadence {
                Cadence::Continuous => unreachable!("handled above"),
                Cadence::Weekly => next_weekly(today, effective_weekday),
                Cadence::Monthly => next_business_day(today, monthly_anchor(today)),
                Cadence::Quarterly => next_business_day(today, quarterly_anchor(today)),
                Cadence::SemiAnnual => next_business_day(today, semiannual_anchor(today)),
                Cadence::Annual => next_annual(today, control.due_by.as_deref()),
            })
        }),
    };

    // A pending pin that legally postpones the upcoming cadence firing
    // suppresses it — that is the deferral. A pin further out (with
    // another firing in between) leaves the cadence candidate in the
    // race, so nearer firings are never silently swallowed. A pin
    // earlier than the cadence date needs no suppression: it wins the
    // earliest-date race on its own.
    let deferred = |d: NaiveDate| pins.iter().any(|(p, _)| *p >= today && pin_defers(d, *p));

    if let Some(d) = cadence_due.filter(|d| !deferred(*d)) {
        let weekday_active =
            matches!(control.cadence, Cadence::Weekly) && weekday_override.is_some();
        let (reason, note, precedence) = if weekday_active {
            (DueReason::OverrideWeekday, weekday_note.clone(), 2u8)
        } else {
            (DueReason::Cadence, None, 3u8)
        };
        candidates.push(DatedCandidate {
            date: d,
            reason,
            note,
            precedence,
        });
    }

    // Pick the earliest date; on ties, lower precedence index wins
    // (insert > dated > weekday > cadence).
    let winner = candidates
        .iter()
        .min_by(|a, b| a.date.cmp(&b.date).then(a.precedence.cmp(&b.precedence)))
        .cloned();

    let winner = winner?;

    if skip_today && winner.reason == DueReason::Cadence {
        // Cadence firing is skipped — fall back to the earliest insert
        // (if any). Dated overrides survive a skip; only the cadence
        // window is removed, per the spec's `skip` semantics.
        return candidates
            .into_iter()
            .filter(|c| c.reason == DueReason::OverrideInsert)
            .min_by_key(|c| c.date)
            .map(Into::into);
    }

    Some(winner.into())
}

#[derive(Debug, Clone)]
struct DatedCandidate {
    date: NaiveDate,
    reason: DueReason,
    note: Option<String>,
    /// Lower wins when dates tie. 0=insert, 1=dated, 2=weekday, 3=cadence.
    precedence: u8,
}

impl From<DatedCandidate> for DueResolution {
    fn from(c: DatedCandidate) -> Self {
        DueResolution {
            date: c.date,
            reason: c.reason,
            note: c.note,
        }
    }
}

/// Does any `schedule.yaml` skip directive for this control cover `date`?
fn skip_covers(control: &Control, schedule: &Schedule, date: NaiveDate) -> bool {
    schedule
        .overrides
        .iter()
        .filter(|o| o.control_id == control.id)
        .any(|o| {
            if let Some(skip) = &o.skip {
                if let Some(q) = &skip.quarter {
                    return quarter_string(date) == *q;
                }
                if let Some(y) = skip.year {
                    return date.year() == y;
                }
            }
            false
        })
}

/// Has the control passed its grace window?
pub fn is_overdue(control: &Control, due: NaiveDate, today: NaiveDate) -> bool {
    today > due + grace(control.cadence)
}

/// Per-cadence grace period after which a due control is overdue.
pub fn grace(cadence: Cadence) -> Duration {
    match cadence {
        Cadence::Continuous => Duration::days(0),
        Cadence::Weekly => Duration::days(3),
        Cadence::Monthly => Duration::days(7),
        Cadence::Quarterly => Duration::days(14),
        Cadence::SemiAnnual => Duration::days(21),
        Cadence::Annual => Duration::days(30),
    }
}

/// The next nominal cadence firing strictly after `after`, resolving the
/// effective weekday (schedule override > control > config default) and
/// rolling past firings whose window a `skip:` directive removes.
///
/// This is the date finalize caches as `state.next_due` once a run has
/// satisfied the current obligation. Pins and inserts are deliberately
/// NOT baked in — the read-side candidate race in
/// [`next_due_with_reason`] applies overrides dynamically, so the cache
/// stays a pure cadence fact. `None` for continuous cadence.
pub fn next_firing_after(
    control: &Control,
    schedule: &Schedule,
    after: NaiveDate,
    config_default_weekday: Option<Weekday>,
) -> Option<NaiveDate> {
    let effective_weekday = effective_weekday(control, schedule, config_default_weekday);
    let mut d = nominal_firing_after(control, effective_weekday, after)?;
    // Roll past skip-removed windows. Bounded: a year-wide skip on a
    // weekly control is ~52 firings; past the bound, return the last
    // computed date rather than loop forever on a pathological schedule.
    for _ in 0..256 {
        if !skip_covers(control, schedule, d) {
            return Some(d);
        }
        d = nominal_firing_after(control, effective_weekday, d)?;
    }
    Some(d)
}

/// The weekday a weekly control fires on: schedule override > control's
/// own `weekday` > config default > Monday. Single source of the chain
/// so [`next_due_with_reason`] and [`next_firing_after`] cannot drift.
fn effective_weekday(
    control: &Control,
    schedule: &Schedule,
    config_default_weekday: Option<Weekday>,
) -> Weekday {
    schedule
        .overrides
        .iter()
        .find(|o| o.control_id == control.id && o.weekday.is_some())
        .and_then(|o| o.weekday)
        .or(control.weekday)
        .or(config_default_weekday)
        .unwrap_or(Weekday::Monday)
}

/// The first nominal cadence firing strictly after `d`, ignoring state
/// and overrides. `None` for continuous cadence, which never fires.
///
/// This is the yardstick for bounded postponement: a `due:` pin may
/// defer an obligation only up to (not past) the firing that follows it.
fn nominal_firing_after(control: &Control, weekday: Weekday, d: NaiveDate) -> Option<NaiveDate> {
    let after = d + Duration::days(1);
    match control.cadence {
        Cadence::Continuous => None,
        Cadence::Weekly => Some(next_weekly(after, weekday)),
        Cadence::Monthly => {
            let this = first_business_day(monthly_anchor(after));
            Some(if this > d {
                this
            } else {
                let (y, m) = if after.month() == 12 {
                    (after.year() + 1, 1)
                } else {
                    (after.year(), after.month() + 1)
                };
                first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
            })
        }
        Cadence::Quarterly => {
            let this = first_business_day(quarterly_anchor(after));
            Some(if this > d {
                this
            } else {
                let anchor = quarterly_anchor(after);
                let (y, m) = if anchor.month() == 10 {
                    (anchor.year() + 1, 1)
                } else {
                    (anchor.year(), anchor.month() + 3)
                };
                first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
            })
        }
        Cadence::SemiAnnual => {
            let this = first_business_day(semiannual_anchor(after));
            Some(if this > d {
                this
            } else {
                let anchor = semiannual_anchor(after);
                let (y, m) = if anchor.month() == 7 {
                    (anchor.year() + 1, 1)
                } else {
                    (anchor.year(), 7)
                };
                first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
            })
        }
        Cadence::Annual => Some(next_annual(after, control.due_by.as_deref())),
    }
}

fn first_business_day(anchor: NaiveDate) -> NaiveDate {
    let mut d = anchor;
    while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
        d += Duration::days(1);
    }
    d
}

fn next_weekly(today: NaiveDate, weekday: Weekday) -> NaiveDate {
    let target = weekday.to_chrono().num_days_from_monday() as i64;
    let cur = today.weekday().num_days_from_monday() as i64;
    let mut delta = target - cur;
    if delta < 0 {
        delta += 7;
    }
    today + Duration::days(delta)
}

fn monthly_anchor(today: NaiveDate) -> NaiveDate {
    NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap()
}

fn quarterly_anchor(today: NaiveDate) -> NaiveDate {
    let q_first = match today.month() {
        1..=3 => 1,
        4..=6 => 4,
        7..=9 => 7,
        _ => 10,
    };
    NaiveDate::from_ymd_opt(today.year(), q_first, 1).unwrap()
}

fn semiannual_anchor(today: NaiveDate) -> NaiveDate {
    let m = if today.month() <= 6 { 1 } else { 7 };
    NaiveDate::from_ymd_opt(today.year(), m, 1).unwrap()
}

fn next_annual(today: NaiveDate, due_by: Option<&str>) -> NaiveDate {
    if let Some(due) = due_by {
        if let Some(d) = parse_due_by(due, today.year()) {
            if d >= today {
                return d;
            }
            return parse_due_by(due, today.year() + 1).unwrap_or(d);
        }
    }
    NaiveDate::from_ymd_opt(today.year(), 12, 31).unwrap_or(today)
}

fn parse_due_by(s: &str, year: i32) -> Option<NaiveDate> {
    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
        return Some(d);
    }
    let mut parts = s.splitn(2, '-');
    let month = parts.next()?;
    let day: u32 = parts.next()?.parse().ok()?;
    let m = match month.to_lowercase().as_str() {
        "january" | "jan" => 1,
        "february" | "feb" => 2,
        "march" | "mar" => 3,
        "april" | "apr" => 4,
        "may" => 5,
        "june" | "jun" => 6,
        "july" | "jul" => 7,
        "august" | "aug" => 8,
        "september" | "sep" => 9,
        "october" | "oct" => 10,
        "november" | "nov" => 11,
        "december" | "dec" => 12,
        _ => return None,
    };
    NaiveDate::from_ymd_opt(year, m, day)
}

fn next_business_day(today: NaiveDate, anchor: NaiveDate) -> NaiveDate {
    let mut d = anchor.max(today);
    while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
        d += Duration::days(1);
    }
    if d < today {
        // Anchor is in the past — push to next month/quarter window.
        // Caller can re-anchor; here we just bump by a month as a safe
        // default.
        return today;
    }
    d
}

fn quarter_string(date: NaiveDate) -> String {
    let q = (date.month() - 1) / 3 + 1;
    format!("{:04}-q{}", date.year(), q)
}

// ---------- scope -----------------------------------------------------------

/// Expand a control's scope against the inventory on the given run date.
pub fn resolve_scope(
    control: &Control,
    inventory: &Inventory,
    run_date: NaiveDate,
) -> Vec<ResolvedSystem> {
    match &control.scope {
        None => Vec::new(),
        Some(Scope::Inline(inline)) => inline
            .inline
            .iter()
            .map(|e| ResolvedSystem {
                name: e.name.clone(),
                kind: e.kind.clone(),
                tags: e.tags.clone(),
                extras: Default::default(),
            })
            .collect(),
        Some(Scope::Inventory(spec)) => {
            let entries = inventory.entries(&spec.kind);
            let want_tags: HashSet<&str> = spec.has_tags.iter().map(String::as_str).collect();
            let control_excludes: HashSet<&str> =
                spec.excludes.iter().map(String::as_str).collect();
            let all = spec.all.unwrap_or(false);

            let mut out: Vec<ResolvedSystem> = entries
                .iter()
                .filter(|e| e.is_active_on(run_date))
                .filter(|e| {
                    if all {
                        true
                    } else {
                        let entry_tags: HashSet<&str> = e.tags.iter().map(String::as_str).collect();
                        want_tags.iter().all(|t| entry_tags.contains(t))
                    }
                })
                .filter(|e| !control_excludes.contains(e.name.as_str()))
                .filter(|e| !e.excludes.iter().any(|s| s == &control.skill))
                .map(|e| ResolvedSystem {
                    name: e.name.clone(),
                    kind: spec.kind.clone(),
                    tags: e.tags.clone(),
                    extras: e.extras.clone(),
                })
                .collect();
            out.sort_by(|a, b| a.name.cmp(&b.name));
            out
        }
    }
}

// ---------- registry-wide helpers ------------------------------------------

#[derive(Debug, Clone)]
pub struct DueRow {
    pub control_id: String,
    pub cadence: Cadence,
    pub next_due: Option<NaiveDate>,
    pub overdue: bool,
}

/// Compute next-due rows for every control in `reg` as of `today`. Sorted
/// by `(next_due ascending, control_id)`; controls without a computable
/// firing date come last.
pub fn due_rows(reg: &LoadedRegistry, today: NaiveDate) -> Vec<DueRow> {
    let mut rows: Vec<DueRow> = reg
        .controls
        .values()
        .map(|c| {
            let state = reg.state.controls.get(&c.id);
            let next = next_due(
                c,
                &reg.schedule,
                state,
                today,
                reg.config.weekly_default_weekday,
            );
            let overdue = next.map(|d| is_overdue(c, d, today)).unwrap_or(false);
            DueRow {
                control_id: c.id.clone(),
                cadence: c.cadence,
                next_due: next,
                overdue,
            }
        })
        .collect();
    rows.sort_by(|a, b| match (a.next_due, b.next_due) {
        (Some(x), Some(y)) => (x, &a.control_id).cmp(&(y, &b.control_id)),
        (Some(_), None) => std::cmp::Ordering::Less,
        (None, Some(_)) => std::cmp::Ordering::Greater,
        (None, None) => a.control_id.cmp(&b.control_id),
    });
    rows
}

/// Return controls due within `window` days of `today` (inclusive).
pub fn due_within(reg: &LoadedRegistry, today: NaiveDate, window_days: i64) -> Vec<DueRow> {
    let cutoff = today + Duration::days(window_days);
    due_rows(reg, today)
        .into_iter()
        .filter(|r| match r.next_due {
            Some(d) => d <= cutoff,
            None => false,
        })
        .collect()
}