Skip to main content

substrate_schedule/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3
4//! Cron/interval/daily/weekly scheduling via [`SchedulePort`].
5
6use chrono::{DateTime, Datelike, Duration, Utc, Weekday as ChronoWeekday};
7use croner::Cron;
8use std::str::FromStr;
9use substrate_core::error::{Result, SubstrateError};
10use substrate_core::schedule_port::{ScheduleInstant, SchedulePort, ScheduleTrigger, Weekday};
11
12/// [`SchedulePort`] backed by the `croner` crate for cron expressions.
13#[derive(Debug, Default, Clone, Copy)]
14pub struct CronSchedule;
15
16impl CronSchedule {
17    /// Create a new scheduler.
18    pub fn new() -> Self {
19        Self
20    }
21}
22
23fn to_instant(dt: DateTime<Utc>) -> ScheduleInstant {
24    ScheduleInstant {
25        secs: dt.timestamp(),
26    }
27}
28
29fn from_instant(inst: ScheduleInstant) -> DateTime<Utc> {
30    DateTime::from_timestamp(inst.secs, 0).unwrap_or_else(Utc::now)
31}
32
33fn weekday_to_chrono(w: Weekday) -> ChronoWeekday {
34    match w {
35        Weekday::Sun => ChronoWeekday::Sun,
36        Weekday::Mon => ChronoWeekday::Mon,
37        Weekday::Tue => ChronoWeekday::Tue,
38        Weekday::Wed => ChronoWeekday::Wed,
39        Weekday::Thu => ChronoWeekday::Thu,
40        Weekday::Fri => ChronoWeekday::Fri,
41        Weekday::Sat => ChronoWeekday::Sat,
42    }
43}
44
45fn validate_hm(hour: u8, minute: u8) -> Result<()> {
46    if hour > 23 || minute > 59 {
47        return Err(SubstrateError::InvalidSchedule(format!(
48            "hour/minute out of range: {hour}:{minute}"
49        )));
50    }
51    Ok(())
52}
53
54fn next_daily(after: DateTime<Utc>, hour: u8, minute: u8) -> Result<ScheduleInstant> {
55    validate_hm(hour, minute)?;
56    let mut candidate = after
57        .date_naive()
58        .and_hms_opt(hour.into(), minute.into(), 0)
59        .unwrap()
60        .and_utc();
61    if candidate <= after {
62        candidate += Duration::days(1);
63    }
64    Ok(to_instant(candidate))
65}
66
67fn next_weekly(
68    after: DateTime<Utc>,
69    weekday: Weekday,
70    hour: u8,
71    minute: u8,
72) -> Result<ScheduleInstant> {
73    validate_hm(hour, minute)?;
74    let target = weekday_to_chrono(weekday);
75    let mut candidate = after
76        .date_naive()
77        .and_hms_opt(hour.into(), minute.into(), 0)
78        .unwrap()
79        .and_utc();
80    for _ in 0..8 {
81        if candidate.weekday() == target && candidate > after {
82            return Ok(to_instant(candidate));
83        }
84        candidate += Duration::days(1);
85    }
86    Err(SubstrateError::InvalidSchedule(
87        "could not compute next weekly run".into(),
88    ))
89}
90
91impl SchedulePort for CronSchedule {
92    fn next_run(
93        &self,
94        trigger: &ScheduleTrigger,
95        after: ScheduleInstant,
96    ) -> Result<ScheduleInstant> {
97        let after_dt = from_instant(after);
98        match trigger {
99            ScheduleTrigger::Cron { expr } => {
100                // croner 3.x: `Cron::from_str` is the documented entry point and the
101                // underlying `CronParser` defaults to `Seconds::Optional`, which matches
102                // the legacy `with_seconds_optional()` behavior for 5- and 6-field patterns.
103                let cron = Cron::from_str(expr)
104                    .map_err(|e| SubstrateError::InvalidSchedule(format!("cron parse: {e}")))?;
105                let next = cron
106                    .find_next_occurrence(&after_dt, false)
107                    .map_err(|e| SubstrateError::InvalidSchedule(format!("cron next: {e}")))?;
108                Ok(to_instant(next))
109            }
110            ScheduleTrigger::Interval { every_secs } => {
111                if *every_secs == 0 {
112                    return Err(SubstrateError::InvalidSchedule(
113                        "interval every_secs must be > 0".into(),
114                    ));
115                }
116                let next = after_dt + Duration::seconds(*every_secs as i64);
117                Ok(to_instant(next))
118            }
119            ScheduleTrigger::Daily { hour, minute } => next_daily(after_dt, *hour, *minute),
120            ScheduleTrigger::Weekly {
121                weekday,
122                hour,
123                minute,
124            } => next_weekly(after_dt, *weekday, *hour, *minute),
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use chrono::{TimeZone, Timelike};
133
134    fn sched() -> CronSchedule {
135        CronSchedule::new()
136    }
137
138    fn at(y: i32, m: u32, d: u32, h: u32, min: u32) -> ScheduleInstant {
139        to_instant(Utc.with_ymd_and_hms(y, m, d, h, min, 0).single().unwrap())
140    }
141
142    #[test]
143    fn cron_next_run_hourly() {
144        let s = sched();
145        let after = at(2026, 6, 15, 10, 30);
146        let next = s
147            .next_run(
148                &ScheduleTrigger::Cron {
149                    expr: "0 * * * *".into(),
150                },
151                after,
152            )
153            .unwrap();
154        assert_eq!(from_instant(next).minute(), 0);
155        assert!(from_instant(next) > from_instant(after));
156    }
157
158    #[test]
159    fn interval_next_run() {
160        let s = sched();
161        let after = at(2026, 6, 15, 10, 0);
162        let next = s
163            .next_run(&ScheduleTrigger::Interval { every_secs: 300 }, after)
164            .unwrap();
165        assert_eq!(next.secs - after.secs, 300);
166    }
167
168    #[test]
169    fn daily_next_run() {
170        let s = sched();
171        let after = at(2026, 6, 15, 8, 0);
172        let next = s
173            .next_run(&ScheduleTrigger::Daily { hour: 9, minute: 0 }, after)
174            .unwrap();
175        let dt = from_instant(next);
176        assert_eq!(dt.hour(), 9);
177        assert_eq!(dt.day(), 15);
178    }
179
180    #[test]
181    fn weekly_next_run() {
182        let s = sched();
183        // 2026-06-15 is a Monday
184        let after = at(2026, 6, 15, 10, 0);
185        let next = s
186            .next_run(
187                &ScheduleTrigger::Weekly {
188                    weekday: Weekday::Wed,
189                    hour: 9,
190                    minute: 0,
191                },
192                after,
193            )
194            .unwrap();
195        let dt = from_instant(next);
196        assert_eq!(dt.weekday(), ChronoWeekday::Wed);
197        assert_eq!(dt.hour(), 9);
198    }
199}