tztr 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/CLAUDE.md +86 -0
- data/Gemfile.lock +10 -4
- data/Makefile +65 -0
- data/README.md +298 -24
- data/bin/tztr +222 -26
- data/lib/tztr/version.rb +1 -1
- data/lib/tztr.rb +705 -54
- data/tztr.gemspec +8 -2
- metadata +32 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d285193d8b72f78de45ce70067ab31b21b41dd51845ee7cf3a875a2d7b896f0
|
|
4
|
+
data.tar.gz: 4c2780b1e58dc0d565aef9921d7a34159de1ecb84264f757baa8c01e51635496
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b275cd983a772305f7982de46e6877f39375c1793f905ed0f36315cc41f9bc288bb09c0f98c37176076a13453792402753acd677959cc92d0d7085c3575a985
|
|
7
|
+
data.tar.gz: c6e2c34828da29253654d7f550b6c024a43be89eb3e437ae35765e55f2d89841f82ac985bad1a623a88414cdad65b069e2878101052537bb9093ac5037b0da0b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,2 +1,65 @@
|
|
|
1
|
+
### 0.2.1 (2026-09-26)
|
|
2
|
+
|
|
3
|
+
#### Breaking
|
|
4
|
+
|
|
5
|
+
- A timestamp with no zone, with no `-f` and `$TZ` unset (the macOS default), is read in the system zone (`/etc/localtime`) and converted. It used to be taken to be in the output zone already and only relabelled: `12:00 | tztr -t utc` printed `12:00 UTC` on a Mac set to Pacific time; it now prints `19:00 UTC`. Set `-f` to choose another source zone.
|
|
6
|
+
|
|
7
|
+
#### Fixed
|
|
8
|
+
|
|
9
|
+
- The Ruby gem is about 4x faster than 0.2.0 on a mixed log, and on par with or faster than 0.1.0: lines without a timestamp are skipped before the scan, zone lookups are cached, and a time without a zone no longer switches `$TZ` and back to convert.
|
|
10
|
+
|
|
11
|
+
### 0.2.0 (2026-09-26)
|
|
12
|
+
|
|
13
|
+
0.1.0 reached users twice: the gem in April, then the Rust crate and Homebrew
|
|
14
|
+
formula in June, which already had `-j`, `-J`, `--detect`, `-d` and `-v`/`-V`.
|
|
15
|
+
Entries marked *(gem)* are new only to gem users.
|
|
16
|
+
|
|
17
|
+
#### Breaking
|
|
18
|
+
|
|
19
|
+
Before upgrading, check any script that passes `-t gmt`, a numeric offset,
|
|
20
|
+
`-F`, `-d` or `-v`, or that relies on an unknown timezone falling back to UTC.
|
|
21
|
+
Also check logs with zone abbreviations tztr used to ignore (`CET`, `BST`,
|
|
22
|
+
`JST`), and runs where `TZ` is set to a zone outside the US.
|
|
23
|
+
|
|
24
|
+
- `-t gmt` now means UTC. It used to follow British Summer Time; use `-t london` for civil UK time.
|
|
25
|
+
- A timezone that can't be resolved is an error (exit 1) instead of silently becoming UTC. That includes `TZ` itself: the POSIX spelling `TZ=:America/New_York` used to make every conversion come out in UTC.
|
|
26
|
+
- Numeric offsets are whole hours in `-12..14`. Sub-hour offsets (`-t +5:30`) are refused rather than silently mis-signed; reach a half-hour zone by name (`-t ist`, `-t Asia/Kolkata`).
|
|
27
|
+
- Zone abbreviations inside text convert as the fixed offset they name. `JST`, `CET`, `BST`, `IST`, `AEST` and the rest used to be ignored, so the time was read as if it were in the source zone. Only the generic `ET`, `CT`, `MT` and `PT` follow DST, so `CEST` is +02:00 even in January. `CET` is +01:00 even in summer, which means a log that writes `CET` year-round for Berlin time converts an hour off from Berlin's clock in summer.
|
|
28
|
+
- An abbreviation your source zone (`-f`, else `$TZ`) uses itself is read in that zone's sense. With `TZ=Asia/Shanghai`, `CST` is China Standard Time (+08:00), not US Central. `PST` in Manila and `IST` in Dublin or Jerusalem change the same way. With `TZ` unset, UTC or anywhere in the US, nothing changes.
|
|
29
|
+
- `-F short` always labels the zone. It used to drop the label whenever the output zone matched `$TZ`, which is every run without `-t`. *(gem)* `-F short` and `-F iso` used to carry no zone or offset at all; they now end in `PDT` and `-07:00`.
|
|
30
|
+
- `-d` accepts only `2026-01-15`, `2026/01/15`, `20260115`, `January 15, 2026`, `Jan 15 2026` and `15 January 2026`, with two-digit months and days and real calendar dates. Anything else is an error. The gem used to take whatever Ruby's `Date.parse` would (`Sept 15`, `2026-1-5`, a date with no year).
|
|
31
|
+
- *(gem)* `-v` is `--verbose`; the version is `-V/--version`.
|
|
32
|
+
- Library: `resolve_tz` raises `Tztr::Error` (Ruby) / returns `Result<String, TzError>` (Rust), and `translate`/`matches` no longer take a `local` parameter.
|
|
33
|
+
|
|
34
|
+
#### Added
|
|
35
|
+
|
|
36
|
+
- `tztr now` prints the current time, as ISO 8601 in `-t`, else `$TZ`, else UTC. Every flag except `-i` works with it.
|
|
37
|
+
- `-v` says what it assumed: the source zone it borrowed from `$TZ`, the date it assumed for DST when there's no `-d`, and any zone abbreviation it passed over for its case (`Pst`) or didn't know (`EEST`).
|
|
38
|
+
- `-j`/`-J` add `group: {type, members}` to a timestamp in a range or list. *(gem)* `-j/--json`, `-J/--ndjson`, `--detect` and `-d/--date` are new.
|
|
39
|
+
- Ranges and lists share the zone and AM/PM written at their end, and a date written at their start: `from 3:30 to 4:45 PM PST` and `3:00, 4:00 or 5:00 PM PST` convert every member. A range is joined by `-`, `–`, `—`, `to`, `until`, `till`, `through` or `thru`; a list by commas, `or` and `and`.
|
|
40
|
+
- More formats, each converted date and all: nginx/Apache access logs (`[15/Jan/2015:12:31:01 -0700]`), slashed dates (`2026/09/25 23:14:42`, Go's log package and nginx's error log), `ls -lT` (`Sep 25 23:40:39 2026`), and glibc's locale form of `date` (`Fri 25 Sep 2026 10:14:42 PM PDT`).
|
|
41
|
+
- A dated clock with seconds takes a numeric offset, glued or spaced: `2026-09-25 22:14:42-07:00` (Python, `date --rfc-3339`), `2026-04-03 09:00:00-07` (Postgres). ISO 8601's comma fraction (`…T22:14:42,123456789-07:00`) is read too.
|
|
42
|
+
- 12-hour forms: `3:45 p.m.`, `11:30 A.M.`, hours alone (`9am`, `9 PM PST`), and a no-break space before AM/PM, as Chrome and macOS write it.
|
|
43
|
+
- Lowercase zone abbreviations (`15:30 pst`), except `est`, `cet`, `et`, `ist`, `ut` and `z`, which are ordinary words that can follow a time.
|
|
44
|
+
- A time followed by a unit of time is left alone as a duration: RSpec's `Finished in 1:05 minutes`.
|
|
45
|
+
|
|
46
|
+
#### Fixed
|
|
47
|
+
|
|
48
|
+
- A dated timestamp without seconds keeps its date. `2026-01-15 23:30` used to be read as a time alone, resolved against today, an hour off in winter; `2026-12-31T23:30+05:30` came out garbled and `2026-12-31T23:30Z` was ignored.
|
|
49
|
+
- `date` output and RFC 2822 dates convert as one timestamp. `date | tztr -t est` used to convert only the clock, so `Fri Sep 25 22:14:42 PDT 2026` came out as `Fri Sep 25 01:14:42 EDT 2026`, a day early; it is now `Sat Sep 26 01:14:42 EDT 2026`. A `date` line with a zone tztr can't resolve (`WIB`) is left as written.
|
|
50
|
+
- A 12-hour time is read in its source zone. AM/PM used to be taken for a zone name, so `11:30 PM` was assumed to be in the output zone already and the `PST` of `3:45 PM PST` was ignored.
|
|
51
|
+
- Every timestamp on a line converts, whatever its format. Only the first format found used to convert, so in `{"ts":"2026-04-03T12:00:00Z","msg":"at 15:30 UTC"}` the `15:30 UTC` was left alone with no warning. A bare time with no date, zone or AM/PM is still left alone when another timestamp on its line has one, as the `0:05` of `2026-04-03T12:00:00Z took 0:05` is most likely a duration.
|
|
52
|
+
- In a range or list with no date, a member earlier on the clock than the one before it is on the next day: `11:30 PM to 12:30 AM` ends tomorrow.
|
|
53
|
+
- A numeric offset glued to a time must follow seconds (`12:34:56-05:00`), and every offset must be within -12..+14. `15:30-16:45 PST` used to be read as 15:30 at an offset of −16:45.
|
|
54
|
+
- Log levels stay in the line: `15:30 INFO server started` keeps its `INFO`.
|
|
55
|
+
- Fractional seconds keep every digit (Docker's nanoseconds were cut to milliseconds), and Python logging's `12:00:00,123` keeps its comma milliseconds.
|
|
56
|
+
- IPv6 addresses (`fe80::1:23:45`) and SMPTE timecodes (`01:02:03:04`) are left alone instead of having a "time" inside them converted.
|
|
57
|
+
- An ambiguous wall clock in a repeated fall-back hour resolves to the earlier (daylight) occurrence, matching `date(1)`, Temporal, RFC 5545 and ICU.
|
|
58
|
+
- `24:00` and a leap-second `23:59:60` normalize; impossible calendar dates (`2026-02-30`) are left exactly as found instead of being rewritten to a different day.
|
|
59
|
+
- A file that can't be read no longer stops a multi-file run. It is reported, every other file is still processed, and the exit status is 1, as with `cat` and `sed -i`. With `-i` it used to leave the job half-done: files before the bad one rewritten, files after it untouched.
|
|
60
|
+
- Errors are one line, with no backtrace, and an error about a file names it.
|
|
61
|
+
- Lines that aren't valid UTF-8 keep their bytes, in every output mode including `-i`.
|
|
62
|
+
- Rust build: the POSIX `--` terminator works, and `tztr -h` and `tztr -l` match the gem byte for byte.
|
|
63
|
+
|
|
1
64
|
### 0.1.0 (2026-04-19)
|
|
2
65
|
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project
|
|
6
|
+
|
|
7
|
+
`tztr` is a small CLI + library that translates timestamps between timezones. It auto-detects timestamp formats in arbitrary text, converts them, and preserves the surrounding text and original format by default.
|
|
8
|
+
|
|
9
|
+
- Library entry point: `lib/tztr.rb` (single file, `Tztr` module)
|
|
10
|
+
- Executable: `bin/tztr` (uses OptionParser, streams stdin/files line-by-line)
|
|
11
|
+
- Required Ruby: `>= 3.2`. CI runs against 3.3, 3.4, and 4.0.
|
|
12
|
+
|
|
13
|
+
## Dual implementation — Ruby (reference) + Rust (port)
|
|
14
|
+
|
|
15
|
+
This repo ships **two implementations kept functionally identical**: the Ruby
|
|
16
|
+
gem at the root (`lib/`, `bin/`) and a Rust crate under `rust/` (`cargo install
|
|
17
|
+
tztr`; Homebrew installs the Rust binary). Ruby is the **reference**; Rust
|
|
18
|
+
mirrors it.
|
|
19
|
+
|
|
20
|
+
**Parity is the contract.** Any behavior change must land in *both* and keep the
|
|
21
|
+
CLI outputs identical:
|
|
22
|
+
1. Change Ruby (`lib/tztr.rb` / `bin/tztr`), add/adjust specs, `bundle exec rspec`.
|
|
23
|
+
2. Port the change to Rust under `rust/tztr/`, add/adjust tests, `make check`
|
|
24
|
+
(`cargo fmt --check` + clippy `-D warnings` + `cargo test`).
|
|
25
|
+
3. `make parity` (or `ruby script/parity.rb`) — the Ruby ↔ Rust CLI parity
|
|
26
|
+
harness diffs both binaries across a matrix of inputs/args/`TZ`; JSON modes
|
|
27
|
+
compared semantically, everything else byte-for-byte. Must be 100%.
|
|
28
|
+
|
|
29
|
+
CI (`.github/workflows/rust.yml`) runs the Rust gate + parity; `make hooks`
|
|
30
|
+
installs a pre-push hook that runs rspec + `make check` + `make parity`.
|
|
31
|
+
Timezone math in Rust uses `jiff` (system tzdb, same source as Ruby's `Time`),
|
|
32
|
+
so DST matches. Watch the replicated `Time.parse` quirks documented in
|
|
33
|
+
`rust/REPORT.md` (unrecognized abbreviations ignored; `from` bypassed when an
|
|
34
|
+
embedded zone is present).
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bundle install # install deps
|
|
40
|
+
bundle exec rspec # run all tests
|
|
41
|
+
bundle exec rspec spec/tztr_spec.rb:42 # run a single test by line number
|
|
42
|
+
bin/tztr ... # run the CLI from a working copy (no install needed)
|
|
43
|
+
gem build tztr.gemspec # build the gem
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
There is no Rubocop / linter configured — only RSpec + SimpleCov. `--require spec_helper` is set in `.rspec`, so specs don't need to require it explicitly.
|
|
47
|
+
|
|
48
|
+
## Architecture notes
|
|
49
|
+
|
|
50
|
+
A few things that aren't obvious from a quick read:
|
|
51
|
+
|
|
52
|
+
**`Tztr.translate` mutates `ENV['TZ']`.** Both `translate` and `parse` set `ENV['TZ']` as a side effect to coerce Ruby's `Time` parsing into the right zone. The spec helper resets `ENV['TZ'] = 'UTC'` in a `before(:each)` to keep tests isolated — anything new that exercises parsing should rely on that, or save/restore `TZ` itself.
|
|
53
|
+
|
|
54
|
+
**Pattern order is priority, not exclusivity.** `PATTERNS` in `lib/tztr.rb` is joined into one alternation (`TIMESTAMP`, and `timestamp()` in Rust) and scanned in a single pass, so every timestamp on a line converts whatever its format. At each position the alternatives are tried top-to-bottom, so more specific patterns (ISO with timezone) must come before less specific ones (bare time) or a shorter format will match inside a longer one. A bare time (`BARE_TIME`) beside a timestamp that names its date or zone is left alone as a probable duration (`Tztr.timestamps`); only a line of nothing more specific converts its bare times. Before that, each member of a range or list (`3:30 to 4:45 PM PST`, `3:00, 4:00 or 5:00 PM`; `RANGE_JOIN` / `LIST_JOIN`) takes the zone and meridiem after it, and `-j` reports the members as `group`. A bare hour counts only as the start of a range (`RANGE_HOUR`, the 9 of `9-9:15am`). A numeric offset glued to a time needs seconds, so `15:30-16:45` is a range. When adding a new format, place it carefully and add tests covering lines that mix formats.
|
|
55
|
+
|
|
56
|
+
**A match's text and its reading differ.** Each match is a `Stamp` with the `text` it replaces and the `effective` reading it is parsed as (`Tztr.reading`): a named-month date (date(1), RFC 2822, CLF) normalized to `YYYY-MM-DD`, `9am` to `9:00am`, a slashed date dashed, a comma fraction dotted, plus whatever zone, meridiem or date it shares with its range. Parse the reading; format from the text, so the output keeps the input's shape.
|
|
57
|
+
|
|
58
|
+
**Zones inside text are fixed offsets; zones given to `-f`/`-t` are IANA.** `CEST` in a line is always +02:00 (`ZONE_OFFSETS`), but `-t est` is New York with DST. Only the generic `ET`/`CT`/`MT`/`PT` follow DST inside text. An abbreviation the source zone itself uses this year wins over the table (`local_abbreviations`): `CST` under `TZ=Asia/Shanghai` is +08:00; under any zone that doesn't use it, the US-centric table stands. A dateless timestamp's date is today *where it was written* (`today_where` in Ruby, `anchor` in Rust), never Time.parse's own guess.
|
|
59
|
+
|
|
60
|
+
**Timezone resolution has three layers** (`Tztr.resolve_tz`):
|
|
61
|
+
1. Numeric offset string (e.g. `"-7"`) → `Etc/GMT±N` — note the POSIX sign inversion (`-7` becomes `Etc/GMT+7`).
|
|
62
|
+
2. Lowercased + underscored lookup in `TIMEZONE_ALIASES` (covers tz abbreviations like `pst`, plus city nicknames like `sf`, `nyc`).
|
|
63
|
+
3. Validated against the system tzdb as an IANA name like `America/Los_Angeles` — an unknown name raises `Tztr::Error` rather than passing through.
|
|
64
|
+
|
|
65
|
+
**Output format preservation** (`format_time`) inspects the *original* matched substring and rebuilds the output to mirror it (ISO `T`, space-separated, time-only, with/without fractional seconds). Explicit `--format iso|short|time` short-circuits this.
|
|
66
|
+
|
|
67
|
+
**CLI streaming.** `bin/tztr` sets `$stdout.sync = true` and processes input line-by-line so it works with `tail -f`. `-i/--in-place` reads, translates, and writes back only if content changed.
|
|
68
|
+
|
|
69
|
+
**Structured output is agent-facing.** `-j/--json` (array) and `-J/--ndjson` (one object per line, streaming-friendly) emit `{original, detected_format, detected_tz, translated}` per match, backed by `Tztr.matches`. `--detect` reports format/zone only (omits `translated`). **Directive: every new CLI option must work in `-j`/`-J` modes** — when adding a flag, make sure it composes with structured output (e.g. `-F` shapes the `translated` field) and add a spec covering it. `-i` is the one exception: it's mutually exclusive with `-j`/`-J`/`--detect` and aborts.
|
|
70
|
+
|
|
71
|
+
## Testing beyond the suites
|
|
72
|
+
|
|
73
|
+
The parity harness only proves the two builds agree; several rounds of bugs were ones both builds shared. `script/parity.rb` has a `golden` group of absolute expectations for exactly those. Add to it whenever a bug is found in both builds at once.
|
|
74
|
+
|
|
75
|
+
User-testing rounds (logs and reports archived locally under `research/`, which is gitignored) found the most with these kinds of input. Reuse them for any matching change:
|
|
76
|
+
|
|
77
|
+
- **Real machine logs, read-only**: macOS `/var/log/*.log`, `log show --style syslog|default`, `ls -lT`, `git log` (`--format=fuller`, `--date=iso|rfc`), real `date`, `date -u`, `date -R` and `TZ=<far zone> date` output, and Rails `log/*.log` and nginx access/error logs found in local checkouts.
|
|
78
|
+
- **Formats from memory, where no real sample exists**: Lograge, ActiveJob, Sidekiq JSON/text, RSpec output (`Finished in 1:05 minutes`), crontab lines and cron syslog, RFC 5424/journald, Postgres, Docker/k8s RFC3339Nano, GitHub Actions, Go's `log` package, Python `logging` (comma milliseconds), log4j, multi-field JSON/NDJSON.
|
|
79
|
+
- **Prose**: meeting invites, standup notes and on-call handoffs with ranges and lists written every way people write them (`9-9:15am`, `3:00, 4:00 or 5:00 PM`, `11:30 PM to 12:30 AM`), deadlines next to dates (`Apr 3 - 5pm`), and French and German text (`à 15:30 est annulée`).
|
|
80
|
+
- **Things that aren't timestamps**: durations, line numbers (`foo.rb:42:10`), `ip:port`, versions, ratios, scores, cron fields, ticket numbers (`#12 - 9:30am`).
|
|
81
|
+
- **Adversarial input**: Latin-1 and invalid UTF-8 bytes beside a match, fullwidth and Arabic-Indic digits, glued offsets (`15:30-1645`), impossible clocks (`99:14`), and 100k+ character lines, timed to catch quadratic paths.
|
|
82
|
+
- **Date boundaries**: midnight, month, leap-day and year ends toward far zones (`Pacific/Kiritimati` +14, `Pacific/Pago_Pago` −11, `Asia/Kathmandu` +5:45), DST transition days in the US, UK and Australia, and anything that depends on today's date (run with `-t 14` and `-t -12`).
|
|
83
|
+
|
|
84
|
+
## Release / distribution
|
|
85
|
+
|
|
86
|
+
Distributed via RubyGems (`gem install tztr`) and Homebrew (`brew install dpep/tools/tztr`). Version lives in `lib/tztr/version.rb`. Dependabot auto-approves and auto-merges minor/patch dependency PRs (see `.github/workflows/dependabot.yml`).
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
tztr (0.1
|
|
4
|
+
tztr (0.2.1)
|
|
5
|
+
optparse (>= 0.5)
|
|
5
6
|
|
|
6
7
|
GEM
|
|
7
8
|
remote: https://rubygems.org/
|
|
@@ -12,13 +13,15 @@ GEM
|
|
|
12
13
|
reline (>= 0.3.8)
|
|
13
14
|
diff-lcs (1.6.2)
|
|
14
15
|
docile (1.4.1)
|
|
15
|
-
erb (6.0.
|
|
16
|
+
erb (6.0.4)
|
|
17
|
+
ice_age (0.2.0)
|
|
16
18
|
io-console (0.8.2)
|
|
17
19
|
irb (1.17.0)
|
|
18
20
|
pp (>= 0.6.0)
|
|
19
21
|
prism (>= 1.3.0)
|
|
20
22
|
rdoc (>= 4.0.0)
|
|
21
23
|
reline (>= 0.4.2)
|
|
24
|
+
optparse (0.8.1)
|
|
22
25
|
pp (0.6.3)
|
|
23
26
|
prettyprint
|
|
24
27
|
prettyprint (0.2.0)
|
|
@@ -62,6 +65,7 @@ PLATFORMS
|
|
|
62
65
|
|
|
63
66
|
DEPENDENCIES
|
|
64
67
|
debug (>= 1)
|
|
68
|
+
ice_age (>= 0.2)
|
|
65
69
|
rspec (>= 3.10)
|
|
66
70
|
rspec-debugging
|
|
67
71
|
simplecov (>= 0.22)
|
|
@@ -72,9 +76,11 @@ CHECKSUMS
|
|
|
72
76
|
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
|
|
73
77
|
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
|
|
74
78
|
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
|
|
75
|
-
erb (6.0.
|
|
79
|
+
erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9
|
|
80
|
+
ice_age (0.2.0) sha256=2cfd333ac6791d2e8abaf3060afd0c88c57868beca40d0d9579b7c5534bbbb73
|
|
76
81
|
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
|
77
82
|
irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
|
|
83
|
+
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
|
78
84
|
pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
|
|
79
85
|
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
|
|
80
86
|
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
@@ -92,7 +98,7 @@ CHECKSUMS
|
|
|
92
98
|
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
|
|
93
99
|
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
|
|
94
100
|
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
|
95
|
-
tztr (0.1
|
|
101
|
+
tztr (0.2.1)
|
|
96
102
|
|
|
97
103
|
BUNDLED WITH
|
|
98
104
|
4.0.9
|
data/Makefile
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# tztr — dual implementation (Ruby reference + Rust port).
|
|
2
|
+
#
|
|
3
|
+
# make build - release build of the Rust CLI into rust/target/release
|
|
4
|
+
# make install - cargo install the Rust CLI into ~/.cargo/bin
|
|
5
|
+
# make test - run both test suites (rspec + cargo test)
|
|
6
|
+
# make check - Rust gate: cargo fmt --check + clippy + test
|
|
7
|
+
# make fmt - cargo fmt the Rust crate
|
|
8
|
+
# make parity - Ruby <-> Rust CLI parity harness (builds Rust first)
|
|
9
|
+
# make hooks - enable the committed git hooks (pre-push runs parity+check)
|
|
10
|
+
#
|
|
11
|
+
# Ruby is the reference implementation; Rust mirrors it. See CLAUDE.md for the
|
|
12
|
+
# parity contract.
|
|
13
|
+
|
|
14
|
+
CARGO ?= cargo
|
|
15
|
+
RUST_DIR := rust
|
|
16
|
+
|
|
17
|
+
.DEFAULT_GOAL := help
|
|
18
|
+
.PHONY: help build install test check fmt parity hooks
|
|
19
|
+
|
|
20
|
+
help:
|
|
21
|
+
@echo "tztr targets:"
|
|
22
|
+
@echo " make build release build of the Rust CLI"
|
|
23
|
+
@echo " make install cargo install the Rust CLI"
|
|
24
|
+
@echo " make test rspec + cargo test"
|
|
25
|
+
@echo " make check Rust gate: fmt --check + clippy + test"
|
|
26
|
+
@echo " make fmt cargo fmt the Rust crate"
|
|
27
|
+
@echo " make parity Ruby <-> Rust CLI parity harness"
|
|
28
|
+
@echo " make hooks enable committed git hooks (.githooks)"
|
|
29
|
+
|
|
30
|
+
build:
|
|
31
|
+
$(CARGO) build --release --manifest-path $(RUST_DIR)/Cargo.toml
|
|
32
|
+
|
|
33
|
+
install:
|
|
34
|
+
$(CARGO) install --path $(RUST_DIR)/tztr
|
|
35
|
+
|
|
36
|
+
test:
|
|
37
|
+
bundle exec rspec
|
|
38
|
+
$(CARGO) test --manifest-path $(RUST_DIR)/Cargo.toml
|
|
39
|
+
|
|
40
|
+
# The Rust gate — mirrors CI. Run before merging/pushing.
|
|
41
|
+
check:
|
|
42
|
+
cd $(RUST_DIR) && $(CARGO) fmt --check
|
|
43
|
+
cd $(RUST_DIR) && $(CARGO) clippy --workspace --all-targets -- -D warnings
|
|
44
|
+
cd $(RUST_DIR) && $(CARGO) test --workspace
|
|
45
|
+
|
|
46
|
+
fmt:
|
|
47
|
+
cd $(RUST_DIR) && $(CARGO) fmt
|
|
48
|
+
|
|
49
|
+
# A bare `ruby` here is macOS system Ruby 2.6 whenever rbenv's shims are off
|
|
50
|
+
# PATH and `rbenv global` is unset, and the harness then reports every case as
|
|
51
|
+
# failing for reasons unrelated to parity. Pick the first interpreter that
|
|
52
|
+
# satisfies the gemspec instead; override with `make parity RUBY=/path/to/ruby`.
|
|
53
|
+
RUBY_CANDIDATES := ruby $(shell rbenv root 2>/dev/null)/versions/*/bin/ruby /opt/homebrew/opt/ruby/bin/ruby
|
|
54
|
+
RUBY ?= $(shell for r in $(RUBY_CANDIDATES); do \
|
|
55
|
+
"$$r" -e 'exit Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2")' 2>/dev/null \
|
|
56
|
+
&& echo "$$r" && break; \
|
|
57
|
+
done)
|
|
58
|
+
|
|
59
|
+
parity: build
|
|
60
|
+
@test -n "$(RUBY)" || { echo "make parity: no Ruby >= 3.2 found (tried: $(RUBY_CANDIDATES))"; exit 1; }
|
|
61
|
+
$(RUBY) script/parity.rb
|
|
62
|
+
|
|
63
|
+
hooks:
|
|
64
|
+
git config core.hooksPath .githooks
|
|
65
|
+
@echo "git hooks enabled (.githooks) — pre-push now runs parity + check"
|
data/README.md
CHANGED
|
@@ -3,17 +3,24 @@ tztr
|
|
|
3
3
|

|
|
4
4
|
[](https://codecov.io/gh/dpep/tztr)
|
|
5
5
|
|
|
6
|
-
Timezone Translator
|
|
6
|
+
Timezone Translator: find the timestamps in any text and convert them to
|
|
7
|
+
another timezone, leaving everything else alone.
|
|
7
8
|
|
|
8
|
-
Reads from stdin or
|
|
9
|
+
Reads from stdin, files, or the clock (`tztr now`), auto-detects timestamp
|
|
10
|
+
formats, and preserves each one's original format by default.
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
## Install
|
|
12
14
|
|
|
13
15
|
```bash
|
|
14
|
-
|
|
16
|
+
brew install dpep/tools/tztr # the Rust binary
|
|
17
|
+
gem install tztr # Ruby
|
|
18
|
+
cargo install tztr # Rust, from source
|
|
15
19
|
```
|
|
16
20
|
|
|
21
|
+
The gem and the crate are two implementations of the same tool, kept
|
|
22
|
+
functionally identical. Same CLI, same output.
|
|
23
|
+
|
|
17
24
|
|
|
18
25
|
## Usage
|
|
19
26
|
|
|
@@ -22,24 +29,60 @@ echo '2026-04-03T12:00:00Z' | tztr -t America/Los_Angeles
|
|
|
22
29
|
# 2026-04-03T05:00:00-07:00
|
|
23
30
|
|
|
24
31
|
echo '15:30 UTC' | tztr -t America/New_York
|
|
25
|
-
# 11:30 EDT
|
|
32
|
+
# 11:30 EDT (a time with no date resolves against today; in January, 10:30 EST)
|
|
26
33
|
|
|
27
34
|
tail -f app.log | tztr
|
|
35
|
+
|
|
36
|
+
tztr now -t tokyo
|
|
37
|
+
# 2026-09-27T07:28:30+09:00 (whatever the time is)
|
|
28
38
|
```
|
|
29
39
|
|
|
40
|
+
`tztr now` reads the current time as though it were piped in, so every flag
|
|
41
|
+
works with it (`-F short`, `-j`, `--detect`) except `-i`. It prints ISO 8601 in
|
|
42
|
+
`-t`, else `$TZ`, else UTC. To read a file named `now`, write `./now`.
|
|
43
|
+
|
|
44
|
+
Several files are processed in order. A file that can't be read is reported
|
|
45
|
+
and skipped, the rest still run (and with `-i`, are still rewritten), and the
|
|
46
|
+
exit status is 1, as with `cat` and `sed -i`.
|
|
47
|
+
|
|
30
48
|
### Options
|
|
31
49
|
|
|
32
50
|
```
|
|
33
|
-
-f, --from TZ Input timezone (default:
|
|
34
|
-
-t, --to TZ Output timezone (default: UTC)
|
|
51
|
+
-f, --from TZ Input timezone for timestamps that name none (default: $TZ, else the system zone)
|
|
52
|
+
-t, --to TZ Output timezone (default: $TZ, else UTC)
|
|
53
|
+
-l, --list List timezone aliases
|
|
54
|
+
-i, --in-place Edit files in place
|
|
35
55
|
-F, --format FMT Output format: iso, short, time (default: preserve input)
|
|
36
|
-
-
|
|
56
|
+
-d, --date DATE Reference date for time-only inputs (resolves DST)
|
|
57
|
+
-j, --json Emit a JSON array of matches
|
|
58
|
+
-J, --ndjson Emit newline-delimited JSON (one object per match)
|
|
59
|
+
--detect Report detected format/zone without converting
|
|
60
|
+
-v, --verbose Print diagnostics to stderr
|
|
61
|
+
-V, --version Show version
|
|
37
62
|
-h, --help Show this help
|
|
38
63
|
```
|
|
39
64
|
|
|
40
|
-
|
|
65
|
+
`-F` replaces the preserved format with one of:
|
|
66
|
+
|
|
67
|
+
| `-F` | Output |
|
|
68
|
+
|---------|-----------------------------|
|
|
69
|
+
| `iso` | `2026-04-03 08:30:00-07:00` |
|
|
70
|
+
| `short` | `2026-04-03 08:30 PDT` |
|
|
71
|
+
| `time` | `08:30:00` |
|
|
72
|
+
|
|
41
73
|
|
|
42
|
-
|
|
74
|
+
## Timezones
|
|
75
|
+
|
|
76
|
+
`-f` and `-t` take an IANA name (`America/Los_Angeles`), an alias or
|
|
77
|
+
abbreviation (`pst`, `nyc`, `jst`; `tztr -l` lists them all), or a whole-hour
|
|
78
|
+
numeric offset from `-12` to `14` (`-8` → `Etc/GMT+8`). Sub-hour offsets
|
|
79
|
+
aren't accepted; reach a half-hour zone by name: `-t ist`, `-t Asia/Kolkata`.
|
|
80
|
+
|
|
81
|
+
Names given to `-f` and `-t` are real zones that follow DST: `-t est` is New
|
|
82
|
+
York time, EDT in summer. `gmt` means UTC; for civil UK time, which follows
|
|
83
|
+
British Summer Time, use `-t london`.
|
|
84
|
+
|
|
85
|
+
`TZ` sets the default for both sides, and `-f`/`-t` override it:
|
|
43
86
|
|
|
44
87
|
```bash
|
|
45
88
|
export TZ=America/Los_Angeles
|
|
@@ -47,16 +90,251 @@ echo '2026-04-03T12:00:00Z' | tztr
|
|
|
47
90
|
# 2026-04-03T05:00:00-07:00
|
|
48
91
|
```
|
|
49
92
|
|
|
50
|
-
|
|
93
|
+
A timestamp that names its own zone is read in that zone, whatever `-f` says.
|
|
94
|
+
One that doesn't is read in `-f`, else `$TZ`, else the machine's own zone (the
|
|
95
|
+
one `/etc/localtime` points to, as on macOS, where `TZ` is usually unset):
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
echo '12:00' | env -u TZ tztr -t utc -d 2026-04-03
|
|
99
|
+
# 19:00 UTC (on a Mac set to Pacific time)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Only if no system zone can be found either is it taken to be in the output
|
|
103
|
+
zone already, and labelled rather than converted.
|
|
104
|
+
|
|
105
|
+
A zone that can't be resolved is an error: exit 1, nothing on stdout.
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
echo '15:30 UTC' | tztr -t Mars/Phobos
|
|
109
|
+
# tztr: unknown timezone: Mars/Phobos
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
## Supported formats
|
|
114
|
+
|
|
115
|
+
Every timestamp on a line converts, whatever its format, and everything around
|
|
116
|
+
it (quotes, JSON, log levels) passes through untouched.
|
|
117
|
+
|
|
118
|
+
### What converts
|
|
51
119
|
|
|
52
120
|
- ISO 8601: `2026-04-03T12:00:00Z`, `2026-04-03T12:00:00+05:30`
|
|
53
|
-
- Date + time: `2026-04-03 12:00:00 UTC
|
|
54
|
-
|
|
55
|
-
-
|
|
121
|
+
- Date + time: `2026-04-03 12:00:00 UTC`, and slashed as Go's log package and
|
|
122
|
+
nginx's error log write it, `2026/04/03 12:00:00`
|
|
123
|
+
- A numeric offset after the seconds, glued or spaced, as Python,
|
|
124
|
+
`date --rfc-3339` and Postgres write it: `2026-09-25 22:14:42-07:00`,
|
|
125
|
+
`2026-04-03 09:00:00-07`
|
|
126
|
+
- `date` output: `Fri Sep 25 22:14:42 PDT 2026`, with or without the weekday
|
|
127
|
+
(`ls -lT`) or zone (ctime), a numeric zone (`+03`), and glibc's locale form,
|
|
128
|
+
`Fri 25 Sep 2026 10:14:42 PM PDT`
|
|
129
|
+
- Email and HTTP dates (RFC 2822): `Fri, 25 Sep 2026 22:14:42 -0700`
|
|
130
|
+
- nginx/Apache access logs: `[15/Jan/2015:12:31:01 -0700]`
|
|
131
|
+
- Time only: `15:30 UTC`, `08:30:45 PDT`, `12:00 +0530`, `12:34:56-05:00`
|
|
132
|
+
- 12-hour: `11:30 PM`, `3:45 p.m.`, `11:30 A.M.`, `3:45 PM PST`, and an hour
|
|
133
|
+
with AM/PM: `9am`, `9 PM PST`
|
|
134
|
+
- Fractional seconds, kept to the nanosecond: `…T12:00:00.123456789Z`, the ISO
|
|
135
|
+
comma (`…T12:00:00,123456789Z`), and Python logging's comma milliseconds,
|
|
136
|
+
`2026-04-03 12:00:00,123`
|
|
137
|
+
|
|
138
|
+
Dated timestamps convert date and all, so `Fri Sep 25 22:14:42 PDT 2026` in
|
|
139
|
+
New York is `Sat Sep 26 01:14:42 EDT 2026`. The output keeps the input's shape,
|
|
140
|
+
seconds and fraction, except that a 12-hour time comes out on the 24-hour
|
|
141
|
+
clock (`11:30 PM` → `23:30 UTC`); only `date`'s locale form and RFC 2822 keep
|
|
142
|
+
their AM/PM.
|
|
143
|
+
|
|
144
|
+
**Seconds** are optional (`2026-04-03 15:30`, `2026-04-03T15:30Z`) except in
|
|
145
|
+
three places: `date` output, access logs, and a numeric offset glued to the
|
|
146
|
+
clock. That last one is what keeps `15:30-16:45` a range rather than 15:30 at
|
|
147
|
+
an offset of −16:45. With a space, an offset needs no seconds
|
|
148
|
+
(`12:00 +0530`). An offset outside -12..+14 isn't an offset.
|
|
149
|
+
|
|
150
|
+
A date, time and zone are read together only when they're written together, in
|
|
151
|
+
one of the formats above. A date in another column
|
|
152
|
+
(`2026-12-31 | 23:30:00 | UTC`), a syslog date with no year
|
|
153
|
+
(`Sep 25 22:14:42`), or a word like `tomorrow` isn't attached to the time
|
|
154
|
+
beside it, which converts as a time alone.
|
|
155
|
+
|
|
156
|
+
### Ranges and lists
|
|
157
|
+
|
|
158
|
+
A range is joined by `-`, `–`, `—`, `to`, `until`, `till`, `through` or `thru`;
|
|
159
|
+
a list by commas, `or` and `and`. Its members share what's written around them:
|
|
160
|
+
|
|
161
|
+
- **The zone and AM/PM at the end apply to every member before it.**
|
|
162
|
+
`from 3:30 to 4:45 PM PST` reads both ends as PST afternoon times, and
|
|
163
|
+
`3:00, 4:00 or 5:00 PM PST` converts all three. AM/PM flips if it would run
|
|
164
|
+
the range backwards: `11:30 to 1:00 PM` starts in the morning.
|
|
165
|
+
- **A date at the start applies to every member after it.**
|
|
166
|
+
`2026-04-03 9:00 AM - 10:00 AM PST` puts both ends on April 3.
|
|
167
|
+
- **Nothing else crosses.** A zone on the start stays there: in
|
|
168
|
+
`3:30 PST to 4:45 PM`, the end is in your source zone.
|
|
169
|
+
- **A member earlier on the clock than the one before it is on the next day.**
|
|
170
|
+
The preserved format shows only the clock; `-F iso` shows the dates:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
echo '11:30 PM to 12:30 AM PST' | tztr -t utc -d 2026-04-03 -F iso
|
|
174
|
+
# 2026-04-04 07:30:00Z to 2026-04-04 08:30:00Z
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
A bare hour is just a number, except as the start of a range whose end has
|
|
178
|
+
AM/PM: `9-9:15am` and `9 to 10am` convert, but `Room 7 - 3pm` and
|
|
179
|
+
`Apr 3 - 5pm` convert only the `3pm` and `5pm`.
|
|
180
|
+
|
|
181
|
+
Any other word between two times keeps them apart, as in `15:30, then 16:45 PST`.
|
|
182
|
+
Here the rule below applies, and the `15:30` is left alone.
|
|
183
|
+
|
|
184
|
+
### What's left alone
|
|
185
|
+
|
|
186
|
+
- **A bare time beside a richer one.** A time with no date, zone or AM/PM, like
|
|
187
|
+
the `0:05` in `2026-04-03T12:00:00Z took 0:05`, is probably a duration. It's
|
|
188
|
+
left alone whenever another timestamp on its line has a date, zone or AM/PM.
|
|
189
|
+
Alone on its line (`request took 0:05`), it can't be told from a time, and
|
|
190
|
+
converts as one.
|
|
191
|
+
- **A time followed by a unit of time.** `Finished in 1:05 minutes`,
|
|
192
|
+
`took 2:30 hrs` (`sec`, `min`, `hr`, `hour`, spelled out or plural). So
|
|
193
|
+
`at 15:30 hrs` is left alone too.
|
|
194
|
+
- **A zone tztr doesn't know.** `Fri Sep 25 22:14:42 EEST 2026` stays as
|
|
195
|
+
written, rather than half-converted, unless your source zone uses `EEST`
|
|
196
|
+
(see below). `-v` names the zone.
|
|
197
|
+
- **Colon runs that aren't times.** IPv6 addresses (`fe80::1:23:45`) and SMPTE
|
|
198
|
+
timecodes (`01:02:03:04`).
|
|
199
|
+
- **Impossible dates.** `2026-02-30` stays exactly as found rather than rolling
|
|
200
|
+
to another day.
|
|
201
|
+
|
|
202
|
+
### Zone abbreviations
|
|
203
|
+
|
|
204
|
+
- **Standard and daylight names are fixed offsets**, whatever the date: `PST`
|
|
205
|
+
is -08:00 even in July, `CEST` is +02:00 even in January. Only the generic
|
|
206
|
+
`ET`, `CT`, `MT` and `PT` follow DST.
|
|
207
|
+
- **Ambiguous names are read the US way** (`CST` is US Central) and `IST` as
|
|
208
|
+
India, **unless your source zone (`-f`, else `$TZ`) uses the name itself**.
|
|
209
|
+
Then it means what it means there: with `TZ=Asia/Shanghai`, `CST` is China
|
|
210
|
+
Standard Time, and with `TZ=Europe/Helsinki`, `date`'s `EEST` converts. With
|
|
211
|
+
`$TZ` unset, UTC or anywhere in the US, the US reading stands.
|
|
212
|
+
- **Uppercase or lowercase, never mixed.** `PST` and `pst` both work; `Pst`
|
|
213
|
+
doesn't. Six are uppercase only, because their lowercase spellings are words
|
|
214
|
+
that can follow a time: `est`, `cet`, `et`, `ist`, `ut` and `z`. In
|
|
215
|
+
`à 15:30 est annulée`, `est` is French for "is", not Eastern time.
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
## JSON output (for agents and scripts)
|
|
219
|
+
|
|
220
|
+
`-j/--json` emits a JSON array; `-J/--ndjson` emits one object per line, which
|
|
221
|
+
streams (works with `tail -f`). Each match is an object:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
echo 'meeting at 15:30 UTC' | tztr -t pst -j
|
|
225
|
+
# [
|
|
226
|
+
# {
|
|
227
|
+
# "original": "15:30 UTC",
|
|
228
|
+
# "detected_format": "time",
|
|
229
|
+
# "detected_tz": "UTC",
|
|
230
|
+
# "translated": "08:30 PDT"
|
|
231
|
+
# }
|
|
232
|
+
# ]
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
(`08:30 PDT` in summer; `07:30 PST` in winter, as the time has no date.)
|
|
236
|
+
|
|
237
|
+
A timestamp in a range or list also carries `group`, which lists every member
|
|
238
|
+
in order, so the two ends of a range can be read together (abridged):
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
echo 'standup 9:00 to 9:15 AM PST' | tztr -t utc -j
|
|
242
|
+
# [
|
|
243
|
+
# { "original": "9:00", "detected_tz": "PST", "translated": "17:00 UTC",
|
|
244
|
+
# "group": { "type": "range", "members": ["9:00", "9:15 AM PST"] }, ... },
|
|
245
|
+
# { "original": "9:15 AM PST", "detected_tz": "PST", "translated": "17:15 UTC",
|
|
246
|
+
# "group": { "type": "range", "members": ["9:00", "9:15 AM PST"] }, ... }
|
|
247
|
+
# ]
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`--detect` reports what was found without translating (omits `translated`),
|
|
251
|
+
and composes with `-j`/`-J`:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
echo '2026-04-03T12:00:00Z' | tztr --detect -j
|
|
255
|
+
# [ { "original": "2026-04-03T12:00:00Z", "detected_format": "iso", "detected_tz": "Z" } ]
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Combine `-h` with `-j`/`-J` to get the help itself as JSON: the full option
|
|
259
|
+
schema (flags, args, descriptions, examples), so an agent can read it instead
|
|
260
|
+
of scraping the text.
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
tztr -h -j
|
|
264
|
+
# { "name": "tztr", "version": "...", "options": [ { "short": "-f", "long": "--from", ... } ], ... }
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
## Diagnostics (`-v`)
|
|
269
|
+
|
|
270
|
+
`-v` explains itself on stderr, leaving stdout untouched. Each note is printed
|
|
271
|
+
once, the first time it applies:
|
|
272
|
+
|
|
273
|
+
- the source and output zones, when `-f` is given
|
|
274
|
+
- the source zone it borrowed from `$TZ` or the system, when a timestamp needed one
|
|
275
|
+
- the date it assumed for DST, when a timestamp had none and there's no `-d`
|
|
276
|
+
- a zone it passed over for its case (`Pst`), or didn't know (`EEST`)
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
echo '15:30' | tztr -f pacific -t utc -v
|
|
280
|
+
# tztr: from=America/Los_Angeles to=UTC
|
|
281
|
+
# tztr: no -d given, assuming 2026-09-26 for DST resolution (whatever today is)
|
|
282
|
+
# 22:30 UTC
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
## DST caveats
|
|
287
|
+
|
|
288
|
+
### Time-only inputs
|
|
289
|
+
|
|
290
|
+
A time with no date can't say whether it was standard or daylight time.
|
|
291
|
+
`tztr` resolves it against **today's** date where the time was written (in the
|
|
292
|
+
zone it names, else the source zone). That can be off by an hour for a
|
|
293
|
+
timestamp from the other side of a DST boundary:
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
echo '15:30' | tztr -f pacific -t utc
|
|
297
|
+
# 22:30 UTC (run in summer: pacific -> PDT, UTC-7. In January: 23:30 UTC)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Two ways to remove the guess:
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
# Supply the date the time belongs to:
|
|
304
|
+
echo '15:30' | tztr -f pacific -t utc -d 2026-01-15
|
|
305
|
+
# 23:30 UTC (January -> PST, UTC-8)
|
|
306
|
+
echo '15:30' | tztr -f pacific -t utc -d 'January 15, 2026'
|
|
307
|
+
|
|
308
|
+
# Or sidestep DST entirely with a fixed numeric offset instead of a named zone:
|
|
309
|
+
echo '15:30' | tztr -f -8 -t utc
|
|
310
|
+
# 23:30 UTC (-8 -> Etc/GMT+8, never observes daylight time)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`-d` takes one of `2026-01-15`, `2026/01/15`, `20260115`, `January 15, 2026`,
|
|
314
|
+
`Jan 15 2026` or `15 January 2026`. Month names are full or exactly three
|
|
315
|
+
letters (`Sep`, not `Sept`); the numeric forms need two digits for month and
|
|
316
|
+
day. Anything else, including a date that isn't on the calendar, is
|
|
317
|
+
`tztr: invalid date: ...` and exit 1.
|
|
318
|
+
|
|
319
|
+
Inputs that carry a date (`2026-04-03 15:30`) or a fixed offset are
|
|
320
|
+
unaffected.
|
|
321
|
+
|
|
322
|
+
### The repeated hour
|
|
323
|
+
|
|
324
|
+
A fall-back repeats an hour, so the same wall clock happens twice, dated or
|
|
325
|
+
not. `tztr` takes the **earlier** (daylight) occurrence, matching macOS
|
|
326
|
+
`date(1)`, Temporal, RFC 5545 and ICU:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
echo '2026-11-01 01:30:00' | tztr -f pacific -t utc
|
|
330
|
+
# 2026-11-01 08:30:00 UTC (01:30 PDT; the later 01:30 PST would be 09:30)
|
|
331
|
+
```
|
|
56
332
|
|
|
57
333
|
|
|
58
334
|
## Library
|
|
59
335
|
|
|
336
|
+
From Ruby:
|
|
337
|
+
|
|
60
338
|
```ruby
|
|
61
339
|
require "tztr"
|
|
62
340
|
|
|
@@ -64,15 +342,11 @@ Tztr.translate("log 2026-04-03T12:00:00Z event", to: "America/Los_Angeles")
|
|
|
64
342
|
# => "log 2026-04-03T05:00:00-07:00 event"
|
|
65
343
|
```
|
|
66
344
|
|
|
345
|
+
`Tztr.matches` returns the `-j` objects for a line, and `Tztr.resolve_tz`
|
|
346
|
+
raises `Tztr::Error` for a zone it can't resolve. Both `translate` and
|
|
347
|
+
`matches` set `ENV['TZ']` as a side effect; save and restore it if the rest of
|
|
348
|
+
your process cares.
|
|
67
349
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
Yes please :)
|
|
72
|
-
|
|
73
|
-
1. Fork it
|
|
74
|
-
1. Create your feature branch (`git checkout -b my-feature`)
|
|
75
|
-
1. Ensure the tests pass (`bundle exec rspec`)
|
|
76
|
-
1. Commit your changes (`git commit -am 'awesome new feature'`)
|
|
77
|
-
1. Push your branch (`git push origin my-feature`)
|
|
78
|
-
1. Create a Pull Request
|
|
350
|
+
The Rust crate exposes the same functions; see
|
|
351
|
+
[`rust/tztr`](rust/tztr). The Ruby gem is the reference implementation, and
|
|
352
|
+
[`rust/REPORT.md`](rust/REPORT.md) has the port notes and a perf comparison.
|