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.
data/bin/tztr CHANGED
@@ -2,11 +2,52 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'optparse'
5
+ require 'json'
6
+ require 'date'
7
+ require 'stringio'
5
8
 
6
9
  $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
7
10
  require 'tztr'
8
11
 
12
+ # Structured description of the CLI, emitted by `-h -j` / `-h -J` so agents can
13
+ # read the option schema instead of scraping the text help.
14
+ HELP_DOC = {
15
+ name: 'tztr',
16
+ version: Tztr::VERSION,
17
+ usage: 'tztr [options] [file | now]',
18
+ summary: 'Timezone Translator - convert timestamps between timezones. Reads from stdin, files, or the clock (now).',
19
+ options: [
20
+ { short: '-f', long: '--from', arg: 'TZ', description: 'Input timezone for timestamps that name none (default: $TZ, else the system zone)' },
21
+ { short: '-t', long: '--to', arg: 'TZ', description: 'Output timezone (default: $TZ, else UTC)' },
22
+ { short: '-l', long: '--list', arg: nil, description: 'List timezone aliases' },
23
+ { short: '-i', long: '--in-place', arg: nil, description: 'Edit files in place' },
24
+ { short: '-F', long: '--format', arg: 'FMT', description: 'Output format: iso, short, time (default: preserve input)' },
25
+ { short: '-d', long: '--date', arg: 'DATE', description: 'Reference date for time-only inputs (resolves DST)' },
26
+ { short: '-j', long: '--json', arg: nil, description: 'Emit a JSON array of matches' },
27
+ { short: '-J', long: '--ndjson', arg: nil, description: 'Emit newline-delimited JSON (one object per match)' },
28
+ { short: nil, long: '--detect', arg: nil, description: 'Report detected format/zone without converting' },
29
+ { short: '-v', long: '--verbose', arg: nil, description: 'Print diagnostics to stderr' },
30
+ { short: '-V', long: '--version', arg: nil, description: 'Show version' },
31
+ { short: '-h', long: '--help', arg: nil, description: 'Show this help' },
32
+ ],
33
+ environment: [
34
+ { name: 'TZ', description: 'Default timezone for input and output (overridden by -f / -t)' },
35
+ ],
36
+ examples: [
37
+ "echo '2026-04-03T12:00:00Z' | tztr -t sf",
38
+ "echo '15:30 UTC' | tztr -t pst",
39
+ "echo '12:00 EST' | tztr -t -8",
40
+ "tail -f app.log | tztr -t nyc",
41
+ "echo '15:30 UTC' | tztr -t pst -j",
42
+ "tail -f app.log | tztr -t nyc -J",
43
+ "echo '2026-04-03T12:00:00Z' | tztr --detect -j",
44
+ "echo '15:30' | tztr -f pacific -t utc -d 2026-01-15",
45
+ "tztr now -t tokyo",
46
+ ],
47
+ }.freeze
48
+
9
49
  local_tz = ENV['TZ']
50
+ local_tz = nil if local_tz&.empty? # TZ="" means unset, not "a zone named ''"
10
51
 
