cmdx 2.0.1 → 2.1.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -8
  3. data/lib/cmdx/callbacks.rb +31 -11
  4. data/lib/cmdx/chain.rb +29 -10
  5. data/lib/cmdx/coercions/big_decimal.rb +1 -1
  6. data/lib/cmdx/coercions/boolean.rb +3 -9
  7. data/lib/cmdx/coercions/coerce.rb +4 -1
  8. data/lib/cmdx/coercions/date_time.rb +1 -1
  9. data/lib/cmdx/coercions/integer.rb +11 -2
  10. data/lib/cmdx/coercions/symbol.rb +23 -4
  11. data/lib/cmdx/coercions.rb +25 -10
  12. data/lib/cmdx/configuration.rb +31 -16
  13. data/lib/cmdx/context.rb +36 -52
  14. data/lib/cmdx/deprecation.rb +4 -7
  15. data/lib/cmdx/deprecators/error.rb +4 -1
  16. data/lib/cmdx/deprecators.rb +17 -8
  17. data/lib/cmdx/errors.rb +11 -10
  18. data/lib/cmdx/executors/fiber.rb +16 -4
  19. data/lib/cmdx/executors/thread.rb +18 -4
  20. data/lib/cmdx/executors.rb +22 -7
  21. data/lib/cmdx/fault.rb +15 -3
  22. data/lib/cmdx/i18n_proxy.rb +9 -5
  23. data/lib/cmdx/input.rb +23 -21
  24. data/lib/cmdx/inputs.rb +14 -26
  25. data/lib/cmdx/log_formatters/json.rb +8 -1
  26. data/lib/cmdx/log_formatters/logstash.rb +7 -1
  27. data/lib/cmdx/mergers.rb +22 -7
  28. data/lib/cmdx/middlewares.rb +40 -24
  29. data/lib/cmdx/output.rb +5 -2
  30. data/lib/cmdx/pipeline.rb +18 -3
  31. data/lib/cmdx/railtie.rb +1 -0
  32. data/lib/cmdx/result.rb +22 -6
  33. data/lib/cmdx/retriers/decorrelated_jitter.rb +10 -5
  34. data/lib/cmdx/retriers/exponential.rb +10 -2
  35. data/lib/cmdx/retriers/fibonacci.rb +29 -12
  36. data/lib/cmdx/retriers.rb +17 -8
  37. data/lib/cmdx/retry.rb +20 -13
  38. data/lib/cmdx/runtime.rb +18 -17
  39. data/lib/cmdx/settings.rb +9 -9
  40. data/lib/cmdx/signal.rb +1 -1
  41. data/lib/cmdx/task.rb +90 -45
  42. data/lib/cmdx/telemetry.rb +37 -10
  43. data/lib/cmdx/util.rb +50 -4
  44. data/lib/cmdx/validators/absence.rb +1 -1
  45. data/lib/cmdx/validators/exclusion.rb +15 -15
  46. data/lib/cmdx/validators/format.rb +12 -4
  47. data/lib/cmdx/validators/inclusion.rb +15 -15
  48. data/lib/cmdx/validators/length.rb +5 -49
  49. data/lib/cmdx/validators/numeric.rb +5 -49
  50. data/lib/cmdx/validators/presence.rb +1 -1
  51. data/lib/cmdx/validators/validate.rb +7 -1
  52. data/lib/cmdx/validators.rb +21 -9
  53. data/lib/cmdx/version.rb +1 -1
  54. data/lib/cmdx/workflow.rb +28 -14
  55. data/lib/cmdx.rb +24 -0
  56. data/lib/generators/cmdx/templates/install.rb +80 -39
  57. data/mkdocs.yml +1 -0
  58. metadata +1 -1
data/lib/cmdx/context.rb CHANGED
@@ -47,10 +47,25 @@ module CMDx
47
47
  elsif context.respond_to?(:to_h)
48
48
  context.to_h
49
49
  else
50
- raise ArgumentError, "must respond to `to_h` or `to_hash`"
50
+ raise ArgumentError, <<~MSG.chomp
51
+ Context.build expected a Hash or an object responding to #to_h/#to_hash (got #{context.class}).
52
+ See https://drexed.github.io/cmdx/basics/context/#assigning-data
53
+ MSG
51
54
  end.transform_keys(&:to_sym)
