Skip to main content

zeroclaw_runtime/cron/
schedule.rs

1use crate::cron::Schedule;
2use anyhow::{Context, Result};
3use chrono::{DateTime, Duration as ChronoDuration, Utc};
4use cron::Schedule as CronExprSchedule;
5use std::str::FromStr;
6
7pub fn next_run_for_schedule(schedule: &Schedule, from: DateTime<Utc>) -> Result<DateTime<Utc>> {
8    match schedule {
9        Schedule::Cron { expr, tz } => {
10            let normalized = normalize_expression(expr)?;
11            let cron = CronExprSchedule::from_str(&normalized)
12                .with_context(|| format!("Invalid cron expression: {expr}"))?;
13
14            if let Some(tz_name) = tz {
15                let timezone = chrono_tz::Tz::from_str(tz_name)
16                    .with_context(|| format!("Invalid IANA timezone: {tz_name}"))?;
17                let localized_from = from.with_timezone(&timezone);
18                let next_local = cron.after(&localized_from).next().ok_or_else(|| {
19                    ::zeroclaw_log::record!(
20                        WARN,
21                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
22                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
23                            .with_attrs(::serde_json::json!({"expr": expr})),
24                        "cron schedule: no future occurrence for expression"
25                    );
26                    anyhow::Error::msg(format!("No future occurrence for expression: {expr}"))
27                })?;
28                Ok(next_local.with_timezone(&Utc))
29            } else {
30                // Default to OS local timezone so schedules match user
31                // expectations instead of always using UTC.
32                let local_from = from.with_timezone(&chrono::Local);
33                let next_local = cron.after(&local_from).next().ok_or_else(|| {
34                    ::zeroclaw_log::record!(
35                        WARN,
36                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
37                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
38                            .with_attrs(::serde_json::json!({"expr": expr})),
39                        "cron schedule: no future occurrence for expression"
40                    );
41                    anyhow::Error::msg(format!("No future occurrence for expression: {expr}"))
42                })?;
43                Ok(next_local.with_timezone(&Utc))
44            }
45        }
46        Schedule::At { at } => Ok(*at),
47        Schedule::Every { every_ms } => {
48            if *every_ms == 0 {
49                anyhow::bail!("Invalid schedule: every_ms must be > 0");
50            }
51            let ms = i64::try_from(*every_ms).context("every_ms is too large")?;
52            let delta = ChronoDuration::milliseconds(ms);
53            from.checked_add_signed(delta).ok_or_else(|| {
54                ::zeroclaw_log::record!(
55                    ERROR,
56                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
57                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
58                        .with_attrs(::serde_json::json!({"every_ms": *every_ms})),
59                    "cron schedule: every_ms overflowed DateTime arithmetic"
60                );
61                anyhow::Error::msg("every_ms overflowed DateTime")
62            })
63        }
64    }
65}
66
67pub fn validate_schedule(schedule: &Schedule, now: DateTime<Utc>) -> Result<()> {
68    match schedule {
69        Schedule::Cron { expr, .. } => {
70            let _ = normalize_expression(expr)?;
71            let _ = next_run_for_schedule(schedule, now)?;
72            Ok(())
73        }
74        Schedule::At { at } => {
75            if *at <= now {
76                anyhow::bail!(
77                    "Invalid schedule: 'at' must be in the future \
78                     (now_utc={}, now_local={}, at_utc={}, at_local={}, delta_seconds={})",
79                    now.to_rfc3339(),
80                    now.with_timezone(&chrono::Local).to_rfc3339(),
81                    at.to_rfc3339(),
82                    at.with_timezone(&chrono::Local).to_rfc3339(),
83                    (*at - now).num_seconds()
84                );
85            }
86            Ok(())
87        }
88        Schedule::Every { every_ms } => {
89            if *every_ms == 0 {
90                anyhow::bail!("Invalid schedule: every_ms must be > 0");
91            }
92            Ok(())
93        }
94    }
95}
96
97pub fn schedule_cron_expression(schedule: &Schedule) -> Option<String> {
98    match schedule {
99        Schedule::Cron { expr, .. } => Some(expr.clone()),
100        _ => None,
101    }
102}
103
104pub fn normalize_expression(expression: &str) -> Result<String> {
105    let expression = expression.trim();
106    let field_count = expression.split_whitespace().count();
107
108    match field_count {
109        // standard crontab syntax: minute hour day month weekday
110        // Normalize weekday field from standard crontab semantics (0/7=Sun, 1=Mon, …, 6=Sat)
111        // to cron-crate semantics (1=Sun, 2=Mon, …, 7=Sat).
112        5 => {
113            let mut fields: Vec<&str> = expression.split_whitespace().collect();
114            let weekday = fields[4];
115            let normalized_weekday = normalize_weekday_field(weekday)?;
116            fields[4] = &normalized_weekday;
117            Ok(format!(
118                "0 {} {} {} {} {}",
119                fields[0], fields[1], fields[2], fields[3], fields[4]
120            ))
121        }
122        // crate-native syntax includes seconds (+ optional year)
123        6 | 7 => Ok(expression.to_string()),
124        _ => anyhow::bail!(
125            "Invalid cron expression: {expression} (expected 5, 6, or 7 fields, got {field_count})"
126        ),
127    }
128}
129
130/// Translate a single numeric weekday value from standard crontab semantics
131/// (0 or 7 = Sunday, 1 = Monday, …, 6 = Saturday) to cron-crate semantics
132/// (1 = Sunday, 2 = Monday, …, 7 = Saturday).
133fn translate_weekday_value(val: u8) -> Result<u8> {
134    match val {
135        0 | 7 => Ok(1), // Sunday
136        1..=6 => Ok(val + 1),
137        _ => anyhow::bail!("Invalid weekday value: {val} (expected 0-7)"),
138    }
139}
140
141/// Normalize the weekday field of a 5-field cron expression from standard
142/// crontab numbering to cron-crate numbering. Passes through `*`, named days
143/// (e.g. `MON`, `MON-FRI`), and already-valid tokens unchanged.
144fn normalize_weekday_field(field: &str) -> Result<String> {
145    // Asterisk and wildcard variants pass through unchanged.
146    if field == "*" || field == "?" {
147        return Ok(field.to_string());
148    }
149
150    // If the field contains any alphabetic character it uses named days
151    // (e.g. MON-FRI) which the cron crate handles natively.
152    if field.chars().any(|c| c.is_ascii_alphabetic()) {
153        return Ok(field.to_string());
154    }
155
156    // The field may be a comma-separated list of items, where each item is
157    // either a single value, a range (start-end), or a range/value with a
158    // step (/N).
159    let parts: Vec<&str> = field.split(',').collect();
160    let mut result_parts = Vec::with_capacity(parts.len());
161
162    for part in parts {
163        // Split off optional step suffix first (e.g. "1-5/2" → "1-5" + "2").
164        let (range_part, step) = if let Some((r, s)) = part.split_once('/') {
165            (r, Some(s))
166        } else {
167            (part, None)
168        };
169
170        let translated = if let Some((start_s, end_s)) = range_part.split_once('-') {
171            let start: u8 = start_s
172                .parse()
173                .with_context(|| format!("Invalid weekday in range: {start_s}"))?;
174            let end: u8 = end_s
175                .parse()
176                .with_context(|| format!("Invalid weekday in range: {end_s}"))?;
177            let new_start = translate_weekday_value(start)?;
178            let new_end = translate_weekday_value(end)?;
179            format!("{new_start}-{new_end}")
180        } else if range_part == "*" {
181            "*".to_string()
182        } else {
183            let val: u8 = range_part
184                .parse()
185                .with_context(|| format!("Invalid weekday value: {range_part}"))?;
186            translate_weekday_value(val)?.to_string()
187        };
188
189        if let Some(s) = step {
190            result_parts.push(format!("{translated}/{s}"));
191        } else {
192            result_parts.push(translated);
193        }
194    }
195
196    Ok(result_parts.join(","))
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use chrono::{Datelike, Offset, TimeZone};
203
204    #[test]
205    fn next_run_for_schedule_supports_every_and_at() {
206        let now = Utc::now();
207        let every = Schedule::Every { every_ms: 60_000 };
208        let next = next_run_for_schedule(&every, now).unwrap();
209        assert!(next > now);
210
211        let at = now + ChronoDuration::minutes(10);
212        let at_schedule = Schedule::At { at };
213        let next_at = next_run_for_schedule(&at_schedule, now).unwrap();
214        assert_eq!(next_at, at);
215    }
216
217    #[test]
218    fn next_run_for_schedule_supports_timezone() {
219        let from = Utc.with_ymd_and_hms(2026, 2, 16, 0, 0, 0).unwrap();
220        let schedule = Schedule::Cron {
221            expr: "0 9 * * *".into(),
222            tz: Some("America/Los_Angeles".into()),
223        };
224
225        let next = next_run_for_schedule(&schedule, from).unwrap();
226        assert_eq!(next, Utc.with_ymd_and_hms(2026, 2, 16, 17, 0, 0).unwrap());
227    }
228
229    #[test]
230    fn normalize_weekday_field_translates_standard_crontab_values() {
231        // Single values: standard crontab → cron crate
232        assert_eq!(normalize_weekday_field("0").unwrap(), "1"); // Sun
233        assert_eq!(normalize_weekday_field("1").unwrap(), "2"); // Mon
234        assert_eq!(normalize_weekday_field("5").unwrap(), "6"); // Fri
235        assert_eq!(normalize_weekday_field("6").unwrap(), "7"); // Sat
236        assert_eq!(normalize_weekday_field("7").unwrap(), "1"); // Sun (alias)
237    }
238
239    #[test]
240    fn normalize_weekday_field_translates_ranges() {
241        // 1-5 (Mon-Fri) → 2-6
242        assert_eq!(normalize_weekday_field("1-5").unwrap(), "2-6");
243        // 0-6 (Sun-Sat) → 1-7
244        assert_eq!(normalize_weekday_field("0-6").unwrap(), "1-7");
245    }
246
247    #[test]
248    fn normalize_weekday_field_translates_lists() {
249        // 0,6 (Sun,Sat) → 1,7
250        assert_eq!(normalize_weekday_field("0,6").unwrap(), "1,7");
251        // 1,3,5 (Mon,Wed,Fri) → 2,4,6
252        assert_eq!(normalize_weekday_field("1,3,5").unwrap(), "2,4,6");
253    }
254
255    #[test]
256    fn normalize_weekday_field_translates_steps() {
257        // 1-5/2 (Mon-Fri every other) → 2-6/2
258        assert_eq!(normalize_weekday_field("1-5/2").unwrap(), "2-6/2");
259        // */2 (every other day) → */2
260        assert_eq!(normalize_weekday_field("*/2").unwrap(), "*/2");
261    }
262
263    #[test]
264    fn normalize_weekday_field_passes_through_wildcards_and_names() {
265        assert_eq!(normalize_weekday_field("*").unwrap(), "*");
266        assert_eq!(normalize_weekday_field("?").unwrap(), "?");
267        assert_eq!(normalize_weekday_field("MON-FRI").unwrap(), "MON-FRI");
268        assert_eq!(
269            normalize_weekday_field("MON,WED,FRI").unwrap(),
270            "MON,WED,FRI"
271        );
272    }
273
274    #[test]
275    fn normalize_expression_applies_weekday_fix_to_5_field() {
276        // "0 9 * * 1-5" should become "0 0 9 * * 2-6"
277        let result = normalize_expression("0 9 * * 1-5").unwrap();
278        assert_eq!(result, "0 0 9 * * 2-6");
279    }
280
281    #[test]
282    fn normalize_expression_does_not_modify_6_field() {
283        // 6-field expressions already use cron-crate semantics
284        let result = normalize_expression("0 0 9 * * 1-5").unwrap();
285        assert_eq!(result, "0 0 9 * * 1-5");
286    }
287
288    #[test]
289    fn weekday_1_5_schedules_monday_through_friday() {
290        // 2026-02-16 is a Monday. With "0 9 * * 1-5" (Mon-Fri at 09:00 UTC),
291        // the next run from Sunday 2026-02-15 should be Monday 2026-02-16.
292        let sunday = Utc.with_ymd_and_hms(2026, 2, 15, 0, 0, 0).unwrap();
293        let schedule = Schedule::Cron {
294            expr: "0 9 * * 1-5".into(),
295            tz: Some("UTC".into()),
296        };
297        let next = next_run_for_schedule(&schedule, sunday).unwrap();
298        // Should be Monday 2026-02-16 at 09:00 UTC (weekday = Mon)
299        assert_eq!(next, Utc.with_ymd_and_hms(2026, 2, 16, 9, 0, 0).unwrap());
300        assert_eq!(next.weekday(), chrono::Weekday::Mon);
301    }
302
303    #[test]
304    fn weekday_1_5_does_not_fire_on_saturday_or_sunday() {
305        // From Friday evening, next run should skip Sat/Sun → Monday
306        let friday_evening = Utc.with_ymd_and_hms(2026, 2, 20, 18, 0, 0).unwrap();
307        let schedule = Schedule::Cron {
308            expr: "0 9 * * 1-5".into(),
309            tz: Some("UTC".into()),
310        };
311        let next = next_run_for_schedule(&schedule, friday_evening).unwrap();
312        // Should be Monday 2026-02-23 at 09:00 UTC
313        assert_eq!(next, Utc.with_ymd_and_hms(2026, 2, 23, 9, 0, 0).unwrap());
314        assert_eq!(next.weekday(), chrono::Weekday::Mon);
315    }
316
317    #[test]
318    fn weekday_0_means_sunday() {
319        // "0 10 * * 0" should fire on Sunday only
320        let monday = Utc.with_ymd_and_hms(2026, 2, 16, 0, 0, 0).unwrap();
321        let schedule = Schedule::Cron {
322            expr: "0 10 * * 0".into(),
323            tz: Some("UTC".into()),
324        };
325        let next = next_run_for_schedule(&schedule, monday).unwrap();
326        assert_eq!(next.weekday(), chrono::Weekday::Sun);
327    }
328
329    #[test]
330    fn weekday_7_means_sunday() {
331        // "0 10 * * 7" should also fire on Sunday (alias)
332        let monday = Utc.with_ymd_and_hms(2026, 2, 16, 0, 0, 0).unwrap();
333        let schedule = Schedule::Cron {
334            expr: "0 10 * * 7".into(),
335            tz: Some("UTC".into()),
336        };
337        let next = next_run_for_schedule(&schedule, monday).unwrap();
338        assert_eq!(next.weekday(), chrono::Weekday::Sun);
339    }
340
341    #[test]
342    fn no_tz_defaults_to_local_timezone() {
343        let from = Utc.with_ymd_and_hms(2026, 6, 15, 12, 0, 0).unwrap();
344        let schedule_no_tz = Schedule::Cron {
345            expr: "0 9 * * *".into(),
346            tz: None,
347        };
348        let schedule_utc = Schedule::Cron {
349            expr: "0 9 * * *".into(),
350            tz: Some("UTC".into()),
351        };
352        let next_local = next_run_for_schedule(&schedule_no_tz, from).unwrap();
353        let next_utc = next_run_for_schedule(&schedule_utc, from).unwrap();
354        assert!(next_local > from);
355        assert!(next_utc > from);
356        let local_offset = chrono::Local::now().offset().fix().local_minus_utc();
357        if local_offset == 0 {
358            assert_eq!(next_local, next_utc);
359        } else {
360            assert_ne!(next_local, next_utc);
361        }
362    }
363}