11
52
  options = {
12
53
  from: nil,
@@ -14,58 +55,213 @@ options = {
14
55
  format: nil
15
56
  }
16
57
 
58
+ FORMATS = %w[iso short time].freeze
59
+
60
+ # Validated as the flag is parsed, not afterwards, so `-h -F bogus` still
61
+ # fails -- the order the Rust port parses in.
62
+ parse_format = lambda do |value|
63
+ unless FORMATS.include?(value)
64
+ abort "tztr: invalid format: #{value} (expected #{FORMATS.join(', ')})"
65
+ end
66
+
67
+ value.to_sym
68
+ end
69
+
17
70
  parser = OptionParser.new do |opts|
18
- opts.banner = "Usage: tztr [options] [file]"
71
+ opts.banner = "Usage: tztr [options] [file | now]"
19
72
  opts.separator ""
20
- opts.separator "Timezone Translator - convert timestamps timezone. Reads from stdin or file."
73
+ opts.separator "Timezone Translator - convert timestamps between timezones. Reads from stdin, files, or the clock (now)."
21
74
  opts.separator ""
22
75
 
23
- opts.on("-f", "--from TZ", "Input timezone (default: auto-detect)") { |v| options[:from] = v }
24
- opts.on("-t", "--to TZ", "Output timezone (default: UTC)") { |v| options[:to] = v }
76
+ opts.on("-f", "--from TZ", "Input timezone for timestamps that name none (default: $TZ, else the system zone)") { |v| options[:from] = v }
77
+ opts.on("-t", "--to TZ", "Output timezone (default: $TZ, else UTC)") { |v| options[:to] = v }
25
78
  opts.on("-l", "--list", "List timezone aliases") { Tztr::TIMEZONE_ALIASES.sort.each { |k, v| puts "%-12s %s" % [k, v] }; exit }
26
- opts.on("-i", "--in-place", "Edit file in place") { options[:inplace] = true }
27
- opts.on("-F", "--format FMT", %i[iso short time], "Output format: iso, short, time (default: preserve input)") { |v| options[:format] = v }
28
- opts.on("-v", "--version", "Show version") { puts Tztr::VERSION; exit }
29
- opts.on("-h", "--help", "Show this help") { puts opts; exit }
79
+ opts.on("-i", "--in-place", "Edit files in place") { options[:inplace] = true }
80
+ opts.on("-F", "--format FMT", "Output format: iso, short, time (default: preserve input)") { |v| options[:format] = parse_format.call(v) }
81
+ opts.on("-d", "--date DATE", "Reference date for time-only inputs (resolves DST)") { |v| options[:date] = v }
82
+ opts.on("-j", "--json", "Emit a JSON array of matches") { options[:json] = true }
83
+ opts.on("-J", "--ndjson", "Emit newline-delimited JSON (one object per match)") { options[:ndjson] = true }
84
+ opts.on("--detect", "Report detected format/zone without converting") { options[:detect] = true }
85
+ opts.on("-v", "--verbose", "Print diagnostics to stderr") { options[:verbose] = true }
86
+ opts.on("-V", "--version", "Show version") { puts Tztr::VERSION; exit }
87
+ opts.on("-h", "--help", "Show this help") { options[:help] = true }
30
88
 
31
89
  opts.separator ""
32
90
  opts.separator "Environment:"
33
- opts.separator " TZ Sets default output timezone (overridden by -t)"
91
+ opts.separator " TZ Default timezone for input and output (overridden by -f / -t)"
34
92
  opts.separator ""
35
93
  opts.separator "Examples:"
36
94
  opts.separator " echo '2026-04-03T12:00:00Z' | tztr -t sf"
37
95
  opts.separator " echo '15:30 UTC' | tztr -t pst"
38
96
  opts.separator " echo '12:00 EST' | tztr -t -8"
39
97
  opts.separator " tail -f app.log | tztr -t nyc"
98
+ opts.separator " echo '15:30 UTC' | tztr -t pst -j"
99
+ opts.separator " tail -f app.log | tztr -t nyc -J"
100
+ opts.separator " echo '2026-04-03T12:00:00Z' | tztr --detect -j"
101
+ opts.separator " echo '15:30' | tztr -f pacific -t utc -d 2026-01-15"
102
+ opts.separator " tztr now -t tokyo"
103
+ end
104
+ begin
105
+ # No prefix abbreviation: --jso must not quietly mean --json, because the
106
+ # Rust binary most users install rejects it.
107
+ parser.require_exact = true
108
+ parser.parse!
109
+ rescue OptionParser::MissingArgument => e
110
+ abort "#{parser.program_name}: missing argument for #{e.args.first}"
111
+ rescue OptionParser::NeedlessArgument => e
112
+ # args carries the "=value" tail; the user only needs the flag named.
113
+ abort "#{parser.program_name}: #{e.args.first.split('=').first} takes no argument"
114
+ rescue OptionParser::InvalidOption, OptionParser::AmbiguousOption => e
115
+ # Built from args rather than e.message, which appends a "Did you mean?" line.
116
+ abort "#{parser.program_name}: invalid option: #{e.args.first}"
117
+ rescue OptionParser::ParseError => e
118
+ abort "#{parser.program_name}: #{e.message}"
119
+ end
120
+
121
+ json_mode = options[:json] || options[:ndjson]
122
+
123
+ if options[:help]
124
+ if json_mode
125
+ puts(options[:ndjson] ? JSON.generate(HELP_DOC) : JSON.pretty_generate(HELP_DOC))
126
+ else
127
+ puts parser
128
+ end
129
+ exit
40
130
  end
41
- parser.parse!
42
131
 
43
- options[:to] = Tztr.resolve_tz(options[:to])
44
- options[:from] = Tztr.resolve_tz(options[:from]) || Tztr.resolve_tz(local_tz)
132
+ # A timestamp with no zone is read in -f, else $TZ, else the machine's own
133
+ # zone; only when none of those is known is it taken to be in -t already.
134
+ system_tz = Tztr.system_zone unless local_tz
135
+ implicit_source =
136
+ if options[:from] then nil
137
+ elsif local_tz then '$TZ'
138
+ elsif system_tz then 'the system zone'
139
+ end
140
+ implicit_from = !implicit_source.nil?
141
+
142
+ begin
143
+ options[:to] = Tztr.resolve_tz(options[:to])
144
+ options[:from] = Tztr.resolve_tz(options[:from]) || Tztr.resolve_tz(local_tz) || system_tz
145
+ options[:date] = Tztr.normalize_date(options[:date]) if options[:date]
146
+ rescue Tztr::Error => e
147
+ abort "#{parser.program_name}: #{e.message}"
148
+ end
45
149
  ENV['TZ'] = options[:to]
46
150
 
151
+ if options[:inplace] && (json_mode || options[:detect])
152
+ abort "#{parser.program_name}: -i cannot be combined with --json/--ndjson/--detect"
153
+ end
154
+
155
+ # An implicit from is announced by `disclose` below instead, and only if some
156
+ # timestamp actually uses it -- naming it here would claim a source zone that a
157
+ # timestamp carrying its own never consults.
158
+ if options[:verbose] && !implicit_from
159
+ warn "#{parser.program_name}: from=#{options[:from] || 'auto'} to=#{options[:to]}"
160
+ end
161
+
162
+ # A bare time silently borrows its source zone from $TZ and resolves DST
163
+ # against today. Say so, once, the first time it actually happens.
164
+ disclosed = []
165
+ disclose = lambda do |line|
166
+ # A date(1) zone we don't know (EEST): the reason its line was left alone.
167
+ (Tztr.unknown_zones(line, from: options[:from]) - disclosed).each do |token|
168
+ disclosed << token
169
+ warn %(#{parser.program_name}: ignored "#{token}": not a zone tztr knows)
170
+ end
171
+
172
+ # A zone the user wrote but we didn't read, because of its case (Pst).
173
+ (Tztr.ignored_zones(line) - disclosed).each do |token|
174
+ disclosed << token
175
+ warn %(#{parser.program_name}: ignored "#{token}": a zone abbreviation is matched in all uppercase or all lowercase)
176
+ end
177
+
178
+ assumed = Tztr.assumptions(line, from: options[:from]) - disclosed
179
+ return if assumed.empty?
180
+
181
+ disclosed.concat(assumed)
182
+ warn "#{parser.program_name}: from=#{options[:from]} (implicit, from #{implicit_source}) to=#{options[:to]}" if assumed.include?(:zone) && implicit_from
183
+ return unless assumed.include?(:date) && !options[:date]
184
+
185
+ warn "#{parser.program_name}: no -d given, assuming #{Tztr.assumed_date(line, from: options[:from], to: options[:to])} for DST resolution"
186
+ end
187
+
188
+ # An error about one of our inputs names it, and the run carries on with the
189
+ # rest, as cat and sed -i do -- stopping would leave -i half-applied. A broken
190
+ # pipe is about stdout and stdin has no name, so neither is about an input
191
+ # file: both fall through to the bare form below.
192
+ failed = false
193
+ file_error = lambda do |path, error|
194
+ raise error if path.nil? || error.is_a?(Errno::EPIPE)
195
+
196
+ warn "#{parser.program_name}: #{path}: #{SystemCallError.new(error.errno).message} (os error #{error.errno})"
197
+ failed = true
198
+ end
199
+
47
200
  $stdout.sync = true
48
201
 
202
+ # `tztr now`: the current time, read as though piped in, so every flag applies.
203
+ if ARGV == ['now']
204
+ abort "#{parser.program_name}: -i cannot be combined with now" if options[:inplace]
205
+
206
+ ARGV.clear
207
+ $stdin = StringIO.new(Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ\n"))
208
+ end
209
+
49
210
  if ARGV.empty? && $stdin.tty?
50
211
  puts parser
51
212
  exit
52
213
  end
53
214
 
54
- if options[:inplace]
55
- abort "#{parser.program_name}: -i requires a file argument" if ARGV.empty?
215
+ begin
216
+ if options[:inplace]
217
+ abort "#{parser.program_name}: -i requires a file argument" if ARGV.empty?
56
218
 
57
- ARGV.each do |file|
58
- content = File.read(file)
59
- translated = content.each_line.map do |line|
60
- Tztr.translate(line, to: options[:to], from: options[:from], format: options[:format])
61
- end.join
62
- File.write(file, translated) if translated != content
63
- end
64
- else
65
- inputs = ARGV.empty? ? [$stdin] : ARGV.map { |f| File.open(f) }
66
- inputs.each do |input|
67
- input.each_line do |line|
68
- print Tztr.translate(line, to: options[:to], from: options[:from], format: options[:format])
219
+ ARGV.each do |file|
220
+ content = File.read(file)
221
+ translated = content.each_line.map do |line|
222
+ disclose.call(line) if options[:verbose] && !options[:detect]
223
+ Tztr.translate(line, to: options[:to], from: options[:from], format: options[:format], date: options[:date])
224
+ end.join
225
+ File.write(file, translated) if translated != content
226
+ rescue SystemCallError => e
227
+ file_error.call(file, e)
69
228
  end
229
+ else
230
+ collected = [] if options[:json]
231
+
232
+ # Opened one at a time: a filter should start emitting as soon as it has
233
+ # something, rather than stat-ing every argument first. nil is stdin.
234
+ paths = ARGV.empty? ? [nil] : ARGV
235
+ paths.each do |path|
236
+ input = path ? File.open(path) : $stdin
237
+
238
+ input.each_line do |line|
239
+ disclose.call(line) if options[:verbose] && !options[:detect]
240
+
241
+ if json_mode
242
+ ms = Tztr.matches(line, to: options[:to], from: options[:from], format: options[:format], detect: options[:detect], date: options[:date])
243
+ if options[:ndjson]
244
+ ms.each { |m| puts JSON.generate(m) }
245
+ else
246
+ collected.concat(ms)
247
+ end
248
+ elsif options[:detect]
249
+ Tztr.matches(line, to: options[:to], from: options[:from], detect: true).each do |m|
250
+ puts [m[:original], m[:detected_format], m[:detected_tz]].join("\t")
251
+ end
252
+ else
253
+ print Tztr.translate(line, to: options[:to], from: options[:from], format: options[:format], date: options[:date])
254
+ end
255
+ end
256
+ rescue SystemCallError => e
257
+ file_error.call(path, e)
258
+ end
259
+
260
+ puts JSON.pretty_generate(collected) if options[:json]
70
261
  end
262
+ exit 1 if failed
263
+ rescue SystemCallError => e
264
+ # Ruby's own message carries the syscall and path; the Rust port prints the
265
+ # bare strerror, and the two must read the same.
266
+ abort "#{parser.program_name}: #{SystemCallError.new(e.errno).message} (os error #{e.errno})"
71
267
  end
data/lib/tztr/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tztr
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end