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 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 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 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
130fn translate_weekday_value(val: u8) -> Result<u8> {
134 match val {
135 0 | 7 => Ok(1), 1..=6 => Ok(val + 1),
137 _ => anyhow::bail!("Invalid weekday value: {val} (expected 0-7)"),
138 }
139}
140
141fn normalize_weekday_field(field: &str) -> Result<String> {
145 if field == "*" || field == "?" {
147 return Ok(field.to_string());
148 }
149
150 if field.chars().any(|c| c.is_ascii_alphabetic()) {
153 return Ok(field.to_string());
154 }
155
156 let parts: Vec<&str> = field.split(',').collect();
160 let mut result_parts = Vec::with_capacity(parts.len());
161
162 for part in parts {
163 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 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"); }
238
239 #[test]
240 fn normalize_weekday_field_translates_ranges() {
241 assert_eq!(normalize_weekday_field("1-5").unwrap(), "2-6");
243 assert_eq!(normalize_weekday_field("0-6").unwrap(), "1-7");
245 }
246
247 #[test]
248 fn normalize_weekday_field_translates_lists() {
249 assert_eq!(normalize_weekday_field("0,6").unwrap(), "1,7");
251 assert_eq!(normalize_weekday_field("1,3,5").unwrap(), "2,4,6");
253 }
254
255 #[test]
256 fn normalize_weekday_field_translates_steps() {
257 assert_eq!(normalize_weekday_field("1-5/2").unwrap(), "2-6/2");
259 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 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 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 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 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 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 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 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 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}