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
156 Time::new(val.timestamp().as_second(), val.offset().seconds())156 Time::new(val.timestamp().as_second(), val.offset().seconds())
157 } else if let Ok(val) = strptime_relaxed(ISO8601_STRICT.0, input) {157 } else if let Ok(val) = strptime_relaxed(ISO8601_STRICT.0, input) {
158 Time::new(val.timestamp().as_second(), val.offset().seconds())158 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
159 } else if let Ok(val) = strptime_relaxed(GITOXIDE.0, input) {162 } else if let Ok(val) = strptime_relaxed(GITOXIDE.0, input) {
160 Time::new(val.timestamp().as_second(), val.offset().seconds())163 Time::new(val.timestamp().as_second(), val.offset().seconds())
161 } else if let Ok(val) = strptime_relaxed(DEFAULT.0, input) {164 } else if let Ok(val) = strptime_relaxed(DEFAULT.0, input) {
@@ -282,6 +285,329 @@ pub(crate) mod function { · 120 unmodified lines
282 Time { seconds, offset }.into()285 Time { seconds, offset }.into()
283 }286 }
284 287
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
285 /// This is just like `Zoned::strptime`, but it allows parsing datetimes611 /// This is just like `Zoned::strptime`, but it allows parsing datetimes
286 /// whose weekdays are inconsistent with the date. While the day-of-week612 /// whose weekdays are inconsistent with the date. While the day-of-week
287 /// still must be parsed, it is otherwise ignored. This seems to be613 /// 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
22 } >> baseline.git22 } >> baseline.git
23}23}
24 24
25# success25# 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
28function 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)
26 31
27# date formats following to https://git-scm.com/docs/git-log#Documentation/git-log.txt---dateltformatgt32 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.
28 55
29# short56# RFC2822 format: "Day, DD Mon YYYY HH:MM:SS +/-ZZZZ"
30# ODO
31#baseline '2022-08-22'
32# rfc2822
33baseline 'Thu, 18 Aug 2022 12:45:06 +0800' 'RFC2822'57baseline 'Thu, 18 Aug 2022 12:45:06 +0800' 'RFC2822'
34# iso860158baseline 'Sat, 01 Jan 2000 00:00:00 +0000' 'RFC2822'
59baseline 'Fri, 13 Feb 2009 23:31:30 +0000' 'RFC2822' # Unix timestamp 1234567890
60baseline 'Wed, 15 Jun 2016 16:13:20 +0200' 'RFC2822' # from git t0006
61baseline 'Thu, 7 Apr 2005 15:14:13 -0700' '' # from git t0006
62
63# GIT_RFC2822 format: like RFC2822 but with non-padded day
64baseline 'Thu, 1 Aug 2022 12:45:06 +0800' ''
65baseline 'Sat, 1 Jan 2000 00:00:00 +0000' ''
66
67# ISO8601 format: "YYYY-MM-DD HH:MM:SS +/-ZZZZ" from git t0006
35baseline '2022-08-17 22:04:58 +0200' 'ISO8601'68baseline '2022-08-17 22:04:58 +0200' 'ISO8601'
36# iso8601_strict69baseline '2000-01-01 00:00:00 +0000' 'ISO8601'
70baseline '1970-01-01 00:00:00 +0000' 'ISO8601'
71baseline '2008-02-14 20:30:45 +0000' '' # from git t0006
72baseline '2008-02-14 20:30:45 -0500' '' # from git t0006
73baseline '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
76baseline '2008.02.14 20:30:45 -0500' ''
77
78# ISO8601_STRICT format: "YYYY-MM-DDTHH:MM:SS+ZZ:ZZ"
37baseline '2022-08-17T21:43:13+08:00' 'ISO8601_STRICT'79baseline '2022-08-17T21:43:13+08:00' 'ISO8601_STRICT'
38# default80baseline '2000-01-01T00:00:00+00:00' 'ISO8601_STRICT'
81baseline '2009-02-13T23:31:30+00:00' 'ISO8601_STRICT' # Unix timestamp 1234567890
82baseline '2016-06-15T16:13:20+02:00' 'ISO8601_STRICT' # from git t0006
83
84# Z suffix for UTC timezone from git t0006
85baseline '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.
90baseline '20080214T20:30:45' ''
91baseline '20080214T203045' ''
92baseline '20080214T203045-04:00' ''
93
94# Subsecond precision (Git ignores the subseconds)
95baseline '2008-02-14 20:30:45.019-04:00' ''
96
97# Various timezone formats from git t0006
98baseline '2008-02-14 20:30:45 -0015' '' # 15-minute offset
99baseline '2008-02-14 20:30:45 -05' '' # 2-digit hour offset
100baseline '2008-02-14 20:30:45 -05:00' '' # colon-separated offset
101baseline '2008-02-14 20:30:45 +00' '' # 2-digit +00
102
103# Timezone edge cases from git t0006
104baseline '1970-01-01 00:00:00 +0000' ''
105baseline '1970-01-01 01:00:00 +0100' ''
106baseline '1970-01-02 00:00:00 +1100' ''
107
108# DEFAULT format (Git's default): "Day Mon D HH:MM:SS YYYY +/-ZZZZ"
39baseline 'Thu Sep 04 2022 10:45:06 -0400' '' # cannot round-trip, incorrect day-of-week109baseline 'Thu Sep 04 2022 10:45:06 -0400' '' # cannot round-trip, incorrect day-of-week
40baseline 'Sun Sep 04 2022 10:45:06 -0400' 'GITOXIDE'110baseline 'Sun Sep 04 2022 10:45:06 -0400' 'GITOXIDE'
41# unix111baseline 'Thu Aug 18 12:45:06 2022 +0800' ''
112baseline '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.
42baseline '1234567890' 'UNIX'117baseline '1234567890' 'UNIX'
43# raw118baseline '100000000' 'UNIX'
119baseline '946684800' 'UNIX' # 2000-01-01 00:00:00 UTC
120baseline '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.
44baseline '1660874655 +0800' 'RAW'125baseline '1660874655 +0800' 'RAW'
126baseline '1660874655 -0800' 'RAW'
127baseline '100000000 +0000' 'RAW'
128baseline '1234567890 +0000' 'RAW'
129baseline '946684800 +0000' 'RAW'
130baseline '1466000000 +0200' 'RAW' # from git t0006
131baseline '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
142baseline_relative '1 second ago' ''
143baseline_relative '2 seconds ago' ''
144baseline_relative '30 seconds ago' ''
145baseline_relative '5 seconds ago' '' # from git t0006 check_relative 5
146
147# Minutes - from git t0006 check_relative 300 = 5 minutes
148baseline_relative '1 minute ago' ''
149baseline_relative '2 minutes ago' ''
150baseline_relative '30 minutes ago' ''
151baseline_relative '5 minutes ago' ''
152baseline_relative '10 minutes ago' ''
153
154# Hours - from git t0006 check_relative 18000 = 5 hours
155baseline_relative '1 hour ago' ''
156baseline_relative '2 hours ago' ''
157baseline_relative '12 hours ago' ''
158baseline_relative '5 hours ago' ''
159
160# Days - from git t0006 check_relative 432000 = 5 days
161baseline_relative '1 day ago' ''
162baseline_relative '2 days ago' ''
163baseline_relative '7 days ago' ''
164baseline_relative '5 days ago' ''
165baseline_relative '3 days ago' ''
166
167# Weeks - from git t0006 check_relative 1728000 = 3 weeks (20 days)
168baseline_relative '1 week ago' ''
169baseline_relative '2 weeks ago' ''
170baseline_relative '4 weeks ago' ''
171baseline_relative '3 weeks ago' ''
172
173# Months - from git t0006 check_relative 13000000 ≈ 5 months
174baseline_relative '1 month ago' ''
175baseline_relative '2 months ago' ''
176baseline_relative '6 months ago' ''
177baseline_relative '5 months ago' ''
178baseline_relative '3 months ago' ''
179baseline_relative '12 months ago' ''
180
181# Years - from git t0006 check_relative 630000000 = 20 years
182baseline_relative '1 year ago' ''
183baseline_relative '2 years ago' ''
184baseline_relative '10 years ago' ''
185baseline_relative '20 years ago' ''
45 186
46# Note that we can't necessarily put 64bit dates here yet as `git` on the system might not yet support it.187# 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
6};6};
7use gix_testtools::Result;7use gix_testtools::Result;
8use std::sync::LazyLock;8use std::sync::LazyLock;
9use std::time::Duration;
9 10
10struct Sample {11struct Sample {
11 format_name: Option<String>,12 format_name: Option<String>,
@@ -13,6 +14,21 @@ struct Sample { · 1 unmodified line
13 seconds: SecondsSinceUnixEpoch,14 seconds: SecondsSinceUnixEpoch,
14}15}
15 16
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.
21fn 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.
28fn fixed_now() -> SystemTime {
29 std::time::UNIX_EPOCH + Duration::from_secs(1_000_000_000)
30}
31
16static BASELINE: LazyLock<HashMap<String, Sample>> = LazyLock::new(|| {32static BASELINE: LazyLock<HashMap<String, Sample>> = LazyLock::new(|| {
17 (|| -> Result<_> {33 (|| -> Result<_> {
18 let base = gix_testtools::scripted_fixture_read_only("generate_git_date_baseline.sh")?;34 let base = gix_testtools::scripted_fixture_read_only("generate_git_date_baseline.sh")?;
@@ -53,7 +69,13 @@ fn parse_compare_format() { · 34 unmodified lines
53 },69 },
54 ) in BASELINE.iter()70 ) in BASELINE.iter()
55 {71 {
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);
57 assert_eq!(79 assert_eq!(
58 res.is_ok(),80 res.is_ok(),
59 *exit_code == 0,81 *exit_code == 0,
@@ -68,6 +90,7 @@ fn parse_compare_format() { · 8 unmodified lines
68 if let Some(format_name) = format_name {90 if let Some(format_name) = format_name {
69 let reformatted = t91 let reformatted = t
70 .format(match format_name.as_str() {92 .format(match format_name.as_str() {
93 "SHORT" => Format::Custom(format::SHORT),
71 "RFC2822" => Format::Custom(format::RFC2822),94 "RFC2822" => Format::Custom(format::RFC2822),
72 "ISO8601" => Format::Custom(format::ISO8601),95 "ISO8601" => Format::Custom(format::ISO8601),
73 "ISO8601_STRICT" => Format::Custom(format::ISO8601_STRICT),96 "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
3use gix_date::Time;3use gix_date::Time;
4 4
5#[test]5#[test]
6fn time_without_offset_is_not_parsed_yet() {6fn time_without_offset_defaults_to_utc() {
7 assert!(7 // Git parses datetime without offset and defaults to UTC (+0000)
8 gix_date::parse("1979-02-26 18:30:00", Some(SystemTime::now())).is_err(),8 let result = gix_date::parse("1979-02-26 18:30:00", Some(SystemTime::now()));
9 "This was a special time with special handling, but it is not anymore"9 assert!(result.is_ok(), "Git parses datetime without offset, defaulting to UTC");
10 );10 let time = result.unwrap();
11 assert_eq!(time.offset, 0, "Offset should default to UTC (+0000)");
11}12}
12 13
13#[test]14#[test]
14fn parse_header_is_not_too_lenient() {15fn parse_header_is_not_too_lenient() {
15 let now = SystemTime::now();
16 for not_a_header_str in ["2005-04-07T22:13:09", "2005-04-07 22:13:09"] {16 for not_a_header_str in ["2005-04-07T22:13:09", "2005-04-07 22:13:09"] {
17 assert!(17 assert!(
18 gix_date::parse_header(not_a_header_str).is_none(),18 gix_date::parse_header(not_a_header_str).is_none(),
19 "It's not timestamp-like, despite some leniency"19 "parse_header only accepts raw format (timestamp +offset), not ISO8601"
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"
24 );20 );
21 // Note: gix_date::parse() DOES accept these formats, matching Git's behavior
22 // Git parses them with default UTC offset
25 }23 }
26}24}
27 25
@@ -341,6 +339,227 @@ mod relative { · 313 unmodified lines
341 }339 }
342}340}
343 341
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.
346mod 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)
475mod 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
493mod 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)
546mod 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
344/// Various cases the fuzzer found563/// Various cases the fuzzer found
345mod fuzz {564mod fuzz {
346 #[test]565 #[test]