Skip to main content

bamboo_server/schedule_app/
trigger_engine.rs

1use std::sync::Arc;
2
3use chrono::{
4    DateTime, Datelike, Days, Duration, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone,
5    Utc, Weekday,
6};
7use chrono_tz::Tz;
8use cron::Schedule as CronSchedule;
9use thiserror::Error;
10
11use bamboo_domain::{ScheduleTrigger, ScheduleWeekday, ScheduleWindow};
12
13pub type DynTriggerEngine = Arc<dyn TriggerEngine>;
14
15/// Interface for computing schedule occurrences.
16///
17/// Bamboo owns schedule state, policies, and execution. Concrete trigger engines
18/// only answer recurrence questions and can be swapped independently.
19pub trait TriggerEngine: Send + Sync {
20    fn kind(&self) -> TriggerEngineKind;
21
22    fn next_after(
23        &self,
24        trigger: &ScheduleTrigger,
25        timezone: Option<&str>,
26        after: DateTime<Utc>,
27        window: &ScheduleWindow,
28    ) -> Result<Option<DateTime<Utc>>, TriggerComputationError>;
29
30    fn due_between(
31        &self,
32        trigger: &ScheduleTrigger,
33        timezone: Option<&str>,
34        from: DateTime<Utc>,
35        to: DateTime<Utc>,
36        window: &ScheduleWindow,
37        limit: usize,
38    ) -> Result<Vec<DateTime<Utc>>, TriggerComputationError> {
39        if limit == 0 {
40            return Ok(Vec::new());
41        }
42
43        let mut current = from;
44        let mut out = Vec::new();
45        while out.len() < limit {
46            let Some(next) = self.next_after(trigger, timezone, current, window)? else {
47                break;
48            };
49            if next > to {
50                break;
51            }
52            out.push(next);
53            current = next;
54        }
55        Ok(out)
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum TriggerEngineKind {
61    Native,
62    CronBacked,
63    RRuleBacked,
64}
65
66#[derive(Debug, Error, Clone, PartialEq, Eq)]
67pub enum TriggerComputationError {
68    #[error("unsupported trigger kind for engine: {0}")]
69    UnsupportedTrigger(&'static str),
70    #[error("invalid trigger: {0}")]
71    InvalidTrigger(String),
72    #[error("timezone support is not available for engine kind {0:?}")]
73    UnsupportedTimezone(TriggerEngineKind),
74}
75
76/// Minimal built-in trigger engine used as the default interface implementation.
77///
78/// It owns Bamboo's native recurrence logic for interval/once/daily/weekly/monthly.
79#[derive(Debug, Default)]
80pub struct NativeTriggerEngine;
81
82#[derive(Debug, Default)]
83pub struct CronBackedTriggerEngine;
84
85#[derive(Debug, Default)]
86pub struct CompositeTriggerEngine {
87    native: NativeTriggerEngine,
88    cron: CronBackedTriggerEngine,
89}
90
91impl NativeTriggerEngine {
92    pub fn new() -> Self {
93        Self
94    }
95}
96
97impl CronBackedTriggerEngine {
98    pub fn new() -> Self {
99        Self
100    }
101}
102
103impl CompositeTriggerEngine {
104    pub fn new() -> Self {
105        Self {
106            native: NativeTriggerEngine::new(),
107            cron: CronBackedTriggerEngine::new(),
108        }
109    }
110}
111
112impl TriggerEngine for NativeTriggerEngine {
113    fn kind(&self) -> TriggerEngineKind {
114        TriggerEngineKind::Native
115    }
116
117    fn next_after(
118        &self,
119        trigger: &ScheduleTrigger,
120        timezone: Option<&str>,
121        after: DateTime<Utc>,
122        window: &ScheduleWindow,
123    ) -> Result<Option<DateTime<Utc>>, TriggerComputationError> {
124        let next = match trigger {
125            ScheduleTrigger::Interval {
126                every_seconds,
127                anchor_at,
128            } => {
129                if timezone.is_some() {
130                    return Err(TriggerComputationError::UnsupportedTimezone(self.kind()));
131                }
132                compute_interval_next(*every_seconds, *anchor_at, after)?
133            }
134            ScheduleTrigger::Once { at } => {
135                // `at` is already an absolute UTC instant, so a timezone is
136                // meaningless here — reject it, mirroring Interval's policy.
137                if timezone.is_some() {
138                    return Err(TriggerComputationError::UnsupportedTimezone(self.kind()));
139                }
140                if *at > after {
141                    Some(*at)
142                } else {
143                    None
144                }
145            }
146            ScheduleTrigger::Daily {
147                hour,
148                minute,
149                second,
150            } => compute_daily_next(*hour, *minute, *second, timezone, after)?,
151            ScheduleTrigger::Weekly {
152                weekdays,
153                hour,
154                minute,
155                second,
156            } => compute_weekly_next(weekdays, *hour, *minute, *second, timezone, after)?,
157            ScheduleTrigger::Monthly {
158                days,
159                hour,
160                minute,
161                second,
162            } => compute_monthly_next(days, *hour, *minute, *second, timezone, after)?,
163            ScheduleTrigger::Cron { .. } => {
164                return Err(TriggerComputationError::UnsupportedTrigger("cron"));
165            }
166        };
167
168        Ok(apply_window(next, window))
169    }
170}
171
172fn parse_timezone(timezone: Option<&str>) -> Result<Tz, TriggerComputationError> {
173    match timezone {
174        Some(value) => value.parse::<Tz>().map_err(|_| {
175            TriggerComputationError::InvalidTrigger(format!("invalid timezone: {value}"))
176        }),
177        None => Ok(chrono_tz::UTC),
178    }
179}
180
181fn resolve_local_datetime(
182    timezone: Tz,
183    local: NaiveDateTime,
184) -> Result<DateTime<Utc>, TriggerComputationError> {
185    match timezone.from_local_datetime(&local) {
186        LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)),
187        LocalResult::Ambiguous(first, second) => Ok(first.min(second).with_timezone(&Utc)),
188        LocalResult::None => Err(TriggerComputationError::InvalidTrigger(format!(
189            "local datetime does not exist in timezone {}: {}",
190            timezone, local
191        ))),
192    }
193}
194
195fn local_candidate_utc(
196    timezone: Tz,
197    date: NaiveDate,
198    hour: u8,
199    minute: u8,
200    second: u8,
201) -> Result<DateTime<Utc>, TriggerComputationError> {
202    let time =
203        NaiveTime::from_hms_opt(hour as u32, minute as u32, second as u32).ok_or_else(|| {
204            TriggerComputationError::InvalidTrigger(format!(
205                "invalid time components: {:02}:{:02}:{:02}",
206                hour, minute, second
207            ))
208        })?;
209    resolve_local_datetime(timezone, NaiveDateTime::new(date, time))
210}
211
212fn compute_daily_next(
213    hour: u8,
214    minute: u8,
215    second: u8,
216    timezone: Option<&str>,
217    after: DateTime<Utc>,
218) -> Result<Option<DateTime<Utc>>, TriggerComputationError> {
219    let timezone = parse_timezone(timezone)?;
220    let after_local = after.with_timezone(&timezone);
221    let mut date = after_local.date_naive();
222
223    for _ in 0..370 {
224        let candidate = local_candidate_utc(timezone, date, hour, minute, second)?;
225        if candidate > after {
226            return Ok(Some(candidate));
227        }
228        date = date.checked_add_days(Days::new(1)).ok_or_else(|| {
229            TriggerComputationError::InvalidTrigger(
230                "daily schedule overflowed date range".to_string(),
231            )
232        })?;
233    }
234
235    Err(TriggerComputationError::InvalidTrigger(
236        "daily schedule failed to produce next occurrence".to_string(),
237    ))
238}
239
240fn to_chrono_weekday(weekday: ScheduleWeekday) -> Weekday {
241    match weekday {
242        ScheduleWeekday::Mon => Weekday::Mon,
243        ScheduleWeekday::Tue => Weekday::Tue,
244        ScheduleWeekday::Wed => Weekday::Wed,
245        ScheduleWeekday::Thu => Weekday::Thu,
246        ScheduleWeekday::Fri => Weekday::Fri,
247        ScheduleWeekday::Sat => Weekday::Sat,
248        ScheduleWeekday::Sun => Weekday::Sun,
249    }
250}
251
252fn compute_weekly_next(
253    weekdays: &[ScheduleWeekday],
254    hour: u8,
255    minute: u8,
256    second: u8,
257    timezone: Option<&str>,
258    after: DateTime<Utc>,
259) -> Result<Option<DateTime<Utc>>, TriggerComputationError> {
260    if weekdays.is_empty() {
261        return Err(TriggerComputationError::InvalidTrigger(
262            "weekly.weekdays must not be empty".to_string(),
263        ));
264    }
265
266    let timezone = parse_timezone(timezone)?;
267    let after_local = after.with_timezone(&timezone);
268    let mut date = after_local.date_naive();
269    let allowed: Vec<Weekday> = weekdays.iter().copied().map(to_chrono_weekday).collect();
270
271    for _ in 0..370 {
272        if allowed.contains(&date.weekday()) {
273            let candidate = local_candidate_utc(timezone, date, hour, minute, second)?;
274            if candidate > after {
275                return Ok(Some(candidate));
276            }
277        }
278        date = date.checked_add_days(Days::new(1)).ok_or_else(|| {
279            TriggerComputationError::InvalidTrigger(
280                "weekly schedule overflowed date range".to_string(),
281            )
282        })?;
283    }
284
285    Err(TriggerComputationError::InvalidTrigger(
286        "weekly schedule failed to produce next occurrence".to_string(),
287    ))
288}
289
290fn days_in_month(year: i32, month: u32) -> Result<u32, TriggerComputationError> {
291    let first = NaiveDate::from_ymd_opt(year, month, 1).ok_or_else(|| {
292        TriggerComputationError::InvalidTrigger(format!("invalid year-month: {year}-{month}"))
293    })?;
294    let next_month = if month == 12 {
295        NaiveDate::from_ymd_opt(year + 1, 1, 1)
296    } else {
297        NaiveDate::from_ymd_opt(year, month + 1, 1)
298    }
299    .ok_or_else(|| {
300        TriggerComputationError::InvalidTrigger(format!(
301            "invalid next year-month from {year}-{month}"
302        ))
303    })?;
304    Ok(next_month.signed_duration_since(first).num_days() as u32)
305}
306
307fn compute_monthly_next(
308    days: &[u8],
309    hour: u8,
310    minute: u8,
311    second: u8,
312    timezone: Option<&str>,
313    after: DateTime<Utc>,
314) -> Result<Option<DateTime<Utc>>, TriggerComputationError> {
315    if days.is_empty() {
316        return Err(TriggerComputationError::InvalidTrigger(
317            "monthly.days must not be empty".to_string(),
318        ));
319    }
320    if days.iter().any(|day| *day == 0 || *day > 31) {
321        return Err(TriggerComputationError::InvalidTrigger(
322            "monthly.days values must be between 1 and 31".to_string(),
323        ));
324    }
325
326    let timezone = parse_timezone(timezone)?;
327    let after_local = after.with_timezone(&timezone);
328    let mut year = after_local.year();
329    let mut month = after_local.month();
330    let mut sorted_days: Vec<u32> = days.iter().copied().map(u32::from).collect();
331    sorted_days.sort_unstable();
332    sorted_days.dedup();
333
334    for _ in 0..1200 {
335        let max_day = days_in_month(year, month)?;
336        for day in &sorted_days {
337            if *day > max_day {
338                continue;
339            }
340            let date = NaiveDate::from_ymd_opt(year, month, *day).ok_or_else(|| {
341                TriggerComputationError::InvalidTrigger(format!(
342                    "invalid monthly occurrence date: {year}-{month}-{day}"
343                ))
344            })?;
345            let candidate = local_candidate_utc(timezone, date, hour, minute, second)?;
346            if candidate > after {
347                return Ok(Some(candidate));
348            }
349        }
350
351        if month == 12 {
352            year += 1;
353            month = 1;
354        } else {
355            month += 1;
356        }
357    }
358
359    Err(TriggerComputationError::InvalidTrigger(
360        "monthly schedule failed to produce next occurrence".to_string(),
361    ))
362}
363
364fn compute_interval_next(
365    every_seconds: u64,
366    anchor_at: Option<DateTime<Utc>>,
367    after: DateTime<Utc>,
368) -> Result<Option<DateTime<Utc>>, TriggerComputationError> {
369    if every_seconds == 0 {
370        return Err(TriggerComputationError::InvalidTrigger(
371            "interval.every_seconds must be > 0".to_string(),
372        ));
373    }
374
375    let duration = Duration::seconds(every_seconds as i64);
376    let next = match anchor_at {
377        Some(anchor) if anchor > after => anchor,
378        Some(anchor) => {
379            let elapsed = after.signed_duration_since(anchor).num_seconds();
380            let step = every_seconds as i64;
381            let intervals_elapsed = elapsed.div_euclid(step) + 1;
382            anchor + Duration::seconds(intervals_elapsed * step)
383        }
384        None => after + duration,
385    };
386
387    Ok(Some(next))
388}
389
390fn apply_window(
391    candidate: Option<DateTime<Utc>>,
392    window: &ScheduleWindow,
393) -> Option<DateTime<Utc>> {
394    let candidate = candidate?;
395    if let Some(start_at) = window.start_at {
396        if candidate < start_at {
397            return Some(start_at);
398        }
399    }
400    if let Some(end_at) = window.end_at {
401        if candidate > end_at {
402            return None;
403        }
404    }
405    Some(candidate)
406}
407
408impl TriggerEngine for CronBackedTriggerEngine {
409    fn kind(&self) -> TriggerEngineKind {
410        TriggerEngineKind::CronBacked
411    }
412
413    fn next_after(
414        &self,
415        trigger: &ScheduleTrigger,
416        timezone: Option<&str>,
417        after: DateTime<Utc>,
418        window: &ScheduleWindow,
419    ) -> Result<Option<DateTime<Utc>>, TriggerComputationError> {
420        let ScheduleTrigger::Cron { expr } = trigger else {
421            return Err(TriggerComputationError::UnsupportedTrigger(
422                trigger.kind_name(),
423            ));
424        };
425
426        let schedule = expr.parse::<CronSchedule>().map_err(|error| {
427            TriggerComputationError::InvalidTrigger(format!(
428                "invalid cron expression '{}': {}",
429                expr, error
430            ))
431        })?;
432
433        let timezone = parse_timezone(timezone)?;
434        let after_local = after.with_timezone(&timezone);
435        let next_local = schedule.after(&after_local).next();
436        Ok(apply_window(
437            next_local.map(|candidate| candidate.with_timezone(&Utc)),
438            window,
439        ))
440    }
441}
442
443impl TriggerEngine for CompositeTriggerEngine {
444    fn kind(&self) -> TriggerEngineKind {
445        TriggerEngineKind::CronBacked
446    }
447
448    fn next_after(
449        &self,
450        trigger: &ScheduleTrigger,
451        timezone: Option<&str>,
452        after: DateTime<Utc>,
453        window: &ScheduleWindow,
454    ) -> Result<Option<DateTime<Utc>>, TriggerComputationError> {
455        match trigger {
456            ScheduleTrigger::Cron { .. } => self.cron.next_after(trigger, timezone, after, window),
457            _ => self.native.next_after(trigger, timezone, after, window),
458        }
459    }
460}
461
462pub fn default_trigger_engine() -> DynTriggerEngine {
463    Arc::new(CompositeTriggerEngine::new())
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use bamboo_domain::{ScheduleTrigger, ScheduleWeekday};
470
471    #[test]
472    fn interval_next_uses_anchor_alignment() {
473        let engine = NativeTriggerEngine::new();
474        let anchor = DateTime::parse_from_rfc3339("2026-04-04T00:00:00Z")
475            .unwrap()
476            .with_timezone(&Utc);
477        let after = DateTime::parse_from_rfc3339("2026-04-04T00:01:01Z")
478            .unwrap()
479            .with_timezone(&Utc);
480        let next = engine
481            .next_after(
482                &ScheduleTrigger::legacy_interval(60, Some(anchor)),
483                None,
484                after,
485                &ScheduleWindow::default(),
486            )
487            .unwrap();
488        assert_eq!(next, Some(anchor + Duration::seconds(120)));
489    }
490
491    #[test]
492    fn due_between_returns_bounded_sequence() {
493        let engine = NativeTriggerEngine::new();
494        let anchor = DateTime::parse_from_rfc3339("2026-04-04T00:00:00Z")
495            .unwrap()
496            .with_timezone(&Utc);
497        let from = anchor;
498        let to = anchor + Duration::seconds(181);
499        let due = engine
500            .due_between(
501                &ScheduleTrigger::legacy_interval(60, Some(anchor)),
502                None,
503                from,
504                to,
505                &ScheduleWindow::default(),
506                10,
507            )
508            .unwrap();
509        assert_eq!(due.len(), 3);
510        assert_eq!(due[0], anchor + Duration::seconds(60));
511        assert_eq!(due[1], anchor + Duration::seconds(120));
512        assert_eq!(due[2], anchor + Duration::seconds(180));
513    }
514
515    #[test]
516    fn once_next_fires_exactly_once() {
517        let engine = NativeTriggerEngine::new();
518        let at = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
519            .unwrap()
520            .with_timezone(&Utc);
521
522        // Before `at`: the single occurrence is still ahead.
523        let before = at - Duration::seconds(60);
524        let next = engine
525            .next_after(
526                &ScheduleTrigger::Once { at },
527                None,
528                before,
529                &ScheduleWindow::default(),
530            )
531            .unwrap();
532        assert_eq!(next, Some(at));
533
534        // Exactly at `at` (the claim advances past it): no further occurrence.
535        let next = engine
536            .next_after(
537                &ScheduleTrigger::Once { at },
538                None,
539                at,
540                &ScheduleWindow::default(),
541            )
542            .unwrap();
543        assert_eq!(next, None);
544
545        // After `at`: still no further occurrence.
546        let next = engine
547            .next_after(
548                &ScheduleTrigger::Once { at },
549                None,
550                at + Duration::seconds(60),
551                &ScheduleWindow::default(),
552            )
553            .unwrap();
554        assert_eq!(next, None);
555    }
556
557    #[test]
558    fn once_next_rejects_timezone_like_interval() {
559        let engine = NativeTriggerEngine::new();
560        let at = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
561            .unwrap()
562            .with_timezone(&Utc);
563        let error = engine
564            .next_after(
565                &ScheduleTrigger::Once { at },
566                Some("Asia/Shanghai"),
567                at - Duration::seconds(60),
568                &ScheduleWindow::default(),
569            )
570            .unwrap_err();
571        assert_eq!(
572            error,
573            TriggerComputationError::UnsupportedTimezone(TriggerEngineKind::Native)
574        );
575    }
576
577    #[test]
578    fn composite_routes_once_to_native_engine() {
579        let engine = CompositeTriggerEngine::new();
580        let at = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
581            .unwrap()
582            .with_timezone(&Utc);
583        let next = engine
584            .next_after(
585                &ScheduleTrigger::Once { at },
586                None,
587                at - Duration::seconds(60),
588                &ScheduleWindow::default(),
589            )
590            .unwrap();
591        assert_eq!(next, Some(at));
592    }
593
594    #[test]
595    fn daily_next_works_in_utc() {
596        let engine = NativeTriggerEngine::new();
597        let after = DateTime::parse_from_rfc3339("2026-04-04T09:30:00Z")
598            .unwrap()
599            .with_timezone(&Utc);
600        let next = engine
601            .next_after(
602                &ScheduleTrigger::Daily {
603                    hour: 10,
604                    minute: 0,
605                    second: 0,
606                },
607                None,
608                after,
609                &ScheduleWindow::default(),
610            )
611            .unwrap();
612        assert_eq!(
613            next,
614            Some(
615                DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
616                    .unwrap()
617                    .with_timezone(&Utc)
618            )
619        );
620    }
621
622    #[test]
623    fn weekly_next_works_in_utc() {
624        let engine = NativeTriggerEngine::new();
625        let after = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
626            .unwrap()
627            .with_timezone(&Utc);
628        let next = engine
629            .next_after(
630                &ScheduleTrigger::Weekly {
631                    weekdays: vec![ScheduleWeekday::Mon],
632                    hour: 9,
633                    minute: 0,
634                    second: 0,
635                },
636                None,
637                after,
638                &ScheduleWindow::default(),
639            )
640            .unwrap();
641        assert_eq!(
642            next,
643            Some(
644                DateTime::parse_from_rfc3339("2026-04-06T09:00:00Z")
645                    .unwrap()
646                    .with_timezone(&Utc)
647            )
648        );
649    }
650
651    #[test]
652    fn daily_next_respects_timezone() {
653        let engine = NativeTriggerEngine::new();
654        let after = DateTime::parse_from_rfc3339("2026-04-04T00:30:00Z")
655            .unwrap()
656            .with_timezone(&Utc);
657        let next = engine
658            .next_after(
659                &ScheduleTrigger::Daily {
660                    hour: 9,
661                    minute: 0,
662                    second: 0,
663                },
664                Some("Asia/Shanghai"),
665                after,
666                &ScheduleWindow::default(),
667            )
668            .unwrap();
669        assert_eq!(
670            next,
671            Some(
672                DateTime::parse_from_rfc3339("2026-04-04T01:00:00Z")
673                    .unwrap()
674                    .with_timezone(&Utc)
675            )
676        );
677    }
678
679    #[test]
680    fn monthly_next_uses_same_month_when_valid_day_remains() {
681        let engine = NativeTriggerEngine::new();
682        let after = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
683            .unwrap()
684            .with_timezone(&Utc);
685        let next = engine
686            .next_after(
687                &ScheduleTrigger::Monthly {
688                    days: vec![10, 20],
689                    hour: 9,
690                    minute: 0,
691                    second: 0,
692                },
693                None,
694                after,
695                &ScheduleWindow::default(),
696            )
697            .unwrap();
698        assert_eq!(
699            next,
700            Some(
701                DateTime::parse_from_rfc3339("2026-04-10T09:00:00Z")
702                    .unwrap()
703                    .with_timezone(&Utc)
704            )
705        );
706    }
707
708    #[test]
709    fn monthly_next_skips_short_month_for_missing_day() {
710        let engine = NativeTriggerEngine::new();
711        let after = DateTime::parse_from_rfc3339("2026-04-30T23:00:00Z")
712            .unwrap()
713            .with_timezone(&Utc);
714        let next = engine
715            .next_after(
716                &ScheduleTrigger::Monthly {
717                    days: vec![31],
718                    hour: 9,
719                    minute: 0,
720                    second: 0,
721                },
722                None,
723                after,
724                &ScheduleWindow::default(),
725            )
726            .unwrap();
727        assert_eq!(
728            next,
729            Some(
730                DateTime::parse_from_rfc3339("2026-05-31T09:00:00Z")
731                    .unwrap()
732                    .with_timezone(&Utc)
733            )
734        );
735    }
736
737    #[test]
738    fn monthly_next_respects_timezone() {
739        let engine = NativeTriggerEngine::new();
740        let after = DateTime::parse_from_rfc3339("2026-04-30T15:30:00Z")
741            .unwrap()
742            .with_timezone(&Utc);
743        let next = engine
744            .next_after(
745                &ScheduleTrigger::Monthly {
746                    days: vec![1],
747                    hour: 9,
748                    minute: 0,
749                    second: 0,
750                },
751                Some("Asia/Shanghai"),
752                after,
753                &ScheduleWindow::default(),
754            )
755            .unwrap();
756        assert_eq!(
757            next,
758            Some(
759                DateTime::parse_from_rfc3339("2026-05-01T01:00:00Z")
760                    .unwrap()
761                    .with_timezone(&Utc)
762            )
763        );
764    }
765
766    #[test]
767    fn cron_backed_next_works_in_utc() {
768        let engine = CronBackedTriggerEngine::new();
769        let after = DateTime::parse_from_rfc3339("2026-04-04T10:15:00Z")
770            .unwrap()
771            .with_timezone(&Utc);
772        let next = engine
773            .next_after(
774                &ScheduleTrigger::Cron {
775                    expr: "0 */30 * * * * *".to_string(),
776                },
777                Some("UTC"),
778                after,
779                &ScheduleWindow::default(),
780            )
781            .unwrap();
782        assert_eq!(
783            next,
784            Some(
785                DateTime::parse_from_rfc3339("2026-04-04T10:30:00Z")
786                    .unwrap()
787                    .with_timezone(&Utc)
788            )
789        );
790    }
791
792    #[test]
793    fn cron_backed_next_respects_timezone() {
794        let engine = CronBackedTriggerEngine::new();
795        let after = DateTime::parse_from_rfc3339("2026-04-04T00:30:00Z")
796            .unwrap()
797            .with_timezone(&Utc);
798        let next = engine
799            .next_after(
800                &ScheduleTrigger::Cron {
801                    expr: "0 0 9 * * * *".to_string(),
802                },
803                Some("Asia/Shanghai"),
804                after,
805                &ScheduleWindow::default(),
806            )
807            .unwrap();
808        assert_eq!(
809            next,
810            Some(
811                DateTime::parse_from_rfc3339("2026-04-04T01:00:00Z")
812                    .unwrap()
813                    .with_timezone(&Utc)
814            )
815        );
816    }
817}