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
data/lib/cmdx/railtie.rb CHANGED
@@ -1,12 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
+ ##
5
+ # Railtie provides seamless integration between CMDx and Ruby on Rails applications.
6
+ # It automatically configures Rails-specific features including internationalization,
7
+ # autoloading paths, and directory structure conventions for CMDx tasks and workflows.
8
+ #
9
+ # The Railtie handles two main integration aspects:
10
+ # 1. **I18n Configuration**: Automatically loads CMDx locale files for available locales
11
+ # 2. **Autoloading Setup**: Configures Rails autoloaders for CMDx command objects
12
+ #
13
+ # ## Directory Structure
14
+ #
15
+ # The Railtie expects CMDx command objects to be organized in the following structure:
16
+ # ```
17
+ # app/
18
+ # cmds/
19
+ # workflows/ # Workflow command objects
20
+ # order_processing_workflow.rb
21
+ # tasks/ # Task command objects
22
+ # process_order_task.rb
23
+ # send_email_task.rb
24
+ # ```
25
+ #
26
+ # ## Automatic Features
27
+ #
28
+ # When CMDx is included in a Rails application, the Railtie automatically:
29
+ # - Adds `app/cmds` to Rails autoload paths
30
+ # - Configures autoloader to collapse `app/cmds/workflows` and `app/cmds/tasks` directories
31
+ # - Loads appropriate locale files from CMDx gem for error messages and validations
32
+ # - Reloads I18n configuration to include CMDx translations
33
+ #
34
+ # @example Rails application structure
35
+ # # app/cmds/tasks/process_order_task.rb
36
+ # class ProcessOrderTask < CMDx::Task
37
+ # required :order_id, type: :integer
38
+ #
39
+ # def call
40
+ # context.order = Order.find(order_id)
41
+ # context.order.process!
42
+ # end
43
+ # end
44
+ #
45
+ # @example Using in Rails controllers
46
+ # class OrdersController < ApplicationController
47
+ # def process
48
+ # result = ProcessOrderTask.call(order_id: params[:id])
49
+ #
50
+ # if result.success?
51
+ # redirect_to order_path(result.context.order), notice: 'Order processed!'
52
+ # else
53
+ # redirect_to order_path(params[:id]), alert: result.metadata[:reason]
54
+ # end
55
+ # end
56
+ # end
57
+ #
58
+ # @example I18n integration
59
+ # # CMDx automatically loads locale files for validation messages
60
+ # # en.yml, es.yml, etc. are automatically available
61
+ # result = MyTask.call(invalid_param: nil)
62
+ # result.errors.full_messages # Uses localized error messages
63
+ #
64
+ # @see Configuration Configuration options for Rails integration
65
+ # @see Task Task base class for command objects
66
+ # @see Workflow Workflow base class for multi-task operations
67
+ # @since 1.0.0
4
68
  class Railtie < Rails::Railtie
5
69
 
6
- CONCEPTS = %w[batches tasks].freeze
7
-
8
70
  railtie_name :cmdx
9
71
 
72
+ ##
73
+ # Configures internationalization (I18n) for CMDx in Rails applications.
74
+ # Automatically loads locale files from the CMDx gem for all configured
75
+ # application locales, ensuring error messages and validations are properly localized.
76
+ #
77
+ # This initializer:
78
+ # 1. Iterates through all configured application locales
79
+ # 2. Checks for corresponding CMDx locale files
80
+ # 3. Adds found locale files to I18n load path
81
+ # 4. Reloads I18n configuration
82
+ #
83
+ # @param app [Rails::Application] the Rails application instance
84
+ # @return [void]
85
+ #
86
+ # @example Available locales
87
+ # # If Rails app has config.i18n.available_locales = [:en, :es]
88
+ # # This will load:
89
+ # # - lib/locales/en.yml (CMDx English translations)
90
+ # # - lib/locales/es.yml (CMDx Spanish translations)
91
+ #
92
+ # @example Localized error messages
93
+ # # With Spanish locale active
94
+ # class MyTask < CMDx::Task
95
+ # required :name, presence: true
96
+ # end
97
+ #
98
+ # result = MyTask.call(name: "")
99
+ # result.errors.full_messages # Returns Spanish error messages
10
100
  initializer("cmdx.configure_locales") do |app|
