familia 2.0.0.pre10 → 2.0.0.pre12

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -12
  3. data/CLAUDE.md +4 -54
  4. data/Gemfile.lock +1 -1
  5. data/changelog.d/README.md +45 -34
  6. data/docs/archive/FAMILIA_RELATIONSHIPS.md +1 -1
  7. data/docs/archive/FAMILIA_UPDATE.md +1 -1
  8. data/docs/archive/README.md +15 -19
  9. data/docs/guides/Home.md +1 -1
  10. data/docs/guides/Implementation-Guide.md +1 -1
  11. data/docs/guides/relationships-methods.md +1 -1
  12. data/docs/migrating/.gitignore +2 -0
  13. data/docs/migrating/v2.0.0-pre.md +84 -0
  14. data/docs/migrating/v2.0.0-pre11.md +255 -0
  15. data/docs/migrating/v2.0.0-pre12.md +306 -0
  16. data/docs/migrating/v2.0.0-pre5.md +110 -0
  17. data/docs/migrating/v2.0.0-pre6.md +154 -0
  18. data/docs/migrating/v2.0.0-pre7.md +222 -0
  19. data/docs/overview.md +6 -7
  20. data/{examples/redis_command_validation_example.rb → docs/reference/auditing_database_commands.rb} +29 -32
  21. data/examples/{bit_encoding_integration.rb → permissions.rb} +30 -27
  22. data/examples/{relationships_basic.rb → relationships.rb} +2 -3
  23. data/examples/safe_dump.rb +281 -0
  24. data/familia.gemspec +4 -4
  25. data/lib/familia/base.rb +52 -0
  26. data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
  27. data/lib/familia/errors.rb +2 -0
  28. data/lib/familia/features/autoloader.rb +57 -0
  29. data/lib/familia/features/external_identifier.rb +310 -0
  30. data/lib/familia/features/object_identifier.rb +307 -0
  31. data/lib/familia/features/safe_dump.rb +66 -72
  32. data/lib/familia/features.rb +93 -5
  33. data/lib/familia/horreum/subclass/definition.rb +47 -3
  34. data/lib/familia/secure_identifier.rb +51 -75
  35. data/lib/familia/verifiable_identifier.rb +162 -0
  36. data/lib/familia/version.rb +1 -1
  37. data/lib/familia.rb +1 -0
  38. data/setup.cfg +1 -8
  39. data/try/core/secure_identifier_try.rb +47 -18
  40. data/try/core/verifiable_identifier_try.rb +171 -0
  41. data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
  42. data/try/features/feature_improvements_try.rb +126 -0
  43. data/try/features/{object_identifiers/object_identifiers_integration_try.rb → object_identifier/object_identifier_integration_try.rb} +28 -30
  44. data/try/features/{object_identifiers/object_identifiers_try.rb → object_identifier/object_identifier_try.rb} +13 -13
  45. data/try/features/real_feature_integration_try.rb +7 -6
  46. data/try/features/safe_dump/safe_dump_try.rb +8 -9
  47. data/try/helpers/test_helpers.rb +17 -17
  48. metadata +30 -22
  49. data/changelog.d/fragments/.keep +0 -0
  50. data/changelog.d/template.md.j2 +0 -29
  51. data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +0 -120
  52. data/lib/familia/features/external_identifiers.rb +0 -111
  53. data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +0 -91
  54. data/lib/familia/features/object_identifiers.rb +0 -194
@@ -1,12 +1,18 @@
1
1
  # lib/familia/features/safe_dump.rb
2
2
 
3
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
4
+ #
5
+ # Class instance variables are used here for feature configuration
6
+ # (e.g., @dump_method, @load_method). These are set once and not mutated
7
+ # at runtime, so thread safety is not a concern for this feature.
8
+ #
3
9
  module Familia::Features
4
10
  # SafeDump is a mixin that allows models to define a list of fields that are
5
11
  # safe to dump. This is useful for serializing objects to JSON or other
6
12
  # formats where you want to ensure that only certain fields are exposed.
7
13
  #
