cmdx 0.4.0 → 1.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/.cursor/rules/cursor-instructions.mdc +6 -0
  4. data/.rubocop.yml +16 -1
  5. data/.ruby-version +1 -1
  6. data/CHANGELOG.md +42 -1
  7. data/README.md +72 -25
  8. data/docs/ai_prompts.md +309 -0
  9. data/docs/basics/call.md +225 -14
  10. data/docs/basics/chain.md +271 -0
  11. data/docs/basics/context.md +232 -33
  12. data/docs/basics/setup.md +76 -12
  13. data/docs/callbacks.md +273 -0
  14. data/docs/configuration.md +158 -28
  15. data/docs/getting_started.md +134 -22
  16. data/docs/interruptions/exceptions.md +189 -11
  17. data/docs/interruptions/faults.md +187 -44
  18. data/docs/interruptions/halt.md +179 -35
  19. data/docs/logging.md +194 -53
  20. data/docs/middlewares.md +735 -0
  21. data/docs/outcomes/result.md +296 -10
  22. data/docs/outcomes/states.md +212 -19
  23. data/docs/outcomes/statuses.md +284 -18
  24. data/docs/parameters/coercions.md +402 -29
  25. data/docs/parameters/defaults.md +249 -25
  26. data/docs/parameters/definitions.md +238 -72
  27. data/docs/parameters/namespacing.md +250 -27
  28. data/docs/parameters/validations.md +193 -168
  29. data/docs/testing.md +550 -0
  30. data/docs/tips_and_tricks.md +95 -43
  31. data/docs/workflows.md +319 -0
  32. data/lib/cmdx/.DS_Store +0 -0
  33. data/lib/cmdx/callback.rb +69 -0
  34. data/lib/cmdx/callback_registry.rb +106 -0
  35. data/lib/cmdx/chain.rb +190 -0
  36. data/lib/cmdx/chain_inspector.rb +149 -0
  37. data/lib/cmdx/chain_serializer.rb +175 -0
  38. data/lib/cmdx/coercions/array.rb +37 -0
  39. data/lib/cmdx/coercions/big_decimal.rb +33 -0
  40. data/lib/cmdx/coercions/boolean.rb +41 -1
  41. data/lib/cmdx/coercions/complex.rb +31 -0
  42. data/lib/cmdx/coercions/date.rb +39 -0
  43. data/lib/cmdx/coercions/date_time.rb +39 -0
  44. data/lib/cmdx/coercions/float.rb +31 -0
  45. data/lib/cmdx/coercions/hash.rb +42 -0
  46. data/lib/cmdx/coercions/integer.rb +32 -0
  47. data/lib/cmdx/coercions/rational.rb +31 -0
  48. data/lib/cmdx/coercions/string.rb +31 -0
  49. data/lib/cmdx/coercions/time.rb +39 -0
  50. data/lib/cmdx/coercions/virtual.rb +31 -0
  51. data/lib/cmdx/configuration.rb +217 -9
  52. data/lib/cmdx/context.rb +173 -2
  53. data/lib/cmdx/core_ext/hash.rb +72 -0
  54. data/lib/cmdx/core_ext/module.rb +94 -0
  55. data/lib/cmdx/core_ext/object.rb +105 -0
  56. data/lib/cmdx/correlator.rb +217 -0
  57. data/lib/cmdx/error.rb +210 -8
  58. data/lib/cmdx/errors.rb +256 -1
  59. data/lib/cmdx/fault.rb +177 -2
  60. data/lib/cmdx/faults.rb +158 -2
  61. data/lib/cmdx/immutator.rb +121 -2
  62. data/lib/cmdx/lazy_struct.rb +261 -18
  63. data/lib/cmdx/log_formatters/json.rb +46 -0
  64. data/lib/cmdx/log_formatters/key_value.rb +46 -0
  65. data/lib/cmdx/log_formatters/line.rb +54 -0
  66. data/lib/cmdx/log_formatters/logstash.rb +64 -0
  67. data/lib/cmdx/log_formatters/pretty_json.rb +57 -0
  68. data/lib/cmdx/log_formatters/pretty_key_value.rb +51 -0
  69. data/lib/cmdx/log_formatters/pretty_line.rb +60 -0
  70. data/lib/cmdx/log_formatters/raw.rb +54 -0
  71. data/lib/cmdx/logger.rb +85 -0
  72. data/lib/cmdx/logger_ansi.rb +93 -7
  73. data/lib/cmdx/logger_serializer.rb +116 -0
  74. data/lib/cmdx/middleware.rb +74 -0
  75. data/lib/cmdx/middleware_registry.rb +106 -0
  76. data/lib/cmdx/middlewares/correlate.rb +266 -0
  77. data/lib/cmdx/middlewares/timeout.rb +232 -0
  78. data/lib/cmdx/parameter.rb +228 -1
  79. data/lib/cmdx/parameter_inspector.rb +61 -0
  80. data/lib/cmdx/parameter_registry.rb +125 -0
  81. data/lib/cmdx/parameter_serializer.rb +83 -0
  82. data/lib/cmdx/parameter_validator.rb +62 -0
  83. data/lib/cmdx/parameter_value.rb +109 -1
  84. data/lib/cmdx/parameters_inspector.rb +59 -0
  85. data/lib/cmdx/parameters_serializer.rb +102 -0
  86. data/lib/cmdx/railtie.rb +123 -3
  87. data/lib/cmdx/result.rb +399 -20
  88. data/lib/cmdx/result_ansi.rb +105 -9
  89. data/lib/cmdx/result_inspector.rb +76 -0
  90. data/lib/cmdx/result_logger.rb +90 -3
  91. data/lib/cmdx/result_serializer.rb +137 -0
  92. data/lib/cmdx/rspec/result_matchers.rb +917 -0
  93. data/lib/cmdx/rspec/task_matchers.rb +570 -0
  94. data/lib/cmdx/task.rb +409 -34
  95. data/lib/cmdx/task_serializer.rb +74 -2
  96. data/lib/cmdx/utils/ansi_color.rb +95 -0
  97. data/lib/cmdx/utils/log_timestamp.rb +48 -0
  98. data/lib/cmdx/utils/monotonic_runtime.rb +71 -4
  99. data/lib/cmdx/utils/name_affix.rb +78 -0
  100. data/lib/cmdx/validators/custom.rb +82 -0
  101. data/lib/cmdx/validators/exclusion.rb +94 -0
  102. data/lib/cmdx/validators/format.rb +102 -8
  103. data/lib/cmdx/validators/inclusion.rb +104 -0
  104. data/lib/cmdx/validators/length.rb +128 -0
  105. data/lib/cmdx/validators/numeric.rb +128 -0
  106. data/lib/cmdx/validators/presence.rb +93 -7
  107. data/lib/cmdx/version.rb +7 -1
  108. data/lib/cmdx/workflow.rb +394 -0
  109. data/lib/cmdx.rb +25 -64
  110. data/lib/generators/cmdx/install_generator.rb +37 -1
  111. data/lib/generators/cmdx/task_generator.rb +69 -1
  112. data/lib/generators/cmdx/templates/install.rb +8 -12
  113. data/lib/generators/cmdx/workflow_generator.rb +109 -0
  114. metadata +54 -15
  115. data/docs/basics/run.md +0 -34
  116. data/docs/batch.md +0 -53
  117. data/docs/example.md +0 -82
  118. data/docs/hooks.md +0 -59
  119. data/lib/cmdx/batch.rb +0 -43
  120. data/lib/cmdx/parameters.rb +0 -34
  121. data/lib/cmdx/run.rb +0 -38
  122. data/lib/cmdx/run_inspector.rb +0 -26
  123. data/lib/cmdx/run_serializer.rb +0 -16
  124. data/lib/cmdx/task_hook.rb +0 -18
  125. data/lib/generators/cmdx/batch_generator.rb +0 -30
  126. /data/lib/generators/cmdx/templates/{batch.rb.tt → workflow.rb.tt} +0 -0
