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    match since {
172        Some(since_str) => {
173            let since_dt = parse_timestamp(since_str)
174                .with_context(|| format!("invalid --since timestamp: {since_str}"))?;
175
176            // Cap at 24 hours to prevent runaway catch-up
177            let cap = now - chrono::Duration::hours(24);
178            let effective_since = if since_dt < cap { cap } else { since_dt };
179
180            // Check if any occurrence falls between since and now
181            Ok(schedule
182                .after(&effective_since)
183                .take_while(|dt| *dt <= now)
184                .next()
185                .is_some())
186        }
187        None => {
188            // Check if the schedule matches the current minute.
189            // We look for the next occurrence after 1 minute ago — if it falls
190            // within the current minute, the schedule is firing now.
191            let one_min_ago = now - chrono::Duration::minutes(1);
192            let next = schedule.after(&one_min_ago).next();
193            match next {
194                Some(dt) => {
195                    let diff = now.signed_duration_since(dt);
196                    Ok(diff >= chrono::Duration::zero() && diff < chrono::Duration::minutes(1))
197                }
198                None => Ok(false),
199            }
200        }
201    }
202}
203
204/// Parse a timestamp string (ISO 8601 or common formats).
205fn parse_timestamp(s: &str) -> anyhow::Result<DateTime<Utc>> {
206    // Try ISO 8601 first
207    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
208        return Ok(dt.with_timezone(&Utc));
209    }
210    // Try without timezone (assume UTC)
211    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
212        return Ok(dt.and_utc());
213    }
214    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
215        return Ok(dt.and_utc());
216    }
217    anyhow::bail!("unrecognized timestamp format: {s}")
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use chrono::Timelike;
224
225    #[test]
226    fn every_minute_matches_now() {
227        // "* * * * *" should always match the current minute
228        let result = check("* * * * *", None).unwrap();
229        assert!(result);
230    }
231
232    #[test]
233    fn impossible_schedule_does_not_match() {
234        // Feb 30 never exists
235        let result = check("0 0 30 2 *", None).unwrap();
236        assert!(!result);
237    }
238
239    #[test]
240    fn since_mode_catches_recent_fire() {
241        // Every minute, since 5 minutes ago — should have fired
242        let five_min_ago = (Utc::now() - chrono::Duration::minutes(5))
243            .format("%Y-%m-%dT%H:%M:%SZ")
244            .to_string();
245        let result = check("* * * * *", Some(&five_min_ago)).unwrap();
246        assert!(result);
247    }
248
249    #[test]
250    fn since_mode_caps_at_24h() {
251        // Since 48 hours ago, but cap is 24h — should still work
252        let old = (Utc::now() - chrono::Duration::hours(48))
253            .format("%Y-%m-%dT%H:%M:%SZ")
254            .to_string();
255        let result = check("* * * * *", Some(&old)).unwrap();
256        assert!(result);
257    }
258
259    #[test]
260    fn invalid_expression_errors() {
261        let result = check("not a cron", None);
262        assert!(result.is_err());
263    }
264
265    #[test]
266    fn invalid_since_timestamp_errors() {
267        let result = check("* * * * *", Some("not-a-date"));
268        assert!(result.is_err());
269    }
270
271    #[test]
272    fn sunday_as_zero_parses() {
273        // Standard cron uses 0 for Sunday; the cron crate uses 1-7 (1=Mon, 7=Sun)
274        let result = check("0 20 * * 0", None);
275        assert!(
276            result.is_ok(),
277            "DOW=0 (Sunday) should be accepted: {result:?}"
278        );
279    }
280
281    #[test]
282    fn sunday_as_seven_still_works() {
283        let result = check("0 20 * * 7", None);
284        assert!(
285            result.is_ok(),
286            "DOW=7 (Sunday) should be accepted: {result:?}"
287        );
288    }
289
290    #[test]
291    fn dow_zero_in_list_parses() {
292        // e.g. "every Sunday and Wednesday"
293        let result = check("0 20 * * 0,3", None);
294        assert!(
295            result.is_ok(),
296            "DOW=0 in list should be accepted: {result:?}"
297        );
298    }
299
300    #[test]
301    fn dow_range_starting_at_zero_parses() {
302        // "0-5" = Sunday through Friday in standard cron
303        let result = check("0 9 * * 0-5", None);
304        assert!(result.is_ok(), "DOW range 0-5 should parse: {result:?}");
305    }
306
307    #[test]
308    fn dow_range_zero_to_four_parses() {
309        // "0-4" = Sunday through Thursday in standard cron
310        let result = check("0 9 * * 0-4", None);
311        assert!(result.is_ok(), "DOW range 0-4 should parse: {result:?}");
312    }
313
314    #[test]
315    fn normalize_dow_range_zero_to_five_includes_sunday() {
316        // "0-5" must normalize to "1-5,7": Mon-Fri (1-5) + Sunday (7)
317        let normalized = normalize_dow("0 9 * * 0-5");
318        assert!(
319            normalized.ends_with("1-5,7"),
320            "Expected DOW field to be '1-5,7' but got: {normalized}"
321        );
322    }
323
324    #[test]
325    fn normalize_dow_range_zero_to_four_includes_sunday() {
326        // "0-4" must normalize to "1-4,7": Mon-Thu (1-4) + Sunday (7)
327        let normalized = normalize_dow("0 9 * * 0-4");
328        assert!(
329            normalized.ends_with("1-4,7"),
330            "Expected DOW field to be '1-4,7' but got: {normalized}"
331        );
332    }
333
334    #[test]
335    fn normalize_dow_range_zero_to_zero_only_sunday() {
336        // "0-0" means only Sunday; should normalize to "7" (not invalid "1-0,7")
337        let normalized = normalize_dow("0 9 * * 0-0");
338        assert!(
339            normalized.ends_with("7"),
340            "Expected DOW field to be '7' but got: {normalized}"
341        );
342    }
343
344    #[test]
345    fn dow_range_zero_to_zero_parses() {
346        // "0-0" should be a valid cron expression meaning "only Sunday"
347        let result = check("0 9 * * 0-0", None);
348        assert!(result.is_ok(), "DOW range 0-0 should parse: {result:?}");
349    }
350
351    #[test]
352    fn dow_range_zero_to_six_includes_sunday() {
353        // "0-6" = every day; normalizes to "1-6,7"
354        let normalized = normalize_dow("0 9 * * 0-6");
355        assert!(
356            normalized.ends_with("1-6,7"),
357            "Expected DOW field to be '1-6,7' but got: {normalized}"
358        );
359        // Also verify it parses and fires (every day schedule always matches now)
360        let result = check("0 9 * * 0-6", None);
361        assert!(result.is_ok(), "DOW range 0-6 should parse: {result:?}");
362    }
363
364    #[test]
365    fn dow_range_zero_to_five_fires_on_sunday() {
366        use chrono::{Datelike, TimeZone};
367        use cron::Schedule;
368        use std::str::FromStr;
369
370        // Normalize and build a 7-field expression, then check that a known Sunday
371        // appears in the schedule's upcoming occurrences.
372        let normalized = normalize_dow("0 9 * * 0-5");
373        let full_expr = format!("0 {normalized} *");
374        let schedule = Schedule::from_str(&full_expr).expect("should parse");
375
376        // Find the next 14 occurrences and verify at least one is a Sunday (weekday 0 in chrono).
377        let base = Utc.with_ymd_and_hms(2026, 3, 23, 0, 0, 0).unwrap(); // Monday
378        let has_sunday = schedule
379            .after(&base)
380            .take(14)
381            .any(|dt| dt.weekday() == chrono::Weekday::Sun);
382        assert!(has_sunday, "Schedule '0 9 * * 0-5' must fire on Sunday");
383    }
384
385    // --- expand_alias tests ---
386
387    #[test]
388    fn expand_alias_hourly_no_param() {
389        assert_eq!(expand_alias("@hourly").unwrap(), "0 * * * *");
390    }
391
392    #[test]
393    fn expand_alias_hourly_with_minute() {
394        assert_eq!(expand_alias("@hourly 30").unwrap(), "30 * * * *");
395    }
396
397    #[test]
398    fn expand_alias_daily_no_param() {
399        assert_eq!(expand_alias("@daily").unwrap(), "0 0 * * *");
400    }
401
402    #[test]
403    fn expand_alias_daily_with_hour() {
404        assert_eq!(expand_alias("@daily 9").unwrap(), "0 9 * * *");
405    }
406
407    #[test]
408    fn expand_alias_daily_with_hour_and_minute() {
409        assert_eq!(expand_alias("@daily 9:30").unwrap(), "30 9 * * *");
410    }
411
412    #[test]
413    fn expand_alias_weekly_no_param() {
414        assert_eq!(expand_alias("@weekly").unwrap(), "0 0 * * 0");
415    }
416
417    #[test]
418    fn expand_alias_weekly_with_day() {
419        assert_eq!(expand_alias("@weekly 1").unwrap(), "0 0 * * 1");
420    }
421
422    #[test]
423    fn expand_alias_monthly_no_param() {
424        assert_eq!(expand_alias("@monthly").unwrap(), "0 0 1 * *");
425    }
426
427    #[test]
428    fn expand_alias_monthly_with_day() {
429        assert_eq!(expand_alias("@monthly 15").unwrap(), "0 0 15 * *");
430    }
431
432    #[test]
433    fn expand_alias_yearly_no_param() {
434        assert_eq!(expand_alias("@yearly").unwrap(), "0 0 1 1 *");
435    }
436
437    #[test]
438    fn expand_alias_yearly_with_month_day() {
439        assert_eq!(expand_alias("@yearly 3-15").unwrap(), "0 0 15 3 *");
440    }
441
442    #[test]
443    fn expand_alias_passthrough_regular_cron() {
444        assert_eq!(expand_alias("30 9 * * 1").unwrap(), "30 9 * * 1");
445    }
446
447    #[test]
448    fn expand_alias_unknown_at_prefix_passthrough() {
449        assert_eq!(expand_alias("@unknown").unwrap(), "@unknown");
450    }
451
452    #[test]
453    fn expand_alias_check_integration_daily_9_30() {
454        // @daily 9:30 should produce a valid parseable cron expression
455        let expanded = expand_alias("@daily 9:30").unwrap();
456        let normalized = normalize_dow(&expanded);
457        let full = format!("0 {normalized} *");
458        assert!(
459            Schedule::from_str(&full).is_ok(),
460            "expanded '@daily 9:30' → '{expanded}' should parse"
461        );
462    }
463
464    #[test]
465    fn expand_alias_check_integration_weekly_monday() {
466        let expanded = expand_alias("@weekly 1").unwrap();
467        let normalized = normalize_dow(&expanded);
468        let full = format!("0 {normalized} *");
469        assert!(
470            Schedule::from_str(&full).is_ok(),
471            "expanded '@weekly 1' → '{expanded}' should parse"
472        );
473    }
474
475    // --- expand_alias error cases ---
476
477    #[test]
478    fn expand_alias_hourly_invalid_param_errors() {
479        assert!(expand_alias("@hourly abc").is_err());
480    }
481
482    #[test]
483    fn expand_alias_hourly_out_of_range_errors() {
484        assert!(expand_alias("@hourly 60").is_err());
485    }
486
487    #[test]
488    fn expand_alias_daily_invalid_param_errors() {
489        assert!(expand_alias("@daily abc").is_err());
490    }
491
492    #[test]
493    fn expand_alias_daily_invalid_hour_colon_errors() {
494        assert!(expand_alias("@daily abc:30").is_err());
495    }
496
497    #[test]
498    fn expand_alias_daily_invalid_minute_colon_errors() {
499        assert!(expand_alias("@daily 9:xyz").is_err());
500    }
501
502    #[test]
503    fn expand_alias_daily_hour_out_of_range_errors() {
504        assert!(expand_alias("@daily 24").is_err());
505    }
506
507    #[test]
508    fn expand_alias_daily_minute_out_of_range_errors() {
509        assert!(expand_alias("@daily 9:60").is_err());
510    }
511
512    #[test]
513    fn expand_alias_weekly_invalid_param_errors() {
514        assert!(expand_alias("@weekly foo").is_err());
515    }
516
517    #[test]
518    fn expand_alias_weekly_out_of_range_errors() {
519        assert!(expand_alias("@weekly 8").is_err());
520    }
521
522    #[test]
523    fn expand_alias_monthly_invalid_param_errors() {
524        assert!(expand_alias("@monthly bar").is_err());
525    }
526
527    #[test]
528    fn expand_alias_monthly_out_of_range_errors() {
529        assert!(expand_alias("@monthly 0").is_err());
530    }
531
532    #[test]
533    fn expand_alias_yearly_invalid_param_no_dash_errors() {
534        assert!(expand_alias("@yearly 3").is_err());
535    }
536
537    #[test]
538    fn expand_alias_yearly_invalid_month_errors() {
539        assert!(expand_alias("@yearly abc-15").is_err());
540    }
541
542    #[test]
543    fn expand_alias_yearly_invalid_dom_errors() {
544        assert!(expand_alias("@yearly 3-xyz").is_err());
545    }
546
547    #[test]
548    fn expand_alias_yearly_month_out_of_range_errors() {
549        assert!(expand_alias("@yearly 13-1").is_err());
550    }
551
552    fn alias_err(s: &str) -> String {
553        expand_alias(s).unwrap_err().to_string()
554    }
555
556    #[test]
557    fn expand_alias_error_messages_are_stable() {
558        assert_eq!(
559            alias_err("@hourly abc"),
560            "invalid minute in @hourly alias: 'abc'"
561        );
562        assert_eq!(
563            alias_err("@hourly 60"),
564            "minute out of range in @hourly alias: 60 (must be 0-59)"
565        );
566        assert_eq!(
567            alias_err("@daily abc"),
568            "invalid hour in @daily alias: 'abc'"
569        );
570        assert_eq!(
571            alias_err("@daily x:30"),
572            "invalid hour in @daily alias: 'x'"
573        );
574        assert_eq!(
575            alias_err("@daily 9:y"),
576            "invalid minute in @daily alias: 'y'"
577        );
578        assert_eq!(
579            alias_err("@daily 24"),
580            "hour out of range in @daily alias: 24 (must be 0-23)"
581        );
582        assert_eq!(
583            alias_err("@daily 24:5"),
584            "hour out of range in @daily alias: 24 (must be 0-23)"
585        );
586        assert_eq!(
587            alias_err("@daily 9:60"),
588            "minute out of range in @daily alias: 60 (must be 0-59)"
589        );
590        assert_eq!(
591            alias_err("@weekly foo"),
592            "invalid day-of-week in @weekly alias: 'foo'"
593        );
594        assert_eq!(
595            alias_err("@weekly 8"),
596            "day-of-week out of range in @weekly alias: 8 (must be 0-7)"
597        );
598        assert_eq!(
599            alias_err("@monthly bar"),
600            "invalid day-of-month in @monthly alias: 'bar'"
601        );
602        assert_eq!(
603            alias_err("@monthly 0"),
604            "day-of-month out of range in @monthly alias: 0 (must be 1-31)"
605        );
606        assert_eq!(
607            alias_err("@monthly 32"),
608            "day-of-month out of range in @monthly alias: 32 (must be 1-31)"
609        );
610        assert_eq!(
611            alias_err("@yearly x-15"),
612            "invalid month in @yearly alias: 'x'"
613        );
614        assert_eq!(
615            alias_err("@yearly 3-y"),
616            "invalid day-of-month in @yearly alias: 'y'"
617        );
618        assert_eq!(
619            alias_err("@yearly 13-1"),
620            "month out of range in @yearly alias: 13 (must be 1-12)"
621        );
622        assert_eq!(
623            alias_err("@yearly 0-1"),
624            "month out of range in @yearly alias: 0 (must be 1-12)"
625        );
626        assert_eq!(
627            alias_err("@yearly 3-32"),
628            "day-of-month out of range in @yearly alias: 32 (must be 1-31)"
629        );
630        assert_eq!(
631            alias_err("@yearly 3"),
632            "invalid @yearly alias parameter: '3' (expected month-day, e.g. '3-15')"
633        );
634    }
635
636    #[test]
637    fn expand_alias_error_order_parses_before_range_checks() {
638        // Both fields are parsed before either range check runs
639        assert_eq!(
640            alias_err("@yearly 13-x"),
641            "invalid day-of-month in @yearly alias: 'x'"
642        );
643        assert_eq!(
644            alias_err("@daily 24:x"),
645            "invalid minute in @daily alias: 'x'"
646        );
647    }
648
649    #[test]
650    fn expand_alias_accepts_range_bounds() {
651        assert_eq!(expand_alias("@hourly 59").unwrap(), "59 * * * *");
652        assert_eq!(expand_alias("@daily 23:59").unwrap(), "59 23 * * *");
653        assert_eq!(expand_alias("@weekly 7").unwrap(), "0 0 * * 7");
654        assert_eq!(expand_alias("@monthly 31").unwrap(), "0 0 31 * *");
655        assert_eq!(expand_alias("@yearly 12-31").unwrap(), "0 0 31 12 *");
656        assert_eq!(expand_alias("@yearly 1-1").unwrap(), "0 0 1 1 *");
657    }
658
659    #[test]
660    fn expand_alias_daily_abc_errors_via_check() {
661        // Regression: @daily abc used to silently expand to "0 0 * * *"
662        assert!(check("@daily abc", None).is_err());
663    }
664
665    #[test]
666    fn expand_alias_hourly_not_a_number_errors_via_check() {
667        // Regression: @hourly not-a-number used to silently expand to "0 * * * *"
668        assert!(check("@hourly not-a-number", None).is_err());
669    }
670
671    #[test]
672    fn parse_rfc3339_timestamp() {
673        let dt = parse_timestamp("2026-02-22T10:30:00Z").unwrap();
674        assert_eq!(dt.hour(), 10);
675        assert_eq!(dt.minute(), 30);
676    }
677
678    #[test]
679    fn parse_naive_timestamp() {
680        let dt = parse_timestamp("2026-02-22T10:30:00").unwrap();
681        assert_eq!(dt.hour(), 10);
682    }
683
684    #[test]
685    fn parse_space_separated_timestamp() {
686        let dt = parse_timestamp("2026-02-22 10:30:00").unwrap();
687        assert_eq!(dt.hour(), 10);
688    }
689}