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 Error, OffsetInSeconds, SecondsSinceUnixEpoch, Time,
9 parse::relative,
10 time::format::{DEFAULT, GITOXIDE, ISO8601, ISO8601_STRICT, SHORT},
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/// A leading `@` may introduce either of the two forms above, as in `@1745582210 +0200`
58/// or `@1700000000`.
59///
60/// See also the [`parse_header()`].
61///
62/// ### 8. GITOXIDE Format
63///
64/// * `Thu Sep 04 2022 10:45:06 -0400`
65/// * `Mon Oct 27 2023 10:30:00 +0000`
66///
67/// ### 9. DEFAULT Format
68///
69/// * `Thu Sep 4 10:45:06 2022 -0400`
70/// * `Mon Oct 27 10:30:00 2023 +0000`
71///
72/// ### 10. Relative Dates (e.g., "2 minutes ago")
73///
74/// These dates are parsed relative to `now`, whose time zone controls calendar arithmetic.
75/// The examples depend entirely on the value of `now`.
76/// If `now` is October 27, 2023 at 10:00:00 UTC:
77/// * `2 minutes ago` (October 27, 2023 at 09:58:00 UTC)
78/// * `3 hours ago` (October 27, 2023 at 07:00:00 UTC)
79///
80/// The forms understood are `now`, `today`, `yesterday`, and one or more `<count> <unit>` pairs,
81/// as in `2 days 3 hours ago`. A count may be spelled out from `one` to `ten`, or be `last`, and
82/// any byte that is neither a digit nor a letter separates the parts, so `1.hour.ago` is the same
83/// as `1 hour ago`. The trailing `ago` is optional.
84///
85/// `<count> <unit>` pairs are applied in input order, the way Git applies them: `second` through `week` each
86/// subtract a fixed number of seconds, while `month` and `year` step down the respective calendar fields,
87/// leaving the day of the month alone.
88/// A day beyond the end of the shorter target month rolls over into the following month: one month before
89/// May 31st is May 1st, not April 30th.
90///
91/// Note that there is no way to name a time in the future: Git has none either, so `1 hour from
92/// now` is an hour in the past to it, and to this function.
93pub fn parse(input: &str, now: Option<Zoned>) -> Result<Time, Exn<Error>> {
94 // Git accepts a leading `@` before a commit-header date: `match_object_header_date()` in
95 // `date.c` takes `<seconds> ±HHMM`, while an offsetless `@<seconds>` arrives at the same
96 // result through the generic loop, which skips the `@` and reads the digits as an epoch.
97 if let Some(rest) = input.strip_prefix('@') {
98 if let Some(val) = parse_raw(rest) {
99 return Ok(val);
100 }
101 if let Ok(seconds) = SecondsSinceUnixEpoch::from_str(rest) {
102 return Ok(Time::new(seconds, 0));
103 }
104 }
105 Ok(if let Ok(val) = Date::strptime(SHORT.0, input) {
106 let val = val
107 .to_zoned(TimeZone::UTC)
108 .or_raise(|| Error::new_with_input("Timezone conversion failed", input))?;
109 Time::new(val.timestamp().as_second(), val.offset().seconds())
110 } else if let Ok(val) = rfc2822_relaxed(input) {
111 Time::new(val.timestamp().as_second(), val.offset().seconds())
112 } else if let Ok(val) = strptime_relaxed(ISO8601.0, input) {
113 Time::new(val.timestamp().as_second(), val.offset().seconds())
114 } else if let Ok(val) = strptime_relaxed(ISO8601_STRICT.0, input) {
115 Time::new(val.timestamp().as_second(), val.offset().seconds())
116 } else if let Ok(val) = strptime_relaxed(GITOXIDE.0, input) {
117 Time::new(val.timestamp().as_second(), val.offset().seconds())
118 } else if let Ok(val) = strptime_relaxed(DEFAULT.0, input) {
119 Time::new(val.timestamp().as_second(), val.offset().seconds())
120 } else if let Ok(val) = SecondsSinceUnixEpoch::from_str(input) {
121 Time::new(val, 0)
122 } else if let Some(val) = parse_git_date_format(input) {
123 val
124 } else if let Some(val) = relative::parse(input, now).transpose()? {
125 Time::new(val.timestamp().as_second(), val.offset().seconds())
126 } else if let Some(val) = parse_raw(input) {
127 // Format::Raw
128 val
129 } else {
130 return Err(Error::new_with_input("Unknown date format", input))?;
131 })
132}
133
134/// Unlike [`parse()`] which handles all kinds of input, this function only parses the commit-header format
135/// like `1745582210 +0200`.
136///
137/// Note that failure to parse the time zone isn't fatal, instead it will default to `0`. To know if
138/// the time is wonky, serialize the return value to see if it matches the `input.`
139pub fn parse_header(input: &str) -> Option<Time> {
140 pub enum Sign {
141 Plus,
142 Minus,
143 }
144 fn parse_offset(offset: &str) -> Option<OffsetInSeconds> {
145 if (offset.len() != 5) && (offset.len() != 7) {
146 return None;
147 }
148 let sign = match offset.get(..1)? {
149 "-" => Some(Sign::Minus),
150 "+" => Some(Sign::Plus),
151 _ => None,
152 }?;
153 if offset.as_bytes().get(1).is_some_and(|b| !b.is_ascii_digit()) {
154 return None;
155 }
156 let hours: i32 = offset.get(1..3)?.parse().ok()?;
157 let minutes: i32 = offset.get(3..5)?.parse().ok()?;
158 let offset_seconds: i32 = if offset.len() == 7 {
159 offset.get(5..7)?.parse().ok()?
160 } else {
161 0
162 };
163 let mut offset_in_seconds = hours * 3600 + minutes * 60 + offset_seconds;
164 if matches!(sign, Sign::Minus) {
165 offset_in_seconds *= -1;
166 }
167 Some(offset_in_seconds)
168 }
169
170 if input.contains(':') {
171 return None;
172 }
173 let mut split = input.split_whitespace();
174 let seconds = split.next()?;
175 let seconds = match seconds.parse::<SecondsSinceUnixEpoch>() {
176 Ok(s) => s,
177 Err(_err) => {
178 // Inefficient, but it's not the common case.
179 let first_digits: String = seconds.chars().take_while(char::is_ascii_digit).collect();
180 first_digits.parse().ok()?
181 }
182 };
183 let offset = match split.next() {
184 None => 0,
185 Some(offset) => {
186 if split.next().is_some() {
187 0
188 } else {
189 parse_offset(offset).unwrap_or_default()
190 }
191 }
192 };
193 let time = Time { seconds, offset };
194 Some(time)
195}
196
197/// This is just like `Zoned::strptime`, but it allows parsing datetimes
198/// whose weekdays are inconsistent with the date. While the day-of-week
199/// still must be parsed, it is otherwise ignored. This seems to be
200/// consistent with how `git` behaves.
201fn strptime_relaxed(fmt: &str, input: &str) -> std::result::Result<Zoned, jiff::Error> {
202 let mut tm = jiff::fmt::strtime::parse(fmt, input)?;
203 tm.set_weekday(None);
204 tm.to_zoned()
205}
206
207/// This is just like strptime_relaxed, except for RFC 2822 parsing.
208/// Namely, it permits the weekday to be inconsistent with the date.
209fn rfc2822_relaxed(input: &str) -> std::result::Result<Zoned, jiff::Error> {
210 static P: rfc2822::DateTimeParser = rfc2822::DateTimeParser::new().relaxed_weekday(true);
211 P.parse_zoned(input)
212}