Skip to main content

scheduler/model/
cron_schedule.rs

1use crate::error::SchedulerError;
2use chrono::{DateTime, Utc};
3use chrono_tz::Tz;
4use cron::Schedule as ParsedCronSchedule;
5use std::fmt::{self, Debug, Display, Formatter};
6use std::str::FromStr;
7
8#[derive(Clone)]
9pub struct CronSchedule {
10    source: String,
11    parsed: ParsedCronSchedule,
12}
13
14impl CronSchedule {
15    pub fn parse(expression: &str) -> Result<Self, SchedulerError> {
16        validate_field_count(expression)?;
17
18        let parsed_expression = format!("0 {expression}");
19        let parsed = ParsedCronSchedule::from_str(&parsed_expression).map_err(|error| {
20            SchedulerError::invalid_cron(format!("invalid cron expression `{expression}`: {error}"))
21        })?;
22
23        Ok(Self {
24            source: expression.to_string(),
25            parsed,
26        })
27    }
28
29    pub fn as_str(&self) -> &str {
30        &self.source
31    }
32
33    pub(crate) fn next_after(&self, after: DateTime<Utc>, timezone: Tz) -> Option<DateTime<Utc>> {
34        let after = after.with_timezone(&timezone);
35        self.parsed
36            .after(&after)
37            .next()
38            .map(|value| value.with_timezone(&Utc))
39    }
40}
41
42impl Debug for CronSchedule {
43    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
44        f.debug_tuple("CronSchedule").field(&self.source).finish()
45    }
46}
47
48impl Display for CronSchedule {
49    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
50        f.write_str(&self.source)
51    }
52}
53
54impl PartialEq for CronSchedule {
55    fn eq(&self, other: &Self) -> bool {
56        self.source == other.source
57    }
58}
59
60impl Eq for CronSchedule {}
61
62impl FromStr for CronSchedule {
63    type Err = SchedulerError;
64
65    fn from_str(expression: &str) -> Result<Self, Self::Err> {
66        Self::parse(expression)
67    }
68}
69
70impl TryFrom<&str> for CronSchedule {
71    type Error = SchedulerError;
72
73    fn try_from(expression: &str) -> Result<Self, Self::Error> {
74        Self::parse(expression)
75    }
76}
77
78fn validate_field_count(expression: &str) -> Result<(), SchedulerError> {
79    let field_count = expression.split_whitespace().count();
80    if field_count == 5 {
81        return Ok(());
82    }
83
84    Err(SchedulerError::invalid_cron(format!(
85        "cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week), got {field_count}: `{expression}`"
86    )))
87}
88
89#[cfg(test)]
90mod tests {
91    use super::CronSchedule;
92    use chrono::{TimeZone, Timelike, Utc};
93    use chrono_tz::{Asia::Shanghai, UTC};
94
95    #[test]
96    fn parses_standard_five_field_expression() {
97        let schedule = CronSchedule::parse("*/15 9-17 * * Mon-Fri").unwrap();
98
99        assert_eq!(schedule.as_str(), "*/15 9-17 * * Mon-Fri");
100    }
101
102    #[test]
103    fn rejects_non_five_field_expressions() {
104        let error = CronSchedule::parse("0 */5 * * * *").unwrap_err();
105        assert!(
106            error
107                .to_string()
108                .contains("cron expression must have exactly 5 fields")
109        );
110
111        let error = CronSchedule::parse("@hourly").unwrap_err();
112        assert!(
113            error
114                .to_string()
115                .contains("cron expression must have exactly 5 fields")
116        );
117    }
118
119    #[test]
120    fn rejects_invalid_five_field_expression() {
121        let error = CronSchedule::parse("bogus * * * *").unwrap_err();
122        let message = error.to_string();
123
124        assert!(message.contains("invalid cron expression `bogus * * * *`"));
125    }
126
127    #[test]
128    fn next_after_uses_configured_timezone() {
129        let schedule = CronSchedule::parse("0 9 * * *").unwrap();
130        let start = Utc.with_ymd_and_hms(2026, 4, 3, 0, 30, 0).unwrap();
131
132        let shanghai_next = schedule.next_after(start, Shanghai).unwrap();
133        let utc_next = schedule.next_after(start, UTC).unwrap();
134
135        assert_eq!(
136            shanghai_next,
137            Utc.with_ymd_and_hms(2026, 4, 3, 1, 0, 0).unwrap()
138        );
139        assert_eq!(utc_next, Utc.with_ymd_and_hms(2026, 4, 3, 9, 0, 0).unwrap());
140    }
141
142    #[test]
143    fn next_after_advances_from_the_scheduled_time() {
144        let schedule = CronSchedule::parse("* * * * *").unwrap();
145        let scheduled_at = Utc.with_ymd_and_hms(2026, 4, 3, 1, 2, 0).unwrap();
146
147        let next = schedule.next_after(scheduled_at, UTC).unwrap();
148
149        assert_eq!(next, Utc.with_ymd_and_hms(2026, 4, 3, 1, 3, 0).unwrap());
150        assert_eq!(next.second(), 0);
151    }
152}