diff --git a/gix-date/src/parse/function.rs b/gix-date/src/parse/function.rs index b700511e72c..a671c41d681 100644 --- a/gix-date/src/parse/function.rs +++ b/gix-date/src/parse/function.rs @@ -54,6 +54,9 @@ use gix_error::{Exn, ResultExt}; /// * `1660874655 +0800` /// * `-1660874655 +0800` /// +/// A leading `@` may introduce either of the two forms above, as in `@1745582210 +0200` +/// or `@1700000000`. +/// /// See also the [`parse_header()`]. /// /// ### 8. GITOXIDE Format @@ -66,13 +69,32 @@ use gix_error::{Exn, ResultExt}; /// * `Thu Sep 4 10:45:06 2022 -0400` /// * `Mon Oct 27 10:30:00 2023 +0000` /// -/// ### 10. Relative Dates (e.g., "2 minutes ago", "1 hour from now") +/// ### 10. Relative Dates (e.g., "2 minutes ago") /// /// These dates are parsed *relative to a `now` timestamp*. The examples depend entirely on the value of `now`. /// If `now` is October 27, 2023 at 10:00:00 UTC: /// * `2 minutes ago` (October 27, 2023 at 09:58:00 UTC) /// * `3 hours ago` (October 27, 2023 at 07:00:00 UTC) +/// +/// The forms understood are `now`, `today`, `yesterday`, and one or more ` ` pairs, +/// as in `2 days 3 hours ago`. A count may be spelled out from `one` to `ten`, or be `last`, and +/// any byte that is neither a digit nor a letter separates the parts, so `1.hour.ago` is the same +/// as `1 hour ago`. The trailing `ago` is optional. +/// +/// Note that there is no way to name a time in the future: Git has none either, so `1 hour from +/// now` is an hour in the past to it, and to this function. pub fn parse(input: &str, now: Option) -> Result> { + // Git accepts a leading `@` before a commit-header date: `match_object_header_date()` in + // `date.c` takes ` ±HHMM`, while an offsetless `@` arrives at the same + // result through the generic loop, which skips the `@` and reads the digits as an epoch. + if let Some(rest) = input.strip_prefix('@') { + if let Some(val) = parse_raw(rest) { + return Ok(val); + } + if let Ok(seconds) = SecondsSinceUnixEpoch::from_str(rest) { + return Ok(Time::new(seconds, 0)); + } + } Ok(if let Ok(val) = Date::strptime(SHORT.0, input) { let val = val .to_zoned(TimeZone::UTC) diff --git a/gix-date/src/parse/relative.rs b/gix-date/src/parse/relative.rs index a86f0a3c66c..c8d7aca1d8a 100644 --- a/gix-date/src/parse/relative.rs +++ b/gix-date/src/parse/relative.rs @@ -39,14 +39,93 @@ fn parse_named(input: &str, now: Option) -> Option Option>> { - let mut split = input.split_whitespace(); - let units = i64::from_str(split.next()?).ok()?; - let period = split.next()?; - if split.next()? != "ago" { + let mut words = input + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|s| !s.is_empty()) + .peekable(); + + // Git applies a unit the moment it sees one and keeps going, so `2 days 3 hours ago` is both + // of them. Stopping after the first pair would turn that into a plausible-looking two days. + let mut pairs = Vec::new(); + let mut ago = false; + while let Some(word) = words.peek() { + if word.eq_ignore_ascii_case("ago") { + ago = true; + words.next(); + continue; + } + let Some(units) = count(word) else { + words.next(); + continue; + }; + words.next(); + let Some(period) = words.next() else { break }; + pairs.push((period, units)); + } + if pairs.is_empty() { return None; } - span(period, units) + let mut total = Span::new(); + for (period, units) in pairs { + match span(total, period, units, ago)? { + Ok(next) => total = next, + Err(err) => return Some(Err(err)), + } + } + Some(Ok(total)) +} + +/// The count in front of the unit, either written out in digits or spelled with one of one-ten. +/// Note that `zero` is deliberately absent: Git's lookup starts at +/// one, so `zero days ago` is not a relative date there either. +fn count(input: &str) -> Option { + if let Ok(units) = i64::from_str(input) { + return Some(units); + } + const NAMES: &[&str] = &[ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", + ]; + NAMES + .iter() + .position(|name| input.eq_ignore_ascii_case(name)) + .map(|pos| pos as i64 + 1) + .or_else(|| input.eq_ignore_ascii_case("last").then_some(1)) +} + +/// Add `units` of `period` to `total`. +/// +/// An unknown period returns `None` unless `ago` occurred in the input, in which case it is +/// treated as seconds. Span validation failures are returned as `Some(Err(_))`. +fn span(total: Span, period: &str, units: i64, ago: bool) -> Option>> { + let period = period + .strip_suffix('s') + .or_else(|| period.strip_suffix('S')) + .unwrap_or(period); + let result = if period.eq_ignore_ascii_case("second") { + total.try_seconds(units) + } else if period.eq_ignore_ascii_case("minute") { + total.try_minutes(units) + } else if period.eq_ignore_ascii_case("hour") { + total.try_hours(units) + } else if period.eq_ignore_ascii_case("day") { + total.try_days(units) + } else if period.eq_ignore_ascii_case("week") { + total.try_weeks(units) + } else if period.eq_ignore_ascii_case("month") { + total.try_months(units) + } else if period.eq_ignore_ascii_case("year") { + total.try_years(units) + } else if ago { + // `ago` makes any period be counted as seconds. + total.try_seconds(units) + } else { + return None; + }; + Some(result.or_raise(|| Error::new(format!("Couldn't parse span from '{period} {units}'")))) } fn subtract_span(now: Option, span: Span) -> Result> { @@ -62,19 +141,3 @@ fn subtract_span(now: Option, span: Span) -> Result Option>> { - let period = period.strip_suffix('s').unwrap_or(period); - let result = match period { - "second" => Span::new().try_seconds(units), - "minute" => Span::new().try_minutes(units), - "hour" => Span::new().try_hours(units), - "day" => Span::new().try_days(units), - "week" => Span::new().try_weeks(units), - "month" => Span::new().try_months(units), - "year" => Span::new().try_years(units), - // Ignore values you don't know, assume seconds then (so does git) - _anything => Span::new().try_seconds(units), - }; - Some(result.or_raise(|| Error::new(format!("Couldn't parse span from '{period} {units}'")))) -} diff --git a/gix-date/tests/fixtures/generate_git_date_baseline.sh b/gix-date/tests/fixtures/generate_git_date_baseline.sh index eee8bf002f3..a4d1b4193eb 100755 --- a/gix-date/tests/fixtures/generate_git_date_baseline.sh +++ b/gix-date/tests/fixtures/generate_git_date_baseline.sh @@ -130,6 +130,13 @@ baseline '946684800 +0000' 'RAW' baseline '1466000000 +0200' 'RAW' # from git t0006 baseline '1466000000 -0200' 'RAW' # from git t0006 +# Git accepts a leading `@` before either of the two forms above. Re-formatting is not checked, +# as the `@` isn't reproduced. +baseline '@1234567890' '' +baseline '@100000000' '' +baseline '@1660874655 +0800' '' +baseline '@1466000000 -0200' '' + # Note: Git does not support negative timestamps through --type=expiry-date # gix-date does support them, but they can't be tested via the baseline. @@ -195,3 +202,27 @@ baseline_relative '10 years ago' '' baseline_relative '20 years ago' '' # Note that we can't necessarily put 64bit dates here yet as `git` on the system might not yet support it. + +# ============================================================================ +# RELATIVE FORMS GIT ACCEPTS BEYOND " ago" +# ============================================================================ +# ascii-alnum is the for relevant partitions, so anything else separates them. +baseline_relative '1.hour.ago' '' +baseline_relative '1-hour-ago' '' + +# Unit names are case-insensitive +baseline_relative '2 HOURS ago' '' +baseline_relative '2 Days ago' '' +baseline_relative '2 Days ago 1 hour' '' +baseline_relative '2 Days 1 hour ago' '' +baseline_relative '2 Days and 1 hour ago' '' + +# Counts can be spelled out, 1-10. +baseline_relative 'zero days ago' '' +baseline_relative 'two days ago' '' +baseline_relative 'ten minutes ago' '' +baseline_relative 'eleven minutes ago' '' + +# `last` is a count of one, and the trailing `ago` is not required. +baseline_relative 'last week' '' +baseline_relative 'last day ago' '' diff --git a/gix-date/tests/fixtures/generated-archives/generate_git_date_baseline.tar b/gix-date/tests/fixtures/generated-archives/generate_git_date_baseline.tar index 3cb5aa33d6c..7af1076d536 100644 Binary files a/gix-date/tests/fixtures/generated-archives/generate_git_date_baseline.tar and b/gix-date/tests/fixtures/generated-archives/generate_git_date_baseline.tar differ diff --git a/gix-date/tests/time/baseline.rs b/gix-date/tests/time/baseline.rs index cee788acca5..ad9555ea4ad 100644 --- a/gix-date/tests/time/baseline.rs +++ b/gix-date/tests/time/baseline.rs @@ -14,12 +14,14 @@ struct Sample { seconds: SecondsSinceUnixEpoch, } -/// Returns true if the pattern looks like a relative date of the form "N unit ago". -/// Note: This only covers the relative dates tested in the baseline (e.g., "1 day ago"). -/// Other relative formats like "yesterday", "last week" etc. are not included in baseline -/// testing because they would require additional handling in the baseline script. +/// Returns true if the pattern is one of the relative dates recorded by `baseline_relative()`, +/// which are the entries whose expected value depends on `GIT_TEST_DATE_NOW`. +/// +/// `ago` is matched anywhere rather than as a suffix, because a separator other than a space is +/// just as good to Git: `1.hour.ago` is a relative date too. fn is_relative_date(pattern: &str) -> bool { - pattern.ends_with(" ago") || pattern == "now" || pattern == "today" || pattern == "yesterday" + let pattern = pattern.trim().to_ascii_lowercase(); + pattern.contains("ago") || matches!(pattern.as_str(), "now" | "today" | "yesterday" | "last week") } /// The fixed "now" timestamp used for testing relative dates. diff --git a/gix-date/tests/time/parse/relative.rs b/gix-date/tests/time/parse/relative.rs index c38ac6e057d..dc9e361981b 100644 --- a/gix-date/tests/time/parse/relative.rs +++ b/gix-date/tests/time/parse/relative.rs @@ -33,6 +33,10 @@ fn various() { ("5 minutes ago", 5.minutes()), ("5 hours ago", 5.hours()), ("5 days ago", 5.days()), + ("2 Days 1 hour ago", 49.hours()), + ("2 Days ago 1 hour", 49.hours()), + ("2 Days and 23 hours ago", 71.hours()), + ("last day", 24.hours()), ("3 weeks ago", 3.weeks()), ("21 days ago", 21.days()), // 3 weeks ("504 hours ago", 504.hours()), // 3 weeks