8
- # To use SafeDump, include it in your model and define a list of fields that
9
- # are safe to dump. The fields can be either symbols or hashes. If a field is
14
+ # To use SafeDump, include it in your model and use the DSL methods to define
15
+ # safe dump fields. The fields can be either symbols or hashes. If a field is
10
16
  # a symbol, the method with the same name will be called on the object to
11
17
  # retrieve the value. If the field is a hash, the key is the field name and
12
18
  # the value is a lambda that will be called with the object as an argument.
@@ -19,97 +25,85 @@ module Familia::Features
19
25
  #
20
26
  # feature :safe_dump
21
27
  #
22
- # @safe_dump_fields = [
23
- # :objid,
24
- # :updated,
25
- # :created,
26
- # { :active => ->(obj) { obj.active? } }
27
- # ]
28
+ # safe_dump_field :objid
29
+ # safe_dump_field :updated
30
+ # safe_dump_field :created
31
+ # safe_dump_field :active, ->(obj) { obj.active? }
28
32
  #
29
- # Internally, all fields are normalized to the hash syntax and stored in
30
- # @safe_dump_field_map. `SafeDump.safe_dump_fields` returns only the list
31
- # of symbols in the order they were defined. From the example above, it would
32
- # return `[:objid, :updated, :created, :active]`.
33
- #
34
- # Standalone Usage:
35
- #
36
- # You can also use SafeDump by including it in your model and defining the
37
- # safe dump fields using the class instance variable `@safe_dump_fields`.
38
- #
39
- # Example:
33
+ # Alternatively, you can define multiple fields at once:
40
34
  #
41
- # class MyModel
42
- # include Familia::Features::SafeDump
35
+ # safe_dump_fields :objid, :updated, :created,
36
+ # { active: ->(obj) { obj.active? } }
43
37
  #
44
- # @safe_dump_fields = [
45
- # :id, :name, { active: ->(obj) { obj.active? } }
46
- # ]
47
- # end
38
+ # Internally, all fields are normalized to the hash syntax and stored in
39
+ # @safe_dump_field_map. `SafeDump.safe_dump_fields` returns only the list
40
+ # of symbols in the order they were defined.
48
41
  #
49
42
  module SafeDump
50
43
  @dump_method = :to_json
51
44
  @load_method = :from_json
52
45
 
53
- @safe_dump_fields = []
54
- @safe_dump_field_map = {}
55
-
56
46
  def self.included(base)
57
- Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
47
+ Familia.trace(:LOADED, self, base, caller(1..1)) if Familia.debug?
58
48
  base.extend ClassMethods
59
49
 
60
- # Optionally define safe_dump_fields in the class to make
61
- # sure we always have an array to work with.
62
- base.instance_variable_set(:@safe_dump_fields, []) unless base.instance_variable_defined?(:@safe_dump_fields)
63
-
64
- # Ditto for the field map
65
- return if base.instance_variable_defined?(:@safe_dump_field_map)
66
-
50
+ # Initialize the safe dump field map
67
51
  base.instance_variable_set(:@safe_dump_field_map, {})
68
52
  end
69
53
 
54
+ # SafeDump::ClassMethods
55
+ #
56
+ # These methods become available on the model class
70
57
  module ClassMethods
71
- def set_safe_dump_fields(*fields)
72
- @safe_dump_fields = fields
58
+ # Define a single safe dump field
59
+ # @param field_name [Symbol] The name of the field
60
+ # @param callable [Proc, nil] Optional callable to transform the value
61
+ def safe_dump_field(field_name, callable = nil)
62
+ @safe_dump_field_map ||= {}
63
+
64
+ field_name = field_name.to_sym
65
+ field_value = callable || lambda { |obj|
66
+ if obj.respond_to?(:[]) && obj[field_name]
67
+ obj[field_name] # Familia::DataType classes
68
+ elsif obj.respond_to?(field_name)
69
+ obj.send(field_name) # Regular method calls
70
+ end
71
+ }
72
+
73
+ @safe_dump_field_map[field_name] = field_value
73
74
  end
74
75
 
