cmdx 1.21.0 → 2.0.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -1
  3. data/README.md +37 -24
  4. data/lib/cmdx/.DS_Store +0 -0
  5. data/lib/cmdx/callbacks.rb +179 -0
  6. data/lib/cmdx/chain.rb +78 -175
  7. data/lib/cmdx/coercions/array.rb +19 -33
  8. data/lib/cmdx/coercions/big_decimal.rb +12 -29
  9. data/lib/cmdx/coercions/boolean.rb +25 -45
  10. data/lib/cmdx/coercions/coerce.rb +32 -0
  11. data/lib/cmdx/coercions/complex.rb +12 -27
  12. data/lib/cmdx/coercions/date.rb +29 -33
  13. data/lib/cmdx/coercions/date_time.rb +29 -33
  14. data/lib/cmdx/coercions/float.rb +8 -29
  15. data/lib/cmdx/coercions/hash.rb +17 -43
  16. data/lib/cmdx/coercions/integer.rb +8 -32
  17. data/lib/cmdx/coercions/rational.rb +12 -33
  18. data/lib/cmdx/coercions/string.rb +6 -24
  19. data/lib/cmdx/coercions/symbol.rb +12 -26
  20. data/lib/cmdx/coercions/time.rb +31 -35
  21. data/lib/cmdx/coercions.rb +174 -0
  22. data/lib/cmdx/configuration.rb +45 -237
  23. data/lib/cmdx/context.rb +264 -243
  24. data/lib/cmdx/deprecation.rb +67 -0
  25. data/lib/cmdx/deprecators/error.rb +22 -0
  26. data/lib/cmdx/deprecators/log.rb +22 -0
  27. data/lib/cmdx/deprecators/warn.rb +21 -0
  28. data/lib/cmdx/deprecators.rb +101 -0
  29. data/lib/cmdx/errors.rb +145 -79
  30. data/lib/cmdx/executors/fiber.rb +42 -0
  31. data/lib/cmdx/executors/thread.rb +36 -0
  32. data/lib/cmdx/executors.rb +95 -0
  33. data/lib/cmdx/fault.rb +85 -78
  34. data/lib/cmdx/i18n_proxy.rb +104 -0
  35. data/lib/cmdx/input.rb +294 -0
  36. data/lib/cmdx/inputs.rb +218 -0
  37. data/lib/cmdx/log_formatters/json.rb +9 -20
  38. data/lib/cmdx/log_formatters/key_value.rb +10 -21
  39. data/lib/cmdx/log_formatters/line.rb +7 -19
  40. data/lib/cmdx/log_formatters/logstash.rb +8 -21
  41. data/lib/cmdx/log_formatters/raw.rb +8 -20
  42. data/lib/cmdx/logger_proxy.rb +30 -0
  43. data/lib/cmdx/mergers/deep_merge.rb +23 -0
  44. data/lib/cmdx/mergers/last_write_wins.rb +23 -0
  45. data/lib/cmdx/mergers/no_merge.rb +20 -0
  46. data/lib/cmdx/mergers.rb +95 -0
  47. data/lib/cmdx/middlewares.rb +128 -0
  48. data/lib/cmdx/output.rb +115 -0
  49. data/lib/cmdx/outputs.rb +66 -0
  50. data/lib/cmdx/pipeline.rb +144 -131
  51. data/lib/cmdx/railtie.rb +10 -36
  52. data/lib/cmdx/result.rb +247 -524
  53. data/lib/cmdx/retriers/bounded_random.rb +24 -0
  54. data/lib/cmdx/retriers/decorrelated_jitter.rb +28 -0
  55. data/lib/cmdx/retriers/exponential.rb +23 -0
  56. data/lib/cmdx/retriers/fibonacci.rb +39 -0
  57. data/lib/cmdx/retriers/full_random.rb +23 -0
  58. data/lib/cmdx/retriers/half_random.rb +24 -0
  59. data/lib/cmdx/retriers/linear.rb +23 -0
  60. data/lib/cmdx/retriers.rb +106 -0
  61. data/lib/cmdx/retry.rb +117 -138
  62. data/lib/cmdx/runtime.rb +251 -0
  63. data/lib/cmdx/settings.rb +68 -200
  64. data/lib/cmdx/signal.rb +165 -0
  65. data/lib/cmdx/task.rb +443 -343
  66. data/lib/cmdx/telemetry.rb +108 -0
  67. data/lib/cmdx/util.rb +73 -0
  68. data/lib/cmdx/validators/absence.rb +10 -39
  69. data/lib/cmdx/validators/exclusion.rb +33 -52
  70. data/lib/cmdx/validators/format.rb +19 -49
  71. data/lib/cmdx/validators/inclusion.rb +33 -54
  72. data/lib/cmdx/validators/length.rb +125 -127
  73. data/lib/cmdx/validators/numeric.rb +123 -123
  74. data/lib/cmdx/validators/presence.rb +10 -39
  75. data/lib/cmdx/validators/validate.rb +31 -0
  76. data/lib/cmdx/validators.rb +161 -0
  77. data/lib/cmdx/version.rb +2 -4
  78. data/lib/cmdx/workflow.rb +71 -96
  79. data/lib/cmdx.rb +111 -42
  80. data/lib/generators/cmdx/install_generator.rb +7 -17
  81. data/lib/generators/cmdx/task_generator.rb +12 -29
  82. data/lib/generators/cmdx/templates/install.rb +120 -48
  83. data/lib/generators/cmdx/templates/task.rb.tt +1 -1
  84. data/lib/generators/cmdx/templates/workflow.rb.tt +1 -2
  85. data/lib/generators/cmdx/workflow_generator.rb +12 -29
  86. data/lib/locales/en.yml +8 -7
  87. data/mkdocs.yml +25 -23
  88. metadata +39 -138
  89. data/lib/cmdx/attribute.rb +0 -440
  90. data/lib/cmdx/attribute_registry.rb +0 -185
  91. data/lib/cmdx/attribute_value.rb +0 -252
  92. data/lib/cmdx/callback_registry.rb +0 -169
  93. data/lib/cmdx/coercion_registry.rb +0 -138
  94. data/lib/cmdx/deprecator.rb +0 -77
  95. data/lib/cmdx/exception.rb +0 -46
  96. data/lib/cmdx/executor.rb +0 -378
  97. data/lib/cmdx/identifier.rb +0 -30
  98. data/lib/cmdx/locale.rb +0 -78
  99. data/lib/cmdx/middleware_registry.rb +0 -148
  100. data/lib/cmdx/middlewares/correlate.rb +0 -140
  101. data/lib/cmdx/middlewares/runtime.rb +0 -77
  102. data/lib/cmdx/middlewares/timeout.rb +0 -78
  103. data/lib/cmdx/parallelizer.rb +0 -100
  104. data/lib/cmdx/utils/call.rb +0 -53
  105. data/lib/cmdx/utils/condition.rb +0 -71
  106. data/lib/cmdx/utils/format.rb +0 -82
  107. data/lib/cmdx/utils/normalize.rb +0 -52
  108. data/lib/cmdx/utils/wrap.rb +0 -38
  109. data/lib/cmdx/validator_registry.rb +0 -143
  110. data/lib/generators/cmdx/locale_generator.rb +0 -39
  111. data/lib/locales/af.yml +0 -55
  112. data/lib/locales/ar.yml +0 -55
  113. data/lib/locales/az.yml +0 -55
  114. data/lib/locales/be.yml +0 -55
  115. data/lib/locales/bg.yml +0 -55
  116. data/lib/locales/bn.yml +0 -55
  117. data/lib/locales/bs.yml +0 -55
  118. data/lib/locales/ca.yml +0 -55
  119. data/lib/locales/cnr.yml +0 -55
  120. data/lib/locales/cs.yml +0 -55
  121. data/lib/locales/cy.yml +0 -55
  122. data/lib/locales/da.yml +0 -55
  123. data/lib/locales/de.yml +0 -55
  124. data/lib/locales/dz.yml +0 -55
  125. data/lib/locales/el.yml +0 -55
  126. data/lib/locales/eo.yml +0 -55
  127. data/lib/locales/es.yml +0 -55
  128. data/lib/locales/et.yml +0 -55
  129. data/lib/locales/eu.yml +0 -55
  130. data/lib/locales/fa.yml +0 -55
  131. data/lib/locales/fi.yml +0 -55
  132. data/lib/locales/fr.yml +0 -55
  133. data/lib/locales/fy.yml +0 -55
  134. data/lib/locales/gd.yml +0 -55
  135. data/lib/locales/gl.yml +0 -55
  136. data/lib/locales/he.yml +0 -55
  137. data/lib/locales/hi.yml +0 -55
  138. data/lib/locales/hr.yml +0 -55
  139. data/lib/locales/hu.yml +0 -55
  140. data/lib/locales/hy.yml +0 -55
  141. data/lib/locales/id.yml +0 -55
  142. data/lib/locales/is.yml +0 -55
  143. data/lib/locales/it.yml +0 -55
  144. data/lib/locales/ja.yml +0 -55
  145. data/lib/locales/ka.yml +0 -55
  146. data/lib/locales/kk.yml +0 -55
  147. data/lib/locales/km.yml +0 -55
  148. data/lib/locales/kn.yml +0 -55
  149. data/lib/locales/ko.yml +0 -55
  150. data/lib/locales/lb.yml +0 -55
  151. data/lib/locales/lo.yml +0 -55
  152. data/lib/locales/lt.yml +0 -55
  153. data/lib/locales/lv.yml +0 -55
  154. data/lib/locales/mg.yml +0 -55
  155. data/lib/locales/mk.yml +0 -55
  156. data/lib/locales/ml.yml +0 -55
  157. data/lib/locales/mn.yml +0 -55
  158. data/lib/locales/mr-IN.yml +0 -55
  159. data/lib/locales/ms.yml +0 -55
  160. data/lib/locales/nb.yml +0 -55
  161. data/lib/locales/ne.yml +0 -55
  162. data/lib/locales/nl.yml +0 -55
  163. data/lib/locales/nn.yml +0 -55
  164. data/lib/locales/oc.yml +0 -55
  165. data/lib/locales/or.yml +0 -55
  166. data/lib/locales/pa.yml +0 -55
  167. data/lib/locales/pl.yml +0 -55
  168. data/lib/locales/pt.yml +0 -55
  169. data/lib/locales/rm.yml +0 -55
  170. data/lib/locales/ro.yml +0 -55
  171. data/lib/locales/ru.yml +0 -55
  172. data/lib/locales/sc.yml +0 -55
  173. data/lib/locales/sk.yml +0 -55
  174. data/lib/locales/sl.yml +0 -55
  175. data/lib/locales/sq.yml +0 -55
  176. data/lib/locales/sr.yml +0 -55
  177. data/lib/locales/st.yml +0 -55
  178. data/lib/locales/sv.yml +0 -55
  179. data/lib/locales/sw.yml +0 -55
  180. data/lib/locales/ta.yml +0 -55
  181. data/lib/locales/te.yml +0 -55
  182. data/lib/locales/th.yml +0 -55
  183. data/lib/locales/tl.yml +0 -55
  184. data/lib/locales/tr.yml +0 -55
  185. data/lib/locales/tt.yml +0 -55
  186. data/lib/locales/ug.yml +0 -55
  187. data/lib/locales/uk.yml +0 -55
  188. data/lib/locales/ur.yml +0 -55
  189. data/lib/locales/uz.yml +0 -55
  190. data/lib/locales/vi.yml +0 -55
  191. data/lib/locales/wo.yml +0 -55
  192. data/lib/locales/zh-CN.yml +0 -55
  193. data/lib/locales/zh-HK.yml +0 -55
  194. data/lib/locales/zh-TW.yml +0 -55
  195. data/lib/locales/zh-YUE.yml +0 -55
