History

feat: `parse()` now supports 'now', 'today' and 'yesterday'.

Sebastian Thiel committed 9 months ago

Commit fdca5631 parent fca0fc56 files · +88 −91Browse files

Unified · Split

6 files
  1. gix-date/src/parse/relative.rs +43 −90
  2. gix-date/src/time/write.rs +2 −1
  3. gix-date/tests/fixtures/generate_git_date_baseline.sh +4 −0
  4. gix-date/tests/fixtures/generated-archives/generate_git_date_baseline.tar +0 −0
  5. gix-date/tests/time/baseline.rs +1 −1
  6. gix-date/tests/time/parse/relative.rs +39 −0
gix-date/src/parse/relative.rs+43−90
@@ -4,43 +4,6 @@ use crate::Error; · 3 unmodified lines
44 use gix_error::{ensure, Exn, ResultExt, ValidationError};
55 use jiff::{tz::TimeZone, Span, Timestamp, Zoned};
66
7−fn parse_inner(input: &str) -> Option<Result<Span, Exn<Error>>> {
8− let mut split = input.split_whitespace();
9− let units = i64::from_str(split.next()?).ok()?;
10− let period = split.next()?;
11− if split.next()? != "ago" {
12− return None;
13− }
14− span(period, units)
15−}
16−
17−/// Parse named relative dates like "now", "today", "yesterday".
18−fn parse_named(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Exn<Error>>> {
19− let input = input.trim().to_lowercase();
20− let span = match input.as_str() {
21− "now" => Span::new(),
22− "today" => {
23− // "today" means midnight of today, but we just return now for simplicity
24− // like git does in many contexts
25− Span::new()
26− }
27− "yesterday" => Span::new().try_days(1).ok()?,
28− _ => return None,
29− };
30−
31− Some((|| -> Result<Zoned, Exn<Error>> {
32− let now = now.ok_or(ValidationError::new("Missing current time"))?;
33− let ts: Timestamp = Timestamp::try_from(now).or_raise(|| Error::new("Could not convert current time"))?;
34− let zdt = ts.to_zoned(TimeZone::UTC);
35− if span.is_zero() {
36− Ok(zdt)
37− } else {
38− zdt.checked_sub(span)
39− .or_raise(|| Error::new(format!("Failed to subtract {zdt} from {span}")))
40− }
41− })())
42−}
43−
447 pub fn parse(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Exn<Error>>> {
458 // First try named dates
469 if let Some(result) = parse_named(input, now) {
@@ -48,27 +11,58 @@ pub fn parse(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Exn<E · 1 unmodified line
4811 }
4912
5013 // Then try numeric relative dates
51− parse_inner(input).map(|result| -> Result<Zoned, Exn<Error>> {
14+ parse_ago(input).map(|result| -> Result<Zoned, Exn<Error>> {
5215 let span = result?;
5316 // This was an error case in a previous version of this code, where
5417 // it would fail when converting from a negative signed integer
5518 // to an unsigned integer. This preserves that failure case even
5619 // though the code below handles it okay.
5720 ensure!(!span.is_negative(), ValidationError::new(""));
58− let now = now.ok_or(ValidationError::new("Missing current time"))?;
59− let ts: Timestamp = Timestamp::try_from(now).or_raise(|| Error::new("Could not convert current time"))?;
60− // N.B. This matches the behavior of this code when it was
61− // written with `time`, but we might consider using the system
62− // time zone here. If we did, then it would implement "1 day
63− // ago" correctly, even when it crosses DST transitions. Since
64− // we're in the UTC time zone here, which has no DST, 1 day is
65− // in practice always 24 hours. ---AG
66− let zdt = ts.to_zoned(TimeZone::UTC);
67− zdt.checked_sub(span)
68− .or_raise(|| Error::new(format!("Failed to subtract {zdt} from {span}")))
21+ subtract_span(now, span)
6922 })
7023 }
7124
25+/// Parse named relative dates like "now", "today", "yesterday".
26+fn parse_named(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Exn<Error>>> {
27+ let input = input.trim();
28+ let span = if input.eq_ignore_ascii_case("now") {
29+ Span::new()
30+ } else if input.eq_ignore_ascii_case("today") {
31+ // "today" is treated the same as "now" (current time) for simplicity
32+ Span::new()
33+ } else if input.eq_ignore_ascii_case("yesterday") {
34+ Span::new().try_days(1).ok()?
35+ } else {
36+ return None;
37+ };
38+
39+ Some(subtract_span(now, span))
40+}
41+
42+fn parse_ago(input: &str) -> Option<Result<Span, Exn<Error>>> {
43+ let mut split = input.split_whitespace();
44+ let units = i64::from_str(split.next()?).ok()?;
45+ let period = split.next()?;
46+ if split.next()? != "ago" {
47+ return None;
48+ }
49+ span(period, units)
50+}
51+
52+fn subtract_span(now: Option<SystemTime>, span: Span) -> Result<Zoned, Exn<ValidationError>> {
53+ let now = now.ok_or(ValidationError::new("Missing current time"))?;
54+ let ts: Timestamp = Timestamp::try_from(now).or_raise(|| Error::new("Could not convert current time"))?;
55+ // N.B. This matches the behavior of this code when it was
56+ // written with `time`, but we might consider using the system
57+ // time zone here. If we did, then it would implement "1 day
58+ // ago" correctly, even when it crosses DST transitions. Since
59+ // we're in the UTC time zone here, which has no DST, 1 day is
60+ // in practice always 24 hours. ---AG
61+ let zdt = ts.to_zoned(TimeZone::UTC);
62+ zdt.checked_sub(span)
63+ .or_raise(|| Error::new(format!("Failed to subtract {zdt} from {span}")))
64+}
65+
7266 fn span(period: &str, units: i64) -> Option<Result<Span, Exn<Error>>> {
7367 let period = period.strip_suffix('s').unwrap_or(period);
7468 let result = match period {
@@ -84,44 +78,3 @@ fn span(period: &str, units: i64) -> Option<Result<Span, Exn<Error>>> { · 9 unmodified lines
8478 };
8579 Some(result.or_raise(|| Error::new(format!("Couldn't parse span from '{period} {units}'"))))
8680 }
87−
88−#[cfg(test)]
89−mod tests {
90− use super::*;
91− use std::time::Duration;
92−
93− #[test]
94− fn two_weeks_ago() {
95− let actual = parse_inner("2 weeks ago").unwrap().unwrap();
96− assert_eq!(actual.fieldwise(), Span::new().weeks(2));
97− }
98−
99− #[test]
100− fn now() {
101− let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
102− let result = parse("now", Some(now)).unwrap().unwrap();
103− assert_eq!(result.timestamp().as_second(), 1_000_000);
104− }
105−
106− #[test]
107− fn today() {
108− let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
109− let result = parse("today", Some(now)).unwrap().unwrap();
110− assert_eq!(result.timestamp().as_second(), 1_000_000);
111− }
112−
113− #[test]
114− fn yesterday() {
115− let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
116− let result = parse("yesterday", Some(now)).unwrap().unwrap();
117− // yesterday is 1 day (86400 seconds) before now
118− assert_eq!(result.timestamp().as_second(), 1_000_000 - 86400);
119− }
120−
121− #[test]
122− fn case_insensitive() {
123− let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
124− let result = parse("NOW", Some(now)).unwrap().unwrap();
125− assert_eq!(result.timestamp().as_second(), 1_000_000);
126− }
127−}
gix-date/src/time/write.rs+2−1
@@ -6,7 +6,8 @@ impl std::fmt::Display for Time { · 5 unmodified lines
66 let mut buf = Vec::with_capacity(Time::MAX.size());
77 self.write_to(&mut buf).expect("write to memory cannot fail");
88 // Time serializes as ASCII, which is a subset of UTF-8.
9− // We use from_utf8_unchecked-free approach for safety.
9+ // Use `from_utf8()` (validated) instead of `from_utf8_unchecked()` for safety,
10+ // with the option to switch to an unsafe version if 30% performance boost are needed here.
1011 let raw = std::str::from_utf8(&buf).expect("time serializes as valid UTF-8");
1112 f.write_str(raw)
1213 }
gix-date/tests/fixtures/generate_git_date_baseline.sh+4−0
@@ -138,6 +138,10 @@ baseline '1466000000 -0200' 'RAW' # from git t0006 · 137 unmodified lines
138138 # ============================================================================
139139 # These tests use GIT_TEST_DATE_NOW=1000000000 (Sun Sep 9 01:46:40 UTC 2001)
140140
141+# Named
142+# 'now' and 'today' don't seem to work.
143+baseline_relative 'yesterday' ''
144+
141145 # Seconds - from git t0006 check_relative
142146 baseline_relative '1 second ago' ''
143147 baseline_relative '2 seconds ago' ''
gix-date/tests/fixtures/generated-archives/generate_git_date_baseline.tar+0−0

Binary file not shown.

gix-date/tests/time/baseline.rs+1−1
@@ -19,7 +19,7 @@ struct Sample { · 18 unmodified lines
1919 /// Other relative formats like "yesterday", "last week" etc. are not included in baseline
2020 /// testing because they would require additional handling in the baseline script.
2121 fn is_relative_date(pattern: &str) -> bool {
22− pattern.ends_with(" ago")
22+ pattern.ends_with(" ago") || pattern == "now" || pattern == "today" || pattern == "yesterday"
2323 }
2424
2525 /// The fixed "now" timestamp used for testing relative dates.
gix-date/tests/time/parse/relative.rs+39−0
@@ -152,3 +152,42 @@ fn various_examples() { · 151 unmodified lines
152152 _ = gix_date::parse(date, None).unwrap_or_else(|err| unreachable!("{date}: all examples can be parsed: {err}"));
153153 }
154154 }
155+
156+mod named {
157+ use std::time::{Duration, SystemTime};
158+
159+ #[test]
160+ fn now() {
161+ let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
162+ let actual = gix_date::parse("now", Some(now)).unwrap();
163+ assert_eq!(actual.seconds, 1_000_000);
164+ }
165+
166+ #[test]
167+ fn today() {
168+ let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
169+ let actual = gix_date::parse(" today ", Some(now)).unwrap();
170+ assert_eq!(
171+ actual.seconds, 1_000_000,
172+ "the input is independent of surrounding whitespace as well"
173+ );
174+ }
175+
176+ #[test]
177+ fn yesterday() {
178+ let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
179+ let actual = gix_date::parse("yesterday", Some(now)).unwrap();
180+ assert_eq!(
181+ actual.seconds,
182+ 1_000_000 - 86400,
183+ "yesterday is 1 day (86400 seconds) before now"
184+ );
185+ }
186+
187+ #[test]
188+ fn now_case_insensitive() {
189+ let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
190+ let actual = gix_date::parse("NOW", Some(now)).unwrap();
191+ assert_eq!(actual.seconds, 1_000_000);
192+ }
193+}