| @@ -4,43 +4,6 @@ use crate::Error; · 3 unmodified lines |
| 4 | 4 | use gix_error::{ensure, Exn, ResultExt, ValidationError}; |
| 5 | 5 | use jiff::{tz::TimeZone, Span, Timestamp, Zoned}; |
| 6 | 6 | |
| 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 | | − |
| 44 | 7 | pub fn parse(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Exn<Error>>> { |
| 45 | 8 | // First try named dates |
| 46 | 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 | 13 | // 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 | 15 | let span = result?; |
| 53 | 16 | // This was an error case in a previous version of this code, where |
| 54 | 17 | // it would fail when converting from a negative signed integer |
| 55 | 18 | // to an unsigned integer. This preserves that failure case even |
| 56 | 19 | // though the code below handles it okay. |
| 57 | 20 | 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) |
| 69 | 22 | }) |
| 70 | 23 | } |
| 71 | 24 | |
| 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 | + |
| 72 | 66 | fn span(period: &str, units: i64) -> Option<Result<Span, Exn<Error>>> { |
| 73 | 67 | let period = period.strip_suffix('s').unwrap_or(period); |
| 74 | 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 | 79 | Some(result.or_raise(|| Error::new(format!("Couldn't parse span from '{period} {units}'")))) |
| 86 | 80 | } |
| 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 | | −} |