Skip to main content

tix/cycle_schedule/
evaluate.rs

1//! Read-only ended/due/look-ahead evaluation (WP03, T013–T017).
2//!
3//! # A refresh is a question, and this module answers exactly that
4//!
5//! A user's refresh asks: **"something is ended or due — what now?"** [`evaluate`]
6//! takes the index WP02 already built ([`ScheduleIndex`]) and an **injected** local
7//! date ([`RuleContext`]) and returns the ordered [`ScheduleWarning`]s a surface can
8//! show. It opens no file, reads no clock, writes no configuration or frontmatter,
9//! advances no cursor and appends no History — `C-004` says detection on refresh is
10//! read-only, and the only way to guarantee that is to give it nothing to write with:
11//! no `Store`, no clock, no `&mut` of anything.
12//!
13//! The date is a parameter and not a call for the same reason every other date in
14//! this crate is: a test that pins the context is testing the arithmetic, not the
15//! machine it runs on, and a start-time warning and a headless run agree because
16//! both were handed the same date (`C-008`).
17//!
18//! # One indexed pass, over the index that already exists
19//!
20//! [`ScheduleIndex::build`] is the one pass over configuration and cycle records;
21//! this module never repeats it. Every question here is a map lookup or a step
22//! through the shared arithmetic owner ([`cadence_rule`] and `RecurrenceRule`), and
23//! the whole pass visits each schedule once, in declaration order (T013). That is
24//! what makes evaluation over 10,000 cycles a single indexed pass (NFR-001) rather
25//! than a re-scan per schedule.
26//!
27//! # Four kinds of warning, and what makes each correct rather than plausible
28//!
29//! | kind | the fact | actions offered |
30//! |---|---|---|
31//! | [`ScheduleWarningKind::Ended`] | an `active` instance of the schedule has `due_date < today` | `close`, `extend`, `ignore` |
32//! | [`ScheduleWarningKind::Due`] | an `active` instance has `due_date == today` | `close`, `extend`, `ignore` |
33//! | [`ScheduleWarningKind::LookAhead`] | a candidate date that does not exist yet, up to the schedule's `look_ahead` count | `generate`, `ignore` |
34//! | [`ScheduleWarningKind::Failure`] | the schedule could not be evaluated | **none** — it is a diagnostic |
35//!
36//! `ended`/`due` are **observed**, never inferred: the instance carries its own
37//! `state` and `due_date`, and the comparison against the injected date is a plain
38//! string comparison on the canonical `YYYY-MM-DD` form, which sorts chronologically
39//! (that is the property `DateKey` promises). Nothing here asks the cadence whether a
40//! cycle "must have" ended.
41//!
42//! A **failure is never a transition.** A schedule whose cadence cannot be resolved
43//! to a valid rule produces failure warnings and nothing else: an ended/due answer
44//! derived from a schedule that cannot be evaluated would be exactly the guess this
45//! mission forbids. An unresolvable *candidate date* on an otherwise valid schedule
46//! produces its observed ended/due warnings plus a failure, because those are facts
47//! about instances and do not depend on the derivation.
48//!
49//! The look-ahead candidates come from [`ScheduleIndex::next_key`] (T015/C-004) — the
50//! same value a confirmed generation would create — and each further candidate is one
51//! step of the shared rule, so this module never becomes a second scheduler. A
52//! candidate's own end date is *not* computed here: the duration arithmetic that turns
53//! `(start_date, duration)` into a cycle's effective range belongs to the confirmed
54//! generation that creates it (WP04), and inventing one here would be a second owner
55//! of that rule.
56//!
57//! # The shared seam
58//!
59//! This is the one evaluator every surface calls on start/refresh (T016): the TUI's
60//! startup warning dialog, the CLI's detect command, MCP and `tix lint` all render
61//! *these* warnings rather than each recomputing one (`C-008`). They receive a value —
62//! never a transition — and a decision remains an explicit, confirmed operation
63//! (WP04/WP05).
64
65use time::{Date, Month};
66
67use crate::cycle_schedule::config::{CycleScheduleConfiguration, CycleScheduleId};
68use crate::cycle_schedule::identity::{
69    GeneratedCycle, GeneratedCycleId, NextCycleKey, ScheduleIndex, cadence_rule,
70};
71use crate::error::render_diagnostics;
72use crate::recurrence::rule::{DateKey, RecurrenceRule, RuleContext, RuleDiagnostic};
73
74/// The kind of question a refresh warning asks (`data-model.md` §5).
75///
76/// The spelling is the wire vocabulary and lives in exactly one place —
77/// [`Self::as_str`] — so `look-ahead`, the one kebab spelling the data model fixes,
78/// cannot drift from the payload.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80pub enum ScheduleWarningKind {
81    /// An `active` instance of the schedule ended (`due_date < today`) and nobody
82    /// closed it.
83    Ended,
84    /// An `active` instance ends today (`due_date == today`).
85    Due,
86    /// A candidate date that does not exist yet.
87    LookAhead,
88    /// The schedule could not be evaluated; the finding is the payload
89    /// ([`ScheduleWarning::diagnostic`]).
90    Failure,
91}
92
93impl ScheduleWarningKind {
94    /// Every kind, in the order `evaluate` sorts warnings that share a sequence.
95    pub const ALL: [Self; 4] = [Self::Ended, Self::Due, Self::LookAhead, Self::Failure];
96
97    /// The contract's spelling, used verbatim in payloads and tests.
98    pub const fn as_str(self) -> &'static str {
99        match self {
100            Self::Ended => "ended",
101            Self::Due => "due",
102            Self::LookAhead => "look-ahead",
103            Self::Failure => "failure",
104        }
105    }
106
107    /// Whether the kind accepts an explicit decision.
108    ///
109    /// A failure never does — it is a diagnostic, not a question, and a surface that
110    /// offered a button for it would be inviting a transition nothing can justify.
111    pub const fn offers_actions(self) -> bool {
112        !matches!(self, Self::Failure)
113    }
114
115    /// Where the kind sits in the fixed order (see [`CycleEvaluation`]).
116    const fn rank(self) -> u8 {
117        match self {
118            Self::Ended => 0,
119            Self::Due => 1,
120            Self::LookAhead => 2,
121            Self::Failure => 3,
122        }
123    }
124}
125
126/// One explicit decision a warning accepts (`data-model.md` §5).
127///
128/// The list is the whole vocabulary the data model defines, so no surface invents a
129/// sixth answer; every answer is carried out by a *confirmed* operation, never by
130/// evaluation. [`Self::Skip`] is part of that vocabulary but no kind this read-only
131/// pass produces offers it — skipping a candidate is a lifecycle decision (WP05)
132/// rather than a question about an ended or due instance.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
134pub enum WarningAction {
135    /// Close the instance, capturing its immutable snapshot (WP05).
136    Close,
137    /// Push the instance's due date out (WP05).
138    Extend,
139    /// Record the sequence as skipped so it is never regenerated (WP05).
140    Skip,
141    /// Close the warning without deciding anything.
142    Ignore,
143    /// Create the candidate instance (WP04, confirmed).
144    Generate,
145}
146
147impl WarningAction {
148    /// Every action, in the data model's order.
149    pub const ALL: [Self; 5] = [
150        Self::Close,
151        Self::Extend,
152        Self::Skip,
153        Self::Ignore,
154        Self::Generate,
155    ];
156
157    /// The actions an ended or due instance accepts.
158    pub const INSTANCE: [Self; 3] = [Self::Close, Self::Extend, Self::Ignore];
159
160    /// The actions a not-yet-generated candidate accepts.
161    pub const CANDIDATE: [Self; 2] = [Self::Generate, Self::Ignore];
162
163    /// The stable spelling the CLI and the MCP tools print.
164    pub const fn as_str(self) -> &'static str {
165        match self {
166            Self::Close => "close",
167            Self::Extend => "extend",
168            Self::Skip => "skip",
169            Self::Ignore => "ignore",
170            Self::Generate => "generate",
171        }
172    }
173}
174
175/// The range an existing instance spans, for the warning that is about it.
176///
177/// Present exactly when the warning is about an instance that exists, which is why
178/// a look-ahead candidate (nothing exists yet) has none: its start date lives in
179/// [`ScheduleWarning::next_generation`], and its end date is the confirmed
180/// generation's to compute.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct EffectiveDates {
183    /// The instance's effective start date, `YYYY-MM-DD`.
184    pub start: String,
185    /// The instance's effective due date, `YYYY-MM-DD`.
186    pub due: String,
187}
188
189/// What one failure finding is, spelled in `snake_case` for payloads and tests.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum ScheduleDiagnosticKind {
192    /// The schedule names no cadence family, so no rule can be resolved.
193    MissingCadence,
194    /// The cadence resolves to a rule the shared validator refuses.
195    InvalidCadence,
196    /// A date the evaluation needs — an instance's or a candidate's — is not a
197    /// canonical `YYYY-MM-DD` date, or the arithmetic produced none.
198    UnresolvableDate,
199}
200
201impl ScheduleDiagnosticKind {
202    /// The contract's spelling.
203    pub const fn as_str(self) -> &'static str {
204        match self {
205            Self::MissingCadence => "missing_cadence",
206            Self::InvalidCadence => "invalid_cadence",
207            Self::UnresolvableDate => "unresolvable_date",
208        }
209    }
210}
211
212/// One field-aware failure finding: what is wrong, which configuration field says
213/// so, and one sentence a user can act on.
214///
215/// Rendered through the crate's one diagnostic renderer, so a refresh warning and a
216/// `tix lint` finding spell the same refusal the same way.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct ScheduleDiagnostic {
219    /// What went wrong.
220    pub kind: ScheduleDiagnosticKind,
221    /// The configuration field path, e.g. `cycle_schedules.cs-1.cadence.kind`.
222    pub field: String,
223    /// The user-facing sentence; it already leads with the field, so the renderer's
224    /// kind prefix completes the line.
225    pub message: String,
226}
227
228impl ScheduleDiagnostic {
229    /// The finding for a schedule that names no cadence family.
230    fn missing_cadence(schedule: &CycleScheduleId) -> Self {
231        Self {
232            kind: ScheduleDiagnosticKind::MissingCadence,
233            field: format!("cycle_schedules.{schedule}.cadence.kind"),
234            message: format!(
235                "cadence.kind: schedule `{schedule}` names no cadence family, so no cadence \
236                 can be resolved"
237            ),
238        }
239    }
240
241    /// The finding for a cadence the shared validator refuses, carrying the
242    /// validator's own field and sentence.
243    ///
244    /// The rule's field names are not all configuration paths: a rule's `anchor` is
245    /// the schedule's `start_date`, and the rule's `timezone` is the schedule's own
246    /// key rather than a cadence field.
247    fn invalid_cadence(schedule: &CycleScheduleId, diagnostic: &RuleDiagnostic) -> Self {
248        let field = match diagnostic.field {
249            "anchor" => "start_date".to_string(),
250            "timezone" => "timezone".to_string(),
251            other => format!("cadence.{other}"),
252        };
253        Self {
254            kind: ScheduleDiagnosticKind::InvalidCadence,
255            field: format!("cycle_schedules.{schedule}.{field}"),
256            message: diagnostic.message.clone(),
257        }
258    }
259
260    /// The finding for an instance whose stored date is not a canonical date.
261    fn unreadable_date(cycle: &GeneratedCycle, field: &str, value: &str) -> Self {
262        Self {
263            kind: ScheduleDiagnosticKind::UnresolvableDate,
264            field: format!("cycles.{}.{field}", cycle.name),
265            message: format!(
266                "{field}: cycle `{}` has `{value}`, which is not a `YYYY-MM-DD` calendar date, \
267                 so its ended/due state cannot be decided",
268                cycle.name
269            ),
270        }
271    }
272
273    /// Render through the crate's one diagnostic renderer.
274    #[must_use]
275    pub fn render(&self) -> String {
276        render_diagnostics(
277            "E-tix-cycle-schedule",
278            std::iter::once((self.kind.as_str(), self.message.as_str())),
279        )
280    }
281}
282
283/// One refresh warning: a question with a closed set of answers, or a diagnostic.
284///
285/// Which fields are meaningful follows the kind, and the two are checked against
286/// each other in this module's tests:
287///
288/// * `ended`/`due` carry the instance in [`Self::cycle_id`] and its range in
289///   [`Self::effective_dates`];
290/// * `look-ahead` carries the candidate a confirmed generation would create in
291///   [`Self::next_generation`] — and no `cycle_id`, because nothing exists yet;
292/// * `failure` carries [`Self::diagnostic`] and no actions.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct ScheduleWarning {
295    /// The schedule the warning is about.
296    pub schedule_id: CycleScheduleId,
297    /// The existing instance the warning is about, when there is one.
298    pub cycle_id: Option<GeneratedCycleId>,
299    /// Which question this is.
300    pub kind: ScheduleWarningKind,
301    /// The existing instance's effective range, when there is one.
302    pub effective_dates: Option<EffectiveDates>,
303    /// The candidate a confirmed generation would create, when the warning is about
304    /// one that does not exist yet (or when a failure could not give it a date).
305    pub next_generation: Option<NextCycleKey>,
306    /// The explicit decisions this warning accepts; empty for a failure.
307    pub available_actions: Vec<WarningAction>,
308    /// The field-aware finding, present exactly for
309    /// [`ScheduleWarningKind::Failure`].
310    pub diagnostic: Option<ScheduleDiagnostic>,
311}
312
313/// What one evaluation pass found (T015).
314///
315/// # Order
316///
317/// Warnings come out in **schedule declaration order, then by sequence, then by
318/// kind** (`ended` before `due` before `look-ahead` before `failure`). Declaration
319/// order is the order the schedules appear in `tix.toml`, so two runs over the same
320/// file produce byte-identical output and a surface can rely on it; sequence order
321/// puts a schedule's earlier instances before its later ones; the kind tie-break
322/// covers the one case sequence alone does not — a failure that is *not* about one
323/// instance (`missing_cadence`/`invalid_cadence`), which has no sequence and
324/// therefore sorts last among its schedule's warnings.
325#[derive(Debug, Clone, Default, PartialEq, Eq)]
326pub struct CycleEvaluation {
327    warnings: Vec<ScheduleWarning>,
328    schedules_examined: usize,
329}
330
331impl CycleEvaluation {
332    /// The warnings, in the order documented on the type.
333    #[must_use]
334    pub fn warnings(&self) -> &[ScheduleWarning] {
335        &self.warnings
336    }
337
338    /// How many schedules the pass looked at, including the ones with nothing to say.
339    #[must_use]
340    pub fn schedules_examined(&self) -> usize {
341        self.schedules_examined
342    }
343
344    /// Whether nothing is ended, due, ahead or broken.
345    #[must_use]
346    pub fn is_empty(&self) -> bool {
347        self.warnings.is_empty()
348    }
349
350    /// The warnings about one schedule.
351    pub fn warnings_of(
352        &self,
353        schedule: &CycleScheduleId,
354    ) -> impl Iterator<Item = &ScheduleWarning> {
355        self.warnings
356            .iter()
357            .filter(move |warning| &warning.schedule_id == schedule)
358    }
359
360    /// The warnings of one kind.
361    pub fn warnings_with_kind(
362        &self,
363        kind: ScheduleWarningKind,
364    ) -> impl Iterator<Item = &ScheduleWarning> {
365        self.warnings
366            .iter()
367            .filter(move |warning| warning.kind == kind)
368    }
369
370    /// Whether any schedule failed to evaluate.
371    #[must_use]
372    pub fn has_failures(&self) -> bool {
373        self.warnings
374            .iter()
375            .any(|warning| warning.kind == ScheduleWarningKind::Failure)
376    }
377}
378
379/// Evaluate every schedule in `index` against the supplied local date (T014).
380///
381/// One pass, in declaration order; no I/O, no clock, no writes. `context` supplies
382/// the local calendar date a caller has already resolved, so the same input always
383/// produces the same output — which is what makes a refresh warning and a headless
384/// run agree (`C-008`).
385///
386/// The context's timezone does not change the answer: a schedule's own validated
387/// offset is the calendar its cadence is expressed in, and date arithmetic on
388/// calendar dates is offset-independent — the same reason
389/// [`ScheduleIndex::next_key`] steps with a timezone-independent context.
390#[must_use]
391pub fn evaluate(index: &ScheduleIndex, context: &RuleContext) -> CycleEvaluation {
392    let today = DateKey::from_date(context.today);
393    let mut warnings = Vec::new();
394    for (schedule, record) in index.schedules() {
395        let mut ordered = evaluate_schedule(index, schedule, record, &today);
396        ordered.sort_by_key(|warning| (warning.sequence.unwrap_or(u32::MAX), warning.kind.rank()));
397        warnings.extend(ordered.into_iter().map(|ordered| ordered.warning));
398    }
399    CycleEvaluation {
400        warnings,
401        schedules_examined: index.schedule_count(),
402    }
403}
404
405/// One warning plus the sequence it belongs to, so a schedule's warnings can be
406/// ordered before they are handed out.
407struct Ordered {
408    sequence: Option<u32>,
409    kind: ScheduleWarningKind,
410    warning: ScheduleWarning,
411}
412
413/// Everything one schedule contributes to a pass.
414fn evaluate_schedule(
415    index: &ScheduleIndex,
416    schedule: &CycleScheduleId,
417    record: &CycleScheduleConfiguration,
418    today: &DateKey,
419) -> Vec<Ordered> {
420    // A schedule with no cadence family cannot be evaluated at all, and the failure
421    // is the whole answer: guessing an ended/due state from a definition nobody can
422    // read is the transition the contract refuses.
423    let Some(rule) = cadence_rule(record) else {
424        return vec![failure(
425            schedule,
426            None,
427            None,
428            ScheduleDiagnostic::missing_cadence(schedule),
429        )];
430    };
431    // The shared validator is authoritative for the cadence, and it collects every
432    // finding: each one becomes its own failure warning, in the validator's
433    // deterministic field order.
434    if let Err(err) = rule.validate() {
435        return err
436            .diagnostics()
437            .iter()
438            .map(|diagnostic| {
439                failure(
440                    schedule,
441                    None,
442                    None,
443                    ScheduleDiagnostic::invalid_cadence(schedule, diagnostic),
444                )
445            })
446            .collect();
447    }
448    let mut warnings = instance_warnings(index, schedule, today);
449    warnings.extend(look_ahead_warnings(index, schedule, record, &rule));
450    warnings
451}
452
453/// The ended/due warnings of a schedule's existing instances.
454fn instance_warnings(
455    index: &ScheduleIndex,
456    schedule: &CycleScheduleId,
457    today: &DateKey,
458) -> Vec<Ordered> {
459    let mut warnings = Vec::new();
460    for cycle in index.cycles_of(schedule) {
461        // Only an `active` instance is a question: a closed or skipped one is a
462        // record, and a paused one has already been answered.
463        if !cycle.is_active() {
464            continue;
465        }
466        // A date that is not a canonical date cannot be compared: the naive string
467        // comparison would answer by accident (and silently ignore an overdue
468        // cycle), so it is a finding instead.
469        if let Some(diagnostic) = unreadable_date(cycle) {
470            warnings.push(failure(schedule, Some(cycle.id.clone()), None, diagnostic));
471            continue;
472        }
473        // Canonical `YYYY-MM-DD` keys sort chronologically as plain strings, so
474        // "overdue" and "ends today" need no parsing (the property `DateKey`
475        // promises, and the reason the identity module keeps dates canonical).
476        let kind = match cycle.due_date.as_str().cmp(today.as_str()) {
477            std::cmp::Ordering::Less => ScheduleWarningKind::Ended,
478            std::cmp::Ordering::Equal => ScheduleWarningKind::Due,
479            // Still running: no question.
480            std::cmp::Ordering::Greater => continue,
481        };
482        warnings.push(Ordered {
483            sequence: Some(cycle.sequence),
484            kind,
485            warning: ScheduleWarning {
486                schedule_id: schedule.clone(),
487                cycle_id: Some(cycle.id.clone()),
488                kind,
489                effective_dates: Some(EffectiveDates {
490                    start: cycle.start_date.clone(),
491                    due: cycle.due_date.clone(),
492                }),
493                next_generation: None,
494                available_actions: WarningAction::INSTANCE.to_vec(),
495                diagnostic: None,
496            },
497        });
498    }
499    warnings
500}
501
502/// The look-ahead warnings of a schedule: the candidates that do not exist yet.
503///
504/// The first comes from [`ScheduleIndex::next_key`] — the same value a confirmed
505/// generation would create — and each further one is a step of the shared rule, so
506/// the list needs no second scheduler. `look_ahead` is the count of candidates
507/// reported, so `look_ahead = 0` reports **none**: a schedule instructed not to look
508/// ahead has no candidate question to ask, and the ended/due questions about the
509/// cycles that already exist are a separate pass.
510///
511/// All-or-nothing: if a step in the middle cannot be resolved, the list is not
512/// reported at all. A candidate list with a hole in it would tell a surface that the
513/// next two cycles start on dates that are not the schedule's.
514fn look_ahead_warnings(
515    index: &ScheduleIndex,
516    schedule: &CycleScheduleId,
517    record: &CycleScheduleConfiguration,
518    rule: &RecurrenceRule,
519) -> Vec<Ordered> {
520    if record.look_ahead == 0 {
521        return Vec::new();
522    }
523    // No candidate is not a failure: an ended schedule and a schedule whose sequence
524    // space is used up both answer `None`, and both are facts about the read model.
525    let Some(first) = index.next_key(schedule) else {
526        return Vec::new();
527    };
528    // Never name a sequence the identity's fixed width cannot hold.
529    let available = GeneratedCycleId::SEQUENCE_LIMIT.saturating_sub(first.sequence);
530    let count = record.look_ahead.min(available);
531
532    let mut warnings = Vec::with_capacity(count as usize);
533    let mut date = first.start_date.clone();
534    for sequence in (first.sequence..).take(count as usize) {
535        // The candidate's date is the one thing this pass must be able to give; when
536        // it cannot, the finding names the field that stopped it.
537        let Some(start) = date.clone() else {
538            // An unreadable date on an *active* instance was already reported as that
539            // instance's own finding; saying the same thing about the same field again
540            // would only bury the one thing to fix. A derivation the instance cannot
541            // explain — the calendar refusing the step — is new information.
542            if last_instance_start_flagged(index, schedule) {
543                return Vec::new();
544            }
545            return vec![failure(
546                schedule,
547                None,
548                Some(candidate(schedule, sequence, None)),
549                unresolved_candidate(index, schedule),
550            )];
551        };
552        warnings.push(Ordered {
553            sequence: Some(sequence),
554            kind: ScheduleWarningKind::LookAhead,
555            warning: ScheduleWarning {
556                schedule_id: schedule.clone(),
557                cycle_id: None,
558                kind: ScheduleWarningKind::LookAhead,
559                effective_dates: None,
560                next_generation: Some(candidate(schedule, sequence, Some(start.clone()))),
561                available_actions: WarningAction::CANDIDATE.to_vec(),
562                diagnostic: None,
563            },
564        });
565        // Only consulted when another candidate is actually wanted, so a failing step
566        // past the last reported candidate is not a failure.
567        date = next_start(rule, &start);
568    }
569    warnings
570}
571
572/// One cadence step after `from`, through the shared arithmetic owner.
573fn next_start(rule: &RecurrenceRule, from: &str) -> Option<String> {
574    let date = as_date(from)?;
575    rule.next_occurrence(&RuleContext::on(date))
576        .ok()
577        .map(|key| key.as_str().to_string())
578}
579
580/// The candidate key a confirmed generation would create for `sequence`.
581fn candidate(
582    schedule: &CycleScheduleId,
583    sequence: u32,
584    start_date: Option<String>,
585) -> NextCycleKey {
586    NextCycleKey {
587        schedule_id: schedule.clone(),
588        sequence,
589        id: GeneratedCycleId::new(schedule, sequence),
590        start_date,
591    }
592}
593
594/// Why a candidate date could not be resolved, naming the field to fix.
595///
596/// The step reads the last instance's start date, so an instance whose date is not a
597/// canonical date is named as the culprit; otherwise the cadence itself is.
598fn unresolved_candidate(index: &ScheduleIndex, schedule: &CycleScheduleId) -> ScheduleDiagnostic {
599    if let Some(cycle) = index.cycles_of(schedule).last()
600        && !crate::model::validate_date(&cycle.start_date)
601    {
602        return ScheduleDiagnostic {
603            kind: ScheduleDiagnosticKind::UnresolvableDate,
604            field: format!("cycles.{}.start_date", cycle.name),
605            message: format!(
606                "start_date: cycle `{}` has `{}`, which is not a `YYYY-MM-DD` calendar date, so \
607                 the next candidate date cannot be derived from it",
608                cycle.name, cycle.start_date
609            ),
610        };
611    }
612    ScheduleDiagnostic {
613        kind: ScheduleDiagnosticKind::UnresolvableDate,
614        field: format!("cycle_schedules.{schedule}.cadence"),
615        message: format!("cadence: schedule `{schedule}` produced no next candidate date"),
616    }
617}
618
619/// Whether the candidate step's blocker is already reported as that instance's own
620/// finding.
621///
622/// The step reads the **last** instance's start date, so that instance is the only one
623/// that can make a candidate date unresolvable while the schedule is otherwise sound.
624/// When it is active, the instance pass reported it; when it is not, nothing has said
625/// the field is wrong yet and the candidate finding must.
626fn last_instance_start_flagged(index: &ScheduleIndex, schedule: &CycleScheduleId) -> bool {
627    index
628        .cycles_of(schedule)
629        .last()
630        .is_some_and(|cycle| cycle.is_active() && !crate::model::validate_date(&cycle.start_date))
631}
632
633/// The finding for an active instance whose stored dates cannot be compared.
634fn unreadable_date(cycle: &GeneratedCycle) -> Option<ScheduleDiagnostic> {
635    for (field, value) in [
636        ("start_date", cycle.start_date.as_str()),
637        ("due_date", cycle.due_date.as_str()),
638    ] {
639        if !crate::model::validate_date(value) {
640            return Some(ScheduleDiagnostic::unreadable_date(cycle, field, value));
641        }
642    }
643    None
644}
645
646/// One failure warning; it never carries actions, because a diagnostic is not a
647/// decision.
648fn failure(
649    schedule: &CycleScheduleId,
650    cycle_id: Option<GeneratedCycleId>,
651    next_generation: Option<NextCycleKey>,
652    diagnostic: ScheduleDiagnostic,
653) -> Ordered {
654    Ordered {
655        // A failure about an instance or a candidate is placed by that sequence, like
656        // every other warning; only a cadence failure has no sequence at all.
657        sequence: cycle_id
658            .as_ref()
659            .map(GeneratedCycleId::sequence)
660            .or_else(|| next_generation.as_ref().map(|key| key.sequence)),
661        kind: ScheduleWarningKind::Failure,
662        warning: ScheduleWarning {
663            schedule_id: schedule.clone(),
664            cycle_id,
665            kind: ScheduleWarningKind::Failure,
666            effective_dates: None,
667            next_generation,
668            available_actions: Vec::new(),
669            diagnostic: Some(diagnostic),
670        },
671    }
672}
673
674/// A canonical `YYYY-MM-DD` date as the shared arithmetic owner's value.
675///
676/// The rule that a stored date is a real calendar date belongs to
677/// [`crate::model::validate_date`]; this is the string→value conversion the shared
678/// [`RuleContext`] needs. The `time` crate's own parser is behind a feature this
679/// crate does not enable, so the same three-field split the identity module uses is
680/// repeated here rather than re-exported.
681fn as_date(value: &str) -> Option<Date> {
682    if !crate::model::validate_date(value) {
683        return None;
684    }
685    let year: i32 = value.get(0..4)?.parse().ok()?;
686    let month: u8 = value.get(5..7)?.parse().ok()?;
687    let day: u8 = value.get(8..10)?.parse().ok()?;
688    Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
689}
690
691#[cfg(test)]
692mod tests {
693    #[allow(clippy::wildcard_imports)]
694    use super::*;
695
696    #[test]
697    fn every_warning_kind_has_a_stable_spelling() {
698        let spellings: Vec<&str> = ScheduleWarningKind::ALL
699            .iter()
700            .map(|kind| kind.as_str())
701            .collect();
702        assert_eq!(
703            spellings,
704            vec!["ended", "due", "look-ahead", "failure"],
705            "the wire vocabulary, with the data model's one kebab spelling"
706        );
707    }
708
709    #[test]
710    fn every_action_has_a_stable_spelling_and_a_fixed_offer_order() {
711        let spellings: Vec<&str> = WarningAction::ALL
712            .iter()
713            .map(|action| action.as_str())
714            .collect();
715        assert_eq!(
716            spellings,
717            vec!["close", "extend", "skip", "ignore", "generate"]
718        );
719        assert_eq!(
720            WarningAction::INSTANCE,
721            [
722                WarningAction::Close,
723                WarningAction::Extend,
724                WarningAction::Ignore
725            ]
726        );
727        assert_eq!(
728            WarningAction::CANDIDATE,
729            [WarningAction::Generate, WarningAction::Ignore]
730        );
731    }
732
733    #[test]
734    fn a_failure_is_the_one_kind_that_offers_no_action() {
735        assert!(ScheduleWarningKind::Ended.offers_actions());
736        assert!(ScheduleWarningKind::Due.offers_actions());
737        assert!(ScheduleWarningKind::LookAhead.offers_actions());
738        assert!(!ScheduleWarningKind::Failure.offers_actions());
739    }
740
741    #[test]
742    fn the_sequence_to_date_conversion_is_the_canonical_one() {
743        assert!(as_date("2026-02-30").is_none(), "not a real calendar date");
744        assert!(as_date("2026-2-3").is_none(), "not canonical");
745        assert!(as_date("").is_none());
746        let date = as_date("2026-01-05").expect("a real date");
747        assert_eq!(date.to_string(), "2026-01-05");
748    }
749
750    #[test]
751    fn a_rule_field_is_translated_to_the_configuration_field_it_came_from() {
752        let schedule = CycleScheduleId::new("cs-1").unwrap();
753        let anchor = RuleDiagnostic {
754            kind: crate::recurrence::rule::RuleDiagnosticKind::InvalidAnchor,
755            field: "anchor",
756            value: "2026-02-30".into(),
757            message: "anchor: `2026-02-30` is not a valid `YYYY-MM-DD` calendar date".into(),
758        };
759        let diagnostic = ScheduleDiagnostic::invalid_cadence(&schedule, &anchor);
760        assert_eq!(diagnostic.field, "cycle_schedules.cs-1.start_date");
761        assert_eq!(diagnostic.kind.as_str(), "invalid_cadence");
762
763        let timezone = RuleDiagnostic {
764            kind: crate::recurrence::rule::RuleDiagnosticKind::UnsupportedTimezone,
765            field: "timezone",
766            value: "Europe/Berlin".into(),
767            message: "timezone: rule `Europe/Berlin` is a named zone".into(),
768        };
769        assert_eq!(
770            ScheduleDiagnostic::invalid_cadence(&schedule, &timezone).field,
771            "cycle_schedules.cs-1.timezone"
772        );
773        assert_eq!(
774            ScheduleDiagnostic::missing_cadence(&schedule).field,
775            "cycle_schedules.cs-1.cadence.kind"
776        );
777    }
778}