1use anyhow::Context;
10use chrono::{DateTime, Utc};
11use cron::Schedule;
12use std::str::FromStr;
13
14pub fn normalize_dow(expression: &str) -> String {
17 let fields: Vec<&str> = expression.split_whitespace().collect();
18 if fields.len() != 5 {
19 return expression.to_string();
20 }
21 let dow = fields[4];
22 let normalized_dow = dow
27 .split(',')
28 .map(|part| {
29 if part == "0" {
30 "7".to_string()
31 } else if let Some(rest) = part.strip_prefix("0-") {
32 if rest == "0" {
33 "7".to_string()
35 } else {
36 format!("1-{rest},7")
38 }
39 } else {
40 part.to_string()
41 }
42 })
43 .collect::<Vec<_>>()
44 .join(",");
45 format!(
46 "{} {} {} {} {}",
47 fields[0], fields[1], fields[2], fields[3], normalized_dow
48 )
49}
50
51pub fn expand_alias(s: &str) -> anyhow::Result<String> {
71 let s = s.trim();
72 let (alias, param) = match s.split_once(char::is_whitespace) {
73 Some((a, p)) => (a, p.trim()),
74 None => (s, ""),
75 };
76 match alias {
77 "@hourly" => {
78 if param.is_empty() {
79 return Ok("0 * * * *".to_string());
80 }
81 let min: u8 = param
82 .parse()
83 .with_context(|| format!("invalid minute in @hourly alias: '{param}'"))?;
84 anyhow::ensure!(
85 min <= 59,
86 "minute out of range in @hourly alias: {min} (must be 0-59)"
87 );
88 Ok(format!("{min} * * * *"))
89 }
90 "@daily" => {
91 if param.is_empty() {
92 return Ok("0 0 * * *".to_string());
93 }
94 if param.contains(':') {
95 let (h, m) = param
96 .split_once(':')
97 .context("invalid time format in @daily alias")?;
98 let hour: u8 = h
99 .parse()
100 .with_context(|| format!("invalid hour in @daily alias: '{h}'"))?;
101 let min: u8 = m
102 .parse()
103 .with_context(|| format!("invalid minute in @daily alias: '{m}'"))?;
104 anyhow::ensure!(
105 hour <= 23,
106 "hour out of range in @daily alias: {hour} (must be 0-23)"
107 );
108 anyhow::ensure!(
109 min <= 59,
110 "minute out of range in @daily alias: {min} (must be 0-59)"
111 );
112 Ok(format!("{min} {hour} * * *"))
113 } else {
114 let hour: u8 = param
115 .parse()
116 .with_context(|| format!("invalid hour in @daily alias: '{param}'"))?;
117 anyhow::ensure!(
118 hour <= 23,
119 "hour out of range in @daily alias: {hour} (must be 0-23)"
120 );
121 Ok(format!("0 {hour} * * *"))
122 }
123 }
124 "@weekly" => {
125 if param.is_empty() {
126 return Ok("0 0 * * 0".to_string());
127 }
128 let day: u8 = param
129 .parse()
130 .with_context(|| format!("invalid day-of-week in @weekly alias: '{param}'"))?;
131 anyhow::ensure!(
132 day <= 7,
133 "day-of-week out of range in @weekly alias: {day} (must be 0-7)"
134 );
135 Ok(format!("0 0 * * {day}"))
136 }
137 "@monthly" => {
138 if param.is_empty() {
139 return Ok("0 0 1 * *".to_string());
140 }
141 let dom: u8 = param
142 .parse()
143 .with_context(|| format!("invalid day-of-month in @monthly alias: '{param}'"))?;
144 anyhow::ensure!(
145 (1..=31).contains(&dom),
146 "day-of-month out of range in @monthly alias: {dom} (must be 1-31)"
147 );
148 Ok(format!("0 0 {dom} * *"))
149 }
150 "@yearly" => {
151 if param.is_empty() {
152 return Ok("0 0 1 1 *".to_string());
153 }
154 if param.contains('-') {
155 let (m, d) = param
156 .split_once('-')
157 .context("invalid date format in @yearly alias")?;
158 let month: u8 = m
159 .parse()
160 .with_context(|| format!("invalid month in @yearly alias: '{m}'"))?;
161 let dom: u8 = d
162 .parse()
163 .with_context(|| format!("invalid day-of-month in @yearly alias: '{d}'"))?;
164 anyhow::ensure!(
165 (1..=12).contains(&month),
166 "month out of range in @yearly alias: {month} (must be 1-12)"
167 );
168 anyhow::ensure!(
169 (1..=31).contains(&dom),
170 "day-of-month out of range in @yearly alias: {dom} (must be 1-31)"
171 );
172 Ok(format!("0 0 {dom} {month} *"))
173 } else {
174 anyhow::bail!(
175 "invalid @yearly alias parameter: '{param}' (expected month-day, e.g. '3-15')"
176 );
177 }
178 }
179 _ => Ok(s.to_string()),
180 }
181}
182
183pub fn check(expression: &str, since: Option<&str>) -> anyhow::Result<bool> {
188 let expanded =
195 expand_alias(expression).with_context(|| format!("invalid cron alias: {expression}"))?;
196 let normalized = normalize_dow(&expanded);
197 let full_expr = format!("0 {normalized} *");
198
199 let schedule = Schedule::from_str(&full_expr)
200 .with_context(|| format!("invalid cron expression: {expression}"))?;
201
202 let now = Utc::now();
203
204 match since {
205 Some(since_str) => {
206 let since_dt = parse_timestamp(since_str)
207 .with_context(|| format!("invalid --since timestamp: {since_str}"))?;
208
209 let cap = now - chrono::Duration::hours(24);
211 let effective_since = if since_dt < cap { cap } else { since_dt };
212
213 Ok(schedule
215 .after(&effective_since)
216 .take_while(|dt| *dt <= now)
217 .next()
218 .is_some())
219 }
220 None => {
221 let one_min_ago = now - chrono::Duration::minutes(1);
225 let next = schedule.after(&one_min_ago).next();
226 match next {
227 Some(dt) => {
228 let diff = now.signed_duration_since(dt);
229 Ok(diff >= chrono::Duration::zero() && diff < chrono::Duration::minutes(1))
230 }
231 None => Ok(false),
232 }
233 }
234 }
235}
236
237fn parse_timestamp(s: &str) -> anyhow::Result<DateTime<Utc>> {
239 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
241 return Ok(dt.with_timezone(&Utc));
242 }
243 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
245 return Ok(dt.and_utc());
246 }
247 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
248 return Ok(dt.and_utc());
249 }
250 anyhow::bail!("unrecognized timestamp format: {s}")
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use chrono::Timelike;
257
258 #[test]
259 fn every_minute_matches_now() {
260 let result = check("* * * * *", None).unwrap();
262 assert!(result);
263 }
264
265 #[test]
266 fn impossible_schedule_does_not_match() {
267 let result = check("0 0 30 2 *", None).unwrap();
269 assert!(!result);
270 }
271
272 #[test]
273 fn since_mode_catches_recent_fire() {
274 let five_min_ago = (Utc::now() - chrono::Duration::minutes(5))
276 .format("%Y-%m-%dT%H:%M:%SZ")
277 .to_string();
278 let result = check("* * * * *", Some(&five_min_ago)).unwrap();
279 assert!(result);
280 }
281
282 #[test]
283 fn since_mode_caps_at_24h() {
284 let old = (Utc::now() - chrono::Duration::hours(48))
286 .format("%Y-%m-%dT%H:%M:%SZ")
287 .to_string();
288 let result = check("* * * * *", Some(&old)).unwrap();
289 assert!(result);
290 }
291
292 #[test]
293 fn invalid_expression_errors() {
294 let result = check("not a cron", None);
295 assert!(result.is_err());
296 }
297
298 #[test]
299 fn invalid_since_timestamp_errors() {
300 let result = check("* * * * *", Some("not-a-date"));
301 assert!(result.is_err());
302 }
303
304 #[test]
305 fn sunday_as_zero_parses() {
306 let result = check("0 20 * * 0", None);
308 assert!(
309 result.is_ok(),
310 "DOW=0 (Sunday) should be accepted: {result:?}"
311 );
312 }
313
314 #[test]
315 fn sunday_as_seven_still_works() {
316 let result = check("0 20 * * 7", None);
317 assert!(
318 result.is_ok(),
319 "DOW=7 (Sunday) should be accepted: {result:?}"
320 );
321 }
322
323 #[test]
324 fn dow_zero_in_list_parses() {
325 let result = check("0 20 * * 0,3", None);
327 assert!(
328 result.is_ok(),
329 "DOW=0 in list should be accepted: {result:?}"
330 );
331 }
332
333 #[test]
334 fn dow_range_starting_at_zero_parses() {
335 let result = check("0 9 * * 0-5", None);
337 assert!(result.is_ok(), "DOW range 0-5 should parse: {result:?}");
338 }
339
340 #[test]
341 fn dow_range_zero_to_four_parses() {
342 let result = check("0 9 * * 0-4", None);
344 assert!(result.is_ok(), "DOW range 0-4 should parse: {result:?}");
345 }
346
347 #[test]
348 fn normalize_dow_range_zero_to_five_includes_sunday() {
349 let normalized = normalize_dow("0 9 * * 0-5");
351 assert!(
352 normalized.ends_with("1-5,7"),
353 "Expected DOW field to be '1-5,7' but got: {normalized}"
354 );
355 }
356
357 #[test]
358 fn normalize_dow_range_zero_to_four_includes_sunday() {
359 let normalized = normalize_dow("0 9 * * 0-4");
361 assert!(
362 normalized.ends_with("1-4,7"),
363 "Expected DOW field to be '1-4,7' but got: {normalized}"
364 );
365 }
366
367 #[test]
368 fn normalize_dow_range_zero_to_zero_only_sunday() {
369 let normalized = normalize_dow("0 9 * * 0-0");
371 assert!(
372 normalized.ends_with("7"),
373 "Expected DOW field to be '7' but got: {normalized}"
374 );
375 }
376
377 #[test]
378 fn dow_range_zero_to_zero_parses() {
379 let result = check("0 9 * * 0-0", None);
381 assert!(result.is_ok(), "DOW range 0-0 should parse: {result:?}");
382 }
383
384 #[test]
385 fn dow_range_zero_to_six_includes_sunday() {
386 let normalized = normalize_dow("0 9 * * 0-6");
388 assert!(
389 normalized.ends_with("1-6,7"),
390 "Expected DOW field to be '1-6,7' but got: {normalized}"
391 );
392 let result = check("0 9 * * 0-6", None);
394 assert!(result.is_ok(), "DOW range 0-6 should parse: {result:?}");
395 }
396
397 #[test]
398 fn dow_range_zero_to_five_fires_on_sunday() {
399 use chrono::{Datelike, TimeZone};
400 use cron::Schedule;
401 use std::str::FromStr;
402
403 let normalized = normalize_dow("0 9 * * 0-5");
406 let full_expr = format!("0 {normalized} *");
407 let schedule = Schedule::from_str(&full_expr).expect("should parse");
408
409 let base = Utc.with_ymd_and_hms(2026, 3, 23, 0, 0, 0).unwrap(); let has_sunday = schedule
412 .after(&base)
413 .take(14)
414 .any(|dt| dt.weekday() == chrono::Weekday::Sun);
415 assert!(has_sunday, "Schedule '0 9 * * 0-5' must fire on Sunday");
416 }
417
418 #[test]
421 fn expand_alias_hourly_no_param() {
422 assert_eq!(expand_alias("@hourly").unwrap(), "0 * * * *");
423 }
424
425 #[test]
426 fn expand_alias_hourly_with_minute() {
427 assert_eq!(expand_alias("@hourly 30").unwrap(), "30 * * * *");
428 }
429
430 #[test]
431 fn expand_alias_daily_no_param() {
432 assert_eq!(expand_alias("@daily").unwrap(), "0 0 * * *");
433 }
434
435 #[test]
436 fn expand_alias_daily_with_hour() {
437 assert_eq!(expand_alias("@daily 9").unwrap(), "0 9 * * *");
438 }
439
440 #[test]
441 fn expand_alias_daily_with_hour_and_minute() {
442 assert_eq!(expand_alias("@daily 9:30").unwrap(), "30 9 * * *");
443 }
444
445 #[test]
446 fn expand_alias_weekly_no_param() {
447 assert_eq!(expand_alias("@weekly").unwrap(), "0 0 * * 0");
448 }
449
450 #[test]
451 fn expand_alias_weekly_with_day() {
452 assert_eq!(expand_alias("@weekly 1").unwrap(), "0 0 * * 1");
453 }
454
455 #[test]
456 fn expand_alias_monthly_no_param() {
457 assert_eq!(expand_alias("@monthly").unwrap(), "0 0 1 * *");
458 }
459
460 #[test]
461 fn expand_alias_monthly_with_day() {
462 assert_eq!(expand_alias("@monthly 15").unwrap(), "0 0 15 * *");
463 }
464
465 #[test]
466 fn expand_alias_yearly_no_param() {
467 assert_eq!(expand_alias("@yearly").unwrap(), "0 0 1 1 *");
468 }
469
470 #[test]
471 fn expand_alias_yearly_with_month_day() {
472 assert_eq!(expand_alias("@yearly 3-15").unwrap(), "0 0 15 3 *");
473 }
474
475 #[test]
476 fn expand_alias_passthrough_regular_cron() {
477 assert_eq!(expand_alias("30 9 * * 1").unwrap(), "30 9 * * 1");
478 }
479
480 #[test]
481 fn expand_alias_unknown_at_prefix_passthrough() {
482 assert_eq!(expand_alias("@unknown").unwrap(), "@unknown");
483 }
484
485 #[test]
486 fn expand_alias_check_integration_daily_9_30() {
487 let expanded = expand_alias("@daily 9:30").unwrap();
489 let normalized = normalize_dow(&expanded);
490 let full = format!("0 {normalized} *");
491 assert!(
492 Schedule::from_str(&full).is_ok(),
493 "expanded '@daily 9:30' → '{expanded}' should parse"
494 );
495 }
496
497 #[test]
498 fn expand_alias_check_integration_weekly_monday() {
499 let expanded = expand_alias("@weekly 1").unwrap();
500 let normalized = normalize_dow(&expanded);
501 let full = format!("0 {normalized} *");
502 assert!(
503 Schedule::from_str(&full).is_ok(),
504 "expanded '@weekly 1' → '{expanded}' should parse"
505 );
506 }
507
508 #[test]
511 fn expand_alias_hourly_invalid_param_errors() {
512 assert!(expand_alias("@hourly abc").is_err());
513 }
514
515 #[test]
516 fn expand_alias_hourly_out_of_range_errors() {
517 assert!(expand_alias("@hourly 60").is_err());
518 }
519
520 #[test]
521 fn expand_alias_daily_invalid_param_errors() {
522 assert!(expand_alias("@daily abc").is_err());
523 }
524
525 #[test]
526 fn expand_alias_daily_invalid_hour_colon_errors() {
527 assert!(expand_alias("@daily abc:30").is_err());
528 }
529
530 #[test]
531 fn expand_alias_daily_invalid_minute_colon_errors() {
532 assert!(expand_alias("@daily 9:xyz").is_err());
533 }
534
535 #[test]
536 fn expand_alias_daily_hour_out_of_range_errors() {
537 assert!(expand_alias("@daily 24").is_err());
538 }
539
540 #[test]
541 fn expand_alias_daily_minute_out_of_range_errors() {
542 assert!(expand_alias("@daily 9:60").is_err());
543 }
544
545 #[test]
546 fn expand_alias_weekly_invalid_param_errors() {
547 assert!(expand_alias("@weekly foo").is_err());
548 }
549
550 #[test]
551 fn expand_alias_weekly_out_of_range_errors() {
552 assert!(expand_alias("@weekly 8").is_err());
553 }
554
555 #[test]
556 fn expand_alias_monthly_invalid_param_errors() {
557 assert!(expand_alias("@monthly bar").is_err());
558 }
559
560 #[test]
561 fn expand_alias_monthly_out_of_range_errors() {
562 assert!(expand_alias("@monthly 0").is_err());
563 }
564
565 #[test]
566 fn expand_alias_yearly_invalid_param_no_dash_errors() {
567 assert!(expand_alias("@yearly 3").is_err());
568 }
569
570 #[test]
571 fn expand_alias_yearly_invalid_month_errors() {
572 assert!(expand_alias("@yearly abc-15").is_err());
573 }
574
575 #[test]
576 fn expand_alias_yearly_invalid_dom_errors() {
577 assert!(expand_alias("@yearly 3-xyz").is_err());
578 }
579
580 #[test]
581 fn expand_alias_yearly_month_out_of_range_errors() {
582 assert!(expand_alias("@yearly 13-1").is_err());
583 }
584
585 #[test]
586 fn expand_alias_daily_abc_errors_via_check() {
587 assert!(check("@daily abc", None).is_err());
589 }
590
591 #[test]
592 fn expand_alias_hourly_not_a_number_errors_via_check() {
593 assert!(check("@hourly not-a-number", None).is_err());
595 }
596
597 #[test]
598 fn parse_rfc3339_timestamp() {
599 let dt = parse_timestamp("2026-02-22T10:30:00Z").unwrap();
600 assert_eq!(dt.hour(), 10);
601 assert_eq!(dt.minute(), 30);
602 }
603
604 #[test]
605 fn parse_naive_timestamp() {
606 let dt = parse_timestamp("2026-02-22T10:30:00").unwrap();
607 assert_eq!(dt.hour(), 10);
608 }
609
610 #[test]
611 fn parse_space_separated_timestamp() {
612 let dt = parse_timestamp("2026-02-22 10:30:00").unwrap();
613 assert_eq!(dt.hour(), 10);
614 }
615}