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}