guard_core_engine/time_window.rs
1//! The route time-window gate: the reference pipeline's tenth check.
2//!
3//! This is the Rust family's port of
4//! `guard_core/core/checks/implementations/time_window.py`. A route's
5//! `time_restrictions` dict (`start`, `end`, optional `timezone`, all
6//! `"HH:MM"` / IANA strings) admits requests only inside its window:
7//!
8//! ```text
9//! current local HH:MM in the zone (invalid zone -> UTC):
10//! start > end: allowed when now >= start OR now <= end
11//! (the window wraps midnight)
12//! otherwise: allowed when start <= now <= end
13//! missing start/end, or any evaluation error: allowed
14//! (the reference's fail-open
15//! except arm, captured as
16//! normative by spec 03)
17//! ```
18//!
19//! The comparisons are plain `"HH:MM"` string comparisons, exactly the
20//! reference's `strftime("%H:MM")` ordering (lexicographic == chronological
21//! on the zero-padded form). Zone resolution uses the IANA database
22//! (`ZoneInfo` in the reference); a name the database does not know falls
23//! back to UTC, exactly the reference's `except` arm around `ZoneInfo`.
24//!
25//! # Example
26//!
27//! ```
28//! use guard_core_engine::time_window::{hhmm_in_zone, is_within, TimeWindow};
29//! use chrono::TimeZone;
30//!
31//! let window = TimeWindow { start: Some("09:00".into()), end: Some("17:00".into()), timezone: None };
32//! assert!(is_within(&window, "09:00"));
33//! assert!(is_within(&window, "12:30"));
34//! assert!(!is_within(&window, "17:01"));
35//!
36//! // A window past midnight wraps.
37//! let night = TimeWindow { start: Some("22:00".into()), end: Some("06:00".into()), timezone: None };
38//! assert!(is_within(&night, "23:30"));
39//! assert!(is_within(&night, "05:59"));
40//! assert!(!is_within(&night, "12:00"));
41//!
42//! // The zone resolution: 12:00 UTC is 07:00 in New York (winter).
43//! let utc = chrono::Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap();
44//! assert_eq!(hhmm_in_zone(utc, Some("America/New_York")), "07:00");
45//! // An unknown zone falls back to UTC.
46//! assert_eq!(hhmm_in_zone(utc, Some("Mars/Olympus")), "12:00");
47//! ```
48
49/// The check's stable name (`check_name`).
50pub const TIME_WINDOW_CHECK_NAME: &str = "time_window";
51
52/// The block status the reference answers (`create_error_response(403,
53/// default_message="Access not allowed at this time")`).
54pub const TIME_WINDOW_BLOCK_STATUS: u16 = 403;
55
56/// The block body the reference answers with.
57pub const TIME_WINDOW_BLOCK_BODY: &str = "Access not allowed at this time";
58
59/// One route's `time_restrictions`: the window bounds and the IANA zone.
60///
61/// `None` bounds mirror the reference's missing dict keys: the evaluation
62/// raises into its `except` arm and the request is allowed.
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct TimeWindow {
65 /// The window's `"HH:MM"` opening bound (`start`).
66 pub start: Option<String>,
67 /// The window's `"HH:MM"` closing bound (`end`).
68 pub end: Option<String>,
69 /// The IANA zone name (`timezone`); `None` or an unknown name is UTC.
70 pub timezone: Option<String>,
71}
72
73/// The wall-clock `"HH:MM"` of `now_utc` observed in `timezone`.
74///
75/// The reference's `datetime.now(ZoneInfo(timezone)).strftime("%H:%M")`.
76/// An unknown or empty zone falls back to UTC (the reference's `except`
77/// around `ZoneInfo`).
78#[must_use]
79pub fn hhmm_in_zone(now_utc: chrono::DateTime<chrono::Utc>, timezone: Option<&str>) -> String {
80 let zone: chrono_tz::Tz = timezone
81 .and_then(|name| name.parse().ok())
82 .unwrap_or(chrono_tz::UTC);
83 now_utc.with_timezone(&zone).format("%H:%M").to_string()
84}
85
86/// The window decision against a current `"HH:MM"`.
87///
88/// Midnight-wrapping when `start > end`, the plain span otherwise, and
89/// **allowed** whenever the bounds are missing (the reference's fail-open
90/// `except` arm).
91#[must_use]
92pub fn is_within(window: &TimeWindow, current_hhmm: &str) -> bool {
93 let (Some(start), Some(end)) = (window.start.as_deref(), window.end.as_deref()) else {
94 return true;
95 };
96 if start > end {
97 current_hhmm >= start || current_hhmm <= end
98 } else {
99 start <= current_hhmm && current_hhmm <= end
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use chrono::TimeZone;
107
108 fn at(hour: u32, minute: u32) -> TimeWindow {
109 TimeWindow {
110 start: Some(format!("{hour:02}:{minute:02}")),
111 ..TimeWindow::default()
112 }
113 }
114
115 #[test]
116 fn plain_windows_admit_only_the_span() {
117 let window = TimeWindow {
118 start: Some("09:00".into()),
119 end: Some("17:00".into()),
120 timezone: None,
121 };
122 assert!(!is_within(&window, "08:59"));
123 assert!(is_within(&window, "09:00"));
124 assert!(is_within(&window, "17:00"));
125 assert!(!is_within(&window, "17:01"));
126 assert!(!is_within(&window, "23:59"));
127 assert!(!is_within(&window, "00:00"));
128 }
129
130 #[test]
131 fn midnight_wrapping_windows_admit_both_sides() {
132 let window = TimeWindow {
133 start: Some("22:00".into()),
134 end: Some("06:00".into()),
135 timezone: None,
136 };
137 assert!(is_within(&window, "22:00"));
138 assert!(is_within(&window, "23:59"));
139 assert!(is_within(&window, "00:00"));
140 assert!(is_within(&window, "06:00"));
141 assert!(!is_within(&window, "06:01"));
142 assert!(!is_within(&window, "21:59"));
143 assert!(!is_within(&window, "12:00"));
144 }
145
146 #[test]
147 fn missing_bounds_fail_open_like_the_reference() {
148 // The reference's KeyError arm: any evaluation error allows.
149 let empty = TimeWindow::default();
150 assert!(is_within(&empty, "12:00"));
151 let no_start = TimeWindow {
152 end: Some("06:00".into()),
153 ..TimeWindow::default()
154 };
155 assert!(is_within(&no_start, "12:00"));
156 let no_end = at(9, 0);
157 assert!(is_within(&no_end, "12:00"));
158 }
159
160 #[test]
161 fn zones_shift_the_wall_clock_and_unknown_zones_fall_back() {
162 let utc = chrono::Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap();
163 assert_eq!(hhmm_in_zone(utc, None), "12:00");
164 assert_eq!(hhmm_in_zone(utc, Some("UTC")), "12:00");
165 assert_eq!(
166 hhmm_in_zone(utc, Some("America/New_York")),
167 "07:00",
168 "EST in January"
169 );
170 assert_eq!(hhmm_in_zone(utc, Some("Asia/Tokyo")), "21:00");
171 assert_eq!(
172 hhmm_in_zone(utc, Some("Not/AZone")),
173 "12:00",
174 "an unknown zone falls back to UTC, the reference's except arm"
175 );
176
177 // The summer side of the DST boundary.
178 let summer = chrono::Utc.with_ymd_and_hms(2026, 7, 15, 12, 0, 0).unwrap();
179 assert_eq!(
180 hhmm_in_zone(summer, Some("America/New_York")),
181 "08:00",
182 "EDT in July"
183 );
184 }
185
186 #[test]
187 fn the_reference_window_example_over_a_real_zone() {
188 // 02:00 in Tokyo is outside a 09:00-17:00 Tokyo window, but
189 // 10:00 is inside; the decision uses the zone's wall clock.
190 let window = TimeWindow {
191 start: Some("09:00".into()),
192 end: Some("17:00".into()),
193 timezone: Some("Asia/Tokyo".into()),
194 };
195 let utc = chrono::Utc.with_ymd_and_hms(2026, 1, 15, 0, 0, 0).unwrap();
196 assert_eq!(hhmm_in_zone(utc, Some("Asia/Tokyo")), "09:00");
197 assert!(is_within(&window, &hhmm_in_zone(utc, Some("Asia/Tokyo"))));
198 }
199}