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 (Rin, 2026-08-08): widening a
4//! grammar later is backwards-compatible, narrowing one is not, so this
5//! starts narrow rather 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 is still a token counter — this exact
620 // input is what the token-count stopgap accepted
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}