11
101
  Array(app.config.i18n.available_locales).each do |locale|
12
102
  path = File.expand_path("../../../lib/locales/#{locale}.yml", __FILE__)
@@ -18,10 +108,40 @@ module CMDx
18
108
  I18n.reload!
19
109
  end
20
110
 
111
+ ##
112
+ # Configures Rails autoloading for CMDx command objects.
113
+ # Sets up proper autoloading paths and directory collapsing to ensure
114
+ # CMDx tasks and workflows are loaded correctly in Rails applications.
115
+ #
116
+ # This initializer:
117
+ # 1. Adds `app/cmds` to Rails autoload paths
118
+ # 2. Configures all autoloaders to collapse concept directories
119
+ # 3. Ensures proper class name resolution for nested directories
120
+ #
121
+ # Directory collapsing means that files in `app/cmds/tasks/` will be loaded
122
+ # as if they were directly in `app/cmds/`, allowing for better organization
123
+ # without affecting class naming conventions.
124
+ #
125
+ # @param app [Rails::Application] the Rails application instance
126
+ # @return [void]
127
+ #
128
+ # @example Directory structure and class loading
129
+ # # File: app/cmds/tasks/process_order_task.rb
130
+ # # Class: ProcessOrderTask (not Tasks::ProcessOrderTask)
131
+ #
132
+ # # File: app/cmds/workflows/order_processing_workflow.rb
133
+ # # Class: OrderProcessingWorkflow (not Workflows::OrderProcessingWorkflow)
134
+ #
135
+ # @example Autoloading in action
136
+ # # Rails will automatically load these classes when referenced:
137
+ # ProcessOrderTask.call(order_id: 123) # Loads from app/cmds/tasks/
138
+ # OrderProcessingWorkflow.call(orders: [...]) # Loads from app/cmds/workflows/
21
139
  initializer("cmdx.configure_rails_auto_load_paths") do |app|
22
140
  app.config.autoload_paths += %w[app/cmds]
141
+
142
+ types = %w[workflows tasks]
23
143
  app.autoloaders.each do |autoloader|
24
- CONCEPTS.each do |concept|
144
+ types.each do |concept|
25
145
  dir = app.root.join("app/cmds/#{concept}")
26
146
  autoloader.collapse(dir)
27
147
  end
data/lib/cmdx/result.rb CHANGED
@@ -1,14 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
+ # Result object representing the outcome of task execution.
5
+ #
6
+ # The Result class encapsulates all information about a task's execution,
7
+ # including its state, status, metadata, and runtime information. It provides
8
+ # a comprehensive interface for tracking task lifecycle, handling failures,
9
+ # and chaining execution outcomes.
10
+ #
11
+ # @example Basic result usage
12
+ # result = ProcessOrderTask.call(order_id: 123)
13
+ # result.success? # => true
14
+ # result.complete? # => true
15
+ # result.runtime # => 0.5
16
+ #
17
+ # @example Result with failure handling
18
+ # result = ProcessOrderTask.call(invalid_params)
19
+ # result.failed? # => true
20
+ # result.bad? # => true
21
+ # result.metadata # => { reason: "Invalid parameters" }
22
+ #
23
+ # @example Result state callbacks
24
+ # ProcessOrderTask.call(order_id: 123)
25
+ # .on_success { |result| logger.info "Order processed successfully" }
26
+ # .on_failed { |result| logger.error "Order processing failed: #{result.metadata[:reason]}" }
27
+ #
28
+ # @example Result chaining and failure propagation
29
+ # result1 = FirstTask.call
30
+ # result2 = SecondTask.call
31
+ # result2.throw!(result1) if result1.failed? # Propagate failure
32
+ #
33
+ # @see CMDx::Task Task execution and result creation
34
+ # @see CMDx::Chain Chain execution context and result tracking
35
+ # @see CMDx::Fault Fault handling for result failures
4
36
  class Result
