Skip to main content

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