data/lib/cmdx/context.rb CHANGED
@@ -1,309 +1,330 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- # A hash-like context object that provides a flexible way to store and access
5
- # key-value pairs during task execution. Keys are automatically converted to
6
- # symbols for consistency.
7
- #
8
- # The Context class extends Forwardable to delegate common hash methods and
9
- # provides additional convenience methods for working with context data.
4
+ # Shared data object passed through task execution. Wraps a symbol-keyed
5
+ # hash; supports `ctx.foo`/`ctx.foo = 1`/`ctx.foo?` dynamic accessors via
6
+ # {#method_missing}. Runtime freezes the root context during teardown so
7
+ # nested subtasks can't mutate the outer task's state after completion.
10
8
  class Context
11
9
 
12
- extend Forwardable
10
+ include Enumerable
13
11
 
14
- # Returns the internal hash storing context data.
15
- #
16
- # @return [Hash{Symbol => Object}] The internal hash table
17
- #
18
- # @example
19
- # context.table # => { name: "John", age: 30 }
20
- #
21
- # @rbs @table: Hash[Symbol, untyped]
22
- attr_reader :table
23
- alias to_h table
12
+ class << self
24
13
 
25
- def_delegators :table, :keys, :values, :each, :each_key, :each_value, :map
14
+ # Normalizes `context` into a Context instance. Passes through an
15
+ # unfrozen Context unchanged (so nested tasks share state); unwraps
16
+ # anything with `#context` (e.g. a Task); wraps hashes/hash-likes into
17
+ # a new Context with symbolized keys.
18
+ #
19
+ # @param context [Context, #context, Hash, #to_h, #to_hash]
20
+ # @return [Context]
21
+ # @raise [ArgumentError] when `context` doesn't respond to `#to_h`/`#to_hash`
22
+ def build(context = EMPTY_HASH)
23
+ if context.is_a?(self) && !context.frozen?
24
+ context
25
+ elsif context.respond_to?(:context)
26
+ build(context.context)
27
+ else
28
+ new(context)
29
+ end
30
+ end
26
31
 
