diff --git a/Cargo.lock b/Cargo.lock index dd84266ea6..846bf205e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,10 +337,22 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-targets 0.52.5", ] +[[package]] +name = "chrono-english" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73d909da7eb4a7d88c679c3f5a1bc09d965754e0adb2e7627426cef96a00d6f" +dependencies = [ + "chrono", + "scanlex", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -1909,6 +1921,7 @@ dependencies = [ "blake2", "bytes", "chrono", + "chrono-english", "config", "criterion", "digest", @@ -2811,6 +2824,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scanlex" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088c5d71572124929ea7549a8ce98e1a6fd33d0a38367b09027b382e67c033db" + [[package]] name = "scm-record" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 14b4ff525e..1b37d640db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ chrono = { version = "0.4.38", default-features = false, features = [ "std", "clock", ] } +chrono-english = { version = "0.1.7" } config = { version = "0.13.4", default-features = false, features = ["toml"] } criterion = "0.5.1" crossterm = { version = "0.27", default-features = false } diff --git a/lib/Cargo.toml b/lib/Cargo.toml index a577d1d17b..5f3859de66 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -38,6 +38,7 @@ backoff = { workspace = true } blake2 = { workspace = true } bytes = { workspace = true } chrono = { workspace = true } +chrono-english = { workspace = true } config = { workspace = true } digest = { workspace = true } either = { workspace = true } diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 149956ad2b..2de66b6848 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -84,6 +84,7 @@ pub mod stacked_table; pub mod store; pub mod str_util; pub mod submodule_store; +pub mod time_util; pub mod transaction; pub mod tree; pub mod tree_builder; diff --git a/lib/src/time_util.rs b/lib/src/time_util.rs new file mode 100644 index 0000000000..f550f70459 --- /dev/null +++ b/lib/src/time_util.rs @@ -0,0 +1,189 @@ +// Copyright 2024 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Provides support for parsing and matching date ranges. + +use chrono::{DateTime, Local, TimeZone, Utc}; +use chrono_english::{parse_date_string, DateError, Dialect}; +use thiserror::Error; + +use crate::backend::Timestamp; + +/// Context needed to create a DatePattern during revset evaluation. +#[derive(Clone, Debug)] +pub enum DatePatternContext { + /// Interpret date patterns using the local machine's time zone + Local(DateTime), + /// Interpret date patterns using UTC + Utc(DateTime), +} + +impl DatePatternContext { + /// Parses a DatePattern from the given string and kind. + pub fn to_date_pattern( + &self, + s: &str, + kind: &str, + ) -> Result { + match &self { + DatePatternContext::Local(dt) => DatePattern::from_str_kind(s, kind, dt.to_owned()), + DatePatternContext::Utc(dt) => DatePattern::from_str_kind(s, kind, dt.to_owned()), + } + } +} + +impl From> for DatePatternContext { + fn from(value: DateTime) -> Self { + DatePatternContext::Local(value) + } +} + +impl From> for DatePatternContext { + fn from(value: DateTime) -> Self { + DatePatternContext::Utc(value) + } +} + +/// Error occurred during date pattern parsing. +#[derive(Debug, Error)] +pub enum DatePatternParseError { + /// Unknown pattern kind is specified. + #[error(r#"Invalid date pattern kind "{0}:""#)] + InvalidKind(String), + /// Failed to parse timestamp. + #[error(transparent)] + ParseError(#[from] DateError), +} + +/// Represents an range of dates that may be matched against. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DatePattern { + /// Represents all dates at or after the given instant. + AtOrAfter(Timestamp), + /// Represents all dates before, but not including, the given instant. + Before(Timestamp), +} + +impl DatePattern { + /// Parses a string into a DatePattern. + /// + /// * `s` is the string to be parsed. + /// + /// * `kind` must be either "after" or "before". This determines whether the + /// pattern will match dates after or before the parsed date. + /// + /// * `now` is the user's current time. This is a DateTime because + /// knowledge of offset changes is needed to correctly process relative + /// times like "today". For example, California entered DST on March 10, + /// 2024, shifting clocks from UTC-8 to UTC-7 at 2:00 AM. If the pattern + /// "today" was parsed at noon on that day, it should be interpreted as + /// 2024-03-10T00:00:00-08:00 even though the current offset is -07:00. + pub fn from_str_kind( + s: &str, + kind: &str, + now: DateTime, + ) -> Result + where + Tz::Offset: Copy, + { + let d = + parse_date_string(s, now, Dialect::Us).map_err(DatePatternParseError::ParseError)?; + let ts = Timestamp::from_datetime(d.to_utc().fixed_offset()); + match kind { + "after" => Ok(DatePattern::AtOrAfter(ts)), + "before" => Ok(DatePattern::Before(ts)), + kind => Err(DatePatternParseError::InvalidKind(kind.to_owned())), + } + } + + /// Determines whether a given timestamp is matched by the pattern. + pub fn matches(&self, timestamp: &Timestamp) -> bool { + match self { + DatePattern::AtOrAfter(earliest) => earliest <= timestamp, + DatePattern::Before(latest) => timestamp < latest, + } + } +} + +#[cfg(test)] +mod tests { + use chrono::DateTime; + + use super::*; + + fn test_equal(now: DateTime, expression: &str, should_equal_time: &str) + where + Tz::Offset: Copy, + { + let expression = DatePattern::from_str_kind(expression, "after", now).unwrap(); + assert_eq!( + expression, + DatePattern::AtOrAfter(Timestamp::from_datetime( + DateTime::parse_from_rfc3339(should_equal_time) + .unwrap() + .to_utc() + .fixed_offset() + )) + ); + } + + #[test] + fn test_date_pattern_parses_dates_without_times_as_the_date_at_local_midnight() { + let now = DateTime::parse_from_rfc3339("2024-01-01T00:00:00-08:00").unwrap(); + test_equal(now, "2023-03-25", "2023-03-25T08:00:00Z"); + test_equal(now, "3/25/2023", "2023-03-25T08:00:00Z"); + test_equal(now, "3/25/23", "2023-03-25T08:00:00Z"); + } + + #[test] + fn test_date_pattern_parses_dates_with_times_without_specifying_an_offset() { + let now = DateTime::parse_from_rfc3339("2024-01-01T00:00:00-08:00").unwrap(); + test_equal(now, "2023-03-25T00:00:00", "2023-03-25T08:00:00Z"); + test_equal(now, "2023-03-25 00:00:00", "2023-03-25T08:00:00Z"); + } + + #[test] + fn test_date_pattern_parses_dates_with_a_specified_offset() { + let now = DateTime::parse_from_rfc3339("2024-01-01T00:00:00-08:00").unwrap(); + test_equal( + now, + "2023-03-25T00:00:00-05:00", + "2023-03-25T00:00:00-05:00", + ); + } + + #[test] + fn test_date_pattern_parses_dates_with_the_z_offset() { + let now = DateTime::parse_from_rfc3339("2024-01-01T00:00:00-08:00").unwrap(); + test_equal(now, "2023-03-25T00:00:00Z", "2023-03-25T00:00:00Z"); + } + + #[test] + fn test_date_pattern_parses_relative_durations() { + let now = DateTime::parse_from_rfc3339("2024-01-01T00:00:00-08:00").unwrap(); + test_equal(now, "2 hours ago", "2024-01-01T06:00:00Z"); + test_equal(now, "5 minutes", "2024-01-01T08:05:00Z"); + test_equal(now, "1 week ago", "2023-12-25T08:00:00Z"); + test_equal(now, "yesterday", "2023-12-31T08:00:00Z"); + test_equal(now, "tomorrow", "2024-01-02T08:00:00Z"); + } + + #[test] + fn test_date_pattern_parses_relative_dates_with_times() { + let now = DateTime::parse_from_rfc3339("2024-01-01T08:00:00-08:00").unwrap(); + test_equal(now, "yesterday 5pm", "2024-01-01T01:00:00Z"); + test_equal(now, "yesterday 10am", "2023-12-31T18:00:00Z"); + test_equal(now, "yesterday 10:30", "2023-12-31T18:30:00Z"); + } +}