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
15pub 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#[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 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 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 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 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}