opening_hours/
opening_hours.rs

1use std::fmt::Display;
2use std::iter::Peekable;
3use std::str::FromStr;
4use std::sync::Arc;
5
6use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
7
8use opening_hours_syntax::extended_time::ExtendedTime;
9use opening_hours_syntax::rules::{OpeningHoursExpression, RuleKind, RuleOperator, RuleSequence};
10use opening_hours_syntax::Error as ParserError;
11
12use crate::filter::date_filter::DateFilter;
13use crate::filter::time_filter::{
14    time_selector_intervals_at, time_selector_intervals_at_next_day, TimeFilter,
15};
16use crate::localization::{Localize, NoLocation};
17use crate::schedule::Schedule;
18use crate::Context;
19use crate::DateTimeRange;
20
21/// The lower bound of dates handled by specification
22pub const DATE_START: NaiveDateTime = {
23    let date = NaiveDate::from_ymd_opt(1900, 1, 1).unwrap();
24    let time = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
25    NaiveDateTime::new(date, time)
26};
27
28/// The upper bound of dates handled by specification
29pub const DATE_END: NaiveDateTime = {
30    let date = NaiveDate::from_ymd_opt(10_000, 1, 1).unwrap();
31    let time = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
32    NaiveDateTime::new(date, time)
33};
34
35// OpeningHours
36
37/// A parsed opening hours expression and its evaluation context.
38///
39/// Note that all big inner structures are immutable and wrapped by an `Arc`
40/// so this is safe and fast to clone.
41#[derive(Clone, Debug, Hash, PartialEq, Eq)]
42pub struct OpeningHours<L: Localize = NoLocation> {
43    /// Rules describing opening hours
44    expr: Arc<OpeningHoursExpression>,
45    /// Evaluation context
46    pub(crate) ctx: Context<L>,
47}
48
49impl OpeningHours<NoLocation> {
50    /// Parse a raw opening hours expression.
51    ///
52    /// ```
53    /// use opening_hours::{Context, OpeningHours};
54    ///
55    /// assert!(OpeningHours::parse("24/7 open").is_ok());
56    /// assert!(OpeningHours::parse("not a valid expression").is_err());
57    /// ```
58    pub fn parse(raw_oh: &str) -> Result<Self, ParserError> {
59        let expr = Arc::new(opening_hours_syntax::parse(raw_oh)?);
60        Ok(Self { expr, ctx: Context::default() })
61    }
62}
63
64impl<L: Localize> OpeningHours<L> {
65    // --
66    // -- Builder Methods
67    // --
68
69    /// Set a new evaluation context for this expression.
70    ///
71    /// ```
72    /// use opening_hours::{Context, OpeningHours};
73    ///
74    /// let oh = OpeningHours::parse("Mo-Fr open")
75    ///     .unwrap()
76    ///     .with_context(Context::default());
77    /// ```
78    pub fn with_context<L2: Localize>(self, ctx: Context<L2>) -> OpeningHours<L2> {
79        OpeningHours { expr: self.expr, ctx }
80    }
81
82    /// Convert the expression into a normalized form. It will not affect the meaning of the
83    /// expression and might impact the performance of evaluations.
84    ///
85    /// ```
86    /// use opening_hours::OpeningHours;
87    ///
88    /// let oh = OpeningHours::parse("24/7 ; Su closed").unwrap();
89    /// assert_eq!(oh.normalize().to_string(), "Mo-Sa");
90    /// ```
91    pub fn normalize(&self) -> Self {
92        Self {
93            expr: Arc::new(self.expr.as_ref().clone().normalize()),
94            ctx: self.ctx.clone(),
95        }
96    }
97
98    // --
99    // -- Low level implementations.
100    // --
101    //
102    // Following functions are used to build the TimeDomainIterator which is
103    // used to implement all other functions.
104    //
105    // This means that performances matters a lot for these functions and it
106    // would be relevant to focus on optimisations to this regard.
107
108    /// Provide a lower bound to the next date when a different set of rules
109    /// could match.
110    fn next_change_hint(&self, date: NaiveDate) -> Option<NaiveDate> {
111        if date < DATE_START.date() {
112            return Some(DATE_START.date());
113        }
114
115        if self.expr.is_constant() {
116            return Some(DATE_END.date());
117        }
118
119        (self.expr.rules)
120            .iter()
121            .map(|rule| {
122                if rule.time_selector.is_immutable_full_day()
123                    || !rule.day_selector.filter(date, &self.ctx)
124                {
125                    rule.day_selector.next_change_hint(date, &self.ctx)
126                } else {
127                    date.succ_opt()
128                }
129            })
130            .min()
131            .flatten()
132    }
133
134    /// Get the schedule at a given day.
135    pub fn schedule_at(&self, date: NaiveDate) -> Schedule {
136        #[cfg(test)]
137        crate::tests::stats::notify::generated_schedule();
138
139        if !(DATE_START.date()..DATE_END.date()).contains(&date) {
140            return Schedule::default();
141        }
142
143        let mut prev_match = false;
144        let mut prev_eval = None;
145
146        for rules_seq in &self.expr.rules {
147            let curr_match = rules_seq.day_selector.filter(date, &self.ctx);
148            let curr_eval = rule_sequence_schedule_at(rules_seq, date, &self.ctx);
149
150            let (new_match, new_eval) = match (rules_seq.operator, rules_seq.kind) {
151                // The normal rule acts like the additional rule when the kind is "closed".
152                (RuleOperator::Normal, RuleKind::Open | RuleKind::Unknown) => (
153                    curr_match || prev_match,
154                    if curr_match {
155                        curr_eval
156                    } else {
157                        prev_eval.or(curr_eval)
158                    },
159                ),
160                (RuleOperator::Additional, _) | (RuleOperator::Normal, RuleKind::Closed) => (
161                    prev_match || curr_match,
162                    match (prev_eval, curr_eval) {
163                        (Some(prev), Some(curr)) => Some(prev.addition(curr)),
164                        (prev, curr) => prev.or(curr),
165                    },
166                ),
167                (RuleOperator::Fallback, _) => {
168                    if prev_match
169                        && !(prev_eval.as_ref())
170                            .map(Schedule::is_always_closed)
171                            .unwrap_or(false)
172                    {
173                        (prev_match, prev_eval)
174                    } else {
175                        (curr_match, curr_eval)
176                    }
177                }
178            };
179
180            prev_match = new_match;
181            prev_eval = new_eval;
182        }
183
184        prev_eval.unwrap_or_else(Schedule::new)
185    }
186
187    /// Same as [`iter_range`], but with naive date input and outputs.
188    fn iter_range_naive(
189        &self,
190        from: NaiveDateTime,
191        to: NaiveDateTime,
192    ) -> impl Iterator<Item = DateTimeRange> + Send + Sync + use<L> {
193        let from = std::cmp::min(DATE_END, from);
194        let to = std::cmp::min(DATE_END, to);
195
196        TimeDomainIterator::new(self, from, to)
197            .take_while(move |dtr| dtr.range.start < to)
198            .map(move |dtr| {
199                let start = std::cmp::max(dtr.range.start, from);
200                let end = std::cmp::min(dtr.range.end, to);
201                DateTimeRange::new_with_sorted_comments(start..end, dtr.kind, dtr.comments)
202            })
203    }
204
205    // --
206    // -- High level implementations / Syntactic sugar
207    // --
208
209    /// Iterate over disjoint intervals of different state restricted to the
210    /// time interval `from..to`.
211    pub fn iter_range(
212        &self,
213        from: L::DateTime,
214        to: L::DateTime,
215    ) -> impl Iterator<Item = DateTimeRange<L::DateTime>> + Send + Sync + use<L> {
216        let locale = self.ctx.locale.clone();
217        let naive_from = std::cmp::min(DATE_END, locale.naive(from));
218        let naive_to = std::cmp::min(DATE_END, locale.naive(to));
219
220        self.iter_range_naive(naive_from, naive_to).map(move |dtr| {
221            DateTimeRange::new_with_sorted_comments(
222                locale.datetime(dtr.range.start)..locale.datetime(dtr.range.end),
223                dtr.kind,
224                dtr.comments,
225            )
226        })
227    }
228
229    // Same as [`OpeningHours::iter_range`] but with an open end.
230    pub fn iter_from(
231        &self,
232        from: L::DateTime,
233    ) -> impl Iterator<Item = DateTimeRange<L::DateTime>> + Send + Sync + use<L> {
234        self.iter_range(from, self.ctx.locale.datetime(DATE_END))
235    }
236
237    /// Get the next time where the state will change.
238    ///
239    /// ```
240    /// use chrono::NaiveDateTime;
241    /// use opening_hours::OpeningHours;
242    /// use opening_hours_syntax::RuleKind;
243    ///
244    /// let oh = OpeningHours::parse("12:00-18:00 open, 18:00-20:00 unknown").unwrap();
245    /// let date_1 = NaiveDateTime::parse_from_str("2024-11-18 15:00", "%Y-%m-%d %H:%M").unwrap();
246    /// let date_2 = NaiveDateTime::parse_from_str("2024-11-18 18:00", "%Y-%m-%d %H:%M").unwrap();
247    /// assert_eq!(oh.next_change(date_1), Some(date_2));
248    /// ```
249    pub fn next_change(&self, current_time: L::DateTime) -> Option<L::DateTime> {
250        let interval = self.iter_from(current_time).next()?;
251
252        if self.ctx.locale.naive(interval.range.end.clone()) >= DATE_END {
253            None
254        } else {
255            Some(interval.range.end)
256        }
257    }
258
259    /// Get the state at given time.
260    ///
261    /// ```
262    /// use chrono::NaiveDateTime;
263    /// use opening_hours::OpeningHours;
264    /// use opening_hours_syntax::RuleKind;
265    ///
266    /// let oh = OpeningHours::parse("12:00-18:00 open, 18:00-20:00 unknown").unwrap();
267    /// let date_1 = NaiveDateTime::parse_from_str("2024-11-18 15:00", "%Y-%m-%d %H:%M").unwrap();
268    /// let date_2 = NaiveDateTime::parse_from_str("2024-11-18 19:00", "%Y-%m-%d %H:%M").unwrap();
269    /// assert_eq!(oh.state(date_1), RuleKind::Open);
270    /// assert_eq!(oh.state(date_2), RuleKind::Unknown);
271    /// ```
272    pub fn state(&self, current_time: L::DateTime) -> RuleKind {
273        self.iter_range(current_time.clone(), current_time + Duration::minutes(1))
274            .next()
275            .map(|dtr| dtr.kind)
276            .unwrap_or(RuleKind::Closed)
277    }
278
279    /// Check if this is open at a given time.
280    ///
281    /// ```
282    /// use chrono::NaiveDateTime;
283    /// use opening_hours::OpeningHours;
284    ///
285    /// let oh = OpeningHours::parse("12:00-18:00 open, 18:00-20:00 unknown").unwrap();
286    /// let date_1 = NaiveDateTime::parse_from_str("2024-11-18 15:00", "%Y-%m-%d %H:%M").unwrap();
287    /// let date_2 = NaiveDateTime::parse_from_str("2024-11-18 19:00", "%Y-%m-%d %H:%M").unwrap();
288    /// assert!(oh.is_open(date_1));
289    /// assert!(!oh.is_open(date_2));
290    /// ```
291    pub fn is_open(&self, current_time: L::DateTime) -> bool {
292        self.state(current_time) == RuleKind::Open
293    }
294
295    /// Check if this is closed at a given time.
296    ///
297    /// ```
298    /// use chrono::NaiveDateTime;
299    /// use opening_hours::OpeningHours;
300    ///
301    /// let oh = OpeningHours::parse("12:00-18:00 open, 18:00-20:00 unknown").unwrap();
302    /// let date_1 = NaiveDateTime::parse_from_str("2024-11-18 10:00", "%Y-%m-%d %H:%M").unwrap();
303    /// let date_2 = NaiveDateTime::parse_from_str("2024-11-18 19:00", "%Y-%m-%d %H:%M").unwrap();
304    /// assert!(oh.is_closed(date_1));
305    /// assert!(!oh.is_closed(date_2));
306    /// ```
307    pub fn is_closed(&self, current_time: L::DateTime) -> bool {
308        self.state(current_time) == RuleKind::Closed
309    }
310
311    /// Check if this is unknown at a given time.
312    ///
313    /// ```
314    /// use chrono::NaiveDateTime;
315    /// use opening_hours::OpeningHours;
316    ///
317    /// let oh = OpeningHours::parse("12:00-18:00 open, 18:00-20:00 unknown").unwrap();
318    /// let date_1 = NaiveDateTime::parse_from_str("2024-11-18 19:00", "%Y-%m-%d %H:%M").unwrap();
319    /// let date_2 = NaiveDateTime::parse_from_str("2024-11-18 15:00", "%Y-%m-%d %H:%M").unwrap();
320    /// assert!(oh.is_unknown(date_1));
321    /// assert!(!oh.is_unknown(date_2));
322    /// ```
323    pub fn is_unknown(&self, current_time: L::DateTime) -> bool {
324        self.state(current_time) == RuleKind::Unknown
325    }
326}
327
328impl FromStr for OpeningHours {
329    type Err = ParserError;
330
331    fn from_str(s: &str) -> Result<Self, Self::Err> {
332        Self::parse(s)
333    }
334}
335
336impl<L: Localize> Display for OpeningHours<L> {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        write!(f, "{}", self.expr)
339    }
340}
341
342fn rule_sequence_schedule_at<L: Localize>(
343    rule_sequence: &RuleSequence,
344    date: NaiveDate,
345    ctx: &Context<L>,
346) -> Option<Schedule> {
347    let from_today = Some(date)
348        .filter(|date| rule_sequence.day_selector.filter(*date, ctx))
349        .map(|date| time_selector_intervals_at(ctx, &rule_sequence.time_selector, date))
350        .map(|rgs| Schedule::from_ranges(rgs, rule_sequence.kind, &rule_sequence.comments));
351
352    let from_yesterday = (date.pred_opt())
353        .filter(|prev| rule_sequence.day_selector.filter(*prev, ctx))
354        .map(|prev| time_selector_intervals_at_next_day(ctx, &rule_sequence.time_selector, prev))
355        .map(|rgs| Schedule::from_ranges(rgs, rule_sequence.kind, &rule_sequence.comments));
356
357    match (from_today, from_yesterday) {
358        (Some(sched_1), Some(sched_2)) => Some(sched_1.addition(sched_2)),
359        (today, yesterday) => today.or(yesterday),
360    }
361}
362
363// TimeDomainIterator
364
365pub struct TimeDomainIterator<L: Clone + Localize> {
366    opening_hours: OpeningHours<L>,
367    curr_date: NaiveDate,
368    curr_schedule: Peekable<crate::schedule::IntoIter>,
369    end_datetime: NaiveDateTime,
370}
371
372impl<L: Localize> TimeDomainIterator<L> {
373    fn new(
374        opening_hours: &OpeningHours<L>,
375        start_datetime: NaiveDateTime,
376        end_datetime: NaiveDateTime,
377    ) -> Self {
378        let opening_hours = opening_hours.clone();
379        let start_date = start_datetime.date();
380        let start_time = start_datetime.time().into();
381        let mut curr_schedule = opening_hours.schedule_at(start_date).into_iter().peekable();
382
383        if start_datetime >= end_datetime {
384            (&mut curr_schedule).for_each(|_| {});
385        }
386
387        while curr_schedule
388            .peek()
389            .map(|tr| !tr.range.contains(&start_time))
390            .unwrap_or(false)
391        {
392            curr_schedule.next();
393        }
394
395        Self {
396            opening_hours,
397            curr_date: start_date,
398            curr_schedule,
399            end_datetime,
400        }
401    }
402
403    fn consume_until_next_kind(&mut self, curr_kind: RuleKind) {
404        let start_date = self.curr_date;
405
406        while self.curr_schedule.peek().map(|tr| tr.kind) == Some(curr_kind) {
407            if let Some(max_interval_size) = self.opening_hours.ctx.approx_bound_interval_size {
408                if self.curr_date - start_date > max_interval_size + chrono::TimeDelta::days(1) {
409                    return;
410                }
411            }
412
413            self.curr_schedule.next();
414
415            if self.curr_schedule.peek().is_none() {
416                let next_change_hint = self
417                    .opening_hours
418                    .next_change_hint(self.curr_date)
419                    .unwrap_or_else(|| self.curr_date.succ_opt().expect("reached invalid date"));
420
421                assert!(next_change_hint > self.curr_date, "infinite loop detected");
422                self.curr_date = next_change_hint;
423
424                if self.curr_date <= self.end_datetime.date() && self.curr_date < DATE_END.date() {
425                    self.curr_schedule = self
426                        .opening_hours
427                        .schedule_at(self.curr_date)
428                        .into_iter()
429                        .peekable();
430                }
431            }
432        }
433    }
434}
435
436impl<L: Localize> Iterator for TimeDomainIterator<L> {
437    type Item = DateTimeRange;
438
439    fn next(&mut self) -> Option<Self::Item> {
440        if let Some(curr_tr) = self.curr_schedule.peek().cloned() {
441            let start = NaiveDateTime::new(
442                self.curr_date,
443                curr_tr
444                    .range
445                    .start
446                    .try_into()
447                    .expect("got invalid time from schedule"),
448            );
449
450            self.consume_until_next_kind(curr_tr.kind);
451            let end_date = self.curr_date;
452
453            let end_time = self
454                .curr_schedule
455                .peek()
456                .map(|tr| tr.range.start)
457                .unwrap_or(ExtendedTime::MIDNIGHT_00);
458
459            let end = std::cmp::min(
460                self.end_datetime,
461                NaiveDateTime::new(
462                    end_date,
463                    end_time.try_into().expect("got invalid time from schedule"),
464                ),
465            );
466
467            if let Some(max_interval_size) = self.opening_hours.ctx.approx_bound_interval_size {
468                if end - start > max_interval_size {
469                    return Some(DateTimeRange::new_with_sorted_comments(
470                        start..DATE_END,
471                        curr_tr.kind,
472                        curr_tr.comments,
473                    ));
474                }
475            }
476
477            Some(DateTimeRange::new_with_sorted_comments(
478                start..end,
479                curr_tr.kind,
480                curr_tr.comments,
481            ))
482        } else {
483            None
484        }
485    }
486}