cmdx 1.18.0 → 1.20.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +73 -9
  4. data/README.md +1 -1
  5. data/lib/cmdx/attribute.rb +88 -20
  6. data/lib/cmdx/attribute_registry.rb +79 -8
  7. data/lib/cmdx/attribute_value.rb +8 -3
  8. data/lib/cmdx/callback_registry.rb +60 -26
  9. data/lib/cmdx/chain.rb +47 -4
  10. data/lib/cmdx/coercion_registry.rb +42 -20
  11. data/lib/cmdx/coercions/array.rb +8 -3
  12. data/lib/cmdx/coercions/big_decimal.rb +1 -1
  13. data/lib/cmdx/coercions/boolean.rb +6 -2
  14. data/lib/cmdx/coercions/complex.rb +1 -1
  15. data/lib/cmdx/coercions/date.rb +2 -7
  16. data/lib/cmdx/coercions/date_time.rb +2 -7
  17. data/lib/cmdx/coercions/float.rb +1 -1
  18. data/lib/cmdx/coercions/hash.rb +1 -1
  19. data/lib/cmdx/coercions/integer.rb +4 -5
  20. data/lib/cmdx/coercions/rational.rb +1 -1
  21. data/lib/cmdx/coercions/string.rb +1 -1
  22. data/lib/cmdx/coercions/symbol.rb +1 -1
  23. data/lib/cmdx/coercions/time.rb +1 -7
  24. data/lib/cmdx/configuration.rb +26 -0
  25. data/lib/cmdx/context.rb +9 -6
  26. data/lib/cmdx/deprecator.rb +27 -14
  27. data/lib/cmdx/errors.rb +3 -4
  28. data/lib/cmdx/exception.rb +7 -0
  29. data/lib/cmdx/executor.rb +77 -54
  30. data/lib/cmdx/identifier.rb +4 -6
  31. data/lib/cmdx/locale.rb +32 -9
  32. data/lib/cmdx/middleware_registry.rb +43 -23
  33. data/lib/cmdx/middlewares/correlate.rb +4 -2
  34. data/lib/cmdx/middlewares/timeout.rb +11 -10
  35. data/lib/cmdx/parallelizer.rb +100 -0
  36. data/lib/cmdx/pipeline.rb +42 -23
  37. data/lib/cmdx/railtie.rb +1 -1
  38. data/lib/cmdx/result.rb +27 -11
  39. data/lib/cmdx/retry.rb +166 -0
  40. data/lib/cmdx/settings.rb +222 -0
  41. data/lib/cmdx/task.rb +53 -61
  42. data/lib/cmdx/utils/format.rb +17 -1
  43. data/lib/cmdx/utils/normalize.rb +52 -0
  44. data/lib/cmdx/utils/wrap.rb +38 -0
  45. data/lib/cmdx/validator_registry.rb +45 -20
  46. data/lib/cmdx/validators/absence.rb +1 -1
  47. data/lib/cmdx/validators/exclusion.rb +2 -2
  48. data/lib/cmdx/validators/format.rb +1 -1
  49. data/lib/cmdx/validators/inclusion.rb +2 -2
  50. data/lib/cmdx/validators/length.rb +1 -1
  51. data/lib/cmdx/validators/numeric.rb +1 -1
  52. data/lib/cmdx/validators/presence.rb +1 -1
  53. data/lib/cmdx/version.rb +1 -1
  54. data/lib/cmdx.rb +12 -0
  55. data/lib/generators/cmdx/templates/install.rb +11 -0
  56. data/mkdocs.yml +5 -1
  57. metadata +6 -15
data/lib/cmdx/chain.rb CHANGED
@@ -31,7 +31,7 @@ module CMDx
31
31
  # @rbs @results: Array[Result]
32
32
  attr_reader :results
33
33
 
34
- def_delegators :results, :index, :first, :last, :size
34
+ def_delegators :results, :first, :last, :size
35
35
  def_delegators :first, :state, :status, :outcome, :runtime
36
36
 
37
37
  # Creates a new chain with a unique identifier and empty results collection.
@@ -40,6 +40,7 @@ module CMDx
40
40
  #
41
41
  # @rbs () -> void
42
42
  def initialize(dry_run: false)