52
55
  end
53
56
 
57
+ # Ensures `#dup` / `#clone` produce a context with an independent backing
58
+ # hash so mutations on the copy do not leak into the original. `@strict`
59
+ # is preserved on the copy.
60
+ #
61
+ # @param source [Context]
62
+ # @return [void]
63
+ def initialize_copy(source)
64
+ super
65
+ @table = source.instance_variable_get(:@table).dup
66
+ @strict = source.strict?
67
+ end
68
+
54
69
  # @return [Boolean] whether dynamic reads for unknown keys raise instead
55
70
  # of returning `nil`
56
71
  def strict?
@@ -87,7 +102,7 @@ module CMDx
87
102
  # @return [Context] self for chaining
88
103
  def deep_merge(context = EMPTY_HASH)
89
104
  other = self.class.build(context)
90
- @table = compute_deep_merge(@table, other.to_h)
105
+ @table = Util.deep_merge(@table, other.to_h)
91
106
  self
92
107
  end
93
108
 
@@ -202,9 +217,11 @@ module CMDx
202
217
  @table.hash
203
218
  end
204
219
 
205
- # @return [Hash{Symbol => Object}] the underlying table (not a copy)
220
+ # @return [Hash{Symbol => Object}] a shallow copy of the underlying table.
221
+ # Frozen contexts return the frozen table directly to preserve
222
+ # `Hash#frozen?` semantics for serialization callers.
206
223
  def to_h
207
- @table
224
+ @table.frozen? ? @table : @table.dup
208
225
  end
209
226
 
210
227
  # JSON-friendly hash view. Aliases {#to_h} for conventional `as_json`
@@ -227,7 +244,14 @@ module CMDx
227
244
 
228
245
  # @return [String] space-separated `key=value.inspect` pairs
229
246
  def to_s
230
- @table.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
247
+ buf = String.new(capacity: 256)
248
+
249
+ @table.each do |k, v|
250
+ buf << " " unless buf.empty?
251
+ buf << k.to_s << "=" << v.inspect
252
+ end
253
+
254
+ buf
231
255
  end
232
256
 
233
257
  # Pattern-matching support for `case context in {...}`.
@@ -247,12 +271,13 @@ module CMDx
247
271
 
248
272
  # Returns a deep copy. Non-mutable scalars are shared; Hashes/Arrays are
249
273
  # recursively duplicated; other objects fall back to `#dup` (and then
250
- # to the original on `StandardError`).
274
+ # to the original on `StandardError`). `@strict` is preserved on the copy.
251
275
  #
252
276
  # @return [Context]
253
277
  def deep_dup
254
278
  ctx = self.class.allocate
255
- ctx.instance_variable_set(:@table, compute_deep_dup(@table))
279
+ ctx.instance_variable_set(:@table, Util.deep_dup(@table))
280
+ ctx.instance_variable_set(:@strict, @strict)
256
281
  ctx
257
282
  end
258
283
 
@@ -267,19 +292,6 @@ module CMDx
267
292
 
268
293
  private
269
294
 
270
- # Provides dynamic read/write/predicate access to context keys.
271
- #
272
- # - `ctx.name` — reads `@table[name]`, `nil` when absent (raises
273
- # `NoMethodError` when {#strict?} is true and the key is absent).
274
- # - `ctx.name = val` — stores `val` under `:name`.
275
- # - `ctx.name?` — truthy check for `@table[:name]`.
276
- #
277
- # @param method_name [Symbol] dynamic reader/writer/predicate name
278
- # @param args [Array<Object>] stores RHS for writers (`name=` → `[value]`)
279
- # @param _kwargs [Hash{Symbol => Object}] ignored (accepted for Ruby keyword forwarding)
280
- # @option _kwargs [Object] ignored
281
- # @raise [NoMethodError] when {#strict?} is true and the key is missing
282
- # @api private
283
295
  def method_missing(method_name, *args, **_kwargs, &)
284
296
  if @table.key?(method_name)
285
297
  @table[method_name]
@@ -288,44 +300,16 @@ module CMDx
288
300
  elsif method_name.end_with?("?")
289
301
  !!@table[method_name[..-2].to_sym]
290
302
  elsif strict?
