Skip to main content

gix_date/parse/
function.rs

1use std::str::FromStr;
2
3use jiff::{Zoned, civil::Date, fmt::rfc2822, tz::TimeZone};
4
5use crate::parse::git::parse_git_date_format;
6use crate::parse::raw::parse_raw;
7use crate::{
8    OffsetInSeconds, SecondsSinceUnixEpoch, Time,
9    parse::relative,
10    time::format::{DEFAULT, GITOXIDE, ISO8601, ISO8601_STRICT, SHORT},
11};
12use gix_error::{ExnMessageResult, ResultExt};
13
14/// The widest timezone offset git reads, as `match_tz()` in `date.c` takes the four digits as a
15/// clock time: hours below 24 and minutes below 60, so `+2359` is the last offset it accepts.
16const MAX_OFFSET_IN_SECONDS: i32 = 23 * 3600 + 59 * 60;
17
18/// Parse `input` as any time that Git can parse when inputting a date.
19/// Unknown formats and timezone conversion failures include `input` bytes as [metadata](gix_error::Exn::metadata()).
20///
21/// ## Examples
22///
23/// ### 1. SHORT Format
24///
25/// *   `2018-12-24`
26/// *   `1970-01-01`
27/// *   `1950-12-31`
28/// *   `2024-12-31`
29///
30/// ### 2. RFC2822 Format
31///
32/// *   `Thu, 18 Aug 2022 12:45:06 +0800`
33/// *   `Mon Oct 27 10:30:00 2023 -0800`
34///
35/// ### 3. GIT_RFC2822 Format
36///
37/// *   `Thu, 8 Aug 2022 12:45:06 +0800`
38/// *   `Mon Oct 27 10:30:00 2023 -0800` (Note the single-digit day)
39///
40/// ### 4. ISO8601 Format
41///
42/// *   `2022-08-17 22:04:58 +0200`
43/// *   `1970-01-01 00:00:00 -0500`
44///
45/// ### 5. ISO8601_STRICT Format
46///
47/// *   `2022-08-17T21:43:13+08:00`
48///
49/// ### 6. UNIX Timestamp (Seconds Since Epoch)
50///
51/// Bare numbers must be at least `100000000`, matching Git's epoch threshold. Smaller numbers
52/// like `20080214` are ambiguous with compact dates and are rejected. Prefix smaller or negative
53/// values with `@` to explicitly request epoch seconds.
54///
55/// *   `123456789`
56/// *   `@0` (January 1, 1970 UTC)
57/// *   `@-1000`
58/// *   `1700000000`
59///
60/// ### 7. Commit Header Format
61///
62/// *   `1745582210 +0200`
63/// *   `1660874655 +0800`
64/// *   `-1660874655 +0800`
65///
66/// A leading `@` may introduce either of the two forms above, as in `@1745582210 +0200`
67/// or `@1700000000`.
68///
69/// See also the [`parse_header()`].
70///
71/// ### 8. GITOXIDE Format
72///
73/// *   `Thu Sep 04 2022 10:45:06 -0400`
74/// *   `Mon Oct 27 2023 10:30:00 +0000`
75///
76/// ### 9. DEFAULT Format
77///
78/// *   `Thu Sep 4 10:45:06 2022 -0400`
79/// *   `Mon Oct 27 10:30:00 2023 +0000`
80///
81/// ### 10. Relative Dates (e.g., "2 minutes ago")
82///
83/// These dates are parsed relative to `now`, whose time zone controls calendar arithmetic.
84/// The examples depend entirely on the value of `now`.
85/// If `now` is October 27, 2023 at 10:00:00 UTC:
86///     *   `2 minutes ago` (October 27, 2023 at 09:58:00 UTC)
87///     *   `3 hours ago` (October 27, 2023 at 07:00:00 UTC)
88///
89/// The forms understood are `now`, `today`, `yesterday`, and one or more `<count> <unit>` pairs,
90/// as in `2 days 3 hours ago`. A count may be spelled out from `one` to `ten`, or be `last`, and
91/// any byte that is neither a digit nor a letter separates the parts, so `1.hour.ago` is the same
92/// as `1 hour ago`. The trailing `ago` is optional.
93///
94/// `<count> <unit>` pairs are applied in input order, the way Git applies them: `second` through `week` each
95/// subtract a fixed number of seconds, while `month` and `year` step down the respective calendar fields,
96/// leaving the day of the month alone.
97/// A day beyond the end of the shorter target month rolls over into the following month: one month before
98/// May 31st is May 1st, not April 30th.
99///
100/// Note that there is no way to name a time in the future: Git has none either, so `1 hour from
101/// now` is an hour in the past to it, and to this function.
102///
103/// In any of these formats, a timezone offset wider than `±23:59` is not a timezone to Git, so it
104/// is not accepted here either.
105pub fn parse(input: &str, now: Option<Zoned>) -> ExnMessageResult<Time> {
106    // A leading `@` explicitly names epoch seconds, including small and negative values.
107    if let Some(rest) = input.strip_prefix('@') {
108        if let Some(val) = parse_raw(rest) {
109            return Ok(val);
110        }
111        if let Ok(seconds) = SecondsSinceUnixEpoch::from_str(rest) {
112            return Ok(Time::new(seconds, 0));
113        }
114    }
115    let time = if let Ok(val) = Date::strptime(SHORT.0, input) {
116        let val = val
117            .to_zoned(TimeZone::UTC)
118            .or_raise(|| gix_error::validation("Timezone conversion failed").with("input", input.as_bytes()))?;
119        Time::new(val.timestamp().as_second(), val.offset().seconds())
120    } else if let Ok(val) = rfc2822_relaxed(input) {
121        Time::new(val.timestamp().as_second(), val.offset().seconds())
122    } else if let Ok(val) = strptime_relaxed(ISO8601.0, input) {
123        Time::new(val.timestamp().as_second(), val.offset().seconds())
124    } else if let Ok(val) = strptime_relaxed(ISO8601_STRICT.0, input) {
125        Time::new(val.timestamp().as_second(), val.offset().seconds())
126    } else if let Ok(val) = strptime_relaxed(GITOXIDE.0, input) {
127        Time::new(val.timestamp().as_second(), val.offset().seconds())
128    } else if let Ok(val) = strptime_relaxed(DEFAULT.0, input) {
129        Time::new(val.timestamp().as_second(), val.offset().seconds())
130    } else if let Ok(val) = SecondsSinceUnixEpoch::from_str(input)
131        && val >= 100_000_000
132    {
133        Time::new(val, 0)
134    } else if let Some(val) = parse_git_date_format(input) {
135        val
136    } else if let Some(val) = relative::parse(input, now).transpose()? {
137        // The offset is inherited from `now`, not parsed from the input, so Git's
138        // textual offset limit does not apply.
139        return Ok(Time::new(val.timestamp().as_second(), val.offset().seconds()));
140    } else if let Some(val) = parse_raw(input) {
141        // Format::Raw
142        val
143    } else {
144        return Err(gix_error::validation("Unknown date format").with("input", input.as_bytes()))?;
145    };
146
147    // Jiff parses textual offsets up to 25:59:59, beyond Git's accepted range.
148    if time.offset.abs() > MAX_OFFSET_IN_SECONDS {
149        Err(gix_error::validation("Unknown date format").with("input", input.as_bytes()))?;
150    }
151    Ok(time)
152}
153
154/// Unlike [`parse()`] which handles all kinds of input, this function only parses the commit-header format
155/// like `1745582210 +0200`.
156///
157/// Note that failure to parse the time zone isn't fatal, instead it will default to `0`. To know if
158/// the time is wonky, serialize the return value to see if it matches the `input.`
159pub fn parse_header(input: &str) -> Option<Time> {
160    pub enum Sign {
161        Plus,
162        Minus,
163    }
164    fn parse_offset(offset: &str) -> Option<OffsetInSeconds> {
165        if (offset.len() != 5) && (offset.len() != 7) {
166            return None;
167        }
168        let sign = match offset.get(..1)? {
169            "-" => Some(Sign::Minus),
170            "+" => Some(Sign::Plus),
171            _ => None,
172        }?;
173        if offset.as_bytes().get(1).is_some_and(|b| !b.is_ascii_digit()) {
174            return None;
175        }
176        let hours: i32 = offset.get(1..3)?.parse().ok()?;
177        let minutes: i32 = offset.get(3..5)?.parse().ok()?;
178        let offset_seconds: i32 = if offset.len() == 7 {
179            offset.get(5..7)?.parse().ok()?
180        } else {
181            0
182        };
183        let mut offset_in_seconds = hours * 3600 + minutes * 60 + offset_seconds;
184        if matches!(sign, Sign::Minus) {
185            offset_in_seconds *= -1;
186        }
187        Some(offset_in_seconds)
188    }
189
190    if input.contains(':') {
191        return None;
192    }
193    let mut split = input.split_whitespace();
194    let seconds = split.next()?;
195    let seconds = match seconds.parse::<SecondsSinceUnixEpoch>() {
196        Ok(s) => s,
197        Err(_err) => {
198            // Inefficient, but it's not the common case.
199            let first_digits: String = seconds.chars().take_while(char::is_ascii_digit).collect();
200            first_digits.parse().ok()?
201        }
202    };
203    let offset = match split.next() {
204        None => 0,
205        Some(offset) => {
206            if split.next().is_some() {
207                0
208            } else {
209                parse_offset(offset).unwrap_or_default()
210            }
211        }
212    };
213    let time = Time { seconds, offset };
214    Some(time)
215}
216
217/// This is just like `Zoned::strptime`, but it allows parsing datetimes
218/// whose weekdays are inconsistent with the date. While the day-of-week
219/// still must be parsed, it is otherwise ignored. This seems to be
220/// consistent with how `git` behaves.
221fn strptime_relaxed(fmt: &str, input: &str) -> std::result::Result<Zoned, jiff::Error> {
222    let mut tm = jiff::fmt::strtime::parse(fmt, input)?;
223    tm.set_weekday(None);
224    tm.to_zoned()
225}
226
227/// This is just like strptime_relaxed, except for RFC 2822 parsing.
228/// Namely, it permits the weekday to be inconsistent with the date.
229fn rfc2822_relaxed(input: &str) -> std::result::Result<Zoned, jiff::Error> {
230    static P: rfc2822::DateTimeParser = rfc2822::DateTimeParser::new().relaxed_weekday(true);
231    P.parse_zoned(input)
232}