43
+ @mutex = Mutex.new
43
44
  @id = Identifier.generate
44
45
  @results = []
45
46
  @dry_run = !!dry_run
@@ -107,7 +108,7 @@ module CMDx
107
108
  raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result)
108
109
 
109
110
  self.current ||= new(dry_run:)
110
- current.results << result
111
+ current.push(result)
111
112
  current
112
113
  end
113
114
 
@@ -118,12 +119,40 @@ module CMDx
118
119
  # @return [Hash] The thread or fiber storage
119
120
  #
120
121
  # @rbs () -> Hash
121
- def thread_or_fiber
122
- Fiber.respond_to?(:storage) ? Fiber.storage : Thread.current
122
+ if Fiber.respond_to?(:storage)
123
+ def thread_or_fiber = Fiber.storage
124
+ else
125
+ def thread_or_fiber = Thread.current
123
126
  end
124
127
 
125
128
  end
126
129
 
130
+ # Thread-safe append of a result to the chain.
131
+ # Caches the result's index to avoid repeated O(n) lookups.
132
+ #
133
+ # @param result [Result] The result to append
134
+ #
135
+ # @return [Array<Result>] The updated results array
136
+ #
137
+ # @rbs (Result result) -> Array[Result]
138
+ def push(result)
139
+ @mutex.synchronize do
140
+ result.instance_variable_set(:@chain_index, @results.size)
141
+ @results << result
142
+ end
143
+ end
144
+
145
+ # Thread-safe lookup of a result's position in the chain.
146
+ #
147
+ # @param result [Result] The result to find
148
+ #
149
+ # @return [Integer, nil] The zero-based index or nil if not found
150
+ #
151
+ # @rbs (Result result) -> Integer?
152
+ def index(result)
153
+ @mutex.synchronize { @results.index(result) }
154
+ end
155
+
127
156
  # Returns whether the chain is running in dry-run mode.
128
157
  #
129
158
  # @return [Boolean] Whether the chain is running in dry-run mode
@@ -136,6 +165,20 @@ module CMDx
136
165
  !!@dry_run
137
166
  end
138
167
 
168
+ # Freezes the chain and its internal results to prevent modifications.
169
+ #
170
+ # @return [Chain] the frozen chain
171
+ #
172
+ # @example
173
+ # chain.freeze
174
+ # chain.results << result # => raises FrozenError
175
+ #
176
+ # @rbs () -> self
177
+ def freeze
178
+ results.freeze
179
+ super
180
+ end
181
+
139
182
  # Converts the chain to a hash representation.
140
183
  #
141
184
  # @option return [String] :id The chain identifier
@@ -5,19 +5,11 @@ module CMDx
5
5
  #
6
6
  # Provides a centralized way to register, deregister, and execute type coercions
7
7
  # for various data types including arrays, numbers, dates, and other primitives.
8
+ #
9
+ # Supports copy-on-write semantics: a duped registry shares the parent's
10
+ # data until a write operation triggers materialization.
8
11
  class CoercionRegistry
9
12
 
10
- # Returns the internal registry mapping coercion types to handler classes.
11
- #
12
- # @return [Hash{Symbol => Class}] Hash of coercion type names to coercion classes
13
- #
14
- # @example
15
- # registry.registry # => { integer: Coercions::Integer, boolean: Coercions::Boolean }
16
- #
17
- # @rbs @registry: Hash[Symbol, Class]
18
- attr_reader :registry
19
- alias to_h registry
20
-
21
13
  # Initialize a new coercion registry.
22
14
  #
23
15
  # @param registry [Hash{Symbol => Class}, nil] optional initial registry hash
@@ -44,17 +36,30 @@ module CMDx
44
36
  }
45
37
  end
46
38
 
47
- # Create a duplicate of this registry.
39
+ # Sets up copy-on-write state when duplicated via dup.
48
40
  #
49
- # @return [CoercionRegistry] a new instance with duplicated registry hash
41
+ # @param source [CoercionRegistry] The registry being duplicated
42
+ #
43
+ # @rbs (CoercionRegistry source) -> void
44
+ def initialize_dup(source)
45
+ @parent = source
46
+ @registry = nil
47
+ super
48
+ end
49
+
50
+ # Returns the internal registry mapping coercion types to handler classes.
51
+ # Delegates to the parent registry when not yet materialized.
52
+ #
53
+ # @return [Hash{Symbol => Class}] Hash of coercion type names to coercion classes
50
54
  #
