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 = parse_field(param, "minute", alias)?;
82 check_range(min, "minute", alias, 0, 59)?;
83 Ok(format!("{min} * * * *"))
84 }
85 "@daily" => {
86 if param.is_empty() {
87 return Ok("0 0 * * *".to_string());
88 }
89 if let Some((h, m)) = param.split_once(':') {
90 let hour = parse_field(h, "hour", alias)?;
91 let min = parse_field(m, "minute", alias)?;
92 check_range(hour, "hour", alias, 0, 23)?;
93 check_range(min, "minute", alias, 0, 59)?;
94 Ok(format!("{min} {hour} * * *"))
95 } else {
96 let hour = parse_field(param, "hour", alias)?;
97 check_range(hour, "hour", alias, 0, 23)?;
98 Ok(format!("0 {hour} * * *"))
99 }
100 }
101 "@weekly" => {
102 if param.is_empty() {
103 return Ok("0 0 * * 0".to_string());
104 }
105 let day = parse_field(param, "day-of-week", alias)?;
106 check_range(day, "day-of-week", alias, 0, 7)?;
107 Ok(format!("0 0 * * {day}"))
108 }
109 "@monthly" => {
110 if param.is_empty() {
111 return Ok("0 0 1 * *".to_string());
112 }
113 let dom = parse_field(param, "day-of-month", alias)?;
114 check_range(dom, "day-of-month", alias, 1, 31)?;
115 Ok(format!("0 0 {dom} * *"))
116 }
117 "@yearly" => {
118 if param.is_empty() {
119 return Ok("0 0 1 1 *".to_string());
120 }
121 if let Some((m, d)) = param.split_once('-') {
122 let month = parse_field(m, "month", alias)?;
123 let dom = parse_field(d, "day-of-month", alias)?;
124 check_range(month, "month", alias, 1, 12)?;
125 check_range(dom, "day-of-month", alias, 1, 31)?;
126 Ok(format!("0 0 {dom} {month} *"))
127 } else {
128 anyhow::bail!(
129 "invalid @yearly alias parameter: '{param}' (expected month-day, e.g. '3-15')"
130 );
131 }
132 }
133 _ => Ok(s.to_string()),
134 }
135}
136
137fn parse_field(raw: &str, field: &str, alias: &str) -> anyhow::Result<u8> {
138 raw.parse()
139 .with_context(|| format!("invalid {field} in {alias} alias: '{raw}'"))
140}
141
142fn check_range(value: u8, field: &str, alias: &str, lo: u8, hi: u8) -> anyhow::Result<()> {
143 anyhow::ensure!(
144 (lo..=hi).contains(&value),
145 "{field} out of range in {alias} alias: {value} (must be {lo}-{hi})"
146 );
147 Ok(())
148}
149
150pub fn check(expression: &str, since: Option<&str>) -> anyhow::Result<bool> {
155 let expanded =
162 expand_alias(expression).with_context(|| format!("invalid cron alias: {expression}"))?;
163 let normalized = normalize_dow(&expanded);
164 let full_expr = format!("0 {normalized} *");
165
166 let schedule = Schedule::from_str(&full_expr)
167 .with_context(|| format!("invalid cron expression: {expression}"))?;
168
169 let now = Utc::now();
170
171 match since {
172 Some(since_str) => {
173 let since_dt = parse_timestamp(since_str)
174 .with_context(|| format!("invalid --since timestamp: {since_str}"))?;
175
176 let cap = now - chrono::Duration::hours(24);
178 let effective_since = if since_dt < cap { cap } else { since_dt };
179
180 Ok(schedule
182 .after(&effective_since)
183 .take_while(|dt| *dt <= now)
184 .next()
185 .is_some())
186 }
187 None => {
188 let one_min_ago = now - chrono::Duration::minutes(1);
192 let next = schedule.after(&one_min_ago).next();
193 match next {
194 Some(dt) => {
195 let diff = now.signed_duration_since(dt);
196 Ok(diff >= chrono::Duration::zero() && diff < chrono::Duration::minutes(1))
197 }
198 None => Ok(false),
199 }
200 }
201 }
202}
203
204fn parse_timestamp(s: &str) -> anyhow::Result<DateTime<Utc>> {
206 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
208 return Ok(dt.with_timezone(&Utc));
209 }
210 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
212 return Ok(dt.and_utc());
213 }
214 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
215 return Ok(dt.and_utc());
216 }
217 anyhow::bail!("unrecognized timestamp format: {s}")
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use chrono::Timelike;
224
225 #[test]
226 fn every_minute_matches_now() {
227 let result = check("* * * * *", None).unwrap();
229 assert!(result);
230 }
231
232 #[test]
233 fn impossible_schedule_does_not_match() {
234 let result = check("0 0 30 2 *", None).unwrap();
236 assert!(!result);
237 }
238
239 #[test]
240 fn since_mode_catches_recent_fire() {
241 let five_min_ago = (Utc::now() - chrono::Duration::minutes(5))
243 .format("%Y-%m-%dT%H:%M:%SZ")
244 .to_string();
245 let result = check("* * * * *", Some(&five_min_ago)).unwrap();
246 assert!(result);
247 }
248
249 #[test]
250 fn since_mode_caps_at_24h() {
251 let old = (Utc::now() - chrono::Duration::hours(48))
253 .format("%Y-%m-%dT%H:%M:%SZ")
254 .to_string();
255 let result = check("* * * * *", Some(&old)).unwrap();
256 assert!(result);
257 }
258
259 #[test]
260 fn invalid_expression_errors() {
261 let result = check("not a cron", None);
262 assert!(result.is_err());
263 }
264
265 #[test]
266 fn invalid_since_timestamp_errors() {
267 let result = check("* * * * *", Some("not-a-date"));
268 assert!(result.is_err());
269 }
270
271 #[test]
272 fn sunday_as_zero_parses() {
273 let result = check("0 20 * * 0", None);
275 assert!(
276 result.is_ok(),
277 "DOW=0 (Sunday) should be accepted: {result:?}"
278 );
279 }
280
281 #[test]
282 fn sunday_as_seven_still_works() {
283 let result = check("0 20 * * 7", None);
284 assert!(
285 result.is_ok(),
286 "DOW=7 (Sunday) should be accepted: {result:?}"
287 );
288 }
289
290 #[test]
291 fn dow_zero_in_list_parses() {
292 let result = check("0 20 * * 0,3", None);
294 assert!(
295 result.is_ok(),
296 "DOW=0 in list should be accepted: {result:?}"
297 );
298 }
299
300 #[test]
301 fn dow_range_starting_at_zero_parses() {
302 let result = check("0 9 * * 0-5", None);
304 assert!(result.is_ok(), "DOW range 0-5 should parse: {result:?}");
305 }
306
307 #[test]
308 fn dow_range_zero_to_four_parses() {
309 let result = check("0 9 * * 0-4", None);
311 assert!(result.is_ok(), "DOW range 0-4 should parse: {result:?}");
312 }
313
314 #[test]
315 fn normalize_dow_range_zero_to_five_includes_sunday() {
316 let normalized = normalize_dow("0 9 * * 0-5");
318 assert!(
319 normalized.ends_with("1-5,7"),
320 "Expected DOW field to be '1-5,7' but got: {normalized}"
321 );
322 }
323
324 #[test]
325 fn normalize_dow_range_zero_to_four_includes_sunday() {
326 let normalized = normalize_dow("0 9 * * 0-4");
328 assert!(
329 normalized.ends_with("1-4,7"),
330 "Expected DOW field to be '1-4,7' but got: {normalized}"
331 );
332 }
333
334 #[test]
335 fn normalize_dow_range_zero_to_zero_only_sunday() {
336 let normalized = normalize_dow("0 9 * * 0-0");
338 assert!(
339 normalized.ends_with("7"),
340 "Expected DOW field to be '7' but got: {normalized}"
341 );
342 }
343
344 #[test]
345 fn dow_range_zero_to_zero_parses() {
346 let result = check("0 9 * * 0-0", None);
348 assert!(result.is_ok(), "DOW range 0-0 should parse: {result:?}");
349 }
350
351 #[test]
352 fn dow_range_zero_to_six_includes_sunday() {
353 let normalized = normalize_dow("0 9 * * 0-6");
355 assert!(
356 normalized.ends_with("1-6,7"),
357 "Expected DOW field to be '1-6,7' but got: {normalized}"
358 );
359 let result = check("0 9 * * 0-6", None);
361 assert!(result.is_ok(), "DOW range 0-6 should parse: {result:?}");
362 }
363
364 #[test]
365 fn dow_range_zero_to_five_fires_on_sunday() {
366 use chrono::{Datelike, TimeZone};
367 use cron::Schedule;
368 use std::str::FromStr;
369
370 let normalized = normalize_dow("0 9 * * 0-5");
373 let full_expr = format!("0 {normalized} *");
374 let schedule = Schedule::from_str(&full_expr).expect("should parse");
375
376 let base = Utc.with_ymd_and_hms(2026, 3, 23, 0, 0, 0).unwrap(); let has_sunday = schedule
379 .after(&base)
380 .take(14)
381 .any(|dt| dt.weekday() == chrono::Weekday::Sun);
382 assert!(has_sunday, "Schedule '0 9 * * 0-5' must fire on Sunday");
383 }
384
385 #[test]
388 fn expand_alias_hourly_no_param() {
389 assert_eq!(expand_alias("@hourly").unwrap(), "0 * * * *");
390 }
391
392 #[test]
393 fn expand_alias_hourly_with_minute() {
394 assert_eq!(expand_alias("@hourly 30").unwrap(), "30 * * * *");
395 }
396
397 #[test]
398 fn expand_alias_daily_no_param() {
399 assert_eq!(expand_alias("@daily").unwrap(), "0 0 * * *");
400 }
401
402 #[test]
403 fn expand_alias_daily_with_hour() {
404 assert_eq!(expand_alias("@daily 9").unwrap(), "0 9 * * *");
405 }
406
407 #[test]
408 fn expand_alias_daily_with_hour_and_minute() {
409 assert_eq!(expand_alias("@daily 9:30").unwrap(), "30 9 * * *");
410 }
411
412 #[test]
413 fn expand_alias_weekly_no_param() {
414 assert_eq!(expand_alias("@weekly").unwrap(), "0 0 * * 0");
415 }
416
417 #[test]
418 fn expand_alias_weekly_with_day() {
419 assert_eq!(expand_alias("@weekly 1").unwrap(), "0 0 * * 1");
420 }
421
422 #[test]
423 fn expand_alias_monthly_no_param() {
424 assert_eq!(expand_alias("@monthly").unwrap(), "0 0 1 * *");
425 }
426
427 #[test]
428 fn expand_alias_monthly_with_day() {
429 assert_eq!(expand_alias("@monthly 15").unwrap(), "0 0 15 * *");
430 }
431
432 #[test]
433 fn expand_alias_yearly_no_param() {
434 assert_eq!(expand_alias("@yearly").unwrap(), "0 0 1 1 *");
435 }
436
437 #[test]
438 fn expand_alias_yearly_with_month_day() {
439 assert_eq!(expand_alias("@yearly 3-15").unwrap(), "0 0 15 3 *");
440 }
441
442 #[test]
443 fn expand_alias_passthrough_regular_cron() {
444 assert_eq!(expand_alias("30 9 * * 1").unwrap(), "30 9 * * 1");
445 }
446
447 #[test]
448 fn expand_alias_unknown_at_prefix_passthrough() {
449 assert_eq!(expand_alias("@unknown").unwrap(), "@unknown");
450 }
451
452 #[test]
453 fn expand_alias_check_integration_daily_9_30() {
454 let expanded = expand_alias("@daily 9:30").unwrap();
456 let normalized = normalize_dow(&expanded);
457 let full = format!("0 {normalized} *");
458 assert!(
459 Schedule::from_str(&full).is_ok(),
460 "expanded '@daily 9:30' → '{expanded}' should parse"
461 );
462 }
463
464 #[test]
465 fn expand_alias_check_integration_weekly_monday() {
466 let expanded = expand_alias("@weekly 1").unwrap();
467 let normalized = normalize_dow(&expanded);
468 let full = format!("0 {normalized} *");
469 assert!(
470 Schedule::from_str(&full).is_ok(),
471 "expanded '@weekly 1' → '{expanded}' should parse"
472 );
473 }
474
475 #[test]
478 fn expand_alias_hourly_invalid_param_errors() {
479 assert!(expand_alias("@hourly abc").is_err());
480 }
481
482 #[test]
483 fn expand_alias_hourly_out_of_range_errors() {
484 assert!(expand_alias("@hourly 60").is_err());
485 }
486
487 #[test]
488 fn expand_alias_daily_invalid_param_errors() {
489 assert!(expand_alias("@daily abc").is_err());
490 }
491
492 #[test]
493 fn expand_alias_daily_invalid_hour_colon_errors() {
494 assert!(expand_alias("@daily abc:30").is_err());
495 }
496
497 #[test]
498 fn expand_alias_daily_invalid_minute_colon_errors() {
499 assert!(expand_alias("@daily 9:xyz").is_err());
500 }
501
502 #[test]
503 fn expand_alias_daily_hour_out_of_range_errors() {
504 assert!(expand_alias("@daily 24").is_err());
505 }
506
507 #[test]
508 fn expand_alias_daily_minute_out_of_range_errors() {
509 assert!(expand_alias("@daily 9:60").is_err());
510 }
511
512 #[test]
513 fn expand_alias_weekly_invalid_param_errors() {
514 assert!(expand_alias("@weekly foo").is_err());
515 }
516
517 #[test]
518 fn expand_alias_weekly_out_of_range_errors() {
519 assert!(expand_alias("@weekly 8").is_err());
520 }
521
522 #[test]
523 fn expand_alias_monthly_invalid_param_errors() {
524 assert!(expand_alias("@monthly bar").is_err());
525 }
526
527 #[test]
528 fn expand_alias_monthly_out_of_range_errors() {
529 assert!(expand_alias("@monthly 0").is_err());
530 }
531
532 #[test]
533 fn expand_alias_yearly_invalid_param_no_dash_errors() {
534 assert!(expand_alias("@yearly 3").is_err());
535 }
536
537 #[test]
538 fn expand_alias_yearly_invalid_month_errors() {
539 assert!(expand_alias("@yearly abc-15").is_err());
540 }
541
542 #[test]
543 fn expand_alias_yearly_invalid_dom_errors() {
544 assert!(expand_alias("@yearly 3-xyz").is_err());
545 }
546
547 #[test]
548 fn expand_alias_yearly_month_out_of_range_errors() {
549 assert!(expand_alias("@yearly 13-1").is_err());
550 }
551
552 fn alias_err(s: &str) -> String {
553 expand_alias(s).unwrap_err().to_string()
554 }
555
556 #[test]
557 fn expand_alias_error_messages_are_stable() {
558 assert_eq!(
559 alias_err("@hourly abc"),
560 "invalid minute in @hourly alias: 'abc'"
561 );
562 assert_eq!(
563 alias_err("@hourly 60"),
564 "minute out of range in @hourly alias: 60 (must be 0-59)"
565 );
566 assert_eq!(
567 alias_err("@daily abc"),
568 "invalid hour in @daily alias: 'abc'"
569 );
570 assert_eq!(
571 alias_err("@daily x:30"),
572 "invalid hour in @daily alias: 'x'"
573 );
574 assert_eq!(
575 alias_err("@daily 9:y"),
576 "invalid minute in @daily alias: 'y'"
577 );
578 assert_eq!(
579 alias_err("@daily 24"),
580 "hour out of range in @daily alias: 24 (must be 0-23)"
581 );
582 assert_eq!(
583 alias_err("@daily 24:5"),
584 "hour out of range in @daily alias: 24 (must be 0-23)"
585 );
586 assert_eq!(
587 alias_err("@daily 9:60"),
588 "minute out of range in @daily alias: 60 (must be 0-59)"
589 );
590 assert_eq!(
591 alias_err("@weekly foo"),
592 "invalid day-of-week in @weekly alias: 'foo'"
593 );
594 assert_eq!(
595 alias_err("@weekly 8"),
596 "day-of-week out of range in @weekly alias: 8 (must be 0-7)"
597 );
598 assert_eq!(
599 alias_err("@monthly bar"),
600 "invalid day-of-month in @monthly alias: 'bar'"
601 );
602 assert_eq!(
603 alias_err("@monthly 0"),
604 "day-of-month out of range in @monthly alias: 0 (must be 1-31)"
605 );
606 assert_eq!(
607 alias_err("@monthly 32"),
608 "day-of-month out of range in @monthly alias: 32 (must be 1-31)"
609 );
610 assert_eq!(
611 alias_err("@yearly x-15"),
612 "invalid month in @yearly alias: 'x'"
613 );
614 assert_eq!(
615 alias_err("@yearly 3-y"),
616 "invalid day-of-month in @yearly alias: 'y'"
617 );
618 assert_eq!(
619 alias_err("@yearly 13-1"),
620 "month out of range in @yearly alias: 13 (must be 1-12)"
621 );
622 assert_eq!(
623 alias_err("@yearly 0-1"),
624 "month out of range in @yearly alias: 0 (must be 1-12)"
625 );
626 assert_eq!(
627 alias_err("@yearly 3-32"),
628 "day-of-month out of range in @yearly alias: 32 (must be 1-31)"
629 );
630 assert_eq!(
631 alias_err("@yearly 3"),
632 "invalid @yearly alias parameter: '3' (expected month-day, e.g. '3-15')"
633 );
634 }
635
636 #[test]
637 fn expand_alias_error_order_parses_before_range_checks() {
638 assert_eq!(
640 alias_err("@yearly 13-x"),
641 "invalid day-of-month in @yearly alias: 'x'"
642 );
643 assert_eq!(
644 alias_err("@daily 24:x"),
645 "invalid minute in @daily alias: 'x'"
646 );
647 }
648
649 #[test]
650 fn expand_alias_accepts_range_bounds() {
651 assert_eq!(expand_alias("@hourly 59").unwrap(), "59 * * * *");
652 assert_eq!(expand_alias("@daily 23:59").unwrap(), "59 23 * * *");
653 assert_eq!(expand_alias("@weekly 7").unwrap(), "0 0 * * 7");
654 assert_eq!(expand_alias("@monthly 31").unwrap(), "0 0 31 * *");
655 assert_eq!(expand_alias("@yearly 12-31").unwrap(), "0 0 31 12 *");
656 assert_eq!(expand_alias("@yearly 1-1").unwrap(), "0 0 1 1 *");
657 }
658
659 #[test]
660 fn expand_alias_daily_abc_errors_via_check() {
661 assert!(check("@daily abc", None).is_err());
663 }
664
665 #[test]
666 fn expand_alias_hourly_not_a_number_errors_via_check() {
667 assert!(check("@hourly not-a-number", None).is_err());
669 }
670
671 #[test]
672 fn parse_rfc3339_timestamp() {
673 let dt = parse_timestamp("2026-02-22T10:30:00Z").unwrap();
674 assert_eq!(dt.hour(), 10);
675 assert_eq!(dt.minute(), 30);
676 }
677
678 #[test]
679 fn parse_naive_timestamp() {
680 let dt = parse_timestamp("2026-02-22T10:30:00").unwrap();
681 assert_eq!(dt.hour(), 10);
682 }
683
684 #[test]
685 fn parse_space_separated_timestamp() {
686 let dt = parse_timestamp("2026-02-22 10:30:00").unwrap();
687 assert_eq!(dt.hour(), 10);
688 }
689}