1use core::fmt;
14
15use chrono::{DateTime, Utc};
16use chrono_tz::Tz;
17use croner::Cron;
18use croner::errors::CronError;
19use croner::parser::{CronParser, Seconds};
20
21const NICKNAMES: [(&str, &str); 7] = [
25 ("@yearly", "0 0 1 1 *"),
26 ("@annually", "0 0 1 1 *"),
27 ("@monthly", "0 0 1 * *"),
28 ("@weekly", "0 0 * * 0"),
29 ("@daily", "0 0 * * *"),
30 ("@midnight", "0 0 * * *"),
31 ("@hourly", "0 * * * *"),
32];
33
34const NAMES: [&str; 19] = [
38 "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC", "SUN",
39 "MON", "TUE", "WED", "THU", "FRI", "SAT",
40];
41
42#[derive(Debug, Clone)]
51pub struct CronSchedule {
52 pattern: String,
55 zone: Tz,
56 cron: Cron,
57}
58
59impl CronSchedule {
60 pub fn parse(pattern: &str, timezone: Option<&str>) -> Result<Self, CronParseError> {
67 let zone = match timezone {
68 Some(name) => parse_timezone_name(name).ok_or_else(|| CronParseError::Timezone {
69 name: name.to_string(),
70 })?,
71 None => Tz::UTC,
72 };
73
74 let trimmed = pattern.trim();
75 let candidate = if is_single_at_token(trimmed) {
76 expand_nickname(trimmed, pattern)?
77 } else {
78 trimmed.to_string()
79 };
80 reject_extension_characters(&candidate, pattern)?;
81
82 let cron = cron_parser()
83 .parse(&candidate)
84 .map_err(|e| CronParseError::Pattern {
85 pattern: pattern.to_string(),
86 reason: e.to_string(),
87 })?;
88
89 Ok(Self {
90 pattern: pattern.to_string(),
91 zone,
92 cron,
93 })
94 }
95
96 pub fn next_after(
109 &self,
110 after: DateTime<Utc>,
111 ) -> Result<Option<DateTime<Utc>>, CronScheduleError> {
112 let start = after.with_timezone(&self.zone);
113 match self.cron.find_next_occurrence(&start, false) {
114 Ok(dt) => Ok(Some(dt.with_timezone(&Utc))),
115 Err(CronError::TimeSearchLimitExceeded) => Ok(None),
116 Err(e) => Err(CronScheduleError::Search {
117 reason: e.to_string(),
118 }),
119 }
120 }
121
122 #[must_use]
124 pub fn pattern(&self) -> &str {
125 &self.pattern
126 }
127}
128
129#[non_exhaustive]
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum CronParseError {
135 Pattern {
139 pattern: String,
141 reason: String,
143 },
144 Timezone {
146 name: String,
148 },
149}
150
151impl fmt::Display for CronParseError {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 match self {
154 Self::Pattern { pattern, reason } => {
155 write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
156 }
157 Self::Timezone { name } => write!(f, "`{name}` is not a recognized IANA timezone"),
158 }
159 }
160}
161
162impl core::error::Error for CronParseError {}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum CronScheduleError {
171 Search {
173 reason: String,
175 },
176}
177
178impl fmt::Display for CronScheduleError {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 match self {
181 Self::Search { reason } => write!(f, "cron schedule search failed: {reason}"),
182 }
183 }
184}
185
186impl core::error::Error for CronScheduleError {}
187
188fn cron_parser() -> CronParser {
200 CronParser::builder().seconds(Seconds::Disallowed).build()
201}
202
203pub(super) fn parse_timezone_name(name: &str) -> Option<Tz> {
208 name.parse::<Tz>().ok()
209}
210
211fn is_single_at_token(trimmed: &str) -> bool {
221 trimmed.starts_with('@') && trimmed.split_whitespace().count() == 1
222}
223
224fn expand_nickname(trimmed: &str, original: &str) -> Result<String, CronParseError> {
229 if trimmed.eq_ignore_ascii_case("@reboot") {
230 return Err(CronParseError::Pattern {
233 pattern: original.to_string(),
234 reason: "shep's own restart policy already decides when a sheep starts".to_string(),
235 });
236 }
237 for (name, expansion) in NICKNAMES {
238 if trimmed.eq_ignore_ascii_case(name) {
239 return Ok(expansion.to_string());
240 }
241 }
242 Err(CronParseError::Pattern {
243 pattern: original.to_string(),
244 reason: format!(
245 "`{trimmed}` is not a recognized cron_restart nickname (expected one of @yearly, \
246 @annually, @monthly, @weekly, @daily, @midnight, @hourly)"
247 ),
248 })
249}
250
251fn reject_extension_characters(candidate: &str, original: &str) -> Result<(), CronParseError> {
257 for field in candidate.split_whitespace() {
258 if let Some(bad) = field_has_bad_char(field) {
259 return Err(CronParseError::Pattern {
260 pattern: original.to_string(),
261 reason: format!(
262 "cron_restart pattern contains `{bad}`, a croner extension character \
263 shep's five-field dialect does not accept"
264 ),
265 });
266 }
267 }
268 Ok(())
269}
270
271fn field_has_bad_char(field: &str) -> Option<char> {
274 let chars: Vec<char> = field.chars().collect();
275 let mut i = 0;
276 while i < chars.len() {
277 if i + 3 <= chars.len() {
278 let window: String = chars[i..i + 3].iter().collect();
279 if NAMES.iter().any(|name| name.eq_ignore_ascii_case(&window)) {
280 i += 3;
281 continue;
282 }
283 }
284 if matches!(chars[i].to_ascii_uppercase(), 'L' | 'W' | '#' | '?') {
285 return Some(chars[i]);
286 }
287 i += 1;
288 }
289 None
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 fn dt(s: &str) -> DateTime<Utc> {
297 s.parse().expect("valid RFC3339 timestamp")
298 }
299
300 fn occurrence_sequence(
303 schedule: &CronSchedule,
304 start: DateTime<Utc>,
305 n: usize,
306 ) -> Vec<DateTime<Utc>> {
307 let mut cursor = start;
308 let mut out = Vec::with_capacity(n);
309 for _ in 0..n {
310 let next = schedule
311 .next_after(cursor)
312 .expect("search succeeds")
313 .expect("has a next occurrence");
314 out.push(next);
315 cursor = next;
316 }
317 out
318 }
319
320 fn assert_extension_char_rejected(pattern: &str, bad: char) {
321 match CronSchedule::parse(pattern, None) {
322 Err(CronParseError::Pattern {
323 pattern: got_pattern,
324 reason,
325 }) => {
326 assert_eq!(got_pattern, pattern);
327 assert_eq!(
328 reason,
329 format!(
330 "cron_restart pattern contains `{bad}`, a croner extension character \
331 shep's five-field dialect does not accept"
332 )
333 );
334 }
335 other => panic!("expected Pattern error, got {other:?}"),
336 }
337 }
338
339 #[test]
340 fn five_field_pattern_produces_pinned_occurrence_sequence() {
341 let schedule = CronSchedule::parse("0 3 * * *", None).unwrap();
344 let seq = occurrence_sequence(&schedule, dt("2026-01-01T00:00:00Z"), 3);
345 assert_eq!(
346 seq,
347 vec![
348 dt("2026-01-01T03:00:00Z"),
349 dt("2026-01-02T03:00:00Z"),
350 dt("2026-01-03T03:00:00Z"),
351 ]
352 );
353 }
354
355 #[test]
356 fn six_field_pattern_is_rejected() {
357 match CronSchedule::parse("30 0 3 * * *", None) {
361 Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "30 0 3 * * *"),
362 other => panic!("expected Pattern error, got {other:?}"),
363 }
364 }
365
366 #[test]
367 fn year_field_pattern_is_rejected() {
368 match CronSchedule::parse("0 3 * * * 2027", None) {
372 Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "0 3 * * * 2027"),
373 other => panic!("expected Pattern error, got {other:?}"),
374 }
375 }
376
377 #[test]
378 fn nicknames_expand_to_the_same_occurrence_sequence_as_their_five_field_form() {
379 let anchor = dt("2026-01-01T00:00:00Z");
383 let expected_expansions: [(&str, &str); 7] = [
384 ("@yearly", "0 0 1 1 *"),
385 ("@annually", "0 0 1 1 *"),
386 ("@monthly", "0 0 1 * *"),
387 ("@weekly", "0 0 * * 0"),
388 ("@daily", "0 0 * * *"),
389 ("@midnight", "0 0 * * *"),
390 ("@hourly", "0 * * * *"),
391 ];
392 for (nickname, five_field) in expected_expansions {
393 let via_nickname = CronSchedule::parse(nickname, None).unwrap();
394 let via_five_field = CronSchedule::parse(five_field, None).unwrap();
395 assert_eq!(
396 occurrence_sequence(&via_nickname, anchor, 3),
397 occurrence_sequence(&via_five_field, anchor, 3),
398 "{nickname} vs {five_field}"
399 );
400 }
401 }
402
403 #[test]
404 fn nickname_matching_is_ascii_case_insensitive() {
405 let anchor = dt("2026-01-01T00:00:00Z");
409 let upper = CronSchedule::parse("@DAILY", None).unwrap();
410 let lower = CronSchedule::parse("@daily", None).unwrap();
411 assert_eq!(
412 occurrence_sequence(&upper, anchor, 3),
413 occurrence_sequence(&lower, anchor, 3)
414 );
415 }
416
417 #[test]
418 fn nickname_pattern_keeps_its_own_spelling() {
419 let schedule = CronSchedule::parse("@daily", None).unwrap();
422 assert_eq!(schedule.pattern(), "@daily");
423 }
424
425 #[test]
426 fn reboot_nickname_is_rejected_with_its_own_message() {
427 match CronSchedule::parse("@reboot", None) {
430 Err(CronParseError::Pattern { pattern, reason }) => {
431 assert_eq!(pattern, "@reboot");
432 assert_eq!(
433 reason,
434 "shep's own restart policy already decides when a sheep starts"
435 );
436 }
437 other => panic!("expected Pattern error, got {other:?}"),
438 }
439 }
440
441 #[test]
442 fn unrecognized_nickname_is_rejected_without_reaching_croner() {
443 match CronSchedule::parse("@fortnightly", None) {
447 Err(CronParseError::Pattern { pattern, reason }) => {
448 assert_eq!(pattern, "@fortnightly");
449 assert_eq!(
450 reason,
451 "`@fortnightly` is not a recognized cron_restart nickname (expected one of \
452 @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly)"
453 );
454 }
455 other => panic!("expected Pattern error, got {other:?}"),
456 }
457 }
458
459 #[test]
460 fn zone_offset_is_applied_before_searching() {
461 let schedule = CronSchedule::parse("0 3 * * *", Some("Europe/Oslo")).unwrap();
465 let seq = occurrence_sequence(&schedule, dt("2026-01-05T00:00:00Z"), 3);
466 assert_eq!(
467 seq,
468 vec![
469 dt("2026-01-05T02:00:00Z"),
470 dt("2026-01-06T02:00:00Z"),
471 dt("2026-01-07T02:00:00Z"),
472 ]
473 );
474 }
475
476 #[test]
477 fn zone_offset_can_move_the_occurrence_to_a_different_utc_date() {
478 let schedule = CronSchedule::parse("30 23 * * *", Some("Pacific/Auckland")).unwrap();
482 let seq = occurrence_sequence(&schedule, dt("2026-07-05T00:00:00Z"), 3);
483 assert_eq!(
484 seq,
485 vec![
486 dt("2026-07-05T11:30:00Z"),
487 dt("2026-07-06T11:30:00Z"),
488 dt("2026-07-07T11:30:00Z"),
489 ]
490 );
491 }
492
493 #[test]
494 fn spring_forward_gap_lands_on_the_first_valid_instant() {
495 let schedule = CronSchedule::parse("30 2 * * *", Some("America/New_York")).unwrap();
498 let seq = occurrence_sequence(&schedule, dt("2026-03-06T12:00:00Z"), 4);
499 assert_eq!(
500 seq,
501 vec![
502 dt("2026-03-07T07:30:00Z"),
503 dt("2026-03-08T07:00:00Z"), dt("2026-03-09T06:30:00Z"),
505 dt("2026-03-10T06:30:00Z"),
506 ]
507 );
508 }
509
510 #[test]
511 fn spring_forward_wildcard_skips_nonexistent_slots() {
512 let schedule = CronSchedule::parse("*/15 * * * *", Some("America/New_York")).unwrap();
515 let seq = occurrence_sequence(&schedule, dt("2026-03-08T06:40:00Z"), 10);
516 assert_eq!(
517 seq,
518 vec![
519 dt("2026-03-08T06:45:00Z"),
520 dt("2026-03-08T07:00:00Z"), dt("2026-03-08T07:15:00Z"),
522 dt("2026-03-08T07:30:00Z"),
523 dt("2026-03-08T07:45:00Z"),
524 dt("2026-03-08T08:00:00Z"),
525 dt("2026-03-08T08:15:00Z"),
526 dt("2026-03-08T08:30:00Z"),
527 dt("2026-03-08T08:45:00Z"),
528 dt("2026-03-08T09:00:00Z"),
529 ]
530 );
531 }
532
533 #[test]
534 fn fall_back_repeated_hour_fires_once() {
535 let schedule = CronSchedule::parse("30 1 * * *", Some("America/New_York")).unwrap();
538 let seq = occurrence_sequence(&schedule, dt("2026-10-30T12:00:00Z"), 4);
539 assert_eq!(
540 seq,
541 vec![
542 dt("2026-10-31T05:30:00Z"),
543 dt("2026-11-01T05:30:00Z"), dt("2026-11-02T06:30:00Z"),
545 dt("2026-11-03T06:30:00Z"),
546 ]
547 );
548 }
549
550 #[test]
551 fn pattern_that_never_matches_returns_none() {
552 let schedule = CronSchedule::parse("0 0 30 2 *", None).unwrap();
555 assert_eq!(schedule.next_after(dt("2026-01-01T00:00:00Z")), Ok(None));
556 }
557
558 #[test]
559 fn search_failure_other_than_exhaustion_surfaces_as_err() {
560 let schedule = CronSchedule::parse("0 3 * * *", None).unwrap();
564 match schedule.next_after(DateTime::<Utc>::MAX_UTC) {
565 Err(CronScheduleError::Search { reason }) => {
566 assert_eq!(reason, "CronScheduler encountered an invalid time.");
567 }
568 other => panic!("expected Err(Search), got {other:?}"),
569 }
570 }
571
572 #[test]
573 fn malformed_pattern_is_rejected() {
574 match CronSchedule::parse("not a cron", None) {
577 Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "not a cron"),
578 other => panic!("expected Pattern error, got {other:?}"),
579 }
580 }
581
582 #[test]
583 fn five_tokens_of_garbage_are_rejected() {
584 match CronSchedule::parse("99 99 99 99 99", None) {
587 Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "99 99 99 99 99"),
588 other => panic!("expected Pattern error, got {other:?}"),
589 }
590 }
591
592 #[test]
593 fn unknown_timezone_is_rejected_at_parse_time() {
594 match CronSchedule::parse("0 3 * * *", Some("Mars/Olympus")) {
598 Err(CronParseError::Timezone { name }) => assert_eq!(name, "Mars/Olympus"),
599 other => panic!("expected Timezone error, got {other:?}"),
600 }
601 }
602
603 #[test]
604 fn day_of_month_last_day_extension_is_rejected() {
605 assert_extension_char_rejected("0 0 L * *", 'L');
607 }
608
609 #[test]
610 fn day_of_month_nearest_weekday_extension_is_rejected() {
611 assert_extension_char_rejected("0 0 1W * *", 'W');
615 }
616
617 #[test]
618 fn day_of_week_nth_occurrence_extension_is_rejected() {
619 assert_extension_char_rejected("0 0 * * 5#3", '#');
621 }
622
623 #[test]
624 fn day_of_week_any_extension_is_rejected() {
625 assert_extension_char_rejected("0 0 ? * *", '?');
627 }
628
629 #[test]
630 fn month_and_weekday_names_are_not_mistaken_for_extension_characters() {
631 let schedule = CronSchedule::parse("0 0 * JUL WED", None).unwrap();
636 assert_eq!(schedule.pattern(), "0 0 * JUL WED");
637 }
638
639 #[test]
640 fn weekday_range_names_are_not_mistaken_for_extension_characters() {
641 let schedule = CronSchedule::parse("0 0 * * MON-FRI", None).unwrap();
643 assert_eq!(schedule.pattern(), "0 0 * * MON-FRI");
644 }
645}