51
55
  # @example
52
- # new_registry = registry.dup
56
+ # registry.registry # => { integer: Coercions::Integer, boolean: Coercions::Boolean }
53
57
  #
54
- # @rbs () -> CoercionRegistry
55
- def dup
56
- self.class.new(registry.dup)
58
+ # @rbs () -> Hash[Symbol, Class]
59
+ def registry
60
+ @registry || @parent.registry
57
61
  end
62
+ alias to_h registry
58
63
 
59
64
  # Register a new coercion handler for a type.
60
65
  #
@@ -69,7 +74,9 @@ module CMDx
69
74
  #
70
75
  # @rbs ((Symbol | String) name, Class coercion) -> self
71
76
  def register(name, coercion)
72
- registry[name.to_sym] = coercion
77
+ materialize!
78
+
79
+ @registry[name.to_sym] = coercion
73
80
  self
74
81
  end
75
82
 
@@ -85,7 +92,9 @@ module CMDx
85
92
  #
86
93
  # @rbs ((Symbol | String) name) -> self
87
94
  def deregister(name)
88
- registry.delete(name.to_sym)
95
+ materialize!
96
+
97
+ @registry.delete(name.to_sym)
89
98
  self
90
99
  end
91
100
 
@@ -106,11 +115,24 @@ module CMDx
106
115
  # result = registry.coerce(:boolean, task, "true", strict: true)
107
116
  #
108
117
  # @rbs (Symbol type, untyped task, untyped value, ?Hash[Symbol, untyped] options) -> untyped
109
- def coerce(type, task, value, options = {})
118
+ def coerce(type, task, value, options = EMPTY_HASH)
110
119
  raise TypeError, "unknown coercion type #{type.inspect}" unless registry.key?(type)
111
120
 
112
121
  Utils::Call.invoke(task, registry[type], value, options)
113
122
  end
114
123
 
124
+ private
125
+
126
+ # Copies the parent's registry data into this instance,
127
+ # severing the copy-on-write link.
128
+ #
129
+ # @rbs () -> void
130
+ def materialize!
131
+ return if @registry
132
+
133
+ @registry = @parent.registry.dup
134
+ @parent = nil
135
+ end
136
+
115
137
  end
116
138
  end
@@ -18,7 +18,7 @@ module CMDx
18
18
  #
19
19
  # @return [Array] The converted array value
20
20
  #
21
- # @raise [JSON::ParserError] If the string value contains invalid JSON
21
+ # @raise [CoercionError] If the value cannot be converted to an array
22
22
  #
23
23
  # @example Convert a JSON-like string to an array
24
24
  # Array.call("[1, 2, 3]") # => [1, 2, 3]
@@ -26,17 +26,22 @@ module CMDx
26
26
  # Array.call("hello") # => ["hello"]
27
27
  # Array.call(42) # => [42]
28
28
  # Array.call(nil) # => []
29
+ # @example Handle invalid JSON-like strings
30
+ # Array.call("[not json") # => raises CoercionError
29
31
  #
30
32
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Array[untyped]
31
- def call(value, options = {})
33
+ def call(value, options = EMPTY_HASH)
32
34
  if value.is_a?(::String) && (
33
35
  value.start_with?("[") ||
34
36
  value.strip == "null"
35
37
  )
36
38
  JSON.parse(value) || []
37
39
  else
38
- Array(value)
40
+ Utils::Wrap.array(value)
39
41
  end
42
+ rescue JSON::ParserError
43
+ type = Locale.t("cmdx.types.array")
44
+ raise CoercionError, Locale.t("cmdx.coercions.into_an", type:)
40
45
  end
41
46
 
42
47
  end
@@ -31,7 +31,7 @@ module CMDx
31
31
  # BigDecimal.call(3.14159) # => #<BigDecimal:7f8b8c0d8e0f '0.314159E1',9(18)>
32
32
  #
