use chrono::{DateTime, Datelike, Local, Timelike, Utc};
use crate::error::{Error, Result};
const SECONDS_PER_DAY: i64 = 86_400;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Tod(i64);
impl Tod {
fn parse(s: &str) -> Result<Self> {
let bad = || Error::Config(format!("invalid time {s:?}, expected HH:MM:SS"));
let mut parts = s.split(':');
let h: i64 = parts.next().ok_or_else(bad)?.parse().map_err(|_| bad())?;
let m: i64 = parts.next().ok_or_else(bad)?.parse().map_err(|_| bad())?;
let sec: i64 = parts.next().ok_or_else(bad)?.parse().map_err(|_| bad())?;
if parts.next().is_some() || h > 23 || m > 59 || sec > 59 {
return Err(bad());
}
Ok(Tod(h * 3600 + m * 60 + sec))
}
}
fn parse_day(s: &str) -> Result<i32> {
let abbr: String = s.chars().take(2).flat_map(|c| c.to_lowercase()).collect();
Ok(match abbr.as_str() {
"su" => 1,
"mo" => 2,
"tu" => 3,
"we" => 4,
"th" => 5,
"fr" => 6,
"sa" => 7,
_ => return Err(Error::Config(format!("invalid day {s:?}"))),
})
}
fn weekday(date: chrono::NaiveDate) -> i32 {
date.weekday().num_days_from_sunday() as i32 + 1
}
fn day_number(date: chrono::NaiveDate) -> i32 {
date.num_days_from_ce()
}
#[derive(Debug, Clone)]
pub struct Schedule {
start: Tod,
end: Tod,
start_day: i32,
end_day: i32,
use_local: bool,
non_stop: bool,
}
impl Default for Schedule {
fn default() -> Self {
Self {
start: Tod(0),
end: Tod(0),
start_day: -1,
end_day: -1,
use_local: false,
non_stop: true,
}
}
}
impl Schedule {
pub fn non_stop() -> Self {
Self::default()
}
pub fn parse(
start: Option<&str>,
end: Option<&str>,
start_day: Option<&str>,
end_day: Option<&str>,
use_local: bool,
non_stop_setting: bool,
) -> Result<Self> {
let start_day = start_day.map(parse_day).transpose()?.unwrap_or(-1);
let end_day = end_day.map(parse_day).transpose()?.unwrap_or(-1);
if (start_day >= 0) != (end_day >= 0) {
return Err(Error::Config("StartDay and EndDay must be set together".into()));
}
match (start, end) {
(Some(s), Some(e)) => Ok(Self {
start: Tod::parse(s)?,
end: Tod::parse(e)?,
start_day,
end_day,
use_local,
non_stop: false,
}),
_ if non_stop_setting || start_day < 0 => Ok(Self::non_stop()),
_ => Ok(Self {
start: Tod(0),
end: Tod(0),
start_day,
end_day,
use_local,
non_stop: false,
}),
}
}
pub fn is_non_stop(&self) -> bool {
self.non_stop
}
fn parts(&self, now: DateTime<Utc>) -> (i64, i32, i32) {
if self.use_local {
let l = now.with_timezone(&Local);
(
l.time().num_seconds_from_midnight() as i64,
weekday(l.date_naive()),
day_number(l.date_naive()),
)
} else {
(
now.time().num_seconds_from_midnight() as i64,
weekday(now.date_naive()),
day_number(now.date_naive()),
)
}
}
pub fn is_in_range(&self, now: DateTime<Utc>) -> bool {
if self.non_stop {
return true;
}
let (tod, wd, _) = self.parts(now);
if self.start_day < 0 {
in_daily(self.start, self.end, tod)
} else {
self.in_weekly(tod, wd)
}
}
fn in_weekly(&self, tod: i64, day: i32) -> bool {
let (sd, ed) = (self.start_day, self.end_day);
if sd == ed {
if day != sd {
return true;
}
return in_daily(self.start, self.end, tod);
} else if sd < ed {
if day < sd || day > ed {
return false;
}
} else {
if day < sd && day > ed {
return false;
}
}
if day == sd && tod < self.start.0 {
return false;
}
if day == ed && tod > self.end.0 {
return false;
}
true
}
pub fn is_in_same_range(&self, now: DateTime<Utc>, other: DateTime<Utc>) -> bool {
if self.non_stop {
return true;
}
if !self.is_in_range(now) || !self.is_in_range(other) {
return false;
}
if now == other {
return true;
}
if self.start_day < 0 {
self.same_daily(now, other)
} else if self.start_day != self.end_day {
self.range_start_date(now) == self.range_start_date(other)
} else {
self.same_weekly_sameday(now, other)
}
}
fn same_daily(&self, t1: DateTime<Utc>, t2: DateTime<Utc>) -> bool {
if self.start.0 <= self.end.0 {
self.parts(t1).2 == self.parts(t2).2
} else {
let session_length = SECONDS_PER_DAY - (self.start.0 - self.end.0);
let diff = (t1 - t2).num_seconds();
if diff > 0 {
let t2_tod = self.parts(t2).0;
let mut delta = t2_tod - self.start.0;
if delta < 0 {
delta = SECONDS_PER_DAY - delta.abs();
}
diff < (session_length - delta)
} else {
-diff < session_length
}
}
}
fn range_start_date(&self, now: DateTime<Utc>) -> i32 {
let (tod, wd, jul) = self.parts(now);
let sd = self.start_day;
if wd > sd {
jul - wd + sd
} else if wd < sd {
jul - wd + sd - 7
} else if tod >= self.start.0 {
jul
} else {
jul - 7
}
}
fn same_weekly_sameday(&self, t1: DateTime<Utc>, t2: DateTime<Utc>) -> bool {
let (tod1, wd1, day1) = self.parts(t1);
let (tod2, _wd2, day2) = self.parts(t2);
let sd = self.start_day;
if day1 == day2 && sd == wd1 {
let both_before_end = tod1 <= self.end.0 && tod2 <= self.end.0;
let both_after_start = tod1 >= self.start.0 && tod2 >= self.start.0;
both_before_end || both_after_start
} else if day1 == day2 && sd != wd1 {
true
} else if (day1 - day2).abs() > 7 {
false
} else if (day1 - day2).abs() == 7 {
if wd1 != sd {
return false;
}
let (earlier_tod, later_tod) =
if day2 > day1 { (tod1, tod2) } else { (tod2, tod1) };
earlier_tod >= self.start.0 && later_tod <= self.end.0
} else {
self.range_start_date(t1) == self.range_start_date(t2)
}
}
}
fn in_daily(start: Tod, end: Tod, tod: i64) -> bool {
if start.0 < end.0 {
tod >= start.0 && tod <= end.0
} else {
tod >= start.0 || tod <= end.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn utc(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, mo, d, h, mi, s).unwrap()
}
fn daily(start: &str, end: &str) -> Schedule {
Schedule::parse(Some(start), Some(end), None, None, false, false).unwrap()
}
#[test]
fn parse_days_and_times() {
assert!(Tod::parse("00:00:00").is_ok());
assert!(Tod::parse("23:59:59").is_ok());
assert!(Tod::parse("24:00:00").is_err());
assert!(Tod::parse("12:60:00").is_err());
assert!(Tod::parse("12:00").is_err());
assert_eq!(parse_day("Sunday").unwrap(), 1);
assert_eq!(parse_day("SA").unwrap(), 7);
assert_eq!(parse_day("we").unwrap(), 4);
assert!(parse_day("xy").is_err());
}
#[test]
fn weekday_numbering_matches_cpp() {
assert_eq!(weekday(chrono::NaiveDate::from_ymd_opt(2024, 5, 19).unwrap()), 1);
assert_eq!(weekday(chrono::NaiveDate::from_ymd_opt(2024, 5, 25).unwrap()), 7); }
#[test]
fn daytime_window() {
let s = daily("08:00:00", "17:00:00");
assert!(!s.is_in_range(utc(2024, 5, 20, 7, 59, 59)));
assert!(s.is_in_range(utc(2024, 5, 20, 8, 0, 0)));
assert!(s.is_in_range(utc(2024, 5, 20, 12, 0, 0)));
assert!(s.is_in_range(utc(2024, 5, 20, 17, 0, 0)));
assert!(!s.is_in_range(utc(2024, 5, 20, 17, 0, 1)));
}
#[test]
fn daily_reset_same_and_different_day() {
let s = daily("08:00:00", "17:00:00");
assert!(s.is_in_same_range(utc(2024, 5, 20, 9, 0, 0), utc(2024, 5, 20, 16, 0, 0)));
assert!(!s.is_in_same_range(utc(2024, 5, 21, 9, 0, 0), utc(2024, 5, 20, 16, 0, 0)));
}
#[test]
fn always_on_window_resets_at_midnight() {
let s = daily("00:00:00", "00:00:00");
assert!(s.is_in_range(utc(2024, 5, 20, 3, 0, 0)));
assert!(s.is_in_same_range(utc(2024, 5, 20, 3, 0, 0), utc(2024, 5, 20, 22, 0, 0)));
assert!(!s.is_in_same_range(utc(2024, 5, 21, 1, 0, 0), utc(2024, 5, 20, 22, 0, 0)));
}
#[test]
fn overnight_window() {
let s = daily("17:00:00", "08:00:00");
assert!(s.is_in_range(utc(2024, 5, 20, 23, 0, 0)));
assert!(s.is_in_range(utc(2024, 5, 21, 3, 0, 0)));
assert!(!s.is_in_range(utc(2024, 5, 20, 12, 0, 0)));
assert!(s.is_in_same_range(utc(2024, 5, 20, 23, 0, 0), utc(2024, 5, 21, 3, 0, 0)));
assert!(!s.is_in_same_range(utc(2024, 5, 20, 23, 0, 0), utc(2024, 5, 21, 23, 30, 0)));
}
#[test]
fn weekly_window() {
let s = Schedule::parse(
Some("00:00:00"),
Some("17:00:00"),
Some("Sunday"),
Some("Friday"),
false,
false,
)
.unwrap();
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)));
assert!(!s.is_in_same_range(utc(2024, 5, 24, 12, 0, 0), utc(2024, 5, 27, 12, 0, 0)));
}
#[test]
fn non_stop_never_resets() {
let s = Schedule::non_stop();
assert!(s.is_in_range(utc(2024, 5, 20, 3, 0, 0)));
assert!(s.is_in_same_range(utc(2024, 1, 1, 0, 0, 0), utc(2024, 12, 31, 23, 59, 59)));
}
}