Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion gix-date/src/parse/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<count> <unit>` 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<SystemTime>) -> Result<Time, Exn<Error>> {
// Git accepts a leading `@` before a commit-header date: `match_object_header_date()` in
// `date.c` takes `<seconds> ±HHMM`, while an offsetless `@<seconds>` 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)
Expand Down
105 changes: 84 additions & 21 deletions gix-date/src/parse/relative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,93 @@ fn parse_named(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Exn
Some(subtract_span(now, span))
}

/// Parse Git-style relative count-unit pairs into one span.
///
/// Returns `None` if no pair is recognized and `Some(Err(_))` if span construction fails.
fn parse_ago(input: &str) -> Option<Result<Span, Exn<Error>>> {
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<i64> {
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<Result<Span, Exn<Error>>> {
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<SystemTime>, span: Span) -> Result<Zoned, Exn<ValidationError>> {
Expand All @@ -62,19 +141,3 @@ fn subtract_span(now: Option<SystemTime>, span: Span) -> Result<Zoned, Exn<Valid
zdt.checked_sub(span)
.or_raise(|| Error::new(format!("Failed to subtract {zdt} from {span}")))
}

fn span(period: &str, units: i64) -> Option<Result<Span, Exn<Error>>> {
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}'"))))
}
31 changes: 31 additions & 0 deletions gix-date/tests/fixtures/generate_git_date_baseline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 "<n> <unit> 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' ''
Binary file not shown.
12 changes: 7 additions & 5 deletions gix-date/tests/time/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions gix-date/tests/time/parse/relative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading