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