Skip to main content

shep_core/config/
cron.rs

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