Skip to main content

shep_core/config/
cron.rs

1//! `cron_restart` schedule parsing — the croner-backed cron grammar (spec §4)
2//!
3//! The dialect is five-field standard cron only: widening a grammar later is
4//! backwards-compatible, narrowing one is not, so this starts narrow rather
5//! than inheriting croner's full extension set. croner
6//! still accepts `L`, `W`, `#` and `?` natively — rejecting them is this
7//! module's job, done by a token-aware pre-parse scan, because a character
8//! scan alone would reject `JUL` and `WED` (both contain a reserved letter).
9//! The seven vixie `@nickname` shorthands are expanded to five fields before
10//! croner ever sees them: croner's own nickname table has no `@midnight`
11//! arm, so delegating would accept `@daily` and reject `@midnight`.
12
13use core::fmt;
14
15use chrono::{DateTime, Utc};
16use chrono_tz::Tz;
17use croner::Cron;
18use croner::errors::CronError;
19use croner::parser::{CronParser, Seconds};
20
21/// The vixie nickname table, in the order spec §4 lists them. Matching is
22/// ASCII-case-insensitive; `@yearly` and `@annually` are two spellings of
23/// the same schedule, as are `@daily` and `@midnight`.
24const NICKNAMES: [(&str, &str); 7] = [
25    ("@yearly", "0 0 1 1 *"),
26    ("@annually", "0 0 1 1 *"),
27    ("@monthly", "0 0 1 * *"),
28    ("@weekly", "0 0 * * 0"),
29    ("@daily", "0 0 * * *"),
30    ("@midnight", "0 0 * * *"),
31    ("@hourly", "0 * * * *"),
32];
33
34/// Three-letter month and weekday names croner's alpha replacement accepts.
35/// The extension-character scan below must treat these as opaque tokens:
36/// `JUL` contains `L`, `WED` contains `W`.
37const NAMES: [&str; 19] = [
38    "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC", "SUN",
39    "MON", "TUE", "WED", "THU", "FRI", "SAT",
40];
41
42/// A validated `cron_restart` pattern together with the zone it is read in.
43///
44/// The croner and chrono-tz types are private: a cron dialect is a Flockfile
45/// grammar promise, and pinning it to a dependency's public types would make
46/// that dependency's next major version a breaking change to shep's own
47/// config surface (IR-11).
48// wire format: the accepted pattern grammar is a config contract; widening or
49// narrowing it is a breaking change
50#[derive(Debug, Clone)]
51pub struct CronSchedule {
52    /// The pattern exactly as the caller wrote it, including a nickname
53    /// spelling — never croner's normalized `Cron::as_str` rendering.
54    pattern: String,
55    zone: Tz,
56    cron: Cron,
57}
58
59impl CronSchedule {
60    /// Parses a `cron_restart` pattern and its optional `cron_timezone`.
61    ///
62    /// # Errors
63    ///
64    /// - [`CronParseError::Pattern`] — croner rejected the pattern.
65    /// - [`CronParseError::Timezone`] — the name is not an IANA zone.
66    pub fn parse(pattern: &str, timezone: Option<&str>) -> Result<Self, CronParseError> {
67        let zone = match timezone {
68            Some(name) => parse_timezone_name(name).ok_or_else(|| CronParseError::Timezone {
69                name: name.to_string(),
70            })?,
71            None => Tz::UTC,
72        };
73
74        let trimmed = pattern.trim();
75        let candidate = if is_single_at_token(trimmed) {
76            expand_nickname(trimmed, pattern)?
77        } else {
78            trimmed.to_string()
79        };
80        reject_extension_characters(&candidate, pattern)?;
81
82        let cron = cron_parser()
83            .parse(&candidate)
84            .map_err(|e| CronParseError::Pattern {
85                pattern: pattern.to_string(),
86                reason: e.to_string(),
87            })?;
88
89        Ok(Self {
90            pattern: pattern.to_string(),
91            zone,
92            cron,
93        })
94    }
95
96    /// The first occurrence strictly after `after`, in UTC.
97    ///
98    /// Returns `None` when the pattern has no further occurrence croner is
99    /// willing to search for — a pattern like `0 0 30 2 *` (30 February) that
100    /// can never match.
101    ///
102    /// A pattern that matches an hour repeated by a DST fall-back can return
103    /// the same wall-clock occurrence twice across two successive calls —
104    /// once for each pass through that hour. This is croner's own standard
105    /// local-time search semantics, not a defect this wrapper introduces or
106    /// could suppress without also making the reverse case (a spring-forward
107    /// hour that never occurs) harder to reason about.
108    ///
109    /// # Errors
110    ///
111    /// - [`CronScheduleError::Search`] — croner failed the search for a reason
112    ///   other than exhaustion, carrying its own sentence.
113    ///
114    /// # Panics
115    ///
116    /// Panics if converting `after` into `zone`'s local calendar lands
117    /// outside the range chrono's `NaiveDateTime` can represent — chrono
118    /// panics rather than erroring in that case, and croner has no fallible
119    /// entry point that avoids it (e.g. `DateTime::<Utc>::MAX_UTC` read in
120    /// `Pacific/Kiritimati`, UTC+14, which pushes the local instant past the
121    /// representable range). Unreachable from `Utc::now()`, which is the
122    /// only `after` this crate's own callers pass; documented rather than
123    /// guarded because a guard would exist solely for an instant no caller
124    /// can construct without going out of their way to do it.
125    pub fn next_after(
126        &self,
127        after: DateTime<Utc>,
128    ) -> Result<Option<DateTime<Utc>>, CronScheduleError> {
129        let start = after.with_timezone(&self.zone);
130        match self.cron.find_next_occurrence(&start, false) {
131            Ok(dt) => Ok(Some(dt.with_timezone(&Utc))),
132            Err(CronError::TimeSearchLimitExceeded) => Ok(None),
133            Err(e) => Err(CronScheduleError::Search {
134                reason: e.to_string(),
135            }),
136        }
137    }
138
139    /// The pattern as written in the Flockfile.
140    #[must_use]
141    pub fn pattern(&self) -> &str {
142        &self.pattern
143    }
144}
145
146/// Growth is expected: croner's dialect has more rejection modes than this
147/// enum distinguishes today, and a future `cron_timezone` shorthand would add
148/// one more (IR-20).
149#[non_exhaustive]
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum CronParseError {
152    /// The pattern is not valid in shep's dialect. Carries the pattern as the
153    /// user wrote it and the rendered reason — croner's own sentence where
154    /// croner did the rejecting, ours where the pre-parse pass did.
155    Pattern {
156        /// The pattern as the user wrote it
157        pattern: String,
158        /// Why it was rejected
159        reason: String,
160    },
161    /// The `cron_timezone` value is not a name in the IANA database.
162    Timezone {
163        /// The value as the user wrote it
164        name: String,
165    },
166}
167
168impl fmt::Display for CronParseError {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        match self {
171            Self::Pattern { pattern, reason } => {
172                write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
173            }
174            Self::Timezone { name } => write!(f, "`{name}` is not a recognized IANA timezone"),
175        }
176    }
177}
178
179impl core::error::Error for CronParseError {}
180
181/// Why a validated schedule could not produce its next occurrence.
182///
183/// One variant today and no `#[non_exhaustive]`: the only failure a search can
184/// have that is not exhaustion is croner's own, and a second variant would be
185/// a second reason, not a second rendering of this one (IR-20).
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum CronScheduleError {
188    /// croner could not resolve the next occurrence; carries its rendered reason.
189    Search {
190        /// croner's own rendered reason
191        reason: String,
192    },
193}
194
195impl fmt::Display for CronScheduleError {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        match self {
198            Self::Search { reason } => write!(f, "cron schedule search failed: {reason}"),
199        }
200    }
201}
202
203impl core::error::Error for CronScheduleError {}
204
205/// Builds the five-field-only parser. Not a `const`/`static`:
206/// `CronParserBuilder::build` is not a `const fn`, and the builder itself is
207/// cheap enough (no allocation) that building fresh per call costs nothing
208/// measurable at Flockfile-parse rates.
209///
210/// `Seconds::Disallowed` is the one load-bearing call here: croner's own
211/// default is `Seconds::Optional`, which accepts a six-field pattern and
212/// would ship the wide dialect by accident. No `.dom_and_dow(true)` call:
213/// croner's default is OR semantics between day-of-month and day-of-week,
214/// which is the dialect map.md promises; `true` would switch to AND and
215/// silently change what an existing pattern means.
216fn cron_parser() -> CronParser {
217    CronParser::builder().seconds(Seconds::Disallowed).build()
218}
219
220/// Parses an IANA timezone name. Shared by [`CronSchedule::parse`] and
221/// `normalize`'s standalone `cron_timezone` check — a Flockfile may carry a
222/// timezone with no `cron_restart` to pair it with, and spec §5 says that
223/// typo fails loudly too.
224pub(super) fn parse_timezone_name(name: &str) -> Option<Tz> {
225    name.parse::<Tz>().ok()
226}
227
228/// True when `trimmed` is exactly one whitespace-free token starting with
229/// `@` — the only shape nickname expansion applies to. A multi-token pattern
230/// that happens to contain `@` is left alone; croner will reject it on its
231/// own terms.
232///
233/// The `split_whitespace().count() == 1` clause has no mutation test of its
234/// own: a multi-token `@`-leading pattern ends in `Err(CronParseError::Pattern)`
235/// either way, whether this function routes it to [`expand_nickname`]'s
236/// "not a recognized nickname" rejection or leaves it for croner's own
237/// malformed-pattern rejection. Weakening the clause changes which message
238/// fires, not whether the pattern is accepted, so it reads as unguarded —
239/// noted here rather than left looking load-bearing.
240fn is_single_at_token(trimmed: &str) -> bool {
241    trimmed.starts_with('@') && trimmed.split_whitespace().count() == 1
242}
243
244/// Expands a single-token `@`-pattern against the closed vixie table.
245/// `@reboot` and anything unrecognized are rejected here, with a message
246/// naming the reason — never handed to croner, whose own rejection would
247/// read as a field-count complaint that says nothing about nicknames.
248fn expand_nickname(trimmed: &str, original: &str) -> Result<String, CronParseError> {
249    if trimmed.eq_ignore_ascii_case("@reboot") {
250        // Just the reason, not "@reboot is not a supported cron_restart
251        // pattern:" again — the caller's `CronParseError::Pattern` Display
252        // impl already renders `invalid cron_restart pattern `@reboot`:`
253        // ahead of this, and repeating it here doubled the message.
254        return Err(CronParseError::Pattern {
255            pattern: original.to_string(),
256            reason: "shep's own restart policy already decides when a sheep starts".to_string(),
257        });
258    }
259    for (name, expansion) in NICKNAMES {
260        if trimmed.eq_ignore_ascii_case(name) {
261            return Ok(expansion.to_string());
262        }
263    }
264    Err(CronParseError::Pattern {
265        pattern: original.to_string(),
266        reason: format!(
267            "`{trimmed}` is not a recognized cron_restart nickname (expected one of @yearly, \
268             @annually, @monthly, @weekly, @daily, @midnight, @hourly)"
269        ),
270    })
271}
272
273/// Rejects croner's `L`, `W`, `#` and `?` extensions before the pattern
274/// reaches croner, which accepts all four natively. Scans token-aware, per
275/// whitespace-separated field, treating a recognized three-letter month or
276/// weekday name as opaque first — a character-wise scan would reject `JUL`
277/// and `WED`, which are valid standard cron.
278fn reject_extension_characters(candidate: &str, original: &str) -> Result<(), CronParseError> {
279    for field in candidate.split_whitespace() {
280        if let Some(bad) = field_has_bad_char(field) {
281            return Err(CronParseError::Pattern {
282                pattern: original.to_string(),
283                reason: format!(
284                    "cron_restart pattern contains `{bad}`, a croner extension character \
285                     shep's five-field dialect does not accept"
286                ),
287            });
288        }
289    }
290    Ok(())
291}
292
293/// Scans one field for `L`/`W`/`#`/`?`, skipping over any three-character
294/// window that case-insensitively matches a recognized month/weekday name.
295fn field_has_bad_char(field: &str) -> Option<char> {
296    let chars: Vec<char> = field.chars().collect();
297    let mut i = 0;
298    while i < chars.len() {
299        if i + 3 <= chars.len() {
300            let window: String = chars[i..i + 3].iter().collect();
301            if NAMES.iter().any(|name| name.eq_ignore_ascii_case(&window)) {
302                i += 3;
303                continue;
304            }
305        }
306        if matches!(chars[i].to_ascii_uppercase(), 'L' | 'W' | '#' | '?') {
307            return Some(chars[i]);
308        }
309        i += 1;
310    }
311    None
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn dt(s: &str) -> DateTime<Utc> {
319        s.parse().expect("valid RFC3339 timestamp")
320    }
321
322    /// Chains `n` successive calls to `next_after`, each starting strictly
323    /// after the previous result.
324    fn occurrence_sequence(
325        schedule: &CronSchedule,
326        start: DateTime<Utc>,
327        n: usize,
328    ) -> Vec<DateTime<Utc>> {
329        let mut cursor = start;
330        let mut out = Vec::with_capacity(n);
331        for _ in 0..n {
332            let next = schedule
333                .next_after(cursor)
334                .expect("search succeeds")
335                .expect("has a next occurrence");
336            out.push(next);
337            cursor = next;
338        }
339        out
340    }
341
342    fn assert_extension_char_rejected(pattern: &str, bad: char) {
343        match CronSchedule::parse(pattern, None) {
344            Err(CronParseError::Pattern {
345                pattern: got_pattern,
346                reason,
347            }) => {
348                assert_eq!(got_pattern, pattern);
349                assert_eq!(
350                    reason,
351                    format!(
352                        "cron_restart pattern contains `{bad}`, a croner extension character \
353                         shep's five-field dialect does not accept"
354                    )
355                );
356            }
357            other => panic!("expected Pattern error, got {other:?}"),
358        }
359    }
360
361    #[test]
362    fn five_field_pattern_produces_pinned_occurrence_sequence() {
363        // fails if the parser is configured with `Seconds::Required`, which
364        // would reject this five-field pattern outright
365        let schedule = CronSchedule::parse("0 3 * * *", None).unwrap();
366        let seq = occurrence_sequence(&schedule, dt("2026-01-01T00:00:00Z"), 3);
367        assert_eq!(
368            seq,
369            vec![
370                dt("2026-01-01T03:00:00Z"),
371                dt("2026-01-02T03:00:00Z"),
372                dt("2026-01-03T03:00:00Z"),
373            ]
374        );
375    }
376
377    #[test]
378    fn six_field_pattern_is_rejected() {
379        // fails if the builder was left on croner's default
380        // `Seconds::Optional`, which accepts the seconds field and ships the
381        // wide dialect by accident
382        match CronSchedule::parse("30 0 3 * * *", None) {
383            Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "30 0 3 * * *"),
384            other => panic!("expected Pattern error, got {other:?}"),
385        }
386    }
387
388    #[test]
389    fn year_field_pattern_is_rejected() {
390        // fails if `.seconds(Seconds::Disallowed)` was "simplified away" on
391        // the theory that a `.year(...)` call was also needed — one setting
392        // closes both widenings
393        match CronSchedule::parse("0 3 * * * 2027", None) {
394            Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "0 3 * * * 2027"),
395            other => panic!("expected Pattern error, got {other:?}"),
396        }
397    }
398
399    #[test]
400    fn nicknames_expand_to_the_same_occurrence_sequence_as_their_five_field_form() {
401        // fails if any expansion is wrong (`@weekly` as `0 0 * * 1` is the
402        // likely slip) and fails if `@midnight` was left to croner, whose
403        // nickname table has no arm for it
404        //
405        // The five-field forms below are transcribed by hand from spec §4
406        // and do NOT read the module's `NICKNAMES` table — comparing a
407        // nickname's expansion against a copy of the same table it came
408        // from is a tautology (corrupting `NICKNAMES` corrupts both sides
409        // of the comparison identically, so the test still passes). Proven
410        // by mutation: with this table read from `NICKNAMES`, mutating
411        // `@weekly`'s row to `0 0 * * 1` — the exact slip named above — left
412        // all tests green.
413        let anchor = dt("2026-01-01T00:00:00Z");
414        let expected_expansions: [(&str, &str); 7] = [
415            ("@yearly", "0 0 1 1 *"),
416            ("@annually", "0 0 1 1 *"),
417            ("@monthly", "0 0 1 * *"),
418            ("@weekly", "0 0 * * 0"),
419            ("@daily", "0 0 * * *"),
420            ("@midnight", "0 0 * * *"),
421            ("@hourly", "0 * * * *"),
422        ];
423        for (nickname, five_field) in expected_expansions {
424            let via_nickname = CronSchedule::parse(nickname, None).unwrap();
425            let via_five_field = CronSchedule::parse(five_field, None).unwrap();
426            assert_eq!(
427                occurrence_sequence(&via_nickname, anchor, 3),
428                occurrence_sequence(&via_five_field, anchor, 3),
429                "{nickname} vs {five_field}"
430            );
431        }
432    }
433
434    #[test]
435    fn nickname_matching_is_ascii_case_insensitive() {
436        // fails if the table is matched with `==` rather than an
437        // ASCII-case-insensitive compare, which would turn `@DAILY` into an
438        // unrecognized nickname
439        let anchor = dt("2026-01-01T00:00:00Z");
440        let upper = CronSchedule::parse("@DAILY", None).unwrap();
441        let lower = CronSchedule::parse("@daily", None).unwrap();
442        assert_eq!(
443            occurrence_sequence(&upper, anchor, 3),
444            occurrence_sequence(&lower, anchor, 3)
445        );
446    }
447
448    #[test]
449    fn nickname_pattern_keeps_its_own_spelling() {
450        // fails if the expansion is stored in place of the user's own text,
451        // the same defect `Cron::as_str` has for the five-field form
452        let schedule = CronSchedule::parse("@daily", None).unwrap();
453        assert_eq!(schedule.pattern(), "@daily");
454    }
455
456    #[test]
457    fn reboot_nickname_is_rejected_with_its_own_message() {
458        // fails if `@reboot` handling is a permissive "leading `@`, not
459        // obviously malformed" check rather than a closed table
460        match CronSchedule::parse("@reboot", None) {
461            Err(CronParseError::Pattern { pattern, reason }) => {
462                assert_eq!(pattern, "@reboot");
463                assert_eq!(
464                    reason,
465                    "shep's own restart policy already decides when a sheep starts"
466                );
467            }
468            other => panic!("expected Pattern error, got {other:?}"),
469        }
470    }
471
472    #[test]
473    fn unrecognized_nickname_is_rejected_without_reaching_croner() {
474        // fails if an unrecognized `@`-token is handed to croner anyway,
475        // which rejects it with a field-count sentence that says nothing
476        // about nicknames
477        match CronSchedule::parse("@fortnightly", None) {
478            Err(CronParseError::Pattern { pattern, reason }) => {
479                assert_eq!(pattern, "@fortnightly");
480                assert_eq!(
481                    reason,
482                    "`@fortnightly` is not a recognized cron_restart nickname (expected one of \
483                     @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly)"
484                );
485            }
486            other => panic!("expected Pattern error, got {other:?}"),
487        }
488    }
489
490    #[test]
491    fn zone_offset_is_applied_before_searching() {
492        // fails if `next_after` ignores the zone and returns 03:00 UTC
493        // directly instead of converting through Europe/Oslo's UTC+1 winter
494        // offset
495        let schedule = CronSchedule::parse("0 3 * * *", Some("Europe/Oslo")).unwrap();
496        let seq = occurrence_sequence(&schedule, dt("2026-01-05T00:00:00Z"), 3);
497        assert_eq!(
498            seq,
499            vec![
500                dt("2026-01-05T02:00:00Z"),
501                dt("2026-01-06T02:00:00Z"),
502                dt("2026-01-07T02:00:00Z"),
503            ]
504        );
505    }
506
507    #[test]
508    fn zone_offset_can_move_the_occurrence_to_a_different_utc_date() {
509        // fails the same way as the Oslo case, but here the local and UTC
510        // calendar dates disagree — a naive UTC-only implementation gets the
511        // date wrong in the other direction
512        let schedule = CronSchedule::parse("30 23 * * *", Some("Pacific/Auckland")).unwrap();
513        let seq = occurrence_sequence(&schedule, dt("2026-07-05T00:00:00Z"), 3);
514        assert_eq!(
515            seq,
516            vec![
517                dt("2026-07-05T11:30:00Z"),
518                dt("2026-07-06T11:30:00Z"),
519                dt("2026-07-07T11:30:00Z"),
520            ]
521        );
522    }
523
524    #[test]
525    fn spring_forward_gap_lands_on_the_first_valid_instant() {
526        // fails if a fixed-time job silently skips the day it lands in the
527        // 2am-3am gap instead of firing at the first valid instant after it
528        let schedule = CronSchedule::parse("30 2 * * *", Some("America/New_York")).unwrap();
529        let seq = occurrence_sequence(&schedule, dt("2026-03-06T12:00:00Z"), 4);
530        assert_eq!(
531            seq,
532            vec![
533                dt("2026-03-07T07:30:00Z"),
534                dt("2026-03-08T07:00:00Z"), // gap day: 02:30 doesn't exist; fires at 03:00 EDT
535                dt("2026-03-09T06:30:00Z"),
536                dt("2026-03-10T06:30:00Z"),
537            ]
538        );
539    }
540
541    #[test]
542    fn spring_forward_wildcard_skips_nonexistent_slots() {
543        // fails if an interval job fires the gap's nominal 02:00-02:45
544        // occurrences anyway instead of resuming on the new wall clock
545        let schedule = CronSchedule::parse("*/15 * * * *", Some("America/New_York")).unwrap();
546        let seq = occurrence_sequence(&schedule, dt("2026-03-08T06:40:00Z"), 10);
547        assert_eq!(
548            seq,
549            vec![
550                dt("2026-03-08T06:45:00Z"),
551                dt("2026-03-08T07:00:00Z"), // 03:00 EDT, right after the gap
552                dt("2026-03-08T07:15:00Z"),
553                dt("2026-03-08T07:30:00Z"),
554                dt("2026-03-08T07:45:00Z"),
555                dt("2026-03-08T08:00:00Z"),
556                dt("2026-03-08T08:15:00Z"),
557                dt("2026-03-08T08:30:00Z"),
558                dt("2026-03-08T08:45:00Z"),
559                dt("2026-03-08T09:00:00Z"),
560            ]
561        );
562    }
563
564    #[test]
565    fn fall_back_repeated_hour_fires_once() {
566        // fails if `next_after` double-fires across the repeated 1am hour
567        // instead of resolving it to the single EDT instant croner picks
568        let schedule = CronSchedule::parse("30 1 * * *", Some("America/New_York")).unwrap();
569        let seq = occurrence_sequence(&schedule, dt("2026-10-30T12:00:00Z"), 4);
570        assert_eq!(
571            seq,
572            vec![
573                dt("2026-10-31T05:30:00Z"),
574                dt("2026-11-01T05:30:00Z"), // repeated hour: EDT instant only, not also EST
575                dt("2026-11-02T06:30:00Z"),
576                dt("2026-11-03T06:30:00Z"),
577            ]
578        );
579    }
580
581    #[test]
582    fn pattern_that_never_matches_returns_none() {
583        // fails if every `CronError` variant is mapped to `Err`, losing the
584        // `Ok(None)` that `TimeSearchLimitExceeded` alone must produce
585        let schedule = CronSchedule::parse("0 0 30 2 *", None).unwrap();
586        assert_eq!(schedule.next_after(dt("2026-01-01T00:00:00Z")), Ok(None));
587    }
588
589    #[test]
590    fn search_failure_other_than_exhaustion_surfaces_as_err() {
591        // fails if `Err(_) => Ok(None)` collapses the two `CronError` arms
592        // into one — the inversion that turns a transient search failure
593        // into "this schedule never fires again", the same shape as the
594        // `Ok(None)` above but silently wrong instead of correct. A
595        // maximal-UTC instant pushes the zone conversion past what croner
596        // can search from, which it reports as `InvalidTime` rather than
597        // `TimeSearchLimitExceeded` — the `Err` arm this schedule must take.
598        let schedule = CronSchedule::parse("0 3 * * *", None).unwrap();
599        match schedule.next_after(DateTime::<Utc>::MAX_UTC) {
600            Err(CronScheduleError::Search { reason }) => {
601                assert_eq!(reason, "CronScheduler encountered an invalid time.");
602            }
603            other => panic!("expected Err(Search), got {other:?}"),
604        }
605    }
606
607    #[test]
608    fn malformed_pattern_is_rejected() {
609        // fails if a genuine parse failure is swallowed into `Ok`, only to
610        // surface later at scheduling time instead of at parse time
611        match CronSchedule::parse("not a cron", None) {
612            Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "not a cron"),
613            other => panic!("expected Pattern error, got {other:?}"),
614        }
615    }
616
617    #[test]
618    fn five_tokens_of_garbage_are_rejected() {
619        // fails if the validator only counts whitespace-separated tokens
620        // instead of checking each field's range
621        match CronSchedule::parse("99 99 99 99 99", None) {
622            Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "99 99 99 99 99"),
623            other => panic!("expected Pattern error, got {other:?}"),
624        }
625    }
626
627    #[test]
628    fn unknown_timezone_is_rejected_at_parse_time() {
629        // fails if `parse` accepts any string, leaving the bad zone to
630        // surface later when the daemon's cron worker tries to schedule
631        // against it
632        match CronSchedule::parse("0 3 * * *", Some("Mars/Olympus")) {
633            Err(CronParseError::Timezone { name }) => assert_eq!(name, "Mars/Olympus"),
634            other => panic!("expected Timezone error, got {other:?}"),
635        }
636    }
637
638    #[test]
639    fn day_of_month_last_day_extension_is_rejected() {
640        // fails if the character scan misses `L` sitting alone in a field
641        assert_extension_char_rejected("0 0 L * *", 'L');
642    }
643
644    #[test]
645    fn day_of_month_nearest_weekday_extension_is_rejected() {
646        // fails if `W` is dropped from the scan — the character most likely
647        // to be skipped, since `JUL`/`WED` make a naive scan treat it as
648        // part of a name
649        assert_extension_char_rejected("0 0 1W * *", 'W');
650    }
651
652    #[test]
653    fn day_of_week_nth_occurrence_extension_is_rejected() {
654        // fails if `#` is missed by the scan
655        assert_extension_char_rejected("0 0 * * 5#3", '#');
656    }
657
658    #[test]
659    fn day_of_week_any_extension_is_rejected() {
660        // fails if `?` is missed by the scan
661        assert_extension_char_rejected("0 0 ? * *", '?');
662    }
663
664    #[test]
665    fn month_and_weekday_names_are_not_mistaken_for_extension_characters() {
666        // fails if the scan is character-wise instead of name-aware — `JUL`
667        // contains `L` and `WED` contains `W`, both legal here. A suite that
668        // only covers rejections would pass an implementation that rejects
669        // every name-bearing pattern, which is why this row exists.
670        let schedule = CronSchedule::parse("0 0 * JUL WED", None).unwrap();
671        assert_eq!(schedule.pattern(), "0 0 * JUL WED");
672    }
673
674    #[test]
675    fn weekday_range_names_are_not_mistaken_for_extension_characters() {
676        // fails the same way, for a range spelled with day names either side
677        let schedule = CronSchedule::parse("0 0 * * MON-FRI", None).unwrap();
678        assert_eq!(schedule.pattern(), "0 0 * * MON-FRI");
679    }
680}