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