cloudiful-scheduler 0.4.2

Single-job async scheduling library for background work with optional Valkey-backed state.
Documentation
use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
use chrono_tz::Tz;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunSkipReason {
    OutsideTimeWindow,
}

impl RunSkipReason {
    pub fn as_str(self) -> &'static str {
        match self {
            RunSkipReason::OutsideTimeWindow => "outside_time_window",
        }
    }
}

impl std::fmt::Display for RunSkipReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeWindowSegment {
    pub start: NaiveTime,
    pub end: NaiveTime,
}

impl TimeWindowSegment {
    pub fn new(start: NaiveTime, end: NaiveTime) -> Self {
        Self { start, end }
    }

    fn contains_time(&self, time: NaiveTime) -> bool {
        if self.start <= self.end {
            time >= self.start && time < self.end
        } else {
            time >= self.start || time < self.end
        }
    }

    fn effective_weekday(&self, weekday: Weekday, time: NaiveTime) -> Weekday {
        if self.start <= self.end || time >= self.start {
            weekday
        } else {
            previous_weekday(weekday)
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct JobTimeWindow {
    pub timezone: Option<Tz>,
    pub weekdays: Vec<Weekday>,
    pub segments: Vec<TimeWindowSegment>,
}

impl JobTimeWindow {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn matches(&self, now: DateTime<Utc>, fallback_timezone: Tz) -> bool {
        let timezone = self.timezone.unwrap_or(fallback_timezone);
        let local = now.with_timezone(&timezone);
        self.matches_local(local)
    }

    fn matches_local(&self, local: DateTime<Tz>) -> bool {
        let weekday = local.weekday();
        let time = local.time();

        if self.segments.is_empty() {
            return self.weekdays.is_empty() || self.weekdays.contains(&weekday);
        }

        self.segments.iter().any(|segment| {
            if !segment.contains_time(time) {
                return false;
            }

            self.weekdays.is_empty()
                || self
                    .weekdays
                    .contains(&segment.effective_weekday(weekday, time))
        })
    }
}

fn previous_weekday(weekday: Weekday) -> Weekday {
    match weekday {
        Weekday::Mon => Weekday::Sun,
        Weekday::Tue => Weekday::Mon,
        Weekday::Wed => Weekday::Tue,
        Weekday::Thu => Weekday::Wed,
        Weekday::Fri => Weekday::Thu,
        Weekday::Sat => Weekday::Fri,
        Weekday::Sun => Weekday::Sat,
    }
}

#[cfg(test)]
mod tests {
    use super::{JobTimeWindow, RunSkipReason, TimeWindowSegment};
    use chrono::{NaiveTime, TimeZone, Utc, Weekday};
    use chrono_tz::{Asia::Shanghai, UTC};

    #[test]
    fn fallback_timezone_is_used_when_window_timezone_is_missing() {
        let window = JobTimeWindow {
            timezone: None,
            weekdays: vec![Weekday::Sat],
            segments: vec![TimeWindowSegment::new(
                NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
                NaiveTime::from_hms_opt(1, 0, 0).unwrap(),
            )],
        };
        let instant = Utc.with_ymd_and_hms(2026, 5, 8, 16, 30, 0).unwrap();

        assert!(!window.matches(instant, UTC));
        assert!(window.matches(instant, Shanghai));
    }

    #[test]
    fn same_day_segments_match_by_local_time() {
        let window = JobTimeWindow {
            timezone: Some(UTC),
            weekdays: vec![Weekday::Fri],
            segments: vec![
                TimeWindowSegment::new(
                    NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
                    NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
                ),
                TimeWindowSegment::new(
                    NaiveTime::from_hms_opt(13, 0, 0).unwrap(),
                    NaiveTime::from_hms_opt(14, 0, 0).unwrap(),
                ),
            ],
        };

        assert!(window.matches(Utc.with_ymd_and_hms(2026, 5, 8, 13, 30, 0).unwrap(), UTC));
        assert!(!window.matches(Utc.with_ymd_and_hms(2026, 5, 8, 11, 30, 0).unwrap(), UTC));
    }

    #[test]
    fn cross_midnight_segments_use_the_effective_local_day() {
        let window = JobTimeWindow {
            timezone: Some(UTC),
            weekdays: vec![Weekday::Thu],
            segments: vec![TimeWindowSegment::new(
                NaiveTime::from_hms_opt(22, 0, 0).unwrap(),
                NaiveTime::from_hms_opt(2, 0, 0).unwrap(),
            )],
        };

        assert!(window.matches(Utc.with_ymd_and_hms(2026, 5, 8, 1, 0, 0).unwrap(), UTC));
        assert!(!window.matches(Utc.with_ymd_and_hms(2026, 5, 8, 3, 0, 0).unwrap(), UTC));
    }

    #[test]
    fn run_skip_reason_has_expected_string_form() {
        assert_eq!(
            RunSkipReason::OutsideTimeWindow.as_str(),
            "outside_time_window"
        );
        assert_eq!(
            RunSkipReason::OutsideTimeWindow.to_string(),
            "outside_time_window"
        );
    }
}