#![cfg(feature = "cron")]
use crate::config::ScheduleError;
use crate::schedule::Schedule;
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
impl Schedule {
#[must_use]
pub fn with_timezone(self, tz: Tz) -> Self {
match self {
Schedule::Crontab {
minute,
hour,
day_of_week,
day_of_month,
month_of_year,
..
} => Schedule::Crontab {
minute,
hour,
day_of_week,
day_of_month,
month_of_year,
timezone: Some(tz.name().to_string()),
},
other => other,
}
}
pub fn timezone_tz(&self) -> Option<Tz> {
match self {
Schedule::Crontab {
timezone: Some(name),
..
} => name.parse::<Tz>().ok(),
_ => None,
}
}
pub fn timezone_name(&self) -> Option<&str> {
match self {
Schedule::Crontab {
timezone: Some(name),
..
} => Some(name.as_str()),
_ => None,
}
}
pub fn has_timezone(&self) -> bool {
self.timezone_name().is_some()
}
pub fn next_run_in_tz(
&self,
last_run: Option<DateTime<Utc>>,
tz: Tz,
) -> Result<DateTime<Utc>, ScheduleError> {
match self {
Schedule::Crontab { .. } => self.clone().with_timezone(tz).next_run(last_run),
_ => self.next_run(last_run),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, TimeZone, Timelike, Weekday};
use chrono_tz::America::New_York;
use chrono_tz::Asia::Tokyo;
use chrono_tz::Europe::London;
use chrono_tz::UTC as TZ_UTC;
#[test]
fn with_timezone_records_iana_name_on_crontab() {
let schedule = Schedule::crontab("0", "9", "1-5", "*", "*").with_timezone(New_York);
assert_eq!(schedule.timezone_name(), Some("America/New_York"));
assert_eq!(schedule.timezone_tz(), Some(New_York));
assert!(schedule.has_timezone());
}
#[test]
fn with_timezone_is_noop_for_interval() {
let schedule = Schedule::interval(60).with_timezone(New_York);
assert!(!schedule.has_timezone());
assert_eq!(schedule.timezone_tz(), None);
let base = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let next = schedule.next_run(Some(base)).unwrap();
assert_eq!(next, Utc.with_ymd_and_hms(2026, 1, 1, 0, 1, 0).unwrap());
}
#[test]
fn with_timezone_is_noop_for_onetime() {
let run_at = Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
let schedule = Schedule::onetime(run_at).with_timezone(Tokyo);
assert!(!schedule.has_timezone());
assert_eq!(schedule.next_run(None).unwrap(), run_at);
}
#[test]
fn utc_default_unchanged_when_no_timezone() {
let schedule = Schedule::crontab("0", "9", "*", "*", "*");
let start = Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap();
let via_utc = schedule.next_run(Some(start)).unwrap();
assert_eq!(via_utc, Utc.with_ymd_and_hms(2026, 6, 1, 9, 0, 0).unwrap());
let via_tz = schedule.next_run_in_tz(Some(start), TZ_UTC).unwrap();
assert_eq!(via_tz, via_utc);
}
#[test]
fn tokyo_nine_am_converts_to_utc_midnight() {
let schedule = Schedule::crontab("0", "9", "*", "*", "*").with_timezone(Tokyo);
let start = Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap();
let next = schedule.next_run(Some(start)).unwrap();
let local = next.with_timezone(&Tokyo);
assert_eq!(local.hour(), 9);
assert_eq!(local.minute(), 0);
}
#[test]
fn new_york_nine_am_summer_is_utc_offset_minus_four() {
let schedule = Schedule::crontab("0", "9", "*", "*", "*").with_timezone(New_York);
let start = Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap();
let next = schedule.next_run(Some(start)).unwrap();
let local = next.with_timezone(&New_York);
assert_eq!(local.hour(), 9);
assert_eq!(next.hour(), 13, "EDT is UTC-4, so 09:00 local is 13:00 UTC");
}
#[test]
fn new_york_nine_am_winter_is_utc_offset_minus_five() {
let schedule = Schedule::crontab("0", "9", "*", "*", "*").with_timezone(New_York);
let start = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let next = schedule.next_run(Some(start)).unwrap();
let local = next.with_timezone(&New_York);
assert_eq!(local.hour(), 9);
assert_eq!(next.hour(), 14, "EST is UTC-5, so 09:00 local is 14:00 UTC");
}
#[test]
fn spring_forward_skips_nonexistent_local_time() {
let schedule = Schedule::crontab("30", "2", "*", "*", "*").with_timezone(New_York);
let start = New_York
.with_ymd_and_hms(2026, 3, 8, 1, 0, 0)
.unwrap()
.with_timezone(&Utc);
let next = schedule.next_run(Some(start)).unwrap();
let local = next.with_timezone(&New_York);
assert_ne!(
local.day(),
8,
"02:30 on the spring-forward day does not exist and must be skipped"
);
assert_eq!(local.day(), 9, "should fire the following day");
assert_eq!(local.hour(), 2);
assert_eq!(local.minute(), 30);
assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 9, 6, 30, 0).unwrap());
}
#[test]
fn fall_back_overlap_resolves_deterministically() {
let schedule = Schedule::crontab("30", "1", "*", "*", "*").with_timezone(New_York);
let start = New_York
.with_ymd_and_hms(2026, 10, 31, 12, 0, 0)
.unwrap()
.with_timezone(&Utc);
let first = schedule.next_run(Some(start)).unwrap();
let first_local = first.with_timezone(&New_York);
assert_eq!(first_local.hour(), 1);
assert_eq!(first_local.minute(), 30);
let second = schedule.next_run(Some(first)).unwrap();
assert!(
second > first,
"evaluation must always advance past last_run"
);
}
#[test]
fn weekday_filter_respects_local_calendar() {
let schedule = Schedule::crontab("0", "9", "1-5", "*", "*").with_timezone(New_York);
let mut at = Utc.with_ymd_and_hms(2026, 3, 6, 23, 0, 0).unwrap(); for _ in 0..5 {
let next = schedule.next_run(Some(at)).unwrap();
let wd = next.with_timezone(&New_York).weekday();
assert!(
matches!(
wd,
Weekday::Mon | Weekday::Tue | Weekday::Wed | Weekday::Thu | Weekday::Fri
),
"timezone-aware weekday cron fired on local {wd:?}"
);
at = next;
}
}
#[test]
fn next_run_in_tz_matches_with_timezone() {
let schedule = Schedule::crontab("0", "9", "*", "*", "*");
let start = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let via_method = schedule.next_run_in_tz(Some(start), London).unwrap();
let via_builder = schedule
.clone()
.with_timezone(London)
.next_run(Some(start))
.unwrap();
assert_eq!(via_method, via_builder);
}
#[test]
fn london_and_new_york_differ() {
let london = Schedule::crontab("0", "12", "*", "*", "*").with_timezone(London);
let ny = Schedule::crontab("0", "12", "*", "*", "*").with_timezone(New_York);
let start = Utc.with_ymd_and_hms(2026, 1, 5, 0, 0, 0).unwrap();
let next_london = london.next_run(Some(start)).unwrap();
let next_ny = ny.next_run(Some(start)).unwrap();
assert_ne!(next_london, next_ny);
}
}