Skip to main content

aion/schedule/
trigger.rs

1//! Trigger parsing and deterministic next-fire-time calculation.
2
3use std::str::FromStr;
4
5use aion_core::TriggerSpec;
6use chrono::{DateTime, Utc};
7
8/// Errors returned while validating or evaluating schedule triggers.
9#[derive(thiserror::Error, Debug)]
10pub enum ScheduleError {
11    /// The supplied cron expression is empty or contains only whitespace.
12    #[error("cron expression must not be empty")]
13    EmptyCronExpression,
14
15    /// The supplied cron expression could not be parsed by the cron parser.
16    #[error("invalid cron expression `{expression}`: {source}")]
17    CronParse {
18        /// The expression that failed validation.
19        expression: String,
20        /// Parser error returned by the underlying cron crate.
21        source: saffron::parse::CronParseError,
22    },
23
24    /// The cron expression has no future matching timestamp.
25    #[error("cron expression `{expression}` has no next fire time")]
26    NoNextFireTime {
27        /// The expression whose next time could not be found.
28        expression: String,
29    },
30
31    /// Interval triggers must advance time by a positive duration.
32    #[error("interval trigger period must be greater than zero")]
33    ZeroInterval,
34
35    /// The supplied interval cannot be represented by chrono's duration type.
36    #[error("interval trigger period cannot be represented as a chrono duration")]
37    IntervalOutOfRange,
38
39    /// Adding the interval to the reference timestamp overflowed.
40    #[error("next fire time overflowed the reference timestamp")]
41    NextFireTimeOutOfRange,
42}
43
44/// Parse and validate a standard five-field cron expression.
45///
46/// The parser accepts expressions in the `minute hour day-of-month month day-of-week` form and
47/// returns a typed [`ScheduleError`] for empty or invalid input.
48///
49/// # Errors
50///
51/// Returns [`ScheduleError::EmptyCronExpression`] for blank input and
52/// [`ScheduleError::CronParse`] when the expression is not accepted by the cron parser.
53pub fn parse_cron_expression(expression: &str) -> Result<saffron::Cron, ScheduleError> {
54    let expression = expression.trim();
55    if expression.is_empty() {
56        return Err(ScheduleError::EmptyCronExpression);
57    }
58
59    saffron::Cron::from_str(expression).map_err(|source| ScheduleError::CronParse {
60        expression: expression.to_owned(),
61        source,
62    })
63}
64
65/// Return the next time a trigger should fire, strictly after `after`.
66///
67/// Cron triggers are parsed and evaluated with the cron parser's strict-after API. Interval
68/// triggers add their period to the supplied reference timestamp using checked arithmetic.
69///
70/// # Errors
71///
72/// Returns [`ScheduleError`] when the cron expression is invalid or has no future time, when an
73/// interval is zero, or when duration conversion/timestamp addition overflows.
74pub fn next_fire_time(
75    trigger: &TriggerSpec,
76    after: DateTime<Utc>,
77) -> Result<DateTime<Utc>, ScheduleError> {
78    match trigger {
79        TriggerSpec::Cron { expression } => {
80            let cron = parse_cron_expression(expression)?;
81            cron.next_after(after)
82                .ok_or_else(|| ScheduleError::NoNextFireTime {
83                    expression: expression.trim().to_owned(),
84                })
85        }
86        TriggerSpec::Interval { period } => {
87            if period.is_zero() {
88                return Err(ScheduleError::ZeroInterval);
89            }
90
91            let chrono_duration = chrono::Duration::from_std(*period)
92                .map_err(|_| ScheduleError::IntervalOutOfRange)?;
93            after
94                .checked_add_signed(chrono_duration)
95                .ok_or(ScheduleError::NextFireTimeOutOfRange)
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use std::error::Error;
103    use std::time::Duration;
104
105    use aion_core::TriggerSpec;
106    use chrono::{DateTime, Utc};
107
108    use super::{ScheduleError, next_fire_time, parse_cron_expression};
109
110    fn parse_utc(value: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
111        DateTime::parse_from_rfc3339(value).map(|date_time| date_time.with_timezone(&Utc))
112    }
113
114    #[test]
115    fn parses_standard_five_field_cron_expressions() {
116        assert!(parse_cron_expression("0 0 * * *").is_ok());
117        assert!(parse_cron_expression("*/5 * * * *").is_ok());
118    }
119
120    #[test]
121    fn invalid_cron_expressions_return_typed_errors() {
122        assert!(matches!(
123            parse_cron_expression("invalid"),
124            Err(ScheduleError::CronParse { .. })
125        ));
126        assert!(matches!(
127            parse_cron_expression(""),
128            Err(ScheduleError::EmptyCronExpression)
129        ));
130        assert!(matches!(
131            parse_cron_expression("   "),
132            Err(ScheduleError::EmptyCronExpression)
133        ));
134    }
135
136    #[test]
137    fn midnight_cron_returns_next_midnight_strictly_after_reference() -> Result<(), Box<dyn Error>>
138    {
139        let trigger = TriggerSpec::Cron {
140            expression: "0 0 * * *".to_owned(),
141        };
142        let reference = parse_utc("2026-06-07T00:00:00Z")?;
143        let expected = parse_utc("2026-06-08T00:00:00Z")?;
144
145        let next = next_fire_time(&trigger, reference)?;
146
147        assert_eq!(next, expected);
148        assert!(next > reference);
149        Ok(())
150    }
151
152    #[test]
153    fn interval_returns_reference_plus_period() -> Result<(), Box<dyn Error>> {
154        let period = Duration::from_secs(5 * 60);
155        let trigger = TriggerSpec::Interval { period };
156        let reference = parse_utc("2026-06-07T12:00:00Z")?;
157
158        let next = next_fire_time(&trigger, reference)?;
159
160        assert_eq!(next, reference + chrono::Duration::minutes(5));
161        assert!(next > reference);
162        Ok(())
163    }
164
165    #[test]
166    fn zero_interval_returns_typed_error() -> Result<(), Box<dyn Error>> {
167        let trigger = TriggerSpec::Interval {
168            period: Duration::ZERO,
169        };
170        let reference = parse_utc("2026-06-07T12:00:00Z")?;
171
172        assert!(matches!(
173            next_fire_time(&trigger, reference),
174            Err(ScheduleError::ZeroInterval)
175        ));
176        Ok(())
177    }
178
179    #[test]
180    fn invalid_cron_trigger_returns_typed_parse_error() -> Result<(), Box<dyn Error>> {
181        let trigger = TriggerSpec::Cron {
182            expression: "invalid".to_owned(),
183        };
184        let reference = parse_utc("2026-06-07T12:00:00Z")?;
185
186        assert!(matches!(
187            next_fire_time(&trigger, reference),
188            Err(ScheduleError::CronParse { .. })
189        ));
190        Ok(())
191    }
192
193    #[test]
194    fn oversized_interval_returns_typed_range_error() -> Result<(), Box<dyn Error>> {
195        let trigger = TriggerSpec::Interval {
196            period: Duration::from_secs(u64::MAX),
197        };
198        let reference = parse_utc("2026-06-07T12:00:00Z")?;
199
200        assert!(matches!(
201            next_fire_time(&trigger, reference),
202            Err(ScheduleError::IntervalOutOfRange)
203        ));
204        Ok(())
205    }
206
207    #[test]
208    fn interval_timestamp_overflow_returns_typed_range_error() {
209        let trigger = TriggerSpec::Interval {
210            period: Duration::from_secs(1),
211        };
212
213        assert!(matches!(
214            next_fire_time(&trigger, DateTime::<Utc>::MAX_UTC),
215            Err(ScheduleError::NextFireTimeOutOfRange)
216        ));
217    }
218
219    #[test]
220    fn successive_calls_are_strictly_increasing() -> Result<(), Box<dyn Error>> {
221        let cron_trigger = TriggerSpec::Cron {
222            expression: "*/5 * * * *".to_owned(),
223        };
224        let first = next_fire_time(&cron_trigger, parse_utc("2026-06-07T00:00:00Z")?)?;
225        let second = next_fire_time(&cron_trigger, first)?;
226        assert!(second > first);
227
228        let interval_trigger = TriggerSpec::Interval {
229            period: Duration::from_secs(60),
230        };
231        let third = next_fire_time(&interval_trigger, second)?;
232        let fourth = next_fire_time(&interval_trigger, third)?;
233        assert!(fourth > third);
234        Ok(())
235    }
236}