message/
scheduling.rs

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/// Represents a restriction on when the message can be sent.
36/// This encodes the permitted times.
37#[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
90/// serde usually does a great job, but for the case of an optional
91/// flattened value, it will silently swallow any errors that should
92/// have been raised from parsing the fields it contained, and convert
93/// that to a None value.
94/// That's very undesirable, and unfortunately for us, it means having
95/// to manually implement Deserialize, so that is what this big chunk
96/// of code is.
97impl<'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        /// Our visitor; we'll collect an optional value for
105        /// each possible field here
106        #[derive(Default)]
107        struct V {
108            // Note that this is serialized as "dow"
109            days_of_week: Option<DaysOfWeek>,
110            // Note that this is serialized as "tz"
111            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                // We must iterate borrowed keys here for broader compatibility.
144                // Even though retrieving the keys as `&str` functions directly
145                // here in the unit tests for this module, it will fail at
146                // runtime when processing lua values, as lua uses Cow<_,str>.
147                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                // We require all of the fields of the restriction to be present,
176                // or none of them. If only some are present, that is an error,
177                // and we must report it.
178                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            // Worst case is 1 week off the current time; if we
243            // can't find a time in a reasonable number of iterations,
244            // something is wrong!
245            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                        // Wonky date/time, try the next day
253                        dt += one_day;
254                        continue;
255                    }
256                };
257
258                if restrict.days_of_week.contains(dow) {
259                    if dt < start {
260                        // Delay until the start time
261                        dt = start;
262                        break;
263                    }
264
265                    if dt < end {
266                        // We're within the permitted range
267                        break;
268                    }
269                }
270
271                // Try the same start time the next day
272                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        // This is a Tuesday
534        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        // Expected to round into wednesday, the next day
540        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        // This is a Monday, but after hours
558        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        // Expected to round into Friday, later that week
564        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}