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.
- checksums.yaml +4 -4
- data/README.md +193 -2
- data/ext/honker/extconf.rb +69 -0
- data/ext/honker/honker-core/Cargo.toml +49 -0
- data/ext/honker/honker-core/LICENSE +6 -0
- data/ext/honker/honker-core/LICENSE-APACHE +176 -0
- data/ext/honker/honker-core/LICENSE-MIT +21 -0
- data/ext/honker/honker-core/README.md +30 -0
- data/ext/honker/honker-core/src/cron.rs +473 -0
- data/ext/honker/honker-core/src/honker_ops.rs +1518 -0
- data/ext/honker/honker-core/src/kernel_watcher.rs +434 -0
- data/ext/honker/honker-core/src/lib.rs +3116 -0
- data/ext/honker/honker-core/src/shm_watcher.rs +250 -0
- data/ext/honker/honker-extension/Cargo.toml +36 -0
- data/ext/honker/honker-extension/LICENSE +6 -0
- data/ext/honker/honker-extension/LICENSE-APACHE +176 -0
- data/ext/honker/honker-extension/LICENSE-MIT +21 -0
- data/ext/honker/honker-extension/README.md +41 -0
- data/ext/honker/honker-extension/src/lib.rs +330 -0
- data/honker.gemspec +24 -1
- data/lib/honker/README.md +28 -0
- data/lib/honker/railtie.rb +11 -0
- data/lib/honker/scheduler.rb +59 -0
- data/lib/honker/version.rb +1 -1
- data/lib/honker.rb +111 -3
- metadata +39 -8
|
@@ -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
|
+
}
|