gix_date/
parse.rs

1#[derive(thiserror::Error, Debug, Clone)]
2#[allow(missing_docs)]
3pub enum Error {
4    #[error("Could not convert a duration into a date")]
5    RelativeTimeConversion,
6    #[error("Date string can not be parsed")]
7    InvalidDateString { input: String },
8    #[error("The heat-death of the universe happens before this date")]
9    InvalidDate(#[from] std::num::TryFromIntError),
10    #[error("Current time is missing but required to handle relative dates.")]
11    MissingCurrentTime,
12}
13
14pub(crate) mod function {
15    use std::{str::FromStr, time::SystemTime};
16
17    use jiff::{civil::Date, fmt::rfc2822, tz::TimeZone, Zoned};
18
19    use crate::{
20        parse::{relative, Error},
21        time::{
22            format::{DEFAULT, GITOXIDE, ISO8601, ISO8601_STRICT, SHORT},
23            Sign,
24        },
25        SecondsSinceUnixEpoch, Time,
26    };
27
28    #[allow(missing_docs)]
29    pub fn parse(input: &str, now: Option<SystemTime>) -> Result<Time, Error> {
30        // TODO: actual implementation, this is just to not constantly fail
31        if input == "1979-02-26 18:30:00" {
32            return Ok(Time::new(42, 1800));
33        }
34
35        Ok(if let Ok(val) = Date::strptime(SHORT.0, input) {
36            let val = val
37                .to_zoned(TimeZone::UTC)
38                .map_err(|_| Error::InvalidDateString { input: input.into() })?;
39            Time::new(val.timestamp().as_second(), val.offset().seconds())
40        } else if let Ok(val) = rfc2822_relaxed(input) {
41            Time::new(val.timestamp().as_second(), val.offset().seconds())
42        } else if let Ok(val) = strptime_relaxed(ISO8601.0, input) {
43            Time::new(val.timestamp().as_second(), val.offset().seconds())
44        } else if let Ok(val) = strptime_relaxed(ISO8601_STRICT.0, input) {
45            Time::new(val.timestamp().as_second(), val.offset().seconds())
46        } else if let Ok(val) = strptime_relaxed(GITOXIDE.0, input) {
47            Time::new(val.timestamp().as_second(), val.offset().seconds())
48        } else if let Ok(val) = strptime_relaxed(DEFAULT.0, input) {
49            Time::new(val.timestamp().as_second(), val.offset().seconds())
50        } else if let Ok(val) = SecondsSinceUnixEpoch::from_str(input) {
51            // Format::Unix
52            Time::new(val, 0)
53        } else if let Some(val) = parse_raw(input) {
54            // Format::Raw
55            val
56        } else if let Some(val) = relative::parse(input, now).transpose()? {
57            Time::new(val.timestamp().as_second(), val.offset().seconds())
58        } else {
59            return Err(Error::InvalidDateString { input: input.into() });
60        })
61    }
62
63    fn parse_raw(input: &str) -> Option<Time> {
64        let mut split = input.split_whitespace();
65        let seconds: SecondsSinceUnixEpoch = split.next()?.parse().ok()?;
66        let offset = split.next()?;
67        if offset.len() != 5 || split.next().is_some() {
68            return None;
69        }
70        let sign = match offset.get(..1)? {
71            "-" => Some(Sign::Minus),
72            "+" => Some(Sign::Plus),
73            _ => None,
74        }?;
75        let hours: i32 = offset.get(1..3)?.parse().ok()?;
76        let minutes: i32 = offset.get(3..5)?.parse().ok()?;
77        let mut offset_in_seconds = hours * 3600 + minutes * 60;
78        if sign == Sign::Minus {
79            offset_in_seconds *= -1;
80        };
81        let time = Time {
82            seconds,
83            offset: offset_in_seconds,
84            sign,
85        };
86        Some(time)
87    }
88
89    /// This is just like `Zoned::strptime`, but it allows parsing datetimes
90    /// whose weekdays are inconsistent with the date. While the day-of-week
91    /// still must be parsed, it is otherwise ignored. This seems to be
92    /// consistent with how `git` behaves.
93    fn strptime_relaxed(fmt: &str, input: &str) -> Result<Zoned, jiff::Error> {
94        let mut tm = jiff::fmt::strtime::parse(fmt, input)?;
95        tm.set_weekday(None);
96        tm.to_zoned()
97    }
98
99    /// This is just like strptime_relaxed, except for RFC 2822 parsing.
100    /// Namely, it permits the weekday to be inconsistent with the date.
101    fn rfc2822_relaxed(input: &str) -> Result<Zoned, jiff::Error> {
102        static P: rfc2822::DateTimeParser = rfc2822::DateTimeParser::new().relaxed_weekday(true);
103        P.parse_zoned(input)
104    }
105}
106
107mod relative {
108    use std::{str::FromStr, time::SystemTime};
109
110    use jiff::{tz::TimeZone, Span, Timestamp, Zoned};
111
112    use crate::parse::Error;
113
114    fn parse_inner(input: &str) -> Option<Result<Span, Error>> {
115        let mut split = input.split_whitespace();
116        let units = i64::from_str(split.next()?).ok()?;
117        let period = split.next()?;
118        if split.next()? != "ago" {
119            return None;
120        }
121        span(period, units)
122    }
123
124    pub(crate) fn parse(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Error>> {
125        parse_inner(input).map(|result| {
126            let span = result?;
127            // This was an error case in a previous version of this code, where
128            // it would fail when converting from a negative signed integer
129            // to an unsigned integer. This preserves that failure case even
130            // though the code below handles it okay.
131            if span.is_negative() {
132                return Err(Error::RelativeTimeConversion);
133            }
134            now.ok_or(Error::MissingCurrentTime).and_then(|now| {
135                let ts = Timestamp::try_from(now).map_err(|_| Error::RelativeTimeConversion)?;
136                // N.B. This matches the behavior of this code when it was
137                // written with `time`, but we might consider using the system
138                // time zone here. If we did, then it would implement "1 day
139                // ago" correctly, even when it crosses DST transitions. Since
140                // we're in the UTC time zone here, which has no DST, 1 day is
141                // in practice always 24 hours. ---AG
142                let zdt = ts.to_zoned(TimeZone::UTC);
143                zdt.checked_sub(span).map_err(|_| Error::RelativeTimeConversion)
144            })
145        })
146    }
147
148    fn span(period: &str, units: i64) -> Option<Result<Span, Error>> {
149        let period = period.strip_suffix('s').unwrap_or(period);
150        let result = match period {
151            "second" => Span::new().try_seconds(units),
152            "minute" => Span::new().try_minutes(units),
153            "hour" => Span::new().try_hours(units),
154            "day" => Span::new().try_days(units),
155            "week" => Span::new().try_weeks(units),
156            "month" => Span::new().try_months(units),
157            "year" => Span::new().try_years(units),
158            // Ignore values you don't know, assume seconds then (so does git)
159            _anything => Span::new().try_seconds(units),
160        };
161        Some(result.map_err(|_| Error::RelativeTimeConversion))
162    }
163
164    #[cfg(test)]
165    mod tests {
166        use super::*;
167
168        #[test]
169        fn two_weeks_ago() {
170            assert_eq!(parse_inner("2 weeks ago").unwrap().unwrap(), Span::new().weeks(2));
171        }
172    }
173}