5
37
 
6
- __cmdx_attr_delegator :context, :run, to: :task
38
+ __cmdx_attr_delegator :context, :chain,
39
+ to: :task
7
40
 
41
+ # @return [CMDx::Task] The task instance that generated this result
42
+ # @return [String] The current execution state (initialized, executing, complete, interrupted)
43
+ # @return [String] The current execution status (success, skipped, failed)
44
+ # @return [Hash] Additional metadata associated with the result
8
45
  attr_reader :task, :state, :status, :metadata
9
46
 
47
+ # Initializes a new Result instance.
48
+ #
49
+ # Creates a result object for tracking task execution outcomes.
50
+ # Results start in initialized state with success status.
51
+ #
52
+ # @param task [CMDx::Task] The task instance this result belongs to
53
+ # @raise [TypeError] If task is not a Task or Workflow instance
54
+ #
55
+ # @example Creating a result
56
+ # task = ProcessOrderTask.new
57
+ # result = Result.new(task)
58
+ # result.initialized? # => true
59
+ # result.success? # => true
10
60
  def initialize(task)
11
- raise ArgumentError, "must be a Task or Batch" unless task.is_a?(Task)
61
+ raise TypeError, "must be a Task or Workflow" unless task.is_a?(Task)
12
62
 
13
63
  @task = task
14
64
  @state = INITIALIZED
@@ -16,26 +66,87 @@ module CMDx
16
66
  @metadata = {}
17
67
  end
18
68
 
69
+ # Available execution states for task results.
70
+ #
71
+ # States represent the execution lifecycle of a task from initialization
72
+ # through completion or interruption.
19
73
  STATES = [
20
- INITIALIZED = "initialized",
21
- EXECUTING = "executing",
22
- COMPLETE = "complete",
23
- INTERRUPTED = "interrupted"
74
+ INITIALIZED = "initialized", # Initial state before execution
75
+ EXECUTING = "executing", # Currently executing task logic
76
+ COMPLETE = "complete", # Successfully completed execution
77
+ INTERRUPTED = "interrupted" # Execution was halted due to failure
24
78
  ].freeze
25
79
 
80
+ # Dynamically defines state predicate and callback methods.
81
+ #
82
+ # For each state, creates:
83
+ # - Predicate method (e.g., `executing?`)
84
+ # - Callback method (e.g., `on_executing`)
26
85
  STATES.each do |s|
27
86
  # eg: executing?
28
87
  define_method(:"#{s}?") { state == s }
88
+
89
+ # eg: on_interrupted { ... }
90
+ define_method(:"on_#{s}") do |&block|
91
+ raise ArgumentError, "block required" unless block
92
+
93
+ block.call(self) if send(:"#{s}?")
94
+ self
95
+ end
29
96
  end
30
97
 
98
+ # Marks the result as executed based on current status.
99
+ #
100
+ # Transitions to complete state if successful, or interrupted state
101
+ # if the task has failed or been skipped.
102
+ #
103
+ # @return [void]
104
+ #
105
+ # @example Successful execution
106
+ # result.executed!
107
+ # result.complete? # => true (if status was success)
108
+ #
109
+ # @example Failed execution
110
+ # result.fail!(reason: "Something went wrong")
111
+ # result.executed!
112
+ # result.interrupted? # => true
31
113
  def executed!
32
114
  success? ? complete! : interrupt!
33
115
  end
34
116
 
117
+ # Checks if the result has been executed (completed or interrupted).
118
+ #
119
+ # @return [Boolean] true if result is complete or interrupted
120
+ #
121
+ # @example
122
+ # result.executed? # => true if complete? || interrupted?
35
123
  def executed?
