History

feat: Add comprehensive data parsing support

Now it should be mostly en-par with Git.

copilot-swe-agent[bot] committed 11 months ago

Commit 7dfb2da1 parent df393f35 files · +731 −22Browse files

Unified · Split

5 files
  1. gix-date/src/parse.rs +326 −0
  2. gix-date/tests/fixtures/generate_git_date_baseline.sh +152 −11
  3. gix-date/tests/fixtures/generated-archives/generate_git_date_baseline.tar +0 −0
  4. gix-date/tests/time/baseline.rs +24 −1
  5. gix-date/tests/time/parse.rs +230 −11
gix-date/src/parse.rs+326−0
@@ -156,6 +156,9 @@ pub(crate) mod function { · 155 unmodified lines
156156 Time::new(val.timestamp().as_second(), val.offset().seconds())
157157 } else if let Ok(val) = strptime_relaxed(ISO8601_STRICT.0, input) {
158158 Time::new(val.timestamp().as_second(), val.offset().seconds())
159+ } else if let Some(val) = parse_git_date_format(input) {
160+ // Git-style flexible date parsing (ISO8601 with dots, compact formats, Z suffix, etc.)
161+ val
159162 } else if let Ok(val) = strptime_relaxed(GITOXIDE.0, input) {
160163 Time::new(val.timestamp().as_second(), val.offset().seconds())
161164 } else if let Ok(val) = strptime_relaxed(DEFAULT.0, input) {
@@ -282,6 +285,329 @@ pub(crate) mod function { · 120 unmodified lines
282285 Time { seconds, offset }.into()
283286 }
284287
288+ /// Parse Git-style flexible date formats that aren't covered by standard strptime:
289+ /// - ISO8601 with dots: `2008.02.14 20:30:45 -0500`
290+ /// - Compact ISO8601: `20080214T203045`, `20080214T20:30:45`, `20080214T2030`, `20080214T20`
291+ /// - Z suffix for UTC: `1970-01-01 00:00:00 Z`
292+ /// - 2-digit hour offset: `2008-02-14 20:30:45 -05`
293+ /// - Colon-separated offset: `2008-02-14 20:30:45 -05:00`
294+ /// - Subsecond precision (ignored): `20080214T203045.019-04:00`
295+ fn parse_git_date_format(input: &str) -> Option<Time> {
296+ // Try ISO8601 with dots: YYYY.MM.DD HH:MM:SS offset
297+ if let Some(time) = parse_iso8601_dots(input) {
298+ return Some(time);
299+ }
300+ // Try compact ISO8601: YYYYMMDDTHHMMSS or YYYYMMDDT...
301+ if let Some(time) = parse_compact_iso8601(input) {
302+ return Some(time);
303+ }
304+ // Try ISO8601 with Z suffix or flexible timezone
305+ if let Some(time) = parse_flexible_iso8601(input) {
306+ return Some(time);
307+ }
308+ None
309+ }
310+
311+ /// Parse ISO8601 with dots: `2008.02.14 20:30:45 -0500`
312+ fn parse_iso8601_dots(input: &str) -> Option<Time> {
313+ // Format: YYYY.MM.DD HH:MM:SS offset
314+ let input = input.trim();
315+ if input.len() < 10 || !input.is_char_boundary(10) {
316+ return None;
317+ }
318+ let first_10 = &input[..10];
319+ if !first_10.is_ascii() || !first_10.contains('.') {
320+ return None;
321+ }
322+
323+ // Replace dots with dashes for date part only
324+ let (date_part, rest) = input.split_once(' ')?;
325+
326+ // Validate date part has dot separators
327+ if date_part.len() != 10 || date_part.chars().nth(4)? != '.' || date_part.chars().nth(7)? != '.' {
328+ return None;
329+ }
330+
331+ // Convert to standard ISO8601 format
332+ let normalized = format!("{} {}", date_part.replace('.', "-"), rest);
333+ parse_flexible_iso8601(&normalized)
334+ }
335+
336+ /// Parse compact ISO8601 formats:
337+ /// - `20080214T203045` (compact time)
338+ /// - `20080214T20:30:45` (normal time)
339+ /// - `20080214T2030` (hours and minutes only)
340+ /// - `20080214T20` (hours only)
341+ /// - With optional subsecond precision (ignored)
342+ /// - With optional timezone
343+ fn parse_compact_iso8601(input: &str) -> Option<Time> {
344+ let input = input.trim();
345+
346+ // Must have T separator and start with 8 digits for YYYYMMDD
347+ let t_pos = input.find('T')?;
348+ if t_pos != 8 || !input.is_char_boundary(8) || !input.is_char_boundary(9) {
349+ return None;
350+ }
351+
352+ let date_part = &input[..8];
353+ // Verify date part is ASCII (valid date chars are all ASCII)
354+ if !date_part.is_ascii() {
355+ return None;
356+ }
357+ let rest = &input[9..]; // after T
358+
359+ // Parse YYYYMMDD
360+ let year: i32 = date_part[0..4].parse().ok()?;
361+ let month: i32 = date_part[4..6].parse().ok()?;
362+ let day: i32 = date_part[6..8].parse().ok()?;
363+
364+ if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
365+ return None;
366+ }
367+
368+ // Parse time part - may have colons or not, may have subseconds, may have timezone
369+ let (time_str, offset_str) = split_time_and_offset(rest);
370+
371+ // Strip subseconds (anything after a dot in the time part, before offset)
372+ let time_str = if let Some(dot_pos) = time_str.find('.') {
373+ &time_str[..dot_pos]
374+ } else {
375+ time_str
376+ };
377+
378+ // Parse time - could be HH:MM:SS, HHMMSS, HH:MM, HHMM, or HH
379+ let (hour, minute, second) = parse_time_component(time_str)?;
380+
381+ // Parse offset
382+ let offset = parse_flexible_offset(offset_str)?;
383+
384+ // Construct the datetime
385+ let date = jiff::civil::Date::new(year as i16, month as i8, day as i8).ok()?;
386+ let time = jiff::civil::Time::new(hour as i8, minute as i8, second as i8, 0).ok()?;
387+ let datetime = date.at(time.hour(), time.minute(), time.second(), 0);
388+ let tz_offset = jiff::tz::Offset::from_seconds(offset).ok()?;
389+ let zoned = datetime.to_zoned(tz_offset.to_time_zone()).ok()?;
390+
391+ Some(Time::new(zoned.timestamp().as_second(), offset))
392+ }
393+
394+ /// Parse ISO8601 with flexible timezone (Z suffix, 2-digit offset, colon-separated offset)
395+ /// and optional subsecond precision
396+ fn parse_flexible_iso8601(input: &str) -> Option<Time> {
397+ let input = input.trim();
398+
399+ // Check if this looks like ISO8601 (YYYY-MM-DD format)
400+ // Must be at least 10 ASCII chars and first 10 chars must be ASCII
401+ if input.len() < 10 || !input.is_char_boundary(10) {
402+ return None;
403+ }
404+ let date_part = &input[..10];
405+ // Verify date part is ASCII (valid date chars are all ASCII)
406+ if !date_part.is_ascii() {
407+ return None;
408+ }
409+ if date_part.chars().nth(4)? != '-' || date_part.chars().nth(7)? != '-' {
410+ return None;
411+ }
412+
413+ // Parse the date
414+ let year: i32 = date_part[0..4].parse().ok()?;
415+ let month: i32 = date_part[5..7].parse().ok()?;
416+ let day: i32 = date_part[8..10].parse().ok()?;
417+
418+ if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
419+ return None;
420+ }
421+
422+ // Rest after date
423+ let rest = &input[10..];
424+ if rest.is_empty() {
425+ return None;
426+ }
427+
428+ // Skip T or space separator
429+ let rest = if rest.starts_with('T') || rest.starts_with(' ') {
430+ &rest[1..]
431+ } else {
432+ return None;
433+ };
434+
435+ // Split into time and offset
436+ let (time_str, offset_str) = split_time_and_offset(rest);
437+
438+ // Strip subseconds
439+ let time_str = if let Some(dot_pos) = time_str.find('.') {
440+ &time_str[..dot_pos]
441+ } else {
442+ time_str
443+ };
444+
445+ // Parse time HH:MM:SS
446+ let (hour, minute, second) = parse_time_component(time_str)?;
447+
448+ // Parse offset
449+ let offset = parse_flexible_offset(offset_str)?;
450+
451+ // Construct the datetime
452+ let date = jiff::civil::Date::new(year as i16, month as i8, day as i8).ok()?;
453+ let time = jiff::civil::Time::new(hour as i8, minute as i8, second as i8, 0).ok()?;
454+ let datetime = date.at(time.hour(), time.minute(), time.second(), 0);
455+ let tz_offset = jiff::tz::Offset::from_seconds(offset).ok()?;
456+ let zoned = datetime.to_zoned(tz_offset.to_time_zone()).ok()?;
457+
458+ Some(Time::new(zoned.timestamp().as_second(), offset))
459+ }
460+
461+ /// Split time string into time component and offset component
462+ fn split_time_and_offset(input: &str) -> (&str, &str) {
463+ // Look for offset indicators: Z, +, - (but - after digits could be in time)
464+ // The offset is at the end, after the time
465+
466+ let input = input.trim();
467+
468+ // Check for Z suffix
469+ if let Some(stripped) = input.strip_suffix('Z') {
470+ return (stripped, "Z");
471+ }
472+
473+ // Look for + or - that indicates timezone (not part of time)
474+ // Time format is HH:MM:SS or HHMMSS, so offset starts after that
475+ // Find the last + or - that's after position 5 (minimum for HH:MM)
476+ let mut offset_start = None;
477+ for (i, c) in input.char_indices().rev() {
478+ if (c == '+' || c == '-') && i >= 5 {
479+ // Check if this looks like an offset (followed by digits)
480+ let after = &input[i + 1..];
481+ if after.chars().next().is_some_and(|c| c.is_ascii_digit()) {
482+ offset_start = Some(i);
483+ break;
484+ }
485+ }
486+ }
487+
488+ // Also handle space-separated offset
489+ if let Some(space_pos) = input.rfind(' ') {
490+ if space_pos > 5 {
491+ let potential_offset = input[space_pos + 1..].trim();
492+ if potential_offset.starts_with('+') || potential_offset.starts_with('-') || potential_offset == "Z" {
493+ return (&input[..space_pos], potential_offset);
494+ }
495+ }
496+ }
497+
498+ if let Some(pos) = offset_start {
499+ (&input[..pos], &input[pos..])
500+ } else {
501+ (input, "")
502+ }
503+ }
504+
505+ /// Parse time component: HH:MM:SS, HHMMSS, HH:MM, HHMM, or HH
506+ fn parse_time_component(time: &str) -> Option<(u32, u32, u32)> {
507+ let time = time.trim();
508+
509+ // Time components must be ASCII
510+ if !time.is_ascii() {
511+ return None;
512+ }
513+
514+ if time.contains(':') {
515+ // Colon-separated: HH:MM:SS or HH:MM
516+ let parts: Vec<&str> = time.split(':').collect();
517+ let hour: u32 = parts.first()?.parse().ok()?;
518+ let minute: u32 = parts.get(1).unwrap_or(&"0").parse().ok()?;
519+ let second: u32 = parts.get(2).unwrap_or(&"0").parse().ok()?;
520+ if hour > 23 || minute > 59 || second > 59 {
521+ return None;
522+ }
523+ Some((hour, minute, second))
524+ } else {
525+ // Compact: HHMMSS, HHMM, or HH
526+ match time.len() {
527+ 2 => {
528+ let hour: u32 = time.parse().ok()?;
529+ if hour > 23 {
530+ return None;
531+ }
532+ Some((hour, 0, 0))
533+ }
534+ 4 => {
535+ let hour: u32 = time[0..2].parse().ok()?;
536+ let minute: u32 = time[2..4].parse().ok()?;
537+ if hour > 23 || minute > 59 {
538+ return None;
539+ }
540+ Some((hour, minute, 0))
541+ }
542+ 6 => {
543+ let hour: u32 = time[0..2].parse().ok()?;
544+ let minute: u32 = time[2..4].parse().ok()?;
545+ let second: u32 = time[4..6].parse().ok()?;
546+ if hour > 23 || minute > 59 || second > 59 {
547+ return None;
548+ }
549+ Some((hour, minute, second))
550+ }
551+ _ => None,
552+ }
553+ }
554+ }
555+
556+ /// Parse flexible timezone offset:
557+ /// - Empty or missing: +0000
558+ /// - Z: +0000
559+ /// - +/-HH: +/-HH00
560+ /// - +/-HHMM: +/-HHMM
561+ /// - +/-HH:MM: +/-HHMM
562+ fn parse_flexible_offset(offset: &str) -> Option<i32> {
563+ let offset = offset.trim();
564+
565+ if offset.is_empty() {
566+ return Some(0);
567+ }
568+
569+ // Offset must be ASCII
570+ if !offset.is_ascii() {
571+ return None;
572+ }
573+
574+ if offset == "Z" {
575+ return Some(0);
576+ }
577+
578+ let (sign, rest) = if let Some(stripped) = offset.strip_prefix('+') {
579+ (1i32, stripped)
580+ } else if let Some(stripped) = offset.strip_prefix('-') {
581+ (-1i32, stripped)
582+ } else {
583+ return None;
584+ };
585+
586+ // Remove colon if present
587+ let rest = rest.replace(':', "");
588+
589+ let (hours, minutes) = match rest.len() {
590+ 2 => {
591+ // HH format
592+ let hours: i32 = rest.parse().ok()?;
593+ (hours, 0)
594+ }
595+ 4 => {
596+ // HHMM format
597+ let hours: i32 = rest[0..2].parse().ok()?;
598+ let minutes: i32 = rest[2..4].parse().ok()?;
599+ (hours, minutes)
600+ }
601+ _ => return None,
602+ };
603+
604+ if hours > 14 || minutes > 59 {
605+ return None;
606+ }
607+
608+ Some(sign * (hours * 3600 + minutes * 60))
609+ }
610+
285611 /// This is just like `Zoned::strptime`, but it allows parsing datetimes
286612 /// whose weekdays are inconsistent with the date. While the day-of-week
287613 /// still must be parsed, it is otherwise ignored. This seems to be
gix-date/tests/fixtures/generate_git_date_baseline.sh+152−11
@@ -22,25 +22,166 @@ function baseline() { · 21 unmodified lines
2222 } >> baseline.git
2323 }
2424
25−# success
25+# Relative dates use a fixed "now" timestamp for reproducibility
26+# GIT_TEST_DATE_NOW sets Git's internal "now" to a specific Unix timestamp
27+# We use 1000000000 (Sun Sep 9 01:46:40 UTC 2001) as our reference point
28+function baseline_relative() {
29+ local test_date="$1" # first argument is the relative date to test
30+ local test_name="$2" # second argument is the format name (usually empty for relative dates)
2631
27−# date formats following to https://git-scm.com/docs/git-log#Documentation/git-log.txt---dateltformatgt
32+ local status=0
33+ GIT_TEST_DATE_NOW=1000000000 git -c section.key="$test_date" config --type=expiry-date section.key || status="$?"
34+
35+ {
36+ echo "$test_date"
37+ echo "$test_name"
38+ echo "$status"
39+ if [ "$status" = 0 ]; then
40+ GIT_TEST_DATE_NOW=1000000000 git -c section.key="$test_date" config --type=expiry-date section.key
41+ else
42+ echo '-1'
43+ fi
44+ } >> baseline.git
45+}
46+
47+# ============================================================================
48+# FIXED DATE FORMATS
49+# ============================================================================
50+# Tests from https://github.com/git/git/blob/master/t/t0006-date.sh
51+
52+# Note: SHORT format (YYYY-MM-DD) is NOT included in baseline tests because
53+# Git fills in current time-of-day, making it non-reproducible for baseline comparison.
54+# SHORT format is tested separately in the unit tests.
2855
29−# short
30−# ODO
31−#baseline '2022-08-22'
32−# rfc2822
56+# RFC2822 format: "Day, DD Mon YYYY HH:MM:SS +/-ZZZZ"
3357 baseline 'Thu, 18 Aug 2022 12:45:06 +0800' 'RFC2822'
34−# iso8601
58+baseline 'Sat, 01 Jan 2000 00:00:00 +0000' 'RFC2822'
59+baseline 'Fri, 13 Feb 2009 23:31:30 +0000' 'RFC2822' # Unix timestamp 1234567890
60+baseline 'Wed, 15 Jun 2016 16:13:20 +0200' 'RFC2822' # from git t0006
61+baseline 'Thu, 7 Apr 2005 15:14:13 -0700' '' # from git t0006
62+
63+# GIT_RFC2822 format: like RFC2822 but with non-padded day
64+baseline 'Thu, 1 Aug 2022 12:45:06 +0800' ''
65+baseline 'Sat, 1 Jan 2000 00:00:00 +0000' ''
66+
67+# ISO8601 format: "YYYY-MM-DD HH:MM:SS +/-ZZZZ" from git t0006
3568 baseline '2022-08-17 22:04:58 +0200' 'ISO8601'
36−# iso8601_strict
69+baseline '2000-01-01 00:00:00 +0000' 'ISO8601'
70+baseline '1970-01-01 00:00:00 +0000' 'ISO8601'
71+baseline '2008-02-14 20:30:45 +0000' '' # from git t0006
72+baseline '2008-02-14 20:30:45 -0500' '' # from git t0006
73+baseline '2016-06-15 16:13:20 +0200' 'ISO8601' # from git t0006
74+
75+# ISO8601 with dots: "YYYY.MM.DD HH:MM:SS +/-ZZZZ" from git t0006
76+baseline '2008.02.14 20:30:45 -0500' ''
77+
78+# ISO8601_STRICT format: "YYYY-MM-DDTHH:MM:SS+ZZ:ZZ"
3779 baseline '2022-08-17T21:43:13+08:00' 'ISO8601_STRICT'
38−# default
80+baseline '2000-01-01T00:00:00+00:00' 'ISO8601_STRICT'
81+baseline '2009-02-13T23:31:30+00:00' 'ISO8601_STRICT' # Unix timestamp 1234567890
82+baseline '2016-06-15T16:13:20+02:00' 'ISO8601_STRICT' # from git t0006
83+
84+# Z suffix for UTC timezone from git t0006
85+baseline '1970-01-01 00:00:00 Z' ''
86+
87+# Compact ISO8601 formats from git t0006 (YYYYMMDDTHHMMSS)
88+# Note: Some compact formats like 20080214T2030 are not universally supported
89+# across all Git versions and platforms, so we only test the most common ones.
90+baseline '20080214T20:30:45' ''
91+baseline '20080214T203045' ''
92+baseline '20080214T203045-04:00' ''
93+
94+# Subsecond precision (Git ignores the subseconds)
95+baseline '2008-02-14 20:30:45.019-04:00' ''
96+
97+# Various timezone formats from git t0006
98+baseline '2008-02-14 20:30:45 -0015' '' # 15-minute offset
99+baseline '2008-02-14 20:30:45 -05' '' # 2-digit hour offset
100+baseline '2008-02-14 20:30:45 -05:00' '' # colon-separated offset
101+baseline '2008-02-14 20:30:45 +00' '' # 2-digit +00
102+
103+# Timezone edge cases from git t0006
104+baseline '1970-01-01 00:00:00 +0000' ''
105+baseline '1970-01-01 01:00:00 +0100' ''
106+baseline '1970-01-02 00:00:00 +1100' ''
107+
108+# DEFAULT format (Git's default): "Day Mon D HH:MM:SS YYYY +/-ZZZZ"
39109 baseline 'Thu Sep 04 2022 10:45:06 -0400' '' # cannot round-trip, incorrect day-of-week
40110 baseline 'Sun Sep 04 2022 10:45:06 -0400' 'GITOXIDE'
41−# unix
111+baseline 'Thu Aug 18 12:45:06 2022 +0800' ''
112+baseline 'Wed Jun 15 16:13:20 2016 +0200' '' # from git t0006
113+
114+# UNIX timestamp format
115+# Note: Git only treats numbers >= 100000000 as UNIX timestamps.
116+# Smaller numbers are interpreted as date components.
42117 baseline '1234567890' 'UNIX'
43−# raw
118+baseline '100000000' 'UNIX'
119+baseline '946684800' 'UNIX' # 2000-01-01 00:00:00 UTC
120+baseline '1466000000' 'UNIX' # from git t0006
121+
122+# RAW format: "SECONDS +/-ZZZZ"
123+# Note: Git only treats timestamps >= 100000000 as raw format.
124+# Smaller numbers are interpreted as date components.
44125 baseline '1660874655 +0800' 'RAW'
126+baseline '1660874655 -0800' 'RAW'
127+baseline '100000000 +0000' 'RAW'
128+baseline '1234567890 +0000' 'RAW'
129+baseline '946684800 +0000' 'RAW'
130+baseline '1466000000 +0200' 'RAW' # from git t0006
131+baseline '1466000000 -0200' 'RAW' # from git t0006
132+
133+# Note: Git does not support negative timestamps through --type=expiry-date
134+# gix-date does support them, but they can't be tested via the baseline.
135+
136+# ============================================================================
137+# RELATIVE DATE FORMATS from git t0006
138+# ============================================================================
139+# These tests use GIT_TEST_DATE_NOW=1000000000 (Sun Sep 9 01:46:40 UTC 2001)
140+
141+# Seconds - from git t0006 check_relative
142+baseline_relative '1 second ago' ''
143+baseline_relative '2 seconds ago' ''
144+baseline_relative '30 seconds ago' ''
145+baseline_relative '5 seconds ago' '' # from git t0006 check_relative 5
146+
147+# Minutes - from git t0006 check_relative 300 = 5 minutes
148+baseline_relative '1 minute ago' ''
149+baseline_relative '2 minutes ago' ''
150+baseline_relative '30 minutes ago' ''
151+baseline_relative '5 minutes ago' ''
152+baseline_relative '10 minutes ago' ''
153+
154+# Hours - from git t0006 check_relative 18000 = 5 hours
155+baseline_relative '1 hour ago' ''
156+baseline_relative '2 hours ago' ''
157+baseline_relative '12 hours ago' ''
158+baseline_relative '5 hours ago' ''
159+
160+# Days - from git t0006 check_relative 432000 = 5 days
161+baseline_relative '1 day ago' ''
162+baseline_relative '2 days ago' ''
163+baseline_relative '7 days ago' ''
164+baseline_relative '5 days ago' ''
165+baseline_relative '3 days ago' ''
166+
167+# Weeks - from git t0006 check_relative 1728000 = 3 weeks (20 days)
168+baseline_relative '1 week ago' ''
169+baseline_relative '2 weeks ago' ''
170+baseline_relative '4 weeks ago' ''
171+baseline_relative '3 weeks ago' ''
172+
173+# Months - from git t0006 check_relative 13000000 ≈ 5 months
174+baseline_relative '1 month ago' ''
175+baseline_relative '2 months ago' ''
176+baseline_relative '6 months ago' ''
177+baseline_relative '5 months ago' ''
178+baseline_relative '3 months ago' ''
179+baseline_relative '12 months ago' ''
180+
181+# Years - from git t0006 check_relative 630000000 = 20 years
182+baseline_relative '1 year ago' ''
183+baseline_relative '2 years ago' ''
184+baseline_relative '10 years ago' ''
185+baseline_relative '20 years ago' ''
45186
46187 # Note that we can't necessarily put 64bit dates here yet as `git` on the system might not yet support it.
gix-date/tests/fixtures/generated-archives/generate_git_date_baseline.tar+0−0