27
- # Creates a new Context instance from the given arguments.
28
- #
29
- # @param args [Hash, Object] arguments to initialize the context with
30
- # @option args [Object] :key the key-value pairs to store in the context
31
- #
32
- # @return [Context] a new Context instance
33
- #
34
- # @raise [ArgumentError] when args doesn't respond to `to_h` or `to_hash`
35
- #
36
- # @example
37
- # context = Context.new(name: "John", age: 30)
38
- # context[:name] # => "John"
32
+ end
33
+
34
+ # Enables strict mode when true, dynamic readers via {#method_missing}
35
+ # raise `NoMethodError` for unknown keys instead of returning `nil`.
36
+ # Set by `Task#initialize` from `Task.settings.strict_context`.
39
37
  #
40
- # @rbs (untyped args) -> void
41
- def initialize(args = {})
38
+ # @return [Boolean]
39
+ attr_accessor :strict
40
+
41
+ # @param context [Hash, #to_h, #to_hash] source hash, keys are symbolized
42
+ # @raise [ArgumentError] when `context` doesn't respond to `#to_h`/`#to_hash`
43
+ def initialize(context = EMPTY_HASH)
42
44
  @table =
43
- if args.respond_to?(:to_hash)
44
- args.to_hash
45
- elsif args.respond_to?(:to_h)
46
- args.to_h
45
+ if context.respond_to?(:to_hash)
46
+ context.to_hash
47
+ elsif context.respond_to?(:to_h)
48
+ context.to_h
47
49
  else
