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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../constants"
4
+
5
+ module Operandi
6
+ module Dsl
7
+ # Shared validation logic for DSL modules
8
+ module Validation
9
+ # Validate that the name is a symbol
10
+ #
11
+ # @param name [Object] the name to validate
12
+ # @param field_type [Symbol] the type of field (:argument, :output, :step)
13
+ # @param service_class [Class] the service class for error messages
14
+ def self.validate_symbol_name!(name, field_type, service_class)
15
+ return if name.is_a?(Symbol)
16
+
17
+ raise Operandi::InvalidNameError,
18
+ "#{field_type.to_s.capitalize} name must be a Symbol, " \
19
+ "got #{name.class} (#{name.inspect}) in #{service_class}"
20
+ end
21
+
22
+ # Validate that the name is not a reserved word
23
+ #
24
+ # @param name [Symbol] the name to validate
25
+ # @param field_type [Symbol] the type of field (:argument, :output, :step)
26
+ # @param service_class [Class] the service class for error messages
27
+ def self.validate_reserved_name!(name, field_type, service_class)
28
+ return unless ReservedNames::ALL.include?(name.to_sym)
29
+
30
+ raise Operandi::ReservedNameError,
31
+ "Cannot use `#{name}` as #{field_type} name in #{service_class} - " \
32
+ "it is a reserved word that conflicts with gem methods"
33
+ end
34
+
35
+ # Validate that the name doesn't conflict with other defined names
36
+ #
37
+ # @param name [Symbol] the name to validate
38
+ # @param field_type [Symbol] the type of field being defined (:argument, :output, :step)
39
+ # @param service_class [Class] the service class to check for conflicts
40
+ def self.validate_name_conflicts!(name, field_type, service_class)
41
+ name_sym = name.to_sym
42
+
43
+ case field_type
44
+ when :argument
45
+ validate_argument_conflicts!(name_sym, service_class)
46
+ when :output
47
+ validate_output_conflicts!(name_sym, service_class)
48
+ when :step
49
+ validate_step_conflicts!(name_sym, service_class)
50
+ end
51
+ end
52
+
53
+ # Validate argument name doesn't conflict with outputs or steps
54
+ def self.validate_argument_conflicts!(name_sym, service_class)
55
+ # Check against existing outputs
56
+ if has_output?(name_sym, service_class)
57
+ raise Operandi::ReservedNameError,
58
+ "Cannot use `#{name_sym}` as argument name in #{service_class} - " \
59
+ "it is already defined as an output"
60
+ end
61
+
62
+ # Check against existing steps
63
+ if has_step?(name_sym, service_class)
64
+ raise Operandi::ReservedNameError,
65
+ "Cannot use `#{name_sym}` as argument name in #{service_class} - " \
66
+ "it is already defined as a step"
67
+ end
68
+ end
69
+
70
+ # Validate output name doesn't conflict with arguments or steps
71
+ def self.validate_output_conflicts!(name_sym, service_class)
72
+ # Check against existing arguments
73
+ if has_argument?(name_sym, service_class)
74
+ raise Operandi::ReservedNameError,
75
+ "Cannot use `#{name_sym}` as output name in #{service_class} - " \
76
+ "it is already defined as an argument"
77
+ end
78
+
79
+ # Check against existing steps
80
+ if has_step?(name_sym, service_class)
81
+ raise Operandi::ReservedNameError,
82
+ "Cannot use `#{name_sym}` as output name in #{service_class} - " \
83
+ "it is already defined as a step"
84
+ end
85
+ end
86
+
87
+ # Validate step name doesn't conflict with arguments or outputs
88
+ def self.validate_step_conflicts!(name_sym, service_class)
89
+ # Check against existing arguments
90
+ if has_argument?(name_sym, service_class)
91
+ raise Operandi::ReservedNameError,
92
+ "Cannot use `#{name_sym}` as step name in #{service_class} - " \
93
+ "it is already defined as an argument"
94
+ end
95
+
96
+ # Check against existing outputs
97
+ if has_output?(name_sym, service_class)
98
+ raise Operandi::ReservedNameError,
99
+ "Cannot use `#{name_sym}` as step name in #{service_class} - " \
100
+ "it is already defined as an output"
101
+ end
102
+ end
103
+
104
+ # Check if a name is already defined as an argument
105
+ def self.has_argument?(name_sym, service_class)
106
+ # Check own_arguments (current class)
107
+ (service_class.respond_to?(:own_arguments) && service_class.own_arguments.key?(name_sym)) ||
108
+ # Check inherited arguments
109
+ (service_class.superclass.respond_to?(:arguments) && service_class.superclass.arguments.key?(name_sym))
110
+ end
111
+
112
+ # Check if a name is already defined as an output
113
+ def self.has_output?(name_sym, service_class)
114
+ # Check own_outputs (current class)
115
+ (service_class.respond_to?(:own_outputs) && service_class.own_outputs.key?(name_sym)) ||
116
+ # Check inherited outputs
117
+ (service_class.superclass.respond_to?(:outputs) && service_class.superclass.outputs.key?(name_sym))
118
+ end
119
+
120
+ # Check if a name is already defined as a step
121
+ def self.has_step?(name_sym, service_class)
122
+ # Check step_operations (current class) for non-removed steps
123
+ (service_class.respond_to?(:step_operations) &&
124
+ service_class.step_operations.any? { |op| op[:name] == name_sym && op[:action] != :remove }) ||
125
+ # Check inherited steps
126
+ (service_class.superclass.respond_to?(:steps) && service_class.superclass.steps.key?(name_sym))
127
+ end
128
+
129
+ # Validate that the type option is provided when require_type is enabled
130
+ #
131
+ # @param name [Symbol] the field name
132
+ # @param field_type [Symbol] the type of field (:argument, :output)
133
+ # @param service_class [Class] the service class for error messages
134
+ # @param opts [Hash] the options hash to check for type
135
+ def self.validate_type_required!(name, field_type, service_class, opts)
136
+ return if opts.key?(:type)
137
+ return unless require_type_enabled_for?(field_type, service_class)
138
+
139
+ config_name = field_type == :argument ? "require_arg_type" : "require_output_type"
140
+ raise Operandi::MissingTypeError,
141
+ "#{field_type.to_s.capitalize} `#{name}` in #{service_class} must have a type specified " \
142
+ "(#{config_name} is enabled)"
143
+ end
144
+
145
+ # Check if require_type is enabled for the given field type and service class
146
+ #
147
+ # @param field_type [Symbol] the type of field (:argument, :output)
148
+ # @param service_class [Class] the service class to check
149
+ # @return [Boolean] whether type is required for the field type
150
+ def self.require_type_enabled_for?(field_type, service_class)
151
+ config_key = field_type == :argument ? :require_arg_type : :require_output_type
152
+
153
+ # Check class-level config in the inheritance chain, then fall back to global config
154
+ klass = service_class
155
+ while klass.respond_to?(:class_config)
156
+ class_config = klass.class_config
157
+
158
+ # Check specific config first (require_arg_type or require_output_type)
159
+ return class_config[config_key] if class_config&.key?(config_key)
160
+
161
+ # Check convenience config (require_type) for backward compatibility
162
+ return class_config[:require_type] if class_config&.key?(:require_type)
163
+
164
+ klass = klass.superclass
165
+ end
166
+
167
+ Operandi.config.public_send(config_key)
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ # Base exception class for all Operandi errors.
5
+ class Error < StandardError; end
6
+
7
+ # Raised for failures that occur while a service instance is running.
8
+ class RuntimeError < Error
9
+ # @return [Base] the service instance that raised the error
10
+ attr_reader :service
11
+
12
+ # @param message [String, nil] the error message
13
+ # @param service [Base] the service instance that raised the error
14
+ def initialize(message = nil, service:)
15
+ raise ArgumentError, "service is required" unless service
16
+
17
+ unless defined?(Operandi::Base) && service.is_a?(Operandi::Base)
18
+ raise ArgumentError, "service must be an Operandi::Base instance"
19
+ end
20
+
21
+ @service = service
22
+ super(message)
23
+ end
24
+ end
25
+
26
+ # Raised when an argument or output value doesn't match the expected type.
27
+ class ArgTypeError < Error
28
+ # @return [Class<Base>] the service class associated with the type error
29
+ attr_reader :service_class
30
+
31
+ # @param message [String, nil] the error message
32
+ # @param service_class [Class<Base>] the service class associated with the type error
33
+ def initialize(message = nil, service_class:)
34
+ raise ArgumentError, "service_class is required" unless service_class
35
+
36
+ unless defined?(Operandi::Base) &&
37
+ service_class.is_a?(Class) &&
38
+ service_class <= Operandi::Base
39
+ raise ArgumentError, "service_class must be an Operandi::Base subclass"
40
+ end
41
+
42
+ @service_class = service_class
43
+ super(message)
44
+ end
45
+ end
46
+
47
+ # Raised when using a reserved name for an argument, output, or step.
48
+ class ReservedNameError < Error; end
49
+
50
+ # Raised when a name is invalid (e.g., not a Symbol).
51
+ class InvalidNameError < Error; end
52
+
53
+ # Raised when a service has no steps defined and no run method.
54
+ class NoStepsError < Error; end
55
+
56
+ # Raised when type is required but not specified for an argument or output.
57
+ class MissingTypeError < Error; end
58
+
59
+ # Control flow exception for stop_immediately!
60
+ # Not an error - used to halt execution gracefully.
61
+ class StopExecution < StandardError; end
62
+
63
+ # Control flow exception for fail_immediately!
64
+ # Unlike StopExecution, this exception causes transaction rollback.
65
+ class FailExecution < StandardError; end
66
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ # Represents a single error or warning message.
5
+ #
6
+ # @example Creating a message
7
+ # message = Message.new(:name, "can't be blank", break: true)
8
+ # message.key # => :name
9
+ # message.text # => "can't be blank"
10
+ # message.break? # => true
11
+ class Message
12
+ # @return [Symbol] the key/field this message belongs to
13
+ attr_reader :key
14
+
15
+ # @return [String] the message text
16
+ attr_reader :text
17
+
18
+ # Create a new message.
19
+ #
20
+ # @param key [Symbol] the key/field this message belongs to
21
+ # @param text [String] the message text
22
+ # @param opts [Hash] additional options
23
+ # @option opts [Boolean] :break whether to stop step execution
24
+ # @option opts [Boolean] :rollback whether to rollback the transaction
25
+ def initialize(key, text, opts = {})
26
+ @key = key
27
+ @text = text
28
+ @opts = opts
29
+ end
30
+
31
+ # Check if this message should stop step execution.
32
+ #
33
+ # @return [Boolean] true if break option was set
34
+ def break?
35
+ @opts[:break]
36
+ end
37
+
38
+ # Check if this message should trigger a transaction rollback.
39
+ #
40
+ # @return [Boolean] true if rollback option was set
41
+ def rollback?
42
+ @opts[:rollback]
43
+ end
44
+
45
+ # Return the message text.
46
+ #
47
+ # @return [String] the message text
48
+ def to_s
49
+ text
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ # Collection of error or warning messages, organized by key.
5
+ #
6
+ # @example Adding and accessing errors
7
+ # errors.add(:name, "can't be blank")
8
+ # errors.add(:email, "is invalid")
9
+ # errors[:name] # => [#<Message key: :name, text: "can't be blank">]
10
+ # errors.to_h # => { name: ["can't be blank"], email: ["is invalid"] }
11
+ class Messages
12
+ extend Forwardable
13
+
14
+ # @!method [](key)
15
+ # Get messages for a specific key.
16
+ # @param key [Symbol] the key to look up
17
+ # @return [Array<Message>, nil] array of messages or nil
18
+
19
+ # @!method any?
20
+ # Check if there are any messages.
21
+ # @return [Boolean] true if messages exist
22
+
23
+ # @!method empty?
24
+ # Check if the collection is empty.
25
+ # @return [Boolean] true if no messages
26
+
27
+ # @!method size
28
+ # Get number of keys with messages.
29
+ # @return [Integer] number of keys
30
+
31
+ # @!method keys
32
+ # Get all keys with messages.
33
+ # @return [Array<Symbol>] array of keys
34
+
35
+ # @!method key?(key)
36
+ # Check if a key has messages.
37
+ # @param key [Symbol] the key to check
38
+ # @return [Boolean] true if key has messages
39
+ def_delegators :@messages,
40
+ :[],
41
+ :any?,
42
+ :empty?,
43
+ :size,
44
+ :keys,
45
+ :values,
46
+ :each,
47
+ :each_with_index,
48
+ :each_with_object,
49
+ :key?
50
+ alias has_key? key?
51
+
52
+ # Initialize a new messages collection.
53
+ #
54
+ # @param config [Hash] configuration options
55
+ # @param service [Base] service instance that owns this collection
56
+ # @option config [Boolean] :break_on_add stop execution when message added
57
+ # @option config [Boolean] :raise_on_add raise exception when message added
58
+ # @option config [Boolean] :rollback_on_add rollback transaction when message added
59
+ def initialize(config = {}, service:)
60
+ @break = false
61
+ @config = config
62
+ @messages = {}
63
+ @service = service
64
+ end
65
+
66
+ # Get total count of all messages across all keys.
67
+ #
68
+ # @return [Integer] total number of messages
69
+ def count
70
+ @messages.values.sum(&:size)
71
+ end
72
+
73
+ # Add a message to the collection.
74
+ #
75
+ # @param key [Symbol] the key/field for this message
76
+ # @param texts [String, Array<String>, Message] the message text(s) to add
77
+ # @param opts [Hash] additional options
78
+ # @option opts [Boolean] :break override break behavior for this message
79
+ # @option opts [Boolean] :rollback override rollback behavior for this message
80
+ # @return [void]
81
+ # @raise [RuntimeError, Error] if text is nil or empty
82
+ #
83
+ # @example Add a single error
84
+ # errors.add(:name, "can't be blank")
85
+ #
86
+ # @example Add multiple errors
87
+ # errors.add(:email, ["is invalid", "is already taken"])
88
+ def add(key, texts, opts = {})
89
+ raise_error("Error must be a non-empty string") unless texts
90
+
91
+ message = nil
92
+
93
+ [*texts].each do |text|
94
+ message = text.is_a?(Message) ? text : Message.new(key, text, opts)
95
+
96
+ raise_error("Error must be a non-empty string") unless valid_error_text?(message.text)
97
+
98
+ @messages[key] ||= []
99
+ @messages[key] << message
100
+ end
101
+
102
+ raise!(message)
103
+ break!(opts.key?(:break) ? opts[:break] : message.break?)
104
+ rollback!(opts.key?(:rollback) ? opts[:rollback] : message.rollback?) if !opts.key?(:last) || opts[:last]
105
+ end
106
+
107
+ # Check if step execution should stop.
108
+ #
109
+ # @return [Boolean] true if a message triggered a break
110
+ def break?
111
+ @break
112
+ end
113
+
114
+ # Copy messages from another source.
115
+ #
116
+ # @param entity [ActiveRecord::Base, Base, Hash, #each] source to copy from
117
+ # @param opts [Hash] options to pass to each added message
118
+ # @return [void]
119
+ # @raise [RuntimeError, Error] if entity type is not supported
120
+ #
121
+ # @example Copy from ActiveRecord model
122
+ # errors.copy_from(user) # copies user.errors
123
+ #
124
+ # @example Copy from another service
125
+ # errors.copy_from(child_service)
126
+ def copy_from(entity, opts = {})
127
+ if defined?(ActiveRecord::Base) && entity.is_a?(ActiveRecord::Base)
128
+ copy_from(entity.errors.messages, opts)
129
+ elsif entity.is_a?(Operandi::Base)
130
+ copy_from(entity.errors, opts)
131
+ elsif entity.respond_to?(:each)
132
+ last_index = entity.size - 1
133
+
134
+ entity.each_with_index do |(key, message), index|
135
+ add(key, message, opts.merge(last: index == last_index))
136
+ end
137
+ else
138
+ raise_error("Don't know how to import errors from #{entity}")
139
+ end
140
+ end
141
+ alias from_record copy_from
142
+
143
+ # Convert messages to a hash with string values.
144
+ #
145
+ # @return [Hash{Symbol => Array<String>}] messages as hash
146
+ def to_h
147
+ @messages.to_h.transform_values { |value| value.map(&:to_s) }
148
+ end
149
+
150
+ private
151
+
152
+ def valid_error_text?(text)
153
+ return false unless text.is_a?(String)
154
+
155
+ !text.strip.empty?
156
+ end
157
+
158
+ def break!(break_execution)
159
+ return unless break_execution.nil? ? @config[:break_on_add] : break_execution
160
+
161
+ @break = true
162
+ end
163
+
164
+ def raise!(message)
165
+ return unless @config[:raise_on_add]
166
+
167
+ if message.key == :base
168
+ raise_error(message.text.capitalize.strip)
169
+ else
170
+ raise_error("#{message.key.to_s.capitalize} #{message.text}".strip)
171
+ end
172
+ end
173
+
174
+ def raise_error(message)
175
+ raise Operandi::RuntimeError.new(message, service: @service)
176
+ end
177
+
178
+ def rollback!(rollback)
179
+ return unless defined?(ActiveRecord::Rollback)
180
+ return unless rollback.nil? ? @config[:rollback_on_add] : rollback
181
+
182
+ raise ActiveRecord::Rollback
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ module RSpec
5
+ module Matchers
6
+ # Matcher for testing argument definitions on a service class
7
+ #
8
+ # @example Basic usage
9
+ # expect(MyService).to define_argument(:name)
10
+ #
11
+ # @example With type constraint
12
+ # expect(MyService).to define_argument(:name).with_type(String)
13
+ #
14
+ # @example With optional flag
15
+ # expect(MyService).to define_argument(:email).optional
16
+ #
17
+ # @example With default value
18
+ # expect(MyService).to define_argument(:status).with_default("pending")
19
+ #
20
+ # @example With context flag
21
+ # expect(MyService).to define_argument(:current_user).with_context
22
+ #
23
+ # @example Combined
24
+ # expect(MyService).to define_argument(:count).with_type(Integer).optional.with_default(0)
25
+ def define_argument(name)
26
+ DefineArgumentMatcher.new(name)
27
+ end
28
+
29
+ class DefineArgumentMatcher
30
+ def initialize(name)
31
+ @name = name
32
+ @expected_type = nil
33
+ @expected_optional = nil
34
+ @expected_default = nil
35
+ @check_default = false
36
+ @expected_context = nil
37
+ end
38
+
39
+ def with_type(type)
40
+ @expected_type = type
41
+ self
42
+ end
43
+
44
+ def optional(value = true)
45
+ @expected_optional = value
46
+ self
47
+ end
48
+
49
+ def required
50
+ @expected_optional = false
51
+ self
52
+ end
53
+
54
+ def with_default(default)
55
+ @check_default = true
56
+ @expected_default = default
57
+ self
58
+ end
59
+
60
+ def with_context(value = true)
61
+ @expected_context = value
62
+ self
63
+ end
64
+
65
+ def matches?(service_class)
66
+ @service_class = service_class
67
+ @actual_class = service_class.is_a?(Class) ? service_class : service_class.class
68
+
69
+ return false unless argument_defined?
70
+ return false unless type_matches?
71
+ return false unless optional_matches?
72
+ return false unless default_matches?
73
+ return false unless context_matches?
74
+
75
+ true
76
+ end
77
+
78
+ def failure_message
79
+ return "expected #{@actual_class} to define argument :#{@name}" unless argument_defined?
80
+ return type_failure_message unless type_matches?
81
+ return optional_failure_message unless optional_matches?
82
+ return default_failure_message unless default_matches?
83
+ return context_failure_message unless context_matches?
84
+
85
+ ""
86
+ end
87
+
88
+ def failure_message_when_negated
89
+ "expected #{@actual_class} not to define argument :#{@name}"
90
+ end
91
+
92
+ def description
93
+ desc = "define argument :#{@name}"
94
+ desc += " with type #{@expected_type}" if @expected_type
95
+ desc += " as optional" if @expected_optional == true
96
+ desc += " as required" if @expected_optional == false
97
+ desc += " with default #{@expected_default.inspect}" if @check_default
98
+ desc += " with context" if @expected_context
99
+ desc
100
+ end
101
+
102
+ private
103
+
104
+ def argument_defined?
105
+ @actual_class.respond_to?(:arguments) && @actual_class.arguments.key?(@name)
106
+ end
107
+
108
+ def argument
109
+ @argument ||= @actual_class.arguments[@name]
110
+ end
111
+
112
+ def type_matches?
113
+ return true if @expected_type.nil?
114
+
115
+ # Access the type via instance variable since there's no public getter
116
+ actual_type = argument.instance_variable_get(:@type)
117
+ actual_type == @expected_type
118
+ end
119
+
120
+ def optional_matches?
121
+ return true if @expected_optional.nil?
122
+
123
+ argument.optional == @expected_optional
124
+ end
125
+
126
+ def default_matches?
127
+ return true unless @check_default
128
+
129
+ argument.default_exists && argument.default == @expected_default
130
+ end
131
+
132
+ def context_matches?
133
+ return true if @expected_context.nil?
134
+
135
+ argument.context == @expected_context
136
+ end
137
+
138
+ def type_failure_message
139
+ actual_type = argument.instance_variable_get(:@type)
140
+ "expected #{@actual_class} argument :#{@name} to have type #{@expected_type}, " \
141
+ "but it has type #{actual_type.inspect}"
142
+ end
143
+
144
+ def optional_failure_message
145
+ if @expected_optional
146
+ "expected #{@actual_class} argument :#{@name} to be optional, but it is required"
147
+ else
148
+ "expected #{@actual_class} argument :#{@name} to be required, but it is optional"
149
+ end
150
+ end
151
+
152
+ def default_failure_message
153
+ if argument.default_exists
154
+ "expected #{@actual_class} argument :#{@name} to have default #{@expected_default.inspect}, " \
155
+ "but it has default #{argument.default.inspect}"
156
+ else
157
+ "expected #{@actual_class} argument :#{@name} to have default #{@expected_default.inspect}, " \
158
+ "but no default is defined"
159
+ end
160
+ end
161
+
162
+ def context_failure_message
163
+ if @expected_context
164
+ "expected #{@actual_class} argument :#{@name} to have context flag, but it doesn't"
165
+ else
166
+ "expected #{@actual_class} argument :#{@name} not to have context flag, but it does"
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end