291
- raise NoMethodError, "unknown context key #{method_name.inspect} (strict mode)"
303
+ raise UnknownAccessorError, <<~MSG.chomp
304
+ unknown context key #{method_name.inspect} (strict mode); declared keys: #{@table.keys.inspect}.
305
+ See https://drexed.github.io/cmdx/basics/context/#strict-mode
306
+ MSG
292
307
  end
293
308
  end
294
309
 
295
- # @param method_name [Symbol]
296
- # @param include_private [Boolean] forwarded to Ruby's `respond_to?` lookup
297
- # @return [Boolean]
298
310
  def respond_to_missing?(method_name, include_private = false)
299
311
  @table.key?(method_name) || method_name.end_with?("=", "?") || super
300
312
  end
301
313
 
302
- # @param value [Object] nested value from the context table
303
- # @return [Object] recursively duplicated scalar/collection snapshot
304
- def compute_deep_dup(value)
305
- case value
306
- when Numeric, Symbol, TrueClass, FalseClass, NilClass
307
- value
308
- when Hash
309
- value.each_with_object({}) { |(k, v), acc| acc[k] = compute_deep_dup(v) }
310
- when Array
311
- value.map { |e| compute_deep_dup(e) }
312
- else
313
- begin
314
- value.dup
315
- rescue StandardError
316
- value
317
- end
318
- end
319
- end
320
-
321
- # @param lhs [Hash]
322
- # @param rhs [Hash]
323
- # @return [Hash] merged hash (recursive for nested `{Hash => Hash}` pairs)
324
- def compute_deep_merge(lhs, rhs)
325
- lhs.merge(rhs) do |_key, l, r|
326
- l.is_a?(Hash) && r.is_a?(Hash) ? compute_deep_merge(l, r) : r
327
- end
328
- end
329
-
330
314
  end
331
315
  end
@@ -43,18 +43,15 @@ module CMDx
43
43
  else
44
44
  return @value.call(task) if @value.respond_to?(:call)
45
45
 
46
- raise ArgumentError, "deprecation must be a Symbol, Proc, or respond to #call"
46
+ raise ArgumentError, <<~MSG.chomp
47
+ deprecation must be a Symbol, Proc, or respond to #call (got #{@value.class}).
48
+ See https://drexed.github.io/cmdx/deprecation/#declarations
49
+ MSG
47
50
  end
48
51
  end
49
52
 
50
53
  private
51
54
 
52
- # Resolves the deprecators registry to consult for built-in actions.
53
- # Prefers the task class's registry (so per-task `register(:deprecator, ...)`
54
- # overrides take effect) and falls back to the global configuration.
55
- #
56
- # @param task [Task]
57
- # @return [Deprecators]
58
55
  def deprecators_registry(task)
59
56
  if task.class.respond_to?(:deprecators)
60
57
  task.class.deprecators
@@ -14,7 +14,10 @@ module CMDx
14
14
  # @return [void]
15
15
  # @raise [DeprecationError]
16
16
  def call(task)
17
- raise DeprecationError, "#{task.class} usage prohibited"
17
+ raise DeprecationError, <<~MSG.chomp
18
+ #{task.class} is deprecated and prohibited from execution.
19
+ See https://drexed.github.io/cmdx/deprecation/#error
20
+ MSG
18
21
  end
19
22
 
20
23
  end
@@ -36,9 +36,12 @@ module CMDx
36
36
  deprecator = callable || block
37
37
 
38
38
  if callable && block
39
- raise ArgumentError, "provide either a callable or a block, not both"
39
+ raise ArgumentError, "deprecator: provide either a callable or a block, not both"
40
40
  elsif !deprecator.respond_to?(:call)
41
- raise ArgumentError, "deprecator must respond to #call"
41
+ raise ArgumentError, <<~MSG.chomp
42
+ deprecator must respond to #call (got #{deprecator.class}).
43
+ See https://drexed.github.io/cmdx/deprecation/#custom-actions-via-the-deprecators-registry
44
+ MSG
42
45
  end
43
46
 
44
47
  registry[name.to_sym] = deprecator
@@ -48,22 +51,25 @@ module CMDx
48
51
  # @param name [Symbol]
49
52
  # @return [Deprecators] self for chaining
50
53
  def deregister(name)
51
- registry.delete(name.to_sym)
54
+ registry.delete(name)
52
55
  self