48
50
  raise ArgumentError, "must respond to `to_h` or `to_hash`"
49
51
  end.transform_keys(&:to_sym)
50
52
  end
51
53
 
52
- # Builds a Context instance, reusing existing unfrozen contexts when possible.
53
- #
54
- # @param context [Context, Object] the context to build from
55
- # @option context [Object] :key the key-value pairs to store in the context
56
- #
57
- # @return [Context] a Context instance, either new or reused
58
- #
59
- # @example
60
- # existing = Context.new(name: "John")
61
- # built = Context.build(existing) # reuses existing context
62
- # built.object_id == existing.object_id # => true
63
- #
64
- # @rbs (untyped context) -> Context
65
- def self.build(context = {})
66
- if context.is_a?(self) && !context.frozen?
67
- context
68
- elsif context.respond_to?(:context)
69
- build(context.context)
70
- else
71
- new(context)
72
- end
73
- end
74
-
75
- # Retrieves a value from the context by key.
76
- #
77
- # @param key [String, Symbol] the key to retrieve
78
- #
79
- # @return [Object, nil] the value associated with the key, or nil if not found
80
- #
81
- # @example
82
- # context = Context.new(name: "John")
83
- # context[:name] # => "John"
84
- # context["name"] # => "John" (automatically converted to symbol)
85
- #
86
- # @rbs ((String | Symbol) key) -> untyped
87
- def [](key)
88
- table[key.to_sym]
54
+ # @return [Boolean] whether dynamic reads for unknown keys raise instead
55
+ # of returning `nil`
56
+ def strict?
57
+ !!@strict
89
58
  end
90
59
 
91
- # Stores a key-value pair in the context.
92
- #
93
- # @param key [String, Symbol] the key to store
94
- # @param value [Object] the value to store
60
+ # Stores `value` under `key`, symbolizing the key. Overwrites any
61
+ # existing entry.
95
62
  #
