Skip to main content

grit_lib/git_date/
parse.rs

1//! Git-compatible date parsing (`parse_date_basic`, `parse_date`).
2
3use thiserror::Error;
4
5/// Date string could not be parsed.
6#[derive(Copy, Clone, Debug, Eq, PartialEq, Error)]
7#[error("invalid date string")]
8pub struct DateParseError;
9
10use super::approx::approxidate_careful_at;
11use super::compat::{self, time_t, tm};
12use super::tm::{
13    empty_tm, init_tm_unknown, is_date_known, local_tzoffset, match_string, maybeiso8601, nodate,
14    parse_timestamp_prefix, process_wall_clock_sec, skip_alpha, tm_to_time_t, TIMESTAMP_MAX,
15};
16
17struct TzName {
18    name: &'static str,
19    offset_hours: i32,
20    dst: i32,
21}
22
23const TIMEZONE_NAMES: &[TzName] = &[
24    TzName {
25        name: "IDLW",
26        offset_hours: -12,
27        dst: 0,
28    },
29    TzName {
30        name: "NT",
31        offset_hours: -11,
32        dst: 0,
33    },
34    TzName {
35        name: "CAT",
36        offset_hours: -10,
37        dst: 0,
38    },
39    TzName {
40        name: "HST",
41        offset_hours: -10,
42        dst: 0,
43    },
44    TzName {
45        name: "HDT",
46        offset_hours: -10,
47        dst: 1,
48    },
49    TzName {
50        name: "YST",
51        offset_hours: -9,
52        dst: 0,
53    },
54    TzName {
55        name: "YDT",
56        offset_hours: -9,
57        dst: 1,
58    },
59    TzName {
60        name: "PST",
61        offset_hours: -8,
62        dst: 0,
63    },
64    TzName {
65        name: "PDT",
66        offset_hours: -8,
67        dst: 1,
68    },
69    TzName {
70        name: "MST",
71        offset_hours: -7,
72        dst: 0,
73    },
74    TzName {
75        name: "MDT",
76        offset_hours: -7,
77        dst: 1,
78    },
79    TzName {
80        name: "CST",
81        offset_hours: -6,
82        dst: 0,
83    },
84    TzName {
85        name: "CDT",
86        offset_hours: -6,
87        dst: 1,
88    },
89    TzName {
90        name: "EST",
91        offset_hours: -5,
92        dst: 0,
93    },
94    TzName {
95        name: "EDT",
96        offset_hours: -5,
97        dst: 1,
98    },
99    TzName {
100        name: "AST",
101        offset_hours: -3,
102        dst: 0,
103    },
104    TzName {
105        name: "ADT",
106        offset_hours: -3,
107        dst: 1,
108    },
109    TzName {
110        name: "WAT",
111        offset_hours: -1,
112        dst: 0,
113    },
114    TzName {
115        name: "GMT",
116        offset_hours: 0,
117        dst: 0,
118    },
119    TzName {
120        name: "UTC",
121        offset_hours: 0,
122        dst: 0,
123    },
124    TzName {
125        name: "Z",
126        offset_hours: 0,
127        dst: 0,
128    },
129    TzName {
130        name: "WET",
131        offset_hours: 0,
132        dst: 0,
133    },
134    TzName {
135        name: "BST",
136        offset_hours: 0,
137        dst: 1,
138    },
139    TzName {
140        name: "CET",
141        offset_hours: 1,
142        dst: 0,
143    },
144    TzName {
145        name: "MET",
146        offset_hours: 1,
147        dst: 0,
148    },
149    TzName {
150        name: "MEWT",
151        offset_hours: 1,
152        dst: 0,
153    },
154    TzName {
155        name: "MEST",
156        offset_hours: 1,
157        dst: 1,
158    },
159    TzName {
160        name: "CEST",
161        offset_hours: 1,
162        dst: 1,
163    },
164    TzName {
165        name: "MESZ",
166        offset_hours: 1,
167        dst: 1,
168    },
169    TzName {
170        name: "FWT",
171        offset_hours: 1,
172        dst: 0,
173    },
174    TzName {
175        name: "FST",
176        offset_hours: 1,
177        dst: 1,
178    },
179    TzName {
180        name: "EET",
181        offset_hours: 2,
182        dst: 0,
183    },
184    TzName {
185        name: "EEST",
186        offset_hours: 2,
187        dst: 1,
188    },
189    TzName {
190        name: "WAST",
191        offset_hours: 7,
192        dst: 0,
193    },
194    TzName {
195        name: "WADT",
196        offset_hours: 7,
197        dst: 1,
198    },
199    TzName {
200        name: "CCT",
201        offset_hours: 8,
202        dst: 0,
203    },
204    TzName {
205        name: "JST",
206        offset_hours: 9,
207        dst: 0,
208    },
209    TzName {
210        name: "EAST",
211        offset_hours: 10,
212        dst: 0,
213    },
214    TzName {
215        name: "EADT",
216        offset_hours: 10,
217        dst: 1,
218    },
219    TzName {
220        name: "GST",
221        offset_hours: 10,
222        dst: 0,
223    },
224    TzName {
225        name: "NZT",
226        offset_hours: 12,
227        dst: 0,
228    },
229    TzName {
230        name: "NZST",
231        offset_hours: 12,
232        dst: 0,
233    },
234    TzName {
235        name: "NZDT",
236        offset_hours: 12,
237        dst: 1,
238    },
239    TzName {
240        name: "IDLE",
241        offset_hours: 12,
242        dst: 0,
243    },
244];
245
246const MONTH_NAME_08: &str = match core::str::from_utf8(&[65, 117, 103, 117, 115, 116]) {
247    Ok(name) => name,
248    Err(_) => panic!(),
249};
250
251pub(crate) const MONTH_NAMES: [&str; 12] = [
252    "January",
253    "February",
254    "March",
255    "April",
256    "May",
257    "June",
258    "July",
259    MONTH_NAME_08,
260    "September",
261    "October",
262    "November",
263    "December",
264];
265
266pub(crate) const WEEKDAY_NAMES: [&str; 7] = [
267    "Sundays",
268    "Mondays",
269    "Tuesdays",
270    "Wednesdays",
271    "Thursdays",
272    "Fridays",
273    "Saturdays",
274];
275
276/// Format a parsed instant like Git's `date_string`.
277pub fn date_string(date: u64, offset: i32) -> String {
278    let mut sign = '+';
279    let mut o = offset;
280    if o < 0 {
281        o = -o;
282        sign = '-';
283    }
284    format!("{} {}{:02}{:02}", date, sign, o / 60, o % 60)
285}
286
287/// Git `parse_date` — returns canonical `date_string` output.
288pub fn parse_date(date: &str) -> Result<String, DateParseError> {
289    parse_date_at(date, process_wall_clock_sec())
290}
291
292/// Like [`parse_date`] but uses `now_sec` for relative and fuzzy fields.
293pub fn parse_date_at(date: &str, now_sec: i64) -> Result<String, DateParseError> {
294    if let Ok((ts, off)) = parse_date_basic_at(date, now_sec) {
295        return Ok(date_string(ts, off));
296    }
297    let mut err = 0;
298    let ts = approxidate_careful_at(date, now_sec, Some(&mut err));
299    if err != 0 {
300        return Err(DateParseError);
301    }
302    let off = tz_hhmm_to_offset_minutes(local_tzoffset(ts));
303    Ok(date_string(ts, off))
304}
305
306fn tz_hhmm_to_offset_minutes(hhmm: i32) -> i32 {
307    let sign = if hhmm < 0 { -1 } else { 1 };
308    let abs = hhmm.unsigned_abs();
309    sign * ((abs / 100) * 60 + (abs % 100)) as i32
310}
311
312/// Git `parse_date_basic` — UTC seconds and timezone offset in **minutes** (signed).
313pub fn parse_date_basic(date: &str) -> Result<(u64, i32), DateParseError> {
314    parse_date_basic_at(date, process_wall_clock_sec())
315}
316
317/// Like [`parse_date_basic`] but uses `now_sec` for relative and fuzzy fields.
318pub fn parse_date_basic_at(date: &str, now_sec: i64) -> Result<(u64, i32), DateParseError> {
319    let bytes = date.as_bytes();
320    let mut tm = init_tm_unknown();
321    let mut offset: i32 = -1;
322    let mut tm_gmt = 0i32;
323    let mut i = 0usize;
324
325    if bytes.first() == Some(&b'@') {
326        if let Some((ts, off)) = match_object_header_date(&bytes[1..]) {
327            return Ok((ts, off));
328        }
329    }
330
331    while i < bytes.len() {
332        let c = bytes[i];
333        if c == 0 || c == b'\n' {
334            break;
335        }
336        let mut m = 0usize;
337        if c.is_ascii_alphabetic() {
338            m = match_alpha(&bytes[i..], &mut tm, &mut offset);
339        } else if c.is_ascii_digit() {
340            m = match_digit(&bytes[i..], &mut tm, &mut offset, &mut tm_gmt, now_sec);
341        } else if (c == b'-' || c == b'+') && bytes.get(i + 1).is_some_and(|x| x.is_ascii_digit()) {
342            m = match_tz(&bytes[i..], &mut offset);
343        }
344        if m == 0 {
345            m = 1;
346        }
347        i += m;
348    }
349
350    let tts = tm_to_time_t(&tm);
351    if tts < 0 {
352        return Err(DateParseError);
353    }
354    let mut ts = tts as u64;
355
356    if offset == -1 {
357        tm.tm_isdst = -1;
358        let temp_time = unsafe { compat::mktime(&mut tm) };
359        let tt = ts as i128;
360        let tloc = temp_time as i128;
361        offset = if tt > tloc {
362            ((tt - tloc) / 60) as i32
363        } else {
364            -(((tloc - tt) / 60) as i32)
365        };
366    }
367
368    if tm_gmt == 0 {
369        if offset > 0 && (offset as i64) * 60 > ts as i64 {
370            return Err(DateParseError);
371        }
372        if offset < 0 && (-(offset as i128)) * 60 > (TIMESTAMP_MAX as i128 - ts as i128) {
373            return Err(DateParseError);
374        }
375        // Git: *timestamp -= *offset * 60 (signed; negative offset adds to the instant).
376        let ts128 = ts as i128;
377        let adj = (offset as i128) * 60;
378        let new_ts = ts128 - adj;
379        if new_ts < 0 {
380            return Err(DateParseError);
381        }
382        ts = new_ts as u64;
383    }
384
385    Ok((ts, offset))
386}
387
388fn match_object_header_date(date: &[u8]) -> Option<(u64, i32)> {
389    if date.is_empty() || !date[0].is_ascii_digit() {
390        return None;
391    }
392    let (stamp, mut rest) = parse_timestamp_prefix(date);
393    if rest >= date.len() || date[rest] != b' ' {
394        return None;
395    }
396    if stamp == u64::MAX {
397        return None;
398    }
399    rest += 1;
400    if rest >= date.len() || (date[rest] != b'+' && date[rest] != b'-') {
401        return None;
402    }
403    let sign = date[rest];
404    rest += 1;
405    if rest + 4 > date.len() {
406        return None;
407    }
408    let tz_digits = std::str::from_utf8(&date[rest..rest + 4]).ok()?;
409    let ofs_raw: i32 = tz_digits.parse().ok()?;
410    let mut ofs = (ofs_raw / 100) * 60 + (ofs_raw % 100);
411    if sign == b'-' {
412        ofs = -ofs;
413    }
414    let end = rest + 4;
415    if end < date.len() && date[end] != b'\n' && date[end] != 0 {
416        return None;
417    }
418    Some((stamp, ofs))
419}
420
421/// Git `match_tz` — writes offset in minutes; returns bytes consumed.
422fn match_tz(date: &[u8], offp: &mut i32) -> usize {
423    if date.is_empty() || (date[0] != b'+' && date[0] != b'-') {
424        return 0;
425    }
426    let (hour_ul, n) = parse_timestamp_prefix(&date[1..]);
427    let mut end = 1 + n;
428    let mut min: i32 = 0;
429    let mut hour: i32 = hour_ul as i32;
430    if n == 4 {
431        min = hour % 100;
432        hour /= 100;
433    } else if n != 2 {
434        min = 99;
435    } else if end < date.len() && date[end] == b':' {
436        let (m2, n2) = parse_timestamp_prefix(&date[end + 1..]);
437        if n2 == 0 {
438            min = 99;
439        } else {
440            min = m2 as i32;
441            end += 1 + n2;
442            if end - 1 != 5 {
443                min = 99;
444            }
445        }
446    }
447    if min < 60 && hour < 24 {
448        let mut off = hour * 60 + min;
449        if date[0] == b'-' {
450            off = -off;
451        }
452        *offp = off;
453    }
454    end
455}
456
457/// Git `strtol` for a leading signed decimal slice (`end+1` style).
458fn parse_long_prefix(s: &[u8]) -> (i64, usize) {
459    if s.is_empty() {
460        return (0, 0);
461    }
462    let mut i = 0usize;
463    let neg = s[0] == b'-';
464    if s[0] == b'+' || s[0] == b'-' {
465        i = 1;
466    }
467    let start = i;
468    while i < s.len() && s[i].is_ascii_digit() {
469        i += 1;
470    }
471    if i == start {
472        return (0, 0);
473    }
474    let Ok(slice) = std::str::from_utf8(&s[start..i]) else {
475        return (0, 0);
476    };
477    let Ok(v) = slice.parse::<i64>() else {
478        return (0, 0);
479    };
480    let v = if neg { -v } else { v };
481    (v, i)
482}
483
484fn parse_uint_suffix(s: &[u8]) -> (u64, usize) {
485    parse_timestamp_prefix(s)
486}
487
488/// Git `set_date` — `0` ok, `1` reject try, `-1` error.
489fn set_date(year: i32, month: i32, day: i32, now_tm: Option<&tm>, now: i64, tm: &mut tm) -> i32 {
490    if !(month > 0 && month < 13 && day > 0 && day < 32) {
491        return -1;
492    }
493    let Some(nt) = now_tm else {
494        tm.tm_mon = month - 1;
495        tm.tm_mday = day;
496        if year == -1 {
497            return 1;
498        }
499        if (1970..2100).contains(&year) {
500            tm.tm_year = year - 1900;
501        } else if (70..100).contains(&year) {
502            tm.tm_year = year;
503        } else if year < 38 {
504            tm.tm_year = year + 100;
505        } else {
506            return -1;
507        }
508        return 0;
509    };
510    let mut check = *tm;
511    check.tm_mon = month - 1;
512    check.tm_mday = day;
513    if year == -1 {
514        check.tm_year = nt.tm_year;
515    } else if (1970..2100).contains(&year) {
516        check.tm_year = year - 1900;
517    } else if (70..100).contains(&year) {
518        check.tm_year = year;
519    } else if year < 38 {
520        check.tm_year = year + 100;
521    } else {
522        return -1;
523    }
524    let specified = tm_to_time_t(&check);
525    if specified >= 0 && now + 10 * 24 * 3600 < specified {
526        return -1;
527    }
528    tm.tm_mon = check.tm_mon;
529    tm.tm_mday = check.tm_mday;
530    if year != -1 {
531        tm.tm_year = check.tm_year;
532    }
533    0
534}
535
536fn set_time(hour: i64, minute: i64, second: i64, tm: &mut tm) -> i32 {
537    if (0..=24).contains(&hour) && (0..60).contains(&minute) && (0..=60).contains(&second) {
538        tm.tm_hour = hour as i32;
539        tm.tm_min = minute as i32;
540        tm.tm_sec = second as i32;
541        0
542    } else {
543        -1
544    }
545}
546
547/// Git `match_multi_number` — `sep_i` is index of separator in `date`; returns bytes consumed from `date` start.
548pub(crate) fn match_multi_number(
549    num: u64,
550    date: &[u8],
551    sep_i: usize,
552    tm: &mut tm,
553    now_in: i64,
554) -> usize {
555    let Some(&c) = date.get(sep_i) else {
556        return 0;
557    };
558    if !matches!(c, b':' | b'-' | b'/' | b'.') {
559        return 0;
560    }
561
562    let (num2, n2) = parse_long_prefix(&date[sep_i + 1..]);
563    if n2 == 0 {
564        return 0;
565    }
566    let mut pos = sep_i + 1 + n2;
567    let mut num3: i64 = -1;
568    if pos < date.len() && date[pos] == c && pos + 1 < date.len() && date[pos + 1].is_ascii_digit()
569    {
570        let (n3, rel) = parse_long_prefix(&date[pos + 1..]);
571        num3 = n3;
572        pos += 1 + rel;
573    }
574
575    match c {
576        b':' => {
577            let mut n3 = num3;
578            if n3 < 0 {
579                n3 = 0;
580            }
581            if set_time(num as i64, num2, n3, tm) == 0 {
582                if pos < date.len()
583                    && date[pos] == b'.'
584                    && pos + 1 < date.len()
585                    && date[pos + 1].is_ascii_digit()
586                    && is_date_known(tm)
587                {
588                    let (_, rel) = parse_long_prefix(&date[pos + 1..]);
589                    pos += 1 + rel;
590                }
591            } else {
592                return 0;
593            }
594        }
595        b'-' | b'/' | b'.' => {
596            let now = if now_in == 0 {
597                process_wall_clock_sec()
598            } else {
599                now_in
600            };
601            let mut now_tm = empty_tm();
602            let refuse_future: Option<&tm> = if compat::gmtime(now as time_t, &mut now_tm) {
603                Some(&now_tm)
604            } else {
605                None
606            };
607
608            let y = num as i32;
609            let m = num2 as i32;
610            let d = if num3 < 0 { 0 } else { num3 as i32 };
611
612            if num > 70 {
613                if set_date(y, m, d, None, now, tm) == 0 {
614                    return pos;
615                }
616                if set_date(y, d, m, None, now, tm) == 0 {
617                    return pos;
618                }
619            }
620            if c != b'.' && set_date(d, y, m, refuse_future, now, tm) == 0 {
621                return pos;
622            }
623            if set_date(d, m, y, refuse_future, now, tm) == 0 {
624                return pos;
625            }
626            if c == b'.' && set_date(d, y, m, refuse_future, now, tm) == 0 {
627                return pos;
628            }
629            return 0;
630        }
631        _ => return 0,
632    }
633    pos
634}
635
636fn match_alpha(date: &[u8], tm: &mut tm, offset: &mut i32) -> usize {
637    for (i, name) in MONTH_NAMES.iter().enumerate() {
638        let m = match_string(date, name);
639        if m >= 3 {
640            tm.tm_mon = i as i32;
641            return m;
642        }
643    }
644
645    for (i, name) in WEEKDAY_NAMES.iter().enumerate() {
646        let m = match_string(date, name);
647        if m >= 3 {
648            tm.tm_wday = i as i32;
649            return m;
650        }
651    }
652
653    for tz in TIMEZONE_NAMES {
654        let m = match_string(date, tz.name);
655        if m >= 3 || m == tz.name.len() {
656            let off = tz.offset_hours + tz.dst;
657            if *offset == -1 {
658                *offset = 60 * off;
659            }
660            return m;
661        }
662    }
663
664    if match_string(date, "PM") == 2 {
665        tm.tm_hour = (tm.tm_hour % 12) + 12;
666        return 2;
667    }
668
669    if match_string(date, "AM") == 2 {
670        tm.tm_hour %= 12;
671        return 2;
672    }
673
674    if date.first() == Some(&b'T')
675        && date.get(1).is_some_and(|b| b.is_ascii_digit())
676        && tm.tm_hour == -1
677    {
678        tm.tm_min = 0;
679        tm.tm_sec = 0;
680        return 1;
681    }
682
683    skip_alpha(date)
684}
685
686fn match_digit(
687    date: &[u8],
688    tm: &mut tm,
689    offset: &mut i32,
690    tm_gmt: &mut i32,
691    now_sec: i64,
692) -> usize {
693    let (num, n) = parse_timestamp_prefix(date);
694    if n == 0 {
695        return 0;
696    }
697    let end = n;
698
699    if num >= 100_000_000 && nodate(tm) && compat::gmtime(num as time_t, tm) {
700        *tm_gmt = 1;
701        return end;
702    }
703
704    if let Some(&sep) = date.get(end) {
705        if matches!(sep, b':' | b'.' | b'/' | b'-')
706            && date.get(end + 1).is_some_and(|b| b.is_ascii_digit())
707        {
708            let m = match_multi_number(num, date, end, tm, now_sec);
709            if m != 0 {
710                return m;
711            }
712        }
713    }
714
715    let mut n_digits = 0usize;
716    loop {
717        n_digits += 1;
718        if n_digits >= date.len() || !date[n_digits].is_ascii_digit() {
719            break;
720        }
721    }
722
723    if n_digits == 8 || n_digits == 6 {
724        let num1 = (num / 10000) as i32;
725        let num2 = ((num % 10000) / 100) as i32;
726        let num3 = (num % 100) as i32;
727        if n_digits == 8 {
728            let _ = set_date(num1, num2, num3, None, now_sec, tm);
729        } else if set_time(num1 as i64, num2 as i64, num3 as i64, tm) == 0
730            && date.get(end) == Some(&b'.')
731            && date.get(end + 1).is_some_and(|b| b.is_ascii_digit())
732        {
733            let (_, rel) = parse_uint_suffix(&date[end + 1..]);
734            return end + 1 + rel;
735        }
736        return end;
737    }
738
739    if maybeiso8601(tm) {
740        let mut num1 = num as u32;
741        let mut num2: u32 = 0;
742        if n_digits == 4 {
743            num1 = (num / 100) as u32;
744            num2 = (num % 100) as u32;
745        }
746        if (n_digits == 4 || n_digits == 2)
747            && !nodate(tm)
748            && set_time(num1 as i64, num2 as i64, 0, tm) == 0
749        {
750            return n_digits;
751        }
752        tm.tm_min = -1;
753        tm.tm_sec = -1;
754    }
755
756    if n_digits == 4 {
757        if num <= 1400 && *offset == -1 {
758            let minutes = (num % 100) as u32;
759            let hours = (num / 100) as u32;
760            *offset = (hours * 60 + minutes) as i32;
761        } else if num > 1900 && num < 2100 {
762            tm.tm_year = (num as i32) - 1900;
763        }
764        return n_digits;
765    }
766
767    if n_digits > 2 {
768        return n_digits;
769    }
770
771    if num > 0 && num < 32 && tm.tm_mday < 0 {
772        tm.tm_mday = num as i32;
773        return n_digits;
774    }
775
776    if n_digits == 2 && tm.tm_year < 0 {
777        if num < 10 && tm.tm_mday >= 0 {
778            tm.tm_year = (num as i32) + 100;
779            return n_digits;
780        }
781        if num >= 70 {
782            tm.tm_year = num as i32;
783            return n_digits;
784        }
785    }
786
787    if num > 0 && num < 13 && tm.tm_mon < 0 {
788        tm.tm_mon = (num as i32) - 1;
789    }
790
791    n_digits
792}