Skip to main content

lenso_contracts/
cron.rs

1use chrono::{DateTime, Datelike, Duration, Timelike, Utc};
2use std::fmt;
3
4const MAX_LOOKAHEAD_MINUTES: usize = 5 * 366 * 24 * 60;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct CronSchedule {
8    minutes: CronField,
9    hours: CronField,
10    days_of_month: CronField,
11    months: CronField,
12    days_of_week: CronField,
13}
14
15impl CronSchedule {
16    pub fn parse(expression: &str) -> Result<Self, CronParseError> {
17        let fields = expression.split_whitespace().collect::<Vec<_>>();
18        if fields.len() != 5 {
19            return Err(CronParseError::new("cron expression must have 5 fields"));
20        }
21
22        Ok(Self {
23            minutes: CronField::parse(fields[0], FieldKind::Minute)?,
24            hours: CronField::parse(fields[1], FieldKind::Hour)?,
25            days_of_month: CronField::parse(fields[2], FieldKind::DayOfMonth)?,
26            months: CronField::parse(fields[3], FieldKind::Month)?,
27            days_of_week: CronField::parse(fields[4], FieldKind::DayOfWeek)?,
28        })
29    }
30
31    pub fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
32        let mut candidate = (after + Duration::minutes(1))
33            .with_second(0)?
34            .with_nanosecond(0)?;
35
36        // ponytail: minute scan, replace with field-jumping if dense schedules become hot.
37        for _ in 0..MAX_LOOKAHEAD_MINUTES {
38            if self.matches(candidate) {
39                return Some(candidate);
40            }
41            candidate += Duration::minutes(1);
42        }
43
44        None
45    }
46
47    fn matches(&self, at: DateTime<Utc>) -> bool {
48        self.minutes.contains(at.minute())
49            && self.hours.contains(at.hour())
50            && self.months.contains(at.month())
51            && self.matches_day(at)
52    }
53
54    fn matches_day(&self, at: DateTime<Utc>) -> bool {
55        let dom_matches = self.days_of_month.contains(at.day());
56        let dow_matches = self
57            .days_of_week
58            .contains(at.weekday().num_days_from_sunday());
59
60        match (
61            self.days_of_month.is_wildcard(),
62            self.days_of_week.is_wildcard(),
63        ) {
64            (true, true) => true,
65            (true, false) => dow_matches,
66            (false, true) => dom_matches,
67            (false, false) => dom_matches || dow_matches,
68        }
69    }
70}
71
72pub fn validate_cron_expression(expression: &str) -> Result<(), CronParseError> {
73    CronSchedule::parse(expression).map(|_| ())
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct CronParseError {
78    message: String,
79}
80
81impl CronParseError {
82    fn new(message: impl Into<String>) -> Self {
83        Self {
84            message: message.into(),
85        }
86    }
87}
88
89impl fmt::Display for CronParseError {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.write_str(&self.message)
92    }
93}
94
95impl std::error::Error for CronParseError {}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98struct CronField {
99    allowed: Vec<bool>,
100    min: u32,
101    max: u32,
102    wildcard: bool,
103}
104
105impl CronField {
106    fn parse(input: &str, kind: FieldKind) -> Result<Self, CronParseError> {
107        let min = kind.min();
108        let max = kind.max();
109        let mut allowed = vec![false; (max + 1) as usize];
110        let wildcard = input == "*";
111
112        if input.trim().is_empty() {
113            return Err(CronParseError::new("cron field must not be empty"));
114        }
115
116        for part in input.split(',') {
117            parse_part(part, kind, &mut allowed)?;
118        }
119
120        Ok(Self {
121            allowed,
122            min,
123            max,
124            wildcard,
125        })
126    }
127
128    fn contains(&self, value: u32) -> bool {
129        if value < self.min || value > self.max {
130            return false;
131        }
132        self.allowed[value as usize]
133    }
134
135    fn is_wildcard(&self) -> bool {
136        self.wildcard
137    }
138}
139
140fn parse_part(part: &str, kind: FieldKind, allowed: &mut [bool]) -> Result<(), CronParseError> {
141    let (base, step) = match part.split_once('/') {
142        Some((base, step)) => {
143            let step = step
144                .parse::<u32>()
145                .map_err(|_| CronParseError::new("cron step must be a positive integer"))?;
146            if step == 0 {
147                return Err(CronParseError::new("cron step must be greater than zero"));
148            }
149            (base, step)
150        }
151        None => (part, 1),
152    };
153
154    let (start, end) = parse_range(base, kind, step != 1)?;
155    let mut value = start;
156    while value <= end {
157        allowed[value as usize] = true;
158        value = value.saturating_add(step);
159        if value == 0 {
160            break;
161        }
162    }
163
164    Ok(())
165}
166
167fn parse_range(base: &str, kind: FieldKind, stepped: bool) -> Result<(u32, u32), CronParseError> {
168    if base == "*" {
169        return Ok((kind.min(), kind.max()));
170    }
171
172    if let Some((start, end)) = base.split_once('-') {
173        let start = parse_value(start, kind)?;
174        let end = parse_value(end, kind)?;
175        if start > end {
176            return Err(CronParseError::new("cron range start must be before end"));
177        }
178        return Ok((start, end));
179    }
180
181    let start = parse_value(base, kind)?;
182    if stepped {
183        Ok((start, kind.max()))
184    } else {
185        Ok((start, start))
186    }
187}
188
189fn parse_value(value: &str, kind: FieldKind) -> Result<u32, CronParseError> {
190    let normalized = value.to_ascii_uppercase();
191    if let Some(named) = kind.named_value(&normalized) {
192        return Ok(named);
193    }
194
195    let parsed = value
196        .parse::<u32>()
197        .map_err(|_| CronParseError::new("cron field contains an invalid value"))?;
198    let parsed = if kind == FieldKind::DayOfWeek && parsed == 7 {
199        0
200    } else {
201        parsed
202    };
203    if parsed < kind.min() || parsed > kind.max() {
204        return Err(CronParseError::new("cron field value is out of range"));
205    }
206    Ok(parsed)
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210enum FieldKind {
211    Minute,
212    Hour,
213    DayOfMonth,
214    Month,
215    DayOfWeek,
216}
217
218impl FieldKind {
219    fn min(self) -> u32 {
220        match self {
221            Self::Minute | Self::Hour | Self::DayOfWeek => 0,
222            Self::DayOfMonth | Self::Month => 1,
223        }
224    }
225
226    fn max(self) -> u32 {
227        match self {
228            Self::Minute => 59,
229            Self::Hour => 23,
230            Self::DayOfMonth => 31,
231            Self::Month => 12,
232            Self::DayOfWeek => 6,
233        }
234    }
235
236    fn named_value(self, value: &str) -> Option<u32> {
237        match self {
238            Self::Month => month_value(value),
239            Self::DayOfWeek => day_of_week_value(value),
240            _ => None,
241        }
242    }
243}
244
245fn month_value(value: &str) -> Option<u32> {
246    [
247        ("JAN", 1),
248        ("FEB", 2),
249        ("MAR", 3),
250        ("APR", 4),
251        ("MAY", 5),
252        ("JUN", 6),
253        ("JUL", 7),
254        ("AUG", 8),
255        ("SEP", 9),
256        ("OCT", 10),
257        ("NOV", 11),
258        ("DEC", 12),
259    ]
260    .into_iter()
261    .find_map(|(name, parsed)| (name == value).then_some(parsed))
262}
263
264fn day_of_week_value(value: &str) -> Option<u32> {
265    [
266        ("SUN", 0),
267        ("MON", 1),
268        ("TUE", 2),
269        ("WED", 3),
270        ("THU", 4),
271        ("FRI", 5),
272        ("SAT", 6),
273    ]
274    .into_iter()
275    .find_map(|(name, parsed)| (name == value).then_some(parsed))
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use chrono::TimeZone;
282
283    #[test]
284    fn cron_next_after_handles_steps_and_names() {
285        let schedule = CronSchedule::parse("*/15 9-17 * JAN,MAR MON-FRI").expect("cron");
286        let after = Utc.with_ymd_and_hms(2026, 1, 5, 9, 7, 10).unwrap();
287
288        assert_eq!(
289            schedule.next_after(after),
290            Some(Utc.with_ymd_and_hms(2026, 1, 5, 9, 15, 0).unwrap())
291        );
292    }
293
294    #[test]
295    fn cron_day_of_month_or_day_of_week_matches_vixie_shape() {
296        let schedule = CronSchedule::parse("0 9 15 * MON").expect("cron");
297
298        assert_eq!(
299            schedule.next_after(Utc.with_ymd_and_hms(2026, 6, 14, 9, 0, 0).unwrap()),
300            Some(Utc.with_ymd_and_hms(2026, 6, 15, 9, 0, 0).unwrap())
301        );
302    }
303
304    #[test]
305    fn cron_rejects_invalid_shapes() {
306        assert!(CronSchedule::parse("* * *").is_err());
307        assert!(CronSchedule::parse("*/0 * * * *").is_err());
308        assert!(CronSchedule::parse("60 * * * *").is_err());
309    }
310}