63
+ # @param key [Symbol, String]
64
+ # @param value [Object]
96
65
  # @return [Object] the stored value
97
- #
98
- # @example
99
- # context = Context.new
100
- # context.store(:name, "John")
101
- # context[:name] # => "John"
102
- #
103
- # @rbs ((String | Symbol) key, untyped value) -> untyped
104
66
  def store(key, value)
105
- table[key.to_sym] = value
67
+ @table[key.to_sym] = value
106
68
  end
107
69
  alias []= store
108
70
 
109
- # Fetches a value from the context by key, with optional default value.
110
- #
111
- # @param key [String, Symbol] the key to fetch
112
- #
113
- # @yield [key] a block to compute the default value
114
- #
115
- # @return [Object] the value associated with the key, or the default/default block result
116
- #
117
- # @example
118
- # context = Context.new(name: "John")
119
- # context.fetch(:name) # => "John"
120
- # context.fetch(:age, 25) # => 25
121
- # context.fetch(:city) { |key| "Unknown #{key}" } # => "Unknown city"
71
+ # Merges another context/hash-like into this one in place. Keys from
72
+ # `context` win on conflict.
73
+ #
74
+ # @param context [Context, Hash, #to_h, #to_hash]
75
+ # @return [Context] self for chaining
76
+ def merge(context = EMPTY_HASH)
77
+ other = self.class.build(context)
78
+ @table.merge!(other.to_h)
79
+ self
80
+ end
81
+
82
+ # Like {#merge} but recursive into Hash values: a nested Hash key collision
83
+ # merges the two Hashes instead of replacing the left with the right.
84
+ # Non-Hash values follow last-write-wins (`context` wins).
85
+ #
86
+ # @param context [Context, Hash, #to_h, #to_hash]
87
+ # @return [Context] self for chaining
88
+ def deep_merge(context = EMPTY_HASH)
89
+ other = self.class.build(context)
90
+ @table = compute_deep_merge(@table, other.to_h)
91
+ self
92
+ end
93
+
94
+ # @param key [Symbol, String]
95
+ # @return [Object, nil]
96
+ def [](key)
97
+ @table[key.to_sym]
98
+ end
99
+
100
+ # Hash-like fetch. Supports a default value, default block, or raises
101
+ # `KeyError` just like `Hash#fetch`.
122
102
  #
123
- # @rbs ((String | Symbol) key, *untyped) ?{ ((String | Symbol)) -> untyped } -> untyped
103
+ # @param key [Symbol, String]
104
+ # @return [Object]
124
105
  def fetch(key, ...)
125
- table.fetch(key.to_sym, ...)
106
+ @table.fetch(key.to_sym, ...)
126
107
  end
127
108
 
128
- # Fetches a value from the context by key, or stores and returns a default value if not found.
129
- #
130
- # @param key [String, Symbol] the key to fetch or store
131
- # @param value [Object] the default value to store if key is not found
132
- #
133
- # @yield [key] a block to compute the default value to store
134
- #
135
- # @return [Object] the existing value if key is found, otherwise the stored default value
136
- #
137
- # @example
138
- # context = Context.new(name: "John")
139
- # context.fetch_or_store(:name, "Default") # => "John" (existing value)
140
- # context.fetch_or_store(:age, 25) # => 25 (stored and returned)
141
- # context.fetch_or_store(:city) { |key| "Unknown #{key}" } # => "Unknown city" (stored and returned)
142
- #
143
- # @rbs ((String | Symbol) key, ?untyped value) ?{ () -> untyped } -> untyped
144
- def fetch_or_store(key, value = nil)
145
- table.fetch(key.to_sym) do
146
- table[key.to_sym] = block_given? ? yield : value
109
+ # @param key [Symbol, String] top-level key (symbolized)
110
+ # @param keys [Array<Object>] nested keys passed through untouched
111
+ # @return [Object, nil]
112
+ def dig(key, *keys)
113
+ @table.dig(key.to_sym, *keys)
114
+ end
115
+
116
+ # Fetch-or-store. Returns the existing value, or stores and returns the
117
+ # default (from block if given, else `value`).
118
+ #
119
+ # @param key [Symbol, String]
120
+ # @param value [Object] fallback when no block is given
121
+ # @yield [] invoked only when `key` is absent
122
+ # @yieldreturn [Object] value to store
123
+ # @return [Object]
124
+ def retrieve(key, value = nil)
125
+ nk = key.to_sym
126
+
127
+ @table.fetch(nk) do
128
+ @table[nk] = block_given? ? yield : value
147
129
  end
