use crate::config::ScheduleConfig;
use crate::session::error::SessionCreationError;
use chrono::{DateTime, Datelike, Days, NaiveDate, NaiveTime, TimeDelta, Utc, Weekday};
use chrono_tz::Tz;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionPeriodComparison {
SamePeriod,
DifferentPeriod,
OutsideSessionTime { which: WhichTime },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WhichTime {
First,
Second,
Both,
}
#[derive(Debug, Error)]
pub enum ScheduleError {
#[error("ambiguous or missing time: {date} {time} in timezone {timezone} (DST transition)")]
AmbiguousOrMissingTime {
date: NaiveDate,
time: NaiveTime,
timezone: Tz,
},
#[error("date calculation overflow: {context}")]
DateCalculationOverflow { context: String },
}
#[derive(Clone, Debug)]
pub enum SessionSchedule {
NonStop,
Daily {
start_time: NaiveTime,
end_time: NaiveTime,
timezone: Tz,
},
Weekdays {
start_time: NaiveTime,
end_time: NaiveTime,
weekdays: Vec<Weekday>,
timezone: Tz,
},
#[allow(dead_code)]
Weekly {
start_day: Weekday,
start_time: NaiveTime,
end_day: Weekday,
end_time: NaiveTime,
timezone: Tz,
},
}
impl SessionSchedule {
pub fn is_active_at(&self, datetime: &DateTime<Utc>) -> bool {
match self {
SessionSchedule::NonStop => true,
SessionSchedule::Daily {
start_time,
end_time,
timezone,
} => {
let adjusted_datetime = datetime.with_timezone(timezone);
Self::check_daily_schedule(&adjusted_datetime, start_time, end_time)
}
SessionSchedule::Weekdays {
weekdays,
start_time,
end_time,
timezone,
} => {
let adjusted_datetime = datetime.with_timezone(timezone);
Self::check_weekdays_schedule(&adjusted_datetime, weekdays, start_time, end_time)
}
SessionSchedule::Weekly { .. } => false,
}
}
pub fn is_same_session_period(
&self,
dt1: &DateTime<Utc>,
dt2: &DateTime<Utc>,
) -> Result<SessionPeriodComparison, ScheduleError> {
let dt1_active = self.is_active_at(dt1);
let dt2_active = self.is_active_at(dt2);
if !dt1_active || !dt2_active {
let which = match (dt1_active, dt2_active) {
(false, false) => WhichTime::Both,
(false, true) => WhichTime::First,
(true, false) => WhichTime::Second,
(true, true) => unreachable!(),
};
return Ok(SessionPeriodComparison::OutsideSessionTime { which });
}
let (start, end) = self.get_session_bounds(dt1)?;
if start <= *dt2 && *dt2 < end {
Ok(SessionPeriodComparison::SamePeriod)
} else {
Ok(SessionPeriodComparison::DifferentPeriod)
}
}
fn get_session_bounds(
&self,
datetime: &DateTime<Utc>,
) -> Result<(DateTime<Utc>, DateTime<Utc>), ScheduleError> {
match self {
SessionSchedule::NonStop => {
Ok((DateTime::default(), Utc::now() + TimeDelta::weeks(1000)))
}
SessionSchedule::Daily {
start_time,
end_time,
timezone,
} => calculate_single_day_session_bounds(datetime, start_time, end_time, timezone),
SessionSchedule::Weekdays {
start_time,
end_time,
timezone,
weekdays: _,
} => calculate_single_day_session_bounds(datetime, start_time, end_time, timezone),
SessionSchedule::Weekly { .. } => unimplemented!(),
}
}
fn check_daily_schedule(
datetime: &DateTime<Tz>,
start_time: &NaiveTime,
end_time: &NaiveTime,
) -> bool {
if start_time < end_time {
&datetime.time() >= start_time && &datetime.time() < end_time
} else {
&datetime.time() >= start_time || &datetime.time() < end_time
}
}
fn check_weekdays_schedule(
datetime: &DateTime<Tz>,
weekdays: &[Weekday],
start_time: &NaiveTime,
end_time: &NaiveTime,
) -> bool {
let time_of_day = &datetime.time();
if start_time < end_time {
weekdays.contains(&datetime.weekday())
&& time_of_day >= start_time
&& time_of_day < end_time
} else {
if time_of_day >= end_time && time_of_day < start_time {
return false;
}
let target_day = if time_of_day >= start_time {
datetime.weekday()
} else {
datetime.weekday().pred()
};
weekdays.contains(&target_day)
}
}
#[allow(unused_variables)]
#[allow(dead_code)]
fn check_weekly_schedule(
datetime: &DateTime<Utc>,
start_day: Weekday,
end_day: Weekday,
) -> bool {
false
}
}
impl TryFrom<&ScheduleConfig> for SessionSchedule {
type Error = SessionCreationError;
fn try_from(config: &ScheduleConfig) -> Result<Self, Self::Error> {
match config {
ScheduleConfig {
start_time: None,
end_time: None,
start_day: None,
end_day: None,
weekdays,
timezone: None,
} if weekdays.is_empty() => Ok(SessionSchedule::NonStop),
ScheduleConfig {
start_time: Some(start),
end_time: Some(end),
start_day: None,
end_day: None,
weekdays,
timezone,
} => {
if weekdays.is_empty() {
if start == end {
Ok(SessionSchedule::NonStop)
} else {
Ok(SessionSchedule::Daily {
start_time: *start,
end_time: *end,
timezone: timezone.unwrap_or(Tz::UTC),
})
}
} else if start == end {
Err(SessionCreationError::InvalidSchedule(
"Start and end times cannot be equal when weekdays is set".to_string(),
))
} else {
Ok(SessionSchedule::Weekdays {
start_time: *start,
end_time: *end,
weekdays: weekdays.clone(),
timezone: timezone.unwrap_or(Tz::UTC),
})
}
}
ScheduleConfig {
start_day: Some(start_day),
start_time: Some(start),
end_day: Some(end_day),
end_time: Some(end),
weekdays,
timezone,
} => {
if !weekdays.is_empty() {
return Err(SessionCreationError::InvalidSchedule(
"weekly sessions cannot have weekdays specified".to_string(),
));
}
let _ = SessionSchedule::Weekly {
start_day: *start_day,
start_time: *start,
end_day: *end_day,
end_time: *end,
timezone: timezone.unwrap_or(Tz::UTC),
};
Err(SessionCreationError::InvalidSchedule(
"weekly sessions are not supported yet".to_string(),
))
}
_ => Err(SessionCreationError::InvalidSchedule(
"invalid schedule configuration: incomplete or conflicting parameters".to_string(),
)),
}
}
}
impl TryFrom<Option<&ScheduleConfig>> for SessionSchedule {
type Error = SessionCreationError;
fn try_from(maybe_schedule: Option<&ScheduleConfig>) -> Result<Self, Self::Error> {
match maybe_schedule {
None => Ok(SessionSchedule::NonStop),
Some(session_config) => session_config.try_into(),
}
}
}
fn construct_utc(
date: &NaiveDate,
time: &NaiveTime,
timezone: &Tz,
) -> Result<DateTime<Utc>, ScheduleError> {
if let Some(dt) = date.and_time(*time).and_local_timezone(*timezone).single() {
Ok(dt.to_utc())
} else {
Err(ScheduleError::AmbiguousOrMissingTime {
date: *date,
time: *time,
timezone: *timezone,
})
}
}
fn calculate_single_day_session_bounds(
datetime: &DateTime<Utc>,
start_time: &NaiveTime,
end_time: &NaiveTime,
timezone: &Tz,
) -> Result<(DateTime<Utc>, DateTime<Utc>), ScheduleError> {
let local_datetime = datetime.with_timezone(timezone);
if local_datetime.time() >= *start_time {
let start = construct_utc(&local_datetime.date_naive(), start_time, timezone)?;
let end_date = if end_time < start_time {
local_datetime
.date_naive()
.checked_add_days(Days::new(1))
.ok_or_else(|| ScheduleError::DateCalculationOverflow {
context: "failed to add day for end date".to_string(),
})?
} else {
local_datetime.date_naive()
};
let end = construct_utc(&end_date, end_time, timezone)?;
Ok((start, end))
} else {
let start_date = local_datetime
.date_naive()
.checked_sub_days(Days::new(1))
.ok_or_else(|| ScheduleError::DateCalculationOverflow {
context: "failed to get previous day for start date".to_string(),
})?;
let start = construct_utc(&start_date, start_time, timezone)?;
let end = construct_utc(&local_datetime.date_naive(), end_time, timezone)?;
Ok((start, end))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveTime, Weekday};
#[test]
fn test_active_at_non_stop_schedule() {
let schedule = SessionSchedule::NonStop;
assert!(schedule.is_active_at(&Utc::now()))
}
#[test]
fn test_active_at_daily_schedule_utc() {
let schedule = SessionSchedule::Daily {
start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
timezone: Tz::UTC,
};
let before_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(8, 59, 59)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&before_start));
let after_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(9, 0, 1)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&after_start));
let middle = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(13, 0, 0)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&middle));
let before_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(16, 59, 59)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&before_end));
let at_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(17, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&at_end));
let after_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(17, 0, 1)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&after_end));
}
#[test]
fn test_active_at_daily_schedule_london() {
let schedule = SessionSchedule::Daily {
start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
timezone: Tz::Europe__London,
};
let before_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(7, 59, 59)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&before_start));
let at_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(8, 0, 0)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&at_start));
let at_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(16, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&at_end));
}
#[test]
fn test_active_at_daily_schedule_london_end_before_start() {
let schedule_1 = SessionSchedule::Daily {
start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(2, 0, 0).unwrap(),
timezone: Tz::Europe__London,
};
let before_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(7, 59, 59)
.unwrap(),
Utc,
);
assert!(!schedule_1.is_active_at(&before_start));
let at_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(8, 0, 0)
.unwrap(),
Utc,
);
assert!(schedule_1.is_active_at(&at_start));
let before_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(0, 59, 59)
.unwrap(),
Utc,
);
assert!(schedule_1.is_active_at(&before_end));
let at_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(1, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule_1.is_active_at(&at_end));
}
#[test]
fn test_active_at_daily_schedule_london_end_before_start_tz_crossing_midnight() {
let schedule_1 = SessionSchedule::Daily {
start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(0, 30, 0).unwrap(),
timezone: Tz::Europe__London,
};
let before_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(7, 59, 59)
.unwrap(),
Utc,
);
assert!(!schedule_1.is_active_at(&before_start));
let at_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(8, 0, 0)
.unwrap(),
Utc,
);
assert!(schedule_1.is_active_at(&at_start));
let before_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(23, 29, 59)
.unwrap(),
Utc,
);
assert!(schedule_1.is_active_at(&before_end));
let at_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(23, 30, 0)
.unwrap(),
Utc,
);
assert!(!schedule_1.is_active_at(&at_end));
}
#[test]
fn test_active_at_weekdays_schedule_utc() {
let schedule = SessionSchedule::Weekdays {
start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
weekdays: vec![
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
],
timezone: Tz::UTC,
};
let monday_before_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 30) .unwrap()
.and_hms_opt(8, 59, 59)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&monday_before_start));
let monday_after_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 30) .unwrap()
.and_hms_opt(9, 0, 1)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&monday_after_start));
let friday_before_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 4) .unwrap()
.and_hms_opt(16, 59, 59)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&friday_before_end));
let friday_at_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 4) .unwrap()
.and_hms_opt(17, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&friday_at_end));
let saturday = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 5) .unwrap()
.and_hms_opt(12, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&saturday));
}
#[test]
fn test_active_at_weekdays_schedule_london() {
let schedule = SessionSchedule::Weekdays {
start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
weekdays: vec![
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
],
timezone: Tz::Europe__London,
};
let monday_before_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 30) .unwrap()
.and_hms_opt(7, 59, 59)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&monday_before_start));
let monday_after_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 30) .unwrap()
.and_hms_opt(8, 0, 1)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&monday_after_start));
let friday_before_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 4) .unwrap()
.and_hms_opt(15, 59, 59)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&friday_before_end));
let friday_at_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 4) .unwrap()
.and_hms_opt(16, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&friday_at_end));
}
#[test]
fn test_active_at_weekdays_schedule_newyork() {
let schedule = SessionSchedule::Weekdays {
start_time: NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
weekdays: vec![
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
],
timezone: Tz::America__New_York,
};
let monday_before_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 30) .unwrap()
.and_hms_opt(13, 29, 59)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&monday_before_start));
let monday_after_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 30) .unwrap()
.and_hms_opt(13, 30, 1)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&monday_after_start));
let tuesday_before_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 1) .unwrap()
.and_hms_opt(19, 59, 59)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&tuesday_before_end));
let tuesday_at_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 1) .unwrap()
.and_hms_opt(20, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&tuesday_at_end));
}
#[test]
fn test_active_at_weekdays_schedule_sydney_crossing_midnight() {
let schedule = SessionSchedule::Weekdays {
start_time: NaiveTime::from_hms_opt(22, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(6, 0, 0).unwrap(),
weekdays: vec![
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
],
timezone: Tz::Australia__Sydney,
};
let monday_before_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 30) .unwrap()
.and_hms_opt(11, 59, 59)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&monday_before_start));
let monday_after_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 30) .unwrap()
.and_hms_opt(12, 0, 1)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&monday_after_start));
let tuesday_before_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 1) .unwrap()
.and_hms_opt(19, 59, 59)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&tuesday_before_end));
let tuesday_at_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 1) .unwrap()
.and_hms_opt(20, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&tuesday_at_end));
let wednesday_inactive = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 2) .unwrap()
.and_hms_opt(10, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&wednesday_inactive));
}
#[test]
fn test_active_at_weekdays_schedule_only_weekend() {
let schedule = SessionSchedule::Weekdays {
start_time: NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
weekdays: vec![Weekday::Sat, Weekday::Sun],
timezone: Tz::UTC,
};
let friday = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 4) .unwrap()
.and_hms_opt(12, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&friday));
let saturday_before_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 5) .unwrap()
.and_hms_opt(9, 59, 59)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&saturday_before_start));
let saturday_after_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 5) .unwrap()
.and_hms_opt(10, 0, 1)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&saturday_after_start));
let sunday_before_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 6) .unwrap()
.and_hms_opt(15, 59, 59)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&sunday_before_end));
}
#[test]
fn test_active_at_weekdays_schedule_overnight_crossing_weekdays() {
let schedule = SessionSchedule::Weekdays {
start_time: NaiveTime::from_hms_opt(22, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(6, 0, 0).unwrap(),
weekdays: vec![Weekday::Mon, Weekday::Tue, Weekday::Wed, Weekday::Thu],
timezone: Tz::Europe__London,
};
let monday_before_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 30) .unwrap()
.and_hms_opt(20, 59, 59)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&monday_before_start));
let monday_after_start = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 30) .unwrap()
.and_hms_opt(21, 0, 1)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&monday_after_start));
let tuesday_before_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 1) .unwrap()
.and_hms_opt(4, 59, 59)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&tuesday_before_end));
let tuesday_at_end = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 1) .unwrap()
.and_hms_opt(5, 0, 0)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&tuesday_at_end));
let friday_night = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 4) .unwrap()
.and_hms_opt(21, 0, 1)
.unwrap(),
Utc,
);
assert!(!schedule.is_active_at(&friday_night));
let thursday_night = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 3) .unwrap()
.and_hms_opt(21, 0, 1)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&thursday_night));
let friday_morning = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 4) .unwrap()
.and_hms_opt(4, 59, 59)
.unwrap(),
Utc,
);
assert!(schedule.is_active_at(&friday_morning));
}
#[test]
fn test_into_non_stop_no_config() {
let config = ScheduleConfig {
start_time: None,
end_time: None,
start_day: None,
end_day: None,
weekdays: vec![],
timezone: None,
};
let schedule = SessionSchedule::try_from(&config).unwrap();
assert!(matches!(schedule, SessionSchedule::NonStop));
}
#[test]
fn test_into_non_stop_equal_times() {
let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
let config = ScheduleConfig {
start_time: Some(time),
end_time: Some(time),
start_day: None,
end_day: None,
weekdays: vec![],
timezone: None,
};
let schedule = SessionSchedule::try_from(&config).unwrap();
assert!(matches!(schedule, SessionSchedule::NonStop));
}
#[test]
fn test_into_daily_session() {
let config = ScheduleConfig {
start_time: Some(NaiveTime::from_hms_opt(9, 0, 0).unwrap()),
end_time: Some(NaiveTime::from_hms_opt(17, 0, 0).unwrap()),
start_day: None,
end_day: None,
weekdays: vec![],
timezone: None,
};
let schedule = SessionSchedule::try_from(&config).unwrap();
match schedule {
SessionSchedule::Daily {
start_time,
end_time,
timezone,
} => {
assert_eq!(start_time, NaiveTime::from_hms_opt(9, 0, 0).unwrap());
assert_eq!(end_time, NaiveTime::from_hms_opt(17, 0, 0).unwrap());
assert_eq!(timezone, Tz::UTC);
}
_ => panic!("Expected Daily schedule"),
}
}
#[test]
fn test_into_weekdays_session() {
let config = ScheduleConfig {
start_time: Some(NaiveTime::from_hms_opt(9, 0, 0).unwrap()),
end_time: Some(NaiveTime::from_hms_opt(17, 0, 0).unwrap()),
start_day: None,
end_day: None,
weekdays: vec![
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
],
timezone: Some(Tz::Europe__London),
};
let schedule = SessionSchedule::try_from(&config).unwrap();
match schedule {
SessionSchedule::Weekdays {
start_time,
end_time,
weekdays,
timezone,
} => {
assert_eq!(start_time, NaiveTime::from_hms_opt(9, 0, 0).unwrap());
assert_eq!(end_time, NaiveTime::from_hms_opt(17, 0, 0).unwrap());
assert_eq!(
weekdays,
vec![
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri
]
);
assert_eq!(timezone, Tz::Europe__London);
}
_ => panic!("Expected Weekdays schedule"),
}
}
#[test]
fn test_into_weekly_session_not_supported() {
let config = ScheduleConfig {
start_time: Some(NaiveTime::from_hms_opt(18, 0, 0).unwrap()),
end_time: Some(NaiveTime::from_hms_opt(17, 0, 0).unwrap()),
start_day: Some(Weekday::Sun),
end_day: Some(Weekday::Fri),
weekdays: vec![],
timezone: None,
};
let result = SessionSchedule::try_from(&config);
assert!(result.is_err());
match result.unwrap_err() {
SessionCreationError::InvalidSchedule(msg) => {
assert!(msg.contains("weekly sessions are not supported yet"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn test_into_weekly_session_with_equal_times_not_supported() {
let time = NaiveTime::from_hms_opt(12, 0, 0).unwrap();
let config = ScheduleConfig {
start_time: Some(time),
end_time: Some(time),
start_day: Some(Weekday::Mon),
end_day: Some(Weekday::Fri),
weekdays: vec![],
timezone: None,
};
let result = SessionSchedule::try_from(&config);
assert!(result.is_err());
match result.unwrap_err() {
SessionCreationError::InvalidSchedule(msg) => {
assert!(msg.contains("weekly sessions are not supported yet"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn test_into_invalid_weekly_with_weekdays() {
let config = ScheduleConfig {
start_time: Some(NaiveTime::from_hms_opt(9, 0, 0).unwrap()),
end_time: Some(NaiveTime::from_hms_opt(17, 0, 0).unwrap()),
start_day: Some(Weekday::Mon),
end_day: Some(Weekday::Fri),
weekdays: vec![Weekday::Mon],
timezone: None,
};
let result = SessionSchedule::try_from(&config);
assert!(result.is_err());
match result.unwrap_err() {
SessionCreationError::InvalidSchedule(msg) => {
assert!(msg.contains("weekly sessions cannot have weekdays specified"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn test_into_invalid_partial_config_start_time_only() {
let config = ScheduleConfig {
start_time: Some(NaiveTime::from_hms_opt(9, 0, 0).unwrap()),
end_time: None,
start_day: None,
end_day: None,
weekdays: vec![],
timezone: None,
};
let result = SessionSchedule::try_from(&config);
assert!(result.is_err());
}
#[test]
fn test_into_invalid_partial_config_end_time_only() {
let config = ScheduleConfig {
start_time: None,
end_time: Some(NaiveTime::from_hms_opt(17, 0, 0).unwrap()),
start_day: None,
end_day: None,
weekdays: vec![],
timezone: None,
};
let result = SessionSchedule::try_from(&config);
assert!(result.is_err());
}
#[test]
fn test_into_invalid_partial_config_start_day_only() {
let config = ScheduleConfig {
start_time: None,
end_time: None,
start_day: Some(Weekday::Mon),
end_day: None,
weekdays: vec![],
timezone: None,
};
let result = SessionSchedule::try_from(&config);
assert!(result.is_err());
}
#[test]
fn test_into_invalid_mixed_config() {
let config = ScheduleConfig {
start_time: Some(NaiveTime::from_hms_opt(9, 0, 0).unwrap()),
end_time: None,
start_day: Some(Weekday::Mon),
end_day: None,
weekdays: vec![],
timezone: None,
};
let result = SessionSchedule::try_from(&config);
assert!(result.is_err());
}
#[test]
fn test_into_weekdays_with_single_day() {
let config = ScheduleConfig {
start_time: Some(NaiveTime::from_hms_opt(9, 0, 0).unwrap()),
end_time: Some(NaiveTime::from_hms_opt(17, 0, 0).unwrap()),
start_day: None,
end_day: None,
weekdays: vec![Weekday::Sat],
timezone: None,
};
let schedule = SessionSchedule::try_from(&config).unwrap();
match schedule {
SessionSchedule::Weekdays { weekdays, .. } => {
assert_eq!(weekdays, vec![Weekday::Sat]);
}
_ => panic!("Expected Weekdays schedule"),
}
}
#[test]
fn test_into_weekdays_with_equal_times_is_invalid() {
let time = NaiveTime::from_hms_opt(10, 30, 0).unwrap();
let config = ScheduleConfig {
start_time: Some(time),
end_time: Some(time),
start_day: None,
end_day: None,
weekdays: vec![Weekday::Mon, Weekday::Wed, Weekday::Fri],
timezone: None,
};
let schedule = SessionSchedule::try_from(&config);
assert!(schedule.is_err());
}
#[test]
fn test_is_same_session_period_nonstop() {
let schedule = SessionSchedule::NonStop;
let dt1 = DateTime::parse_from_rfc3339("2025-01-15T01:30:00-05:00")
.unwrap()
.to_utc();
let dt2 = DateTime::parse_from_rfc3339("2026-01-15T23:30:00-05:00")
.unwrap()
.to_utc();
assert_eq!(
schedule.is_same_session_period(&dt1, &dt2).unwrap(),
SessionPeriodComparison::SamePeriod
);
}
#[test]
fn test_is_same_session_period_daily_utc() {
let schedule = SessionSchedule::Daily {
start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
timezone: Tz::UTC,
};
let dt1 = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(10, 0, 0)
.unwrap(),
Utc,
);
let dt2 = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(15, 0, 0)
.unwrap(),
Utc,
);
assert_eq!(
schedule.is_same_session_period(&dt1, &dt2).unwrap(),
SessionPeriodComparison::SamePeriod
);
assert_eq!(
schedule.is_same_session_period(&dt2, &dt1).unwrap(),
SessionPeriodComparison::SamePeriod
);
let dt3 = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 28)
.unwrap()
.and_hms_opt(10, 0, 0)
.unwrap(),
Utc,
);
assert_eq!(
schedule.is_same_session_period(&dt1, &dt3).unwrap(),
SessionPeriodComparison::DifferentPeriod
);
assert_eq!(
schedule.is_same_session_period(&dt3, &dt1).unwrap(),
SessionPeriodComparison::DifferentPeriod
);
let dt4 = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 6, 27)
.unwrap()
.and_hms_opt(19, 0, 0)
.unwrap(),
Utc,
);
assert!(matches!(
schedule.is_same_session_period(&dt1, &dt4).unwrap(),
SessionPeriodComparison::OutsideSessionTime {
which: WhichTime::Second
}
));
assert!(matches!(
schedule.is_same_session_period(&dt4, &dt1).unwrap(),
SessionPeriodComparison::OutsideSessionTime {
which: WhichTime::First
}
));
}
#[test]
fn test_is_same_session_period_daily_nyc() {
let schedule = SessionSchedule::Daily {
start_time: NaiveTime::from_hms_opt(1, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(23, 0, 0).unwrap(),
timezone: Tz::America__New_York,
};
let dt1 = DateTime::parse_from_rfc3339("2025-01-15T01:30:00-05:00")
.unwrap()
.to_utc();
let dt2 = DateTime::parse_from_rfc3339("2025-01-15T22:45:00-05:00")
.unwrap()
.to_utc();
assert_eq!(
schedule.is_same_session_period(&dt1, &dt2).unwrap(),
SessionPeriodComparison::SamePeriod
);
let dt3 = DateTime::parse_from_rfc3339("2024-01-15T22:30:00-05:00")
.unwrap()
.to_utc();
let dt4 = DateTime::parse_from_rfc3339("2024-01-16T02:30:00-05:00")
.unwrap()
.to_utc();
assert_eq!(
schedule.is_same_session_period(&dt3, &dt4).unwrap(),
SessionPeriodComparison::DifferentPeriod
);
let dt5 = DateTime::parse_from_rfc3339("2024-01-15T22:59:59-05:00")
.unwrap()
.to_utc();
let dt6 = DateTime::parse_from_rfc3339("2024-01-16T01:00:01-05:00")
.unwrap()
.to_utc();
assert_eq!(
schedule.is_same_session_period(&dt5, &dt6).unwrap(),
SessionPeriodComparison::DifferentPeriod
);
let dt7 = DateTime::parse_from_rfc3339("2024-01-15T23:30:00-05:00")
.unwrap()
.to_utc();
let dt8 = DateTime::parse_from_rfc3339("2024-01-15T10:00:00-05:00")
.unwrap()
.to_utc();
assert!(matches!(
schedule.is_same_session_period(&dt7, &dt8).unwrap(),
SessionPeriodComparison::OutsideSessionTime {
which: WhichTime::First
}
));
}
#[test]
fn test_is_same_session_period_daily_nyc_with_midnight_crossover() {
let schedule = SessionSchedule::Daily {
start_time: NaiveTime::from_hms_opt(6, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(1, 0, 0).unwrap(),
timezone: Tz::America__New_York,
};
let dt1 = DateTime::parse_from_rfc3339("2025-01-15T15:30:00-05:00")
.unwrap()
.to_utc();
let dt2 = DateTime::parse_from_rfc3339("2025-01-16T00:45:00-05:00")
.unwrap()
.to_utc();
assert_eq!(
schedule.is_same_session_period(&dt1, &dt2).unwrap(),
SessionPeriodComparison::SamePeriod
);
assert_eq!(
schedule.is_same_session_period(&dt2, &dt1).unwrap(),
SessionPeriodComparison::SamePeriod
);
let dt1 = DateTime::parse_from_rfc3339("2025-01-15T15:30:00-05:00")
.unwrap()
.to_utc();
let dt2 = DateTime::parse_from_rfc3339("2025-01-15T00:45:00-05:00")
.unwrap()
.to_utc();
assert_eq!(
schedule.is_same_session_period(&dt1, &dt2).unwrap(),
SessionPeriodComparison::DifferentPeriod
);
assert_eq!(
schedule.is_same_session_period(&dt2, &dt1).unwrap(),
SessionPeriodComparison::DifferentPeriod
);
}
#[test]
fn test_is_same_session_period_weekdays_utc() {
let schedule = SessionSchedule::Weekdays {
start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
weekdays: vec![
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
],
timezone: Tz::UTC,
};
let dt1 = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 10)
.unwrap()
.and_hms_opt(10, 0, 0)
.unwrap(),
Utc,
);
let dt2 = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 10)
.unwrap()
.and_hms_opt(15, 0, 0)
.unwrap(),
Utc,
);
assert_eq!(
schedule.is_same_session_period(&dt1, &dt2).unwrap(),
SessionPeriodComparison::SamePeriod
);
assert_eq!(
schedule.is_same_session_period(&dt2, &dt1).unwrap(),
SessionPeriodComparison::SamePeriod
);
let dt3 = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 11)
.unwrap()
.and_hms_opt(10, 0, 0)
.unwrap(),
Utc,
);
assert_eq!(
schedule.is_same_session_period(&dt1, &dt3).unwrap(),
SessionPeriodComparison::DifferentPeriod
);
assert_eq!(
schedule.is_same_session_period(&dt3, &dt1).unwrap(),
SessionPeriodComparison::DifferentPeriod
);
let dt4 = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 10)
.unwrap()
.and_hms_opt(19, 0, 0)
.unwrap(),
Utc,
);
assert!(matches!(
schedule.is_same_session_period(&dt1, &dt4).unwrap(),
SessionPeriodComparison::OutsideSessionTime {
which: WhichTime::Second
}
));
assert!(matches!(
schedule.is_same_session_period(&dt4, &dt1).unwrap(),
SessionPeriodComparison::OutsideSessionTime {
which: WhichTime::First
}
));
let dt5 = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2025, 7, 12)
.unwrap()
.and_hms_opt(13, 0, 0)
.unwrap(),
Utc,
);
assert!(matches!(
schedule.is_same_session_period(&dt1, &dt5).unwrap(),
SessionPeriodComparison::OutsideSessionTime {
which: WhichTime::Second
}
));
assert!(matches!(
schedule.is_same_session_period(&dt5, &dt1).unwrap(),
SessionPeriodComparison::OutsideSessionTime {
which: WhichTime::First
}
));
}
#[test]
fn construct_utc_at_gap() {
let date = NaiveDate::from_ymd_opt(2024, 3, 10).unwrap();
let time = NaiveTime::from_hms_opt(2, 30, 0).unwrap(); let timezone = chrono_tz::US::Eastern;
let result = construct_utc(&date, &time, &timezone);
assert!(matches!(
result,
Err(ScheduleError::AmbiguousOrMissingTime { .. })
));
}
#[test]
fn construct_utc_at_fold() {
let date = NaiveDate::from_ymd_opt(2024, 11, 3).unwrap();
let time = NaiveTime::from_hms_opt(1, 30, 0).unwrap(); let timezone = chrono_tz::US::Eastern;
let result = construct_utc(&date, &time, &timezone);
assert!(matches!(
result,
Err(ScheduleError::AmbiguousOrMissingTime { .. })
));
}
}