1use time::macros::format_description;
13use time::{Date, OffsetDateTime, Time, Weekday};
14use time_tz::{timezones, OffsetDateTimeExt, Tz};
15
16use crate::model::{HoursException, Node, TimeRange, WeeklySchedule};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum HoursResult {
21 Open,
22 Closed,
23}
24
25impl HoursResult {
26 pub fn exit(self) -> &'static str {
28 match self {
29 HoursResult::Open => "open",
30 HoursResult::Closed => "closed",
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39pub enum HoursError {
40 #[error("unknown IANA timezone {0:?}")]
41 UnknownTimezone(String),
42 #[error("time {0:?} is not HH:MM")]
43 BadTime(String),
44 #[error("date {0:?} is not YYYY-MM-DD")]
45 BadDate(String),
46 #[error("range open {open:?} is not before close {close:?}")]
47 NonPositiveRange { open: String, close: String },
48}
49
50impl HoursError {
51 pub fn code(&self) -> &'static str {
56 match self {
57 HoursError::UnknownTimezone(_) => "unknown_timezone",
58 HoursError::BadTime(_) => "bad_time",
59 HoursError::BadDate(_) => "bad_date",
60 HoursError::NonPositiveRange { .. } => "non_positive_range",
61 }
62 }
63}
64
65pub fn resolve_tz(name: &str) -> Result<&'static Tz, HoursError> {
67 timezones::get_by_name(name).ok_or_else(|| HoursError::UnknownTimezone(name.to_string()))
68}
69
70pub fn parse_time(s: &str) -> Result<Time, HoursError> {
72 let fmt = format_description!("[hour]:[minute]");
73 Time::parse(s, fmt).map_err(|_| HoursError::BadTime(s.to_string()))
74}
75
76pub fn parse_date(s: &str) -> Result<Date, HoursError> {
78 let fmt = format_description!("[year]-[month]-[day]");
79 Date::parse(s, fmt).map_err(|_| HoursError::BadDate(s.to_string()))
80}
81
82pub fn validate_config(
88 schedule: &WeeklySchedule,
89 timezone: &str,
90 exceptions: &[HoursException],
91) -> Result<(), HoursError> {
92 resolve_tz(timezone)?;
93 for range in weekdays(schedule).into_iter().flatten() {
94 check_range(range)?;
95 }
96 for ex in exceptions {
97 parse_date(&ex.date)?;
98 for range in &ex.ranges {
99 check_range(range)?;
100 }
101 }
102 Ok(())
103}
104
105fn check_range(range: &TimeRange) -> Result<(), HoursError> {
106 let open = parse_time(&range.open)?;
107 let close = parse_time(&range.close)?;
108 if open >= close {
109 return Err(HoursError::NonPositiveRange {
110 open: range.open.clone(),
111 close: range.close.clone(),
112 });
113 }
114 Ok(())
115}
116
117pub fn evaluate(node: &Node, now: OffsetDateTime) -> Result<HoursResult, HoursError> {
123 let Node::Hours {
124 schedule,
125 timezone,
126 exceptions,
127 ..
128 } = node
129 else {
130 return Err(HoursError::UnknownTimezone(String::new()));
131 };
132
133 let tz = resolve_tz(timezone)?;
134 let local = now.to_timezone(tz);
135 let today = local.date();
136 let now_time = local.time();
137
138 for ex in exceptions {
141 if parse_date(&ex.date)? == today {
142 if ex.closed {
143 return Ok(HoursResult::Closed);
144 }
145 return open_if_within(&ex.ranges, now_time);
146 }
147 }
148
149 let ranges = ranges_for(schedule, local.weekday());
150 open_if_within(ranges, now_time)
151}
152
153fn open_if_within(ranges: &[TimeRange], now: Time) -> Result<HoursResult, HoursError> {
154 for range in ranges {
155 let open = parse_time(&range.open)?;
156 let close = parse_time(&range.close)?;
157 if now >= open && now < close {
158 return Ok(HoursResult::Open);
159 }
160 }
161 Ok(HoursResult::Closed)
162}
163
164fn ranges_for(schedule: &WeeklySchedule, day: Weekday) -> &[TimeRange] {
165 match day {
166 Weekday::Monday => &schedule.mon,
167 Weekday::Tuesday => &schedule.tue,
168 Weekday::Wednesday => &schedule.wed,
169 Weekday::Thursday => &schedule.thu,
170 Weekday::Friday => &schedule.fri,
171 Weekday::Saturday => &schedule.sat,
172 Weekday::Sunday => &schedule.sun,
173 }
174}
175
176fn weekdays(schedule: &WeeklySchedule) -> [&[TimeRange]; 7] {
177 [
178 &schedule.mon,
179 &schedule.tue,
180 &schedule.wed,
181 &schedule.thu,
182 &schedule.fri,
183 &schedule.sat,
184 &schedule.sun,
185 ]
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::model::Node;
192 use time::macros::datetime;
193
194 fn open_11_to_22() -> WeeklySchedule {
195 let range = TimeRange {
196 open: "11:00".into(),
197 close: "22:00".into(),
198 };
199 WeeklySchedule {
200 tue: vec![range.clone()],
201 wed: vec![range.clone()],
202 thu: vec![range.clone()],
203 fri: vec![range.clone()],
204 sat: vec![range.clone()],
205 sun: vec![range],
206 ..Default::default()
207 }
208 }
209
210 fn hours(exceptions: Vec<HoursException>) -> Node {
211 Node::Hours {
212 schedule: open_11_to_22(),
213 timezone: "America/New_York".into(),
214 exceptions,
215 exits: None,
216 }
217 }
218
219 #[test]
220 fn open_during_business_hours() {
221 let now = datetime!(2026-07-07 19:00 UTC);
224 assert_eq!(evaluate(&hours(vec![]), now).unwrap(), HoursResult::Open);
225 }
226
227 #[test]
228 fn closed_before_opening() {
229 let now = datetime!(2026-07-07 14:00 UTC);
231 assert_eq!(evaluate(&hours(vec![]), now).unwrap(), HoursResult::Closed);
232 }
233
234 #[test]
235 fn closed_at_exactly_close_time() {
236 let now = datetime!(2026-07-08 02:00 UTC);
239 assert_eq!(evaluate(&hours(vec![]), now).unwrap(), HoursResult::Closed);
240 }
241
242 #[test]
243 fn closed_on_a_day_with_no_ranges() {
244 let now = datetime!(2026-07-06 19:00 UTC);
246 assert_eq!(evaluate(&hours(vec![]), now).unwrap(), HoursResult::Closed);
247 }
248
249 #[test]
250 fn timezone_is_the_flows_not_utc() {
251 let now = datetime!(2026-07-07 23:30 UTC);
255 assert_eq!(evaluate(&hours(vec![]), now).unwrap(), HoursResult::Open);
256 }
257
258 #[test]
259 fn holiday_exception_forces_closed() {
260 let now = datetime!(2026-07-07 19:00 UTC);
262 let closed = hours(vec![HoursException {
263 date: "2026-07-07".into(),
264 closed: true,
265 ranges: vec![],
266 }]);
267 assert_eq!(evaluate(&closed, now).unwrap(), HoursResult::Closed);
268 }
269
270 #[test]
271 fn special_hours_exception_overrides_weekly() {
272 let now = datetime!(2026-07-06 15:00 UTC); let special = hours(vec![HoursException {
275 date: "2026-07-06".into(),
276 closed: false,
277 ranges: vec![TimeRange {
278 open: "10:00".into(),
279 close: "14:00".into(),
280 }],
281 }]);
282 assert_eq!(evaluate(&special, now).unwrap(), HoursResult::Open);
283
284 let later = datetime!(2026-07-06 19:00 UTC); assert_eq!(evaluate(&special, later).unwrap(), HoursResult::Closed);
288 }
289
290 #[test]
291 fn validate_rejects_bad_timezone() {
292 let err = validate_config(&open_11_to_22(), "Mars/Olympus", &[]).unwrap_err();
293 assert!(matches!(err, HoursError::UnknownTimezone(_)));
294 }
295
296 #[test]
297 fn validate_rejects_overnight_range() {
298 let overnight = WeeklySchedule {
299 fri: vec![TimeRange {
300 open: "22:00".into(),
301 close: "02:00".into(),
302 }],
303 ..Default::default()
304 };
305 let err = validate_config(&overnight, "America/New_York", &[]).unwrap_err();
306 assert!(matches!(err, HoursError::NonPositiveRange { .. }));
307 }
308
309 #[test]
310 fn validate_rejects_malformed_time_and_date() {
311 let bad_time = WeeklySchedule {
312 mon: vec![TimeRange {
313 open: "9am".into(),
314 close: "17:00".into(),
315 }],
316 ..Default::default()
317 };
318 assert!(matches!(
319 validate_config(&bad_time, "UTC", &[]).unwrap_err(),
320 HoursError::BadTime(_)
321 ));
322
323 let bad_date = vec![HoursException {
324 date: "July 7".into(),
325 closed: true,
326 ranges: vec![],
327 }];
328 assert!(matches!(
329 validate_config(&open_11_to_22(), "UTC", &bad_date).unwrap_err(),
330 HoursError::BadDate(_)
331 ));
332 }
333}