75
- # `SafeDump.safe_dump_fields` returns only the list
76
- # of symbols in the order they were defined.
77
- def safe_dump_fields
78
- @safe_dump_fields.map do |field|
79
- field.is_a?(Symbol) ? field : field.keys.first
76
+ # Define multiple safe dump fields at once
77
+ # @param fields [Array] Mixed array of symbols and hashes
78
+ def safe_dump_fields(*fields)
79
+ # If no arguments, return field names (getter behavior)
80
+ return safe_dump_field_names if fields.empty?
81
+
82
+ # Otherwise, define fields (setter behavior)
83
+ fields.each do |field|
84
+ if field.is_a?(Symbol)
85
+ safe_dump_field(field)
86
+ elsif field.is_a?(Hash)
87
+ field.each do |name, callable|
88
+ safe_dump_field(name, callable)
89
+ end
90
+ end
80
91
  end
81
92
  end
82
93
 
83
- # `SafeDump.safe_dump_field_map` returns the field map
84
- # that is used to dump the fields. The keys are the
85
- # field names and the values are callables that will
86
- # expect to receive the instance object as an argument.
87
- #
88
- # The map is cached on the first call to this method.
89
- #
94
+ # Returns an array of safe dump field names in the order they were defined
95
+ def safe_dump_field_names
96
+ (@safe_dump_field_map || {}).keys
97
+ end
98
+
99
+ # Returns the field map used for dumping
90
100
  def safe_dump_field_map
91
- return @safe_dump_field_map if @safe_dump_field_map.any?
101
+ @safe_dump_field_map || {}
102
+ end
92
103
 
93
- # Operate directly on the @safe_dump_fields array to
94
- # build the map. This way we'll get the elements defined
95
- # in the hash syntax (i.e. since the safe_dump_fields getter
96
- # method returns only the symbols).
97
- @safe_dump_field_map = @safe_dump_fields.each_with_object({}) do |el, map|
98
- if el.is_a?(Symbol)
99
- field_name = el
100
- callable = lambda { |obj|
101
- if obj.respond_to?(:[]) && obj[field_name]
102
- obj[field_name] # Familia::DataType classes
103
- elsif obj.respond_to?(field_name)
104
- obj.send(field_name) # Onetime::Models::RedisHash classes via method_missing 😩
105
- end
106
- }
107
- else
108
- field_name = el.keys.first
109
- callable = el.values.first
110
- end
111
- map[field_name] = callable
112
- end
104
+ # Legacy method for setting safe dump fields (for backward compatibility)
105
+ def set_safe_dump_fields(*fields)
106
+ safe_dump_fields(*fields)
113
107
  end
114
108
  end
115
109
 
@@ -155,5 +149,5 @@ module Familia::Features
155
149
 
156
150
  Familia::Base.add_feature self, :safe_dump
157
151
  end
158
- # end SafeDump
159
152
  end
153
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
@@ -5,18 +5,108 @@ module Familia
5
5
 
6
6
  # Familia::Features
7
7
  #
