Skip to main content

opening_hours_syntax/rules/
mod.rs

1pub mod day;
2pub mod time;
3
4use alloc::str::FromStr;
5use alloc::string::String;
6use alloc::sync::Arc;
7use alloc::vec::Vec;
8use core::fmt::Display;
9
10use crate::normalize::frame::Bounded;
11use crate::normalize::paving::{Paving, Paving5D, UnpackFromBack};
12use crate::normalize::{canonical_to_seq, ruleseq_to_selector};
13
14// OpeningHoursExpression
15
16#[derive(Clone, Debug, Hash, PartialEq, Eq)]
17pub struct OpeningHoursExpression {
18    pub rules: Vec<RuleSequence>,
19}
20
21impl OpeningHoursExpression {
22    /// Check if this expression is *trivially* constant (ie. always evaluated at the exact same
23    /// status). Note that this may return `false` for an expression that is constant but should
24    /// cover most common cases.
25    ///
26    /// ```
27    /// use opening_hours_syntax::parse;
28    ///
29    /// assert!(parse("24/7").unwrap().is_constant());
30    /// assert!(parse("24/7 closed").unwrap().is_constant());
31    /// assert!(parse("00:00-24:00 open").unwrap().is_constant());
32    /// assert!(!parse("00:00-18:00 open").unwrap().is_constant());
33    /// assert!(!parse("24/7 ; PH off").unwrap().is_constant());
34    /// ```
35    pub fn is_constant(&self) -> bool {
36        let Some(state) = self.rules.last().map(|rs| rs.as_state()) else {
37            return true;
38        };
39
40        // Ignores rules from the end as long as they are all evaluated to the same kind.
41        let search_tail_full = self.rules.iter().rev().find(|rs| {
42            rs.day_selector.is_empty() || !rs.time_selector.is_00_24() || rs.as_state() != state
43        });
44
45        let Some(tail) = search_tail_full else {
46            return state == Default::default();
47        };
48
49        tail.as_state() == state && tail.is_constant()
50    }
51
52    /// Convert the expression into a normalized form. It will not affect the meaning of the
53    /// expression and might impact the performance of evaluations.
54    ///
55    /// ```
56    /// let oh = opening_hours_syntax::parse("24/7 ; Su closed").unwrap();
57    /// assert_eq!(oh.normalize().to_string(), "Mo-Sa");
58    /// ```
59    pub fn normalize(self) -> Self {
60        let mut rules_queue = self.rules.into_iter().peekable();
61        let mut paving = Paving5D::default();
62
63        #[allow(clippy::result_large_err)]
64        while let Some((rule, selector)) = rules_queue.next_if_map(|rule| {
65            // As long as this is not a fallback operator
66            if rule.operator == RuleOperator::Fallback {
67                return Err(rule);
68            }
69
70            // And as long as we can parse it
71            let Some(selector) = ruleseq_to_selector(&rule) else {
72                return Err(rule);
73            };
74
75            Ok((rule, selector))
76        }) {
77            // If the rule is not explicitly targeting a closed kind, then it overrides
78            // previous rules for the whole day.
79            if rule.operator == RuleOperator::Normal && rule.kind != RuleKind::Closed {
80                let mut full_day_selector = selector.clone();
81                full_day_selector.substitute_back([Bounded::bounds()]);
82                paving.set(&full_day_selector, &Default::default());
83            }
84
85            paving.set(&selector, &(rule.kind, rule.comment));
86        }
87
88        Self {
89            rules: canonical_to_seq(paving).chain(rules_queue).collect(),
90        }
91    }
92}
93
94impl Display for OpeningHoursExpression {
95    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
96        let Some(first) = self.rules.first() else {
97            return write!(f, "closed");
98        };
99
100        write!(f, "{first}")?;
101
102        for rule in &self.rules[1..] {
103            let separator = match rule.operator {
104                RuleOperator::Normal => "; ",
105                RuleOperator::Additional => ", ",
106                RuleOperator::Fallback => " || ",
107            };
108
109            write!(f, "{separator}")?;
110
111            // If the rule operatior is an addition, we need to make sure that the time selector is
112            // prefixed with a day selector to avoid ambiguous syntax. For eg. "Mo 10:00-12:00,
113            // 13:00-14:00" is parsed as a single rule while "Mo 10:00-12:00, Mo-Su 13:00-14:00"
114            // isn't.
115            rule.display(f, rule.operator == RuleOperator::Additional)?;
116        }
117
118        Ok(())
119    }
120}
121
122// RuleSequence
123
124#[derive(Clone, Debug, Hash, PartialEq, Eq)]
125pub struct RuleSequence {
126    pub day_selector: day::DaySelector,
127    pub time_selector: time::TimeSelector,
128    pub kind: RuleKind,
129    pub operator: RuleOperator,
130    pub comment: Arc<str>,
131}
132
133impl RuleSequence {
134    /// If this returns `true`, then this expression is always open, but it
135    /// can't detect all cases.
136    pub fn is_constant(&self) -> bool {
137        self.day_selector.is_empty() && self.time_selector.is_00_24()
138    }
139
140    /// Extract the kind and comment from the range, which are the values that define current state
141    /// of an expression.
142    pub fn as_state(&self) -> (RuleKind, &str) {
143        (self.kind, &self.comment)
144    }
145
146    /// Format rule sequence into given formatter.
147    ///
148    /// If `force_day_selector` is set to true, the day selector part is guaranteed to yield a
149    /// non-empty string by adding "Mo-Su" as fallback.
150    pub(crate) fn display(
151        &self,
152        f: &mut core::fmt::Formatter<'_>,
153        force_day_selector: bool,
154    ) -> core::fmt::Result {
155        let mut is_empty;
156
157        if self.is_constant() {
158            is_empty = false;
159            write!(f, "24/7")?;
160        } else {
161            self.day_selector.display(f, force_day_selector)?;
162            is_empty = !force_day_selector && self.day_selector.is_empty();
163
164            if !self.time_selector.is_00_24() {
165                if !is_empty {
166                    write!(f, " ")?;
167                }
168
169                is_empty = is_empty && self.time_selector.is_00_24();
170                write!(f, "{}", self.time_selector)?;
171            }
172        }
173
174        if self.kind != RuleKind::Open {
175            if !is_empty {
176                write!(f, " ")?;
177            }
178
179            is_empty = false;
180            write!(f, "{}", self.kind)?;
181        }
182
183        if !self.comment.is_empty() {
184            if !is_empty {
185                write!(f, " ")?;
186            }
187
188            write!(f, "\"{}\"", self.comment)?;
189        }
190
191        Ok(())
192    }
193}
194
195impl Display for RuleSequence {
196    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
197        self.display(f, false)
198    }
199}
200
201// RuleKind
202
203#[derive(Copy, Clone, Debug, Default, Hash, Eq, Ord, PartialEq, PartialOrd)]
204pub enum RuleKind {
205    Open,
206    #[default]
207    Closed,
208    Unknown,
209}
210
211impl RuleKind {
212    pub const fn as_str(self) -> &'static str {
213        match self {
214            Self::Open => "open",
215            Self::Closed => "closed",
216            Self::Unknown => "unknown",
217        }
218    }
219}
220
221impl FromStr for RuleKind {
222    type Err = String;
223
224    fn from_str(s: &str) -> Result<Self, Self::Err> {
225        match s.to_lowercase().as_str() {
226            "open" => Ok(Self::Open),
227            "closed" => Ok(Self::Closed),
228            "unknown" => Ok(Self::Unknown),
229            other => Err(format!("Unknown rule kind {other:?}")),
230        }
231    }
232}
233
234impl Display for RuleKind {
235    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
236        write!(f, "{}", self.as_str())
237    }
238}
239
240// RuleOperator
241
242#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)]
243pub enum RuleOperator {
244    Normal,
245    Additional,
246    Fallback,
247}