1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3
4use 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#[derive(Debug, Default, Clone, Copy)]
14pub struct CronSchedule;
15
16impl CronSchedule {
17 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 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 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}