8
+ # This module provides the feature system for Familia classes. Features are
9
+ # modular capabilities that can be mixed into classes with configurable options.
10
+ # Features provide a powerful way to:
11
+ #
12
+ # - **Add new methods**: Both class and instance methods can be added
13
+ # - **Override existing methods**: Extend or replace default behavior
14
+ # - **Add new fields**: Define additional data storage capabilities
15
+ # - **Manage complexity**: Large, complex model classes can use features to
16
+ # organize functionality into focused, reusable modules
17
+ #
18
+ # ## Feature Options Storage
19
+ #
20
+ # Feature options are stored **per-class** using class-level instance variables.
21
+ # This means each Familia::Horreum subclass maintains its own isolated set of
22
+ # feature options. When you enable a feature with options in different models,
23
+ # each model stores its own separate configuration without interference.
24
+ #
25
+ # ## Project Organization with Autoloader
26
+ #
27
+ # For large projects, use {Familia::Features::Autoloader} to automatically load
28
+ # project-specific features from a dedicated directory structure. This helps
29
+ # organize complex models by separating features into individual files.
30
+ #
31
+ # @example Different models with different feature options
32
+ # class UserModel < Familia::Horreum
33
+ # feature :object_identifier, generator: :uuid_v4
34
+ # end
35
+ #
36
+ # class SessionModel < Familia::Horreum
37
+ # feature :object_identifier, generator: :hex
38
+ # end
39
+ #
40
+ # UserModel.feature_options(:object_identifier) #=> {generator: :uuid_v4}
41
+ # SessionModel.feature_options(:object_identifier) #=> {generator: :hex}
42
+ #
43
+ # @example Using features for complexity management
44
+ # class ComplexModel < Familia::Horreum
45
+ # # Organize functionality using features
46
+ # feature :expiration # TTL management
47
+ # feature :safe_dump # API-safe serialization
48
+ # feature :relationships # CRUD operations for related objects
49
+ # feature :custom_validation # Project-specific validation logic
50
+ # feature :audit_trail # Change tracking
51
+ # end
52
+ #
53
+ # @example Project-specific features with autoloader
54
+ # # In your model file: app/models/customer.rb
55
+ # class Customer < Familia::Horreum
56
+ # module Features
57
+ # include Familia::Features::Autoloader
58
+ # # Automatically loads all .rb files from app/models/customer/features/
59
+ # end
60
+ # end
61
+ #
62
+ # @see Familia::Features::Autoloader For automatic feature loading
63
+ #
8
64
  module Features
9
65
  @features_enabled = nil
10
66
  attr_reader :features_enabled
11
67
 
68
+ # Enables a feature for the current class with optional configuration.
69
+ #
70
+ # Features are modular capabilities that can be mixed into Familia::Horreum
71
+ # classes. Each feature can be configured with options that are stored
72
+ # **per-class**, ensuring complete isolation between different models.
73
+ #
74
+ # @param feature_name [Symbol, String, nil] the name of the feature to enable.
75
+ # If nil, returns the list of currently enabled features.
76
+ # @param options [Hash] configuration options for the feature. These are
77
+ # stored per-class and do not interfere with other models' configurations.
78
+ # @return [Array, nil] the list of enabled features if feature_name is nil,
79
+ # otherwise nil
80
+ #
81
+ # @example Enable feature without options
82
+ # class User < Familia::Horreum
83
+ # feature :expiration
84
+ # end
85
+ #
86
+ # @example Enable feature with options (per-class storage)
87
+ # class User < Familia::Horreum
88
+ # feature :object_identifier, generator: :uuid_v4
89
+ # end
90
+ #
91
+ # class Session < Familia::Horreum
92
+ # feature :object_identifier, generator: :hex # Different options
93
+ # end
94
+ #
95
+ # # Each class maintains separate options:
96
+ # User.feature_options(:object_identifier) #=> {generator: :uuid_v4}
97
+ # Session.feature_options(:object_identifier) #=> {generator: :hex}
98
+ #
99
+ # @raise [Familia::Problem] if the feature is not supported
100
+ #
12
101
  def feature(feature_name = nil, **options)
13
102
  @features_enabled ||= []
14
103
 
15
104
  return features_enabled if feature_name.nil?
16
105
 
17
- # If there's a value provied check that it's a valid feature
106
+ # If there's a value provided check that it's a valid feature
18
107
  feature_name = feature_name.to_sym
19
- unless Familia::Base.features_available.key?(feature_name)
108
+ feature_class = Familia::Base.find_feature(feature_name, self)
109
+ unless feature_class
20
110
  raise Familia::Problem, "Unsupported feature: #{feature_name}"
21
111
  end
22
112
 
@@ -46,10 +136,8 @@ module Familia
46
136
  add_feature_options(feature_name, **options)
47
137
  end
48
138
 
49
- klass = Familia::Base.features_available[feature_name]
50
-
51
139
  # Extend the Familia::Base subclass (e.g. Customer) with the feature module
52
- include klass
140
+ include feature_class
53
141
 
54
142
  # NOTE: Do we want to extend Familia::DataType here? That would make it
55
143
  # possible to call safe_dump on relations fields (e.g. list, zset, hashkey).
@@ -237,10 +237,36 @@ module Familia
237
237
  field_types[field_type.name] = field_type
