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