Skip to main content

orch/
cron.rs

1//! Cron expression matcher — replaces the Python `cron_match.py` script.
2//!
3//! Supports:
4//! - Standard 5-field cron expressions
5//! - `--since TIMESTAMP` mode: check if the schedule fired between a timestamp and now
6//!
7//! This eliminates the `python3` subprocess that v0 forks on every tick.
8
9use anyhow::Context;
10use chrono::{DateTime, Utc};
11use cron::Schedule;
12use std::str::FromStr;
13
14/// Normalize day-of-week field: standard cron allows 0 for Sunday, but the
15/// `cron` crate only accepts 1-7 (1=Mon, 7=Sun). Replace standalone `0` with `7`.
16pub fn normalize_dow(expression: &str) -> String {
17    let fields: Vec<&str> = expression.split_whitespace().collect();
18    if fields.len() != 5 {
19        return expression.to_string();
20    }
21    let dow = fields[4];
22    // Replace 0 with 7 in the DOW field, handling ranges/lists.
23    // Examples: "0" → "7", "0,3" → "7,3", "0-5" → "1-5,7" (split: Mon-Fri + Sunday)
24    // Ranges starting at 0 cannot be expressed as a single contiguous range in the
25    // cron crate (Sun=7 > Sat=6), so we split: "0-N" → "1-N,7".
26    let normalized_dow = dow
27        .split(',')
28        .map(|part| {
29            if part == "0" {
30                "7".to_string()
31            } else if let Some(rest) = part.strip_prefix("0-") {
32                if rest == "0" {
33                    // 0-0 means only Sunday — same as plain 0
34                    "7".to_string()
35                } else {
36                    // 0-N (N > 0): split into Mon-N plus Sunday
37                    format!("1-{rest},7")
38                }
39            } else {
40                part.to_string()
41            }
42        })
43        .collect::<Vec<_>>()
44        .join(",");
45    format!(
46        "{} {} {} {} {}",
47        fields[0], fields[1], fields[2], fields[3], normalized_dow
48    )
49}
50
51/// Expand cron alias strings (like `@hourly`, `@daily 9:30`) into standard
52/// 5-field cron expressions.  Unrecognized strings are returned unchanged.
53///
54/// | Alias      | Parameter     | Example        | Expands to      |
55/// |------------|---------------|----------------|-----------------|
56/// | `@hourly`  | minute 0-59   | `@hourly 30`   | `30 * * * *`    |
57/// | `@hourly`  | none          | `@hourly`      | `0 * * * *`     |
58/// | `@daily`   | hour          | `@daily 9`     | `0 9 * * *`     |
59/// | `@daily`   | hour:min      | `@daily 9:30`  | `30 9 * * *`    |
60/// | `@daily`   | none          | `@daily`       | `0 0 * * *`     |
61/// | `@weekly`  | day (0=Sun)   | `@weekly 1`    | `0 0 * * 1`     |
62/// | `@weekly`  | none          | `@weekly`      | `0 0 * * 0`     |
63/// | `@monthly` | day-of-month  | `@monthly 15`  | `0 0 15 * *`    |
64/// | `@monthly` | none          | `@monthly`     | `0 0 1 * *`     |
65/// | `@yearly`  | month-day     | `@yearly 3-15` | `0 0 15 3 *`    |
66/// | `@yearly`  | none          | `@yearly`      | `0 0 1 1 *`     |
67///
68/// Returns an error if a parameter is present but not a valid number, or is
69/// out of the accepted range for that field.
70pub fn expand_alias(s: &str) -> anyhow::Result<String> {
71    let s = s.trim();
72    let (alias, param) = match s.split_once(char::is_whitespace) {
73        Some((a, p)) => (a, p.trim()),
74        None => (s, ""),
75    };
76    match alias {
77        "@hourly" => {
78            if param.is_empty() {
79                return Ok("0 * * * *".to_string());
80            }
81            let min = parse_field(param, "minute", alias)?;
82            check_range(min, "minute", alias, 0, 59)?;
83            Ok(format!("{min} * * * *"))
84        }
85        "@daily" => {
86            if param.is_empty() {
87                return Ok("0 0 * * *".to_string());
88            }
89            if let Some((h, m)) = param.split_once(':') {
90                let hour = parse_field(h, "hour", alias)?;
91                let min = parse_field(m, "minute", alias)?;
92                check_range(hour, "hour", alias, 0, 23)?;
93                check_range(min, "minute", alias, 0, 59)?;
94                Ok(format!("{min} {hour} * * *"))
95            } else {
96                let hour = parse_field(param, "hour", alias)?;
97                check_range(hour, "hour", alias, 0, 23)?;
98                Ok(format!("0 {hour} * * *"))
99            }
100        }
101        "@weekly" => {
102            if param.is_empty() {
103                return Ok("0 0 * * 0".to_string());
104            }
105            let day = parse_field(param, "day-of-week", alias)?;
106            check_range(day, "day-of-week", alias, 0, 7)?;
107            Ok(format!("0 0 * * {day}"))
108        }
109        "@monthly" => {
110            if param.is_empty() {
111                return Ok("0 0 1 * *".to_string());
112            }
113            let dom = parse_field(param, "day-of-month", alias)?;
114            check_range(dom, "day-of-month", alias, 1, 31)?;
115            Ok(format!("0 0 {dom} * *"))
116        }
117        "@yearly" => {
118            if param.is_empty() {
119                return Ok("0 0 1 1 *".to_string());
120            }
121            if let Some((m, d)) = param.split_once('-') {
122                let month = parse_field(m, "month", alias)?;
123                let dom = parse_field(d, "day-of-month", alias)?;
124                check_range(month, "month", alias, 1, 12)?;
125                check_range(dom, "day-of-month", alias, 1, 31)?;
126                Ok(format!("0 0 {dom} {month} *"))
127            } else {
128                anyhow::bail!(
129                    "invalid @yearly alias parameter: '{param}' (expected month-day, e.g. '3-15')"
130                );
131            }
132        }
133        _ => Ok(s.to_string()),
134    }
135}
136
137fn parse_field(raw: &str, field: &str, alias: &str) -> anyhow::Result<u8> {
138    raw.parse()
139        .with_context(|| format!("invalid {field} in {alias} alias: '{raw}'"))
140}
141
142fn check_range(value: u8, field: &str, alias: &str, lo: u8, hi: u8) -> anyhow::Result<()> {
143    anyhow::ensure!(
144        (lo..=hi).contains(&value),
145        "{field} out of range in {alias} alias: {value} (must be {lo}-{hi})"
146    );
147    Ok(())
148}
149
150/// Check if a cron expression matches now, or (with `since`) has matched
151/// at any point between `since` and now.
152///
153/// Returns `true` if the cron fired, `false` otherwise.
154pub fn check(expression: &str, since: Option<&str>) -> anyhow::Result<bool> {
155    // cron crate expects 7-field expressions (sec min hour dom mon dow year)
156    // We accept 5-field (min hour dom mon dow) and wrap with "0" seconds + "*" year.
157    //
158    // The cron crate uses 1-7 for DOW where 1=Mon and 7=Sun (ISO weekday
159    // ordering, with Sunday mapped to 7 instead of 0). Standard cron accepts
160    // 0-7 where both 0 and 7 mean Sunday. Normalize DOW=0 → DOW=7.
161    let expanded =
162        expand_alias(expression).with_context(|| format!("invalid cron alias: {expression}"))?;
163    let normalized = normalize_dow(&expanded);
164    let full_expr = format!("0 {normalized} *");
165
166    let schedule = Schedule::from_str(&full_expr)
167        .with_context(|| format!("invalid cron expression: {expression}"))?;
168
169    let now = Utc::now();
170
171    if let Some(since_str) = since {
172        let since_dt = parse_timestamp(since_str)
173            .with_context(|| format!("invalid --since timestamp: {since_str}"))?;
174
175        // Cap at 24 hours to prevent runaway catch-up
176        let cap = now - chrono::Duration::hours(24);
177        let effective_since = if since_dt < cap { cap } else { since_dt };
178
179        // Check if any occurrence falls between since and now
180        Ok(schedule
181            .after(&effective_since)
182            .take_while(|dt| *dt <= now)
183            .next()
184            .is_some())
185    } else {
186        // Check if the schedule matches the current minute.
187        // We look for the next occurrence after 1 minute ago — if it falls
188        // within the current minute, the schedule is firing now.
189        let one_min_ago = now - chrono::Duration::minutes(1);
190        let next = schedule.after(&one_min_ago).next();
191        match next {
192            Some(dt) => {
193                let diff = now.signed_duration_since(dt);
194                Ok(diff >= chrono::Duration::zero() && diff < chrono::Duration::minutes(1))
195            }
196            None => Ok(false),
197        }
198    }
199}
200
201/// Parse a timestamp string (ISO 8601 or common formats).
202fn parse_timestamp(s: &str) -> anyhow::Result<DateTime<Utc>> {
203    // Try ISO 8601 first
204    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
205        return Ok(dt.with_timezone(&Utc));
206    }
207    // Try without timezone (assume UTC)
208    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
209        return Ok(dt.and_utc());
210    }
211    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
212        return Ok(dt.and_utc());
213    }
214    anyhow::bail!("unrecognized timestamp format: {s}")
215}
216
217#[cfg(test)]
218mod tests {
219    use std::str::FromStr;
220
221    use super::*;
222    use chrono::Datelike;
223    use chrono::TimeZone;
224    use chrono::Timelike;
225    use cron::Schedule;
226
227    #[test]
228    fn every_minute_matches_now() {
229        // "* * * * *" should always match the current minute
230        let result = check("* * * * *", None).unwrap();
231        assert!(result);
232    }
233
234    #[test]
235    fn impossible_schedule_does_not_match() {
236        // Feb 30 never exists
237        let result = check("0 0 30 2 *", None).unwrap();
238        assert!(!result);
239    }
240
241    #[test]
242    fn since_mode_catches_recent_fire() {
243        // Every minute, since 5 minutes ago — should have fired
244        let five_min_ago = (Utc::now() - chrono::Duration::minutes(5))
245            .format("%Y-%m-%dT%H:%M:%SZ")
246            .to_string();
247        let result = check("* * * * *", Some(&five_min_ago)).unwrap();
248        assert!(result);
249    }
250
251    #[test]
252    fn since_mode_caps_at_24h() {
253        // Since 48 hours ago, but cap is 24h — should still work
254        let old = (Utc::now() - chrono::Duration::hours(48))
255            .format("%Y-%m-%dT%H:%M:%SZ")
256            .to_string();
257        let result = check("* * * * *", Some(&old)).unwrap();
258        assert!(result);
259    }
260
261    #[test]
262    fn invalid_expression_errors() {
263        let result = check("not a cron", None);
264        assert!(result.is_err());
265    }
266
267    #[test]
268    fn invalid_since_timestamp_errors() {
269        let result = check("* * * * *", Some("not-a-date"));
270        assert!(result.is_err());
271    }
272
273    #[test]
274    fn sunday_as_zero_parses() {
275        // Standard cron uses 0 for Sunday; the cron crate uses 1-7 (1=Mon, 7=Sun)
276        let result = check("0 20 * * 0", None);
277        assert!(
278            result.is_ok(),
279            "DOW=0 (Sunday) should be accepted: {result:?}"
280        );
281    }
282
283    #[test]
284    fn sunday_as_seven_still_works() {
285        let result = check("0 20 * * 7", None);
286        assert!(
287            result.is_ok(),
288            "DOW=7 (Sunday) should be accepted: {result:?}"
289        );
290    }
291
292    #[test]
293    fn dow_zero_in_list_parses() {
294        // e.g. "every Sunday and Wednesday"
295        let result = check("0 20 * * 0,3", None);
296        assert!(
297            result.is_ok(),
298            "DOW=0 in list should be accepted: {result:?}"
299        );
300    }
301
302    #[test]
303    fn dow_range_starting_at_zero_parses() {
304        // "0-5" = Sunday through Friday in standard cron
305        let result = check("0 9 * * 0-5", None);
306        assert!(result.is_ok(), "DOW range 0-5 should parse: {result:?}");
307    }
308
309    #[test]
310    fn dow_range_zero_to_four_parses() {
311        // "0-4" = Sunday through Thursday in standard cron
312        let result = check("0 9 * * 0-4", None);
313        assert!(result.is_ok(), "DOW range 0-4 should parse: {result:?}");
314    }
315
316    #[test]
317    fn normalize_dow_range_zero_to_five_includes_sunday() {
318        // "0-5" must normalize to "1-5,7": Mon-Fri (1-5) + Sunday (7)
319        let normalized = normalize_dow("0 9 * * 0-5");
320        assert!(
321            normalized.ends_with("1-5,7"),
322            "Expected DOW field to be '1-5,7' but got: {normalized}"
323        );
324    }
325
326    #[test]
327    fn normalize_dow_range_zero_to_four_includes_sunday() {
328        // "0-4" must normalize to "1-4,7": Mon-Thu (1-4) + Sunday (7)
329        let normalized = normalize_dow("0 9 * * 0-4");
330        assert!(
331            normalized.ends_with("1-4,7"),
332            "Expected DOW field to be '1-4,7' but got: {normalized}"
333        );
334    }
335
336    #[test]
337    fn normalize_dow_range_zero_to_zero_only_sunday() {
338        // "0-0" means only Sunday; should normalize to "7" (not invalid "1-0,7")
339        let normalized = normalize_dow("0 9 * * 0-0");
340        assert!(
341            normalized.ends_with('7'),
342            "Expected DOW field to be '7' but got: {normalized}"
343        );
344    }
345
346    #[test]
347    fn dow_range_zero_to_zero_parses() {
348        // "0-0" should be a valid cron expression meaning "only Sunday"
349        let result = check("0 9 * * 0-0", None);
350        assert!(result.is_ok(), "DOW range 0-0 should parse: {result:?}");
351    }
352
353    #[test]
354    fn dow_range_zero_to_six_includes_sunday() {
355        // "0-6" = every day; normalizes to "1-6,7"
356        let normalized = normalize_dow("0 9 * * 0-6");
357        assert!(
358            normalized.ends_with("1-6,7"),
359            "Expected DOW field to be '1-6,7' but got: {normalized}"
360        );
361        // Also verify it parses and fires (every day schedule always matches now)
362        let result = check("0 9 * * 0-6", None);
363        assert!(result.is_ok(), "DOW range 0-6 should parse: {result:?}");
364    }
365
366    #[test]
367    fn dow_range_zero_to_five_fires_on_sunday() {
368        // Normalize and build a 7-field expression, then check that a known Sunday
369        // appears in the schedule's upcoming occurrences.
370        let normalized = normalize_dow("0 9 * * 0-5");
371        let full_expr = format!("0 {normalized} *");
372        let schedule = Schedule::from_str(&full_expr).expect("should parse");
373
374        // Find the next 14 occurrences and verify at least one is a Sunday (weekday 0 in chrono).
375        let base = Utc.with_ymd_and_hms(2026, 3, 23, 0, 0, 0).unwrap(); // Monday
376        let has_sunday = schedule
377            .after(&base)
378            .take(14)
379            .any(|dt| dt.weekday() == chrono::Weekday::Sun);
380        assert!(has_sunday, "Schedule '0 9 * * 0-5' must fire on Sunday");
381    }
382
383    // --- expand_alias tests ---
384
385    #[test]
386    fn expand_alias_hourly_no_param() {
387        assert_eq!(expand_alias("@hourly").unwrap(), "0 * * * *");
388    }
389
390    #[test]
391    fn expand_alias_hourly_with_minute() {
392        assert_eq!(expand_alias("@hourly 30").unwrap(), "30 * * * *");
393    }
394
395    #[test]
396    fn expand_alias_daily_no_param() {
397        assert_eq!(expand_alias("@daily").unwrap(), "0 0 * * *");
398    }
399
400    #[test]
401    fn expand_alias_daily_with_hour() {
402        assert_eq!(expand_alias("@daily 9").unwrap(), "0 9 * * *");
403    }
404
405    #[test]
406    fn expand_alias_daily_with_hour_and_minute() {
407        assert_eq!(expand_alias("@daily 9:30").unwrap(), "30 9 * * *");
408    }
409
410    #[test]
411    fn expand_alias_weekly_no_param() {
412        assert_eq!(expand_alias("@weekly").unwrap(), "0 0 * * 0");
413    }
414
415    #[test]
416    fn expand_alias_weekly_with_day() {
417        assert_eq!(expand_alias("@weekly 1").unwrap(), "0 0 * * 1");
418    }
419
420    #[test]
421    fn expand_alias_monthly_no_param() {
422        assert_eq!(expand_alias("@monthly").unwrap(), "0 0 1 * *");
423    }
424
425    #[test]
426    fn expand_alias_monthly_with_day() {
427        assert_eq!(expand_alias("@monthly 15").unwrap(), "0 0 15 * *");
428    }
429
430    #[test]
431    fn expand_alias_yearly_no_param() {
432        assert_eq!(expand_alias("@yearly").unwrap(), "0 0 1 1 *");
433    }
434
435    #[test]
436    fn expand_alias_yearly_with_month_day() {
437        assert_eq!(expand_alias("@yearly 3-15").unwrap(), "0 0 15 3 *");
438    }
439
440    #[test]
441    fn expand_alias_passthrough_regular_cron() {
442        assert_eq!(expand_alias("30 9 * * 1").unwrap(), "30 9 * * 1");
443    }
444
445    #[test]
446    fn expand_alias_unknown_at_prefix_passthrough() {
447        assert_eq!(expand_alias("@unknown").unwrap(), "@unknown");
448    }
449
450    #[test]
451    fn expand_alias_check_integration_daily_9_30() {
452        // @daily 9:30 should produce a valid parseable cron expression
453        let expanded = expand_alias("@daily 9:30").unwrap();
454        let normalized = normalize_dow(&expanded);
455        let full = format!("0 {normalized} *");
456        assert!(
457            Schedule::from_str(&full).is_ok(),
458            "expanded '@daily 9:30' → '{expanded}' should parse"
459        );
460    }
461
462    #[test]
463    fn expand_alias_check_integration_weekly_monday() {
464        let expanded = expand_alias("@weekly 1").unwrap();
465        let normalized = normalize_dow(&expanded);
466        let full = format!("0 {normalized} *");
467        assert!(
468            Schedule::from_str(&full).is_ok(),
469            "expanded '@weekly 1' → '{expanded}' should parse"
470        );
471    }
472
473    // --- expand_alias error cases ---
474
475    #[test]
476    fn expand_alias_hourly_invalid_param_errors() {
477        assert!(expand_alias("@hourly abc").is_err());
478    }
479
480    #[test]
481    fn expand_alias_hourly_out_of_range_errors() {
482        assert!(expand_alias("@hourly 60").is_err());
483    }
484
485    #[test]
486    fn expand_alias_daily_invalid_param_errors() {
487        assert!(expand_alias("@daily abc").is_err());
488    }
489
490    #[test]
491    fn expand_alias_daily_invalid_hour_colon_errors() {
492        assert!(expand_alias("@daily abc:30").is_err());
493    }
494
495    #[test]
496    fn expand_alias_daily_invalid_minute_colon_errors() {
497        assert!(expand_alias("@daily 9:xyz").is_err());
498    }
499
500    #[test]
501    fn expand_alias_daily_hour_out_of_range_errors() {
502        assert!(expand_alias("@daily 24").is_err());
503    }
504
505    #[test]
506    fn expand_alias_daily_minute_out_of_range_errors() {
507        assert!(expand_alias("@daily 9:60").is_err());
508    }
509
510    #[test]
511    fn expand_alias_weekly_invalid_param_errors() {
512        assert!(expand_alias("@weekly foo").is_err());
513    }
514
515    #[test]
516    fn expand_alias_weekly_out_of_range_errors() {
517        assert!(expand_alias("@weekly 8").is_err());
518    }
519
520    #[test]
521    fn expand_alias_monthly_invalid_param_errors() {
522        assert!(expand_alias("@monthly bar").is_err());
523    }
524
525    #[test]
526    fn expand_alias_monthly_out_of_range_errors() {
527        assert!(expand_alias("@monthly 0").is_err());
528    }
529
530    #[test]
531    fn expand_alias_yearly_invalid_param_no_dash_errors() {
532        assert!(expand_alias("@yearly 3").is_err());
533    }
534
535    #[test]
536    fn expand_alias_yearly_invalid_month_errors() {
537        assert!(expand_alias("@yearly abc-15").is_err());
538    }
539
540    #[test]
541    fn expand_alias_yearly_invalid_dom_errors() {
542        assert!(expand_alias("@yearly 3-xyz").is_err());
543    }
544
545    #[test]
546    fn expand_alias_yearly_month_out_of_range_errors() {
547        assert!(expand_alias("@yearly 13-1").is_err());
548    }
549
550    fn alias_err(s: &str) -> String {
551        expand_alias(s).unwrap_err().to_string()
552    }
553
554    #[test]
555    fn expand_alias_error_messages_are_stable() {
556        assert_eq!(
557            alias_err("@hourly abc"),
558            "invalid minute in @hourly alias: 'abc'"
559        );
560        assert_eq!(
561            alias_err("@hourly 60"),
562            "minute out of range in @hourly alias: 60 (must be 0-59)"
563        );
564        assert_eq!(
565            alias_err("@daily abc"),
566            "invalid hour in @daily alias: 'abc'"
567        );
568        assert_eq!(
569            alias_err("@daily x:30"),
570            "invalid hour in @daily alias: 'x'"
571        );
572        assert_eq!(
573            alias_err("@daily 9:y"),
574            "invalid minute in @daily alias: 'y'"
575        );
576        assert_eq!(
577            alias_err("@daily 24"),
578            "hour out of range in @daily alias: 24 (must be 0-23)"
579        );
580        assert_eq!(
581            alias_err("@daily 24:5"),
582            "hour out of range in @daily alias: 24 (must be 0-23)"
583        );
584        assert_eq!(
585            alias_err("@daily 9:60"),
586            "minute out of range in @daily alias: 60 (must be 0-59)"
587        );
588        assert_eq!(
589            alias_err("@weekly foo"),
590            "invalid day-of-week in @weekly alias: 'foo'"
591        );
592        assert_eq!(
593            alias_err("@weekly 8"),
594            "day-of-week out of range in @weekly alias: 8 (must be 0-7)"
595        );
596        assert_eq!(
597            alias_err("@monthly bar"),
598            "invalid day-of-month in @monthly alias: 'bar'"
599        );
600        assert_eq!(
601            alias_err("@monthly 0"),
602            "day-of-month out of range in @monthly alias: 0 (must be 1-31)"
603        );
604        assert_eq!(
605            alias_err("@monthly 32"),
606            "day-of-month out of range in @monthly alias: 32 (must be 1-31)"
607        );
608        assert_eq!(
609            alias_err("@yearly x-15"),
610            "invalid month in @yearly alias: 'x'"
611        );
612        assert_eq!(
613            alias_err("@yearly 3-y"),
614            "invalid day-of-month in @yearly alias: 'y'"
615        );
616        assert_eq!(
617            alias_err("@yearly 13-1"),
618            "month out of range in @yearly alias: 13 (must be 1-12)"
619        );
620        assert_eq!(
621            alias_err("@yearly 0-1"),
622            "month out of range in @yearly alias: 0 (must be 1-12)"
623        );
624        assert_eq!(
625            alias_err("@yearly 3-32"),
626            "day-of-month out of range in @yearly alias: 32 (must be 1-31)"
627        );
628        assert_eq!(
629            alias_err("@yearly 3"),
630            "invalid @yearly alias parameter: '3' (expected month-day, e.g. '3-15')"
631        );
632    }
633
634    #[test]
635    fn expand_alias_error_order_parses_before_range_checks() {
636        // Both fields are parsed before either range check runs
637        assert_eq!(
638            alias_err("@yearly 13-x"),
639            "invalid day-of-month in @yearly alias: 'x'"
640        );
641        assert_eq!(
642            alias_err("@daily 24:x"),
643            "invalid minute in @daily alias: 'x'"
644        );
645    }
646
647    #[test]
648    fn expand_alias_accepts_range_bounds() {
649        assert_eq!(expand_alias("@hourly 59").unwrap(), "59 * * * *");
650        assert_eq!(expand_alias("@daily 23:59").unwrap(), "59 23 * * *");
651        assert_eq!(expand_alias("@weekly 7").unwrap(), "0 0 * * 7");
652        assert_eq!(expand_alias("@monthly 31").unwrap(), "0 0 31 * *");
653        assert_eq!(expand_alias("@yearly 12-31").unwrap(), "0 0 31 12 *");
654        assert_eq!(expand_alias("@yearly 1-1").unwrap(), "0 0 1 1 *");
655    }
656
657    #[test]
658    fn expand_alias_daily_abc_errors_via_check() {
659        // Regression: @daily abc used to silently expand to "0 0 * * *"
660        assert!(check("@daily abc", None).is_err());
661    }
662
663    #[test]
664    fn expand_alias_hourly_not_a_number_errors_via_check() {
665        // Regression: @hourly not-a-number used to silently expand to "0 * * * *"
666        assert!(check("@hourly not-a-number", None).is_err());
667    }
668
669    #[test]
670    fn parse_rfc3339_timestamp() {
671        let dt = parse_timestamp("2026-02-22T10:30:00Z").unwrap();
672        assert_eq!(dt.hour(), 10);
673        assert_eq!(dt.minute(), 30);
674    }
675
676    #[test]
677    fn parse_naive_timestamp() {
678        let dt = parse_timestamp("2026-02-22T10:30:00").unwrap();
679        assert_eq!(dt.hour(), 10);
680    }
681
682    #[test]
683    fn parse_space_separated_timestamp() {
684        let dt = parse_timestamp("2026-02-22 10:30:00").unwrap();
685        assert_eq!(dt.hour(), 10);
686    }
687}