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}