gix_date/parse/
function.rs

1use std::{str::FromStr, time::SystemTime};
2
3use jiff::{civil::Date, fmt::rfc2822, tz::TimeZone, Zoned};
4
5use crate::parse::git::parse_git_date_format;
6use crate::parse::raw::parse_raw;
7use crate::{
8    parse::relative,
9    time::format::{DEFAULT, GITOXIDE, ISO8601, ISO8601_STRICT, SHORT},
10    Error, OffsetInSeconds, SecondsSinceUnixEpoch, Time,
11};
12use gix_error::{Exn, ResultExt};
13
14/// Parse `input` as any time that Git can parse when inputting a date.
15///
16/// ## Examples
17///
18/// ### 1. SHORT Format
19///
20/// *   `2018-12-24`
21/// *   `1970-01-01`
22/// *   `1950-12-31`
23/// *   `2024-12-31`
24///
25/// ### 2. RFC2822 Format
26///
27/// *   `Thu, 18 Aug 2022 12:45:06 +0800`
28/// *   `Mon Oct 27 10:30:00 2023 -0800`
29///
30/// ### 3. GIT_RFC2822 Format
31///
32/// *   `Thu, 8 Aug 2022 12:45:06 +0800`
33/// *   `Mon Oct 27 10:30:00 2023 -0800` (Note the single-digit day)
34///
35/// ### 4. ISO8601 Format
36///
37/// *   `2022-08-17 22:04:58 +0200`
38/// *   `1970-01-01 00:00:00 -0500`
39///
40/// ### 5. ISO8601_STRICT Format
41///
42/// *   `2022-08-17T21:43:13+08:00`
43///
44/// ### 6. UNIX Timestamp (Seconds Since Epoch)
45///
46/// *   `123456789`
47/// *   `0` (January 1, 1970 UTC)
48/// *   `-1000`
49/// *   `1700000000`
50///
51/// ### 7. Commit Header Format
52///
53/// *   `1745582210 +0200`
54/// *   `1660874655 +0800`
55/// *   `-1660874655 +0800`
56///
57/// See also the [`parse_header()`].
58///
59/// ### 8. GITOXIDE Format
60///
61/// *   `Thu Sep 04 2022 10:45:06 -0400`
62/// *   `Mon Oct 27 2023 10:30:00 +0000`
63///
64/// ### 9. DEFAULT Format
65///
66/// *   `Thu Sep 4 10:45:06 2022 -0400`
67/// *   `Mon Oct 27 10:30:00 2023 +0000`
68///
69/// ### 10. Relative Dates (e.g., "2 minutes ago", "1 hour from now")
70///
71/// These dates are parsed *relative to a `now` timestamp*. The examples depend entirely on the value of `now`.
72/// If `now` is October 27, 2023 at 10:00:00 UTC:
73///     *   `2 minutes ago` (October 27, 2023 at 09:58:00 UTC)
74///     *   `3 hours ago` (October 27, 2023 at 07:00:00 UTC)
75pub fn parse(input: &str, now: Option<SystemTime>) -> Result<Time, Exn<Error>> {
76    Ok(if let Ok(val) = Date::strptime(SHORT.0, input) {
77        let val = val
78            .to_zoned(TimeZone::UTC)
79            .or_raise(|| Error::new_with_input("Timezone conversion failed", input))?;
80        Time::new(val.timestamp().as_second(), val.offset().seconds())
81    } else if let Ok(val) = rfc2822_relaxed(input) {
82        Time::new(val.timestamp().as_second(), val.offset().seconds())
83    } else if let Ok(val) = strptime_relaxed(ISO8601.0, input) {
84        Time::new(val.timestamp().as_second(), val.offset().seconds())
85    } else if let Ok(val) = strptime_relaxed(ISO8601_STRICT.0, input) {
86        Time::new(val.timestamp().as_second(), val.offset().seconds())
87    } else if let Ok(val) = strptime_relaxed(GITOXIDE.0, input) {
88        Time::new(val.timestamp().as_second(), val.offset().seconds())
89    } else if let Ok(val) = strptime_relaxed(DEFAULT.0, input) {
90        Time::new(val.timestamp().as_second(), val.offset().seconds())
91    } else if let Ok(val) = SecondsSinceUnixEpoch::from_str(input) {
92        Time::new(val, 0)
93    } else if let Some(val) = parse_git_date_format(input) {
94        val
95    } else if let Some(val) = relative::parse(input, now).transpose()? {
96        Time::new(val.timestamp().as_second(), val.offset().seconds())
97    } else if let Some(val) = parse_raw(input) {
98        // Format::Raw
99        val
100    } else {
101        return Err(Error::new_with_input("Unknown date format", input))?;
102    })
103}
104
105/// Unlike [`parse()`] which handles all kinds of input, this function only parses the commit-header format
106/// like `1745582210 +0200`.
107///
108/// Note that failure to parse the time zone isn't fatal, instead it will default to `0`. To know if
109/// the time is wonky, serialize the return value to see if it matches the `input.`
110pub fn parse_header(input: &str) -> Option<Time> {
111    pub enum Sign {
112        Plus,
113        Minus,
114    }
115    fn parse_offset(offset: &str) -> Option<OffsetInSeconds> {
116        if (offset.len() != 5) && (offset.len() != 7) {
117            return None;
118        }
119        let sign = match offset.get(..1)? {
120            "-" => Some(Sign::Minus),
121            "+" => Some(Sign::Plus),
122            _ => None,
123        }?;
124        if offset.as_bytes().get(1).is_some_and(|b| !b.is_ascii_digit()) {
125            return None;
126        }
127        let hours: i32 = offset.get(1..3)?.parse().ok()?;
128        let minutes: i32 = offset.get(3..5)?.parse().ok()?;
129        let offset_seconds: i32 = if offset.len() == 7 {
130            offset.get(5..7)?.parse().ok()?
131        } else {
132            0
133        };
134        let mut offset_in_seconds = hours * 3600 + minutes * 60 + offset_seconds;
135        if matches!(sign, Sign::Minus) {
136            offset_in_seconds *= -1;
137        }
138        Some(offset_in_seconds)
139    }
140
141    if input.contains(':') {
142        return None;
143    }
144    let mut split = input.split_whitespace();
145    let seconds = split.next()?;
146    let seconds = match seconds.parse::<SecondsSinceUnixEpoch>() {
147        Ok(s) => s,
148        Err(_err) => {
149            // Inefficient, but it's not the common case.
150            let first_digits: String = seconds.chars().take_while(char::is_ascii_digit).collect();
151            first_digits.parse().ok()?
152        }
153    };
154    let offset = match split.next() {
155        None => 0,
156        Some(offset) => {
157            if split.next().is_some() {
158                0
159            } else {
160                parse_offset(offset).unwrap_or_default()
161            }
162        }
163    };
164    let time = Time { seconds, offset };
165    Some(time)
166}
167
168/// This is just like `Zoned::strptime`, but it allows parsing datetimes
169/// whose weekdays are inconsistent with the date. While the day-of-week
170/// still must be parsed, it is otherwise ignored. This seems to be
171/// consistent with how `git` behaves.
172fn strptime_relaxed(fmt: &str, input: &str) -> std::result::Result<Zoned, jiff::Error> {
173    let mut tm = jiff::fmt::strtime::parse(fmt, input)?;
174    tm.set_weekday(None);
175    tm.to_zoned()
176}
177
178/// This is just like strptime_relaxed, except for RFC 2822 parsing.
179/// Namely, it permits the weekday to be inconsistent with the date.
180fn rfc2822_relaxed(input: &str) -> std::result::Result<Zoned, jiff::Error> {
181    static P: rfc2822::DateTimeParser = rfc2822::DateTimeParser::new().relaxed_weekday(true);
182    P.parse_zoned(input)
183}