53
56
  end
54
57
 
55
58
  # @param name [Symbol]
56
59
  # @return [Boolean] whether a deprecator is registered under `name`
57
60
  def key?(name)
58
- registry.key?(name.to_sym)
61
+ registry.key?(name)
59
62
  end
60
63
 
61
64
  # @param name [Symbol]
62
65
  # @return [#call] the registered deprecator
63
- # @raise [ArgumentError] when `name` isn't registered
66
+ # @raise [UnknownEntryError] when `name` isn't registered
64
67
  def lookup(name)
65
68
  registry[name] || begin
66
- raise ArgumentError, "unknown deprecator: #{name.inspect}"
69
+ raise UnknownEntryError, <<~MSG.chomp
70
+ unknown deprecator #{name.inspect}; registered: #{registry.keys.inspect}.
71
+ See https://drexed.github.io/cmdx/deprecation/#custom-actions-via-the-deprecators-registry
72
+ MSG
67
73
  end
68
74
  end
69
75
 
@@ -73,7 +79,7 @@ module CMDx
73
79
  #
74
80
  # @param spec [Symbol, #call, nil]
75
81
  # @return [#call, nil]
76
- # @raise [ArgumentError] when `spec` is an unknown symbol or not callable
82
+ # @raise [UnknownEntryError] when `spec` is an unknown symbol or not callable
77
83
  def resolve(spec)
78
84
  case spec
79
85
  when NilClass
@@ -83,7 +89,10 @@ module CMDx
83
89
  else
84
90
  return spec if spec.respond_to?(:call)
85
91
 
86
- raise ArgumentError, "unknown deprecator: #{spec.inspect}"
92
+ raise UnknownEntryError, <<~MSG.chomp
93
+ unknown deprecator #{spec.inspect}; expected a Symbol from #{registry.keys.inspect} or a callable.
94
+ See https://drexed.github.io/cmdx/deprecation/#custom-actions-via-the-deprecators-registry
95
+ MSG
87
96
  end
88
97
  end
89
98
 
data/lib/cmdx/errors.rb CHANGED
@@ -15,12 +15,13 @@ module CMDx
15
15
  end
16
16
 
17
17
  # Adds `message` under `key`. Duplicate messages are silently dropped.
18
+ # `key` is coerced to a Symbol to match {Context}'s key normalization.
18
19
  #
19
- # @param key [Symbol]
20
+ # @param key [Symbol, String]
20
21
  # @param message [String]
21
22
  # @return [Set<String>] the set of messages now stored under `key`
22
23
  def add(key, message)
23
- (messages[key] ||= Set.new) << message
24
+ (messages[key.to_sym] ||= Set.new) << message
24
25
  end
25
26
  alias []= add
26
27
 
@@ -40,23 +41,23 @@ module CMDx
40
41
  end
41
42
  end
42
43
 
43
- # @param key [Symbol]
44
+ # @param key [Symbol, String]
44
45
  # @return [Array<String>] messages for `key`, or a frozen empty array
45
46
  def [](key)
46
- messages[key]&.to_a || EMPTY_ARRAY
47
+ messages[key.to_sym]&.to_a || EMPTY_ARRAY
47
48
  end
48
49
 
49
- # @param key [Symbol]
50
+ # @param key [Symbol, String]
50
51
  # @param message [String]
51
52
  # @return [Boolean] true when `message` is recorded under `key`
52
53
  def added?(key, message)
53
- !!messages[key]&.include?(message)
54
+ !!messages[key.to_sym]&.include?(message)
54
55
  end
55
56
 
56
- # @param key [Symbol]
57
+ # @param key [Symbol, String]
57
58
  # @return [Boolean]
58
59
  def key?(key)
59
- messages.key?(key)
60
+ messages.key?(key.to_sym)
60
61
  end
61
62
  alias for? key?
62
63
 
@@ -98,10 +99,10 @@ module CMDx
98
99
  messages.each_value(&)
99
100
  end
100
101
 
101
- # @param key [Symbol]
102
+ # @param key [Symbol, String]
102
103
  # @return [Set<String>, nil] the removed set, or nil when absent
103
104
  def delete(key)
104
- messages.delete(key)
105
+ messages.delete(key.to_sym)
105
106
  end
106
107
 
