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 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 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
96fn translate_weekday_value(val: u8) -> Result<u8> {
100 match val {
101 0 | 7 => Ok(1), 1..=6 => Ok(val + 1),
103 _ => anyhow::bail!("Invalid weekday value: {val} (expected 0-7)"),
104 }
105}
106
107fn normalize_weekday_field(field: &str) -> Result<String> {
111 if field == "*" || field == "?" {
113 return Ok(field.to_string());
114 }
115
116 if field.chars().any(|c| c.is_ascii_alphabetic()) {
119 return Ok(field.to_string());
120 }
121
122 let parts: Vec<&str> = field.split(',').collect();
126 let mut result_parts = Vec::with_capacity(parts.len());
127
128 for part in parts {
129 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 assert_eq!(normalize_weekday_field("0").unwrap(), "1"); assert_eq!(normalize_weekday_field("1").unwrap(), "2"); assert_eq!(normalize_weekday_field("5").unwrap(), "6"); assert_eq!(normalize_weekday_field("6").unwrap(), "7"); assert_eq!(normalize_weekday_field("7").unwrap(), "1"); }
204
205 #[test]
206 fn normalize_weekday_field_translates_ranges() {
207 assert_eq!(normalize_weekday_field("1-5").unwrap(), "2-6");
209 assert_eq!(normalize_weekday_field("0-6").unwrap(), "1-7");
211 }
212
213 #[test]
214 fn normalize_weekday_field_translates_lists() {
215 assert_eq!(normalize_weekday_field("0,6").unwrap(), "1,7");
217 assert_eq!(normalize_weekday_field("1,3,5").unwrap(), "2,4,6");
219 }
220
221 #[test]
222 fn normalize_weekday_field_translates_steps() {
223 assert_eq!(normalize_weekday_field("1-5/2").unwrap(), "2-6/2");
225 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 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 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 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 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 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 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 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 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}