33
33
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> BigDecimal
34
- def call(value, options = {})
34
+ def call(value, options = EMPTY_HASH)
35
35
  BigDecimal(value, options[:precision] || DEFAULT_PRECISION)
36
36
  rescue ArgumentError, TypeError
37
37
  type = Locale.t("cmdx.types.big_decimal")
@@ -34,14 +34,18 @@ module CMDx
34
34
  # Boolean.call("false") # => false
35
35
  # Boolean.call("no") # => false
36
36
  # Boolean.call("0") # => false
37
+ # Boolean.call(nil) # => false
38
+ # Boolean.call("") # => false
37
39
  # @example Handle case-insensitive input
38
40
  # Boolean.call("TRUE") # => true
39
41
  # Boolean.call("False") # => false
42
+ # @example Handle edge cases
43
+ # Boolean.call("abc") # => raises CoercionError
40
44
  #
41
45
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> bool
42
- def call(value, options = {})
46
+ def call(value, options = EMPTY_HASH)
43
47
  case value.to_s
44
- when FALSEY then false
48
+ when FALSEY, EMPTY_STRING then false
45
49
  when TRUTHY then true
46
50
  else
47
51
  type = Locale.t("cmdx.types.boolean")
@@ -29,7 +29,7 @@ module CMDx
29
29
  # Complex.call(Complex(1, 2)) # => (1+2i)
30
30
  #
31
31
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Complex
32
- def call(value, options = {})
32
+ def call(value, options = EMPTY_HASH)
33
33
  Complex(value)
34
34
  rescue ArgumentError, TypeError
35
35
  type = Locale.t("cmdx.types.complex")
@@ -11,11 +11,6 @@ module CMDx
11
11
 
12
12
  extend self
13
13
 
14
- # Types that are already date-like and don't need conversion
15
- #
16
- # @rbs ANALOG_TYPES: Array[String]
17
- ANALOG_TYPES = %w[Date DateTime Time].freeze
18
-
19
14
  # Converts a value to a Date object
20
15
  #
21
16
  # @param value [Object] The value to convert to a Date
@@ -37,8 +32,8 @@ module CMDx
37
32
  # Date.call(DateTime.new(2023, 12, 25)) # => #<Date: 2023-12-25>
38
33
  #
39
34
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Date
40
- def call(value, options = {})
41
- return value if ANALOG_TYPES.include?(value.class.name)
35
+ def call(value, options = EMPTY_HASH)
36
+ return value.to_date if value.respond_to?(:to_date)
42
37
  return ::Date.strptime(value, options[:strptime]) if options[:strptime]
43
38
 
44
39
  ::Date.parse(value)
@@ -11,11 +11,6 @@ module CMDx
11
11
 
12
12
  extend self
13
13
 
14
- # Types that are already date-time-like and don't need conversion
15
- #
16
- # @rbs ANALOG_TYPES: Array[String]
17
- ANALOG_TYPES = %w[Date DateTime Time].freeze
18
-
19
14
  # Converts a value to a DateTime
20
15
  #
21
16
  # @param value [Object] The value to convert to DateTime
@@ -37,8 +32,8 @@ module CMDx
37
32
  # DateTime.call(Time.new(2023, 12, 25)) # => #<DateTime: 2023-12-25T00:00:00+00:00>
38
33
  #
39
34
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> DateTime
40
- def call(value, options = {})
41
- return value if ANALOG_TYPES.include?(value.class.name)
35
+ def call(value, options = EMPTY_HASH)
36
+ return value.to_datetime if value.respond_to?(:to_datetime)
42
37
  return ::DateTime.strptime(value, options[:strptime]) if options[:strptime]
43
38
 
44
39
  ::DateTime.parse(value)
@@ -32,7 +32,7 @@ module CMDx
32
32
  # Float.call(Complex(5.0, 0)) # => 5.0
33
33
  #
34
34
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Float
35
- def call(value, options = {})
35
+ def call(value, options = EMPTY_HASH)
36
36
  Float(value)
37
37
  rescue ArgumentError, RangeError, TypeError
38
38
  type = Locale.t("cmdx.types.float")
@@ -33,7 +33,7 @@ module CMDx
33
33
  # Hash.call('{"key": "value"}') # => {"key" => "value"}
34
34
  #