107
108
  # @return [Hash{Symbol => Set<String>}] empties the container
@@ -5,7 +5,8 @@ module CMDx
5
5
  # Fiber-scheduler backed executor. Spawns one fiber per job, bounded by
6
6
  # `concurrency` via a `SizedQueue` semaphore. Requires a Fiber scheduler to
7
7
  # be installed on the current thread (e.g. inside `Async { ... }` from the
8
- # `async` gem). `pool_size` caps in-flight fibers.
8
+ # `async` gem). `pool_size` caps in-flight fibers. Exceptions raised inside
9
+ # `on_job` are captured and re-raised once every fiber has completed.
9
10
  #
10
11
  # @api private
11
12
  module Fiber
@@ -13,21 +14,30 @@ module CMDx
13
14
  extend self
14
15
 
15
16
  # @param jobs [Array]
16
- # @param concurrency [Integer] max in-flight fibers
17
+ # @param concurrency [Integer] max in-flight fibers (must be >= 1)
17
18
  # @param on_job [#call]
18
19
  # @return [void]
20
+ # @raise [ArgumentError] when `concurrency` is not a positive Integer
19
21
  # @raise [RuntimeError] when no `Fiber.scheduler` is installed
22
+ # @raise [StandardError] re-raises the first exception captured from any fiber
20
23
  def call(jobs:, concurrency:, on_job:)
24
+ raise ArgumentError, "executor concurrency must be a positive Integer (got #{concurrency.inspect})" unless concurrency.is_a?(Integer) && concurrency.positive?
25
+
21
26
  raise "executor: :fibers requires Fiber.scheduler; run the workflow inside a scheduler block (e.g. Async { ... })" unless ::Fiber.scheduler
22
27
 
23
28
  slots = SizedQueue.new(concurrency)
24
29
  concurrency.times { slots << :slot }
25
- done = Queue.new
30
+ done = Queue.new
31
+ errors = Queue.new
26
32
 
27
33
  jobs.each do |job|
28
34
  slots.pop
29
35
  ::Fiber.schedule do
30
- on_job.call(job)
36
+ begin
37
+ on_job.call(job)
38
+ rescue StandardError => e
39
+ errors << e
40
+ end
31
41
  ensure
32
42
  slots << :slot
33
43
  done << true
@@ -35,6 +45,8 @@ module CMDx
35
45
  end
36
46
 
37
47
  jobs.size.times { done.pop }
48
+
49
+ raise errors.pop unless errors.empty?
38
50
  end
39
51
 
40
52
  end
@@ -4,7 +4,9 @@ module CMDx
4
4
  class Executors
5
5
  # Default executor. Uses a fixed-size `Thread` pool drained via a `Queue`;
6
6
  # sentinel `nil`s terminate workers. Workers inherit the parent's chain via
7
- # fiber-local storage.
7
+ # fiber-local storage. Exceptions raised inside `on_job` are captured per
8
+ # worker and re-raised on the main thread once every worker has joined, so
9
+ # callers see failures instead of silently dropped jobs.
8
10
  #
9
11
  # @api private
10
12
  module Thread
@@ -12,23 +14,35 @@ module CMDx
12
14
  extend self
13
15
 
14
16
  # @param jobs [Array] opaque job objects forwarded to `on_job`
15
- # @param concurrency [Integer] worker count
17
+ # @param concurrency [Integer] worker count (must be >= 1)
16
18
  # @param on_job [#call] unary callable invoked per job
17
19
  # @return [void]
20
+ # @raise [ArgumentError] when `concurrency` is not a positive Integer
21
+ # @raise [StandardError] re-raises the first exception captured from any worker
18
22
  def call(jobs:, concurrency:, on_job:)
19
- queue = Queue.new
23
+ raise ArgumentError, "executor concurrency must be a positive Integer (got #{concurrency.inspect})" unless concurrency.is_a?(Integer) && concurrency.positive?
24
+
25
+ queue = Queue.new
26
+ errors = Queue.new
27
+
20
28
  jobs.each { |job| queue << job }
21
29
  concurrency.times { queue << nil }
22
30
 
23
31
  workers = Array.new(concurrency) do
24
32
  ::Thread.new do
25
33
  while (job = queue.pop)
26
- on_job.call(job)
34
+ begin
35
+ on_job.call(job)
36
+ rescue StandardError => e
37
+ errors << e
38
+ end
27
39
  end