36
124
  complete? || interrupted?
37
125
  end
38
126
 
127
+ # Executes a callback if the result has been executed.
128
+ #
129
+ # @yield [Result] The result instance
130
+ # @return [Result] Self for method chaining
131
+ # @raise [ArgumentError] If no block is provided
132
+ #
133
+ # @example
134
+ # result.on_executed { |r| logger.info "Task finished: #{r.status}" }
135
+ def on_executed(&)
136
+ raise ArgumentError, "block required" unless block_given?
137
+
138
+ yield(self) if executed?
139
+ self
140
+ end
141
+
142
+ # Transitions the result to executing state.
143
+ #
144
+ # @return [void]
145
+ # @raise [RuntimeError] If not transitioning from initialized state
146
+ #
147
+ # @example
148
+ # result.executing!
149
+ # result.executing? # => true
39
150
  def executing!
40
151
  return if executing?
41
152
 
@@ -44,6 +155,14 @@ module CMDx
44
155
  @state = EXECUTING
45
156
  end
46
157
 
158
+ # Transitions the result to complete state.
159
+ #
160
+ # @return [void]
161
+ # @raise [RuntimeError] If not transitioning from executing state
162
+ #
163
+ # @example
164
+ # result.complete!
165
+ # result.complete? # => true
47
166
  def complete!
48
167
  return if complete?
49
168
 
@@ -52,6 +171,14 @@ module CMDx
52
171
  @state = COMPLETE
53
172
  end
54
173
 
174
+ # Transitions the result to interrupted state.
175
+ #
176
+ # @return [void]
177
+ # @raise [RuntimeError] If trying to interrupt from complete state
178
+ #
179
+ # @example
180
+ # result.interrupt!
181
+ # result.interrupted? # => true
55
182
  def interrupt!
56
183
  return if interrupted?
57
184
 
@@ -60,25 +187,99 @@ module CMDx
60
187
  @state = INTERRUPTED
61
188
  end
62
189
 
190
+ # Available execution statuses for task results.
191
+ #
192
+ # Statuses represent the outcome of task logic execution.
63
193
  STATUSES = [
64
- SUCCESS = "success",
65
- SKIPPED = "skipped",
66
- FAILED = "failed"
194
+ SUCCESS = "success", # Task completed successfully
195
+ SKIPPED = "skipped", # Task was skipped intentionally
196
+ FAILED = "failed" # Task failed due to error or validation
67
197
  ].freeze
68
198
 
199
+ # Dynamically defines status predicate and callback methods.
200
+ #
201
+ # For each status, creates:
202
+ # - Predicate method (e.g., `success?`)
203
+ # - Callback method (e.g., `on_success`)
69
204
  STATUSES.each do |s|
70
205
  # eg: skipped?
71
206
  define_method(:"#{s}?") { status == s }
207
+
208
+ # eg: on_failed { ... }
209
+ define_method(:"on_#{s}") do |&block|
210
+ raise ArgumentError, "block required" unless block
211
+
212
+ block.call(self) if send(:"#{s}?")
213
+ self
214
+ end
72
215
  end
73
216
 
217
+ # Checks if the result represents a good outcome (success or skipped).
218
+ #
219
+ # @return [Boolean] true if not failed
220
+ #
221
+ # @example
222
+ # result.good? # => true if success? || skipped?
74
223
  def good?
75
224
  !failed?
76
225
  end
77
226
 
227
+ # Executes a callback if the result has a good outcome.
228
+ #
229
+ # @yield [Result] The result instance
230
+ # @return [Result] Self for method chaining
231
+ # @raise [ArgumentError] If no block is provided
232
+ #
233
+ # @example
234
+ # result.on_good { |r| logger.info "Task completed successfully" }
235
+ def on_good(&)
236
+ raise ArgumentError, "block required" unless block_given?
237
+
238
+ yield(self) if good?
239
+ self
240
+ end
241
+
242
+ # Checks if the result represents a bad outcome (skipped or failed).
243
+ #
244
+ # @return [Boolean] true if not successful
245
+ #
246
+ # @example
247
+ # result.bad? # => true if skipped? || failed?
78
248
  def bad?
