Now it should be mostly en-par with Git.
Unified · Split
| @@ -156,6 +156,9 @@ pub(crate) mod function { · 155 unmodified lines | ||
| 156 | 156 | Time::new(val.timestamp().as_second(), val.offset().seconds()) |
| 157 | 157 | } else if let Ok(val) = strptime_relaxed(ISO8601_STRICT.0, input) { |
| 158 | 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 | 162 | } else if let Ok(val) = strptime_relaxed(GITOXIDE.0, input) { |
| 160 | 163 | Time::new(val.timestamp().as_second(), val.offset().seconds()) |
| 161 | 164 | } else if let Ok(val) = strptime_relaxed(DEFAULT.0, input) { |
| @@ -282,6 +285,329 @@ pub(crate) mod function { · 120 unmodified lines | ||
| 282 | 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 | 611 | /// This is just like `Zoned::strptime`, but it allows parsing datetimes |
| 286 | 612 | /// whose weekdays are inconsistent with the date. While the day-of-week |
| 287 | 613 | /// still must be parsed, it is otherwise ignored. This seems to be |
| @@ -22,25 +22,166 @@ function baseline() { · 21 unmodified lines | ||
| 22 | 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 | |
| 30 | −# ODO | |
| 31 | −#baseline '2022-08-22' | |
| 32 | −# rfc2822 | |
| 56 | +# RFC2822 format: "Day, DD Mon YYYY HH:MM:SS +/-ZZZZ" | |
| 33 | 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 | 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 | 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 | 109 | baseline 'Thu Sep 04 2022 10:45:06 -0400' '' # cannot round-trip, incorrect day-of-week |
| 40 | 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 | 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 | 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 | 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 | 7 | use gix_testtools::Result; |
| 8 | 8 | use std::sync::LazyLock; |
| 9 | +use std::time::Duration; | |
| 9 | 10 | |
| 10 | 11 | struct Sample { |
| 11 | 12 | format_name: Option<String>, |
| @@ -13,6 +14,21 @@ struct Sample { · 1 unmodified line | ||
| 13 | 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 | 32 | static BASELINE: LazyLock<HashMap<String, Sample>> = LazyLock::new(|| { |
| 17 | 33 | (|| -> Result<_> { |
| 18 | 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 | 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 | 79 | assert_eq!( |
| 58 | 80 | res.is_ok(), |
| 59 | 81 | *exit_code == 0, |
| @@ -68,6 +90,7 @@ fn parse_compare_format() { · 8 unmodified lines | ||
| 68 | 90 | if let Some(format_name) = format_name { |
| 69 | 91 | let reformatted = t |
| 70 | 92 | .format(match format_name.as_str() { |
| 93 | + "SHORT" => Format::Custom(format::SHORT), | |
| 71 | 94 | "RFC2822" => Format::Custom(format::RFC2822), |
| 72 | 95 | "ISO8601" => Format::Custom(format::ISO8601), |
| 73 | 96 | "ISO8601_STRICT" => Format::Custom(format::ISO8601_STRICT), |
| @@ -3,25 +3,23 @@ use std::time::SystemTime; · 2 unmodified lines | ||
| 3 | 3 | use gix_date::Time; |
| 4 | 4 | |
| 5 | 5 | #[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)"); | |
| 11 | 12 | } |
| 12 | 13 | |
| 13 | 14 | #[test] |
| 14 | 15 | fn parse_header_is_not_too_lenient() { |
| 15 | − let now = SystemTime::now(); | |
| 16 | 16 | for not_a_header_str in ["2005-04-07T22:13:09", "2005-04-07 22:13:09"] { |
| 17 | 17 | assert!( |
| 18 | 18 | 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" | |
| 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 | 563 | /// Various cases the fuzzer found |
| 345 | 564 | mod fuzz { |
| 346 | 565 | #[test] |