28
40
  end
29
41
  end
30
42
 
31
43
  workers.each(&:join)
44
+
45
+ raise errors.pop unless errors.empty?
32
46
  end
33
47
 
34
48
  end
@@ -36,9 +36,12 @@ module CMDx
36
36
  executor = callable || block
37
37
 
38
38
  if callable && block
39
- raise ArgumentError, "provide either a callable or a block, not both"
39
+ raise ArgumentError, "executor: provide either a callable or a block, not both"
40
40
  elsif !executor.respond_to?(:call)
41
- raise ArgumentError, "executor must respond to #call"
41
+ raise ArgumentError, <<~MSG.chomp
42
+ executor must respond to #call (got #{executor.class}).
43
+ See https://drexed.github.io/cmdx/workflows/#executors
44
+ MSG
42
45
  end
43
46
 
44
47
  registry[name.to_sym] = executor
@@ -48,16 +51,25 @@ module CMDx
48
51
  # @param name [Symbol]
49
52
  # @return [Executors] self for chaining
50
53
  def deregister(name)
51
- registry.delete(name.to_sym)
54
+ registry.delete(name)
52
55
  self
53
56
  end
54
57
 
58
+ # @param name [Symbol]
59
+ # @return [Boolean] whether an executor is registered under `name`
60
+ def key?(name)
61
+ registry.key?(name)
62
+ end
63
+
55
64
  # @param name [Symbol]
56
65
  # @return [#call] the registered executor
57
- # @raise [ArgumentError] when `name` isn't registered
66
+ # @raise [UnknownEntryError] when `name` isn't registered
58
67
  def lookup(name)
59
68
  registry[name] || begin
60
- raise ArgumentError, "unknown executor: #{name.inspect}"
69
+ raise UnknownEntryError, <<~MSG.chomp
70
+ unknown executor #{name.inspect}; registered: #{registry.keys.inspect}.
71
+ See https://drexed.github.io/cmdx/workflows/#executors
72
+ MSG
61
73
  end
62
74
  end
63
75
 
@@ -67,7 +79,7 @@ module CMDx
67
79
  #
68
80
  # @param spec [Symbol, #call, nil]
69
81
  # @return [#call]
70
- # @raise [ArgumentError] when `spec` is an unknown symbol or not callable
82
+ # @raise [UnknownEntryError] when `spec` is an unknown symbol or not callable
71
83
  def resolve(spec)
72
84
  case spec
73
85
  when NilClass
@@ -77,7 +89,10 @@ module CMDx
77
89
  else
78
90
  return spec if spec.respond_to?(:call)
79
91
 
80
- raise ArgumentError, "unknown executor: #{spec.inspect}"
92
+ raise UnknownEntryError, <<~MSG.chomp
93
+ unknown executor #{spec.inspect}; expected a Symbol from #{registry.keys.inspect} or a callable.
94
+ See https://drexed.github.io/cmdx/workflows/#executors
95
+ MSG
81
96
  end
82
97
  end
83
98
 
data/lib/cmdx/fault.rb CHANGED
@@ -16,6 +16,10 @@ module CMDx
16
16
  # inherits from) any of the given task classes. Suitable for use in
17
17
  # `rescue`.
18
18
  #
19
+ # @note Each call allocates a new anonymous subclass. Hoist the matcher
20
+ # to module scope (`MY_FAULT = CMDx::Fault.for?(MyTask)`) when used
21
+ # on hot paths to avoid class allocation churn over time.
22
+ #
19
23
  # @param tasks [Array<Class>] one or more Task classes
20
24
  # @return [Class<Fault>] anonymous matcher subclass
21
25
  # @raise [ArgumentError] when no tasks are given
@@ -28,7 +32,7 @@ module CMDx
28
32
  # end
29
33
  def for?(*tasks)
30
34
  tasks = tasks.flatten
31
- raise ArgumentError, "at least one task required" if tasks.empty?
35
+ raise ArgumentError, "Fault.for? requires at least one Task class" if tasks.empty?
32
36
 
33
37
  matcher do |other|
34
38
  tasks.any? { |task| other.task <= task }
@@ -38,6 +42,10 @@ module CMDx
38
42
  # Returns a matcher subclass that matches Faults whose `result.reason`
