gix_date/
parse.rs

1use std::str::FromStr;
2
3use smallvec::SmallVec;
4
5use crate::Time;
6
7#[derive(thiserror::Error, Debug, Clone)]
8#[allow(missing_docs)]
9pub enum Error {
10    #[error("Could not convert a duration into a date")]
11    RelativeTimeConversion,
12    #[error("Date string can not be parsed")]
13    InvalidDateString { input: String },
14    #[error("The heat-death of the universe happens before this date")]
15    InvalidDate(#[from] std::num::TryFromIntError),
16    #[error("Current time is missing but required to handle relative dates.")]
17    MissingCurrentTime,
18}
19
20/// A container for just enough bytes to hold the largest-possible [`time`](Time) instance.
21/// It's used in conjunction with
22#[derive(Default, Clone)]
23pub struct TimeBuf {
24    buf: SmallVec<[u8; Time::MAX.size()]>,
25}
26
27impl TimeBuf {
28    /// Represent this instance as standard string, serialized in a format compatible with
29    /// signature fields in Git commits, also known as anything parseable as [raw format](function::parse_header()).
30    pub fn as_str(&self) -> &str {
31        // SAFETY: We know that serialized times are pure ASCII, a subset of UTF-8.
32        //         `buf` and `len` are written only by time-serialization code.
33        let time_bytes = self.buf.as_slice();
34        #[allow(unsafe_code)]
35        unsafe {
36            std::str::from_utf8_unchecked(time_bytes)
37        }
38    }
39
40    /// Clear the previous content.
41    fn clear(&mut self) {
42        self.buf.clear();
43    }
44}
45
46impl std::io::Write for TimeBuf {
47    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
48        self.buf.write(buf)
49    }
50
51    fn flush(&mut self) -> std::io::Result<()> {
52        self.buf.flush()
53    }
54}
55
56impl Time {
57    /// Serialize this instance into `buf`, exactly as it would appear in the header of a Git commit,
58    /// and return `buf` as `&str` for easy consumption.
59    pub fn to_str<'a>(&self, buf: &'a mut TimeBuf) -> &'a str {
60        buf.clear();
61        self.write_to(buf)
62            .expect("write to memory of just the right size cannot fail");
63        buf.as_str()
64    }
65}
66
67impl FromStr for Time {
68    type Err = Error;
69
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        crate::parse_header(s).ok_or_else(|| Error::InvalidDateString { input: s.into() })
72    }
73}
74
75pub(crate) mod function {
76    use std::{str::FromStr, time::SystemTime};
77
78    use jiff::{civil::Date, fmt::rfc2822, tz::TimeZone, Zoned};
79
80    use crate::{
81        parse::{relative, Error},
82        time::format::{DEFAULT, GITOXIDE, ISO8601, ISO8601_STRICT, SHORT},
83        OffsetInSeconds, SecondsSinceUnixEpoch, Time,
84    };
85
86    /// Parse `input` as any time that Git can parse when inputting a date.
87    ///
88    /// ## Examples
89    ///
90    /// ### 1. SHORT Format
91    ///
92    /// *   `2018-12-24`
93    /// *   `1970-01-01`
94    /// *   `1950-12-31`
95    /// *   `2024-12-31`
96    ///
97    /// ### 2. RFC2822 Format
98    ///
99    /// *   `Thu, 18 Aug 2022 12:45:06 +0800`
100    /// *   `Mon Oct 27 10:30:00 2023 -0800`
101    ///
102    /// ### 3. GIT_RFC2822 Format
103    ///
104    /// *   `Thu, 8 Aug 2022 12:45:06 +0800`
105    /// *   `Mon Oct 27 10:30:00 2023 -0800` (Note the single-digit day)
106    ///
107    /// ### 4. ISO8601 Format
108    ///
109    /// *   `2022-08-17 22:04:58 +0200`
110    /// *   `1970-01-01 00:00:00 -0500`
111    ///
112    /// ### 5. ISO8601_STRICT Format
113    ///
114    /// *   `2022-08-17T21:43:13+08:00`
115    ///
116    /// ### 6. UNIX Timestamp (Seconds Since Epoch)
117    ///
118    /// *   `123456789`
119    /// *   `0` (January 1, 1970 UTC)
120    /// *   `-1000`
121    /// *   `1700000000`
122    ///
123    /// ### 7. Commit Header Format
124    ///
125    /// *   `1745582210 +0200`
126    /// *   `1660874655 +0800`
127    /// *   `-1660874655 +0800`
128    ///
129    /// See also the [`parse_header()`].
130    ///
131    /// ### 8. GITOXIDE Format
132    ///
133    /// *   `Thu Sep 04 2022 10:45:06 -0400`
134    /// *   `Mon Oct 27 2023 10:30:00 +0000`
135    ///
136    /// ### 9. DEFAULT Format
137    ///
138    /// *   `Thu Sep 4 10:45:06 2022 -0400`
139    /// *   `Mon Oct 27 10:30:00 2023 +0000`
140    ///
141    /// ### 10. Relative Dates (e.g., "2 minutes ago", "1 hour from now")
142    ///
143    /// These dates are parsed *relative to a `now` timestamp*. The examples depend entirely on the value of `now`.
144    /// If `now` is October 27, 2023 at 10:00:00 UTC:
145    ///     *   `2 minutes ago` (October 27, 2023 at 09:58:00 UTC)
146    ///     *   `3 hours ago` (October 27, 2023 at 07:00:00 UTC)
147    pub fn parse(input: &str, now: Option<SystemTime>) -> Result<Time, Error> {
148        // TODO: actual implementation, this is just to not constantly fail
149        if input == "1979-02-26 18:30:00" {
150            return Ok(Time::new(42, 1800));
151        }
152
153        Ok(if let Ok(val) = Date::strptime(SHORT.0, input) {
154            let val = val
155                .to_zoned(TimeZone::UTC)
156                .map_err(|_| Error::InvalidDateString { input: input.into() })?;
157            Time::new(val.timestamp().as_second(), val.offset().seconds())
158        } else if let Ok(val) = rfc2822_relaxed(input) {
159            Time::new(val.timestamp().as_second(), val.offset().seconds())
160        } else if let Ok(val) = strptime_relaxed(ISO8601.0, input) {
161            Time::new(val.timestamp().as_second(), val.offset().seconds())
162        } else if let Ok(val) = strptime_relaxed(ISO8601_STRICT.0, input) {
163            Time::new(val.timestamp().as_second(), val.offset().seconds())
164        } else if let Ok(val) = strptime_relaxed(GITOXIDE.0, input) {
165            Time::new(val.timestamp().as_second(), val.offset().seconds())
166        } else if let Ok(val) = strptime_relaxed(DEFAULT.0, input) {
167            Time::new(val.timestamp().as_second(), val.offset().seconds())
168        } else if let Ok(val) = SecondsSinceUnixEpoch::from_str(input) {
169            // Format::Unix
170            Time::new(val, 0)
171        } else if let Some(val) = relative::parse(input, now).transpose()? {
172            Time::new(val.timestamp().as_second(), val.offset().seconds())
173        } else if let Some(val) = parse_header(input) {
174            // Format::Raw
175            val
176        } else {
177            return Err(Error::InvalidDateString { input: input.into() });
178        })
179    }
180
181    /// Unlike [`parse()`] which handles all kinds of input, this function only parses the commit-header format
182    /// like `1745582210 +0200`.
183    ///
184    /// Note that failure to parse the time zone isn't fatal, instead it will default to `0`. To know if
185    /// the time is wonky, serialize the return value to see if it matches the `input.`
186    pub fn parse_header(input: &str) -> Option<Time> {
187        pub enum Sign {
188            Plus,
189            Minus,
190        }
191        fn parse_offset(offset: &str) -> Option<OffsetInSeconds> {
192            if (offset.len() != 5) && (offset.len() != 7) {
193                return None;
194            }
195            let sign = match offset.get(..1)? {
196                "-" => Some(Sign::Minus),
197                "+" => Some(Sign::Plus),
198                _ => None,
199            }?;
200            if offset.as_bytes().get(1).is_some_and(|b| !b.is_ascii_digit()) {
201                return None;
202            }
203            let hours: i32 = offset.get(1..3)?.parse().ok()?;
204            let minutes: i32 = offset.get(3..5)?.parse().ok()?;
205            let offset_seconds: i32 = if offset.len() == 7 {
206                offset.get(5..7)?.parse().ok()?
207            } else {
208                0
209            };
210            let mut offset_in_seconds = hours * 3600 + minutes * 60 + offset_seconds;
211            if matches!(sign, Sign::Minus) {
212                offset_in_seconds *= -1;
213            }
214            Some(offset_in_seconds)
215        }
216
217        let mut split = input.split_whitespace();
218        let seconds = split.next()?;
219        let seconds = match seconds.parse::<SecondsSinceUnixEpoch>() {
220            Ok(s) => s,
221            Err(_err) => {
222                // Inefficient, but it's not the common case.
223                let first_digits: String = seconds.chars().take_while(char::is_ascii_digit).collect();
224                first_digits.parse().ok()?
225            }
226        };
227        let offset = match split.next() {
228            None => 0,
229            Some(offset) => {
230                if split.next().is_some() {
231                    0
232                } else {
233                    parse_offset(offset).unwrap_or_default()
234                }
235            }
236        };
237        let time = Time { seconds, offset };
238        Some(time)
239    }
240
241    /// This is just like `Zoned::strptime`, but it allows parsing datetimes
242    /// whose weekdays are inconsistent with the date. While the day-of-week
243    /// still must be parsed, it is otherwise ignored. This seems to be
244    /// consistent with how `git` behaves.
245    fn strptime_relaxed(fmt: &str, input: &str) -> Result<Zoned, jiff::Error> {
246        let mut tm = jiff::fmt::strtime::parse(fmt, input)?;
247        tm.set_weekday(None);
248        tm.to_zoned()
249    }
250
251    /// This is just like strptime_relaxed, except for RFC 2822 parsing.
252    /// Namely, it permits the weekday to be inconsistent with the date.
253    fn rfc2822_relaxed(input: &str) -> Result<Zoned, jiff::Error> {
254        static P: rfc2822::DateTimeParser = rfc2822::DateTimeParser::new().relaxed_weekday(true);
255        P.parse_zoned(input)
256    }
257}
258
259mod relative {
260    use std::{str::FromStr, time::SystemTime};
261
262    use jiff::{tz::TimeZone, Span, Timestamp, Zoned};
263
264    use crate::parse::Error;
265
266    fn parse_inner(input: &str) -> Option<Result<Span, Error>> {
267        let mut split = input.split_whitespace();
268        let units = i64::from_str(split.next()?).ok()?;
269        let period = split.next()?;
270        if split.next()? != "ago" {
271            return None;
272        }
273        span(period, units)
274    }
275
276    pub(crate) fn parse(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Error>> {
277        parse_inner(input).map(|result| {
278            let span = result?;
279            // This was an error case in a previous version of this code, where
280            // it would fail when converting from a negative signed integer
281            // to an unsigned integer. This preserves that failure case even
282            // though the code below handles it okay.
283            if span.is_negative() {
284                return Err(Error::RelativeTimeConversion);
285            }
286            now.ok_or(Error::MissingCurrentTime).and_then(|now| {
287                let ts = Timestamp::try_from(now).map_err(|_| Error::RelativeTimeConversion)?;
288                // N.B. This matches the behavior of this code when it was
289                // written with `time`, but we might consider using the system
290                // time zone here. If we did, then it would implement "1 day
291                // ago" correctly, even when it crosses DST transitions. Since
292                // we're in the UTC time zone here, which has no DST, 1 day is
293                // in practice always 24 hours. ---AG
294                let zdt = ts.to_zoned(TimeZone::UTC);
295                zdt.checked_sub(span).map_err(|_| Error::RelativeTimeConversion)
296            })
297        })
298    }
299
300    fn span(period: &str, units: i64) -> Option<Result<Span, Error>> {
301        let period = period.strip_suffix('s').unwrap_or(period);
302        let result = match period {
303            "second" => Span::new().try_seconds(units),
304            "minute" => Span::new().try_minutes(units),
305            "hour" => Span::new().try_hours(units),
306            "day" => Span::new().try_days(units),
307            "week" => Span::new().try_weeks(units),
308            "month" => Span::new().try_months(units),
309            "year" => Span::new().try_years(units),
310            // Ignore values you don't know, assume seconds then (so does git)
311            _anything => Span::new().try_seconds(units),
312        };
313        Some(result.map_err(|_| Error::RelativeTimeConversion))
314    }
315
316    #[cfg(test)]
317    mod tests {
318        use super::*;
319
320        #[test]
321        fn two_weeks_ago() {
322            let actual = parse_inner("2 weeks ago").unwrap().unwrap();
323            assert_eq!(actual.fieldwise(), Span::new().weeks(2));
324        }
325    }
326}