ace-b36ts 0.13.0

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
+ require "json"
4
+ require "time"
5
+
6
+ module Ace
7
+ module B36ts
8
+ module Commands
9
+ # Command to encode a timestamp to a compact ID
10
+ #
11
+ # @example Usage
12
+ # EncodeCommand.execute("2025-01-06 12:30:00")
13
+ # # => "i50jj3"
14
+ #
15
+ class EncodeCommand
16
+ class << self
17
+ # Execute the encode command
18
+ #
19
+ # @param time_string [String] Time string to encode (various formats supported)
20
+ # @param options [Hash] Command options
21
+ # @option options [Integer] :year_zero Override year_zero config
22
+ # @option options [String] :format Output format
23
+ # @option options [Boolean] :quiet Suppress config summary output
24
+ # @return [Integer] Exit code (0 for success, 1 for error)
25
+ def execute(time_string, options = {})
26
+ time = parse_time(time_string)
27
+ config = Molecules::ConfigResolver.resolve(options)
28
+
29
+ display_config_summary("encode", config, options)
30
+
31
+ # Validate mutually exclusive options
32
+ if options[:split]
33
+ if options[:format]
34
+ raise ArgumentError, "--split and --format are mutually exclusive"
35
+ end
36
+ if options[:count]
37
+ raise ArgumentError, "--count and --split are mutually exclusive"
38
+ end
39
+
40
+ levels = parse_split_levels(options[:split])
41
+ output = Atoms::CompactIdEncoder.encode_split(
42
+ time,
43
+ levels: levels,
44
+ year_zero: config[:year_zero],
45
+ alphabet: config[:alphabet]
46
+ )
47
+
48
+ if options[:path_only]
49
+ puts output[:path]
50
+ elsif options[:json]
51
+ puts JSON.generate(output.transform_keys(&:to_s))
52
+ else
53
+ display_split_output(levels, output)
54
+ end
55
+ else
56
+ # Get format from options or config (default: :"2sec")
57
+ # Normalize hyphens to underscores for CLI compatibility (e.g., high-8 -> high_8)
58
+ format = options[:format]
59
+ format = format.to_s.tr("-", "_").to_sym if format
60
+ format ||= config[:default_format]&.to_sym || :"2sec"
61
+
62
+ if options[:count]
63
+ count = options[:count].to_i
64
+ ids = Atoms::CompactIdEncoder.encode_sequence(
65
+ time,
66
+ count: count,
67
+ format: format,
68
+ year_zero: config[:year_zero],
69
+ alphabet: config[:alphabet]
70
+ )
71
+
72
+ if options[:json]
73
+ puts JSON.generate(ids)
74
+ else
75
+ ids.each { |id| puts id }
76
+ end
77
+ else
78
+ compact_id = Atoms::CompactIdEncoder.encode_with_format(
79
+ time,
80
+ format: format,
81
+ year_zero: config[:year_zero],
82
+ alphabet: config[:alphabet]
83
+ )
84
+
85
+ puts compact_id
86
+ end
87
+ end
88
+ 0
89
+ rescue ArgumentError => e
90
+ warn "Error: #{e.message}"
91
+ raise
92
+ rescue => e
93
+ warn "Error encoding timestamp: #{e.message}"
94
+ warn e.backtrace.first(5).join("\n") if Ace::B36ts.debug?
95
+ raise
96
+ end
97
+
98
+ private
99
+
100
+ # Parse various time string formats
101
+ #
102
+ # @param time_string [String] Time string to parse
103
+ # @return [Time] Parsed time
104
+ # @raise [ArgumentError] If format is unrecognized
105
+ def parse_time(time_string)
106
+ return Time.now.utc if time_string.nil? || time_string.empty? || time_string == "now"
107
+
108
+ # Check for legacy timestamp format FIRST (YYYYMMDD-HHMMSS)
109
+ # Time.parse incorrectly parses this format (treats -HHMMSS as timezone offset)
110
+ if Atoms::Formats.timestamp?(time_string)
111
+ return Atoms::Formats.parse_timestamp(time_string)
112
+ end
113
+
114
+ parsed = Time.parse(time_string)
115
+ return parsed if has_explicit_timezone?(time_string)
116
+
117
+ # Treat naïve timestamps as UTC to avoid local-time offsets on date-only
118
+ # inputs and systems with non-UTC defaults.
119
+ Time.utc(parsed.year, parsed.month, parsed.day, parsed.hour, parsed.min, parsed.sec, parsed.nsec)
120
+ rescue ArgumentError
121
+ raise ArgumentError, "Cannot parse time: #{time_string}"
122
+ end
123
+
124
+ def has_explicit_timezone?(time_string)
125
+ value = time_string.to_s
126
+ return true if value.match?(%r{[+-]\d{2}:?\d{2}\b})
127
+ return true if value.match?(%r{(?:\A|[[:space:]])(?:Z|UTC|GMT)\b}i)
128
+
129
+ false
130
+ end
131
+
132
+ # Display configuration summary (unless quiet mode)
133
+ #
134
+ # @param command [String] Command name
135
+ # @param config [Hash] Resolved configuration
136
+ # @param options [Hash] Command options
137
+ def display_config_summary(command, config, options)
138
+ return if options[:quiet] || options[:path_only] || options[:json]
139
+
140
+ require "ace/core"
141
+ Ace::Core::Atoms::ConfigSummary.display(
142
+ command: command,
143
+ config: config,
144
+ defaults: Molecules::ConfigResolver::FALLBACK_DEFAULTS,
145
+ options: options,
146
+ quiet: false
147
+ )
148
+ end
149
+
150
+ # Normalize split levels from string or array
151
+ #
152
+ # @param levels [String, Array<Symbol>] Split level list
153
+ # @return [Array<Symbol>] Normalized levels
154
+ def parse_split_levels(levels)
155
+ list = levels.is_a?(String) ? levels.split(",") : Array(levels)
156
+ list.map { |level| level.to_s.strip }
157
+ .reject(&:empty?)
158
+ .map(&:to_sym)
159
+ end
160
+
161
+ # Display split output in key/value format
162
+ #
163
+ # @param levels [Array<Symbol>] Split levels in order
164
+ # @param output [Hash] Split output hash
165
+ def display_split_output(levels, output)
166
+ lines = []
167
+ levels.each do |level|
168
+ lines << "#{level}: #{output[level]}"
169
+ end
170
+ lines << "rest: #{output[:rest]}"
171
+ lines << "path: #{output[:path]}"
172
+ lines << "full: #{output[:full]}"
173
+ puts lines.join("\n")
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/format_specs"
4
+
5
+ module Ace
6
+ module B36ts
7
+ module Molecules
8
+ # Resolves configuration for ace-b36ts using the ace-config cascade.
9
+ #
10
+ # Configuration sources (in order of precedence):
11
+ # 1. Runtime options (passed directly to methods)
12
+ # 2. Project config (.ace/b36ts/config.yml)
13
+ # 3. User config (~/.ace/b36ts/config.yml)
14
+ # 4. Gem defaults (.ace-defaults/b36ts/config.yml)
15
+ #
16
+ # @example Get resolved configuration
17
+ # config = ConfigResolver.resolve
18
+ # config[:year_zero] # => 2000
19
+ # config[:alphabet] # => "0123456789abcdefghijklmnopqrstuvwxyz"
20
+ #
21
+ # @example Override with runtime options
22
+ # config = ConfigResolver.resolve(year_zero: 2025)
23
+ # config[:year_zero] # => 2025
24
+ #
25
+ module ConfigResolver
26
+ # Fallback defaults (used only if .ace-defaults files cannot be loaded)
27
+ FALLBACK_DEFAULTS = {
28
+ year_zero: 2000,
29
+ alphabet: "0123456789abcdefghijklmnopqrstuvwxyz",
30
+ default_format: :"2sec"
31
+ }.freeze
32
+
33
+ class << self
34
+ # Resolve configuration with optional overrides
35
+ #
36
+ # Uses Ace::Support::Config::Models::Config.wrap for proper merging per ADR-022.
37
+ #
38
+ # @param overrides [Hash] Runtime configuration overrides
39
+ # @return [Hash] Merged configuration
40
+ # @raise [ArgumentError] If configuration values are invalid
41
+ def resolve(overrides = {})
42
+ base_config = load_config
43
+
44
+ # Apply runtime overrides (symbolize keys, skip nil values)
45
+ symbolized_overrides = {}
46
+ overrides.each do |key, value|
47
+ symbolized_overrides[key.to_sym] = value unless value.nil?
48
+ end
49
+
50
+ # Merge base config with runtime overrides
51
+ config = Ace::Support::Config::Models::Config.wrap(
52
+ base_config,
53
+ symbolized_overrides,
54
+ source: "ace-b36ts"
55
+ )
56
+
57
+ # Validate the merged configuration
58
+ validate_config!(config)
59
+
60
+ config
61
+ end
62
+
63
+ # Get the year_zero value from configuration
64
+ #
65
+ # @param override [Integer, nil] Optional runtime override
66
+ # @return [Integer] The year_zero value
67
+ def year_zero(override = nil)
68
+ override || resolve[:year_zero]
69
+ end
70
+
71
+ # Get the alphabet value from configuration
72
+ #
73
+ # @param override [String, nil] Optional runtime override
74
+ # @return [String] The alphabet value
75
+ def alphabet(override = nil)
76
+ override || resolve[:alphabet]
77
+ end
78
+
79
+ # Get the default_format value from configuration
80
+ #
81
+ # @param override [Symbol, String, nil] Optional runtime override
82
+ # @return [Symbol] The default_format value (always a symbol)
83
+ def default_format(override = nil)
84
+ value = override || resolve[:default_format]
85
+ value.is_a?(String) ? value.to_sym : value
86
+ end
87
+
88
+ # Reset cached configuration (useful for testing)
89
+ def reset!
90
+ @config = nil
91
+ end
92
+
93
+ private
94
+
95
+ # Validate configuration values
96
+ #
97
+ # @param config [Hash] Configuration to validate
98
+ # @raise [ArgumentError] If configuration values are invalid
99
+ def validate_config!(config)
100
+ alphabet = config[:alphabet]
101
+ year_zero = config[:year_zero]
102
+ default_format = config[:default_format]
103
+
104
+ unless alphabet.is_a?(String) && alphabet.length == 36
105
+ raise ArgumentError, "alphabet must be exactly 36 characters, got #{alphabet&.length || "nil"}"
106
+ end
107
+
108
+ # Verify all characters in the alphabet are unique
109
+ unless alphabet.chars.uniq.length == 36
110
+ raise ArgumentError, "alphabet must contain 36 unique characters (duplicates found)"
111
+ end
112
+
113
+ unless year_zero.is_a?(Integer) && year_zero.between?(1900, 2100)
114
+ raise ArgumentError, "year_zero must be between 1900-2100, got #{year_zero.inspect}"
115
+ end
116
+
117
+ # Validate default_format is a supported format
118
+ format_sym = default_format.is_a?(String) ? default_format.to_sym : default_format
119
+ unless format_sym.nil? || Ace::B36ts::Atoms::FormatSpecs.valid_format?(format_sym)
120
+ raise ArgumentError, "default_format must be one of #{Ace::B36ts::Atoms::FormatSpecs.all_formats.join(", ")}, got #{default_format.inspect}"
121
+ end
122
+ end
123
+
124
+ # Load configuration using ace-config cascade
125
+ #
126
+ # @return [Hash] Loaded configuration with symbolized keys
127
+ def load_config
128
+ @config ||= begin
129
+ gem_root = Gem.loaded_specs["ace-b36ts"]&.gem_dir ||
130
+ File.expand_path("../../../..", __dir__)
131
+
132
+ resolver = Ace::Support::Config.create(
133
+ config_dir: ".ace",
134
+ defaults_dir: ".ace-defaults",
135
+ gem_path: gem_root
136
+ )
137
+
138
+ loaded_config = resolver.resolve_namespace("b36ts")
139
+ # loaded_config.data is already namespaced (no need to fetch "b36ts" key again)
140
+ user_config = symbolize_keys(loaded_config.data)
141
+
142
+ # Merge user config with fallback defaults (user values take precedence)
143
+ FALLBACK_DEFAULTS.merge(user_config)
144
+ rescue Psych::SyntaxError => e
145
+ config_path = File.join(Dir.pwd, ".ace", "b36ts", "config.yml")
146
+ warn "Error: Failed to parse #{config_path}: #{e.message}" if Ace::B36ts.debug?
147
+ warn " Check YAML syntax at line #{e.line}, column #{e.column}" if Ace::B36ts.debug? && e.line
148
+ FALLBACK_DEFAULTS.dup
149
+ rescue => e
150
+ warn "Warning: Could not load ace-b36ts config: #{e.message}" if Ace::B36ts.debug?
151
+ FALLBACK_DEFAULTS.dup
152
+ end
153
+ end
154
+
155
+ # Convert string keys to symbols
156
+ #
157
+ # @param hash [Hash] Hash with string or symbol keys
158
+ # @return [Hash] Hash with symbol keys
159
+ def symbolize_keys(hash)
160
+ hash.transform_keys(&:to_sym)
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module B36ts
5
+ # Version 0.2.0: Gem renamed from ace-timestamp to ace-support-timestamp
6
+ # Namespace changed from Ace::Timestamp to Ace::Support::Timestamp
7
+ # Version 0.6.0: Gem renamed from ace-support-timestamp to ace-b36ts
8
+ # Namespace changed from Ace::Support::Timestamp to Ace::B36ts
9
+ VERSION = "0.13.0"
10
+ end
11
+ end
data/lib/ace/b36ts.rb ADDED
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "b36ts/version"
4
+
5
+ # Load ace-config for configuration cascade management
6
+ require "ace/support/config"
7
+
8
+ # Atoms
9
+ require_relative "b36ts/atoms/compact_id_encoder"
10
+ require_relative "b36ts/atoms/formats"
11
+ require_relative "b36ts/atoms/format_specs"
12
+
13
+ # Molecules
14
+ require_relative "b36ts/molecules/config_resolver"
15
+
16
+ # Commands
17
+ require_relative "b36ts/commands/encode_command"
18
+ require_relative "b36ts/commands/decode_command"
19
+ require_relative "b36ts/commands/config_command"
20
+
21
+ # CLI
22
+ require_relative "b36ts/cli"
23
+
24
+ module Ace
25
+ # B36ts module providing Base36 compact ID generation for timestamps.
26
+ #
27
+ # This module provides a 6-character Base36 encoding for timestamps that
28
+ # replaces traditional 14-character timestamp formats (YYYYMMDD-HHMMSS).
29
+ #
30
+ # @example Quick encoding
31
+ # Ace::B36ts.encode(Time.now)
32
+ # # => "i50jj3"
33
+ #
34
+ # @example Quick decoding
35
+ # Ace::B36ts.decode("i50jj3")
36
+ # # => 2025-01-06 12:30:00 UTC
37
+ #
38
+ # @example Format detection
39
+ # Ace::B36ts.detect_format("i50jj3") # => :"2sec"
40
+ # Ace::B36ts.detect_format("20250106-123000") # => :timestamp
41
+ #
42
+ module B36ts
43
+ class Error < StandardError; end
44
+
45
+ # Check if debug mode is enabled
46
+ # @return [Boolean] True if debug mode is enabled
47
+ def self.debug?
48
+ ENV["ACE_DEBUG"] == "1" || ENV["DEBUG"] == "1"
49
+ end
50
+
51
+ # Encode a Time object to a compact ID (convenience method)
52
+ #
53
+ # @param time [Time] The time to encode
54
+ # @param format [Symbol, nil] Output format (default: :"2sec")
55
+ # Supported formats: :month (2 chars), :week (3 chars), :day (3 chars),
56
+ # :"40min" (4 chars), :"2sec" (6 chars), :"50ms" (7 chars), :ms (8 chars)
57
+ # @param year_zero [Integer, nil] Optional year_zero override
58
+ # @return [String] Compact ID (length varies by format)
59
+ def self.encode(time, format: nil, year_zero: nil)
60
+ config = Molecules::ConfigResolver.resolve(year_zero: year_zero)
61
+ effective_format = format || config[:default_format]&.to_sym || :"2sec"
62
+
63
+ Atoms::CompactIdEncoder.encode_with_format(
64
+ time,
65
+ format: effective_format,
66
+ year_zero: config[:year_zero],
67
+ alphabet: config[:alphabet]
68
+ )
69
+ end
70
+
71
+ # Decode a compact ID to a Time object (convenience method)
72
+ #
73
+ # @param compact_id [String] The compact ID to decode
74
+ # @param format [Symbol, nil] Format of the ID (default: :"2sec" for 6-char, or auto-detect)
75
+ # @param year_zero [Integer, nil] Optional year_zero override
76
+ # @return [Time] Decoded time in UTC
77
+ def self.decode(compact_id, format: nil, year_zero: nil)
78
+ config = Molecules::ConfigResolver.resolve(year_zero: year_zero)
79
+
80
+ if format
81
+ Atoms::CompactIdEncoder.decode_with_format(
82
+ compact_id,
83
+ format: format,
84
+ year_zero: config[:year_zero],
85
+ alphabet: config[:alphabet]
86
+ )
87
+ else
88
+ # Default to 2sec format for backward compatibility with 6-char IDs
89
+ Atoms::CompactIdEncoder.decode(
90
+ compact_id,
91
+ year_zero: config[:year_zero],
92
+ alphabet: config[:alphabet]
93
+ )
94
+ end
95
+ end
96
+
97
+ # Decode a compact ID with automatic format detection (convenience method)
98
+ #
99
+ # Automatically detects the format based on ID length and value ranges:
100
+ # - 2 chars: month format
101
+ # - 3 chars: day or week format (auto-detected by 3rd char value)
102
+ # - 4 chars: 40min format
103
+ # - 6 chars: 2sec format
104
+ # - 7 chars: 50ms format
105
+ # - 8 chars: ms format
106
+ #
107
+ # @param compact_id [String] The compact ID to decode (2-8 characters)
108
+ # @param year_zero [Integer, nil] Optional year_zero override
109
+ # @return [Time] Decoded time in UTC
110
+ # @raise [ArgumentError] If format cannot be detected
111
+ def self.decode_auto(compact_id, year_zero: nil)
112
+ config = Molecules::ConfigResolver.resolve(year_zero: year_zero)
113
+ Atoms::CompactIdEncoder.decode_auto(
114
+ compact_id,
115
+ year_zero: config[:year_zero],
116
+ alphabet: config[:alphabet]
117
+ )
118
+ end
119
+
120
+ # Encode a Time object into split components for hierarchical paths
121
+ #
122
+ # @param time [Time] The time to encode
123
+ # @param levels [Array<Symbol>, String] Split levels (month, week, day, block)
124
+ # @param path_only [Boolean] Return only the path string
125
+ # @param year_zero [Integer, nil] Optional year_zero override
126
+ # @return [Hash, String] Split component hash or path string
127
+ def self.encode_split(time, levels:, path_only: false, year_zero: nil)
128
+ config = Molecules::ConfigResolver.resolve(year_zero: year_zero)
129
+ result = Atoms::CompactIdEncoder.encode_split(
130
+ time,
131
+ levels: levels,
132
+ year_zero: config[:year_zero],
133
+ alphabet: config[:alphabet]
134
+ )
135
+
136
+ path_only ? result[:path] : result
137
+ end
138
+
139
+ # Decode a hierarchical split path into a Time object
140
+ #
141
+ # @param path_string [String] Split path string
142
+ # @param year_zero [Integer, nil] Optional year_zero override
143
+ # @return [Time] Decoded time in UTC
144
+ def self.decode_path(path_string, year_zero: nil)
145
+ config = Molecules::ConfigResolver.resolve(year_zero: year_zero)
146
+ Atoms::CompactIdEncoder.decode_path(
147
+ path_string,
148
+ year_zero: config[:year_zero],
149
+ alphabet: config[:alphabet]
150
+ )
151
+ end
152
+
153
+ # Validate if a string is a valid 6-character compact ID (legacy method)
154
+ #
155
+ # NOTE: This method only validates 6-character "2sec" format IDs.
156
+ # For validating IDs of any format, use valid_any_format? instead.
157
+ #
158
+ # @param compact_id [String] The string to validate
159
+ # @return [Boolean] True if valid 6-char compact ID format
160
+ # @see valid_any_format? for validating all format lengths
161
+ def self.valid?(compact_id)
162
+ Atoms::CompactIdEncoder.valid?(compact_id)
163
+ end
164
+
165
+ # Validate if a string is a valid compact ID of any format
166
+ #
167
+ # Supports all 7 formats: month (2 chars), week (3 chars), day (3 chars),
168
+ # 40min (4 chars), 2sec (6 chars), 50ms (7 chars), ms (8 chars).
169
+ #
170
+ # @param compact_id [String] The string to validate (2-8 characters)
171
+ # @return [Boolean] True if valid compact ID of any format
172
+ def self.valid_any_format?(compact_id)
173
+ Atoms::CompactIdEncoder.valid_any_format?(compact_id)
174
+ end
175
+
176
+ # Detect the format of a timestamp string
177
+ #
178
+ # @param value [String] The timestamp string
179
+ # @return [Symbol, nil] :"2sec", :timestamp, or nil
180
+ def self.detect_format(value)
181
+ Atoms::Formats.detect(value)
182
+ end
183
+
184
+ # Generate a compact ID for the current time (convenience method)
185
+ #
186
+ # @param year_zero [Integer, nil] Optional year_zero override
187
+ # @return [String] 6-character compact ID for current time
188
+ def self.now(year_zero: nil)
189
+ encode(Time.now.utc, year_zero: year_zero)
190
+ end
191
+
192
+ # Load configuration using ace-config cascade
193
+ # @return [Hash] Configuration hash
194
+ def self.config
195
+ Molecules::ConfigResolver.resolve
196
+ end
197
+
198
+ # Reset configuration cache (useful for testing)
199
+ def self.reset_config!
200
+ Molecules::ConfigResolver.reset!
201
+ end
202
+ end
203
+ end