39
43
  # is equal to the given string. Suitable for use in `rescue`.
40
44
  #
45
+ # @note Each call allocates a new anonymous subclass. Hoist the matcher
46
+ # to module scope (`PAYMENT_FAULT = CMDx::Fault.reason?("Payment failed")`)
47
+ # when used on hot paths to avoid class allocation churn.
48
+ #
41
49
  # @param reason [String] the reason to match
42
50
  # @return [Class<Fault>] anonymous matcher subclass
43
51
  # @raise [ArgumentError] when no reason is given
@@ -49,7 +57,7 @@ module CMDx
49
57
  # Alert.for_fault(fault)
50
58
  # end
51
59
  def reason?(reason)
52
- raise ArgumentError, "reason required" unless reason
60
+ raise ArgumentError, "Fault.reason? requires a reason" unless reason
53
61
 
54
62
  matcher do |other|
55
63
  other.result.reason == reason
@@ -58,13 +66,17 @@ module CMDx
58
66
 
59
67
  # Returns a matcher subclass whose `===` runs `block` against the fault.
60
68
  #
69
+ # @note Each call allocates a new anonymous subclass. Hoist the matcher
70
+ # to module scope when used on hot paths to avoid class allocation
71
+ # churn.
72
+ #
61
73
  # @param block [#call] `(fault) -> Boolean` matcher body
62
74
  # @yieldparam fault [Fault]
63
75
  # @yieldreturn [Boolean]
64
76
  # @return [Class<Fault>] anonymous matcher subclass
65
77
  # @raise [ArgumentError] when no block is given
66
78
  def matches?(&block)
67
- raise ArgumentError, "block required" unless block
79
+ raise ArgumentError, "Fault.matches? requires a block" unless block
68
80
 
69
81
  matcher(&block)
70
82
  end
@@ -79,8 +79,6 @@ module CMDx
79
79
 
80
80
  private
81
81
 
82
- # @param key [String, Symbol] lookup key (without locale prefix)
83
- # @return [Object, nil] message template from bundled YAML, when present
84
82
  def translation_default(key)
85
83
  default_locale = CMDx.configuration.default_locale || "en"
86
84
  translation_key = "#{default_locale}.#{key}"
@@ -92,9 +90,15 @@ module CMDx
92
90
  @translations[default_locale] ||= begin
93
91
  file = "#{default_locale}.yml"
94
92
  paths = self.class.locale_paths.map { |dir| File.join(dir, file) }.select { |p| File.exist?(p) }
95
- raise LoadError, "unable to load #{default_locale} translations" if paths.empty?
96
-
97
- paths.reduce({}) { |hash, path| hash.merge(YAML.safe_load_file(path)) }.freeze
93
+ if paths.empty?
94
+ raise UnknownLocaleError, <<~MSG.chomp
95
+ unable to load #{default_locale.inspect} translations;
96
+ searched: #{self.class.locale_paths.map { |dir| File.join(dir, file) }.inspect}.
97
+ See https://drexed.github.io/cmdx/internationalization/#custom-locale-paths
98
+ MSG
99
+ end
100
+
101
+ paths.reduce({}) { |acc, path| Util.deep_merge(acc, YAML.safe_load_file(path)) }.freeze
98
102
  end
99
103
 
100
104
  @defaults[translation_key] = @translations[default_locale].dig(*translation_key.split("."))
data/lib/cmdx/input.rb CHANGED
@@ -128,6 +128,11 @@ module CMDx
128
128
  # validation error to `task.errors`. Returns `nil` when coercion or any
129
129
  # validator fails (the failure message is recorded on `task.errors`).
130
130
  #
131
+ # @note "Required" here means "the key is present in the source"; an
132
+ # explicit `nil` under an existing key satisfies the required check
133
+ # and is then routed through `:default`. Combine with `:presence` /
134
+ # `:validate` to reject explicit `nil` values.
135
+ #
131
136
  # @param task [Task]
132
137
  # @return [Object, nil] the resolved value (`nil` on failure)
133
138
  def resolve(task)
@@ -177,10 +182,6 @@ module CMDx
177
182
 
178
183
  private
179
184
 