@@ -0,0 +1,570 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Custom RSpec matchers for CMDx task behavior testing
4
+ #
5
+ # This module provides specialized matchers for testing task classes and their
6
+ # behavior rather than execution results. These matchers focus on task structure,
7
+ # parameter validation, middleware composition, callback registration, and lifecycle
8
+ # management following RSpec Style Guide conventions.
9
+ #
10
+ # The matchers are designed to test task classes before execution, validating
11
+ # their configuration, behavior patterns, and architectural compliance.
12
+ #
13
+ # @example Parameter validation testing
14
+ # expect(TaskClass).to validate_required_parameter(:user_id)
15
+ # expect(TaskClass).to validate_parameter_type(:count, :integer)
16
+ # expect(TaskClass).to use_default_value(:timeout, 30)
17
+ #
18
+ # @example Middleware and callback testing
19
+ # expect(TaskClass).to have_middleware(LoggingMiddleware)
20
+ # expect(TaskClass).to have_callback(:before_validation)
21
+ # expect(TaskClass).to execute_callbacks(:before_validation, :on_success)
22
+ #
23
+ # @example Task lifecycle and behavior testing
24
+ # expect(TaskClass).to be_well_formed_task
25
+ # expect(TaskClass).to handle_exceptions_gracefully
26
+ #
27
+ # @see https://rspec.rubystyle.guide/ RSpec Style Guide
28
+ # @since 1.0.0
29
+
30
+ # Tests that a task class validates a required parameter
31
+ #
32
+ # This matcher verifies that a task properly validates the presence of
33
+ # a required parameter by calling the task without the parameter and
34
+ # ensuring it fails with an appropriate validation message.
35
+ #
36
+ # @param [Symbol] parameter_name The name of the required parameter to test
37
+ #
38
+ # @example Basic required parameter validation
39
+ # expect(CreateUserTask).to validate_required_parameter(:email)
40
+ # expect(ProcessOrderTask).to validate_required_parameter(:order_id)
41
+ #
42
+ # @example Negated usage
43
+ # expect(OptionalTask).not_to validate_required_parameter(:optional_field)
44
+ #
45
+ # @return [Boolean] true if task fails validation when parameter is missing
46
+ #
47
+ # @since 1.0.0
48
+ RSpec::Matchers.define :validate_required_parameter do |parameter_name|
49
+ match do |task_class|
50
+ result = task_class.call
51
+ result.failed? &&
52
+ result.metadata[:reason]&.include?("#{parameter_name} is a required parameter")
53
+ end
54
+
55
+ failure_message do |task_class|
56
+ result = task_class.call
57
+ if result.success?
58
+ "expected task to fail validation for required parameter #{parameter_name}, but it succeeded"
59
+ elsif result.failed?
60
+ "expected task to fail with message about required parameter #{parameter_name}, but failed with: #{result.metadata[:reason]}"
61
+ else
62
+ "expected task to fail validation for required parameter #{parameter_name}, but was #{result.status}"
63
+ end
64
+ end
65
+
66
+ failure_message_when_negated do |_task_class|
67
+ "expected task not to validate required parameter #{parameter_name}, but it did"
68
+ end
69
+
70
+ description do
71
+ "validate required parameter #{parameter_name}"
72
+ end
73
+ end
74
+
75
+ # Tests that a task class validates parameter type coercion
76
+ #
77
+ # This matcher verifies that a task properly validates parameter types by
78
+ # passing an invalid type value and ensuring the task fails with an
79
+ # appropriate type validation message.
80
+ #
81
+ # @param [Symbol] parameter_name The name of the parameter to test
82
+ # @param [Symbol] expected_type The expected parameter type (:integer, :string, :boolean, etc.)
83
+ #
84
+ # @example Basic type validation testing
85
+ # expect(CreateUserTask).to validate_parameter_type(:age, :integer)
86
+ # expect(UpdateSettingsTask).to validate_parameter_type(:enabled, :boolean)
87
+ # expect(SearchTask).to validate_parameter_type(:filters, :hash)
88
+ #
89
+ # @example Negated usage
90
+ # expect(FlexibleTask).not_to validate_parameter_type(:flexible_param, :string)
91
+ #
92
+ # @return [Boolean] true if task fails validation when invalid type is provided
93
+ #
94
+ # @since 1.0.0
95
+ RSpec::Matchers.define :validate_parameter_type do |parameter_name, expected_type|
96
+ match do |task_class|
97
+ # Test with invalid type - use string when expecting integer, etc.
98
+ invalid_value = case expected_type
99
+ when :integer then "not_an_integer"
100
+ when :string then 123
101
+ when :boolean then "not_a_boolean"
102
+ when :hash then "not_a_hash"
103
+ when :array then "not_an_array"
104
+ else "invalid_value"
105
+ end
106
+
107
+ result = task_class.call(parameter_name => invalid_value)
108
+ result.failed? &&
109
+ result.metadata[:reason]&.include?("#{parameter_name} must be a #{expected_type}")
110
+ end
111
+
112
+ failure_message do |task_class|
113
+ invalid_value = case expected_type
114
+ when :integer then "not_an_integer"
115
+ when :string then 123
116
+ when :boolean then "not_a_boolean"
117
+ when :hash then "not_a_hash"
118
+ when :array then "not_an_array"
119
+ else "invalid_value"
120
+ end
121
+
122
+ result = task_class.call(parameter_name => invalid_value)
123
+ if result.success?
124
+ "expected task to fail type validation for parameter #{parameter_name} (#{expected_type}), but it succeeded"
125
+ elsif result.failed?
126
+ "expected task to fail with type validation message for #{parameter_name} (#{expected_type}), but failed with: #{result.metadata[:reason]}"
127
+ else
128
+ "expected task to fail type validation for parameter #{parameter_name} (#{expected_type}), but was #{result.status}"
129
+ end
130
+ end
131
+
132
+ failure_message_when_negated do |_task_class|
133
+ "expected task not to validate parameter type #{parameter_name} (#{expected_type}), but it did"
134
+ end
135
+
136
+ description do
137
+ "validate parameter type #{parameter_name} (#{expected_type})"
138
+ end
139
+ end
140
+
141
+ # Tests that a task class uses a specific default value for a parameter
142
+ #
143
+ # This matcher verifies that when a parameter is not provided, the task
144
+ # uses the expected default value by calling the task without the parameter
145
+ # and checking the context contains the default value.
146
+ #
147
+ # @param [Symbol] parameter_name The name of the parameter to test
148
+ # @param [Object] default_value The expected default value
149
+ #
150
+ # @example Basic default value testing
151
+ # expect(ProcessTask).to use_default_value(:timeout, 30)
152
+ # expect(EmailTask).to use_default_value(:priority, "normal")
153
+ # expect(ConfigTask).to use_default_value(:enabled, true)
154
+ #
155
+ # @example Negated usage
156
+ # expect(RequiredParamTask).not_to use_default_value(:required_field, nil)
157
+ #
158
+ # @return [Boolean] true if task uses the expected default value
159
+ #
160
+ # @since 1.0.0
161
+ RSpec::Matchers.define :use_default_value do |parameter_name, default_value|
162
+ match do |task_class|
163
+ result = task_class.call
164
+ result.success? &&
165
+ result.context.public_send(parameter_name) == default_value
166
+ end
167
+
168
+ failure_message do |task_class|
169
+ result = task_class.call
170
+ if result.failed?
171
+ "expected task to use default value #{default_value} for #{parameter_name}, but task failed: #{result.metadata[:reason]}"
172
+ else
173
+ actual_value = result.context.public_send(parameter_name)
174
+ "expected task to use default value #{default_value} for #{parameter_name}, but was #{actual_value}"
175
+ end
176
+ end
177
+
178
+ failure_message_when_negated do |_task_class|
179
+ "expected task not to use default value #{default_value} for #{parameter_name}, but it did"
180
+ end
181
+
182
+ description do
183
+ "use default value #{default_value} for parameter #{parameter_name}"
184
+ end
185
+ end
186
+
187
+ # Tests that a task class has a specific middleware registered
188
+ #
189
+ # This matcher verifies that a task has registered a specific middleware
190
+ # class in its middleware registry, ensuring proper middleware composition.
191
+ #
192
+ # @param [Class] middleware_class The middleware class to check for
193
+ #
194
+ # @example Basic middleware testing
195
+ # expect(AuthenticatedTask).to have_middleware(AuthenticationMiddleware)
196
+ # expect(LoggedTask).to have_middleware(LoggingMiddleware)
197
+ # expect(TimedTask).to have_middleware(TimeoutMiddleware)
198
+ #
199
+ # @example Negated usage
200
+ # expect(SimpleTask).not_to have_middleware(ComplexMiddleware)
201
+ #
202
+ # @return [Boolean] true if task has the specified middleware registered
203
+ #
204
+ # @since 1.0.0
205
+ RSpec::Matchers.define :have_middleware do |middleware_class|
206
+ match do |task_class|
207
+ task_class.cmd_middlewares.any? do |middleware|
208
+ middleware.is_a?(middleware_class) || middleware.instance_of?(middleware_class)
209
+ end
210
+ end
211
+
212
+ failure_message do |task_class|
213
+ middleware_classes = task_class.cmd_middlewares.map(&:class)
214
+ "expected task to have middleware #{middleware_class}, but had #{middleware_classes}"
215
+ end
216
+
217
+ failure_message_when_negated do |_task_class|
218
+ "expected task not to have middleware #{middleware_class}, but it did"
219
+ end
220
+
221
+ description do
222
+ "have middleware #{middleware_class}"
223
+ end
224
+ end
225
+
226
+ # Tests that a task class has a specific callback registered
227
+ #
228
+ # This matcher verifies that a task has registered a callback for a specific
229
+ # lifecycle event. Optionally validates the callback uses a specific callable.
230
+ #
231
+ # @param [Symbol] callback_name The name of the callback to check for
232
+ #
233
+ # @example Basic callback testing
234
+ # expect(ValidatedTask).to have_callback(:before_validation)
235
+ # expect(NotifiedTask).to have_callback(:on_success)
236
+ # expect(CleanupTask).to have_callback(:after_execution)
237
+ #
238
+ # @example Callback with specific callable
239
+ # expect(CustomTask).to have_callback(:on_failure).with_callable(my_proc)
240
+ #
241
+ # @example Negated usage
242
+ # expect(SimpleTask).not_to have_callback(:complex_callback)
243
+ #
244
+ # @return [Boolean] true if task has the specified callback registered
245
+ #
246
+ # @since 1.0.0
247
+ RSpec::Matchers.define :have_callback do |callback_name|
248
+ match do |task_class|
249
+ task_class.cmd_callbacks.registered?(callback_name)
250
+ end
251
+
252
+ chain :with_callable do |callable|
253
+ @expected_callable = callable
254
+ end
255
+
256
+ match do |task_class|
257
+ callbacks_registered = task_class.cmd_callbacks.registered?(callback_name)
258
+ return false unless callbacks_registered
259
+
260
+ if @expected_callable
261
+ task_class.cmd_callbacks.find(callback_name).any? do |callback|
262
+ callback.callable == @expected_callable
263
+ end
264
+ else
265
+ true
266
+ end
267
+ end
268
+
269
+ failure_message do |task_class|
270
+ if @expected_callable
271
+ "expected task to have callback #{callback_name} with callable #{@expected_callable}, but it didn't"
272
+ else
273
+ registered_callbacks = task_class.cmd_callbacks.registered_callbacks
274
+ "expected task to have callback #{callback_name}, but had #{registered_callbacks}"
275
+ end
276
+ end
277
+
278
+ failure_message_when_negated do |_task_class|
279
+ if @expected_callable
280
+ "expected task not to have callback #{callback_name} with callable #{@expected_callable}, but it did"
281
+ else
282
+ "expected task not to have callback #{callback_name}, but it did"
283
+ end
284
+ end
285
+
286
+ description do
287
+ desc = "have callback #{callback_name}"
288
+ desc += " with callable #{@expected_callable}" if @expected_callable
289
+ desc
290
+ end
291
+ end
292
+
293
+ # Tests that a task executes specific callbacks during its lifecycle
294
+ #
295
+ # This matcher verifies that when a task is executed, it triggers the
296
+ # expected callbacks in the proper sequence. Works by mocking callback execution
297
+ # and tracking which callbacks are called.
298
+ #
299
+ # @param [Array<Symbol>] callback_names The names of callbacks that should be executed
300
+ #
301
+ # @example Basic callback execution testing
302
+ # expect(task).to execute_callbacks(:before_validation, :after_validation)
303
+ # expect(failed_task).to execute_callbacks(:before_execution, :on_failure)
304
+ #
305
+ # @example Single callback execution
306
+ # expect(simple_task).to execute_callbacks(:on_success)
307
+ #
308
+ # @example Negated usage
309
+ # expect(task).not_to execute_callbacks(:unused_callback)
310
+ #
311
+ # @note This matcher requires the task to be executed and may mock internal
312
+ # callback execution mechanisms for testing purposes.
313
+ #
314
+ # @return [Boolean] true if task executes all specified callbacks
315
+ #
316
+ # @since 1.0.0
317
+ RSpec::Matchers.define :execute_callbacks do |*callback_names|
318
+ match do |task_or_result|
319
+ @executed_callbacks = []
320
+
321
+ # Mock the callback execution to track what gets called
322
+ if task_or_result.is_a?(CMDx::Task)
323
+ task = task_or_result
324
+ original_callback_call = task.cmd_callbacks.method(:call)
325
+
326
+ allow(task.cmd_callbacks).to receive(:call) do |task_instance, callback_name|
327
+ @executed_callbacks << callback_name
328
+ original_callback_call.call(task_instance, callback_name)
329
+ end
330
+
331
+ task.perform
332
+ else
333
+ # If it's a result, check if callbacks were executed during task execution
334
+ result = task_or_result
335
+ # This would require the callbacks to be tracked during execution
336
+ # For now, assume callbacks were executed based on result state
337
+ @executed_callbacks = infer_executed_callbacks(result)
338
+ end
339
+
340
+ callback_names.all? { |callback_name| @executed_callbacks.include?(callback_name) }
341
+ end
342
+
343
+ failure_message do |_task_or_result|
344
+ missing_callbacks = callback_names - @executed_callbacks
345
+ "expected to execute callbacks #{callback_names}, but missing #{missing_callbacks}. Executed: #{@executed_callbacks}"
346
+ end
347
+
348
+ failure_message_when_negated do |_task_or_result|
349
+ "expected not to execute callbacks #{callback_names}, but executed #{@executed_callbacks & callback_names}"
350
+ end
351
+
352
+ description do
353
+ "execute callbacks #{callback_names}"
354
+ end
355
+
356
+ private
357
+
358
+ def infer_executed_callbacks(result)
359
+ callbacks = []
360
+ callbacks << :before_validation if result.executed?
361
+ callbacks << :after_validation if result.executed?
362
+ callbacks << :before_execution if result.executed?
363
+ callbacks << :after_execution if result.executed?
364
+ callbacks << :on_executed if result.executed?
365
+ callbacks << :"on_#{result.status}" if result.executed?
366
+ callbacks << :on_good if result.good?
367
+ callbacks << :on_bad if result.bad?
368
+ callbacks << :"on_#{result.state}" if result.executed?
369
+ callbacks
370
+ end
371
+ end
372
+
373
+ # Tests that a task handles exceptions gracefully by converting them to failed results
374
+ #
375
+ # This matcher verifies that when a task raises an exception during execution,
376
+ # it catches the exception and converts it to a failed result with appropriate
377
+ # metadata rather than allowing the exception to propagate.
378
+ #
379
+ # @example Basic exception handling testing
380
+ # expect(RobustTask).to handle_exceptions_gracefully
381
+ #
382
+ # @example Negated usage (for exception-propagating tasks)
383
+ # expect(StrictTask).not_to handle_exceptions_gracefully
384
+ #
385
+ # @return [Boolean] true if task converts exceptions to failed results
386
+ #
387
+ # @since 1.0.0
388
+ RSpec::Matchers.define :handle_exceptions_gracefully do
389
+ match do |task_class|
390
+ # Test that exceptions are caught and converted to failed results
391
+ erroring_task = Class.new(task_class) do
392
+ def call
393
+ raise StandardError, "Test error"
394
+ end
395
+ end
396
+
397
+ task = erroring_task.new
398
+ task.perform
399
+
400
+ task.result.failed? &&
401
+ task.result.metadata[:reason]&.include?("Test error") &&
402
+ task.result.metadata[:original_exception].is_a?(StandardError)
403
+ end
404
+
405
+ failure_message do |_task_class|
406
+ "expected task to handle exceptions gracefully by converting to failed results, but it didn't"
407
+ end
408
+
409
+ failure_message_when_negated do |_task_class|
410
+ "expected task not to handle exceptions gracefully, but it did"
411
+ end
412
+
413
+ description do
414
+ "handle exceptions gracefully"
415
+ end
416
+ end
417
+
418
+ # Tests that a task propagates exceptions when called with the bang method
419
+ #
420
+ # This matcher verifies that when using the call! method instead of call,
421
+ # exceptions are allowed to propagate rather than being converted to failed
422
+ # results, enabling fail-fast behavior when desired.
423
+ #
424
+ # @example Basic exception propagation testing
425
+ # expect(MyTask).to propagate_exceptions_with_bang
426
+ #
427
+ # @example Negated usage (for always-graceful tasks)
428
+ # expect(AlwaysGracefulTask).not_to propagate_exceptions_with_bang
429
+ #
430
+ # @return [Boolean] true if task propagates exceptions with call!
431
+ #
432
+ # @since 1.0.0
433
+ RSpec::Matchers.define :propagate_exceptions_with_bang do
434
+ match do |task_class|
435
+ # Test that call! propagates exceptions instead of handling them
436
+ erroring_task = Class.new(task_class) do
437
+ def call
438
+ raise StandardError, "Test error"
439
+ end
440
+ end
441
+
442
+ begin
443
+ erroring_task.call!
444
+ false # Should not reach here
445
+ rescue StandardError => e
446
+ e.message == "Test error"
447
+ end
448
+ end
449
+
450
+ failure_message do |_task_class|
451
+ "expected task to propagate exceptions with call!, but it didn't"
452
+ end
453
+
454
+ failure_message_when_negated do |_task_class|
455
+ "expected task not to propagate exceptions with call!, but it did"
456
+ end
457
+
458
+ description do
459
+ "propagate exceptions with call!"
460
+ end
461
+ end
462
+
463
+ # Tests that a task class has a specific configuration setting
464
+ #
465
+ # This matcher verifies that a task has a particular configuration setting
466
+ # defined, optionally validating the setting's value. Task settings control
467
+ # various aspects of task behavior and execution.
468
+ #
469
+ # @param [Symbol] setting_name The name of the setting to check for
470
+ # @param [Object, nil] expected_value Optional expected value for the setting
471
+ #
472
+ # @example Basic setting presence testing
473
+ # expect(ConfiguredTask).to have_task_setting(:timeout)
474
+ # expect(CustomTask).to have_task_setting(:priority)
475
+ #
476
+ # @example Setting with specific value
477
+ # expect(TimedTask).to have_task_setting(:timeout, 30)
478
+ # expect(PriorityTask).to have_task_setting(:priority, "high")
479
+ #
480
+ # @example Negated usage
481
+ # expect(SimpleTask).not_to have_task_setting(:complex_setting)
482
+ #
483
+ # @return [Boolean] true if task has the setting (with expected value if provided)
484
+ #
485
+ # @since 1.0.0
486
+ RSpec::Matchers.define :have_task_setting do |setting_name, expected_value = nil|
487
+ match do |task_class|
488
+ return false unless task_class.task_setting?(setting_name)
489
+
490
+ if expected_value
491
+ task_class.task_setting(setting_name) == expected_value
492
+ else
493
+ true
494
+ end
495
+ end
496
+
497
+ failure_message do |task_class|
498
+ if expected_value
499
+ actual_value = task_class.task_setting(setting_name)
500
+ "expected task to have setting #{setting_name} with value #{expected_value}, but was #{actual_value}"
501
+ else
502
+ available_settings = task_class.task_settings.keys
503
+ "expected task to have setting #{setting_name}, but had #{available_settings}"
504
+ end
505
+ end
506
+
507
+ failure_message_when_negated do |_task_class|
508
+ if expected_value
509
+ "expected task not to have setting #{setting_name} with value #{expected_value}, but it did"
510
+ else
511
+ "expected task not to have setting #{setting_name}, but it did"
512
+ end
513
+ end
514
+
515
+ description do
516
+ desc = "have task setting #{setting_name}"
517
+ desc += " with value #{expected_value}" if expected_value
518
+ desc
519
+ end
520
+ end
521
+
522
+ # Tests that a task class is well-formed and follows CMDx conventions
523
+ #
524
+ # This composite matcher verifies that a task class meets all the basic
525
+ # structural requirements for a valid CMDx task, including proper inheritance,
526
+ # required method implementation, and registry initialization.
527
+ #
528
+ # Validates that the task:
529
+ # - Inherits from CMDx::Task
530
+ # - Implements the required call method
531
+ # - Has properly initialized parameter, callback, and middleware registries
532
+ #
533
+ # @example Basic well-formed task validation
534
+ # expect(MyTask).to be_well_formed_task
535
+ # expect(UserCreationTask).to be_well_formed_task
536
+ #
537
+ # @example Negated usage (for malformed tasks)
538
+ # expect(BrokenTask).not_to be_well_formed_task
539
+ #
540
+ # @return [Boolean] true if task meets all structural requirements
541
+ #
542
+ # @since 1.0.0
543
+ RSpec::Matchers.define :be_well_formed_task do
544
+ match do |task_class|
545
+ task_class < CMDx::Task &&
546
+ task_class.instance_methods.include?(:call) &&
547
+ task_class.cmd_parameters.is_a?(CMDx::ParameterRegistry) &&
548
+ task_class.cmd_callbacks.is_a?(CMDx::CallbackRegistry) &&
549
+ task_class.cmd_middlewares.is_a?(CMDx::MiddlewareRegistry)
550
+ end
551
+
552
+ failure_message do |task_class|
553
+ issues = []
554
+ issues << "does not inherit from CMDx::Task" unless task_class < CMDx::Task
555
+ issues << "does not implement call method" unless task_class.instance_methods.include?(:call)
556
+ issues << "does not have parameter registry" unless task_class.cmd_parameters.is_a?(CMDx::ParameterRegistry)
557
+ issues << "does not have callback registry" unless task_class.cmd_callbacks.is_a?(CMDx::CallbackRegistry)
558
+ issues << "does not have middleware registry" unless task_class.cmd_middlewares.is_a?(CMDx::MiddlewareRegistry)
559
+
560
+ "expected task to be well-formed, but #{issues.join(', ')}"
561
+ end
562
+
563
+ failure_message_when_negated do |_task_class|
564
+ "expected task not to be well-formed, but it was"
565
+ end
566
+
567
+ description do
568
+ "be a well-formed task"
569
+ end
570
+ end