148
130
  end
149
131
 
150
- # Merges the given arguments into the current context, modifying it in place.
151
- #
152
- # @param args [Hash, Object] arguments to merge into the context
153
- # @option args [Object] :key the key-value pairs to merge
154
- #
155
- # @return [Context] self for method chaining
156
- #
157
- # @example
158
- # context = Context.new(name: "John")
159
- # context.merge!(age: 30, city: "NYC")
160
- # context.to_h # => {name: "John", age: 30, city: "NYC"}
161
- #
162
- # @rbs (?untyped args) -> self
163
- def merge!(args = EMPTY_HASH)
164
- table.merge!(args.to_h.transform_keys(&:to_sym))
165
- self
132
+ # @param key [Symbol, String]
133
+ # @return [Boolean]
134
+ def key?(key)
135
+ @table.key?(key.to_sym)
166
136
  end
167
- alias merge merge!
168
137
 
169
- # Deletes a key-value pair from the context.
170
- #
171
- # @param key [String, Symbol] the key to delete
172
- #
173
- # @yield [key] a block to handle the case when key is not found
174
- #
175
- # @return [Object, nil] the deleted value, or the block result if key not found
176
- #
177
- # @example
178
- # context = Context.new(name: "John", age: 30)
179
- # context.delete!(:age) # => 30
180
- # context.delete!(:city) { |key| "Key #{key} not found" } # => "Key city not found"
181
- #
182
- # @rbs ((String | Symbol) key) ?{ ((String | Symbol)) -> untyped } -> untyped
183
- def delete!(key, &)
184
- table.delete(key.to_sym, &)
138
+ # @return [Array<Symbol>]
139
+ def keys
140
+ @table.keys
185
141
  end
186
- alias delete delete!
187
142
 
188
- # Clears all key-value pairs from the context.
189
- #
190
- # @return [Context] self for method chaining
191
- #
192
- # @example
193
- # context = Context.new(name: "John")
194
- # context.clear!
195
- # context.to_h # => {}
143
+ # @return [Array<Object>]
144
+ def values
145
+ @table.values
146
+ end
147
+
148
+ # @return [Boolean]
149
+ def empty?
150
+ @table.empty?
151
+ end
152
+
153
+ # @return [Integer]
154
+ def size
155
+ @table.size
156
+ end
157
+
158
+ # @yield [key, value]
159
+ # @return [Context, Enumerator]
160
+ def each(&)
161
+ @table.each(&)
162
+ end
163
+
164
+ # @yield [Symbol]
165
+ # @return [Context, Enumerator]
166
+ def each_key(&)
167
+ @table.each_key(&)
168
+ end
169
+
170
+ # @yield [Object]
171
+ # @return [Context, Enumerator]
172
+ def each_value(&)
173
+ @table.each_value(&)
174
+ end
175
+
176
+ # @param key [Symbol, String]
177
+ # @yield [Symbol] optional default block, receives the symbolized key
178
+ # @return [Object, nil] removed value
179
+ def delete(key, &)
180
+ @table.delete(key.to_sym, &)
181
+ end
182
+
183
+ # Removes every entry.
196
184
  #
197
- # @rbs () -> self
198
- def clear!
199
- table.clear
185
+ # @return [Context] self
186
+ def clear
187
+ @table.clear
200
188
  self
201
189
  end
202
- alias clear clear!
203
190
 
204
- # Compares this context with another object for equality.
205
- #
206
- # @param other [Object] the object to compare with
191
+ # Equal when `other` is a Context with the same underlying hash.
207
192
  #
208
- # @return [Boolean] true if other is a Context with the same data
209
- #
210
- # @example
211
- # context1 = Context.new(name: "John")
212
- # context2 = Context.new(name: "John")
213
- # context1 == context2 # => true
214
- #
215
- # @rbs (untyped other) -> bool
193
+ # @param other [Object]
194
+ # @return [Boolean]
216
195
  def eql?(other)
217
- other.is_a?(self.class) && (table == other.to_h)
196
+ other.is_a?(self.class) && (to_h == other.to_h)
218
197
  end
219
198
  alias == eql?
220
199
 
