1use std::str::FromStr;
4
5use aion_core::TriggerSpec;
6use chrono::{DateTime, Utc};
7
8#[derive(thiserror::Error, Debug)]
10pub enum ScheduleError {
11 #[error("cron expression must not be empty")]
13 EmptyCronExpression,
14
15 #[error("invalid cron expression `{expression}`: {source}")]
17 CronParse {
18 expression: String,
20 source: saffron::parse::CronParseError,
22 },
23
24 #[error("cron expression `{expression}` has no next fire time")]
26 NoNextFireTime {
27 expression: String,
29 },
30
31 #[error("interval trigger period must be greater than zero")]
33 ZeroInterval,
34
35 #[error("interval trigger period cannot be represented as a chrono duration")]
37 IntervalOutOfRange,
38
39 #[error("next fire time overflowed the reference timestamp")]
41 NextFireTimeOutOfRange,
42}
43
44pub 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
65pub 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}