35
35
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Hash[untyped, untyped]
36
- def call(value, options = {})
36
+ def call(value, options = EMPTY_HASH)
37
37
  if value.nil?
38
38
  {}
39
39
  elsif value.is_a?(::Hash)
@@ -30,13 +30,12 @@ module CMDx
30
30
  # Integer.call(3.14) # => 3
31
31
  # Integer.call(0.0) # => 0
32
32
  # @example Handle edge cases
33
- # Integer.call("") # => 0
34
- # Integer.call(nil) # => 0
35
- # Integer.call(false) # => 0
36
- # Integer.call(true) # => 1
33
+ # Integer.call("") # => raises CoercionError
34
+ # Integer.call(nil) # => raises CoercionError
35
+ # Integer.call("abc") # => raises CoercionError
37
36
  #
38
37
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Integer
39
- def call(value, options = {})
38
+ def call(value, options = EMPTY_HASH)
40
39
  Integer(value)
41
40
  rescue ArgumentError, FloatDomainError, RangeError, TypeError
42
41
  type = Locale.t("cmdx.types.integer")
@@ -35,7 +35,7 @@ module CMDx
35
35
  # Rational.call(0) # => (0/1)
36
36
  #
37
37
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Rational
38
- def call(value, options = {})
38
+ def call(value, options = EMPTY_HASH)
39
39
  Rational(value)
40
40
  rescue ArgumentError, FloatDomainError, RangeError, TypeError, ZeroDivisionError
41
41
  type = Locale.t("cmdx.types.rational")
@@ -29,7 +29,7 @@ module CMDx
29
29
  # String.call(true) # => "true"
30
30
  #
31
31
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> String
32
- def call(value, options = {})
32
+ def call(value, options = EMPTY_HASH)
33
33
  String(value)
34
34
  end
35
35
 
@@ -28,7 +28,7 @@ module CMDx
28
28
  # Symbol.call(:existing) # => :existing
29
29
  #
30
30
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Symbol
31
- def call(value, options = {})
31
+ def call(value, options = EMPTY_HASH)
32
32
  value.to_sym
33
33
  rescue NoMethodError
34
34
  type = Locale.t("cmdx.types.symbol")
@@ -11,11 +11,6 @@ module CMDx
11
11
 
12
12
  extend self
13
13
 
14
- # Types that are already time-like and don't need conversion
15
- #
16
- # @rbs ANALOG_TYPES: Array[String]
17
- ANALOG_TYPES = %w[DateTime Time].freeze
18
-
19
14
  # Converts a value to a Time object
20
15
  #
21
16
  # @param value [Object] The value to convert to a Time object
@@ -39,8 +34,7 @@ module CMDx
39
34
  # Time.call("12-25-2023", strptime: "%m-%d-%Y") # => Time object
40
35
  #
41
36
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Time
42
- def call(value, options = {})
43
- return value if ANALOG_TYPES.include?(value.class.name)
37
+ def call(value, options = EMPTY_HASH)
44
38
  return value.to_time if value.respond_to?(:to_time)
45
39
  return ::Time.strptime(value, options[:strptime]) if options[:strptime]
46
40
 
@@ -123,6 +123,28 @@ module CMDx
123
123
  # @rbs @rollback_on: Array[String]
124
124
  attr_accessor :rollback_on
125
125
 
126
+ # Returns whether to freeze task results after execution.
127
+ # Set to false in test environments to allow stubbing on result objects.
128
+ #
129
+ # @return [Boolean] true if results should be frozen (default: true)
130
+ #
131
+ # @example
132
+ # config.freeze_results = false
133
+ #
134
+ # @rbs @freeze_results: bool
135
+ attr_accessor :freeze_results
136
+
137
+ # Returns the default locale used for built-in translation lookups.
138
+ # Must match the basename of a YAML file in lib/locales/ (e.g. "en", "es", "ja").
139
+ #
140
+ # @return [String] The locale identifier (default: "en")
141
+ #
142
+ # @example
143
+ # config.default_locale = "es"
144
+ #
145
+ # @rbs @locale: String
146
+ attr_accessor :default_locale
147
+
126
148
  # Initializes a new Configuration instance with default values.
127
149
  #
128
150
  # Creates new registry instances for middlewares, callbacks, coercions, and