79
249
  !success?
80
250
  end
81
251
 
252
+ # Executes a callback if the result has a bad outcome.
253
+ #
254
+ # @yield [Result] The result instance
255
+ # @return [Result] Self for method chaining
256
+ # @raise [ArgumentError] If no block is provided
257
+ #
258
+ # @example
259
+ # result.on_bad { |r| logger.error "Task had issues: #{r.status}" }
260
+ def on_bad(&)
261
+ raise ArgumentError, "block required" unless block_given?
262
+
263
+ yield(self) if bad?
264
+ self
265
+ end
266
+
267
+ # Marks the result as skipped with optional metadata.
268
+ #
269
+ # Transitions from success to skipped status and halts execution
270
+ # unless the skip was caused by an original exception.
271
+ #
272
+ # @param metadata [Hash] Additional metadata about the skip
273
+ # @return [void]
274
+ # @raise [RuntimeError] If not transitioning from success status
275
+ # @raise [CMDx::Fault] If halting due to skip (unless original_exception present)
276
+ #
277
+ # @example Basic skip
278
+ # result.skip!(reason: "Order already processed")
279
+ # result.skipped? # => true
280
+ #
281
+ # @example Skip with exception context
282
+ # result.skip!(original_exception: StandardError.new("DB unavailable"))
82
283
  def skip!(**metadata)
83
284
  return if skipped?
84
285
 
@@ -90,6 +291,22 @@ module CMDx
90
291
  halt! unless metadata[:original_exception]
91
292
  end
92
293
 
294
+ # Marks the result as failed with optional metadata.
295
+ #
296
+ # Transitions from success to failed status and halts execution
297
+ # unless the failure was caused by an original exception.
298
+ #
299
+ # @param metadata [Hash] Additional metadata about the failure
300
+ # @return [void]
301
+ # @raise [RuntimeError] If not transitioning from success status
302
+ # @raise [CMDx::Fault] If halting due to failure (unless original_exception present)
303
+ #
304
+ # @example Basic failure
305
+ # result.fail!(reason: "Invalid order data", code: 422)
306
+ # result.failed? # => true
307
+ #
308
+ # @example Failure with exception context
309
+ # result.fail!(original_exception: StandardError.new("Validation failed"))
93
310
  def fail!(**metadata)
94
311
  return if failed?
95
312
 
@@ -101,14 +318,39 @@ module CMDx
101
318
  halt! unless metadata[:original_exception]
102
319
  end
103
320
 
321
+ # Halts execution by raising a fault if the result is not successful.
322
+ #
323
+ # @return [void]
324
+ # @raise [CMDx::Fault] If result status is not success
325
+ #
326
+ # @example
327
+ # result.fail!(reason: "Something went wrong")
328
+ # result.halt! # Raises CMDx::Fault
104
329
  def halt!
105
330
  return if success?
106
331
 
107
332
  raise Fault.build(self)
108
333
  end
109
334
 
335
+ # Propagates another result's failure status to this result.
336
+ #
337
+ # Copies the failure or skip status from another result, merging
338
+ # metadata and preserving failure chain information.
339
+ #
340
+ # @param result [CMDx::Result] The result to propagate from
341
+ # @param local_metadata [Hash] Additional metadata to merge
342
+ # @return [void]
343
+ # @raise [TypeError] If result parameter is not a Result instance
344
+ #
345
+ # @example Propagating failure
346
+ # first_result = FirstTask.call
347
+ # second_result = SecondTask.call
348
+ # second_result.throw!(first_result) if first_result.failed?
349
+ #
350
+ # @example Propagating with additional context
351
+ # result.throw!(other_result, context: "During order processing")
110
352
  def throw!(result, local_metadata = {})
