jj_lib/
time_util.rs

1// Copyright 2024 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Provides support for parsing and matching date ranges.
16
17use chrono::DateTime;
18use chrono::FixedOffset;
19use chrono::Local;
20use chrono::TimeZone;
21use interim::DateError;
22use interim::Dialect;
23use interim::parse_date_string;
24use thiserror::Error;
25
26use crate::backend::MillisSinceEpoch;
27use crate::backend::Timestamp;
28
29/// Context needed to create a DatePattern during revset evaluation.
30#[derive(Copy, Clone, Debug)]
31pub enum DatePatternContext {
32    /// Interpret date patterns using the local machine's time zone
33    Local(DateTime<Local>),
34    /// Interpret date patterns using any FixedOffset time zone
35    Fixed(DateTime<FixedOffset>),
36}
37
38impl DatePatternContext {
39    /// Parses a DatePattern from the given string and kind.
40    pub fn parse_relative(
41        &self,
42        s: &str,
43        kind: &str,
44    ) -> Result<DatePattern, DatePatternParseError> {
45        match *self {
46            Self::Local(dt) => DatePattern::from_str_kind(s, kind, dt),
47            Self::Fixed(dt) => DatePattern::from_str_kind(s, kind, dt),
48        }
49    }
50}
51
52impl From<DateTime<Local>> for DatePatternContext {
53    fn from(value: DateTime<Local>) -> Self {
54        Self::Local(value)
55    }
56}
57
58impl From<DateTime<FixedOffset>> for DatePatternContext {
59    fn from(value: DateTime<FixedOffset>) -> Self {
60        Self::Fixed(value)
61    }
62}
63
64/// Error occurred during date pattern parsing.
65#[derive(Debug, Error)]
66pub enum DatePatternParseError {
67    /// Unknown pattern kind is specified.
68    #[error("Invalid date pattern kind `{0}:`")]
69    InvalidKind(String),
70    /// Failed to parse timestamp.
71    #[error(transparent)]
72    ParseError(#[from] DateError),
73}
74
75/// Represents an range of dates that may be matched against.
76#[derive(Copy, Clone, Debug, Eq, PartialEq)]
77pub enum DatePattern {
78    /// Represents all dates at or after the given instant.
79    AtOrAfter(MillisSinceEpoch),
80    /// Represents all dates before, but not including, the given instant.
81    Before(MillisSinceEpoch),
82}
83
84impl DatePattern {
85    /// Parses a string into a DatePattern.
86    ///
87    /// * `s` is the string to be parsed.
88    ///
89    /// * `kind` must be either "after" or "before". This determines whether the
90    ///   pattern will match dates after or before the parsed date.
91    ///
92    /// * `now` is the user's current time. This is a [`DateTime<Tz>`] because
93    ///   knowledge of offset changes is needed to correctly process relative
94    ///   times like "today". For example, California entered DST on March 10,
95    ///   2024, shifting clocks from UTC-8 to UTC-7 at 2:00 AM. If the pattern
96    ///   "today" was parsed at noon on that day, it should be interpreted as
97    ///   2024-03-10T00:00:00-08:00 even though the current offset is -07:00.
98    pub fn from_str_kind<Tz: TimeZone>(
99        s: &str,
100        kind: &str,
101        now: DateTime<Tz>,
102    ) -> Result<Self, DatePatternParseError>
103    where
104        Tz::Offset: Copy,
105    {
106        let d =
107            parse_date_string(s, now, Dialect::Us).map_err(DatePatternParseError::ParseError)?;
108        let millis_since_epoch = MillisSinceEpoch(d.timestamp_millis());
109        match kind {
110            "after" => Ok(Self::AtOrAfter(millis_since_epoch)),
111            "before" => Ok(Self::Before(millis_since_epoch)),
112            kind => Err(DatePatternParseError::InvalidKind(kind.to_owned())),
113        }
114    }
115
116    /// Determines whether a given timestamp is matched by the pattern.
117    pub fn matches(&self, timestamp: &Timestamp) -> bool {
118        match self {
119            Self::AtOrAfter(earliest) => *earliest <= timestamp.timestamp,
120            Self::Before(latest) => timestamp.timestamp < *latest,
121        }
122    }
123}
124
125// @TODO ideally we would have this unified with the other parsing code. However
126// we use the interim crate which does not handle explicitly given time zone
127// information
128/// Parse a string with time zone information into a `Timestamp`
129pub fn parse_datetime(s: &str) -> chrono::ParseResult<Timestamp> {
130    Ok(Timestamp::from_datetime(
131        DateTime::parse_from_rfc2822(s).or_else(|_| DateTime::parse_from_rfc3339(s))?,
132    ))
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn test_equal<Tz: TimeZone>(now: DateTime<Tz>, expression: &str, should_equal_time: &str)
140    where
141        Tz::Offset: Copy,
142    {
143        let expression = DatePattern::from_str_kind(expression, "after", now).unwrap();
144        assert_eq!(
145            expression,
146            DatePattern::AtOrAfter(MillisSinceEpoch(
147                DateTime::parse_from_rfc3339(should_equal_time)
148                    .unwrap()
149                    .timestamp_millis()
150            ))
151        );
152    }
153
154    #[test]
155    fn test_date_pattern_parses_dates_without_times_as_the_date_at_local_midnight() {
156        let now = DateTime::parse_from_rfc3339("2024-01-01T00:00:00-08:00").unwrap();
157        test_equal(now, "2023-03-25", "2023-03-25T08:00:00Z");
158        test_equal(now, "3/25/2023", "2023-03-25T08:00:00Z");
159        test_equal(now, "3/25/23", "2023-03-25T08:00:00Z");
160    }
161
162    #[test]
163    fn test_date_pattern_parses_dates_with_times_without_specifying_an_offset() {
164        let now = DateTime::parse_from_rfc3339("2024-01-01T00:00:00-08:00").unwrap();
165        test_equal(now, "2023-03-25T00:00:00", "2023-03-25T08:00:00Z");
166        test_equal(now, "2023-03-25 00:00:00", "2023-03-25T08:00:00Z");
167    }
168
169    #[test]
170    fn test_date_pattern_parses_dates_with_a_specified_offset() {
171        let now = DateTime::parse_from_rfc3339("2024-01-01T00:00:00-08:00").unwrap();
172        test_equal(
173            now,
174            "2023-03-25T00:00:00-05:00",
175            "2023-03-25T00:00:00-05:00",
176        );
177    }
178
179    #[test]
180    fn test_date_pattern_parses_dates_with_the_z_offset() {
181        let now = DateTime::parse_from_rfc3339("2024-01-01T00:00:00-08:00").unwrap();
182        test_equal(now, "2023-03-25T00:00:00Z", "2023-03-25T00:00:00Z");
183    }
184
185    #[test]
186    fn test_date_pattern_parses_relative_durations() {
187        let now = DateTime::parse_from_rfc3339("2024-01-01T00:00:00-08:00").unwrap();
188        test_equal(now, "2 hours ago", "2024-01-01T06:00:00Z");
189        test_equal(now, "5 minutes", "2024-01-01T08:05:00Z");
190        test_equal(now, "1 week ago", "2023-12-25T08:00:00Z");
191        test_equal(now, "yesterday", "2023-12-31T08:00:00Z");
192        test_equal(now, "tomorrow", "2024-01-02T08:00:00Z");
193    }
194
195    #[test]
196    fn test_date_pattern_parses_relative_dates_with_times() {
197        let now = DateTime::parse_from_rfc3339("2024-01-01T08:00:00-08:00").unwrap();
198        test_equal(now, "yesterday 5pm", "2024-01-01T01:00:00Z");
199        test_equal(now, "yesterday 10am", "2023-12-31T18:00:00Z");
200        test_equal(now, "yesterday 10:30", "2023-12-31T18:30:00Z");
201    }
202
203    #[test]
204    fn test_parse_datetime_non_sense_yields_error() {
205        let parse_error = parse_datetime("aaaaa").err().unwrap();
206        assert_eq!(parse_error.kind(), chrono::format::ParseErrorKind::Invalid);
207    }
208}