1use chrono::{DateTime, Datelike, Local, Timelike, Utc};
11
12use crate::error::{Error, Result};
13
14const SECONDS_PER_DAY: i64 = 86_400;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18struct Tod(i64);
19
20impl Tod {
21 fn parse(s: &str) -> Result<Self> {
22 let bad = || Error::Config(format!("invalid time {s:?}, expected HH:MM:SS"));
23 let mut parts = s.split(':');
24 let h: i64 = parts.next().ok_or_else(bad)?.parse().map_err(|_| bad())?;
25 let m: i64 = parts.next().ok_or_else(bad)?.parse().map_err(|_| bad())?;
26 let sec: i64 = parts.next().ok_or_else(bad)?.parse().map_err(|_| bad())?;
27 if parts.next().is_some() || h > 23 || m > 59 || sec > 59 {
28 return Err(bad());
29 }
30 Ok(Tod(h * 3600 + m * 60 + sec))
31 }
32}
33
34fn parse_day(s: &str) -> Result<i32> {
37 let abbr: String = s.chars().take(2).flat_map(|c| c.to_lowercase()).collect();
38 Ok(match abbr.as_str() {
39 "su" => 1,
40 "mo" => 2,
41 "tu" => 3,
42 "we" => 4,
43 "th" => 5,
44 "fr" => 6,
45 "sa" => 7,
46 _ => return Err(Error::Config(format!("invalid day {s:?}"))),
47 })
48}
49
50fn weekday(date: chrono::NaiveDate) -> i32 {
52 date.weekday().num_days_from_sunday() as i32 + 1
53}
54
55fn day_number(date: chrono::NaiveDate) -> i32 {
58 date.num_days_from_ce()
59}
60
61#[derive(Debug, Clone)]
62pub struct Schedule {
63 start: Tod,
64 end: Tod,
65 start_day: i32,
67 end_day: i32,
68 use_local: bool,
69 non_stop: bool,
72}
73
74impl Default for Schedule {
75 fn default() -> Self {
77 Self {
78 start: Tod(0),
79 end: Tod(0),
80 start_day: -1,
81 end_day: -1,
82 use_local: false,
83 non_stop: true,
84 }
85 }
86}
87
88impl Schedule {
89 pub fn non_stop() -> Self {
90 Self::default()
91 }
92
93 pub fn parse(
97 start: Option<&str>,
98 end: Option<&str>,
99 start_day: Option<&str>,
100 end_day: Option<&str>,
101 use_local: bool,
102 non_stop_setting: bool,
103 ) -> Result<Self> {
104 let start_day = start_day.map(parse_day).transpose()?.unwrap_or(-1);
105 let end_day = end_day.map(parse_day).transpose()?.unwrap_or(-1);
106 if (start_day >= 0) != (end_day >= 0) {
107 return Err(Error::Config("StartDay and EndDay must be set together".into()));
108 }
109 match (start, end) {
110 (Some(s), Some(e)) => Ok(Self {
111 start: Tod::parse(s)?,
112 end: Tod::parse(e)?,
113 start_day,
114 end_day,
115 use_local,
116 non_stop: false,
117 }),
118 _ if non_stop_setting || start_day < 0 => Ok(Self::non_stop()),
119 _ => Ok(Self {
122 start: Tod(0),
123 end: Tod(0),
124 start_day,
125 end_day,
126 use_local,
127 non_stop: false,
128 }),
129 }
130 }
131
132 pub fn is_non_stop(&self) -> bool {
133 self.non_stop
134 }
135
136 fn parts(&self, now: DateTime<Utc>) -> (i64, i32, i32) {
138 if self.use_local {
139 let l = now.with_timezone(&Local);
140 (
141 l.time().num_seconds_from_midnight() as i64,
142 weekday(l.date_naive()),
143 day_number(l.date_naive()),
144 )
145 } else {
146 (
147 now.time().num_seconds_from_midnight() as i64,
148 weekday(now.date_naive()),
149 day_number(now.date_naive()),
150 )
151 }
152 }
153
154 pub fn is_in_range(&self, now: DateTime<Utc>) -> bool {
156 if self.non_stop {
157 return true;
158 }
159 let (tod, wd, _) = self.parts(now);
160 if self.start_day < 0 {
161 in_daily(self.start, self.end, tod)
162 } else {
163 self.in_weekly(tod, wd)
164 }
165 }
166
167 fn in_weekly(&self, tod: i64, day: i32) -> bool {
168 let (sd, ed) = (self.start_day, self.end_day);
169 if sd == ed {
170 if day != sd {
171 return true;
172 }
173 return in_daily(self.start, self.end, tod);
174 } else if sd < ed {
175 if day < sd || day > ed {
176 return false;
177 }
178 } else {
179 if day < sd && day > ed {
181 return false;
182 }
183 }
184 if day == sd && tod < self.start.0 {
185 return false;
186 }
187 if day == ed && tod > self.end.0 {
188 return false;
189 }
190 true
191 }
192
193 pub fn is_in_same_range(&self, now: DateTime<Utc>, other: DateTime<Utc>) -> bool {
197 if self.non_stop {
198 return true;
199 }
200 if !self.is_in_range(now) || !self.is_in_range(other) {
201 return false;
202 }
203 if now == other {
204 return true;
205 }
206 if self.start_day < 0 {
207 self.same_daily(now, other)
208 } else if self.start_day != self.end_day {
209 self.range_start_date(now) == self.range_start_date(other)
210 } else {
211 self.same_weekly_sameday(now, other)
212 }
213 }
214
215 fn same_daily(&self, t1: DateTime<Utc>, t2: DateTime<Utc>) -> bool {
216 if self.start.0 <= self.end.0 {
217 self.parts(t1).2 == self.parts(t2).2
219 } else {
220 let session_length = SECONDS_PER_DAY - (self.start.0 - self.end.0);
222 let diff = (t1 - t2).num_seconds();
223 if diff > 0 {
224 let t2_tod = self.parts(t2).0;
225 let mut delta = t2_tod - self.start.0;
226 if delta < 0 {
227 delta = SECONDS_PER_DAY - delta.abs();
228 }
229 diff < (session_length - delta)
230 } else {
231 -diff < session_length
232 }
233 }
234 }
235
236 fn range_start_date(&self, now: DateTime<Utc>) -> i32 {
239 let (tod, wd, jul) = self.parts(now);
240 let sd = self.start_day;
241 if wd > sd {
242 jul - wd + sd
243 } else if wd < sd {
244 jul - wd + sd - 7
245 } else if tod >= self.start.0 {
246 jul
247 } else {
248 jul - 7
249 }
250 }
251
252 fn same_weekly_sameday(&self, t1: DateTime<Utc>, t2: DateTime<Utc>) -> bool {
253 let (tod1, wd1, day1) = self.parts(t1);
254 let (tod2, _wd2, day2) = self.parts(t2);
255 let sd = self.start_day;
256 if day1 == day2 && sd == wd1 {
257 let both_before_end = tod1 <= self.end.0 && tod2 <= self.end.0;
258 let both_after_start = tod1 >= self.start.0 && tod2 >= self.start.0;
259 both_before_end || both_after_start
260 } else if day1 == day2 && sd != wd1 {
261 true
262 } else if (day1 - day2).abs() > 7 {
263 false
264 } else if (day1 - day2).abs() == 7 {
265 if wd1 != sd {
266 return false;
267 }
268 let (earlier_tod, later_tod) =
269 if day2 > day1 { (tod1, tod2) } else { (tod2, tod1) };
270 earlier_tod >= self.start.0 && later_tod <= self.end.0
271 } else {
272 self.range_start_date(t1) == self.range_start_date(t2)
273 }
274 }
275}
276
277fn in_daily(start: Tod, end: Tod, tod: i64) -> bool {
278 if start.0 < end.0 {
279 tod >= start.0 && tod <= end.0
280 } else {
281 tod >= start.0 || tod <= end.0
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use chrono::TimeZone;
290
291 fn utc(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> DateTime<Utc> {
292 Utc.with_ymd_and_hms(y, mo, d, h, mi, s).unwrap()
293 }
294
295 fn daily(start: &str, end: &str) -> Schedule {
296 Schedule::parse(Some(start), Some(end), None, None, false, false).unwrap()
297 }
298
299 #[test]
300 fn parse_days_and_times() {
301 assert!(Tod::parse("00:00:00").is_ok());
302 assert!(Tod::parse("23:59:59").is_ok());
303 assert!(Tod::parse("24:00:00").is_err());
304 assert!(Tod::parse("12:60:00").is_err());
305 assert!(Tod::parse("12:00").is_err());
306 assert_eq!(parse_day("Sunday").unwrap(), 1);
307 assert_eq!(parse_day("SA").unwrap(), 7);
308 assert_eq!(parse_day("we").unwrap(), 4);
309 assert!(parse_day("xy").is_err());
310 }
311
312 #[test]
313 fn weekday_numbering_matches_cpp() {
314 assert_eq!(weekday(chrono::NaiveDate::from_ymd_opt(2024, 5, 19).unwrap()), 1);
316 assert_eq!(weekday(chrono::NaiveDate::from_ymd_opt(2024, 5, 25).unwrap()), 7); }
318
319 #[test]
320 fn daytime_window() {
321 let s = daily("08:00:00", "17:00:00");
322 assert!(!s.is_in_range(utc(2024, 5, 20, 7, 59, 59)));
323 assert!(s.is_in_range(utc(2024, 5, 20, 8, 0, 0)));
324 assert!(s.is_in_range(utc(2024, 5, 20, 12, 0, 0)));
325 assert!(s.is_in_range(utc(2024, 5, 20, 17, 0, 0)));
326 assert!(!s.is_in_range(utc(2024, 5, 20, 17, 0, 1)));
327 }
328
329 #[test]
330 fn daily_reset_same_and_different_day() {
331 let s = daily("08:00:00", "17:00:00");
332 assert!(s.is_in_same_range(utc(2024, 5, 20, 9, 0, 0), utc(2024, 5, 20, 16, 0, 0)));
334 assert!(!s.is_in_same_range(utc(2024, 5, 21, 9, 0, 0), utc(2024, 5, 20, 16, 0, 0)));
336 }
337
338 #[test]
339 fn always_on_window_resets_at_midnight() {
340 let s = daily("00:00:00", "00:00:00");
342 assert!(s.is_in_range(utc(2024, 5, 20, 3, 0, 0)));
343 assert!(s.is_in_same_range(utc(2024, 5, 20, 3, 0, 0), utc(2024, 5, 20, 22, 0, 0)));
344 assert!(!s.is_in_same_range(utc(2024, 5, 21, 1, 0, 0), utc(2024, 5, 20, 22, 0, 0)));
345 }
346
347 #[test]
348 fn overnight_window() {
349 let s = daily("17:00:00", "08:00:00");
351 assert!(s.is_in_range(utc(2024, 5, 20, 23, 0, 0)));
352 assert!(s.is_in_range(utc(2024, 5, 21, 3, 0, 0)));
353 assert!(!s.is_in_range(utc(2024, 5, 20, 12, 0, 0)));
354 assert!(s.is_in_same_range(utc(2024, 5, 20, 23, 0, 0), utc(2024, 5, 21, 3, 0, 0)));
356 assert!(!s.is_in_same_range(utc(2024, 5, 20, 23, 0, 0), utc(2024, 5, 21, 23, 30, 0)));
358 }
359
360 #[test]
361 fn weekly_window() {
362 let s = Schedule::parse(
364 Some("00:00:00"),
365 Some("17:00:00"),
366 Some("Sunday"),
367 Some("Friday"),
368 false,
369 false,
370 )
371 .unwrap();
372 assert!(s.is_in_range(utc(2024, 5, 19, 1, 0, 0))); assert!(s.is_in_range(utc(2024, 5, 22, 12, 0, 0))); assert!(s.is_in_range(utc(2024, 5, 24, 17, 0, 0))); assert!(!s.is_in_range(utc(2024, 5, 24, 17, 0, 1))); assert!(!s.is_in_range(utc(2024, 5, 25, 12, 0, 0))); assert!(s.is_in_same_range(utc(2024, 5, 22, 12, 0, 0), utc(2024, 5, 24, 12, 0, 0)));
380 assert!(!s.is_in_same_range(utc(2024, 5, 24, 12, 0, 0), utc(2024, 5, 27, 12, 0, 0)));
382 }
383
384 #[test]
385 fn non_stop_never_resets() {
386 let s = Schedule::non_stop();
387 assert!(s.is_in_range(utc(2024, 5, 20, 3, 0, 0)));
388 assert!(s.is_in_same_range(utc(2024, 1, 1, 0, 0, 0), utc(2024, 12, 31, 23, 59, 59)));
389 }
390}