honker 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,473 @@
1
+ //! Scheduler expression parser + next-fire calculator.
2
+ //!
3
+ //! Exposed as `honker_cron_next_after(expr, from_unix) -> next_unix` via
4
+ //! `attach_honker_functions`. The historical name sticks, but `expr` now
5
+ //! supports three forms:
6
+ //!
7
+ //! * 5-field cron: `minute hour dom month dow`
8
+ //! * 6-field cron: `second minute hour dom month dow`
9
+ //! * interval expressions: `@every <n><unit>` (e.g. `@every 1s`)
10
+ //!
11
+ //! Calendar arithmetic runs in the SYSTEM LOCAL TIME ZONE — same as the
12
+ //! previous implementation and standard cron. Interval expressions are
13
+ //! deterministic second-based steps.
14
+
15
+ use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, Timelike, Weekday};
16
+
17
+ use std::collections::BTreeSet;
18
+
19
+ #[derive(Debug, Clone)]
20
+ enum ScheduleExpr {
21
+ Every { interval_s: i64 },
22
+ Cron(CronSchedule),
23
+ }
24
+
25
+ impl ScheduleExpr {
26
+ fn parse(expr: &str) -> Result<Self, String> {
27
+ let expr = expr.trim();
28
+ if let Some(rest) = expr.strip_prefix("@every") {
29
+ return Ok(Self::Every {
30
+ interval_s: parse_every_interval(rest.trim())?,
31
+ });
32
+ }
33
+ Ok(Self::Cron(CronSchedule::parse(expr)?))
34
+ }
35
+ }
36
+
37
+ /// Parsed cron expression. Each field is the set of integer values
38
+ /// that satisfy that field.
39
+ #[derive(Debug, Clone)]
40
+ pub struct CronSchedule {
41
+ seconds: BTreeSet<u32>,
42
+ minutes: BTreeSet<u32>,
43
+ hours: BTreeSet<u32>,
44
+ days: BTreeSet<u32>,
45
+ months: BTreeSet<u32>,
46
+ dows: BTreeSet<u32>,
47
+ }
48
+
49
+ impl CronSchedule {
50
+ pub fn parse(expr: &str) -> Result<Self, String> {
51
+ let parts: Vec<&str> = expr.split_whitespace().collect();
52
+ match parts.len() {
53
+ 5 => Ok(Self {
54
+ seconds: BTreeSet::from([0]),
55
+ minutes: parse_field(parts[0], 0, 59)?,
56
+ hours: parse_field(parts[1], 0, 23)?,
57
+ days: parse_field(parts[2], 1, 31)?,
58
+ months: parse_field(parts[3], 1, 12)?,
59
+ dows: parse_field(parts[4], 0, 6)?,
60
+ }),
61
+ 6 => Ok(Self {
62
+ seconds: parse_field(parts[0], 0, 59)?,
63
+ minutes: parse_field(parts[1], 0, 59)?,
64
+ hours: parse_field(parts[2], 0, 23)?,
65
+ days: parse_field(parts[3], 1, 31)?,
66
+ months: parse_field(parts[4], 1, 12)?,
67
+ dows: parse_field(parts[5], 0, 6)?,
68
+ }),
69
+ _ => Err(format!(
70
+ "schedule expression requires 5 or 6 cron fields, or '@every <n><unit>'; got {}: {:?}",
71
+ parts.len(),
72
+ expr
73
+ )),
74
+ }
75
+ }
76
+
77
+ fn matches(&self, dt: &NaiveDateTime) -> bool {
78
+ // chrono Weekday: Mon=0..Sun=6. Cron dow: Sun=0..Sat=6.
79
+ let cron_dow = match dt.weekday() {
80
+ Weekday::Sun => 0,
81
+ Weekday::Mon => 1,
82
+ Weekday::Tue => 2,
83
+ Weekday::Wed => 3,
84
+ Weekday::Thu => 4,
85
+ Weekday::Fri => 5,
86
+ Weekday::Sat => 6,
87
+ };
88
+ self.seconds.contains(&dt.second())
89
+ && self.minutes.contains(&dt.minute())
90
+ && self.hours.contains(&dt.hour())
91
+ && self.days.contains(&(dt.day() as u32))
92
+ && self.months.contains(&(dt.month() as u32))
93
+ && self.dows.contains(&cron_dow)
94
+ }
95
+ }
96
+
97
+ fn parse_every_interval(body: &str) -> Result<i64, String> {
98
+ if body.is_empty() {
99
+ return Err("interval expression must be '@every <n><unit>'".to_string());
100
+ }
101
+ let digits_len = body.chars().take_while(|c| c.is_ascii_digit()).count();
102
+ if digits_len == 0 || digits_len == body.len() {
103
+ return Err(format!(
104
+ "interval expression must be '@every <n><unit>'; got {:?}",
105
+ body
106
+ ));
107
+ }
108
+ let n: i64 = body[..digits_len]
109
+ .parse()
110
+ .map_err(|_| format!("interval count must be an integer: {:?}", body))?;
111
+ if n <= 0 {
112
+ return Err(format!("interval count must be positive: {:?}", body));
113
+ }
114
+ let unit = &body[digits_len..];
115
+ let mult = match unit {
116
+ "s" => 1,
117
+ "m" => 60,
118
+ "h" => 60 * 60,
119
+ "d" => 60 * 60 * 24,
120
+ _ => {
121
+ return Err(format!(
122
+ "unsupported interval unit {:?}; expected one of s, m, h, d",
123
+ unit
124
+ ));
125
+ }
126
+ };
127
+ n.checked_mul(mult)
128
+ .ok_or_else(|| format!("interval is too large: {:?}", body))
129
+ }
130
+
131
+ fn parse_field(field: &str, lo: u32, hi: u32) -> Result<BTreeSet<u32>, String> {
132
+ let mut out = BTreeSet::new();
133
+ for part in field.split(',') {
134
+ let (range_part, step) = match part.split_once('/') {
135
+ Some((r, s)) => {
136
+ let step: u32 = s
137
+ .parse()
138
+ .map_err(|_| format!("cron step must be a positive integer: {:?}", part))?;
139
+ if step == 0 {
140
+ return Err(format!("cron step must be positive: {:?}", part));
141
+ }
142
+ (r, step)
143
+ }
144
+ None => (part, 1u32),
145
+ };
146
+ let (start, end) = if range_part == "*" {
147
+ (lo, hi)
148
+ } else if let Some((a, b)) = range_part.split_once('-') {
149
+ let a: u32 = a
150
+ .parse()
151
+ .map_err(|_| format!("cron field {:?} not an integer", part))?;
152
+ let b: u32 = b
153
+ .parse()
154
+ .map_err(|_| format!("cron field {:?} not an integer", part))?;
155
+ (a, b)
156
+ } else {
157
+ let v: u32 = range_part
158
+ .parse()
159
+ .map_err(|_| format!("cron field {:?} not an integer", part))?;
160
+ (v, v)
161
+ };
162
+ if start < lo || end > hi || start > end {
163
+ return Err(format!(
164
+ "cron field {:?} out of range [{},{}] or inverted",
165
+ part, lo, hi
166
+ ));
167
+ }
168
+ let mut v = start;
169
+ while v <= end {
170
+ out.insert(v);
171
+ v = v.saturating_add(step);
172
+ if step == 0 {
173
+ break;
174
+ }
175
+ }
176
+ }
177
+ Ok(out)
178
+ }
179
+
180
+ fn cron_day_matches(sched: &CronSchedule, dt: &NaiveDateTime) -> bool {
181
+ let cron_dow = match dt.weekday() {
182
+ Weekday::Sun => 0,
183
+ Weekday::Mon => 1,
184
+ Weekday::Tue => 2,
185
+ Weekday::Wed => 3,
186
+ Weekday::Thu => 4,
187
+ Weekday::Fri => 5,
188
+ Weekday::Sat => 6,
189
+ };
190
+ sched.days.contains(&(dt.day() as u32)) && sched.dows.contains(&cron_dow)
191
+ }
192
+
193
+ fn make_dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> NaiveDateTime {
194
+ NaiveDate::from_ymd_opt(year, month, day)
195
+ .and_then(|d| d.and_hms_opt(hour, minute, second))
196
+ .expect("constructed invalid local datetime")
197
+ }
198
+
199
+ fn next_or_first(set: &BTreeSet<u32>, current: u32) -> (u32, bool) {
200
+ if let Some(v) = set.range(current..).next() {
201
+ (*v, false)
202
+ } else {
203
+ (*set.first().expect("schedule field set is empty"), true)
204
+ }
205
+ }
206
+
207
+ fn cron_next_after_naive(
208
+ sched: &CronSchedule,
209
+ from: NaiveDateTime,
210
+ ) -> Result<NaiveDateTime, String> {
211
+ let mut cand = from + Duration::seconds(1);
212
+ let cap_year = cand.year() + 100;
213
+
214
+ while cand.year() <= cap_year {
215
+ let month = cand.month();
216
+ if !sched.months.contains(&month) {
217
+ let (next_month, wrapped) = next_or_first(&sched.months, month);
218
+ let year = if wrapped {
219
+ cand.year() + 1
220
+ } else {
221
+ cand.year()
222
+ };
223
+ cand = make_dt(year, next_month, 1, 0, 0, 0);
224
+ continue;
225
+ }
226
+
227
+ if !cron_day_matches(sched, &cand) {
228
+ cand = make_dt(cand.year(), cand.month(), cand.day(), 0, 0, 0) + Duration::days(1);
229
+ continue;
230
+ }
231
+
232
+ let hour = cand.hour();
233
+ if !sched.hours.contains(&hour) {
234
+ let (next_hour, wrapped) = next_or_first(&sched.hours, hour);
235
+ if wrapped {
236
+ cand = make_dt(cand.year(), cand.month(), cand.day(), 0, 0, 0) + Duration::days(1);
237
+ cand = make_dt(cand.year(), cand.month(), cand.day(), next_hour, 0, 0);
238
+ } else {
239
+ cand = make_dt(cand.year(), cand.month(), cand.day(), next_hour, 0, 0);
240
+ }
241
+ continue;
242
+ }
243
+
244
+ let minute = cand.minute();
245
+ if !sched.minutes.contains(&minute) {
246
+ let (next_minute, wrapped) = next_or_first(&sched.minutes, minute);
247
+ if wrapped {
248
+ cand = make_dt(cand.year(), cand.month(), cand.day(), cand.hour(), 0, 0)
249
+ + Duration::hours(1);
250
+ cand = make_dt(
251
+ cand.year(),
252
+ cand.month(),
253
+ cand.day(),
254
+ cand.hour(),
255
+ next_minute,
256
+ 0,
257
+ );
258
+ } else {
259
+ cand = make_dt(
260
+ cand.year(),
261
+ cand.month(),
262
+ cand.day(),
263
+ cand.hour(),
264
+ next_minute,
265
+ 0,
266
+ );
267
+ }
268
+ continue;
269
+ }
270
+
271
+ let second = cand.second();
272
+ if !sched.seconds.contains(&second) {
273
+ let (next_second, wrapped) = next_or_first(&sched.seconds, second);
274
+ if wrapped {
275
+ cand = make_dt(
276
+ cand.year(),
277
+ cand.month(),
278
+ cand.day(),
279
+ cand.hour(),
280
+ cand.minute(),
281
+ 0,
282
+ ) + Duration::minutes(1);
283
+ cand = make_dt(
284
+ cand.year(),
285
+ cand.month(),
286
+ cand.day(),
287
+ cand.hour(),
288
+ cand.minute(),
289
+ next_second,
290
+ );
291
+ } else {
292
+ cand = make_dt(
293
+ cand.year(),
294
+ cand.month(),
295
+ cand.day(),
296
+ cand.hour(),
297
+ cand.minute(),
298
+ next_second,
299
+ );
300
+ }
301
+ continue;
302
+ }
303
+
304
+ if sched.matches(&cand) {
305
+ return Ok(cand);
306
+ }
307
+
308
+ cand += Duration::seconds(1);
309
+ }
310
+
311
+ Err(format!(
312
+ "no schedule match found within 100 years after local datetime {:?}",
313
+ from
314
+ ))
315
+ }
316
+
317
+ /// Return the unix timestamp of the next boundary strictly after
318
+ /// `from_unix`, in the system local time zone.
319
+ pub fn next_after_unix(expr: &str, from_unix: i64) -> Result<i64, String> {
320
+ next_after_unix_in_tz(expr, from_unix, &Local)
321
+ }
322
+
323
+ /// TZ-parameterized variant of `next_after_unix`. Production code uses
324
+ /// `Local`; tests use fixed zones so DST edge cases are reproducible
325
+ /// regardless of the host's timezone.
326
+ pub(crate) fn next_after_unix_in_tz<Tz>(expr: &str, from_unix: i64, tz: &Tz) -> Result<i64, String>
327
+ where
328
+ Tz: chrono::TimeZone,
329
+ {
330
+ match ScheduleExpr::parse(expr)? {
331
+ ScheduleExpr::Every { interval_s } => from_unix
332
+ .checked_add(interval_s)
333
+ .ok_or_else(|| format!("next interval overflows unix timestamp for {:?}", expr)),
334
+ ScheduleExpr::Cron(sched) => {
335
+ let local = match tz.timestamp_opt(from_unix, 0) {
336
+ chrono::LocalResult::Single(t) => t,
337
+ chrono::LocalResult::Ambiguous(t, _) => t,
338
+ chrono::LocalResult::None => {
339
+ return Err(format!("invalid timestamp: {}", from_unix));
340
+ }
341
+ };
342
+ let mut cand = cron_next_after_naive(&sched, local.naive_local())?;
343
+ let cap_year = cand.year() + 100;
344
+ while cand.year() <= cap_year {
345
+ match tz.from_local_datetime(&cand) {
346
+ chrono::LocalResult::Single(t) => return Ok(t.timestamp()),
347
+ chrono::LocalResult::Ambiguous(t, _) => return Ok(t.timestamp()),
348
+ chrono::LocalResult::None => {
349
+ cand += Duration::seconds(1);
350
+ }
351
+ }
352
+ }
353
+ Err(format!(
354
+ "no valid local schedule match found within 100 years after unix_ts={}: {:?}",
355
+ from_unix, expr
356
+ ))
357
+ }
358
+ }
359
+ }
360
+
361
+ #[cfg(test)]
362
+ mod tests {
363
+ use super::*;
364
+ use chrono::TimeZone;
365
+
366
+ fn ts(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> i64 {
367
+ Local
368
+ .with_ymd_and_hms(y, mo, d, h, mi, s)
369
+ .single()
370
+ .unwrap()
371
+ .timestamp()
372
+ }
373
+
374
+ #[test]
375
+ fn star_every_minute() {
376
+ let from = ts(2026, 4, 19, 12, 30, 0);
377
+ let got = next_after_unix("* * * * *", from).unwrap();
378
+ assert_eq!(got, ts(2026, 4, 19, 12, 31, 0));
379
+ }
380
+
381
+ #[test]
382
+ fn every_five_minutes() {
383
+ let from = ts(2026, 4, 19, 12, 30, 0);
384
+ let got = next_after_unix("*/5 * * * *", from).unwrap();
385
+ assert_eq!(got, ts(2026, 4, 19, 12, 35, 0));
386
+ }
387
+
388
+ #[test]
389
+ fn six_field_seconds() {
390
+ let from = ts(2026, 4, 19, 12, 30, 5);
391
+ let got = next_after_unix("*/10 * * * * *", from).unwrap();
392
+ assert_eq!(got, ts(2026, 4, 19, 12, 30, 10));
393
+ }
394
+
395
+ #[test]
396
+ fn every_interval_seconds() {
397
+ let from = ts(2026, 4, 19, 12, 30, 5);
398
+ let got = next_after_unix("@every 1s", from).unwrap();
399
+ assert_eq!(got, ts(2026, 4, 19, 12, 30, 6));
400
+ }
401
+
402
+ #[test]
403
+ fn nightly_3am() {
404
+ let from = ts(2026, 4, 19, 10, 0, 0);
405
+ let got = next_after_unix("0 3 * * *", from).unwrap();
406
+ assert_eq!(got, ts(2026, 4, 20, 3, 0, 0));
407
+ }
408
+
409
+ #[test]
410
+ fn strictly_after() {
411
+ let from = ts(2026, 4, 19, 3, 0, 0);
412
+ let got = next_after_unix("0 3 * * *", from).unwrap();
413
+ assert_eq!(got, ts(2026, 4, 20, 3, 0, 0));
414
+ }
415
+
416
+ #[test]
417
+ fn range_with_step() {
418
+ let from = ts(2026, 4, 19, 12, 5, 0);
419
+ let got = next_after_unix("0-30/10 * * * *", from).unwrap();
420
+ assert_eq!(got, ts(2026, 4, 19, 12, 10, 0));
421
+ }
422
+
423
+ #[test]
424
+ fn comma_list() {
425
+ let from = ts(2026, 4, 19, 12, 10, 0);
426
+ let got = next_after_unix("0,30 * * * *", from).unwrap();
427
+ assert_eq!(got, ts(2026, 4, 19, 12, 30, 0));
428
+ }
429
+
430
+ #[test]
431
+ fn dow_filter() {
432
+ let from = ts(2026, 4, 19, 0, 0, 0);
433
+ let got = next_after_unix("0 12 * * 1", from).unwrap();
434
+ assert_eq!(got, ts(2026, 4, 20, 12, 0, 0));
435
+ }
436
+
437
+ #[test]
438
+ fn field_count_error() {
439
+ assert!(next_after_unix("* * * *", 0).is_err());
440
+ assert!(next_after_unix("* * *", 0).is_err());
441
+ }
442
+
443
+ #[test]
444
+ fn interval_parse_error() {
445
+ assert!(next_after_unix("@every", 0).is_err());
446
+ assert!(next_after_unix("@every 0s", 0).is_err());
447
+ assert!(next_after_unix("@every 5w", 0).is_err());
448
+ }
449
+
450
+ #[test]
451
+ fn six_field_validation() {
452
+ assert!(next_after_unix("60 * * * * *", 0).is_err());
453
+ assert!(next_after_unix("* * 24 * * *", 0).is_err());
454
+ }
455
+
456
+ #[test]
457
+ fn dst_spring_forward_gap_skips_to_real_time() {
458
+ use chrono_tz::US::Eastern;
459
+
460
+ let from = Eastern
461
+ .with_ymd_and_hms(2026, 3, 8, 1, 59, 59)
462
+ .single()
463
+ .unwrap()
464
+ .timestamp();
465
+ let got = next_after_unix_in_tz("0 * * * * *", from, &Eastern).unwrap();
466
+ let expected = Eastern
467
+ .with_ymd_and_hms(2026, 3, 8, 3, 0, 0)
468
+ .single()
469
+ .unwrap()
470
+ .timestamp();
471
+ assert_eq!(got, expected);
472
+ }
473
+ }