Now it should be mostly en-par with Git.
Unified · Split
| @@ -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 datetimes | 611 | /// This is just like `Zoned::strptime`, but it allows parsing datetimes |
| 286 | /// whose weekdays are inconsistent with the date. While the day-of-week | 612 | /// whose weekdays are inconsistent with the date. While the day-of-week |
| 287 | /// still must be parsed, it is otherwise ignored. This seems to be | 613 | /// still must be parsed, it is otherwise ignored. This seems to be |
| @@ -22,25 +22,166 @@ function baseline() { · 21 unmodified lines | |||
| 22 | } >> baseline.git | 22 | } >> baseline.git |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 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) | ||
| 26 | 31 | ||
| 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. | ||
| 28 | 55 | ||
| 29 | # short | 56 | # RFC2822 format: "Day, DD Mon YYYY HH:MM:SS +/-ZZZZ" |
| 30 | # ODO | ||
| 31 | #baseline '2022-08-22' | ||
| 32 | # rfc2822 | ||
| 33 | baseline 'Thu, 18 Aug 2022 12:45:06 +0800' 'RFC2822' | 57 | 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 | ||
| 35 | baseline '2022-08-17 22:04:58 +0200' 'ISO8601' | 68 | 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" | ||
| 37 | baseline '2022-08-17T21:43:13+08:00' 'ISO8601_STRICT' | 79 | 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" | ||
| 39 | baseline 'Thu Sep 04 2022 10:45:06 -0400' '' # cannot round-trip, incorrect day-of-week | 109 | baseline 'Thu Sep 04 2022 10:45:06 -0400' '' # cannot round-trip, incorrect day-of-week |
| 40 | baseline 'Sun Sep 04 2022 10:45:06 -0400' 'GITOXIDE' | 110 | 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. | ||
| 42 | baseline '1234567890' 'UNIX' | 117 | 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. | ||
| 44 | baseline '1660874655 +0800' 'RAW' | 125 | 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' '' | ||
| 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. |
Binary file not shown.
| @@ -6,6 +6,7 @@ use gix_date::{ · 5 unmodified lines | |||
| 6 | }; | 6 | }; |
| 7 | use gix_testtools::Result; | 7 | use gix_testtools::Result; |
| 8 | use std::sync::LazyLock; | 8 | use std::sync::LazyLock; |
| 9 | use std::time::Duration; | ||
| 9 | 10 | ||
| 10 | struct Sample { | 11 | struct 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. | ||
| 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 | |||
| 16 | static BASELINE: LazyLock<HashMap<String, Sample>> = LazyLock::new(|| { | 32 | static 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 = t | 91 | 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), |
| @@ -3,25 +3,23 @@ use std::time::SystemTime; · 2 unmodified lines | |||
| 3 | use gix_date::Time; | 3 | use gix_date::Time; |
| 4 | 4 | ||
| 5 | #[test] | 5 | #[test] |
| 6 | fn time_without_offset_is_not_parsed_yet() { | 6 | fn 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] |
| 14 | fn parse_header_is_not_too_lenient() { | 15 | fn 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. | ||
| 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 | |||
| 344 | /// Various cases the fuzzer found | 563 | /// Various cases the fuzzer found |
| 345 | mod fuzz { | 564 | mod fuzz { |
| 346 | #[test] | 565 | #[test] |