diff --git a/Cargo.lock b/Cargo.lock index 7c609fe231..4e6feee10c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,10 +336,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" @@ -1728,6 +1740,7 @@ dependencies = [ "blake2", "bytes", "chrono", + "chrono-english", "config", "criterion", "digest", @@ -2620,6 +2633,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 099ce5e702..748c327293 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 e6a47dede4..8a480acae4 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..72902eb54c 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_pattern; pub mod transaction; pub mod tree; pub mod tree_builder; diff --git a/lib/src/time_pattern.rs b/lib/src/time_pattern.rs new file mode 100644 index 0000000000..71859659da --- /dev/null +++ b/lib/src/time_pattern.rs @@ -0,0 +1,137 @@ +// 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 a TimeExpression type that represents a range of time. + +use chrono::{DateTime, TimeZone}; +use chrono_english::{parse_date_string, DateError, Dialect}; +use thiserror::Error; + +use crate::backend::Timestamp; + +/// Error occurred during time pattern parsing. +#[derive(Debug, Error)] +pub enum TimePatternParseError { + /// Unknown pattern kind is specified. + #[error(r#"Invalid time pattern kind "{0}:""#)] + InvalidKind(String), + /// Failed to parse input UI path. + #[error(transparent)] + ParseError(#[from] DateError), +} + +/// Represents an range of dates and times that revisions may be matched +/// against. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TimePattern { + /// Represents all times at or after the given instant in time. + AtOrAfter(Timestamp), + /// Represents all times before, but not including, the given instant in + /// time. + Before(Timestamp), +} + +impl TimePattern { + /// Parses a string into a TimePattern. + pub fn from_str_kind( + s: &str, + kind: Option<&str>, + now: DateTime, + ) -> Result + where + Tz::Offset: Copy, + { + let d = parse_date_string(s, now, Dialect::Us) + .map_err(|err| TimePatternParseError::ParseError(err))?; + let ts = Timestamp::from_datetime(d.to_utc().fixed_offset()); + match kind { + None => Ok(TimePattern::AtOrAfter(ts)), + Some("after") => Ok(TimePattern::AtOrAfter(ts)), + Some("before") => Ok(TimePattern::Before(ts)), + Some(kind) => Err(TimePatternParseError::InvalidKind(kind.to_owned())), + } + } +} + +#[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 = TimePattern::from_str_kind(expression, Some("after"), now).unwrap(); + assert_eq!( + expression, + TimePattern::AtOrAfter(Timestamp::from_datetime( + DateTime::parse_from_rfc3339(should_equal_time) + .unwrap() + .to_utc() + .fixed_offset() + )) + ); + } + + #[test] + fn test_time_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_time_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_time_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_time_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_time_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_time_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"); + } +}