221
- # Checks if the context contains a specific key.
222
- #
223
- # @param key [String, Symbol] the key to check
224
- #
225
- # @return [Boolean] true if the key exists in the context
226
- #
227
- # @example
228
- # context = Context.new(name: "John")
229
- # context.key?(:name) # => true
230
- # context.key?(:age) # => false
231
- #
232
- # @rbs ((String | Symbol) key) -> bool
233
- def key?(key)
234
- table.key?(key.to_sym)
200
+ # @return [Integer]
201
+ def hash
202
+ @table.hash
235
203
  end
236
204
 
237
- # Digs into nested structures using the given keys.
238
- #
239
- # @param key [String, Symbol] the first key to dig with
240
- # @param keys [Array<String, Symbol>] additional keys for deeper digging
241
- #
242
- # @return [Object, nil] the value found by digging, or nil if not found
205
+ # @return [Hash{Symbol => Object}] the underlying table (not a copy)
206
+ def to_h
207
+ @table
208
+ end
209
+
210
+ # JSON-friendly hash view. Aliases {#to_h} for conventional `as_json`
211
+ # callers (e.g. Rails); values pass through unchanged — non-primitive
212
+ # entries rely on their own `as_json` / `to_json`.
243
213
  #
244
- # @example
245
- # context = Context.new(user: {profile: {name: "John"}})
246
- # context.dig(:user, :profile, :name) # => "John"
247
- # context.dig(:user, :profile, :age) # => nil
214
+ # @return [Hash{Symbol => Object}]
215
+ def as_json(*)
216
+ to_h
217
+ end
218
+
219
+ # Serializes the context to a JSON string. Symbol keys are emitted as
220
+ # strings by the `json` stdlib.
248
221
  #
249
- # @rbs ((String | Symbol) key, *(String | Symbol) keys) -> untyped
250
- def dig(key, *keys)
251
- table.dig(key.to_sym, *keys)
222
+ # @param args [Array] forwarded to `Hash#to_json`
223
+ # @return [String]
224
+ def to_json(*args)
225
+ to_h.to_json(*args)
252
226
  end
253
227
 
254
- # Converts the context to a string representation.
228
+ # @return [String] space-separated `key=value.inspect` pairs
229
+ def to_s
230
+ @table.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
231
+ end
232
+
233
+ # Pattern-matching support for `case context in {...}`.
255
234
  #
256
- # @return [String] a formatted string representation of the context data
235
+ # @param keys [Array<Symbol>, nil] restrict the returned hash to these keys
236
+ # @return [Hash{Symbol => Object}]
237
+ def deconstruct_keys(keys)
238
+ keys.nil? ? @table : @table.slice(*keys)
239
+ end
240
+
241
+ # Pattern-matching support for `case context in [...]`.
257
242
  #
258
- # @example
259
- # context = Context.new(name: "John", age: 30)
260
- # context.to_s # => "name: John, age: 30"
243
+ # @return [Array<Array(Symbol, Object)>]
244
+ def deconstruct
245
+ @table.to_a
246
+ end
247
+
248
+ # Returns a deep copy. Non-mutable scalars are shared; Hashes/Arrays are
249
+ # recursively duplicated; other objects fall back to `#dup` (and then
250
+ # to the original on `StandardError`).
251
+ #
252
+ # @return [Context]
253
+ def deep_dup
254
+ ctx = self.class.allocate
255
+ ctx.instance_variable_set(:@table, compute_deep_dup(@table))
256
+ ctx
257
+ end
258
+
259
+ # Freezes the context and its backing hash. Runtime calls this on the
260
+ # root task's context during teardown.
261
261
  #
262
- # @rbs () -> String
263
- def to_s
264
- Utils::Format.to_str(to_h)
262
+ # @return [Context] self
263
+ def freeze
264
+ @table.freeze
265
+ super
265
266
  end
266
267
 
267
268
  private
268
269
 
269
- # Handles method calls that don't match defined methods.
270
- # Supports assignment-style calls (e.g., `name=`) and key access.
271
- #
272
- # @param method_name [Symbol] the method name that was called
273
- # @param args [Array<Object>] arguments passed to the method
274
- # @param _kwargs [Hash] keyword arguments (unused)
275
- # @option _kwargs [Object] :* Any keyword arguments (unused)
276
- #
277
- # @yield [Object] optional block
278
- #
279
- # @return [Object] the result of the method call
280
- #
281
- # @rbs (Symbol method_name, *untyped args, **untyped _kwargs) ?{ () -> untyped } -> untyped
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
282
283
  def method_missing(method_name, *args, **_kwargs, &)