Binary file not shown.

gix-date/tests/time/baseline.rs+24−1
@@ -6,6 +6,7 @@ use gix_date::{ · 5 unmodified lines
66 };
77 use gix_testtools::Result;
88 use std::sync::LazyLock;
9+use std::time::Duration;
910
1011 struct Sample {
1112 format_name: Option<String>,
@@ -13,6 +14,21 @@ struct Sample { · 1 unmodified line
1314 seconds: SecondsSinceUnixEpoch,
1415 }
1516
17+/// Returns true if the pattern looks like a relative date of the form "N unit ago".
18+/// Note: This only covers the relative dates tested in the baseline (e.g., "1 day ago").
19+/// Other relative formats like "yesterday", "last week" etc. are not included in baseline
20+/// testing because they would require additional handling in the baseline script.
21+fn is_relative_date(pattern: &str) -> bool {
22+ pattern.ends_with(" ago")
23+}
24+
25+/// The fixed "now" timestamp used for testing relative dates.
26+/// This matches GIT_TEST_DATE_NOW=1000000000 in the baseline script.
27+/// This is Sun Sep 9 01:46:40 UTC 2001.
28+fn fixed_now() -> SystemTime {
29+ std::time::UNIX_EPOCH + Duration::from_secs(1_000_000_000)
30+}
31+
1632 static BASELINE: LazyLock<HashMap<String, Sample>> = LazyLock::new(|| {
1733 (|| -> Result<_> {
1834 let base = gix_testtools::scripted_fixture_read_only("generate_git_date_baseline.sh")?;
@@ -53,7 +69,13 @@ fn parse_compare_format() { · 34 unmodified lines
5369 },
5470 ) in BASELINE.iter()
5571 {
56− let res = gix_date::parse(pattern.as_str(), Some(SystemTime::now()));
72+ // Use fixed_now() for relative dates, current time for absolute dates
73+ let now = if is_relative_date(pattern) {
74+ Some(fixed_now())
75+ } else {
76+ Some(SystemTime::now())
77+ };
78+ let res = gix_date::parse(pattern.as_str(), now);
5779 assert_eq!(
5880 res.is_ok(),
5981 *exit_code == 0,
@@ -68,6 +90,7 @@ fn parse_compare_format() { · 8 unmodified lines
6890 if let Some(format_name) = format_name {
6991 let reformatted = t
7092 .format(match format_name.as_str() {
93+ "SHORT" => Format::Custom(format::SHORT),
7194 "RFC2822" => Format::Custom(format::RFC2822),
7295 "ISO8601" => Format::Custom(format::ISO8601),
7396 "ISO8601_STRICT" => Format::Custom(format::ISO8601_STRICT),
gix-date/tests/time/parse.rs+230−11
@@ -3,25 +3,23 @@ use std::time::SystemTime; · 2 unmodified lines
33 use gix_date::Time;
44
55 #[test]
6−fn time_without_offset_is_not_parsed_yet() {
7− assert!(
8− gix_date::parse("1979-02-26 18:30:00", Some(SystemTime::now())).is_err(),
9− "This was a special time with special handling, but it is not anymore"
10− );
6+fn time_without_offset_defaults_to_utc() {
7+ // Git parses datetime without offset and defaults to UTC (+0000)
8+ let result = gix_date::parse("1979-02-26 18:30:00", Some(SystemTime::now()));
9+ assert!(result.is_ok(), "Git parses datetime without offset, defaulting to UTC");
10+ let time = result.unwrap();
11+ assert_eq!(time.offset, 0, "Offset should default to UTC (+0000)");
1112 }
1213
1314 #[test]
1415 fn parse_header_is_not_too_lenient() {
15− let now = SystemTime::now();
1616 for not_a_header_str in ["2005-04-07T22:13:09", "2005-04-07 22:13:09"] {
1717 assert!(
1818 gix_date::parse_header(not_a_header_str).is_none(),
19− "It's not timestamp-like, despite some leniency"
20− );
21− assert!(
22− gix_date::parse(not_a_header_str, Some(now)).is_err(),
23− "it misses the timezone offset, so can't be parsed"
19+ "parse_header only accepts raw format (timestamp +offset), not ISO8601"
2420 );
21+ // Note: gix_date::parse() DOES accept these formats, matching Git's behavior
22+ // Git parses them with default UTC offset
2523 }
2624 }
2725
@@ -341,6 +339,227 @@ mod relative { · 313 unmodified lines
341339 }
342340 }
343341
342+/// Tests for compact ISO8601 formats (YYYYMMDDTHHMMSS variants)
343+/// These formats are parsed by gix-date but may not be universally
344+/// supported across all Git versions, so they have dedicated unit tests
345+/// rather than baseline tests.
346+mod compact_iso8601 {
347+ use gix_date::Time;
348+
349+ #[test]
350+ fn full_format() {
351+ // 20080214T203045 = Feb 14, 2008 20:30:45 UTC
352+ assert_eq!(
353+ gix_date::parse("20080214T203045", None).unwrap(),
354+ Time {
355+ seconds: 1203021045,
356+ offset: 0,
357+ },
358+ );
359+ }
360+
361+ #[test]
362+ fn with_colons_in_time() {
363+ // 20080214T20:30:45 = Feb 14, 2008 20:30:45 UTC
364+ assert_eq!(
365+ gix_date::parse("20080214T20:30:45", None).unwrap(),
366+ Time {
367+ seconds: 1203021045,
368+ offset: 0,
369+ },
370+ );
371+ }
372+
373+ #[test]
374+ fn hour_minute_only() {
375+ // 20080214T2030 = Feb 14, 2008 20:30:00 UTC
376+ assert_eq!(
377+ gix_date::parse("20080214T2030", None).unwrap(),
378+ Time {
379+ seconds: 1203021000,
380+ offset: 0,
381+ },
382+ );
383+ }
384+
385+ #[test]
386+ fn hour_minute_with_colon() {
387+ // 20080214T20:30 = Feb 14, 2008 20:30:00 UTC
388+ assert_eq!(
389+ gix_date::parse("20080214T20:30", None).unwrap(),
390+ Time {
391+ seconds: 1203021000,
392+ offset: 0,
393+ },
394+ );
395+ }
396+
397+ #[test]
398+ fn hour_only() {
399+ // 20080214T20 = Feb 14, 2008 20:00:00 UTC
400+ assert_eq!(
401+ gix_date::parse("20080214T20", None).unwrap(),
402+ Time {
403+ seconds: 1203019200,
404+ offset: 0,
405+ },
406+ );
407+ }
408+
409+ #[test]
410+ fn with_timezone() {
411+ // 20080214T203045-04:00 = Feb 14, 2008 20:30:45 -04:00
412+ // UTC seconds = local time + 4 hours = 1203021045 + 14400 = 1203035445
413+ assert_eq!(
414+ gix_date::parse("20080214T203045-04:00", None).unwrap(),
415+ Time {
416+ seconds: 1203035445,
417+ offset: -14400,
418+ },
419+ );
420+ }
421+
422+ #[test]
423+ fn with_space_before_timezone() {
424+ // 20080214T203045 -04:00 = Feb 14, 2008 20:30:45 -04:00
425+ // UTC seconds = local time + 4 hours = 1203021045 + 14400 = 1203035445
426+ assert_eq!(
427+ gix_date::parse("20080214T203045 -04:00", None).unwrap(),
428+ Time {
429+ seconds: 1203035445,
430+ offset: -14400,
431+ },
432+ );
433+ }
434+
435+ #[test]
436+ fn with_subseconds_ignored() {
437+ // Subsecond precision is ignored, like Git does
438+ // 20080214T203045.019-04:00 = Feb 14, 2008 20:30:45.019 -04:00
439+ // UTC seconds = local time + 4 hours = 1203021045 + 14400 = 1203035445
440+ assert_eq!(
441+ gix_date::parse("20080214T203045.019-04:00", None).unwrap(),
442+ Time {
443+ seconds: 1203035445,
444+ offset: -14400,
445+ },
446+ );
447+ }
448+
449+ #[test]
450+ fn with_subseconds_no_timezone() {
451+ // 20080214T000000.20 = Feb 14, 2008 00:00:00.20 UTC
452+ assert_eq!(
453+ gix_date::parse("20080214T000000.20", None).unwrap(),
454+ Time {
455+ seconds: 1202947200,
456+ offset: 0,
457+ },
458+ );
459+ }
460+
461+ #[test]
462+ fn with_subseconds_colon_time() {
463+ // 20080214T00:00:00.20 = Feb 14, 2008 00:00:00.20 UTC
464+ assert_eq!(
465+ gix_date::parse("20080214T00:00:00.20", None).unwrap(),
466+ Time {
467+ seconds: 1202947200,
468+ offset: 0,
469+ },
470+ );
471+ }
472+}
473+
474+/// Tests for ISO8601 with dots format (YYYY.MM.DD HH:MM:SS offset)
475+mod iso8601_dots {
476+ use gix_date::Time;
477+
478+ #[test]
479+ fn basic() {
480+ // 2008.02.14 20:30:45 -0500
481+ // UTC seconds = local time + 5 hours = 1203021045 + 18000 = 1203039045
482+ assert_eq!(
483+ gix_date::parse("2008.02.14 20:30:45 -0500", None).unwrap(),
484+ Time {
485+ seconds: 1203039045,
486+ offset: -18000,
487+ },
488+ );
489+ }
490+}
491+
492+/// Tests for flexible timezone offset formats
493+mod flexible_offset {
494+ use gix_date::Time;
495+
496+ #[test]
497+ fn z_suffix_for_utc() {
498+ // 1970-01-01 00:00:00 Z = Unix epoch
499+ assert_eq!(
500+ gix_date::parse("1970-01-01 00:00:00 Z", None).unwrap(),
501+ Time { seconds: 0, offset: 0 },
502+ );
503+ }
504+
505+ #[test]
506+ fn two_digit_hour_offset() {
507+ // 2008-02-14 20:30:45 -05 = 2008-02-14 20:30:45 -0500
508+ // UTC seconds = local time + 5 hours = 1203021045 + 18000 = 1203039045
509+ assert_eq!(
510+ gix_date::parse("2008-02-14 20:30:45 -05", None).unwrap(),
511+ Time {
512+ seconds: 1203039045,
513+ offset: -18000,
514+ },
515+ );
516+ }
517+
518+ #[test]
519+ fn colon_separated_offset() {
520+ // 2008-02-14 20:30:45 -05:00 = 2008-02-14 20:30:45 -0500
521+ // UTC seconds = local time + 5 hours = 1203021045 + 18000 = 1203039045
522+ assert_eq!(
523+ gix_date::parse("2008-02-14 20:30:45 -05:00", None).unwrap(),
524+ Time {
525+ seconds: 1203039045,
526+ offset: -18000,
527+ },
528+ );
529+ }
530+
531+ #[test]
532+ fn fifteen_minute_offset() {
533+ // 2008-02-14 20:30:45 -0015
534+ // UTC seconds = local time + 15 min = 1203021045 + 900 = 1203021945
535+ assert_eq!(
536+ gix_date::parse("2008-02-14 20:30:45 -0015", None).unwrap(),
537+ Time {
538+ seconds: 1203021945,
539+ offset: -900,
540+ },
541+ );
542+ }
543+}
544+
545+/// Tests for subsecond precision in ISO8601 formats (ignored like Git)
546+mod subsecond_precision {
547+ use gix_date::Time;
548+
549+ #[test]
550+ fn iso8601_with_subseconds() {
551+ // 2008-02-14 20:30:45.019-04:00 - subseconds ignored
552+ // UTC seconds = local time + 4 hours = 1203021045 + 14400 = 1203035445
553+ assert_eq!(
554+ gix_date::parse("2008-02-14 20:30:45.019-04:00", None).unwrap(),
555+ Time {
556+ seconds: 1203035445,
557+ offset: -14400,
558+ },
559+ );
560+ }
561+}
562+
344563 /// Various cases the fuzzer found
345564 mod fuzz {
346565 #[test]