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