238
238
  end
239
239
 
240
- # Get feature options for a specific feature or all features
240
+ # Retrieves feature options for the current class.
241
241
  #
242
- # @param feature_name [Symbol, nil] The feature name to get options for
243
- # @return [Hash] The options hash for the feature, or empty hash if none
242
+ # Feature options are stored **per-class** in instance variables, ensuring
243
+ # complete isolation between different Familia::Horreum subclasses. Each
244
+ # class maintains its own @feature_options hash that does not interfere
245
+ # with other classes' configurations.
246
+ #
247
+ # @param feature_name [Symbol, String, nil] the name of the feature to get options for.
248
+ # If nil, returns the entire feature options hash for this class.
249
+ # @return [Hash] the feature options hash, either for a specific feature or all features
250
+ #
251
+ # @example Getting options for a specific feature
252
+ # class MyModel < Familia::Horreum
253
+ # feature :object_identifier, generator: :uuid_v4
254
+ # end
255
+ #
256
+ # MyModel.feature_options(:object_identifier) #=> {generator: :uuid_v4}
257
+ # MyModel.feature_options #=> {object_identifier: {generator: :uuid_v4}}
258
+ #
259
+ # @example Per-class isolation
260
+ # class UserModel < Familia::Horreum
261
+ # feature :object_identifier, generator: :uuid_v4
262
+ # end
263
+ #
264
+ # class SessionModel < Familia::Horreum
265
+ # feature :object_identifier, generator: :hex
266
+ # end
267
+ #
268
+ # UserModel.feature_options(:object_identifier) #=> {generator: :uuid_v4}
269
+ # SessionModel.feature_options(:object_identifier) #=> {generator: :hex}
244
270
  #
245
271
  def feature_options(feature_name = nil)
246
272
  @feature_options ||= {}
@@ -255,10 +281,28 @@ module Familia
255
281
  # without worrying about initialization state. Similar to register_field_type
256
282
  # for field types.
257
283
  #
284
+ # Feature options are stored at the **class level** using instance variables,
285
+ # ensuring complete isolation between different Familia::Horreum subclasses.
286
+ # Each class maintains its own @feature_options hash.
287
+ #
258
288
  # @param feature_name [Symbol] The feature name
259
289
  # @param options [Hash] The options to add/merge
260
290
  # @return [Hash] The updated options for the feature
261
291
  #
292
+ # @note This method only sets defaults for options that don't already exist,
293
+ # using the ||= operator to prevent overwrites.
294
+ #
295
+ # @example Per-class storage behavior
296
+ # class ModelA < Familia::Horreum
297
+ # # This stores options in ModelA's @feature_options
298
+ # add_feature_options(:my_feature, key: 'value_a')
299
+ # end
300
+ #
301
+ # class ModelB < Familia::Horreum
302
+ # # This stores options in ModelB's @feature_options (separate from ModelA)
303
+ # add_feature_options(:my_feature, key: 'value_b')
304
+ # end
305
+ #
262
306
  def add_feature_options(feature_name, **options)
263
307
  @feature_options ||= {}
264
308
  @feature_options[feature_name.to_sym] ||= {}
@@ -2,13 +2,14 @@
2
2
 
3
3
  require 'securerandom'
4
4
 
5
+ # Provides a suite of tools for generating and manipulating cryptographically
6
+ # secure identifiers in various formats and lengths.
5
7
  module Familia
6
8
  module SecureIdentifier
7
-
8
9
  # Generates a 256-bit cryptographically secure hexadecimal identifier.
9
10
  #
10
11
  # @return [String] A 64-character hex string representing 256 bits of entropy.
11
- # @security Provides ~10^77 possible values, far exceeding UUID4's 128 bits.
12
+ # @security Provides ~10^77 possible values, far exceeding UUIDv4's 128 bits.
12
13
  def generate_hex_id
13
14
  SecureRandom.hex(32)
14
15
  end
@@ -26,13 +27,6 @@ module Familia
26
27
  #
27
28
  # @param base [Integer] The base for encoding the output string (2-36, default: 36).
