zone 0.1.1 → 0.1.2

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.
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Extracted from command_kit gem (https://github.com/postmodern/command_kit.rb)
4
+ # Copyright (c) 2021-2024 Hal Brodigan
5
+ # Licensed under the MIT License
6
+ #
7
+ # This is a simplified version containing only the ANSI color functionality.
8
+ # The full command_kit gem provides many additional features.
9
+
10
+ module Zone
11
+ #
12
+ # ANSI color codes for terminal output.
13
+ #
14
+ # @see https://en.wikipedia.org/wiki/ANSI_escape_code
15
+ #
16
+ module Colors
17
+ #
18
+ # Applies ANSI formatting to text.
19
+ #
20
+ module ANSI
21
+ # ANSI reset code
22
+ RESET = "\e[0m"
23
+
24
+ # ANSI code for bold text
25
+ BOLD = "\e[1m"
26
+
27
+ # ANSI code to disable boldness
28
+ RESET_INTENSITY = "\e[22m"
29
+
30
+ # ANSI color code for red text
31
+ RED = "\e[31m"
32
+
33
+ # ANSI color code for yellow text
34
+ YELLOW = "\e[33m"
35
+
36
+ # ANSI color code for green text
37
+ GREEN = "\e[32m"
38
+
39
+ # ANSI color code for cyan text
40
+ CYAN = "\e[36m"
41
+
42
+ # ANSI color for the default foreground color
43
+ RESET_FG = "\e[39m"
44
+
45
+ module_function
46
+
47
+ #
48
+ # Bolds the text.
49
+ #
50
+ # @param [String, nil] string
51
+ # An optional string.
52
+ #
53
+ # @return [String, BOLD]
54
+ # The bolded string or just {BOLD} if no arguments were given.
55
+ #
56
+ def bold(string=nil)
57
+ if string then "#{BOLD}#{string}#{RESET_INTENSITY}"
58
+ else BOLD
59
+ end
60
+ end
61
+
62
+ #
63
+ # Sets the text color to red.
64
+ #
65
+ # @param [String, nil] string
66
+ # An optional string.
67
+ #
68
+ # @return [String, RED]
69
+ # The colorized string or just {RED} if no arguments were given.
70
+ #
71
+ def red(string=nil)
72
+ if string then "#{RED}#{string}#{RESET_FG}"
73
+ else RED
74
+ end
75
+ end
76
+
77
+ #
78
+ # Sets the text color to yellow.
79
+ #
80
+ # @param [String, nil] string
81
+ # An optional string.
82
+ #
83
+ # @return [String, YELLOW]
84
+ # The colorized string or just {YELLOW} if no arguments were given.
85
+ #
86
+ def yellow(string=nil)
87
+ if string then "#{YELLOW}#{string}#{RESET_FG}"
88
+ else YELLOW
89
+ end
90
+ end
91
+
92
+ #
93
+ # Sets the text color to green.
94
+ #
95
+ # @param [String, nil] string
96
+ # An optional string.
97
+ #
98
+ # @return [String, GREEN]
99
+ # The colorized string or just {GREEN} if no arguments were given.
100
+ #
101
+ def green(string=nil)
102
+ if string then "#{GREEN}#{string}#{RESET_FG}"
103
+ else GREEN
104
+ end
105
+ end
106
+
107
+ #
108
+ # Sets the text color to cyan.
109
+ #
110
+ # @param [String, nil] string
111
+ # An optional string.
112
+ #
113
+ # @return [String, CYAN]
114
+ # The colorized string or just {CYAN} if no arguments were given.
115
+ #
116
+ def cyan(string=nil)
117
+ if string then "#{CYAN}#{string}#{RESET_FG}"
118
+ else CYAN
119
+ end
120
+ end
121
+ end
122
+
123
+ #
124
+ # Dummy module with the same interface as {ANSI}, but for when ANSI is not
125
+ # supported.
126
+ #
127
+ module PlainText
128
+ ANSI.constants(false).each do |name|
129
+ const_set(name,'')
130
+ end
131
+
132
+ module_function
133
+
134
+ [:bold, :red, :yellow, :green, :cyan].each do |name|
135
+ define_method(name) do |string=nil|
136
+ string || ''
137
+ end
138
+ end
139
+ end
140
+
141
+ module_function
142
+
143
+ #
144
+ # Checks if the stream supports ANSI output.
145
+ #
146
+ # @param [IO] stream
147
+ #
148
+ # @return [Boolean]
149
+ #
150
+ # @note
151
+ # When the env variable `TERM` is set to `dumb` or when the `NO_COLOR`
152
+ # env variable is set, it will disable color output. Color output will
153
+ # also be disabled if the given stream is not a TTY.
154
+ #
155
+ def ansi?(stream=$stdout)
156
+ ENV['TERM'] != 'dumb' && !ENV['NO_COLOR'] && stream.tty?
157
+ end
158
+
159
+ #
160
+ # Returns the colors available for the given stream.
161
+ #
162
+ # @param [IO] stream
163
+ #
164
+ # @return [ANSI, PlainText]
165
+ # The ANSI module or PlainText dummy module.
166
+ #
167
+ # @example
168
+ # puts colors.green("Hello world")
169
+ #
170
+ # @example Using colors with stderr output:
171
+ # stderr.puts colors(stderr).green("Hello world")
172
+ #
173
+ def colors(stream=$stdout)
174
+ if ansi?(stream) then ANSI
175
+ else PlainText
176
+ end
177
+ end
178
+ end
179
+ end
data/lib/zone/field.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'field_line'
4
+ require_relative 'field_mapping'
5
+
6
+ module Zone
7
+ module Field
8
+ module_function
9
+
10
+ def process(input, output, transformation, options, logger)
11
+ mapping, header_line = build_mapping(input, options)
12
+
13
+ # Output header line if present
14
+ output.puts(header_line) if header_line
15
+
16
+ input.each_line do |line|
17
+ process_line(line, input.skip_headers?, output, transformation, options, mapping, logger)
18
+ end
19
+ end
20
+
21
+ def process_line(line, skip, output, transformation, options, mapping, logger)
22
+ return if skip
23
+
24
+ field_line = FieldLine.parse(line, delimiter: options.delimiter, mapping: mapping, logger: logger)
25
+
26
+ # Check if field exists before transforming
27
+ original_value = field_line[options.field]
28
+ if original_value.nil?
29
+ logger.warn("Field '#{options.field}' not found or out of bounds in line: #{line}")
30
+ return
31
+ end
32
+
33
+ # Transform the field
34
+ field_line.transform(options.field, &transformation)
35
+
36
+ # Get transformed value
37
+ transformed_value = field_line[options.field]
38
+ if transformed_value.nil?
39
+ logger.warn("Could not parse timestamp in field '#{options.field}': #{original_value.inspect}")
40
+ return
41
+ end
42
+
43
+ output.puts_highlighted(field_line.to_s, highlight: transformed_value)
44
+ end
45
+ private_class_method :process_line
46
+
47
+ def build_mapping(input, options)
48
+ return [FieldMapping.numeric, nil] unless options.headers
49
+
50
+ input.mark_skip_headers!
51
+ header_line = input.each_line.first
52
+
53
+ parsed = FieldLine.parse_delimiter(options.delimiter)
54
+ fields = FieldLine.split_line(header_line, parsed)
55
+
56
+ # Format header line the same way as data lines
57
+ formatted_header = FieldLine.new(
58
+ fields: fields,
59
+ delimiter: parsed,
60
+ mapping: FieldMapping.numeric
61
+ ).to_s
62
+
63
+ [FieldMapping.from_fields(fields.map(&:strip)), formatted_header]
64
+ end
65
+ private_class_method :build_mapping
66
+ end
67
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'field_mapping'
4
+
5
+ module Zone
6
+ class FieldLine
7
+ def self.parse(text, delimiter:, mapping: nil, logger: nil)
8
+ parsed_delimiter = parse_delimiter(delimiter)
9
+
10
+ fields = split_line(text, parsed_delimiter)
11
+
12
+ new(
13
+ fields: fields,
14
+ delimiter: parsed_delimiter,
15
+ mapping: mapping
16
+ )
17
+ end
18
+
19
+ def self.parse_delimiter(delimiter_string)
20
+ case delimiter_string
21
+ in /^\/(.*)\/$/
22
+ # Regex delimiter wrapped in slashes: "/\s+/" -> /\s+/
23
+ Regexp.new($1)
24
+ in String => d
25
+ # String delimiter: "," -> ","
26
+ d
27
+ else
28
+ raise ArgumentError, "Invalid delimiter: #{delimiter_string.inspect}"
29
+ end
30
+ end
31
+
32
+ def self.split_line(line, delimiter)
33
+ case [line, delimiter]
34
+ in [String, ""]
35
+ [line]
36
+ in [String, String | Regexp]
37
+ line.split(delimiter)
38
+ else
39
+ raise ArgumentError, "Invalid delimiter type: #{delimiter.class}"
40
+ end
41
+ end
42
+
43
+ def initialize(fields:, delimiter:, mapping: nil)
44
+ @fields = fields.map(&:strip)
45
+ @delimiter = delimiter
46
+ @mapping = mapping || FieldMapping.numeric
47
+ end
48
+
49
+ def [](key)
50
+ index = @mapping.resolve(key)
51
+ @fields[index]
52
+ end
53
+
54
+ def transform(key, &block)
55
+ index = @mapping.resolve(key)
56
+ @fields[index] = block.call(@fields[index])
57
+ self
58
+ end
59
+
60
+ def transform_all(&block)
61
+ @fields.map!(&block)
62
+ self
63
+ end
64
+
65
+ def to_s
66
+ output_delim = case @delimiter
67
+ when Regexp
68
+ "\t"
69
+ when ","
70
+ "\t"
71
+ else
72
+ @delimiter
73
+ end
74
+
75
+ case @fields.count
76
+ in 1
77
+ @fields[0].to_s
78
+ else
79
+ @fields.join(output_delim)
80
+ end
81
+ end
82
+
83
+ def to_a
84
+ @fields.dup
85
+ end
86
+
87
+ def to_h
88
+ return {} unless @mapping.has_names?
89
+
90
+ @mapping.names.zip(@fields).to_h
91
+ end
92
+
93
+ def fields
94
+ @fields.dup
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zone
4
+ class FieldMapping
5
+ def self.from_fields(fields)
6
+ name_to_index = fields.each_with_index.to_h
7
+ new(name_to_index: name_to_index)
8
+ end
9
+
10
+ def self.numeric
11
+ new(name_to_index: nil)
12
+ end
13
+
14
+ def initialize(name_to_index:)
15
+ @name_to_index = name_to_index
16
+ end
17
+
18
+ def resolve(key)
19
+ case key
20
+ in String => name if name.match?(/^\d+$/)
21
+ # Numeric string - convert to integer and resolve
22
+ name.to_i - 1
23
+ in String => name
24
+ if @name_to_index
25
+ @name_to_index.fetch(name) do
26
+ raise KeyError, "Field '#{name}' not found in mapping"
27
+ end
28
+ else
29
+ raise KeyError, "Cannot access field by name without headers. Use --headers or numeric field index."
30
+ end
31
+ in Integer => index
32
+ # Convert 1-based user input to 0-based array index
33
+ index - 1
34
+ else
35
+ raise ArgumentError, "Key must be String or Integer, got #{key.class}"
36
+ end
37
+ end
38
+
39
+ def [](key)
40
+ resolve(key)
41
+ end
42
+
43
+ def names
44
+ @name_to_index&.keys || []
45
+ end
46
+
47
+ def has_names?
48
+ !@name_to_index.nil?
49
+ end
50
+ end
51
+ end
data/lib/zone/input.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'timestamp_patterns'
4
+
5
+ module Zone
6
+ class Input
7
+ def initialize(argv, stdin: $stdin)
8
+ @argv = argv
9
+ @stdin = stdin
10
+ end
11
+
12
+ def each_line(&block)
13
+ source.each(&block)
14
+ end
15
+
16
+ def skip_headers?
17
+ @skip_first ||= false
18
+ if @skip_first
19
+ @skip_first = false
20
+ true
21
+ else
22
+ false
23
+ end
24
+ end
25
+
26
+ def mark_skip_headers!
27
+ @skip_first = true
28
+ end
29
+
30
+ def from_arguments?
31
+ @argv.any?
32
+ end
33
+
34
+ private
35
+
36
+ def source
37
+ @source ||= begin
38
+ if @argv.any?
39
+ # Arguments provided - use them as timestamps
40
+ @argv
41
+ elsif !@stdin.tty?
42
+ # No arguments but stdin is piped - read from stdin
43
+ # Convert to array so it can be iterated multiple times (e.g., for headers)
44
+ @stdin.each_line(chomp: true).to_a
45
+ else
46
+ # Interactive mode with no arguments - use current time
47
+ [Time.now.to_s]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require_relative 'colors'
5
+
6
+ module Zone
7
+ module Logging
8
+ module_function
9
+
10
+ def build(verbose:)
11
+ Logger.new($stderr).tap do |l|
12
+ l.formatter = formatter
13
+ l.level = verbose ? Logger::DEBUG : Logger::WARN
14
+ end
15
+ end
16
+
17
+ def formatter
18
+ ->(severity, _datetime, _progname, message) {
19
+ prefix, color = log_style(severity)
20
+ formatted = "#{prefix} #{message}"
21
+ colored = color ? Colors.colors($stderr).send(color, formatted) : formatted
22
+ "#{colored}\n"
23
+ }
24
+ end
25
+ private_class_method :formatter
26
+
27
+ def log_style(severity)
28
+ case severity
29
+ in "INFO" then ["→", :cyan]
30
+ in "WARN" then ["⚠", :yellow]
31
+ in "ERROR" then ["✗", :red]
32
+ in "DEBUG" then ["DEBUG:", nil]
33
+ else ["?", nil]
34
+ end
35
+ end
36
+ private_class_method :log_style
37
+ end
38
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Zone
6
+ class Options < OptionParser
7
+ attr_reader :field, :delimiter, :zone, :format, :color, :headers
8
+ attr_accessor :verbose
9
+
10
+ def initialize
11
+ super
12
+
13
+ @field = nil
14
+ @delimiter = nil
15
+ @zone = 'local'
16
+ @format = { pretty: 1 }
17
+ @color = 'auto'
18
+ @headers = false
19
+ @verbose = false
20
+
21
+ setup_options
22
+ end
23
+
24
+ #
25
+ # Validate options and their combinations.
26
+ #
27
+ # @return [self]
28
+ # Returns self for method chaining
29
+ #
30
+ # @raise [ArgumentError]
31
+ # If validation fails
32
+ #
33
+ def validate!
34
+ validate_timezone!
35
+ validate_field_mode!
36
+ self
37
+ end
38
+
39
+ private
40
+
41
+ def validate_timezone!
42
+ case @zone
43
+ in 'utc' | 'UTC' | 'local'
44
+ # Valid special timezone keywords
45
+ else
46
+ tz = Zone.find(@zone)
47
+ raise ArgumentError, "Could not find timezone '#{@zone}'" if tz.nil?
48
+ end
49
+ end
50
+
51
+ def validate_field_mode!
52
+ if @field && !@delimiter
53
+ raise ArgumentError, "--field requires --delimiter\nExample: zone --field 2 --delimiter ','"
54
+ end
55
+
56
+ if @headers && !@field
57
+ raise ArgumentError, "--headers requires --field"
58
+ end
59
+ end
60
+
61
+ def setup_options
62
+ self.banner = "Usage: zone [options] [timestamps...]"
63
+
64
+ separator ""
65
+ separator "Modes:"
66
+ separator " Pattern Mode (default): Finds and converts timestamps in arbitrary text"
67
+ separator " Example: echo 'Event at 2025-01-15T10:30:00Z' | zone"
68
+ separator ""
69
+ separator " Field Mode: Converts specific field in delimited data (requires --field and --delimiter)"
70
+ separator " Example: echo 'alice,1736937000,active' | zone --field 2 --delimiter ','"
71
+
72
+ separator ""
73
+ separator "Output Formats:"
74
+ on '--iso8601', 'Output in ISO 8601' do
75
+ @format = :to_iso8601
76
+ end
77
+
78
+ on '-f', '--strftime FORMAT', 'Output format using strftime' do |fmt|
79
+ @format = { strftime: fmt }
80
+ end
81
+
82
+ on '-p', '--pretty [STYLE]', Integer, 'Pretty format (1=12hr, 2=24hr, 3=ISO-compact, default: 1)' do |style|
83
+ style ||= 1
84
+ unless [1, 2, 3].include?(style)
85
+ raise ArgumentError, "Invalid pretty format -p#{style} (must be 1, 2, or 3)"
86
+ end
87
+ @format = { pretty: style }
88
+ end
89
+
90
+ on '--unix', 'Output as Unix timestamp' do
91
+ @format = :to_unix
92
+ end
93
+
94
+ separator ""
95
+ separator "Timezones:"
96
+ on '--require STRING', 'Require external library like "active_support" for more powerful parsing' do |requirement|
97
+ require requirement
98
+ end
99
+
100
+ on '--zone TZ', '-z', 'Convert to time zone (default: local)' do |tz|
101
+ @zone = tz
102
+ end
103
+
104
+ on '--local', 'Convert to local time zone (alias for --zone local)' do
105
+ @zone = 'local'
106
+ end
107
+
108
+ on '--utc', 'Convert to UTC time zone (alias for --zone UTC)' do
109
+ @zone = 'utc'
110
+ end
111
+
112
+ separator ""
113
+ separator "Field Mode Options:"
114
+ on '--field FIELD', String, 'Field index or name to convert (requires --delimiter)' do |field|
115
+ @field = field
116
+ end
117
+
118
+ on '-d', '--delimiter PATTERN', 'Field separator (string or /regex/, required for --field)' do |delim|
119
+ @delimiter = delim
120
+ end
121
+
122
+ on '--headers', 'Skip first line as headers (requires --field)' do
123
+ @headers = true
124
+ end
125
+
126
+ separator ""
127
+ separator "Other:"
128
+ on '--color MODE', ['auto', 'always', 'never'], 'Colorize output (auto, always, never, default: auto)' do |mode|
129
+ @color = mode
130
+ end
131
+
132
+ on '--verbose', '-v', 'Enable verbose/debug output' do
133
+ @verbose = true
134
+ end
135
+
136
+ on '--help', '-h', 'Show this help message' do
137
+ puts self
138
+ exit
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'colors'
4
+
5
+ module Zone
6
+ class Output
7
+ def initialize(color_mode: 'auto', stream: $stdout)
8
+ @stream = stream
9
+ @colors = colorize(color_mode)
10
+ end
11
+
12
+ def puts(text)
13
+ @stream.puts(text)
14
+ end
15
+
16
+ def puts_highlighted(text, highlight:)
17
+ highlight_str = highlight.to_s
18
+ output = text.sub(highlight_str, @colors.cyan(highlight_str))
19
+ @stream.puts(output)
20
+ end
21
+
22
+ def colorize_timestamp(timestamp)
23
+ @colors.cyan(timestamp)
24
+ end
25
+
26
+ private
27
+
28
+ def colorize(mode)
29
+ case mode
30
+ when 'always'
31
+ Colors::ANSI
32
+ when 'never'
33
+ Colors::PlainText
34
+ when 'auto'
35
+ Colors.colors(@stream)
36
+ end
37
+ end
38
+ end
39
+ end