shepherd-launcher/crates/shepherd-util/src/time.rs
2026-02-08 14:01:49 -05:00

642 lines
21 KiB
Rust

//! Time utilities for shepherdd
//!
//! Provides both monotonic time (for countdown enforcement) and
//! wall-clock time (for availability windows).
//!
//! # Mock Time for Development
//!
//! In debug builds, the `SHEPHERD_MOCK_TIME` environment variable can be set
//! to override the system time for all time-sensitive operations. This is useful
//! for testing availability windows and time-based policies.
//!
//! Format: `YYYY-MM-DD HH:MM:SS` (e.g., `2025-12-25 14:30:00`)
//!
//! Example:
//! ```bash
//! SHEPHERD_MOCK_TIME="2025-12-25 14:30:00" ./run-dev
//! ```
use chrono::{DateTime, Datelike, Local, NaiveDateTime, NaiveTime, TimeZone, Timelike, Weekday};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
/// Environment variable name for mock time (debug builds only)
pub const MOCK_TIME_ENV_VAR: &str = "SHEPHERD_MOCK_TIME";
/// Cached mock time offset from the real time when the process started.
/// This allows mock time to advance naturally.
static MOCK_TIME_OFFSET: OnceLock<Option<chrono::Duration>> = OnceLock::new();
/// Initialize the mock time offset based on the environment variable.
/// Returns the offset between mock time and real time at process start.
#[allow(clippy::disallowed_methods)] // This is the internal implementation that wraps Local::now()
fn get_mock_time_offset() -> Option<chrono::Duration> {
*MOCK_TIME_OFFSET.get_or_init(|| {
#[cfg(debug_assertions)]
{
if let Ok(mock_time_str) = std::env::var(MOCK_TIME_ENV_VAR) {
// Parse the mock time string
if let Ok(naive_dt) =
NaiveDateTime::parse_from_str(&mock_time_str, "%Y-%m-%d %H:%M:%S")
{
if let Some(mock_dt) = Local.from_local_datetime(&naive_dt).single() {
let real_now = chrono::Local::now();
let offset = mock_dt.signed_duration_since(real_now);
tracing::info!(
mock_time = %mock_time_str,
offset_secs = offset.num_seconds(),
"Mock time enabled"
);
return Some(offset);
} else {
tracing::warn!(
mock_time = %mock_time_str,
"Failed to convert mock time to local timezone"
);
}
} else {
tracing::warn!(
mock_time = %mock_time_str,
expected_format = "%Y-%m-%d %H:%M:%S",
"Invalid mock time format"
);
}
}
None
}
#[cfg(not(debug_assertions))]
{
None
}
})
}
/// Returns whether mock time is currently active.
pub fn is_mock_time_active() -> bool {
get_mock_time_offset().is_some()
}
/// Get the current local time, respecting mock time settings in debug builds.
///
/// In release builds, this always returns the real system time.
/// In debug builds, if `SHEPHERD_MOCK_TIME` is set, this returns a time
/// that advances from the mock time at the same rate as real time.
#[allow(clippy::disallowed_methods)] // This is the wrapper that provides mock time support
pub fn now() -> DateTime<Local> {
let real_now = chrono::Local::now();
if let Some(offset) = get_mock_time_offset() {
real_now + offset
} else {
real_now
}
}
/// Format a DateTime for display in the HUD clock.
/// Always uses 12-hour format with AM/PM, ignoring system locale.
pub fn format_clock_time(dt: &DateTime<Local>) -> String {
dt.format("%l:%M %p").to_string()
}
/// Format a DateTime for display with full date and time.
pub fn format_datetime_full(dt: &DateTime<Local>) -> String {
dt.format("%Y-%m-%d %l:%M:%S %p").to_string()
}
/// Represents a point in monotonic time for countdown enforcement.
/// This is immune to wall-clock changes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct MonotonicInstant(Instant);
impl MonotonicInstant {
pub fn now() -> Self {
Self(Instant::now())
}
pub fn elapsed(&self) -> Duration {
self.0.elapsed()
}
pub fn duration_since(&self, earlier: MonotonicInstant) -> Duration {
self.0.duration_since(earlier.0)
}
pub fn checked_add(&self, duration: Duration) -> Option<MonotonicInstant> {
self.0.checked_add(duration).map(MonotonicInstant)
}
/// Returns duration until `self`, or zero if `self` is in the past
pub fn saturating_duration_until(&self, from: MonotonicInstant) -> Duration {
if self.0 > from.0 {
self.0.duration_since(from.0)
} else {
Duration::ZERO
}
}
}
impl std::ops::Add<Duration> for MonotonicInstant {
type Output = MonotonicInstant;
fn add(self, rhs: Duration) -> Self::Output {
MonotonicInstant(self.0 + rhs)
}
}
/// Wall-clock time for availability windows
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WallClock {
pub hour: u8,
pub minute: u8,
}
impl WallClock {
pub fn new(hour: u8, minute: u8) -> Option<Self> {
if hour < 24 && minute < 60 {
Some(Self { hour, minute })
} else {
None
}
}
pub fn to_naive_time(self) -> NaiveTime {
NaiveTime::from_hms_opt(self.hour as u32, self.minute as u32, 0).unwrap()
}
pub fn from_naive_time(time: NaiveTime) -> Self {
Self {
hour: time.hour() as u8,
minute: time.minute() as u8,
}
}
/// Returns seconds since midnight
pub fn as_seconds_from_midnight(&self) -> u32 {
(self.hour as u32) * 3600 + (self.minute as u32) * 60
}
}
impl PartialOrd for WallClock {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for WallClock {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.as_seconds_from_midnight()
.cmp(&other.as_seconds_from_midnight())
}
}
/// Days of the week mask
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DaysOfWeek(u8);
impl DaysOfWeek {
pub const MONDAY: u8 = 1 << 0;
pub const TUESDAY: u8 = 1 << 1;
pub const WEDNESDAY: u8 = 1 << 2;
pub const THURSDAY: u8 = 1 << 3;
pub const FRIDAY: u8 = 1 << 4;
pub const SATURDAY: u8 = 1 << 5;
pub const SUNDAY: u8 = 1 << 6;
pub const WEEKDAYS: DaysOfWeek =
DaysOfWeek(Self::MONDAY | Self::TUESDAY | Self::WEDNESDAY | Self::THURSDAY | Self::FRIDAY);
pub const WEEKENDS: DaysOfWeek = DaysOfWeek(Self::SATURDAY | Self::SUNDAY);
pub const ALL_DAYS: DaysOfWeek = DaysOfWeek(0x7F);
pub const NONE: DaysOfWeek = DaysOfWeek(0);
pub fn new(mask: u8) -> Self {
Self(mask & 0x7F)
}
pub fn contains(&self, weekday: Weekday) -> bool {
let bit = match weekday {
Weekday::Mon => Self::MONDAY,
Weekday::Tue => Self::TUESDAY,
Weekday::Wed => Self::WEDNESDAY,
Weekday::Thu => Self::THURSDAY,
Weekday::Fri => Self::FRIDAY,
Weekday::Sat => Self::SATURDAY,
Weekday::Sun => Self::SUNDAY,
};
(self.0 & bit) != 0
}
pub fn is_empty(&self) -> bool {
self.0 == 0
}
}
impl std::ops::BitOr for DaysOfWeek {
type Output = Self;
fn bitor(self, rhs: Self) -> Self::Output {
Self(self.0 | rhs.0)
}
}
/// A time window during which an entry is available
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimeWindow {
pub days: DaysOfWeek,
pub start: WallClock,
pub end: WallClock,
}
impl TimeWindow {
pub fn new(days: DaysOfWeek, start: WallClock, end: WallClock) -> Self {
Self { days, start, end }
}
/// Check if the given local datetime falls within this window
pub fn contains(&self, dt: &DateTime<Local>) -> bool {
let weekday = dt.weekday();
if !self.days.contains(weekday) {
return false;
}
let time = WallClock::from_naive_time(dt.time());
// Handle windows that don't cross midnight
if self.start <= self.end {
time >= self.start && time < self.end
} else {
// Window crosses midnight (e.g., 22:00 - 02:00)
time >= self.start || time < self.end
}
}
/// Calculate duration remaining in this window from the given time
pub fn remaining_duration(&self, dt: &DateTime<Local>) -> Option<Duration> {
if !self.contains(dt) {
return None;
}
let now_time = WallClock::from_naive_time(dt.time());
let now_secs = now_time.as_seconds_from_midnight();
let end_secs = self.end.as_seconds_from_midnight();
let remaining_secs = if self.start <= self.end {
// Normal window
end_secs.saturating_sub(now_secs)
} else {
// Cross-midnight window
if now_secs >= self.start.as_seconds_from_midnight() {
// We're in the evening portion, count until midnight then add morning
(86400 - now_secs) + end_secs
} else {
// We're in the morning portion
end_secs.saturating_sub(now_secs)
}
};
Some(Duration::from_secs(remaining_secs as u64))
}
}
/// Helper to format durations in human-readable form
pub fn format_duration(d: Duration) -> String {
let total_secs = d.as_secs();
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{}h {}m {}s", hours, minutes, seconds)
} else if minutes > 0 {
format!("{}m {}s", minutes, seconds)
} else {
format!("{}s", seconds)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_wall_clock_ordering() {
let morning = WallClock::new(8, 0).unwrap();
let noon = WallClock::new(12, 0).unwrap();
let evening = WallClock::new(18, 30).unwrap();
assert!(morning < noon);
assert!(noon < evening);
assert!(morning < evening);
}
#[test]
fn test_days_of_week() {
let weekdays = DaysOfWeek::WEEKDAYS;
assert!(weekdays.contains(Weekday::Mon));
assert!(weekdays.contains(Weekday::Fri));
assert!(!weekdays.contains(Weekday::Sat));
assert!(!weekdays.contains(Weekday::Sun));
let weekends = DaysOfWeek::WEEKENDS;
assert!(!weekends.contains(Weekday::Mon));
assert!(weekends.contains(Weekday::Sat));
assert!(weekends.contains(Weekday::Sun));
}
#[test]
fn test_time_window_contains() {
let window = TimeWindow::new(
DaysOfWeek::WEEKDAYS,
WallClock::new(14, 0).unwrap(), // 2 PM
WallClock::new(18, 0).unwrap(), // 6 PM
);
// Monday at 3 PM - should be in window
let dt = Local.with_ymd_and_hms(2025, 12, 29, 15, 0, 0).unwrap(); // Monday
assert!(window.contains(&dt));
// Monday at 10 AM - outside window
let dt = Local.with_ymd_and_hms(2025, 12, 29, 10, 0, 0).unwrap();
assert!(!window.contains(&dt));
// Saturday at 3 PM - wrong day
let dt = Local.with_ymd_and_hms(2025, 12, 27, 15, 0, 0).unwrap();
assert!(!window.contains(&dt));
}
#[test]
fn test_time_window_remaining() {
let window = TimeWindow::new(
DaysOfWeek::ALL_DAYS,
WallClock::new(14, 0).unwrap(),
WallClock::new(18, 0).unwrap(),
);
let dt = Local.with_ymd_and_hms(2025, 12, 26, 15, 0, 0).unwrap(); // 3 PM
let remaining = window.remaining_duration(&dt).unwrap();
assert_eq!(remaining, Duration::from_secs(3 * 3600)); // 3 hours
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(Duration::from_secs(30)), "30s");
assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
assert_eq!(format_duration(Duration::from_secs(3661)), "1h 1m 1s");
}
#[test]
fn test_monotonic_instant() {
let t1 = MonotonicInstant::now();
std::thread::sleep(Duration::from_millis(10));
let t2 = MonotonicInstant::now();
assert!(t2 > t1);
assert!(t2.duration_since(t1) >= Duration::from_millis(10));
}
#[test]
fn test_format_clock_time() {
let dt = Local.with_ymd_and_hms(2025, 12, 25, 14, 30, 45).unwrap();
assert_eq!(format_clock_time(&dt), " 2:30 PM");
}
#[test]
fn test_format_datetime_full() {
let dt = Local.with_ymd_and_hms(2025, 12, 25, 14, 30, 45).unwrap();
assert_eq!(format_datetime_full(&dt), "2025-12-25 2:30:45 PM");
}
#[test]
fn test_now_returns_time() {
// Basic test that now() returns a valid time
let t = now();
// Should be a reasonable year (after 2020, before 2100)
assert!(t.year() >= 2020);
assert!(t.year() <= 2100);
}
#[test]
fn test_mock_time_env_var_name() {
// Verify the environment variable name is correct
assert_eq!(MOCK_TIME_ENV_VAR, "SHEPHERD_MOCK_TIME");
}
#[test]
fn test_parse_mock_time_format() {
// Test that the expected format parses correctly
let valid_formats = [
"2025-12-25 14:30:00",
"2025-01-01 00:00:00",
"2025-12-31 23:59:59",
"2020-06-15 12:00:00",
];
for format_str in &valid_formats {
let result = NaiveDateTime::parse_from_str(format_str, "%Y-%m-%d %H:%M:%S");
assert!(
result.is_ok(),
"Expected '{}' to parse successfully, got {:?}",
format_str,
result
);
}
}
#[test]
fn test_parse_mock_time_invalid_formats() {
// Test that invalid formats are rejected
let invalid_formats = [
"2025-12-25", // Missing time
"14:30:00", // Missing date
"2025/12/25 14:30:00", // Wrong date separator
"2025-12-25T14:30:00", // ISO format (not supported)
"Dec 25, 2025 14:30", // Wrong format
"25-12-2025 14:30:00", // Wrong date order
"", // Empty string
"not a date", // Invalid string
];
for format_str in &invalid_formats {
let result = NaiveDateTime::parse_from_str(format_str, "%Y-%m-%d %H:%M:%S");
assert!(
result.is_err(),
"Expected '{}' to fail parsing, but it succeeded",
format_str
);
}
}
#[test]
#[allow(clippy::disallowed_methods)] // Testing the offset calculation requires real time
fn test_mock_time_offset_calculation() {
// Test that the offset calculation works correctly
let mock_time_str = "2025-12-25 14:30:00";
let naive_dt = NaiveDateTime::parse_from_str(mock_time_str, "%Y-%m-%d %H:%M:%S").unwrap();
let mock_dt = Local.from_local_datetime(&naive_dt).single().unwrap();
let real_now = chrono::Local::now();
let offset = mock_dt.signed_duration_since(real_now);
// The offset should be applied correctly
let simulated_now = real_now + offset;
// The simulated time should be very close to the mock time
// (within a second, accounting for test execution time)
let diff = (simulated_now - mock_dt).num_seconds().abs();
assert!(
diff <= 1,
"Expected simulated time to be within 1 second of mock time, got {} seconds difference",
diff
);
}
#[test]
#[allow(clippy::disallowed_methods)] // Testing time advancement requires real time
fn test_mock_time_advances_with_real_time() {
// Test that mock time advances at the same rate as real time
// This tests the concept, not the actual implementation (since OnceLock is static)
let mock_time_str = "2025-12-25 14:30:00";
let naive_dt = NaiveDateTime::parse_from_str(mock_time_str, "%Y-%m-%d %H:%M:%S").unwrap();
let mock_dt = Local.from_local_datetime(&naive_dt).single().unwrap();
let real_t1 = chrono::Local::now();
let offset = mock_dt.signed_duration_since(real_t1);
// Simulate time passing
std::thread::sleep(Duration::from_millis(100));
let real_t2 = chrono::Local::now();
let simulated_t1 = real_t1 + offset;
let simulated_t2 = real_t2 + offset;
// The simulated times should have advanced by the same amount as real times
let real_elapsed = real_t2.signed_duration_since(real_t1);
let simulated_elapsed = simulated_t2.signed_duration_since(simulated_t1);
assert_eq!(
real_elapsed.num_milliseconds(),
simulated_elapsed.num_milliseconds(),
"Mock time should advance at the same rate as real time"
);
}
#[test]
fn test_availability_with_specific_time() {
// Test that availability windows work correctly with a specific time
// This validates that the mock time would affect availability checks
let window = TimeWindow::new(
DaysOfWeek::ALL_DAYS,
WallClock::new(14, 0).unwrap(), // 2 PM
WallClock::new(18, 0).unwrap(), // 6 PM
);
// Time within window
let in_window = Local.with_ymd_and_hms(2025, 12, 25, 15, 0, 0).unwrap();
assert!(
window.contains(&in_window),
"15:00 should be within 14:00-18:00 window"
);
// Time before window
let before_window = Local.with_ymd_and_hms(2025, 12, 25, 10, 0, 0).unwrap();
assert!(
!window.contains(&before_window),
"10:00 should be before 14:00-18:00 window"
);
// Time after window
let after_window = Local.with_ymd_and_hms(2025, 12, 25, 20, 0, 0).unwrap();
assert!(
!window.contains(&after_window),
"20:00 should be after 14:00-18:00 window"
);
}
#[test]
fn test_availability_with_day_restriction() {
// Test that day-of-week restrictions work correctly
let window = TimeWindow::new(
DaysOfWeek::WEEKDAYS,
WallClock::new(14, 0).unwrap(),
WallClock::new(18, 0).unwrap(),
);
// Thursday at 3 PM - should be available (weekday, in time window)
let thursday = Local.with_ymd_and_hms(2025, 12, 25, 15, 0, 0).unwrap(); // Christmas 2025 is Thursday
assert!(
window.contains(&thursday),
"Thursday 15:00 should be in weekday afternoon window"
);
// Saturday at 3 PM - should NOT be available (weekend)
let saturday = Local.with_ymd_and_hms(2025, 12, 27, 15, 0, 0).unwrap();
assert!(
!window.contains(&saturday),
"Saturday should not be in weekday window"
);
// Sunday at 3 PM - should NOT be available (weekend)
let sunday = Local.with_ymd_and_hms(2025, 12, 28, 15, 0, 0).unwrap();
assert!(
!window.contains(&sunday),
"Sunday should not be in weekday window"
);
}
}
/// Tests that require running in a separate process to test environment variable handling.
/// These are integration-style tests for the mock time feature.
#[cfg(test)]
mod mock_time_integration_tests {
use super::*;
/// This test documents the expected behavior of the mock time feature.
/// Due to the static OnceLock, actual integration testing requires
/// running with the environment variable set externally.
///
/// To manually test:
/// ```bash
/// SHEPHERD_MOCK_TIME="2025-12-25 14:30:00" cargo test
/// ```
#[test]
fn test_mock_time_documentation() {
// This test verifies the mock time constants and expected behavior
assert_eq!(MOCK_TIME_ENV_VAR, "SHEPHERD_MOCK_TIME");
// The expected format is documented
let expected_format = "%Y-%m-%d %H:%M:%S";
let example = "2025-12-25 14:30:00";
assert!(NaiveDateTime::parse_from_str(example, expected_format).is_ok());
}
#[test]
#[cfg(debug_assertions)]
fn test_is_mock_time_active_in_debug() {
// In debug mode, is_mock_time_active() should return based on env var
// Since we can't control the env var within a single test run due to OnceLock,
// we just verify the function doesn't panic
let _ = is_mock_time_active();
}
#[test]
fn test_now_consistency() {
// now() should return consistent, advancing times
let t1 = now();
std::thread::sleep(Duration::from_millis(50));
let t2 = now();
// t2 should be after t1
assert!(t2 > t1, "Time should advance forward");
// The difference should be approximately 50ms (with some tolerance)
let diff = t2.signed_duration_since(t1);
assert!(
diff.num_milliseconds() >= 40 && diff.num_milliseconds() <= 200,
"Expected ~50ms difference, got {}ms",
diff.num_milliseconds()
);
}
}