Skip to main content

scheduler/model/
time_window.rs

1use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
2use chrono_tz::Tz;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum RunSkipReason {
6    OutsideTimeWindow,
7}
8
9impl RunSkipReason {
10    pub fn as_str(self) -> &'static str {
11        match self {
12            RunSkipReason::OutsideTimeWindow => "outside_time_window",
13        }
14    }
15}
16
17impl std::fmt::Display for RunSkipReason {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        f.write_str(self.as_str())
20    }
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct TimeWindowSegment {
25    pub start: NaiveTime,
26    pub end: NaiveTime,
27}
28
29impl TimeWindowSegment {
30    pub fn new(start: NaiveTime, end: NaiveTime) -> Self {
31        Self { start, end }
32    }
33
34    fn contains_time(&self, time: NaiveTime) -> bool {
35        if self.start <= self.end {
36            time >= self.start && time < self.end
37        } else {
38            time >= self.start || time < self.end
39        }
40    }
41
42    fn effective_weekday(&self, weekday: Weekday, time: NaiveTime) -> Weekday {
43        if self.start <= self.end || time >= self.start {
44            weekday
45        } else {
46            previous_weekday(weekday)
47        }
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct JobTimeWindow {
53    pub timezone: Option<Tz>,
54    pub weekdays: Vec<Weekday>,
55    pub segments: Vec<TimeWindowSegment>,
56}
57
58impl JobTimeWindow {
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    pub fn matches(&self, now: DateTime<Utc>, fallback_timezone: Tz) -> bool {
64        let timezone = self.timezone.unwrap_or(fallback_timezone);
65        let local = now.with_timezone(&timezone);
66        self.matches_local(local)
67    }
68
69    fn matches_local(&self, local: DateTime<Tz>) -> bool {
70        let weekday = local.weekday();
71        let time = local.time();
72
73        if self.segments.is_empty() {
74            return self.weekdays.is_empty() || self.weekdays.contains(&weekday);
75        }
76
77        self.segments.iter().any(|segment| {
78            if !segment.contains_time(time) {
79                return false;
80            }
81
82            self.weekdays.is_empty()
83                || self
84                    .weekdays
85                    .contains(&segment.effective_weekday(weekday, time))
86        })
87    }
88}
89
90fn previous_weekday(weekday: Weekday) -> Weekday {
91    match weekday {
92        Weekday::Mon => Weekday::Sun,
93        Weekday::Tue => Weekday::Mon,
94        Weekday::Wed => Weekday::Tue,
95        Weekday::Thu => Weekday::Wed,
96        Weekday::Fri => Weekday::Thu,
97        Weekday::Sat => Weekday::Fri,
98        Weekday::Sun => Weekday::Sat,
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::{JobTimeWindow, RunSkipReason, TimeWindowSegment};
105    use chrono::{NaiveTime, TimeZone, Utc, Weekday};
106    use chrono_tz::{Asia::Shanghai, UTC};
107
108    #[test]
109    fn fallback_timezone_is_used_when_window_timezone_is_missing() {
110        let window = JobTimeWindow {
111            timezone: None,
112            weekdays: vec![Weekday::Sat],
113            segments: vec![TimeWindowSegment::new(
114                NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
115                NaiveTime::from_hms_opt(1, 0, 0).unwrap(),
116            )],
117        };
118        let instant = Utc.with_ymd_and_hms(2026, 5, 8, 16, 30, 0).unwrap();
119
120        assert!(!window.matches(instant, UTC));
121        assert!(window.matches(instant, Shanghai));
122    }
123
124    #[test]
125    fn same_day_segments_match_by_local_time() {
126        let window = JobTimeWindow {
127            timezone: Some(UTC),
128            weekdays: vec![Weekday::Fri],
129            segments: vec![
130                TimeWindowSegment::new(
131                    NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
132                    NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
133                ),
134                TimeWindowSegment::new(
135                    NaiveTime::from_hms_opt(13, 0, 0).unwrap(),
136                    NaiveTime::from_hms_opt(14, 0, 0).unwrap(),
137                ),
138            ],
139        };
140
141        assert!(window.matches(Utc.with_ymd_and_hms(2026, 5, 8, 13, 30, 0).unwrap(), UTC));
142        assert!(!window.matches(Utc.with_ymd_and_hms(2026, 5, 8, 11, 30, 0).unwrap(), UTC));
143    }
144
145    #[test]
146    fn cross_midnight_segments_use_the_effective_local_day() {
147        let window = JobTimeWindow {
148            timezone: Some(UTC),
149            weekdays: vec![Weekday::Thu],
150            segments: vec![TimeWindowSegment::new(
151                NaiveTime::from_hms_opt(22, 0, 0).unwrap(),
152                NaiveTime::from_hms_opt(2, 0, 0).unwrap(),
153            )],
154        };
155
156        assert!(window.matches(Utc.with_ymd_and_hms(2026, 5, 8, 1, 0, 0).unwrap(), UTC));
157        assert!(!window.matches(Utc.with_ymd_and_hms(2026, 5, 8, 3, 0, 0).unwrap(), UTC));
158    }
159
160    #[test]
161    fn run_skip_reason_has_expected_string_form() {
162        assert_eq!(
163            RunSkipReason::OutsideTimeWindow.as_str(),
164            "outside_time_window"
165        );
166        assert_eq!(
167            RunSkipReason::OutsideTimeWindow.to_string(),
168            "outside_time_window"
169        );
170    }
171}