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, Parser, Warning};
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    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    #[deprecated(
59        since = "2.0.0",
60        note = "Use `OpeningHours::from_str(raw_oh: &str)` or `raw_oh.parse()` via the trait `std::str::FromStr`"
61    )]
62    pub fn parse(raw_oh: &str) -> Result<Self, ParserError> {
63        raw_oh.parse()
64    }
65
66    /// Use a specific parser configuration to parse an expression.
67    pub fn parse_with<F: FnMut(Warning)>(
68        parser: &mut Parser<F>,
69        raw_oh: &str,
70    ) -> Result<Self, ParserError> {
71        let expr = parser.parse(raw_oh)?;
72        Ok(Self { expr: Arc::new(expr), ctx: Context::default() })
73    }
74}
75
76impl<L: Localize> OpeningHours<L> {
77    // --
78    // -- Builder Methods
79    // --
80
81    /// Get the evaluation context for this expression.
82    pub fn get_context(&self) -> &Context<L> {
83        &self.ctx
84    }
85
86    /// Get the inner expression object.
87    pub fn get_expression(&self) -> Arc<OpeningHoursExpression> {
88        self.expr.clone()
89    }
90
91    /// Set a new evaluation context for this expression.
92    ///
93    /// ```
94    /// use opening_hours::{Context, OpeningHours};
95    ///
96    /// let oh = OpeningHours::parse("Mo-Fr open")
97    ///     .unwrap()
98    ///     .with_context(Context::default());
99    /// ```
100    pub fn with_context<L2: Localize>(self, ctx: Context<L2>) -> OpeningHours<L2> {
101        OpeningHours { expr: self.expr, ctx }
102    }
103
104    /// Convert the expression into a normalized form. It will not affect the meaning of the
105    /// expression and might impact the performance of evaluations.
106    ///
107    /// ```
108    /// use opening_hours::OpeningHours;
109    ///
110    /// let oh = OpeningHours::parse("24/7 ; Su closed").unwrap();
111    /// assert_eq!(oh.normalize().to_string(), "Mo-Sa");
112    /// ```
113    pub fn normalize(&self) -> Self {
114        Self {
115            expr: Arc::new(self.expr.as_ref().clone().normalize()),
116            ctx: self.ctx.clone(),
117        }
118    }
119
120    // --
121    // -- Low level implementations.
122    // --
123    //
124    // Following functions are used to build the TimeDomainIterator which is
125    // used to implement all other functions.
126    //
127    // This means that performances matters a lot for these functions and it
128    // would be relevant to focus on optimisations to this regard.
129
130    /// Provide a lower bound to the next date when a different set of rules
131    /// could match.
132    fn next_change_hint(&self, date: NaiveDate) -> Option<NaiveDate> {
133        if date < DATE_START.date() {
134            return Some(DATE_START.date());
135        }
136
137        if self.expr.is_constant() {
138            return Some(DATE_END.date());
139        }
140
141        (self.expr.rules)
142            .iter()
143            .map(|rule| {
144                if rule.time_selector.is_immutable_full_day()
145                    || !rule.day_selector.filter(date, &self.ctx)
146                {
147                    rule.day_selector.next_change_hint(date, &self.ctx)
148                } else {
149                    date.succ_opt()
150                }
151            })
152            .min()
153            .flatten()
154    }
155
156    /// Get the schedule at a given day.
157    pub fn schedule_at(&self, date: NaiveDate) -> Schedule {
158        if !(DATE_START.date()..DATE_END.date()).contains(&date) {
159            return Schedule::default();
160        }
161
162        let mut prev_match = false;
163        let mut prev_eval = None;
164
165        for rules_seq in &self.expr.rules {
166            let curr_match = rules_seq.day_selector.filter(date, &self.ctx);
167            let curr_eval = rule_sequence_schedule_at(rules_seq, date, &self.ctx);
168
169            let (new_match, new_eval) = match (rules_seq.operator, rules_seq.kind) {
170                // The normal rule acts like the additional rule when the kind is "closed".
171                (RuleOperator::Normal, RuleKind::Open | RuleKind::Unknown) => (
172                    curr_match || prev_match,
173                    if curr_match {
174                        curr_eval
175                    } else {
176                        prev_eval.or(curr_eval)
177                    },
178                ),
179                (RuleOperator::Additional, _) | (RuleOperator::Normal, RuleKind::Closed) => (
180                    prev_match || curr_match,
181                    match (prev_eval, curr_eval) {
182                        (Some(prev), Some(curr)) => Some(prev.addition(curr)),
183                        (prev, curr) => prev.or(curr),
184                    },
185                ),
186                (RuleOperator::Fallback, _) => {
187                    if prev_match
188                        && !(prev_eval.as_ref())
189                            .map(Schedule::is_always_closed_with_no_comments)
190                            .unwrap_or(false)
191                    {
192                        (prev_match, prev_eval)
193                    } else {
194                        (curr_match, curr_eval)
195                    }
196                }
197            };
198
199            prev_match = new_match;
200            prev_eval = new_eval;
201        }
202
203        prev_eval
204            .map(Schedule::filter_closed_ranges)
205            .unwrap_or_else(Schedule::new)
206    }
207
208    /// Same as [`iter_range`], but with naive date input and outputs.
209    fn iter_range_naive(
210        &self,
211        from: NaiveDateTime,
212        to: NaiveDateTime,
213    ) -> impl Iterator<Item = DateTimeRange> + Send + Sync + use<L> {
214        let from = std::cmp::min(DATE_END, from);
215        let to = std::cmp::min(DATE_END, to);
216
217        TimeDomainIterator::new(self, from, to)
218            .take_while(move |dtr| dtr.range.start < to)
219            .map(move |dtr| {
220                let start = std::cmp::max(dtr.range.start, from);
221                let end = std::cmp::min(dtr.range.end, to);
222
223                DateTimeRange {
224                    range: start..end,
225                    kind: dtr.kind,
226                    comment: dtr.comment.clone(),
227                }
228            })
229    }
230
231    // --
232    // -- High level implementations / Syntactic sugar
233    // --
234
235    /// Iterate over disjoint intervals of different state restricted to the
236    /// time interval `from..to`.
237    pub fn iter_range(
238        &self,
239        from: L::DateTime,
240        to: L::DateTime,
241    ) -> impl Iterator<Item = DateTimeRange<L::DateTime>> + Send + Sync + use<L> {
242        let locale = self.ctx.locale.clone();
243        let naive_from = std::cmp::min(DATE_END, locale.naive(from));
244        let naive_to = std::cmp::min(DATE_END, locale.naive(to));
245
246        self.iter_range_naive(naive_from, naive_to)
247            .map(move |dtr| DateTimeRange {
248                range: locale.datetime(dtr.range.start)..locale.datetime(dtr.range.end),
249                kind: dtr.kind,
250                comment: dtr.comment.clone(),
251            })
252    }
253
254    // Same as [`OpeningHours::iter_range`] but with an open end.
255    pub fn iter_from(
256        &self,
257        from: L::DateTime,
258    ) -> impl Iterator<Item = DateTimeRange<L::DateTime>> + Send + Sync + use<L> {
259        self.iter_range(from, self.ctx.locale.datetime(DATE_END))
260    }
261
262    /// Get the next time where the state will change.
263    ///
264    /// ```
265    /// use chrono::NaiveDateTime;
266    /// use opening_hours::OpeningHours;
267    /// use opening_hours_syntax::RuleKind;
268    ///
269    /// let oh = OpeningHours::parse("12:00-18:00 open, 18:00-20:00 unknown").unwrap();
270    /// let date_1 = NaiveDateTime::parse_from_str("2024-11-18 15:00", "%Y-%m-%d %H:%M").unwrap();
271    /// let date_2 = NaiveDateTime::parse_from_str("2024-11-18 18:00", "%Y-%m-%d %H:%M").unwrap();
272    /// assert_eq!(oh.next_change(date_1), Some(date_2));
273    /// ```
274    pub fn next_change(&self, current_time: L::DateTime) -> Option<L::DateTime> {
275        let interval = self.iter_from(current_time).next()?;
276
277        if self.ctx.locale.naive(interval.range.end.clone()) >= DATE_END {
278            None
279        } else {
280            Some(interval.range.end)
281        }
282    }
283
284    /// Get the state at given time.
285    ///
286    /// ```
287    /// use chrono::NaiveDateTime;
288    /// use opening_hours::OpeningHours;
289    /// use opening_hours_syntax::RuleKind;
290    ///
291    /// let oh = OpeningHours::parse("12:00-18:00 open, 18:00-20:00 unknown").unwrap();
292    /// let date_1 = NaiveDateTime::parse_from_str("2024-11-18 15:00", "%Y-%m-%d %H:%M").unwrap();
293    /// let date_2 = NaiveDateTime::parse_from_str("2024-11-18 19:00", "%Y-%m-%d %H:%M").unwrap();
294    /// assert_eq!(oh.state(date_1), (RuleKind::Open, "".into()));
295    /// assert_eq!(oh.state(date_2), (RuleKind::Unknown, "".into()));
296    /// ```
297    pub fn state(&self, current_time: L::DateTime) -> (RuleKind, Arc<str>) {
298        self.iter_range(current_time.clone(), current_time + Duration::minutes(1))
299            .next()
300            .map(|dtr| dtr.into_state())
301            .unwrap_or_default()
302    }
303
304    /// Check if this is open at a given time.
305    ///
306    /// ```
307    /// use chrono::NaiveDateTime;
308    /// use opening_hours::OpeningHours;
309    ///
310    /// let oh = OpeningHours::parse("12:00-18:00 open, 18:00-20:00 unknown").unwrap();
311    /// let date_1 = NaiveDateTime::parse_from_str("2024-11-18 15:00", "%Y-%m-%d %H:%M").unwrap();
312    /// let date_2 = NaiveDateTime::parse_from_str("2024-11-18 19:00", "%Y-%m-%d %H:%M").unwrap();
313    /// assert!(oh.is_open(date_1));
314    /// assert!(!oh.is_open(date_2));
315    /// ```
316    pub fn is_open(&self, current_time: L::DateTime) -> bool {
317        self.state(current_time).0 == RuleKind::Open
318    }
319
320    /// Check if this is closed at a given time.
321    ///
322    /// ```
323    /// use chrono::NaiveDateTime;
324    /// use opening_hours::OpeningHours;
325    ///
326    /// let oh = OpeningHours::parse("12:00-18:00 open, 18:00-20:00 unknown").unwrap();
327    /// let date_1 = NaiveDateTime::parse_from_str("2024-11-18 10:00", "%Y-%m-%d %H:%M").unwrap();
328    /// let date_2 = NaiveDateTime::parse_from_str("2024-11-18 19:00", "%Y-%m-%d %H:%M").unwrap();
329    /// assert!(oh.is_closed(date_1));
330    /// assert!(!oh.is_closed(date_2));
331    /// ```
332    pub fn is_closed(&self, current_time: L::DateTime) -> bool {
333        self.state(current_time).0 == RuleKind::Closed
334    }
335
336    /// Check if this is unknown at a given time.
337    ///
338    /// ```
339    /// use chrono::NaiveDateTime;
340    /// use opening_hours::OpeningHours;
341    ///
342    /// let oh = OpeningHours::parse("12:00-18:00 open, 18:00-20:00 unknown").unwrap();
343    /// let date_1 = NaiveDateTime::parse_from_str("2024-11-18 19:00", "%Y-%m-%d %H:%M").unwrap();
344    /// let date_2 = NaiveDateTime::parse_from_str("2024-11-18 15:00", "%Y-%m-%d %H:%M").unwrap();
345    /// assert!(oh.is_unknown(date_1));
346    /// assert!(!oh.is_unknown(date_2));
347    /// ```
348    pub fn is_unknown(&self, current_time: L::DateTime) -> bool {
349        self.state(current_time).0 == RuleKind::Unknown
350    }
351}
352
353impl FromStr for OpeningHours {
354    type Err = ParserError;
355
356    fn from_str(s: &str) -> Result<Self, Self::Err> {
357        OpeningHours::parse_with(&mut Parser::default(), s)
358    }
359}
360
361impl<L: Localize> Display for OpeningHours<L> {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        write!(f, "{}", self.expr)
364    }
365}
366
367/// Build the full schedule at a given date from a rule sequence:
368/// - handles overlap with previous day,
369/// - handles unknown kind overrides.
370fn rule_sequence_schedule_at<L: Localize>(
371    rule_sequence: &RuleSequence,
372    date: NaiveDate,
373    ctx: &Context<L>,
374) -> Option<Schedule> {
375    #[cfg(test)]
376    crate::tests::utils::stats::notify::generated_schedule();
377
378    /// Build a schedule at a given date from a list of intervals.
379    fn build_from_rules_at_date<L: Localize>(
380        rule_sequence: &RuleSequence,
381        date: NaiveDate,
382        ctx: &Context<L>,
383        intervals: impl Iterator<Item = std::ops::Range<ExtendedTime>>,
384    ) -> Option<Schedule> {
385        if !rule_sequence.day_selector.filter(date, ctx) {
386            return None;
387        }
388
389        let overriden_kind = {
390            if rule_sequence
391                .day_selector
392                .overrides_kind_to_unknown(date, ctx)
393            {
394                RuleKind::Unknown
395            } else {
396                rule_sequence.kind
397            }
398        };
399
400        Some(Schedule::from_ranges(
401            intervals,
402            overriden_kind,
403            rule_sequence.comment.clone(),
404        ))
405    }
406
407    let schedule_from_today = build_from_rules_at_date(
408        rule_sequence,
409        date,
410        ctx,
411        time_selector_intervals_at(ctx, &rule_sequence.time_selector, date),
412    );
413
414    // We can't just return the schedule obtained this way for the given date because a rule from
415    // previous day could overlap (extended times can be specified up to 48h).
416    let schedule_from_yesterday = date.pred_opt().and_then(|yesterday| {
417        build_from_rules_at_date(
418            rule_sequence,
419            yesterday,
420            ctx,
421            time_selector_intervals_at_next_day(ctx, &rule_sequence.time_selector, yesterday),
422        )
423    });
424
425    match (schedule_from_today, schedule_from_yesterday) {
426        (Some(sched_1), Some(sched_2)) => Some(sched_1.addition(sched_2)),
427        (opt_1, opt_2) => opt_1.or(opt_2),
428    }
429}
430
431// TimeDomainIterator
432
433pub struct TimeDomainIterator<L: Clone + Localize> {
434    opening_hours: OpeningHours<L>,
435    curr_date: NaiveDate,
436    curr_schedule: Peekable<crate::schedule::IntoIter>,
437    end_datetime: NaiveDateTime,
438}
439
440impl<L: Localize> TimeDomainIterator<L> {
441    fn new(
442        opening_hours: &OpeningHours<L>,
443        start_datetime: NaiveDateTime,
444        end_datetime: NaiveDateTime,
445    ) -> Self {
446        let opening_hours = opening_hours.clone();
447        let start_date = start_datetime.date();
448        let start_time = start_datetime.time().into();
449        let mut curr_schedule = opening_hours.schedule_at(start_date).into_iter().peekable();
450
451        if start_datetime >= end_datetime {
452            (&mut curr_schedule).for_each(|_| {});
453        }
454
455        while curr_schedule
456            .peek()
457            .map(|tr| !tr.range.contains(&start_time))
458            .unwrap_or(false)
459        {
460            curr_schedule.next();
461        }
462
463        Self {
464            opening_hours,
465            curr_date: start_date,
466            curr_schedule,
467            end_datetime,
468        }
469    }
470
471    fn consume_until_next_state(&mut self, curr_state: (RuleKind, &str)) {
472        let start_date = self.curr_date;
473
474        while self
475            .curr_schedule
476            .peek()
477            .map(|tr| tr.as_state() == curr_state)
478            .unwrap_or(false)
479        {
480            // Early return if infinite approximation is enabled
481            if let Some(max_interval_size) = self.opening_hours.ctx.approx_bound_interval_size {
482                if self.curr_date - start_date > max_interval_size + chrono::TimeDelta::days(1) {
483                    return;
484                }
485            }
486
487            self.curr_schedule.next();
488
489            if self.curr_schedule.peek().is_none() {
490                let next_change_hint = self
491                    .opening_hours
492                    .next_change_hint(self.curr_date)
493                    .unwrap_or_else(|| self.curr_date.succ_opt().expect("reached invalid date"));
494
495                assert!(next_change_hint > self.curr_date, "infinite loop detected");
496                self.curr_date = next_change_hint;
497
498                if self.curr_date > self.end_datetime.date() || self.curr_date >= DATE_END.date() {
499                    break;
500                }
501
502                self.curr_schedule = (self.opening_hours)
503                    .schedule_at(self.curr_date)
504                    .into_iter()
505                    .peekable();
506            }
507        }
508    }
509}
510
511impl<L: Localize> Iterator for TimeDomainIterator<L> {
512    type Item = DateTimeRange;
513
514    fn next(&mut self) -> Option<Self::Item> {
515        if let Some(curr_tr) = self.curr_schedule.peek().cloned() {
516            let start = NaiveDateTime::new(
517                self.curr_date,
518                curr_tr
519                    .range
520                    .start
521                    .try_into()
522                    .expect("got invalid time from schedule"),
523            );
524
525            self.consume_until_next_state(curr_tr.as_state());
526            let end_date = self.curr_date;
527
528            let end_time = self
529                .curr_schedule
530                .peek()
531                .map(|tr| tr.range.start)
532                .unwrap_or(ExtendedTime::MIDNIGHT_00);
533
534            let end = std::cmp::min(
535                self.end_datetime,
536                NaiveDateTime::new(
537                    end_date,
538                    end_time.try_into().expect("got invalid time from schedule"),
539                ),
540            );
541
542            // Infinity approximation, if enabled
543            if let Some(max_interval_size) = self.opening_hours.ctx.approx_bound_interval_size {
544                if end - start > max_interval_size {
545                    return Some(DateTimeRange {
546                        range: start..DATE_END,
547                        kind: curr_tr.kind,
548                        comment: curr_tr.comment.clone(),
549                    });
550                }
551            }
552
553            Some(DateTimeRange {
554                range: start..end,
555                kind: curr_tr.kind,
556                comment: curr_tr.comment.clone(),
557            })
558        } else {
559            None
560        }
561    }
562}