2.8 KiB · text History 6dd7389
//! Opening hours, from OSM's `opening_hours` syntax.
//!
//! Expressions are evaluated at naive local times with no location: OSM hours
//! are written in the place's own local time, and the times we ask about
//! (a lunch date at 12:00) are meant in the same local time, so no time zone
//! is ever involved — which also keeps trip areas in other zones correct.
//!
//! Anything uncertain counts as open: an expression that doesn't parse, an
//! `unknown` rule ("by appointment"), or no hours at all. Only a definite
//! "closed" keeps a place out of a draw.
use chrono::NaiveDateTime;
use opening_hours::OpeningHours;
use opening_hours::RuleKind;
use serde::Serialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum State {
    Open,
    Closed,
    /// The hours say so ("unknown", "by appointment").
    Unknown,
    /// We couldn't read the expression.
    Unreadable,
}

pub fn parse(raw: &str) -> Option<OpeningHours> {
    raw.trim().parse().ok()
}

pub fn state_at(raw: &str, at: NaiveDateTime) -> State {
    match parse(raw) {
        None => State::Unreadable,
        Some(oh) => match oh.state(at).0 {
            RuleKind::Open => State::Open,
            RuleKind::Closed => State::Closed,
            RuleKind::Unknown => State::Unknown,
        },
    }
}

/// Definitely closed at `at`. False for no hours, unreadable hours, or
/// unknown.
pub fn closed_at(raw: Option<&str>, at: NaiveDateTime) -> bool {
    raw.is_some_and(|r| state_at(r, at) == State::Closed)
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::NaiveDate;

    fn at(d: u32, h: u32) -> NaiveDateTime {
        // 2026-10-26 is a Monday.
        NaiveDate::from_ymd_opt(2026, 10, d).unwrap().and_hms_opt(h, 0, 0).unwrap()
    }

    #[test]
    fn weekday_lunch_spot() {
        let oh = "Mo-Fr 11:00-15:00";
        assert_eq!(state_at(oh, at(26, 12)), State::Open);
        assert_eq!(state_at(oh, at(26, 16)), State::Closed);
        // Saturday
        assert!(closed_at(Some(oh), at(31, 12)));
    }

    #[test]
    fn uncertainty_is_not_closed() {
        assert!(!closed_at(None, at(26, 12)));
        assert!(!closed_at(Some("whenever we feel like it"), at(26, 12)));
        assert_eq!(state_at("whenever we feel like it", at(26, 12)), State::Unreadable);
        assert_eq!(state_at("Mo-Su unknown", at(26, 12)), State::Unknown);
        assert!(!closed_at(Some("Mo-Su unknown"), at(26, 12)));
    }

    #[test]
    fn real_osm_values() {
        // From the Boston data this app was built against.
        assert_eq!(state_at("Mo-Fr 07:00-20:00; Sa-Su 09:00-18:00", at(31, 12)), State::Open);
        assert_eq!(state_at("Su-Th 11:00-23:00; Fr-Sa 11:00-24:00", at(26, 10)), State::Closed);
        assert_eq!(state_at("24/7", at(26, 3)), State::Open);
    }
}