@@ -145,6 +167,8 @@ module CMDx
145
167
  @task_breakpoints = DEFAULT_BREAKPOINTS
146
168
  @workflow_breakpoints = DEFAULT_BREAKPOINTS
147
169
  @rollback_on = DEFAULT_ROLLPOINTS
170
+ @freeze_results = true
171
+ @default_locale = "en"
148
172
 
149
173
  @backtrace = false
150
174
  @backtrace_cleaner = nil
@@ -177,6 +201,8 @@ module CMDx
177
201
  task_breakpoints: @task_breakpoints,
178
202
  workflow_breakpoints: @workflow_breakpoints,
179
203
  rollback_on: @rollback_on,
204
+ freeze_results: @freeze_results,
205
+ default_locale: @default_locale,
180
206
  backtrace: @backtrace,
181
207
  backtrace_cleaner: @backtrace_cleaner,
182
208
  exception_handler: @exception_handler,
data/lib/cmdx/context.rb CHANGED
@@ -160,8 +160,8 @@ module CMDx
160
160
  # context.to_h # => {name: "John", age: 30, city: "NYC"}
161
161
  #
162
162
  # @rbs (?untyped args) -> self
163
- def merge!(args = {})
164
- args.to_h.each { |key, value| self[key.to_sym] = value }
163
+ def merge!(args = EMPTY_HASH)
164
+ table.merge!(args.to_h.transform_keys(&:to_sym))
165
165
  self
166
166
  end
167
167
  alias merge merge!
@@ -280,13 +280,15 @@ module CMDx
280
280
  #
281
281
  # @rbs (Symbol method_name, *untyped args, **untyped _kwargs) ?{ () -> untyped } -> untyped
282
282
  def method_missing(method_name, *args, **_kwargs, &)
283
- fetch(method_name) do
284
- str_name = method_name.to_s
285
- store(str_name.chop, args.first) if str_name.end_with?("=")
283
+ if method_name.end_with?("=")
284
+ store(method_name.name.chop, args.first)
285
+ else
286
+ table[method_name]
286
287
  end
287
288
  end
288
289
 
289
290
  # Checks if the object responds to a given method.
291
+ # Supports both getter access for existing keys and setter methods.
290
292
  #
291
293
  # @param method_name [Symbol] the method name to check
292
294
  # @param include_private [Boolean] whether to include private methods
@@ -296,11 +298,12 @@ module CMDx
296
298
  # @example
297
299
  # context = Context.new(name: "John")
298
300
  # context.respond_to?(:name) # => true
301
+ # context.respond_to?(:name=) # => true
299
302
  # context.respond_to?(:age) # => false
300
303
  #
301
304
  # @rbs (Symbol method_name, ?bool include_private) -> bool
302
305
  def respond_to_missing?(method_name, include_private = false)
303
- key?(method_name) || super
306
+ key?(method_name) || method_name.end_with?("=") || super
304
307
  end
305
308
 
306
309
  end
@@ -10,11 +10,23 @@ module CMDx
10
10
 
11
11
  extend self
12
12
 
13
+ # @rbs RAISE_REGEXP: Regexp
14
+ RAISE_REGEXP = /\Araise\z/
15
+ private_constant :RAISE_REGEXP
16
+
17
+ # @rbs LOG_REGEXP: Regexp
18
+ LOG_REGEXP = /\Alog\z/
19
+ private_constant :LOG_REGEXP
20
+
21
+ # @rbs WARN_REGEXP: Regexp
22
+ WARN_REGEXP = /\Awarn\z/
23
+ private_constant :WARN_REGEXP
24
+
13
25
  # @rbs EVAL: Proc
14
26
  EVAL = proc do |target, callable|
15
27
  case callable
16
- when /raise|log|warn/ then callable
17
28
  when NilClass, FalseClass, TrueClass then !!callable
29
+ when RAISE_REGEXP, LOG_REGEXP, WARN_REGEXP then callable
18
30
  when Symbol then target.send(callable)
19
31
  when Proc then target.instance_eval(&callable)
20
32
  else
@@ -28,14 +40,14 @@ module CMDx
28
40
  # Restricts task usage based on deprecation settings.
29
41
  #
