fix(offset): treat a numeric offset after a time of day as a zone correction - #326
Socialpranker wants to merge 1 commit into
Conversation
`2026-08-27 12:00 +3 hours` was read as a relative shift of three hours. GNU date reads the `+3` as the zone `+03:00` and the bare `hours` as one hour, giving 10:00 UTC instead of 15:00. The offset parser backtracked whenever a relative item followed, on the assumption that GNU prefers the relative reading. It does not: a signed integer directly after a time of day always fills the zone slot. Drop that guard and keep only the narrow case where it is really a relative item -- a fractional number such as `+1.5 seconds`, which cannot be a zone correction.
Merging this PR will improve performance by 23.41%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | parse_iso_datetime_t_separator |
73.9 µs | 53.4 µs | +38.5% |
| ⚡ | parse_iso_datetime |
76.1 µs | 55.8 µs | +36.23% |
| ⚡ | parse_datetime_ending_in_z |
135.9 µs | 106.1 µs | +28.14% |
| ⚡ | parse_datetime_with_tz_name |
138.6 µs | 108.2 µs | +28.12% |
| ⚡ | parse_datetime_with_delta |
104.7 µs | 88.6 µs | +18.09% |
| ⚡ | parse_timezone_offset |
101.1 µs | 87.7 µs | +15.19% |
| ⚡ | parse_relative_time_complex |
156.8 µs | 137.1 µs | +14.37% |
| ⚡ | parse_ctime_format |
154.6 µs | 138.5 µs | +11.61% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing Socialpranker:fix-numeric-offset-before-relative (eb12121) with main (618dda7)
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #326 +/- ##
==========================================
+ Coverage 99.34% 99.42% +0.07%
==========================================
Files 21 21
Lines 4123 4161 +38
Branches 136 136
==========================================
+ Hits 4096 4137 +41
+ Misses 26 23 -3
Partials 1 1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
|
||
| // `+30` is not a valid zone correction, and GNU rejects the whole | ||
| // input rather than falling back to a relative item. | ||
| assert!(parse(&mut "2026-08-27 12:00 +30 hours").is_err()); |
There was a problem hiding this comment.
out of range is a hard error now, so please check what date -d '12:00 +30 hours' prints, it should still look like gnu's invalid date
| // | ||
| // Return early if the input can be parsed as a relative time. | ||
| if peek(relative::parse).parse_next(input).is_ok() { | ||
| // A number with a fractional part is never a zone correction: GNU `date` |
There was a problem hiding this comment.
4 lines of comment for this guard, could you please make it one or two?
| one_of(AsChar::is_dec_digit), | ||
| )) | ||
| .parse_next(input); | ||
| if fraction.is_ok() { |
There was a problem hiding this comment.
fraction for a Result reads odd, has_fraction and inline the peek, no?
2026-08-27 12:00 +3 hoursis parsed as a relative shift of three hours. GNUdateparses the+3as the time zone correction+03:00and the barehoursas a relative item with an implicit count of one:+3alone already parsed as a zone (12:00 +3→ 09:00), so the reading of thenumber flipped depending on whether a unit word happened to follow it.
The rule
Both halves are documented in the GNU manual. From "Time zone items in date
strings": a time of day may be followed by a time zone correction, e.g.
+0530,-05:30, and+3is such a correction. From "Relative items in datestrings": "The unit of time displacement may be selected by the string 'year'
… A unit of time with no number means 1." So
+3 hoursafter a time of day isthe zone
+03:00plus one hour: 12:00+03:00 = 09:00 UTC, +1h = 10:00 UTC.The zone slot is filled only once and only by a time of day, which is why the
cases below stay relative in GNU, and stayed correct here:
The change
timezone_offsetbacktracked whenever the remaining input parsed as a relativeitem, with a comment stating that GNU prefers the relative reading for
+8 years. It does not — the transcripts above show the opposite. The guard isdropped.
One narrow case really is a relative item and keeps a guard: a number with a
fractional part cannot be a zone correction, and GNU reads it as a relative
item.
That is why the new check tests for a fraction specifically rather than for a
relative item, and why it runs before the offset is parsed and range-checked:
+25.5 secondsis a valid relative item even though+25is not a valid zone.An out-of-range correction is now a hard error rather than a silent fallback to
a relative reading, which is what GNU does:
The existing
"+23 days"case intimezone_offset_without_colonasserted theold behavior and is replaced by
"+25 days", which is still rejected, nowbecause 25 is not a valid hour offset.
How the GNU behavior was established
By running the installed GNU binary (coreutils 9.11) as a black box and
recording its output, plus the two manual sections quoted above. I did not read
GNU coreutils source.
Testing
cargo test: 418 passed, 0 failed (417 pre-existing, plus the newnumeric_offset_before_relative_item). Two tests,floating_seconds_offsetand
floating_seconds_offset_spaceless(0 + 0.0 seconds), fail without thefraction guard — they are the mutation check for that half of the change and
needed no modification.
cargo fmt --all --checkandcargo clippy --all-targets -- -D warnings:clean.
× 20 numeric prefixes (
+3,-3,+0300,+05:30,+1.5,+25, bare, …)× relative units ×
ago. 400 cases moved from differing to identical, 0regressions.
The 162 cases that still differ are pre-existing and unrelated: fractional
seconds round instead of truncating (
-0.5 secs→11:59:58vs GNU11:59:59), and<zone> +N <unit> agois accepted here while GNU rejects it.Disclosure
Prepared with AI assistance (Claude Opus 5, via Claude Code), per the uutils AI
policy. Every GNU behavior quoted above came from running the installed binary
and reading the GNU manual, not from reading GPL source. All testing was run
locally.