Skip to main content

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
342/// Build the full schedule at a given date from a rule sequence:
343/// - handles overlap with previous day,
344/// - handles unknown kind overrides.
345fn rule_sequence_schedule_at<L: Localize>(
346    rule_sequence: &RuleSequence,
347    date: NaiveDate,
348    ctx: &Context<L>,
349) -> Option<Schedule> {
350    /// Build a schedule at a given date from a list of intervals.
351    fn build_from_rules_at_date<L: Localize>(
352        rule_sequence: &RuleSequence,
353        date: NaiveDate,
354        ctx: &Context<L>,
355        intervals: impl Iterator<Item = std::ops::Range<ExtendedTime>>,
356    ) -> Option<Schedule> {
357        if !rule_sequence.day_selector.filter(date, ctx) {
358            return None;
359        }
360
361        let overriden_kind = {
362            if rule_sequence
363                .day_selector
364                .overrides_kind_to_unknown(date, ctx)
365            {
366                RuleKind::Unknown
367            } else {
368                rule_sequence.kind
369            }
370        };
371
372        Some(Schedule::from_ranges(
373            intervals,
374            overriden_kind,
375            &rule_sequence.comments,
376        ))
377    }
378
379    let schedule_from_today = build_from_rules_at_date(
380        rule_sequence,
381        date,
382        ctx,
383        time_selector_intervals_at(ctx, &rule_sequence.time_selector, date),
384    );
385
386    // We can't just return the schedule obtained this way for the given date because a rule from
387    // previous day could overlap (extended times can be specified up to 48h).
388    let schedule_from_yesterday = date.pred_opt().and_then(|yesterday| {
389        build_from_rules_at_date(
390            rule_sequence,
391            yesterday,
392            ctx,
393            time_selector_intervals_at_next_day(ctx, &rule_sequence.time_selector, yesterday),
394        )
395    });
396
397    match (schedule_from_today, schedule_from_yesterday) {
398        (Some(sched_1), Some(sched_2)) => Some(sched_1.addition(sched_2)),
399        (opt_1, opt_2) => opt_1.or(opt_2),
400    }
401}
402
403// TimeDomainIterator
404
405pub struct TimeDomainIterator<L: Clone + Localize> {
406    opening_hours: OpeningHours<L>,
407    curr_date: NaiveDate,
408    curr_schedule: Peekable<crate::schedule::IntoIter>,
409    end_datetime: NaiveDateTime,
410}
411
412impl<L: Localize> TimeDomainIterator<L> {
413    fn new(
414        opening_hours: &OpeningHours<L>,
415        start_datetime: NaiveDateTime,
416        end_datetime: NaiveDateTime,
417    ) -> Self {
418        let opening_hours = opening_hours.clone();
419        let start_date = start_datetime.date();
420        let start_time = start_datetime.time().into();
421        let mut curr_schedule = opening_hours.schedule_at(start_date).into_iter().peekable();
422
423        if start_datetime >= end_datetime {
424            (&mut curr_schedule).for_each(|_| {});
425        }
426
427        while curr_schedule
428            .peek()
429            .map(|tr| !tr.range.contains(&start_time))
430            .unwrap_or(false)
431        {
432            curr_schedule.next();
433        }
434
435        Self {
436            opening_hours,
437            curr_date: start_date,
438            curr_schedule,
439            end_datetime,
440        }
441    }
442
443    fn consume_until_next_kind(&mut self, curr_kind: RuleKind) {
444        let start_date = self.curr_date;
445
446        while self.curr_schedule.peek().map(|tr| tr.kind) == Some(curr_kind) {
447            if let Some(max_interval_size) = self.opening_hours.ctx.approx_bound_interval_size {
448                if self.curr_date - start_date > max_interval_size + chrono::TimeDelta::days(1) {
449                    return;
450                }
451            }
452
453            self.curr_schedule.next();
454
455            if self.curr_schedule.peek().is_none() {
456                let next_change_hint = self
457                    .opening_hours
458                    .next_change_hint(self.curr_date)
459                    .unwrap_or_else(|| self.curr_date.succ_opt().expect("reached invalid date"));
460
461                assert!(next_change_hint > self.curr_date, "infinite loop detected");
462                self.curr_date = next_change_hint;
463
464                if self.curr_date <= self.end_datetime.date() && self.curr_date < DATE_END.date() {
465                    self.curr_schedule = self
466                        .opening_hours
467                        .schedule_at(self.curr_date)
468                        .into_iter()
469                        .peekable();
470                }
471            }
472        }
473    }
474}
475
476impl<L: Localize> Iterator for TimeDomainIterator<L> {
477    type Item = DateTimeRange;
478
479    fn next(&mut self) -> Option<Self::Item> {
480        if let Some(curr_tr) = self.curr_schedule.peek().cloned() {
481            let start = NaiveDateTime::new(
482                self.curr_date,
483                curr_tr
484                    .range
485                    .start
486                    .try_into()
487                    .expect("got invalid time from schedule"),
488            );
489
490            self.consume_until_next_kind(curr_tr.kind);
491            let end_date = self.curr_date;
492
493            let end_time = self
494                .curr_schedule
495                .peek()
496                .map(|tr| tr.range.start)
497                .unwrap_or(ExtendedTime::MIDNIGHT_00);
498
499            let end = std::cmp::min(
500                self.end_datetime,
501                NaiveDateTime::new(
502                    end_date,
503                    end_time.try_into().expect("got invalid time from schedule"),
504                ),
505            );
506
507            if let Some(max_interval_size) = self.opening_hours.ctx.approx_bound_interval_size {
508                if end - start > max_interval_size {
509                    return Some(DateTimeRange::new_with_sorted_comments(
510                        start..DATE_END,
511                        curr_tr.kind,
512                        curr_tr.comments,
513                    ));
514                }
515            }
516
517            Some(DateTimeRange::new_with_sorted_comments(
518                start..end,
519                curr_tr.kind,
520                curr_tr.comments,
521            ))
522        } else {
523            None
524        }
525    }
526}