111
- raise ArgumentError, "must be a Result" unless result.is_a?(Result)
353
+ raise TypeError, "must be a Result" unless result.is_a?(Result)
112
354
 
113
355
  md = result.metadata.merge(local_metadata)
114
356
 
@@ -116,61 +358,198 @@ module CMDx
116
358
  fail!(**md) if result.failed?
117
359
  end
118
360
 
361
+ # Finds the result that originally caused a failure in the execution chain.
362
+ #
363
+ # @return [CMDx::Result, nil] The result that first failed, or nil if not failed
364
+ #
365
+ # @example
366
+ # failed_result = result.caused_failure
367
+ # puts "Original failure: #{failed_result.metadata[:reason]}" if failed_result
119
368
  def caused_failure
120
369
  return unless failed?
121
370
 
122
- run.results.reverse.find(&:failed?)
371
+ chain.results.reverse.find(&:failed?)
123
372
  end
124
373
 
374
+ # Checks if this result was the original cause of failure.
375
+ #
376
+ # @return [Boolean] true if this result caused the failure chain
377
+ #
378
+ # @example
379
+ # result.caused_failure? # => true if this result started the failure chain
125
380
  def caused_failure?
126
381
  return false unless failed?
127
382
 
128
383
  caused_failure == self
129
384
  end
130
385
 
386
+ # Finds the result that threw/propagated the failure to this result.
387
+ #
388
+ # @return [CMDx::Result, nil] The result that threw the failure, or nil if not failed
389
+ #
390
+ # @example
391
+ # throwing_result = result.threw_failure
392
+ # puts "Failure thrown by: #{throwing_result.task.class}" if throwing_result
131
393
  def threw_failure
132
394
  return unless failed?
133
395
 
134
- results = run.results.select(&:failed?)
396
+ results = chain.results.select(&:failed?)
135
397
  results.find { |r| r.index > index } || results.last
136
398
  end
137
399
 
400
+ # Checks if this result threw/propagated a failure.
401
+ #
402
+ # @return [Boolean] true if this result threw a failure to another result
403
+ #
404
+ # @example
405
+ # result.threw_failure? # => true if this result propagated failure
138
406
  def threw_failure?
139
407
  return false unless failed?
140
408
 
141
409
  threw_failure == self
142
410
  end
143
411
 
412
+ # Checks if this result received a thrown failure (not the original cause).
413
+ #
414
+ # @return [Boolean] true if failed but not the original cause
415
+ #
416
+ # @example
417
+ # result.thrown_failure? # => true if failed due to propagated failure
144
418
  def thrown_failure?
145
419
  failed? && !caused_failure?
146
420
  end
147
421
 
422
+ # Gets the index of this result within the execution chain.
423
+ #
424
+ # @return [Integer] The zero-based index of this result in the chain
425
+ #
426
+ # @example
427
+ # result.index # => 0 for first result, 1 for second, etc.
148
428
  def index
149
- run.index(self)
429
+ chain.index(self)
150
430
  end
151
431
 
432
+ # Gets the outcome of the result based on state and status.
433
+ #
434
+ # Returns state for initialized results or thrown failures,
435
+ # otherwise returns the status.
436
+ #
437
+ # @return [String] The result outcome (state or status)
438
+ #
439
+ # @example
440
+ # result.outcome # => "success", "failed", "interrupted", etc.
152
441
  def outcome
153
442
  initialized? || thrown_failure? ? state : status
154
443
  end
155
444
 
