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
4use gix_error::{ensure, Exn, ResultExt, ValidationError};4use gix_error::{ensure, Exn, ResultExt, ValidationError};
5use jiff::{tz::TimeZone, Span, Timestamp, Zoned};5use jiff::{tz::TimeZone, Span, Timestamp, Zoned};
6 6
7fn 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".
18fn 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
44pub fn parse(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Exn<Error>>> {7pub fn parse(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Exn<Error>>> {
45 // First try named dates8 // First try named dates
46 if let Some(result) = parse_named(input, now) {9 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
48 }11 }
49 12
50 // Then try numeric relative dates13 // 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>> {
52 let span = result?;15 let span = result?;
53 // This was an error case in a previous version of this code, where16 // This was an error case in a previous version of this code, where
54 // it would fail when converting from a negative signed integer17 // it would fail when converting from a negative signed integer
55 // to an unsigned integer. This preserves that failure case even18 // to an unsigned integer. This preserves that failure case even
56 // though the code below handles it okay.19 // though the code below handles it okay.
57 ensure!(!span.is_negative(), ValidationError::new(""));20 ensure!(!span.is_negative(), ValidationError::new(""));
58 let now = now.ok_or(ValidationError::new("Missing current time"))?;21 subtract_span(now, span)
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}")))
69 })22 })
70}23}
71 24
25/// Parse named relative dates like "now", "today", "yesterday".
26fn 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
42fn 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
52fn 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
72fn span(period: &str, units: i64) -> Option<Result<Span, Exn<Error>>> {66fn span(period: &str, units: i64) -> Option<Result<Span, Exn<Error>>> {
73 let period = period.strip_suffix('s').unwrap_or(period);67 let period = period.strip_suffix('s').unwrap_or(period);
74 let result = match period {68 let result = match period {
@@ -84,44 +78,3 @@ fn span(period: &str, units: i64) -> Option<Result<Span, Exn<Error>>> { · 9 unmodified lines
84 };78 };
85 Some(result.or_raise(|| Error::new(format!("Couldn't parse span from '{period} {units}'"))))79 Some(result.or_raise(|| Error::new(format!("Couldn't parse span from '{period} {units}'"))))
86}80}
87
88#[cfg(test)]
89mod 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
6 let mut buf = Vec::with_capacity(Time::MAX.size());6 let mut buf = Vec::with_capacity(Time::MAX.size());
7 self.write_to(&mut buf).expect("write to memory cannot fail");7 self.write_to(&mut buf).expect("write to memory cannot fail");
8 // Time serializes as ASCII, which is a subset of UTF-8.8 // 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.
10 let raw = std::str::from_utf8(&buf).expect("time serializes as valid UTF-8");11 let raw = std::str::from_utf8(&buf).expect("time serializes as valid UTF-8");
11 f.write_str(raw)12 f.write_str(raw)
12 }13 }
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
138# ============================================================================138# ============================================================================
139# These tests use GIT_TEST_DATE_NOW=1000000000 (Sun Sep 9 01:46:40 UTC 2001)139# These tests use GIT_TEST_DATE_NOW=1000000000 (Sun Sep 9 01:46:40 UTC 2001)
140 140
141# Named
142# 'now' and 'today' don't seem to work.
143baseline_relative 'yesterday' ''
144
141# Seconds - from git t0006 check_relative145# Seconds - from git t0006 check_relative
142baseline_relative '1 second ago' ''146baseline_relative '1 second ago' ''
143baseline_relative '2 seconds ago' ''147baseline_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
19/// Other relative formats like "yesterday", "last week" etc. are not included in baseline19/// Other relative formats like "yesterday", "last week" etc. are not included in baseline
20/// testing because they would require additional handling in the baseline script.20/// testing because they would require additional handling in the baseline script.
21fn is_relative_date(pattern: &str) -> bool {21fn is_relative_date(pattern: &str) -> bool {
22 pattern.ends_with(" ago")22 pattern.ends_with(" ago") || pattern == "now" || pattern == "today" || pattern == "yesterday"
23}23}
24 24
25/// The fixed "now" timestamp used for testing relative dates.25/// 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
152 _ = gix_date::parse(date, None).unwrap_or_else(|err| unreachable!("{date}: all examples can be parsed: {err}"));152 _ = gix_date::parse(date, None).unwrap_or_else(|err| unreachable!("{date}: all examples can be parsed: {err}"));
153 }153 }
154}154}
155
156mod 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}