1use chrono::naive::NaiveTime;
2use chrono::{DateTime, Datelike, FixedOffset, LocalResult, TimeZone, Timelike, Utc, Weekday};
3use chrono_tz::Tz;
4use kumo_chrono_helper::*;
5use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7
8bitflags::bitflags! {
9 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
10 pub struct DaysOfWeek: u8 {
11 const MON = 1;
12 const TUE = 2;
13 const WED = 4;
14 const THU = 8;
15 const FRI = 16;
16 const SAT = 32;
17 const SUN = 64;
18 }
19}
20
21impl From<Weekday> for DaysOfWeek {
22 fn from(day: Weekday) -> DaysOfWeek {
23 match day {
24 Weekday::Mon => DaysOfWeek::MON,
25 Weekday::Tue => DaysOfWeek::TUE,
26 Weekday::Wed => DaysOfWeek::WED,
27 Weekday::Thu => DaysOfWeek::THU,
28 Weekday::Fri => DaysOfWeek::FRI,
29 Weekday::Sat => DaysOfWeek::SAT,
30 Weekday::Sun => DaysOfWeek::SUN,
31 }
32 }
33}
34
35#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Copy)]
38pub struct ScheduleRestriction {
39 #[serde(rename = "dow")]
40 pub days_of_week: DaysOfWeek,
41 #[serde(rename = "tz")]
42 pub timezone: Tz,
43 pub start: NaiveTime,
44 pub end: NaiveTime,
45}
46
47impl ScheduleRestriction {
48 fn start_end_on_day(&self, dt: DateTime<Tz>) -> Option<(DateTime<Tz>, DateTime<Tz>)> {
49 let y = dt.year();
50 let m = dt.month();
51 let d = dt.day();
52
53 let start = match self.timezone.with_ymd_and_hms(
54 y,
55 m,
56 d,
57 self.start.hour(),
58 self.start.minute(),
59 self.start.second(),
60 ) {
61 LocalResult::Single(t) => t,
62 _ => return None,
63 };
64
65 let end = match self.timezone.with_ymd_and_hms(
66 y,
67 m,
68 d,
69 self.end.hour(),
70 self.end.minute(),
71 self.end.second(),
72 ) {
73 LocalResult::Single(t) => t,
74 _ => return None,
75 };
76 Some((start, end))
77 }
78}
79
80#[derive(Debug, Serialize, Clone, PartialEq, Copy)]
81pub struct Scheduling {
82 #[serde(flatten, skip_serializing_if = "Option::is_none")]
83 pub restriction: Option<ScheduleRestriction>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub first_attempt: Option<DateTime<FixedOffset>>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub expires: Option<DateTime<FixedOffset>>,
88}
89
90impl<'de> Deserialize<'de> for Scheduling {
98 fn deserialize<D>(deserializer: D) -> Result<Self, <D as serde::Deserializer<'de>>::Error>
99 where
100 D: serde::Deserializer<'de>,
101 {
102 use serde::de::Error;
103
104 #[derive(Default)]
107 struct V {
108 days_of_week: Option<DaysOfWeek>,
110 timezone: Option<Tz>,
112 start: Option<NaiveTime>,
113 end: Option<NaiveTime>,
114 first_attempt: Option<DateTime<FixedOffset>>,
115 expires: Option<DateTime<FixedOffset>>,
116 }
117
118 fn do_value<'de, T: Deserialize<'de>, M: serde::de::MapAccess<'de>>(
119 map: &mut M,
120 label: &str,
121 target: &mut Option<T>,
122 ) -> Result<(), M::Error> {
123 match map.next_value() {
124 Err(err) => Err(M::Error::custom(format!("{label}: {err:#}"))),
125 Ok(v) => {
126 target.replace(v);
127 Ok(())
128 }
129 }
130 }
131
132 impl<'de> serde::de::Visitor<'de> for V {
133 type Value = Scheduling;
134
135 fn expecting(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
136 fmt.write_str("a map containing the Scheduling spec")
137 }
138
139 fn visit_map<M>(mut self, mut map: M) -> Result<Scheduling, M::Error>
140 where
141 M: serde::de::MapAccess<'de>,
142 {
143 use std::borrow::Cow;
148
149 while let Some(k) = map.next_key::<Cow<'_, str>>()? {
150 match k.as_ref() {
151 "dow" => {
152 do_value(&mut map, "dow", &mut self.days_of_week)?;
153 }
154 "tz" => {
155 do_value(&mut map, "tz", &mut self.timezone)?;
156 }
157 "start" => {
158 do_value(&mut map, "start", &mut self.start)?;
159 }
160 "end" => {
161 do_value(&mut map, "end", &mut self.end)?;
162 }
163 "first_attempt" => {
164 do_value(&mut map, "first_attempt", &mut self.first_attempt)?;
165 }
166 "expires" => {
167 do_value(&mut map, "expires", &mut self.expires)?;
168 }
169 _ => {
170 return Err(M::Error::custom(format!("invalid field name {k}")));
171 }
172 }
173 }
174
175 let restriction = match (self.days_of_week, self.timezone, self.start, self.end) {
179 (Some(days_of_week), Some(timezone), Some(start), Some(end)) => {
180 if start > end {
181 return Err(M::Error::custom(format!(
182 "'start' must be before 'end' and define a time window \
183 within a given day. start={start:?}, end={end:?}"
184 )));
185 }
186 Some(ScheduleRestriction {
187 days_of_week,
188 timezone,
189 start,
190 end,
191 })
192 }
193 (None, None, None, None) => None,
194 (days_of_week, timezone, start, end) => {
195 let mut missing = vec![];
196 if days_of_week.is_none() {
197 missing.push("dow");
198 }
199 if timezone.is_none() {
200 missing.push("tz");
201 }
202 if start.is_none() {
203 missing.push("start");
204 }
205 if end.is_none() {
206 missing.push("end");
207 }
208 let is_are = if missing.len() == 1 { "is" } else { "are" };
209 return Err(M::Error::custom(format!(
210 "scheduling restrictions requires all restriction \
211 fields to be present. {} {is_are} missing",
212 missing.join(", ")
213 )));
214 }
215 };
216
217 Ok(Scheduling {
218 restriction,
219 first_attempt: self.first_attempt,
220 expires: self.expires,
221 })
222 }
223 }
224
225 deserializer.deserialize_any(V::default())
226 }
227}
228
229impl Scheduling {
230 pub fn adjust_for_schedule(&self, mut dt: DateTime<Utc>) -> DateTime<Utc> {
231 if let Some(start) = &self.first_attempt {
232 if dt < *start {
233 dt = (*start).into();
234 }
235 }
236
237 if let Some(restrict) = &self.restriction {
238 let mut dt = dt.with_timezone(&restrict.timezone);
239
240 let one_day = chrono::Duration::try_days(1).expect("always able to represent 1 day");
241
242 for _ in 0..8 {
246 let weekday = dt.weekday();
247 let dow: DaysOfWeek = weekday.into();
248
249 let (start, end) = match restrict.start_end_on_day(dt) {
250 Some(result) => result,
251 None => {
252 dt += one_day;
254 continue;
255 }
256 };
257
258 if restrict.days_of_week.contains(dow) {
259 if dt < start {
260 dt = start;
262 break;
263 }
264
265 if dt < end {
266 break;
268 }
269 }
270
271 dt = start + one_day;
273 }
274 dt.with_timezone(&Utc)
275 } else {
276 dt
277 }
278 }
279
280 pub fn is_within_schedule(&self, dt: DateTime<Utc>) -> bool {
281 if let Some(start) = &self.first_attempt {
282 if dt < *start {
283 return false;
284 }
285 }
286
287 if let Some(restrict) = &self.restriction {
288 let dt = dt.with_timezone(&restrict.timezone);
289
290 let weekday: DaysOfWeek = dt.weekday().into();
291
292 if !restrict.days_of_week.contains(weekday) {
293 return false;
294 }
295
296 let (start, end) = match restrict.start_end_on_day(dt) {
297 Some(result) => result,
298 None => return false,
299 };
300
301 if dt < start {
302 return false;
303 }
304 if dt >= end {
305 return false;
306 }
307 }
308
309 true
310 }
311}
312
313const DAYS: &[(&str, DaysOfWeek)] = &[
314 ("Monday", DaysOfWeek::MON),
315 ("Tuesday", DaysOfWeek::TUE),
316 ("Wednesday", DaysOfWeek::WED),
317 ("Thursday", DaysOfWeek::THU),
318 ("Friday", DaysOfWeek::FRI),
319 ("Saturday", DaysOfWeek::SAT),
320 ("Sunday", DaysOfWeek::SUN),
321];
322
323impl FromStr for DaysOfWeek {
324 type Err = String;
325 fn from_str(s: &str) -> Result<Self, String> {
326 let mut days = DaysOfWeek::empty();
327 'next: for dow in s.split(',') {
328 for (label, value) in DAYS {
329 if dow.eq_ignore_ascii_case(label) || dow.eq_ignore_ascii_case(&label[0..3]) {
330 days.set(*value, true);
331 continue 'next;
332 }
333 }
334 return Err(format!("invalid day '{dow}'"));
335 }
336
337 Ok(days)
338 }
339}
340
341impl Serialize for DaysOfWeek {
342 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
343 where
344 S: serde::Serializer,
345 {
346 let mut result = String::new();
347 for (label, value) in DAYS {
348 if self.contains(*value) {
349 if !result.is_empty() {
350 result.push(',');
351 }
352 result.push_str(&label[0..3]);
353 }
354 }
355 serializer.serialize_str(&result)
356 }
357}
358
359impl<'de> Deserialize<'de> for DaysOfWeek {
360 fn deserialize<D>(deserializer: D) -> Result<DaysOfWeek, D::Error>
361 where
362 D: serde::Deserializer<'de>,
363 {
364 struct Visit {}
365 impl serde::de::Visitor<'_> for Visit {
366 type Value = DaysOfWeek;
367
368 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
369 formatter.write_str("a comma separated list of days of the week like 'Mon,Tue'")
370 }
371
372 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
373 where
374 E: serde::de::Error,
375 {
376 value.parse::<DaysOfWeek>().map_err(|err| E::custom(err))
377 }
378 }
379
380 deserializer.deserialize_str(Visit {})
381 }
382}
383
384#[cfg(test)]
385mod test {
386 use super::*;
387
388 #[test]
389 fn days_of_week() {
390 let all = "Mon,Tue,Wed,Thu,Fri,Sat,Sun".parse::<DaysOfWeek>().unwrap();
391 k9::assert_equal!(
392 all,
393 DaysOfWeek::MON
394 | DaysOfWeek::TUE
395 | DaysOfWeek::WED
396 | DaysOfWeek::THU
397 | DaysOfWeek::FRI
398 | DaysOfWeek::SAT
399 | DaysOfWeek::SUN
400 );
401
402 let middle = "Wed,Tue,Thursday".parse::<DaysOfWeek>().unwrap();
403 k9::assert_equal!(middle, DaysOfWeek::TUE | DaysOfWeek::WED | DaysOfWeek::THU);
404
405 k9::assert_equal!(
406 "Wed,Sumday".parse::<DaysOfWeek>().unwrap_err(),
407 "invalid day 'Sumday'"
408 );
409 }
410
411 #[test]
412 fn schedule_parse_restriction() {
413 let sched = Scheduling {
414 restriction: Some(ScheduleRestriction {
415 days_of_week: DaysOfWeek::MON | DaysOfWeek::WED,
416 timezone: "America/Phoenix".parse().unwrap(),
417 start: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
418 end: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
419 }),
420 first_attempt: None,
421 expires: None,
422 };
423
424 let serialized = serde_json::to_string(&sched).unwrap();
425 k9::snapshot!(
426 &serialized,
427 r#"{"dow":"Mon,Wed","tz":"America/Phoenix","start":"09:00:00","end":"17:00:00"}"#
428 );
429
430 let round_trip: Scheduling = serde_json::from_str(&serialized).unwrap();
431 k9::assert_equal!(sched, round_trip);
432 }
433
434 #[test]
435 fn schedule_bogus_date() {
436 k9::snapshot!(
437 serde_json::from_str::<Scheduling>(r#"{"first_attempt":"09:00:00"}"#,).unwrap_err(),
438 r#"Error("first_attempt: input contains invalid characters", line: 1, column: 27)"#
439 );
440 }
441
442 #[test]
443 fn schedule_parse_missing_parts() {
444 k9::snapshot!(
445 serde_json::from_str::<Scheduling>(
446 r#"{"tz":"America/Phoenix","end":"17:00:00","start":"09:00:00"}"#,
447 )
448 .unwrap_err(),
449 r#"Error("scheduling restrictions requires all restriction fields to be present. dow is missing", line: 1, column: 60)"#
450 );
451
452 k9::snapshot!(
453 serde_json::from_str::<Scheduling>(r#"{"tz":"America/Phoenix","start":"09:00:00"}"#,)
454 .unwrap_err(),
455 r#"Error("scheduling restrictions requires all restriction fields to be present. dow, end are missing", line: 1, column: 43)"#
456 );
457 }
458
459 #[test]
460 fn schedule_parse_restriction_and_start() {
461 let sched = Scheduling {
462 restriction: Some(ScheduleRestriction {
463 days_of_week: DaysOfWeek::MON | DaysOfWeek::WED,
464 timezone: "America/Phoenix".parse().unwrap(),
465 start: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
466 end: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
467 }),
468 first_attempt: DateTime::parse_from_rfc3339("1996-12-19T16:39:57-08:00").ok(),
469 expires: None,
470 };
471
472 let serialized = serde_json::to_string(&sched).unwrap();
473 k9::snapshot!(
474 &serialized,
475 r#"{"dow":"Mon,Wed","tz":"America/Phoenix","start":"09:00:00","end":"17:00:00","first_attempt":"1996-12-19T16:39:57-08:00"}"#
476 );
477
478 let round_trip: Scheduling = serde_json::from_str(&serialized).unwrap();
479 k9::assert_equal!(sched, round_trip);
480
481 let _: Scheduling = serde_json::from_str(
482 r#"{"dow":"Mon","tz":"America/Phoenix","end":"17:00:00","start":"09:00:00"}"#,
483 )
484 .unwrap();
485 }
486
487 #[test]
488 fn schedule_parse_no_restriction_and_start() {
489 let sched = Scheduling {
490 restriction: None,
491 first_attempt: DateTime::parse_from_rfc3339("1996-12-19T16:39:57-08:00").ok(),
492 expires: None,
493 };
494
495 let serialized = serde_json::to_string(&sched).unwrap();
496 k9::snapshot!(
497 &serialized,
498 r#"{"first_attempt":"1996-12-19T16:39:57-08:00"}"#
499 );
500
501 let round_trip: Scheduling = serde_json::from_str(&serialized).unwrap();
502 k9::assert_equal!(sched, round_trip);
503 }
504
505 #[test]
506 fn schedule_adjust_start() {
507 let sched = Scheduling {
508 restriction: None,
509 first_attempt: DateTime::parse_from_rfc3339("2023-03-20T16:39:57-08:00").ok(),
510 expires: None,
511 };
512
513 let now: DateTime<Utc> = DateTime::parse_from_rfc3339("2023-03-20T08:00:00-08:00")
514 .unwrap()
515 .into();
516 k9::assert_equal!(sched.adjust_for_schedule(now), sched.first_attempt.unwrap());
517 }
518
519 #[test]
520 fn schedule_adjust_dow() {
521 let phoenix: Tz = "America/Phoenix".parse().unwrap();
522 let sched = Scheduling {
523 restriction: Some(ScheduleRestriction {
524 days_of_week: DaysOfWeek::MON | DaysOfWeek::WED,
525 timezone: phoenix,
526 start: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
527 end: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
528 }),
529 first_attempt: None,
530 expires: None,
531 };
532
533 let now: DateTime<Utc> = DateTime::parse_from_rfc3339("2023-03-28T08:00:00-08:00")
535 .unwrap()
536 .into();
537
538 let adjusted = sched.adjust_for_schedule(now).with_timezone(&phoenix);
539 k9::assert_equal!(adjusted.to_string(), "2023-03-29 09:00:00 MST");
541 }
542
543 #[test]
544 fn schedule_adjust_dow_2() {
545 let phoenix: Tz = "America/Phoenix".parse().unwrap();
546 let sched = Scheduling {
547 restriction: Some(ScheduleRestriction {
548 days_of_week: DaysOfWeek::MON | DaysOfWeek::FRI,
549 timezone: phoenix,
550 start: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
551 end: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
552 }),
553 first_attempt: None,
554 expires: None,
555 };
556
557 let now: DateTime<Utc> = DateTime::parse_from_rfc3339("2023-03-27T18:00:00-08:00")
559 .unwrap()
560 .into();
561
562 let adjusted = sched.adjust_for_schedule(now).with_timezone(&phoenix);
563 k9::assert_equal!(adjusted.to_string(), "2023-03-31 09:00:00 MST");
565 }
566
567 #[test]
568 fn start_after_end() {
569 k9::snapshot!(serde_json::from_str::<Scheduling>(
570 r#"{"dow":"Mon,Tue,Wed,Thu,Fri,Sat,Sun","tz":"Etc/UTC","end":"20:57:49","start":"21:10:49", "first_attempt":"2025-03-14T21:10:49Z"}"#,
571 )
572 .unwrap_err(), r#"Error("'start' must be before 'end' and define a time window within a given day. start=21:10:49, end=20:57:49", line: 1, column: 128)"#);
573 }
574}