30
42
  # @param task [Object] The task object to check for deprecation
31
- # @option task.class.settings[:deprecate] [Symbol, Proc, String, Boolean]
43
+ # @option task.class.settings.deprecate [Symbol, Proc, String, Boolean]
32
44
  # The deprecation configuration for the task
33
- # @option task.class.settings[:deprecate] :raise Raises DeprecationError
34
- # @option task.class.settings[:deprecate] :log Logs deprecation warning
35
- # @option task.class.settings[:deprecate] :warn Outputs warning to stderr
36
- # @option task.class.settings[:deprecate] true Raises DeprecationError
37
- # @option task.class.settings[:deprecate] false No action taken
38
- # @option task.class.settings[:deprecate] nil No action taken
45
+ # @option task.class.settings.deprecate :raise Raises DeprecationError
46
+ # @option task.class.settings.deprecate :log Logs deprecation warning
47
+ # @option task.class.settings.deprecate :warn Outputs warning to stderr
48
+ # @option task.class.settings.deprecate true Raises DeprecationError
49
+ # @option task.class.settings.deprecate false No action taken
50
+ # @option task.class.settings.deprecate nil No action taken
39
51
  #
40
52
  # @raise [DeprecationError] When deprecation type is :raise or true
41
53
  # @raise [RuntimeError] When deprecation type is unknown
@@ -49,13 +61,14 @@ module CMDx
49
61
  #
50
62
  # @rbs (Task task) -> void
51
63
  def restrict(task)
52
- type = EVAL.call(task, task.class.settings[:deprecate])
64
+ setting = task.class.settings.deprecate
65
+ return unless setting
53
66
 
54
- case type
55
- when NilClass, FalseClass # Do nothing
56
- when TrueClass, /raise/ then raise DeprecationError, "#{task.class.name} usage prohibited"
57
- when /log/ then task.logger.warn { "DEPRECATED: migrate to a replacement or discontinue use" }
58
- when /warn/ then warn("[#{task.class.name}] DEPRECATED: migrate to a replacement or discontinue use", category: :deprecated)
67
+ case type = EVAL.call(task, setting)
68
+ when NilClass, FalseClass then nil # Do nothing
69
+ when TrueClass, RAISE_REGEXP then raise DeprecationError, "#{task.class.name} usage prohibited"
70
+ when LOG_REGEXP then task.logger.warn { "DEPRECATED: migrate to a replacement or discontinue use" }
71
+ when WARN_REGEXP then warn("[#{task.class.name}] DEPRECATED: migrate to a replacement or discontinue use", category: :deprecated)
59
72
  else raise "unknown deprecation type #{type.inspect}"
60
73
  end
61
74
  end
data/lib/cmdx/errors.rb CHANGED
@@ -18,7 +18,7 @@ module CMDx
18
18
  # @rbs @messages: Hash[Symbol, Set[String]]
19
19
  attr_reader :messages
20
20
 
21
- def_delegators :messages, :empty?
21
+ def_delegators :messages, :any?, :clear, :empty?, :size
22
22
 
23
23
  # Initialize a new error collection.
24
24
  #
@@ -57,9 +57,8 @@ module CMDx
57
57
  #
58
58
  # @rbs (Symbol attribute) -> bool
59
59
  def for?(attribute)
60
- return false unless messages.key?(attribute)
61
-
62
- !messages[attribute].empty?
60
+ set = messages[attribute]
61
+ !set.nil? && !set.empty?
63
62
  end
64
63
 
65
64
  # Convert errors to a hash format with arrays of full messages.
@@ -29,6 +29,13 @@ module CMDx
29
29
  # of required functionality.
30
30
  UndefinedMethodError = Class.new(Error)
31
31
 
32
+ # Error raised when task execution exceeds the configured timeout limit.
33
+ #
34
+ # This error occurs when a task takes longer to execute than the specified
35
+ # time limit. Timeout errors are raised by Ruby's Timeout module and are
36
+ # caught by the middleware to properly fail the task with timeout information.
37
+ TimeoutError = Class.new(Interrupt)
38
+
32
39
  # Raised when attribute validation fails during task execution.
33
40
  #
34
41
  # This error occurs when a attribute value doesn't meet the validation criteria