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