28
29
  # @return [String] A secure identifier.
29
- #
30
- # @example Generate a 256-bit ID in base-36 (default)
31
- # generate_id # => "25nkfebno45yy36z47ffxef2a7vpg4qk06ylgxzwgpnz4q3os4"
32
- #
33
- # @example Generate a 256-bit ID in base-16 (hexadecimal)
34
- # generate_id(16) # => "568bdb582bc5042bf435d3f126cf71593981067463709c880c91df1ad9777a34"
35
- #
36
30
  def generate_id(base = 36)
37
31
  target_length = SecureIdentifier.min_length_for_bits(256, base)
38
32
  generate_hex_id.to_i(16).to_s(base).rjust(target_length, '0')
@@ -43,87 +37,69 @@ module Familia
43
37
  #
44
38
  # @param base [Integer] The base for encoding the output string (2-36, default: 36).
45
39
  # @return [String] A secure short identifier.
46
- #
47
- # @example Generate a 64-bit short ID in base-36 (default)
48
- # generate_trace_id # => "lh7uap704unf"
49
- #
50
- # @example Generate a 64-bit short ID in base-16 (hexadecimal)
51
- # generate_trace_id(16) # => "94cf9f8cfb0eb692"
52
- #
53
40
  def generate_trace_id(base = 36)
54
41
  target_length = SecureIdentifier.min_length_for_bits(64, base)
55
42
  generate_hex_trace_id.to_i(16).to_s(base).rjust(target_length, '0')
56
43
  end
57
44
 
58
- # Truncates a 256-bit hexadecimal ID to 64 bits and encodes it in a given base.
59
- # These short, deterministic IDs are useful for secure logging. By inputting the
60
- # full hexadecimal string, you can generate a consistent short ID that allows
61
- # tracking an entity through logs without exposing the entity's full identifier..
45
+ # Creates a deterministic 64-bit trace identifier from a longer hex ID.
62
46
  #
63
- # @param hex_id [String] A 64-character hexadecimal string (representing 256 bits).
64
- # @param base [Integer] The base for encoding the output string (2-36, default: 36).
65
- # @return [String] A 64-bit identifier, encoded in the specified base.
47
+ # This is a convenience method for `truncate_hex(hex_id, bits: 64)`.
48
+ # Useful for creating short, consistent IDs for logging and tracing.
49
+ #
50
+ # @param (see #truncate_hex)
51
+ # @return (see #truncate_hex)
66
52
  def shorten_to_trace_id(hex_id, base: 36)
67
- target_length = SecureIdentifier.min_length_for_bits(64, base)
68
- truncated = hex_id.to_i(16) >> (256 - 64) # Always 64 bits
69
- truncated.to_s(base).rjust(target_length, '0')
53
+ truncate_hex(hex_id, bits: 64, base: base)
70
54
  end
71
55
 
72
- # Truncates a 256-bit hexadecimal ID to 128 bits and encodes it in a given base.
73
- # This function takes the most significant bits from the hex string to maintain
74
- # randomness while creating a shorter, deterministic identifier that's safe for
75
- # outdoor use.
76
- #
77
- # @param hex_id [String] A 64-character hexadecimal string (representing 256 bits).
78
- # @param base [Integer] The base for encoding the output string (2-36, default: 36).
79
- # @return [String] A 128-bit identifier, encoded in the specified base.
80
- #
81
- # @example Create a shorter external ID from a full 256-bit internal ID
82
- # hex_id = generate_hex_id
83
- # external_id = shorten_to_external_id(hex_id)
84
- #
85
- # @note This is useful for creating shorter, public-facing IDs from secure internal ones.
86
- # @security Truncation preserves the cryptographic properties of the most significant bits.
87
- def shorten_to_external_id(hex_id, base: 36)
88
- target_length = SecureIdentifier.min_length_for_bits(128, base)
89
- truncated = hex_id.to_i(16) >> (256 - 128) # Always 128 bits
90
- truncated.to_s(base).rjust(target_length, '0')
91
- end
56
+ # Deterministically truncates a hexadecimal ID to a specified bit length.
57
+ #
58
+ # This function preserves the most significant bits of the input `hex_id` to
59
+ # create a shorter, yet still random-looking, identifier.
60
+ #
61
+ # @param hex_id [String] The input hexadecimal string.
62
+ # @param bits [Integer] The desired output bit length (e.g., 128, 64). Defaults to 128.
63
+ # @param base [Integer] The numeric base for the output string (2-36). Defaults to 36.
64
+ # @return [String] A new, shorter identifier in the specified base.
65
+ # @raise [ArgumentError] if `hex_id` is not a valid hex string, or if `input_bits`
66
+ # is less than the desired output `bits`.
67
+ def truncate_hex(hex_id, bits: 128, base: 36)
68
+ target_length = SecureIdentifier.min_length_for_bits(bits, base)
69
+ input_bits = hex_id.length * 4
92
70
 