156
- def runtime(&block)
445
+ # Measures and returns the runtime of a block execution.
446
+ #
447
+ # If called without a block, returns the stored runtime value.
448
+ # If called with a block, executes and measures the execution
449
+ # time using monotonic clock.
450
+ #
451
+ # @yield Block to execute and measure
452
+ # @return [Float] Runtime in seconds
453
+ #
454
+ # @example Getting stored runtime
455
+ # result.runtime # => 0.5
456
+ #
457
+ # @example Measuring block execution
458
+ # result.runtime do
459
+ # # Task execution logic
460
+ # perform_work
461
+ # end # => 0.5 (and stores the runtime)
462
+ def runtime(&)
157
463
  return @runtime unless block_given?
158
464
 
159
- timeout_type = is_a?(Batch) ? :batch_timeout : :task_timeout
160
- timeout_secs = task.task_setting(timeout_type)
161
-
162
- Timeout.timeout(timeout_secs, TimeoutError, "execution exceeded #{timeout_secs} seconds") do
163
- @runtime = Utils::MonotonicRuntime.call(&block)
164
- end
465
+ @runtime = Utils::MonotonicRuntime.call(&)
165
466
  end
166
467
 
468
+ # Converts the result to a hash representation.
469
+ #
470
+ # @return [Hash] Serialized result data including task info, state, status, and metadata
471
+ #
472
+ # @example
473
+ # result.to_h
474
+ # # => {
475
+ # # class: "ProcessOrderTask",
476
+ # # type: "Task",
477
+ # # index: 0,
478
+ # # id: "018c2b95-b764-7615-a924-cc5b910ed1e5",
479
+ # # state: "complete",
480
+ # # status: "success",
481
+ # # outcome: "success",
482
+ # # metadata: {},
483
+ # # runtime: 0.5
484
+ # # }
167
485
  def to_h
168
486
  ResultSerializer.call(self)
169
487
  end
170
488
 
489
+ # Converts the result to a string representation for inspection.
490
+ #
491
+ # @return [String] Human-readable result description
492
+ #
493
+ # @example
494
+ # result.to_s
495
+ # # => "ProcessOrderTask: type=Task index=0 id=018c2b95... state=complete status=success outcome=success runtime=0.5"
171
496
  def to_s
172
497
  ResultInspector.call(to_h)
173
498
  end
174
499
 
500
+ # Deconstructs the result for array pattern matching.
501
+ #
502
+ # Enables pattern matching with array syntax to match against
503
+ # state and status in order.
504
+ #
505
+ # @return [Array<String>] Array containing [state, status]
506
+ #
507
+ # @example Array pattern matching
508
+ # result = ProcessOrderTask.call(order_id: 123)
509
+ # case result
510
+ # in ["complete", "success"]
511
+ # puts "Task completed successfully"
512
+ # in ["interrupted", "failed"]
513
+ # puts "Task failed"
514
+ # end
515
+ def deconstruct
516
+ [state, status]
517
+ end
518
+
519
+ # Deconstructs the result for hash pattern matching.
520
+ #
521
+ # Enables pattern matching with hash syntax to match against
522
+ # specific result attributes.
523
+ #
524
+ # @param keys [Array<Symbol>] Specific keys to extract (optional)
525
+ # @return [Hash] Hash containing result attributes
526
+ #
527
+ # @example Hash pattern matching
528
+ # result = ProcessOrderTask.call(order_id: 123)
529
+ # case result
530
+ # in { state: "complete", status: "success" }
531
+ # puts "Success!"
532
+ # in { state: "interrupted", status: "failed", metadata: { reason: String => reason } }
533
+ # puts "Failed: #{reason}"
534
+ # end
535
+ #
536
+ # @example Specific key extraction
537
+ # result.deconstruct_keys([:state, :status])
538
+ # # => { state: "complete", status: "success" }
539
+ def deconstruct_keys(keys)
540
+ attributes = {
541
+ state: state,
542
+ status: status,
543
+ metadata: metadata,
544
+ executed: executed?,
545
+ good: good?,
546
+ bad: bad?
547
+ }
548
+
549
+ return attributes if keys.nil?
550
+
551
+ attributes.slice(*keys)
552
+ end
553
+
175
554
  end
176
555
  end