Skip to main content

tix/recurrence/
rule.rs

1//! Supported recurrence rules: the typed model, its validation, and the
2//! deterministic next-date arithmetic (mission `recurring-issues-01M3C7G7`,
3//! WP01; contract `kitty-specs/recurring-issues-01M3C7G7/contracts/recurrence-rule.md`).
4//!
5//! # The five families
6//!
7//! [`RuleKind`] names exactly the families the mission approves: `daily`,
8//! `weekly`, `monthly`, `interval`, and `cron-like`. A [`RecurrenceRule`] is a
9//! flat, serializable record of that family plus its optional cadence fields —
10//! the shape `data-model.md` §2 defines — so a rule round-trips through issue
11//! frontmatter without a second schema and without becoming code.
12//!
13//! # Everything is injected
14//!
15//! [`RecurrenceRule::next_occurrence`] takes a [`RuleContext`]: the local
16//! calendar date and (optionally) the local timezone the caller resolved. Domain
17//! code never calls `now()` and never consults the system clock, so a test that
18//! pins the context is testing the arithmetic, not the machine it runs on
19//! (research R2, C-004).
20//!
21//! # Refusals are the product
22//!
23//! [`RecurrenceRule::validate`] collects **every** finding as a
24//! [`RuleDiagnostic`] rather than failing on the first, and each diagnostic names
25//! the offending field, its value, and one actionable sentence. The three cases
26//! the mission escalates are all diagnostics rather than guesses:
27//!
28//! * **Month-end and leap-day arithmetic** is defined, not guessed: a monthly
29//!   rule re-anchors on the target month's last day, and the rule is documented
30//!   here (see [`RecurrenceRule::next_occurrence`]). A *stored* anchor that is
31//!   not a real calendar date (`2025-02-29`, `2026-02-30`) is refused with
32//!   [`RuleDiagnosticKind::InvalidAnchor`].
33//! * **Daylight-saving ambiguity** can only come from a named IANA zone, which
34//!   needs a tzdb this crate does not depend on. [`Timezone::Named`] is carried
35//!   but refused with [`RuleDiagnosticKind::UnsupportedTimezone`], and a context
36//!   offset that contradicts the rule's is refused with
37//!   [`RuleDiagnosticKind::TimezoneMismatch`].
38//! * **A general cron expression** needs a parser this mission has not approved
39//!   (`docs/recurrence-dependency-review.md`). The parser seam
40//!   ([`CronParser`]) ships with a bounded implementation over a closed macro
41//!   vocabulary; anything else is refused with
42//!   [`RuleDiagnosticKind::MalformedCronExpression`].
43//!
44//! # Canonical date keys
45//!
46//! Arithmetic produces a [`DateKey`], whose string form is `YYYY-MM-DD`. The
47//! ordering is the point: sorting keys as strings sorts date
48//! keys chronologically, so a caller needs no second comparator (C-002/C-006).
49#![allow(clippy::module_name_repetitions)]
50
51use std::collections::BTreeMap;
52use std::fmt;
53
54use serde::{Deserialize, Deserializer, Serialize, Serializer};
55use time::{Date, Month, UtcOffset};
56
57use crate::error::{TixError, render_diagnostics};
58
59/// The largest interval any family accepts.
60///
61/// The field is bounded so arithmetic stays cheap and the stored value cannot
62/// imply a schedule no user could mean. A hundred years of weekly steps is well
63/// inside it; anything larger is far more likely to be a typo than an intent.
64pub const MAX_INTERVAL: u32 = 1000;
65
66/// The longest cron-like expression accepted, in bytes.
67///
68/// The bounded fallback vocabulary needs six characters; the cap exists so an
69/// expression field can never carry an unbounded payload (T001's "reject
70/// executable payloads").
71pub const MAX_EXPRESSION_LEN: usize = 128;
72
73/// The characters that mark an `expression` as code rather than a schedule.
74///
75/// A cron-like expression is a schedule, not a command line. Rather than hand the
76/// text to a parser and hope it refuses, the obvious shell/comment metacharacters
77/// are refused first, with a diagnostic that says which rule was broken.
78const EXECUTABLE_MARKERS: &[char] = &[';', '`', '$', '|', '&', '\n', '\r', '\0'];
79
80// ---------------------------------------------------------------------------
81// T001 — supported rule kinds and typed representation
82// ---------------------------------------------------------------------------
83
84/// Which recurrence family a rule names (`data-model.md` §2).
85///
86/// The serde spelling is the contract's (`daily`, `weekly`, `monthly`,
87/// `interval`, `cron-like`) because these strings are persisted in issue
88/// frontmatter and read by `tix lint`, the CLI, and MCP.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90#[serde(rename_all = "kebab-case")]
91pub enum RuleKind {
92    /// Every `interval` days (default 1) from the anchor.
93    Daily,
94    /// Every `interval` weeks (default 1) from the anchor, on its weekday.
95    Weekly,
96    /// Every `interval` months (default 1) from the anchor, on its day-of-month.
97    Monthly,
98    /// Every `interval` units from the anchor; the unit is `days`, `weeks`, or
99    /// `months`, and both `interval` and `unit` are required.
100    Interval,
101    /// A cron-like expression, interpreted through [`CronParser`].
102    CronLike,
103}
104
105impl RuleKind {
106    /// The contract's spelling, used verbatim in diagnostics and payloads.
107    pub const fn as_str(self) -> &'static str {
108        match self {
109            RuleKind::Daily => "daily",
110            RuleKind::Weekly => "weekly",
111            RuleKind::Monthly => "monthly",
112            RuleKind::Interval => "interval",
113            RuleKind::CronLike => "cron-like",
114        }
115    }
116
117    /// Whether the family takes a `unit` field.
118    const fn allows_unit(self) -> bool {
119        matches!(self, RuleKind::Interval)
120    }
121
122    /// Whether the family takes an `expression` field.
123    const fn allows_expression(self) -> bool {
124        matches!(self, RuleKind::CronLike)
125    }
126}
127
128/// The unit an [`RuleKind::Interval`] rule steps in.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
130#[serde(rename_all = "kebab-case")]
131pub enum IntervalUnit {
132    /// Step in whole days.
133    Days,
134    /// Step in whole weeks (seven days).
135    Weeks,
136    /// Step in calendar months, re-anchoring on the month's last day.
137    Months,
138}
139
140impl IntervalUnit {
141    /// The contract's spelling.
142    pub const fn as_str(self) -> &'static str {
143        match self {
144            IntervalUnit::Days => "days",
145            IntervalUnit::Weeks => "weeks",
146            IntervalUnit::Months => "months",
147        }
148    }
149}
150
151/// The timezone policy a rule is expressed in (`data-model.md` §2).
152///
153/// Parsing never fails: text that is not a fixed offset is *classified* so that
154/// validation can name it. That is deliberate — a deserializer that returned a
155/// bare `unknown timezone` error would tell a user what `serde` thought, not
156/// which field was wrong or why.
157///
158/// `UTC` and `Z` are accepted as conveniences and canonicalize to `+00:00`.
159#[derive(Debug, Clone, PartialEq, Eq, Hash)]
160pub enum Timezone {
161    /// A fixed UTC offset, e.g. `+01:00`. Supported.
162    Fixed(UtcOffset),
163    /// A named IANA zone, e.g. `Europe/Berlin`. Refused by validation: a
164    /// daylight-saving transition makes a local date ambiguous, and resolving it
165    /// needs a tzdb this crate does not depend on.
166    Named(String),
167    /// Text that looks like an offset attempt (`+1:00`) but is not `±HH:MM`.
168    Invalid(String),
169}
170
171impl Timezone {
172    /// Classify a stored timezone string. Total; validation is what refuses.
173    pub fn parse(raw: &str) -> Self {
174        let value = raw.trim();
175        if value.eq_ignore_ascii_case("utc") || value.eq_ignore_ascii_case("z") {
176            return Timezone::Fixed(UtcOffset::UTC);
177        }
178        if let Some(offset) = parse_offset(value) {
179            return Timezone::Fixed(offset);
180        }
181        if value.starts_with('+') || value.starts_with('-') {
182            return Timezone::Invalid(raw.to_string());
183        }
184        Timezone::Named(raw.to_string())
185    }
186
187    /// Whether validation accepts this policy.
188    pub const fn is_supported(&self) -> bool {
189        matches!(self, Timezone::Fixed(_))
190    }
191}
192
193impl fmt::Display for Timezone {
194    /// Canonical `±HH:MM` (or `±HH:MM:SS` when seconds are present) for a fixed
195    /// offset; the original text for anything else, so a diagnostic quotes what
196    /// the user wrote.
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        match self {
199            Timezone::Fixed(offset) => {
200                let (hours, minutes, seconds) = offset.as_hms();
201                let sign = if hours < 0 || minutes < 0 || seconds < 0 {
202                    '-'
203                } else {
204                    '+'
205                };
206                if seconds == 0 {
207                    write!(
208                        f,
209                        "{sign}{:02}:{:02}",
210                        hours.unsigned_abs(),
211                        minutes.unsigned_abs()
212                    )
213                } else {
214                    write!(
215                        f,
216                        "{sign}{:02}:{:02}:{:02}",
217                        hours.unsigned_abs(),
218                        minutes.unsigned_abs(),
219                        seconds.unsigned_abs()
220                    )
221                }
222            }
223            Timezone::Named(raw) | Timezone::Invalid(raw) => f.write_str(raw),
224        }
225    }
226}
227
228impl Serialize for Timezone {
229    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
230        serializer.serialize_str(&self.to_string())
231    }
232}
233
234impl<'de> Deserialize<'de> for Timezone {
235    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
236        Ok(Timezone::parse(&String::deserialize(deserializer)?))
237    }
238}
239
240/// Parse `±HH:MM` or `±HH:MM:SS`; `None` for anything else.
241///
242/// The offset grammar is nine characters at most and entirely bounded, so it is
243/// parsed here rather than by a `time` feature this crate has not enabled
244/// (`time` is depended on with default features only — see `Cargo.toml`).
245fn parse_offset(value: &str) -> Option<UtcOffset> {
246    let bytes = value.as_bytes();
247    let sign = match bytes.first()? {
248        b'+' => 1_i8,
249        b'-' => -1_i8,
250        _ => return None,
251    };
252    let rest = &bytes[1..];
253    if rest.len() != 5 && rest.len() != 8 {
254        return None;
255    }
256    if rest[2] != b':' {
257        return None;
258    }
259    if rest.len() == 8 && rest[5] != b':' {
260        return None;
261    }
262    let digits: Vec<u8> = rest.iter().copied().filter(|c| *c != b':').collect();
263    if !digits.iter().all(u8::is_ascii_digit) {
264        return None;
265    }
266    let number =
267        |pair: usize| -> i8 { (digits[pair] - b'0') as i8 * 10 + (digits[pair + 1] - b'0') as i8 };
268    let hours = sign * number(0);
269    let minutes = sign * number(2);
270    let seconds = if digits.len() == 6 {
271        sign * number(4)
272    } else {
273        0
274    };
275    // Bound the components **here** rather than leaning on `UtcOffset::from_hms`'s
276    // internal ranges: a fixed offset lies within `±23:59:59`, and `+24:00` is not
277    // a timezone. `from_hms` accepts the hour `24`, so the check is not redundant.
278    if hours.unsigned_abs() > 23 || minutes.unsigned_abs() > 59 || seconds.unsigned_abs() > 59 {
279        return None;
280    }
281    UtcOffset::from_hms(hours, minutes, seconds).ok()
282}
283
284/// A supported schedule definition (`data-model.md` §2).
285///
286/// Field literals are public so a surface can build a rule directly; the
287/// `with_*` builders exist for the common "start from a kind and add fields"
288/// case. Nothing here is executable: `expression` is text a [`CronParser`]
289/// interprets, and `extensions` is inert metadata.
290///
291/// # Unknown metadata (T005)
292///
293/// A key this build does not interpret lands in [`RecurrenceRule::extensions`]
294/// and is written back on the next serialization. A newer `tix` that adds a
295/// cadence field writes it into the same record, and this build preserves it
296/// rather than silently dropping it on the next edit.
297///
298/// # Why `PartialEq` and not `Eq`
299///
300/// `extensions` holds `serde_yaml::Value`, which carries floats and therefore
301/// has no `Eq`. Whole-struct equality is the priority; the missing `Eq` is the
302/// documented consequence.
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304pub struct RecurrenceRule {
305    /// The supported family this rule names.
306    pub kind: RuleKind,
307    /// The cadence, where the family uses one.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub interval: Option<u32>,
310    /// The stable schedule origin, as a canonical `YYYY-MM-DD` date.
311    ///
312    /// Required for every family: an origin is what makes a schedule
313    /// deterministic, and inventing one from the inject context would be exactly
314    /// the silent guess C-009 escalates.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub anchor: Option<String>,
317    /// The explicit timezone policy, or `None` for a timezone-independent
318    /// calendar rule.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub timezone: Option<Timezone>,
321    /// The cron-like expression; only meaningful for [`RuleKind::CronLike`].
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub expression: Option<String>,
324    /// The [`RuleKind::Interval`] unit.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub unit: Option<IntervalUnit>,
327    /// Metadata this build does not interpret, preserved verbatim.
328    #[serde(flatten)]
329    pub extensions: BTreeMap<String, serde_yaml::Value>,
330}
331
332impl RecurrenceRule {
333    /// A bare rule of `kind`, with no cadence fields and no extensions.
334    pub fn new(kind: RuleKind) -> Self {
335        Self {
336            kind,
337            interval: None,
338            anchor: None,
339            timezone: None,
340            expression: None,
341            unit: None,
342            extensions: BTreeMap::new(),
343        }
344    }
345
346    /// Set the cadence.
347    pub fn with_interval(mut self, interval: u32) -> Self {
348        self.interval = Some(interval);
349        self
350    }
351
352    /// Set the anchor, as a `YYYY-MM-DD` string.
353    pub fn with_anchor(mut self, anchor: impl Into<String>) -> Self {
354        self.anchor = Some(anchor.into());
355        self
356    }
357
358    /// Set the timezone policy.
359    pub fn with_timezone(mut self, timezone: Timezone) -> Self {
360        self.timezone = Some(timezone);
361        self
362    }
363
364    /// Set the cron-like expression.
365    pub fn with_expression(mut self, expression: impl Into<String>) -> Self {
366        self.expression = Some(expression.into());
367        self
368    }
369
370    /// Set the interval unit.
371    pub fn with_unit(mut self, unit: IntervalUnit) -> Self {
372        self.unit = Some(unit);
373        self
374    }
375}
376
377// ---------------------------------------------------------------------------
378// T002 / T005 — validation and field-aware diagnostics
379// ---------------------------------------------------------------------------
380
381/// The diagnostic kinds rule validation produces, spelled in `snake_case`.
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub enum RuleDiagnosticKind {
384    /// No anchor was given for a family that requires one.
385    MissingAnchor,
386    /// The anchor text is not a real calendar date.
387    InvalidAnchor,
388    /// The interval is outside `1..=MAX_INTERVAL`.
389    InvalidInterval,
390    /// An `interval` family rule has no interval.
391    MissingInterval,
392    /// An `interval` family rule has no unit.
393    MissingUnit,
394    /// A `unit` was given on a family that has none.
395    UnexpectedUnit,
396    /// An `expression` was given on a family that has none.
397    UnexpectedExpression,
398    /// A `cron-like` rule has no expression.
399    MissingExpression,
400    /// The expression exceeds [`MAX_EXPRESSION_LEN`].
401    ExpressionTooLong,
402    /// The expression carries code-like characters, not a schedule.
403    ExecutablePayload,
404    /// The expression is not in the supported cron-like vocabulary.
405    MalformedCronExpression,
406    /// The timezone text is not a fixed offset.
407    InvalidTimezone,
408    /// The timezone is a named zone whose DST behaviour cannot be resolved.
409    UnsupportedTimezone,
410    /// The rule's offset and the injected context's offset disagree.
411    TimezoneMismatch,
412    /// The computed occurrence falls outside the supported calendar range.
413    CalendarRange,
414}
415
416impl RuleDiagnosticKind {
417    /// The contract's spelling, used verbatim in lint and MCP payloads.
418    pub const fn as_str(self) -> &'static str {
419        match self {
420            RuleDiagnosticKind::MissingAnchor => "missing_anchor",
421            RuleDiagnosticKind::InvalidAnchor => "invalid_anchor",
422            RuleDiagnosticKind::InvalidInterval => "invalid_interval",
423            RuleDiagnosticKind::MissingInterval => "missing_interval",
424            RuleDiagnosticKind::MissingUnit => "missing_unit",
425            RuleDiagnosticKind::UnexpectedUnit => "unexpected_unit",
426            RuleDiagnosticKind::UnexpectedExpression => "unexpected_expression",
427            RuleDiagnosticKind::MissingExpression => "missing_expression",
428            RuleDiagnosticKind::ExpressionTooLong => "expression_too_long",
429            RuleDiagnosticKind::ExecutablePayload => "executable_payload",
430            RuleDiagnosticKind::MalformedCronExpression => "malformed_cron_expression",
431            RuleDiagnosticKind::InvalidTimezone => "invalid_timezone",
432            RuleDiagnosticKind::UnsupportedTimezone => "unsupported_timezone",
433            RuleDiagnosticKind::TimezoneMismatch => "timezone_mismatch",
434            RuleDiagnosticKind::CalendarRange => "calendar_range",
435        }
436    }
437}
438
439/// One field-aware rule diagnostic.
440///
441/// `value` is the offending value as text, and is **empty** when the field was
442/// absent entirely; `message` is the single actionable sentence a surface
443/// prints. The message already leads with the field name, so the shared
444/// renderer's kind prefix completes the line.
445#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct RuleDiagnostic {
447    /// Which rule was broken.
448    pub kind: RuleDiagnosticKind,
449    /// The offending field (`interval`, `anchor`, `timezone`, `unit`,
450    /// `expression`).
451    pub field: &'static str,
452    /// The offending value, or empty when the field was absent.
453    pub value: String,
454    /// The user-facing sentence.
455    pub message: String,
456}
457
458impl RuleDiagnostic {
459    fn new(
460        kind: RuleDiagnosticKind,
461        field: &'static str,
462        value: impl Into<String>,
463        message: impl Into<String>,
464    ) -> Self {
465        Self {
466            kind,
467            field,
468            value: value.into(),
469            message: message.into(),
470        }
471    }
472}
473
474/// Why a rule was refused: one or more diagnostics, never a partial answer.
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub struct RuleError {
477    /// Every diagnostic found, in a deterministic field order.
478    pub errors: Vec<RuleDiagnostic>,
479}
480
481impl RuleError {
482    /// An error carrying `errors`.
483    fn new(errors: Vec<RuleDiagnostic>) -> Self {
484        Self { errors }
485    }
486
487    /// An error carrying one diagnostic.
488    fn single(error: RuleDiagnostic) -> Self {
489        Self {
490            errors: vec![error],
491        }
492    }
493
494    /// Every diagnostic, in order.
495    pub fn diagnostics(&self) -> &[RuleDiagnostic] {
496        &self.errors
497    }
498
499    /// The first diagnostic.
500    pub fn first(&self) -> &RuleDiagnostic {
501        &self.errors[0]
502    }
503}
504
505impl fmt::Display for RuleError {
506    /// **Every** diagnostic, through the shared renderer, so a rule refusal
507    /// reads like every other validation refusal in the crate.
508    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
509        if self.errors.is_empty() {
510            return f.write_str("recurrence rule validation failed");
511        }
512        f.write_str(&render_diagnostics(
513            "E-tix-recurrence",
514            self.errors
515                .iter()
516                .map(|e| (e.kind.as_str(), e.message.as_str())),
517        ))
518    }
519}
520
521impl std::error::Error for RuleError {}
522
523/// A rule refusal becomes a rendered error at the surface, like every other
524/// validation refusal (`crate::error::TixError::Refused`).
525impl From<RuleError> for TixError {
526    fn from(err: RuleError) -> TixError {
527        TixError::Refused(err.to_string())
528    }
529}
530
531fn missing_anchor(kind: RuleKind) -> RuleDiagnostic {
532    RuleDiagnostic::new(
533        RuleDiagnosticKind::MissingAnchor,
534        "anchor",
535        "",
536        format!("anchor: is required for a {} rule", kind.as_str()),
537    )
538}
539
540fn invalid_anchor(value: &str) -> RuleDiagnostic {
541    RuleDiagnostic::new(
542        RuleDiagnosticKind::InvalidAnchor,
543        "anchor",
544        value,
545        format!("anchor: `{value}` is not a valid `YYYY-MM-DD` calendar date"),
546    )
547}
548
549fn invalid_interval(value: u32) -> RuleDiagnostic {
550    RuleDiagnostic::new(
551        RuleDiagnosticKind::InvalidInterval,
552        "interval",
553        value.to_string(),
554        format!("interval: must be between 1 and {MAX_INTERVAL} (got {value})"),
555    )
556}
557
558fn missing_interval() -> RuleDiagnostic {
559    RuleDiagnostic::new(
560        RuleDiagnosticKind::MissingInterval,
561        "interval",
562        "",
563        "interval: is required for an interval rule",
564    )
565}
566
567fn missing_unit() -> RuleDiagnostic {
568    RuleDiagnostic::new(
569        RuleDiagnosticKind::MissingUnit,
570        "unit",
571        "",
572        "unit: is required for an interval rule",
573    )
574}
575
576fn unexpected_unit() -> RuleDiagnostic {
577    RuleDiagnostic::new(
578        RuleDiagnosticKind::UnexpectedUnit,
579        "unit",
580        "",
581        "unit: is only allowed on an interval rule",
582    )
583}
584
585fn unexpected_expression() -> RuleDiagnostic {
586    RuleDiagnostic::new(
587        RuleDiagnosticKind::UnexpectedExpression,
588        "expression",
589        "",
590        "expression: is only allowed on a cron-like rule",
591    )
592}
593
594fn missing_expression() -> RuleDiagnostic {
595    RuleDiagnostic::new(
596        RuleDiagnosticKind::MissingExpression,
597        "expression",
598        "",
599        "expression: is required for a cron-like rule",
600    )
601}
602
603fn expression_too_long(value: &str) -> RuleDiagnostic {
604    RuleDiagnostic::new(
605        RuleDiagnosticKind::ExpressionTooLong,
606        "expression",
607        value,
608        format!(
609            "expression: must be at most {MAX_EXPRESSION_LEN} characters (got {})",
610            value.len()
611        ),
612    )
613}
614
615fn executable_payload(value: &str) -> RuleDiagnostic {
616    RuleDiagnostic::new(
617        RuleDiagnosticKind::ExecutablePayload,
618        "expression",
619        value,
620        format!(
621            "expression: `{value}` contains characters that are not part of a cron-like expression"
622        ),
623    )
624}
625
626fn malformed_expression(value: &str) -> RuleDiagnostic {
627    RuleDiagnostic::new(
628        RuleDiagnosticKind::MalformedCronExpression,
629        "expression",
630        value,
631        format!(
632            "expression: `{value}` is not a supported cron-like expression \
633             (only @daily, @weekly, and @monthly are accepted; a general parser needs the reviewed dependency)"
634        ),
635    )
636}
637
638fn unsupported_timezone(value: &str) -> RuleDiagnostic {
639    RuleDiagnostic::new(
640        RuleDiagnosticKind::UnsupportedTimezone,
641        "timezone",
642        value,
643        format!(
644            "timezone: rule `{value}` is a named zone; only fixed UTC offsets are supported \
645             (daylight-saving needs a reviewed tzdb)"
646        ),
647    )
648}
649
650fn invalid_timezone(value: &str) -> RuleDiagnostic {
651    RuleDiagnostic::new(
652        RuleDiagnosticKind::InvalidTimezone,
653        "timezone",
654        value,
655        format!("timezone: rule `{value}` is not a fixed UTC offset (use ±HH:MM or UTC)"),
656    )
657}
658
659fn context_unsupported_timezone(value: &str) -> RuleDiagnostic {
660    RuleDiagnostic::new(
661        RuleDiagnosticKind::UnsupportedTimezone,
662        "timezone",
663        value,
664        format!(
665            "timezone: context `{value}` is a named zone; only fixed UTC offsets are supported \
666             (daylight-saving needs a reviewed tzdb)"
667        ),
668    )
669}
670
671fn context_invalid_timezone(value: &str) -> RuleDiagnostic {
672    RuleDiagnostic::new(
673        RuleDiagnosticKind::InvalidTimezone,
674        "timezone",
675        value,
676        format!("timezone: context `{value}` is not a fixed UTC offset (use ±HH:MM or UTC)"),
677    )
678}
679
680fn timezone_mismatch(rule: UtcOffset, context: UtcOffset) -> RuleDiagnostic {
681    let rule = Timezone::Fixed(rule);
682    let context = Timezone::Fixed(context);
683    RuleDiagnostic::new(
684        RuleDiagnosticKind::TimezoneMismatch,
685        "timezone",
686        rule.to_string(),
687        format!("timezone: rule offset `{rule}` does not match context offset `{context}`"),
688    )
689}
690
691fn calendar_range(anchor: Date, today: Date) -> RuleDiagnostic {
692    RuleDiagnostic::new(
693        RuleDiagnosticKind::CalendarRange,
694        "anchor",
695        anchor.to_string(),
696        format!(
697            "anchor: the next occurrence after `{today}` falls outside the supported calendar range"
698        ),
699    )
700}
701
702impl RecurrenceRule {
703    /// Validate the rule before it is persisted (T002, C-004).
704    ///
705    /// Collects **every** finding rather than failing on the first, so a user
706    /// fixes one rule once instead of discovering one problem per attempt. A
707    /// refusal writes nothing, so an invalid rule can never reach a series file.
708    pub fn validate(&self) -> Result<(), RuleError> {
709        let errors = self.diagnostics();
710        if errors.is_empty() {
711            Ok(())
712        } else {
713            Err(RuleError::new(errors))
714        }
715    }
716
717    /// Every diagnostic, in deterministic field order.
718    pub fn diagnostics(&self) -> Vec<RuleDiagnostic> {
719        let mut errors = Vec::new();
720
721        // anchor — required for every family, and required to be a real date.
722        match self.anchor.as_deref() {
723            None => errors.push(missing_anchor(self.kind)),
724            Some(raw) if parse_date(raw).is_none() => errors.push(invalid_anchor(raw)),
725            Some(_) => {}
726        }
727
728        // interval — bounded where present, required for the interval family.
729        if let Some(interval) = self.interval {
730            if !(1..=MAX_INTERVAL).contains(&interval) {
731                errors.push(invalid_interval(interval));
732            }
733        } else if self.kind == RuleKind::Interval {
734            errors.push(missing_interval());
735        }
736
737        // unit — only the interval family has one, and it must have it.
738        if self.unit.is_some() && !self.kind.allows_unit() {
739            errors.push(unexpected_unit());
740        } else if self.unit.is_none() && self.kind.allows_unit() {
741            errors.push(missing_unit());
742        }
743
744        // expression — only the cron-like family has one, and it must parse.
745        self.push_expression_diagnostics(&mut errors);
746
747        // timezone — carried but only a fixed offset is supported.
748        match &self.timezone {
749            Some(Timezone::Named(raw)) => errors.push(unsupported_timezone(raw)),
750            Some(Timezone::Invalid(raw)) => errors.push(invalid_timezone(raw)),
751            _ => {}
752        }
753
754        errors
755    }
756
757    /// The expression checks, kept out of [`RecurrenceRule::diagnostics`] so the
758    /// field order there stays readable.
759    fn push_expression_diagnostics(&self, errors: &mut Vec<RuleDiagnostic>) {
760        match self.expression.as_deref() {
761            Some(raw) if self.kind.allows_expression() => {
762                if raw.len() > MAX_EXPRESSION_LEN {
763                    errors.push(expression_too_long(raw));
764                } else if raw.contains(EXECUTABLE_MARKERS) {
765                    errors.push(executable_payload(raw));
766                } else if let Err(diagnostic) = BoundedCronParser.parse(raw) {
767                    errors.push(diagnostic);
768                }
769            }
770            Some(_) => errors.push(unexpected_expression()),
771            None if self.kind.allows_expression() => errors.push(missing_expression()),
772            None => {}
773        }
774    }
775
776    /// The anchor as a calendar date, if the rule has a valid one.
777    pub fn anchor_date(&self) -> Result<Date, RuleError> {
778        let raw = self
779            .anchor
780            .as_deref()
781            .ok_or_else(|| RuleError::single(missing_anchor(self.kind)))?;
782        parse_date(raw).ok_or_else(|| RuleError::single(invalid_anchor(raw)))
783    }
784
785    /// The canonical anchor date key.
786    pub fn anchor_key(&self) -> Result<DateKey, RuleError> {
787        Ok(DateKey::from_date(self.anchor_date()?))
788    }
789
790    /// A one-line human summary of the rule, for a warning's `rule_summary`.
791    ///
792    /// Best-effort on an invalid rule: the summary is display text, and refusing
793    /// to render it would hide the very rule a user is trying to fix.
794    pub fn summary(&self) -> String {
795        let interval = self.interval.unwrap_or(1);
796        match self.kind {
797            RuleKind::Daily if interval == 1 => "daily".to_string(),
798            RuleKind::Daily => format!("every {interval} days"),
799            RuleKind::Weekly if interval == 1 => "weekly".to_string(),
800            RuleKind::Weekly => format!("every {interval} weeks"),
801            RuleKind::Monthly if interval == 1 => "monthly".to_string(),
802            RuleKind::Monthly => format!("every {interval} months"),
803            RuleKind::Interval => {
804                let unit = self.unit.map_or("days", IntervalUnit::as_str);
805                format!("every {interval} {unit}")
806            }
807            RuleKind::CronLike => {
808                format!(
809                    "cron-like `{}`",
810                    self.expression.as_deref().unwrap_or_default()
811                )
812            }
813        }
814    }
815}
816
817// ---------------------------------------------------------------------------
818// T003 — deterministic next-date arithmetic
819// ---------------------------------------------------------------------------
820
821/// The injected local date/timezone context.
822///
823/// Nothing in this module reads the clock: the caller resolves "now" to a local
824/// calendar date and hands it over, which is what makes detection and generation
825/// reproducible in a test (research R2).
826#[derive(Debug, Clone, PartialEq, Eq)]
827pub struct RuleContext {
828    /// The local calendar date to evaluate relative to.
829    pub today: Date,
830    /// The local timezone the date was resolved in, or `None` for a
831    /// timezone-independent context.
832    pub timezone: Option<Timezone>,
833}
834
835impl RuleContext {
836    /// A timezone-independent context for `today`.
837    pub const fn on(today: Date) -> Self {
838        Self {
839            today,
840            timezone: None,
841        }
842    }
843
844    /// Attach the timezone the date was resolved in.
845    pub fn with_timezone(mut self, timezone: Timezone) -> Self {
846        self.timezone = Some(timezone);
847        self
848    }
849}
850
851/// A canonical local date key: `YYYY-MM-DD`.
852///
853/// `Ord` is the whole point: keys sort chronologically as plain strings, so a
854/// caller can order occurrences without parsing them (C-002/C-006). WP02 builds
855/// the occurrence key from a series ID and this value.
856#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
857pub struct DateKey(String);
858
859impl DateKey {
860    /// The canonical key for `date`.
861    pub fn from_date(date: Date) -> Self {
862        Self(date.to_string())
863    }
864
865    /// The key as a string slice.
866    pub fn as_str(&self) -> &str {
867        &self.0
868    }
869}
870
871impl fmt::Display for DateKey {
872    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
873        f.write_str(&self.0)
874    }
875}
876
877/// The resolved cadence behind a validated rule.
878enum Schedule {
879    /// A whole number of days between occurrences.
880    Days(u32),
881    /// A whole number of weeks between occurrences.
882    Weeks(u32),
883    /// A whole number of calendar months between occurrences.
884    Months(u32),
885}
886
887impl RecurrenceRule {
888    /// The first occurrence strictly after `context.today` (T003).
889    ///
890    /// The schedule is `anchor`, `anchor + step`, `anchor + 2·step`, …; the
891    /// result is the first of those dates after the injected date key. An anchor
892    /// in the future is therefore the answer, and an occurrence exactly on the
893    /// injected date is *not* the answer — "next" means next.
894    ///
895    /// # Month-end and leap-day arithmetic, defined not guessed
896    ///
897    /// A monthly step keeps the anchor's day-of-month and re-anchors on the
898    /// target month's **last day** when that day does not exist. An anchor of
899    /// `2026-01-31` therefore recurs on `2026-02-28`, and a `2024-02-29` anchor
900    /// recurs on `2025-02-28`; the rules are here, in one place, rather than
901    /// left to whichever arithmetic a caller happened to write. A *stored* anchor
902    /// that is not a real date at all (`2025-02-29`) never reaches this function:
903    /// validation refuses it first.
904    ///
905    /// # Ambiguity is refused
906    ///
907    /// The rule is validated, then the injected context: a named zone on either
908    /// side, an unparseable offset, or a rule/context offset disagreement is a
909    /// diagnostic rather than a silent normalization. A computed occurrence
910    /// outside the supported calendar range is a diagnostic too.
911    pub fn next_occurrence(&self, context: &RuleContext) -> Result<DateKey, RuleError> {
912        self.validate()?;
913        self.validate_context(context)?;
914        let anchor = self.anchor_date()?;
915        let today = context.today;
916        let date = match self.schedule()? {
917            Schedule::Days(step) => next_day_step(anchor, today, step)?,
918            Schedule::Weeks(step) => next_day_step(anchor, today, step.saturating_mul(7))?,
919            Schedule::Months(step) => next_month_step(anchor, today, step)?,
920        };
921        Ok(DateKey::from_date(date))
922    }
923
924    /// Refuse an injected context this module cannot evaluate deterministically.
925    fn validate_context(&self, context: &RuleContext) -> Result<(), RuleError> {
926        let mut errors = Vec::new();
927        match &context.timezone {
928            Some(Timezone::Named(raw)) => errors.push(context_unsupported_timezone(raw)),
929            Some(Timezone::Invalid(raw)) => errors.push(context_invalid_timezone(raw)),
930            _ => {}
931        }
932        if let (Some(Timezone::Fixed(rule)), Some(Timezone::Fixed(injected))) =
933            (self.timezone.as_ref(), context.timezone.as_ref())
934            && rule != injected
935        {
936            errors.push(timezone_mismatch(*rule, *injected));
937        }
938        if errors.is_empty() {
939            Ok(())
940        } else {
941            Err(RuleError::new(errors))
942        }
943    }
944
945    /// The resolved cadence. Only reachable once validation has passed, so a
946    /// missing field is reported rather than panicked on.
947    fn schedule(&self) -> Result<Schedule, RuleError> {
948        let interval = self.interval.unwrap_or(1);
949        Ok(match self.kind {
950            RuleKind::Daily => Schedule::Days(interval),
951            RuleKind::Weekly => Schedule::Weeks(interval),
952            RuleKind::Monthly => Schedule::Months(interval),
953            RuleKind::Interval => match self.unit {
954                Some(IntervalUnit::Days) => Schedule::Days(interval),
955                Some(IntervalUnit::Weeks) => Schedule::Weeks(interval),
956                Some(IntervalUnit::Months) => Schedule::Months(interval),
957                None => return Err(RuleError::single(missing_unit())),
958            },
959            RuleKind::CronLike => match BoundedCronParser
960                .parse(self.expression.as_deref().unwrap_or_default())
961                .map_err(RuleError::single)?
962            {
963                CronSchedule::Daily => Schedule::Days(1),
964                CronSchedule::Weekly => Schedule::Weeks(1),
965                CronSchedule::Monthly => Schedule::Months(1),
966            },
967        })
968    }
969}
970
971/// Parse a canonical `YYYY-MM-DD` date.
972///
973/// The calendar authority is [`crate::model::validate_date`] — the same check
974/// every other stored date passes — so a rule anchor cannot be "a date" to one
975/// part of the crate and not another. `time` provides the arithmetic value.
976fn parse_date(value: &str) -> Option<Date> {
977    if !crate::model::validate_date(value) {
978        return None;
979    }
980    let year: i32 = value.get(0..4)?.parse().ok()?;
981    let month: u8 = value.get(5..7)?.parse().ok()?;
982    let day: u8 = value.get(8..10)?.parse().ok()?;
983    Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
984}
985
986/// The first `anchor + k·step` day after `today`, for day-based cadences.
987fn next_day_step(anchor: Date, today: Date, step: u32) -> Result<Date, RuleError> {
988    let anchor_day = i64::from(anchor.to_julian_day());
989    let today_day = i64::from(today.to_julian_day());
990    let step = i64::from(step);
991    let elapsed = today_day - anchor_day;
992    let next = if elapsed < 0 { 0 } else { elapsed / step + 1 };
993    let julian = i32::try_from(anchor_day + next * step)
994        .map_err(|_| RuleError::single(calendar_range(anchor, today)))?;
995    Date::from_julian_day(julian).map_err(|_| RuleError::single(calendar_range(anchor, today)))
996}
997
998/// The first monthly occurrence after `today`, re-anchoring on the last day of
999/// a month that is too short for the anchor's day.
1000fn next_month_step(anchor: Date, today: Date, step: u32) -> Result<Date, RuleError> {
1001    let step = i64::from(step);
1002    let anchor_month = month_index(anchor);
1003    let today_month = month_index(today);
1004    let mut candidate = if today_month <= anchor_month {
1005        anchor_month
1006    } else {
1007        anchor_month + ((today_month - anchor_month) / step) * step
1008    };
1009    loop {
1010        let (year, month) = split_month_index(candidate)
1011            .ok_or_else(|| RuleError::single(calendar_range(anchor, today)))?;
1012        let day = anchor.day().min(month.length(year));
1013        let date = Date::from_calendar_date(year, month, day)
1014            .map_err(|_| RuleError::single(calendar_range(anchor, today)))?;
1015        if date > today {
1016            return Ok(date);
1017        }
1018        candidate += step;
1019    }
1020}
1021
1022/// A date's absolute month index, for month arithmetic that cannot lose a year.
1023fn month_index(date: Date) -> i64 {
1024    i64::from(date.year()) * 12 + i64::from(u8::from(date.month())) - 1
1025}
1026
1027/// The `(year, month)` a month index names.
1028fn split_month_index(index: i64) -> Option<(i32, Month)> {
1029    let year = i32::try_from(index.div_euclid(12)).ok()?;
1030    let month = u8::try_from(index.rem_euclid(12) + 1).ok()?;
1031    Month::try_from(month).ok().map(|month| (year, month))
1032}
1033
1034// ---------------------------------------------------------------------------
1035// T004 — the cron-like parser seam and its supply-chain gate
1036// ---------------------------------------------------------------------------
1037
1038/// The cadence a cron-like expression resolves to.
1039///
1040/// Deliberately the *same* shapes the other families produce, so a cron-like
1041/// rule goes through the reviewed arithmetic rather than a second scheduler.
1042#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1043pub enum CronSchedule {
1044    /// Once a day.
1045    Daily,
1046    /// Once a week, on the anchor's weekday.
1047    Weekly,
1048    /// Once a month, on the anchor's day-of-month.
1049    Monthly,
1050}
1051
1052/// The seam through which a cron-like expression is interpreted (T004).
1053///
1054/// The mission approves **no** cron parser dependency: `Cargo.toml` is unchanged
1055/// and `docs/recurrence-dependency-review.md` records why. This trait is the
1056/// place a reviewed parser would be substituted — one implementation swap, not a
1057/// grammar spread through the module — and [`BoundedCronParser`] is what ships
1058/// today.
1059pub trait CronParser: fmt::Debug {
1060    /// Interpret `expression`, or refuse with a field-aware diagnostic.
1061    fn parse(&self, expression: &str) -> Result<CronSchedule, RuleDiagnostic>;
1062}
1063
1064/// The shipped parser: a **closed macro vocabulary**, not a cron grammar.
1065///
1066/// `@daily`, `@weekly`, and `@monthly` are the entire accepted language, and each
1067/// maps onto arithmetic this module already owns. Parsing a general five-field
1068/// expression was rejected for this work package on purpose: hand-rolling that
1069/// grammar is itself an escalation (`research.md` R8, C-009), and the maintained
1070/// crates all pull a second date/time stack along with them (see the review
1071/// record). A general expression is therefore refused, not guessed.
1072#[derive(Debug, Clone, Copy, Default)]
1073pub struct BoundedCronParser;
1074
1075impl CronParser for BoundedCronParser {
1076    fn parse(&self, expression: &str) -> Result<CronSchedule, RuleDiagnostic> {
1077        match expression.trim().to_ascii_lowercase().as_str() {
1078            "@daily" => Ok(CronSchedule::Daily),
1079            "@weekly" => Ok(CronSchedule::Weekly),
1080            "@monthly" => Ok(CronSchedule::Monthly),
1081            _ => Err(malformed_expression(expression)),
1082        }
1083    }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089
1090    fn date(year: i32, month: u8, day: u8) -> Date {
1091        Date::from_calendar_date(year, Month::try_from(month).expect("1..=12"), day)
1092            .expect("test date is valid")
1093    }
1094
1095    fn weekly(anchor: &str) -> RecurrenceRule {
1096        RecurrenceRule::new(RuleKind::Weekly).with_anchor(anchor)
1097    }
1098
1099    #[test]
1100    fn validate_accepts_every_family() {
1101        let rules = [
1102            RecurrenceRule::new(RuleKind::Daily).with_anchor("2026-01-05"),
1103            weekly("2026-01-05"),
1104            RecurrenceRule::new(RuleKind::Monthly).with_anchor("2026-01-31"),
1105            RecurrenceRule::new(RuleKind::Interval)
1106                .with_anchor("2026-01-05")
1107                .with_interval(2)
1108                .with_unit(IntervalUnit::Weeks),
1109            RecurrenceRule::new(RuleKind::CronLike)
1110                .with_anchor("2026-01-05")
1111                .with_expression("@weekly"),
1112        ];
1113        for rule in rules {
1114            assert!(
1115                rule.validate().is_ok(),
1116                "expected {:?} to be valid",
1117                rule.kind
1118            );
1119        }
1120    }
1121
1122    #[test]
1123    fn validate_collects_every_diagnostic_not_just_the_first() {
1124        let rule = RecurrenceRule::new(RuleKind::Interval).with_interval(0);
1125        let errors = rule.validate().expect_err("rule is invalid").errors;
1126        let kinds: Vec<_> = errors.iter().map(|e| e.kind).collect();
1127        assert_eq!(
1128            kinds,
1129            vec![
1130                RuleDiagnosticKind::MissingAnchor,
1131                RuleDiagnosticKind::InvalidInterval,
1132                RuleDiagnosticKind::MissingUnit,
1133            ]
1134        );
1135    }
1136
1137    #[test]
1138    fn daily_interval_defaults_to_one_day() {
1139        let rule = RecurrenceRule::new(RuleKind::Daily).with_anchor("2026-01-05");
1140        let next = rule
1141            .next_occurrence(&RuleContext::on(date(2026, 1, 5)))
1142            .expect("valid rule");
1143        assert_eq!(next.as_str(), "2026-01-06");
1144    }
1145
1146    #[test]
1147    fn anchor_in_the_future_is_the_next_occurrence() {
1148        let next = weekly("2026-03-02")
1149            .next_occurrence(&RuleContext::on(date(2026, 1, 5)))
1150            .expect("valid rule");
1151        assert_eq!(next.as_str(), "2026-03-02");
1152    }
1153
1154    #[test]
1155    fn weekly_interval_steps_by_whole_weeks() {
1156        let rule = weekly("2026-01-05").with_interval(2);
1157        let next = rule
1158            .next_occurrence(&RuleContext::on(date(2026, 1, 12)))
1159            .expect("valid rule");
1160        assert_eq!(next.as_str(), "2026-01-19");
1161    }
1162
1163    #[test]
1164    fn occurrence_on_the_context_date_is_not_the_next_one() {
1165        let next = weekly("2026-01-05")
1166            .next_occurrence(&RuleContext::on(date(2026, 1, 5)))
1167            .expect("valid rule");
1168        assert_eq!(next.as_str(), "2026-01-12");
1169    }
1170
1171    #[test]
1172    fn monthly_step_reanchors_on_the_last_day_of_a_short_month() {
1173        let rule = RecurrenceRule::new(RuleKind::Monthly).with_anchor("2026-01-31");
1174        let next = rule
1175            .next_occurrence(&RuleContext::on(date(2026, 1, 31)))
1176            .expect("valid rule");
1177        assert_eq!(next.as_str(), "2026-02-28");
1178        let following = rule
1179            .next_occurrence(&RuleContext::on(date(2026, 2, 28)))
1180            .expect("valid rule");
1181        assert_eq!(following.as_str(), "2026-03-31");
1182    }
1183
1184    #[test]
1185    fn monthly_step_keeps_the_original_day_after_a_clamped_month() {
1186        let rule = RecurrenceRule::new(RuleKind::Monthly).with_anchor("2026-01-30");
1187        let next = rule
1188            .next_occurrence(&RuleContext::on(date(2026, 2, 28)))
1189            .expect("valid rule");
1190        assert_eq!(next.as_str(), "2026-03-30");
1191    }
1192
1193    #[test]
1194    fn monthly_leap_day_anchor_clamps_in_a_common_year() {
1195        let rule = RecurrenceRule::new(RuleKind::Monthly).with_anchor("2024-02-29");
1196        let next = rule
1197            .next_occurrence(&RuleContext::on(date(2024, 2, 29)))
1198            .expect("valid rule");
1199        assert_eq!(next.as_str(), "2024-03-29");
1200        let common_year = rule
1201            .next_occurrence(&RuleContext::on(date(2025, 2, 1)))
1202            .expect("valid rule");
1203        assert_eq!(common_year.as_str(), "2025-02-28");
1204    }
1205
1206    #[test]
1207    fn far_anchor_does_not_scan_every_step() {
1208        let rule = RecurrenceRule::new(RuleKind::Daily).with_anchor("1970-01-01");
1209        let next = rule
1210            .next_occurrence(&RuleContext::on(date(2026, 1, 5)))
1211            .expect("valid rule");
1212        assert_eq!(next.as_str(), "2026-01-06");
1213    }
1214
1215    #[test]
1216    fn occurrence_beyond_the_calendar_range_is_a_diagnostic_not_a_panic() {
1217        let rule = RecurrenceRule::new(RuleKind::Daily).with_anchor("9999-12-31");
1218        let error = rule
1219            .next_occurrence(&RuleContext::on(date(9999, 12, 31)))
1220            .expect_err("occurrence would fall outside the supported range");
1221        assert_eq!(error.first().kind, RuleDiagnosticKind::CalendarRange);
1222    }
1223
1224    #[test]
1225    fn interval_rule_without_unit_is_reported() {
1226        let rule = RecurrenceRule::new(RuleKind::Interval)
1227            .with_anchor("2026-01-05")
1228            .with_interval(3);
1229        assert_eq!(
1230            rule.validate().expect_err("invalid").first().kind,
1231            RuleDiagnosticKind::MissingUnit
1232        );
1233    }
1234
1235    #[test]
1236    fn a_named_zone_on_the_context_is_refused() {
1237        let context =
1238            RuleContext::on(date(2026, 1, 5)).with_timezone(Timezone::parse("Europe/Berlin"));
1239        let error = weekly("2026-01-05")
1240            .next_occurrence(&context)
1241            .expect_err("named context zone is refused");
1242        assert_eq!(error.first().kind, RuleDiagnosticKind::UnsupportedTimezone);
1243        assert_eq!(error.first().value, "Europe/Berlin");
1244    }
1245
1246    #[test]
1247    fn matching_offsets_are_accepted() {
1248        let rule = weekly("2026-01-05").with_timezone(Timezone::parse("+01:00"));
1249        let context = RuleContext::on(date(2026, 1, 12)).with_timezone(Timezone::parse("+01:00"));
1250        assert_eq!(
1251            rule.next_occurrence(&context).expect("valid rule").as_str(),
1252            "2026-01-19"
1253        );
1254    }
1255
1256    #[test]
1257    fn timezone_text_round_trips_through_its_classification() {
1258        assert_eq!(
1259            Timezone::parse("+05:30"),
1260            Timezone::Fixed(UtcOffset::from_hms(5, 30, 0).expect("valid offset"))
1261        );
1262        assert_eq!(Timezone::parse("UTC").to_string(), "+00:00");
1263        assert_eq!(
1264            Timezone::parse("+1:00"),
1265            Timezone::Invalid("+1:00".to_string())
1266        );
1267        assert_eq!(
1268            Timezone::parse("America/New_York"),
1269            Timezone::Named("America/New_York".to_string())
1270        );
1271        assert!(Timezone::parse("+02:00").is_supported());
1272        assert!(!Timezone::parse("America/New_York").is_supported());
1273    }
1274
1275    #[test]
1276    fn summary_renders_each_family() {
1277        assert_eq!(
1278            RecurrenceRule::new(RuleKind::Daily)
1279                .with_anchor("2026-01-05")
1280                .summary(),
1281            "daily"
1282        );
1283        assert_eq!(
1284            weekly("2026-01-05").with_interval(2).summary(),
1285            "every 2 weeks"
1286        );
1287        assert_eq!(
1288            RecurrenceRule::new(RuleKind::Interval)
1289                .with_unit(IntervalUnit::Months)
1290                .with_interval(3)
1291                .summary(),
1292            "every 3 months"
1293        );
1294        assert_eq!(
1295            RecurrenceRule::new(RuleKind::CronLike)
1296                .with_expression("@weekly")
1297                .summary(),
1298            "cron-like `@weekly`"
1299        );
1300    }
1301
1302    #[test]
1303    fn anchor_date_uses_the_crate_calendar_authority() {
1304        let rule = weekly("2026-01-05");
1305        assert_eq!(rule.anchor_date().expect("valid"), date(2026, 1, 5));
1306        assert_eq!(rule.anchor_key().expect("valid").as_str(), "2026-01-05");
1307        let bad = weekly("2026-02-30");
1308        assert_eq!(
1309            bad.anchor_date().expect_err("not a date").first().kind,
1310            RuleDiagnosticKind::InvalidAnchor
1311        );
1312    }
1313
1314    #[test]
1315    fn date_key_orders_chronologically() {
1316        let january = DateKey::from_date(date(2026, 1, 5));
1317        let february = DateKey::from_date(date(2026, 2, 5));
1318        let next_year = DateKey::from_date(date(2027, 1, 5));
1319        assert!(january < february);
1320        assert!(february < next_year);
1321        assert_eq!(january.to_string(), "2026-01-05");
1322    }
1323
1324    #[test]
1325    fn bounded_parser_accepts_the_closed_vocabulary() {
1326        assert_eq!(
1327            BoundedCronParser.parse("@daily").expect("supported"),
1328            CronSchedule::Daily
1329        );
1330        assert_eq!(
1331            BoundedCronParser.parse("  @WEEKLY  ").expect("supported"),
1332            CronSchedule::Weekly
1333        );
1334        assert_eq!(
1335            BoundedCronParser.parse("@monthly").expect("supported"),
1336            CronSchedule::Monthly
1337        );
1338    }
1339
1340    #[test]
1341    fn parse_offset_rejects_out_of_range_components() {
1342        assert!(parse_offset("+24:00").is_none());
1343        assert!(parse_offset("+01:60").is_none());
1344        assert!(parse_offset("+00:00:60").is_none());
1345        assert!(parse_offset("+00:0").is_none());
1346        assert!(parse_offset("+01:0a").is_none());
1347        assert_eq!(
1348            parse_offset("+23:59:59"),
1349            Some(UtcOffset::from_hms(23, 59, 59).expect("valid offset"))
1350        );
1351        assert_eq!(
1352            parse_offset("-08:00"),
1353            Some(UtcOffset::from_hms(-8, 0, 0).expect("valid offset"))
1354        );
1355    }
1356}