93
- # Calculate minimum string length to represent N bits in given base
94
- #
95
- # When generating random IDs, we need to know how many characters are required
96
- # to represent a certain amount of entropy. This ensures consistent ID lengths.
97
- #
98
- # Formula: ceil(bits * log(2) / log(base))
99
- #
100
- # @example Common usage with SecureRandom
101
- # SecureRandom.hex(32) # 32 bytes = 256 bits = 64 hex chars
102
- # SecureRandom.hex(16) # 16 bytes = 128 bits = 32 hex chars
103
- #
104
- # @example Using the method
105
- # min_length_for_bits(256, 16) # => 64 (hex)
106
- # min_length_for_bits(256, 36) # => 50 (base36)
107
- # min_length_for_bits(128, 10) # => 39 (decimal)
71
+ unless hex_id.match?(/\A[0-9a-fA-F]+\z/)
72
+ raise ArgumentError, "Invalid hexadecimal string: #{hex_id}"
73
+ end
108
74
 
109
- # Fast lookup for hex (base 16) - our most common case
110
- # Avoids calculation overhead for 99% of ID generation
111
- HEX_LENGTHS = {
112
- 256 => 64, # SHA-256 equivalent entropy
113
- 128 => 32, # UUID equivalent entropy
114
- 64 => 16, # Compact ID
115
- }.freeze
75
+ if input_bits < bits
76
+ raise ArgumentError, "Input bits (#{input_bits}) cannot be less than desired output bits (#{bits})."
77
+ end
116
78
 
117
- # Get minimum character length needed to encode `bits` of entropy in `base`
79
+ # Truncate by right-shifting to keep the most significant bits
80
+ truncated_int = hex_id.to_i(16) >> (input_bits - bits)
81
+ truncated_int.to_s(base).rjust(target_length, '0')
82
+ end
83
+
84
+ # Calculates the minimum string length required to represent a given number of
85
+ # bits in a specific numeric base.
86
+ #
87
+ # @private
118
88
  #
119
- # @param bits [Integer] Number of bits of entropy needed
120
- # @param base [Integer] Numeric base (2-36)
121
- # @return [Integer] Minimum string length required
89
+ # @param bits [Integer] The number of bits of entropy.
90
+ # @param base [Integer] The numeric base (2-36).
91
+ # @return [Integer] The minimum string length required.
122
92
  def self.min_length_for_bits(bits, base)
123
- return HEX_LENGTHS[bits] if base == 16 && HEX_LENGTHS.key?(bits)
93
+ # Fast lookup for hex (base 16) - our most common case
94
+ hex_lengths = {
95
+ 256 => 64, # SHA-256 equivalent entropy
96
+ 128 => 32, # UUID equivalent entropy
97
+ 64 => 16, # Compact ID
98
+ }.freeze
99
+ return hex_lengths[bits] if base == 16 && hex_lengths.key?(bits)
124
100
 
125
- @length_cache ||= {}
126
- @length_cache[[bits, base]] ||= (bits * Math.log(2) / Math.log(base)).ceil
101
+ @min_length_for_bits_cache ||= {}
102
+ @min_length_for_bits_cache[[bits, base]] ||= (bits * Math.log(2) / Math.log(base)).ceil
127
103
  end
128
104
  end
129
105
  end