Skip to main content

areev_trigger/
schedule.rs

1//! When is this trigger next due?
2//!
3//! Interval, cron and one-shot are three declarations of one primitive: a
4//! function from "when did it last fire" to "when should it fire next". They
5//! are kept apart in the vocabulary because they read differently to a person,
6//! not because the evaluator treats them differently.
7//!
8//! Two behaviours here are worth stating because they are the ones people get
9//! wrong:
10//!
11//! **Rate drifts; cron does not.** `interval_secs` anchors to the *last firing*,
12//! so a 3-minute job on a 5-minute interval leaves a 2-minute gap and the
13//! effective period depends on runtime. `*/5 * * * *` anchors to wall-clock
14//! boundaries and does not. Both are correct; users need to know which they
15//! chose, so the two are separate kinds rather than one field with a flag.
16//!
17//! **Catch-up collapses by default.** A laptop closed over a weekend has missed
18//! hundreds of occurrences. `Catchup::Last` fires once on resume, which is what
19//! anacron does and what systemd's `Persistent=true` means. `All` replays them
20//! and `None` drops them; the three-way is Quartz's misfire vocabulary — fire
21//! once now / skip / replay all.
22
23use areev_core::types::{Catchup, Trigger, TriggerKind};
24use croner::Cron;
25
26use crate::error::{Result, TriggerError};
27
28/// The zone every build understands, with or without zone data.
29const SUPPORTED_TIMEZONE: &str = "UTC";
30
31/// Cron evaluates in the trigger's declared IANA zone under the `tz` feature
32/// (#297); a build without it keeps refusing anything but UTC, BY NAME.
33///
34/// The DST policy is declared here rather than inherited, because real
35/// implementations disagree and a silent guess is the wrong move:
36///
37/// * **Gap** (spring forward): fire ONCE, at the first valid instant after
38///   the gap. AWS EventBridge and robfig/cron skip the occurrence entirely;
39///   Vixie cron fires it immediately. A daily 02:30 job that simply does not
40///   run on one day a year is a silent missed obligation, so Areev fires it
41///   late rather than not at all.
42/// * **Fold** (fall back): fire ONCE, at the EARLIER occurrence. Everyone
43///   agrees on this one.
44/// * Missed occurrences collapse per the trigger's own `catchup` policy,
45///   unchanged.
46///
47/// Wildcard and step minute fields are evaluated on local WALL time, which
48/// is what "every 15 minutes" means to the person who wrote it.
49#[cfg(feature = "tz")]
50fn resolve_zone(tz: &str) -> Option<chrono_tz::Tz> {
51    tz.parse::<chrono_tz::Tz>().ok()
52}
53
54/// Parse and validate a declaration's schedule without evaluating it.
55///
56/// Called at authoring time so a bad expression is refused when it is written
57/// rather than discovered as silence.
58pub fn validate(trigger: &Trigger) -> Result<()> {
59    if let Some(why) = trigger.incoherence() {
60        return Err(TriggerError::Malformed { what: why });
61    }
62    // A workflow reference that cannot be decoded can never start anything,
63    // and `incoherence()` only established it is non-empty. Checking it HERE
64    // is what puts it in `trigger status`'s `unusable` column (#67) instead
65    // of leaving it to report `waiting` forever and fail at fire time (#73).
66    // The scheme prefix is stripped first: both spellings are accepted on
67    // write, so both have to be readable.
68    if let Err(e) = areev_core::error::Hash::from_hex(trigger.workflow_hash()) {
69        return Err(TriggerError::UnresolvedWorkflow {
70            hash: trigger.workflow.clone(),
71            why: format!(
72                "{e} — a workflow is named by its 64-hex content address, \
73                 optionally prefixed `sha256:`"
74            ),
75        });
76    }
77    // A malformed `--context-query` spelling (#92) can never fire — refuse
78    // it at authoring time with the parser's own message. The fire path
79    // re-parses (fail-closed) for declarations that bypassed validation.
80    if let Some(spec) = trigger.context_query.as_deref() {
81        if let Err(why) = crate::context::parse_context_query(spec) {
82            return Err(TriggerError::Malformed {
83                what: format!("context_query: {why}"),
84            });
85        }
86    }
87    if trigger.kind == TriggerKind::Schedule {
88        let expr = trigger.cron.as_deref().unwrap_or_default();
89        parse_cron(expr)?;
90        if let Some(tz) = trigger.config_str(areev_core::types::config_keys::TIMEZONE) {
91            check_timezone(expr, tz)?;
92        }
93    }
94    if trigger.kind == TriggerKind::Composite {
95        // Refuse at authoring time exactly what firing refuses later. A gate
96        // naming a member the declaration does not carry can never be
97        // satisfied, so the trigger is dead on arrival -- and a dead trigger's
98        // only symptom is nothing happening, which is the whole category this
99        // validation exists to catch. Same TRG-E008 either way: the code names
100        // the defect, not the moment it was noticed.
101        //
102        // `incoherence()` has already established the predicate is present.
103        if let Some(raw) = trigger.predicate.as_ref() {
104            let predicate = crate::predicate::from_value(raw)?;
105            for referenced in crate::predicate::referenced_members(&predicate) {
106                if !trigger.members.contains_key(&referenced) {
107                    return Err(TriggerError::UnknownMember {
108                        trigger: "being declared".into(),
109                        member: referenced,
110                    });
111                }
112            }
113        }
114    }
115    Ok(())
116}
117
118/// Validate a declared zone against what THIS build can evaluate (#297).
119///
120/// An unknown zone name is refused whether or not zone data is compiled in —
121/// a typo must never fall back to UTC and fire at the wrong hour while
122/// looking correct. A build without the feature names the feature, so an
123/// operator meeting the refusal knows what to change rather than believing
124/// the zone itself is unsupported.
125fn check_timezone(expr: &str, tz: &str) -> Result<()> {
126    if tz.eq_ignore_ascii_case(SUPPORTED_TIMEZONE) {
127        return Ok(());
128    }
129    #[cfg(feature = "tz")]
130    {
131        if resolve_zone(tz).is_some() {
132            return Ok(());
133        }
134        Err(TriggerError::BadSchedule {
135            expr: expr.to_string(),
136            why: format!(
137                "timezone {tz:?} is not an IANA zone name this build's zone data knows \
138                 (e.g. \"America/New_York\", \"Asia/Kolkata\", \"UTC\")"
139            ),
140        })
141    }
142    #[cfg(not(feature = "tz"))]
143    {
144        Err(TriggerError::BadSchedule {
145            expr: expr.to_string(),
146            why: format!(
147                "timezone {tz:?} needs zone data this build does not carry — rebuild \
148                 areev-trigger with the `tz` feature, or declare the expression in UTC"
149            ),
150        })
151    }
152}
153
154/// The zone this trigger DECLARES, for `trigger status` (#297).
155///
156/// `None` for UTC and for every non-schedule kind — a status surface should
157/// say "this is zoned" only when it is.
158pub fn declared_zone(trigger: &Trigger) -> Option<String> {
159    if trigger.kind != TriggerKind::Schedule {
160        return None;
161    }
162    trigger_zone(trigger)
163}
164
165/// `at_ms` rendered in the trigger's own zone (#297), beside the UTC
166/// instant.
167///
168/// On a DST boundary the UTC instant moves by an hour while the local wall
169/// time does not, so a surface showing only UTC looks like the schedule
170/// drifted.
171#[cfg(feature = "tz")]
172pub fn render_local(trigger: &Trigger, at_ms: i64) -> Option<String> {
173    let tz = declared_zone(trigger)?;
174    let zone = resolve_zone(&tz)?;
175    let utc = chrono::DateTime::from_timestamp_millis(at_ms)?;
176    Some(format!(
177        "{} {}",
178        utc.with_timezone(&zone).format("%Y-%m-%dT%H:%M:%S%:z"),
179        tz
180    ))
181}
182
183#[cfg(not(feature = "tz"))]
184pub fn render_local(_trigger: &Trigger, _at_ms: i64) -> Option<String> {
185    None
186}
187
188/// The zone a schedule trigger evaluates in.
189fn trigger_zone(trigger: &Trigger) -> Option<String> {
190    trigger
191        .config_str(areev_core::types::config_keys::TIMEZONE)
192        .filter(|tz| !tz.eq_ignore_ascii_case(SUPPORTED_TIMEZONE))
193        .map(str::to_string)
194}
195
196fn parse_cron(expr: &str) -> Result<Cron> {
197    Cron::new(expr)
198        .with_seconds_optional()
199        .parse()
200        .map_err(|e| TriggerError::BadSchedule { expr: expr.to_string(), why: e.to_string() })
201}
202
203/// The next instant at or after `after_ms` at which `trigger` should fire.
204///
205/// `None` means "never again", which only a one-shot past its instant can be.
206pub fn next_due_after(trigger: &Trigger, after_ms: i64) -> Result<Option<i64>> {
207    match trigger.kind {
208        TriggerKind::Once => {
209            let at = trigger.at_ms.ok_or_else(|| TriggerError::Malformed {
210                what: "a once trigger needs at_ms".into(),
211            })?;
212            Ok(if at > after_ms { Some(at) } else { None })
213        }
214        TriggerKind::Interval | TriggerKind::Polling => {
215            let secs = trigger.interval_secs.ok_or_else(|| TriggerError::Malformed {
216                what: format!("a {} trigger needs interval_secs", trigger.kind.as_str()),
217            })?;
218            Ok(Some(after_ms.saturating_add(i64::from(secs) * 1000)))
219        }
220        TriggerKind::Schedule => {
221            let expr = trigger.cron.as_deref().unwrap_or_default();
222            let cron = parse_cron(expr)?;
223            let after = chrono::DateTime::from_timestamp_millis(after_ms).ok_or_else(|| {
224                TriggerError::BadSchedule {
225                    expr: expr.to_string(),
226                    why: format!("{after_ms} is not a representable instant"),
227                }
228            })?;
229            match trigger_zone(trigger) {
230                None => {
231                    let next = cron.find_next_occurrence(&after, false).map_err(|e| {
232                        TriggerError::BadSchedule { expr: expr.to_string(), why: e.to_string() }
233                    })?;
234                    Ok(Some(next.timestamp_millis()))
235                }
236                Some(tz) => next_local_occurrence(expr, &cron, &tz, after_ms, after),
237            }
238        }
239        // Push and gate kinds are not clocked: they fire when something arrives.
240        TriggerKind::Memory | TriggerKind::Webhook | TriggerKind::Manual | TriggerKind::Composite => {
241            Ok(None)
242        }
243    }
244}
245
246/// The next occurrence of `cron` in `tz`, strictly after `after_ms` (#297).
247///
248/// **The fold hazard, and why the loop exists.** Evaluating in local wall
249/// time means an instant inside the repeated hour maps to two UTC instants,
250/// and the earlier one wins. So `30 1 * * *` asked from 01:10 EST resolves to
251/// 01:30 EDT — an instant BEFORE the question. `advance_after_firing`'s
252/// `Some(n) if n > next` guard would then break out of its catch-up loop and
253/// leave the schedule stuck. The contract here is therefore "strictly after",
254/// enforced by searching forward past the fold rather than trusting the
255/// zone-aware search to be monotonic.
256#[cfg(feature = "tz")]
257fn next_local_occurrence(
258    expr: &str,
259    cron: &Cron,
260    tz: &str,
261    after_ms: i64,
262    after: chrono::DateTime<chrono::Utc>,
263) -> Result<Option<i64>> {
264    use chrono::TimeZone;
265    let zone = resolve_zone(tz).ok_or_else(|| TriggerError::BadSchedule {
266        expr: expr.to_string(),
267        why: format!("timezone {tz:?} is not a zone name this build knows"),
268    })?;
269    let mut cursor = after.with_timezone(&zone);
270    // A handful of steps is always enough: at most one fold can sit between
271    // a question and its answer, and each step advances the cursor by at
272    // least the schedule's own period.
273    for _ in 0..8 {
274        let next = cron.find_next_occurrence(&cursor, false).map_err(|e| {
275            TriggerError::BadSchedule { expr: expr.to_string(), why: e.to_string() }
276        })?;
277        let ms = next.timestamp_millis();
278        if ms > after_ms {
279            return Ok(Some(ms));
280        }
281        // Inside the fold: step one second past the answer in LOCAL terms
282        // and ask again, which lands on the second pass of the repeated hour.
283        cursor = zone.from_utc_datetime(
284            &chrono::DateTime::from_timestamp_millis(ms + 1_000)
285                .ok_or_else(|| TriggerError::BadSchedule {
286                    expr: expr.to_string(),
287                    why: "instant out of range while resolving a DST fold".into(),
288                })?
289                .naive_utc(),
290        );
291    }
292    Err(TriggerError::BadSchedule {
293        expr: expr.to_string(),
294        why: format!(
295            "no occurrence strictly after {after_ms} in {tz} within the DST search bound"
296        ),
297    })
298}
299
300/// Without zone data there is no local evaluation to do — `validate` already
301/// refused the declaration, and this is the fail-closed backstop for a
302/// declaration that reached the fire path unvalidated (imported in a bundle,
303/// say).
304#[cfg(not(feature = "tz"))]
305fn next_local_occurrence(
306    expr: &str,
307    _cron: &Cron,
308    tz: &str,
309    _after_ms: i64,
310    _after: chrono::DateTime<chrono::Utc>,
311) -> Result<Option<i64>> {
312    Err(TriggerError::BadSchedule {
313        expr: expr.to_string(),
314        why: format!(
315            "timezone {tz:?} needs zone data this build does not carry — rebuild \
316             areev-trigger with the `tz` feature, or declare the expression in UTC"
317        ),
318    })
319}
320
321/// Where to set `next_due_at` after a firing that happened at `fired_at_ms`,
322/// given that it is now `now_ms`.
323///
324/// This is where catch-up policy lands. If evaluation stalled — the host was
325/// asleep, the process was down — the naive next instant can be far in the
326/// past, and the question is whether to replay the gap.
327pub fn advance_after_firing(trigger: &Trigger, fired_at_ms: i64, now_ms: i64) -> Result<Option<i64>> {
328    let Some(mut next) = next_due_after(trigger, fired_at_ms)? else {
329        return Ok(None);
330    };
331    if next > now_ms {
332        return Ok(Some(next));
333    }
334    match trigger.catchup {
335        // Replay: leave the schedule where it is and let each stale occurrence
336        // fire in turn.
337        Catchup::All => Ok(Some(next)),
338        // Collapse the backlog into a single firing. Walk forward to the first
339        // occurrence still ahead of us; one firing already happened, and the
340        // others are missed rather than owed.
341        Catchup::Last | Catchup::None => {
342            // Bounded so a long-dormant memory with a fast interval cannot spin:
343            // a year of one-second occurrences would otherwise be 31 million
344            // steps. Past the bound we jump straight off `now`.
345            const MAX_STEPS: u32 = 10_000;
346            let mut steps = 0;
347            while next <= now_ms && steps < MAX_STEPS {
348                match next_due_after(trigger, next)? {
349                    Some(n) if n > next => next = n,
350                    // A schedule that does not advance would loop forever.
351                    _ => break,
352                }
353                steps += 1;
354            }
355            if next <= now_ms {
356                next = next_due_after(trigger, now_ms)?.unwrap_or(next);
357            }
358            Ok(Some(next))
359        }
360    }
361}
362
363/// When a never-evaluated trigger first becomes due.
364///
365/// `None` means "now". `Some(t)` means "not yet — at `t`", and the evaluator
366/// records it so the trigger wakes at the right moment instead of being asked
367/// this question again on every pass.
368///
369/// The split is between two kinds of cadence:
370///
371/// - **Relative** (`Interval`, `Polling`) has no baseline to wait out, so it
372///   fires at once — the loop gate's rule, and what makes a polling trigger
373///   seed its cursor promptly instead of an interval later.
374/// - **Absolute** (`Schedule`, `Once`) waits for the instant it names. A cron of
375///   `0 9 * * *` must not fire at midnight merely because it was just declared.
376/// - **Unclocked** kinds are eligible immediately; what gates them is whether
377///   anything arrived, which the evaluator decides.
378pub fn initial_due(trigger: &Trigger, now_ms: i64) -> Result<Option<i64>> {
379    Ok(match trigger.kind {
380        TriggerKind::Schedule => next_due_after(trigger, now_ms)?,
381        // A one-shot whose instant has already passed fires once, now.
382        TriggerKind::Once => trigger.at_ms.filter(|at| *at > now_ms),
383        TriggerKind::Interval
384        | TriggerKind::Polling
385        | TriggerKind::Memory
386        | TriggerKind::Webhook
387        | TriggerKind::Manual
388        | TriggerKind::Composite => None,
389    })
390}
391
392/// Is this trigger due at `now_ms`?
393///
394/// The complete check: state alone cannot answer it, because an absent
395/// `next_due_at` means "never evaluated" and what that implies depends on the
396/// declaration. See [`initial_due`].
397pub fn is_due(trigger: &Trigger, state: &areev_store::TriggerState, now_ms: i64) -> Result<bool> {
398    if !state.permits_firing(now_ms) {
399        return Ok(false);
400    }
401    if state.next_due_at.is_some() {
402        return Ok(true); // already compared against `now` above
403    }
404    Ok(initial_due(trigger, now_ms)?.is_none())
405}
406
407/// Whether this trigger can never fire again after a firing at `fired_at_ms`.
408///
409/// Only a one-shot exhausts. The other kinds that return no next instant
410/// (`Memory`, `Webhook`, `Manual`, `Composite`) are not clocked at all — they
411/// are eligible on every pass and gated by whether anything arrived — so
412/// treating "no next instant" as exhaustion would silence them permanently.
413pub fn exhausts_after(trigger: &Trigger, fired_at_ms: i64) -> bool {
414    trigger.kind == TriggerKind::Once
415        && next_due_after(trigger, fired_at_ms).ok().flatten().is_none()
416}
417
418/// Back off `next_due_at` after a failure, so a broken connector does not
419/// hot-loop against a source that is down.
420///
421/// Exponential on consecutive failures, capped, and never shorter than the
422/// declared interval — a trigger that fails fast should not end up polling
423/// *more* often than a healthy one.
424pub fn backoff_until(trigger: &Trigger, consecutive_failures: u32, now_ms: i64) -> i64 {
425    const BASE_MS: i64 = 30_000;
426    const CAP_MS: i64 = 3_600_000;
427    let shift = consecutive_failures.saturating_sub(1).min(7);
428    let penalty = (BASE_MS << shift).min(CAP_MS);
429    let floor = trigger.interval_secs.map(|s| i64::from(s) * 1000).unwrap_or(0);
430    now_ms.saturating_add(penalty.max(floor))
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    const WF: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
438    /// 2026-01-01T00:00:00Z
439    const T0: i64 = 1_767_225_600_000;
440
441    fn interval(secs: u32) -> Trigger {
442        Trigger::new(TriggerKind::Interval, WF).interval_secs(secs)
443    }
444    fn schedule(expr: &str) -> Trigger {
445        Trigger::new(TriggerKind::Schedule, WF).cron(expr)
446    }
447
448    #[test]
449    fn interval_anchors_to_the_last_firing() {
450        let t = interval(120);
451        assert_eq!(next_due_after(&t, T0).unwrap(), Some(T0 + 120_000));
452        // …and therefore drifts: firing late pushes the next one out.
453        assert_eq!(next_due_after(&t, T0 + 5_000).unwrap(), Some(T0 + 125_000));
454    }
455
456    #[test]
457    fn cron_anchors_to_wall_clock_boundaries() {
458        let t = schedule("*/5 * * * *");
459        let first = next_due_after(&t, T0).unwrap().unwrap();
460        assert_eq!(first, T0 + 300_000, "next boundary from midnight");
461        // Firing late still lands on the boundary — no drift.
462        let after_late = next_due_after(&t, T0 + 301_000).unwrap().unwrap();
463        assert_eq!(after_late, T0 + 600_000);
464    }
465
466    #[test]
467    fn a_five_field_weekday_expression_parses_with_posix_numbering() {
468        // The trap this guards: the most-downloaded Rust cron crate requires 7
469        // fields with mandatory seconds and Quartz day numbering where 1=Sun and
470        // 0 is rejected, so it reads this expression as the wrong days.
471        let t = schedule("0 9 * * 1-5");
472        let next = next_due_after(&t, T0).unwrap().unwrap();
473        let dt = chrono::DateTime::from_timestamp_millis(next).unwrap();
474        use chrono::{Datelike, Timelike};
475        assert_eq!(dt.hour(), 9);
476        assert_eq!(dt.minute(), 0);
477        let wd = dt.weekday().num_days_from_monday();
478        assert!(wd < 5, "must land Mon-Fri, got {:?}", dt.weekday());
479    }
480
481    #[test]
482    fn a_one_shot_fires_once_and_then_never() {
483        let t = Trigger::new(TriggerKind::Once, WF).at_ms(T0 + 1_000);
484        assert_eq!(next_due_after(&t, T0).unwrap(), Some(T0 + 1_000));
485        assert_eq!(next_due_after(&t, T0 + 1_000).unwrap(), None, "not due at its own instant again");
486        assert_eq!(next_due_after(&t, T0 + 9_999).unwrap(), None);
487    }
488
489    #[test]
490    fn push_and_gate_kinds_are_not_clocked() {
491        for kind in [TriggerKind::Webhook, TriggerKind::Manual, TriggerKind::Memory] {
492            let t = Trigger::new(kind, WF);
493            assert_eq!(next_due_after(&t, T0).unwrap(), None, "{kind:?} should not be clocked");
494        }
495    }
496
497    #[test]
498    fn catchup_last_collapses_a_weekend_of_missed_occurrences() {
499        // Fired Friday, evaluated Monday: a 5-minute schedule missed ~800
500        // occurrences. Exactly one firing is owed, not eight hundred.
501        let t = schedule("*/5 * * * *").catchup(Catchup::Last);
502        let three_days = 3 * 86_400_000;
503        let next = advance_after_firing(&t, T0, T0 + three_days).unwrap().unwrap();
504        assert!(next > T0 + three_days, "must be scheduled ahead of now, not in the backlog");
505        assert!(next <= T0 + three_days + 300_000, "and at the very next boundary");
506    }
507
508    #[test]
509    fn catchup_all_leaves_the_backlog_in_place() {
510        let t = schedule("*/5 * * * *").catchup(Catchup::All);
511        let three_days = 3 * 86_400_000;
512        let next = advance_after_firing(&t, T0, T0 + three_days).unwrap().unwrap();
513        assert_eq!(next, T0 + 300_000, "the stale occurrence stays due, to be replayed");
514    }
515
516    #[test]
517    fn collapsing_is_bounded_for_a_fast_interval_over_a_long_gap() {
518        // A one-second interval dormant for a year is ~31M occurrences. The walk
519        // must not attempt them.
520        let t = interval(1).catchup(Catchup::Last);
521        let a_year = 365 * 86_400_000i64;
522        let started = std::time::Instant::now();
523        let next = advance_after_firing(&t, T0, T0 + a_year).unwrap().unwrap();
524        assert!(next > T0 + a_year);
525        assert!(started.elapsed().as_millis() < 500, "the collapse walk must be bounded");
526    }
527
528    #[test]
529    fn a_firing_that_is_still_ahead_of_now_is_left_alone() {
530        let t = interval(300);
531        let next = advance_after_firing(&t, T0, T0 + 1_000).unwrap().unwrap();
532        assert_eq!(next, T0 + 300_000);
533    }
534
535    #[test]
536    fn backoff_grows_then_caps_and_never_polls_faster_than_the_interval() {
537        let t = interval(600); // 10 minutes
538        let first = backoff_until(&t, 1, T0) - T0;
539        let second = backoff_until(&t, 2, T0) - T0;
540        assert!(second >= first, "backoff must not shrink as failures accumulate");
541
542        // Never more often than the declared interval: a failing trigger must
543        // not end up hammering a source harder than a healthy one.
544        assert!(first >= 600_000, "floor is the declared interval");
545
546        let far = backoff_until(&t, 99, T0) - T0;
547        assert!(far <= 3_600_000, "and it caps rather than growing without bound");
548    }
549
550    #[test]
551    fn an_unparseable_expression_is_refused_with_its_code() {
552        let t = schedule("not a cron expression");
553        let e = validate(&t).unwrap_err();
554        assert_eq!(e.code(), "TRG-E006");
555        assert!(e.to_string().starts_with("TRG-E006"));
556    }
557
558    #[test]
559    fn utc_is_accepted_however_it_is_spelled() {
560        let ok = schedule("0 9 * * *").config(serde_json::json!({ "int:timezone": "utc" }));
561        assert!(validate(&ok).is_ok());
562        let ok = schedule("0 9 * * *").config(serde_json::json!({ "int:timezone": "UTC" }));
563        assert!(validate(&ok).is_ok());
564    }
565
566    #[test]
567    fn an_unknown_zone_name_is_refused_whatever_the_build() {
568        // A typo must never fall back to UTC and fire at the wrong hour while
569        // looking correct.
570        let t = schedule("0 9 * * *")
571            .config(serde_json::json!({ "int:timezone": "Mars/Olympus" }));
572        let e = validate(&t).unwrap_err();
573        assert_eq!(e.code(), "TRG-E006");
574    }
575
576    #[cfg(not(feature = "tz"))]
577    #[test]
578    fn a_build_without_zone_data_refuses_and_names_the_feature() {
579        let t = schedule("0 9 * * *")
580            .config(serde_json::json!({ "int:timezone": "America/New_York" }));
581        let e = validate(&t).unwrap_err();
582        assert_eq!(e.code(), "TRG-E006");
583        assert!(e.to_string().contains("`tz` feature"), "{e}");
584    }
585
586    #[cfg(feature = "tz")]
587    mod tz_tests {
588        use super::*;
589
590        fn zoned(expr: &str, tz: &str) -> Trigger {
591            schedule(expr).config(serde_json::json!({ "int:timezone": tz }))
592        }
593
594        fn ms(iso: &str) -> i64 {
595            areev_core::time::iso8601_to_ms(iso).unwrap()
596        }
597
598        #[test]
599        fn a_declared_zone_validates() {
600            assert!(validate(&zoned("0 8 * * 1", "America/New_York")).is_ok());
601            assert!(validate(&zoned("0 9 * * *", "Asia/Kolkata")).is_ok());
602        }
603
604        #[test]
605        fn a_weekly_local_time_tracks_the_offset_across_the_dst_boundary() {
606            // 08:00 New York is 12:00Z in EDT and 13:00Z in EST. Declaring it
607            // in UTC means being an hour wrong for half the year — the thing
608            // a zone declaration exists to prevent.
609            let t = zoned("0 8 * * 1", "America/New_York");
610            let first = next_due_after(&t, ms("2026-10-19T12:00:01Z")).unwrap().unwrap();
611            assert_eq!(first, ms("2026-10-26T12:00:00Z"), "still EDT");
612            let second = next_due_after(&t, first).unwrap().unwrap();
613            assert_eq!(second, ms("2026-11-02T13:00:00Z"), "EST after the change");
614        }
615
616        #[test]
617        fn a_time_skipped_by_spring_forward_fires_once_at_the_next_valid_minute() {
618            // 02:30 does not exist on 2026-03-08 in New York: the clock jumps
619            // 02:00 EST → 03:00 EDT. Skipping the occurrence entirely (what
620            // EventBridge and robfig/cron do) is a silent missed obligation
621            // once a year, so Areev fires it LATE rather than not at all —
622            // once, at the first valid instant after the gap.
623            let t = zoned("30 2 * * *", "America/New_York");
624            let gap_day = next_due_after(&t, ms("2026-03-07T12:00:00Z")).unwrap().unwrap();
625            assert_eq!(
626                gap_day,
627                ms("2026-03-08T07:00:00Z"),
628                "03:00 EDT — the first valid instant after the gap"
629            );
630            // Exactly one firing for that day: the next is the following day
631            // at 02:30 EDT (06:30Z), not a second pass at the gap.
632            let next = next_due_after(&t, gap_day).unwrap().unwrap();
633            assert_eq!(next, ms("2026-03-09T06:30:00Z"));
634        }
635
636        #[test]
637        fn an_ordinary_day_fires_at_the_declared_local_time() {
638            // The control for the gap case: 02:30 EST is 07:30Z.
639            let t = zoned("30 2 * * *", "America/New_York");
640            let fired = next_due_after(&t, ms("2026-03-06T12:00:00Z")).unwrap().unwrap();
641            assert_eq!(fired, ms("2026-03-07T07:30:00Z"));
642        }
643
644        #[test]
645        fn a_repeated_time_fires_once_at_the_earlier_occurrence() {
646            // 01:30 happens twice on 2026-11-01 in New York (05:30Z EDT and
647            // 06:30Z EST). Firing twice would double-run a daily job.
648            let t = zoned("30 1 * * *", "America/New_York");
649            let first = next_due_after(&t, ms("2026-11-01T00:00:00Z")).unwrap().unwrap();
650            assert_eq!(first, ms("2026-11-01T05:30:00Z"), "the earlier one");
651            let next = next_due_after(&t, first).unwrap().unwrap();
652            assert_eq!(
653                next,
654                ms("2026-11-02T06:30:00Z"),
655                "the next DAY, not the second pass of the repeated hour"
656            );
657        }
658
659        #[test]
660        fn every_answer_is_strictly_after_the_question_across_the_fold() {
661            // The hazard: inside the repeated hour the earlier instant wins,
662            // so a naive local search can answer at or before `after_ms` —
663            // and `advance_after_firing`'s `n > next` guard would then break
664            // out of its loop and leave the schedule stuck.
665            let t = zoned("30 1 * * *", "America/New_York");
666            let start = ms("2026-11-01T05:00:00Z");
667            let end = ms("2026-11-01T07:00:00Z");
668            let mut at = start;
669            while at < end {
670                let got = next_due_after(&t, at).unwrap().unwrap();
671                assert!(got > at, "answer {got} must be strictly after {at}");
672                at += 60_000;
673            }
674        }
675
676        #[test]
677        fn catchup_last_across_a_dst_boundary_collapses_to_one_firing() {
678            let mut t = zoned("30 1 * * *", "America/New_York");
679            t.catchup = Catchup::Last;
680            let fired = ms("2026-10-30T05:30:00Z");
681            let now = ms("2026-11-03T12:00:00Z");
682            let next = advance_after_firing(&t, fired, now).unwrap().unwrap();
683            assert!(next > now, "one firing ahead, not a backlog: {next}");
684        }
685
686        #[test]
687        fn an_absent_or_utc_zone_is_unchanged() {
688            let utc = schedule("0 9 * * *");
689            let zoned_utc = zoned("0 9 * * *", "UTC");
690            let at = ms("2026-03-08T00:00:00Z");
691            assert_eq!(
692                next_due_after(&utc, at).unwrap(),
693                next_due_after(&zoned_utc, at).unwrap()
694            );
695        }
696    }
697
698    #[test]
699    fn validate_rejects_an_incoherent_declaration_before_the_schedule() {
700        let t = Trigger::new(TriggerKind::Interval, WF); // no interval_secs
701        let e = validate(&t).unwrap_err();
702        assert_eq!(e.code(), "TRG-E001");
703    }
704}