1use jiff::civil::Date;
8use jiff::{ToSpan, Zoned};
9
10const SEARCH_DAYS: u32 = 366 * 8 + 2;
12
13const MONTHS: [&str; 12] =
14 ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
15const DAYS: [&str; 7] = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub struct Schedule {
20 minutes: u64,
21 hours: u32,
22 days: u32,
23 months: u16,
24 weekdays: u8,
25 any_day: bool,
26 any_weekday: bool,
27}
28
29impl Schedule {
30 pub fn parse(text: &str) -> Result<Self, String> {
32 let fields: Vec<&str> = text.split_whitespace().collect();
33 let [minute, hour, day, month, weekday] = fields[..] else {
34 return Err(format!("'{text}' must have five fields: minute hour day month weekday"));
35 };
36 let weekdays = field(weekday, 0, 7, &DAYS, 0)?;
38 let weekdays = (weekdays & 0x7f) | (weekdays >> 7);
39 Ok(Self {
40 minutes: field(minute, 0, 59, &[], 0)?,
41 hours: field(hour, 0, 23, &[], 0)? as u32,
42 days: field(day, 1, 31, &[], 0)? as u32,
43 months: field(month, 1, 12, &MONTHS, 1)? as u16,
44 weekdays: weekdays as u8,
45 any_day: day.starts_with('*'),
46 any_weekday: weekday.starts_with('*'),
47 })
48 }
49
50 fn matches_date(&self, date: Date) -> bool {
51 if self.months & (1 << date.month()) == 0 {
52 return false;
53 }
54 let day = self.days & (1 << date.day()) != 0;
55 let weekday = self.weekdays & (1 << date.weekday().to_sunday_zero_offset()) != 0;
56 match (self.any_day, self.any_weekday) {
57 (false, false) => day || weekday,
58 _ => day && weekday,
59 }
60 }
61
62 pub fn next_after(&self, after: &Zoned) -> Option<Zoned> {
67 let zone = after.time_zone();
68 let start = after.datetime();
69 let mut date = start.date();
70 for _ in 0..SEARCH_DAYS {
71 if self.matches_date(date) {
72 for hour in 0..24_i8 {
73 if self.hours & (1 << hour) == 0 {
74 continue;
75 }
76 for minute in 0..60_i8 {
77 if self.minutes & (1 << minute) == 0 {
78 continue;
79 }
80 let candidate = date.at(hour, minute, 0, 0);
81 if candidate < start.date().at(start.hour(), start.minute(), 0, 0) {
82 continue;
83 }
84 let zoned = zone.to_ambiguous_zoned(candidate).compatible().ok()?;
85 if zoned.timestamp() > after.timestamp() {
86 return Some(zoned);
87 }
88 }
89 }
90 }
91 date = date.checked_add(1.day()).ok()?;
92 }
93 None
94 }
95}
96
97fn field(text: &str, min: u8, max: u8, names: &[&str], offset: u8) -> Result<u64, String> {
99 let mut bits = 0_u64;
100 for part in text.split(',') {
101 let (range, step) = match part.split_once('/') {
102 Some((range, step)) => {
103 let step: u8 = step.parse().map_err(|_| format!("bad step in '{part}'"))?;
104 if step == 0 {
105 return Err(format!("the step in '{part}' cannot be zero"));
106 }
107 (range, step)
108 }
109 None => (part, 1),
110 };
111 let (low, high) = if range == "*" {
112 (min, max)
113 } else if let Some((low, high)) = range.split_once('-') {
114 (value(low, names, offset)?, value(high, names, offset)?)
115 } else {
116 let low = value(range, names, offset)?;
117 (low, if part.contains('/') { max } else { low })
119 };
120 if low < min || high > max || low > high {
121 return Err(format!("'{part}' is outside {min}-{max}"));
122 }
123 let mut current = low;
124 while current <= high {
125 bits |= 1 << current;
126 match current.checked_add(step) {
127 Some(next) => current = next,
128 None => break,
129 }
130 }
131 }
132 Ok(bits)
133}
134
135fn value(text: &str, names: &[&str], offset: u8) -> Result<u8, String> {
136 if let Ok(number) = text.parse() {
137 return Ok(number);
138 }
139 let upper = text.to_ascii_uppercase();
140 names
141 .iter()
142 .position(|name| *name == upper)
143 .map(|i| i as u8 + offset)
144 .ok_or_else(|| format!("'{text}' is not a value of this field"))
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn at(text: &str) -> Zoned {
152 text.parse().unwrap_or_else(|e| panic!("{text}: {e}"))
153 }
154
155 fn next(schedule: &str, after: &str) -> String {
156 let next = Schedule::parse(schedule).unwrap_or_else(|e| panic!("{e}")).next_after(&at(after));
157 next.map_or("never".to_owned(), |z| z.strftime("%Y-%m-%d %H:%M %:z").to_string())
158 }
159
160 const RECIFE: &str = "[America/Recife]";
161
162 #[test]
163 fn simple_schedules() {
164 let now = format!("2026-11-01T10:15:30-03:00{RECIFE}");
165 assert_eq!(next("* * * * *", &now), "2026-11-01 10:16 -03:00");
166 assert_eq!(next("0 8 * * *", &now), "2026-11-02 08:00 -03:00");
167 assert_eq!(next("30 10 * * *", &now), "2026-11-01 10:30 -03:00");
168 assert_eq!(next("*/20 * * * *", &now), "2026-11-01 10:20 -03:00");
169 assert_eq!(next("0 8 1 * *", &now), "2026-12-01 08:00 -03:00");
170 }
171
172 #[test]
173 fn a_run_at_this_exact_minute_is_not_repeated() {
174 assert_eq!(
175 next("15 10 * * *", &format!("2026-11-01T10:15:00-03:00{RECIFE}")),
176 "2026-11-02 10:15 -03:00"
177 );
178 assert_eq!(
180 next("15 10 * * *", &format!("2026-11-01T10:14:59-03:00{RECIFE}")),
181 "2026-11-01 10:15 -03:00"
182 );
183 }
184
185 #[test]
186 fn names_ranges_lists_and_sunday_as_seven() {
187 let now = format!("2026-11-01T10:00:00-03:00{RECIFE}");
189 assert_eq!(next("0 8 * * MON", &now), "2026-11-02 08:00 -03:00");
190 assert_eq!(next("0 8 * * mon-fri", &now), "2026-11-02 08:00 -03:00");
191 assert_eq!(next("0 12 * * 7", &now), "2026-11-01 12:00 -03:00");
192 assert_eq!(next("0 12 * * 0", &now), "2026-11-01 12:00 -03:00");
193 assert_eq!(next("0 8 1 JAN,JUL *", &now), "2027-01-01 08:00 -03:00");
194 assert_eq!(next("5/15 10 * * *", &now), "2026-11-01 10:05 -03:00");
195 }
196
197 #[test]
198 fn restricted_day_fields_match_either() {
199 let now = format!("2026-11-03T10:00:00-03:00{RECIFE}");
201 assert_eq!(next("0 8 13 * MON", &now), "2026-11-09 08:00 -03:00");
202 }
203
204 #[test]
205 fn leap_days_and_impossible_dates() {
206 let now = format!("2026-11-01T10:00:00-03:00{RECIFE}");
207 assert_eq!(next("0 0 29 2 *", &now), "2028-02-29 00:00 -03:00");
208 assert_eq!(next("0 0 31 2 *", &now), "never");
209 }
210
211 #[test]
212 fn a_local_time_clocks_skip_runs_shifted_forward() {
213 let now = "2026-03-08T01:00:00-05:00[America/New_York]";
215 assert_eq!(next("30 2 * * *", now), "2026-03-08 03:30 -04:00");
216 }
217
218 #[test]
219 fn a_local_time_clocks_repeat_runs_once() {
220 let first = "2026-11-01T00:00:00-04:00[America/New_York]";
222 assert_eq!(next("30 1 * * *", first), "2026-11-01 01:30 -04:00");
223 let after_first = "2026-11-01T01:30:00-04:00[America/New_York]";
224 assert_eq!(next("30 1 * * *", after_first), "2026-11-02 01:30 -05:00");
225 }
226
227 #[test]
228 fn bad_schedules_are_refused() {
229 for bad in [
230 "",
231 "* * * *",
232 "* * * * * *",
233 "60 * * * *",
234 "* 24 * * *",
235 "* * 0 * *",
236 "* * * 13 *",
237 "*/0 * * * *",
238 "5-1 * * * *",
239 "x * * * *",
240 "* * * * FUNDAY",
241 ] {
242 assert!(Schedule::parse(bad).is_err(), "{bad}");
243 }
244 }
245}