operandi 5.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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/services/RULE.md +269 -0
  3. data/.cursor/rules/services-rspec/RULE.md +354 -0
  4. data/.github/dependabot.yml +11 -0
  5. data/.github/workflows/ci.yml +77 -0
  6. data/.gitignore +25 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +134 -0
  9. data/.ruby-version +1 -0
  10. data/.vscode/cspell.json +18 -0
  11. data/.vscode/project-words.txt +12 -0
  12. data/AGENTS.md +139 -0
  13. data/CHANGELOG.md +111 -0
  14. data/CLAUDE.md +139 -0
  15. data/CODE_OF_CONDUCT.md +74 -0
  16. data/Gemfile +28 -0
  17. data/Gemfile.lock +149 -0
  18. data/LICENSE.txt +21 -0
  19. data/README.md +172 -0
  20. data/Rakefile +8 -0
  21. data/bin/console +15 -0
  22. data/bin/setup +8 -0
  23. data/config/default.yml +57 -0
  24. data/docs/README.md +105 -0
  25. data/docs/SUMMARY.md +31 -0
  26. data/docs/arguments.md +275 -0
  27. data/docs/best-practices.md +153 -0
  28. data/docs/callbacks.md +475 -0
  29. data/docs/concepts.md +79 -0
  30. data/docs/configuration.md +218 -0
  31. data/docs/context.md +128 -0
  32. data/docs/crud.md +525 -0
  33. data/docs/errors.md +331 -0
  34. data/docs/generators.md +250 -0
  35. data/docs/outputs.md +150 -0
  36. data/docs/pundit-authorization.md +320 -0
  37. data/docs/quickstart.md +133 -0
  38. data/docs/recipes.md +14 -0
  39. data/docs/rubocop.md +430 -0
  40. data/docs/ruby-lsp.md +121 -0
  41. data/docs/service-rendering.md +222 -0
  42. data/docs/sorbet-runtime.md +283 -0
  43. data/docs/steps.md +438 -0
  44. data/docs/tapioca.md +188 -0
  45. data/docs/testing.md +548 -0
  46. data/lib/generators/operandi/install/USAGE +15 -0
  47. data/lib/generators/operandi/install/install_generator.rb +45 -0
  48. data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
  49. data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
  50. data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
  51. data/lib/generators/operandi/service/USAGE +21 -0
  52. data/lib/generators/operandi/service/service_generator.rb +80 -0
  53. data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
  54. data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
  55. data/lib/operandi/base.rb +230 -0
  56. data/lib/operandi/base_with_context.rb +57 -0
  57. data/lib/operandi/callbacks.rb +353 -0
  58. data/lib/operandi/collection.rb +166 -0
  59. data/lib/operandi/concerns/execution.rb +80 -0
  60. data/lib/operandi/concerns/parent_service.rb +32 -0
  61. data/lib/operandi/concerns/state_management.rb +34 -0
  62. data/lib/operandi/config.rb +142 -0
  63. data/lib/operandi/constants.rb +96 -0
  64. data/lib/operandi/dsl/arguments_dsl.rb +83 -0
  65. data/lib/operandi/dsl/outputs_dsl.rb +79 -0
  66. data/lib/operandi/dsl/steps_dsl.rb +206 -0
  67. data/lib/operandi/dsl/validation.rb +171 -0
  68. data/lib/operandi/exceptions.rb +66 -0
  69. data/lib/operandi/message.rb +52 -0
  70. data/lib/operandi/messages.rb +185 -0
  71. data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
  72. data/lib/operandi/rspec/matchers/define_output.rb +145 -0
  73. data/lib/operandi/rspec/matchers/define_step.rb +223 -0
  74. data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
  75. data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
  76. data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
  77. data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
  78. data/lib/operandi/rspec.rb +15 -0
  79. data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
  80. data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
  81. data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
  82. data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
  83. data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
  84. data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
  85. data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
  86. data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
  87. data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
  88. data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
  89. data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
  90. data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
  91. data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
  92. data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
  93. data/lib/operandi/rubocop.rb +22 -0
  94. data/lib/operandi/settings/field.rb +147 -0
  95. data/lib/operandi/settings/step.rb +105 -0
  96. data/lib/operandi/utils.rb +36 -0
  97. data/lib/operandi/version.rb +5 -0
  98. data/lib/operandi.rb +11 -0
  99. data/lib/ruby_lsp/operandi/addon.rb +37 -0
  100. data/lib/ruby_lsp/operandi/definition.rb +134 -0
  101. data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
  102. data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
  103. data/operandi.gemspec +33 -0
  104. data/rbi/operandi.rbi +197 -0
  105. data/sorbet/cache/data.mdb +0 -0
  106. data/sorbet/cache/lock.mdb +0 -0
  107. metadata +151 -0
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ class << self
5
+ # Configure Operandi with a block.
6
+ #
7
+ # @yield [Config] the configuration object
8
+ # @return [void]
9
+ #
10
+ # @example
11
+ # Operandi.configure do |config|
12
+ # config.require_arg_type = true
13
+ # config.require_output_type = true
14
+ # config.use_transactions = false
15
+ # end
16
+ def configure
17
+ yield config
18
+ end
19
+
20
+ # Get the global configuration object.
21
+ #
22
+ # @return [Config] the configuration instance
23
+ def config
24
+ @config ||= Config.new
25
+ end
26
+ end
27
+
28
+ # Configuration class for Operandi global settings.
29
+ #
30
+ # @example Accessing configuration
31
+ # Operandi.config.require_arg_type # => true
32
+ #
33
+ # @example Modifying configuration
34
+ # Operandi.config.use_transactions = false
35
+ class Config
36
+ # @return [Boolean] whether arguments must have a type specified
37
+ attr_reader :require_arg_type
38
+
39
+ # @return [Boolean] whether outputs must have a type specified
40
+ attr_reader :require_output_type
41
+
42
+ # @return [Boolean] whether to wrap service execution in a database transaction
43
+ attr_reader :use_transactions
44
+
45
+ # @return [Boolean] whether to copy errors to parent service in chain
46
+ attr_reader :load_errors
47
+
48
+ # @return [Boolean] whether to stop executing steps when an error is added
49
+ attr_reader :break_on_error
50
+
51
+ # @return [Boolean] whether to raise Operandi::RuntimeError when service fails
52
+ attr_reader :raise_on_error
53
+
54
+ # @return [Boolean] whether to rollback the transaction when an error is added
55
+ attr_reader :rollback_on_error
56
+
57
+ # @return [Boolean] whether to copy warnings to parent service in chain
58
+ attr_reader :load_warnings
59
+
60
+ # @return [Boolean] whether to stop executing steps when a warning is added
61
+ attr_reader :break_on_warning
62
+
63
+ # @return [Boolean] whether to raise Operandi::RuntimeError when service has warnings
64
+ attr_reader :raise_on_warning
65
+
66
+ # @return [Boolean] whether to rollback the transaction when a warning is added
67
+ attr_reader :rollback_on_warning
68
+
69
+ # @return [Hash{String => String}] custom type mappings for Ruby LSP addon.
70
+ # Maps custom types to Ruby types for hover/completion.
71
+ # @example { "CustomTypes::UUID" => "String", "CustomTypes::Money" => "BigDecimal" }
72
+ attr_reader :ruby_lsp_type_mappings
73
+
74
+ DEFAULTS = {
75
+ require_arg_type: true,
76
+ require_output_type: true,
77
+ use_transactions: true,
78
+
79
+ load_errors: true,
80
+ break_on_error: true,
81
+ raise_on_error: false,
82
+ rollback_on_error: true,
83
+
84
+ load_warnings: true,
85
+ break_on_warning: false,
86
+ raise_on_warning: false,
87
+ rollback_on_warning: false,
88
+
89
+ ruby_lsp_type_mappings: {}.freeze,
90
+ }.freeze
91
+
92
+ DEFAULTS.each_key do |name|
93
+ define_method(:"#{name}=") do |value|
94
+ instance_variable_set(:"@#{name}", value)
95
+ @to_h = nil # Invalidate memoized hash
96
+ end
97
+ end
98
+
99
+ # Convenience setter for backward compatibility.
100
+ # Sets both require_arg_type and require_output_type.
101
+ #
102
+ # @param value [Boolean] whether to require types for arguments and outputs
103
+ # @return [void]
104
+ def require_type=(value)
105
+ self.require_arg_type = value
106
+ self.require_output_type = value
107
+ end
108
+
109
+ # Initialize configuration with default values.
110
+ def initialize
111
+ reset_to_defaults!
112
+ end
113
+
114
+ # Reset all configuration options to their default values.
115
+ #
116
+ # @return [void]
117
+ def reset_to_defaults!
118
+ DEFAULTS.each do |key, value|
119
+ instance_variable_set(:"@#{key}", value)
120
+ end
121
+
122
+ @to_h = nil # Invalidate memoized hash
123
+ end
124
+
125
+ # Convert configuration to a hash.
126
+ #
127
+ # @return [Hash{Symbol => Object}] all configuration options as a hash
128
+ def to_h
129
+ @to_h ||= DEFAULTS.keys.to_h do |key|
130
+ [key, public_send(key)]
131
+ end
132
+ end
133
+
134
+ # Merge configuration with additional options.
135
+ #
136
+ # @param config [Hash] options to merge
137
+ # @return [Hash] merged configuration hash
138
+ def merge(config)
139
+ to_h.merge(config)
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ # Collection type constants
5
+ module CollectionTypes
6
+ ARGUMENTS = :arguments
7
+ OUTPUTS = :outputs
8
+
9
+ ALL = [ARGUMENTS, OUTPUTS].freeze
10
+ end
11
+
12
+ # Field type constants
13
+ module FieldTypes
14
+ ARGUMENT = :argument
15
+ OUTPUT = :output
16
+
17
+ ALL = [ARGUMENT, OUTPUT].freeze
18
+ end
19
+
20
+ # Reserved names that cannot be used for arguments, outputs, or steps
21
+ # These names would conflict with existing gem methods
22
+ module ReservedNames
23
+ # Instance methods from Base class and concerns
24
+ BASE_METHODS = [
25
+ :arg,
26
+ :output,
27
+ :errors,
28
+ :warnings,
29
+ :success?,
30
+ :failed?,
31
+ :errors?,
32
+ :warnings?,
33
+ :stop!,
34
+ :stopped?,
35
+ :stop_immediately!,
36
+ :call,
37
+ :run_callbacks,
38
+ ].freeze
39
+
40
+ # Class methods that could conflict
41
+ CLASS_METHODS = [
42
+ :config,
43
+ :run,
44
+ :run!,
45
+ :with,
46
+ :arg,
47
+ :remove_arg,
48
+ :output,
49
+ :remove_output,
50
+ :step,
51
+ :remove_step,
52
+ :steps,
53
+ :outputs,
54
+ :arguments,
55
+ ].freeze
56
+
57
+ # Callback method names
58
+ CALLBACK_METHODS = [
59
+ :before_step_run,
60
+ :after_step_run,
61
+ :around_step_run,
62
+ :on_step_success,
63
+ :on_step_failure,
64
+ :on_step_crash,
65
+ :before_service_run,
66
+ :after_service_run,
67
+ :around_service_run,
68
+ :on_service_success,
69
+ :on_service_failure,
70
+ ].freeze
71
+
72
+ # Ruby reserved words and common Object methods
73
+ RUBY_RESERVED = [
74
+ :initialize,
75
+ :class,
76
+ :object_id,
77
+ :send,
78
+ :__send__,
79
+ :public_send,
80
+ :respond_to?,
81
+ :method,
82
+ :methods,
83
+ :instance_variable_get,
84
+ :instance_variable_set,
85
+ :instance_variables,
86
+ :extend,
87
+ :include,
88
+ :new,
89
+ :allocate,
90
+ :superclass,
91
+ ].freeze
92
+
93
+ # All reserved names combined (used for validation)
94
+ ALL = (BASE_METHODS + CALLBACK_METHODS + RUBY_RESERVED).uniq.freeze
95
+ end
96
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../constants"
4
+ require_relative "validation"
5
+
6
+ module Operandi
7
+ module Dsl
8
+ # DSL for defining and managing service arguments
9
+ module ArgumentsDsl
10
+ def self.included(base)
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ module ClassMethods
15
+ # Define an argument for the service
16
+ #
17
+ # @param name [Symbol] the argument name
18
+ # @param opts [Hash] options for configuring the argument
19
+ # @option opts [Class, Array<Class>] :type Type(s) to validate against
20
+ # (e.g., String, Integer, [String, Symbol])
21
+ # @option opts [Boolean] :optional (false) Whether nil values are allowed
22
+ # @option opts [Object, Proc] :default Default value or proc to evaluate in instance context
23
+ # @option opts [Boolean] :context (false) Whether to pass this argument to child services
24
+ #
25
+ # @example Define a required string argument
26
+ # arg :name, type: String
27
+ #
28
+ # @example Define an optional argument with default
29
+ # arg :age, type: Integer, optional: true, default: 25
30
+ #
31
+ # @example Define an argument with multiple allowed types
32
+ # arg :id, type: [String, Integer]
33
+ #
34
+ # @example Define an argument with proc default
35
+ # arg :timestamp, type: Time, default: -> { Time.now }
36
+ #
37
+ # @example Define a context argument passed to child services
38
+ # arg :current_user, type: User, context: true
39
+ def arg(name, opts = {})
40
+ Validation.validate_symbol_name!(name, :argument, self)
41
+ Validation.validate_reserved_name!(name, :argument, self)
42
+ Validation.validate_name_conflicts!(name, :argument, self)
43
+ Validation.validate_type_required!(name, :argument, self, opts)
44
+
45
+ own_arguments[name] = Settings::Field.new(name, self, opts.merge(field_type: FieldTypes::ARGUMENT))
46
+ @arguments = nil # Clear memoized arguments since we're modifying them
47
+ end
48
+
49
+ # Remove an argument from the service
50
+ #
51
+ # @param name [Symbol] the argument name to remove
52
+ def remove_arg(name)
53
+ own_arguments.delete(name)
54
+ @arguments = nil # Clear memoized arguments since we're modifying them
55
+ end
56
+
57
+ # Get all arguments including inherited ones
58
+ #
59
+ # @return [Hash] all arguments defined for this service
60
+ def arguments
61
+ @arguments ||= build_arguments
62
+ end
63
+
64
+ # Get only arguments defined in this class
65
+ #
66
+ # @return [Hash] arguments defined in this class only
67
+ def own_arguments
68
+ @own_arguments ||= {}
69
+ end
70
+
71
+ private
72
+
73
+ # Build arguments by merging inherited arguments with own arguments
74
+ #
75
+ # @return [Hash] merged arguments
76
+ def build_arguments
77
+ inherited = superclass.respond_to?(:arguments) ? superclass.arguments.dup : {}
78
+ inherited.merge(own_arguments)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../constants"
4
+ require_relative "validation"
5
+
6
+ module Operandi
7
+ module Dsl
8
+ # DSL for defining and managing service outputs
9
+ module OutputsDsl
10
+ def self.included(base)
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ module ClassMethods
15
+ # Define an output for the service
16
+ #
17
+ # @param name [Symbol] the output name
18
+ # @param opts [Hash] options for configuring the output
19
+ # @option opts [Class, Array<Class>] :type Type(s) to validate against
20
+ # (e.g., Hash, String, [String, Symbol])
21
+ # @option opts [Boolean] :optional (false) Whether nil values are allowed
22
+ # @option opts [Object, Proc] :default Default value or proc to evaluate in instance context
23
+ #
24
+ # @example Define a required hash output
25
+ # output :result, type: Hash
26
+ #
27
+ # @example Define an optional output with default
28
+ # output :status, type: String, optional: true, default: "pending"
29
+ #
30
+ # @example Define an output with multiple allowed types
31
+ # output :data, type: [Hash, Array]
32
+ #
33
+ # @example Define an output with proc default
34
+ # output :metadata, type: Hash, default: -> { {} }
35
+ def output(name, opts = {})
36
+ Validation.validate_symbol_name!(name, :output, self)
37
+ Validation.validate_reserved_name!(name, :output, self)
38
+ Validation.validate_name_conflicts!(name, :output, self)
39
+ Validation.validate_type_required!(name, :output, self, opts)
40
+
41
+ own_outputs[name] = Settings::Field.new(name, self, opts.merge(field_type: FieldTypes::OUTPUT))
42
+ @outputs = nil # Clear memoized outputs since we're modifying them
43
+ end
44
+
45
+ # Remove an output from the service
46
+ #
47
+ # @param name [Symbol] the output name to remove
48
+ def remove_output(name)
49
+ own_outputs.delete(name)
50
+ @outputs = nil # Clear memoized outputs since we're modifying them
51
+ end
52
+
53
+ # Get all outputs including inherited ones
54
+ #
55
+ # @return [Hash] all outputs defined for this service
56
+ def outputs
57
+ @outputs ||= build_outputs
58
+ end
59
+
60
+ # Get only outputs defined in this class
61
+ #
62
+ # @return [Hash] outputs defined in this class only
63
+ def own_outputs
64
+ @own_outputs ||= {}
65
+ end
66
+
67
+ private
68
+
69
+ # Build outputs by merging inherited outputs with own outputs
70
+ #
71
+ # @return [Hash] merged outputs
72
+ def build_outputs
73
+ inherited = superclass.respond_to?(:outputs) ? superclass.outputs.dup : {}
74
+ inherited.merge(own_outputs)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../constants"
4
+ require_relative "validation"
5
+
6
+ module Operandi
7
+ module Dsl
8
+ # DSL for defining and managing service steps
9
+ module StepsDsl
10
+ def self.included(base)
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ module ClassMethods
15
+ # Define a step for the service
16
+ #
17
+ # @param name [Symbol] the step name (must correspond to a private method)
18
+ # @param opts [Hash] options for configuring the step
19
+ # @option opts [Symbol, Proc] :if Condition to determine if step should run
20
+ # @option opts [Symbol, Proc] :unless Condition to skip step (returns truthy to skip)
21
+ # @option opts [Boolean] :always (false) Run step even after errors/warnings
22
+ # @option opts [Symbol] :before Insert this step before the specified step
23
+ # @option opts [Symbol] :after Insert this step after the specified step
24
+ #
25
+ # @example Define a simple step
26
+ # step :validate_input
27
+ #
28
+ # @example Define a conditional step
29
+ # step :send_notification, if: :should_notify?
30
+ # step :skip_validation, unless: :production?
31
+ #
32
+ # @example Define a step that always runs
33
+ # step :cleanup, always: true
34
+ #
35
+ # @example Define step ordering
36
+ # step :log_start, before: :validate_input
37
+ # step :log_end, after: :process_data
38
+ #
39
+ # @example Define a step with proc condition
40
+ # step :premium_feature, if: -> { user.premium? && feature_enabled? }
41
+ def step(name, opts = {}) # rubocop:disable Metrics/MethodLength
42
+ Validation.validate_symbol_name!(name, :step, self)
43
+ Validation.validate_reserved_name!(name, :step, self)
44
+ Validation.validate_name_conflicts!(name, :step, self)
45
+ validate_step_opts!(name, opts)
46
+
47
+ # Build current steps to check for duplicates and find insertion targets
48
+ current = steps
49
+ if current.key?(name)
50
+ raise Operandi::Error,
51
+ "Step `#{name}` is already defined in service #{self}. Each step must have a unique name."
52
+ end
53
+
54
+ if (target = opts[:before] || opts[:after]) && !current.key?(target)
55
+ available = current.keys.join(", ")
56
+ raise Operandi::Error,
57
+ "Cannot find target step `#{target}` in service #{self}. Available steps: [#{available}]"
58
+ end
59
+
60
+ step_obj = Settings::Step.new(name, self, opts)
61
+
62
+ if opts[:before] || opts[:after]
63
+ step_operations << { action: :insert,
64
+ name: name,
65
+ step: step_obj,
66
+ before: opts[:before],
67
+ after: opts[:after], }
68
+ else
69
+ step_operations << { action: :add, name: name, step: step_obj }
70
+ end
71
+
72
+ # Clear memoized steps since we're modifying them
73
+ @steps = nil
74
+ end
75
+
76
+ # Remove a step from the service
77
+ #
78
+ # @param name [Symbol] the step name to remove
79
+ def remove_step(name)
80
+ step_operations << { action: :remove, name: name }
81
+
82
+ # Clear memoized steps since we're modifying them
83
+ @steps = nil
84
+ end
85
+
86
+ # Get all steps including inherited ones
87
+ #
88
+ # @return [Hash] all steps defined for this service
89
+ def steps
90
+ @steps ||= build_steps
91
+ end
92
+
93
+ # Get the list of step operations to be applied
94
+ #
95
+ # @return [Array] list of operations
96
+ def step_operations
97
+ @step_operations ||= []
98
+ end
99
+
100
+ # Validate that the service has steps defined
101
+ # Called before executing the service
102
+ #
103
+ # @raise [NoStepsError] if no steps are defined
104
+ def validate_steps!
105
+ return unless steps.empty?
106
+
107
+ raise Operandi::NoStepsError,
108
+ "Service #{self} has no steps defined. Define at least one step or implement a `run` method."
109
+ end
110
+
111
+ private
112
+
113
+ # Validate step options to ensure they are valid
114
+ #
115
+ # @param name [Symbol] the step name
116
+ # @param opts [Hash] the step options
117
+ def validate_step_opts!(name, opts)
118
+ return unless opts[:before] && opts[:after]
119
+
120
+ raise Operandi::Error, "You cannot specify `before` and `after` " \
121
+ "for step `#{name}` in service #{self} at the same time"
122
+ end
123
+
124
+ # Build steps by applying operations to inherited steps
125
+ #
126
+ # @return [Hash] the final steps hash
127
+ def build_steps
128
+ # Start with inherited steps
129
+ result = inherit_steps
130
+
131
+ # Apply operations in order
132
+ step_operations.each { |op| apply_step_operation(result, op) }
133
+
134
+ # If no steps defined, check for `run` method as fallback
135
+ result[:run] = Settings::Step.new(:run, self, {}) if result.empty? && instance_method_defined?(:run)
136
+
137
+ result
138
+ end
139
+
140
+ # Check if an instance method is defined in this class or its ancestors
141
+ # (excluding Operandi::Base and its modules)
142
+ #
143
+ # @param method_name [Symbol] the method name to check
144
+ # @return [Boolean] true if the method is defined
145
+ def instance_method_defined?(method_name)
146
+ # Check if method exists and is not from base service classes
147
+ return false unless method_defined?(method_name) || private_method_defined?(method_name)
148
+
149
+ # Get the method owner to ensure it's defined in user's service class
150
+ owner = instance_method(method_name).owner
151
+
152
+ # Method should be defined in a class that inherits from Base,
153
+ # not in Base itself or its included modules
154
+ !owner.to_s.start_with?("Operandi")
155
+ end
156
+
157
+ # Inherit steps from parent class
158
+ #
159
+ # @return [Hash] inherited steps
160
+ def inherit_steps
161
+ superclass.respond_to?(:steps) ? superclass.steps.dup : {}
162
+ end
163
+
164
+ # Apply a single step operation to the steps hash
165
+ #
166
+ # @param steps [Hash] the steps hash
167
+ # @param operation [Hash] the operation to apply
168
+ def apply_step_operation(steps, operation)
169
+ case operation[:action]
170
+ when :add
171
+ steps[operation[:name]] = operation[:step]
172
+ when :remove
173
+ steps.delete(operation[:name])
174
+ when :insert
175
+ insert_step(steps, operation)
176
+ end
177
+ end
178
+
179
+ # Insert a step before or after a target step
180
+ #
181
+ # @param steps [Hash] the steps hash
182
+ # @param operation [Hash] the insert operation details
183
+ def insert_step(steps, operation)
184
+ target = operation[:before] || operation[:after]
185
+ keys = steps.keys
186
+ index = keys.index(target)
187
+ return unless index
188
+
189
+ # More efficient insertion using ordered hash reconstruction
190
+ new_steps = {}
191
+
192
+ keys.each_with_index do |key, i|
193
+ # Insert before target
194
+ new_steps[operation[:name]] = operation[:step] if operation[:before] && i == index
195
+ new_steps[key] = steps[key]
196
+
197
+ # Insert after target
198
+ new_steps[operation[:name]] = operation[:step] if operation[:after] && i == index
199
+ end
200
+
201
+ steps.replace(new_steps)
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end