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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e92587c8ab85fb2d8d30742355d39affa2732dfca85f3e2593f0b24747db174e
4
- data.tar.gz: 9bcb138c9a6d4344251307e0d302e9ecd33d2b36a2ed00d28e6dfc3f15ed943a
3
+ metadata.gz: 6d285193d8b72f78de45ce70067ab31b21b41dd51845ee7cf3a875a2d7b896f0
4
+ data.tar.gz: 4c2780b1e58dc0d565aef9921d7a34159de1ecb84264f757baa8c01e51635496
5
5
  SHA512:
6
- metadata.gz: 6a4438297ef18d9e41e8b2b9cf2a69c3c0edbadc6f22e5ef55ada8614f13bc4c838c125e5438e446ca7b34c9e0aac8123425f46656302ba019c4206764430397
7
- data.tar.gz: 70432ef4777d42567ff4f84e34460e503a6d46de6216f1079621d4c6cc15c032c65684fce712b6c2c78350e7695aa4fa35662adcad4144ae92500baf64e544d2
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.0)
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.2)
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.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
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.0)
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
  ![Gem](https://img.shields.io/gem/dt/tztr?style=plastic)
4
4
  [![codecov](https://codecov.io/gh/dpep/tztr/branch/main/graph/badge.svg)](https://codecov.io/gh/dpep/tztr)
5
5
 
6
- Timezone Translator - convert timestamps to local time.
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 file, auto-detects timestamp formats, and preserves the original format by default.
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
- gem install tztr
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: auto-detect)
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
- -v, --version Show version
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
- ### Environment
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
- Set `TZ` to change the default output timezone (overridden by `-t`):
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
- ### Supported Formats
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
- - Time only: `15:30 UTC`, `08:30:45 PDT`
55
- - Fractional seconds: `2026-04-03T12:00:00.123Z`
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
- ## Contributing
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.