180
- # @param value [Object] candidate after source resolution
181
- # @param key_provided [Boolean] whether the source reported an explicit key/value pair
182
- # @param task [Task]
183
- # @return [Object, nil]
184
185
  def run_pipeline(value, key_provided, task)
185
186
  if required?(task) && !key_provided
186
187
  task.errors.add(accessor_name, I18nProxy.t("cmdx.attributes.required"))
@@ -202,15 +203,13 @@ module CMDx
202
203
  value
203
204
  end
204
205
 
205
- # @param task [Task]
206
- # @return [Array(Object, Boolean)] `[value, key_provided]`
207
206
  def resolve_with_key(task)
208
207
  case source
209
208
  when :context
210
209
  [task.context[name], task.context.key?(name)]
211
210
  when Symbol
212
211
  obj = task.send(source)
213
- return [nil, false] if obj.nil?
212
+ return [nil, false] unless obj
214
213
 
215
214
  fetch_by_name(obj)
216
215
  when Proc
@@ -218,20 +217,19 @@ module CMDx
218
217
  else
219
218
  return [source.call(task), true] if source.respond_to?(:call)
220
219
 
221
- raise ArgumentError, "source must be a Symbol, Proc, or respond to #call"
220
+ raise ArgumentError, <<~MSG.chomp
221
+ input source must be a Symbol, Proc, or respond to #call (got #{source.class}).
222
+ See https://drexed.github.io/cmdx/inputs/definitions/#sources
223
+ MSG
222
224
  end
223
225
  end
224
226
 
225
- # @param parent_value [#[], #key?, Object]
226
- # @return [Array(Object, Boolean)] `[value, key_provided]`
227
227
  def resolve_from_parent_with_key(parent_value)
228
- return [nil, false] unless parent_value.respond_to?(:[])
228
+ return [nil, false] unless parent_value.respond_to?(:[]) || parent_value.respond_to?(:fetch)
229
229
 
230
230
  fetch_by_name(parent_value)
231
231
  end
232
232
 
233
- # @param obj [Object] source object (`Hash`, duck-typed reader, etc.)
234
- # @return [Array(Object, Boolean)] `[value, key_provided]`
235
233
  def fetch_by_name(obj)
236
234
  if obj.respond_to?(name, true)
237
235
  [obj.send(name), true]
@@ -243,9 +241,15 @@ module CMDx
243
241
  else
244
242
  [nil, false]
245
243
  end
244
+ elsif obj.respond_to?(:fetch)
245
+ # Prefer #fetch with a sentinel so an explicit `nil` value is
246
+ # distinguishable from a missing key (Hash, Array, etc.).
247
+ value = obj.fetch(name) { obj.fetch(name.to_s) { EMPTY_SENTINEL } }
248
+ value.equal?(EMPTY_SENTINEL) ? [nil, false] : [value, true]
246
249
  elsif obj.respond_to?(:[])
247
- # Without #key? we cannot distinguish "key absent" from "value is nil",
248
- # so an explicit nil is treated as not provided (triggers default/required).
250
+ # Without #key? or #fetch we cannot distinguish "key absent" from
251
+ # "value is nil", so an explicit nil is treated as not provided
252
+ # (triggers default/required).
249
253
  value = obj[name] || obj[name.to_s]
250
254
  [value, !value.nil?]
251
255
  else
@@ -253,8 +257,6 @@ module CMDx
253
257
  end
254
258
  end
255
259
 
256
- # @param task [Task]
257
- # @return [Object, nil]
258
260
  def apply_default(task)
259
261
  return if default.nil?
260
262
 
@@ -270,9 +272,6 @@ module CMDx
270
272
  end
271
273
  end
272
274
 
273
- # @param value [Object]
274
- # @param task [Task]
275
- # @return [Object]
276
275
  def apply_transform(value, task)
277
276
  case transform
278
277
  when Symbol
@@ -286,7 +285,10 @@ module CMDx
286
285
  else
287
286
  return transform.call(value, task) if transform.respond_to?(:call)
288
287
 
289
- raise ArgumentError, "transform must be a Symbol, Proc, or respond to #call"
288
+ raise ArgumentError, <<~MSG.chomp
289
+ input transform must be a Symbol, Proc, or respond to #call (got #{transform.class}).
290
+ See https://drexed.github.io/cmdx/inputs/transformations/#declarations
291
+ MSG
290
292
  end
291
293
  end
292
294