283
284
  if method_name.end_with?("=")
284
- store(method_name.name.chop, args.first)
285
+ @table[method_name[..-2].to_sym] = args.first
286
+ elsif method_name.end_with?("?")
287
+ !!@table[method_name[..-2].to_sym]
288
+ elsif strict? && !@table.key?(method_name)
289
+ raise NoMethodError, "unknown context key #{method_name.inspect} (strict mode)"
285
290
  else
286
- table[method_name]
291
+ @table[method_name]
287
292
  end
288
293
  end
289
294
 
290
- # Checks if the object responds to a given method.
291
- # Supports both getter access for existing keys and setter methods.
292
- #
293
- # @param method_name [Symbol] the method name to check
294
- # @param include_private [Boolean] whether to include private methods
295
- #
296
- # @return [Boolean] true if the method can be called
297
- #
298
- # @example
299
- # context = Context.new(name: "John")
300
- # context.respond_to?(:name) # => true
301
- # context.respond_to?(:name=) # => true
302
- # context.respond_to?(:age) # => false
303
- #
304
- # @rbs (Symbol method_name, ?bool include_private) -> bool
295
+ # @param method_name [Symbol]
296
+ # @param include_private [Boolean] forwarded to Ruby's `respond_to?` lookup
297
+ # @return [Boolean]
305
298
  def respond_to_missing?(method_name, include_private = false)
306
- key?(method_name) || method_name.end_with?("=") || super
299
+ @table.key?(method_name) || method_name.end_with?("=", "?") || super
300
+ end
301
+
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
307
328
  end
308
329
 
309
330
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Declared via `Task.deprecation`. Runs before a task's lifecycle to warn,
5
+ # log, raise, or delegate when a task class has been marked deprecated.
6
+ # Supports conditional `:if` / `:unless` gating via {Util.satisfied?}.
7
+ class Deprecation
8
+
9
+ # @param value [:log, :warn, :error, Symbol, Proc, #call, nil] action to take;
10
+ # `nil` disables
11
+ # @param options [Hash{Symbol => Object}]
12
+ # @option options [Symbol, Proc, #call] :if
13
+ # @option options [Symbol, Proc, #call] :unless
14
+ def initialize(value, options = EMPTY_HASH)
15
+ @value = value
16
+ @options = options.freeze
17
+ end
18
+
19
+ # Runs the configured deprecation action, yielding first so Runtime can
20
+ # mark the result as deprecated for telemetry.
21
+ #
22
+ # @param task [Task]
23
+ # @yield invoked immediately before the action runs, only when conditions pass
24
+ # @return [void]
25
+ # @raise [DeprecationError] when `value` is `:error`
26
+ # @raise [ArgumentError] when `value` is an unsupported type
27
+ def execute(task)
28
+ return if @value.nil?
29
+ return unless Util.satisfied?(@options[:if], @options[:unless], task)
30
+
31
+ yield
32
+
33
+ case @value
34
+ when Symbol
35
+ registry = deprecators_registry(task)
36
+ if registry.key?(@value)
37
+ registry.lookup(@value).call(task)
38
+ else
39
+ task.send(@value)
40
+ end
41
+ when Proc
42
+ task.instance_exec(task, &@value)
43
+ else
44
+ return @value.call(task) if @value.respond_to?(:call)
45
+
46
+ raise ArgumentError, "deprecation must be a Symbol, Proc, or respond to #call"
47
+ end
48
+ end
49
+
50
+ private
51
+
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
+ def deprecators_registry(task)
59
+ if task.class.respond_to?(:deprecators)
60
+ task.class.deprecators
61
+ else
62
+ CMDx.configuration.deprecators
63
+ end
64
+ end
65
+
66
+ end
67
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ class Deprecators
5
+ # Raises {DeprecationError} to prevent the task from executing. Use for
6
+ # tasks that must no longer run.
7
+ #
8
+ # @api private
9
+ module Error
10
+
11
+ extend self
12
+
13
+ # @param task [Task]
14
+ # @return [void]
15
+ # @raise [DeprecationError]
16
+ def call(task)
17
+ raise DeprecationError, "#{task.class} usage prohibited"
18
+ end
19
+
20
+ end
21
+ end
22
+ end