From 633b2107ed6baf2cf26ddbe4ca2d30be455a3aa1 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Thu, 9 Apr 2026 22:15:51 -0400 Subject: [PATCH 1/4] Move result transition to a resolver class Move more method to resolver Update result.rb Fix specs --- CHANGELOG.md | 3 + lib/cmdx/executor.rb | 27 +- lib/cmdx/middlewares/timeout.rb | 14 +- lib/cmdx/resolver.rb | 263 ++++++++++++++++ lib/cmdx/result.rb | 247 ++------------- lib/cmdx/task.rb | 13 +- spec/cmdx/executor_spec.rb | 75 ++--- spec/cmdx/faults_spec.rb | 11 +- spec/cmdx/middlewares/timeout_spec.rb | 8 +- spec/cmdx/resolver_spec.rb | 316 ++++++++++++++++++++ spec/cmdx/result_spec.rb | 414 ++++---------------------- spec/cmdx/task_spec.rb | 16 +- 12 files changed, 761 insertions(+), 646 deletions(-) create mode 100644 lib/cmdx/resolver.rb create mode 100644 spec/cmdx/resolver_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5ee2e1c..f6b473901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [UNRELEASED] +### Added +- Add `CMDx::Resolver` class to manage result transitions + ## [1.21.0] - 2026-04-09 ### Added diff --git a/lib/cmdx/executor.rb b/lib/cmdx/executor.rb index 5a2e2711b..727d7d032 100644 --- a/lib/cmdx/executor.rb +++ b/lib/cmdx/executor.rb @@ -28,7 +28,7 @@ class Executor # @rbs @task: Task attr_reader :task - def_delegators :task, :result + def_delegators :task, :result, :resolver # @param task [CMDx::Task] The task to execute # @@ -78,13 +78,13 @@ def execute rescue UndefinedMethodError => e raise_exception(e) rescue Fault => e - result.throw!(e.result, halt: false, cause: e) + resolver.throw!(e.result, halt: false, cause: e) rescue StandardError => e retry if retry_execution?(e) - result.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) + resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) task.class.settings.exception_handler&.call(task, e) ensure - result.executed! + resolver.executed! post_execution! end @@ -111,20 +111,20 @@ def execute! rescue UndefinedMethodError => e raise_exception(e) rescue Fault => e - result.throw!(e.result, halt: false, cause: e) + resolver.throw!(e.result, halt: false, cause: e) if halt_execution?(e) raise_exception(e) else - result.executed! + resolver.executed! post_execution! end rescue StandardError => e retry if retry_execution?(e) - result.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) + resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) raise_exception(e) else - result.executed! + resolver.executed! post_execution! end @@ -221,7 +221,7 @@ def pre_execution! task.class.settings.attributes.define_and_verify(task) return if task.errors.empty? - result.fail!( + resolver.fail!( Locale.t("cmdx.faults.invalid"), source: :validation, errors: { @@ -238,7 +238,7 @@ def pre_execution! def execution! invoke_callbacks(:before_execution) - result.executing! + resolver.executing! catch(:cmdx_halt) { task.work } end @@ -254,7 +254,7 @@ def verify_context_returns! missing.each { |name| task.errors.add(name, Locale.t("cmdx.returns.missing")) } - result.fail!( + resolver.fail!( Locale.t("cmdx.faults.invalid"), source: :context, errors: { @@ -285,12 +285,12 @@ def post_execution! def verify_middleware_yield! return unless result.initialized? - result.fail!( + resolver.fail!( Locale.t("cmdx.faults.invalid"), halt: false, source: :middleware ) - result.executed! + resolver.executed! end # Finalizes execution by freezing the task, logging results, and rolling back work. @@ -339,6 +339,7 @@ def freeze_execution! return unless CMDx.configuration.freeze_results task.freeze + resolver.freeze result.freeze # Freezing the context and chain can only be done once the outer-most diff --git a/lib/cmdx/middlewares/timeout.rb b/lib/cmdx/middlewares/timeout.rb index 8c40dee74..a7dad82b2 100644 --- a/lib/cmdx/middlewares/timeout.rb +++ b/lib/cmdx/middlewares/timeout.rb @@ -63,14 +63,12 @@ def call(task, **options, &) ::Timeout.timeout(limit, TimeoutError, "execution exceeded #{limit} seconds", &) rescue TimeoutError => e - task.result.tap do |r| - r.fail!( - Utils::Normalize.exception(e), - cause: e, - source: :timeout, - limit: - ) - end + task.resolver.fail!( + Utils::Normalize.exception(e), + cause: e, + source: :timeout, + limit: + ) end end diff --git a/lib/cmdx/resolver.rb b/lib/cmdx/resolver.rb new file mode 100644 index 000000000..d201eea3b --- /dev/null +++ b/lib/cmdx/resolver.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +module CMDx + # Manages state transitions for a {CMDx::Result}, handling success annotation, + # skipping, failure, throwing, and halting with fault raising. + # + # The Resolver owns the +strict+ flag and all transition logic. It writes to the + # Result via +instance_variable_set+ so that Result remains a pure data object. + # + # @example Accessed through the task + # task.resolver.fail!("Validation failed", cause: validation_error) + # task.resolver.skip!("Already processed", halt: false) + class Resolver + + extend Forwardable + + # Returns the result being resolved. + # + # @return [CMDx::Result] The result instance + # + # @rbs @result: Result + attr_reader :result + + def_delegators :result, :task, :success?, :skipped?, :failed?, + :initialized?, :executing?, :complete?, :interrupted? + + # @param result [CMDx::Result] The result to manage transitions for + # + # @return [CMDx::Resolver] A new resolver instance + # + # @raise [TypeError] When result is not a CMDx::Result instance + # + # @example + # resolver = CMDx::Resolver.new(result) + # + # @rbs (Result) -> void + def initialize(result) + raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) + + @result = result + end + + # Transitions the result to the appropriate final state based on status. + # Delegates to {#complete!} when successful, {#interrupt!} otherwise. + # + # @return [void] + # + # @example + # resolver.executed! + # + # @rbs () -> void + def executed! + success? ? complete! : interrupt! + end + + # Transitions the result state from initialized to executing. + # + # @raise [RuntimeError] When attempting to transition from invalid state + # + # @example + # resolver.executing! + # + # @rbs () -> void + def executing! + return if executing? + + raise "can only transition to #{Result::EXECUTING} from #{Result::INITIALIZED}" unless initialized? + + assign(state: Result::EXECUTING) + end + + # Transitions the result state from executing to complete. + # + # @raise [RuntimeError] When attempting to transition from invalid state + # + # @example + # resolver.complete! + # + # @rbs () -> void + def complete! + return if complete? + + raise "can only transition to #{Result::COMPLETE} from #{Result::EXECUTING}" unless executing? + + assign(state: Result::COMPLETE) + end + + # Transitions the result state to interrupted. + # + # @raise [RuntimeError] When attempting to transition from invalid state + # + # @example + # resolver.interrupt! + # + # @rbs () -> void + def interrupt! + return if interrupted? + + raise "cannot transition to #{Result::INTERRUPTED} from #{Result::COMPLETE}" if complete? + + assign(state: Result::INTERRUPTED) + end + + # Sets a reason and optional metadata on a successful result without + # changing its state or status. Useful for annotating why a task succeeded. + # When halt is true, uses throw/catch to exit the work method early. + # + # @param reason [String, nil] Reason or note for the success + # @param halt [Boolean] Whether to halt execution after success + # @param metadata [Hash] Additional metadata about the success + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [RuntimeError] When status is not success + # + # @example + # resolver.success!("Created 42 records") + # resolver.success!("Imported", halt: false, rows: 100) + # + # @rbs (?String? reason, halt: bool, **untyped metadata) -> void + def success!(reason = nil, halt: true, **metadata) + raise "can only be used while #{Result::SUCCESS}" unless success? + + assign( + reason:, + metadata: + ) + + throw(:cmdx_halt) if halt + end + + # @param reason [String, nil] Reason for skipping the task + # @param halt [Boolean] Whether to halt execution after skipping + # @param cause [Exception, nil] Exception that caused the skip + # @param strict [Boolean] Whether this skip is strict (default: true). + # @param metadata [Hash] Additional metadata about the skip + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [RuntimeError] When attempting to skip from invalid status + # + # @example + # resolver.skip!("Dependencies not met", cause: dependency_error) + # resolver.skip!("Already processed", halt: false) + # resolver.skip!("Optional step", strict: false) + # + # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void + def skip!(reason = nil, halt: true, cause: nil, strict: true, **metadata) + return if skipped? + + raise "can only transition to #{Result::SKIPPED} from #{Result::SUCCESS}" unless success? + + assign( + state: Result::INTERRUPTED, + status: Result::SKIPPED, + reason: reason || Locale.t("cmdx.reasons.unspecified"), + cause:, + strict:, + metadata: + ) + + halt! if halt + end + + # @param reason [String, nil] Reason for task failure + # @param halt [Boolean] Whether to halt execution after failure + # @param cause [Exception, nil] Exception that caused the failure + # @param strict [Boolean] Whether this failure is strict (default: true). + # @param metadata [Hash] Additional metadata about the failure + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [RuntimeError] When attempting to fail from invalid status + # + # @example + # resolver.fail!("Validation failed", cause: validation_error) + # resolver.fail!("Network timeout", halt: false, timeout: 30) + # resolver.fail!("Soft failure", strict: false) + # + # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void + def fail!(reason = nil, halt: true, cause: nil, strict: true, **metadata) + return if failed? + + raise "can only transition to #{Result::FAILED} from #{Result::SUCCESS}" unless success? + + assign( + state: Result::INTERRUPTED, + status: Result::FAILED, + reason: reason || Locale.t("cmdx.reasons.unspecified"), + cause:, + strict:, + metadata: + ) + + halt! if halt + end + + # @raise [SkipFault] When task was skipped + # @raise [FailFault] When task failed + # + # @example + # resolver.halt! + # + # @rbs () -> void + def halt! + return if success? + + klass = skipped? ? SkipFault : FailFault + fault = klass.new(result) + + frames = caller_locations(3..-1) + + unless frames.empty? + frames = frames.map(&:to_s) + + if (cleaner = task.class.settings.backtrace_cleaner) + cleaner.call(frames) + end + + fault.set_backtrace(frames) + end + + raise(fault) + end + + # @param other [CMDx::Result] Result to throw from current result + # @param halt [Boolean] Whether to halt execution after throwing + # @param cause [Exception, nil] Exception that caused the throw + # @param metadata [Hash] Additional metadata to merge + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [TypeError] When other is not a CMDx::Result instance + # + # @example + # other_result = OtherTask.execute + # resolver.throw!(other_result, cause: upstream_error) + # + # @rbs (Result other, halt: bool, cause: Exception?, **untyped metadata) -> void + def throw!(other, halt: true, cause: nil, **metadata) + raise TypeError, "must be a CMDx::Result" unless other.is_a?(Result) + + assign( + state: other.state, + status: other.status, + reason: other.reason, + cause: cause || other.cause, + strict: other.strict?, + metadata: other.metadata.merge(metadata) + ) + + halt! if halt + end + + private + + # Writes attributes to the result via instance_variable_set. + # + # @param attrs [Hash] Attributes to assign to the result + # + # @rbs (**untyped attrs) -> void + def assign(**attrs) + attrs.each { |key, value| @result.instance_variable_set(:"@#{key}", value) } + end + + end +end diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb index e13d213d1..d46141660 100644 --- a/lib/cmdx/result.rb +++ b/lib/cmdx/result.rb @@ -99,21 +99,11 @@ class Result # @rbs @cause: (Exception | nil) attr_reader :cause - # Returns the number of retries attempted. - # - # @return [Integer] The number of retries attempted - # - # @example - # result.retries # => 2 - # - # @rbs @retries: Integer - attr_accessor :retries - - # Returns whether this result is strict. + # Returns whether this result transitions are strict. # When false, {CMDx::Executor#halt_execution?} returns false # regardless of the task's breakpoint settings. # - # @return [Boolean] Whether the result is strict + # @return [Boolean] Whether the result transitions are strict # # @example # result.strict? # => true @@ -121,6 +111,16 @@ class Result # @rbs @strict: bool attr_reader :strict + # Returns the number of retries attempted. + # + # @return [Integer] The number of retries attempted + # + # @example + # result.retries # => 2 + # + # @rbs @retries: Integer + attr_accessor :retries + # Returns whether the result has been rolled back. # # @return [Boolean] Whether the result has been rolled back @@ -154,8 +154,8 @@ def initialize(task) @metadata = {} @reason = nil @cause = nil - @retries = 0 @strict = true + @retries = 0 @rolled_back = false end @@ -170,16 +170,6 @@ def initialize(task) define_method(:"#{s}?") { state == s } end - # @return [self] Returns self for method chaining - # - # @example - # result.executed! # Transitions to complete or interrupted - # - # @rbs () -> self - def executed! - success? ? complete! : interrupt! - end - # @return [Boolean] Whether the task has been executed (complete or interrupted) # # @example @@ -190,48 +180,6 @@ def executed? complete? || interrupted? end - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # result.executing! # Transitions from initialized to executing - # - # @rbs () -> void - def executing! - return if executing? - - raise "can only transition to #{EXECUTING} from #{INITIALIZED}" unless initialized? - - @state = EXECUTING - end - - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # result.complete! # Transitions from executing to complete - # - # @rbs () -> void - def complete! - return if complete? - - raise "can only transition to #{COMPLETE} from #{EXECUTING}" unless executing? - - @state = COMPLETE - end - - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # result.interrupt! # Transitions from executing to interrupted - # - # @rbs () -> void - def interrupt! - return if interrupted? - - raise "cannot transition to #{INTERRUPTED} from #{COMPLETE}" if complete? - - @state = INTERRUPTED - end - STATUSES.each do |s| # @return [Boolean] Whether the result has the specified status # @@ -282,147 +230,6 @@ def on(*states_or_statuses, &) self end - # Sets a reason and optional metadata on a successful result without - # changing its state or status. Useful for annotating why a task succeeded. - # When halt is true, uses throw/catch to exit the work method early. - # - # @param reason [String, nil] Reason or note for the success - # @param halt [Boolean] Whether to halt execution after success - # @param metadata [Hash] Additional metadata about the success - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When status is not success - # - # @example - # result.success!("Created 42 records") - # result.success!("Imported", halt: false, rows: 100) - # - # @rbs (?String? reason, halt: bool, **untyped metadata) -> void - def success!(reason = nil, halt: true, **metadata) - raise "can only be used while #{SUCCESS}" unless success? - - @reason = reason - @metadata = metadata - - throw(:cmdx_halt) if halt - end - - # @param reason [String, nil] Reason for skipping the task - # @param halt [Boolean] Whether to halt execution after skipping - # @param cause [Exception, nil] Exception that caused the skip - # @param strict [Boolean] Whether this skip is strict (default: true). - # When false, {CMDx::Executor#halt_execution?} returns false regardless of task settings. - # @param metadata [Hash] Additional metadata about the skip - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to skip from invalid status - # - # @example - # result.skip!("Dependencies not met", cause: dependency_error) - # result.skip!("Already processed", halt: false) - # result.skip!("Optional step", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def skip!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if skipped? - - raise "can only transition to #{SKIPPED} from #{SUCCESS}" unless success? - - @state = INTERRUPTED - @status = SKIPPED - @reason = reason || Locale.t("cmdx.reasons.unspecified") - @cause = cause - @strict = strict - @metadata = metadata - - halt! if halt - end - - # @param reason [String, nil] Reason for task failure - # @param halt [Boolean] Whether to halt execution after failure - # @param cause [Exception, nil] Exception that caused the failure - # @param strict [Boolean] Whether this failure is strict (default: true). - # When false, {CMDx::Executor#halt_execution?} returns false regardless of task settings. - # @param metadata [Hash] Additional metadata about the failure - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to fail from invalid status - # - # @example - # result.fail!("Validation failed", cause: validation_error) - # result.fail!("Network timeout", halt: false, timeout: 30) - # result.fail!("Soft failure", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def fail!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if failed? - - raise "can only transition to #{FAILED} from #{SUCCESS}" unless success? - - @state = INTERRUPTED - @status = FAILED - @reason = reason || Locale.t("cmdx.reasons.unspecified") - @cause = cause - @strict = strict - @metadata = metadata - - halt! if halt - end - - # @raise [SkipFault] When task was skipped - # @raise [FailFault] When task failed - # - # @example - # result.halt! # Raises appropriate fault based on status - # - # @rbs () -> void - def halt! - return if success? - - klass = skipped? ? SkipFault : FailFault - fault = klass.new(self) - - # Strip the first two frames (this method and the delegator) - frames = caller_locations(3..-1) - - unless frames.empty? - frames = frames.map(&:to_s) - - if (cleaner = task.class.settings.backtrace_cleaner) - cleaner.call(frames) - end - - fault.set_backtrace(frames) - end - - raise(fault) - end - - # @param result [CMDx::Result] Result to throw from current result - # @param halt [Boolean] Whether to halt execution after throwing - # @param cause [Exception, nil] Exception that caused the throw - # @param metadata [Hash] Additional metadata to merge - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [TypeError] When result is not a CMDx::Result instance - # - # @example - # other_result = OtherTask.execute - # result.throw!(other_result, cause: upstream_error) - # - # @rbs (Result result, halt: bool, cause: Exception?, **untyped metadata) -> void - def throw!(result, halt: true, cause: nil, **metadata) - raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) - - @state = result.state - @status = result.status - @reason = result.reason - @cause = cause || result.cause - @metadata = result.metadata.merge(metadata) - - halt! if halt - end - # @return [CMDx::Result, nil] The result that caused this failure, or nil # # @example @@ -500,24 +307,24 @@ def thrown_failure? failed? && !caused_failure? end - # @return [Boolean] Whether the result has been retried + # @return [Boolean] Whether the result transitions are strict # # @example - # result.retried? # => true + # result.strict? # => true # # @rbs () -> bool - def retried? - retries.positive? + def strict? + !!@strict end - # @return [Boolean] Whether the result is strict + # @return [Boolean] Whether the result has been retried # # @example - # result.strict? # => true + # result.retried? # => true # # @rbs () -> bool - def strict? - !!@strict + def retried? + retries.positive? end # @return [Boolean] Whether the result has been rolled back @@ -620,12 +427,12 @@ def deconstruct(*) # @rbs (*untyped) -> Hash[Symbol, untyped] def deconstruct_keys(*) { - state: state, - status: status, - reason: reason, - cause: cause, - metadata: metadata, - outcome: outcome, + state:, + status:, + reason:, + cause:, + metadata:, + outcome:, executed: executed?, good: good?, bad: bad? diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index bd6d6e772..0dde605db 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -70,7 +70,17 @@ class Task # @rbs @chain: Chain attr_reader :chain - def_delegators :result, :success!, :skip!, :fail!, :throw! + # Returns the resolver for managing result state transitions. + # + # @return [Resolver] The resolver instance + # + # @example + # task.resolver.fail!("Validation failed") + # + # @rbs @resolver: Resolver + attr_reader :resolver + + def_delegators :resolver, :success!, :skip!, :fail!, :throw! def_delegators :chain, :dry_run? # @param context [Hash, Context, nil] The initial context for the task @@ -92,6 +102,7 @@ def initialize(context = nil) @context = Context.build(context) @errors = Errors.new @result = Result.new(self) + @resolver = Resolver.new(@result) @chain = Chain.build(@result, dry_run: @context.delete(:dry_run)) @attributes = {} diff --git a/spec/cmdx/executor_spec.rb b/spec/cmdx/executor_spec.rb index 561fdc228..9bfadfd42 100644 --- a/spec/cmdx/executor_spec.rb +++ b/spec/cmdx/executor_spec.rb @@ -66,8 +66,8 @@ # Setup result state to support proper transitions allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.result).to receive(:complete!) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:complete!) + allow(task.resolver).to receive(:executed!) end context "when execution is successful" do @@ -75,7 +75,7 @@ expect(middlewares).to receive(:call!).with(task).and_yield expect(worker).to receive(:pre_execution!) expect(worker).to receive(:execution!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -87,7 +87,7 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) expect(logger).to receive(:info) @@ -121,9 +121,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - expect(task.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) + expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) worker.execute @@ -134,9 +134,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - allow(task.result).to receive(:throw!) + allow(task.resolver).to receive(:throw!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -153,9 +153,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(standard_error) - expect(task.result).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) + expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) worker.execute @@ -166,9 +166,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(standard_error) - allow(task.result).to receive(:fail!) + allow(task.resolver).to receive(:fail!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -185,9 +185,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(custom_error) - expect(task.result).to receive(:fail!).with("[CMDx::TestError] test error", halt: false, cause: custom_error, source: :exception) + expect(task.resolver).to receive(:fail!).with("[CMDx::TestError] test error", halt: false, cause: custom_error, source: :exception) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) worker.execute @@ -209,8 +209,8 @@ # Setup result state to support proper transitions allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.result).to receive(:complete!) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:complete!) + allow(task.resolver).to receive(:executed!) end context "when execution is successful" do @@ -218,7 +218,7 @@ expect(middlewares).to receive(:call!).with(task).and_yield expect(worker).to receive(:pre_execution!) expect(worker).to receive(:execution!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -252,8 +252,8 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - expect(task.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:halt_execution?).with(fault).and_return(false) expect(worker).to receive(:post_execution!) @@ -268,7 +268,7 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - expect(task.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) + expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) expect(worker).to receive(:halt_execution?).with(fault).and_return(true) expect(worker).to receive(:raise_exception).with(fault).and_raise(fault) @@ -286,7 +286,7 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(standard_error) - expect(task.result).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) + expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) expect(worker).to receive(:raise_exception).with(standard_error).and_raise(standard_error) expect { worker.execute! }.to raise_error(standard_error) @@ -295,12 +295,13 @@ end describe "#halt_execution?" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", strict?: true, reason: "test failure") } + let(:fault_resolver) { instance_double(CMDx::Resolver) } + let(:fault_task) { instance_double(CMDx::Task, resolver: fault_resolver) } + let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: true) } let(:fault) { CMDx::FailFault.new(fault_result) } - context "when result has strict: false" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", strict?: false, reason: "test failure") } - let(:fault) { CMDx::FailFault.new(fault_result) } + context "when resolver has strict: false" do + let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: false) } before do allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: %w[failed skipped])) @@ -323,7 +324,9 @@ end context "when exception result status is not in breakpoints" do - let(:success_result) { instance_double(CMDx::Result, status: "success", strict?: true, reason: "test success") } + let(:success_resolver) { instance_double(CMDx::Resolver) } + let(:success_task) { instance_double(CMDx::Task, resolver: success_resolver) } + let(:success_result) { instance_double(CMDx::Result, status: "success", task: success_task, reason: "test success", strict?: true) } let(:success_fault) { CMDx::SkipFault.new(success_result) } it "returns false" do @@ -686,11 +689,11 @@ def call(_task, retries) to_s: "Validation failed", to_h: { name: ["is required"] } ) - allow(task.result).to receive(:fail!) + allow(task.resolver).to receive(:fail!) end it "calls fail! on result with error information" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( "Invalid", source: :validation, errors: { @@ -710,13 +713,13 @@ def call(_task, retries) before do allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) allow(callbacks).to receive(:invoke) - allow(task.result).to receive(:executing!) + allow(task.resolver).to receive(:executing!) allow(task).to receive(:work) end it "invokes before_execution callback, sets executing state, and calls work" do expect(callbacks).to receive(:invoke).with(:before_execution, task) - expect(task.result).to receive(:executing!) + expect(task.resolver).to receive(:executing!) expect(task).to receive(:work) worker.send(:execution!) @@ -788,7 +791,7 @@ def call(_task, retries) end it "adds errors for missing returns and fails the result" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( "Invalid", source: :context, errors: { @@ -808,7 +811,7 @@ def call(_task, retries) end it "adds errors for all missing returns and fails the result" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( "Invalid", source: :context, errors: { @@ -1075,12 +1078,12 @@ def call(_task, retries) end it "marks result as failed and transitions to executed" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( CMDx::Locale.t("cmdx.faults.invalid"), halt: false, source: :middleware ) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) worker.send(:verify_middleware_yield!) end @@ -1092,8 +1095,8 @@ def call(_task, retries) end it "does nothing" do - expect(task.result).not_to receive(:fail!) - expect(task.result).not_to receive(:executed!) + expect(task.resolver).not_to receive(:fail!) + expect(task.resolver).not_to receive(:executed!) worker.send(:verify_middleware_yield!) end diff --git a/spec/cmdx/faults_spec.rb b/spec/cmdx/faults_spec.rb index 5e0a3fdc2..1572309a5 100644 --- a/spec/cmdx/faults_spec.rb +++ b/spec/cmdx/faults_spec.rb @@ -6,9 +6,8 @@ let(:task_class) { create_successful_task(name: "TestTask") } let(:task) { task_class.new } let(:result) do - task.result.tap do |r| - r.fail!("test failure reason", halt: false) - end + task.resolver.fail!("test failure reason", halt: false) + task.result end describe "#initialize" do @@ -110,10 +109,8 @@ describe ".matches?" do let(:fault_with_metadata) do - result_with_metadata = task.result.tap do |r| - r.fail!("failure", halt: false, metadata: { error_code: 500 }) - end - described_class.new(result_with_metadata) + task.resolver.fail!("failure", halt: false, metadata: { error_code: 500 }) + described_class.new(task.result) end context "when block is provided" do diff --git a/spec/cmdx/middlewares/timeout_spec.rb b/spec/cmdx/middlewares/timeout_spec.rb index 4c91e157c..2f5543479 100644 --- a/spec/cmdx/middlewares/timeout_spec.rb +++ b/spec/cmdx/middlewares/timeout_spec.rb @@ -5,14 +5,14 @@ RSpec.describe CMDx::Middlewares::Timeout, type: :unit do subject(:timeout_middleware) { described_class } - let(:task) { double("CMDx::Task", result: result) } # rubocop:disable RSpec/VerifiedDoubles + let(:task) { double("CMDx::Task", result: result, resolver: resolver) } # rubocop:disable RSpec/VerifiedDoubles let(:result) { instance_double(CMDx::Result) } + let(:resolver) { instance_double(CMDx::Resolver) } let(:block_result) { "block executed" } let(:test_block) { proc { block_result } } before do - allow(result).to receive(:fail!) - allow(result).to receive(:tap).and_return(result) + allow(resolver).to receive(:fail!) end describe ".call" do @@ -191,7 +191,7 @@ it "re-raises non-timeout errors without calling fail!" do expect(Timeout).to receive(:timeout).and_yield.and_raise(standard_error) - expect(result).not_to receive(:fail!) + expect(resolver).not_to receive(:fail!) expect do timeout_middleware.call(task, seconds: 5, &error_block) diff --git a/spec/cmdx/resolver_spec.rb b/spec/cmdx/resolver_spec.rb new file mode 100644 index 000000000..bcaf7bdc1 --- /dev/null +++ b/spec/cmdx/resolver_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Resolver, type: :unit do + let(:task_class) { create_successful_task(name: "TestTask") } + let(:task) { task_class.new } + let(:result) { task.result } + let(:resolver) { task.resolver } + + describe "#initialize" do + context "with valid result" do + it "initializes with correct defaults" do + expect(resolver.result).to eq(result) + expect(result.strict?).to be(true) + end + end + + context "with invalid result" do + it "raises TypeError when result is not a CMDx::Result" do + expect { described_class.new("not a result") }.to raise_error(TypeError, "must be a CMDx::Result") + end + end + end + + describe "#success!" do + context "when successful" do + it "sets the reason" do + catch(:cmdx_halt) { resolver.success!("Created 42 records") } + + expect(result.status).to eq(CMDx::Result::SUCCESS) + expect(result.reason).to eq("Created 42 records") + expect(result.metadata).to eq({}) + end + + it "accepts metadata" do + catch(:cmdx_halt) { resolver.success!("Imported", rows: 100) } + + expect(result.reason).to eq("Imported") + expect(result.metadata).to eq({ rows: 100 }) + end + + it "allows nil reason" do + catch(:cmdx_halt) { resolver.success! } + + expect(result.reason).to be_nil + end + + it "does not change state or status" do + original_state = result.state + original_status = result.status + catch(:cmdx_halt) { resolver.success!("note") } + + expect(result.state).to eq(original_state) + expect(result.status).to eq(original_status) + end + + it "throws :cmdx_halt by default" do + expect { resolver.success!("done") }.to throw_symbol(:cmdx_halt) + end + + it "does not throw when halt is false" do + expect { resolver.success!("done", halt: false) }.not_to throw_symbol(:cmdx_halt) + end + end + + context "when not successful" do + it "raises error when skipped" do + resolver.skip!("test", halt: false) + + expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) + end + + it "raises error when failed" do + resolver.fail!("test", halt: false) + + expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) + end + end + end + + describe "#skip!" do + context "when successful" do + it "transitions to skipped status" do + resolver.skip!("test reason", halt: false) + + expect(result.status).to eq(CMDx::Result::SKIPPED) + expect(result.state).to eq(CMDx::Result::INTERRUPTED) + expect(result.reason).to eq("test reason") + expect(result.cause).to be_nil + expect(result.metadata).to eq({}) + end + + it "accepts metadata" do + resolver.skip!("test reason", halt: false, foo: "bar") + + expect(result.metadata).to eq({ foo: "bar" }) + end + + it "accepts cause" do + cause = StandardError.new("cause") + resolver.skip!("test reason", halt: false, cause: cause) + + expect(result.cause).to eq(cause) + end + + it "uses default reason when none provided" do + allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") + + resolver.skip!(halt: false) + + expect(result.reason).to eq("Unspecified") + end + + it "calls halt! by default" do + expect { resolver.skip!("test reason") }.to raise_error(CMDx::SkipFault) + end + + it "does not call halt! when halt: false" do + expect { resolver.skip!("test reason", halt: false) }.not_to raise_error + end + end + + context "when already skipped" do + it "returns early without changes" do + resolver.skip!("first reason", halt: false) + original_reason = result.reason + resolver.skip!("second reason", halt: false) + + expect(result.reason).to eq(original_reason) + end + end + + context "when not successful" do + it "raises error when trying to skip from failed" do + resolver.fail!("test reason", halt: false) + + expect { resolver.skip!("another reason", halt: false) }.to raise_error(/can only transition to skipped from success/) + end + end + end + + describe "#fail!" do + context "when successful" do + it "transitions to failed status" do + resolver.fail!("test reason", halt: false) + + expect(result.status).to eq(CMDx::Result::FAILED) + expect(result.state).to eq(CMDx::Result::INTERRUPTED) + expect(result.reason).to eq("test reason") + expect(result.cause).to be_nil + expect(result.metadata).to eq({}) + end + + it "accepts metadata" do + resolver.fail!("test reason", halt: false, foo: "bar") + + expect(result.metadata).to eq({ foo: "bar" }) + end + + it "accepts cause" do + cause = StandardError.new("cause") + resolver.fail!("test reason", halt: false, cause: cause) + + expect(result.cause).to eq(cause) + end + + it "uses default reason when none provided" do + allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") + + resolver.fail!(halt: false) + + expect(result.reason).to eq("Unspecified") + end + + it "calls halt! by default" do + expect { resolver.fail!("test reason") }.to raise_error(CMDx::FailFault) + end + + it "does not call halt! when halt: false" do + expect { resolver.fail!("test reason", halt: false) }.not_to raise_error + end + end + + context "when already failed" do + it "returns early without changes" do + resolver.fail!("first reason", halt: false) + original_reason = result.reason + resolver.fail!("second reason", halt: false) + + expect(result.reason).to eq(original_reason) + end + end + + context "when not successful" do + it "raises error when trying to fail from skipped" do + resolver.skip!("test reason", halt: false) + + expect { resolver.fail!("another reason", halt: false) }.to raise_error(/can only transition to failed from success/) + end + end + end + + describe "#halt!" do + context "when successful" do + it "returns early without raising" do + expect { resolver.halt! }.not_to raise_error + end + end + + context "when skipped" do + it "raises SkipFault" do + resolver.skip!("test reason", halt: false) + + expect { resolver.halt! }.to raise_error(CMDx::SkipFault) do |fault| + expect(fault.result).to eq(result) + expect(fault.message).to eq("test reason") + end + end + + it "sets proper backtrace" do + resolver.skip!("test reason", halt: false) + + begin + resolver.halt! + rescue CMDx::SkipFault => e + expect(e.backtrace).to be_an(Array) + expect(e.backtrace).not_to be_empty + end + end + end + + context "when failed" do + it "raises FailFault" do + resolver.fail!("test reason", halt: false) + + expect { resolver.halt! }.to raise_error(CMDx::FailFault) do |fault| + expect(fault.result).to eq(result) + expect(fault.message).to eq("test reason") + end + end + end + end + + describe "#throw!" do + let(:other_task) { create_failing_task.new } + let(:other_result) { other_task.result } + + before do + other_task.resolver.fail!("source failure", halt: false, foo: "bar") + end + + context "with valid result" do + it "copies state and status from other result" do + resolver.throw!(other_result, halt: false) + + expect(result.state).to eq(other_result.state) + expect(result.status).to eq(other_result.status) + expect(result.reason).to eq(other_result.reason) + end + + it "merges metadata" do + resolver.throw!(other_result, halt: false, baz: "qux") + + expect(result.metadata).to eq({ foo: "bar", baz: "qux" }) + end + + it "uses provided cause over other result's cause" do + custom_cause = StandardError.new("custom") + + resolver.throw!(other_result, halt: false, cause: custom_cause) + expect(result.cause).to eq(custom_cause) + end + + it "uses other result's cause when none provided" do + other_cause = StandardError.new("other") + other_result.instance_variable_set(:@cause, other_cause) + resolver.throw!(other_result, halt: false) + + expect(result.cause).to eq(other_cause) + end + + it "calls halt! by default" do + expect { resolver.throw!(other_result) }.to raise_error(CMDx::FailFault) + end + + it "does not call halt! when halt: false" do + expect { resolver.throw!(other_result, halt: false) }.not_to raise_error + end + end + + context "with invalid result" do + it "raises TypeError when not a CMDx::Result" do + expect { resolver.throw!("not a result", halt: false) }.to raise_error(TypeError, "must be a CMDx::Result") + end + end + end + + describe "#strict?" do + it "returns true by default" do + expect(result.strict?).to be(true) + end + + it "returns false when strict is false via fail!" do + resolver.fail!("test reason", halt: false, strict: false) + + expect(result.strict?).to be(false) + end + + it "returns false when strict is false via skip!" do + resolver.skip!("test reason", halt: false, strict: false) + + expect(result.strict?).to be(false) + end + end +end diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb index ad801e62b..e06438848 100644 --- a/spec/cmdx/result_spec.rb +++ b/spec/cmdx/result_spec.rb @@ -6,6 +6,7 @@ let(:task_class) { create_successful_task(name: "TestTask") } let(:task) { task_class.new } let(:result) { task.result } + let(:resolver) { task.resolver } describe "#initialize" do context "with valid task" do @@ -38,7 +39,7 @@ end it "returns false when state is not initialized" do - result.executing! + resolver.executing! expect(result.initialized?).to be(false) end @@ -50,7 +51,7 @@ end it "returns true when state is executing" do - result.executing! + resolver.executing! expect(result.executing?).to be(true) end @@ -62,8 +63,8 @@ end it "returns true when state is complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! expect(result.complete?).to be(true) end @@ -75,8 +76,8 @@ end it "returns true when state is interrupted" do - result.executing! - result.interrupt! + resolver.executing! + resolver.interrupt! expect(result.interrupted?).to be(true) end @@ -87,15 +88,15 @@ describe "#executing!" do context "when initialized" do it "transitions to executing state" do - result.executing! + resolver.executing! expect(result.state).to eq(CMDx::Result::EXECUTING) end it "returns early if already executing" do - result.executing! + resolver.executing! initial_state = result.state - result.executing! + resolver.executing! expect(result.state).to eq(initial_state) end @@ -103,35 +104,35 @@ context "when not initialized" do it "raises error when trying to transition from complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! - expect { result.executing! }.to raise_error(/can only transition to executing from initialized/) + expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) end it "raises error when trying to transition from interrupted" do - result.executing! - result.interrupt! + resolver.executing! + resolver.interrupt! - expect { result.executing! }.to raise_error(/can only transition to executing from initialized/) + expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) end end end describe "#complete!" do context "when executing" do - before { result.executing! } + before { resolver.executing! } it "transitions to complete state" do - result.complete! + resolver.complete! expect(result.state).to eq(CMDx::Result::COMPLETE) end it "returns early if already complete" do - result.complete! + resolver.complete! initial_state = result.state - result.complete! + resolver.complete! expect(result.state).to eq(initial_state) end @@ -139,7 +140,7 @@ context "when not executing" do it "raises error when trying to transition from initialized" do - expect { result.complete! }.to raise_error(/can only transition to complete from executing/) + expect { resolver.complete! }.to raise_error(/can only transition to complete from executing/) end end end @@ -147,22 +148,22 @@ describe "#interrupt!" do context "when not complete" do it "transitions to interrupted from initialized" do - result.interrupt! + resolver.interrupt! expect(result.state).to eq(CMDx::Result::INTERRUPTED) end it "transitions to interrupted from executing" do - result.executing! - result.interrupt! + resolver.executing! + resolver.interrupt! expect(result.state).to eq(CMDx::Result::INTERRUPTED) end it "returns early if already interrupted" do - result.interrupt! + resolver.interrupt! initial_state = result.state - result.interrupt! + resolver.interrupt! expect(result.state).to eq(initial_state) end @@ -170,10 +171,10 @@ context "when complete" do it "raises error when trying to transition from complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! - expect { result.interrupt! }.to raise_error(/cannot transition to interrupted from complete/) + expect { resolver.interrupt! }.to raise_error(/cannot transition to interrupted from complete/) end end end @@ -186,13 +187,13 @@ end it "returns false after skip!" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.success?).to be(false) end it "returns false after fail!" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.success?).to be(false) end @@ -204,7 +205,7 @@ end it "returns true after skip!" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.skipped?).to be(true) end @@ -216,7 +217,7 @@ end it "returns true after fail!" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.failed?).to be(true) end @@ -228,13 +229,13 @@ end it "returns true when skipped" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.good?).to be(true) end it "returns false when failed" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.good?).to be(false) end @@ -246,30 +247,18 @@ end it "returns true when skipped" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.bad?).to be(true) end it "returns true when failed" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.bad?).to be(true) end end - describe "#strict?" do - it "returns true by default" do - expect(result.strict?).to be(true) - end - - it "returns false when strict is false" do - result.fail!("test reason", halt: false, strict: false) - - expect(result.strict?).to be(false) - end - end - describe "#rolled_back?" do it "returns false by default" do expect(result.rolled_back?).to be(false) @@ -299,8 +288,8 @@ describe "#executed!" do context "when successful" do it "calls complete!" do - result.executing! - result.executed! + resolver.executing! + resolver.executed! expect(result.complete?).to be(true) end @@ -308,15 +297,15 @@ context "when not successful" do it "calls interrupt! when skipped" do - result.skip!("test reason", halt: false) - result.executed! + resolver.skip!("test reason", halt: false) + resolver.executed! expect(result.interrupted?).to be(true) end it "calls interrupt! when failed" do - result.fail!("test reason", halt: false) - result.executed! + resolver.fail!("test reason", halt: false) + resolver.executed! expect(result.interrupted?).to be(true) end @@ -329,14 +318,14 @@ end it "returns true when complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! expect(result.executed?).to be(true) end it "returns true when interrupted" do - result.interrupt! + resolver.interrupt! expect(result.executed?).to be(true) end @@ -348,7 +337,7 @@ end it "calls block when executed" do - result.interrupt! + resolver.interrupt! called = false result.on(:executed) { |_r| called = true } @@ -368,279 +357,6 @@ end end - describe "#success!" do - context "when successful" do - it "sets the reason" do - catch(:cmdx_halt) { result.success!("Created 42 records") } - - expect(result.status).to eq(CMDx::Result::SUCCESS) - expect(result.reason).to eq("Created 42 records") - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - catch(:cmdx_halt) { result.success!("Imported", rows: 100) } - - expect(result.reason).to eq("Imported") - expect(result.metadata).to eq({ rows: 100 }) - end - - it "allows nil reason" do - catch(:cmdx_halt) { result.success! } - - expect(result.reason).to be_nil - end - - it "does not change state or status" do - original_state = result.state - original_status = result.status - catch(:cmdx_halt) { result.success!("note") } - - expect(result.state).to eq(original_state) - expect(result.status).to eq(original_status) - end - - it "throws :cmdx_halt by default" do - expect { result.success!("done") }.to throw_symbol(:cmdx_halt) - end - - it "does not throw when halt is false" do - expect { result.success!("done", halt: false) }.not_to throw_symbol(:cmdx_halt) - end - end - - context "when not successful" do - it "raises error when skipped" do - result.skip!("test", halt: false) - - expect { result.success!("reason") }.to raise_error(/can only be used while success/) - end - - it "raises error when failed" do - result.fail!("test", halt: false) - - expect { result.success!("reason") }.to raise_error(/can only be used while success/) - end - end - end - - describe "#skip!" do - context "when successful" do - it "transitions to skipped status" do - result.skip!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::SKIPPED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - result.skip!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - result.skip!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - result.skip!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { result.skip!("test reason") }.to raise_error(CMDx::SkipFault) - end - - it "does not call halt! when halt: false" do - expect { result.skip!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already skipped" do - it "returns early without changes" do - result.skip!("first reason", halt: false) - original_reason = result.reason - result.skip!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to skip from failed" do - result.fail!("test reason", halt: false) - - expect { result.skip!("another reason", halt: false) }.to raise_error(/can only transition to skipped from success/) - end - end - end - - describe "#fail!" do - context "when successful" do - it "transitions to failed status" do - result.fail!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::FAILED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - result.fail!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - result.fail!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - result.fail!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { result.fail!("test reason") }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { result.fail!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already failed" do - it "returns early without changes" do - result.fail!("first reason", halt: false) - original_reason = result.reason - result.fail!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to fail from skipped" do - result.skip!("test reason", halt: false) - - expect { result.fail!("another reason", halt: false) }.to raise_error(/can only transition to failed from success/) - end - end - end - - describe "#halt!" do - context "when successful" do - it "returns early without raising" do - expect { result.halt! }.not_to raise_error - end - end - - context "when skipped" do - it "raises SkipFault" do - result.skip!("test reason", halt: false) - - expect { result.halt! }.to raise_error(CMDx::SkipFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - - it "sets proper backtrace" do - result.skip!("test reason", halt: false) - - begin - result.halt! - rescue CMDx::SkipFault => e - expect(e.backtrace).to be_an(Array) - expect(e.backtrace).not_to be_empty - end - end - end - - context "when failed" do - it "raises FailFault" do - result.fail!("test reason", halt: false) - - expect { result.halt! }.to raise_error(CMDx::FailFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - end - end - - describe "#throw!" do - let(:other_task) { create_failing_task.new } - let(:other_result) { other_task.result } - - before do - other_result.fail!("source failure", halt: false, foo: "bar") - end - - context "with valid result" do - it "copies state and status from other result" do - result.throw!(other_result, halt: false) - - expect(result.state).to eq(other_result.state) - expect(result.status).to eq(other_result.status) - expect(result.reason).to eq(other_result.reason) - end - - it "merges metadata" do - result.throw!(other_result, halt: false, baz: "qux") - - expect(result.metadata).to eq({ foo: "bar", baz: "qux" }) - end - - it "uses provided cause over other result's cause" do - custom_cause = StandardError.new("custom") - - result.throw!(other_result, halt: false, cause: custom_cause) - expect(result.cause).to eq(custom_cause) - end - - it "uses other result's cause when none provided" do - other_cause = StandardError.new("other") - other_result.instance_variable_set(:@cause, other_cause) - result.throw!(other_result, halt: false) - - expect(result.cause).to eq(other_cause) - end - - it "calls halt! by default" do - expect { result.throw!(other_result) }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { result.throw!(other_result, halt: false) }.not_to raise_error - end - end - - context "with invalid result" do - it "raises TypeError when not a CMDx::Result" do - expect { result.throw!("not a result", halt: false) }.to raise_error(TypeError, "must be a CMDx::Result") - end - end - end - describe "failure tracking methods" do let(:chain) { CMDx::Chain.new } let(:first_task) { create_successful_task.new } @@ -657,7 +373,7 @@ r.instance_variable_set(:@chain, chain) end - second_result.fail!("test failure", halt: false) + second_task.resolver.fail!("test failure", halt: false) end describe "#caused_failure" do @@ -697,7 +413,7 @@ chain.results << fourth_result fourth_result.instance_variable_set(:@chain, chain) - fourth_result.fail!("another failure", halt: false) + fourth_task.resolver.fail!("another failure", halt: false) end it "returns the next failed result after current" do @@ -767,7 +483,7 @@ context "when not initialized and not thrown failure" do it "returns status" do - result.executing! + resolver.executing! expect(result.outcome).to eq(result.status) end @@ -795,7 +511,7 @@ context "when successful with reason" do it "includes reason without cause or rolled_back" do - catch(:cmdx_halt) { result.success!("Created 42 records") } + catch(:cmdx_halt) { resolver.success!("Created 42 records") } hash = result.to_h @@ -806,7 +522,7 @@ context "when interrupted" do it "includes reason, cause and rolled_back status" do - result.skip!("test reason", halt: false, cause: StandardError.new("test")) + resolver.skip!("test reason", halt: false, cause: StandardError.new("test")) hash = result.to_h @@ -818,7 +534,7 @@ context "when failed" do it "includes failure information" do - result.fail!("test failure", halt: false) + resolver.fail!("test failure", halt: false) # Create mock objects that avoid calling to_h to prevent infinite recursion threw_failure_mock = instance_double(described_class, to_h: { index: 1, class: "Test", id: "123" }) @@ -913,12 +629,12 @@ when CMDx::Result::INITIALIZED # Already in initialized state when CMDx::Result::EXECUTING - result.executing! + resolver.executing! when CMDx::Result::COMPLETE - result.executing! - result.complete! + resolver.executing! + resolver.complete! when CMDx::Result::INTERRUPTED - result.interrupt! + resolver.interrupt! end end @@ -941,7 +657,7 @@ before do case state when CMDx::Result::INITIALIZED - result.executing! + resolver.executing! when CMDx::Result::EXECUTING # Stay in initialized state when CMDx::Result::COMPLETE @@ -979,9 +695,9 @@ when CMDx::Result::SUCCESS # Already in success status when CMDx::Result::SKIPPED - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) when CMDx::Result::FAILED - result.fail!("test", halt: false) + resolver.fail!("test", halt: false) end end @@ -1004,7 +720,7 @@ before do case status when CMDx::Result::SUCCESS - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) when CMDx::Result::SKIPPED # Stay in success status when CMDx::Result::FAILED @@ -1041,7 +757,7 @@ end it "calls the block for skipped" do - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) called = false result.on(:good) { |_r| called = true } @@ -1051,7 +767,7 @@ context "when not good" do it "does not call the block for failed" do - result.fail!("test", halt: false) + resolver.fail!("test", halt: false) called = false result.on(:good) { |_r| called = true } @@ -1071,7 +787,7 @@ context "when bad" do it "calls the block for skipped" do - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) called = false result.on(:bad) { |_r| called = true } @@ -1079,7 +795,7 @@ end it "calls the block for failed" do - result.fail!("test", halt: false) + resolver.fail!("test", halt: false) called = false result.on(:bad) { |_r| called = true } diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb index caded1fad..0b01f311c 100644 --- a/spec/cmdx/task_spec.rb +++ b/spec/cmdx/task_spec.rb @@ -75,26 +75,26 @@ end describe "delegated methods" do - it "delegates success! to result" do - expect(task.result).to receive(:success!).with("reason", metadata: "data") + it "delegates success! to resolver" do + expect(task.resolver).to receive(:success!).with("reason", metadata: "data") task.success!("reason", metadata: "data") end - it "delegates skip! to result" do - expect(task.result).to receive(:skip!).with("reason", metadata: "data") + it "delegates skip! to resolver" do + expect(task.resolver).to receive(:skip!).with("reason", metadata: "data") task.skip!("reason", metadata: "data") end - it "delegates fail! to result" do - expect(task.result).to receive(:fail!).with("reason", metadata: "data") + it "delegates fail! to resolver" do + expect(task.resolver).to receive(:fail!).with("reason", metadata: "data") task.fail!("reason", metadata: "data") end - it "delegates throw! to result" do - expect(task.result).to receive(:throw!).with("reason", metadata: "data") + it "delegates throw! to resolver" do + expect(task.resolver).to receive(:throw!).with("reason", metadata: "data") task.throw!("reason", metadata: "data") end From 877354a6d23ce2fae1ce58ff1e6a1cb6c77cf390 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Fri, 10 Apr 2026 00:08:23 -0400 Subject: [PATCH 2/4] Remove all old code --- CHANGELOG.md | 298 ----- lib/cmdx/attribute.rb | 440 ------- lib/cmdx/attribute_registry.rb | 185 --- lib/cmdx/attribute_value.rb | 252 ---- lib/cmdx/callback_registry.rb | 169 --- lib/cmdx/chain.rb | 216 --- lib/cmdx/coercion_registry.rb | 138 -- lib/cmdx/coercions/array.rb | 49 - lib/cmdx/coercions/big_decimal.rb | 43 - lib/cmdx/coercions/boolean.rb | 58 - lib/cmdx/coercions/complex.rb | 41 - lib/cmdx/coercions/date.rb | 47 - lib/cmdx/coercions/date_time.rb | 47 - lib/cmdx/coercions/float.rb | 44 - lib/cmdx/coercions/hash.rb | 69 - lib/cmdx/coercions/integer.rb | 47 - lib/cmdx/coercions/rational.rb | 47 - lib/cmdx/coercions/string.rb | 38 - lib/cmdx/coercions/symbol.rb | 40 - lib/cmdx/coercions/time.rb | 49 - lib/cmdx/configuration.rb | 280 ---- lib/cmdx/context.rb | 310 ----- lib/cmdx/deprecator.rb | 77 -- lib/cmdx/errors.rb | 117 -- lib/cmdx/exception.rb | 46 - lib/cmdx/executor.rb | 379 ------ lib/cmdx/fault.rb | 109 -- lib/cmdx/identifier.rb | 30 - lib/cmdx/locale.rb | 78 -- lib/cmdx/log_formatters/json.rb | 40 - lib/cmdx/log_formatters/key_value.rb | 40 - lib/cmdx/log_formatters/line.rb | 32 - lib/cmdx/log_formatters/logstash.rb | 42 - lib/cmdx/log_formatters/raw.rb | 33 - lib/cmdx/middleware_registry.rb | 148 --- lib/cmdx/middlewares/correlate.rb | 140 -- lib/cmdx/middlewares/runtime.rb | 77 -- lib/cmdx/middlewares/timeout.rb | 76 -- lib/cmdx/parallelizer.rb | 100 -- lib/cmdx/pipeline.rb | 169 --- lib/cmdx/railtie.rb | 49 - lib/cmdx/resolver.rb | 263 ---- lib/cmdx/result.rb | 443 ------- lib/cmdx/retry.rb | 166 --- lib/cmdx/settings.rb | 226 ---- lib/cmdx/task.rb | 430 ------ lib/cmdx/utils/call.rb | 53 - lib/cmdx/utils/condition.rb | 71 - lib/cmdx/utils/format.rb | 82 -- lib/cmdx/utils/normalize.rb | 52 - lib/cmdx/utils/wrap.rb | 38 - lib/cmdx/validator_registry.rb | 143 -- lib/cmdx/validators/absence.rb | 61 - lib/cmdx/validators/exclusion.rb | 81 -- lib/cmdx/validators/format.rb | 68 - lib/cmdx/validators/inclusion.rb | 83 -- lib/cmdx/validators/length.rb | 185 --- lib/cmdx/validators/numeric.rb | 180 --- lib/cmdx/validators/presence.rb | 61 - lib/cmdx/workflow.rb | 135 -- spec/cmdx/attribute_registry_spec.rb | 335 ----- spec/cmdx/attribute_spec.rb | 707 ---------- spec/cmdx/attribute_value_spec.rb | 763 ----------- spec/cmdx/callback_registry_spec.rb | 396 ------ spec/cmdx/chain_spec.rb | 355 ----- spec/cmdx/coercion_registry_spec.rb | 290 ---- spec/cmdx/coercions/array_spec.rb | 216 --- spec/cmdx/coercions/big_decimal_spec.rb | 168 --- spec/cmdx/coercions/boolean_spec.rb | 252 ---- spec/cmdx/coercions/complex_spec.rb | 220 ---- spec/cmdx/coercions/date_spec.rb | 231 ---- spec/cmdx/coercions/date_time_spec.rb | 180 --- spec/cmdx/coercions/float_spec.rb | 227 ---- spec/cmdx/coercions/hash_spec.rb | 287 ---- spec/cmdx/coercions/integer_spec.rb | 204 --- spec/cmdx/coercions/rational_spec.rb | 256 ---- spec/cmdx/coercions/string_spec.rb | 275 ---- spec/cmdx/coercions/symbol_spec.rb | 229 ---- spec/cmdx/coercions/time_spec.rb | 226 ---- spec/cmdx/configuration_spec.rb | 278 ---- spec/cmdx/context_spec.rb | 465 ------- spec/cmdx/deprecator_spec.rb | 177 --- spec/cmdx/errors_spec.rb | 425 ------ spec/cmdx/executor_spec.rb | 1162 ----------------- spec/cmdx/faults_spec.rb | 189 --- spec/cmdx/identifier_spec.rb | 49 - spec/cmdx/locale_spec.rb | 147 --- spec/cmdx/log_formatters/json_spec.rb | 243 ---- spec/cmdx/log_formatters/key_value_spec.rb | 385 ------ spec/cmdx/log_formatters/line_spec.rb | 285 ---- spec/cmdx/log_formatters/logstash_spec.rb | 254 ---- spec/cmdx/log_formatters/raw_spec.rb | 203 --- spec/cmdx/middleware_registry_spec.rb | 453 ------- spec/cmdx/middlewares/correlate_spec.rb | 444 ------- spec/cmdx/middlewares/runtime_spec.rb | 180 --- spec/cmdx/middlewares/timeout_spec.rb | 272 ---- spec/cmdx/parallelizer_spec.rb | 65 - spec/cmdx/pipeline_spec.rb | 389 ------ spec/cmdx/resolver_spec.rb | 316 ----- spec/cmdx/result_spec.rb | 851 ------------ spec/cmdx/retry_spec.rb | 360 ----- spec/cmdx/settings_spec.rb | 304 ----- spec/cmdx/task_spec.rb | 610 --------- spec/cmdx/utils/call_spec.rb | 424 ------ spec/cmdx/utils/condition_spec.rb | 444 ------- spec/cmdx/utils/format_spec.rb | 289 ---- spec/cmdx/utils/normalize_spec.rb | 94 -- spec/cmdx/utils/wrap_spec.rb | 60 - spec/cmdx/validator_registry_spec.rb | 433 ------ spec/cmdx/validators/absence_spec.rb | 154 --- spec/cmdx/validators/exclusion_spec.rb | 350 ----- spec/cmdx/validators/format_spec.rb | 279 ---- spec/cmdx/validators/inclusion_spec.rb | 359 ----- spec/cmdx/validators/length_spec.rb | 431 ------ spec/cmdx/validators/numeric_spec.rb | 379 ------ spec/cmdx/validators/presence_spec.rb | 165 --- spec/cmdx/workflow_spec.rb | 352 ----- spec/cmdx_spec.rb | 216 --- spec/integration/tasks/attributes_spec.rb | 482 ------- spec/integration/tasks/callbacks_spec.rb | 536 -------- spec/integration/tasks/execution_spec.rb | 815 ------------ spec/integration/tasks/handlers_spec.rb | 332 ----- spec/integration/tasks/middlewares_spec.rb | 56 - spec/integration/tasks/returns_spec.rb | 346 ----- .../integration/workflows/breakpoints_spec.rb | 190 --- .../workflows/conditionals_spec.rb | 184 --- spec/integration/workflows/execution_spec.rb | 105 -- spec/support/config/i18n.rb | 13 - spec/support/helpers/ruby_version.rb | 19 - spec/support/helpers/task_builders.rb | 110 -- spec/support/helpers/workflow_builders.rb | 69 - 131 files changed, 29519 deletions(-) delete mode 100644 lib/cmdx/attribute.rb delete mode 100644 lib/cmdx/attribute_registry.rb delete mode 100644 lib/cmdx/attribute_value.rb delete mode 100644 lib/cmdx/callback_registry.rb delete mode 100644 lib/cmdx/chain.rb delete mode 100644 lib/cmdx/coercion_registry.rb delete mode 100644 lib/cmdx/coercions/array.rb delete mode 100644 lib/cmdx/coercions/big_decimal.rb delete mode 100644 lib/cmdx/coercions/boolean.rb delete mode 100644 lib/cmdx/coercions/complex.rb delete mode 100644 lib/cmdx/coercions/date.rb delete mode 100644 lib/cmdx/coercions/date_time.rb delete mode 100644 lib/cmdx/coercions/float.rb delete mode 100644 lib/cmdx/coercions/hash.rb delete mode 100644 lib/cmdx/coercions/integer.rb delete mode 100644 lib/cmdx/coercions/rational.rb delete mode 100644 lib/cmdx/coercions/string.rb delete mode 100644 lib/cmdx/coercions/symbol.rb delete mode 100644 lib/cmdx/coercions/time.rb delete mode 100644 lib/cmdx/configuration.rb delete mode 100644 lib/cmdx/context.rb delete mode 100644 lib/cmdx/deprecator.rb delete mode 100644 lib/cmdx/errors.rb delete mode 100644 lib/cmdx/exception.rb delete mode 100644 lib/cmdx/executor.rb delete mode 100644 lib/cmdx/fault.rb delete mode 100644 lib/cmdx/identifier.rb delete mode 100644 lib/cmdx/locale.rb delete mode 100644 lib/cmdx/log_formatters/json.rb delete mode 100644 lib/cmdx/log_formatters/key_value.rb delete mode 100644 lib/cmdx/log_formatters/line.rb delete mode 100644 lib/cmdx/log_formatters/logstash.rb delete mode 100644 lib/cmdx/log_formatters/raw.rb delete mode 100644 lib/cmdx/middleware_registry.rb delete mode 100644 lib/cmdx/middlewares/correlate.rb delete mode 100644 lib/cmdx/middlewares/runtime.rb delete mode 100644 lib/cmdx/middlewares/timeout.rb delete mode 100644 lib/cmdx/parallelizer.rb delete mode 100644 lib/cmdx/pipeline.rb delete mode 100644 lib/cmdx/railtie.rb delete mode 100644 lib/cmdx/resolver.rb delete mode 100644 lib/cmdx/result.rb delete mode 100644 lib/cmdx/retry.rb delete mode 100644 lib/cmdx/settings.rb delete mode 100644 lib/cmdx/task.rb delete mode 100644 lib/cmdx/utils/call.rb delete mode 100644 lib/cmdx/utils/condition.rb delete mode 100644 lib/cmdx/utils/format.rb delete mode 100644 lib/cmdx/utils/normalize.rb delete mode 100644 lib/cmdx/utils/wrap.rb delete mode 100644 lib/cmdx/validator_registry.rb delete mode 100644 lib/cmdx/validators/absence.rb delete mode 100644 lib/cmdx/validators/exclusion.rb delete mode 100644 lib/cmdx/validators/format.rb delete mode 100644 lib/cmdx/validators/inclusion.rb delete mode 100644 lib/cmdx/validators/length.rb delete mode 100644 lib/cmdx/validators/numeric.rb delete mode 100644 lib/cmdx/validators/presence.rb delete mode 100644 lib/cmdx/workflow.rb delete mode 100644 spec/cmdx/attribute_registry_spec.rb delete mode 100644 spec/cmdx/attribute_spec.rb delete mode 100644 spec/cmdx/attribute_value_spec.rb delete mode 100644 spec/cmdx/callback_registry_spec.rb delete mode 100644 spec/cmdx/chain_spec.rb delete mode 100644 spec/cmdx/coercion_registry_spec.rb delete mode 100644 spec/cmdx/coercions/array_spec.rb delete mode 100644 spec/cmdx/coercions/big_decimal_spec.rb delete mode 100644 spec/cmdx/coercions/boolean_spec.rb delete mode 100644 spec/cmdx/coercions/complex_spec.rb delete mode 100644 spec/cmdx/coercions/date_spec.rb delete mode 100644 spec/cmdx/coercions/date_time_spec.rb delete mode 100644 spec/cmdx/coercions/float_spec.rb delete mode 100644 spec/cmdx/coercions/hash_spec.rb delete mode 100644 spec/cmdx/coercions/integer_spec.rb delete mode 100644 spec/cmdx/coercions/rational_spec.rb delete mode 100644 spec/cmdx/coercions/string_spec.rb delete mode 100644 spec/cmdx/coercions/symbol_spec.rb delete mode 100644 spec/cmdx/coercions/time_spec.rb delete mode 100644 spec/cmdx/configuration_spec.rb delete mode 100644 spec/cmdx/context_spec.rb delete mode 100644 spec/cmdx/deprecator_spec.rb delete mode 100644 spec/cmdx/errors_spec.rb delete mode 100644 spec/cmdx/executor_spec.rb delete mode 100644 spec/cmdx/faults_spec.rb delete mode 100644 spec/cmdx/identifier_spec.rb delete mode 100644 spec/cmdx/locale_spec.rb delete mode 100644 spec/cmdx/log_formatters/json_spec.rb delete mode 100644 spec/cmdx/log_formatters/key_value_spec.rb delete mode 100644 spec/cmdx/log_formatters/line_spec.rb delete mode 100644 spec/cmdx/log_formatters/logstash_spec.rb delete mode 100644 spec/cmdx/log_formatters/raw_spec.rb delete mode 100644 spec/cmdx/middleware_registry_spec.rb delete mode 100644 spec/cmdx/middlewares/correlate_spec.rb delete mode 100644 spec/cmdx/middlewares/runtime_spec.rb delete mode 100644 spec/cmdx/middlewares/timeout_spec.rb delete mode 100644 spec/cmdx/parallelizer_spec.rb delete mode 100644 spec/cmdx/pipeline_spec.rb delete mode 100644 spec/cmdx/resolver_spec.rb delete mode 100644 spec/cmdx/result_spec.rb delete mode 100644 spec/cmdx/retry_spec.rb delete mode 100644 spec/cmdx/settings_spec.rb delete mode 100644 spec/cmdx/task_spec.rb delete mode 100644 spec/cmdx/utils/call_spec.rb delete mode 100644 spec/cmdx/utils/condition_spec.rb delete mode 100644 spec/cmdx/utils/format_spec.rb delete mode 100644 spec/cmdx/utils/normalize_spec.rb delete mode 100644 spec/cmdx/utils/wrap_spec.rb delete mode 100644 spec/cmdx/validator_registry_spec.rb delete mode 100644 spec/cmdx/validators/absence_spec.rb delete mode 100644 spec/cmdx/validators/exclusion_spec.rb delete mode 100644 spec/cmdx/validators/format_spec.rb delete mode 100644 spec/cmdx/validators/inclusion_spec.rb delete mode 100644 spec/cmdx/validators/length_spec.rb delete mode 100644 spec/cmdx/validators/numeric_spec.rb delete mode 100644 spec/cmdx/validators/presence_spec.rb delete mode 100644 spec/cmdx/workflow_spec.rb delete mode 100644 spec/cmdx_spec.rb delete mode 100644 spec/integration/tasks/attributes_spec.rb delete mode 100644 spec/integration/tasks/callbacks_spec.rb delete mode 100644 spec/integration/tasks/execution_spec.rb delete mode 100644 spec/integration/tasks/handlers_spec.rb delete mode 100644 spec/integration/tasks/middlewares_spec.rb delete mode 100644 spec/integration/tasks/returns_spec.rb delete mode 100644 spec/integration/workflows/breakpoints_spec.rb delete mode 100644 spec/integration/workflows/conditionals_spec.rb delete mode 100644 spec/integration/workflows/execution_spec.rb delete mode 100644 spec/support/config/i18n.rb delete mode 100644 spec/support/helpers/ruby_version.rb delete mode 100644 spec/support/helpers/task_builders.rb delete mode 100644 spec/support/helpers/workflow_builders.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b473901..d593de3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,301 +5,3 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [UNRELEASED] - -### Added -- Add `CMDx::Resolver` class to manage result transitions - -## [1.21.0] - 2026-04-09 - -### Added -- Add `strict` option to `Result#skip!` and `Result#fail!` to bypass breakpoint halting in `execute!` -- Add `dump_context` global/local setting to include `context` in `Task#to_h` -- Add `Result#success!` for annotating successful results -- Add `started_at` and `ended_at` to runtime middleware payload -- Add `keys`, `values`, `each`, `each_key`, `each_value` methods to context -- Add `subtasks` to returns tasks defined in a workflow - -### Changed -- Move `faults.unspecified` locale key to `reasons.unspecified` - -## [1.20.0] - 2026-03-12 - -### Added -- Add `CallbackRegistry#empty?` for fast callback presence checking -- Add `Parallelizer` class for bounded thread pool execution -- Add `Configuration#default_locale` setting (defaults to `"en"`) -- Add `Task.type` to return task mechanics -- Add `Utils::Normalize` module for exception and status normalization -- Add `Utils::Wrap` module for array value normalization -- Add `Retry` class for retry logic, state tracking, and jitter computation -- Add `Settings` object with method-based access -- Add `freeze_results` configuration option to replace `SKIP_CMDX_FREEZING` env var -- Add `any?`, `clear`, and `size` delegators to `Errors` -- Add `Context#respond_to_missing?` for setter methods -- Add `Attribute#clear_task_tree!` to prevent stale task instance retention -- Add thread-safe `Chain#push` and `Chain#index` via `Mutex` -- Add identity-aware `Executor#clear_chain!` for parallel execution safety -- Add `Executor#verify_middleware_yield!` to detect non-yielding middlewares -- Add copy-on-write semantics to `MiddlewareRegistry`, `CallbackRegistry`, `CoercionRegistry`, and `ValidatorRegistry` -- Add `Attribute#allocation_name` for task-free reader name resolution -- Add `AttributeRegistry#define_readers_on!` and `#undefine_readers_on!` for eager reader definition - -### Changed -- Short-circuit `Executor#post_execution!` when callback registry is empty -- Optimize `Context#method_missing` to avoid `String` allocation on getter path -- Replace `parallel` gem with native `Parallelizer` thread pool -- Rename `in_threads` option to `pool_size` -- Move `TimeoutError` to `exception.rb` for Zeitwerk autoloading -- Update Rails initializer install script -- Dup attributes in `AttributeRegistry#define_and_verify` for thread-safe concurrent execution -- Default `retry_on` to `[StandardError, CMDx::TimeoutError]` -- Replace hash-based `settings[:]` with method-based `settings.` access -- Lazy-load locale translations instead of eager-loading -- Use compile-time method definition for `Identifier#generate` and `Chain`/`Correlate` `thread_or_fiber` -- Use `define_method` on task class for attribute readers -- Tighten `Deprecator` regex to exact word boundaries -- Use `public_send` instead of `send` in `Result` for state/status checks - -### Fixed -- Fix `Attribute#source` and `#method_name` memoization without a task -- Fix `execute!` to call `executed!` before `post_execution!` on non-halt path -- Clear `task.errors` before each retry attempt -- Fix `Pipeline#execute_tasks_in_parallel` to snapshot context per thread -- Reject `in_processes` and `in_reactors` options in parallel tasks - -### Removed -- Remove `SKIP_CMDX_FREEZING` env var in favor of `CMDx.configuration.freeze_results` - -## [1.19.0] - 2026-03-09 - -### Changed -- Fall back attribute `source` to `:context` when no task is given -- Improve falsy attribute derived hash value lookup -- Freeze chain results -- Use `to_date`, `to_time`, `to_datetime` for date/time coercion checks - -### Fixed -- Fix missing fault cause `NoMethodError` -- Fix validator `allow_nil` inverted logic -- Fix array coercion JSON parse error to return `CoercionError` -- Fix boolean coercions to return `false` for `nil` and `""` - -## [1.18.0] - 2026-03-09 - -### Changed -- Use `Fiber.storage` instead of `Thread.current` for `Chain` and `Correlate` storage, with fallback to `Thread.current` for Ruby < 3.2, making them thread and fiber safe -- Clone shared logger in `Task#logger` when `log_level` or `log_formatter` is customized to prevent mutation of the shared instance -- Derive attribute values from source objects that respond to the attribute name (via `send`) as fallback when the source is not callable - -## [1.17.0] - 2026-02-23 - -### Added -- Add `returns` macro for context output validation after task execution -- Add `remove_return`/`remove_returns` macro to remove declared returns (supports inheritance) -- Add array coercion for JSON `"null"` string as empty array -- Add hash coercion for JSON `"null"` string as empty hash -- Add attribute sourcing to support both string and symbol keys when sourcing/deriving from Hash - -### Changed -- Include the source method in the required attribute error message - -### Fixed -- Fix coercion to not fail on `nil` for optional attributes - -## [1.16.0] - 2026-02-06 - -### Added -- Add `CMDx::Exception` alias for `CMDx::Error` - -### Changed -- Rename `exceptions.rb` file to `exception.rb` (zeitwerk compatibility) -- Rename `faults.rb` file to `fault.rb` (zeitwerk compatibility) - -## [1.15.0] - 2026-01-21 - -### Added -- Add attribute `Absence` validator -- Add attribute `:description` option - -## [1.14.0] - 2026-01-09 - -### Added -- Add Ruby 4.0 compatibility -- Add `Context#clear!` method to remove all context data -- Add `Task.attribute_schema` class method for attribute introspection -- Add `#to_h` method for attribute serialization - -### Changed -- **BREAKING**: Switch license from MIT to LGPLv3 -- Replace `instance_eval` with `define_singleton_method` for attribute method definitions -- Move retry count from metadata to result object -- Exclude non-essential files from gem package - -### Removed -- Remove public `Result#rolled_back!` method to hide internal implementation - -## [1.13.0] - 2025-12-23 - -### Added -- Add rollback tracking and logging for task execution -- Add `dry_run` execution option with inheritance support for nested tasks -- Add `Context#delete` alias for `Context#delete!` -- Add `Context#merge` alias for `Context#merge!` - -## [1.12.0] - 2025-12-18 - -### Changed -- Optimize logging ancestor chain lookup performance -- Use `String#chop` instead of range indexing for improved string performance -- Make boolean coercion `TRUTHY` and `FALSEY` patterns case-insensitive -- Enhance YARD documentation using `yard-lint` validation - -### Removed -- Remove `handle_*` callback methods in favor of `on(*states_or_statuses)` for flexible state handling - -## [1.11.0] - 2025-11-08 - -### Changed -- Add conditional requirement support for attribute validation -- Update specs to use new `cmdx-rspec` matcher naming conventions - -## [1.10.1] - 2025-11-06 - -### Added -- Add YARDoc documentation to documentation site - -### Removed -- Remove unused `Executor#repeator` method - -## [1.10.0] - 2025-10-26 - -### Added -- Add `rollback` capability to undo operations based on status -- Add retry mechanism documentation - -### Changed -- Extend `retry_jitter` option to accept symbols, procs, and callable objects - -## [1.9.1] - 2025-10-22 - -### Added -- Add RBS inline type signatures -- Add YARDocs for `attr_reader` and `attr_accessor` methods - -## [1.9.0] - 2025-10-21 - -### Added -- Add `transform` option for attribute value transformations -- Add optional failure backtrace output -- Add exception handling for non-bang execution methods -- Add automatic retry mechanism for execution durability -- Add `to_h` hash coercion support -- Add MkDocs configuration with Material theme - -### Changed -- Improve task settings initialization performance -- Improve exception error message clarity -- Improve parent settings inheritance behavior -- Clean halt backtrace frames for better readability - -### Removed -- Remove `Freezer` module; consolidate logic into `Executor#freeze_execution!` -- Remove `task` parameter from callback method signatures -- Remove `task` and `workflow` arguments from conditional checks -- Remove chain persistence after execution in specs - -## [1.8.0] - 2025-09-22 - -### Changed -- Generalize locale values for `invalid` and `unspecified` faults -- Nest attribute error messages under `error` key in metadata -- Reorder Logstash formatter keys for consistency -- Improve error messaging for duplicate item definitions -- Return empty hash `{}` for `nil` hash coercion - -## [1.7.5] - 2025-09-10 - -### Added -- Add `Context#fetch_or_store` method for atomic get-or-set operations -- Add `Result#ctx` alias for `Result#context` -- Add `Result#ok?` alias for `Result#good?` -- Add result deconstruction support for pattern matching - -## [1.7.4] - 2025-09-03 - -### Added -- Add errors delegation from result object -- Add `Errors#full_messages` and `Errors#to_hash` methods - -## [1.7.3] - 2025-09-03 - -### Changed -- Use generic validation reason values -- Move validation full message to `:full_message` key in metadata - -## [1.7.2] - 2025-09-03 - -### Changed -- Set correlation ID before proceeding to subsequent execution steps - -## [1.7.1] - 2025-08-26 - -### Added -- Add block yielding support to `execute` and `execute!` methods - -## [1.7.0] - 2025-08-25 - -### Added -- Add workflow generator - -### Changed -- Integrate `cmdx-parallel` functionality into core -- Integrate `cmdx-i18n` functionality into core - -## [1.6.2] - 2025-08-24 - -### Changed -- Prefix railtie I18n with `::` for `CMDx::I18n` compatibility -- Switch to `cmdx-rspec` for matcher support - -## [1.6.1] - 2025-08-23 - -### Changed -- Log task results before freezing execution state -- Rename `execute_tasks_sequentially` to `execute_tasks_in_sequence` - -## [1.6.0] - 2025-08-22 - -### Added -- Add workflow task `:breakpoints` support - -### Changed -- Rename `Worker` class to `Executor` -- Extract workflow execution logic into `Pipeline` class - -## [1.5.2] - 2025-08-22 - -### Changed -- Rename workflow `execution_groups` attribute to `pipeline` - -## [1.5.1] - 2025-08-21 - -### Changed -- Prefix locale I18n with `::` for `CMDx::I18n` compatibility -- Add safe navigation to length and numeric validators -- Fix railtie file path to reference correct directory - -## [1.5.0] - 2025-08-21 - -### Changed -- **BREAKING**: Complete architecture redesign for improved clarity, transparency, and performance - -## [1.1.2] - 2025-07-20 - -### Changed -- See git tags for changes between versions 0.1.0 and 1.1.2 - -## [0.1.0] - 2025-03-07 - -### Added -- Initial release diff --git a/lib/cmdx/attribute.rb b/lib/cmdx/attribute.rb deleted file mode 100644 index 67010afef..000000000 --- a/lib/cmdx/attribute.rb +++ /dev/null @@ -1,440 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Represents a configurable attribute within a CMDx task. - # Attributes define the data structure and validation rules for task parameters. - # They can be nested to create complex hierarchical data structures. - class Attribute - - # @rbs AFFIX: Proc - AFFIX = proc do |value, &block| - value == true ? block.call : value - end.freeze - private_constant :AFFIX - - # Returns the task instance associated with this attribute. - # - # @return [CMDx::Task] The task instance - # - # @example - # attribute.task.context[:user_id] # => 42 - # - # @rbs @task: Task - attr_accessor :task - - # Returns the name of this attribute. - # - # @return [Symbol] The attribute name - # - # @example - # attribute.name # => :user_id - # - # @rbs @name: Symbol - attr_reader :name - - # Returns the configuration options for this attribute. - # - # @return [Hash{Symbol => Object}] Configuration options hash - # - # @example - # attribute.options # => { required: true, default: 0 } - # - # @rbs @options: Hash[Symbol, untyped] - attr_reader :options - - # Returns the child attributes for nested structures. - # - # @return [Array] Array of child attributes - # - # @example - # attribute.children # => [#, #] - # - # @rbs @children: Array[Attribute] - attr_reader :children - - # Returns the parent attribute if this is a nested attribute. - # - # @return [Attribute, nil] The parent attribute, or nil if root-level - # - # @example - # attribute.parent # => # - # - # @rbs @parent: (Attribute | nil) - attr_reader :parent - - # Returns the expected type(s) for this attribute's value. - # - # @return [Array] Array of expected type classes - # - # @example - # attribute.types # => [Integer, String] - # - # @rbs @types: Array[Class] - attr_reader :types - - # Returns the description of the attribute. - # - # @return [String] The description of the attribute - # - # @example - # attribute.description # => "The user's name" - # - # @rbs @description: String - attr_reader :description - - # Creates a new attribute with the specified name and configuration. - # - # @param name [Symbol, String] The name of the attribute - # @param options [Hash] Configuration options for the attribute - # @option options [Attribute] :parent The parent attribute for nested structures - # @option options [Boolean] :required Whether the attribute is required (default: false) - # @option options [Array, Class] :types The expected type(s) for the attribute value - # @option options [String] :description The description of the attribute - # @option options [Symbol, String, Proc] :source The source of the attribute value - # @option options [Symbol, String] :as The method name to use for this attribute - # @option options [Symbol, String, Boolean] :prefix The prefix to add to the method name - # @option options [Symbol, String, Boolean] :suffix The suffix to add to the method name - # @option options [Object] :default The default value for the attribute - # - # @yield [self] Block to configure nested attributes - # - # @example - # Attribute.new(:user_id, required: true, types: [Integer, String]) do - # required :name, types: String - # optional :email, types: String - # end - # - # @rbs ((Symbol | String) name, ?Hash[Symbol, untyped] options) ?{ () -> void } -> void - def initialize(name, options = {}, &) - @parent = options.delete(:parent) - @required = options.delete(:required) || false - @types = Utils::Wrap.array(options.delete(:types) || options.delete(:type)) - @description = options.delete(:description) || options.delete(:desc) - - @name = name.to_sym - @options = options - @children = [] - - instance_eval(&) if block_given? - end - - # Deep-copies children and clears task-dependent memoization so that - # duped attributes can be safely bound to a task without mutating the - # class-level originals. This makes concurrent execution thread-safe. - # - # @param source [Attribute] The attribute being duplicated - # - # @rbs (Attribute source) -> void - def initialize_dup(source) - super - @children = source.children.map(&:dup) - remove_instance_variable(:@source) if defined?(@source) - remove_instance_variable(:@method_name) if defined?(@method_name) - @task = nil - end - - class << self - - # Builds multiple attributes with the same configuration. - # - # @param names [Array] The names of the attributes to create - # @param options [Hash] Configuration options for the attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure nested attributes - # - # @return [Array] Array of created attributes - # - # @raise [ArgumentError] When no names are provided or :as is used with multiple attributes - # - # @example - # Attribute.build(:first_name, :last_name, required: true, types: String) - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def build(*names, **options, &) - if names.none? - raise ArgumentError, "no attributes given" - elsif (names.size > 1) && options.key?(:as) - raise ArgumentError, "the :as option only supports one attribute per definition" - end - - names.filter_map { |name| new(name, **options, &) } - end - - # Creates optional attributes (not required). - # - # @param names [Array] The names of the attributes to create - # @param options [Hash] Configuration options for the attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure nested attributes - # - # @return [Array] Array of created optional attributes - # - # @example - # Attribute.optional(:description, :tags, types: String) - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def optional(*names, **options, &) - build(*names, **options.merge(required: false), &) - end - - # Creates required attributes. - # - # @param names [Array] The names of the attributes to create - # @param options [Hash] Configuration options for the attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure nested attributes - # - # @return [Array] Array of created required attributes - # - # @example - # Attribute.required(:id, :name, types: [Integer, String]) - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def required(*names, **options, &) - build(*names, **options.merge(required: true), &) - end - - end - - # Checks if the attribute is optional. - # - # @return [Boolean] true if the attribute is optional, false otherwise - # - # @example - # attribute.optional? # => true - # - # @rbs () -> bool - def optional? - !required? || !!options[:optional] - end - - # Checks if the attribute is required. - # - # @return [Boolean] true if the attribute is required, false otherwise - # - # @example - # attribute.required? # => true - # - # @rbs () -> bool - def required? - !!@required - end - - # Determines the source of the attribute value. Returns :context - # as a safe fallback when task is not yet set (e.g., schema introspection). - # - # @return [Symbol] The source identifier for the attribute value - # - # @example - # attribute.source # => :context - # - # @rbs () -> untyped - def source - return @source if defined?(@source) - - parent&.method_name || begin - value = options[:source] - - if value.is_a?(Proc) - task ? @source = task.instance_eval(&value) : :context - elsif value.respond_to?(:call) - task ? @source = value.call(task) : :context - else - @source = value || :context - end - end - end - - # Returns the method name for this attribute when it can be resolved - # statically (without a task instance). Returns nil for Proc/callable - # sources whose method name depends on runtime evaluation. - # - # @return [Symbol, nil] The static method name, or nil if dynamic - # - # @example - # attribute.allocation_name # => :user_name - # - # @rbs () -> Symbol? - def allocation_name - return @allocation_name if defined?(@allocation_name) - - @allocation_name = options[:as] || begin - src = options[:source] - source_name = - if parent - parent.allocation_name - elsif !src.is_a?(Proc) && !src.respond_to?(:call) - src || :context - end - - if source_name.is_a?(Symbol) - prefix = AFFIX.call(options[:prefix]) { "#{source_name}_" } - suffix = AFFIX.call(options[:suffix]) { "_#{source_name}" } - :"#{prefix}#{name}#{suffix}" - end - end - end - - # Generates the method name for accessing this attribute. - # - # @return [Symbol] The method name for the attribute - # - # @example - # attribute.method_name # => :user_name - # - # @rbs () -> Symbol - def method_name - return @method_name if defined?(@method_name) - - result = options[:as] || begin - prefix = AFFIX.call(options[:prefix]) { "#{source}_" } - suffix = AFFIX.call(options[:suffix]) { "_#{source}" } - :"#{prefix}#{name}#{suffix}" - end - - # Only memoize if @source is defined to avoid memoizing method - # name when no task is present. - return result unless defined?(@source) - - @method_name = result - end - - # Defines and verifies the entire attribute tree including nested children. - # - # @rbs () -> void - def define_and_verify_tree - define_and_verify - - children.each do |child| - child.task = task - child.define_and_verify_tree - end - end - - # Recursively clears the task reference from this attribute and all children. - # Prevents the class-level attribute from retaining the last-executed task instance. - # - # @rbs () -> void - def clear_task_tree! - @task = nil - children.each(&:clear_task_tree!) - end - - # @return [Hash] A hash representation of the attribute - # - # @example - # attribute.to_h # => { - # name: :user_id, - # method_name: :current_user_id, - # description: "The user's name", - # required: true, - # types: [:integer], - # options: {}, - # children: [] - # } - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - { - name: name, - method_name: method_name, - description: description, - required: required?, - types: types, - options: options.except(:if, :unless), - children: children.map(&:to_h) - } - end - - private - - # Creates nested attributes as children of this attribute. - # - # @param names [Array] The names of the child attributes - # @param options [Hash] Configuration options for the child attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure the child attributes - # - # @return [Array] Array of created child attributes - # - # @example - # attributes :street, :city, :zip, types: String - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def attributes(*names, **options, &) - attrs = self.class.build(*names, **options.merge(parent: self), &) - children.concat(attrs) - end - alias attribute attributes - - # Creates optional nested attributes. - # - # @param names [Array] The names of the optional child attributes - # @param options [Hash] Configuration options for the child attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure the child attributes - # - # @return [Array] Array of created optional child attributes - # - # @example - # optional :middle_name, :nickname, types: String - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def optional(*names, **options, &) - attributes(*names, **options.merge(required: false), &) - end - - # Creates required nested attributes. - # - # @param names [Array] The names of the required child attributes - # @param options [Hash] Configuration options for the child attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure the child attributes - # - # @return [Array] Array of created required child attributes - # - # @example - # required :first_name, :last_name, types: String - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def required(*names, **options, &) - attributes(*names, **options.merge(required: true), &) - end - - # Defines the attribute reader on the task class (once) and - # generates/validates the per-instance value (every execution). - # - # @raise [RuntimeError] When the method name is already defined on the task - # - # @rbs () -> void - def define_and_verify - name_of_method = method_name - - unless task.class.method_defined?(name_of_method) - if task.respond_to?(name_of_method, true) - raise <<~MESSAGE - The method #{name_of_method.inspect} is already defined on the #{task.class.name} task. - This may be due conflicts with one of the task's user defined or internal methods/attributes. - - Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods. - MESSAGE - end - - task.class.define_method(name_of_method) do - attributes[name_of_method] - end - end - - attribute_value = AttributeValue.new(self) - attribute_value.generate - attribute_value.validate - end - - end -end diff --git a/lib/cmdx/attribute_registry.rb b/lib/cmdx/attribute_registry.rb deleted file mode 100644 index ce7f9425a..000000000 --- a/lib/cmdx/attribute_registry.rb +++ /dev/null @@ -1,185 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages a collection of attributes for task definition and verification. - # The registry provides methods to register, deregister, and process attributes - # in a hierarchical structure, supporting nested attribute definitions. - class AttributeRegistry - - # Returns the collection of registered attributes. - # - # @return [Array] Array of registered attributes - # - # @example - # registry.registry # => [#, #] - # - # @rbs @registry: Array[Attribute] - attr_reader :registry - alias to_a registry - - # Creates a new attribute registry with an optional initial collection. - # - # @param registry [Array] Initial attributes to register - # - # @return [AttributeRegistry] A new registry instance - # - # @example - # registry = AttributeRegistry.new - # registry = AttributeRegistry.new([attr1, attr2]) - # - # @rbs (?Array[Attribute] registry) -> void - def initialize(registry = []) - @registry = registry - end - - # Creates a duplicate of this registry with copied attributes. - # - # @return [AttributeRegistry] A new registry with duplicated attributes - # - # @example - # new_registry = registry.dup - # - # @rbs () -> AttributeRegistry - def dup - self.class.new(registry.dup) - end - - # Registers one or more attributes to the registry. - # - # @param attributes [Attribute, Array] Attribute(s) to register - # - # @return [AttributeRegistry] Self for method chaining - # - # @example - # registry.register(attribute) - # registry.register([attr1, attr2]) - # - # @rbs (Attribute | Array[Attribute] attributes) -> self - def register(attributes) - @registry.concat(Utils::Wrap.array(attributes)) - self - end - - # Removes attributes from the registry by name. - # Supports hierarchical attribute removal by matching the entire attribute tree. - # - # @param names [Symbol, String, Array] Name(s) of attributes to remove - # - # @return [AttributeRegistry] Self for method chaining - # - # @example - # registry.deregister(:name) - # registry.deregister(['name1', 'name2']) - # - # @rbs ((Symbol | String | Array[Symbol | String]) names) -> self - def deregister(names) - Utils::Wrap.array(names).each do |name| - @registry.reject! { |attribute| matches_attribute_tree?(attribute, name.to_sym) } - end - - self - end - - # Eagerly defines attribute reader methods on the task class for all - # attributes whose method names can be statically resolved. Called at - # class definition time so readers are defined once, not per-execution. - # - # @param task_class [Class] The task class to define readers on - # @param attrs [Array] Attributes to process (defaults to all) - # - # @rbs (Class task_class, ?Array[Attribute] attrs) -> void - def define_readers_on!(task_class, attrs = registry) - attrs.each { |attr| define_reader_tree!(task_class, attr) } - end - - # Removes eagerly defined reader methods from the task class for - # attributes about to be deregistered. Must be called before {#deregister}. - # - # @param task_class [Class] The task class to undefine readers on - # @param names [Array] Attribute method names being removed - # - # @rbs (Class task_class, (Symbol | String | Array[Symbol | String]) names) -> void - def undefine_readers_on!(task_class, names) - Utils::Wrap.array(names).each do |name| - sym = name.to_sym - - registry.each do |attribute| - next unless matches_attribute_tree?(attribute, sym) - - undefine_reader_tree!(task_class, attribute) - end - end - end - - # Verifies attribute definitions against a task instance. - # Each attribute is duped before binding so the class-level originals - # are never mutated — this eliminates the concurrency hazard of - # shared mutable @task, @source, and @method_name state. - # - # @param task [Task] The task to verify attributes against - # - # @rbs (Task task) -> void - def define_and_verify(task) - registry.each do |attribute| - duplicate = attribute.dup - duplicate.task = task - duplicate.define_and_verify_tree - end - end - - private - - # Recursively defines reader methods for an attribute and its children - # when the method name is statically resolvable. - # - # @param task_class [Class] The task class to define readers on - # @param attribute [Attribute] The attribute to define a reader for - # - # @rbs (Class task_class, Attribute attribute) -> void - def define_reader_tree!(task_class, attribute) - name = attribute.allocation_name - - if name && !task_class.method_defined?(name) - if task_class.private_method_defined?(name) - raise <<~MESSAGE - The method #{name.inspect} is already defined on the #{task_class.name} task. - This may be due conflicts with one of the task's user defined or internal methods/attributes. - - Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods. - MESSAGE - end - - task_class.define_method(name) { attributes[name] } - end - - attribute.children.each { |child| define_reader_tree!(task_class, child) } - end - - # Recursively removes reader methods for an attribute and its children. - # - # @param task_class [Class] The task class to undefine readers on - # @param attribute [Attribute] The attribute whose readers to remove - # - # @rbs (Class task_class, Attribute attribute) -> void - def undefine_reader_tree!(task_class, attribute) - name = attribute.allocation_name - task_class.remove_method(name) if name && task_class.method_defined?(name, false) - attribute.children.each { |child| undefine_reader_tree!(task_class, child) } - end - - # Recursively checks if an attribute or any of its children match the given name. - # - # @param attribute [Attribute] The attribute to check - # @param name [Symbol] The name to match against - # - # @return [Boolean] True if the attribute or any child matches the name - # - # @rbs (Attribute attribute, Symbol name) -> bool - def matches_attribute_tree?(attribute, name) - return true if attribute.method_name == name - - attribute.children.any? { |child| matches_attribute_tree?(child, name) } - end - - end -end diff --git a/lib/cmdx/attribute_value.rb b/lib/cmdx/attribute_value.rb deleted file mode 100644 index 92032f0f6..000000000 --- a/lib/cmdx/attribute_value.rb +++ /dev/null @@ -1,252 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages the value lifecycle for a single attribute within a task. - # Handles value sourcing, derivation, coercion, and validation through - # a coordinated pipeline that ensures data integrity and type safety. - class AttributeValue - - extend Forwardable - - # Returns the attribute managed by this value handler. - # - # @return [Attribute] The attribute instance - # - # @example - # attr_value.attribute.name # => :user_id - # - # @rbs @attribute: Attribute - attr_reader :attribute - - def_delegators :attribute, :task, :parent, :name, :options, :types, :source, :method_name, :required?, :optional? - def_delegators :task, :attributes, :errors - - # Creates a new attribute value manager for the given attribute. - # - # @param attribute [Attribute] The attribute to manage values for - # - # @example - # attr = Attribute.new(:user_id, required: true) - # attr_value = AttributeValue.new(attr) - # - # @rbs (Attribute attribute) -> void - def initialize(attribute) - @attribute = attribute - end - - # Retrieves the current value for this attribute from the task's attributes. - # - # @return [Object, nil] The current attribute value or nil if not set - # - # @example - # attr_value.value # => "john_doe" - # - # @rbs () -> untyped - def value - attributes[method_name] - end - - # Generates the attribute value through the complete pipeline: - # sourcing, derivation, coercion, and storage. - # - # @return [Object, nil] The generated value or nil if generation failed - # - # @example - # attr_value.generate # => 42 - # - # @rbs () -> untyped - def generate - return value if attributes.key?(method_name) - - sourced_value = source_value - return if errors.for?(method_name) - - derived_value = derive_value(sourced_value) - return if errors.for?(method_name) - - coerced_value = coerce_value(derived_value) - transformed_value = transform_value(coerced_value) - return if errors.for?(method_name) - - attributes[method_name] = transformed_value - end - - # Validates the current attribute value against configured validators. - # - # @raise [ValidationError] When validation fails (handled internally) - # - # @example - # attr_value.validate - # # Validates value against :presence, :format, etc. - # - # @rbs () -> void - def validate - registry = task.class.settings.validators - - options.slice(*registry.keys).each do |type, opts| - registry.validate(type, task, value, opts) - rescue ValidationError => e - errors.add(method_name, e.message) - nil - end - end - - private - - # Retrieves the source value for this attribute from various sources. - # - # @return [Object, nil] The sourced value or nil if unavailable - # - # @raise [NoMethodError] When the source method doesn't exist - # - # @example - # # Sources from task method, proc, or direct value - # source_value # => "raw_value" - # @rbs () -> untyped - def source_value - sourced_value = - case source - when Symbol then task.send(source) - when Proc then task.instance_eval(&source) - else source.respond_to?(:call) ? source.call(task) : source - end - - if required? && (parent.nil? || parent&.required?) && Utils::Condition.evaluate(task, options) - case sourced_value - when Context then sourced_value.key?(name) - when Hash then sourced_value.key?(name.to_s) || sourced_value.key?(name.to_sym) - when Proc then true # HACK: Cannot be determined so assume it will respond - else sourced_value.respond_to?(name, true) - end || errors.add(method_name, Locale.t("cmdx.attributes.required", method: source)) - end - - sourced_value - rescue NoMethodError - errors.add(method_name, Locale.t("cmdx.attributes.undefined", method: source)) - nil - end - - # Retrieves the default value for this attribute if configured. - # - # @return [Object, nil] The default value or nil if not configured - # - # @example - # # Default can be symbol, proc, or direct value - # -> { rand(100) } # => 23 - # - # @rbs () -> untyped - def default_value - default = options[:default] - - if default.is_a?(Symbol) && task.respond_to?(default, true) - task.send(default) - elsif default.is_a?(Proc) - task.instance_eval(&default) - elsif default.respond_to?(:call) - default.call(task) - else - default - end - end - - # Derives the actual value from the source value using various strategies. - # - # @param source_value [Object] The source value to derive from - # - # @return [Object, nil] The derived value or nil if derivation failed - # - # @raise [NoMethodError] When the derivation method doesn't exist - # - # @example - # # Derives from hash key, method call, or proc execution - # context.user_id # => 42 - # - # @rbs (untyped source_value) -> untyped - def derive_value(source_value) - derived_value = - case source_value - when Context then source_value[name] - when Hash - if source_value.key?(name.to_s) - source_value[name.to_s] - elsif source_value.key?(name.to_sym) - source_value[name.to_sym] - end - when Symbol then source_value.send(name) - when Proc then task.instance_exec(name, &source_value) - else - if source_value.respond_to?(:call) - source_value.call(task, name) - elsif source_value.respond_to?(name, true) - source_value.send(name) - end - end - - derived_value.nil? ? default_value : derived_value - rescue NoMethodError - errors.add(method_name, Locale.t("cmdx.attributes.undefined", method: name)) - nil - end - - # Transforms the derived value using the transform option. - # - # @param derived_value [Object] The value to transform - # - # @return [Object, nil] The transformed value or nil if transformation failed - # - # @example - # :downcase # => "hello" - # - # @rbs (untyped derived_value) -> untyped - def transform_value(derived_value) - transform = options[:transform] - - if transform.is_a?(Symbol) && derived_value.respond_to?(transform, true) - derived_value.send(transform) - elsif transform.respond_to?(:call) - transform.call(derived_value) - else - derived_value - end - end - - # Coerces the derived value to the expected type(s) using the coercion registry. - # - # @param transformed_value [Object] The value to coerce - # - # @return [Object, nil] The coerced value or nil if coercion failed - # - # @raise [CoercionError] When coercion fails (handled internally) - # - # @example - # # Coerces "42" to Integer, "true" to Boolean, etc. - # coerce_value("42") # => 42 - # - # @rbs (untyped transformed_value) -> untyped - def coerce_value(transformed_value) - return transformed_value if types.empty? - - registry = task.class.settings.coercions - last_idx = types.size - 1 - - types.find.with_index do |type, i| - break registry.coerce(type, task, transformed_value, options) - rescue CoercionError => e - next if i != last_idx - - unless optional? && transformed_value.nil? - message = - if last_idx.zero? - e.message - else - tl = types.map { |t| Locale.t("cmdx.types.#{t}") }.join(", ") - Locale.t("cmdx.coercions.into_any", types: tl) - end - - errors.add(method_name, message) - end - end - end - - end -end diff --git a/lib/cmdx/callback_registry.rb b/lib/cmdx/callback_registry.rb deleted file mode 100644 index 1724eab5e..000000000 --- a/lib/cmdx/callback_registry.rb +++ /dev/null @@ -1,169 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Registry for managing callbacks that can be executed at various points during task execution. - # - # Callbacks are organized by type and can be registered with optional conditions and options. - # Each callback type represents a specific execution phase or outcome. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. - class CallbackRegistry - - extend Forwardable - - # @rbs TYPES: Array[Symbol] - TYPES = %i[ - before_validation - before_execution - on_complete - on_interrupted - on_executed - on_success - on_skipped - on_failed - on_good - on_bad - ].freeze - - # @rbs TYPES_SET: Set[Symbol] - TYPES_SET = TYPES.to_set.freeze - private_constant :TYPES_SET - - def_delegators :registry, :empty? - - # @param registry [Hash, nil] Initial registry hash, defaults to empty - # - # @rbs (?Hash[Symbol, Set[Array[untyped]]]? registry) -> void - def initialize(registry = nil) - @registry = registry || {} - end - - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [CallbackRegistry] The registry being duplicated - # - # @rbs (CallbackRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end - - # Returns the internal registry of callbacks organized by type. - # Delegates to the parent registry when not yet materialized. - # - # @return [Hash{Symbol => Set}] Hash mapping callback types to their registered callables - # - # @example - # registry.registry # => { before_execution: # } - # - # @rbs () -> Hash[Symbol, Set[Array[untyped]]] - def registry - @registry || @parent.registry - end - alias to_h registry - - # Registers one or more callables for a specific callback type - # - # @param type [Symbol] The callback type from TYPES - # @param callables [Array<#call>] Callable objects to register - # @param options [Hash] Options to pass to the callback - # @param block [Proc] Optional block to register as a callable - # @option options [Hash] :if Condition hash for conditional execution - # @option options [Hash] :unless Inverse condition hash for conditional execution - # - # @return [CallbackRegistry] self for method chaining - # - # @raise [ArgumentError] When type is not a valid callback type - # - # @example Register a method callback - # registry.register(:before_execution, :validate_inputs) - # @example Register a block with conditions - # registry.register(:on_success, if: { status: :completed }) do |task| - # task.log("Success callback executed") - # end - # - # @rbs (Symbol type, *untyped callables, **untyped options) ?{ (Task) -> void } -> self - def register(type, *callables, **options, &block) - materialize! - - callables << block if block_given? - - @registry[type] ||= Set.new - @registry[type] << [callables, options] - self - end - - # Removes one or more callables for a specific callback type - # - # @param type [Symbol] The callback type from TYPES - # @param callables [Array<#call>] Callable objects to remove - # @param options [Hash] Options that were used during registration - # @param block [Proc] Optional block to remove - # @option options [Object] :* Any option key-value pairs - # - # @return [CallbackRegistry] self for method chaining - # - # @example Remove a specific callback - # registry.deregister(:before_execution, :validate_inputs) - # - # @rbs (Symbol type, *untyped callables, **untyped options) ?{ (Task) -> void } -> self - def deregister(type, *callables, **options, &block) - materialize! - - callables << block if block_given? - return self unless @registry[type] - - @registry[type].delete([callables, options]) - @registry.delete(type) if @registry[type].empty? - self - end - - # Invokes all registered callbacks for a given type - # - # @param type [Symbol] The callback type to invoke - # @param task [Task] The task instance to pass to callbacks - # - # @raise [TypeError] When type is not a valid callback type - # - # @example Invoke all before_execution callbacks - # registry.invoke(:before_execution, task) - # - # @rbs (Symbol type, Task task) -> void - def invoke(type, task) - raise TypeError, "unknown callback type #{type.inspect}" unless TYPES_SET.include?(type) - return unless registry[type] - - registry[type].each do |callables, options| - next unless Utils::Condition.evaluate(task, options) - - Utils::Wrap.array(callables).each do |callable| - if callable.is_a?(Symbol) - task.send(callable) - elsif callable.is_a?(Proc) - task.instance_exec(&callable) - elsif callable.respond_to?(:call) - callable.call(task) - else - raise "cannot invoke #{callable}" - end - end - end - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.transform_values(&:dup) - @parent = nil - end - - end -end diff --git a/lib/cmdx/chain.rb b/lib/cmdx/chain.rb deleted file mode 100644 index 9f5016f5e..000000000 --- a/lib/cmdx/chain.rb +++ /dev/null @@ -1,216 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages a collection of task execution results in a thread and fiber safe manner. - # Chains provide a way to track related task executions and their outcomes - # within the same execution context. - class Chain - - extend Forwardable - - # @rbs CONCURRENCY_KEY: Symbol - CONCURRENCY_KEY = :cmdx_chain - - # Returns the unique identifier for this chain. - # - # @return [String] The chain identifier - # - # @example - # chain.id # => "abc123xyz" - # - # @rbs @id: String - attr_reader :id - - # Returns the collection of execution results in this chain. - # - # @return [Array] Array of task results - # - # @example - # chain.results # => [#, #] - # - # @rbs @results: Array[Result] - attr_reader :results - - def_delegators :results, :first, :last, :size - def_delegators :first, :state, :status, :outcome - - # Creates a new chain with a unique identifier and empty results collection. - # - # @return [Chain] A new chain instance - # - # @rbs () -> void - def initialize(dry_run: false) - @mutex = Mutex.new - @id = Identifier.generate - @results = [] - @dry_run = !!dry_run - end - - class << self - - # Retrieves the current chain for the current execution context. - # - # @return [Chain, nil] The current chain or nil if none exists - # - # @example - # chain = Chain.current - # if chain - # puts "Current chain: #{chain.id}" - # end - # - # @rbs () -> Chain? - def current - thread_or_fiber[CONCURRENCY_KEY] - end - - # Sets the current chain for the current execution context. - # - # @param chain [Chain] The chain to set as current - # - # @return [Chain] The set chain - # - # @example - # Chain.current = my_chain - # - # @rbs (Chain chain) -> Chain - def current=(chain) - thread_or_fiber[CONCURRENCY_KEY] = chain - end - - # Clears the current chain for the current execution context. - # - # @return [nil] Always returns nil - # - # @example - # Chain.clear - # - # @rbs () -> nil - def clear - thread_or_fiber[CONCURRENCY_KEY] = nil - end - - # Builds or extends the current chain by adding a result. - # Creates a new chain if none exists, otherwise appends to the current one. - # - # @param result [Result] The task execution result to add - # - # @return [Chain] The current chain (newly created or existing) - # - # @raise [TypeError] If result is not a CMDx::Result instance - # - # @example - # result = task.execute - # chain = Chain.build(result) - # puts "Chain size: #{chain.size}" - # - # @rbs (Result result) -> Chain - def build(result, dry_run: false) - raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) - - self.current ||= new(dry_run:) - current.push(result) - current - end - - private - - # Returns the thread or fiber storage for the current execution context. - # - # @return [Hash] The thread or fiber storage - # - # @rbs () -> Hash - if Fiber.respond_to?(:storage) - def thread_or_fiber = Fiber.storage - else - def thread_or_fiber = Thread.current - end - - end - - # Thread-safe append of a result to the chain. - # Caches the result's index to avoid repeated O(n) lookups. - # - # @param result [Result] The result to append - # - # @return [Array] The updated results array - # - # @rbs (Result result) -> Array[Result] - def push(result) - @mutex.synchronize do - result.instance_variable_set(:@chain_index, @results.size) - @results << result - end - end - - # Thread-safe lookup of a result's position in the chain. - # - # @param result [Result] The result to find - # - # @return [Integer, nil] The zero-based index or nil if not found - # - # @rbs (Result result) -> Integer? - def index(result) - @mutex.synchronize { @results.index(result) } - end - - # Returns whether the chain is running in dry-run mode. - # - # @return [Boolean] Whether the chain is running in dry-run mode - # - # @example - # chain.dry_run? # => true - # - # @rbs () -> bool - def dry_run? - !!@dry_run - end - - # Freezes the chain and its internal results to prevent modifications. - # - # @return [Chain] the frozen chain - # - # @example - # chain.freeze - # chain.results << result # => raises FrozenError - # - # @rbs () -> self - def freeze - results.freeze - super - end - - # Converts the chain to a hash representation. - # - # @option return [String] :id The chain identifier - # @option return [Array] :results Array of result hashes - # - # @return [Hash] Hash containing chain id and serialized results - # - # @example - # chain_hash = chain.to_h - # puts chain_hash[:id] - # puts chain_hash[:results].size - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - { - id:, - dry_run: dry_run?, - results: results.map(&:to_h) - } - end - - # Converts the chain to a string representation. - # - # @return [String] Formatted string representation of the chain - # - # @example - # puts chain.to_s - # - # @rbs () -> String - def to_s - Utils::Format.to_str(to_h) - end - - end -end diff --git a/lib/cmdx/coercion_registry.rb b/lib/cmdx/coercion_registry.rb deleted file mode 100644 index 8affbf9b3..000000000 --- a/lib/cmdx/coercion_registry.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Registry for managing type coercion handlers. - # - # Provides a centralized way to register, deregister, and execute type coercions - # for various data types including arrays, numbers, dates, and other primitives. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. - class CoercionRegistry - - # Initialize a new coercion registry. - # - # @param registry [Hash{Symbol => Class}, nil] optional initial registry hash - # - # @example - # registry = CoercionRegistry.new - # registry = CoercionRegistry.new(custom: CustomCoercion) - # - # @rbs (?Hash[Symbol, Class]? registry) -> void - def initialize(registry = nil) - @registry = registry || { - array: Coercions::Array, - big_decimal: Coercions::BigDecimal, - boolean: Coercions::Boolean, - complex: Coercions::Complex, - date: Coercions::Date, - datetime: Coercions::DateTime, - float: Coercions::Float, - hash: Coercions::Hash, - integer: Coercions::Integer, - rational: Coercions::Rational, - string: Coercions::String, - time: Coercions::Time - } - end - - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [CoercionRegistry] The registry being duplicated - # - # @rbs (CoercionRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end - - # Returns the internal registry mapping coercion types to handler classes. - # Delegates to the parent registry when not yet materialized. - # - # @return [Hash{Symbol => Class}] Hash of coercion type names to coercion classes - # - # @example - # registry.registry # => { integer: Coercions::Integer, boolean: Coercions::Boolean } - # - # @rbs () -> Hash[Symbol, Class] - def registry - @registry || @parent.registry - end - alias to_h registry - - # Register a new coercion handler for a type. - # - # @param name [Symbol, String] the type name to register - # @param coercion [Class] the coercion class to handle this type - # - # @return [CoercionRegistry] self for method chaining - # - # @example - # registry.register(:custom_type, CustomCoercion) - # registry.register("another_type", AnotherCoercion) - # - # @rbs ((Symbol | String) name, Class coercion) -> self - def register(name, coercion) - materialize! - - @registry[name.to_sym] = coercion - self - end - - # Remove a coercion handler for a type. - # - # @param name [Symbol, String] the type name to deregister - # - # @return [CoercionRegistry] self for method chaining - # - # @example - # registry.deregister(:custom_type) - # registry.deregister("another_type") - # - # @rbs ((Symbol | String) name) -> self - def deregister(name) - materialize! - - @registry.delete(name.to_sym) - self - end - - # Coerce a value to the specified type using the registered handler. - # - # @param type [Symbol] the type to coerce to - # @param task [Object] the task context for the coercion - # @param value [Object] the value to coerce - # @param options [Hash] additional options for the coercion - # @option options [Object] :* Any coercion option key-value pairs - # - # @return [Object] the coerced value - # - # @raise [TypeError] when the type is not registered - # - # @example - # result = registry.coerce(:integer, task, "42") - # result = registry.coerce(:boolean, task, "true", strict: true) - # - # @rbs (Symbol type, untyped task, untyped value, ?Hash[Symbol, untyped] options) -> untyped - def coerce(type, task, value, options = EMPTY_HASH) - raise TypeError, "unknown coercion type #{type.inspect}" unless registry.key?(type) - - Utils::Call.invoke(task, registry[type], value, options) - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.dup - @parent = nil - end - - end -end diff --git a/lib/cmdx/coercions/array.rb b/lib/cmdx/coercions/array.rb deleted file mode 100644 index 805cc5f65..000000000 --- a/lib/cmdx/coercions/array.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Converts various input types to Array format - # - # Handles conversion from strings that look like JSON arrays and other - # values that can be wrapped in an array using Ruby's Array() method. - module Array - - extend self - - # Converts a value to an Array - # - # @param value [Object] The value to convert to an array - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Array] The converted array value - # - # @raise [CoercionError] If the value cannot be converted to an array - # - # @example Convert a JSON-like string to an array - # Array.call("[1, 2, 3]") # => [1, 2, 3] - # @example Convert other values using Array() - # Array.call("hello") # => ["hello"] - # Array.call(42) # => [42] - # Array.call(nil) # => [] - # @example Handle invalid JSON-like strings - # Array.call("[not json") # => raises CoercionError - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Array[untyped] - def call(value, options = EMPTY_HASH) - if value.is_a?(::String) && ( - value.start_with?("[") || - value.strip == "null" - ) - JSON.parse(value) || [] - else - Utils::Wrap.array(value) - end - rescue JSON::ParserError - type = Locale.t("cmdx.types.array") - raise CoercionError, Locale.t("cmdx.coercions.into_an", type:) - end - - end - end -end diff --git a/lib/cmdx/coercions/big_decimal.rb b/lib/cmdx/coercions/big_decimal.rb deleted file mode 100644 index 2fa28615c..000000000 --- a/lib/cmdx/coercions/big_decimal.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Converts various input types to BigDecimal format - # - # Handles conversion from numeric strings, integers, floats, and other - # values that can be converted to BigDecimal using Ruby's BigDecimal() method. - module BigDecimal - - extend self - - # @rbs DEFAULT_PRECISION: Integer - DEFAULT_PRECISION = 14 - - # Converts a value to a BigDecimal - # - # @param value [Object] The value to convert to BigDecimal - # @param options [Hash] Optional configuration parameters - # @option options [Integer] :precision The precision to use (defaults to DEFAULT_PRECISION) - # - # @return [BigDecimal] The converted BigDecimal value - # - # @raise [CoercionError] If the value cannot be converted to BigDecimal - # - # @example Convert numeric strings to BigDecimal - # BigDecimal.call("123.45") # => # - # BigDecimal.call("0.001", precision: 6) # => # - # @example Convert other numeric types - # BigDecimal.call(42) # => # - # BigDecimal.call(3.14159) # => # - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> BigDecimal - def call(value, options = EMPTY_HASH) - BigDecimal(value, options[:precision] || DEFAULT_PRECISION) - rescue ArgumentError, TypeError - type = Locale.t("cmdx.types.big_decimal") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end - - end - end -end diff --git a/lib/cmdx/coercions/boolean.rb b/lib/cmdx/coercions/boolean.rb deleted file mode 100644 index 9901b2af7..000000000 --- a/lib/cmdx/coercions/boolean.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Converts various input types to Boolean format - # - # Handles conversion from strings, numbers, and other values to boolean - # using predefined truthy and falsey patterns. - module Boolean - - extend self - - # @rbs FALSEY: Regexp - FALSEY = /\A(false|f|no|n|0)\z/i - - # @rbs TRUTHY: Regexp - TRUTHY = /\A(true|t|yes|y|1)\z/i - - # Converts a value to a Boolean - # - # @param value [Object] The value to convert to boolean - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Boolean] The converted boolean value - # - # @raise [CoercionError] If the value cannot be converted to boolean - # - # @example Convert truthy strings to true - # Boolean.call("true") # => true - # Boolean.call("yes") # => true - # Boolean.call("1") # => true - # @example Convert falsey strings to false - # Boolean.call("false") # => false - # Boolean.call("no") # => false - # Boolean.call("0") # => false - # Boolean.call(nil) # => false - # Boolean.call("") # => false - # @example Handle case-insensitive input - # Boolean.call("TRUE") # => true - # Boolean.call("False") # => false - # @example Handle edge cases - # Boolean.call("abc") # => raises CoercionError - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> bool - def call(value, options = EMPTY_HASH) - case value.to_s - when FALSEY, EMPTY_STRING then false - when TRUTHY then true - else - type = Locale.t("cmdx.types.boolean") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end - end - - end - end -end diff --git a/lib/cmdx/coercions/complex.rb b/lib/cmdx/coercions/complex.rb deleted file mode 100644 index ddceafa36..000000000 --- a/lib/cmdx/coercions/complex.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Converts various input types to Complex number format - # - # Handles conversion from numeric strings, integers, floats, and other - # values that can be converted to Complex using Ruby's Complex() method. - module Complex - - extend self - - # Converts a value to a Complex number - # - # @param value [Object] The value to convert to Complex - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :* Any configuration option (unused) - # - # @return [Complex] The converted Complex number value - # - # @raise [CoercionError] If the value cannot be converted to Complex - # - # @example Convert numeric strings to Complex - # Complex.call("3+4i") # => (3+4i) - # Complex.call("2.5") # => (2.5+0i) - # @example Convert other numeric types - # Complex.call(5) # => (5+0i) - # Complex.call(3.14) # => (3.14+0i) - # Complex.call(Complex(1, 2)) # => (1+2i) - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Complex - def call(value, options = EMPTY_HASH) - Complex(value) - rescue ArgumentError, TypeError - type = Locale.t("cmdx.types.complex") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end - - end - end -end diff --git a/lib/cmdx/coercions/date.rb b/lib/cmdx/coercions/date.rb deleted file mode 100644 index 691bee6f9..000000000 --- a/lib/cmdx/coercions/date.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Converts various input types to Date format - # - # Handles conversion from strings, Date objects, DateTime objects, Time objects, - # and other date-like values to Date objects using Ruby's built-in parsing - # capabilities and optional custom format parsing. - module Date - - extend self - - # Converts a value to a Date object - # - # @param value [Object] The value to convert to a Date - # @param options [Hash] Optional configuration parameters - # @option options [String] :strptime Custom date format string for parsing - # - # @return [Date] The converted Date object - # - # @raise [CoercionError] If the value cannot be converted to a Date - # - # @example Convert string to Date using default parsing - # Date.call("2023-12-25") # => # - # Date.call("Dec 25, 2023") # => # - # @example Convert string using custom format - # Date.call("25/12/2023", strptime: "%d/%m/%Y") # => # - # Date.call("12-25-2023", strptime: "%m-%d-%Y") # => # - # @example Return existing Date objects unchanged - # Date.call(Date.new(2023, 12, 25)) # => # - # Date.call(DateTime.new(2023, 12, 25)) # => # - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Date - def call(value, options = EMPTY_HASH) - return value.to_date if value.respond_to?(:to_date) - return ::Date.strptime(value, options[:strptime]) if options[:strptime] - - ::Date.parse(value) - rescue TypeError, ::Date::Error - type = Locale.t("cmdx.types.date") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end - - end - end -end diff --git a/lib/cmdx/coercions/date_time.rb b/lib/cmdx/coercions/date_time.rb deleted file mode 100644 index 4211a5c13..000000000 --- a/lib/cmdx/coercions/date_time.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Converts various input types to DateTime format - # - # Handles conversion from date strings, Date objects, Time objects, and other - # values that can be converted to DateTime using Ruby's DateTime.parse method - # or custom strptime formats. - module DateTime - - extend self - - # Converts a value to a DateTime - # - # @param value [Object] The value to convert to DateTime - # @param options [Hash] Optional configuration parameters - # @option options [String] :strptime Custom date format string for parsing - # - # @return [DateTime] The converted DateTime value - # - # @raise [CoercionError] If the value cannot be converted to DateTime - # - # @example Convert date strings to DateTime - # DateTime.call("2023-12-25") # => # - # DateTime.call("Dec 25, 2023") # => # - # @example Convert with custom strptime format - # DateTime.call("25/12/2023", strptime: "%d/%m/%Y") - # # => # - # @example Convert existing date objects - # DateTime.call(Date.new(2023, 12, 25)) # => # - # DateTime.call(Time.new(2023, 12, 25)) # => # - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> DateTime - def call(value, options = EMPTY_HASH) - return value.to_datetime if value.respond_to?(:to_datetime) - return ::DateTime.strptime(value, options[:strptime]) if options[:strptime] - - ::DateTime.parse(value) - rescue TypeError, ::Date::Error - type = Locale.t("cmdx.types.date_time") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end - - end - end -end diff --git a/lib/cmdx/coercions/float.rb b/lib/cmdx/coercions/float.rb deleted file mode 100644 index d2cda10db..000000000 --- a/lib/cmdx/coercions/float.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Converts various input types to Float format - # - # Handles conversion from numeric strings, integers, and other numeric types - # that can be converted to floats using Ruby's Float() method. - module Float - - extend self - - # Converts a value to a Float - # - # @param value [Object] The value to convert to a float - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Float] The converted float value - # - # @raise [CoercionError] If the value cannot be converted to a float - # - # @example Convert numeric strings to float - # Float.call("123") # => 123.0 - # Float.call("123.456") # => 123.456 - # Float.call("-42.5") # => -42.5 - # Float.call("1.23e4") # => 12300.0 - # @example Convert numeric types to float - # Float.call(42) # => 42.0 - # Float.call(BigDecimal("123.456")) # => 123.456 - # Float.call(Rational(3, 4)) # => 0.75 - # Float.call(Complex(5.0, 0)) # => 5.0 - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Float - def call(value, options = EMPTY_HASH) - Float(value) - rescue ArgumentError, RangeError, TypeError - type = Locale.t("cmdx.types.float") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end - - end - end -end diff --git a/lib/cmdx/coercions/hash.rb b/lib/cmdx/coercions/hash.rb deleted file mode 100644 index baa553928..000000000 --- a/lib/cmdx/coercions/hash.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Coerces various input types into Hash objects - # - # Supports conversion from: - # - Nil values (converted to empty Hash) - # - Hash objects (returned as-is) - # - Array objects (converted using Hash[*array]) - # - JSON strings starting with "{" (parsed into Hash) - # - JSON strings that are "null" (parsed into empty Hash) - # - Other types raise CoercionError - module Hash - - extend self - - # Coerces a value into a Hash - # - # @param value [Object] The value to coerce - # @param options [Hash] Additional options (currently unused) - # @option options [Symbol] :strict Whether to enforce strict conversion - # - # @return [Hash] The coerced hash value - # - # @raise [CoercionError] When the value cannot be coerced to a Hash - # - # @example Coerce from existing Hash - # Hash.call({a: 1, b: 2}) # => {a: 1, b: 2} - # @example Coerce from Array - # Hash.call([:a, 1, :b, 2]) # => {a: 1, b: 2} - # @example Coerce from JSON string - # Hash.call('{"key": "value"}') # => {"key" => "value"} - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Hash[untyped, untyped] - def call(value, options = EMPTY_HASH) - if value.nil? - {} - elsif value.is_a?(::Hash) - value - elsif value.is_a?(::Array) - ::Hash[*value] - elsif value.is_a?(::String) && ( - value.start_with?("{") || - value.strip == "null" - ) - JSON.parse(value) || {} - elsif value.respond_to?(:to_h) - value.to_h - else - raise_coercion_error! - end - rescue ArgumentError, TypeError, JSON::ParserError - raise_coercion_error! - end - - private - - # Raises a CoercionError with localized message - # - # @raise [CoercionError] Always raised with localized error message - def raise_coercion_error! - type = Locale.t("cmdx.types.hash") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end - - end - end -end diff --git a/lib/cmdx/coercions/integer.rb b/lib/cmdx/coercions/integer.rb deleted file mode 100644 index 287fc4851..000000000 --- a/lib/cmdx/coercions/integer.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Converts various input types to Integer format - # - # Handles conversion from strings, numbers, and other values to integers - # using Ruby's Integer() method. Raises CoercionError for values that - # cannot be converted to integers. - module Integer - - extend self - - # Converts a value to an Integer - # - # @param value [Object] The value to convert to an integer - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Integer] The converted integer value - # - # @raise [CoercionError] If the value cannot be converted to an integer - # - # @example Convert numeric strings to integers - # Integer.call("42") # => 42 - # Integer.call("-123") # => -123 - # Integer.call("0") # => 0 - # @example Convert numeric types to integers - # Integer.call(42.0) # => 42 - # Integer.call(3.14) # => 3 - # Integer.call(0.0) # => 0 - # @example Handle edge cases - # Integer.call("") # => raises CoercionError - # Integer.call(nil) # => raises CoercionError - # Integer.call("abc") # => raises CoercionError - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Integer - def call(value, options = EMPTY_HASH) - Integer(value) - rescue ArgumentError, FloatDomainError, RangeError, TypeError - type = Locale.t("cmdx.types.integer") - raise CoercionError, Locale.t("cmdx.coercions.into_an", type:) - end - - end - end -end diff --git a/lib/cmdx/coercions/rational.rb b/lib/cmdx/coercions/rational.rb deleted file mode 100644 index 42256d106..000000000 --- a/lib/cmdx/coercions/rational.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Converts various input types to Rational format - # - # Handles conversion from strings, numbers, and other values to rational - # numbers using Ruby's Rational() method. Raises CoercionError for values - # that cannot be converted to rational numbers. - module Rational - - extend self - - # Converts a value to a Rational - # - # @param value [Object] The value to convert to a rational number - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Rational] The converted rational number - # - # @raise [CoercionError] If the value cannot be converted to a rational number - # - # @example Convert numeric strings to rational numbers - # Rational.call("3/4") # => (3/4) - # Rational.call("2.5") # => (5/2) - # Rational.call("0") # => (0/1) - # @example Convert numeric types to rational numbers - # Rational.call(3.14) # => (157/50) - # Rational.call(2) # => (2/1) - # Rational.call(0.5) # => (1/2) - # @example Handle edge cases - # Rational.call("") # => (0/1) - # Rational.call(nil) # => (0/1) - # Rational.call(0) # => (0/1) - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Rational - def call(value, options = EMPTY_HASH) - Rational(value) - rescue ArgumentError, FloatDomainError, RangeError, TypeError, ZeroDivisionError - type = Locale.t("cmdx.types.rational") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end - - end - end -end diff --git a/lib/cmdx/coercions/string.rb b/lib/cmdx/coercions/string.rb deleted file mode 100644 index 44b015668..000000000 --- a/lib/cmdx/coercions/string.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Coerces values to String type using Ruby's built-in String() method. - # - # This coercion handles various input types by converting them to their - # string representation. It's a simple wrapper around Ruby's String() - # method for consistency with the CMDx coercion interface. - module String - - extend self - - # Coerces a value to String type. - # - # @param value [Object] The value to coerce to a string - # @param options [Hash] Optional configuration parameters (unused in this coercion) - # @option options [Object] :* Any configuration option (unused) - # - # @return [String] The coerced string value - # - # @raise [TypeError] If the value cannot be converted to a string - # - # @example Basic string coercion - # String.call("hello") # => "hello" - # String.call(42) # => "42" - # String.call([1, 2, 3]) # => "[1, 2, 3]" - # String.call(nil) # => "" - # String.call(true) # => "true" - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> String - def call(value, options = EMPTY_HASH) - String(value) - end - - end - end -end diff --git a/lib/cmdx/coercions/symbol.rb b/lib/cmdx/coercions/symbol.rb deleted file mode 100644 index 7fbdd3b40..000000000 --- a/lib/cmdx/coercions/symbol.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Coerces values to Symbol type using Ruby's to_sym method. - # - # This coercion handles various input types by converting them to symbols. - # It provides error handling for values that cannot be converted to symbols - # and raises appropriate CMDx coercion errors with localized messages. - module Symbol - - extend self - - # Coerces a value to Symbol type. - # - # @param value [Object] The value to coerce to a symbol - # @param options [Hash] Optional configuration parameters (unused in this coercion) - # @option options [Object] :* Any configuration option (unused) - # - # @return [Symbol] The coerced symbol value - # - # @raise [CoercionError] If the value cannot be converted to a symbol - # - # @example Basic symbol coercion - # Symbol.call("hello") # => :hello - # Symbol.call("user_id") # => :user_id - # Symbol.call("") # => :"" - # Symbol.call(:existing) # => :existing - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Symbol - def call(value, options = EMPTY_HASH) - value.to_sym - rescue NoMethodError - type = Locale.t("cmdx.types.symbol") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end - - end - end -end diff --git a/lib/cmdx/coercions/time.rb b/lib/cmdx/coercions/time.rb deleted file mode 100644 index 888580eed..000000000 --- a/lib/cmdx/coercions/time.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Coercions - # Converts various input types to Time format - # - # Handles conversion from strings, dates, and other time-like objects to Time - # using Ruby's built-in time parsing methods. Supports custom strptime formats - # and raises CoercionError for values that cannot be converted to Time. - module Time - - extend self - - # Converts a value to a Time object - # - # @param value [Object] The value to convert to a Time object - # @param options [Hash] Optional configuration parameters - # @option options [String] :strptime Custom strptime format string for parsing - # - # @return [Time] The converted Time object - # - # @raise [CoercionError] If the value cannot be converted to a Time object - # - # @example Convert time-like objects - # Time.call(Time.now) # => Time object (unchanged) - # Time.call(DateTime.now) # => Time object (converted) - # Time.call(Date.today) # => Time object (converted) - # @example Convert strings with default parsing - # Time.call("2023-12-25 10:30:00") # => Time object - # Time.call("2023-12-25") # => Time object - # Time.call("10:30:00") # => Time object - # @example Convert strings with custom format - # Time.call("25/12/2023", strptime: "%d/%m/%Y") # => Time object - # Time.call("12-25-2023", strptime: "%m-%d-%Y") # => Time object - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Time - def call(value, options = EMPTY_HASH) - return value.to_time if value.respond_to?(:to_time) - return ::Time.strptime(value, options[:strptime]) if options[:strptime] - - ::Time.parse(value) - rescue ArgumentError, TypeError - type = Locale.t("cmdx.types.time") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end - - end - end -end diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb deleted file mode 100644 index 293538a91..000000000 --- a/lib/cmdx/configuration.rb +++ /dev/null @@ -1,280 +0,0 @@ -# frozen_string_literal: true - -module CMDx - - # Configuration class that manages global settings for CMDx including middlewares, - # callbacks, coercions, validators, breakpoints, backtraces, and logging. - class Configuration - - # @rbs DEFAULT_BREAKPOINTS: Array[String] - DEFAULT_BREAKPOINTS = %w[failed].freeze - - # @rbs DEFAULT_ROLLPOINTS: Array[String] - DEFAULT_ROLLPOINTS = %w[failed].freeze - - # Returns the middleware registry for task execution. - # - # @return [MiddlewareRegistry] The middleware registry - # - # @example - # config.middlewares.register(CustomMiddleware) - # - # @rbs @middlewares: MiddlewareRegistry - attr_accessor :middlewares - - # Returns the callback registry for task lifecycle hooks. - # - # @return [CallbackRegistry] The callback registry - # - # @example - # config.callbacks.register(:before_execution, :log_start) - # - # @rbs @callbacks: CallbackRegistry - attr_accessor :callbacks - - # Returns the coercion registry for type conversions. - # - # @return [CoercionRegistry] The coercion registry - # - # @example - # config.coercions.register(:custom, CustomCoercion) - # - # @rbs @coercions: CoercionRegistry - attr_accessor :coercions - - # Returns the validator registry for attribute validation. - # - # @return [ValidatorRegistry] The validator registry - # - # @example - # config.validators.register(:email, EmailValidator) - # - # @rbs @validators: ValidatorRegistry - attr_accessor :validators - - # Returns the breakpoint statuses for task execution interruption. - # - # @return [Array] Array of status names that trigger breakpoints - # - # @example - # config.task_breakpoints = ["failed", "skipped"] - # - # @rbs @task_breakpoints: Array[String] - attr_accessor :task_breakpoints - - # Returns the breakpoint statuses for workflow execution interruption. - # - # @return [Array] Array of status names that trigger breakpoints - # - # @example - # config.workflow_breakpoints = ["failed", "skipped"] - # - # @rbs @task_breakpoints: Array[String] - # @rbs @workflow_breakpoints: Array[String] - attr_accessor :workflow_breakpoints - - # Returns the logger instance for CMDx operations. - # - # @return [Logger] The logger instance - # - # @example - # config.logger.level = Logger::DEBUG - # - # @rbs @logger: Logger - attr_accessor :logger - - # Returns whether to log backtraces for failed tasks. - # - # @return [Boolean] true if backtraces should be logged - # - # @example - # config.backtrace = true - # - # @rbs @backtrace: bool - attr_accessor :backtrace - - # Returns the proc used to clean backtraces before logging. - # - # @return [Proc, nil] The backtrace cleaner proc, or nil if not set - # - # @example - # config.backtrace_cleaner = ->(bt) { bt.first(5) } - # - # @rbs @backtrace_cleaner: (Proc | nil) - attr_accessor :backtrace_cleaner - - # Returns the proc called when exceptions occur during execution. - # - # @return [Proc, nil] The exception handler proc, or nil if not set - # - # @example - # config.exception_handler = ->(task, error) { Sentry.capture_exception(error) } - # - # @rbs @exception_handler: (Proc | nil) - attr_accessor :exception_handler - - # Returns the statuses that trigger a task execution rollback. - # - # @return [Array] Array of status names that trigger rollback - # - # @example - # config.rollback_on = ["failed", "skipped"] - # - # @rbs @rollback_on: Array[String] - attr_accessor :rollback_on - - # Returns whether to include context data in hash representation output. - # - # @return [Boolean] true if context should be included (default: false) - # - # @example - # config.dump_context = true - # - # @rbs @dump_context: bool - attr_accessor :dump_context - - # Returns whether to freeze task results after execution. - # Set to false in test environments to allow stubbing on result objects. - # - # @return [Boolean] true if results should be frozen (default: true) - # - # @example - # config.freeze_results = false - # - # @rbs @freeze_results: bool - attr_accessor :freeze_results - - # Returns the default locale used for built-in translation lookups. - # Must match the basename of a YAML file in lib/locales/ (e.g. "en", "es", "ja"). - # - # @return [String] The locale identifier (default: "en") - # - # @example - # config.default_locale = "es" - # - # @rbs @locale: String - attr_accessor :default_locale - - # Initializes a new Configuration instance with default values. - # - # Creates new registry instances for middlewares, callbacks, coercions, and - # validators. Sets default breakpoints and configures a basic logger. - # - # @return [Configuration] a new Configuration instance - # - # @example - # config = Configuration.new - # config.middlewares.class # => MiddlewareRegistry - # config.task_breakpoints # => ["failed"] - # - # @rbs () -> void - def initialize - @middlewares = MiddlewareRegistry.new - @callbacks = CallbackRegistry.new - @coercions = CoercionRegistry.new - @validators = ValidatorRegistry.new - - @task_breakpoints = DEFAULT_BREAKPOINTS - @workflow_breakpoints = DEFAULT_BREAKPOINTS - @rollback_on = DEFAULT_ROLLPOINTS - @dump_context = false - @freeze_results = true - @default_locale = "en" - - @backtrace = false - @backtrace_cleaner = nil - @exception_handler = nil - - @logger = Logger.new( - $stdout, - progname: "cmdx", - formatter: LogFormatters::Line.new, - level: Logger::INFO - ) - end - - # Converts the configuration to a hash representation. - # - # @return [Hash{Symbol => Object}] hash containing all configuration values - # - # @example - # config = Configuration.new - # config.to_h - # # => { middlewares: #, callbacks: #, ... } - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - { - middlewares: @middlewares, - callbacks: @callbacks, - coercions: @coercions, - validators: @validators, - task_breakpoints: @task_breakpoints, - workflow_breakpoints: @workflow_breakpoints, - rollback_on: @rollback_on, - freeze_results: @freeze_results, - default_locale: @default_locale, - backtrace: @backtrace, - backtrace_cleaner: @backtrace_cleaner, - exception_handler: @exception_handler, - dump_context: @dump_context, - logger: @logger - } - end - - end - - extend self - - # Returns the global configuration instance, creating it if it doesn't exist. - # - # @return [Configuration] the global configuration instance - # - # @example - # config = CMDx.configuration - # config.middlewares # => # - # - # @rbs () -> Configuration - def configuration - return @configuration if @configuration - - @configuration ||= Configuration.new - end - - # Configures CMDx using a block that receives the configuration instance. - # - # @yield [Configuration] the configuration instance to configure - # - # @return [Configuration] the configured configuration instance - # - # @raise [ArgumentError] when no block is provided - # - # @example - # CMDx.configure do |config| - # config.task_breakpoints = ["failed", "skipped"] - # config.logger.level = Logger::DEBUG - # end - # - # @rbs () { (Configuration) -> void } -> Configuration - def configure - raise ArgumentError, "block required" unless block_given? - - config = configuration - yield(config) - config - end - - # Resets the global configuration to a new instance with default values. - # - # @return [Configuration] the new configuration instance - # - # @example - # CMDx.reset_configuration! - # # Configuration is now reset to defaults - # - # @rbs () -> Configuration - def reset_configuration! - @configuration = Configuration.new - end - -end diff --git a/lib/cmdx/context.rb b/lib/cmdx/context.rb deleted file mode 100644 index 32e7adf4c..000000000 --- a/lib/cmdx/context.rb +++ /dev/null @@ -1,310 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # A hash-like context object that provides a flexible way to store and access - # key-value pairs during task execution. Keys are automatically converted to - # symbols for consistency. - # - # The Context class extends Forwardable to delegate common hash methods and - # provides additional convenience methods for working with context data. - class Context - - extend Forwardable - - # Returns the internal hash storing context data. - # - # @return [Hash{Symbol => Object}] The internal hash table - # - # @example - # context.table # => { name: "John", age: 30 } - # - # @rbs @table: Hash[Symbol, untyped] - attr_reader :table - alias to_h table - - def_delegators :table, :keys, :values, :each, :each_key, :each_value, :map - - # Creates a new Context instance from the given arguments. - # - # @param args [Hash, Object] arguments to initialize the context with - # @option args [Object] :key the key-value pairs to store in the context - # - # @return [Context] a new Context instance - # - # @raise [ArgumentError] when args doesn't respond to `to_h` or `to_hash` - # - # @example - # context = Context.new(name: "John", age: 30) - # context[:name] # => "John" - # - # @rbs (untyped args) -> void - def initialize(args = {}) - @table = - if args.respond_to?(:to_hash) - args.to_hash - elsif args.respond_to?(:to_h) - args.to_h - else - raise ArgumentError, "must respond to `to_h` or `to_hash`" - end.transform_keys(&:to_sym) - end - - # Builds a Context instance, reusing existing unfrozen contexts when possible. - # - # @param context [Context, Object] the context to build from - # @option context [Object] :key the key-value pairs to store in the context - # - # @return [Context] a Context instance, either new or reused - # - # @example - # existing = Context.new(name: "John") - # built = Context.build(existing) # reuses existing context - # built.object_id == existing.object_id # => true - # - # @rbs (untyped context) -> Context - def self.build(context = {}) - if context.is_a?(self) && !context.frozen? - context - elsif context.respond_to?(:context) - build(context.context) - else - new(context) - end - end - - # Retrieves a value from the context by key. - # - # @param key [String, Symbol] the key to retrieve - # - # @return [Object, nil] the value associated with the key, or nil if not found - # - # @example - # context = Context.new(name: "John") - # context[:name] # => "John" - # context["name"] # => "John" (automatically converted to symbol) - # - # @rbs ((String | Symbol) key) -> untyped - def [](key) - table[key.to_sym] - end - - # Stores a key-value pair in the context. - # - # @param key [String, Symbol] the key to store - # @param value [Object] the value to store - # - # @return [Object] the stored value - # - # @example - # context = Context.new - # context.store(:name, "John") - # context[:name] # => "John" - # - # @rbs ((String | Symbol) key, untyped value) -> untyped - def store(key, value) - table[key.to_sym] = value - end - alias []= store - - # Fetches a value from the context by key, with optional default value. - # - # @param key [String, Symbol] the key to fetch - # - # @yield [key] a block to compute the default value - # - # @return [Object] the value associated with the key, or the default/default block result - # - # @example - # context = Context.new(name: "John") - # context.fetch(:name) # => "John" - # context.fetch(:age, 25) # => 25 - # context.fetch(:city) { |key| "Unknown #{key}" } # => "Unknown city" - # - # @rbs ((String | Symbol) key, *untyped) ?{ ((String | Symbol)) -> untyped } -> untyped - def fetch(key, ...) - table.fetch(key.to_sym, ...) - end - - # Fetches a value from the context by key, or stores and returns a default value if not found. - # - # @param key [String, Symbol] the key to fetch or store - # @param value [Object] the default value to store if key is not found - # - # @yield [key] a block to compute the default value to store - # - # @return [Object] the existing value if key is found, otherwise the stored default value - # - # @example - # context = Context.new(name: "John") - # context.fetch_or_store(:name, "Default") # => "John" (existing value) - # context.fetch_or_store(:age, 25) # => 25 (stored and returned) - # context.fetch_or_store(:city) { |key| "Unknown #{key}" } # => "Unknown city" (stored and returned) - # - # @rbs ((String | Symbol) key, ?untyped value) ?{ () -> untyped } -> untyped - def fetch_or_store(key, value = nil) - table.fetch(key.to_sym) do - table[key.to_sym] = block_given? ? yield : value - end - end - - # Merges the given arguments into the current context, modifying it in place. - # - # @param args [Hash, Object] arguments to merge into the context - # @option args [Object] :key the key-value pairs to merge - # - # @return [Context] self for method chaining - # - # @example - # context = Context.new(name: "John") - # context.merge!(age: 30, city: "NYC") - # context.to_h # => {name: "John", age: 30, city: "NYC"} - # - # @rbs (?untyped args) -> self - def merge!(args = EMPTY_HASH) - table.merge!(args.to_h.transform_keys(&:to_sym)) - self - end - alias merge merge! - - # Deletes a key-value pair from the context. - # - # @param key [String, Symbol] the key to delete - # - # @yield [key] a block to handle the case when key is not found - # - # @return [Object, nil] the deleted value, or the block result if key not found - # - # @example - # context = Context.new(name: "John", age: 30) - # context.delete!(:age) # => 30 - # context.delete!(:city) { |key| "Key #{key} not found" } # => "Key city not found" - # - # @rbs ((String | Symbol) key) ?{ ((String | Symbol)) -> untyped } -> untyped - def delete!(key, &) - table.delete(key.to_sym, &) - end - alias delete delete! - - # Clears all key-value pairs from the context. - # - # @return [Context] self for method chaining - # - # @example - # context = Context.new(name: "John") - # context.clear! - # context.to_h # => {} - # - # @rbs () -> self - def clear! - table.clear - self - end - alias clear clear! - - # Compares this context with another object for equality. - # - # @param other [Object] the object to compare with - # - # @return [Boolean] true if other is a Context with the same data - # - # @example - # context1 = Context.new(name: "John") - # context2 = Context.new(name: "John") - # context1 == context2 # => true - # - # @rbs (untyped other) -> bool - def eql?(other) - other.is_a?(self.class) && (table == other.to_h) - end - alias == eql? - - # Checks if the context contains a specific key. - # - # @param key [String, Symbol] the key to check - # - # @return [Boolean] true if the key exists in the context - # - # @example - # context = Context.new(name: "John") - # context.key?(:name) # => true - # context.key?(:age) # => false - # - # @rbs ((String | Symbol) key) -> bool - def key?(key) - table.key?(key.to_sym) - end - - # Digs into nested structures using the given keys. - # - # @param key [String, Symbol] the first key to dig with - # @param keys [Array] additional keys for deeper digging - # - # @return [Object, nil] the value found by digging, or nil if not found - # - # @example - # context = Context.new(user: {profile: {name: "John"}}) - # context.dig(:user, :profile, :name) # => "John" - # context.dig(:user, :profile, :age) # => nil - # - # @rbs ((String | Symbol) key, *(String | Symbol) keys) -> untyped - def dig(key, *keys) - table.dig(key.to_sym, *keys) - end - - # Converts the context to a string representation. - # - # @return [String] a formatted string representation of the context data - # - # @example - # context = Context.new(name: "John", age: 30) - # context.to_s # => "name: John, age: 30" - # - # @rbs () -> String - def to_s - Utils::Format.to_str(to_h) - end - - private - - # Handles method calls that don't match defined methods. - # Supports assignment-style calls (e.g., `name=`) and key access. - # - # @param method_name [Symbol] the method name that was called - # @param args [Array] arguments passed to the method - # @param _kwargs [Hash] keyword arguments (unused) - # @option _kwargs [Object] :* Any keyword arguments (unused) - # - # @yield [Object] optional block - # - # @return [Object] the result of the method call - # - # @rbs (Symbol method_name, *untyped args, **untyped _kwargs) ?{ () -> untyped } -> untyped - def method_missing(method_name, *args, **_kwargs, &) - if method_name.end_with?("=") - store(method_name.name.chop, args.first) - else - table[method_name] - end - end - - # Checks if the object responds to a given method. - # Supports both getter access for existing keys and setter methods. - # - # @param method_name [Symbol] the method name to check - # @param include_private [Boolean] whether to include private methods - # - # @return [Boolean] true if the method can be called - # - # @example - # context = Context.new(name: "John") - # context.respond_to?(:name) # => true - # context.respond_to?(:name=) # => true - # context.respond_to?(:age) # => false - # - # @rbs (Symbol method_name, ?bool include_private) -> bool - def respond_to_missing?(method_name, include_private = false) - key?(method_name) || method_name.end_with?("=") || super - end - - end -end diff --git a/lib/cmdx/deprecator.rb b/lib/cmdx/deprecator.rb deleted file mode 100644 index ba1fac7d1..000000000 --- a/lib/cmdx/deprecator.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Handles deprecation warnings and restrictions for tasks. - # - # The Deprecator module provides functionality to restrict usage of deprecated - # tasks based on configuration settings. It supports different deprecation - # behaviors including warnings, logging, and errors. - module Deprecator - - extend self - - # @rbs RAISE_REGEXP: Regexp - RAISE_REGEXP = /\Araise\z/ - private_constant :RAISE_REGEXP - - # @rbs LOG_REGEXP: Regexp - LOG_REGEXP = /\Alog\z/ - private_constant :LOG_REGEXP - - # @rbs WARN_REGEXP: Regexp - WARN_REGEXP = /\Awarn\z/ - private_constant :WARN_REGEXP - - # @rbs EVAL: Proc - EVAL = proc do |target, callable| - case callable - when NilClass, FalseClass, TrueClass then !!callable - when RAISE_REGEXP, LOG_REGEXP, WARN_REGEXP then callable - when Symbol then target.send(callable) - when Proc then target.instance_eval(&callable) - else - raise "cannot evaluate #{callable.inspect}" unless callable.respond_to?(:call) - - callable.call(target) - end - end.freeze - private_constant :EVAL - - # Restricts task usage based on deprecation settings. - # - # @param task [Object] The task object to check for deprecation - # @option task.class.settings.deprecate [Symbol, Proc, String, Boolean] - # The deprecation configuration for the task - # @option task.class.settings.deprecate :raise Raises DeprecationError - # @option task.class.settings.deprecate :log Logs deprecation warning - # @option task.class.settings.deprecate :warn Outputs warning to stderr - # @option task.class.settings.deprecate true Raises DeprecationError - # @option task.class.settings.deprecate false No action taken - # @option task.class.settings.deprecate nil No action taken - # - # @raise [DeprecationError] When deprecation type is :raise or true - # @raise [RuntimeError] When deprecation type is unknown - # - # @example - # class MyTask - # settings(deprecate: :warn) - # end - # - # MyTask.new # => [MyTask] DEPRECATED: migrate to a replacement or discontinue use - # - # @rbs (Task task) -> void - def restrict(task) - setting = task.class.settings.deprecate - return unless setting - - case type = EVAL.call(task, setting) - when NilClass, FalseClass then nil # Do nothing - when TrueClass, RAISE_REGEXP then raise DeprecationError, "#{task.class.name} usage prohibited" - when LOG_REGEXP then task.logger.warn { "DEPRECATED: migrate to a replacement or discontinue use" } - when WARN_REGEXP then warn("[#{task.class.name}] DEPRECATED: migrate to a replacement or discontinue use", category: :deprecated) - else raise "unknown deprecation type #{type.inspect}" - end - end - - end -end diff --git a/lib/cmdx/errors.rb b/lib/cmdx/errors.rb deleted file mode 100644 index eb907d72b..000000000 --- a/lib/cmdx/errors.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Collection of validation and execution errors organized by attribute. - # Provides methods to add, query, and format error messages for different - # attributes in a task or workflow execution. - class Errors - - extend Forwardable - - # Returns the internal hash of error messages by attribute. - # - # @return [Hash{Symbol => Set}] Hash mapping attribute names to error message sets - # - # @example - # errors.messages # => { email: # } - # - # @rbs @messages: Hash[Symbol, Set[String]] - attr_reader :messages - - def_delegators :messages, :any?, :clear, :empty?, :size - - # Initialize a new error collection. - # - # @rbs () -> void - def initialize - @messages = {} - end - - # Add an error message for a specific attribute. - # - # @param attribute [Symbol] The attribute name associated with the error - # @param message [String] The error message to add - # - # @example - # errors = CMDx::Errors.new - # errors.add(:email, "must be valid format") - # errors.add(:email, "cannot be blank") - # - # @rbs (Symbol attribute, String message) -> void - def add(attribute, message) - return if message.empty? - - messages[attribute] ||= Set.new - messages[attribute] << message - end - - # Check if there are any errors for a specific attribute. - # - # @param attribute [Symbol] The attribute name to check for errors - # - # @return [Boolean] true if the attribute has errors, false otherwise - # - # @example - # errors.for?(:email) # => true - # errors.for?(:name) # => false - # - # @rbs (Symbol attribute) -> bool - def for?(attribute) - set = messages[attribute] - !set.nil? && !set.empty? - end - - # Convert errors to a hash format with arrays of full messages. - # - # @return [Hash{Symbol => Array}] Hash with attribute keys and message arrays - # - # @example - # errors.full_messages # => { email: ["email must be valid format", "email cannot be blank"] } - # - # @rbs () -> Hash[Symbol, Array[String]] - def full_messages - messages.each_with_object({}) do |(attribute, messages), hash| - hash[attribute] = messages.map { |message| "#{attribute} #{message}" } - end - end - - # Convert errors to a hash format with arrays of messages. - # - # @return [Hash{Symbol => Array}] Hash with attribute keys and message arrays - # - # @example - # errors.to_h # => { email: ["must be valid format", "cannot be blank"] } - # - # @rbs () -> Hash[Symbol, Array[String]] - def to_h - messages.transform_values(&:to_a) - end - - # Convert errors to a hash format with optional full messages. - # - # @param full [Boolean] Whether to include full messages with attribute names - # @return [Hash{Symbol => Array}] Hash with attribute keys and message arrays - # - # @example - # errors.to_hash # => { email: ["must be valid format", "cannot be blank"] } - # errors.to_hash(true) # => { email: ["email must be valid format", "email cannot be blank"] } - # - # @rbs (?bool full) -> Hash[Symbol, Array[String]] - def to_hash(full = false) - full ? full_messages : to_h - end - - # Convert errors to a human-readable string format. - # - # @return [String] Formatted error messages joined with periods - # - # @example - # errors.to_s # => "email must be valid format. email cannot be blank" - # - # @rbs () -> String - def to_s - full_messages.values.flatten.join(". ") - end - - end -end diff --git a/lib/cmdx/exception.rb b/lib/cmdx/exception.rb deleted file mode 100644 index 9d7151840..000000000 --- a/lib/cmdx/exception.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module CMDx - - # Base exception class for all CMDx-related errors. - # - # This serves as the root exception class for all errors raised by the CMDx - # framework. It inherits from StandardError and provides a common base for - # handling CMDx-specific exceptions. - Exception = Error = Class.new(StandardError) - - # Raised when attribute coercion fails during task execution. - # - # This error occurs when a attribute value cannot be converted to the expected - # type using the registered coercion handlers. It indicates that the provided - # value is incompatible with the attribute's defined type. - CoercionError = Class.new(Error) - - # Raised when a deprecated task is used. - # - # This error occurs when a deprecated task is called. It indicates that the - # task is no longer supported and should be replaced with a newer alternative. - DeprecationError = Class.new(Error) - - # Raised when an abstract method is called without being implemented. - # - # This error occurs when a subclass fails to implement required abstract - # methods such as 'task' in tasks. It indicates incomplete implementation - # of required functionality. - UndefinedMethodError = Class.new(Error) - - # Error raised when task execution exceeds the configured timeout limit. - # - # This error occurs when a task takes longer to execute than the specified - # time limit. Timeout errors are raised by Ruby's Timeout module and are - # caught by the middleware to properly fail the task with timeout information. - TimeoutError = Class.new(Interrupt) - - # Raised when attribute validation fails during task execution. - # - # This error occurs when a attribute value doesn't meet the validation criteria - # defined by the validator. It indicates that the provided value violates - # business rules or data integrity constraints. - ValidationError = Class.new(Error) - -end diff --git a/lib/cmdx/executor.rb b/lib/cmdx/executor.rb deleted file mode 100644 index 727d7d032..000000000 --- a/lib/cmdx/executor.rb +++ /dev/null @@ -1,379 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Executes CMDx tasks with middleware support, error handling, and lifecycle management. - # - # The Executor class is responsible for orchestrating task execution, including - # pre-execution validation, execution with middleware, post-execution callbacks, - # and proper error handling for different types of failures. - class Executor - - extend Forwardable - - # @rbs STATE_CALLBACKS: Hash[String, Symbol] - STATE_CALLBACKS = Result::STATES.to_h { |s| [s, :"on_#{s}"] }.freeze - private_constant :STATE_CALLBACKS - - # @rbs STATUS_CALLBACKS: Hash[String, Symbol] - STATUS_CALLBACKS = Result::STATUSES.to_h { |s| [s, :"on_#{s}"] }.freeze - private_constant :STATUS_CALLBACKS - - # Returns the task being executed. - # - # @return [Task] The task instance - # - # @example - # executor.task.id # => "abc123" - # - # @rbs @task: Task - attr_reader :task - - def_delegators :task, :result, :resolver - - # @param task [CMDx::Task] The task to execute - # - # @return [CMDx::Executor] A new executor instance - # - # @example - # executor = CMDx::Executor.new(my_task) - # - # @rbs (Task task) -> void - def initialize(task) - @task = task - end - - # Executes a task with optional exception raising. - # - # @param task [CMDx::Task] The task to execute - # @param raise [Boolean] Whether to raise exceptions (default: false) - # - # @return [CMDx::Result] The execution result - # - # @raise [StandardError] When raise is true and execution fails - # - # @example - # CMDx::Executor.execute(my_task) - # CMDx::Executor.execute(my_task, raise: true) - # - # @rbs (Task task, raise: bool) -> Result - def self.execute(task, raise: false) - instance = new(task) - raise ? instance.execute! : instance.execute - end - - # Executes the task with graceful error handling. - # - # @return [CMDx::Result] The execution result - # - # @example - # executor = CMDx::Executor.new(my_task) - # result = executor.execute - # - # @rbs () -> Result - def execute - task.class.settings.middlewares.call!(task) do - pre_execution! unless @pre_execution - execution! - verify_context_returns! - rescue UndefinedMethodError => e - raise_exception(e) - rescue Fault => e - resolver.throw!(e.result, halt: false, cause: e) - rescue StandardError => e - retry if retry_execution?(e) - resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) - task.class.settings.exception_handler&.call(task, e) - ensure - resolver.executed! - post_execution! - end - - verify_middleware_yield! - finalize_execution! - end - - # Executes the task with exception raising on failure. - # - # @return [CMDx::Result] The execution result - # - # @raise [StandardError] When execution fails - # - # @example - # executor = CMDx::Executor.new(my_task) - # result = executor.execute! - # - # @rbs () -> Result - def execute! - task.class.settings.middlewares.call!(task) do - pre_execution! unless @pre_execution - execution! - verify_context_returns! - rescue UndefinedMethodError => e - raise_exception(e) - rescue Fault => e - resolver.throw!(e.result, halt: false, cause: e) - - if halt_execution?(e) - raise_exception(e) - else - resolver.executed! - post_execution! - end - rescue StandardError => e - retry if retry_execution?(e) - resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) - raise_exception(e) - else - resolver.executed! - post_execution! - end - - verify_middleware_yield! - finalize_execution! - end - - protected - - # Determines if execution should halt based on breakpoint configuration. - # Returns false when the result was created with +strict: false+. - # - # @param exception [Exception] The exception that occurred - # - # @return [Boolean] Whether execution should halt - # - # @rbs (Exception exception) -> bool - def halt_execution?(exception) - return false unless exception.result.strict? - - @halt_statuses ||= Utils::Normalize.statuses( - task.class.settings.breakpoints || - task.class.settings.task_breakpoints - ).freeze - - @halt_statuses.include?(exception.result.status) - end - - # Determines if execution should be retried based on retry configuration. - # - # @param exception [Exception] The exception that occurred - # - # @return [Boolean] Whether execution should be retried - # - # @rbs (Exception exception) -> bool - def retry_execution?(exception) - @retry ||= Retry.new(task) - - return false unless @retry.available? && @retry.remaining? - return false unless @retry.exception?(exception) - - result.retries += 1 - - task.logger.warn do - reason = Utils::Normalize.exception(exception) - task.to_h.merge!(reason:, remaining_retries: @retry.remaining) - end - - task.errors.clear - - wait = @retry.wait - sleep(wait) if wait.positive? - - true - end - - # Raises an exception and clears the chain. - # - # @param exception [Exception] The exception to raise - # - # @raise [Exception] The provided exception - # - # @rbs (Exception exception) -> void - def raise_exception(exception) - Chain.clear - - raise(exception) - end - - # Invokes callbacks of a specific type for the task. - # - # @param type [Symbol] The type of callback to invoke - # - # @return [void] - # - # @example - # invoke_callbacks(:before_execution) - # - # @rbs (Symbol type) -> void - def invoke_callbacks(type) - task.class.settings.callbacks.invoke(type, task) - end - - private - - # Performs pre-execution tasks including validation and attribute verification. - # - # @rbs () -> void - def pre_execution! - @pre_execution = true - - invoke_callbacks(:before_validation) - - task.class.settings.attributes.define_and_verify(task) - return if task.errors.empty? - - resolver.fail!( - Locale.t("cmdx.faults.invalid"), - source: :validation, - errors: { - full_message: task.errors.to_s, - messages: task.errors.to_h - } - ) - end - - # Executes the main task logic. - # Wraps task.work in catch(:cmdx_halt) so that success! can halt early. - # - # @rbs () -> void - def execution! - invoke_callbacks(:before_execution) - - resolver.executing! - catch(:cmdx_halt) { task.work } - end - - # Verifies that all declared returns are present in the context after execution. - # - # @rbs () -> void - def verify_context_returns! - return unless result.success? - - returns = Utils::Wrap.array(task.class.settings.returns) - missing = returns.reject { |name| task.context.key?(name) } - return if missing.empty? - - missing.each { |name| task.errors.add(name, Locale.t("cmdx.returns.missing")) } - - resolver.fail!( - Locale.t("cmdx.faults.invalid"), - source: :context, - errors: { - full_message: task.errors.to_s, - messages: task.errors.to_h - } - ) - end - - # Performs post-execution tasks including callback invocation. - # - # @rbs () -> void - def post_execution! - return if task.class.settings.callbacks.empty? - - invoke_callbacks(STATE_CALLBACKS[result.state]) - invoke_callbacks(:on_executed) if result.executed? - - invoke_callbacks(STATUS_CALLBACKS[result.status]) - invoke_callbacks(:on_good) if result.good? - invoke_callbacks(:on_bad) if result.bad? - end - - # Detects if middleware swallowed the block without yielding. - # When this happens the result is still in the initialized state. - # - # @rbs () -> void - def verify_middleware_yield! - return unless result.initialized? - - resolver.fail!( - Locale.t("cmdx.faults.invalid"), - halt: false, - source: :middleware - ) - resolver.executed! - end - - # Finalizes execution by freezing the task, logging results, and rolling back work. - # - # @rbs () -> void - def finalize_execution! - log_execution! - log_backtrace! if task.class.settings.backtrace - - rollback_execution! - freeze_execution! - clear_chain! - end - - # Logs the execution result at the configured log level. - # - # @rbs () -> void - def log_execution! - task.logger.info { result.to_h } - end - - # Logs the backtrace of the exception if the task failed. - # - # @rbs () -> void - def log_backtrace! - return unless result.failed? - - exception = result.caused_failure&.cause - return if exception.nil? || exception.is_a?(Fault) - - task.logger.error do - Utils::Normalize.exception(exception) << "\n" << - if (cleaner = task.class.settings.backtrace_cleaner) - cleaner.call(exception.backtrace).join("\n\t") - else - exception.full_message(highlight: false) - end - end - end - - # Freezes the task and its associated objects to prevent modifications. - # - # @rbs () -> void - def freeze_execution! - # Stubbing on frozen objects is not allowed in most test environments. - return unless CMDx.configuration.freeze_results - - task.freeze - resolver.freeze - result.freeze - - # Freezing the context and chain can only be done once the outer-most - # task has completed. - return unless result.index.zero? - - task.context.freeze - task.chain.freeze - end - - # Clears the chain if the task is the outermost (top-level) task - # and the current thread's chain is the same instance this task belongs to. - # - # @rbs () -> void - def clear_chain! - return unless result.index.zero? - return unless Chain.current.equal?(task.chain) - - Chain.clear - end - - # Rolls back the work of a task. - # - # @rbs () -> void - def rollback_execution! - return if result.rolled_back? - return unless task.respond_to?(:rollback) - - @rollback_statuses ||= Utils::Normalize.statuses(task.class.settings.rollback_on).freeze - return unless @rollback_statuses.include?(result.status) - - result.rolled_back = true - task.rollback - end - - end -end diff --git a/lib/cmdx/fault.rb b/lib/cmdx/fault.rb deleted file mode 100644 index d43191754..000000000 --- a/lib/cmdx/fault.rb +++ /dev/null @@ -1,109 +0,0 @@ -# frozen_string_literal: true - -module CMDx - - # Base fault class for handling task execution failures and interruptions. - # - # Faults represent error conditions that occur during task execution, providing - # a structured way to handle and categorize different types of failures. - # Each fault contains a reference to the result object that caused the fault. - class Fault < Error - - extend Forwardable - - # Returns the result that caused this fault. - # - # @return [Result] The result instance - # - # @example - # fault.result.reason # => "Validation failed" - # - # @rbs @result: Result - attr_reader :result - - def_delegators :result, :task, :context, :chain - - # Initialize a new fault with the given result. - # - # @param result [Result] the result object that caused this fault - # - # @raise [ArgumentError] if result is nil or invalid - # - # @example - # fault = Fault.new(task_result) - # fault.result.reason # => "Task validation failed" - # - # @rbs (Result result) -> void - def initialize(result) - @result = result - - super(result.reason) - end - - class << self - - # Create a fault class that matches specific task types. - # - # @param tasks [Array] array of task classes to match against - # - # @return [Class] a new fault class that matches the specified tasks - # - # @example - # Fault.for?(UserTask, AdminUserTask) - # # => true if fault.task is a UserTask or AdminUserTask - # - # @rbs (*Class tasks) -> Class - def for?(*tasks) - temp_fault = Class.new(self) do - def self.===(other) - other.is_a?(superclass) && @tasks.any? { |task| other.task.is_a?(task) } - end - end - - temp_fault.tap { |c| c.instance_variable_set(:@tasks, tasks) } - end - - # Create a fault class that matches based on a custom block. - # - # @param block [Proc] block that determines if a fault matches - # - # @return [Class] a new fault class that matches based on the block - # - # @raise [ArgumentError] if no block is provided - # - # @example - # Fault.matches? { |fault| fault.result.metadata[:critical] } - # # => true if fault has critical metadata - # - # @rbs () { (Fault) -> bool } -> Class - def matches?(&block) - raise ArgumentError, "block required" unless block_given? - - temp_fault = Class.new(self) do - def self.===(other) - other.is_a?(superclass) && @block.call(other) - end - end - - temp_fault.tap { |c| c.instance_variable_set(:@block, block) } - end - - end - - end - - # Fault raised when a task is intentionally skipped during execution. - # - # This fault occurs when a task determines it should not execute based on - # its current context or conditions. Skipped tasks are not considered failures - # but rather intentional bypasses of task execution logic. - SkipFault = Class.new(Fault) - - # Fault raised when a task execution fails due to errors or validation failures. - # - # This fault occurs when a task encounters an error condition, validation failure, - # or any other condition that prevents successful completion. Failed tasks indicate - # that the intended operation could not be completed successfully. - FailFault = Class.new(Fault) - -end diff --git a/lib/cmdx/identifier.rb b/lib/cmdx/identifier.rb deleted file mode 100644 index 671f5e4d3..000000000 --- a/lib/cmdx/identifier.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Generates unique identifiers for tasks, workflows, and other CMDx components. - # - # The Identifier module provides a consistent way to generate unique identifiers - # across the CMDx system, with fallback support for different Ruby versions. - module Identifier - - extend self - - # Generates a unique identifier string. - # - # @return [String] A unique identifier string (UUID v7 if available, otherwise UUID v4) - # - # @raise [StandardError] If SecureRandom is unavailable or fails to generate an identifier - # - # @example Generate a unique identifier - # CMDx::Identifier.generate - # # => "01890b2c-1234-5678-9abc-def123456789" - # - # @rbs () -> String - if SecureRandom.respond_to?(:uuid_v7) - def generate = SecureRandom.uuid_v7 - else - def generate = SecureRandom.uuid - end - - end -end diff --git a/lib/cmdx/locale.rb b/lib/cmdx/locale.rb deleted file mode 100644 index 5d1bf3847..000000000 --- a/lib/cmdx/locale.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Provides internationalization and localization support for CMDx. - # Handles translation lookups with fallback to the configured locale's - # default messages when I18n gem is not available. - module Locale - - extend self - - # Translates a key to the current locale with optional interpolation. - # Falls back to the configured locale's YAML file translations if - # I18n gem is unavailable. - # - # @param key [String, Symbol] The translation key (supports dot notation) - # @param options [Hash] Translation options - # @option options [String] :default Fallback message if translation missing - # @option options [String] :locale Target locale (when I18n available) - # @option options [Hash] :scope Translation scope (when I18n available) - # @option options [Object] :* Any other options passed to I18n.t or string interpolation - # - # @return [String] The translated message - # - # @raise [ArgumentError] When interpolation fails due to missing keys - # - # @example Basic translation - # Locale.translate("errors.invalid_input") - # # => "Invalid input provided" - # @example With interpolation - # Locale.translate("welcome.message", name: "John") - # # => "Welcome, John!" - # @example With fallback - # Locale.translate("missing.key", default: "Custom fallback message") - # # => "Custom fallback message" - # - # @rbs ((String | Symbol) key, **untyped options) -> String - def translate(key, **options) - options[:default] ||= translation_default(key) - return ::I18n.t(key, **options) if defined?(::I18n) - - case message = options.delete(:default) - when String then message % options - when NilClass then "Translation missing: #{key}" - else message - end - end - - # @see #translate - alias t translate - - private - - # Resolves and caches the default translation for a key by digging - # into the configured locale's YAML translations. - # - # @param key [String, Symbol] The translation key - # - # @return [String, nil] The resolved translation or nil - # - # @rbs ((String | Symbol) key) -> String? - def translation_default(key) - tkey = "#{CMDx.configuration.default_locale}.#{key}" - - @translation_defaults ||= {} - return @translation_defaults[tkey] if @translation_defaults.key?(tkey) - - @default_translations ||= begin - path = CMDx.gem_path.join("lib/locales/#{CMDx.configuration.default_locale}.yml") - raise ArgumentError, "locale file not found: #{path}" unless path.exist? - - YAML.load_file(path).freeze - end - - @translation_defaults[tkey] = @default_translations.dig(*tkey.split(".")) - end - - end -end diff --git a/lib/cmdx/log_formatters/json.rb b/lib/cmdx/log_formatters/json.rb deleted file mode 100644 index 3a4b60672..000000000 --- a/lib/cmdx/log_formatters/json.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module LogFormatters - # Formats log messages as JSON for structured logging - # - # This formatter converts log entries into JSON format with standardized fields - # including severity, timestamp, program name, process ID, and formatted message. - # The output is suitable for log aggregation systems and structured analysis. - class JSON - - # Formats a log entry as a JSON string - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content - # - # @return [String] A JSON-formatted log entry with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => '{"severity":"INFO","timestamp":"2024-01-15T10:30:45.123456Z","progname":"MyApp","pid":12345,"message":"User logged in"}\n' - # - # @rbs (String severity, Time time, String? progname, String message) -> String - def call(severity, time, progname, message) - hash = { - severity:, - timestamp: time.utc.iso8601(6), - progname:, - pid: Process.pid, - message: Utils::Format.to_log(message) - } - - ::JSON.dump(hash) << "\n" - end - - end - end -end diff --git a/lib/cmdx/log_formatters/key_value.rb b/lib/cmdx/log_formatters/key_value.rb deleted file mode 100644 index 87e0f4087..000000000 --- a/lib/cmdx/log_formatters/key_value.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module LogFormatters - # Formats log messages as key-value pairs for structured logging - # - # This formatter converts log entries into key-value format with standardized fields - # including severity, timestamp, program name, process ID, and formatted message. - # The output is suitable for log parsing tools and human-readable structured logs. - class KeyValue - - # Formats a log entry as a key-value string - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content - # - # @return [String] A key-value formatted log entry with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => "severity=INFO timestamp=2024-01-15T10:30:45.123456Z progname=MyApp pid=12345 message=User logged in\n" - # - # @rbs (String severity, Time time, String? progname, String message) -> String - def call(severity, time, progname, message) - hash = { - severity:, - timestamp: time.utc.iso8601(6), - progname:, - pid: Process.pid, - message: Utils::Format.to_log(message) - } - - Utils::Format.to_str(hash) << "\n" - end - - end - end -end diff --git a/lib/cmdx/log_formatters/line.rb b/lib/cmdx/log_formatters/line.rb deleted file mode 100644 index a58b32ad4..000000000 --- a/lib/cmdx/log_formatters/line.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module LogFormatters - # Formats log messages as single-line text for human-readable logging - # - # This formatter converts log entries into a compact single-line format with - # severity abbreviation, ISO8601 timestamp, process ID, and formatted message. - # The output is optimized for human readability and traditional log file formats. - class Line - - # Formats a log entry as a single-line string - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content - # - # @return [String] A single-line formatted log entry with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => "I, [2024-01-15T10:30:45.123456Z #12345] INFO -- MyApp: User logged in\n" - # - # @rbs (String severity, Time time, String? progname, String message) -> String - def call(severity, time, progname, message) - "#{severity[0]}, [#{time.utc.iso8601(6)} ##{Process.pid}] #{severity} -- #{progname}: #{message}\n" - end - - end - end -end diff --git a/lib/cmdx/log_formatters/logstash.rb b/lib/cmdx/log_formatters/logstash.rb deleted file mode 100644 index 4df2b9082..000000000 --- a/lib/cmdx/log_formatters/logstash.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module LogFormatters - # Formats log messages as Logstash-compatible JSON for structured logging - # - # This formatter converts log entries into Logstash-compatible JSON format with - # standardized fields including @version, @timestamp, severity, program name, - # process ID, and formatted message. The output follows Logstash event format - # specifications for seamless integration with ELK stack and similar systems. - class Logstash - - # Formats a log entry as a Logstash-compatible JSON string - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content - # - # @return [String] A Logstash-compatible JSON-formatted log entry with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => '{"severity":"INFO","progname":"MyApp","pid":12345,"message":"User logged in","@version":"1","@timestamp":"2024-01-15T10:30:45.123456Z"}\n' - # - # @rbs (String severity, Time time, String? progname, String message) -> String - def call(severity, time, progname, message) - hash = { - severity:, - progname:, - pid: Process.pid, - message: Utils::Format.to_log(message), - "@version" => "1", - "@timestamp" => time.utc.iso8601(6) - } - - ::JSON.dump(hash) << "\n" - end - - end - end -end diff --git a/lib/cmdx/log_formatters/raw.rb b/lib/cmdx/log_formatters/raw.rb deleted file mode 100644 index 57a43fcdc..000000000 --- a/lib/cmdx/log_formatters/raw.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module LogFormatters - # Formats log messages as raw text without additional formatting - # - # This formatter outputs log messages in their original form with minimal - # processing, adding only a trailing newline. It's useful for scenarios - # where you want to preserve the exact message content without metadata - # or structured formatting. - class Raw - - # Formats a log entry as raw text - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content - # - # @return [String] The raw message with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => "User logged in\n" - # - # @rbs (String severity, Time time, String? progname, String message) -> String - def call(severity, time, progname, message) - "#{message}\n" - end - - end - end -end diff --git a/lib/cmdx/middleware_registry.rb b/lib/cmdx/middleware_registry.rb deleted file mode 100644 index a1d5070dc..000000000 --- a/lib/cmdx/middleware_registry.rb +++ /dev/null @@ -1,148 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Registry for managing middleware components in a task execution chain. - # - # The MiddlewareRegistry maintains an ordered list of middleware components - # that can be inserted, removed, and executed in sequence. Each middleware - # can be configured with specific options and is executed in the order - # they were registered. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. - class MiddlewareRegistry - - # Initialize a new middleware registry. - # - # @param registry [Array, nil] Initial array of middleware entries - # - # @example - # registry = MiddlewareRegistry.new - # registry = MiddlewareRegistry.new([[MyMiddleware, {option: 'value'}]]) - # - # @rbs (?Array[Array[untyped]]? registry) -> void - def initialize(registry = nil) - @registry = registry || [] - end - - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [MiddlewareRegistry] The registry being duplicated - # - # @rbs (MiddlewareRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end - - # Returns the ordered collection of middleware entries. - # Delegates to the parent registry when not yet materialized. - # - # @return [Array] Array of middleware-options pairs - # - # @example - # registry.registry # => [[LoggingMiddleware, {level: :debug}], [AuthMiddleware, {}]] - # - # @rbs () -> Array[Array[untyped]] - def registry - @registry || @parent.registry - end - alias to_a registry - - # Register a middleware component in the registry. - # - # @param middleware [Object] The middleware object to register - # @param at [Integer] Position to insert the middleware (default: -1, end of list) - # @param options [Hash] Configuration options for the middleware - # @option options [Symbol] :key Configuration key for the middleware - # @option options [Object] :value Configuration value for the middleware - # - # @return [MiddlewareRegistry] Returns self for method chaining - # - # @example - # registry.register(LoggingMiddleware, at: 0, log_level: :debug) - # registry.register(AuthMiddleware, at: -1, timeout: 30) - # - # @rbs (untyped middleware, ?at: Integer, **untyped options) -> self - def register(middleware, at: -1, **options) - materialize! - - @registry.insert(at, [middleware, options]) - self - end - - # Remove a middleware component from the registry. - # - # @param middleware [Object] The middleware object to remove - # - # @return [MiddlewareRegistry] Returns self for method chaining - # - # @example - # registry.deregister(LoggingMiddleware) - # - # @rbs (untyped middleware) -> self - def deregister(middleware) - materialize! - - @registry.reject! { |mw, _opts| mw == middleware } - self - end - - # Execute the middleware chain for a given task. - # - # @param task [Object] The task object to process through middleware - # - # @yield [task] Block to execute after all middleware processing - # @yieldparam task [Object] The processed task object - # - # @return [Object] Result of the block execution - # - # @raise [ArgumentError] When no block is provided - # - # @example - # result = registry.call!(my_task) do |processed_task| - # processed_task.execute - # end - # - # @rbs (untyped task) { (untyped) -> untyped } -> untyped - def call!(task, &) - raise ArgumentError, "block required" unless block_given? - - recursively_call_middleware(0, task, &) - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.map(&:dup) - @parent = nil - end - - # Recursively execute middleware in the chain. - # - # @param index [Integer] Current middleware index in the chain - # @param task [Object] The task object being processed - # @param block [Proc] Block to execute after middleware processing - # - # @yield [task] Block to execute after middleware processing - # @yieldparam task [Object] The processed task object - # - # @return [Object] Result of the block execution or next middleware call - # - # @rbs (Integer index, untyped task) { (untyped) -> untyped } -> untyped - def recursively_call_middleware(index, task, &block) - return yield(task) if index >= registry.size - - middleware, options = registry[index] - middleware.call(task, **options) { recursively_call_middleware(index + 1, task, &block) } - end - - end -end diff --git a/lib/cmdx/middlewares/correlate.rb b/lib/cmdx/middlewares/correlate.rb deleted file mode 100644 index aa9fcdf0f..000000000 --- a/lib/cmdx/middlewares/correlate.rb +++ /dev/null @@ -1,140 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Middlewares - # Middleware for correlating task executions with unique identifiers. - # - # The Correlate middleware provides thread and fiber safe correlation ID management - # for tracking task execution flows across different operations. It automatically - # generates correlation IDs when none are provided and stores them in task result - # metadata for traceability. - module Correlate - - extend self - - # @rbs CONCURRENCY_KEY: Symbol - CONCURRENCY_KEY = :cmdx_correlate - - # Retrieves the current correlation ID from local storage. - # - # @return [String, nil] The current correlation ID or nil if not set - # - # @example Get current correlation ID - # Correlate.id # => "550e8400-e29b-41d4-a716-446655440000" - # - # @rbs () -> String? - def id - thread_or_fiber[CONCURRENCY_KEY] - end - - # Sets the correlation ID in local storage. - # - # @param id [String] The correlation ID to set - # @return [String] The set correlation ID - # - # @example Set correlation ID - # Correlate.id = "abc-123-def" - # - # @rbs (String id) -> String - def id=(id) - thread_or_fiber[CONCURRENCY_KEY] = id - end - - # Clears the current correlation ID from local storage. - # - # @return [nil] Always returns nil - # - # @example Clear correlation ID - # Correlate.clear - # - # @rbs () -> nil - def clear - thread_or_fiber[CONCURRENCY_KEY] = nil - end - - # Temporarily uses a new correlation ID for the duration of a block. - # Restores the previous ID after the block completes, even if an error occurs. - # - # @param new_id [String] The correlation ID to use temporarily - # @yield The block to execute with the new correlation ID - # @return [Object] The result of the yielded block - # - # @example Use temporary correlation ID - # Correlate.use("temp-id") do - # # Operations here use "temp-id" - # perform_operation - # end - # # Previous ID is restored - # - # @rbs (String new_id) { () -> untyped } -> untyped - def use(new_id) - old_id = id - self.id = new_id - yield - ensure - self.id = old_id - end - - # Middleware entry point that applies correlation ID logic to task execution. - # - # Evaluates the condition from options and applies correlation ID handling - # if enabled. Generates or retrieves correlation IDs based on the :id option - # and stores them in task result metadata. - # - # @param task [Task] The task being executed - # @param options [Hash] Configuration options for correlation - # @option options [Symbol, Proc, Object, nil] :id The correlation ID source - # @option options [Symbol, Proc, Object, nil] :if Condition to enable correlation - # @option options [Symbol, Proc, Object, nil] :unless Condition to disable correlation - # - # @yield The task execution block - # - # @return [Object] The result of task execution - # - # @example Basic usage with automatic ID generation - # Correlate.call(task, &block) - # @example Use custom correlation ID - # Correlate.call(task, id: "custom-123", &block) - # @example Use task method for ID - # Correlate.call(task, id: :correlation_id, &block) - # @example Use proc for dynamic ID generation - # Correlate.call(task, id: -> { "dynamic-#{Time.now.to_i}" }, &block) - # @example Conditional correlation - # Correlate.call(task, if: :enable_correlation, &block) - # - # @rbs (Task task, **untyped options) { () -> untyped } -> untyped - def call(task, **options, &) - return yield unless Utils::Condition.evaluate(task, options) - - correlation_id = task.result.metadata[:correlation_id] ||= - id || - case callable = options[:id] - when Symbol then task.send(callable) - when Proc then task.instance_eval(&callable) - else - if callable.respond_to?(:call) - callable.call(task) - else - callable || id || Identifier.generate - end - end - - use(correlation_id, &) - end - - private - - # Returns the thread or fiber storage for the current execution context. - # - # @return [Hash] The thread or fiber storage - # - # @rbs () -> Hash - if Fiber.respond_to?(:storage) - def thread_or_fiber = Fiber.storage - else - def thread_or_fiber = Thread.current - end - - end - end -end diff --git a/lib/cmdx/middlewares/runtime.rb b/lib/cmdx/middlewares/runtime.rb deleted file mode 100644 index 09d102ed6..000000000 --- a/lib/cmdx/middlewares/runtime.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Middlewares - # Middleware for measuring task execution runtime. - # - # The Runtime middleware provides performance monitoring by measuring - # the execution time of tasks using monotonic clock for accuracy. - # It stores runtime measurements in task result metadata for analysis. - module Runtime - - extend self - - # Middleware entry point that measures task execution runtime and - # task execution start and end times. - # - # Evaluates the condition from options and measures execution time - # if enabled. Uses monotonic clock for precise timing measurements - # and stores the result in task metadata. - # - # @param task [Task] The task being executed - # @param options [Hash] Configuration options for runtime measurement - # @option options [Symbol, Proc, Object, nil] :if Condition to enable runtime measurement - # @option options [Symbol, Proc, Object, nil] :unless Condition to disable runtime measurement - # - # @yield The task execution block - # - # @return [Object] The result of task execution - # - # @example Basic usage with automatic runtime measurement - # Runtime.call(task, &block) - # @example Conditional runtime measurement - # Runtime.call(task, if: :enable_profiling, &block) - # @example Disable runtime measurement - # Runtime.call(task, unless: :skip_profiling, &block) - # - # @rbs (Task task, **untyped options) { () -> untyped } -> untyped - def call(task, **options) - unow = utc_time - mnow = monotonic_time - return yield unless Utils::Condition.evaluate(task, options) - - result = yield - task.result.metadata.merge!( - runtime: monotonic_time - mnow, - ended_at: utc_time, - started_at: unow - ) - result - end - - private - - # Gets the current monotonic time in milliseconds. - # - # Uses Process.clock_gettime with CLOCK_MONOTONIC for consistent - # timing measurements that are not affected by system clock changes. - # - # @return [Integer] Current monotonic time in milliseconds - # - # @rbs () -> Integer - def monotonic_time - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - end - - # Gets the current UTC time in ISO 8601 format. - # - # @return [String] Current UTC time in ISO 8601 format - # - # @rbs () -> String - def utc_time - Time.now.utc.iso8601 - end - - end - end -end diff --git a/lib/cmdx/middlewares/timeout.rb b/lib/cmdx/middlewares/timeout.rb deleted file mode 100644 index a7dad82b2..000000000 --- a/lib/cmdx/middlewares/timeout.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Middlewares - # Middleware for enforcing execution time limits on tasks. - # - # The Timeout middleware provides execution time control by wrapping - # task execution with Ruby's Timeout module. It automatically fails - # tasks that exceed the configured time limit and provides detailed - # error information including the exceeded limit. - module Timeout - - extend self - - # Default timeout limit in seconds when none is specified. - # - # @rbs DEFAULT_LIMIT: Integer - DEFAULT_LIMIT = 3 - - # Middleware entry point that enforces execution time limits. - # - # Evaluates the condition from options and applies timeout control - # if enabled. Supports various timeout limit configurations including - # numeric values, task method calls, and dynamic proc evaluation. - # - # @param task [Task] The task being executed - # @param options [Hash] Configuration options for timeout control - # @option options [Numeric, Symbol, Proc, Object] :seconds The timeout limit source - # @option options [Symbol, Proc, Object, nil] :if Condition to enable timeout control - # @option options [Symbol, Proc, Object, nil] :unless Condition to disable timeout control - # - # @yield The task execution block - # - # @return [Object] The result of task execution - # - # @raise [TimeoutError] When execution exceeds the configured limit - # - # @example Basic usage with default 3 second timeout - # Timeout.call(task, &block) - # @example Custom timeout limit in seconds - # Timeout.call(task, seconds: 10, &block) - # @example Use task method for timeout limit - # Timeout.call(task, seconds: :timeout_limit, &block) - # @example Use proc for dynamic timeout calculation - # Timeout.call(task, seconds: -> { calculate_timeout }, &block) - # @example Conditional timeout control - # Timeout.call(task, if: :enable_timeout, &block) - # - # @rbs (Task task, **untyped options) { () -> untyped } -> untyped - def call(task, **options, &) - return yield unless Utils::Condition.evaluate(task, options) - - limit = - case callable = options[:seconds] - when Numeric then callable - when Symbol then task.send(callable) - when Proc then task.instance_eval(&callable) - else callable.respond_to?(:call) ? callable.call(task) : DEFAULT_LIMIT - end - - limit = Float(limit) - return yield unless limit.positive? - - ::Timeout.timeout(limit, TimeoutError, "execution exceeded #{limit} seconds", &) - rescue TimeoutError => e - task.resolver.fail!( - Utils::Normalize.exception(e), - cause: e, - source: :timeout, - limit: - ) - end - - end - end -end diff --git a/lib/cmdx/parallelizer.rb b/lib/cmdx/parallelizer.rb deleted file mode 100644 index ea51f275f..000000000 --- a/lib/cmdx/parallelizer.rb +++ /dev/null @@ -1,100 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Bounded thread pool that processes items concurrently. - # - # Distributes work across a fixed number of threads using a queue, - # collecting results in submission order. - class Parallelizer - - # Returns the items to process. - # - # @return [Array] the items to process - # - # @example - # parallelizer.items # => [1, 2, 3] - # - # @rbs @items: Array[untyped] - attr_reader :items - - # Returns the number of threads in the pool. - # - # @return [Integer] the thread pool size - # - # @example - # parallelizer.pool_size # => 4 - # - # @rbs @pool_size: Integer - attr_reader :pool_size - - # Creates a new Parallelizer instance. - # - # @param items [Array] the items to process concurrently - # @param pool_size [Integer] number of threads (defaults to item count) - # - # @return [Parallelizer] a new parallelizer instance - # - # @example - # Parallelizer.new([1, 2, 3], pool_size: 2) - # - # @rbs (Array[untyped] items, ?pool_size: Integer) -> void - def initialize(items, pool_size: nil) - @items = items - @pool_size = Integer(pool_size || items.size) - end - - # Processes items concurrently and returns results in submission order. - # - # @param items [Array] the items to process concurrently - # @param pool_size [Integer] number of threads (defaults to item count) - # - # @yield [item] block called for each item in a worker thread - # @yieldparam item [Object] an item from the items array - # @yieldreturn [Object] the result for this item - # - # @return [Array] results in the same order as input items - # - # @example - # Parallelizer.call([1, 2, 3], pool_size: 2) { |n| n * 10 } - # # => [10, 20, 30] - # - # @rbs [T, R] (Array[T] items, ?pool_size: Integer) { (T) -> R } -> Array[R] - def self.call(items, pool_size: nil, &block) - new(items, pool_size:).call(&block) - end - - # Distributes items across the thread pool and returns results - # in submission order. - # - # @yield [item] block called for each item in a worker thread - # @yieldparam item [Object] an item from the items array - # @yieldreturn [Object] the result for this item - # - # @return [Array] results in the same order as input items - # - # @example - # Parallelizer.new(%w[a b c]).call { |s| s.upcase } - # # => ["A", "B", "C"] - # - # @rbs [T, R] () { (T) -> R } -> Array[R] - def call(&block) - results = Array.new(items.size) - queue = Queue.new - - items.each_with_index { |item, i| queue << [item, i] } - pool_size.times { queue << nil } - - Array.new(pool_size) do - Thread.new do - while (entry = queue.pop) - item, index = entry - results[index] = block.call(item) # rubocop:disable Performance/RedundantBlockCall - end - end - end.each(&:join) - - results - end - - end -end diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb deleted file mode 100644 index 40eeb6741..000000000 --- a/lib/cmdx/pipeline.rb +++ /dev/null @@ -1,169 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Executes workflows by processing task groups with conditional logic and breakpoint handling. - # The Pipeline class manages the execution flow of workflow tasks, evaluating conditions - # and handling breakpoints that can interrupt execution at specific task statuses. - class Pipeline - - # @rbs SEQUENTIAL_REGEXP: Regexp - SEQUENTIAL_REGEXP = /\Asequential\z/ - private_constant :SEQUENTIAL_REGEXP - - # @rbs PARALLEL_REGEXP: Regexp - PARALLEL_REGEXP = /\Aparallel\z/ - private_constant :PARALLEL_REGEXP - - # Returns the workflow being executed by this pipeline. - # - # @return [Workflow] The workflow instance - # - # @example - # pipeline.workflow.context[:status] # => "processing" - # - # @rbs @workflow: Workflow - attr_reader :workflow - - # @param workflow [Workflow] The workflow to execute - # - # @return [Pipeline] A new pipeline instance - # - # @example - # pipeline = Pipeline.new(my_workflow) - # - # @rbs (Workflow workflow) -> void - def initialize(workflow) - @workflow = workflow - end - - # Executes a workflow using a new pipeline instance. - # - # @param workflow [Workflow] The workflow to execute - # - # @return [void] - # - # @example - # Pipeline.execute(my_workflow) - # - # @rbs (Workflow workflow) -> void - def self.execute(workflow) - new(workflow).execute - end - - # Executes the workflow by processing all task groups in sequence. - # Each group is evaluated against its conditions, and breakpoints are checked - # after each task execution to determine if workflow should continue or halt. - # - # @return [void] - # - # @example - # pipeline = Pipeline.new(my_workflow) - # pipeline.execute - # - # @rbs () -> void - def execute - default_breakpoints = Utils::Normalize.statuses( - workflow.class.settings.breakpoints || - workflow.class.settings.workflow_breakpoints - ) - - workflow.class.pipeline.each do |group| - next unless Utils::Condition.evaluate(workflow, group.options) - - breakpoints = - if group.options.key?(:breakpoints) - Utils::Normalize.statuses(group.options[:breakpoints]) - else - default_breakpoints - end - - execute_group_tasks(group, breakpoints) - end - end - - private - - # Executes a group of tasks using the specified execution strategy. - # - # @param group [CMDx::Group] The task group to execute - # @param breakpoints [Array] Status values that trigger execution breaks - # @option group.options [Symbol, String] :strategy Execution strategy (:sequential, :parallel, or nil for default) - # - # @return [void] - # - # @example - # execute_group_tasks(group, ["failed", "skipped"]) - # - # @rbs (untyped group, Array[String] breakpoints) -> void - def execute_group_tasks(group, breakpoints) - case strategy = group.options[:strategy] - when NilClass, SEQUENTIAL_REGEXP then execute_tasks_in_sequence(group, breakpoints) - when PARALLEL_REGEXP then execute_tasks_in_parallel(group, breakpoints) - else raise "unknown execution strategy #{strategy.inspect}" - end - end - - # Executes tasks sequentially within a group, checking breakpoints after each task. - # If a task result status matches a breakpoint, the workflow is interrupted. - # - # @param group [ExecutionGroup] The group of tasks to execute - # @param breakpoints [Array] Breakpoint statuses that trigger workflow interruption - # - # @return [void] - # - # @raise [HaltError] When a task result status matches a breakpoint - # - # @example - # execute_tasks_in_sequence(group, ["failed", "skipped"]) - # - # @rbs (untyped group, Array[String] breakpoints) -> void - def execute_tasks_in_sequence(group, breakpoints) - group.tasks.each do |task| - task_result = task.execute(workflow.context) - next unless breakpoints.include?(task_result.status) - - workflow.throw!(task_result) - end - end - - # Each task receives a snapshot of the workflow context to prevent - # unsynchronized concurrent writes to a shared Hash. Snapshots are - # merged back into the workflow context after all tasks complete. - # - # @param group [CMDx::Group] The task group to execute in parallel - # @param breakpoints [Array] Status values that trigger execution breaks - # @option group.options [Integer] :pool_size Number of concurrent threads (defaults to task count) - # - # @return [void] - # - # @raise [Fault] When a task result status matches a breakpoint - # - # @example - # execute_tasks_in_parallel(group, ["failed"]) - # - # @rbs (untyped group, Array[String] breakpoints) -> void - def execute_tasks_in_parallel(group, breakpoints) - contexts = group.tasks.map { Context.new(workflow.context.to_h) } - ctx_pairs = group.tasks.zip(contexts) - pool_size = group.options.fetch(:pool_size, ctx_pairs.size) - - results = Parallelizer.call(ctx_pairs, pool_size:) do |task, context| - Chain.current = workflow.chain - task.execute(context) - end - - contexts.each { |ctx| workflow.context.merge!(ctx) } - - faulted = results.select { |r| breakpoints.include?(r.status) } - return if faulted.empty? - - workflow.public_send( - :"#{faulted.last.status}!", - Locale.t("cmdx.reasons.unspecified"), - source: :parallel, - faults: faulted.map(&:to_h) - ) - end - - end -end diff --git a/lib/cmdx/railtie.rb b/lib/cmdx/railtie.rb deleted file mode 100644 index 8b4d03fd4..000000000 --- a/lib/cmdx/railtie.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Rails integration class that automatically configures CMDx when the Rails - # application initializes. Handles locale configuration and I18n setup. - class Railtie < Rails::Railtie - - railtie_name :cmdx - - # Configures CMDx locales during Rails application initialization. - # - # Iterates through available locales from the Rails configuration and loads - # corresponding CMDx locale files. Reloads the I18n system to ensure - # all locales are properly registered. - # - # @param app [Rails::Application] the Rails application instance - # - # @raise [LoadError] if locale files cannot be loaded - # - # @example - # # This initializer runs automatically when Rails starts - # # It will load locales like en.yml, es.yml, fr.yml if they exist - # # in the CMDx gem's locales directory - # - # @rbs (untyped app) -> void - initializer("cmdx.configure_locales") do |app| - Utils::Wrap.array(app.config.i18n.available_locales).each do |locale| - path = CMDx.gem_path.join("lib/locales/#{locale}.yml") - next unless File.file?(path) - - ::I18n.load_path << path - end - - ::I18n.reload! - end - - # Configures the backtrace cleaner for CMDx in a Rails environment. - # - # Sets the backtrace cleaner to the Rails backtrace cleaner. - # - # @rbs () -> void - initializer("cmdx.backtrace_cleaner") do - CMDx.configuration.backtrace_cleaner = lambda do |backtrace| - Rails.backtrace_cleaner.clean(backtrace) - end - end - - end -end diff --git a/lib/cmdx/resolver.rb b/lib/cmdx/resolver.rb deleted file mode 100644 index d201eea3b..000000000 --- a/lib/cmdx/resolver.rb +++ /dev/null @@ -1,263 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages state transitions for a {CMDx::Result}, handling success annotation, - # skipping, failure, throwing, and halting with fault raising. - # - # The Resolver owns the +strict+ flag and all transition logic. It writes to the - # Result via +instance_variable_set+ so that Result remains a pure data object. - # - # @example Accessed through the task - # task.resolver.fail!("Validation failed", cause: validation_error) - # task.resolver.skip!("Already processed", halt: false) - class Resolver - - extend Forwardable - - # Returns the result being resolved. - # - # @return [CMDx::Result] The result instance - # - # @rbs @result: Result - attr_reader :result - - def_delegators :result, :task, :success?, :skipped?, :failed?, - :initialized?, :executing?, :complete?, :interrupted? - - # @param result [CMDx::Result] The result to manage transitions for - # - # @return [CMDx::Resolver] A new resolver instance - # - # @raise [TypeError] When result is not a CMDx::Result instance - # - # @example - # resolver = CMDx::Resolver.new(result) - # - # @rbs (Result) -> void - def initialize(result) - raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) - - @result = result - end - - # Transitions the result to the appropriate final state based on status. - # Delegates to {#complete!} when successful, {#interrupt!} otherwise. - # - # @return [void] - # - # @example - # resolver.executed! - # - # @rbs () -> void - def executed! - success? ? complete! : interrupt! - end - - # Transitions the result state from initialized to executing. - # - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # resolver.executing! - # - # @rbs () -> void - def executing! - return if executing? - - raise "can only transition to #{Result::EXECUTING} from #{Result::INITIALIZED}" unless initialized? - - assign(state: Result::EXECUTING) - end - - # Transitions the result state from executing to complete. - # - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # resolver.complete! - # - # @rbs () -> void - def complete! - return if complete? - - raise "can only transition to #{Result::COMPLETE} from #{Result::EXECUTING}" unless executing? - - assign(state: Result::COMPLETE) - end - - # Transitions the result state to interrupted. - # - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # resolver.interrupt! - # - # @rbs () -> void - def interrupt! - return if interrupted? - - raise "cannot transition to #{Result::INTERRUPTED} from #{Result::COMPLETE}" if complete? - - assign(state: Result::INTERRUPTED) - end - - # Sets a reason and optional metadata on a successful result without - # changing its state or status. Useful for annotating why a task succeeded. - # When halt is true, uses throw/catch to exit the work method early. - # - # @param reason [String, nil] Reason or note for the success - # @param halt [Boolean] Whether to halt execution after success - # @param metadata [Hash] Additional metadata about the success - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When status is not success - # - # @example - # resolver.success!("Created 42 records") - # resolver.success!("Imported", halt: false, rows: 100) - # - # @rbs (?String? reason, halt: bool, **untyped metadata) -> void - def success!(reason = nil, halt: true, **metadata) - raise "can only be used while #{Result::SUCCESS}" unless success? - - assign( - reason:, - metadata: - ) - - throw(:cmdx_halt) if halt - end - - # @param reason [String, nil] Reason for skipping the task - # @param halt [Boolean] Whether to halt execution after skipping - # @param cause [Exception, nil] Exception that caused the skip - # @param strict [Boolean] Whether this skip is strict (default: true). - # @param metadata [Hash] Additional metadata about the skip - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to skip from invalid status - # - # @example - # resolver.skip!("Dependencies not met", cause: dependency_error) - # resolver.skip!("Already processed", halt: false) - # resolver.skip!("Optional step", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def skip!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if skipped? - - raise "can only transition to #{Result::SKIPPED} from #{Result::SUCCESS}" unless success? - - assign( - state: Result::INTERRUPTED, - status: Result::SKIPPED, - reason: reason || Locale.t("cmdx.reasons.unspecified"), - cause:, - strict:, - metadata: - ) - - halt! if halt - end - - # @param reason [String, nil] Reason for task failure - # @param halt [Boolean] Whether to halt execution after failure - # @param cause [Exception, nil] Exception that caused the failure - # @param strict [Boolean] Whether this failure is strict (default: true). - # @param metadata [Hash] Additional metadata about the failure - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to fail from invalid status - # - # @example - # resolver.fail!("Validation failed", cause: validation_error) - # resolver.fail!("Network timeout", halt: false, timeout: 30) - # resolver.fail!("Soft failure", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def fail!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if failed? - - raise "can only transition to #{Result::FAILED} from #{Result::SUCCESS}" unless success? - - assign( - state: Result::INTERRUPTED, - status: Result::FAILED, - reason: reason || Locale.t("cmdx.reasons.unspecified"), - cause:, - strict:, - metadata: - ) - - halt! if halt - end - - # @raise [SkipFault] When task was skipped - # @raise [FailFault] When task failed - # - # @example - # resolver.halt! - # - # @rbs () -> void - def halt! - return if success? - - klass = skipped? ? SkipFault : FailFault - fault = klass.new(result) - - frames = caller_locations(3..-1) - - unless frames.empty? - frames = frames.map(&:to_s) - - if (cleaner = task.class.settings.backtrace_cleaner) - cleaner.call(frames) - end - - fault.set_backtrace(frames) - end - - raise(fault) - end - - # @param other [CMDx::Result] Result to throw from current result - # @param halt [Boolean] Whether to halt execution after throwing - # @param cause [Exception, nil] Exception that caused the throw - # @param metadata [Hash] Additional metadata to merge - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [TypeError] When other is not a CMDx::Result instance - # - # @example - # other_result = OtherTask.execute - # resolver.throw!(other_result, cause: upstream_error) - # - # @rbs (Result other, halt: bool, cause: Exception?, **untyped metadata) -> void - def throw!(other, halt: true, cause: nil, **metadata) - raise TypeError, "must be a CMDx::Result" unless other.is_a?(Result) - - assign( - state: other.state, - status: other.status, - reason: other.reason, - cause: cause || other.cause, - strict: other.strict?, - metadata: other.metadata.merge(metadata) - ) - - halt! if halt - end - - private - - # Writes attributes to the result via instance_variable_set. - # - # @param attrs [Hash] Attributes to assign to the result - # - # @rbs (**untyped attrs) -> void - def assign(**attrs) - attrs.each { |key, value| @result.instance_variable_set(:"@#{key}", value) } - end - - end -end diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb deleted file mode 100644 index d46141660..000000000 --- a/lib/cmdx/result.rb +++ /dev/null @@ -1,443 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Represents the execution result of a CMDx task, tracking state transitions, - # status changes, and providing methods for handling different outcomes. - # - # The Result class manages the lifecycle of task execution from initialization - # through completion or interruption, offering a fluent interface for status - # checking and conditional handling. - class Result - - extend Forwardable - - # @rbs STATES: Array[String] - STATES = [ - INITIALIZED = "initialized", # Initial state before execution - EXECUTING = "executing", # Currently executing task logic - COMPLETE = "complete", # Successfully completed execution - INTERRUPTED = "interrupted" # Execution was halted due to failure - ].freeze - - # @rbs STATUSES: Array[String] - STATUSES = [ - SUCCESS = "success", # Task completed successfully - SKIPPED = "skipped", # Task was skipped intentionally - FAILED = "failed" # Task failed due to error or validation - ].freeze - - # @rbs STRIP_FAILURE: Proc - STRIP_FAILURE = proc do |hash, result, key| - unless result.public_send(:"#{key}?") - # Strip caused/threw failures since its the same info as the log line - hash[key] = result.public_send(key).to_h.except(:caused_failure, :threw_failure) - end - end.freeze - private_constant :STRIP_FAILURE - - # @rbs FAILURE_KEY_REGEX: Regexp - FAILURE_KEY_REGEX = /_failure\z/ - private_constant :FAILURE_KEY_REGEX - - # Returns the task instance associated with this result. - # - # @return [CMDx::Task] The task instance - # - # @example - # result.task.id # => "users/create" - # - # @rbs @task: Task - attr_reader :task - - # Returns the current execution state of the result. - # - # @return [String] One of: "initialized", "executing", "complete", "interrupted" - # - # @example - # result.state # => "complete" - # - # @rbs @state: String - attr_reader :state - - # Returns the execution status of the result. - # - # @return [String] One of: "success", "skipped", "failed" - # - # @example - # result.status # => "success" - # - # @rbs @status: String - attr_reader :status - - # Returns additional metadata about the result. - # - # @return [Hash{Symbol => Object}] Metadata hash - # - # @example - # result.metadata # => { duration: 1.5, code: 200, message: "Success" } - # - # @rbs @metadata: Hash[Symbol, untyped] - attr_reader :metadata - - # Returns the reason for interruption (skip or failure). - # - # @return [String, nil] The reason message, or nil if not interrupted - # - # @example - # result.reason # => "Validation failed" - # - # @rbs @reason: (String | nil) - attr_reader :reason - - # Returns the exception that caused the interruption. - # - # @return [Exception, nil] The causing exception, or nil if not interrupted - # - # @example - # result.cause # => # - # - # @rbs @cause: (Exception | nil) - attr_reader :cause - - # Returns whether this result transitions are strict. - # When false, {CMDx::Executor#halt_execution?} returns false - # regardless of the task's breakpoint settings. - # - # @return [Boolean] Whether the result transitions are strict - # - # @example - # result.strict? # => true - # - # @rbs @strict: bool - attr_reader :strict - - # Returns the number of retries attempted. - # - # @return [Integer] The number of retries attempted - # - # @example - # result.retries # => 2 - # - # @rbs @retries: Integer - attr_accessor :retries - - # Returns whether the result has been rolled back. - # - # @return [Boolean] Whether the result has been rolled back - # - # @example - # result.rolled_back? # => true - # - # @rbs @rolled_back: bool - attr_accessor :rolled_back - - def_delegators :task, :context, :chain, :errors, :dry_run? - alias ctx context - - # @param task [CMDx::Task] The task instance this result represents - # - # @return [CMDx::Result] A new result instance for the task - # - # @raise [TypeError] When task is not a CMDx::Task instance - # - # @example - # result = CMDx::Result.new(my_task) - # result.state # => "initialized" - # - # @rbs (Task) -> void - def initialize(task) - raise TypeError, "must be a CMDx::Task" unless task.is_a?(CMDx::Task) - - @task = task - @state = INITIALIZED - @status = SUCCESS - @metadata = {} - @reason = nil - @cause = nil - @strict = true - @retries = 0 - @rolled_back = false - end - - STATES.each do |s| - # @return [Boolean] Whether the result is in the specified state - # - # @example - # result.initialized? # => true - # result.executing? # => false - # - # @rbs () -> bool - define_method(:"#{s}?") { state == s } - end - - # @return [Boolean] Whether the task has been executed (complete or interrupted) - # - # @example - # result.executed? # => true if complete? || interrupted? - # - # @rbs () -> bool - def executed? - complete? || interrupted? - end - - STATUSES.each do |s| - # @return [Boolean] Whether the result has the specified status - # - # @example - # result.success? # => true - # result.failed? # => false - # - # @rbs () -> bool - define_method(:"#{s}?") { status == s } - end - - # @return [Boolean] Whether the task execution was successful (not failed) - # - # @example - # result.good? # => true if !failed? - # - # @rbs () -> bool - def good? - !failed? - end - alias ok? good? - - # @return [Boolean] Whether the task execution was unsuccessful (not success) - # - # @example - # result.bad? # => true if !success? - # - # @rbs () -> bool - def bad? - !success? - end - - # @yield [self] Executes the block if task status or state matches - # - # @return [self] Returns self for method chaining - # - # @raise [ArgumentError] When no block is provided - # - # @example - # result.on(:bad) { |r| puts "Task had issues: #{r.reason}" } - # result.on(:success, :complete) { |r| puts "Task completed successfully" } - # - # @rbs () { (Result) -> void } -> self - def on(*states_or_statuses, &) - raise ArgumentError, "block required" unless block_given? - - yield(self) if states_or_statuses.any? { |s| public_send(:"#{s}?") } - self - end - - # @return [CMDx::Result, nil] The result that caused this failure, or nil - # - # @example - # cause = result.caused_failure - # puts "Caused by: #{cause.task.id}" if cause - # - # @rbs () -> Result? - def caused_failure - return unless failed? - - chain.results.reverse_each.find(&:failed?) - end - - # @return [Boolean] Whether this result caused the failure - # - # @example - # if result.caused_failure? - # puts "This task caused the failure" - # end - # - # @rbs () -> bool - def caused_failure? - return false unless failed? - - caused_failure == self - end - - # @return [CMDx::Result, nil] The result that threw this failure, or nil - # - # @example - # thrown = result.threw_failure - # puts "Thrown by: #{thrown.task.id}" if thrown - # - # @rbs () -> Result? - def threw_failure - return unless failed? - - current = index - last_failed = nil - - chain.results.each do |r| - next unless r.failed? - - return r if r.index > current - - last_failed = r - end - - last_failed - end - - # @return [Boolean] Whether this result threw the failure - # - # @example - # if result.threw_failure? - # puts "This task threw the failure" - # end - # - # @rbs () -> bool - def threw_failure? - return false unless failed? - - threw_failure == self - end - - # @return [Boolean] Whether this result is a thrown failure - # - # @example - # if result.thrown_failure? - # puts "This failure was thrown from another task" - # end - # - # @rbs () -> bool - def thrown_failure? - failed? && !caused_failure? - end - - # @return [Boolean] Whether the result transitions are strict - # - # @example - # result.strict? # => true - # - # @rbs () -> bool - def strict? - !!@strict - end - - # @return [Boolean] Whether the result has been retried - # - # @example - # result.retried? # => true - # - # @rbs () -> bool - def retried? - retries.positive? - end - - # @return [Boolean] Whether the result has been rolled back - # - # @example - # result.rolled_back? # => true - # - # @rbs () -> bool - def rolled_back? - !!@rolled_back - end - - # @return [Integer] Index of this result in the chain - # - # @example - # position = result.index - # puts "Task #{position + 1} of #{chain.results.count}" - # - # @rbs () -> Integer - def index - @chain_index || chain.index(self) - end - - # @return [String] The outcome of the task execution - # - # @example - # result.outcome # => "success" or "interrupted" - # - # @rbs () -> String - def outcome - initialized? || thrown_failure? ? state : status - end - - # @return [Hash] Hash representation of the result - # - # @example - # result.to_h - # # => {state: "complete", status: "success", outcome: "success", reason: "Unspecified", metadata: {}} - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - task.to_h.merge!( - state:, - status:, - outcome:, - reason:, - metadata: - ).tap do |hash| - if interrupted? - hash[:cause] = cause - hash[:rolled_back] = rolled_back? - end - - if failed? - STRIP_FAILURE.call(hash, self, :threw_failure) - STRIP_FAILURE.call(hash, self, :caused_failure) - end - end - end - - # @return [String] String representation of the result - # - # @example - # result.to_s # => "task_id=my_task state=complete status=success" - # @example With failure - # result.to_s # => "task_id=my_task state=complete status=failed threw_failure=<[1] MyTask: my_task>" - # - # @rbs () -> String - def to_s - Utils::Format.to_str(to_h) do |key, value| - if FAILURE_KEY_REGEX.match?(key) - "#{key}=<[#{value[:index]}] #{value[:class]}: #{value[:id]}>" - else - "#{key}=#{value.inspect}" - end - end - end - - # @return [Array] Array containing state, status, reason, cause, and metadata - # - # @example - # state, status = result.deconstruct - # puts "State: #{state}, Status: #{status}" - # - # @rbs (*untyped) -> Array[untyped] - def deconstruct(*) - [state, status, reason, cause, metadata] - end - - # @return [Hash] Hash with key-value pairs for pattern matching - # - # @example - # case result.deconstruct_keys - # in {state: "complete", good: true} - # puts "Task completed successfully" - # in {bad: true} - # puts "Task had issues" - # end - # - # @rbs (*untyped) -> Hash[Symbol, untyped] - def deconstruct_keys(*) - { - state:, - status:, - reason:, - cause:, - metadata:, - outcome:, - executed: executed?, - good: good?, - bad: bad? - } - end - - end -end diff --git a/lib/cmdx/retry.rb b/lib/cmdx/retry.rb deleted file mode 100644 index 4f9019794..000000000 --- a/lib/cmdx/retry.rb +++ /dev/null @@ -1,166 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages retry logic and state for task execution. - # - # The Retry class tracks retry availability, attempt counts, and - # remaining retries for a given task. It also resolves exception - # matching and computes wait times using configurable jitter strategies. - class Retry - - # Returns the task instance associated with this retry. - # - # @return [Task] the task being retried - # - # @example - # retry_instance.task # => # - # - # @rbs @task: Task - attr_reader :task - - # Creates a new Retry instance for the given task. - # - # @param task [Task] the task to manage retries for - # - # @return [Retry] a new Retry instance - # - # @example - # retry_instance = Retry.new(task) - # - # @rbs (Task task) -> void - def initialize(task) - @task = task - end - - # Returns the total number of retries configured for the task. - # - # @return [Integer] the configured retry count - # - # @example - # retry_instance.available # => 3 - # - # @rbs () -> Integer - def available - Integer(task.class.settings.retries || 0) - end - - # Checks if the task has any retries configured. - # - # @return [Boolean] true if retries are configured - # - # @example - # retry_instance.available? # => true - # - # @rbs () -> bool - def available? - available.positive? - end - - # Returns the number of retry attempts already made. - # - # @return [Integer] the current retry attempt count - # - # @example - # retry_instance.attempts # => 1 - # - # @rbs () -> Integer - def attempts - Integer(task.result.retries || 0) - end - - # Checks if the task has been retried at least once. - # - # @return [Boolean] true if at least one retry has occurred - # - # @example - # retry_instance.retried? # => true - # - # @rbs () -> bool - def retried? - attempts.positive? - end - - # Returns the number of retries still available. - # - # @return [Integer] the remaining retry count - # - # @example - # retry_instance.remaining # => 2 - # - # @rbs () -> Integer - def remaining - available - attempts - end - - # Checks if there are retries still available. - # - # @return [Boolean] true if remaining retries exist - # - # @example - # retry_instance.remaining? # => true - # - # @rbs () -> bool - def remaining? - remaining.positive? - end - - # Returns the list of exception classes eligible for retry. - # - # @return [Array] exception classes that trigger a retry - # - # @example - # retry_instance.exceptions # => [StandardError, CMDx::TimeoutError] - # - # @rbs () -> Array[Class] - def exceptions - @exceptions ||= Utils::Wrap.array( - task.class.settings.retry_on || - [StandardError, CMDx::TimeoutError] - ) - end - - # Checks if the given exception matches any configured retry exception. - # - # @param exception [Exception] the exception to check - # - # @return [Boolean] true if the exception qualifies for retry - # - # @example - # retry_instance.exception?(RuntimeError.new("fail")) # => true - # - # @rbs (Exception exception) -> bool - def exception?(exception) - exceptions.any? { |e| exception.class <= e } - end - - # Computes the wait time before the next retry attempt. - # - # Supports multiple jitter strategies: a Symbol calls a task method, - # a Proc is evaluated in the task instance context, a callable object - # receives the task and attempts, and a Numeric is multiplied by the - # attempt count. - # - # @return [Float] the wait duration in seconds - # - # @example With numeric jitter (0.5 * attempts) - # retry_instance.wait # => 1.0 - # @example With symbol jitter referencing a task method - # retry_instance.wait # => 2.5 - # - # @rbs () -> Float - def wait - jitter = task.class.settings.retry_jitter - - if jitter.is_a?(Symbol) - task.send(jitter, attempts) - elsif jitter.is_a?(Proc) - task.instance_exec(attempts, &jitter) - elsif jitter.respond_to?(:call) - jitter.call(task, attempts) - else - jitter.to_f * attempts - end.to_f - end - - end -end diff --git a/lib/cmdx/settings.rb b/lib/cmdx/settings.rb deleted file mode 100644 index 920c20c6d..000000000 --- a/lib/cmdx/settings.rb +++ /dev/null @@ -1,226 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Value object encapsulating all per-task configuration. Registries are - # deep-duped on inheritance; scalar settings delegate to a parent Settings - # or to the global Configuration rather than eagerly copying values. - class Settings - - class << self - - private - - # Defines a reader that delegates to the parent Settings chain, - # falling through to Configuration when no parent exists. - # - # @param names [Array] Setting names to define - # - # @rbs (*Symbol names) -> void - def delegate_to_configuration(*names) - names.each do |name| - ivar = :"@#{name}" - - attr_writer(name) - - define_method(name) do - return instance_variable_get(ivar) if instance_variable_defined?(ivar) - - value = @parent ? @parent.public_send(name) : CMDx.configuration.public_send(name) - instance_variable_set(ivar, value) - - value - end - end - end - - # Defines a reader that delegates to the parent Settings only. - # Returns nil when the chain is exhausted. - # - # @param names [Array] Setting names to define - # @param with_fallback [Boolean] Whether to fall back to Configuration - # - # @rbs (*Symbol names, with_fallback: bool) -> void - def delegate_to_parent(*names, with_fallback: false) - names.each do |name| - ivar = :"@#{name}" - - attr_writer(name) - - define_method(name) do - return instance_variable_get(ivar) if instance_variable_defined?(ivar) - - value = @parent&.public_send(name) - value ||= CMDx.configuration.public_send(name) if with_fallback - instance_variable_set(ivar, value) - - value - end - end - end - - end - - # Returns the attribute registry for task parameters. - # - # @return [AttributeRegistry] The attribute registry - # - # @rbs @attributes: AttributeRegistry - attr_accessor :attributes - - # Returns the callback registry for task lifecycle hooks. - # - # @return [CallbackRegistry] The callback registry - # - # @rbs @callbacks: CallbackRegistry - attr_accessor :callbacks - - # Returns the coercion registry for type conversions. - # - # @return [CoercionRegistry] The coercion registry - # - # @rbs @coercions: CoercionRegistry - attr_accessor :coercions - - # Returns the middleware registry for task execution. - # - # @return [MiddlewareRegistry] The middleware registry - # - # @rbs @middlewares: MiddlewareRegistry - attr_accessor :middlewares - - # Returns the validator registry for attribute validation. - # - # @return [ValidatorRegistry] The validator registry - # - # @rbs @validators: ValidatorRegistry - attr_accessor :validators - - # Returns the expected return keys after execution. - # - # @return [Array] Expected return keys after execution - # - # @rbs @returns: Array[Symbol] - attr_accessor :returns - - # Returns the tags for task categorization. - # - # @return [Array] Tags for categorization - # - # @rbs @tags: Array[Symbol] - attr_accessor :tags - - # @!attribute [rw] backtrace - # @return [Boolean] true if backtraces should be logged - delegate_to_configuration :backtrace - - # @!attribute [rw] dump_context - # @return [Boolean] true if context should be included in Task#to_h - delegate_to_configuration :dump_context - - # @!attribute [rw] rollback_on - # @return [Array] Statuses that trigger rollback - delegate_to_configuration :rollback_on - - # @!attribute [rw] task_breakpoints - # @return [Array] Default task breakpoint statuses - delegate_to_configuration :task_breakpoints - - # @!attribute [rw] workflow_breakpoints - # @return [Array] Default workflow breakpoint statuses - delegate_to_configuration :workflow_breakpoints - - # @!attribute [rw] backtrace_cleaner - # @return [Proc, nil] The backtrace cleaner proc - delegate_to_parent :backtrace_cleaner, with_fallback: true - - # @!attribute [rw] breakpoints - # @return [Array, nil] Per-task breakpoints override - delegate_to_parent :breakpoints - - # @!attribute [rw] deprecate - # @return [Symbol, Proc, Boolean, nil] Deprecation behavior - delegate_to_parent :deprecate - - # @!attribute [rw] exception_handler - # @return [Proc, nil] The exception handler proc - delegate_to_parent :exception_handler, with_fallback: true - - # @!attribute [rw] logger - # @return [Logger] The logger instance - delegate_to_parent :logger, with_fallback: true - - # @!attribute [rw] log_formatter - # @return [Proc, nil] Per-task log formatter override - delegate_to_parent :log_formatter - - # @!attribute [rw] log_level - # @return [Integer, nil] Per-task log level override - delegate_to_parent :log_level - - # @!attribute [rw] retries - # @return [Integer, nil] Number of retries on failure - delegate_to_parent :retries - - # @!attribute [rw] retry_jitter - # @return [Numeric, Symbol, Proc, nil] Jitter between retries - delegate_to_parent :retry_jitter - - # @!attribute [rw] retry_on - # @return [Array, Class, nil] Exception classes to retry on - delegate_to_parent :retry_on - - # Creates a new Settings instance, inheriting registries from a parent - # Settings or the global Configuration. Scalar settings are resolved - # lazily via delegation rather than eagerly copied. - # - # @param parent [Settings, nil] Parent settings to inherit from - # @param overrides [Hash] Field values to override after inheritance - # - # @example - # Settings.new(parent: ParentTask.settings, deprecate: true) - # - # @rbs (?parent: Settings?, **untyped overrides) -> void - def initialize(parent: nil, **overrides) - @parent = parent - - init_registries - init_collections - - overrides.each { |key, value| public_send(:"#{key}=", value) } - end - - private - - # Dups registries from the parent Settings or global Configuration - # so each task class gets its own mutable copy. - # - # @rbs () -> void - def init_registries - if @parent - @middlewares = @parent.middlewares.dup - @callbacks = @parent.callbacks.dup - @coercions = @parent.coercions.dup - @validators = @parent.validators.dup - @attributes = @parent.attributes.dup - else - config = CMDx.configuration - - @middlewares = config.middlewares.dup - @callbacks = config.callbacks.dup - @coercions = config.coercions.dup - @validators = config.validators.dup - @attributes = AttributeRegistry.new - end - end - - # Initializes array-valued settings that need their own copy - # to avoid cross-class mutation. - # - # @rbs () -> void - def init_collections - @returns = @parent&.returns&.dup || EMPTY_ARRAY - @tags = @parent&.tags&.dup || EMPTY_ARRAY - end - - end -end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb deleted file mode 100644 index 0dde605db..000000000 --- a/lib/cmdx/task.rb +++ /dev/null @@ -1,430 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Represents a task that can be executed within the CMDx framework. - # Tasks define attributes, callbacks, and execution logic that can be - # chained together to form workflows. - class Task - - extend Forwardable - - # Returns the hash of processed attribute values for this task. - # - # @return [Hash{Symbol => Object}] Hash of attribute names to their values - # - # @example - # task.attributes # => { user_id: 42, user_name: "John" } - # - # @rbs @attributes: Hash[Symbol, untyped] - attr_reader :attributes - - # Returns the collection of validation and execution errors. - # - # @return [Errors] The errors collection - # - # @example - # task.errors.to_h # => { email: ["must be valid"] } - # - # @rbs @errors: Errors - attr_reader :errors - - # Returns the unique identifier for this task instance. - # - # @return [String] The task identifier - # - # @example - # task.id # => "abc123xyz" - # - # @rbs @id: String - attr_reader :id - - # Returns the execution context for this task. - # - # @return [Context] The context instance - # - # @example - # task.context[:user_id] # => 42 - # - # @rbs @context: Context - attr_reader :context - alias ctx context - - # Returns the execution result for this task. - # - # @return [Result] The result instance - # - # @example - # task.result.status # => "success" - # - # @rbs @result: Result - attr_reader :result - alias res result - - # Returns the execution chain containing all task results. - # - # @return [Chain] The chain instance - # - # @example - # task.chain.results.size # => 3 - # - # @rbs @chain: Chain - attr_reader :chain - - # Returns the resolver for managing result state transitions. - # - # @return [Resolver] The resolver instance - # - # @example - # task.resolver.fail!("Validation failed") - # - # @rbs @resolver: Resolver - attr_reader :resolver - - def_delegators :resolver, :success!, :skip!, :fail!, :throw! - def_delegators :chain, :dry_run? - - # @param context [Hash, Context, nil] The initial context for the task - # - # @return [Task] A new task instance - # - # @raise [DeprecationError] If the task class is deprecated - # - # @example - # task = MyTask.new - # task = MyTask.new(name: "example", priority: :high) - # task = MyTask.new(Context.build(name: "example")) - # - # @rbs (untyped context) -> void - def initialize(context = nil) - Deprecator.restrict(self) - - @id = Identifier.generate - @context = Context.build(context) - @errors = Errors.new - @result = Result.new(self) - @resolver = Resolver.new(@result) - @chain = Chain.build(@result, dry_run: @context.delete(:dry_run)) - - @attributes = {} - end - - class << self - - # Returns the cached task type string for this class. - # - # @return [String] "Workflow" or "Task" - # - # @rbs () -> String - def type - @type ||= include?(Workflow) ? "Workflow" : "Task" - end - - # Returns (and lazily creates) the task-level Settings object. - # On first access, inherits from the superclass settings or - # the global Configuration. Optional overrides are applied once. - # - # @param overrides [Hash] Configuration overrides applied on first access - # @option overrides [Object] :* Any configuration override key-value pairs - # - # @return [Settings] The settings instance for this task class - # - # @example - # class MyTask < Task - # settings deprecate: true, tags: [:experimental] - # end - # - # @rbs (**untyped overrides) -> Settings - def settings(**overrides) - @settings ||= begin - parent = superclass.settings if superclass.respond_to?(:settings) - Settings.new(parent:, **overrides) - end - end - - # @param type [Symbol] The type of registry to register with - # @param object [Object] The object to register - # - # @raise [RuntimeError] If the registry type is unknown - # - # @example - # register(:attribute, MyAttribute.new) - # register(:callback, :before, -> { puts "before" }) - # - # @rbs (Symbol type, untyped object, *untyped) -> void - def register(type, object, ...) - case type - when :attribute - settings.attributes.register(object) - settings.attributes.define_readers_on!(self, Utils::Wrap.array(object)) - when :callback then settings.callbacks.register(object, ...) - when :middleware then settings.middlewares.register(object, ...) - when :validator then settings.validators.register(object, ...) - when :coercion then settings.coercions.register(object, ...) - else raise "unknown registry type #{type.inspect}" - end - end - - # @param type [Symbol] The type of registry to deregister from - # @param object [Object] The object to deregister - # - # @raise [RuntimeError] If the registry type is unknown - # - # @example - # deregister(:attribute, :name) - # deregister(:callback, :before, MyCallback) - # - # @rbs (Symbol type, untyped object, *untyped) -> void - def deregister(type, object, ...) - case type - when :attribute - settings.attributes.undefine_readers_on!(self, object) - settings.attributes.deregister(object) - when :callback then settings.callbacks.deregister(object, ...) - when :middleware then settings.middlewares.deregister(object, ...) - when :validator then settings.validators.deregister(object, ...) - when :coercion then settings.coercions.deregister(object, ...) - else raise "unknown registry type #{type.inspect}" - end - end - - # @example - # attributes :name, :email - # attributes :age, type: Integer, default: 18 - # - # @rbs (*untyped) -> void - def attributes(...) - register(:attribute, Attribute.build(...)) - end - alias attribute attributes - - # @example - # optional :description, :notes - # optional :priority, type: Symbol, default: :normal - # - # @rbs (*untyped) -> void - def optional(...) - register(:attribute, Attribute.optional(...)) - end - - # @example - # required :name, :email - # required :age, type: Integer, min: 0 - # - # @rbs (*untyped) -> void - def required(...) - register(:attribute, Attribute.required(...)) - end - - # @param names [Array] Names of attributes to remove - # - # @example - # remove_attributes :old_field, :deprecated_field - # - # @rbs (*Symbol names) -> void - def remove_attributes(*names) - deregister(:attribute, names) - end - alias remove_attribute remove_attributes - - # Declares expected context returns that must be set after task execution. - # If any declared return is missing from the context after {#work} completes - # successfully, the task will fail with a validation error. - # - # @param names [Array] Names of expected return keys in the context - # - # @example - # returns :user, :token - # - # @rbs (*untyped names) -> void - def returns(*names) - settings.returns |= names.map(&:to_sym) - end - - # Removes declared returns from the task. - # - # @param names [Array] Names of returns to remove - # - # @example - # remove_returns :old_return - # - # @rbs (*Symbol names) -> void - def remove_returns(*names) - settings.returns -= names.map(&:to_sym) - end - alias remove_return remove_returns - - # @return [Hash] Hash of attribute names to their configurations - # - # @example - # MyTask.attributes_schema #=> { - # user_id: { name: :user_id, method_name: :user_id, required: true, types: [:integer], options: {}, children: [] }, - # email: { name: :email, method_name: :email, required: false, types: [:string], options: { default: nil }, children: [] }, - # profile: { name: :profile, method_name: :profile, required: false, types: [:hash], options: {}, children: [ - # { name: :bio, method_name: :bio, required: false, types: [:string], options: {}, children: [] }, - # { name: :name, method_name: :name, required: true, types: [:string], options: {}, children: [] } - # ] } - # } - # - # @rbs () -> Hash[Symbol, Hash[Symbol, untyped]] - def attributes_schema - Utils::Wrap.array(settings.attributes).to_h do |attr| - [attr.method_name, attr.to_h] - end - end - - CallbackRegistry::TYPES.each do |callback| - # @param callables [Array] Callable objects to register as callbacks - # @param options [Hash] Options for the callback registration - # @option options [Symbol] :priority Priority of the callback - # @option options [Boolean] :async Whether the callback should run asynchronously - # @param block [Proc] Block to register as a callback - # - # @example - # before { puts "before execution" } - # after :cleanup, priority: :high - # around ->(task) { task.logger.info("starting") } - # - # @rbs (*untyped callables, **untyped options) ?{ () -> void } -> void - define_method(callback) do |*callables, **options, &block| - register(:callback, callback, *callables, **options, &block) - end - end - - # @param args [Array] Arguments to pass to the task constructor - # @param kwargs [Hash] Keyword arguments to pass to the task constructor - # @option kwargs [Object] :* Any key-value pairs to pass to the task constructor - # - # @return [Result] The execution result - # - # @example - # result = MyTask.execute(name: "example") - # - # @rbs (*untyped args, dry_run: bool, **untyped kwargs) ?{ (Result) -> void } -> Result - def execute(*args, **kwargs) - task = new(*args, **kwargs) - task.execute(raise: false) - block_given? ? yield(task.result) : task.result - end - - # @param args [Array] Arguments to pass to the task constructor - # @param kwargs [Hash] Keyword arguments to pass to the task constructor - # @option kwargs [Object] :* Any key-value pairs to pass to the task constructor - # - # @return [Result] The execution result - # - # @raise [ExecutionError] If the task execution fails - # - # @example - # result = MyTask.execute!(name: "example") - # - # @rbs (*untyped args, dry_run: bool, **untyped kwargs) ?{ (Result) -> void } -> Result - def execute!(*args, **kwargs) - task = new(*args, **kwargs) - task.execute(raise: true) - block_given? ? yield(task.result) : task.result - end - - end - - # @param raise [Boolean] Whether to raise exceptions on failure - # - # @return [Result] The execution result - # - # @example - # result = task.execute - # result = task.execute(raise: true) - # - # @rbs (raise: bool) ?{ (Result) -> void } -> Result - def execute(raise: false) - Executor.execute(self, raise:) - block_given? ? yield(result) : result - end - - # @raise [UndefinedMethodError] Always raised as this method must be overridden - # - # @example - # class MyTask < Task - # def work - # # Custom work logic here - # puts "Performing work..." - # end - # end - # - # @rbs () -> void - def work - raise UndefinedMethodError, "undefined method #{self.class.name}#work" - end - - # Returns a logger for this task. When a custom log_level or - # log_formatter is configured, the shared logger is duplicated - # so the original instance is never mutated. - # - # @return [Logger] The logger instance for this task - # - # @example - # logger.info "Starting task execution" - # logger.error "Task failed", error: exception - # - # @rbs () -> Logger - def logger - @logger ||= begin - settings = self.class.settings - log_instance = settings.logger || CMDx.configuration.logger - - if settings.log_level || settings.log_formatter - log_instance = log_instance.dup - log_instance.level = settings.log_level if settings.log_level - log_instance.formatter = settings.log_formatter if settings.log_formatter - end - - log_instance - end - end - - # @option return [Integer] :index The result index - # @option return [String] :chain_id The chain identifier - # @option return [String] :type The task type ("Task" or "Workflow") - # @option return [Array] :tags The task tags - # @option return [String] :class The task class name - # @option return [String] :id The task identifier - # @option return [Hash] :context The task context (when dump_context is true) - # - # @return [Hash] A hash representation of the task - # - # @example - # task_hash = task.to_h - # puts "Task type: #{task_hash[:type]}" - # puts "Task tags: #{task_hash[:tags].join(', ')}" - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - { - index: result.index, - chain_id: chain.id, - type: self.class.type, - class: self.class.name, - id:, - dry_run: dry_run?, - tags: self.class.settings.tags - }.tap do |hash| - if self.class.settings.dump_context - # Large context can make dumps and logs very noisy, - # so only include it if explicitly enabled - hash[:context] = context.to_h - end - end - end - - # @return [String] A string representation of the task - # - # @example - # puts task.to_s - # # Output: "Task[MyTask] tags: [:important] id: abc123" - # - # @rbs () -> String - def to_s - Utils::Format.to_str(to_h) - end - - end -end diff --git a/lib/cmdx/utils/call.rb b/lib/cmdx/utils/call.rb deleted file mode 100644 index 7a53b6d06..000000000 --- a/lib/cmdx/utils/call.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Utils - # Utility module for invoking callable objects with different invocation strategies. - # - # This module provides a unified interface for calling methods, procs, and other - # callable objects on target objects, handling the appropriate invocation method - # based on the callable type. - module Call - - extend self - - # Invokes a callable object on the target with the given arguments. - # - # @param target [Object] The target object to invoke the callable on - # @param callable [Symbol, Proc, #call] The callable to invoke - # @param args [Array] Positional arguments to pass to the callable - # @param kwargs [Hash] Keyword arguments to pass to the callable - # @option kwargs [Object] :* Any keyword arguments to pass to the callable - # - # @yield [Object] Block to pass to the callable - # - # @return [Object] The result of invoking the callable - # - # @raise [RuntimeError] When the callable cannot be invoked - # - # @example Invoking a method by symbol - # Call.invoke(user, :name) - # Call.invoke(user, :update, { name: 'John' }) - # @example Invoking a proc - # proc = ->(name) { "Hello #{name}" } - # Call.invoke(user, proc, 'John') - # @example Invoking a callable object - # callable = MyCallable.new - # Call.invoke(user, callable, 'data') - # - # @rbs (untyped target, (Symbol | Proc | untyped) callable, *untyped args, **untyped kwargs) ?{ () -> untyped } -> untyped - def invoke(target, callable, *args, **kwargs, &) - if callable.is_a?(Symbol) - target.send(callable, *args, **kwargs, &) - elsif callable.is_a?(Proc) - target.instance_exec(*args, **kwargs, &callable) - elsif callable.respond_to?(:call) - callable.call(*args, **kwargs, &) - else - raise "cannot invoke #{callable}" - end - end - - end - end -end diff --git a/lib/cmdx/utils/condition.rb b/lib/cmdx/utils/condition.rb deleted file mode 100644 index bf052e65b..000000000 --- a/lib/cmdx/utils/condition.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Utils - # Provides conditional evaluation utilities for CMDx tasks and workflows. - # - # This module handles conditional logic evaluation with support for `if` and `unless` - # conditions using various callable types including symbols, procs, and objects - # responding to `call`. - module Condition - - extend self - - # @rbs EVAL: Proc - EVAL = proc do |target, callable, *args, **kwargs, &block| - case callable - when NilClass, FalseClass, TrueClass then !!callable - when Symbol then target.send(callable, *args, **kwargs, &block) - when Proc then target.instance_exec(*args, **kwargs, &callable) - else - raise "cannot evaluate #{callable.inspect}" unless callable.respond_to?(:call) - - callable.call(*args, **kwargs, &block) - end - end.freeze - private_constant :EVAL - - # Evaluates conditional logic based on provided options. - # - # Supports both `if` and `unless` conditions, with `unless` taking precedence - # when both are specified. Returns true if no conditions are provided. - # - # @param target [Object] The target object to evaluate conditions against - # @param options [Hash] Conditional options hash - # @option options [Object] :if Condition that must be true for evaluation to succeed - # @option options [Object] :unless Condition that must be false for evaluation to succeed - # - # @return [Boolean] true if conditions are met, false otherwise - # - # @raise [RuntimeError] When a callable cannot be evaluated - # - # @example Basic if condition - # Condition.evaluate(user, if: :active?) - # # => true if user.active? returns true - # @example Unless condition - # Condition.evaluate(user, unless: :blocked?) - # # => true if user.blocked? returns false - # @example Combined conditions - # Condition.evaluate(user, if: :verified?, unless: :suspended?) - # # => true if user.verified? is true AND user.suspended? is false - # @example With arguments and block - # Condition.evaluate(user, if: ->(u) { u.has_permission?(:admin) }, :admin) - # # => true if the proc returns true when called with user and :admin - # - # @rbs (untyped target, Hash[Symbol, untyped] options, *untyped) ?{ () -> untyped } -> bool - def evaluate(target, options, ...) - case options - in if: if_cond, unless: unless_cond - EVAL.call(target, if_cond, ...) && !EVAL.call(target, unless_cond, ...) - in if: if_cond - EVAL.call(target, if_cond, ...) - in unless: unless_cond - !EVAL.call(target, unless_cond, ...) - else - true - end - end - - end - end -end diff --git a/lib/cmdx/utils/format.rb b/lib/cmdx/utils/format.rb deleted file mode 100644 index 4b092c1f6..000000000 --- a/lib/cmdx/utils/format.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Utils - # Utility module for formatting data structures into log-friendly strings - # and converting messages to appropriate formats for logging - module Format - - extend self - - # @rbs FORMATTER: Proc - FORMATTER = proc do |key, value| - "#{key}=#{value.inspect}" - end.freeze - private_constant :FORMATTER - - # Converts a message to a format suitable for logging - # - # @param message [Object] The message to format - # - # @return [Hash, Object] Returns a hash if the message responds to to_h and is a CMDx object, otherwise returns the original message - # - # @example Hash like objects - # Format.to_log({user_id: 123, action: "login"}) - # # => {user_id: 123, action: "login"} - # @example Simple message - # Format.to_log("simple message") - # # => "simple message" - # @example CMDx object - # Format.to_log(CMDx::Task.new(name: "task1")) - # # => {name: "task1"} - # - # @rbs (untyped message) -> untyped - def to_log(message) - if message.respond_to?(:to_h) && cmdx_based_object?(message.class) - message.to_h - else - message - end - end - - # Converts a hash to a formatted string using a custom formatter - # - # @param hash [Hash] The hash to convert to string - # @param block [Proc, nil] Optional custom formatter block - # @option block [String] :key The hash key - # @option block [Object] :value The hash value - # - # @return [String] Space-separated formatted key-value pairs - # - # @example Default formatter - # Format.to_str({user_id: 123, status: "active"}) - # # => "user_id=123 status=\"active\"" - # @example Custom formatter - # Format.to_str({count: 5, total: 100}) { |k, v| "#{k}:#{v}" } - # # => "count:5 total:100" - # - # @rbs (Hash[untyped, untyped] hash) ?{ (untyped, untyped) -> String } -> String - def to_str(hash, &block) - block ||= FORMATTER - hash.map(&block).join(" ") - end - - private - - # Checks if a class belongs to the CMDx namespace, caching per class. - # - # @param klass [Class] The class to check - # - # @return [Boolean] true if the class is in the CMDx namespace - # - # @rbs (Class klass) -> bool - def cmdx_based_object?(klass) - @cmdx_classes ||= {} - return @cmdx_classes[klass] if @cmdx_classes.key?(klass) - - @cmdx_classes[klass] = klass.ancestors.any? { |a| a.name&.start_with?("CMDx::") } - end - - end - end -end diff --git a/lib/cmdx/utils/normalize.rb b/lib/cmdx/utils/normalize.rb deleted file mode 100644 index 6163719bd..000000000 --- a/lib/cmdx/utils/normalize.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Utils - # Provides normalization utilities for a variety of objects - # into consistent formats. - module Normalize - - extend self - - # Normalizes an exception into a string representation. - # - # @param exception [Exception] The exception to normalize - # - # @return [String] The normalized exception string - # - # @example From exception - # Normalize.exception(StandardError.new("test")) - # # => "[StandardError] test" - # - # @rbs (Exception exception) -> String - def exception(exception) - "[#{exception.class}] #{exception.message}" - end - - # Normalizes an object into an array of unique status strings. - # - # @param object [Object] The object to normalize into status strings - # - # @return [Array] Unique status strings - # - # @example From array of symbols - # Normalize.statuses([:success, :pending, :success]) - # # => ["success", "pending"] - # @example From single value - # Normalize.statuses(:success) - # # => ["success"] - # @example From nil - # Normalize.statuses(nil) - # # => [] - # - # @rbs (untyped object) -> Array[String] - def statuses(object) - ary = Wrap.array(object) - return EMPTY_ARRAY if ary.empty? - - ary.map(&:to_s).uniq - end - - end - end -end diff --git a/lib/cmdx/utils/wrap.rb b/lib/cmdx/utils/wrap.rb deleted file mode 100644 index 1ad151e47..000000000 --- a/lib/cmdx/utils/wrap.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Utils - # Provides array wrapping utilities for normalizing input values - # into consistent array structures. - module Wrap - - extend self - - # Wraps an object in an array if it is not already an array. - # - # @param object [Object] The object to wrap in an array - # - # @return [Array] The wrapped array - # - # @example Already an array - # Wrap.array([1, 2, 3]) - # # => [1, 2, 3] - # @example Single value - # Wrap.array(1) - # # => [1] - # @example Nil value - # Wrap.array(nil) - # # => [] - # - # @rbs (untyped object) -> Array[untyped] - def array(object) - case object - when Array then object - when NilClass then EMPTY_ARRAY - else Array(object) - end - end - - end - end -end diff --git a/lib/cmdx/validator_registry.rb b/lib/cmdx/validator_registry.rb deleted file mode 100644 index 8b5381532..000000000 --- a/lib/cmdx/validator_registry.rb +++ /dev/null @@ -1,143 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Registry for managing validation rules and their corresponding validator classes. - # Provides methods to register, deregister, and execute validators against task values. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. - class ValidatorRegistry - - extend Forwardable - - def_delegators :registry, :keys - - # Initialize a new validator registry with default validators. - # - # @param registry [Hash, nil] Optional hash mapping validator names to validator classes - # - # @return [ValidatorRegistry] A new validator registry instance - # - # @rbs (?Hash[Symbol, Class]? registry) -> void - def initialize(registry = nil) - @registry = registry || { - absence: Validators::Absence, - exclusion: Validators::Exclusion, - format: Validators::Format, - inclusion: Validators::Inclusion, - length: Validators::Length, - numeric: Validators::Numeric, - presence: Validators::Presence - } - end - - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [ValidatorRegistry] The registry being duplicated - # - # @rbs (ValidatorRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end - - # Returns the internal registry mapping validator types to classes. - # Delegates to the parent registry when not yet materialized. - # - # @return [Hash{Symbol => Class}] Hash of validator type names to validator classes - # - # @example - # registry.registry # => { presence: Validators::Presence, format: Validators::Format } - # - # @rbs () -> Hash[Symbol, Class] - def registry - @registry || @parent.registry - end - alias to_h registry - - # Register a new validator class with the given name. - # - # @param name [String, Symbol] The name to register the validator under - # @param validator [Class] The validator class to register - # - # @return [ValidatorRegistry] Returns self for method chaining - # - # @example - # registry.register(:custom, CustomValidator) - # registry.register("email", EmailValidator) - # - # @rbs ((String | Symbol) name, Class validator) -> self - def register(name, validator) - materialize! - - @registry[name.to_sym] = validator - self - end - - # Remove a validator from the registry by name. - # - # @param name [String, Symbol] The name of the validator to remove - # - # @return [ValidatorRegistry] Returns self for method chaining - # - # @example - # registry.deregister(:format) - # registry.deregister("presence") - # - # @rbs ((String | Symbol) name) -> self - def deregister(name) - materialize! - - @registry.delete(name.to_sym) - self - end - - # Validate a value using the specified validator type and options. - # - # @param type [Symbol] The type of validator to use - # @param task [Task] The task context for validation - # @param value [Object] The value to validate - # @param options [Hash, Object] Validation options or condition - # @option options [Boolean] :allow_nil Whether to allow nil values - # - # @raise [TypeError] When the validator type is not registered - # - # @example - # registry.validate(:presence, task, user.name, presence: true) - # registry.validate(:length, task, password, { min: 8, allow_nil: false }) - # - # @rbs (Symbol type, Task task, untyped value, untyped options) -> untyped - def validate(type, task, value, options = EMPTY_HASH) - raise TypeError, "unknown validator type #{type.inspect}" unless registry.key?(type) - - match = - if options.is_a?(Hash) - case options - in allow_nil: then !(allow_nil && value.nil?) - else Utils::Condition.evaluate(task, options, value) - end - else - options - end - - return unless match - - Utils::Call.invoke(task, registry[type], value, options) - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.dup - @parent = nil - end - - end -end diff --git a/lib/cmdx/validators/absence.rb b/lib/cmdx/validators/absence.rb deleted file mode 100644 index d46799005..000000000 --- a/lib/cmdx/validators/absence.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Validators - # Validates that a value is absent or empty - # - # This validator ensures that the given value is nil, empty, or consists only of whitespace. - # It handles different value types appropriately: - # - Strings: checks for absence of non-whitespace characters - # - Collections: checks for empty collections - # - Other objects: checks for nil values - module Absence - - extend self - - # Validates that a value is absent or empty - # - # @param value [Object] The value to validate for absence - # @param options [Hash] Validation configuration options - # @option options [String] :message Custom error message - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value is present, not empty, or contains non-whitespace characters - # - # @example Validate string absence - # Absence.call("") - # # => nil (validation passes) - # @example Validate non-empty string - # Absence.call("hello") - # # => raises ValidationError - # @example Validate array absence - # Absence.call([]) - # # => nil (validation passes) - # @example Validate non-empty array - # Absence.call([1, 2, 3]) - # # => raises ValidationError - # @example Validate with custom message - # Absence.call("hello", message: "Value must be empty") - # # => raises ValidationError with custom message - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - match = - if value.is_a?(String) - /\S/.match?(value) - elsif value.respond_to?(:empty?) - !value.empty? - else - !value.nil? - end - - return unless match - - message = options[:message] if options.is_a?(Hash) - raise ValidationError, message || Locale.t("cmdx.validators.absence") - end - - end - end -end diff --git a/lib/cmdx/validators/exclusion.rb b/lib/cmdx/validators/exclusion.rb deleted file mode 100644 index b49b27462..000000000 --- a/lib/cmdx/validators/exclusion.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Validators - # Validates that a value is not included in a specified set or range - # - # This validator ensures that the given value is excluded from a collection - # of forbidden values or falls outside a specified range. It supports both - # discrete value lists and range-based exclusions. - module Exclusion - - extend self - - # Validates that a value is excluded from the specified options - # - # @param value [Object] The value to validate for exclusion - # @param options [Hash] Validation configuration options - # @option options [Array, Range] :in The collection of forbidden values or range - # @option options [Array, Range] :within Alias for :in option - # @option options [String] :message Custom error message template - # @option options [String] :of_message Custom message for discrete value exclusions - # @option options [String] :in_message Custom message for range-based exclusions - # @option options [String] :within_message Custom message for range-based exclusions - # - # @raise [ValidationError] When the value is found in the forbidden collection - # - # @example Exclude specific values - # Exclusion.call("admin", in: ["admin", "root", "superuser"]) - # # => raises ValidationError if value is "admin" - # @example Exclude values within a range - # Exclusion.call(5, in: 1..10) - # # => raises ValidationError if value is 5 (within 1..10) - # @example Exclude with custom message - # Exclusion.call("test", in: ["test", "demo"], message: "value %{values} is forbidden") - # - # @rbs (untyped value, Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - values = options[:in] || options[:within] - - if values.is_a?(Range) - raise_within_validation_error!(values.begin, values.end, options) if values.cover?(value) - elsif Utils::Wrap.array(values).any? { |v| v === value } - raise_of_validation_error!(values, options) - end - end - - private - - # Raises validation error for discrete value exclusions - # - # @param values [Array] The forbidden values that caused the error - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_of_validation_error!(values, options) - values = values.map(&:inspect).join(", ") unless values.nil? - message = options[:of_message] || options[:message] - message %= { values: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.exclusion.of", values:) - end - - # Raises validation error for range-based exclusions - # - # @param min [Object] The minimum value of the forbidden range - # @param max [Object] The maximum value of the forbidden range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_within_validation_error!(min, max, options) - message = options[:in_message] || options[:within_message] || options[:message] - message %= { min:, max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.exclusion.within", min:, max:) - end - - end - end -end diff --git a/lib/cmdx/validators/format.rb b/lib/cmdx/validators/format.rb deleted file mode 100644 index ee3de1f2b..000000000 --- a/lib/cmdx/validators/format.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Validators - # Validates that a value matches a specified format pattern - # - # This validator ensures that the given value conforms to a specific format - # using regular expressions. It supports both direct regex matching and - # conditional matching with inclusion/exclusion patterns. - module Format - - extend self - - # Validates that a value matches the specified format pattern - # - # @param value [Object] The value to validate for format compliance - # @param options [Hash, Regexp] Validation configuration options or direct regex pattern - # @option options [Regexp] :with Required pattern that the value must match - # @option options [Regexp] :without Pattern that the value must not match - # @option options [String] :message Custom error message - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value doesn't match the required format - # - # @example Direct regex validation - # Format.call("user@example.com", /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i) - # # => nil (validation passes) - # @example Validate with required pattern - # Format.call("ABC123", with: /\A[A-Z]{3}\d{3}\z/) - # # => nil (validation passes) - # @example Validate with exclusion pattern - # Format.call("hello", without: /\d/) - # # => nil (validation passes - no digits) - # @example Validate with both patterns - # Format.call("test123", with: /\A\w+\z/, without: /\A\d+\z/) - # # => nil (validation passes - alphanumeric but not all digits) - # @example Validate with custom message - # Format.call("invalid", with: /\A\d+\z/, message: "Must contain only digits") - # # => raises ValidationError with custom message - # - # @rbs (untyped value, (Hash[Symbol, untyped] | Regexp) options) -> nil - def call(value, options = EMPTY_HASH) - match = - if options.is_a?(Regexp) - value&.match?(options) - else - case options - in with:, without: - value&.match?(with) && !value&.match?(without) - in with: - value&.match?(with) - in without: - !value&.match?(without) - else - false - end - end - - return if match - - message = options[:message] if options.is_a?(Hash) - raise ValidationError, message || Locale.t("cmdx.validators.format") - end - - end - end -end diff --git a/lib/cmdx/validators/inclusion.rb b/lib/cmdx/validators/inclusion.rb deleted file mode 100644 index 82a860282..000000000 --- a/lib/cmdx/validators/inclusion.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Validators - # Validates that a value is included in a specified set or range - # - # This validator ensures that the given value is present within a collection - # of allowed values or falls within a specified range. It supports both - # discrete value lists and range-based validations. - module Inclusion - - extend self - - # Validates that a value is included in the specified options - # - # @param value [Object] The value to validate for inclusion - # @param options [Hash] Validation configuration options - # @option options [Array, Range] :in The collection of allowed values or range - # @option options [Array, Range] :within Alias for :in option - # @option options [String] :message Custom error message template - # @option options [String] :of_message Custom message for discrete value inclusions - # @option options [String] :in_message Custom message for range-based inclusions - # @option options [String] :within_message Custom message for range-based inclusions - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value is not found in the allowed collection - # - # @example Include specific values - # Inclusion.call("admin", in: ["admin", "user", "guest"]) - # # => nil (validation passes) - # @example Include values within a range - # Inclusion.call(5, in: 1..10) - # # => nil (validation passes - 5 is within 1..10) - # @example Include with custom message - # Inclusion.call("test", in: ["admin", "user"], message: "must be one of: %{values}") - # - # @rbs (untyped value, Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - values = options[:in] || options[:within] - - if values.is_a?(Range) - raise_within_validation_error!(values.begin, values.end, options) unless values.cover?(value) - elsif Utils::Wrap.array(values).none? { |v| v === value } - raise_of_validation_error!(values, options) - end - end - - private - - # Raises validation error for discrete value inclusions - # - # @param values [Array] The allowed values that caused the error - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_of_validation_error!(values, options) - values = values.map(&:inspect).join(", ") unless values.nil? - message = options[:of_message] || options[:message] - message %= { values: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.inclusion.of", values:) - end - - # Raises validation error for range-based inclusions - # - # @param min [Object] The minimum value of the allowed range - # @param max [Object] The maximum value of the allowed range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_within_validation_error!(min, max, options) - message = options[:in_message] || options[:within_message] || options[:message] - message %= { min:, max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.inclusion.within", min:, max:) - end - - end - end -end diff --git a/lib/cmdx/validators/length.rb b/lib/cmdx/validators/length.rb deleted file mode 100644 index 04fa18ee5..000000000 --- a/lib/cmdx/validators/length.rb +++ /dev/null @@ -1,185 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Validators - # Validates the length of a value against various constraints. - # - # This validator supports multiple length validation strategies including exact length, - # minimum/maximum bounds, and range-based validation. It can be used to ensure - # values meet specific length requirements for strings, arrays, and other - # enumerable objects. - module Length - - extend self - - # Validates a value's length against specified constraints. - # - # @param value [String, Array, Hash, Object] The value to validate (must respond to #length) - # @param options [Hash] Validation options - # @option options [Range] :within Range that the length must fall within (inclusive) - # @option options [Range] :not_within Range that the length must not fall within - # @option options [Range] :in Alias for :within - # @option options [Range] :not_in Range that the length must not fall within - # @option options [Integer] :min Minimum allowed length - # @option options [Integer] :max Maximum allowed length - # @option options [Integer] :is Exact required length - # @option options [Integer] :is_not Length that is not allowed - # @option options [String] :message Custom error message for all validations - # @option options [String] :within_message Custom message for within/range validations - # @option options [String] :in_message Custom message for :in validation - # @option options [String] :not_within_message Custom message for not_within validation - # @option options [String] :not_in_message Custom message for not_in validation - # @option options [String] :min_message Custom message for minimum length validation - # @option options [String] :max_message Custom message for maximum length validation - # @option options [String] :is_message Custom message for exact length validation - # @option options [String] :is_not_message Custom message for is_not validation - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When validation fails - # @raise [ArgumentError] When unknown validation options are provided - # - # @example Exact length validation - # Length.call("hello", is: 5) - # # => nil (validation passes) - # @example Range-based validation - # Length.call("test", within: 3..6) - # # => nil (validation passes - length 4 is within range) - # @example Min/max validation - # Length.call("username", min: 3, max: 20) - # # => nil (validation passes - length 8 is between 3 and 20) - # @example Exclusion validation - # Length.call("short", not_in: 1..3) - # # => nil (validation passes - length 5 is not in excluded range) - # - # @rbs (untyped value, Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - length = value&.length - - case options - in within: - raise_within_validation_error!(within.begin, within.end, options) unless within&.cover?(length) - in not_within: - raise_not_within_validation_error!(not_within.begin, not_within.end, options) if not_within&.cover?(length) - in in: xin - raise_within_validation_error!(xin.begin, xin.end, options) unless xin&.cover?(length) - in not_in: - raise_not_within_validation_error!(not_in.begin, not_in.end, options) if not_in&.cover?(length) - in min:, max: - raise_within_validation_error!(min, max, options) unless length&.between?(min, max) - in min: - raise_min_validation_error!(min, options) unless !length.nil? && (min <= length) - in max: - raise_max_validation_error!(max, options) unless !length.nil? && (length <= max) - in is: - raise_is_validation_error!(is, options) unless !length.nil? && (length == is) - in is_not: - raise_is_not_validation_error!(is_not, options) if !length.nil? && (length == is_not) - else - raise ArgumentError, "unknown length validator options given" - end - end - - private - - # Raises validation error for within/range validations. - # - # @param min [Integer] Minimum length value - # @param max [Integer] Maximum length value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer min, Integer max, Hash[Symbol, untyped] options) -> void - def raise_within_validation_error!(min, max, options) - message = options[:within_message] || options[:in_message] || options[:message] - message %= { min:, max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.length.within", min:, max:) - end - - # Raises validation error for not_within validations. - # - # @param min [Integer] Minimum length value - # @param max [Integer] Maximum length value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer min, Integer max, Hash[Symbol, untyped] options) -> void - def raise_not_within_validation_error!(min, max, options) - message = options[:not_within_message] || options[:not_in_message] || options[:message] - message %= { min:, max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.length.not_within", min:, max:) - end - - # Raises validation error for minimum length validation. - # - # @param min [Integer] Minimum required length - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer min, Hash[Symbol, untyped] options) -> void - def raise_min_validation_error!(min, options) - message = options[:min_message] || options[:message] - message %= { min: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.length.min", min:) - end - - # Raises validation error for maximum length validation. - # - # @param max [Integer] Maximum allowed length - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer max, Hash[Symbol, untyped] options) -> void - def raise_max_validation_error!(max, options) - message = options[:max_message] || options[:message] - message %= { max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.length.max", max:) - end - - # Raises validation error for exact length validation. - # - # @param is [Integer] Required exact length - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer is, Hash[Symbol, untyped] options) -> void - def raise_is_validation_error!(is, options) - message = options[:is_message] || options[:message] - message %= { is: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.length.is", is:) - end - - # Raises validation error for is_not length validation. - # - # @param is_not [Integer] Length that is not allowed - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer is_not, Hash[Symbol, untyped] options) -> void - def raise_is_not_validation_error!(is_not, options) - message = options[:is_not_message] || options[:message] - message %= { is_not: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.length.is_not", is_not:) - end - - end - end -end diff --git a/lib/cmdx/validators/numeric.rb b/lib/cmdx/validators/numeric.rb deleted file mode 100644 index d20e9e852..000000000 --- a/lib/cmdx/validators/numeric.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Validators - # Validates numeric values against various constraints and ranges - # - # This validator ensures that numeric values meet specified criteria such as - # minimum/maximum bounds, exact matches, or range inclusions. It supports - # both inclusive and exclusive range validations with customizable error messages. - module Numeric - - extend self - - # Validates a numeric value against the specified options - # - # @param value [Numeric] The numeric value to validate - # @param options [Hash] Validation configuration options - # @option options [Range] :within Range that the value must fall within (inclusive) - # @option options [Range] :not_within Range that the value must not fall within - # @option options [Range] :in Alias for :within option - # @option options [Range] :not_in Alias for :not_within option - # @option options [Numeric] :min Minimum allowed value (inclusive) - # @option options [Numeric] :max Maximum allowed value (inclusive) - # @option options [Numeric] :is Exact value that must match - # @option options [Numeric] :is_not Value that must not match - # @option options [String] :message Custom error message template - # @option options [String] :within_message Custom message for range validations - # @option options [String] :not_within_message Custom message for exclusion validations - # @option options [String] :min_message Custom message for minimum validation - # @option options [String] :max_message Custom message for maximum validation - # @option options [String] :is_message Custom message for exact match validation - # @option options [String] :is_not_message Custom message for exclusion validation - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value fails validation - # @raise [ArgumentError] When unknown validator options are provided - # - # @example Validate value within a range - # Numeric.call(5, within: 1..10) - # # => nil (validation passes) - # @example Validate minimum and maximum bounds - # Numeric.call(15, min: 10, max: 20) - # # => nil (validation passes) - # @example Validate exact value match - # Numeric.call(42, is: 42) - # # => nil (validation passes) - # @example Validate value exclusion - # Numeric.call(5, not_in: 1..10) - # # => nil (validation passes - 5 is not in 1..10) - # - # @rbs (Numeric value, Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - case options - in within: - raise_within_validation_error!(within.begin, within.end, options) unless within&.cover?(value) - in not_within: - raise_not_within_validation_error!(not_within.begin, not_within.end, options) if not_within&.cover?(value) - in in: xin - raise_within_validation_error!(xin.begin, xin.end, options) unless xin&.cover?(value) - in not_in: - raise_not_within_validation_error!(not_in.begin, not_in.end, options) if not_in&.cover?(value) - in min:, max: - raise_within_validation_error!(min, max, options) unless value&.between?(min, max) - in min: - raise_min_validation_error!(min, options) unless !value.nil? && (min <= value) - in max: - raise_max_validation_error!(max, options) unless !value.nil? && (value <= max) - in is: - raise_is_validation_error!(is, options) unless !value.nil? && (value == is) - in is_not: - raise_is_not_validation_error!(is_not, options) if !value.nil? && (value == is_not) - else - raise ArgumentError, "unknown numeric validator options given" - end - end - - private - - # Raises validation error for range inclusion validation - # - # @param min [Numeric] The minimum value of the allowed range - # @param max [Numeric] The maximum value of the allowed range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric min, Numeric max, Hash[Symbol, untyped] options) -> void - def raise_within_validation_error!(min, max, options) - message = options[:within_message] || options[:in_message] || options[:message] - message %= { min:, max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.numeric.within", min:, max:) - end - - # Raises validation error for range exclusion validation - # - # @param min [Numeric] The minimum value of the excluded range - # @param max [Numeric] The maximum value of the excluded range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric min, Numeric max, Hash[Symbol, untyped] options) -> void - def raise_not_within_validation_error!(min, max, options) - message = options[:not_within_message] || options[:not_in_message] || options[:message] - message %= { min:, max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.numeric.not_within", min:, max:) - end - - # Raises validation error for minimum value validation - # - # @param min [Numeric] The minimum allowed value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric min, Hash[Symbol, untyped] options) -> void - def raise_min_validation_error!(min, options) - message = options[:min_message] || options[:message] - message %= { min: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.numeric.min", min:) - end - - # Raises validation error for maximum value validation - # - # @param max [Numeric] The maximum allowed value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric max, Hash[Symbol, untyped] options) -> void - def raise_max_validation_error!(max, options) - message = options[:max_message] || options[:message] - message %= { max: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.numeric.max", max:) - end - - # Raises validation error for exact value match validation - # - # @param is [Numeric] The exact value that was expected - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric is, Hash[Symbol, untyped] options) -> void - def raise_is_validation_error!(is, options) - message = options[:is_message] || options[:message] - message %= { is: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.numeric.is", is:) - end - - # Raises validation error for value exclusion validation - # - # @param is_not [Numeric] The value that was not allowed - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric is_not, Hash[Symbol, untyped] options) -> void - def raise_is_not_validation_error!(is_not, options) - message = options[:is_not_message] || options[:message] - message %= { is_not: } unless message.nil? - - raise ValidationError, message || Locale.t("cmdx.validators.numeric.is_not", is_not:) - end - - end - end -end diff --git a/lib/cmdx/validators/presence.rb b/lib/cmdx/validators/presence.rb deleted file mode 100644 index de51ab27b..000000000 --- a/lib/cmdx/validators/presence.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Validators - # Validates that a value is present and not empty - # - # This validator ensures that the given value exists and contains meaningful content. - # It handles different value types appropriately: - # - Strings: checks for non-whitespace characters - # - Collections: checks for non-empty collections - # - Other objects: checks for non-nil values - module Presence - - extend self - - # Validates that a value is present and not empty - # - # @param value [Object] The value to validate for presence - # @param options [Hash] Validation configuration options - # @option options [String] :message Custom error message - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value is empty, nil, or contains only whitespace - # - # @example Validate string presence - # Presence.call("hello world") - # # => nil (validation passes) - # @example Validate empty string - # Presence.call(" ") - # # => raises ValidationError - # @example Validate array presence - # Presence.call([1, 2, 3]) - # # => nil (validation passes) - # @example Validate empty array - # Presence.call([]) - # # => raises ValidationError - # @example Validate with custom message - # Presence.call(nil, message: "Value cannot be blank") - # # => raises ValidationError with custom message - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> nil - def call(value, options = EMPTY_HASH) - match = - if value.is_a?(String) - /\S/.match?(value) - elsif value.respond_to?(:empty?) - !value.empty? - else - !value.nil? - end - - return if match - - message = options[:message] if options.is_a?(Hash) - raise ValidationError, message || Locale.t("cmdx.validators.presence") - end - - end - end -end diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb deleted file mode 100644 index 5a006e4a9..000000000 --- a/lib/cmdx/workflow.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Provides workflow execution capabilities by organizing tasks into execution groups. - # Workflows allow you to define sequences of tasks that can be executed conditionally - # with breakpoint handling and context management. - module Workflow - - module ClassMethods - - # Prevents redefinition of the work method to maintain workflow integrity. - # - # @param method_name [Symbol] The name of the method being added - # - # @raise [RuntimeError] If attempting to redefine the work method - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # # This would raise an error: - # # def work; end - # end - # - # @rbs (Symbol method_name) -> void - def method_added(method_name) - raise "cannot redefine #{name}##{method_name} method" if method_name == :work - - super - end - - # Returns the collection of execution groups for this workflow. - # - # @return [Array] Array of execution groups - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # task Task1 - # task Task2 - # puts pipeline.size # => 2 - # end - # - # @rbs () -> Array[ExecutionGroup] - def pipeline - @pipeline ||= [] - end - - # Adds multiple tasks to the workflow with optional configuration. - # - # @param tasks [Array] Array of task classes to add - # @param options [Hash] Configuration options for the task execution - # @option options [Hash] :breakpoints Breakpoints that trigger workflow interruption - # @option options [Hash] :conditions Conditional logic for task execution - # - # @raise [TypeError] If any task is not a CMDx::Task subclass - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # tasks ValidateTask, ProcessTask, NotifyTask, breakpoints: [:failure, :halt] - # end - # - # @rbs (*untyped tasks, **untyped options) -> void - def tasks(*tasks, **options) - pipeline << ExecutionGroup.new( - tasks.map do |task| - next task if task.is_a?(Class) && (task <= Task) - - raise TypeError, "must be a CMDx::Task" - end, - options - ) - end - alias task tasks - - # Returns all tasks in the pipeline. - # - # @return [Array] Array of task classes - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # task Task1 - # task Task2 - # puts subtasks.size # => 2 - # end - # - # @rbs () -> Array[Class] - def subtasks - pipeline.flat_map(&:tasks) - end - - end - - # Represents a group of tasks with shared execution options. - # @attr tasks [Array] Array of task classes in this group - # @attr options [Hash] Configuration options for the group - ExecutionGroup = Struct.new(:tasks, :options) - - # Extends the including class with workflow capabilities. - # - # @param base [Class] The class including this module - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # # Now has access to task, tasks, and work methods - # end - # - # @rbs (Class base) -> void - def self.included(base) - base.extend(ClassMethods) - end - - # Executes the workflow by processing all tasks in the pipeline. - # This method delegates execution to the Pipeline class which handles - # the processing of tasks with proper error handling and context management. - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # task ValidateTask - # task ProcessTask - # end - # - # workflow = MyWorkflow.new - # result = workflow.work - # - # @rbs () -> void - def work - Pipeline.execute(self) - end - - end -end diff --git a/spec/cmdx/attribute_registry_spec.rb b/spec/cmdx/attribute_registry_spec.rb deleted file mode 100644 index 14b15c067..000000000 --- a/spec/cmdx/attribute_registry_spec.rb +++ /dev/null @@ -1,335 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::AttributeRegistry, type: :unit do - let(:attribute1) { instance_double(CMDx::Attribute) } - let(:attribute2) { instance_double(CMDx::Attribute) } - let(:initial_registry) { [attribute1] } - let(:task) { instance_double(CMDx::Task) } - - describe "#initialize" do - context "without arguments" do - subject(:registry) { described_class.new } - - it "initializes with empty registry" do - expect(registry.registry).to eq([]) - end - end - - context "with initial registry" do - subject(:registry) { described_class.new(initial_registry) } - - it "initializes with provided registry" do - expect(registry.registry).to eq(initial_registry) - end - end - end - - describe "#registry" do - subject(:registry) { described_class.new(initial_registry) } - - it "returns the internal registry array" do - expect(registry.registry).to eq(initial_registry) - end - end - - describe "#to_a" do - subject(:registry) { described_class.new(initial_registry) } - - it "aliases to registry method" do - expect(registry.to_a).to eq(registry.registry) - expect(registry.to_a).to eq(initial_registry) - end - end - - describe "#dup" do - subject(:registry) { described_class.new(initial_registry) } - - let(:duplicated_registry) { registry.dup } - - it "creates new instance with duplicated registry" do - expect(duplicated_registry).to be_a(described_class) - expect(duplicated_registry).not_to be(registry) - expect(duplicated_registry.registry).to eq(registry.registry) - expect(duplicated_registry.registry).not_to be(registry.registry) - end - - it "maintains independence between original and duplicate" do - new_attribute = instance_double(CMDx::Attribute) - - duplicated_registry.register(new_attribute) - - expect(duplicated_registry.registry).to include(new_attribute) - expect(registry.registry).not_to include(new_attribute) - end - end - - describe "#register" do - subject(:registry) { described_class.new } - - context "with single attribute" do - it "adds attribute to registry and returns self" do - result = registry.register(attribute1) - - expect(registry.registry).to include(attribute1) - expect(result).to be(registry) - end - end - - context "with multiple attributes as array" do - let(:attributes) { [attribute1, attribute2] } - - it "adds all attributes to registry" do - registry.register(attributes) - - expect(registry.registry).to include(attribute1) - expect(registry.registry).to include(attribute2) - expect(registry.registry.size).to eq(2) - end - end - - context "with non-array attribute" do - it "converts to array and adds to registry" do - registry.register(attribute1) - - expect(registry.registry).to eq([attribute1]) - end - end - - context "when adding to existing registry" do - subject(:registry) { described_class.new(initial_registry) } - - it "appends new attributes to existing ones" do - registry.register(attribute2) - - expect(registry.registry).to eq([attribute1, attribute2]) - expect(registry.registry.size).to eq(2) - end - end - - context "with empty array" do - it "does not modify registry" do - original_size = registry.registry.size - - registry.register([]) - - expect(registry.registry.size).to eq(original_size) - end - end - end - - describe "#deregister" do - subject(:registry) { described_class.new([parent_attribute, other_attribute]) } - - let(:child_attribute) { instance_double(CMDx::Attribute, method_name: :child_attr, children: []) } - let(:parent_attribute) { instance_double(CMDx::Attribute, method_name: :parent_attr, children: [child_attribute]) } - let(:other_attribute) { instance_double(CMDx::Attribute, method_name: :other_attr, children: []) } - - context "with single attribute name" do - it "removes attribute by method_name and returns self" do - result = registry.deregister(:parent_attr) - - expect(registry.registry).not_to include(parent_attribute) - expect(registry.registry).to include(other_attribute) - expect(result).to be(registry) - end - - it "converts string names to symbols" do - registry.deregister("parent_attr") - - expect(registry.registry).not_to include(parent_attribute) - expect(registry.registry).to include(other_attribute) - end - end - - context "with multiple attribute names" do - it "handles array of names" do - registry.deregister(%i[parent_attr other_attr]) - - expect(registry.registry).to be_empty - end - end - - context "with child attribute name" do - it "removes parent attribute when child matches" do - registry.deregister(:child_attr) - - expect(registry.registry).not_to include(parent_attribute) - expect(registry.registry).to include(other_attribute) - end - end - - context "with non-existent attribute name" do - it "does not modify registry" do - original_registry = registry.registry.dup - - registry.deregister(:non_existent) - - expect(registry.registry).to eq(original_registry) - end - end - - context "with nested children" do - subject(:registry) { described_class.new([complex_parent, other_attribute]) } - - let(:grandchild_attribute) { instance_double(CMDx::Attribute, method_name: :grandchild_attr, children: []) } - let(:child_with_children) { instance_double(CMDx::Attribute, method_name: :child_with_children, children: [grandchild_attribute]) } - let(:complex_parent) { instance_double(CMDx::Attribute, method_name: :complex_parent, children: [child_with_children]) } - - it "removes parent when deeply nested child matches" do - registry.deregister(:grandchild_attr) - - expect(registry.registry).not_to include(complex_parent) - expect(registry.registry).to include(other_attribute) - end - end - - context "with empty registry" do - subject(:registry) { described_class.new([]) } - - it "does not raise error" do - expect { registry.deregister(:any_name) }.not_to raise_error - end - end - end - - describe "#define_readers_on!" do - let(:task_class) { Class.new(CMDx::Task) } - - context "with static attributes" do - it "defines reader methods on the task class" do - attr = CMDx::Attribute.new(:username) - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:username)).to be(true) - end - end - - context "with nested static attributes" do - it "defines readers for parent and children" do - attr = CMDx::Attribute.new(:address) do - required :street - required :city - end - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:address)).to be(true) - expect(task_class.method_defined?(:street)).to be(true) - expect(task_class.method_defined?(:city)).to be(true) - end - end - - context "with dynamic (Proc) source" do - it "skips the reader definition" do - attr = CMDx::Attribute.new(:username, source: -> { :dynamic }) - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:username)).to be(false) - end - end - - context "when method already exists as private" do - before { task_class.define_method(:taken_name) { "private" } } - - it "raises a conflict error" do - task_class.send(:private, :taken_name) - attr = CMDx::Attribute.new(:taken_name) - registry = described_class.new([attr]) - - expect { registry.define_readers_on!(task_class) }.to raise_error(/already defined/) - end - end - - context "with subset of attributes" do - it "only defines readers for the given attributes" do - attr1 = CMDx::Attribute.new(:first) - attr2 = CMDx::Attribute.new(:second) - registry = described_class.new([attr1]) - registry.define_readers_on!(task_class, [attr2]) - - expect(task_class.method_defined?(:first)).to be(false) - expect(task_class.method_defined?(:second)).to be(true) - end - end - end - - describe "#undefine_readers_on!" do - let(:task_class) { Class.new(CMDx::Task) } - - it "removes eagerly defined reader methods" do - attr = CMDx::Attribute.new(:username) - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:username)).to be(true) - - registry.undefine_readers_on!(task_class, [:username]) - - expect(task_class.method_defined?(:username, false)).to be(false) - end - - it "removes nested readers" do - attr = CMDx::Attribute.new(:profile) do - required :bio - end - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - registry.undefine_readers_on!(task_class, [:profile]) - - expect(task_class.method_defined?(:profile, false)).to be(false) - expect(task_class.method_defined?(:bio, false)).to be(false) - end - - it "does not raise for non-existent methods" do - registry = described_class.new([]) - - expect { registry.undefine_readers_on!(task_class, [:missing]) }.not_to raise_error - end - end - - describe "#define_and_verify" do - subject(:registry) { described_class.new([attribute1, attribute2]) } - - it "dups each attribute, binds the task to the dup, and verifies without mutating originals" do - bound1 = instance_double(CMDx::Attribute) - bound2 = instance_double(CMDx::Attribute) - - allow(attribute1).to receive(:dup).and_return(bound1) - allow(attribute2).to receive(:dup).and_return(bound2) - - expect(bound1).to receive(:task=).with(task).ordered - expect(bound1).to receive(:define_and_verify_tree).ordered - expect(bound2).to receive(:task=).with(task).ordered - expect(bound2).to receive(:define_and_verify_tree).ordered - - expect(attribute1).not_to receive(:task=) - expect(attribute2).not_to receive(:task=) - - registry.define_and_verify(task) - end - - context "with empty registry" do - subject(:registry) { described_class.new([]) } - - it "does not call any methods" do - expect { registry.define_and_verify(task) }.not_to raise_error - end - end - - context "when attribute raises error" do - it "propagates the error without requiring cleanup" do - bound1 = instance_double(CMDx::Attribute) - allow(attribute1).to receive(:dup).and_return(bound1) - allow(bound1).to receive(:task=) - allow(bound1).to receive(:define_and_verify_tree).and_raise(StandardError, "attribute error") - - expect { registry.define_and_verify(task) }.to raise_error(StandardError, "attribute error") - end - end - end -end diff --git a/spec/cmdx/attribute_spec.rb b/spec/cmdx/attribute_spec.rb deleted file mode 100644 index e19fa0455..000000000 --- a/spec/cmdx/attribute_spec.rb +++ /dev/null @@ -1,707 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Attribute, type: :unit do - let(:task_class) { create_task_class } - let(:task) { task_class.new } - let(:attribute_name) { :test_attr } - let(:attribute_options) { {} } - let(:attribute) { described_class.new(attribute_name, **attribute_options) } - - describe "#initialize" do - subject(:new_attribute) { described_class.new(attribute_name, **attribute_options) } - - context "with basic parameters" do - it "sets the name" do - expect(new_attribute.name).to eq(attribute_name) - end - - it "sets empty options" do - expect(new_attribute.options).to eq({}) - end - - it "initializes empty children array" do - expect(new_attribute.children).to eq([]) - end - - it "sets parent to nil" do - expect(new_attribute.parent).to be_nil - end - - it "sets required to false by default" do - expect(new_attribute.required?).to be(false) - end - - it "sets empty types array" do - expect(new_attribute.types).to eq([]) - end - end - - context "with parent option" do - let(:parent_attribute) { described_class.new(:parent_attr) } - let(:attribute_options) { { parent: parent_attribute } } - - it "sets the parent" do - expect(new_attribute.parent).to eq(parent_attribute) - end - - it "removes parent from options" do - expect(new_attribute.options).not_to have_key(:parent) - end - end - - context "with required option" do - let(:attribute_options) { { required: true } } - - it "sets required to true" do - expect(new_attribute.required?).to be(true) - end - - it "removes required from options" do - expect(new_attribute.options).not_to have_key(:required) - end - end - - context "with optional option" do - let(:attribute_options) { { required: false } } - - it "sets optional to true" do - expect(new_attribute.optional?).to be(true) - end - - it "removes optional from options" do - expect(new_attribute.options).not_to have_key(:required) - end - end - - context "with types option" do - let(:attribute_options) { { types: %i[string integer] } } - - it "sets types array" do - expect(new_attribute.types).to eq(%i[string integer]) - end - - it "removes types from options" do - expect(new_attribute.options).not_to have_key(:types) - end - end - - context "with type option (singular)" do - let(:attribute_options) { { type: :string } } - - it "converts type to types array" do - expect(new_attribute.types).to eq([:string]) - end - - it "removes type from options" do - expect(new_attribute.options).not_to have_key(:type) - end - end - - context "with other options" do - let(:attribute_options) { { source: :context, default: "value", as: :custom_name } } - - it "preserves other options" do - expect(new_attribute.options).to eq({ source: :context, default: "value", as: :custom_name }) - end - end - - context "with block" do - it "executes the block in instance context" do - expect do - described_class.new(attribute_name) { nil } - end.not_to raise_error - end - end - end - - describe ".build" do - subject(:defined_attributes) { described_class.build(*names, **attribute_options) } - - let(:names) { %i[attr1 attr2] } - - context "with multiple names" do - it "creates attributes for each name" do - expect(defined_attributes.size).to eq(2) - expect(defined_attributes.map(&:name)).to eq(%i[attr1 attr2]) - end - - it "returns array of attributes" do - expect(defined_attributes).to all(be_a(described_class)) - end - end - - context "with no names" do - let(:names) { [] } - - it "raises ArgumentError" do - expect { defined_attributes }.to raise_error(ArgumentError, "no attributes given") - end - end - - context "with multiple names and :as option" do - let(:attribute_options) { { as: :custom_name } } - - it "raises ArgumentError" do - expect { defined_attributes }.to raise_error(ArgumentError, "the :as option only supports one attribute per definition") - end - end - - context "with single name and :as option" do - let(:names) { [:attr1] } - let(:attribute_options) { { as: :custom_name } } - - it "creates attribute with custom name" do - expect(defined_attributes.size).to eq(1) - expect(defined_attributes.first.options[:as]).to eq(:custom_name) - end - end - - context "with block" do - it "passes block to each attribute" do - attributes = described_class.build(*names) { nil } - - expect(attributes.size).to eq(2) - end - end - end - - describe ".optional" do - subject(:optional_attributes) { described_class.optional(*names, **attribute_options) } - - let(:names) { %i[attr1 attr2] } - - it "creates attributes with required: false" do - expect(optional_attributes).to all(satisfy { |attr| !attr.required? }) - end - - context "with existing required option" do - let(:attribute_options) { { required: true } } - - it "overrides with required: false" do - expect(optional_attributes).to all(satisfy { |attr| !attr.required? }) - end - end - end - - describe ".required" do - subject(:required_attributes) { described_class.required(*names, **attribute_options) } - - let(:names) { %i[attr1 attr2] } - - it "creates attributes with required: true" do - expect(required_attributes).to all(satisfy(&:required?)) - end - - context "with existing required option" do - let(:attribute_options) { { required: false } } - - it "overrides with required: true" do - expect(required_attributes).to all(satisfy(&:required?)) - end - end - end - - describe "#required?" do - subject { attribute.required? } - - context "when required is false" do - let(:attribute_options) { { required: false } } - - it { is_expected.to be(false) } - end - - context "when required is true" do - let(:attribute_options) { { required: true } } - - it { is_expected.to be(true) } - end - - context "when required is nil" do - it { is_expected.to be(false) } - end - end - - describe "#source" do - subject(:source_value) { attribute.source } - - before { attribute.task = task } - - context "when parent has method_name" do - let(:parent_attribute) do - described_class.new(:parent_attr).tap do |attr| - allow(attr).to receive(:method_name).and_return(:parent_method) - end - end - let(:attribute_options) { { parent: parent_attribute } } - - it "returns parent method_name" do - expect(source_value).to eq(:parent_method) - end - end - - context "without parent" do - context "when source option is a symbol" do - let(:attribute_options) { { source: :custom_source } } - - it "returns the symbol" do - expect(source_value).to eq(:custom_source) - end - end - - context "when source option is a proc" do - let(:attribute_options) { { source: proc { :proc_result } } } - - it "evaluates proc in task context" do - expect(source_value).to eq(:proc_result) - end - end - - context "when source option responds to call" do - let(:callable_source) do - object = Object.new - allow(object).to receive(:call).and_return(:callable_result) - object - end - let(:attribute_options) { { source: callable_source } } - - it "calls the object with task" do - expect(callable_source).to receive(:call).with(task) - expect(source_value).to eq(:callable_result) - end - end - - context "when source option is a string" do - let(:attribute_options) { { source: "string_source" } } - - it "returns the string" do - expect(source_value).to eq("string_source") - end - end - - context "without source option" do - it "returns :context as default" do - expect(source_value).to eq(:context) - end - end - end - - context "with memoization" do - let(:attribute_options) { { source: proc { Time.now.to_f } } } - - it "memoizes the result" do - first_result = attribute.source - second_result = attribute.source - - expect(first_result).to eq(second_result) - end - end - end - - describe "#allocation_name" do - subject(:static_name) { attribute.allocation_name } - - context "when :as option is provided" do - let(:attribute_options) { { as: :custom_method } } - - it "returns the custom method name" do - expect(static_name).to eq(:custom_method) - end - end - - context "with static symbol source" do - let(:attribute_options) { { source: :params } } - - it "returns the attribute name" do - expect(static_name).to eq(attribute_name) - end - end - - context "with prefix and suffix" do - let(:attribute_options) { { prefix: true, suffix: true, source: :params } } - - it "applies prefix and suffix using source" do - expect(static_name).to eq(:params_test_attr_params) - end - end - - context "with Proc source" do - let(:attribute_options) { { source: -> { :dynamic } } } - - it "returns nil" do - expect(static_name).to be_nil - end - end - - context "with callable source" do - let(:callable) { Class.new { def call(_task) = :dynamic }.new } - let(:attribute_options) { { source: callable } } - - it "returns nil" do - expect(static_name).to be_nil - end - end - - context "with static parent" do - let(:parent_attribute) { described_class.new(:parent_attr) } - let(:attribute_options) { { parent: parent_attribute } } - - it "uses parent allocation_name as source" do - expect(static_name).to eq(attribute_name) - end - end - - context "with dynamic parent" do - let(:parent_attribute) { described_class.new(:parent_attr, source: -> { :dynamic }) } - let(:attribute_options) { { parent: parent_attribute } } - - it "returns nil" do - expect(static_name).to be_nil - end - end - - context "with memoization" do - it "memoizes the result" do - first = attribute.allocation_name - second = attribute.allocation_name - - expect(first).to equal(second) - end - end - end - - describe "#method_name" do - subject(:method_name_value) { attribute.method_name } - - before { attribute.task = task } - - context "when :as option is provided" do - let(:attribute_options) { { as: :custom_method } } - - it "returns the custom method name" do - expect(method_name_value).to eq(:custom_method) - end - end - - context "without :as option" do - context "with default settings" do - it "returns the attribute name" do - expect(method_name_value).to eq(attribute_name) - end - end - - context "with prefix option set to true" do - let(:attribute_options) { { prefix: true, source: :params } } - - it "adds source as prefix" do - expect(method_name_value).to eq(:params_test_attr) - end - end - - context "with prefix option set to string" do - let(:attribute_options) { { prefix: "custom" } } - - it "uses custom prefix" do - expect(method_name_value).to eq(:customtest_attr) - end - end - - context "with suffix option set to true" do - let(:attribute_options) { { suffix: true, source: :params } } - - it "adds source as suffix" do - expect(method_name_value).to eq(:test_attr_params) - end - end - - context "with suffix option set to string" do - let(:attribute_options) { { suffix: "custom" } } - - it "uses custom suffix" do - expect(method_name_value).to eq(:test_attrcustom) - end - end - - context "with both prefix and suffix" do - let(:attribute_options) { { prefix: "pre", suffix: "suf" } } - - it "combines both" do - expect(method_name_value).to eq(:pretest_attrsuf) - end - end - end - - context "with memoization" do - it "memoizes the result" do - first_result = attribute.method_name - second_result = attribute.method_name - - expect(first_result).to equal(second_result) - end - end - end - - describe "#define_and_verify_tree" do - let(:child1) { described_class.new(:child1) } - let(:child2) { described_class.new(:child2) } - - before do - attribute.task = task - attribute.children.push(child1, child2) - - allow(attribute).to receive(:define_and_verify) - allow(child1).to receive(:define_and_verify_tree) - allow(child2).to receive(:define_and_verify_tree) - end - - it "calls define_and_verify on self" do - expect(attribute).to receive(:define_and_verify) - - attribute.define_and_verify_tree - end - - it "sets task on all children" do - attribute.define_and_verify_tree - - expect(child1.task).to eq(task) - expect(child2.task).to eq(task) - end - - it "calls define_and_verify_tree on all children" do - expect(child1).to receive(:define_and_verify_tree) - expect(child2).to receive(:define_and_verify_tree) - - attribute.define_and_verify_tree - end - end - - describe "#to_h" do - context "with basic attribute" do - let(:attribute_options) { { type: :string, default: "test" } } - - it "returns a hash representation" do - result = attribute.to_h - - expect(result).to be_a(Hash) - expect(result).to include( - name: :test_attr, - method_name: :test_attr, - required: false, - types: [:string], - children: [] - ) - end - - it "includes options without :if and :unless" do - result = attribute.to_h - - expect(result[:options]).to eq(default: "test") - end - end - - context "with required attribute" do - let(:attribute_options) { { required: true, type: :integer } } - - it "sets required to true" do - expect(attribute.to_h[:required]).to be(true) - end - end - - context "with conditional options" do - let(:attribute_options) { { type: :string, if: :some_condition?, unless: :other_condition?, default: "value" } } - - it "excludes :if and :unless from options" do - result = attribute.to_h - - expect(result[:options]).not_to have_key(:if) - expect(result[:options]).not_to have_key(:unless) - expect(result[:options]).to eq(default: "value") - end - end - - context "with nested children" do - let(:attribute) do - described_class.new(:parent_attr, type: :hash) do - optional :child1, type: :string - required :child2, type: :integer - end - end - - it "includes children as array of hashes" do - result = attribute.to_h - - expect(result[:children]).to be_an(Array) - expect(result[:children].size).to eq(2) - end - - it "recursively converts children to hashes" do - result = attribute.to_h - child_names = result[:children].map { |c| c[:name] } - - expect(child_names).to contain_exactly(:child1, :child2) - end - - it "preserves child metadata" do - result = attribute.to_h - child1 = result[:children].find { |c| c[:name] == :child1 } - child2 = result[:children].find { |c| c[:name] == :child2 } - - expect(child1[:required]).to be(false) - expect(child1[:types]).to eq([:string]) - expect(child2[:required]).to be(true) - expect(child2[:types]).to eq([:integer]) - end - end - - context "with custom method name" do - let(:attribute_options) { { as: :custom_method, type: :string } } - - it "includes the custom method name" do - expect(attribute.to_h[:method_name]).to eq(:custom_method) - end - end - end - - describe "private methods" do - describe "#attribute" do - let(:child_options) { { required: true } } - - before do - attribute.send(:attribute, :child_attr, **child_options) - end - - it "creates child attribute" do - expect(attribute.children.size).to eq(1) - expect(attribute.children.first.name).to eq(:child_attr) - end - - it "sets parent on child" do - expect(attribute.children.first.parent).to eq(attribute) - end - - it "merges parent option with provided options" do - expect(attribute.children.first.required?).to be(true) - end - end - - describe "#attributes" do - let(:names) { %i[child1 child2] } - let(:child_options) { { required: true } } - - before do - attribute.send(:attributes, *names, **child_options) - end - - it "creates multiple child attributes" do - expect(attribute.children.size).to eq(2) - expect(attribute.children.map(&:name)).to eq(%i[child1 child2]) - end - - it "sets parent on all children" do - expect(attribute.children).to all(have_attributes(parent: attribute)) - end - end - - describe "#optional" do - before do - attribute.send(:optional, :child1, :child2, type: :string) - end - - it "creates optional child attributes" do - expect(attribute.children.size).to eq(2) - expect(attribute.children).to all(satisfy { |attr| !attr.required? }) - end - end - - describe "#required" do - before do - attribute.send(:required, :child1, :child2, type: :string) - end - - it "creates required child attributes" do - expect(attribute.children.size).to eq(2) - expect(attribute.children).to all(satisfy(&:required?)) - end - end - - describe "#define_and_verify" do - let(:attribute_value) { instance_double(CMDx::AttributeValue, generate: nil, validate: nil) } - - before do - attribute.task = task - - allow(CMDx::AttributeValue).to receive(:new).and_return(attribute_value) - allow(attribute).to receive(:method_name).and_return(:test_method) - end - - context "when method is not already defined" do - before do - allow(task).to receive(:respond_to?).with(:test_method, true).and_return(false) - end - - it "creates AttributeValue instance" do - expect(CMDx::AttributeValue).to receive(:new).with(attribute) - - attribute.send(:define_and_verify) - end - - it "calls generate and validate on AttributeValue" do - expect(attribute_value).to receive(:generate) - expect(attribute_value).to receive(:validate) - - attribute.send(:define_and_verify) - end - - it "defines method on task class" do - expect(task.class).to receive(:define_method).with(:test_method) - - attribute.send(:define_and_verify) - end - end - - context "when method is already defined" do - before do - allow(task).to receive(:respond_to?).with(:test_method, true).and_return(true) - allow(task.class).to receive(:name).and_return("TestTask") - end - - it "raises error" do - expect do - attribute.send(:define_and_verify) - end.to raise_error(<<~MESSAGE) - The method :test_method is already defined on the TestTask task. - This may be due conflicts with one of the task's user defined or internal methods/attributes. - - Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods. - MESSAGE - end - end - end - end - - describe "AFFIX constant" do - subject(:affix_proc) { described_class.const_get(:AFFIX) } - - it "is a frozen proc" do - expect(affix_proc).to be_a(Proc) - expect(affix_proc).to be_frozen - end - - context "when value is true" do - it "calls the block" do - result = affix_proc.call(true) { "block_result" } - - expect(result).to eq("block_result") - end - end - - context "when value is not true" do - it "returns the value" do - result = affix_proc.call("custom_value") { "block_result" } - - expect(result).to eq("custom_value") - end - end - end -end diff --git a/spec/cmdx/attribute_value_spec.rb b/spec/cmdx/attribute_value_spec.rb deleted file mode 100644 index 4dd5a1852..000000000 --- a/spec/cmdx/attribute_value_spec.rb +++ /dev/null @@ -1,763 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::AttributeValue, type: :unit do - let(:task_class) { create_task_class } - let(:errors) { instance_double(CMDx::Errors, add: nil, for?: false) } - let(:task) { task_class.new } - let(:attribute) { CMDx::Attribute.new(:test_attr, **attribute_options) } - let(:attribute_options) { {} } - let(:attribute_value) { described_class.new(attribute) } - - before do - attribute.task = task - allow(task).to receive_messages(attributes: {}, errors: errors) - end - - describe "#initialize" do - it "sets the attribute" do - expect(attribute_value.attribute).to eq(attribute) - end - end - - describe "#value" do - let(:method_name) { :test_method } - - before do - allow(attribute_value).to receive(:method_name).and_return(method_name) - allow(task).to receive(:attributes).and_return({ method_name => "test_value" }) - end - - it "returns value from attributes hash" do - expect(attribute_value.value).to eq("test_value") - end - end - - describe "#generate" do - let(:method_name) { :test_method } - let(:attributes) { {} } - - before do - allow(attribute_value).to receive(:method_name).and_return(method_name) - allow(task).to receive(:attributes).and_return(attributes) - end - - context "when value already exists in attributes" do - let(:attributes) { { method_name => "existing_value" } } - - it "returns existing value" do - expect(attribute_value.generate).to eq("existing_value") - end - - it "does not call source_value" do - expect(attribute_value).not_to receive(:source_value) - attribute_value.generate - end - end - - context "when value does not exist" do - before do - allow(attribute_value).to receive_messages( - source_value: "sourced", - derive_value: "derived", - coerce_value: "coerced" - ) - end - - it "processes value through pipeline and stores result" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - allow(attribute_value).to receive(:derive_value).with("sourced").and_return("derived") - allow(attribute_value).to receive(:coerce_value).with("derived").and_return("coerced") - - expect { attribute_value.generate }.to change { attributes[method_name] }.to("coerced") - end - - context "when errors occur after source_value" do - before { allow(errors).to receive(:for?).with(method_name).and_return(true) } - - it "returns nil without processing further" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - - expect(attribute_value).not_to receive(:derive_value) - - expect(attribute_value.generate).to be_nil - end - end - - context "when errors occur after derive_value" do - before do - allow(errors).to receive(:for?).with(method_name).and_return(false, true) - allow(attribute_value).to receive(:derive_value).and_return("derived") - end - - it "returns nil without coercing" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - allow(attribute_value).to receive(:derive_value).with("sourced").and_return("derived") - - expect(attribute_value).not_to receive(:coerce_value) - - expect(attribute_value.generate).to be_nil - end - end - - context "when errors occur after coerce_value" do - before do - allow(errors).to receive(:for?).with(method_name).and_return(false, false, true) - allow(attribute_value).to receive(:coerce_value).and_return("coerced") - end - - it "returns nil without storing value" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - allow(attribute_value).to receive(:derive_value).with("sourced").and_return("derived") - allow(attribute_value).to receive(:coerce_value).with("derived").and_return("coerced") - - result = attribute_value.generate - - expect(result).to be_nil - expect(attributes).not_to have_key(method_name) - end - end - end - end - - describe "#validate" do - let(:method_name) { :test_method } - let(:validator_registry) { instance_double(CMDx::ValidatorRegistry) } - let(:task_settings) { mock_settings(validators: validator_registry) } - let(:attribute_options) { { format: /\d+/, presence: true } } - - before do - allow(attribute_value).to receive_messages(method_name: method_name, value: "test_value") - allow(task.class).to receive(:settings).and_return(task_settings) - allow(validator_registry).to receive(:keys).and_return(%i[format presence]) - allow(validator_registry).to receive(:validate) - end - - it "validates each matching validator option" do - expect(validator_registry).to receive(:validate).with(:format, task, "test_value", /\d+/) - expect(validator_registry).to receive(:validate).with(:presence, task, "test_value", true) - - expect { attribute_value.validate }.not_to raise_error - end - - context "when validation fails" do - let(:validation_error) { CMDx::ValidationError.new("invalid format") } - - before do - allow(validator_registry).to receive(:validate).and_raise(validation_error) - end - - it "adds error message" do - expect(errors).to receive(:add).with(method_name, "invalid format").twice - - attribute_value.validate - end - end - - context "when options don't match registry keys" do - let(:attribute_options) { { unknown_option: true } } - - it "does not validate unknown options" do - expect(validator_registry).not_to receive(:validate) - - expect { attribute_value.validate }.not_to raise_error - end - end - end - - describe "private methods" do - describe "#source_value" do - let(:source) { :context } - let(:method_name) { :test_method } - - before do - allow(attribute_value).to receive_messages( - source: source, - method_name: method_name, - required?: false - ) - end - - context "when source is a symbol" do - let(:source) { :config } - - before { allow(task).to receive(:config).and_return("config_value") } - - it "calls method on task" do - allow(task).to receive(:config).and_return("config_value") - - expect(attribute_value.send(:source_value)).to eq("config_value") - end - end - - context "when source is a proc" do - let(:source) { proc { "proc_result" } } - - before { allow(task).to receive(:instance_eval).and_return("proc_result") } - - it "evaluates proc in task context" do - allow(task).to receive(:instance_eval).and_return("proc_result") - - expect(attribute_value.send(:source_value)).to eq("proc_result") - end - end - - context "when source is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_result") } - let(:source) { callable } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with task" do - allow(source).to receive(:call).with(task).and_return("callable_result") - - expect(attribute_value.send(:source_value)).to eq("callable_result") - end - end - - context "when source is not callable" do - let(:source) { "string_value" } - - it "returns source directly" do - expect(attribute_value.send(:source_value)).to eq("string_value") - end - end - - context "when source method raises NoMethodError" do - let(:source) { :nonexistent_method } - - before do - allow(task).to receive(:nonexistent_method).and_raise(NoMethodError) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.undefined", method: source).and_return("undefined method error") - end - - it "adds error and returns nil" do - expect(errors).to receive(:add).with(method_name, "undefined method error") - - expect(attribute_value.send(:source_value)).to be_nil - end - end - - context "when attribute is required" do - let(:name) { :test_name } - - before do - allow(attribute_value).to receive_messages( - required?: true, - parent: nil, - name: name, - options: {} - ) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, {}).and_return(true) - end - - context "with Context source" do - let(:context) { CMDx::Context.new(test_name: "value") } - let(:source) { context } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(false) } - - it "checks if context has key" do - expect(attribute_value.send(:source_value)).to eq(context) - end - - context "when context missing key" do - let(:context) { CMDx::Context.new({}) } - - before do - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, {}).and_return(true) - end - - it "adds required error" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(context) - end - end - end - - context "with Hash source" do - let(:source) { { test_name: "value" } } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(false) } - - it "checks if hash has key" do - expect(attribute_value.send(:source_value)).to eq(source) - end - end - - context "with string-keyed Hash source" do - let(:source) { { "test_name" => "value" } } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(false) } - - it "checks both string and symbol key presence" do - expect(attribute_value.send(:source_value)).to eq(source) - end - - context "when hash missing key" do - let(:source) { { "other_key" => "value" } } - - before do - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - end - - it "adds required error" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source) - end - end - end - - context "with Proc source" do - let(:source) { proc { "value" } } - - before { allow(task).to receive(:instance_eval).and_return("value") } - - it "assumes proc can provide value" do - expect(attribute_value.send(:source_value)).to eq("value") - end - end - - context "with object that responds to method" do - let(:source_object) { instance_double("MockSource", test_name: "value") } - let(:source) { source_object } - - before do - allow(source).to receive(:respond_to?).with(:call).and_return(false) - allow(source).to receive(:respond_to?).with(name, true).and_return(true) - end - - it "checks if object responds to method" do - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when parent is required" do - let(:parent) { instance_double(CMDx::Attribute, required?: true) } - - before { allow(attribute_value).to receive(:parent).and_return(parent) } - - context "when source doesn't provide value" do - let(:source_object) { instance_double("MockSource") } - let(:source) { source_object } - - before do - allow(source).to receive(:respond_to?).with(:call).and_return(false) - allow(source).to receive(:respond_to?).with(name, true).and_return(false) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - allow(attribute_value).to receive(:options).and_return({}) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, {}).and_return(true) - end - - it "adds required error" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - end - - context "with conditional requirement" do - let(:source_object) { instance_double("MockSource") } - let(:source) { source_object } - - before do - allow(source).to receive(:respond_to?).with(:call).and_return(false) - allow(source).to receive(:respond_to?).with(name, true).and_return(false) - allow(attribute_value).to receive(:options).and_return(attribute_options) - end - - context "when condition evaluates to true" do - let(:attribute_options) { { if: :enabled? } } - - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(true) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - end - - it "validates required attribute" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when condition evaluates to false" do - let(:attribute_options) { { if: :enabled? } } - - before { allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(false) } - - it "skips required validation" do - expect(errors).not_to receive(:add) - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when no condition is specified" do - let(:attribute_options) { {} } - - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(true) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - end - - it "defaults to true and validates required attribute" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when condition uses unless" do - let(:attribute_options) { { unless: :disabled? } } - - before { allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(false) } - - it "skips required validation when unless condition is true" do - expect(errors).not_to receive(:add) - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - end - end - end - - describe "#default_value" do - let(:attribute_options) { { default: default_option } } - let(:default_option) { "default_string" } - - context "when default is a string" do - it "returns the string" do - expect(attribute_value.send(:default_value)).to eq("default_string") - end - end - - context "when default is a symbol and task responds to it" do - let(:default_option) { :default_method } - - before do - allow(task).to receive(:respond_to?).with(:default_method, true).and_return(true) - allow(task).to receive(:respond_to?).with(:default_method).and_return(true) - allow(task).to receive(:default_method).and_return("method_result") - end - - it "calls method on task" do - allow(task).to receive(:default_method).and_return("method_result") - - expect(attribute_value.send(:default_value)).to eq("method_result") - end - end - - context "when default is a symbol but task doesn't respond" do - let(:default_option) { :nonexistent_method } - - before { allow(task).to receive(:respond_to?).with(:nonexistent_method, true).and_return(false) } - - it "returns the symbol" do - expect(attribute_value.send(:default_value)).to eq(:nonexistent_method) - end - end - - context "when default is a proc" do - let(:default_option) { proc { "proc_default" } } - - before { allow(task).to receive(:instance_eval).and_return("proc_default") } - - it "evaluates proc in task context" do - allow(task).to receive(:instance_eval).and_return("proc_default") - - expect(attribute_value.send(:default_value)).to eq("proc_default") - end - end - - context "when default is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_default") } - let(:default_option) { callable } - - before { allow(default_option).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with task" do - allow(default_option).to receive(:call).with(task).and_return("callable_default") - - expect(attribute_value.send(:default_value)).to eq("callable_default") - end - end - - context "when no default option" do - let(:attribute_options) { {} } - - it "returns nil" do - expect(attribute_value.send(:default_value)).to be_nil - end - end - end - - describe "#derive_value" do - let(:name) { :test_name } - let(:method_name) { :test_method } - - before do - allow(attribute_value).to receive_messages( - name: name, - method_name: method_name, - default_value: "default" - ) - end - - context "when source_value is Context" do - let(:context) { CMDx::Context.new(test_name: "context_value") } - - it "extracts value using name key" do - expect(attribute_value.send(:derive_value, context)).to eq("context_value") - end - - context "when context doesn't have key" do - let(:context) { CMDx::Context.new({}) } - - it "returns default value" do - expect(attribute_value.send(:derive_value, context)).to eq("default") - end - end - end - - context "when source_value is Hash" do - let(:hash) { { test_name: "hash_value" } } - - it "extracts value using name key" do - expect(attribute_value.send(:derive_value, hash)).to eq("hash_value") - end - - context "with string keys" do - let(:hash) { { "test_name" => "hash_value" } } - - it "extracts value using string or symbol key" do - expect(attribute_value.send(:derive_value, hash)).to eq("hash_value") - end - end - end - - context "when source_value responds to attribute name" do - let(:source_object) { instance_double("MockSource", test_name: "object_value") } - - before do - allow(source_object).to receive(:respond_to?).with(:call).and_return(false) - allow(source_object).to receive(:respond_to?).with(name, true).and_return(true) - end - - it "derives value via send" do - expect(attribute_value.send(:derive_value, source_object)).to eq("object_value") - end - - context "when send raises NoMethodError" do - before do - allow(source_object).to receive(:test_name).and_raise(NoMethodError) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.undefined", method: name).and_return("undefined error") - end - - it "adds error and returns nil" do - expect(errors).to receive(:add).with(method_name, "undefined error") - - expect(attribute_value.send(:derive_value, source_object)).to be_nil - end - end - end - - context "when source_value does not respond to call or attribute name" do - let(:source_object) { instance_double("MockSource") } - - before do - allow(source_object).to receive(:respond_to?).with(:call).and_return(false) - allow(source_object).to receive(:respond_to?).with(name, true).and_return(false) - end - - it "returns default value" do - expect(attribute_value.send(:derive_value, source_object)).to eq("default") - end - end - - context "when source_value is Proc" do - let(:proc_obj) { proc { |n| "proc_#{n}" } } - - before { allow(task).to receive(:instance_exec).with(name, &proc_obj).and_return("proc_test_name") } - - it "executes proc with name in task context" do - allow(task).to receive(:instance_exec).with(name, &proc_obj).and_return("proc_test_name") - - expect(attribute_value.send(:derive_value, proc_obj)).to eq("proc_test_name") - end - end - - context "when source_value is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_value") } - - before { allow(callable).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with task and name" do - allow(callable).to receive(:call).with(task, name).and_return("callable_value") - - expect(attribute_value.send(:derive_value, callable)).to eq("callable_value") - end - end - - context "when source_value is not callable" do - it "returns default value" do - expect(attribute_value.send(:derive_value, "not_callable")).to eq("default") - end - end - - context "when derived_value is nil" do - let(:hash) { { other_key: "value" } } - - it "returns default value" do - expect(attribute_value.send(:derive_value, hash)).to eq("default") - end - end - end - - describe "#transform_value" do - let(:attribute_options) { { transform: transform_option } } - let(:transform_option) { :upcase } - let(:derived_value) { "hello" } - - context "when transform is a symbol and value responds to it" do - let(:transform_option) { :upcase } - - it "calls method on derived value" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("HELLO") - end - end - - context "when transform is a symbol but value doesn't respond to it" do - let(:transform_option) { :nonexistent_method } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - - context "when transform is a proc" do - let(:transform_option) { proc { |v| "transformed_#{v}" } } - - before { allow(task).to receive(:instance_eval).with(derived_value, &transform_option).and_return("transformed_hello") } - - it "evaluates proc with derived value in task context" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("transformed_hello") - end - end - - context "when transform is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_transformed") } - let(:transform_option) { callable } - - before { allow(transform_option).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with derived value" do - allow(transform_option).to receive(:call).with(derived_value).and_return("callable_transformed") - - expect(attribute_value.send(:transform_value, derived_value)).to eq("callable_transformed") - end - end - - context "when transform is not callable" do - let(:transform_option) { "string_transform" } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - - context "when no transform option" do - let(:attribute_options) { {} } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - - context "when transform is nil" do - let(:transform_option) { nil } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - end - - describe "#coerce_value" do - let(:method_name) { :test_method } - let(:coercion_registry) { instance_double(CMDx::CoercionRegistry) } - let(:task_settings) { mock_settings(coercions: coercion_registry) } - let(:types) { %i[string integer] } - - before do - allow(attribute_value).to receive(:method_name).and_return(method_name) - allow(attribute).to receive(:types).and_return(types) - allow(task.class).to receive(:settings).and_return(task_settings) - end - - context "when attribute has no types" do - let(:types) { [] } - - it "returns value unchanged" do - expect(attribute_value.send(:coerce_value, "unchanged")).to eq("unchanged") - end - end - - context "when coercion succeeds on first type" do - before do - allow(coercion_registry).to receive(:coerce).with(:string, task, "123", {}).and_return("coerced_string") - end - - it "returns coerced value" do - expect(attribute_value.send(:coerce_value, "123")).to eq("coerced_string") - end - end - - context "when first coercion fails but second succeeds" do - before do - allow(coercion_registry).to receive(:coerce).with(:string, task, "123", {}).and_raise(CMDx::CoercionError) - allow(coercion_registry).to receive(:coerce).with(:integer, task, "123", {}).and_return(123) - end - - it "returns coerced value from successful type" do - expect(attribute_value.send(:coerce_value, "123")).to eq(123) - end - end - - context "when all coercions fail" do - before do - allow(coercion_registry).to receive(:coerce).and_raise(CMDx::CoercionError) - allow(CMDx::Locale).to receive(:t).with("cmdx.types.string").and_return("String") - allow(CMDx::Locale).to receive(:t).with("cmdx.types.integer").and_return("Integer") - allow(CMDx::Locale).to receive(:t).with("cmdx.coercions.into_any", types: "String, Integer").and_return("coercion error") - end - - it "adds error and returns nil" do - expect(errors).to receive(:add).with(method_name, "coercion error") - - expect(attribute_value.send(:coerce_value, "invalid")).to be_nil - end - end - - context "when coercion fails on intermediate type" do - let(:types) { %i[string integer float] } - - before do - allow(coercion_registry).to receive(:coerce).with(:string, task, "123", {}).and_raise(CMDx::CoercionError) - allow(coercion_registry).to receive(:coerce).with(:integer, task, "123", {}).and_raise(CMDx::CoercionError) - allow(coercion_registry).to receive(:coerce).with(:float, task, "123", {}).and_return(123.0) - end - - it "continues to next type and succeeds" do - expect(attribute_value.send(:coerce_value, "123")).to eq(123.0) - end - end - end - end -end diff --git a/spec/cmdx/callback_registry_spec.rb b/spec/cmdx/callback_registry_spec.rb deleted file mode 100644 index 14e0843e5..000000000 --- a/spec/cmdx/callback_registry_spec.rb +++ /dev/null @@ -1,396 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::CallbackRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { {} } - let(:mock_task) { instance_double(CMDx::Task) } - let(:callable_proc) { proc { |task| } } - let(:callable_symbol) { :some_method } - let(:callable_object) { instance_double("MockCallable", call: nil) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with an empty hash" do - expect(registry.registry).to eq({}) - end - end - - context "when a registry is provided" do - let(:initial_registry) { { before_execution: Set.new } } - - it "initializes with the provided registry" do - expect(registry.registry).to eq(initial_registry) - end - end - end - - describe "#registry" do - it "returns the internal registry" do - expect(registry.registry).to eq(initial_registry) - end - end - - describe "#to_h" do - it "aliases the registry method" do - expect(registry.to_h).to eq(registry.registry) - end - end - - describe "#dup" do - context "when registry has values" do - let(:initial_registry) do - { - before_execution: Set.new([[callable_proc], {}]), - on_success: Set.new([[callable_symbol], { if: :success? }]) - } - end - - it "returns a new instance" do - duplicated = registry.dup - - expect(duplicated).not_to be(registry) - expect(duplicated).to be_a(described_class) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) - end - - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - original_size = registry.registry[:before_execution].size - - duplicated.register(:before_execution, :new_callback) - - expect(duplicated.registry).not_to be(registry.registry) - expect(duplicated.registry[:before_execution]).not_to be(registry.registry[:before_execution]) - expect(registry.registry[:before_execution].size).to eq(original_size) - end - end - - context "when registry is empty" do - let(:initial_registry) { {} } - - it "returns a new instance with empty registry" do - duplicated = registry.dup - - expect(duplicated.registry).to eq({}) - expect(duplicated.registry).to be(registry.registry) - end - end - end - - describe "#register" do - it "returns self for method chaining" do - result = registry.register(:before_execution, callable_proc) - - expect(result).to be(registry) - end - - context "when registering a single callable" do - it "adds the callable to the registry" do - registry.register(:before_execution, callable_proc) - - expect(registry.registry[:before_execution]).to be_a(Set) - expect(registry.registry[:before_execution]).to include([[callable_proc], {}]) - end - end - - context "when registering multiple callables" do - it "adds all callables as a single entry" do - registry.register(:before_execution, callable_proc, callable_symbol) - - expect(registry.registry[:before_execution]).to include([[callable_proc, callable_symbol], {}]) - end - end - - context "when registering with options" do - let(:options) { { if: :active?, unless: :disabled? } } - - it "stores the options with the callables" do - registry.register(:before_execution, callable_proc, **options) - - expect(registry.registry[:before_execution]).to include([[callable_proc], options]) - end - end - - context "when registering with a block" do - it "adds the block to the callables list" do - block = proc { |task| task.blocked = true } - registry.register(:before_execution, callable_proc, &block) - - expect(registry.registry[:before_execution]).to include([[callable_proc, block], {}]) - end - end - - context "when registering to the same type multiple times" do - it "accumulates callbacks in a Set" do - registry.register(:before_execution, callable_proc) - registry.register(:before_execution, callable_symbol) - - expect(registry.registry[:before_execution].size).to eq(2) - expect(registry.registry[:before_execution]).to include([[callable_proc], {}]) - expect(registry.registry[:before_execution]).to include([[callable_symbol], {}]) - end - end - - context "when registering the same callback twice" do - it "stores only one copy due to Set behavior" do - registry.register(:before_execution, callable_proc) - registry.register(:before_execution, callable_proc) - - expect(registry.registry[:before_execution].size).to eq(1) - end - end - end - - describe "#deregister" do - it "returns self for method chaining" do - registry.register(:before_execution, callable_proc) - result = registry.deregister(:before_execution, callable_proc) - - expect(result).to be(registry) - end - - context "when deregistering a single callable" do - before do - registry.register(:before_execution, callable_proc) - end - - it "removes the callable from the registry" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering multiple callables" do - before do - registry.register(:before_execution, callable_proc, callable_symbol) - end - - it "removes the exact callables entry" do - registry.deregister(:before_execution, callable_proc, callable_symbol) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering with options" do - let(:options) { { if: :active?, unless: :disabled? } } - - before do - registry.register(:before_execution, callable_proc, **options) - end - - it "removes only the entry with matching options" do - registry.deregister(:before_execution, callable_proc, **options) - - expect(registry.registry).not_to have_key(:before_execution) - end - - it "does not remove entries with different options" do - registry.register(:before_execution, callable_proc, if: :other?) - registry.deregister(:before_execution, callable_proc, **options) - - expect(registry.registry[:before_execution]).to include([[callable_proc], { if: :other? }]) - end - end - - context "when deregistering with a block" do - it "removes the entry with the block" do - block = proc { |task| task.blocked = true } - registry.register(:before_execution, callable_proc, &block) - registry.deregister(:before_execution, callable_proc, &block) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering from empty registry" do - it "does not raise an error" do - expect { registry.deregister(:before_execution, callable_proc) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:before_execution, callable_proc) - - expect(result).to be(registry) - end - end - - context "when deregistering non-existent callback" do - before do - registry.register(:before_execution, callable_symbol) - end - - it "does not affect existing callbacks" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry[:before_execution]).to include([[callable_symbol], {}]) - end - end - - context "when deregistering from non-existent type" do - it "does not raise an error" do - expect { registry.deregister(:non_existent, callable_proc) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:non_existent, callable_proc) - - expect(result).to be(registry) - end - end - - context "when deregistering the last callback of a type" do - before do - registry.register(:before_execution, callable_proc) - end - - it "removes the type from the registry" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering one of multiple callbacks" do - before do - registry.register(:before_execution, callable_proc) - registry.register(:before_execution, callable_symbol) - end - - it "removes only the specified callback" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry[:before_execution]).not_to include([[callable_proc], {}]) - expect(registry.registry[:before_execution]).to include([[callable_symbol], {}]) - end - end - end - - describe "#invoke" do - context "when type is valid" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - end - - context "with symbol callbacks" do - before do - registry.register(:before_execution, callable_symbol) - end - - it "evaluates conditions for each callback entry" do - allow(mock_task).to receive(:send).with(callable_symbol) - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, {}) - - registry.invoke(:before_execution, mock_task) - end - - it "invokes the symbol via task.send" do - expect(mock_task).to receive(:send).with(callable_symbol) - - registry.invoke(:before_execution, mock_task) - end - end - - context "with proc callbacks" do - before do - registry.register(:before_execution, callable_proc) - end - - it "invokes the proc via task.instance_exec" do - expect(mock_task).to receive(:instance_exec) do |&block| - expect(block).to eq(callable_proc) - end - - registry.invoke(:before_execution, mock_task) - end - end - - context "with callable object callbacks" do - before do - registry.register(:before_execution, callable_object) - end - - it "invokes the callable via .call" do - expect(callable_object).to receive(:call).with(mock_task) - - registry.invoke(:before_execution, mock_task) - end - end - - context "when conditions are not met" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(false) - registry.register(:before_execution, callable_symbol) - end - - it "does not invoke the callables" do - expect(mock_task).not_to receive(:send) - - registry.invoke(:before_execution, mock_task) - end - end - - context "when multiple callback entries exist" do - before do - registry.register(:before_execution, callable_proc, if: :active?) - registry.register(:before_execution, callable_symbol, unless: :disabled?) - allow(mock_task).to receive(:send).with(callable_symbol) - allow(mock_task).to receive(:instance_exec) - end - - it "processes all entries" do - expect(CMDx::Utils::Condition).to receive(:evaluate).twice - - registry.invoke(:before_execution, mock_task) - end - end - end - - context "when type is invalid" do - it "raises TypeError for unknown callback type" do - expect { registry.invoke(:invalid_type, mock_task) } - .to raise_error(TypeError, "unknown callback type :invalid_type") - end - end - - context "when no callbacks are registered for the type" do - it "does not raise an error" do - expect { registry.invoke(:before_execution, mock_task) }.not_to raise_error - end - - it "does not attempt to invoke any callables" do - expect(mock_task).not_to receive(:send) - expect(mock_task).not_to receive(:instance_exec) - - registry.invoke(:before_execution, mock_task) - end - end - - context "when registry value is not a Set" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - registry.registry[:before_execution] = [[callable_proc], {}] - end - - it "handles Array conversion gracefully" do - expect(mock_task).to receive(:instance_exec) do |&block| - expect(block).to eq(callable_proc) - end - - expect { registry.invoke(:before_execution, mock_task) }.not_to raise_error - end - end - end -end diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb deleted file mode 100644 index a7e7735ce..000000000 --- a/spec/cmdx/chain_spec.rb +++ /dev/null @@ -1,355 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Chain, type: :unit do - subject(:chain) { described_class.new } - - let(:fiber_or_thread) { Fiber.respond_to?(:storage) ? Fiber.storage : Thread.current } - let(:mock_task) { instance_double(CMDx::Task) } - let(:mock_result) do - instance_double(CMDx::Result, to_h: { id: "result-1" }).tap do |mock| - allow(mock).to receive(:is_a?) do |klass| - klass == CMDx::Result - end - end - end - let(:mock_result2) do - instance_double(CMDx::Result, to_h: { id: "result-2" }).tap do |mock| - allow(mock).to receive(:is_a?) do |klass| - klass == CMDx::Result - end - end - end - - before do - allow(CMDx::Identifier).to receive(:generate).and_return("chain-id-123") - end - - describe "#initialize" do - it "generates a unique id" do - expect(CMDx::Identifier).to receive(:generate) - - chain - end - - it "initializes with an empty results array" do - expect(chain.results).to eq([]) - end - - it "sets the id from Identifier.generate" do - expect(chain.id).to eq("chain-id-123") - end - end - - describe "attr_readers" do - it "provides read access to id" do - expect(chain.id).to eq("chain-id-123") - end - - it "provides read access to results" do - expect(chain.results).to eq([]) - end - end - - describe ".current" do - after { described_class.clear } - - context "when no chain is set" do - it "returns nil" do - expect(described_class.current).to be_nil - end - end - - context "when a chain is set in current thread" do - it "returns the current chain" do - described_class.current = chain - expect(described_class.current).to eq(chain) - end - end - end - - describe ".current=" do - after { described_class.clear } - - it "sets the current chain in thread storage" do - described_class.current = chain - expect(fiber_or_thread[described_class::CONCURRENCY_KEY]).to eq(chain) - end - - it "allows setting to nil" do - described_class.current = chain - described_class.current = nil - expect(described_class.current).to be_nil - end - end - - describe ".clear" do - before { described_class.current = chain } - - it "sets current chain to nil" do - described_class.clear - expect(described_class.current).to be_nil - end - - it "clears thread storage" do - described_class.clear - expect(fiber_or_thread[described_class::CONCURRENCY_KEY]).to be_nil - end - end - - describe ".build" do - after { described_class.clear } - - context "when result is not a CMDx::Result" do - it "raises TypeError" do - expect { described_class.build("not-a-result") }.to raise_error( - TypeError, "must be a CMDx::Result" - ) - end - - it "raises TypeError for nil" do - expect { described_class.build(nil) }.to raise_error( - TypeError, "must be a CMDx::Result" - ) - end - end - - context "when result is a valid CMDx::Result" do - context "when no current chain exists" do - it "creates a new chain and sets it as current" do - result_chain = described_class.build(mock_result) - - expect(result_chain).to be_a(described_class) - expect(described_class.current).to eq(result_chain) - expect(result_chain.results).to contain_exactly(mock_result) - end - end - - context "when a current chain already exists" do - before { described_class.current = chain } - - it "uses the existing chain and adds the result" do - result_chain = described_class.build(mock_result) - - expect(result_chain).to eq(chain) - expect(chain.results).to contain_exactly(mock_result) - end - end - - context "when building multiple results" do - before { described_class.current = chain } - - it "adds results in order" do - described_class.build(mock_result) - described_class.build(mock_result2) - - expect(chain.results).to eq([mock_result, mock_result2]) - end - end - - context "with dry_run option" do - context "when no current chain exists" do - it "creates a new chain with dry_run set to true" do - result_chain = described_class.build(mock_result, dry_run: true) - - expect(result_chain.dry_run?).to be(true) - end - - it "creates a new chain with dry_run set to false" do - result_chain = described_class.build(mock_result, dry_run: false) - - expect(result_chain.dry_run?).to be(false) - end - end - - context "when a current chain already exists" do - before { described_class.current = chain } - - it "does not update existing chain dry_run status" do - result_chain = described_class.build(mock_result, dry_run: true) - - expect(result_chain).to eq(chain) - expect(result_chain.dry_run?).to be(false) - end - end - end - end - end - - describe "#to_h" do - let(:result_hash1) { { id: "result-1", status: "success" } } - let(:result_hash2) { { id: "result-2", status: "failed" } } - - before do - allow(mock_result).to receive(:to_h).and_return(result_hash1) - allow(mock_result2).to receive(:to_h).and_return(result_hash2) - end - - context "when results array is empty" do - it "returns hash with id and empty results array" do - expect(chain.to_h).to eq( - { - id: "chain-id-123", - dry_run: false, - results: [] - } - ) - end - end - - context "when results array has results" do - before do - chain.results << mock_result - chain.results << mock_result2 - - allow(mock_result).to receive(:to_h).and_return(result_hash1) - allow(mock_result2).to receive(:to_h).and_return(result_hash2) - end - - it "returns hash with id and results converted to hashes" do - expect(chain.to_h).to eq( - { - id: "chain-id-123", - dry_run: false, - results: [result_hash1, result_hash2] - } - ) - end - - it "calls to_h on each result" do - expect(mock_result).to receive(:to_h) - expect(mock_result2).to receive(:to_h) - - chain.to_h - end - end - end - - describe "#to_s" do - let(:formatted_string) { "id=\"chain-id-123\" dry_run=false results=[]" } - - it "converts to hash and formats as string" do - expect(CMDx::Utils::Format).to receive(:to_str).with( - { - id: "chain-id-123", - dry_run: false, - results: [] - } - ) - - chain.to_s - end - end - - describe "#index" do - it "returns the position of a result in the chain" do - chain.push(mock_result) - chain.push(mock_result2) - - expect(chain.index(mock_result)).to eq(0) - expect(chain.index(mock_result2)).to eq(1) - end - - it "returns nil for a result not in the chain" do - expect(chain.index(mock_result)).to be_nil - end - - it "is thread-safe under concurrent push and index" do - results = Array.new(100) do |i| - instance_double(CMDx::Result, to_h: { id: "result-#{i}" }).tap do |mock| - allow(mock).to receive(:is_a?).with(CMDx::Result).and_return(true) - end - end - - threads = results.map { |r| Thread.new { chain.push(r) } } - threads.each(&:join) - - results.each do |r| - expect(chain.index(r)).to be_a(Integer) - end - end - end - - describe "thread safety" do - after { described_class.clear } - - it "maintains separate chains per thread" do - thread1_chain = nil - thread2_chain = nil - - thread1 = Thread.new do - described_class.current = described_class.new - thread1_chain = described_class.current - end - - thread2 = Thread.new do - described_class.current = described_class.new - thread2_chain = described_class.current - end - - thread1.join - thread2.join - - expect(thread1_chain).not_to eq(thread2_chain) - expect(thread1_chain).to be_a(described_class) - expect(thread2_chain).to be_a(described_class) - end - - it "does not interfere with main thread chain" do - main_chain = described_class.new - described_class.current = main_chain - - thread_chain = nil - thread = Thread.new do - described_class.current = described_class.new - thread_chain = described_class.current - end - thread.join - - expect(described_class.current).to eq(main_chain) - expect(thread_chain).not_to eq(main_chain) - end - end - - describe "fiber safety" do - after { described_class.clear } - - it "maintains separate chains per fiber" do - fiber1_chain = nil - fiber2_chain = nil - - fiber1 = Fiber.new do - described_class.current = described_class.new - fiber1_chain = described_class.current - end - - fiber2 = Fiber.new do - described_class.current = described_class.new - fiber2_chain = described_class.current - end - - fiber1.resume - fiber2.resume - - expect(fiber1_chain).not_to eq(fiber2_chain) - expect(fiber1_chain).to be_a(described_class) - expect(fiber2_chain).to be_a(described_class) - end - - it "does not interfere with main fiber chain" do - main_chain = described_class.new - described_class.current = main_chain - - fiber_chain = nil - fiber = Fiber.new do - described_class.current = described_class.new - fiber_chain = described_class.current - end - fiber.resume - - expect(described_class.current).to eq(main_chain) - expect(fiber_chain).not_to eq(main_chain) - end - end -end diff --git a/spec/cmdx/coercion_registry_spec.rb b/spec/cmdx/coercion_registry_spec.rb deleted file mode 100644 index f9787fe06..000000000 --- a/spec/cmdx/coercion_registry_spec.rb +++ /dev/null @@ -1,290 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::CoercionRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { nil } - let(:mock_coercion) { instance_double("MockCoercion") } - let(:mock_task) { instance_double(CMDx::Task) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with default coercions" do - expect(registry.registry).to include( - array: CMDx::Coercions::Array, - big_decimal: CMDx::Coercions::BigDecimal, - boolean: CMDx::Coercions::Boolean, - complex: CMDx::Coercions::Complex, - date: CMDx::Coercions::Date, - datetime: CMDx::Coercions::DateTime, - float: CMDx::Coercions::Float, - hash: CMDx::Coercions::Hash, - integer: CMDx::Coercions::Integer, - rational: CMDx::Coercions::Rational, - string: CMDx::Coercions::String, - time: CMDx::Coercions::Time - ) - end - end - - context "when a registry is provided" do - let(:initial_registry) { { custom: mock_coercion } } - - it "initializes with the provided registry" do - expect(registry.registry).to eq({ custom: mock_coercion }) - end - end - end - - describe "#registry" do - let(:initial_registry) { { custom: mock_coercion } } - - it "returns the internal registry hash" do - expect(registry.registry).to eq({ custom: mock_coercion }) - end - end - - describe "#to_h" do - let(:initial_registry) { { custom: mock_coercion } } - - it "returns the registry hash" do - expect(registry.to_h).to eq({ custom: mock_coercion }) - end - - it "is an alias for registry" do - expect(registry.method(:to_h)).to eq(registry.method(:registry)) - end - end - - describe "#dup" do - it "returns a new CoercionRegistry instance" do - duplicated = registry.dup - - expect(duplicated).to be_a(described_class) - expect(duplicated).not_to be(registry) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) - end - - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - - duplicated.register(:new_type, mock_coercion) - - expect(duplicated.registry).to have_key(:new_type) - expect(registry.registry).not_to have_key(:new_type) - expect(duplicated.registry).not_to be(registry.registry) - end - end - - describe "#register" do - context "when registering a coercion with string name" do - it "adds the coercion to the registry with symbol key" do - registry.register("custom", mock_coercion) - - expect(registry.registry[:custom]).to eq(mock_coercion) - end - - it "returns self for method chaining" do - result = registry.register("custom", mock_coercion) - - expect(result).to be(registry) - end - end - - context "when registering a coercion with symbol name" do - it "adds the coercion to the registry" do - registry.register(:custom, mock_coercion) - - expect(registry.registry[:custom]).to eq(mock_coercion) - end - end - - context "when registering to an existing registry" do - let(:initial_registry) { { existing: "existing_coercion" } } - - it "adds new coercion to existing ones" do - registry.register(:new_type, mock_coercion) - - expect(registry.registry).to include(existing: "existing_coercion", new_type: mock_coercion) - end - end - - context "when registering over an existing coercion" do - let(:initial_registry) { { existing: "old_coercion" } } - - it "overwrites the existing coercion" do - registry.register(:existing, mock_coercion) - - expect(registry.registry[:existing]).to eq(mock_coercion) - end - end - end - - describe "#deregister" do - context "when deregistering with string name" do - before do - registry.register("custom", mock_coercion) - end - - it "removes the coercion from the registry" do - registry.deregister("custom") - - expect(registry.registry).not_to have_key(:custom) - end - - it "returns self for method chaining" do - result = registry.deregister("custom") - - expect(result).to be(registry) - end - end - - context "when deregistering with symbol name" do - before do - registry.register(:custom, mock_coercion) - end - - it "removes the coercion from the registry" do - registry.deregister(:custom) - - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering from existing registry" do - let(:initial_registry) { { existing: "existing_coercion", custom: mock_coercion } } - - it "removes only the specified coercion" do - registry.deregister(:custom) - - expect(registry.registry).to include(existing: "existing_coercion") - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering non-existent coercion" do - it "does not raise an error" do - expect { registry.deregister(:nonexistent) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:nonexistent) - - expect(result).to be(registry) - end - - it "does not affect existing coercions" do - registry.register(:existing, mock_coercion) - registry.deregister(:nonexistent) - - expect(registry.registry[:existing]).to eq(mock_coercion) - end - end - - context "when deregistering from empty registry" do - let(:initial_registry) { {} } - - it "does not raise an error" do - expect { registry.deregister(:custom) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:custom) - - expect(result).to be(registry) - end - end - - context "when deregistering default coercions" do - subject(:registry) { described_class.new } - - it "removes the default coercion" do - registry.deregister(:string) - - expect(registry.registry).not_to have_key(:string) - end - - it "does not affect other default coercions" do - registry.deregister(:string) - - expect(registry.registry).to have_key(:integer) - expect(registry.registry).to have_key(:boolean) - end - end - end - - describe "#coerce" do - let(:initial_registry) { { custom: mock_coercion } } - let(:value) { "test_value" } - let(:options) { { option1: "value1" } } - - context "when coercion type exists" do - it "calls Utils::Call.invoke with correct parameters" do - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_coercion, value, options - ) - - registry.coerce(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - allow(CMDx::Utils::Call).to receive(:invoke).and_return("coerced_value") - - result = registry.coerce(:custom, mock_task, value, options) - - expect(result).to eq("coerced_value") - end - - context "with string type name" do - let(:initial_registry) { { "custom" => mock_coercion } } - - it "works with string type names" do - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_coercion, value, options - ) - - registry.coerce("custom", mock_task, value, options) - end - end - end - - context "when options are not provided" do - it "passes empty hash as options" do - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_coercion, value, {} - ) - - registry.coerce(:custom, mock_task, value) - end - end - - context "when coercion type does not exist" do - it "raises TypeError with descriptive message" do - expect { registry.coerce(:nonexistent, mock_task, value) } - .to raise_error(TypeError, "unknown coercion type :nonexistent") - end - - it "raises TypeError for string type names" do - expect { registry.coerce("string", mock_task, value) } - .to raise_error(TypeError, 'unknown coercion type "string"') - end - end - - context "when type is nil" do - it "raises TypeError" do - expect { registry.coerce(nil, mock_task, value) } - .to raise_error(TypeError, "unknown coercion type nil") - end - end - end -end diff --git a/spec/cmdx/coercions/array_spec.rb b/spec/cmdx/coercions/array_spec.rb deleted file mode 100644 index d39c7e571..000000000 --- a/spec/cmdx/coercions/array_spec.rb +++ /dev/null @@ -1,216 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Array, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is a JSON string starting with '['" do - it "parses valid JSON array string" do - result = coercion.call('["a", "b", "c"]') - - expect(result).to eq(%w[a b c]) - end - - it "parses JSON array with mixed types" do - result = coercion.call('[1, "string", true, null]') - - expect(result).to eq([1, "string", true, nil]) - end - - it "parses empty JSON array" do - result = coercion.call("[]") - - expect(result).to eq([]) - end - - it "parses nested JSON arrays" do - result = coercion.call("[[1, 2], [3, 4]]") - - expect(result).to eq([[1, 2], [3, 4]]) - end - - it "parses JSON array with objects" do - result = coercion.call('[{"key": "value"}, {"number": 42}]') - - expect(result).to eq([{ "key" => "value" }, { "number" => 42 }]) - end - - it "parses JSON null string as empty array" do - result = coercion.call("null") - - expect(result).to eq([]) - end - - it "parses JSON null string with whitespace as empty array" do - result = coercion.call(" null ") - - expect(result).to eq([]) - end - - context "with invalid JSON" do - it "raises CoercionError for malformed JSON" do - expect { coercion.call("[invalid json") } - .to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for incomplete array" do - expect { coercion.call("[1, 2,") } - .to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for unquoted strings" do - expect { coercion.call("[unquoted, string]") } - .to raise_error(CMDx::CoercionError) - end - end - end - - context "when value is a string not starting with '['" do - it "wraps single string value in array" do - result = coercion.call("hello") - - expect(result).to eq(["hello"]) - end - - it "wraps empty string in array" do - result = coercion.call("") - - expect(result).to eq([""]) - end - - it "wraps string starting with other characters" do - result = coercion.call("{key: value}") - - expect(result).to eq(["{key: value}"]) - end - - it "wraps numeric string in array" do - result = coercion.call("123") - - expect(result).to eq(["123"]) - end - end - - context "when value is already an array" do - it "returns the array unchanged" do - input = [1, 2, 3] - - result = coercion.call(input) - - expect(result).to eq([1, 2, 3]) - end - - it "returns empty array unchanged" do - input = [] - - result = coercion.call(input) - - expect(result).to eq([]) - end - - it "returns nested array unchanged" do - input = [[1, 2], [3, 4]] - - result = coercion.call(input) - - expect(result).to eq([[1, 2], [3, 4]]) - end - end - - context "when value is nil" do - it "converts nil to empty array" do - result = coercion.call(nil) - - expect(result).to eq([]) - end - end - - context "when value is a number" do - it "wraps integer in array" do - result = coercion.call(42) - - expect(result).to eq([42]) - end - - it "wraps float in array" do - result = coercion.call(3.14) - - expect(result).to eq([3.14]) - end - - it "wraps zero in array" do - result = coercion.call(0) - - expect(result).to eq([0]) - end - end - - context "when value is a boolean" do - it "wraps true in array" do - result = coercion.call(true) - - expect(result).to eq([true]) - end - - it "wraps false in array" do - result = coercion.call(false) - - expect(result).to eq([false]) - end - end - - context "when value is a hash" do - it "converts hash to array of key-value pairs" do - input = { key: "value" } - - result = coercion.call(input) - - expect(result).to eq([[:key, "value"]]) - end - - it "converts empty hash to empty array" do - result = coercion.call({}) - - expect(result).to eq([]) - end - end - - context "when value is an enumerable object" do - it "converts range to array" do - result = coercion.call(1..3) - - expect(result).to eq([1, 2, 3]) - end - - it "converts set to array" do - input = Set.new([1, 2, 3]) - - result = coercion.call(input) - - expect(result).to eq([1, 2, 3]) - end - end - - context "with options parameter" do - it "ignores options when processing JSON string" do - result = coercion.call('["a", "b"]', { unused: "option" }) - - expect(result).to eq(%w[a b]) - end - - it "ignores options when wrapping non-JSON values" do - result = coercion.call("hello", { unused: "option" }) - - expect(result).to eq(["hello"]) - end - - it "works with empty options hash" do - result = coercion.call([1, 2, 3], {}) - - expect(result).to eq([1, 2, 3]) - end - end - end -end diff --git a/spec/cmdx/coercions/big_decimal_spec.rb b/spec/cmdx/coercions/big_decimal_spec.rb deleted file mode 100644 index 7f7cd6428..000000000 --- a/spec/cmdx/coercions/big_decimal_spec.rb +++ /dev/null @@ -1,168 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::BigDecimal, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is a valid string" do - it "coerces string integer to BigDecimal" do - result = coercion.call("123") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal(123)) - end - - it "coerces string decimal to BigDecimal" do - result = coercion.call("123.456") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "coerces negative string to BigDecimal" do - result = coercion.call("-123.456") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("-123.456")) - end - - it "coerces string with scientific notation to BigDecimal" do - result = coercion.call("1.23e4") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("1.23e4")) - end - - it "coerces zero string to BigDecimal" do - result = coercion.call("0") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal(0)) - end - end - - context "when value is a numeric type" do - it "coerces integer to BigDecimal" do - result = coercion.call(123) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal(123)) - end - - it "coerces float to BigDecimal" do - result = coercion.call(123.456) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "coerces rational to BigDecimal" do - result = coercion.call(Rational(22, 7)) - - expect(result).to be_a(BigDecimal) - expect(result.to_f).to be_within(0.001).of(3.14285) - end - - it "coerces existing BigDecimal" do - original = BigDecimal("123.456") - result = coercion.call(original) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(original) - end - end - - context "with precision option" do - it "uses custom precision when provided" do - result = coercion.call("123.456789", precision: 4) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456789", 4)) - end - - it "uses default precision when not provided" do - result = coercion.call("123.456789") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456789", 14)) - end - - it "uses zero precision" do - result = coercion.call("123.456", precision: 0) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456", 0)) - end - end - - context "with invalid values" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("invalid") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for complex number" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - end - - context "with edge cases" do - it "coerces string with leading/trailing whitespace" do - result = coercion.call(" 123.456 ") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "coerces string with plus sign" do - result = coercion.call("+123.456") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "raises CoercionError for string with invalid characters" do - expect { coercion.call("123.45.67") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for string with letters mixed in" do - expect { coercion.call("12a3.456") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - end - end -end diff --git a/spec/cmdx/coercions/boolean_spec.rb b/spec/cmdx/coercions/boolean_spec.rb deleted file mode 100644 index cef3be908..000000000 --- a/spec/cmdx/coercions/boolean_spec.rb +++ /dev/null @@ -1,252 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Boolean, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is truthy" do - it "coerces string 'true' to true" do - result = coercion.call("true") - - expect(result).to be(true) - end - - it "coerces string 'TRUE' to true" do - result = coercion.call("TRUE") - - expect(result).to be(true) - end - - it "coerces string 'True' to true" do - result = coercion.call("True") - - expect(result).to be(true) - end - - it "coerces string 't' to true" do - result = coercion.call("t") - - expect(result).to be(true) - end - - it "coerces string 'T' to true" do - result = coercion.call("T") - - expect(result).to be(true) - end - - it "coerces string 'yes' to true" do - result = coercion.call("yes") - - expect(result).to be(true) - end - - it "coerces string 'YES' to true" do - result = coercion.call("YES") - - expect(result).to be(true) - end - - it "coerces string 'Yes' to true" do - result = coercion.call("Yes") - - expect(result).to be(true) - end - - it "coerces string 'y' to true" do - result = coercion.call("y") - - expect(result).to be(true) - end - - it "coerces string 'Y' to true" do - result = coercion.call("Y") - - expect(result).to be(true) - end - - it "coerces string '1' to true" do - result = coercion.call("1") - - expect(result).to be(true) - end - - it "coerces integer 1 to true" do - result = coercion.call(1) - - expect(result).to be(true) - end - - it "coerces boolean true to true" do - result = coercion.call(true) - - expect(result).to be(true) - end - end - - context "when value is falsey" do - it "coerces string 'false' to false" do - result = coercion.call("false") - - expect(result).to be(false) - end - - it "coerces string 'FALSE' to false" do - result = coercion.call("FALSE") - - expect(result).to be(false) - end - - it "coerces string 'False' to false" do - result = coercion.call("False") - - expect(result).to be(false) - end - - it "coerces string 'f' to false" do - result = coercion.call("f") - - expect(result).to be(false) - end - - it "coerces string 'F' to false" do - result = coercion.call("F") - - expect(result).to be(false) - end - - it "coerces string 'no' to false" do - result = coercion.call("no") - - expect(result).to be(false) - end - - it "coerces string 'NO' to false" do - result = coercion.call("NO") - - expect(result).to be(false) - end - - it "coerces string 'No' to false" do - result = coercion.call("No") - - expect(result).to be(false) - end - - it "coerces string 'n' to false" do - result = coercion.call("n") - - expect(result).to be(false) - end - - it "coerces string 'N' to false" do - result = coercion.call("N") - - expect(result).to be(false) - end - - it "coerces string '0' to false" do - result = coercion.call("0") - - expect(result).to be(false) - end - - it "coerces integer 0 to false" do - result = coercion.call(0) - - expect(result).to be(false) - end - - it "coerces boolean false to false" do - result = coercion.call(false) - - expect(result).to be(false) - end - - it "coerces nil to false" do - result = coercion.call(nil) - - expect(result).to be(false) - end - - it "coerces empty string to false" do - result = coercion.call("") - - expect(result).to be(false) - end - end - - context "when value is invalid" do - it "raises CoercionError for invalid string" do - expect { coercion.call("invalid") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for float" do - expect { coercion.call(3.14) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for integer 2" do - expect { coercion.call(2) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for negative integer" do - expect { coercion.call(-1) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for partial match string" do - expect { coercion.call("truth") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for string with whitespace" do - expect { coercion.call(" true ") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for string with mixed case that doesn't match patterns" do - expect { coercion.call("TrUeX") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - end - - context "with options parameter" do - it "ignores options parameter for valid truthy value" do - result = coercion.call("true", { some: "option" }) - - expect(result).to be(true) - end - - it "ignores options parameter for valid falsey value" do - result = coercion.call("false", { some: "option" }) - - expect(result).to be(false) - end - - it "ignores options parameter for invalid value" do - expect { coercion.call("invalid", { some: "option" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - end - end -end diff --git a/spec/cmdx/coercions/complex_spec.rb b/spec/cmdx/coercions/complex_spec.rb deleted file mode 100644 index 757171e39..000000000 --- a/spec/cmdx/coercions/complex_spec.rb +++ /dev/null @@ -1,220 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Complex, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Complex" do - result = coercion.call("123") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123)) - end - - it "coerces string decimal to Complex" do - result = coercion.call("123.456") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123.456)) - end - - it "coerces negative string to Complex" do - result = coercion.call("-123.456") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(-123.456)) - end - - it "coerces string with imaginary unit to Complex" do - result = coercion.call("3+4i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, 4)) - end - - it "coerces string with negative imaginary unit to Complex" do - result = coercion.call("3-4i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, -4)) - end - - it "coerces pure imaginary string to Complex" do - result = coercion.call("4i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0, 4)) - end - - it "coerces zero string to Complex" do - result = coercion.call("0") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0)) - end - - it "coerces string with scientific notation to Complex" do - result = coercion.call("1.23e4") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(1.23e4)) - end - end - - context "when value is a numeric type" do - it "coerces integer to Complex" do - result = coercion.call(123) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123)) - end - - it "coerces float to Complex" do - result = coercion.call(123.456) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123.456)) - end - - it "coerces rational to Complex" do - result = coercion.call(Rational(22, 7)) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(Rational(22, 7))) - end - - it "coerces existing Complex" do - original = Complex(3, 4) - result = coercion.call(original) - - expect(result).to be_a(Complex) - expect(result).to eq(original) - end - - it "coerces zero to Complex" do - result = coercion.call(0) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0)) - end - - it "coerces negative number to Complex" do - result = coercion.call(-42) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(-42)) - end - end - - context "with invalid values" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("invalid") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for Object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - end - - context "with edge cases" do - it "coerces string with decimal imaginary unit" do - result = coercion.call("1.5+2.7i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(1.5, 2.7)) - end - - it "coerces string with just 'i' as imaginary unit" do - result = coercion.call("i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0, 1)) - end - - it "coerces string with negative imaginary unit 'i'" do - result = coercion.call("-i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0, -1)) - end - - it "coerces string with 'j' notation" do - result = coercion.call("3+4j") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, 4)) - end - - it "raises CoercionError for string with spaces" do - expect { coercion.call("3 + 4i") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for string with multiple operators" do - expect { coercion.call("3++4i") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for malformed complex string" do - expect { coercion.call("3+i4") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - end - - context "with options parameter" do - it "ignores options parameter for valid complex number" do - result = coercion.call("3+4i", { some: "option" }) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, 4)) - end - - it "ignores options parameter for valid numeric value" do - result = coercion.call(42, { some: "option" }) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(42)) - end - - it "ignores options parameter for invalid value" do - expect { coercion.call("invalid", { some: "option" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - end - end -end diff --git a/spec/cmdx/coercions/date_spec.rb b/spec/cmdx/coercions/date_spec.rb deleted file mode 100644 index 0a395d7a0..000000000 --- a/spec/cmdx/coercions/date_spec.rb +++ /dev/null @@ -1,231 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Date, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is already an analog type" do - it "returns Date unchanged" do - date = Date.new(2023, 12, 25) - result = coercion.call(date) - - expect(result).to eq(date) - end - - it "converts DateTime to Date" do - datetime = DateTime.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(datetime) - - expect(result).to be_a(Date) - expect(result).not_to be_a(DateTime) - expect(result).to eq(Date.new(2023, 12, 25)) - end - - it "converts Time to Date" do - time = Time.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(time) - - expect(result).to be_a(Date) - expect(result).to eq(Date.new(2023, 12, 25)) - end - end - - context "when value is a parseable string" do - it "parses ISO 8601 date string" do - result = coercion.call("2023-12-25") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses YYYY/MM/DD format date string" do - result = coercion.call("2023/12/25") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses DD/MM/YYYY format date string" do - result = coercion.call("25/12/2023") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses DD.MM.YYYY format date string" do - result = coercion.call("25.12.2023") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses textual date format" do - result = coercion.call("Dec 25, 2023") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses date with time component, extracting date part" do - result = coercion.call("2023-12-25 14:30:00") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - end - - context "with strptime option" do - it "parses date using custom format" do - result = coercion.call("25-Dec-2023", strptime: "%d-%b-%Y") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses date using different custom format" do - result = coercion.call("2023/12/25", strptime: "%Y/%m/%d") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses date with year first format" do - result = coercion.call("23-12-25", strptime: "%y-%m-%d") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "raises CoercionError when string doesn't match strptime format" do - expect { coercion.call("invalid-date", strptime: "%Y-%m-%d") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - end - - context "when value cannot be coerced" do - it "raises CoercionError for invalid date string" do - expect { coercion.call("not-a-date") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for float" do - expect { coercion.call(12.3) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for array" do - expect { coercion.call([2023, 12, 25]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ year: 2023, month: 12, day: 25 }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:date) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for invalid date components" do - expect { coercion.call("2023-13-45") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for US format date string" do - expect { coercion.call("12/25/2023") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for malformed date string" do - expect { coercion.call("2023/25/12/extra") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for partially valid date string" do - expect { coercion.call("2023-12") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - end - - context "with edge cases" do - it "handles leap year date" do - result = coercion.call("2024-02-29") - - expect(result).to be_a(Date) - expect(result.year).to eq(2024) - expect(result.month).to eq(2) - expect(result.day).to eq(29) - end - - it "raises CoercionError for invalid leap year date" do - expect { coercion.call("2023-02-29") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "handles end of year date" do - result = coercion.call("2023-12-31") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(31) - end - - it "handles start of year date" do - result = coercion.call("2023-01-01") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(1) - expect(result.day).to eq(1) - end - end - end -end diff --git a/spec/cmdx/coercions/date_time_spec.rb b/spec/cmdx/coercions/date_time_spec.rb deleted file mode 100644 index e44c545d4..000000000 --- a/spec/cmdx/coercions/date_time_spec.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::DateTime, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is already an analog type" do - it "converts Date to DateTime" do - date = Date.new(2023, 12, 25) - result = coercion.call(date) - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "returns DateTime unchanged" do - datetime = DateTime.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(datetime) - - expect(result).to eq(datetime) - end - - it "converts Time to DateTime" do - time = Time.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(time) - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - end - - context "when value is a parseable string" do - it "parses ISO 8601 datetime string" do - result = coercion.call("2023-12-25T14:30:00") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.minute).to eq(30) - expect(result.second).to eq(0) - end - - it "parses date string" do - result = coercion.call("2023-12-25") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses datetime with timezone" do - result = coercion.call("2023-12-25T14:30:00+02:00") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.minute).to eq(30) - expect(result.offset).to eq(Rational(2, 24)) - end - - it "parses slash format date" do - result = coercion.call("2023/12/25") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses natural language date" do - result = coercion.call("December 25, 2023") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - end - - context "with strptime option" do - it "parses string using custom format" do - result = coercion.call("25-12-2023 14:30", strptime: "%d-%m-%Y %H:%M") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.minute).to eq(30) - end - - it "parses date-only string with custom format" do - result = coercion.call("25/12/2023", strptime: "%d/%m/%Y") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "raises error when string doesn't match strptime format" do - expect { coercion.call("2023-12-25", strptime: "%d/%m/%Y") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - end - - context "when value is invalid" do - it "raises CoercionError for invalid date string" do - expect { coercion.call("invalid-date") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for float" do - expect { coercion.call(12.34) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for array" do - expect { coercion.call([2023, 12, 25]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ year: 2023, month: 12, day: 25 }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:datetime) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for invalid date components" do - expect { coercion.call("2023-13-32") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for malformed datetime string" do - expect { coercion.call("2023/12/25T25:61:70") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - end - end -end diff --git a/spec/cmdx/coercions/float_spec.rb b/spec/cmdx/coercions/float_spec.rb deleted file mode 100644 index 86349be0e..000000000 --- a/spec/cmdx/coercions/float_spec.rb +++ /dev/null @@ -1,227 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Float, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Float" do - result = coercion.call("123") - - expect(result).to be_a(Float) - expect(result).to eq(123.0) - end - - it "coerces string decimal to Float" do - result = coercion.call("123.456") - - expect(result).to be_a(Float) - expect(result).to eq(123.456) - end - - it "coerces negative string to Float" do - result = coercion.call("-123.456") - - expect(result).to be_a(Float) - expect(result).to eq(-123.456) - end - - it "coerces zero string to Float" do - result = coercion.call("0") - - expect(result).to be_a(Float) - expect(result).to eq(0.0) - end - - it "coerces negative zero string to Float" do - result = coercion.call("-0") - - expect(result).to be_a(Float) - expect(result).to eq(-0.0) - end - - it "coerces string with scientific notation to Float" do - result = coercion.call("1.23e4") - - expect(result).to be_a(Float) - expect(result).to eq(1.23e4) - end - - it "coerces string with negative scientific notation to Float" do - result = coercion.call("-1.23e-4") - - expect(result).to be_a(Float) - expect(result).to eq(-1.23e-4) - end - - it "coerces string with positive exponent notation to Float" do - result = coercion.call("1.5E+2") - - expect(result).to be_a(Float) - expect(result).to eq(150.0) - end - - it "coerces string with leading/trailing whitespace to Float" do - result = coercion.call(" 123.45 ") - - expect(result).to be_a(Float) - expect(result).to eq(123.45) - end - end - - context "when value is a numeric type" do - it "coerces integer to Float" do - result = coercion.call(42) - - expect(result).to be_a(Float) - expect(result).to eq(42.0) - end - - it "coerces negative integer to Float" do - result = coercion.call(-42) - - expect(result).to be_a(Float) - expect(result).to eq(-42.0) - end - - it "coerces zero integer to Float" do - result = coercion.call(0) - - expect(result).to be_a(Float) - expect(result).to eq(0.0) - end - - it "returns Float unchanged" do - value = 123.456 - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(value) - expect(result).to be(value) - end - - it "coerces BigDecimal to Float" do - value = BigDecimal("123.456") - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(123.456) - end - - it "coerces Rational to Float" do - value = Rational(3, 4) - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(0.75) - end - - it "coerces Complex with zero imaginary part to Float" do - value = Complex(123.456, 0) - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(123.456) - end - end - - context "when value is an invalid type" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("not_a_number") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for string with letters mixed with numbers" do - expect { coercion.call("123abc") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for string with multiple decimal points" do - expect { coercion.call("123.45.67") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for Complex with non-zero imaginary part" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for infinity string" do - expect { coercion.call("Infinity") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for negative infinity string" do - expect { coercion.call("-Infinity") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for NaN string" do - expect { coercion.call("NaN") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - end - - context "when value is out of range" do - it "converts extremely large numbers to Infinity" do - large_number = "1" + ("0" * 400) - result = coercion.call(large_number) - - expect(result).to be_a(Float) - expect(result).to be_infinite - expect(result).to be > 0 - end - end - - context "with options parameter" do - it "ignores options and coerces successfully" do - result = coercion.call("123.45", precision: 2) - - expect(result).to be_a(Float) - expect(result).to eq(123.45) - end - - it "ignores options and raises error for invalid input" do - expect { coercion.call("invalid", precision: 2) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - end - end -end diff --git a/spec/cmdx/coercions/hash_spec.rb b/spec/cmdx/coercions/hash_spec.rb deleted file mode 100644 index 86d232701..000000000 --- a/spec/cmdx/coercions/hash_spec.rb +++ /dev/null @@ -1,287 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Hash, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is nil" do - it "returns an empty hash" do - hash = {} - - result = coercion.call(nil) - - expect(result).to be_a(Hash) - expect(result).to eq(hash) - end - end - - context "when value is already a Hash" do - it "returns the hash unchanged" do - hash = { key: "value", nested: { inner: "data" } } - - result = coercion.call(hash) - - expect(result).to be_a(Hash) - expect(result).to eq(hash) - expect(result).to be(hash) - end - - it "returns an empty hash unchanged" do - hash = {} - - result = coercion.call(hash) - - expect(result).to be_a(Hash) - expect(result).to eq({}) - expect(result).to be(hash) - end - end - - context "when value is an Array" do - it "converts even-length array to hash" do - array = [:key1, "value1", :key2, "value2"] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq(key1: "value1", key2: "value2") - end - - it "converts array with string keys to hash" do - array = %w[name John age 30] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq("name" => "John", "age" => "30") - end - - it "converts array with mixed types to hash" do - array = [:symbol, 123, "string", true] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq(symbol: 123, "string" => true) - end - - it "converts empty array to empty hash" do - result = coercion.call([]) - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "raises CoercionError for odd-length array" do - expect { coercion.call([:key1, "value1", :key2]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - end - - context "when value is a JSON string" do - it "parses valid JSON object string" do - json_string = '{"name": "John", "age": 30}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("name" => "John", "age" => 30) - end - - it "parses JSON string with nested objects" do - json_string = '{"user": {"name": "John", "details": {"age": 30}}}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("user" => { "name" => "John", "details" => { "age" => 30 } }) - end - - it "parses JSON string with arrays" do - json_string = '{"tags": ["ruby", "programming"], "count": 2}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("tags" => %w[ruby programming], "count" => 2) - end - - it "parses empty JSON object" do - result = coercion.call("{}") - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "parses JSON null string as empty hash" do - result = coercion.call("null") - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "parses JSON null string with whitespace as empty hash" do - result = coercion.call(" null ") - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "raises CoercionError for invalid JSON" do - expect { coercion.call('{"invalid": json}') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for JSON array string" do - expect { coercion.call('["not", "a", "hash"]') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for JSON string primitive" do - expect { coercion.call('"just a string"') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for unclosed JSON" do - expect { coercion.call('{"unclosed": "object"') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - end - - context "when value is an object that responds to to_h" do - it "converts the object to a hash" do - object = Object.new - def object.to_h - { key: "value" } - end - - result = coercion.call(object) - - expect(result).to be_a(Hash) - expect(result).to eq(key: "value") - end - end - - context "when value is invalid" do - it "raises CoercionError for string not starting with '{'" do - expect { coercion.call("not json") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for float" do - expect { coercion.call(123.45) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for string starting with '{' but invalid JSON" do - expect { coercion.call("{not valid json}") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - end - - context "with options parameter" do - it "ignores options and converts valid hash" do - hash = { key: "value" } - - result = coercion.call(hash, some: "option") - - expect(result).to be_a(Hash) - expect(result).to eq(hash) - end - - it "ignores options and converts valid array" do - array = [:key, "value"] - - result = coercion.call(array, precision: 2) - - expect(result).to be_a(Hash) - expect(result).to eq(key: "value") - end - - it "ignores options and converts valid JSON string" do - json_string = '{"test": "value"}' - - result = coercion.call(json_string, format: :json) - - expect(result).to be_a(Hash) - expect(result).to eq("test" => "value") - end - - it "ignores options and raises error for invalid input" do - expect { coercion.call("invalid", some: "option") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - end - - context "with edge cases" do - it "handles array with nil values" do - array = [:key1, nil, :key2, "value"] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq(key1: nil, key2: "value") - end - - it "handles JSON string with null values" do - json_string = '{"key1": null, "key2": "value"}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("key1" => nil, "key2" => "value") - end - - it "handles JSON string with special characters" do - json_string = '{"special": "\\n\\t\\r\\"", "unicode": "\\u0041"}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("special" => "\n\t\r\"", "unicode" => "A") - end - - it "handles very large JSON string" do - large_hash = (1..100).to_h { |i| ["key#{i}", "value#{i}"] } - json_string = large_hash.to_json - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq(large_hash) - end - end - end -end diff --git a/spec/cmdx/coercions/integer_spec.rb b/spec/cmdx/coercions/integer_spec.rb deleted file mode 100644 index f9eac66b1..000000000 --- a/spec/cmdx/coercions/integer_spec.rb +++ /dev/null @@ -1,204 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Integer, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Integer" do - result = coercion.call("123") - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces negative string to Integer" do - result = coercion.call("-456") - - expect(result).to be_a(Integer) - expect(result).to eq(-456) - end - - it "coerces zero string to Integer" do - result = coercion.call("0") - - expect(result).to be_a(Integer) - expect(result).to eq(0) - end - - it "coerces string with leading/trailing whitespace to Integer" do - result = coercion.call(" 789 ") - - expect(result).to be_a(Integer) - expect(result).to eq(789) - end - - it "coerces string with positive sign to Integer" do - result = coercion.call("+42") - - expect(result).to be_a(Integer) - expect(result).to eq(42) - end - - it "coerces octal string to Integer" do - result = coercion.call("0777") - - expect(result).to be_a(Integer) - expect(result).to eq(511) - end - - it "coerces hexadecimal string to Integer" do - result = coercion.call("0xFF") - - expect(result).to be_a(Integer) - expect(result).to eq(255) - end - - it "coerces binary string to Integer" do - result = coercion.call("0b1010") - - expect(result).to be_a(Integer) - expect(result).to eq(10) - end - end - - context "when value is a numeric type" do - it "coerces Integer to Integer" do - result = coercion.call(123) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces negative Integer to Integer" do - result = coercion.call(-456) - - expect(result).to be_a(Integer) - expect(result).to eq(-456) - end - - it "coerces Float to Integer" do - result = coercion.call(123.0) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces negative Float to Integer" do - result = coercion.call(-456.0) - - expect(result).to be_a(Integer) - expect(result).to eq(-456) - end - - it "coerces Rational to Integer" do - result = coercion.call(Rational(15, 3)) - - expect(result).to be_a(Integer) - expect(result).to eq(5) - end - - it "coerces BigDecimal to Integer" do - result = coercion.call(BigDecimal(123)) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces Complex with zero imaginary part to Integer" do - result = coercion.call(Complex(123, 0)) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - end - - context "when value has fractional part" do - it "truncates Float with fractional part to Integer" do - result = coercion.call(123.456) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "truncates negative Float with fractional part to Integer" do - result = coercion.call(-123.456) - - expect(result).to be_a(Integer) - expect(result).to eq(-123) - end - - it "raises CoercionError for string decimal" do - expect { coercion.call("123.789") }.to raise_error(CMDx::CoercionError) - end - end - - context "when value is invalid" do - it "raises CoercionError for invalid string" do - expect { coercion.call("abc") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for hash" do - expect { coercion.call({ a: 1 }) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for Complex with non-zero imaginary part" do - expect { coercion.call(Complex(1, 2)) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for Infinity" do - expect { coercion.call(Float::INFINITY) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for NaN" do - expect { coercion.call(Float::NAN) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for string with invalid characters" do - expect { coercion.call("123abc") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for string with multiple decimal points" do - expect { coercion.call("12.34.56") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for value that triggers RangeError" do - # Test using a value that would trigger RangeError in Integer conversion - very_large_number = ("9" * 1000) + ".0" - - expect { coercion.call(very_large_number) }.to raise_error(CMDx::CoercionError) - end - end - - context "with options parameter" do - it "accepts options parameter but ignores it" do - result = coercion.call("123", { unused_option: true }) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - end - end -end diff --git a/spec/cmdx/coercions/rational_spec.rb b/spec/cmdx/coercions/rational_spec.rb deleted file mode 100644 index aed4f6439..000000000 --- a/spec/cmdx/coercions/rational_spec.rb +++ /dev/null @@ -1,256 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Rational, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Rational" do - result = coercion.call("123") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(123)) - end - - it "coerces string fraction to Rational" do - result = coercion.call("3/4") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 4)) - end - - it "coerces string decimal to Rational" do - result = coercion.call("0.5") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1, 2)) - end - - it "coerces negative string to Rational" do - result = coercion.call("-456") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-456)) - end - - it "coerces negative fraction string to Rational" do - result = coercion.call("-3/4") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-3, 4)) - end - - it "coerces zero string to Rational" do - result = coercion.call("0") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(0)) - end - - it "coerces string with positive sign to Rational" do - result = coercion.call("+42") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(42)) - end - - it "coerces string with leading/trailing whitespace to Rational" do - result = coercion.call(" 3/5 ") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 5)) - end - - it "coerces improper fraction string to Rational" do - result = coercion.call("7/3") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(7, 3)) - end - - it "coerces decimal with many digits to Rational" do - result = coercion.call("0.125") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1, 8)) - end - - it "coerces scientific notation string to Rational" do - result = coercion.call("1e3") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1000)) - end - end - - context "when value is a valid number" do - it "coerces integer to Rational" do - result = coercion.call(42) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(42)) - end - - it "coerces negative integer to Rational" do - result = coercion.call(-42) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-42)) - end - - it "coerces zero to Rational" do - result = coercion.call(0) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(0)) - end - - it "coerces float to Rational" do - result = coercion.call(0.75) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 4)) - end - - it "coerces negative float to Rational" do - result = coercion.call(-0.25) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-1, 4)) - end - - it "coerces BigDecimal to Rational" do - result = coercion.call(BigDecimal("0.333")) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(333, 1000)) - end - - it "coerces existing Rational to Rational" do - input = Rational(5, 8) - result = coercion.call(input) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(5, 8)) - expect(result).to be(input) - end - - it "coerces Complex with zero imaginary part to Rational" do - result = coercion.call(Complex(3, 0)) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3)) - end - end - - context "when value is invalid" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("not_a_number") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with letters mixed with numbers" do - expect { coercion.call("123abc") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with invalid fraction" do - expect { coercion.call("3//4") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for Complex with non-zero imaginary part" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for Infinity" do - expect { coercion.call(Float::INFINITY) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for NaN" do - expect { coercion.call(Float::NAN) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for division by zero string" do - expect { coercion.call("1/0") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with multiple decimal points" do - expect { coercion.call("1.2.3") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with multiple slashes" do - expect { coercion.call("1/2/3") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with text after number" do - expect { coercion.call("42units") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - end - - context "with options parameter" do - it "accepts options parameter but ignores it" do - result = coercion.call("3/4", { unused_option: true }) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 4)) - end - - it "accepts empty options hash" do - result = coercion.call("1/2", {}) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1, 2)) - end - end - end -end diff --git a/spec/cmdx/coercions/string_spec.rb b/spec/cmdx/coercions/string_spec.rb deleted file mode 100644 index 9a3e2ee02..000000000 --- a/spec/cmdx/coercions/string_spec.rb +++ /dev/null @@ -1,275 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::String, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is already a String" do - it "returns the string unchanged" do - string = "hello world" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("hello world") - expect(result).to be(string) - end - - it "returns an empty string unchanged" do - string = "" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("") - expect(result).to be(string) - end - - it "returns string with special characters unchanged" do - string = "hello\nworld\t!" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("hello\nworld\t!") - expect(result).to be(string) - end - - it "returns unicode string unchanged" do - string = "héllo wörld 🌍" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("héllo wörld 🌍") - expect(result).to be(string) - end - end - - context "when value is a Symbol" do - it "converts symbol to string" do - result = coercion.call(:hello) - - expect(result).to be_a(String) - expect(result).to eq("hello") - end - - it "converts symbol with underscores to string" do - result = coercion.call(:hello_world) - - expect(result).to be_a(String) - expect(result).to eq("hello_world") - end - - it "converts empty symbol to string" do - result = coercion.call(:"") - - expect(result).to be_a(String) - expect(result).to eq("") - end - end - - context "when value is a numeric type" do - it "converts integer to string" do - result = coercion.call(123) - - expect(result).to be_a(String) - expect(result).to eq("123") - end - - it "converts negative integer to string" do - result = coercion.call(-456) - - expect(result).to be_a(String) - expect(result).to eq("-456") - end - - it "converts zero to string" do - result = coercion.call(0) - - expect(result).to be_a(String) - expect(result).to eq("0") - end - - it "converts float to string" do - result = coercion.call(123.456) - - expect(result).to be_a(String) - expect(result).to eq("123.456") - end - - it "converts Rational to string" do - result = coercion.call(Rational(3, 4)) - - expect(result).to be_a(String) - expect(result).to eq("3/4") - end - - it "converts Complex to string" do - result = coercion.call(Complex(3, 4)) - - expect(result).to be_a(String) - expect(result).to eq("3+4i") - end - - it "converts BigDecimal to string" do - result = coercion.call(BigDecimal("123.456")) - - expect(result).to be_a(String) - expect(result).to eq("0.123456e3") - end - end - - context "when value is a boolean" do - it "converts true to string" do - result = coercion.call(true) - - expect(result).to be_a(String) - expect(result).to eq("true") - end - - it "converts false to string" do - result = coercion.call(false) - - expect(result).to be_a(String) - expect(result).to eq("false") - end - end - - context "when value is nil" do - it "converts nil to empty string" do - result = coercion.call(nil) - - expect(result).to be_a(String) - expect(result).to eq("") - end - end - - context "when value is a collection" do - it "converts array to string" do - result = coercion.call([1, 2, 3]) - - expect(result).to be_a(String) - expect(result).to eq("[1, 2, 3]") - end - - it "converts empty array to string" do - result = coercion.call([]) - - expect(result).to be_a(String) - expect(result).to eq("[]") - end - - it "converts hash to string" do - result = coercion.call({ a: 1, b: 2 }) - - expect(result).to be_a(String) - - if RubyVersion.min?(3.4) - expect(result).to eq("{a: 1, b: 2}") - else - expect(result).to eq("{:a=>1, :b=>2}") - end - end - - it "converts empty hash to string" do - result = coercion.call({}) - - expect(result).to be_a(String) - expect(result).to eq("{}") - end - - it "converts set to string" do - result = coercion.call(Set.new([1, 2, 3])) - - expect(result).to be_a(String) - - if RubyVersion.min?(4.0) - expect(result).to eq("Set[1, 2, 3]") - else - expect(result).to eq("#") - end - end - end - - context "when value is a special object" do - it "converts class to string" do - result = coercion.call(String) - - expect(result).to be_a(String) - expect(result).to eq("String") - end - - it "converts module to string" do - result = coercion.call(Enumerable) - - expect(result).to be_a(String) - expect(result).to eq("Enumerable") - end - - it "converts regex to string" do - result = coercion.call(/hello/) - - expect(result).to be_a(String) - expect(result).to eq("(?-mix:hello)") - end - - it "converts range to string" do - result = coercion.call(1..5) - - expect(result).to be_a(String) - expect(result).to eq("1..5") - end - - it "converts time to string" do - time = Time.new(2023, 12, 25, 10, 30, 45) - - result = coercion.call(time) - - expect(result).to be_a(String) - expect(result).to include("2023-12-25") - end - - it "converts date to string" do - date = Date.new(2023, 12, 25) - - result = coercion.call(date) - - expect(result).to be_a(String) - expect(result).to eq("2023-12-25") - end - end - - context "when value has custom to_s method" do - it "converts object with custom to_s to string" do - custom_object = Object.new - def custom_object.to_s - "custom object" - end - - result = coercion.call(custom_object) - - expect(result).to be_a(String) - expect(result).to eq("custom object") - end - end - - context "when options are provided" do - it "ignores options parameter" do - result = coercion.call(123, { format: :uppercase }) - - expect(result).to be_a(String) - expect(result).to eq("123") - end - - it "works with empty options hash" do - result = coercion.call("hello", {}) - - expect(result).to be_a(String) - expect(result).to eq("hello") - end - end - end -end diff --git a/spec/cmdx/coercions/symbol_spec.rb b/spec/cmdx/coercions/symbol_spec.rb deleted file mode 100644 index a82483913..000000000 --- a/spec/cmdx/coercions/symbol_spec.rb +++ /dev/null @@ -1,229 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Symbol, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is already a Symbol" do - it "returns the symbol unchanged" do - symbol = :hello - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello) - expect(result).to be(symbol) - end - - it "returns empty symbol unchanged" do - symbol = :"" - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:"") - expect(result).to be(symbol) - end - - it "returns symbol with special characters unchanged" do - symbol = :hello_world! - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello_world!) - expect(result).to be(symbol) - end - - it "returns symbol with unicode characters unchanged" do - symbol = :héllo_wörld_🌍 - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:héllo_wörld_🌍) - expect(result).to be(symbol) - end - end - - context "when value is a String" do - it "converts string to symbol" do - result = coercion.call("hello") - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello) - end - - it "converts empty string to empty symbol" do - result = coercion.call("") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"") - end - - it "converts string with special characters to symbol" do - result = coercion.call("hello_world!") - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello_world!) - end - - it "converts string with spaces to symbol" do - result = coercion.call("hello world") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"hello world") - end - - it "converts string with unicode characters to symbol" do - result = coercion.call("héllo wörld 🌍") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"héllo wörld 🌍") - end - - it "converts string with newlines and tabs to symbol" do - result = coercion.call("hello\nworld\t!") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"hello\nworld\t!") - end - - it "converts numeric string to symbol" do - result = coercion.call("123") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"123") - end - end - - context "when value has custom to_sym method" do - it "uses the custom to_sym method" do - custom_object = Object.new - def custom_object.to_sym - :custom_symbol - end - - result = coercion.call(custom_object) - - expect(result).to be_a(Symbol) - expect(result).to eq(:custom_symbol) - end - end - - context "when value is invalid" do - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for float" do - expect { coercion.call(3.14) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for rational" do - expect { coercion.call(Rational(3, 4)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for complex" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for BigDecimal" do - expect { coercion.call(BigDecimal("123.456")) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for object without to_sym method" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for class" do - expect { coercion.call(String) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for module" do - expect { coercion.call(Enumerable) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for proc" do - expect { coercion.call(proc { "hello" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for lambda" do - expect { coercion.call(-> { "hello" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for method object" do - expect { coercion.call("test".method(:upcase)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for regex" do - expect { coercion.call(/pattern/) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for range" do - expect { coercion.call(1..10) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for set" do - expect { coercion.call(Set.new([1, 2, 3])) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - end - - context "with options parameter" do - it "ignores options and converts value normally" do - result = coercion.call("hello", { some: "option" }) - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello) - end - - it "works with empty options hash" do - result = coercion.call("world", {}) - - expect(result).to be_a(Symbol) - expect(result).to eq(:world) - end - end - end -end diff --git a/spec/cmdx/coercions/time_spec.rb b/spec/cmdx/coercions/time_spec.rb deleted file mode 100644 index c444d012a..000000000 --- a/spec/cmdx/coercions/time_spec.rb +++ /dev/null @@ -1,226 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Coercions::Time, type: :unit do - subject(:coercion) { described_class } - - describe ".call" do - context "when value is already an analog type" do - it "converts DateTime to Time" do - datetime = DateTime.new(2023, 12, 25, 14, 30, 0) - - result = coercion.call(datetime) - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "returns Time unchanged" do - time = Time.new(2023, 12, 25, 14, 30, 0) - - result = coercion.call(time) - - expect(result).to eq(time) - end - end - - context "when value responds to to_time" do - it "calls to_time on the value" do - date = Date.new(2023, 12, 25) - - result = coercion.call(date) - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "returns the to_time result" do - time_result = Time.new(2023, 1, 1) - value = instance_double("MockTime", to_time: time_result) - - result = coercion.call(value) - - expect(result).to eq(time_result) - end - end - - context "with strptime option" do - it "parses string using custom format" do - result = coercion.call("25-12-2023 14:30", strptime: "%d-%m-%Y %H:%M") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - end - - it "parses date string with custom format" do - result = coercion.call("2023/12/25", strptime: "%Y/%m/%d") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "raises CoercionError when string doesn't match strptime format" do - expect { coercion.call("2023-12-25", strptime: "%d/%m/%Y") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for invalid date with strptime" do - expect { coercion.call("32/13/2023", strptime: "%d/%m/%Y") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - end - - context "when value is a parseable string" do - it "parses ISO 8601 time string" do - result = coercion.call("2023-12-25T14:30:00") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - expect(result.sec).to eq(0) - end - - it "parses date string" do - result = coercion.call("2023-12-25") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses time string" do - result = coercion.call("14:30:00") - - expect(result).to be_a(Time) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - expect(result.sec).to eq(0) - end - - it "parses RFC 2822 formatted string" do - result = coercion.call("Mon, 25 Dec 2023 14:30:00 +0000") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - end - - it "parses human-readable date string" do - result = coercion.call("December 25, 2023") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - end - - context "when value cannot be coerced" do - it "raises CoercionError for invalid string" do - expect { coercion.call("invalid-time") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for float" do - expect { coercion.call(123.45) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ year: 2023 }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:time) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError when Time.parse raises ArgumentError" do - allow(Time).to receive(:parse).and_raise(ArgumentError) - - expect { coercion.call("some-string") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError when Time.parse raises TypeError" do - allow(Time).to receive(:parse).and_raise(TypeError) - - expect { coercion.call("some-string") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - end - - context "without options" do - it "calls Time.parse when no options provided" do - time_string = "2023-12-25T14:30:00" - parsed_time = Time.new(2023, 12, 25, 14, 30, 0) - - expect(Time).to receive(:parse).with(time_string).and_return(parsed_time) - - result = coercion.call(time_string) - - expect(result).to eq(parsed_time) - end - - it "does not call Time.strptime when no strptime option" do - expect(Time).not_to receive(:strptime) - - coercion.call("2023-12-25") - end - end - end -end diff --git a/spec/cmdx/configuration_spec.rb b/spec/cmdx/configuration_spec.rb deleted file mode 100644 index 829f60d5f..000000000 --- a/spec/cmdx/configuration_spec.rb +++ /dev/null @@ -1,278 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Configuration, type: :unit do - subject(:configuration) { described_class.new } - - describe "#initialize" do - it "initializes middlewares registry" do - expect(configuration.middlewares).to be_a(CMDx::MiddlewareRegistry) - expect(configuration.middlewares.registry).to be_empty - end - - it "initializes callbacks registry" do - expect(configuration.callbacks).to be_a(CMDx::CallbackRegistry) - expect(configuration.callbacks.registry).to be_empty - end - - it "initializes coercions registry with default coercions" do - expect(configuration.coercions).to be_a(CMDx::CoercionRegistry) - expect(configuration.coercions.registry).to include( - array: CMDx::Coercions::Array, - boolean: CMDx::Coercions::Boolean, - string: CMDx::Coercions::String, - integer: CMDx::Coercions::Integer, - float: CMDx::Coercions::Float, - hash: CMDx::Coercions::Hash, - big_decimal: CMDx::Coercions::BigDecimal, - complex: CMDx::Coercions::Complex, - date: CMDx::Coercions::Date, - datetime: CMDx::Coercions::DateTime, - rational: CMDx::Coercions::Rational, - time: CMDx::Coercions::Time - ) - end - - it "initializes validators registry with default validators" do - expect(configuration.validators).to be_a(CMDx::ValidatorRegistry) - expect(configuration.validators.registry).to include( - presence: CMDx::Validators::Presence, - format: CMDx::Validators::Format, - inclusion: CMDx::Validators::Inclusion, - exclusion: CMDx::Validators::Exclusion, - length: CMDx::Validators::Length, - numeric: CMDx::Validators::Numeric - ) - end - - it "sets breakpoints to default values" do - expect(configuration.task_breakpoints).to eq(%w[failed]) - expect(configuration.workflow_breakpoints).to eq(%w[failed]) - end - - it "sets dump_context to false by default" do - expect(configuration.dump_context).to be(false) - end - - it "sets locale to en by default" do - expect(configuration.default_locale).to eq("en") - end - - it "initializes logger with default configuration" do - logger = configuration.logger - - expect(logger).to be_a(Logger) - expect(logger.progname).to eq("cmdx") - expect(logger.formatter).to be_a(CMDx::LogFormatters::Line) - expect(logger.level).to eq(Logger::INFO) - end - end - - describe "attribute accessors" do - describe "#middlewares" do - let(:custom_registry) { CMDx::MiddlewareRegistry.new } - - it "allows setting and getting middlewares" do - configuration.middlewares = custom_registry - - expect(configuration.middlewares).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.middlewares = nil }.not_to raise_error - expect(configuration.middlewares).to be_nil - end - end - end - - describe "#callbacks" do - let(:custom_registry) { CMDx::CallbackRegistry.new } - - it "allows setting and getting callbacks" do - configuration.callbacks = custom_registry - - expect(configuration.callbacks).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.callbacks = nil }.not_to raise_error - expect(configuration.callbacks).to be_nil - end - end - end - - describe "#coercions" do - let(:custom_registry) { CMDx::CoercionRegistry.new } - - it "allows setting and getting coercions" do - configuration.coercions = custom_registry - - expect(configuration.coercions).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.coercions = nil }.not_to raise_error - expect(configuration.coercions).to be_nil - end - end - end - - describe "#validators" do - let(:custom_registry) { CMDx::ValidatorRegistry.new } - - it "allows setting and getting validators" do - configuration.validators = custom_registry - - expect(configuration.validators).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.validators = nil }.not_to raise_error - expect(configuration.validators).to be_nil - end - end - end - - describe "#task_breakpoints" do - let(:custom_breakpoints) { %w[failed error timeout] } - - it "allows setting and getting task_breakpoints" do - configuration.task_breakpoints = custom_breakpoints - - expect(configuration.task_breakpoints).to eq(custom_breakpoints) - end - - context "with empty array" do - it "accepts empty array assignment" do - configuration.task_breakpoints = [] - - expect(configuration.task_breakpoints).to eq([]) - end - end - - context "with nil value" do - it "accepts nil assignment" do - configuration.task_breakpoints = nil - - expect(configuration.task_breakpoints).to be_nil - end - end - end - - describe "#workflow_breakpoints" do - let(:custom_breakpoints) { %w[failed timeout interrupted] } - - it "allows setting and getting workflow_breakpoints" do - configuration.workflow_breakpoints = custom_breakpoints - - expect(configuration.workflow_breakpoints).to eq(custom_breakpoints) - end - - context "with empty array" do - it "accepts empty array assignment" do - configuration.workflow_breakpoints = [] - - expect(configuration.workflow_breakpoints).to eq([]) - end - end - - context "with nil value" do - it "accepts nil assignment" do - configuration.workflow_breakpoints = nil - - expect(configuration.workflow_breakpoints).to be_nil - end - end - end - - describe "#dump_context" do - it "allows setting and getting dump_context" do - configuration.dump_context = true - - expect(configuration.dump_context).to be(true) - end - end - - describe "#default_locale" do - it "allows setting and getting locale" do - configuration.default_locale = "es" - - expect(configuration.default_locale).to eq("es") - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.default_locale = nil }.not_to raise_error - expect(configuration.default_locale).to be_nil - end - end - end - - describe "#logger" do - let(:custom_logger) { Logger.new($stderr, progname: "test") } - - it "allows setting and getting logger" do - configuration.logger = custom_logger - - expect(configuration.logger).to eq(custom_logger) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.logger = nil }.not_to raise_error - expect(configuration.logger).to be_nil - end - end - end - end - - describe "#to_h" do - let(:result) { configuration.to_h } - - it "returns hash with all configuration attributes" do - expect(result).to include( - middlewares: configuration.middlewares, - callbacks: configuration.callbacks, - coercions: configuration.coercions, - validators: configuration.validators, - task_breakpoints: configuration.task_breakpoints, - workflow_breakpoints: configuration.workflow_breakpoints, - dump_context: configuration.dump_context, - default_locale: configuration.default_locale, - logger: configuration.logger - ) - end - - context "with modified attributes" do - let(:custom_breakpoints) { %w[custom_failed custom_error] } - let(:custom_logger) { Logger.new($stderr, progname: "test") } - - before do - configuration.task_breakpoints = custom_breakpoints - configuration.logger = custom_logger - end - - it "reflects the current attribute values" do - expect(result[:task_breakpoints]).to eq(custom_breakpoints) - expect(result[:logger]).to eq(custom_logger) - end - end - - context "with nil attributes" do - before do - configuration.middlewares = nil - configuration.logger = nil - end - - it "includes nil values in the hash" do - expect(result[:middlewares]).to be_nil - expect(result[:logger]).to be_nil - end - end - end -end diff --git a/spec/cmdx/context_spec.rb b/spec/cmdx/context_spec.rb deleted file mode 100644 index 02a5a8f74..000000000 --- a/spec/cmdx/context_spec.rb +++ /dev/null @@ -1,465 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Context, type: :unit do - subject(:context) { described_class.new(initial_data) } - - let(:initial_data) { { name: "John", age: 30 } } - - describe "#initialize" do - context "when given a hash" do - let(:initial_data) { { name: "Alice", age: 25 } } - - it "converts keys to symbols and stores the data" do - expect(context.table).to eq(name: "Alice", age: 25) - end - end - - context "when given an object that responds to to_hash" do - let(:initial_data) do - object = Object.new - def object.to_hash - { "city" => "NYC", "country" => "USA" } - end - object - end - - it "converts to hash and symbolizes keys" do - expect(context.table).to eq(city: "NYC", country: "USA") - end - end - - context "when given an object that responds to to_h" do - let(:initial_data) do - object = Object.new - def object.to_h - { "score" => 100, "level" => 5 } - end - object - end - - it "converts to hash and symbolizes keys" do - expect(context.table).to eq(score: 100, level: 5) - end - end - - context "when given an object that responds to neither to_h nor to_hash" do - let(:initial_data) { "invalid" } - - it "raises ArgumentError" do - expect { context }.to raise_error(ArgumentError, "must respond to `to_h` or `to_hash`") - end - end - - context "when given string keys" do - let(:initial_data) { { "name" => "Bob", "active" => true } } - - it "converts string keys to symbols" do - expect(context.table).to eq(name: "Bob", active: true) - end - end - - context "when given no arguments" do - subject(:context) { described_class.new } - - it "creates empty context" do - expect(context.table).to eq({}) - end - end - end - - describe ".build" do - context "when given a Context instance that is not frozen" do - let(:existing_context) { described_class.new(name: "test") } - - it "returns the same instance" do - result = described_class.build(existing_context) - - expect(result).to be(existing_context) - end - end - - context "when given a frozen Context instance" do - let(:frozen_context) { described_class.new(name: "test").freeze } - - it "creates a new Context instance" do - result = described_class.build(frozen_context) - - expect(result).not_to be(frozen_context) - expect(result.table).to eq(name: "test") - end - end - - context "when given an object that responds to context" do - let(:object_with_context) do - object = Object.new - def object.context - { user_id: 123 } - end - object - end - - it "recursively builds from the context method result" do - result = described_class.build(object_with_context) - - expect(result).to be_a(described_class) - expect(result.table).to eq(user_id: 123) - end - end - - context "when given a hash" do - let(:hash_data) { { role: "admin", permissions: %w[read write] } } - - it "creates new Context instance" do - result = described_class.build(hash_data) - - expect(result).to be_a(described_class) - expect(result.table).to eq(role: "admin", permissions: %w[read write]) - end - end - - context "when given nil" do - it "creates empty Context instance" do - result = described_class.build(nil) - - expect(result).to be_a(described_class) - expect(result.table).to eq({}) - end - end - end - - describe "#[]" do - it "retrieves value by symbol key" do - expect(context[:name]).to eq("John") - end - - it "retrieves value by string key converted to symbol" do - expect(context["name"]).to eq("John") - end - - it "returns nil for non-existent key" do - expect(context[:missing]).to be_nil - end - end - - describe "#store and #[]=" do - it "stores value with symbol key" do - context.store(:email, "john@example.com") - - expect(context[:email]).to eq("john@example.com") - end - - it "stores value with string key converted to symbol" do - context["phone"] = "555-1234" - - expect(context[:phone]).to eq("555-1234") - end - - it "overwrites existing value" do - context[:age] = 35 - - expect(context[:age]).to eq(35) - end - end - - describe "#fetch" do - it "retrieves existing value" do - expect(context.fetch(:name)).to eq("John") - end - - it "retrieves value with string key converted to symbol" do - expect(context.fetch("age")).to eq(30) - end - - it "returns default value for missing key" do - expect(context.fetch(:missing, "default")).to eq("default") - end - - it "executes block for missing key" do - result = context.fetch(:missing) { 1 + 1 } - - expect(result).to eq(2) - end - - it "raises KeyError for missing key without default" do - expect { context.fetch(:missing) }.to raise_error(KeyError) - end - end - - describe "#fetch_or_store" do - it "returns existing value when key exists" do - result = context.fetch_or_store(:name, "Default") - - expect(result).to eq("John") - expect(context.table).to include(name: "John") - end - - it "stores and returns default value when key does not exist" do - result = context.fetch_or_store(:email, "john@example.com") - - expect(result).to eq("john@example.com") - expect(context.table).to include(email: "john@example.com") - end - - it "converts string key to symbol" do - result = context.fetch_or_store("phone", "555-1234") - - expect(result).to eq("555-1234") - expect(context.table).to include(phone: "555-1234") - end - - it "returns existing value and does not execute block when key exists" do - block_called = false - result = context.fetch_or_store(:name) do - block_called = true - "Default" - end - - expect(result).to eq("John") - expect(block_called).to be(false) - end - - it "stores nil when no value or block provided" do - result = context.fetch_or_store(:status) - - expect(result).to be_nil - expect(context.table).to include(status: nil) - end - - it "overwrites existing value when block is provided and key exists" do - context[:counter] = 5 - result = context.fetch_or_store(:counter) { 10 } - - expect(result).to eq(5) - expect(context.table).to include(counter: 5) - end - - it "handles complex default values" do - default_hash = { active: true, role: "admin" } - result = context.fetch_or_store(:settings, default_hash) - - expect(result).to eq(default_hash) - expect(context.table).to include(settings: default_hash) - end - end - - describe "#merge!" do - context "when given a hash" do - it "merges new data and returns self" do - result = context.merge!(email: "john@example.com", active: true) - - expect(result).to be(context) - expect(context.table).to include( - name: "John", - age: 30, - email: "john@example.com", - active: true - ) - end - end - - context "when given object with to_h" do - let(:mergeable) do - object = Object.new - def object.to_h - { "status" => "active", "role" => "user" } - end - object - end - - it "converts to hash and merges with symbol keys" do - context.merge!(mergeable) - - expect(context.table).to include(status: "active", role: "user") - end - end - - context "when given empty hash" do - it "returns self without changes" do - original_table = context.table.dup - result = context.merge!({}) - - expect(result).to be(context) - expect(context.table).to eq(original_table) - end - end - - it "overwrites existing keys" do - context.merge!(name: "Jane", age: 25) - - expect(context.table).to eq(name: "Jane", age: 25) - end - end - - describe "#delete!" do - it "deletes existing key and returns value" do - result = context.delete!(:name) - - expect(result).to eq("John") - expect(context.table).not_to have_key(:name) - end - - it "deletes key with string converted to symbol" do - result = context.delete!("age") - - expect(result).to eq(30) - expect(context.table).not_to have_key(:age) - end - - it "returns nil for non-existent key" do - result = context.delete!(:missing) - - expect(result).to be_nil - end - - it "executes block for non-existent key" do - result = context.delete!(:missing) { "not found" } - - expect(result).to eq("not found") - end - end - - describe "#clear!" do - it "clears all data and returns self" do - result = context.clear! - - expect(result).to be(context) - expect(context.table).to be_empty - end - end - - describe "#eql? and #==" do - let(:other_context) { described_class.new(name: "John", age: 30) } - let(:different_context) { described_class.new(name: "Jane", age: 25) } - - it "returns true for contexts with same data" do - expect(context).to eql(other_context) - expect(context).to eq(other_context) - end - - it "returns false for contexts with different data" do - expect(context).not_to eql(different_context) - expect(context).not_to eq(different_context) - end - - it "returns false when compared to non-Context object" do - expect(context).not_to eql({ name: "John", age: 30 }) - expect(context).not_to eq({ name: "John", age: 30 }) - end - end - - describe "#key?" do - it "returns true for existing symbol key" do - expect(context.key?(:name)).to be(true) - end - - it "returns true for existing key given as string" do - expect(context.key?("name")).to be(true) - end - - it "returns false for non-existent key" do - expect(context.key?(:missing)).to be(false) - end - end - - describe "#dig" do - let(:initial_data) do - { - user: { - profile: { - name: "John", - settings: { theme: "dark" } - } - } - } - end - - it "digs into nested hash with symbol keys" do - expect(context.dig(:user, :profile, :name)).to eq("John") - end - - it "digs into nested hash with string key converted to symbol" do - expect(context.dig("user", :profile, :settings, :theme)).to eq("dark") - end - - it "returns nil for non-existent nested path" do - expect(context.dig(:user, :missing, :key)).to be_nil - end - - it "returns nil for partial path" do - expect(context.dig(:user, :profile, :missing)).to be_nil - end - end - - describe "#to_h" do - it "returns the internal table" do - expect(context.to_h).to eq(context.table) - expect(context.to_h).to be(context.table) - end - end - - describe "#to_s" do - it "delegates to Utils::Format.to_str" do - allow(CMDx::Utils::Format).to receive(:to_str).with(context.table).and_return("formatted string") - - expect(context.to_s).to eq("formatted string") - end - end - - describe "#each" do - it "delegates to table" do - result = [] - context.each { |key, value| result << [key, value] } # rubocop:disable Style/MapIntoArray - - expect(result).to contain_exactly([:name, "John"], [:age, 30]) - end - end - - describe "#map" do - it "delegates to table" do - result = context.map { |key, value| "#{key}:#{value}" } - - expect(result).to contain_exactly("name:John", "age:30") - end - end - - describe "method_missing and respond_to_missing?" do - context "when method name matches existing key" do - it "returns the value" do - expect(context.name).to eq("John") - expect(context.age).to eq(30) - end - - it "responds to the method" do - expect(context).to respond_to(:name) - expect(context).to respond_to(:age) - end - end - - context "when method name does not match any key" do - it "returns nil" do - expect(context.missing_method).to be_nil - end - - it "does not respond to the method" do - expect(context).not_to respond_to(:missing_method) - end - end - - context "when method name ends with equals sign" do - it "stores the value" do - context.email = "john@example.com" - - expect(context.table).to include(email: "john@example.com") - end - end - - context "when checking private method visibility" do - it "delegates to super for respond_to_missing?" do - expect(context.respond_to?(:name, true)).to be(true) - expect(context.respond_to?(:missing, true)).to be(false) - end - end - end -end diff --git a/spec/cmdx/deprecator_spec.rb b/spec/cmdx/deprecator_spec.rb deleted file mode 100644 index 35edc4a69..000000000 --- a/spec/cmdx/deprecator_spec.rb +++ /dev/null @@ -1,177 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Deprecator, type: :unit do - let(:mock_task) { instance_double("MockTask") } - let(:mock_task_class) { class_double(CMDx::Task, name: "TestTask") } - let(:mock_logger) { instance_double(Logger) } - let(:task_settings) { mock_settings(deprecate: deprecate_value) } - let(:deprecate_value) { false } - - before do - allow(mock_task_class).to receive(:settings).and_return(task_settings) - allow(mock_task).to receive_messages(class: mock_task_class, logger: mock_logger) - allow(mock_logger).to receive(:warn) - end - - describe "#restrict" do - context "when deprecate setting is nil or false" do - let(:deprecate_value) { nil } - - it "does nothing for nil" do - expect { described_class.restrict(mock_task) }.not_to raise_error - end - - context "when deprecate setting is false" do - let(:deprecate_value) { false } - - it "does nothing for false" do - expect { described_class.restrict(mock_task) }.not_to raise_error - end - end - end - - context "when deprecate setting is true" do - let(:deprecate_value) { true } - - it "raises DeprecationError" do - expect { described_class.restrict(mock_task) }.to raise_error( - CMDx::DeprecationError, "TestTask usage prohibited" - ) - end - end - - context "when deprecate setting contains 'raise'" do - let(:deprecate_value) { "raise" } - - it "raises DeprecationError" do - expect { described_class.restrict(mock_task) }.to raise_error( - CMDx::DeprecationError, "TestTask usage prohibited" - ) - end - - context "when deprecate setting is 'custom_raise'" do - let(:deprecate_value) { "custom_raise" } - - it "raises error for unevaluable deprecation value" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "custom_raise"' - ) - end - end - end - - context "when deprecate setting contains 'log'" do - let(:deprecate_value) { "log" } - - it "logs a warning message" do - expect(mock_logger).to receive(:warn) - - described_class.restrict(mock_task) - end - - context "when deprecate setting is 'custom_log'" do - let(:deprecate_value) { "custom_log" } - - it "raises error for unevaluable deprecation value" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "custom_log"' - ) - end - end - end - - context "when deprecate setting contains 'warn'" do - let(:deprecate_value) { "warn" } - - it "calls warn with deprecation message" do - expect(described_class).to receive(:warn).with( - "[TestTask] DEPRECATED: migrate to a replacement or discontinue use", - category: :deprecated - ) - - described_class.restrict(mock_task) - end - - context "when deprecate setting is 'custom_warn'" do - let(:deprecate_value) { "custom_warn" } - - it "raises error for unevaluable deprecation value" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "custom_warn"' - ) - end - end - end - - context "when deprecate setting is an unknown type" do - let(:deprecate_value) { "unknown" } - - it "raises an error for unknown deprecation type" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "unknown"' - ) - end - end - - context "when deprecate setting is a symbol" do - let(:deprecate_value) { :test_method } - - it "evaluates the symbol and processes the result" do - allow(mock_task).to receive(:test_method).and_return("symbol_result") - - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'unknown deprecation type "symbol_result"' - ) - end - end - - context "when deprecate setting is a proc" do - let(:deprecate_value) { proc { "log" } } - - it "evaluates the proc and processes the result" do - expect(mock_logger).to receive(:warn) - - described_class.restrict(mock_task) - end - end - - context "when deprecate setting is a callable object" do - let(:callable_object) { instance_double("MockCallable", call: "warn") } - let(:deprecate_value) { callable_object } - - it "calls the object and processes the result" do - allow(callable_object).to receive(:call).with(mock_task).and_return("warn") - expect(described_class).to receive(:warn).with( - "[TestTask] DEPRECATED: migrate to a replacement or discontinue use", - category: :deprecated - ) - - described_class.restrict(mock_task) - end - end - - context "when deprecate setting returns a boolean from symbol" do - let(:deprecate_value) { :check_deprecated } - - it "evaluates symbol and handles boolean result" do - allow(mock_task).to receive(:check_deprecated).and_return(false) - - expect { described_class.restrict(mock_task) }.not_to raise_error - end - end - - context "when deprecate setting returns true from symbol" do - let(:deprecate_value) { :check_deprecated } - - it "evaluates symbol and raises error for true result" do - allow(mock_task).to receive(:check_deprecated).and_return(true) - - expect { described_class.restrict(mock_task) }.to raise_error( - CMDx::DeprecationError, "TestTask usage prohibited" - ) - end - end - end -end diff --git a/spec/cmdx/errors_spec.rb b/spec/cmdx/errors_spec.rb deleted file mode 100644 index 370746f40..000000000 --- a/spec/cmdx/errors_spec.rb +++ /dev/null @@ -1,425 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Errors, type: :unit do - subject(:errors) { described_class.new } - - describe "#initialize" do - it "initializes with empty messages hash" do - expect(errors.messages).to eq({}) - end - - it "is empty by default" do - expect(errors).to be_empty - end - end - - describe "#add" do - context "when adding a valid message" do - it "adds a message for an attribute" do - errors.add(:name, "is required") - - expect(errors.messages[:name]).to include("is required") - end - - it "creates a Set for the attribute if it doesn't exist" do - errors.add(:email, "is invalid") - - expect(errors.messages[:email]).to be_a(Set) - end - - it "adds multiple messages for the same attribute" do - errors.add(:password, "is too short") - errors.add(:password, "must contain numbers") - - expect(errors.messages[:password]).to include("is too short", "must contain numbers") - expect(errors.messages[:password].size).to eq(2) - end - - it "does not duplicate the same message for an attribute" do - errors.add(:username, "is taken") - errors.add(:username, "is taken") - - expect(errors.messages[:username].size).to eq(1) - expect(errors.messages[:username]).to include("is taken") - end - - it "handles string attributes" do - errors.add("category", "is invalid") - - expect(errors.messages["category"]).to include("is invalid") - end - - it "handles symbol attributes" do - errors.add(:status, "is not allowed") - - expect(errors.messages[:status]).to include("is not allowed") - end - end - - context "when adding an empty message" do - it "does not add empty string messages" do - errors.add(:name, "") - - expect(errors.messages).to eq({}) - expect(errors).to be_empty - end - - it "raises an error when trying to add nil messages" do - expect { errors.add(:name, nil) }.to raise_error(NoMethodError, /undefined method.*empty.*for nil/) - end - end - end - - describe "#for?" do - context "when attribute has errors" do - before do - errors.add(:email, "is required") - end - - it "returns true for attributes with errors" do - expect(errors.for?(:email)).to be(true) - end - end - - context "when attribute has no errors" do - it "returns false for attributes without errors" do - expect(errors.for?(:name)).to be(false) - end - - it "returns false for non-existent attributes" do - expect(errors.for?(:nonexistent)).to be(false) - end - end - - context "when attribute exists but has empty errors" do - before do - errors.messages[:status] = Set.new - end - - it "returns false for attributes with empty error sets" do - expect(errors.for?(:status)).to be(false) - end - end - end - - describe "#any?" do - context "when no errors have been added" do - it "returns false" do - expect(errors.any?).to be(false) - end - end - - context "when errors have been added" do - before do - errors.add(:name, "is required") - end - - it "returns true" do - expect(errors.any?).to be(true) - end - end - end - - describe "#clear" do - context "when errors have been added" do - before do - errors.add(:name, "is required") - errors.add(:email, "is invalid") - end - - it "removes all errors" do - errors.clear - - expect(errors).to be_empty - end - end - end - - describe "#size" do - context "when no errors have been added" do - it "returns 0" do - expect(errors.size).to eq(0) - end - end - - context "when errors have been added for multiple attributes" do - before do - errors.add(:name, "is required") - errors.add(:email, "is invalid") - end - - it "returns the number of attributes with errors" do - expect(errors.size).to eq(2) - end - end - end - - describe "#empty?" do - context "when no errors have been added" do - it "returns true" do - expect(errors).to be_empty - end - end - - context "when errors have been added" do - before do - errors.add(:name, "is required") - end - - it "returns false" do - expect(errors).not_to be_empty - end - end - - context "when only empty messages were attempted to be added" do - before do - errors.add(:name, "") - end - - it "returns true" do - expect(errors).to be_empty - end - end - end - - describe "#to_h" do - context "when there are no errors" do - it "returns an empty hash" do - expect(errors.to_h).to eq({}) - end - end - - context "when there are errors" do - before do - errors.add(:name, "is required") - errors.add(:name, "is too short") - errors.add(:email, "is invalid") - end - - it "returns a hash with arrays as values" do - result = errors.to_h - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "converts Sets to Arrays for each attribute" do - result = errors.to_h - - expect(result[:name]).to contain_exactly("is required", "is too short") - expect(result[:email]).to contain_exactly("is invalid") - end - - it "preserves all error messages" do - result = errors.to_h - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end - end - end - - describe "#full_messages" do - context "when there are no errors" do - it "returns an empty hash" do - expect(errors.full_messages).to eq({}) - end - end - - context "when there are errors" do - before do - errors.add(:name, "is required") - errors.add(:name, "is too short") - errors.add(:email, "is invalid") - end - - it "returns a hash with full messages as values" do - result = errors.full_messages - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "includes attribute names in the messages" do - result = errors.full_messages - - expect(result[:name]).to contain_exactly("name is required", "name is too short") - expect(result[:email]).to contain_exactly("email is invalid") - end - - it "preserves all error messages" do - result = errors.full_messages - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end - end - - context "when there are mixed attribute types" do - before do - errors.add(:symbol_attr, "symbol error") - errors.add("string_attr", "string error") - end - - it "handles both string and symbol attributes" do - result = errors.full_messages - - expect(result[:symbol_attr]).to contain_exactly("symbol_attr symbol error") - expect(result["string_attr"]).to contain_exactly("string_attr string error") - end - end - end - - describe "#to_hash" do - context "when there are no errors" do - it "returns an empty hash when full is false" do - expect(errors.to_hash).to eq({}) - end - - it "returns an empty hash when full is true" do - expect(errors.to_hash(true)).to eq({}) - end - end - - context "when there are errors" do - before do - errors.add(:name, "is required") - errors.add(:name, "is too short") - errors.add(:email, "is invalid") - end - - context "when full is false (default)" do - it "returns a hash with arrays as values" do - result = errors.to_hash - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "converts Sets to Arrays for each attribute" do - result = errors.to_hash - - expect(result[:name]).to contain_exactly("is required", "is too short") - expect(result[:email]).to contain_exactly("is invalid") - end - - it "preserves all error messages" do - result = errors.to_hash - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end - end - - context "when full is true" do - it "returns a hash with full messages as values" do - result = errors.to_hash(true) - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "includes attribute names in the messages" do - result = errors.to_hash(true) - - expect(result[:name]).to contain_exactly("name is required", "name is too short") - expect(result[:email]).to contain_exactly("email is invalid") - end - - it "preserves all error messages" do - result = errors.to_hash(true) - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end - end - end - - context "when there are mixed attribute types" do - before do - errors.add(:symbol_attr, "symbol error") - errors.add("string_attr", "string error") - end - - it "handles both string and symbol attributes when full is false" do - result = errors.to_hash - - expect(result[:symbol_attr]).to contain_exactly("symbol error") - expect(result["string_attr"]).to contain_exactly("string error") - end - - it "handles both string and symbol attributes when full is true" do - result = errors.to_hash(true) - - expect(result[:symbol_attr]).to contain_exactly("symbol_attr symbol error") - expect(result["string_attr"]).to contain_exactly("string_attr string error") - end - end - end - - describe "#to_s" do - context "when there are no errors" do - it "returns an empty string" do - expect(errors.to_s).to eq("") - end - end - - context "when there is one error for one attribute" do - before do - errors.add(:name, "is required") - end - - it "returns a formatted string" do - expect(errors.to_s).to eq("name is required") - end - end - - context "when there are multiple errors for one attribute" do - before do - errors.add(:password, "is too short") - errors.add(:password, "must contain numbers") - end - - it "returns all errors for the attribute separated by periods" do - result = errors.to_s - - expect(result).to include("password is too short") - expect(result).to include("password must contain numbers") - expect(result.split(". ").length).to eq(2) - end - end - - context "when there are errors for multiple attributes" do - before do - errors.add(:name, "is required") - errors.add(:email, "is invalid") - errors.add(:password, "is too short") - end - - it "returns all errors separated by periods" do - result = errors.to_s - - expect(result).to include("name is required") - expect(result).to include("email is invalid") - expect(result).to include("password is too short") - expect(result.split(". ").length).to eq(3) - end - end - - context "when there are mixed attribute types" do - before do - errors.add(:symbol_attr, "symbol error") - errors.add("string_attr", "string error") - end - - it "handles both string and symbol attributes" do - result = errors.to_s - - expect(result).to include("symbol_attr symbol error") - expect(result).to include("string_attr string error") - end - end - end -end diff --git a/spec/cmdx/executor_spec.rb b/spec/cmdx/executor_spec.rb deleted file mode 100644 index 9bfadfd42..000000000 --- a/spec/cmdx/executor_spec.rb +++ /dev/null @@ -1,1162 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Executor, type: :unit do - subject(:worker) { described_class.new(task) } - - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - - describe "#initialize" do - it "assigns the task" do - expect(worker.task).to eq(task) - end - - it "provides read access to task attribute" do - expect(described_class.instance_methods).to include(:task) - expect(described_class.private_instance_methods).not_to include(:task) - end - end - - describe ".execute" do - context "with raise: false" do - it "creates worker instance and calls execute" do - expect(described_class).to receive(:new).with(task).and_return(worker) - expect(worker).to receive(:execute).and_return(:result) - - result = described_class.execute(task, raise: false) - - expect(result).to eq(:result) - end - end - - context "with raise: true" do - it "creates worker instance and calls execute!" do - expect(described_class).to receive(:new).with(task).and_return(worker) - expect(worker).to receive(:execute!).and_return(:result) - - result = described_class.execute(task, raise: true) - - expect(result).to eq(:result) - end - end - - context "without raise parameter" do - it "defaults to raise: false" do - expect(described_class).to receive(:new).with(task).and_return(worker) - expect(worker).to receive(:execute).and_return(:result) - - described_class.execute(task) - end - end - end - - describe "#execute" do - let(:middlewares) { instance_double(CMDx::MiddlewareRegistry) } - let(:logger) { instance_double(Logger) } - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(callbacks).to receive_messages(invoke: nil, empty?: false) - allow(task.class).to receive(:settings).and_return(mock_settings(middlewares: middlewares, callbacks: callbacks)) - allow(task).to receive(:logger).and_return(logger) - allow(logger).to receive(:info) - allow(worker).to receive(:freeze_execution!) - - # Setup result state to support proper transitions - allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.resolver).to receive(:complete!) - allow(task.resolver).to receive(:executed!) - end - - context "when execution is successful" do - it "calls middleware with task and executes successfully" do - expect(middlewares).to receive(:call!).with(task).and_yield - expect(worker).to receive(:pre_execution!) - expect(worker).to receive(:execution!) - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute - end - - it "logs execution information" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!) - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - expect(logger).to receive(:info) - - worker.execute - end - end - - context "when UndefinedMethodError is raised" do - let(:undefined_error) { CMDx::UndefinedMethodError.new("undefined method") } - - it "re-raises the exception without clearing chain" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(undefined_error) - - expect(CMDx::Chain).to receive(:clear).at_least(:once) - - expect { worker.execute }.to raise_error(CMDx::UndefinedMethodError) - end - end - - context "when Fault is raised" do - let(:fault_result) { instance_double(CMDx::Result, reason: "test failure") } - let(:fault) { CMDx::FailFault.new(fault_result) } - - it "calls throw! on task result with fault result" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - - expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - worker.execute - end - - it "continues with normal execution flow" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - allow(task.resolver).to receive(:throw!) - - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute - end - end - - context "when StandardError is raised" do - let(:standard_error) { StandardError.new("something went wrong") } - - it "calls fail! on task result with formatted error message" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(standard_error) - - expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) - - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - worker.execute - end - - it "continues with normal execution flow" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(standard_error) - allow(task.resolver).to receive(:fail!) - - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute - end - end - - context "when custom error is raised" do - let(:custom_error) { CMDx::TestError.new("test error") } - - it "calls fail! on task result with formatted error message" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(custom_error) - - expect(task.resolver).to receive(:fail!).with("[CMDx::TestError] test error", halt: false, cause: custom_error, source: :exception) - - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - worker.execute - end - end - end - - describe "#execute!" do - let(:middlewares) { instance_double(CMDx::MiddlewareRegistry) } - let(:logger) { instance_double(Logger) } - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(callbacks).to receive_messages(invoke: nil, empty?: false) - allow(task.class).to receive(:settings).and_return(mock_settings(middlewares: middlewares, callbacks: callbacks)) - allow(task).to receive(:logger).and_return(logger) - allow(logger).to receive(:info) - allow(worker).to receive(:freeze_execution!) - - # Setup result state to support proper transitions - allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.resolver).to receive(:complete!) - allow(task.resolver).to receive(:executed!) - end - - context "when execution is successful" do - it "calls middleware with task and executes successfully" do - expect(middlewares).to receive(:call!).with(task).and_yield - expect(worker).to receive(:pre_execution!) - expect(worker).to receive(:execution!) - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute! - end - end - - context "when UndefinedMethodError is raised" do - let(:undefined_error) { CMDx::UndefinedMethodError.new("undefined method") } - - it "calls raise_exception with the error" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(undefined_error) - - expect(worker).to receive(:raise_exception).with(undefined_error).and_raise(undefined_error) - - expect { worker.execute! }.to raise_error(undefined_error) - end - end - - context "when Fault is raised" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", reason: "test failure") } - let(:fault) { CMDx::FailFault.new(fault_result) } - - context "when halt_execution? returns false" do - it "calls throw!, executed!, and post_execution!" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - - expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:halt_execution?).with(fault).and_return(false) - expect(worker).to receive(:post_execution!) - - worker.execute! - end - end - - context "when halt_execution? returns true" do - it "calls throw! and raise_exception" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - - expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - expect(worker).to receive(:halt_execution?).with(fault).and_return(true) - expect(worker).to receive(:raise_exception).with(fault).and_raise(fault) - - expect { worker.execute! }.to raise_error(fault) - end - end - end - - context "when StandardError is raised" do - let(:standard_error) { StandardError.new("something went wrong") } - - it "calls fail! and raise_exception" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(standard_error) - - expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) - expect(worker).to receive(:raise_exception).with(standard_error).and_raise(standard_error) - - expect { worker.execute! }.to raise_error(standard_error) - end - end - end - - describe "#halt_execution?" do - let(:fault_resolver) { instance_double(CMDx::Resolver) } - let(:fault_task) { instance_double(CMDx::Task, resolver: fault_resolver) } - let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: true) } - let(:fault) { CMDx::FailFault.new(fault_result) } - - context "when resolver has strict: false" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: false) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: %w[failed skipped])) - end - - it "returns false regardless of breakpoints config" do - expect(worker.send(:halt_execution?, fault)).to be(false) - end - end - - context "when breakpoints setting exists" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: %w[failed skipped])) - end - - context "when exception result status is in breakpoints" do - it "returns true" do - expect(worker.send(:halt_execution?, fault)).to be(true) - end - end - - context "when exception result status is not in breakpoints" do - let(:success_resolver) { instance_double(CMDx::Resolver) } - let(:success_task) { instance_double(CMDx::Task, resolver: success_resolver) } - let(:success_result) { instance_double(CMDx::Result, status: "success", task: success_task, reason: "test success", strict?: true) } - let(:success_fault) { CMDx::SkipFault.new(success_result) } - - it "returns false" do - expect(worker.send(:halt_execution?, success_fault)).to be(false) - end - end - end - - context "when task_breakpoints setting exists" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(task_breakpoints: [:failed])) - end - - it "converts symbols to strings and checks inclusion" do - expect(worker.send(:halt_execution?, fault)).to be(true) - end - end - - context "when no breakpoints are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(task_breakpoints: nil)) - end - - it "returns false" do - expect(worker.send(:halt_execution?, fault)).to be(false) - end - end - - context "when breakpoints is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: nil, task_breakpoints: nil)) - end - - it "returns false" do - expect(worker.send(:halt_execution?, fault)).to be(false) - end - end - - context "with duplicate breakpoints" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: ["failed", "failed", :failed])) - end - - it "removes duplicates after string conversion" do - expect(worker.send(:halt_execution?, fault)).to be(true) - end - end - end - - describe "#retry_execution?" do - let(:exception) { StandardError.new("test error") } - let(:logger) { instance_double(Logger) } - - before do - allow(logger).to receive(:warn) - allow(task).to receive_messages(logger: logger, to_h: { id: "123" }) - end - - context "when retries is not configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when retries is 0" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 0)) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when retries are exhausted" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2)) - allow(task.result).to receive(:retries).and_return(2) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when exception type does not match retry_on" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_on: [ArgumentError])) - allow(task.result).to receive(:retries).and_return(0) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when retry should happen" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3)) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true" do - expect(worker.send(:retry_execution?, exception)).to be(true) - end - - it "increments retry count on result" do - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - - expect(task.result).to receive(:retries=).with(2) - - worker.send(:retry_execution?, exception) - end - - it "clears task errors" do - task.errors.add(:base, "previous error") - - worker.send(:retry_execution?, exception) - - expect(task.errors).to be_empty - end - - it "logs warning with reason and remaining retries" do - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - - expect(logger).to receive(:warn) do |&block| - result = block.call - expect(result[:reason]).to eq("[StandardError] test error") - expect(result[:remaining_retries]).to eq(3) - end - - worker.send(:retry_execution?, exception) - end - end - - context "with retry_on configuration" do - context "when exception matches configured type" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2, retry_on: [StandardError])) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true" do - expect(worker.send(:retry_execution?, exception)).to be(true) - end - end - - context "when exception is subclass of configured type" do - let(:custom_error) { CMDx::TestError.new("test error") } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2, retry_on: [StandardError])) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true" do - expect(worker.send(:retry_execution?, custom_error)).to be(true) - end - end - - context "when multiple exception types are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2, retry_on: [ArgumentError, StandardError])) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true if exception matches any type" do - expect(worker.send(:retry_execution?, exception)).to be(true) - end - end - end - - context "with retry_jitter as numeric value" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: 0.5)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "sleeps for jitter multiplied by current retries" do - expect(worker).to receive(:sleep).with(0.5) - - worker.send(:retry_execution?, exception) - end - - context "when first retry" do - before do - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "does not sleep when jitter calculation is 0" do - expect(worker).not_to receive(:sleep) - - worker.send(:retry_execution?, exception) - end - end - - context "when second retry" do - before do - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "sleeps for jitter * 1" do - expect(worker).to receive(:sleep).with(0.5) - - worker.send(:retry_execution?, exception) - end - end - - context "when third retry" do - before do - allow(task.result).to receive_messages(retries: 2, :retries= => nil) - end - - it "sleeps for jitter * 2" do - expect(worker).to receive(:sleep).with(1.0) - - worker.send(:retry_execution?, exception) - end - end - end - - context "with retry_jitter as symbol" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: :custom_jitter)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - allow(task).to receive(:custom_jitter).with(1).and_return(2.5) - end - - it "calls method on task with current retries" do - expect(task).to receive(:custom_jitter).with(1).and_return(2.5) - expect(worker).to receive(:sleep).with(2.5) - - worker.send(:retry_execution?, exception) - end - end - - context "with retry_jitter as proc" do - let(:jitter_proc) { ->(retries) { retries * 0.75 } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_proc)) - allow(task.result).to receive_messages(retries: 2, :retries= => nil) - end - - it "instance_execs proc with current retries" do - expect(worker).to receive(:sleep).with(1.5) - - worker.send(:retry_execution?, exception) - end - end - - context "with retry_jitter as callable object" do - let(:jitter_callable) do - Class.new do - def call(_task, retries) - retries * 1.25 - end - end.new - end - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_callable)) - allow(task.result).to receive_messages(retries: 2, :retries= => nil) - end - - it "calls object with task and current retries" do - expect(jitter_callable).to receive(:call).with(task, 2).and_return(2.5) - expect(worker).to receive(:sleep).with(2.5) - - worker.send(:retry_execution?, exception) - end - end - - context "when jitter calculation returns negative value" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: -0.5)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "does not sleep" do - expect(worker).not_to receive(:sleep) - - worker.send(:retry_execution?, exception) - end - end - - context "when jitter calculation returns zero" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: 0)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "does not sleep" do - expect(worker).not_to receive(:sleep) - - worker.send(:retry_execution?, exception) - end - end - end - - describe "#raise_exception" do - let(:exception) { StandardError.new("test error") } - - it "clears the chain and raises the exception" do - expect(CMDx::Chain).to receive(:clear).at_least(:once) - - expect { worker.send(:raise_exception, exception) }.to raise_error(exception) - end - end - - describe "#invoke_callbacks" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) - end - - it "delegates to callbacks registry with type and task" do - expect(callbacks).to receive(:invoke).with(:before_validation, task) - - worker.send(:invoke_callbacks, :before_validation) - end - end - - describe "#pre_execution!" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - let(:attributes) { instance_double(CMDx::AttributeRegistry) } - let(:errors) { instance_double(CMDx::Errors) } - - before do - allow(task.class).to receive(:settings).and_return( - mock_settings(callbacks: callbacks, attributes: attributes) - ) - allow(task).to receive(:errors).and_return(errors) - allow(callbacks).to receive(:invoke) - allow(attributes).to receive(:define_and_verify) - end - - context "when task has no errors" do - before do - allow(errors).to receive(:empty?).and_return(true) - end - - it "invokes before_validation callback and defines attributes" do - expect(callbacks).to receive(:invoke).with(:before_validation, task) - expect(attributes).to receive(:define_and_verify).with(task) - - worker.send(:pre_execution!) - end - end - - context "when task has errors" do - before do - allow(errors).to receive_messages( - empty?: false, - to_s: "Validation failed", - to_h: { name: ["is required"] } - ) - allow(task.resolver).to receive(:fail!) - end - - it "calls fail! on result with error information" do - expect(task.resolver).to receive(:fail!).with( - "Invalid", - source: :validation, - errors: { - full_message: "Validation failed", - messages: { name: ["is required"] } - } - ) - - worker.send(:pre_execution!) - end - end - end - - describe "#execution!" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) - allow(callbacks).to receive(:invoke) - allow(task.resolver).to receive(:executing!) - allow(task).to receive(:work) - end - - it "invokes before_execution callback, sets executing state, and calls work" do - expect(callbacks).to receive(:invoke).with(:before_execution, task) - expect(task.resolver).to receive(:executing!) - expect(task).to receive(:work) - - worker.send(:execution!) - end - end - - describe "#verify_context_returns!" do - let(:errors) { task.errors } - - context "when no returns are declared" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: [])) - allow(task.result).to receive(:success?).and_return(true) - end - - it "does not fail the result" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when returns setting is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: nil)) - allow(task.result).to receive(:success?).and_return(true) - end - - it "does not fail the result" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when result is not success" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: [:user])) - allow(task.result).to receive(:success?).and_return(false) - end - - it "skips verification" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when all declared returns are present in context" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: %i[user token])) - allow(task.result).to receive(:success?).and_return(true) - task.context[:user] = "John" - task.context[:token] = "abc123" - end - - it "does not fail the result" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when some declared returns are missing" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: %i[user token])) - allow(task.result).to receive(:success?).and_return(true) - task.context[:user] = "John" - end - - it "adds errors for missing returns and fails the result" do - expect(task.resolver).to receive(:fail!).with( - "Invalid", - source: :context, - errors: { - full_message: "token must be set in the context", - messages: { token: ["must be set in the context"] } - } - ) - - worker.send(:verify_context_returns!) - end - end - - context "when all declared returns are missing" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: %i[user token])) - allow(task.result).to receive(:success?).and_return(true) - end - - it "adds errors for all missing returns and fails the result" do - expect(task.resolver).to receive(:fail!).with( - "Invalid", - source: :context, - errors: { - full_message: "user must be set in the context. token must be set in the context", - messages: { - user: ["must be set in the context"], - token: ["must be set in the context"] - } - } - ) - - worker.send(:verify_context_returns!) - end - end - end - - describe "#post_execution!" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - let(:result) { instance_double(CMDx::Result) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) - allow(task).to receive(:result).and_return(result) - allow(callbacks).to receive(:invoke) - end - - context "when callback registry is empty" do - before do - allow(callbacks).to receive(:empty?).and_return(true) - end - - it "skips all callback invocations" do - expect(callbacks).not_to receive(:invoke) - - worker.send(:post_execution!) - end - end - - context "when result is executed and good" do - before do - allow(callbacks).to receive(:empty?).and_return(false) - allow(result).to receive_messages( - state: "complete", - status: "success", - executed?: true, - good?: true, - bad?: false - ) - end - - it "invokes all appropriate callbacks" do - expect(callbacks).to receive(:invoke).with(:on_complete, task) - expect(callbacks).to receive(:invoke).with(:on_executed, task) - expect(callbacks).to receive(:invoke).with(:on_success, task) - expect(callbacks).to receive(:invoke).with(:on_good, task) - - worker.send(:post_execution!) - end - end - - context "when result is failed and bad" do - before do - allow(callbacks).to receive(:empty?).and_return(false) - allow(result).to receive_messages( - state: "interrupted", - status: "failed", - executed?: false, - good?: false, - bad?: true - ) - end - - it "invokes all appropriate callbacks" do - expect(callbacks).to receive(:invoke).with(:on_interrupted, task) - expect(callbacks).to receive(:invoke).with(:on_failed, task) - expect(callbacks).to receive(:invoke).with(:on_bad, task) - - worker.send(:post_execution!) - end - end - - context "when result is skipped" do - before do - allow(callbacks).to receive(:empty?).and_return(false) - allow(result).to receive_messages( - state: "interrupted", - status: "skipped", - executed?: false, - good?: true, - bad?: false - ) - end - - it "invokes appropriate callbacks for skipped state" do - expect(callbacks).to receive(:invoke).with(:on_interrupted, task) - expect(callbacks).to receive(:invoke).with(:on_skipped, task) - expect(callbacks).to receive(:invoke).with(:on_good, task) - - worker.send(:post_execution!) - end - end - end - - describe "#finalize_execution!" do - let(:logger) { instance_double(Logger) } - - before do - allow(worker).to receive(:freeze_execution!) - allow(task).to receive(:logger).and_return(logger) - allow(logger).to receive(:info) - allow(task.result).to receive(:to_h).and_return({ id: "123", status: "success" }) - end - - it "freezes the task" do - expect(worker).to receive(:freeze_execution!) - - worker.send(:finalize_execution!) - end - - it "logs the result information at info level" do - expect(task).to receive(:logger) - expect(logger).to receive(:info) - - worker.send(:finalize_execution!) - end - - context "when logger block is called" do - it "calls to_h on task result" do - expect(task.result).to receive(:to_h).and_return({ id: "123", status: "success" }) - - expect(logger).to receive(:info) do |&block| - # When the block is called, it should return the result of to_h - expect(block.call).to eq({ id: "123", status: "success" }) - end - - worker.send(:finalize_execution!) - end - end - end - - describe "#rollback_execution!" do - context "when task does not respond to rollback" do - before do - allow(task).to receive(:respond_to?).with(:rollback).and_return(false) - allow(task.result).to receive(:status).and_return("failed") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - - context "when task responds to rollback" do - before do - allow(task).to receive(:respond_to?).with(:rollback).and_return(true) - allow(task).to receive(:rollback) - end - - context "when rollpoints setting exists" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: %w[failed skipped])) - end - - context "when result status is in rollpoints" do - before do - allow(task.result).to receive(:status).and_return("failed") - end - - it "calls rollback and marks result as rolled back" do - expect(task).to receive(:rollback) - - worker.send(:rollback_execution!) - - expect(task.result.rolled_back?).to be(true) - end - end - - context "when result status is not in rollpoints" do - before do - allow(task.result).to receive(:status).and_return("success") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - end - - context "when no rollpoints are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: [])) - allow(task.result).to receive(:status).and_return("failed") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - - context "when rollpoints is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: nil)) - allow(task.result).to receive(:status).and_return("failed") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - - context "with duplicate rollpoints" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: ["failed", "failed", :failed])) - allow(task.result).to receive(:status).and_return("failed") - end - - it "removes duplicates after string conversion and calls rollback" do - expect(task).to receive(:rollback).once - - worker.send(:rollback_execution!) - end - end - - context "with multiple statuses in rollpoints" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: %w[failed skipped])) - end - - it "calls rollback and marks result as rolled back when status is failed" do - allow(task.result).to receive(:status).and_return("failed") - - expect(task).to receive(:rollback) - - worker.send(:rollback_execution!) - - expect(task.result.rolled_back?).to be(true) - end - - it "calls rollback and marks result as rolled back when status is skipped" do - allow(task.result).to receive(:status).and_return("skipped") - - expect(task).to receive(:rollback) - - worker.send(:rollback_execution!) - - expect(task.result.rolled_back?).to be(true) - end - end - end - end - - describe "#verify_middleware_yield!" do - context "when result is still initialized (middleware did not yield)" do - before do - allow(task.result).to receive(:initialized?).and_return(true) - end - - it "marks result as failed and transitions to executed" do - expect(task.resolver).to receive(:fail!).with( - CMDx::Locale.t("cmdx.faults.invalid"), - halt: false, - source: :middleware - ) - expect(task.resolver).to receive(:executed!) - - worker.send(:verify_middleware_yield!) - end - end - - context "when result has progressed past initialized" do - before do - allow(task.result).to receive(:initialized?).and_return(false) - end - - it "does nothing" do - expect(task.resolver).not_to receive(:fail!) - expect(task.resolver).not_to receive(:executed!) - - worker.send(:verify_middleware_yield!) - end - end - - context "when middleware swallows the block during #execute" do - let(:swallowing_task_class) { create_successful_task(name: "SwallowTestTask") } - - before do - swallowing_task_class.settings.middlewares.register( - Class.new do - def self.call(_task, **_opts); end - end - ) - end - - it "detects and fails the result" do - result = swallowing_task_class.execute - - expect(result.failed?).to be(true) - expect(result.metadata[:source]).to eq(:middleware) - expect(result.interrupted?).to be(true) - end - end - end - - describe "#clear_chain!" do - let(:chain) { task.chain } - - after { CMDx::Chain.clear } - - context "when result is the first in the chain" do - it "clears the chain when current matches task chain" do - expect(task.result.index).to eq(0) - expect { worker.send(:clear_chain!) }.to change(CMDx::Chain, :current).to(nil) - end - end - - context "when result is not the first in the chain" do - before do - other_task = task_class.new - described_class.new(other_task) - end - - it "does not clear the chain" do - expect(task.result.index).not_to eq(0) - expect { worker.send(:clear_chain!) }.not_to change(CMDx::Chain, :current) - end - end - - context "when current chain is a different instance" do - it "does not clear the chain" do - worker # force creation so task.chain is established - - other_chain = CMDx::Chain.new - CMDx::Chain.current = other_chain - - worker.send(:clear_chain!) - expect(CMDx::Chain.current).to equal(other_chain) - end - end - end -end diff --git a/spec/cmdx/faults_spec.rb b/spec/cmdx/faults_spec.rb deleted file mode 100644 index 1572309a5..000000000 --- a/spec/cmdx/faults_spec.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Fault, type: :unit do - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - let(:result) do - task.resolver.fail!("test failure reason", halt: false) - task.result - end - - describe "#initialize" do - subject(:fault) { described_class.new(result) } - - it "initializes with result and sets reason from result" do - expect(fault.result).to eq(result) - expect(fault.message).to eq("test failure reason") - end - - it "inherits from CMDx::Error" do - expect(fault).to be_a(CMDx::Error) - end - - it "inherits from StandardError" do - expect(fault).to be_a(StandardError) - end - - context "when result has no reason" do - let(:result) do - task.result.tap do |r| - r.instance_variable_set(:@reason, nil) - end - end - - it "initializes with nil message passed to Error" do - expect(fault.message).to eq("CMDx::Fault") - end - end - end - - describe ".for?" do - let(:task_class_a) { create_successful_task(name: "TaskA") } - let(:task_class_b) { create_successful_task(name: "TaskB") } - let(:task_a) { task_class_a.new } - let(:task_b) { task_class_b.new } - let(:fault_a) { described_class.new(task_a.result) } - let(:fault_b) { described_class.new(task_b.result) } - - context "when matching single task class" do - subject(:custom_fault_class) { described_class.for?(task_class_a) } - - it "returns a new fault class" do - expect(custom_fault_class).to be_a(Class) - expect(custom_fault_class.superclass).to eq(described_class) - end - - it "matches faults from specified task class" do - allow(fault_a).to receive(:task).and_return(fault_a.result.task) - expect(custom_fault_class === fault_a).to be(true) - end - - it "does not match faults from other task classes" do - allow(fault_b).to receive(:task).and_return(fault_b.result.task) - expect(custom_fault_class === fault_b).to be(false) - end - - it "does not match non-fault objects" do - expect(custom_fault_class === "not a fault").to be(false) - end - - it "stores task classes in instance variable" do - expect(custom_fault_class.instance_variable_get(:@tasks)).to eq([task_class_a]) - end - end - - context "when matching multiple task classes" do - subject(:custom_fault_class) { described_class.for?(task_class_a, task_class_b) } - - it "matches faults from any specified task class" do - allow(fault_a).to receive(:task).and_return(fault_a.result.task) - allow(fault_b).to receive(:task).and_return(fault_b.result.task) - - expect(custom_fault_class === fault_a).to be(true) - expect(custom_fault_class === fault_b).to be(true) - end - - it "stores all task classes in instance variable" do - expect(custom_fault_class.instance_variable_get(:@tasks)).to eq([task_class_a, task_class_b]) - end - end - - context "when no task classes provided" do - subject(:custom_fault_class) { described_class.for? } - - it "returns fault class that matches no faults" do - allow(fault_a).to receive(:task).and_return(fault_a.result.task) - allow(fault_b).to receive(:task).and_return(fault_b.result.task) - - expect(custom_fault_class === fault_a).to be(false) - expect(custom_fault_class === fault_b).to be(false) - end - - it "stores empty array in instance variable" do - expect(custom_fault_class.instance_variable_get(:@tasks)).to eq([]) - end - end - end - - describe ".matches?" do - let(:fault_with_metadata) do - task.resolver.fail!("failure", halt: false, metadata: { error_code: 500 }) - described_class.new(task.result) - end - - context "when block is provided" do - subject(:custom_fault_class) do - described_class.matches? { |fault| fault.result.reason == "failure" } - end - - it "returns a new fault class" do - expect(custom_fault_class).to be_a(Class) - expect(custom_fault_class.superclass).to eq(described_class) - end - - it "matches faults that satisfy the block condition" do - expect(custom_fault_class === fault_with_metadata).to be_truthy - end - - it "does not match faults that don't satisfy the block condition" do - simple_fault = described_class.new(result) - expect(custom_fault_class === simple_fault).to be(false) - end - - it "does not match non-fault objects" do - expect(custom_fault_class === "not a fault").to be(false) - end - - it "stores block in instance variable" do - expect(custom_fault_class.instance_variable_get(:@block)).to be_a(Proc) - end - end - - context "when no block is provided" do - it "raises ArgumentError" do - expect { described_class.matches? }.to raise_error(ArgumentError, "block required") - end - end - - context "when block returns falsy values" do - subject(:custom_fault_class) do - described_class.matches? { |fault| fault.result.metadata[:nonexistent] } - end - - it "does not match when block returns nil" do - simple_fault = described_class.new(result) - expect(custom_fault_class === simple_fault).to be_falsy - end - - it "does not match when block returns false" do - custom_false_class = described_class.matches? { |_| false } - simple_fault = described_class.new(result) - expect(custom_false_class === simple_fault).to be_falsy - end - end - - context "when block returns truthy values" do - it "matches when block returns true" do - custom_true_class = described_class.matches? { |_| true } - simple_fault = described_class.new(result) - expect(custom_true_class === simple_fault).to be_truthy - end - - it "matches when block returns truthy object" do - custom_truthy_class = described_class.matches? { |_| "truthy" } - simple_fault = described_class.new(result) - expect(custom_truthy_class === simple_fault).to be_truthy - end - end - end - - describe "task accessor" do - subject(:fault) { described_class.new(result) } - - it "provides access to task through result" do - expect(fault.result.task).to eq(task) - end - end -end diff --git a/spec/cmdx/identifier_spec.rb b/spec/cmdx/identifier_spec.rb deleted file mode 100644 index eae822cb4..000000000 --- a/spec/cmdx/identifier_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Identifier, type: :unit do - subject(:identifier) { described_class } - - describe ".generate" do - context "when SecureRandom responds to uuid_v7" do - let(:uuid_v7) { "018f1234-5678-7000-8000-123456789abc" } - - before do - allow(SecureRandom).to receive(:respond_to?).with(:uuid_v7).and_return(true) - end - - it "returns a UUID v7" do - allow(SecureRandom).to receive(:uuid_v7).and_return(uuid_v7) - - expect(identifier.generate).to eq(uuid_v7) - end - - it "does not call the fallback uuid method" do - skip("Not supported on Ruby versions prior to 3.4") unless RubyVersion.min?(3.4) - - expect(SecureRandom).not_to receive(:uuid) - - identifier.generate - end - end - - context "when called multiple times" do - before do - allow(SecureRandom).to receive(:respond_to?).with(:uuid_v7).and_return(true) - end - - it "generates different UUIDs" do - allow(SecureRandom).to receive(:uuid_v7).and_return( - "018f1234-5678-7000-8000-123456789abc", - "018f1234-5678-7000-8000-123456789def" - ) - - first_id = described_class.generate - second_id = described_class.generate - - expect(first_id).not_to eq(second_id) - end - end - end -end diff --git a/spec/cmdx/locale_spec.rb b/spec/cmdx/locale_spec.rb deleted file mode 100644 index 2ee0ee0ef..000000000 --- a/spec/cmdx/locale_spec.rb +++ /dev/null @@ -1,147 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Locale, type: :unit do - subject(:translate_result) { described_class.translate(key, **options) } - - describe "#translate" do - context "when I18n is not defined" do - before { hide_const("I18n") } - - context "with a valid key" do - let(:key) { "cmdx.validators.presence" } - let(:options) { {} } - - it "returns the default translation from EN" do - expect(translate_result).to eq("cannot be empty") - end - end - - context "with a nested key" do - let(:key) { "cmdx.validators.length.min" } - let(:options) { { min: 5 } } - - it "returns the interpolated message" do - expect(translate_result).to eq("length must be at least 5") - end - end - - context "with a key that has multiple interpolations" do - let(:key) { "cmdx.validators.length.within" } - let(:options) { { min: 3, max: 10 } } - - it "interpolates all variables" do - expect(translate_result).to eq("length must be within 3 and 10") - end - end - - context "with a non-existent key" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { {} } - - it "returns a missing translation message" do - expect(translate_result).to eq("Translation missing: cmdx.non.existent.key") - end - end - - context "with an explicit default option" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { { default: "Custom default message" } } - - it "uses the provided default" do - expect(translate_result).to eq("Custom default message") - end - end - - context "with an explicit default option and interpolation" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { { default: "Custom %s message", value: "test" } } - - it "interpolates the custom default" do - expect(translate_result).to eq("Custom test message") - end - end - - context "with a non-string default" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { { default: [:array, "value"] } } - - it "returns the default as-is" do - expect(translate_result).to eq([:array, "value"]) - end - end - - context "with a symbol key" do - let(:key) { :"cmdx.validators.presence" } - let(:options) { {} } - - it "converts the symbol to string and translates" do - expect(translate_result).to eq("cannot be empty") - end - end - - context "with a key containing dots" do - let(:key) { "cmdx.types.big_decimal" } - let(:options) { {} } - - it "properly navigates nested hash structure" do - expect(translate_result).to eq("big decimal") - end - end - - context "with empty options" do - let(:key) { "cmdx.validators.format" } - let(:options) { {} } - - it "returns the translation without interpolation" do - expect(translate_result).to eq("is an invalid format") - end - end - - context "with extra options that are not in the template" do - let(:key) { "cmdx.validators.format" } - let(:options) { { extra: "value", another: "option" } } - - it "ignores extra options and returns the translation" do - expect(translate_result).to eq("is an invalid format") - end - end - end - - context "when I18n is defined" do - let(:i18n_double) { class_double("I18n") } - let(:key) { "cmdx.validators.presence" } - let(:options) { { locale: :es } } - - before do - stub_const("I18n", i18n_double) - end - - it "delegates to I18n.t with the key and options" do - expect(i18n_double).to receive(:t).with(key, **options, default: "cannot be empty").and_return("no puede estar vacío") - expect(translate_result).to eq("no puede estar vacío") - end - - context "when the key is not found in EN" do - let(:key) { "some.unknown.key" } - - it "sets default to nil and delegates to I18n" do - expect(i18n_double).to receive(:t).with(key, **options, default: nil).and_return("some translation") - - translate_result - end - end - - context "with an explicit default option" do - let(:options) { { default: "Custom default", locale: :es } } - - it "preserves the provided default" do - expect(i18n_double).to receive(:t).with(key, **options).and_return("some translation") - - translate_result - end - end - end - end -end diff --git a/spec/cmdx/log_formatters/json_spec.rb b/spec/cmdx/log_formatters/json_spec.rb deleted file mode 100644 index 2eadc06fa..000000000 --- a/spec/cmdx/log_formatters/json_spec.rb +++ /dev/null @@ -1,243 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::LogFormatters::JSON, type: :unit do - subject(:formatter) { described_class.new } - - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } - - describe "#call" do - context "with typical log parameters" do - it "returns properly formatted JSON with newline" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed).to eq( - { - "severity" => "INFO", - "timestamp" => "2023-12-15T10:30:45.123454Z", - "progname" => "TestApp", - "pid" => Process.pid, - "message" => "Test message" - } - ) - expect(result).to end_with("\n") - end - - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["pid"]).to eq(Process.pid) - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("2023-12-15T10:30:45.123454Z") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity" do - result = formatter.call(level, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq(level) - end - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("2023-12-15T20:30:45.123454Z") - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to be_nil - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to be_nil - end - - it "handles nil message" do - allow(CMDx::Utils::Format).to receive(:to_log).with(nil).and_return(nil) - - result = formatter.call(severity, time, progname, nil) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be_nil - end - end - - context "with special characters in strings" do - it "properly escapes quotes in severity" do - severity_with_quotes = 'INFO "quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq('INFO "quoted"') - end - - it "properly escapes newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to eq("TestApp\nSecondLine") - end - - it "handles unicode characters" do - unicode_message = "Test message with émojis 🚀" - - allow(CMDx::Utils::Format).to receive(:to_log).with(unicode_message).and_return(unicode_message) - - result = formatter.call(severity, time, progname, unicode_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq("Test message with émojis 🚀") - end - end - - context "with CMDx objects as message" do - let(:cmdx_hash) { { "state" => "complete", "status" => "success" } } - - it "uses Utils::Format.to_log for message processing" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(cmdx_hash) - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(cmdx_hash) - end - - it "handles complex nested structures" do - complex_hash = { - "task" => "ProcessData", - "context" => { "user_id" => 123, "session" => "abc123" }, - "metadata" => { "attempts" => 1, "duration" => 0.45 } - } - - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(complex_hash) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(complex_hash) - end - end - - context "with numeric and boolean messages" do - it "handles integer messages" do - integer_message = 42 - allow(CMDx::Utils::Format).to receive(:to_log).with(integer_message).and_return(integer_message) - - result = formatter.call(severity, time, progname, integer_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(42) - end - - it "handles boolean messages" do - boolean_message = true - - allow(CMDx::Utils::Format).to receive(:to_log).with(boolean_message).and_return(boolean_message) - - result = formatter.call(severity, time, progname, boolean_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be(true) - end - - it "handles float messages" do - float_message = 3.14159 - - allow(CMDx::Utils::Format).to receive(:to_log).with(float_message).and_return(float_message) - - result = formatter.call(severity, time, progname, float_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(3.14159) - end - end - - context "with array messages" do - it "handles array messages" do - array_message = %w[item1 item2 item3] - - result = formatter.call(severity, time, progname, array_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(%w[item1 item2 item3]) - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("1970-01-01T00:00:00.000000Z") - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("2099-12-31T23:59:59.999999Z") - end - end - - context "when Utils::Format.to_log raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_log).and_raise(StandardError, "Format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "Format error") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - allow(CMDx::Utils::Format).to receive(:to_log).with(large_message).and_return(large_message) - - result = formatter.call(severity, time, progname, large_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(large_message) - expect(parsed["message"].length).to eq(10_000) - end - end - end -end diff --git a/spec/cmdx/log_formatters/key_value_spec.rb b/spec/cmdx/log_formatters/key_value_spec.rb deleted file mode 100644 index 52e859084..000000000 --- a/spec/cmdx/log_formatters/key_value_spec.rb +++ /dev/null @@ -1,385 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::LogFormatters::KeyValue, type: :unit do - subject(:formatter) { described_class.new } - - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } - - describe "#call" do - context "with typical log parameters" do - it "returns properly formatted key-value string with newline" do - result = formatter.call(severity, time, progname, message) - - expect(result).to eq( - 'severity="INFO" timestamp="2023-12-15T10:30:45.123454Z" progname="TestApp" ' \ - "pid=#{Process.pid} message=\"Test message\"\n" - ) - expect(result).to end_with("\n") - end - - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("pid=#{Process.pid}") - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include('timestamp="2023-12-15T10:30:45.123454Z"') - end - - it "uses Utils::Format.to_log for message processing" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return("processed message") - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - - expect(result).to include('message="processed message"') - end - - it "uses Utils::Format.to_str to format the hash" do - expected_hash = { - severity: severity, - timestamp: time.utc.iso8601(6), - progname: progname, - pid: Process.pid, - message: message - } - - allow(CMDx::Utils::Format).to receive(:to_str).with(expected_hash).and_return(+"formatted output") - - expect(CMDx::Utils::Format).to receive(:to_str).with(expected_hash) - - result = formatter.call(severity, time, progname, message) - - expect(result).to eq("formatted output\n") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity" do - result = formatter.call(level, time, progname, message) - - expect(result).to include("severity=\"#{level}\"") - end - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - - expect(result).to include('timestamp="2023-12-15T20:30:45.123454Z"') - end - - it "handles UTC time correctly" do - utc_time = Time.new(2023, 12, 15, 10, 30, 45.123454, "+00:00") - - result = formatter.call(severity, utc_time, progname, message) - - expect(result).to include('timestamp="2023-12-15T10:30:45.123454Z"') - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - - expect(result).to include("severity=nil") - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - - expect(result).to include("progname=nil") - end - - it "handles nil message" do - allow(CMDx::Utils::Format).to receive(:to_log).with(nil).and_return(nil) - - result = formatter.call(severity, time, progname, nil) - - expect(result).to include("message=nil") - end - end - - context "with special characters in strings" do - it "properly escapes quotes in severity" do - severity_with_quotes = 'INFO "quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - - expect(result).to include('severity="INFO \"quoted\""') - end - - it "properly escapes newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - - expect(result).to include("progname=\"TestApp\\nSecondLine\"") - end - - it "handles unicode characters" do - unicode_message = "Test message with émojis 🚀" - - allow(CMDx::Utils::Format).to receive(:to_log).with(unicode_message).and_return(unicode_message) - - result = formatter.call(severity, time, progname, unicode_message) - - expect(result).to include("message=\"Test message with émojis 🚀\"") - end - - it "handles backslashes in strings" do - message_with_backslash = "C:\\Windows\\Path" - - allow(CMDx::Utils::Format).to receive(:to_log).with(message_with_backslash).and_return(message_with_backslash) - - result = formatter.call(severity, time, progname, message_with_backslash) - - expect(result).to include("message=\"C:\\\\Windows\\\\Path\"") - end - end - - context "with CMDx objects as message" do - let(:cmdx_hash) { { "state" => "complete", "status" => "success" } } - - it "processes CMDx objects through Utils::Format.to_log" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(cmdx_hash) - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - - if RubyVersion.min?(3.4) - expect(result).to include('message={"state" => "complete", "status" => "success"}') - else - expect(result).to include('message={"state"=>"complete", "status"=>"success"}') - end - end - - it "handles complex nested structures" do - complex_hash = { - "task" => "ProcessData", - "context" => { "user_id" => 123, "session" => "abc123" }, - "metadata" => { "attempts" => 1, "duration" => 0.45 } - } - - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(complex_hash) - - result = formatter.call(severity, time, progname, message) - - expect(result).to include("message=#{complex_hash.inspect}") - end - end - - context "with numeric and boolean messages" do - it "handles integer messages" do - integer_message = 42 - - allow(CMDx::Utils::Format).to receive(:to_log).with(integer_message).and_return(integer_message) - - result = formatter.call(severity, time, progname, integer_message) - - expect(result).to include("message=42") - end - - it "handles boolean messages" do - boolean_message = true - - allow(CMDx::Utils::Format).to receive(:to_log).with(boolean_message).and_return(boolean_message) - - result = formatter.call(severity, time, progname, boolean_message) - - expect(result).to include("message=true") - end - - it "handles float messages" do - float_message = 3.14159 - - allow(CMDx::Utils::Format).to receive(:to_log).with(float_message).and_return(float_message) - - result = formatter.call(severity, time, progname, float_message) - - expect(result).to include("message=3.14159") - end - - it "handles false boolean message" do - false_message = false - - allow(CMDx::Utils::Format).to receive(:to_log).with(false_message).and_return(false_message) - - result = formatter.call(severity, time, progname, false_message) - - expect(result).to include("message=false") - end - end - - context "with array messages" do - it "handles array messages" do - array_message = %w[item1 item2 item3] - - allow(CMDx::Utils::Format).to receive(:to_log).with(array_message).and_return(array_message) - - result = formatter.call(severity, time, progname, array_message) - - expect(result).to include('message=["item1", "item2", "item3"]') - end - - it "handles empty array messages" do - empty_array = [] - - allow(CMDx::Utils::Format).to receive(:to_log).with(empty_array).and_return(empty_array) - - result = formatter.call(severity, time, progname, empty_array) - - expect(result).to include("message=[]") - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - - expect(result).to include('timestamp="1970-01-01T00:00:00.000000Z"') - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - - expect(result).to include('timestamp="2099-12-31T23:59:59.999999Z"') - end - - it "handles time with no microseconds" do - time_no_microseconds = Time.new(2023, 1, 1, 12, 0, 0) - - result = formatter.call(severity, time_no_microseconds, progname, message) - - expect(result).to include('timestamp="2023-01-01T12:00:00.000000Z"') - end - end - - context "when Utils::Format.to_log raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_log).and_raise(StandardError, "Format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "Format error") - end - end - - context "when Utils::Format.to_str raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_str).and_raise(StandardError, "String format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "String format error") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - allow(CMDx::Utils::Format).to receive(:to_log).with(large_message).and_return(large_message) - - result = formatter.call(severity, time, progname, large_message) - - expect(result).to include("message=\"#{'x' * 10_000}\"") - expect(result.length).to be > 10_000 - end - - it "handles large progname" do - large_progname = "LongApplicationName" * 100 - - result = formatter.call(severity, time, large_progname, message) - - expect(result).to include("progname=\"#{large_progname}\"") - end - end - - context "with symbol values" do - it "handles symbol severity" do - symbol_severity = :warning - - result = formatter.call(symbol_severity, time, progname, message) - - expect(result).to include("severity=:warning") - end - - it "handles symbol message" do - symbol_message = :success - - allow(CMDx::Utils::Format).to receive(:to_log).with(symbol_message).and_return(symbol_message) - - result = formatter.call(severity, time, progname, symbol_message) - - expect(result).to include("message=:success") - end - end - - context "with hash message" do - it "handles hash messages" do - hash_message = { key: "value", count: 5 } - - allow(CMDx::Utils::Format).to receive(:to_log).with(hash_message).and_return(hash_message) - - result = formatter.call(severity, time, progname, hash_message) - - if RubyVersion.min?(3.4) - expect(result).to include('message={key: "value", count: 5}') - else - expect(result).to include('message={:key=>"value", :count=>5}') - end - end - - it "handles empty hash messages" do - empty_hash = {} - - allow(CMDx::Utils::Format).to receive(:to_log).with(empty_hash).and_return(empty_hash) - - result = formatter.call(severity, time, progname, empty_hash) - - expect(result).to include("message={}") - end - end - - context "with empty string values" do - it "handles empty string severity" do - result = formatter.call("", time, progname, message) - - expect(result).to include('severity=""') - end - - it "handles empty string progname" do - result = formatter.call(severity, time, "", message) - - expect(result).to include('progname=""') - end - - it "handles empty string message" do - allow(CMDx::Utils::Format).to receive(:to_log).with("").and_return("") - - result = formatter.call(severity, time, progname, "") - - expect(result).to include('message=""') - end - end - end -end diff --git a/spec/cmdx/log_formatters/line_spec.rb b/spec/cmdx/log_formatters/line_spec.rb deleted file mode 100644 index d8c5179d8..000000000 --- a/spec/cmdx/log_formatters/line_spec.rb +++ /dev/null @@ -1,285 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::LogFormatters::Line, type: :unit do - subject(:formatter) { described_class.new } - - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } - - describe "#call" do - context "with typical log parameters" do - it "returns properly formatted line string with newline" do - result = formatter.call(severity, time, progname, message) - - expect(result).to eq("I, [2023-12-15T10:30:45.123454Z ##{Process.pid}] INFO -- TestApp: Test message\n") - expect(result).to end_with("\n") - end - - it "includes severity prefix as first character" do - result = formatter.call(severity, time, progname, message) - - expect(result).to start_with("I,") - end - - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("##{Process.pid}") - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("[2023-12-15T10:30:45.123454Z") - end - - it "includes full severity name" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("] INFO --") - end - - it "includes progname and message" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("TestApp: Test message") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity with correct prefix" do - result = formatter.call(level, time, progname, message) - expected_prefix = level[0] - - expect(result).to start_with("#{expected_prefix},") - expect(result).to include("] #{level} --") - end - end - - it "handles custom severity levels" do - custom_severity = "TRACE" - - result = formatter.call(custom_severity, time, progname, message) - - expect(result).to start_with("T,") - expect(result).to include("] TRACE --") - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - - expect(result).to include("[2023-12-15T20:30:45.123454Z") - end - - it "handles UTC time correctly" do - utc_time = Time.new(2023, 12, 15, 10, 30, 45.123454, "+00:00") - - result = formatter.call(severity, utc_time, progname, message) - - expect(result).to include("[2023-12-15T10:30:45.123454Z") - end - end - - context "with nil values" do - it "handles nil severity" do - expect do - formatter.call(nil, time, progname, message) - end.to raise_error(NoMethodError) - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - - expect(result).to include("INFO -- : Test message") - end - - it "handles nil message" do - result = formatter.call(severity, time, progname, nil) - - expect(result).to include("TestApp: \n") - end - end - - context "with special characters in strings" do - it "handles quotes in severity" do - severity_with_quotes = 'INFO"quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - - expect(result).to include('] INFO"quoted" --') - end - - it "handles newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - - expect(result).to include("TestApp\nSecondLine: Test message") - end - - it "handles unicode characters in message" do - unicode_message = "Test message with émojis 🚀" - - result = formatter.call(severity, time, progname, unicode_message) - - expect(result).to include("TestApp: Test message with émojis 🚀") - end - - it "handles backslashes in message" do - message_with_backslash = "C:\\Windows\\Path" - - result = formatter.call(severity, time, progname, message_with_backslash) - - expect(result).to include("TestApp: C:\\Windows\\Path") - end - end - - context "with complex objects as message" do - it "handles hash messages" do - hash_message = { "state" => "complete", "status" => "success" } - - result = formatter.call(severity, time, progname, hash_message) - - if RubyVersion.min?(3.4) - expect(result).to include('TestApp: {"state" => "complete", "status" => "success"}') - else - expect(result).to include('TestApp: {"state"=>"complete", "status"=>"success"}') - end - end - - it "handles array messages" do - array_message = %w[item1 item2 item3] - - result = formatter.call(severity, time, progname, array_message) - - expect(result).to include('TestApp: ["item1", "item2", "item3"]') - end - - it "handles numeric messages" do - numeric_message = 42 - - result = formatter.call(severity, time, progname, numeric_message) - - expect(result).to include("TestApp: 42") - end - - it "handles boolean messages" do - boolean_message = true - - result = formatter.call(severity, time, progname, boolean_message) - - expect(result).to include("TestApp: true") - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - - expect(result).to include("[1970-01-01T00:00:00.000000Z") - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - - expect(result).to include("[2099-12-31T23:59:59.999999Z") - end - - it "handles time with no microseconds" do - time_no_microseconds = Time.new(2023, 1, 1, 12, 0, 0) - - result = formatter.call(severity, time_no_microseconds, progname, message) - - expect(result).to include("[2023-01-01T12:00:00.000000Z") - end - end - - context "with empty string values" do - it "handles empty string severity" do - result = formatter.call("", time, progname, message) - - expect(result).to start_with(", ") - end - - it "handles empty string progname" do - result = formatter.call(severity, time, "", message) - - expect(result).to include("INFO -- : Test message") - end - - it "handles empty string message" do - result = formatter.call(severity, time, progname, "") - - expect(result).to include("TestApp: \n") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - result = formatter.call(severity, time, progname, large_message) - - expect(result).to include("TestApp: #{'x' * 10_000}") - expect(result.length).to be > 10_000 - end - - it "handles large progname" do - large_progname = "LongApplicationName" * 100 - - result = formatter.call(severity, time, large_progname, message) - - expect(result).to include("#{large_progname}: Test message") - end - - it "handles large severity" do - large_severity = "VERYLONGSEVERITYLEVEL" * 10 - - result = formatter.call(large_severity, time, progname, message) - - expect(result).to start_with("V,") - expect(result).to include("] #{large_severity} --") - end - end - - context "with symbol values" do - it "handles symbol severity" do - symbol_severity = :warning - - result = formatter.call(symbol_severity, time, progname, message) - - expect(result).to start_with("w,") - expect(result).to include("] warning --") - end - - it "handles symbol progname" do - symbol_progname = :my_app - - result = formatter.call(severity, time, symbol_progname, message) - - expect(result).to include("my_app: Test message") - end - - it "handles symbol message" do - symbol_message = :success - - result = formatter.call(severity, time, progname, symbol_message) - - expect(result).to include("TestApp: success") - end - end - end -end diff --git a/spec/cmdx/log_formatters/logstash_spec.rb b/spec/cmdx/log_formatters/logstash_spec.rb deleted file mode 100644 index 49601c020..000000000 --- a/spec/cmdx/log_formatters/logstash_spec.rb +++ /dev/null @@ -1,254 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::LogFormatters::Logstash, type: :unit do - subject(:formatter) { described_class.new } - - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } - - describe "#call" do - context "with typical log parameters" do - it "returns properly formatted JSON with newline" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed).to eq( - { - "@version" => "1", - "@timestamp" => "2023-12-15T10:30:45.123454Z", - "severity" => "INFO", - "progname" => "TestApp", - "pid" => Process.pid, - "message" => "Test message" - } - ) - expect(result).to end_with("\n") - end - - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["pid"]).to eq(Process.pid) - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("2023-12-15T10:30:45.123454Z") - end - - it "includes Logstash version field" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@version"]).to eq("1") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity" do - result = formatter.call(level, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq(level) - end - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("2023-12-15T20:30:45.123454Z") - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to be_nil - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to be_nil - end - - it "handles nil message" do - allow(CMDx::Utils::Format).to receive(:to_log).with(nil).and_return(nil) - - result = formatter.call(severity, time, progname, nil) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be_nil - end - end - - context "with special characters in strings" do - it "properly escapes quotes in severity" do - severity_with_quotes = 'INFO "quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq('INFO "quoted"') - end - - it "properly escapes newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to eq("TestApp\nSecondLine") - end - - it "handles unicode characters" do - unicode_message = "Test message with émojis 🚀" - - allow(CMDx::Utils::Format).to receive(:to_log).with(unicode_message).and_return(unicode_message) - - result = formatter.call(severity, time, progname, unicode_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq("Test message with émojis 🚀") - end - end - - context "with CMDx objects as message" do - let(:cmdx_hash) { { "state" => "complete", "status" => "success" } } - - it "uses Utils::Format.to_log for message processing" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(cmdx_hash) - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(cmdx_hash) - end - - it "handles complex nested structures" do - complex_hash = { - "task" => "ProcessData", - "context" => { "user_id" => 123, "session" => "abc123" }, - "metadata" => { "attempts" => 1, "duration" => 0.45 } - } - - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(complex_hash) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(complex_hash) - end - end - - context "with numeric and boolean messages" do - it "handles integer messages" do - integer_message = 42 - - allow(CMDx::Utils::Format).to receive(:to_log).with(integer_message).and_return(integer_message) - - result = formatter.call(severity, time, progname, integer_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(42) - end - - it "handles boolean messages" do - boolean_message = true - - allow(CMDx::Utils::Format).to receive(:to_log).with(boolean_message).and_return(boolean_message) - - result = formatter.call(severity, time, progname, boolean_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be(true) - end - - it "handles float messages" do - float_message = 3.14159 - - allow(CMDx::Utils::Format).to receive(:to_log).with(float_message).and_return(float_message) - - result = formatter.call(severity, time, progname, float_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(3.14159) - end - end - - context "with array messages" do - it "handles array messages" do - array_message = %w[item1 item2 item3] - - allow(CMDx::Utils::Format).to receive(:to_log).with(array_message).and_return(array_message) - - result = formatter.call(severity, time, progname, array_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(%w[item1 item2 item3]) - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("1970-01-01T00:00:00.000000Z") - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("2099-12-31T23:59:59.999999Z") - end - end - - context "when Utils::Format.to_log raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_log).and_raise(StandardError, "Format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "Format error") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - allow(CMDx::Utils::Format).to receive(:to_log).with(large_message).and_return(large_message) - - result = formatter.call(severity, time, progname, large_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(large_message) - expect(parsed["message"].length).to eq(10_000) - end - end - end -end diff --git a/spec/cmdx/log_formatters/raw_spec.rb b/spec/cmdx/log_formatters/raw_spec.rb deleted file mode 100644 index e729326c8..000000000 --- a/spec/cmdx/log_formatters/raw_spec.rb +++ /dev/null @@ -1,203 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::LogFormatters::Raw, type: :unit do - subject(:formatter) { described_class.new } - - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } - - describe "#call" do - context "with typical log parameters" do - it "returns message with newline" do - result = formatter.call(severity, time, progname, message) - - expect(result).to eq("Test message\n") - end - - it "ends with newline" do - result = formatter.call(severity, time, progname, message) - - expect(result).to end_with("\n") - end - - it "ignores severity parameter" do - result1 = formatter.call("DEBUG", time, progname, message) - result2 = formatter.call("FATAL", time, progname, message) - - expect(result1).to eq(result2) - expect(result1).to eq("Test message\n") - end - - it "ignores time parameter" do - time1 = Time.new(2020, 1, 1) - time2 = Time.new(2030, 12, 31) - result1 = formatter.call(severity, time1, progname, message) - result2 = formatter.call(severity, time2, progname, message) - - expect(result1).to eq(result2) - expect(result1).to eq("Test message\n") - end - - it "ignores progname parameter" do - result1 = formatter.call(severity, time, "App1", message) - result2 = formatter.call(severity, time, "App2", message) - - expect(result1).to eq(result2) - expect(result1).to eq("Test message\n") - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - - expect(result).to eq("Test message\n") - end - - it "handles nil time" do - result = formatter.call(severity, nil, progname, message) - - expect(result).to eq("Test message\n") - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - - expect(result).to eq("Test message\n") - end - - it "handles nil message" do - result = formatter.call(severity, time, progname, nil) - - expect(result).to eq("\n") - end - end - - context "with special characters in message" do - it "handles newlines in message" do - message_with_newline = "Line 1\nLine 2" - - result = formatter.call(severity, time, progname, message_with_newline) - - expect(result).to eq("Line 1\nLine 2\n") - end - - it "handles unicode characters in message" do - unicode_message = "Test message with émojis 🚀" - - result = formatter.call(severity, time, progname, unicode_message) - - expect(result).to eq("Test message with émojis 🚀\n") - end - - it "handles backslashes in message" do - message_with_backslash = "C:\\Windows\\Path" - - result = formatter.call(severity, time, progname, message_with_backslash) - - expect(result).to eq("C:\\Windows\\Path\n") - end - - it "handles quotes in message" do - message_with_quotes = 'Message with "quotes" and \'apostrophes\'' - - result = formatter.call(severity, time, progname, message_with_quotes) - - expect(result).to eq("Message with \"quotes\" and 'apostrophes'\n") - end - end - - context "with complex objects as message" do - it "handles hash messages" do - hash_message = { "state" => "complete", "status" => "success" } - - result = formatter.call(severity, time, progname, hash_message) - - if RubyVersion.min?(3.4) - expect(result).to eq("{\"state\" => \"complete\", \"status\" => \"success\"}\n") - else - expect(result).to eq("{\"state\"=>\"complete\", \"status\"=>\"success\"}\n") - end - end - - it "handles array messages" do - array_message = %w[item1 item2 item3] - - result = formatter.call(severity, time, progname, array_message) - - expect(result).to eq("[\"item1\", \"item2\", \"item3\"]\n") - end - - it "handles numeric messages" do - numeric_message = 42 - - result = formatter.call(severity, time, progname, numeric_message) - - expect(result).to eq("42\n") - end - - it "handles boolean messages" do - boolean_message = true - - result = formatter.call(severity, time, progname, boolean_message) - - expect(result).to eq("true\n") - end - - it "handles symbol messages" do - symbol_message = :success - - result = formatter.call(severity, time, progname, symbol_message) - - expect(result).to eq("success\n") - end - end - - context "with empty string values" do - it "handles empty string message" do - result = formatter.call(severity, time, progname, "") - - expect(result).to eq("\n") - end - - it "handles empty string for other parameters" do - result = formatter.call("", nil, "", message) - - expect(result).to eq("Test message\n") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - result = formatter.call(severity, time, progname, large_message) - - expect(result).to eq("#{'x' * 10_000}\n") - expect(result.length).to eq(10_001) - end - end - - context "with different message types" do - it "handles string interpolation correctly" do - interpolated_message = "Value is 42" - - result = formatter.call(severity, time, progname, interpolated_message) - - expect(result).to eq("Value is 42\n") - end - - it "handles frozen strings" do - frozen_message = "Frozen message" - - result = formatter.call(severity, time, progname, frozen_message) - - expect(result).to eq("Frozen message\n") - end - end - end -end diff --git a/spec/cmdx/middleware_registry_spec.rb b/spec/cmdx/middleware_registry_spec.rb deleted file mode 100644 index df5662468..000000000 --- a/spec/cmdx/middleware_registry_spec.rb +++ /dev/null @@ -1,453 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::MiddlewareRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { [] } - let(:mock_task) { instance_double(CMDx::Task) } - let(:mock_middleware1) { instance_double("MockMiddleware1", call: nil) } - let(:mock_middleware2) { instance_double("MockMiddleware2", call: nil) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with an empty array" do - expect(registry.registry).to eq([]) - end - end - - context "when a registry is provided" do - let(:initial_registry) { [[mock_middleware1, {}]] } - - it "initializes with the provided registry" do - expect(registry.registry).to eq([[mock_middleware1, {}]]) - end - end - end - - describe "#registry" do - let(:initial_registry) { [[mock_middleware1, {}]] } - - it "returns the internal registry array" do - expect(registry.registry).to eq([[mock_middleware1, {}]]) - end - end - - describe "#to_a" do - let(:initial_registry) { [[mock_middleware1, {}]] } - - it "returns the registry array" do - expect(registry.to_a).to eq([[mock_middleware1, {}]]) - end - - it "is an alias for registry" do - expect(registry.method(:to_a)).to eq(registry.method(:registry)) - end - end - - describe "#dup" do - let(:initial_registry) { [[mock_middleware1, { option: "value" }]] } - - it "returns a new MiddlewareRegistry instance" do - duplicated = registry.dup - - expect(duplicated).to be_a(described_class) - expect(duplicated).not_to be(registry) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) - end - - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - - duplicated.register(mock_middleware2) - - expect(duplicated.registry.size).to eq(2) - expect(registry.registry.size).to eq(1) - expect(duplicated.registry).not_to be(registry.registry) - end - - it "materializes on deregister and does not affect the parent" do - duplicated = registry.dup - - duplicated.deregister(mock_middleware1) - - expect(duplicated.registry).to be_empty - expect(registry.registry.size).to eq(1) - end - - context "when registry is empty" do - let(:initial_registry) { [] } - - it "returns a new empty MiddlewareRegistry" do - duplicated = registry.dup - - expect(duplicated.registry).to eq([]) - expect(duplicated).not_to be(registry) - end - end - end - - describe "#register" do - context "when registering middleware without options" do - it "adds the middleware to the end of the registry" do - registry.register(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware1, {}]]) - end - - it "returns self for method chaining" do - result = registry.register(mock_middleware1) - - expect(result).to be(registry) - end - end - - context "when registering middleware with options" do - let(:options) { { timeout: 30, retry: true } } - - it "stores the middleware with its options" do - registry.register(mock_middleware1, **options) - - expect(registry.registry).to eq([[mock_middleware1, options]]) - end - end - - context "when registering middleware at a specific position" do - let(:initial_registry) { [[mock_middleware1, {}]] } - - it "inserts at the beginning when at: 0" do - registry.register(mock_middleware2, at: 0) - - expect(registry.registry).to eq( - [ - [mock_middleware2, {}], - [mock_middleware1, {}] - ] - ) - end - - it "inserts at a specific index" do - registry.register(mock_middleware2, at: 1) - - expect(registry.registry).to eq( - [ - [mock_middleware1, {}], - [mock_middleware2, {}] - ] - ) - end - - it "inserts at the end when at: -1 (default)" do - registry.register(mock_middleware2, at: -1) - - expect(registry.registry).to eq( - [ - [mock_middleware1, {}], - [mock_middleware2, {}] - ] - ) - end - end - - context "when registering multiple middlewares" do - it "maintains insertion order" do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - - expect(registry.registry).to eq( - [ - [mock_middleware1, {}], - [mock_middleware2, {}] - ] - ) - end - end - - context "when registering middleware with position and options" do - let(:initial_registry) { [[mock_middleware1, {}]] } - let(:options) { { timeout: 30 } } - - it "inserts at specified position with options" do - registry.register(mock_middleware2, at: 0, **options) - - expect(registry.registry).to eq( - [ - [mock_middleware2, options], - [mock_middleware1, {}] - ] - ) - end - end - end - - describe "#deregister" do - context "when deregistering middleware without options" do - before do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - end - - it "removes the middleware from the registry" do - registry.deregister(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware2, {}]]) - end - - it "returns self for method chaining" do - result = registry.deregister(mock_middleware1) - - expect(result).to be(registry) - end - end - - context "when deregistering middleware with options" do - let(:options) { { timeout: 30, retry: true } } - - before do - registry.register(mock_middleware1, **options) - registry.register(mock_middleware2) - end - - it "removes all instances of the middleware regardless of options" do - registry.deregister(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware2, {}]]) - end - - it "removes middleware with any options" do - registry.register(mock_middleware1, timeout: 60) - registry.deregister(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware2, {}]]) - expect(registry.registry).not_to include([mock_middleware1, { timeout: 60 }]) - expect(registry.registry).not_to include([mock_middleware1, options]) - end - end - - context "when deregistering from empty registry" do - it "does not raise an error" do - expect { registry.deregister(mock_middleware1) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(mock_middleware1) - - expect(result).to be(registry) - end - end - - context "when deregistering non-existent middleware" do - before do - registry.register(mock_middleware1) - end - - it "does not affect existing middleware" do - registry.deregister(mock_middleware2) - - expect(registry.registry).to include([mock_middleware1, {}]) - end - - it "returns self" do - result = registry.deregister(mock_middleware2) - - expect(result).to be(registry) - end - end - - context "when deregistering middleware registered multiple times" do - let(:options1) { { timeout: 30 } } - let(:options2) { { timeout: 60 } } - - before do - registry.register(mock_middleware1, **options1) - registry.register(mock_middleware1, **options2) - end - - it "removes all instances of the middleware" do - registry.deregister(mock_middleware1) - - expect(registry.registry).to be_empty - end - end - - context "when deregistering one of multiple middleware" do - before do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - end - - it "removes only the specified middleware" do - registry.deregister(mock_middleware1) - - expect(registry.registry).not_to include([mock_middleware1, {}]) - expect(registry.registry).to include([mock_middleware2, {}]) - end - - it "maintains order of remaining middleware" do - registry.register(mock_middleware1) # Now we have [mw1, mw2, mw1] - registry.deregister(mock_middleware2) - - expect(registry.registry).to eq([ - [mock_middleware1, {}], - [mock_middleware1, {}] - ]) - end - end - end - - describe "#call!" do - let(:block_result) { "block_executed" } - let(:test_block) { proc { |_task| block_result } } - - context "when no block is given" do - it "raises ArgumentError" do - expect { registry.call!(mock_task) }.to raise_error(ArgumentError, "block required") - end - end - - context "when registry is empty" do - it "yields to the block immediately" do - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(block_result) - end - - it "passes the task to the block" do - yielded_task = nil - registry.call!(mock_task) { |task| yielded_task = task } - - expect(yielded_task).to be(mock_task) - end - end - - context "when registry has one middleware" do - before do - registry.register(mock_middleware1) - end - - it "calls the middleware with empty options by default" do - expect(mock_middleware1).to receive(:call).with(mock_task).and_yield - - registry.call!(mock_task, &test_block) - end - - it "yields the result when middleware calls the block" do - allow(mock_middleware1).to receive(:call).and_yield - - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(block_result) - end - end - - context "when middleware is registered with options" do - let(:options) { { timeout: 30, retry: true } } - - before do - registry.register(mock_middleware1, **options) - end - - it "passes options to the middleware" do - expect(mock_middleware1).to receive(:call).with(mock_task, **options).and_yield - - registry.call!(mock_task, &test_block) - end - end - - context "when registry has multiple middlewares" do - before do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - end - - it "calls middlewares in order" do - call_order = [] - - allow(mock_middleware1).to receive(:call) do |_task, &block| - call_order << :middleware1 - block.call - end - allow(mock_middleware2).to receive(:call) do |_task, &block| - call_order << :middleware2 - block.call - end - - registry.call!(mock_task) { call_order << :block } - - expect(call_order).to eq(%i[middleware1 middleware2 block]) - end - - it "passes the task through the middleware chain" do - expect(mock_middleware1).to receive(:call).with(mock_task).and_yield - expect(mock_middleware2).to receive(:call).with(mock_task).and_yield - - registry.call!(mock_task, &test_block) - end - - it "returns the final result from the block" do - allow(mock_middleware1).to receive(:call).and_yield - allow(mock_middleware2).to receive(:call).and_yield - - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(block_result) - end - end - - context "when middleware modifies the result" do - let(:middleware_result) { "modified_result" } - - before do - registry.register(mock_middleware1) - - allow(mock_middleware1).to receive(:call) do |_task, &block| - block.call - middleware_result - end - end - - it "returns the middleware's result" do - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(middleware_result) - end - end - - context "when middleware doesn't yield" do - before do - registry.register(mock_middleware1) - - allow(mock_middleware1).to receive(:call).and_return("middleware_stopped") - end - - it "stops execution and returns middleware result" do - block_called = false - result = registry.call!(mock_task) { block_called = true } - - expect(block_called).to be(false) - expect(result).to eq("middleware_stopped") - end - end - - context "when middleware raises an error" do - before do - registry.register(mock_middleware1) - - allow(mock_middleware1).to receive(:call).and_raise(StandardError, "middleware error") - end - - it "propagates the error" do - expect { registry.call!(mock_task, &test_block) }.to raise_error(StandardError, "middleware error") - end - end - end -end diff --git a/spec/cmdx/middlewares/correlate_spec.rb b/spec/cmdx/middlewares/correlate_spec.rb deleted file mode 100644 index 024185e3e..000000000 --- a/spec/cmdx/middlewares/correlate_spec.rb +++ /dev/null @@ -1,444 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Middlewares::Correlate, type: :unit do - subject(:correlate) { described_class } - - let(:task) { double("CMDx::Task", id: "task-123", result: result) } # rubocop:disable RSpec/VerifiedDoubles - let(:result) { instance_double(CMDx::Result, metadata: metadata) } - let(:metadata) { {} } - - before do - described_class.clear - end - - describe ".id" do - context "when no correlation ID is set" do - it "returns nil" do - expect(correlate.id).to be_nil - end - end - - context "when correlation ID is set" do - before { correlate.id = "test-correlation-id" } - - it "returns the correlation ID" do - expect(correlate.id).to eq("test-correlation-id") - end - end - end - - describe ".id=" do - it "sets the correlation ID in fiber or thread local storage" do - correlate.id = "new-correlation-id" - - expect(correlate.id).to eq("new-correlation-id") - end - - it "overwrites existing correlation ID" do - correlate.id = "first-id" - correlate.id = "second-id" - - expect(correlate.id).to eq("second-id") - end - - it "accepts nil values" do - correlate.id = "some-id" - correlate.id = nil - - expect(correlate.id).to be_nil - end - end - - describe ".clear" do - it "sets correlation ID to nil" do - correlate.id = "test-id" - - correlate.clear - - expect(correlate.id).to be_nil - end - - context "when no ID was set" do - it "remains nil" do - correlate.clear - - expect(correlate.id).to be_nil - end - end - end - - describe ".use" do - let(:new_id) { "temporary-id" } - let(:block_result) { "block executed" } - - context "when no existing ID is set" do - it "sets the new ID during block execution" do - called_with_id = nil - - correlate.use(new_id) do - called_with_id = correlate.id - end - - expect(called_with_id).to eq(new_id) - end - - it "clears the ID after block execution" do - correlate.use(new_id) { nil } - - expect(correlate.id).to be_nil - end - - it "returns the block result" do - result = correlate.use(new_id) { block_result } - - expect(result).to eq(block_result) - end - end - - context "when existing ID is set" do - let(:original_id) { "original-id" } - - before { correlate.id = original_id } - - it "temporarily replaces the ID during block execution" do - called_with_id = nil - - correlate.use(new_id) do - called_with_id = correlate.id - end - - expect(called_with_id).to eq(new_id) - end - - it "restores the original ID after block execution" do - correlate.use(new_id) { nil } - - expect(correlate.id).to eq(original_id) - end - - it "restores original ID even when block raises error" do - expect do - correlate.use(new_id) { raise StandardError, "test error" } - end.to raise_error(StandardError, "test error") - - expect(correlate.id).to eq(original_id) - end - end - - context "when block raises an error" do - it "ensures cleanup and re-raises the error" do - expect do - correlate.use(new_id) { raise ArgumentError, "block error" } - end.to raise_error(ArgumentError, "block error") - end - end - end - - describe ".call" do - let(:block_result) { "task executed" } - let(:test_block) { proc { block_result } } - - before do - allow(CMDx::Identifier).to receive(:generate).and_return("generated-uuid") - end - - context "when no id option is provided" do - context "with no existing correlation ID" do - it "generates a new correlation ID using Identifier.generate" do - expect(CMDx::Identifier).to receive(:generate) - - correlate.call(task, &test_block) - end - - it "sets the generated ID in metadata" do - correlate.call(task, &test_block) - - expect(metadata[:correlation_id]).to eq("generated-uuid") - end - end - - context "with existing correlation ID" do - before { correlate.id = "existing-id" } - - it "uses the existing correlation ID" do - expect(CMDx::Identifier).not_to receive(:generate) - - correlate.call(task, &test_block) - - expect(metadata[:correlation_id]).to eq("existing-id") - end - end - end - - context "when id option is a Symbol" do - let(:method_name) { :id } - - it "calls the method on the task" do - expect(task).to receive(method_name) - - correlate.call(task, id: method_name, &test_block) - end - - it "uses the method result as correlation ID" do - correlate.call(task, id: method_name, &test_block) - - expect(metadata[:correlation_id]).to eq("task-123") - end - end - - context "when id option is a Proc" do - let(:id_proc) { proc { "proc-generated-id" } } - - it "uses the proc result as correlation ID" do - correlate.call(task, id: id_proc, &test_block) - - expect(metadata[:correlation_id]).to eq("proc-generated-id") - end - end - - context "when id option responds to call" do - let(:callable) { instance_double("MockCallable", call: "callable-id") } - - it "calls the callable with the task" do - expect(callable).to receive(:call).with(task) - - correlate.call(task, id: callable, &test_block) - end - - it "uses the callable result as correlation ID" do - correlate.call(task, id: callable, &test_block) - - expect(metadata[:correlation_id]).to eq("callable-id") - end - end - - context "when id option is a string value" do - let(:static_id) { "static-correlation-id" } - - it "uses the static value as correlation ID" do - expect(CMDx::Identifier).not_to receive(:generate) - - correlate.call(task, id: static_id, &test_block) - - expect(metadata[:correlation_id]).to eq(static_id) - end - end - - context "when id option is nil" do - context "with no existing correlation ID" do - it "generates a new correlation ID" do - expect(CMDx::Identifier).to receive(:generate) - - correlate.call(task, id: nil, &test_block) - - expect(metadata[:correlation_id]).to eq("generated-uuid") - end - end - - context "with existing correlation ID" do - before { correlate.id = "existing-id" } - - it "uses the existing correlation ID" do - expect(CMDx::Identifier).not_to receive(:generate) - - correlate.call(task, id: nil, &test_block) - - expect(metadata[:correlation_id]).to eq("existing-id") - end - end - end - - context "when id option is false" do - it "generates a new correlation ID when falsy value provided" do - expect(CMDx::Identifier).to receive(:generate) - - correlate.call(task, id: false, &test_block) - - expect(metadata[:correlation_id]).to eq("generated-uuid") - end - end - - it "executes the block with the correlation ID set" do - called_with_id = nil - - correlate.call(task, id: "test-id") do - called_with_id = correlate.id - end - - expect(called_with_id).to eq("test-id") - end - - it "returns the block result" do - result = correlate.call(task, id: "test-id", &test_block) - - expect(result).to eq(block_result) - end - - it "restores the original correlation ID after execution" do - original_id = "original-id" - correlate.id = original_id - - correlate.call(task, id: "temporary-id", &test_block) - - expect(correlate.id).to eq(original_id) - end - - context "when block raises an error" do - let(:error_block) { proc { raise StandardError, "execution error" } } - - it "ensures cleanup and re-raises the error" do - original_id = "original-id" - correlate.id = original_id - - expect do - correlate.call(task, id: "temp-id", &error_block) - end.to raise_error(StandardError, "execution error") - - expect(correlate.id).to eq(original_id) - end - - it "still sets the correlation ID in metadata before cleanup" do - correlate.call(task, id: "error-id") do - task.result.metadata[:correlation_id] = "error-id" - raise StandardError, "execution error" - end - rescue StandardError - expect(metadata[:correlation_id]).to eq("error-id") - end - end - - context "with conditional execution using 'if'" do - before do - allow(task).to receive(:should_correlate?).and_return(true) - end - - it "applies correlation when 'if' condition is true" do - correlate.call(task, id: "test-id", if: :should_correlate?, &test_block) - - expect(metadata[:correlation_id]).to eq("test-id") - end - - it "skips correlation when 'if' condition is false" do - allow(task).to receive(:should_correlate?).and_return(false) - - result = correlate.call(task, id: "test-id", if: :should_correlate?, &test_block) - - expect(metadata[:correlation_id]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with conditional execution using 'unless'" do - before do - allow(task).to receive(:skip_correlation?).and_return(false) - end - - it "applies correlation when 'unless' condition is false" do - correlate.call(task, id: "test-id", unless: :skip_correlation?, &test_block) - - expect(metadata[:correlation_id]).to eq("test-id") - end - - it "skips correlation when 'unless' condition is true" do - allow(task).to receive(:skip_correlation?).and_return(true) - - result = correlate.call(task, id: "test-id", unless: :skip_correlation?, &test_block) - - expect(metadata[:correlation_id]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with additional options" do - it "ignores unknown options" do - expect do - correlate.call(task, id: "test-id", unknown_option: "value", &test_block) - end.not_to raise_error - end - end - end - - describe "thread safety" do - after { described_class.clear } - - it "maintains separate correlation IDs per thread" do - thread1_id = nil - thread2_id = nil - - thread1 = Thread.new do - described_class.id = "thread-1-id" - thread1_id = described_class.id - end - - thread2 = Thread.new do - described_class.id = "thread-2-id" - thread2_id = described_class.id - end - - thread1.join - thread2.join - - expect(thread1_id).to eq("thread-1-id") - expect(thread2_id).to eq("thread-2-id") - expect(thread1_id).not_to eq(thread2_id) - end - - it "does not interfere with main thread correlation ID" do - described_class.id = "main-thread-id" - - thread_id = nil - thread = Thread.new do - described_class.id = "child-thread-id" - thread_id = described_class.id - end - thread.join - - expect(described_class.id).to eq("main-thread-id") - expect(thread_id).to eq("child-thread-id") - end - end - - describe "fiber safety" do - after { described_class.clear } - - it "maintains separate correlation IDs per fiber" do - fiber1_id = nil - fiber2_id = nil - - fiber1 = Fiber.new do - described_class.id = "fiber-1-id" - fiber1_id = described_class.id - end - - fiber2 = Fiber.new do - described_class.id = "fiber-2-id" - fiber2_id = described_class.id - end - - fiber1.resume - fiber2.resume - - expect(fiber1_id).to eq("fiber-1-id") - expect(fiber2_id).to eq("fiber-2-id") - expect(fiber1_id).not_to eq(fiber2_id) - end - - it "does not interfere with main fiber correlation ID" do - described_class.id = "main-fiber-id" - - fiber_id = nil - fiber = Fiber.new do - described_class.id = "child-fiber-id" - fiber_id = described_class.id - end - fiber.resume - - expect(described_class.id).to eq("main-fiber-id") - expect(fiber_id).to eq("child-fiber-id") - end - end -end diff --git a/spec/cmdx/middlewares/runtime_spec.rb b/spec/cmdx/middlewares/runtime_spec.rb deleted file mode 100644 index 05de432f3..000000000 --- a/spec/cmdx/middlewares/runtime_spec.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Middlewares::Runtime, type: :unit do - subject(:runtime) { described_class } - - let(:task) { double("CMDx::Task", result: result) } # rubocop:disable RSpec/VerifiedDoubles - let(:result) { instance_double(CMDx::Result, metadata: metadata) } - let(:metadata) { {} } - let(:block_result) { "task executed" } - let(:test_block) { proc { block_result } } - - describe ".call" do - before do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(100, 150) # Start time: 100ms, end time: 150ms - end - - it "measures runtime and stores it in metadata" do - result = runtime.call(task, &test_block) - - expect(metadata[:runtime]).to eq(50) # 150 - 100 = 50ms - expect(result).to eq(block_result) - end - - it "calls monotonic_time twice to measure duration" do - expect(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .twice - - runtime.call(task, &test_block) - end - - it "returns the block result" do - result = runtime.call(task, &test_block) - - expect(result).to eq(block_result) - end - - context "when block execution takes no measurable time" do - before do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(100, 100) # Same time - end - - it "stores zero runtime" do - runtime.call(task, &test_block) - - expect(metadata[:runtime]).to eq(0) - end - end - - context "when block raises an error" do - let(:error_block) { proc { raise StandardError, "test error" } } - - before do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(100) - end - - it "re-raises the error without storing runtime" do - expect do - runtime.call(task, &error_block) - end.to raise_error(StandardError, "test error") - - expect(metadata[:runtime]).to be_nil - end - - it "only calls monotonic_time once before the error" do - expect(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .once - - begin - runtime.call(task, &error_block) - rescue StandardError - # Expected to raise - end - end - end - - context "with conditional execution using 'if'" do - before do - allow(task).to receive(:should_measure_runtime?).and_return(true) - end - - it "measures runtime when 'if' condition is true" do - result = runtime.call(task, if: :should_measure_runtime?, &test_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to eq(block_result) - end - - it "skips runtime measurement when 'if' condition is false" do - allow(task).to receive(:should_measure_runtime?).and_return(false) - - result = runtime.call(task, if: :should_measure_runtime?, &test_block) - - expect(metadata[:runtime]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with conditional execution using 'unless'" do - before do - allow(task).to receive(:skip_runtime_measurement?).and_return(false) - end - - it "measures runtime when 'unless' condition is false" do - result = runtime.call(task, unless: :skip_runtime_measurement?, &test_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to eq(block_result) - end - - it "skips runtime measurement when 'unless' condition is true" do - allow(task).to receive(:skip_runtime_measurement?).and_return(true) - - result = runtime.call(task, unless: :skip_runtime_measurement?, &test_block) - - expect(metadata[:runtime]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with additional options" do - it "ignores unknown options" do - expect do - runtime.call(task, unknown_option: "value", &test_block) - end.not_to raise_error - - expect(metadata[:runtime]).to eq(50) - end - end - - context "when block returns nil" do - let(:nil_block) { proc {} } - - it "still measures runtime correctly" do - result = runtime.call(task, &nil_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to be_nil - end - end - - context "when block returns false" do - let(:false_block) { proc { false } } - - it "still measures runtime correctly" do - result = runtime.call(task, &false_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to be(false) - end - end - end - - describe "#monotonic_time" do - it "uses Process.clock_gettime with CLOCK_MONOTONIC in milliseconds" do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(123_456) - - time = runtime.send(:monotonic_time) - - expect(time).to eq(123_456) - end - end -end diff --git a/spec/cmdx/middlewares/timeout_spec.rb b/spec/cmdx/middlewares/timeout_spec.rb deleted file mode 100644 index 2f5543479..000000000 --- a/spec/cmdx/middlewares/timeout_spec.rb +++ /dev/null @@ -1,272 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Middlewares::Timeout, type: :unit do - subject(:timeout_middleware) { described_class } - - let(:task) { double("CMDx::Task", result: result, resolver: resolver) } # rubocop:disable RSpec/VerifiedDoubles - let(:result) { instance_double(CMDx::Result) } - let(:resolver) { instance_double(CMDx::Resolver) } - let(:block_result) { "block executed" } - let(:test_block) { proc { block_result } } - - before do - allow(resolver).to receive(:fail!) - end - - describe ".call" do - context "when seconds option is a Numeric" do - it "uses the numeric value as timeout limit coerced to float" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, &test_block) - - expect(result).to eq(block_result) - end - - it "handles float values" do - expect(Timeout).to receive(:timeout).with(2.5, CMDx::TimeoutError, "execution exceeded 2.5 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: 2.5, &test_block) - end - - it "yields directly when timeout is zero" do - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: 0, &test_block) - - expect(result).to eq(block_result) - end - - it "yields directly when timeout is negative" do - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: -1, &test_block) - - expect(result).to eq(block_result) - end - end - - context "when seconds option is a Symbol" do - let(:method_name) { :timeout_value } - - before do - allow(task).to receive(:send).with(method_name).and_return(10) - end - - it "calls the method on the task and uses the result as float" do - allow(task).to receive(:send).with(method_name).and_return(10) - - expect(Timeout).to receive(:timeout).with(10.0, CMDx::TimeoutError, "execution exceeded 10.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: method_name, &test_block) - end - - it "raises ArgumentError when method returns non-numeric value" do - allow(task).to receive(:send).with(method_name).and_return("invalid") - - expect do - timeout_middleware.call(task, seconds: method_name, &test_block) - end.to raise_error(ArgumentError) - end - end - - context "when seconds option is a Proc" do - let(:timeout_proc) { proc { 15 } } - - before do - allow(task).to receive(:instance_eval).and_yield.and_return(15) - end - - it "evaluates the proc in task context and uses the result as float" do - expect(task).to receive(:instance_eval).and_yield.and_return(15) - - expect(Timeout).to receive(:timeout).with(15.0, CMDx::TimeoutError, "execution exceeded 15.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: timeout_proc, &test_block) - end - - it "raises TypeError when proc returns nil" do - expect(task).to receive(:instance_eval).and_yield.and_return(nil) - - expect do - timeout_middleware.call(task, seconds: timeout_proc, &test_block) - end.to raise_error(TypeError) - end - end - - context "when seconds option responds to call" do - let(:callable) { instance_double("MockCallable", call: 20) } - - it "calls the callable with the task and uses the result as float" do - allow(callable).to receive(:call).with(task).and_return(20) - - expect(Timeout).to receive(:timeout).with(20.0, CMDx::TimeoutError, "execution exceeded 20.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: callable, &test_block) - end - - it "yields directly when callable returns zero" do - allow(callable).to receive(:call).with(task).and_return(0) - - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: callable, &test_block) - - expect(result).to eq(block_result) - end - end - - context "when seconds option is nil" do - it "uses the default timeout limit as float" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: nil, &test_block) - end - end - - context "when seconds option is false" do - it "uses the default timeout limit as float" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: false, &test_block) - end - end - - context "when no seconds option is provided" do - it "uses the default timeout limit as float" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, &test_block) - end - end - - context "when seconds option is an unsupported type" do - it "uses the default timeout limit for string values" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: "invalid", &test_block) - end - - it "uses the default timeout limit for array values" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: [1, 2, 3], &test_block) - end - end - - context "when block execution succeeds" do - it "returns the block result" do - allow(Timeout).to receive(:timeout).and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, &test_block) - - expect(result).to eq(block_result) - end - - it "returns nil when block returns nil" do - nil_block = proc {} - allow(Timeout).to receive(:timeout).and_yield.and_return(nil) - - result = timeout_middleware.call(task, seconds: 5, &nil_block) - - expect(result).to be_nil - end - - it "returns false when block returns false" do - false_block = proc { false } - allow(Timeout).to receive(:timeout).and_yield.and_return(false) - - result = timeout_middleware.call(task, seconds: 5, &false_block) - - expect(result).to be(false) - end - end - - context "when block raises other errors" do - let(:standard_error) { StandardError.new("unexpected error") } - let(:error_block) { proc { raise standard_error } } - - it "re-raises non-timeout errors without calling fail!" do - expect(Timeout).to receive(:timeout).and_yield.and_raise(standard_error) - - expect(resolver).not_to receive(:fail!) - - expect do - timeout_middleware.call(task, seconds: 5, &error_block) - end.to raise_error(StandardError, "unexpected error") - end - end - - context "with conditional execution using 'if'" do - before do - allow(task).to receive(:should_timeout?).and_return(true) - allow(Timeout).to receive(:timeout) - end - - it "executes timeout when 'if' condition is true" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, if: :should_timeout?, &test_block) - - expect(result).to eq(block_result) - end - - it "skips timeout when 'if' condition is false" do - allow(task).to receive(:should_timeout?).and_return(false) - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: 5, if: :should_timeout?, &test_block) - - expect(result).to eq(block_result) - end - end - - context "with conditional execution using 'unless'" do - before do - allow(task).to receive(:skip_timeout?).and_return(false) - allow(Timeout).to receive(:timeout) - end - - it "executes timeout when 'unless' condition is false" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, unless: :skip_timeout?, &test_block) - - expect(result).to eq(block_result) - end - - it "skips timeout when 'unless' condition is true" do - allow(task).to receive(:skip_timeout?).and_return(true) - - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: 5, unless: :skip_timeout?, &test_block) - - expect(result).to eq(block_result) - end - end - - context "with additional options" do - it "ignores unknown options" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - expect do - timeout_middleware.call(task, seconds: 5, unknown_option: "value", &test_block) - end.not_to raise_error - end - end - end - - describe "CMDx::TimeoutError" do - it "is a subclass of Interrupt" do - expect(CMDx::TimeoutError.superclass).to eq(Interrupt) - end - - it "can be instantiated with a message" do - error = CMDx::TimeoutError.new("test timeout") - expect(error.message).to eq("test timeout") - end - end -end diff --git a/spec/cmdx/parallelizer_spec.rb b/spec/cmdx/parallelizer_spec.rb deleted file mode 100644 index 7c4799e64..000000000 --- a/spec/cmdx/parallelizer_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Parallelizer, type: :unit do - describe ".call" do - it "processes all items and returns results in order" do - results = described_class.call([3, 1, 2]) { |n| n * 10 } - - expect(results).to eq([30, 10, 20]) - end - - it "runs items concurrently across threads" do - mutex = Mutex.new - thread_ids = [] - - described_class.call(%w[a b c]) do |_item| - mutex.synchronize { thread_ids << Thread.current.object_id } - end - - expect(thread_ids.size).to eq(3) - end - - context "with pool_size smaller than item count" do - it "limits the number of concurrent threads" do - mutex = Mutex.new - thread_ids = [] - - described_class.call([1, 2, 3, 4], pool_size: 2) do |item| - mutex.synchronize { thread_ids << Thread.current.object_id } - item - end - - expect(thread_ids.uniq.size).to be <= 2 - end - - it "still processes all items" do - results = described_class.call([1, 2, 3, 4], pool_size: 2) { |n| n + 1 } - - expect(results).to eq([2, 3, 4, 5]) - end - end - - context "with an empty items array" do - it "returns an empty array" do - results = described_class.call([]) { |n| n } - - expect(results).to eq([]) - end - end - end - - describe "#call" do - it "yields each item to the block" do - yielded = [] - mutex = Mutex.new - - described_class.new(%w[x y z]).call do |item| - mutex.synchronize { yielded << item } - end - - expect(yielded).to match_array(%w[x y z]) - end - end -end diff --git a/spec/cmdx/pipeline_spec.rb b/spec/cmdx/pipeline_spec.rb deleted file mode 100644 index 15e5c8e87..000000000 --- a/spec/cmdx/pipeline_spec.rb +++ /dev/null @@ -1,389 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Pipeline, type: :unit do - let(:pipeline) { described_class.new(workflow) } - let(:workflow_class) { class_double("WorkflowClass") } - let(:workflow) { instance_double("WorkflowInstance", class: workflow_class) } - - describe ".execute" do - subject(:execute) { described_class.execute(workflow) } - - it "creates a new instance and executes it" do - expect(described_class).to receive(:new).with(workflow).and_return(pipeline) - expect(pipeline).to receive(:execute) - execute - end - end - - describe "#initialize" do - it "sets the workflow" do - expect(pipeline.workflow).to eq(workflow) - end - end - - describe "#execute" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:group_options) { {} } - let(:breakpoints) { [] } - - before do - allow(execution_group).to receive_messages(options: group_options, tasks: []) - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - allow(workflow_class).to receive_messages(pipeline: [execution_group], settings: mock_settings(workflow_breakpoints: [])) - end - - it "iterates through workflow pipeline groups" do - expect(workflow_class).to receive(:pipeline).and_return([execution_group]) - pipeline.execute - end - - context "when condition evaluates to true" do - it "executes the group tasks" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, []) - pipeline.execute - end - - context "with breakpoints in group options" do - let(:group_options) { { breakpoints: %w[step1 step2] } } - - it "uses group breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, %w[step1 step2]) - pipeline.execute - end - end - - context "with breakpoints in workflow settings" do - before do - allow(workflow_class).to receive(:settings).and_return(mock_settings(breakpoints: ["workflow_step"])) - end - - it "uses workflow breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, ["workflow_step"]) - pipeline.execute - end - end - - context "with breakpoints in workflow_breakpoints setting" do - before do - allow(workflow_class).to receive(:settings).and_return(mock_settings(workflow_breakpoints: ["wf_step"])) - end - - it "uses workflow_breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, ["wf_step"]) - pipeline.execute - end - end - - context "with multiple breakpoint sources" do - let(:group_options) { { breakpoints: ["group_step"] } } - - before do - allow(workflow_class).to receive(:settings).and_return( - mock_settings(breakpoints: ["workflow_step"], workflow_breakpoints: ["wf_step"]) - ) - end - - it "prioritizes group breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, ["group_step"]) - pipeline.execute - end - end - - context "with string and symbol breakpoints" do - let(:group_options) { { breakpoints: ["step1", :step2, "step3"] } } - - it "converts all breakpoints to strings and removes duplicates" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, %w[step1 step2 step3]) - pipeline.execute - end - end - end - - context "when condition evaluates to false" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(false) - end - - it "skips the group tasks" do - expect(pipeline).not_to receive(:execute_group_tasks) - pipeline.execute - end - end - - context "with multiple execution groups" do - let(:execution_group2) { instance_double("ExecutionGroup2") } - let(:group_options2) { {} } - - before do - allow(workflow_class).to receive(:pipeline).and_return([execution_group, execution_group2]) - allow(execution_group2).to receive_messages(options: group_options2, tasks: []) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(workflow, group_options, workflow).and_return(true) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(workflow, group_options2, workflow).and_return(true) - end - - it "processes each group independently" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, []) - expect(pipeline).to receive(:execute_group_tasks).with(execution_group2, []) - pipeline.execute - end - end - end - - describe "#execute_group_tasks" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:breakpoints) { [] } - - context "when strategy is nil" do - before do - allow(execution_group).to receive(:options).and_return({}) - end - - it "calls execute_tasks_in_sequence" do - expect(pipeline).to receive(:execute_tasks_in_sequence).with(execution_group, breakpoints) - pipeline.send(:execute_group_tasks, execution_group, breakpoints) - end - end - - context "when strategy is sequential" do - before do - allow(execution_group).to receive(:options).and_return({ strategy: :sequential }) - end - - it "calls execute_tasks_in_sequence" do - expect(pipeline).to receive(:execute_tasks_in_sequence).with(execution_group, breakpoints) - pipeline.send(:execute_group_tasks, execution_group, breakpoints) - end - end - - context "when strategy is parallel" do - before do - allow(execution_group).to receive(:options).and_return({ strategy: :parallel }) - end - - it "calls execute_tasks_in_parallel" do - expect(pipeline).to receive(:execute_tasks_in_parallel).with(execution_group, breakpoints) - pipeline.send(:execute_group_tasks, execution_group, breakpoints) - end - end - - context "when strategy is unknown" do - before do - allow(execution_group).to receive(:options).and_return({ strategy: :unknown }) - end - - it "raises an error" do - expect { pipeline.send(:execute_group_tasks, execution_group, breakpoints) } - .to raise_error("unknown execution strategy :unknown") - end - end - end - - describe "#execute_tasks_in_sequence" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:tasks) { [task1, task2, task3] } - let(:task1) { instance_double("Task1") } - let(:task2) { instance_double("Task2") } - let(:task3) { instance_double("Task3") } - let(:breakpoints) { [] } - let(:context) { instance_double("Context") } - let(:result1) { instance_double("Result1") } - let(:result2) { instance_double("Result2") } - let(:result3) { instance_double("Result3") } - - before do - allow(workflow).to receive(:context).and_return(context) - allow(execution_group).to receive(:tasks).and_return(tasks) - allow(task1).to receive(:execute).with(context).and_return(result1) - allow(task2).to receive(:execute).with(context).and_return(result2) - allow(task3).to receive(:execute).with(context).and_return(result3) - allow(result1).to receive(:status).and_return("success") - allow(result2).to receive(:status).and_return("success") - allow(result3).to receive(:status).and_return("success") - allow(workflow).to receive(:throw!) - end - - it "executes all tasks in sequence" do - expect(task1).to receive(:execute).with(context).and_return(result1) - expect(task2).to receive(:execute).with(context).and_return(result2) - expect(task3).to receive(:execute).with(context).and_return(result3) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - - context "when breakpoint is triggered on first task" do - let(:breakpoints) { ["success"] } - - before do - allow(result1).to receive(:status).and_return("success") - end - - it "throws on first task and continues executing remaining tasks" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - expect(task2).to receive(:execute).with(context).and_return(result2) - expect(task3).to receive(:execute).with(context).and_return(result3) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - - context "when breakpoint is triggered on second task" do - let(:breakpoints) { ["success"] } - - before do - allow(result1).to receive(:status).and_return("failure") - allow(result2).to receive(:status).and_return("success") - end - - it "executes first task, then throws on second and continues with remaining tasks" do - expect(workflow).to receive(:throw!).with(result2) - expect(task1).to receive(:execute).with(context).and_return(result1) - expect(task2).to receive(:execute).with(context).and_return(result2) - expect(task3).to receive(:execute).with(context).and_return(result3) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - - context "with string breakpoints" do - let(:breakpoints) { ["halt"] } - - before do - allow(result1).to receive(:status).and_return("halt") - end - - it "matches string breakpoints correctly" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - - context "with symbol breakpoints" do - let(:breakpoints) { ["halt"] } - - before do - allow(result1).to receive(:status).and_return("halt") - end - - it "matches symbol breakpoints correctly" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - - context "with mixed breakpoint types" do - let(:breakpoints) { %w[success failure] } - - before do - allow(result1).to receive(:status).and_return("success") - end - - it "matches both string breakpoints" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - end - - describe "#execute_tasks_in_parallel" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:tasks) { [task1, task2, task3] } - let(:task1) { instance_double("Task1") } - let(:task2) { instance_double("Task2") } - let(:task3) { instance_double("Task3") } - let(:breakpoints) { [] } - let(:context) { CMDx::Context.new(user_id: 1) } - let(:result1) { instance_double("Result1") } - let(:result2) { instance_double("Result2") } - let(:result3) { instance_double("Result3") } - let(:chain) { instance_double("Chain") } - let(:result_hash) { { status: "failed" } } - - before do - allow(workflow).to receive_messages(context: context, chain: chain) - allow(execution_group).to receive_messages(tasks: tasks, options: {}) - allow(task1).to receive(:execute).and_return(result1) - allow(task2).to receive(:execute).and_return(result2) - allow(task3).to receive(:execute).and_return(result3) - allow(result1).to receive_messages(status: "success", to_h: result_hash) - allow(result2).to receive_messages(status: "success", to_h: result_hash) - allow(result3).to receive_messages(status: "success", to_h: result_hash) - allow(CMDx::Chain).to receive(:current=) - end - - it "creates context snapshots for each task" do - expect(task1).to receive(:execute) do |ctx| - expect(ctx).to be_a(CMDx::Context) - expect(ctx.to_h).to eq(user_id: 1) - expect(ctx).not_to equal(context) - result1 - end - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - - it "executes all tasks via Parallelizer" do - expect(task1).to receive(:execute).and_return(result1) - expect(task2).to receive(:execute).and_return(result2) - expect(task3).to receive(:execute).and_return(result3) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - - it "merges context snapshots back into workflow context" do - expect(workflow.context).to receive(:merge!).exactly(3).times - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - - it "sets Chain.current for each thread" do - expect(CMDx::Chain).to receive(:current=).with(chain).at_least(3).times - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - - context "when breakpoint is triggered" do - let(:breakpoints) { ["failed"] } - - before do - allow(result2).to receive(:status).and_return("failed") - allow(workflow).to receive(:failed!) - end - - it "calls the faulted status method on the workflow" do - expect(workflow).to receive(:failed!).with( - CMDx::Locale.t("cmdx.reasons.unspecified"), - source: :parallel, - faults: [result_hash] - ) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - end - - context "when multiple breakpoints are triggered" do - let(:breakpoints) { %w[failed skipped] } - - before do - allow(result1).to receive(:status).and_return("skipped") - allow(result3).to receive(:status).and_return("failed") - allow(workflow).to receive(:failed!) - end - - it "uses the last faulted result status and includes all faulted results" do - expect(workflow).to receive(:failed!).with( - CMDx::Locale.t("cmdx.reasons.unspecified"), - source: :parallel, - faults: [result_hash, result_hash] - ) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - end - - context "when no breakpoints are triggered" do - let(:breakpoints) { ["failed"] } - - it "does not call any fault method on the workflow" do - expect(workflow).not_to receive(:failed!) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - end - end -end diff --git a/spec/cmdx/resolver_spec.rb b/spec/cmdx/resolver_spec.rb deleted file mode 100644 index bcaf7bdc1..000000000 --- a/spec/cmdx/resolver_spec.rb +++ /dev/null @@ -1,316 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Resolver, type: :unit do - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - let(:result) { task.result } - let(:resolver) { task.resolver } - - describe "#initialize" do - context "with valid result" do - it "initializes with correct defaults" do - expect(resolver.result).to eq(result) - expect(result.strict?).to be(true) - end - end - - context "with invalid result" do - it "raises TypeError when result is not a CMDx::Result" do - expect { described_class.new("not a result") }.to raise_error(TypeError, "must be a CMDx::Result") - end - end - end - - describe "#success!" do - context "when successful" do - it "sets the reason" do - catch(:cmdx_halt) { resolver.success!("Created 42 records") } - - expect(result.status).to eq(CMDx::Result::SUCCESS) - expect(result.reason).to eq("Created 42 records") - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - catch(:cmdx_halt) { resolver.success!("Imported", rows: 100) } - - expect(result.reason).to eq("Imported") - expect(result.metadata).to eq({ rows: 100 }) - end - - it "allows nil reason" do - catch(:cmdx_halt) { resolver.success! } - - expect(result.reason).to be_nil - end - - it "does not change state or status" do - original_state = result.state - original_status = result.status - catch(:cmdx_halt) { resolver.success!("note") } - - expect(result.state).to eq(original_state) - expect(result.status).to eq(original_status) - end - - it "throws :cmdx_halt by default" do - expect { resolver.success!("done") }.to throw_symbol(:cmdx_halt) - end - - it "does not throw when halt is false" do - expect { resolver.success!("done", halt: false) }.not_to throw_symbol(:cmdx_halt) - end - end - - context "when not successful" do - it "raises error when skipped" do - resolver.skip!("test", halt: false) - - expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) - end - - it "raises error when failed" do - resolver.fail!("test", halt: false) - - expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) - end - end - end - - describe "#skip!" do - context "when successful" do - it "transitions to skipped status" do - resolver.skip!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::SKIPPED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - resolver.skip!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - resolver.skip!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - resolver.skip!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { resolver.skip!("test reason") }.to raise_error(CMDx::SkipFault) - end - - it "does not call halt! when halt: false" do - expect { resolver.skip!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already skipped" do - it "returns early without changes" do - resolver.skip!("first reason", halt: false) - original_reason = result.reason - resolver.skip!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to skip from failed" do - resolver.fail!("test reason", halt: false) - - expect { resolver.skip!("another reason", halt: false) }.to raise_error(/can only transition to skipped from success/) - end - end - end - - describe "#fail!" do - context "when successful" do - it "transitions to failed status" do - resolver.fail!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::FAILED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - resolver.fail!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - resolver.fail!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - resolver.fail!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { resolver.fail!("test reason") }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { resolver.fail!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already failed" do - it "returns early without changes" do - resolver.fail!("first reason", halt: false) - original_reason = result.reason - resolver.fail!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to fail from skipped" do - resolver.skip!("test reason", halt: false) - - expect { resolver.fail!("another reason", halt: false) }.to raise_error(/can only transition to failed from success/) - end - end - end - - describe "#halt!" do - context "when successful" do - it "returns early without raising" do - expect { resolver.halt! }.not_to raise_error - end - end - - context "when skipped" do - it "raises SkipFault" do - resolver.skip!("test reason", halt: false) - - expect { resolver.halt! }.to raise_error(CMDx::SkipFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - - it "sets proper backtrace" do - resolver.skip!("test reason", halt: false) - - begin - resolver.halt! - rescue CMDx::SkipFault => e - expect(e.backtrace).to be_an(Array) - expect(e.backtrace).not_to be_empty - end - end - end - - context "when failed" do - it "raises FailFault" do - resolver.fail!("test reason", halt: false) - - expect { resolver.halt! }.to raise_error(CMDx::FailFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - end - end - - describe "#throw!" do - let(:other_task) { create_failing_task.new } - let(:other_result) { other_task.result } - - before do - other_task.resolver.fail!("source failure", halt: false, foo: "bar") - end - - context "with valid result" do - it "copies state and status from other result" do - resolver.throw!(other_result, halt: false) - - expect(result.state).to eq(other_result.state) - expect(result.status).to eq(other_result.status) - expect(result.reason).to eq(other_result.reason) - end - - it "merges metadata" do - resolver.throw!(other_result, halt: false, baz: "qux") - - expect(result.metadata).to eq({ foo: "bar", baz: "qux" }) - end - - it "uses provided cause over other result's cause" do - custom_cause = StandardError.new("custom") - - resolver.throw!(other_result, halt: false, cause: custom_cause) - expect(result.cause).to eq(custom_cause) - end - - it "uses other result's cause when none provided" do - other_cause = StandardError.new("other") - other_result.instance_variable_set(:@cause, other_cause) - resolver.throw!(other_result, halt: false) - - expect(result.cause).to eq(other_cause) - end - - it "calls halt! by default" do - expect { resolver.throw!(other_result) }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { resolver.throw!(other_result, halt: false) }.not_to raise_error - end - end - - context "with invalid result" do - it "raises TypeError when not a CMDx::Result" do - expect { resolver.throw!("not a result", halt: false) }.to raise_error(TypeError, "must be a CMDx::Result") - end - end - end - - describe "#strict?" do - it "returns true by default" do - expect(result.strict?).to be(true) - end - - it "returns false when strict is false via fail!" do - resolver.fail!("test reason", halt: false, strict: false) - - expect(result.strict?).to be(false) - end - - it "returns false when strict is false via skip!" do - resolver.skip!("test reason", halt: false, strict: false) - - expect(result.strict?).to be(false) - end - end -end diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb deleted file mode 100644 index e06438848..000000000 --- a/spec/cmdx/result_spec.rb +++ /dev/null @@ -1,851 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Result, type: :unit do - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - let(:result) { task.result } - let(:resolver) { task.resolver } - - describe "#initialize" do - context "with valid task" do - it "initializes with correct defaults" do - expect(result.task).to eq(task) - expect(result.state).to eq(CMDx::Result::INITIALIZED) - expect(result.status).to eq(CMDx::Result::SUCCESS) - expect(result.metadata).to eq({}) - expect(result.reason).to be_nil - expect(result.cause).to be_nil - end - - it "delegates context and chain to task" do - expect(result.context).to eq(task.context) - expect(result.chain).to eq(task.chain) - end - end - - context "with invalid task" do - it "raises TypeError when task is not a CMDx::Task" do - expect { described_class.new("not a task") }.to raise_error(TypeError, "must be a CMDx::Task") - end - end - end - - describe "state predicates" do - describe "#initialized?" do - it "returns true when state is initialized" do - expect(result.initialized?).to be(true) - end - - it "returns false when state is not initialized" do - resolver.executing! - - expect(result.initialized?).to be(false) - end - end - - describe "#executing?" do - it "returns false when state is initialized" do - expect(result.executing?).to be(false) - end - - it "returns true when state is executing" do - resolver.executing! - - expect(result.executing?).to be(true) - end - end - - describe "#complete?" do - it "returns false when state is not complete" do - expect(result.complete?).to be(false) - end - - it "returns true when state is complete" do - resolver.executing! - resolver.complete! - - expect(result.complete?).to be(true) - end - end - - describe "#interrupted?" do - it "returns false when state is not interrupted" do - expect(result.interrupted?).to be(false) - end - - it "returns true when state is interrupted" do - resolver.executing! - resolver.interrupt! - - expect(result.interrupted?).to be(true) - end - end - end - - describe "state transitions" do - describe "#executing!" do - context "when initialized" do - it "transitions to executing state" do - resolver.executing! - - expect(result.state).to eq(CMDx::Result::EXECUTING) - end - - it "returns early if already executing" do - resolver.executing! - initial_state = result.state - resolver.executing! - - expect(result.state).to eq(initial_state) - end - end - - context "when not initialized" do - it "raises error when trying to transition from complete" do - resolver.executing! - resolver.complete! - - expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) - end - - it "raises error when trying to transition from interrupted" do - resolver.executing! - resolver.interrupt! - - expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) - end - end - end - - describe "#complete!" do - context "when executing" do - before { resolver.executing! } - - it "transitions to complete state" do - resolver.complete! - - expect(result.state).to eq(CMDx::Result::COMPLETE) - end - - it "returns early if already complete" do - resolver.complete! - initial_state = result.state - resolver.complete! - - expect(result.state).to eq(initial_state) - end - end - - context "when not executing" do - it "raises error when trying to transition from initialized" do - expect { resolver.complete! }.to raise_error(/can only transition to complete from executing/) - end - end - end - - describe "#interrupt!" do - context "when not complete" do - it "transitions to interrupted from initialized" do - resolver.interrupt! - - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - end - - it "transitions to interrupted from executing" do - resolver.executing! - resolver.interrupt! - - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - end - - it "returns early if already interrupted" do - resolver.interrupt! - initial_state = result.state - resolver.interrupt! - - expect(result.state).to eq(initial_state) - end - end - - context "when complete" do - it "raises error when trying to transition from complete" do - resolver.executing! - resolver.complete! - - expect { resolver.interrupt! }.to raise_error(/cannot transition to interrupted from complete/) - end - end - end - end - - describe "status predicates" do - describe "#success?" do - it "returns true by default" do - expect(result.success?).to be(true) - end - - it "returns false after skip!" do - resolver.skip!("test reason", halt: false) - - expect(result.success?).to be(false) - end - - it "returns false after fail!" do - resolver.fail!("test reason", halt: false) - - expect(result.success?).to be(false) - end - end - - describe "#skipped?" do - it "returns false by default" do - expect(result.skipped?).to be(false) - end - - it "returns true after skip!" do - resolver.skip!("test reason", halt: false) - - expect(result.skipped?).to be(true) - end - end - - describe "#failed?" do - it "returns false by default" do - expect(result.failed?).to be(false) - end - - it "returns true after fail!" do - resolver.fail!("test reason", halt: false) - - expect(result.failed?).to be(true) - end - end - - describe "#good?" do - it "returns true when not failed" do - expect(result.good?).to be(true) - end - - it "returns true when skipped" do - resolver.skip!("test reason", halt: false) - - expect(result.good?).to be(true) - end - - it "returns false when failed" do - resolver.fail!("test reason", halt: false) - - expect(result.good?).to be(false) - end - end - - describe "#bad?" do - it "returns false when successful" do - expect(result.bad?).to be(false) - end - - it "returns true when skipped" do - resolver.skip!("test reason", halt: false) - - expect(result.bad?).to be(true) - end - - it "returns true when failed" do - resolver.fail!("test reason", halt: false) - - expect(result.bad?).to be(true) - end - end - - describe "#rolled_back?" do - it "returns false by default" do - expect(result.rolled_back?).to be(false) - end - - it "returns true when rolled back" do - result.rolled_back = true - - expect(result.rolled_back?).to be(true) - end - end - - describe "#retried?" do - it "returns false when retries is zero" do - expect(result.retried?).to be(false) - end - - it "returns true when retries is positive" do - result.retries = 1 - - expect(result.retried?).to be(true) - end - end - end - - describe "execution methods" do - describe "#executed!" do - context "when successful" do - it "calls complete!" do - resolver.executing! - resolver.executed! - - expect(result.complete?).to be(true) - end - end - - context "when not successful" do - it "calls interrupt! when skipped" do - resolver.skip!("test reason", halt: false) - resolver.executed! - - expect(result.interrupted?).to be(true) - end - - it "calls interrupt! when failed" do - resolver.fail!("test reason", halt: false) - resolver.executed! - - expect(result.interrupted?).to be(true) - end - end - end - - describe "#executed?" do - it "returns false when not executed" do - expect(result.executed?).to be(false) - end - - it "returns true when complete" do - resolver.executing! - resolver.complete! - - expect(result.executed?).to be(true) - end - - it "returns true when interrupted" do - resolver.interrupt! - - expect(result.executed?).to be(true) - end - end - - describe "#on(:executed)" do - it "raises ArgumentError without block" do - expect { result.on(:executed) }.to raise_error(ArgumentError, "block required") - end - - it "calls block when executed" do - resolver.interrupt! - called = false - result.on(:executed) { |_r| called = true } - - expect(called).to be(true) - end - - it "does not call block when not executed" do - called = false - result.on(:executed) { |_r| called = true } - - expect(called).to be(false) - end - - it "returns self" do - expect(result.on(:executed) { "test" }).to eq(result) - end - end - end - - describe "failure tracking methods" do - let(:chain) { CMDx::Chain.new } - let(:first_task) { create_successful_task.new } - let(:second_task) { create_failing_task.new } - let(:third_task) { create_successful_task.new } - let(:first_result) { first_task.result } - let(:second_result) { second_task.result } - let(:third_result) { third_task.result } - - before do - chain.results.push(first_result, second_result, third_result) - - [first_result, second_result, third_result].each do |r| - r.instance_variable_set(:@chain, chain) - end - - second_task.resolver.fail!("test failure", halt: false) - end - - describe "#caused_failure" do - context "when failed" do - it "returns the first failed result in chain" do - expect(second_result.caused_failure).to eq(second_result) - end - end - - context "when not failed" do - it "returns nil" do - expect(first_result.caused_failure).to be_nil - end - end - end - - describe "#caused_failure?" do - it "returns true for the causing failure" do - expect(second_result.caused_failure?).to be(true) - end - - it "returns false for non-failing results" do - expect(first_result.caused_failure?).to be(false) - end - - it "returns false for non-failed results" do - expect(third_result.caused_failure?).to be(false) - end - end - - describe "#threw_failure" do - context "when failed" do - let(:fourth_task) { create_failing_task.new } - let(:fourth_result) { fourth_task.result } - - before do - chain.results << fourth_result - - fourth_result.instance_variable_set(:@chain, chain) - fourth_task.resolver.fail!("another failure", halt: false) - end - - it "returns the next failed result after current" do - expect(second_result.threw_failure).to eq(fourth_result) - end - - it "returns the last failed result when no failures after current" do - expect(fourth_result.threw_failure).to eq(fourth_result) - end - end - - context "when not failed" do - it "returns nil" do - expect(first_result.threw_failure).to be_nil - end - end - end - - describe "#threw_failure?" do - it "returns true when the result is the last failure" do - expect(second_result.threw_failure?).to be(true) - end - - it "returns false for non-failing results" do - expect(first_result.threw_failure?).to be(false) - end - end - - describe "#thrown_failure?" do - it "returns false when result caused the failure" do - expect(second_result.thrown_failure?).to be(false) - end - - it "returns false for non-failed results" do - expect(first_result.thrown_failure?).to be(false) - end - end - end - - describe "#index" do - it "returns the cached chain index set during push" do - expect(result.index).to eq(0) - end - - it "falls back to chain.index when cache is not set" do - result.remove_instance_variable(:@chain_index) if result.instance_variable_defined?(:@chain_index) - allow(result.chain).to receive(:index).with(result).and_return(42) - - expect(result.index).to eq(42) - end - end - - describe "#outcome" do - context "when initialized" do - it "returns state" do - expect(result.outcome).to eq(result.state) - end - end - - context "when thrown failure" do - it "returns state" do - allow(result).to receive(:thrown_failure?).and_return(true) - - expect(result.outcome).to eq(result.state) - end - end - - context "when not initialized and not thrown failure" do - it "returns status" do - resolver.executing! - - expect(result.outcome).to eq(result.status) - end - end - end - - describe "#to_h" do - it "includes basic task and result information" do - hash = result.to_h - task_hash = task.to_h - - expect(hash).to include( - state: result.state, - status: result.status, - outcome: result.outcome, - metadata: result.metadata, - index: task_hash[:index], - chain_id: task_hash[:chain_id], - type: task_hash[:type], - tags: task_hash[:tags], - id: task_hash[:id], - class: start_with("TestTask") - ) - end - - context "when successful with reason" do - it "includes reason without cause or rolled_back" do - catch(:cmdx_halt) { resolver.success!("Created 42 records") } - - hash = result.to_h - - expect(hash[:reason]).to eq("Created 42 records") - expect(hash).not_to include(:cause, :rolled_back) - end - end - - context "when interrupted" do - it "includes reason, cause and rolled_back status" do - resolver.skip!("test reason", halt: false, cause: StandardError.new("test")) - - hash = result.to_h - - expect(hash).to include(:reason, :cause, :rolled_back) - expect(hash[:reason]).to eq("test reason") - expect(hash[:rolled_back]).to be(false) - end - end - - context "when failed" do - it "includes failure information" do - resolver.fail!("test failure", halt: false) - - # Create mock objects that avoid calling to_h to prevent infinite recursion - threw_failure_mock = instance_double(described_class, to_h: { index: 1, class: "Test", id: "123" }) - caused_failure_mock = instance_double(described_class, to_h: { index: 0, class: "Test", id: "456" }) - - allow(result).to receive_messages(threw_failure?: false, caused_failure?: false, threw_failure: threw_failure_mock, caused_failure: caused_failure_mock) - - hash = result.to_h - - expect(hash).to include(:threw_failure, :caused_failure) - expect(hash[:threw_failure]).to eq({ index: 1, class: "Test", id: "123" }) - expect(hash[:caused_failure]).to eq({ index: 0, class: "Test", id: "456" }) - end - end - end - - describe "#to_s" do - it "formats hash using Utils::Format.to_str" do - expect(CMDx::Utils::Format).to receive(:to_str).and_return("formatted string") - - expect(result.to_s).to eq("formatted string") - end - - it "handles failure formatting in block" do - expect(CMDx::Utils::Format).to receive(:to_str).and_return("formatted string") - - result.to_s - end - end - - describe "#deconstruct" do - it "returns state and status as array" do - expect(result.deconstruct).to eq( - [ - result.state, - result.status, - result.reason, - result.cause, - result.metadata - ] - ) - end - - it "ignores arguments" do - expect(result.deconstruct(:anything, :here)).to eq( - [ - result.state, - result.status, - result.reason, - result.cause, - result.metadata - ] - ) - end - end - - describe "#deconstruct_keys" do - it "returns hash with key attributes" do - expected = { - state: result.state, - status: result.status, - reason: result.reason, - cause: result.cause, - metadata: result.metadata, - outcome: result.outcome, - executed: result.executed?, - good: result.good?, - bad: result.bad? - } - - expect(result.deconstruct_keys).to eq(expected) - end - - it "ignores arguments" do - expected = result.deconstruct_keys - - expect(result.deconstruct_keys(:anything)).to eq(expected) - end - end - - describe "handle methods" do - describe "state handle methods" do - CMDx::Result::STATES.each do |state| - describe "#on(#{state})" do - it "raises ArgumentError without block" do - expect { result.on(state) }.to raise_error(ArgumentError, "block required") - end - - context "when in #{state} state" do - before do - case state - when CMDx::Result::INITIALIZED - # Already in initialized state - when CMDx::Result::EXECUTING - resolver.executing! - when CMDx::Result::COMPLETE - resolver.executing! - resolver.complete! - when CMDx::Result::INTERRUPTED - resolver.interrupt! - end - end - - it "calls the block" do - called = false - result.on(state) { |_r| called = true } - - expect(called).to be(true) - end - - it "passes result to block" do - block_result = nil - result.on(state) { |r| block_result = r } - - expect(block_result).to eq(result) - end - end - - context "when not in #{state} state" do - before do - case state - when CMDx::Result::INITIALIZED - resolver.executing! - when CMDx::Result::EXECUTING - # Stay in initialized state - when CMDx::Result::COMPLETE - # Stay in initialized state - when CMDx::Result::INTERRUPTED - # Stay in initialized state - end - end - - it "does not call the block" do - called = false - result.on(state) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(state) { "test" }).to eq(result) - end - end - end - end - - describe "status handle methods" do - CMDx::Result::STATUSES.each do |status| - describe "#on(#{status})" do - it "raises ArgumentError without block" do - expect { result.on(status) }.to raise_error(ArgumentError, "block required") - end - - context "when in #{status} status" do - before do - case status - when CMDx::Result::SUCCESS - # Already in success status - when CMDx::Result::SKIPPED - resolver.skip!("test", halt: false) - when CMDx::Result::FAILED - resolver.fail!("test", halt: false) - end - end - - it "calls the block" do - called = false - result.on(status) { |_r| called = true } - - expect(called).to be(true) - end - - it "passes result to block" do - block_result = nil - result.on(status) { |r| block_result = r } - - expect(block_result).to eq(result) - end - end - - context "when not in #{status} status" do - before do - case status - when CMDx::Result::SUCCESS - resolver.skip!("test", halt: false) - when CMDx::Result::SKIPPED - # Stay in success status - when CMDx::Result::FAILED - # Stay in success status - end - end - - it "does not call the block" do - called = false - result.on(status) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(status) { "test" }).to eq(result) - end - end - end - end - - describe "#on(:good)" do - it "raises ArgumentError without block" do - expect { result.on(:good) }.to raise_error(ArgumentError, "block required") - end - - context "when good" do - it "calls the block for success" do - called = false - result.on(:good) { |_r| called = true } - - expect(called).to be(true) - end - - it "calls the block for skipped" do - resolver.skip!("test", halt: false) - called = false - result.on(:good) { |_r| called = true } - - expect(called).to be(true) - end - end - - context "when not good" do - it "does not call the block for failed" do - resolver.fail!("test", halt: false) - called = false - result.on(:good) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(:good) { "test" }).to eq(result) - end - end - - describe "#on(:bad)" do - it "raises ArgumentError without block" do - expect { result.on(:bad) }.to raise_error(ArgumentError, "block required") - end - - context "when bad" do - it "calls the block for skipped" do - resolver.skip!("test", halt: false) - called = false - result.on(:bad) { |_r| called = true } - - expect(called).to be(true) - end - - it "calls the block for failed" do - resolver.fail!("test", halt: false) - called = false - result.on(:bad) { |_r| called = true } - - expect(called).to be(true) - end - end - - context "when not bad" do - it "does not call the block for success" do - called = false - result.on(:bad) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(:bad) { "test" }).to eq(result) - end - end - end - - describe "constants" do - describe "STATES" do - it "defines all expected states" do - expect(CMDx::Result::STATES).to contain_exactly( - "initialized", - "executing", - "complete", - "interrupted" - ) - end - - it "freezes the array" do - expect(CMDx::Result::STATES).to be_frozen - end - end - - describe "STATUSES" do - it "defines all expected statuses" do - expect(CMDx::Result::STATUSES).to contain_exactly( - "success", - "skipped", - "failed" - ) - end - - it "freezes the array" do - expect(CMDx::Result::STATUSES).to be_frozen - end - end - end -end diff --git a/spec/cmdx/retry_spec.rb b/spec/cmdx/retry_spec.rb deleted file mode 100644 index e35a57c75..000000000 --- a/spec/cmdx/retry_spec.rb +++ /dev/null @@ -1,360 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Retry, type: :unit do - subject(:retry_instance) { described_class.new(task) } - - let(:task_class) { create_successful_task(name: "RetryTask") } - let(:task) { task_class.new } - - describe "#initialize" do - it "assigns the task" do - expect(retry_instance.task).to eq(task) - end - - it "provides read access to task attribute" do - expect(described_class.instance_methods).to include(:task) - expect(described_class.private_instance_methods).not_to include(:task) - end - end - - describe "#available" do - context "when retries is configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3)) - end - - it "returns the configured retry count" do - expect(retry_instance.available).to eq(3) - end - end - - context "when retries is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: nil)) - end - - it "returns 0" do - expect(retry_instance.available).to eq(0) - end - end - - context "when retries is not configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - end - - it "returns 0" do - expect(retry_instance.available).to eq(0) - end - end - end - - describe "#available?" do - context "when retries are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2)) - end - - it "returns true" do - expect(retry_instance.available?).to be(true) - end - end - - context "when retries is 0" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 0)) - end - - it "returns false" do - expect(retry_instance.available?).to be(false) - end - end - - context "when retries is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: nil)) - end - - it "returns false" do - expect(retry_instance.available?).to be(false) - end - end - end - - describe "#attempts" do - context "when no retries have occurred" do - it "returns 0" do - expect(retry_instance.attempts).to eq(0) - end - end - - context "when retries have occurred" do - before do - task.result.retries = 2 - end - - it "returns the number of attempts" do - expect(retry_instance.attempts).to eq(2) - end - end - end - - describe "#retried?" do - context "when no retries have occurred" do - it "returns false" do - expect(retry_instance.retried?).to be(false) - end - end - - context "when at least one retry has occurred" do - before do - task.result.retries = 1 - end - - it "returns true" do - expect(retry_instance.retried?).to be(true) - end - end - end - - describe "#remaining" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 5)) - end - - context "when no retries have occurred" do - it "returns the full retry count" do - expect(retry_instance.remaining).to eq(5) - end - end - - context "when some retries have occurred" do - before do - task.result.retries = 2 - end - - it "returns the difference between available and attempts" do - expect(retry_instance.remaining).to eq(3) - end - end - - context "when all retries are exhausted" do - before do - task.result.retries = 5 - end - - it "returns 0" do - expect(retry_instance.remaining).to eq(0) - end - end - end - - describe "#remaining?" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3)) - end - - context "when retries remain" do - it "returns true" do - expect(retry_instance.remaining?).to be(true) - end - end - - context "when all retries are exhausted" do - before do - task.result.retries = 3 - end - - it "returns false" do - expect(retry_instance.remaining?).to be(false) - end - end - end - - describe "#exceptions" do - context "when retry_on is configured with specific exceptions" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError, RuntimeError])) - end - - it "returns the configured exception classes" do - expect(retry_instance.exceptions).to eq([ArgumentError, RuntimeError]) - end - end - - context "when retry_on is a single exception class" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: ArgumentError)) - end - - it "wraps it in an array" do - expect(retry_instance.exceptions).to eq([ArgumentError]) - end - end - - context "when retry_on is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: nil)) - end - - it "defaults to StandardError" do - expect(retry_instance.exceptions).to eq([StandardError, CMDx::TimeoutError]) - end - end - - context "when retry_on is not configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - end - - it "defaults to StandardError" do - expect(retry_instance.exceptions).to eq([StandardError, CMDx::TimeoutError]) - end - end - - it "memoizes the result" do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError])) - - first_call = retry_instance.exceptions - second_call = retry_instance.exceptions - - expect(first_call).to equal(second_call) - end - end - - describe "#exception?" do - context "with default retry_on (StandardError)" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: nil)) - end - - it "returns true for StandardError" do - expect(retry_instance.exception?(StandardError.new)).to be(true) - end - - it "returns true for subclass of StandardError" do - expect(retry_instance.exception?(ArgumentError.new)).to be(true) - end - - it "returns false for non-matching exception" do - expect(retry_instance.exception?(Exception.new)).to be(false) - end - end - - context "with specific retry_on" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError])) - end - - it "returns true for exact match" do - expect(retry_instance.exception?(ArgumentError.new)).to be(true) - end - - it "returns false for non-matching exception" do - expect(retry_instance.exception?(RuntimeError.new)).to be(false) - end - - it "returns false for parent class of configured exception" do - expect(retry_instance.exception?(StandardError.new)).to be(false) - end - end - - context "with multiple retry_on exceptions" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError, RuntimeError])) - end - - it "returns true when matching any configured exception" do - expect(retry_instance.exception?(ArgumentError.new)).to be(true) - expect(retry_instance.exception?(RuntimeError.new)).to be(true) - end - end - end - - describe "#wait" do - context "with numeric jitter" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: 0.5)) - end - - it "returns jitter multiplied by attempts" do - task.result.retries = 2 - - expect(retry_instance.wait).to eq(1.0) - end - - it "returns 0.0 when no attempts have been made" do - expect(retry_instance.wait).to eq(0.0) - end - end - - context "with symbol jitter" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: :custom_wait)) - allow(task).to receive(:custom_wait).with(1).and_return(2.5) - task.result.retries = 1 - end - - it "calls the named method on the task with attempts" do - expect(task).to receive(:custom_wait).with(1) - - retry_instance.wait - end - - it "returns the method result as a float" do - expect(retry_instance.wait).to eq(2.5) - end - end - - context "with proc jitter" do - let(:jitter_proc) { ->(retries) { retries * 0.75 } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_proc)) - task.result.retries = 2 - end - - it "instance_execs the proc with attempts" do - expect(retry_instance.wait).to eq(1.5) - end - end - - context "with callable object jitter" do - let(:jitter_callable) do - Class.new do - def call(_task, retries) - retries * 1.25 - end - end.new - end - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_callable)) - task.result.retries = 2 - end - - it "calls the object with task and attempts" do - expect(jitter_callable).to receive(:call).with(task, 2).and_call_original - - retry_instance.wait - end - - it "returns the callable result as a float" do - expect(retry_instance.wait).to eq(2.5) - end - end - - context "with nil jitter" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: nil)) - task.result.retries = 2 - end - - it "returns 0.0" do - expect(retry_instance.wait).to eq(0.0) - end - end - end -end diff --git a/spec/cmdx/settings_spec.rb b/spec/cmdx/settings_spec.rb deleted file mode 100644 index 02bb98162..000000000 --- a/spec/cmdx/settings_spec.rb +++ /dev/null @@ -1,304 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Settings, type: :unit do - describe "#initialize" do - context "without parent" do - subject(:settings) { described_class.new } - - it "creates fresh registries from configuration" do - expect(settings.attributes).to be_a(CMDx::AttributeRegistry) - expect(settings.attributes.registry).to be_empty - expect(settings.middlewares).to be_a(CMDx::MiddlewareRegistry) - expect(settings.callbacks).to be_a(CMDx::CallbackRegistry) - expect(settings.coercions).to be_a(CMDx::CoercionRegistry) - expect(settings.validators).to be_a(CMDx::ValidatorRegistry) - end - - it "dups registries so they are independent from configuration" do - expect(settings.middlewares).not_to equal(CMDx.configuration.middlewares) - expect(settings.callbacks).not_to equal(CMDx.configuration.callbacks) - expect(settings.coercions).not_to equal(CMDx.configuration.coercions) - expect(settings.validators).not_to equal(CMDx.configuration.validators) - end - - it "inherits scalar values from configuration" do - expect(settings.task_breakpoints).to eq(CMDx.configuration.task_breakpoints) - expect(settings.workflow_breakpoints).to eq(CMDx.configuration.workflow_breakpoints) - expect(settings.rollback_on).to eq(CMDx.configuration.rollback_on) - expect(settings.backtrace).to eq(CMDx.configuration.backtrace) - expect(settings.dump_context).to eq(CMDx.configuration.dump_context) - expect(settings.logger).to eq(CMDx.configuration.logger) - end - - it "initializes nullable fields from configuration" do - expect(settings.backtrace_cleaner).to eq(CMDx.configuration.backtrace_cleaner) - expect(settings.exception_handler).to eq(CMDx.configuration.exception_handler) - end - - it "sets arrays to frozen empty arrays" do - expect(settings.returns).to eq([]) - expect(settings.returns).to be_frozen - expect(settings.tags).to eq([]) - expect(settings.tags).to be_frozen - end - - it "defaults task-level settings to nil" do - expect(settings.breakpoints).to be_nil - expect(settings.log_level).to be_nil - expect(settings.log_formatter).to be_nil - expect(settings.retries).to be_nil - expect(settings.retry_on).to be_nil - expect(settings.retry_jitter).to be_nil - expect(settings.deprecate).to be_nil - end - end - - context "with parent" do - subject(:settings) { described_class.new(parent: parent) } - - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed skipped], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: %w[failed], - backtrace: true, - backtrace_cleaner: ->(bt) { bt.first(3) }, - exception_handler: ->(_task, _err) {}, - logger: Logger.new(nil), - log_level: Logger::WARN, - log_formatter: proc { |*| "fmt" }, - retries: 3, - retry_on: [StandardError, CMDx::TimeoutError], - retry_jitter: :exponential, - deprecate: :warn, - returns: %i[user token], - tags: %i[auth critical] - ) - end - - it "deep-dups registries from parent" do - expect(settings.attributes.registry).to eq(parent.attributes.registry) - expect(settings.attributes).not_to equal(parent.attributes) - - expect(settings.middlewares).not_to equal(parent.middlewares) - expect(settings.callbacks).not_to equal(parent.callbacks) - expect(settings.coercions).not_to equal(parent.coercions) - expect(settings.validators).not_to equal(parent.validators) - end - - it "inherits scalar/callable values from parent" do - expect(settings.task_breakpoints).to eq(%w[failed skipped]) - expect(settings.workflow_breakpoints).to eq(%w[failed]) - expect(settings.rollback_on).to eq(%w[failed]) - expect(settings.breakpoints).to eq(%w[failed]) - expect(settings.backtrace).to be true - expect(settings.backtrace_cleaner).to eq(parent.backtrace_cleaner) - expect(settings.exception_handler).to eq(parent.exception_handler) - expect(settings.logger).to eq(parent.logger) - expect(settings.log_level).to eq(Logger::WARN) - expect(settings.log_formatter).to eq(parent.log_formatter) - end - - it "inherits task-level values from parent" do - expect(settings.retries).to eq(3) - expect(settings.retry_on).to eq([StandardError, CMDx::TimeoutError]) - expect(settings.retry_jitter).to eq(:exponential) - expect(settings.deprecate).to eq(:warn) - end - - it "dups returns and tags arrays" do - expect(settings.returns).to eq(%i[user token]) - expect(settings.returns).not_to equal(parent.returns) - expect(settings.tags).to eq(%i[auth critical]) - expect(settings.tags).not_to equal(parent.tags) - end - - context "when parent has nil returns and tags" do - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: nil, - backtrace: false, - backtrace_cleaner: nil, - exception_handler: nil, - logger: nil, - log_level: nil, - log_formatter: nil, - retries: nil, - retry_on: nil, - retry_jitter: nil, - deprecate: nil, - returns: nil, - tags: nil - ) - end - - it "falls back to frozen empty arrays" do - expect(settings.returns).to eq([]) - expect(settings.returns).to be_frozen - expect(settings.tags).to eq([]) - expect(settings.tags).to be_frozen - end - end - - context "when parent has nil backtrace_cleaner, exception_handler, and logger" do - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: nil, - backtrace: false, - backtrace_cleaner: nil, - exception_handler: nil, - logger: nil, - log_level: nil, - log_formatter: nil, - retries: nil, - retry_on: nil, - retry_jitter: nil, - deprecate: nil, - returns: nil, - tags: nil - ) - end - - it "falls back to configuration values" do - expect(settings.backtrace_cleaner).to eq(CMDx.configuration.backtrace_cleaner) - expect(settings.exception_handler).to eq(CMDx.configuration.exception_handler) - expect(settings.logger).to eq(CMDx.configuration.logger) - end - end - end - - context "with overrides" do - subject(:settings) { described_class.new(retries: 5, backtrace: true) } - - it "applies overrides after inheriting from configuration" do - expect(settings.retries).to eq(5) - expect(settings.backtrace).to be true - end - end - - context "with parent and overrides" do - subject(:settings) { described_class.new(parent: parent, retries: 10, deprecate: :warn) } - - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: nil, - backtrace: false, - backtrace_cleaner: nil, - exception_handler: nil, - logger: nil, - log_level: nil, - log_formatter: nil, - retries: 2, - retry_on: nil, - retry_jitter: nil, - deprecate: nil, - returns: nil, - tags: nil - ) - end - - it "overrides parent values" do - expect(settings.retries).to eq(10) - expect(settings.deprecate).to eq(:warn) - end - end - end - - describe "delegation" do - context "with delegate_to_configuration settings" do - it "reflects configuration changes without re-creating settings" do - settings = described_class.new - - CMDx.configuration.task_breakpoints = %w[failed skipped] - expect(settings.task_breakpoints).to eq(%w[failed skipped]) - - CMDx.configuration.rollback_on = %w[skipped] - expect(settings.rollback_on).to eq(%w[skipped]) - end - - it "stops delegating once locally overridden" do - settings = described_class.new - settings.task_breakpoints = %w[custom] - - CMDx.configuration.task_breakpoints = %w[failed skipped] - expect(settings.task_breakpoints).to eq(%w[custom]) - end - end - - context "with delegate_with_fallback settings" do - it "reflects configuration logger changes" do - settings = described_class.new - new_logger = Logger.new(nil) - - CMDx.configuration.logger = new_logger - expect(settings.logger).to equal(new_logger) - end - - it "stops delegating once locally overridden" do - settings = described_class.new - local_logger = Logger.new(nil) - settings.logger = local_logger - - CMDx.configuration.logger = Logger.new(nil) - expect(settings.logger).to equal(local_logger) - end - end - - context "with parent chain delegation" do - it "walks the parent chain to resolve values" do - grandparent = described_class.new(retries: 5) - parent = described_class.new(parent: grandparent) - child = described_class.new(parent: parent) - - expect(child.retries).to eq(5) - end - - it "prefers the closest override in the chain" do - grandparent = described_class.new(retries: 5) - parent = described_class.new(parent: grandparent, retries: 10) - child = described_class.new(parent: parent) - - expect(child.retries).to eq(10) - end - - it "local override wins over parent chain" do - parent = described_class.new(retries: 5) - child = described_class.new(parent: parent, retries: 20) - - expect(child.retries).to eq(20) - end - end - end -end diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb deleted file mode 100644 index 0b01f311c..000000000 --- a/spec/cmdx/task_spec.rb +++ /dev/null @@ -1,610 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Task, type: :unit do - let(:task_class) { create_task_class(name: "TestTask") } - let(:task) { task_class.new } - let(:context_hash) { { foo: "bar", baz: 42 } } - - describe "#initialize" do - context "with no arguments" do - it "initializes with default values" do - expect(task.attributes).to eq({}) - expect(task.errors).to be_a(CMDx::Errors) - expect(task.errors).to be_empty - expect(task.id).to be_a(String) - expect(task.context).to be_a(CMDx::Context) - expect(task.context.to_h).to eq({}) - expect(task.result).to be_a(CMDx::Result) - expect(task.result.task).to eq(task) - expect(task.chain).to be_a(CMDx::Chain) - expect(task.dry_run?).to be(false) - end - - it "generates unique IDs for different instances" do - task1 = task_class.new - task2 = task_class.new - - expect(task1.id).not_to eq(task2.id) - end - end - - context "with context hash" do - let(:task) { task_class.new(context_hash) } - - it "initializes context with provided hash" do - expect(task.context.to_h).to eq(context_hash) - end - end - - context "with context object" do - let(:context_obj) { CMDx::Context.new(context_hash) } - let(:task) { task_class.new(context_obj) } - - it "uses the provided context object" do - expect(task.context).to eq(context_obj) - expect(task.context.to_h).to eq(context_hash) - end - end - - context "with object that responds to context" do - let(:context_wrapper) { instance_double("MockContextWrapper", context: context_hash) } - let(:task) { task_class.new(context_wrapper) } - - it "extracts context from the wrapper object" do - expect(task.context.to_h).to eq(context_hash) - end - end - - it "calls Deprecator.restrict" do - expect(CMDx::Deprecator).to receive(:restrict).with(an_instance_of(task_class)) - - task_class.new - end - end - - describe "aliases" do - it "aliases ctx to context" do - expect(task.ctx).to eq(task.context) - end - - it "aliases res to result" do - expect(task.res).to eq(task.result) - end - end - - describe "delegated methods" do - it "delegates success! to resolver" do - expect(task.resolver).to receive(:success!).with("reason", metadata: "data") - - task.success!("reason", metadata: "data") - end - - it "delegates skip! to resolver" do - expect(task.resolver).to receive(:skip!).with("reason", metadata: "data") - - task.skip!("reason", metadata: "data") - end - - it "delegates fail! to resolver" do - expect(task.resolver).to receive(:fail!).with("reason", metadata: "data") - - task.fail!("reason", metadata: "data") - end - - it "delegates throw! to resolver" do - expect(task.resolver).to receive(:throw!).with("reason", metadata: "data") - - task.throw!("reason", metadata: "data") - end - end - - describe ".settings" do - context "when called for the first time" do - it "returns default settings with required keys" do - settings = task_class.settings - - expect(settings).to be_a(CMDx::Settings) - expect(settings.attributes).to be_a(CMDx::AttributeRegistry) - expect(settings.tags).to eq([]) - end - end - - context "when superclass has settings" do - let(:parent_class) { create_task_class(name: "ParentTask") } - let(:child_class) { Class.new(parent_class) } - - before do - parent_class.settings(deprecate: true) - end - - it "inherits from superclass settings" do - child_settings = child_class.settings - - expect(child_settings.deprecate).to be(true) - end - - it "can override inherited settings" do - child_settings = child_class.settings(deprecate: false) - - expect(child_settings.deprecate).to be(false) - end - end - - context "with custom options" do - it "merges custom options with defaults" do - settings = task_class.settings(deprecate: :warn, tags: ["tag1"]) - - expect(settings.deprecate).to eq(:warn) - expect(settings.tags).to eq(["tag1"]) - end - end - - it "memoizes settings" do - settings1 = task_class.settings - settings2 = task_class.settings - - expect(settings1).to be(settings2) - end - end - - describe ".register" do - let(:mock_registry) { instance_double("MockRegistry") } - - before do - allow(task_class.settings).to receive_messages( - attributes: mock_registry, - callbacks: mock_registry, - coercions: mock_registry, - middlewares: mock_registry, - validators: mock_registry - ) - end - - context "with :attribute type" do - it "registers with attribute registry and defines readers" do - expect(mock_registry).to receive(:register).with("object") - expect(mock_registry).to receive(:define_readers_on!).with(task_class, ["object"]) - - task_class.register(:attribute, "object") - end - end - - context "with :callback type" do - it "registers with callback registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:callback, "object", "arg1", "arg2") - end - end - - context "with :coercion type" do - it "registers with coercion registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:coercion, "object", "arg1", "arg2") - end - end - - context "with :middleware type" do - it "registers with middleware registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:middleware, "object", "arg1", "arg2") - end - end - - context "with :validator type" do - it "registers with validator registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:validator, "object", "arg1", "arg2") - end - end - - context "with unknown type" do - it "raises an error" do - expect { task_class.register(:unknown, "object") }.to raise_error("unknown registry type :unknown") - end - end - end - - describe ".attributes" do - it "defines and registers multiple attributes" do - mock_attributes = instance_double(CMDx::Attribute) - expect(CMDx::Attribute).to receive(:build).with("arg1", "arg2").and_return(mock_attributes) - expect(task_class).to receive(:register).with(:attribute, mock_attributes) - - task_class.attributes("arg1", "arg2") - end - end - - describe ".optional" do - it "defines and registers optional attributes" do - mock_attribute = instance_double(CMDx::Attribute) - expect(CMDx::Attribute).to receive(:optional).with("arg1", "arg2").and_return(mock_attribute) - expect(task_class).to receive(:register).with(:attribute, mock_attribute) - - task_class.optional("arg1", "arg2") - end - end - - describe ".required" do - it "defines and registers required attributes" do - mock_attribute = instance_double(CMDx::Attribute) - expect(CMDx::Attribute).to receive(:required).with("arg1", "arg2").and_return(mock_attribute) - expect(task_class).to receive(:register).with(:attribute, mock_attribute) - - task_class.required("arg1", "arg2") - end - end - - describe ".remove_attributes" do - it "removes multiple attributes from the registry" do - mock_registry = instance_double(CMDx::AttributeRegistry) - allow(task_class.settings).to receive(:attributes).and_return(mock_registry) - - expect(mock_registry).to receive(:undefine_readers_on!).with(task_class, %w[attr1 attr2 attr3]) - expect(mock_registry).to receive(:deregister).with(%w[attr1 attr2 attr3]) - - task_class.remove_attributes("attr1", "attr2", "attr3") - end - - it "handles single attribute removal" do - mock_registry = instance_double(CMDx::AttributeRegistry) - allow(task_class.settings).to receive(:attributes).and_return(mock_registry) - - expect(mock_registry).to receive(:undefine_readers_on!).with(task_class, ["single_attr"]) - expect(mock_registry).to receive(:deregister).with(["single_attr"]) - - task_class.remove_attributes("single_attr") - end - end - - describe ".attributes_schema" do - let(:task_with_attrs) do - Class.new(CMDx::Task) do - required :user_id, type: :integer - optional :email, type: :string, default: "test@example.com" - optional :profile, type: :hash do - optional :bio, type: :string - required :name, type: :string - end - - def work; end - end - end - - it "returns a hash keyed by attribute method names" do - schema = task_with_attrs.attributes_schema - - expect(schema).to be_a(Hash) - expect(schema.keys).to contain_exactly(:user_id, :email, :profile) - end - - it "includes attribute metadata from to_h" do - schema = task_with_attrs.attributes_schema - - expect(schema[:user_id]).to include( - name: :user_id, - method_name: :user_id, - required: true, - types: [:integer] - ) - - expect(schema[:email]).to include( - name: :email, - method_name: :email, - required: false, - types: [:string] - ) - expect(schema[:email][:options]).to include(default: "test@example.com") - end - - it "includes nested children" do - schema = task_with_attrs.attributes_schema - - expect(schema[:profile][:children]).to be_an(Array) - expect(schema[:profile][:children].size).to eq(2) - - child_names = schema[:profile][:children].map { |c| c[:name] } - expect(child_names).to contain_exactly(:bio, :name) - end - - it "returns empty hash when no attributes defined" do - empty_task = Class.new(CMDx::Task) { def work; end } - - expect(empty_task.attributes_schema).to eq({}) - end - end - - describe "callback methods" do - CMDx::CallbackRegistry::TYPES.each do |callback_type| - describe ".#{callback_type}" do - it "registers the callback" do - expect(task_class).to receive(:register).with(:callback, callback_type, "callable1", "callable2", option: "value") - - task_class.public_send(callback_type, "callable1", "callable2", option: "value") - end - - it "accepts a block" do - block = proc { "test" } - expect(task_class).to receive(:register).with(:callback, callback_type, option: "value") do |*_args, &passed_block| - expect(passed_block).to eq(block) - end - - task_class.public_send(callback_type, option: "value", &block) - end - end - end - end - - describe "#dry_run?" do - it "returns false by default" do - expect(task.dry_run?).to be(false) - end - - context "when initialized with dry_run: true" do - let(:task) { task_class.new(dry_run: true) } - - it "returns true" do - expect(task.dry_run?).to be(true) - end - end - - context "when executed with dry_run: true" do - let(:dry_run_class) do - create_task_class do - def work; end - end - end - - it "returns true via execute" do - result = dry_run_class.execute(dry_run: true) - - expect(result).to be_dry_run - end - - it "returns true via execute!" do - result = dry_run_class.execute!(dry_run: true) - - expect(result).to be_dry_run - end - end - end - - describe ".execute" do - let(:mock_task) { instance_double(described_class, result: "result") } - - it "creates new task instance and executes with raise: false" do - expect(task_class).to receive(:new).with("arg1", "arg2").and_return(mock_task) - expect(mock_task).to receive(:execute).with(raise: false) - - result = task_class.execute("arg1", "arg2") - - expect(result).to eq("result") - end - end - - describe ".execute!" do - let(:mock_task) { instance_double(described_class, result: "result") } - - it "creates new task instance and executes with raise: true" do - expect(task_class).to receive(:new).with("arg1", "arg2").and_return(mock_task) - expect(mock_task).to receive(:execute).with(raise: true) - - result = task_class.execute!("arg1", "arg2") - - expect(result).to eq("result") - end - end - - describe "#execute" do - context "with raise: false" do - it "delegates to Executor.execute with raise: false" do - expect(CMDx::Executor).to receive(:execute).with(task, raise: false) - - task.execute(raise: false) - end - end - - context "with raise: true" do - it "delegates to Executor.execute with raise: true" do - expect(CMDx::Executor).to receive(:execute).with(task, raise: true) - - task.execute(raise: true) - end - end - - context "with no arguments" do - it "defaults to raise: false" do - expect(CMDx::Executor).to receive(:execute).with(task, raise: false) - - task.execute - end - end - end - - describe "#work" do - it "raises UndefinedMethodError" do - expect { task.work }.to raise_error( - CMDx::UndefinedMethodError, - /undefined method.*#work/ - ) - end - - it "includes the class name in the error message" do - expect { task.work }.to raise_error( - CMDx::UndefinedMethodError, - /TestTask\d+#work/ - ) - end - end - - describe "#logger" do - context "when class settings has logger" do - let(:class_logger) { Logger.new(IO::NULL) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(logger: class_logger)) - end - - it "returns the class logger" do - expect(task.logger).to equal(class_logger) - end - end - - context "when class settings has no logger" do - let(:config_logger) { Logger.new(IO::NULL) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - allow(CMDx.configuration).to receive(:logger).and_return(config_logger) - end - - it "returns the configuration logger" do - expect(task.logger).to equal(config_logger) - end - end - - context "when log_level is customized" do - let(:shared_logger) { Logger.new(IO::NULL).tap { |l| l.level = Logger::INFO } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(logger: shared_logger, log_level: Logger::DEBUG)) - end - - it "does not mutate the shared logger" do - task.logger - expect(shared_logger.level).to eq(Logger::INFO) - end - - it "returns a different logger instance" do - expect(task.logger).not_to equal(shared_logger) - expect(task.logger.level).to eq(Logger::DEBUG) - end - end - - context "when log_formatter is customized" do - let(:shared_logger) { Logger.new(IO::NULL) } - let(:original_formatter) { shared_logger.formatter } - let(:custom_formatter) { proc { |_s, _d, _p, msg| "#{msg}\n" } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(logger: shared_logger, log_formatter: custom_formatter)) - end - - it "does not mutate the shared logger" do - task.logger - expect(shared_logger.formatter).to eq(original_formatter) - end - - it "returns a different logger instance" do - expect(task.logger).not_to equal(shared_logger) - expect(task.logger.formatter).to eq(custom_formatter) - end - end - end - - describe "#to_h" do - let(:workflow_class) { Class.new(task_class) { include CMDx::Workflow } } - let(:workflow_task) { workflow_class.new } - - before do - allow(task.result).to receive(:index).and_return(5) - allow(task.chain).to receive(:id).and_return("chain-123") - allow(task.class).to receive(:settings).and_return(mock_settings(tags: %w[tag1 tag2])) - end - - context "when task is regular task" do - it "returns hash representation" do - result_hash = task.to_h - - expect(result_hash[:index]).to eq(5) - expect(result_hash[:chain_id]).to eq("chain-123") - expect(result_hash[:type]).to eq("Task") - expect(result_hash[:tags]).to eq(%w[tag1 tag2]) - expect(result_hash[:class]).to be_a(String) - expect(result_hash[:class]).to match(/TestTask\d+/) - expect(result_hash[:dry_run]).to be(false) - expect(result_hash[:id]).to eq(task.id) - expect(result_hash).not_to have_key(:context) - end - end - - context "when dump_context is enabled globally" do - let(:task) { task_class.new(foo: "bar") } - - before do - allow(task.result).to receive(:index).and_return(5) - allow(task.chain).to receive(:id).and_return("chain-123") - CMDx.configuration.dump_context = true - end - - after { CMDx.configuration.dump_context = false } - - it "includes context in hash" do - expect(task.to_h[:context]).to eq(foo: "bar") - end - end - - context "when dump_context is enabled via task settings" do - let(:ctx_task_class) do - create_task_class(name: "CtxTask") do - settings dump_context: true - end - end - let(:task) { ctx_task_class.new(baz: 42) } - - before do - allow(task.result).to receive(:index).and_return(0) - allow(task.chain).to receive(:id).and_return("chain-456") - allow(task.class).to receive(:settings).and_return( - mock_settings(tags: [], dump_context: true) - ) - end - - it "includes context in hash" do - expect(task.to_h[:context]).to eq(baz: 42) - end - end - - context "when task is workflow task" do - before do - allow(workflow_task.result).to receive(:index).and_return(3) - allow(workflow_task.chain).to receive(:id).and_return("workflow-chain-456") - allow(workflow_task.class).to receive(:settings).and_return(mock_settings(tags: ["workflow"])) - end - - it "returns hash with type 'Workflow'" do - result_hash = workflow_task.to_h - - expect(result_hash[:index]).to eq(3) - expect(result_hash[:chain_id]).to eq("workflow-chain-456") - expect(result_hash[:type]).to eq("Workflow") - expect(result_hash[:tags]).to eq(["workflow"]) - expect(result_hash[:class]).to be_a(String) - expect(result_hash[:class]).to match(/TestTask\d+/) - expect(result_hash[:dry_run]).to be(false) - expect(result_hash[:id]).to eq(workflow_task.id) - end - end - end - - describe "#to_s" do - let(:hash_representation) { { key: "value", number: 42 } } - - before do - allow(task).to receive(:to_h).and_return(hash_representation) - end - - it "formats the hash using Utils::Format.to_str" do - expect(CMDx::Utils::Format).to receive(:to_str).with(hash_representation).and_return("formatted_string") - - result = task.to_s - - expect(result).to eq("formatted_string") - end - end -end diff --git a/spec/cmdx/utils/call_spec.rb b/spec/cmdx/utils/call_spec.rb deleted file mode 100644 index cef60a7ef..000000000 --- a/spec/cmdx/utils/call_spec.rb +++ /dev/null @@ -1,424 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Call, type: :unit do - subject(:call_module) { described_class } - - let(:target_object) do - Class.new do - def test_method(*args, **kwargs, &block) - result = { args: args, kwargs: kwargs } - result[:block_result] = yield if block - result - end - - def no_args_method - "no_args_result" - end - - def method_with_return_value(value) - "returned_#{value}" - end - - def method_with_side_effect - @side_effect_called = true - "side_effect_result" - end - - def side_effect_called? - @side_effect_called == true - end - end.new - end - - describe ".invoke" do - context "when callable is a Symbol" do - it "calls the method on target with no arguments" do - result = call_module.invoke(target_object, :no_args_method) - - expect(result).to eq("no_args_result") - end - - it "calls the method on target with positional arguments" do - result = call_module.invoke(target_object, :test_method, "arg1", "arg2") - - expect(result).to eq({ - args: %w[arg1 arg2], - kwargs: {} - }) - end - - it "calls the method on target with keyword arguments" do - result = call_module.invoke(target_object, :test_method, key1: "value1", key2: "value2") - - expect(result).to eq({ - args: [], - kwargs: { key1: "value1", key2: "value2" } - }) - end - - it "calls the method on target with both positional and keyword arguments" do - result = call_module.invoke(target_object, :test_method, "arg1", key: "value") - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" } - }) - end - - it "calls the method on target with a block" do - result = call_module.invoke(target_object, :test_method) { "block_result" } - - expect(result).to eq({ - args: [], - kwargs: {}, - block_result: "block_result" - }) - end - - it "calls the method on target with arguments and block" do - result = call_module.invoke(target_object, :test_method, "arg1", key: "value") { "block_result" } - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" }, - block_result: "block_result" - }) - end - - it "returns the method's return value" do - result = call_module.invoke(target_object, :method_with_return_value, "test") - - expect(result).to eq("returned_test") - end - - it "executes method with side effects" do - expect(target_object.side_effect_called?).to be(false) - - call_module.invoke(target_object, :method_with_side_effect) - - expect(target_object.side_effect_called?).to be(true) - end - end - - context "when callable is a Proc" do - it "executes proc in target context with no arguments" do - proc_callable = proc { no_args_method } - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq("no_args_result") - end - - it "executes proc in target context with positional arguments" do - proc_callable = proc { |arg1, arg2| test_method(arg1, arg2) } - - result = call_module.invoke(target_object, proc_callable, "arg1", "arg2") - - expect(result).to eq({ - args: %w[arg1 arg2], - kwargs: {} - }) - end - - it "executes proc in target context with keyword arguments" do - proc_callable = proc { |key1:, key2:| test_method(key1: key1, key2: key2) } - - result = call_module.invoke(target_object, proc_callable, key1: "value1", key2: "value2") - - expect(result).to eq({ - args: [], - kwargs: { key1: "value1", key2: "value2" } - }) - end - - it "executes proc in target context with mixed arguments" do - proc_callable = proc { |arg, key:| test_method(arg, key: key) } - - result = call_module.invoke(target_object, proc_callable, "arg1", key: "value") - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" } - }) - end - - it "executes proc with access to target instance variables" do - proc_callable = proc do - @test_var = "set_by_proc" - @test_var # rubocop:disable RSpec/InstanceVariable - end - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq("set_by_proc") - expect(target_object.instance_variable_get(:@test_var)).to eq("set_by_proc") - end - - it "executes proc with access to target methods" do - proc_callable = proc { method_with_return_value("from_proc") } - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq("returned_from_proc") - end - - it "executes proc with block" do - proc_callable = proc { test_method { "block_result" } } - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq({ - args: [], - kwargs: {}, - block_result: "block_result" - }) - end - - it "executes proc that modifies target state" do - proc_callable = proc { method_with_side_effect } - - expect(target_object.side_effect_called?).to be(false) - - call_module.invoke(target_object, proc_callable) - - expect(target_object.side_effect_called?).to be(true) - end - - it "handles proc that returns nil" do - proc_callable = proc {} - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to be_nil - end - - it "handles proc that raises an exception" do - proc_callable = proc { raise StandardError, "proc error" } - - expect do - call_module.invoke(target_object, proc_callable) - end.to raise_error(StandardError, "proc error") - end - end - - context "when callable responds to :call" do - let(:callable_object) do - Class.new do - def call(*args, **kwargs, &block) - result = { args: args, kwargs: kwargs } - result[:block_result] = yield if block - result - end - end.new - end - - it "calls the callable object with no arguments" do - result = call_module.invoke(target_object, callable_object) - - expect(result).to eq({ - args: [], - kwargs: {} - }) - end - - it "calls the callable object with positional arguments" do - result = call_module.invoke(target_object, callable_object, "arg1", "arg2") - - expect(result).to eq({ - args: %w[arg1 arg2], - kwargs: {} - }) - end - - it "calls the callable object with keyword arguments" do - result = call_module.invoke(target_object, callable_object, key1: "value1", key2: "value2") - - expect(result).to eq({ - args: [], - kwargs: { key1: "value1", key2: "value2" } - }) - end - - it "calls the callable object with mixed arguments" do - result = call_module.invoke(target_object, callable_object, "arg1", key: "value") - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" } - }) - end - - it "calls the callable object with a block" do - result = call_module.invoke(target_object, callable_object) { "block_result" } - - expect(result).to eq({ - args: [], - kwargs: {}, - block_result: "block_result" - }) - end - - it "returns the callable object's return value" do - return_value_callable = Class.new do - def call - "callable_result" - end - end.new - - result = call_module.invoke(target_object, return_value_callable) - - expect(result).to eq("callable_result") - end - - it "handles callable that returns nil" do - nil_callable = Class.new do - def call - nil - end - end.new - - result = call_module.invoke(target_object, nil_callable) - - expect(result).to be_nil - end - - it "handles callable that raises an exception" do - error_callable = Class.new do - def call - raise StandardError, "callable error" - end - end.new - - expect do - call_module.invoke(target_object, error_callable) - end.to raise_error(StandardError, "callable error") - end - end - - context "when callable is lambda" do - it "executes lambda in target context" do - lambda_callable = -> { no_args_method } - - result = call_module.invoke(target_object, lambda_callable) - - expect(result).to eq("no_args_result") - end - - it "executes lambda with strict argument checking" do - lambda_callable = ->(arg) { method_with_return_value(arg) } - - result = call_module.invoke(target_object, lambda_callable, "test") - - expect(result).to eq("returned_test") - end - - it "raises error when lambda argument count doesn't match" do - lambda_callable = ->(arg) { method_with_return_value(arg) } - - expect do - call_module.invoke(target_object, lambda_callable) - end.to raise_error(ArgumentError) - end - end - - context "when callable is invalid" do - it "raises error for string" do - expect do - call_module.invoke(target_object, "invalid_string") - end.to raise_error(/cannot invoke invalid_string/) - end - - it "raises error for integer" do - expect do - call_module.invoke(target_object, 42) - end.to raise_error(/cannot invoke 42/) - end - - it "raises error for array" do - expect do - call_module.invoke(target_object, [1, 2, 3]) - end.to raise_error(/cannot invoke \[1, 2, 3\]/) - end - - it "raises error for hash" do - if RubyVersion.min?(3.4) - expect do - call_module.invoke(target_object, { key: "value" }) - end.to raise_error(/cannot invoke {key: "value"}/) - else - expect do - call_module.invoke(target_object, { key: "value" }) - end.to raise_error(/cannot invoke {:key=>"value"}/) - end - end - - it "raises error for nil" do - expect do - call_module.invoke(target_object, nil) - end.to raise_error(/cannot invoke /) - end - - it "raises error for object that doesn't respond to call" do - non_callable = Class.new.new - - expect do - call_module.invoke(target_object, non_callable) - end.to raise_error(/cannot invoke/) - end - end - - context "when target raises NoMethodError for symbol callable" do - it "raises NoMethodError for undefined method" do - expect do - call_module.invoke(target_object, :undefined_method) - end.to raise_error(NoMethodError) - end - end - - context "with edge cases" do - it "handles empty symbol" do - empty_symbol = :"" - - expect do - call_module.invoke(target_object, empty_symbol) - end.to raise_error(NoMethodError) - end - - it "handles proc with default parameters" do - proc_with_defaults = proc { |arg = "default"| method_with_return_value(arg) } - - result = call_module.invoke(target_object, proc_with_defaults) - - expect(result).to eq("returned_default") - end - - it "handles proc with variable arguments" do - proc_with_splat = proc { |*args| test_method(*args) } - - result = call_module.invoke(target_object, proc_with_splat, "arg1", "arg2", "arg3") - - expect(result).to eq({ - args: %w[arg1 arg2 arg3], - kwargs: {} - }) - end - - it "handles callable with variable arguments" do - splat_callable = Class.new do - def call(*args, **kwargs) - { received_args: args, received_kwargs: kwargs } - end - end.new - - result = call_module.invoke(target_object, splat_callable, "a", "b", x: 1, y: 2) - - expect(result).to eq({ - received_args: %w[a b], - received_kwargs: { x: 1, y: 2 } - }) - end - end - end -end diff --git a/spec/cmdx/utils/condition_spec.rb b/spec/cmdx/utils/condition_spec.rb deleted file mode 100644 index cf21d20ae..000000000 --- a/spec/cmdx/utils/condition_spec.rb +++ /dev/null @@ -1,444 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Condition, type: :unit do - subject(:condition_module) { described_class } - - let(:target_object) do - Class.new do - def test_method(*args, **kwargs, &block) - result = { args: args, kwargs: kwargs } - result[:block_result] = yield if block - result - end - - def no_args_method - "no_args_result" - end - - def method_with_args(arg1, arg2) - "#{arg1}_#{arg2}" - end - - def method_with_kwargs(name:, value:) - "#{name}: #{value}" - end - - def truthy_method? - true - end - - def falsy_method? - false - end - - def instance_variable_check - @instance_variable_check ||= "instance_value" - end - - attr_accessor :accessible_value - end.new - end - - describe ".evaluate" do - context "when options contain if condition" do - context "with Symbol if condition" do - it "returns true when if condition evaluates to truthy" do - result = condition_module.evaluate(target_object, { if: :truthy_method? }) - - expect(result).to be(true) - end - - it "returns false when if condition evaluates to falsy" do - result = condition_module.evaluate(target_object, { if: :falsy_method? }) - - expect(result).to be(false) - end - - it "passes arguments to the if condition method" do - result = condition_module.evaluate(target_object, { if: :method_with_args }, "hello", "world") - - expect(result).to be_truthy - end - - it "passes keyword arguments to the if condition method" do - result = condition_module.evaluate(target_object, { if: :method_with_kwargs }, name: "test", value: "data") - - expect(result).to be_truthy - end - - it "passes block to the if condition method" do - expect(target_object).to receive(:test_method).and_return(true) - - condition_module.evaluate(target_object, { if: :test_method }) { "block_value" } - end - end - - context "with Proc if condition" do - it "returns true when Proc evaluates to truthy" do - truthy_proc = proc { true } - result = condition_module.evaluate(target_object, { if: truthy_proc }) - - expect(result).to be(true) - end - - it "returns false when Proc evaluates to falsy" do - falsy_proc = proc { false } - result = condition_module.evaluate(target_object, { if: falsy_proc }) - - expect(result).to be(false) - end - - it "executes Proc in the context of target object" do - context_proc = proc { instance_variable_check } - result = condition_module.evaluate(target_object, { if: context_proc }) - - expect(result).to be_truthy - end - - it "passes arguments to the Proc" do - arg_proc = proc { |arg1, arg2| arg1 == "hello" && arg2 == "world" } - result = condition_module.evaluate(target_object, { if: arg_proc }, "hello", "world") - - expect(result).to be(true) - end - - it "passes keyword arguments to the Proc" do - kwarg_proc = proc { |name:, value:| name == "test" && value == "data" } - result = condition_module.evaluate(target_object, { if: kwarg_proc }, name: "test", value: "data") - - expect(result).to be(true) - end - end - - context "with callable object if condition" do - let(:callable_object) do - Class.new do - def call(*_args, **_kwargs, &) - true - end - end.new - end - - let(:falsy_callable_object) do - Class.new do - def call(*_args, **_kwargs, &) - false - end - end.new - end - - it "returns true when callable returns truthy" do - result = condition_module.evaluate(target_object, { if: callable_object }) - - expect(result).to be(true) - end - - it "returns false when callable returns falsy" do - result = condition_module.evaluate(target_object, { if: falsy_callable_object }) - - expect(result).to be(false) - end - - it "passes arguments to the callable" do - arg_callable = Class.new do - def call(arg1, arg2) - arg1 == "hello" && arg2 == "world" - end - end.new - - result = condition_module.evaluate(target_object, { if: arg_callable }, "hello", "world") - - expect(result).to be(true) - end - end - - context "with boolean if condition" do - it "returns true when if condition is true" do - result = condition_module.evaluate(target_object, { if: true }) - - expect(result).to be(true) - end - - it "returns false when if condition is false" do - result = condition_module.evaluate(target_object, { if: false }) - - expect(result).to be(false) - end - - it "returns false when if condition is nil" do - result = condition_module.evaluate(target_object, { if: nil }) - - expect(result).to be(false) - end - end - end - - context "when options contain unless condition" do - context "with Symbol unless condition" do - it "returns false when unless condition evaluates to truthy" do - result = condition_module.evaluate(target_object, { unless: :truthy_method? }) - - expect(result).to be(false) - end - - it "returns true when unless condition evaluates to falsy" do - result = condition_module.evaluate(target_object, { unless: :falsy_method? }) - - expect(result).to be(true) - end - - it "passes arguments to the unless condition method" do - result = condition_module.evaluate(target_object, { unless: :method_with_args }, "hello", "world") - - expect(result).to be_falsy - end - end - - context "with Proc unless condition" do - it "returns false when Proc evaluates to truthy" do - truthy_proc = proc { true } - result = condition_module.evaluate(target_object, { unless: truthy_proc }) - - expect(result).to be(false) - end - - it "returns true when Proc evaluates to falsy" do - falsy_proc = proc { false } - result = condition_module.evaluate(target_object, { unless: falsy_proc }) - - expect(result).to be(true) - end - end - - context "with boolean unless condition" do - it "returns false when unless condition is true" do - result = condition_module.evaluate(target_object, { unless: true }) - - expect(result).to be(false) - end - - it "returns true when unless condition is false" do - result = condition_module.evaluate(target_object, { unless: false }) - - expect(result).to be(true) - end - - it "returns true when unless condition is nil" do - result = condition_module.evaluate(target_object, { unless: nil }) - - expect(result).to be(true) - end - end - end - - context "when options contain both if and unless conditions" do - it "returns true when if is truthy and unless is falsy" do - result = condition_module.evaluate(target_object, { if: :truthy_method?, unless: :falsy_method? }) - - expect(result).to be(true) - end - - it "returns false when if is truthy and unless is truthy" do - result = condition_module.evaluate(target_object, { if: :truthy_method?, unless: :truthy_method? }) - - expect(result).to be(false) - end - - it "returns false when if is falsy and unless is falsy" do - result = condition_module.evaluate(target_object, { if: :falsy_method?, unless: :falsy_method? }) - - expect(result).to be(false) - end - - it "returns false when if is falsy and unless is truthy" do - result = condition_module.evaluate(target_object, { if: :falsy_method?, unless: :truthy_method? }) - - expect(result).to be(false) - end - - it "passes arguments to both conditions" do - if_proc = proc { |arg| arg == "test" } - unless_proc = proc { |arg| arg == "fail" } - - result = condition_module.evaluate(target_object, { if: if_proc, unless: unless_proc }, "test") - - expect(result).to be(true) - end - end - - context "when options contain neither if nor unless" do - it "returns true for empty options" do - result = condition_module.evaluate(target_object, {}) - - expect(result).to be(true) - end - - it "returns true for options with other keys" do - result = condition_module.evaluate(target_object, { other_key: "value" }) - - expect(result).to be(true) - end - end - - context "with invalid callable objects" do - let(:invalid_callable) do - Object.new - end - - it "raises an error when if condition is not callable" do - expect do - condition_module.evaluate(target_object, { if: invalid_callable }) - end.to raise_error(RuntimeError, /cannot evaluate/) - end - - it "raises an error when unless condition is not callable" do - expect do - condition_module.evaluate(target_object, { unless: invalid_callable }) - end.to raise_error(RuntimeError, /cannot evaluate/) - end - - it "includes the invalid object in the error message" do - expect do - condition_module.evaluate(target_object, { if: invalid_callable }) - end.to raise_error(RuntimeError, /#{Regexp.escape(invalid_callable.inspect)}/) - end - end - - context "when target object doesn't respond to method" do - it "raises NoMethodError for Symbol condition" do - expect do - condition_module.evaluate(target_object, { if: :nonexistent_method }) - end.to raise_error(NoMethodError) - end - end - end - - describe "EVAL constant" do - let(:eval_proc) { described_class.const_get(:EVAL) } - - context "when callable is NilClass" do - it "returns false" do - result = eval_proc.call(target_object, nil) - - expect(result).to be(false) - end - end - - context "when callable is FalseClass" do - it "returns false" do - result = eval_proc.call(target_object, false) - - expect(result).to be(false) - end - end - - context "when callable is TrueClass" do - it "returns true" do - result = eval_proc.call(target_object, true) - - expect(result).to be(true) - end - end - - context "when callable is Symbol" do - it "calls the method on target" do - result = eval_proc.call(target_object, :no_args_method) - - expect(result).to eq("no_args_result") - end - - it "passes arguments to the method" do - result = eval_proc.call(target_object, :method_with_args, "hello", "world") - - expect(result).to eq("hello_world") - end - - it "passes keyword arguments to the method" do - result = eval_proc.call(target_object, :method_with_kwargs, name: "test", value: "data") - - expect(result).to eq("test: data") - end - - it "passes block to the method" do - result = eval_proc.call(target_object, :test_method) { "block_value" } - - expect(result[:block_result]).to eq("block_value") - end - end - - context "when callable is Proc" do - it "executes the Proc in target context" do - test_proc = proc { instance_variable_check } - result = eval_proc.call(target_object, test_proc) - - expect(result).to eq("instance_value") - end - - it "passes arguments to the Proc" do - arg_proc = proc { |arg1, arg2| "#{arg1}_#{arg2}" } - result = eval_proc.call(target_object, arg_proc, "hello", "world") - - expect(result).to eq("hello_world") - end - - it "passes keyword arguments to the Proc" do - kwarg_proc = proc { |name:, value:| "#{name}: #{value}" } - result = eval_proc.call(target_object, kwarg_proc, name: "test", value: "data") - - expect(result).to eq("test: data") - end - end - - context "when callable has call method" do - let(:callable_object) do - Class.new do - def call(*args, **kwargs, &block) - { args: args, kwargs: kwargs, block_called: block&.call } - end - end.new - end - - it "calls the call method" do - result = eval_proc.call(target_object, callable_object) - - expect(result).to eq({ args: [], kwargs: {}, block_called: nil }) - end - - it "passes arguments to the call method" do - result = eval_proc.call(target_object, callable_object, "arg1", "arg2") - - expect(result).to eq({ args: %w[arg1 arg2], kwargs: {}, block_called: nil }) - end - - it "passes keyword arguments to the call method" do - result = eval_proc.call(target_object, callable_object, name: "test", value: "data") - - expect(result).to eq({ args: [], kwargs: { name: "test", value: "data" }, block_called: nil }) - end - - it "passes block to the call method" do - result = eval_proc.call(target_object, callable_object) { "block_value" } - - expect(result).to eq({ args: [], kwargs: {}, block_called: "block_value" }) - end - end - - context "when callable doesn't respond to call" do - let(:invalid_object) { Object.new } - - it "raises an error" do - expect do - eval_proc.call(target_object, invalid_object) - end.to raise_error(RuntimeError, /cannot evaluate/) - end - - it "includes the object in the error message" do - expect do - eval_proc.call(target_object, invalid_object) - end.to raise_error(RuntimeError, /#{Regexp.escape(invalid_object.inspect)}/) - end - end - end -end diff --git a/spec/cmdx/utils/format_spec.rb b/spec/cmdx/utils/format_spec.rb deleted file mode 100644 index 0d43e1587..000000000 --- a/spec/cmdx/utils/format_spec.rb +++ /dev/null @@ -1,289 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Format, type: :unit do - subject(:format_module) { described_class } - - describe ".to_log" do - context "when message is a CMDx object with to_h method" do - let(:cmdx_context) { CMDx::Context.new(user_id: 123, name: "test") } - let(:task_class) do - Class.new(CMDx::Task) do - def work - # no-op - end - end - end - let(:task) { task_class.new } - - it "returns the hash representation of CMDx::Context" do - result = format_module.to_log(cmdx_context) - - expect(result).to eq({ user_id: 123, name: "test" }) - end - - it "returns the hash representation of CMDx::Task" do - result = format_module.to_log(task) - - expect(result).to be_a(Hash) - expect(result).to include(:class, :index, :chain_id, :type, :tags, :id) - end - - it "returns the hash representation of CMDx::Chain" do - chain = CMDx::Chain.new - - result = format_module.to_log(chain) - - expect(result).to be_a(Hash) - expect(result).to include(:id, :results) - end - - it "returns the hash representation of CMDx::Result" do - task = task_class.new - result_obj = CMDx::Result.new(task) - - result = format_module.to_log(result_obj) - - expect(result).to be_a(Hash) - expect(result).to include(:state, :status, :outcome, :metadata) - end - - it "returns the hash representation of CMDx::Errors" do - errors = CMDx::Errors.new - errors.add(:name, "can't be blank") - - result = format_module.to_log(errors) - - expect(result).to eq({ name: ["can't be blank"] }) - end - end - - context "when message responds to to_h but is not a CMDx object" do - let(:non_cmdx_object) do - Class.new do - def to_h - { key: "value" } - end - - def class - Class.new do - def ancestors - [Object, BasicObject] - end - end.new - end - end.new - end - - it "returns the original message unchanged" do - result = format_module.to_log(non_cmdx_object) - - expect(result).to eq(non_cmdx_object) - end - end - - context "when message does not respond to to_h" do - it "returns string message unchanged" do - message = "Simple log message" - - result = format_module.to_log(message) - - expect(result).to eq("Simple log message") - end - - it "returns integer message unchanged" do - message = 42 - - result = format_module.to_log(message) - - expect(result).to eq(42) - end - - it "returns array message unchanged" do - message = [1, 2, 3] - - result = format_module.to_log(message) - - expect(result).to eq([1, 2, 3]) - end - - it "returns hash message unchanged" do - message = { key: "value" } - - result = format_module.to_log(message) - - expect(result).to eq({ key: "value" }) - end - - it "returns nil message unchanged" do - result = format_module.to_log(nil) - - expect(result).to be_nil - end - end - - context "when message responds to to_h but ancestors check returns false" do - let(:object_with_to_h) do - obj = Object.new - def obj.to_h - { custom: "hash" } - end - obj - end - - it "returns the original message unchanged" do - result = format_module.to_log(object_with_to_h) - - expect(result).to eq(object_with_to_h) - end - end - end - - describe ".to_str" do - context "when using default formatter" do - let(:hash) { { name: "John", age: 30, active: true } } - - it "formats hash using default key=value.inspect format" do - result = format_module.to_str(hash) - - expect(result).to eq('name="John" age=30 active=true') - end - - it "handles empty hash" do - result = format_module.to_str({}) - - expect(result).to eq("") - end - - it "handles hash with symbol keys" do - hash = { user_id: 123, email: "test@example.com" } - - result = format_module.to_str(hash) - - expect(result).to eq('user_id=123 email="test@example.com"') - end - - it "handles hash with string keys" do - hash = { "name" => "Alice", "role" => "admin" } - - result = format_module.to_str(hash) - - expect(result).to eq('name="Alice" role="admin"') - end - - it "handles hash with mixed value types" do - hash = { id: 1, name: "Test", data: [1, 2, 3], meta: { nested: true } } - - result = format_module.to_str(hash) - - if RubyVersion.min?(3.4) - expect(result).to eq('id=1 name="Test" data=[1, 2, 3] meta={nested: true}') - else - expect(result).to eq('id=1 name="Test" data=[1, 2, 3] meta={:nested=>true}') - end - end - - it "handles hash with nil values" do - hash = { name: "John", email: nil, age: 25 } - - result = format_module.to_str(hash) - - expect(result).to eq('name="John" email=nil age=25') - end - end - - context "when using custom formatter block" do - let(:hash) { { name: "John", age: 30, city: "NYC" } } - - it "uses the provided block for formatting" do - result = format_module.to_str(hash) { |key, value| "#{key}: #{value}" } - - expect(result).to eq("name: John age: 30 city: NYC") - end - - it "allows custom separator in block" do - result = format_module.to_str(hash) { |key, value| "#{key}=#{value}" } - - expect(result).to eq("name=John age=30 city=NYC") - end - - it "handles complex formatting logic in block" do - result = format_module.to_str(hash) do |key, value| - case key - when :name then "USER: #{value.upcase}" - when :age then "AGE: #{value} years" - else "#{key.upcase}: #{value}" - end - end - - expect(result).to eq("USER: JOHN AGE: 30 years CITY: NYC") - end - - it "handles block that returns nil" do - result = format_module.to_str(hash) { |_key, _value| nil } - - expect(result).to eq(" ") - end - - it "handles block that returns empty string" do - result = format_module.to_str(hash) { |_key, _value| "" } - - expect(result).to eq(" ") - end - end - - context "with edge cases" do - it "handles hash with special characters in values" do - hash = { message: "Hello\nWorld", path: "/tmp/test file.txt" } - - result = format_module.to_str(hash) - - expect(result).to eq('message="Hello\nWorld" path="/tmp/test file.txt"') - end - - it "handles hash with unicode characters" do - hash = { name: "José", emoji: "🚀", chinese: "测试" } - - result = format_module.to_str(hash) - - expect(result).to eq('name="José" emoji="🚀" chinese="测试"') - end - - it "handles large hash efficiently" do - large_hash = (1..100).to_h { |i| ["key#{i}", "value#{i}"] } - - expect { format_module.to_str(large_hash) }.not_to raise_error - end - end - end - - describe "FORMATTER constant" do - it "is private" do - expect { described_class::FORMATTER }.to raise_error(NameError) - end - - it "is frozen" do - formatter = described_class.send(:const_get, :FORMATTER) - - expect(formatter).to be_frozen - end - - it "formats key-value pairs correctly" do - formatter = described_class.send(:const_get, :FORMATTER) - - result = formatter.call(:name, "John") - - expect(result).to eq('name="John"') - end - - it "handles different value types" do - formatter = described_class.send(:const_get, :FORMATTER) - - expect(formatter.call(:id, 123)).to eq("id=123") - expect(formatter.call(:active, true)).to eq("active=true") - expect(formatter.call(:data, nil)).to eq("data=nil") - expect(formatter.call(:items, [1, 2])).to eq("items=[1, 2]") - end - end -end diff --git a/spec/cmdx/utils/normalize_spec.rb b/spec/cmdx/utils/normalize_spec.rb deleted file mode 100644 index 5e27b6c8e..000000000 --- a/spec/cmdx/utils/normalize_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Normalize, type: :unit do - subject(:normalize_module) { described_class } - - describe ".exception" do - context "when given a standard error" do - it "returns class and message in bracket format" do - error = StandardError.new("something went wrong") - result = normalize_module.exception(error) - - expect(result).to eq("[StandardError] something went wrong") - end - end - - context "when given a custom error class" do - it "includes the full class name" do - error = ArgumentError.new("bad argument") - result = normalize_module.exception(error) - - expect(result).to eq("[ArgumentError] bad argument") - end - end - - context "when given an error with an empty message" do - it "returns the class with an empty message" do - error = RuntimeError.new("") - result = normalize_module.exception(error) - - expect(result).to eq("[RuntimeError] ") - end - end - end - - describe ".statuses" do - context "when object is an array of symbols" do - it "returns unique string representations" do - result = normalize_module.statuses(%i[success pending success]) - - expect(result).to eq(%w[success pending]) - end - end - - context "when object is an array of strings" do - it "returns unique strings" do - result = normalize_module.statuses(%w[success pending success]) - - expect(result).to eq(%w[success pending]) - end - end - - context "when object is an array of mixed types" do - it "converts all to strings and deduplicates" do - result = normalize_module.statuses([:success, "success"]) - - expect(result).to eq(["success"]) - end - end - - context "when object is a single symbol" do - it "returns a single-element string array" do - result = normalize_module.statuses(:success) - - expect(result).to eq(["success"]) - end - end - - context "when object is a single string" do - it "returns a single-element string array" do - result = normalize_module.statuses("pending") - - expect(result).to eq(["pending"]) - end - end - - context "when object is nil" do - it "returns an empty array" do - result = normalize_module.statuses(nil) - - expect(result).to eq([]) - end - end - - context "when object is an empty array" do - it "returns an empty array" do - result = normalize_module.statuses([]) - - expect(result).to eq([]) - end - end - end -end diff --git a/spec/cmdx/utils/wrap_spec.rb b/spec/cmdx/utils/wrap_spec.rb deleted file mode 100644 index a7129ee49..000000000 --- a/spec/cmdx/utils/wrap_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Wrap, type: :unit do - subject(:wrap_module) { described_class } - - describe ".array" do - context "when object is already an array" do - it "returns the same array" do - array = [1, 2, 3] - result = wrap_module.array(array) - - expect(result).to be(array) - end - - it "returns an empty array as-is" do - result = wrap_module.array([]) - - expect(result).to eq([]) - end - end - - context "when object is nil" do - it "returns an empty array" do - result = wrap_module.array(nil) - - expect(result).to eq([]) - end - end - - context "when object is a single value" do - it "wraps an integer" do - result = wrap_module.array(1) - - expect(result).to eq([1]) - end - - it "wraps a string" do - result = wrap_module.array("hello") - - expect(result).to eq(["hello"]) - end - - it "wraps a symbol" do - result = wrap_module.array(:foo) - - expect(result).to eq([:foo]) - end - end - - context "when object is a hash" do - it "wraps the hash in an array" do - result = wrap_module.array({ a: 1, b: 2 }) - - expect(result).to eq([[:a, 1], [:b, 2]]) - end - end - end -end diff --git a/spec/cmdx/validator_registry_spec.rb b/spec/cmdx/validator_registry_spec.rb deleted file mode 100644 index cf0d308da..000000000 --- a/spec/cmdx/validator_registry_spec.rb +++ /dev/null @@ -1,433 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::ValidatorRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { nil } - let(:mock_validator) { instance_double("MockValidator") } - let(:mock_task) { instance_double(CMDx::Task) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with default coercions" do - expect(registry.registry).to include( - exclusion: CMDx::Validators::Exclusion, - format: CMDx::Validators::Format, - inclusion: CMDx::Validators::Inclusion, - length: CMDx::Validators::Length, - numeric: CMDx::Validators::Numeric, - presence: CMDx::Validators::Presence - ) - end - end - - context "when a registry is provided" do - let(:initial_registry) { { custom: mock_validator } } - - it "initializes with the provided registry" do - expect(registry.registry).to eq({ custom: mock_validator }) - end - end - end - - describe "#registry" do - let(:initial_registry) { { custom: mock_validator } } - - it "returns the internal registry hash" do - expect(registry.registry).to eq({ custom: mock_validator }) - end - end - - describe "#to_h" do - let(:initial_registry) { { custom: mock_validator } } - - it "returns the registry hash" do - expect(registry.to_h).to eq({ custom: mock_validator }) - end - - it "is an alias for registry" do - expect(registry.method(:to_h)).to eq(registry.method(:registry)) - end - end - - describe "#keys" do - let(:initial_registry) { { custom: mock_validator, another: mock_validator } } - - it "returns the keys from the registry" do - expect(registry.keys).to match_array(%i[custom another]) - end - - it "delegates to the registry hash" do - allow(registry.registry).to receive(:keys).and_return([:delegated]) - - expect(registry.keys).to eq([:delegated]) - end - end - - describe "#dup" do - it "returns a new ValidatorRegistry instance" do - duplicated = registry.dup - - expect(duplicated).to be_a(described_class) - expect(duplicated).not_to be(registry) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) - end - - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - - duplicated.register(:new_type, mock_validator) - - expect(duplicated.registry).to have_key(:new_type) - expect(registry.registry).not_to have_key(:new_type) - expect(duplicated.registry).not_to be(registry.registry) - end - end - - describe "#register" do - context "when registering a validator with string name" do - it "adds the validator to the registry with symbol key" do - registry.register("custom", mock_validator) - - expect(registry.registry[:custom]).to eq(mock_validator) - end - - it "returns self for method chaining" do - result = registry.register("custom", mock_validator) - - expect(result).to be(registry) - end - end - - context "when registering a validator with symbol name" do - it "adds the validator to the registry" do - registry.register(:custom, mock_validator) - - expect(registry.registry[:custom]).to eq(mock_validator) - end - end - - context "when registering to an existing registry" do - let(:initial_registry) { { existing: "existing_validator" } } - - it "adds new validator to existing ones" do - registry.register(:new_type, mock_validator) - - expect(registry.registry).to include(existing: "existing_validator", new_type: mock_validator) - end - end - - context "when registering over an existing validator" do - let(:initial_registry) { { existing: "old_validator" } } - - it "overwrites the existing validator" do - registry.register(:existing, mock_validator) - - expect(registry.registry[:existing]).to eq(mock_validator) - end - end - end - - describe "#deregister" do - context "when deregistering with string name" do - before do - registry.register("custom", mock_validator) - end - - it "removes the validator from the registry" do - registry.deregister("custom") - - expect(registry.registry).not_to have_key(:custom) - end - - it "returns self for method chaining" do - result = registry.deregister("custom") - - expect(result).to be(registry) - end - end - - context "when deregistering with symbol name" do - before do - registry.register(:custom, mock_validator) - end - - it "removes the validator from the registry" do - registry.deregister(:custom) - - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering from existing registry" do - let(:initial_registry) { { existing: "existing_validator", custom: mock_validator } } - - it "removes only the specified validator" do - registry.deregister(:custom) - - expect(registry.registry).to include(existing: "existing_validator") - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering non-existent validator" do - it "does not raise an error" do - expect { registry.deregister(:nonexistent) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:nonexistent) - - expect(result).to be(registry) - end - - it "does not affect existing validators" do - registry.register(:existing, mock_validator) - registry.deregister(:nonexistent) - - expect(registry.registry[:existing]).to eq(mock_validator) - end - end - - context "when deregistering from empty registry" do - let(:initial_registry) { {} } - - it "does not raise an error" do - expect { registry.deregister(:custom) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:custom) - - expect(result).to be(registry) - end - end - - context "when deregistering default validators" do - subject(:registry) { described_class.new } - - it "removes the default validator" do - registry.deregister(:presence) - - expect(registry.registry).not_to have_key(:presence) - end - - it "does not affect other default validators" do - registry.deregister(:presence) - - expect(registry.registry).to have_key(:format) - expect(registry.registry).to have_key(:length) - end - end - end - - describe "#validate" do - let(:initial_registry) { { custom: mock_validator } } - let(:value) { "test_value" } - let(:options) { { option1: "value1" } } - - before do - allow(CMDx::Utils::Call).to receive(:invoke).and_return("validation_result") - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - end - - context "when validator type exists" do - context "with hash options" do - it "evaluates condition and calls Utils::Call.invoke when condition is true" do - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, options, value) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - - context "when condition evaluates to false" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(false) - end - - it "does not call the validator" do - expect(CMDx::Utils::Call).not_to receive(:invoke) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns nil" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to be_nil - end - end - - context "with allow_nil: true" do - context "when value is nil" do - let(:value) { nil } - let(:options) { { allow_nil: true } } - - it "skips the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).not_to receive(:invoke) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns nil" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to be_nil - end - end - - context "when value is not nil" do - let(:options) { { allow_nil: true } } - - it "calls the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - end - end - - context "with allow_nil: false" do - context "when value is nil" do - let(:value) { nil } - let(:options) { { allow_nil: false } } - - it "calls the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - end - - context "when value is not nil" do - let(:options) { { allow_nil: false } } - - it "calls the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - end - end - end - - context "with non-hash options" do - let(:options) { true } - - it "uses options directly as condition" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - context "when options is false" do - let(:options) { false } - - it "does not call the validator" do - expect(CMDx::Utils::Call).not_to receive(:invoke) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns nil" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to be_nil - end - end - end - - context "with string type name" do - let(:initial_registry) { { "custom" => mock_validator } } - - it "works with string type names" do - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, options, value) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate("custom", mock_task, value, options) - end - end - end - - context "when options are not provided" do - it "passes empty hash as options and evaluates condition" do - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, {}, value) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, {} - ) - - registry.validate(:custom, mock_task, value) - end - end - - context "when validator type does not exist" do - it "raises TypeError with descriptive message" do - expect { registry.validate(:nonexistent, mock_task, value) } - .to raise_error(TypeError, "unknown validator type :nonexistent") - end - - it "raises TypeError for string type names" do - expect { registry.validate("string", mock_task, value) } - .to raise_error(TypeError, 'unknown validator type "string"') - end - end - - context "when type is nil" do - it "raises TypeError" do - expect { registry.validate(nil, mock_task, value) } - .to raise_error(TypeError, "unknown validator type nil") - end - end - end -end diff --git a/spec/cmdx/validators/absence_spec.rb b/spec/cmdx/validators/absence_spec.rb deleted file mode 100644 index 052b8bbbb..000000000 --- a/spec/cmdx/validators/absence_spec.rb +++ /dev/null @@ -1,154 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Absence, type: :unit do - subject(:validator) { described_class } - - describe ".call" do - context "when value is absent" do - context "with string values" do - it "does not raise error for empty strings" do - expect { validator.call("") }.not_to raise_error - end - - it "does not raise error for whitespace-only strings" do - expect { validator.call(" ") }.not_to raise_error - expect { validator.call("\t\n\r ") }.not_to raise_error - end - end - - context "with objects responding to empty?" do - it "does not raise error for empty arrays" do - expect { validator.call([]) }.not_to raise_error - end - - it "does not raise error for empty hashes" do - expect { validator.call({}) }.not_to raise_error - end - - it "does not raise error for empty string-like objects" do - empty_obj = Object.new - def empty_obj.empty? = true - expect { validator.call(empty_obj) }.not_to raise_error - end - end - - context "with nil values" do - it "does not raise error for nil" do - expect { validator.call(nil) }.not_to raise_error - end - end - end - - context "when value is present" do - context "with string values" do - it "raises ValidationError for non-whitespace strings" do - expect { validator.call("hello") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("a") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("123") } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for strings with mixed whitespace and content" do - expect { validator.call(" hello ") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("\thello\n") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(" a ") } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end - - context "with objects responding to empty?" do - it "raises ValidationError for non-empty arrays" do - expect { validator.call([1, 2, 3]) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(["a"]) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for non-empty hashes" do - expect { validator.call({ a: 1 }) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call({ "key" => "value" }) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for non-empty string-like objects" do - string_obj = Object.new - def string_obj.empty? = false - expect { validator.call(string_obj) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end - - context "with objects not responding to empty?" do - it "raises ValidationError for non-nil values" do - expect { validator.call(42) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(true) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(false) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(0) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for objects" do - expect { validator.call(Object.new) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(Date.today) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end - end - - context "with custom message option" do - let(:custom_message) { "must be blank" } - let(:options) { { message: custom_message } } - - it "uses custom message for present strings" do - expect { validator.call("hello", options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for present arrays" do - expect { validator.call([1], options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for present objects" do - expect { validator.call(true, options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "does not raise error for absent values" do - expect { validator.call(nil, options) }.not_to raise_error - expect { validator.call("", options) }.not_to raise_error - end - end - - context "without options" do - it "does not raise error when no options provided for absent value" do - expect { validator.call("") }.not_to raise_error - end - - it "raises error with default message when no options provided for present value" do - expect { validator.call("hello") } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end - - context "with non-hash options" do - it "ignores non-hash options and uses default message" do - expect { validator.call("hello", "invalid_options") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("hello", 123) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end - end -end diff --git a/spec/cmdx/validators/exclusion_spec.rb b/spec/cmdx/validators/exclusion_spec.rb deleted file mode 100644 index db983f124..000000000 --- a/spec/cmdx/validators/exclusion_spec.rb +++ /dev/null @@ -1,350 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Exclusion, type: :unit do - subject(:validator) { described_class } - - describe ".call" do - context "with array exclusions" do - context "when using :in option" do - let(:options) { { in: %w[admin root system] } } - - context "when value is excluded" do - it "raises ValidationError with default message" do - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin", "root", "system"') - end - - it "raises ValidationError for any excluded value" do - %w[admin root system].each do |excluded_value| - expect { validator.call(excluded_value, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin", "root", "system"') - end - end - end - - context "when value is not excluded" do - it "does not raise error for allowed values" do - expect { validator.call("user", options) }.not_to raise_error - expect { validator.call("guest", options) }.not_to raise_error - expect { validator.call("", options) }.not_to raise_error - end - end - - context "with custom :of_message" do - let(:options) { { in: %w[reserved blocked], of_message: "is a reserved username" } } - - it "uses custom message" do - expect { validator.call("reserved", options) } - .to raise_error(CMDx::ValidationError, "is a reserved username") - end - end - - context "with custom :message" do - let(:options) { { in: %w[forbidden], message: "cannot be used" } } - - it "uses custom message" do - expect { validator.call("forbidden", options) } - .to raise_error(CMDx::ValidationError, "cannot be used") - end - end - - context "with message interpolation" do - let(:options) { { in: %w[bad evil], of_message: "value %s not allowed" } } - - it "interpolates values into custom message" do - expect { validator.call("bad", options) } - .to raise_error(CMDx::ValidationError, 'value "bad", "evil" not allowed') - end - end - end - - context "when using :within option" do - let(:options) { { within: %w[admin root] } } - - context "when value is excluded" do - it "raises ValidationError with default message" do - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin", "root"') - end - end - - context "when value is not excluded" do - it "does not raise error" do - expect { validator.call("user", options) }.not_to raise_error - end - end - end - - context "with different data types" do - context "with integers" do - let(:options) { { in: [1, 2, 3] } } - - it "excludes integer values" do - expect { validator.call(2, options) } - .to raise_error(CMDx::ValidationError, "must not be one of: 1, 2, 3") - end - - it "allows non-excluded integers" do - expect { validator.call(4, options) }.not_to raise_error - end - end - - context "with symbols" do - let(:options) { { in: %i[pending cancelled] } } - - it "excludes symbol values" do - expect { validator.call(:pending, options) } - .to raise_error(CMDx::ValidationError, "must not be one of: :pending, :cancelled") - end - - it "allows non-excluded symbols" do - expect { validator.call(:active, options) }.not_to raise_error - end - end - - context "with mixed types" do - let(:options) { { in: ["string", 42, :symbol] } } - - it "excludes string value" do - expect { validator.call("string", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "string", 42, :symbol') - end - - it "excludes integer value" do - expect { validator.call(42, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "string", 42, :symbol') - end - - it "excludes symbol value" do - expect { validator.call(:symbol, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "string", 42, :symbol') - end - end - end - - context "with case-sensitive comparison" do - let(:options) { { in: %w[Admin ROOT] } } - - it "is case sensitive" do - expect { validator.call("admin", options) }.not_to raise_error - expect { validator.call("root", options) }.not_to raise_error - expect { validator.call("Admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "Admin", "ROOT"') - end - end - end - - context "with range exclusions" do - context "with integer range" do - let(:options) { { in: (1..10) } } - - context "when value is within range" do - it "raises ValidationError with default message" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - end - - it "raises error for boundary values" do - expect { validator.call(1, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - end - end - - context "when value is outside range" do - it "does not raise error" do - expect { validator.call(0, options) }.not_to raise_error - expect { validator.call(11, options) }.not_to raise_error - expect { validator.call(-5, options) }.not_to raise_error - end - end - - context "with custom :in_message" do - let(:options) { { in: (18..65), in_message: "age restricted" } } - - it "uses custom message" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "age restricted") - end - end - - context "with custom :within_message" do - let(:options) { { within: (1..5), within_message: "not in allowed range" } } - - it "uses custom message" do - expect { validator.call(3, options) } - .to raise_error(CMDx::ValidationError, "not in allowed range") - end - end - - context "with message interpolation" do - let(:options) { { in: (1..10), in_message: "between %s and %s not allowed" } } - - it "interpolates min and max into custom message" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "between 1 and 10 not allowed") - end - end - end - - context "with exclusive range" do - let(:options) { { in: (1...10) } } - - it "excludes values within range but not the end" do - expect { validator.call(9, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - expect { validator.call(10, options) }.not_to raise_error - end - end - - context "with string range" do - let(:options) { { in: ("a".."z") } } - - it "excludes values within string range" do - expect { validator.call("m", options) } - .to raise_error(CMDx::ValidationError, "must not be within a and z") - expect { validator.call("A", options) }.not_to raise_error - end - end - - context "with date range" do - let(:start_date) { Date.new(2023, 1, 1) } - let(:end_date) { Date.new(2023, 12, 31) } - let(:options) { { in: (start_date..end_date) } } - - it "excludes dates within range" do - test_date = Date.new(2023, 6, 15) - expect { validator.call(test_date, options) } - .to raise_error(CMDx::ValidationError, "must not be within #{start_date} and #{end_date}") - end - - it "allows dates outside range" do - expect { validator.call(Date.new(2022, 12, 31), options) }.not_to raise_error - expect { validator.call(Date.new(2024, 1, 1), options) }.not_to raise_error - end - end - end - - context "with edge cases" do - context "when exclusion list is empty" do - let(:options) { { in: [] } } - - it "does not raise error for any value" do - expect { validator.call("anything", options) }.not_to raise_error - expect { validator.call(123, options) }.not_to raise_error - expect { validator.call(nil, options) }.not_to raise_error - end - end - - context "when exclusion list is nil" do - let(:options) { { in: nil } } - - it "does not raise error for any value" do - expect { validator.call("anything", options) }.not_to raise_error - expect { validator.call(123, options) }.not_to raise_error - end - end - - context "when testing nil value" do - let(:options) { { in: [nil, "null"] } } - - it "excludes nil when explicitly included" do - expect { validator.call(nil, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: nil, "null"') - end - - it "allows nil when not in exclusion list" do - expect { validator.call(nil, { in: ["other"] }) }.not_to raise_error - end - end - - context "with object comparison using ===" do - let(:regex_pattern) { /admin/ } - let(:options) { { in: [regex_pattern] } } - - it "uses === for comparison with regex" do - expect { validator.call("admin_user", options) } - .to raise_error(CMDx::ValidationError) - expect { validator.call("user", options) }.not_to raise_error - end - end - - context "when no exclusion option provided" do - let(:options) { {} } - - it "does not raise error" do - expect { validator.call("anything", options) }.not_to raise_error - end - end - - context "with both :in and :within options" do - let(:options) { { in: %w[admin], within: %w[root] } } - - it "uses :in option when both are present" do - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin"') - expect { validator.call("root", options) }.not_to raise_error - end - end - end - - context "with custom message priority" do - context "when multiple message options are provided" do - let(:options) do - { - in: %w[test], - message: "generic message", - of_message: "specific of message" - } - end - - it "prioritizes of_message over message for arrays" do - expect { validator.call("test", options) } - .to raise_error(CMDx::ValidationError, "specific of message") - end - end - - context "with range and multiple message options" do - let(:options) do - { - in: (1..5), - message: "generic message", - in_message: "specific in message", - within_message: "specific within message" - } - end - - it "prioritizes in_message over within_message and message for ranges" do - expect { validator.call(3, options) } - .to raise_error(CMDx::ValidationError, "specific in message") - end - end - end - - context "with internationalization" do - it "calls Locale.t for default array exclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.exclusion.of", - values: '"admin"' - ).and_return("localized message") - - expect { validator.call("admin", { in: %w[admin] }) } - .to raise_error(CMDx::ValidationError, "localized message") - end - - it "calls Locale.t for default range exclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.exclusion.within", - min: 1, - max: 10 - ).and_return("localized range message") - - expect { validator.call(5, { in: (1..10) }) } - .to raise_error(CMDx::ValidationError, "localized range message") - end - end - end -end diff --git a/spec/cmdx/validators/format_spec.rb b/spec/cmdx/validators/format_spec.rb deleted file mode 100644 index 61f154199..000000000 --- a/spec/cmdx/validators/format_spec.rb +++ /dev/null @@ -1,279 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Format, type: :unit do - subject(:validator) { described_class } - - describe ".call" do - context "with direct Regexp argument" do - it "validates value against the regex pattern" do - expect { validator.call("hello", /\A[a-z]+\z/) }.not_to raise_error - expect { validator.call("123", /\A\d+\z/) }.not_to raise_error - expect { validator.call("test@example.com", /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i) }.not_to raise_error - end - - it "raises ValidationError when value doesn't match pattern" do - expect { validator.call("Hello", /\A[a-z]+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("abc", /\A\d+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("invalid-email", /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "handles complex regex patterns" do - phone_regex = /\A\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\z/ - expect { validator.call("123-456-7890", phone_regex) }.not_to raise_error - expect { validator.call("(123) 456-7890", phone_regex) }.not_to raise_error - expect { validator.call("123-45-6789", phone_regex) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "works with edge cases" do - expect { validator.call("", /\A.*\z/) }.not_to raise_error - expect { validator.call("", /\A.+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call(nil, /\A.+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "with :with option" do - let(:options) { { with: /\A[a-z]+\z/ } } - - context "when value matches pattern" do - it "does not raise error for matching values" do - expect { validator.call("hello", options) }.not_to raise_error - expect { validator.call("world", options) }.not_to raise_error - expect { validator.call("test", options) }.not_to raise_error - end - end - - context "when value does not match pattern" do - it "raises ValidationError with default message" do - expect { validator.call("Hello", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "raises ValidationError for various invalid formats" do - ["Hello", "123", "test_case", ""].each do |invalid_value| - expect { validator.call(invalid_value, options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - end - - context "with custom message" do - let(:options) { { with: /\A\d+\z/, message: "must contain only digits" } } - - it "uses custom message when validation fails" do - expect { validator.call("abc", options) } - .to raise_error(CMDx::ValidationError, "must contain only digits") - end - end - - context "with email pattern" do - let(:options) { { with: /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i } } - - it "validates email format correctly" do - expect { validator.call("user@example.com", options) }.not_to raise_error - expect { validator.call("test.email+tag@domain.co.uk", options) }.not_to raise_error - end - - it "rejects invalid email formats" do - expect { validator.call("invalid-email", options) } - .to raise_error(CMDx::ValidationError) - expect { validator.call("@example.com", options) } - .to raise_error(CMDx::ValidationError) - end - end - end - - context "with :without option" do - let(:options) { { without: /[^a-zA-Z]/ } } - - context "when value does not match forbidden pattern" do - it "does not raise error for valid values" do - expect { validator.call("Hello", options) }.not_to raise_error - expect { validator.call("World", options) }.not_to raise_error - expect { validator.call("", options) }.not_to raise_error - end - end - - context "when value matches forbidden pattern" do - it "raises ValidationError with default message" do - expect { validator.call("hello123", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "raises ValidationError for various invalid formats" do - ["test_case", "hello!", "123", "hello world"].each do |invalid_value| - expect { validator.call(invalid_value, options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - end - - context "with custom message" do - let(:options) { { without: /\d/, message: "cannot contain numbers" } } - - it "uses custom message when validation fails" do - expect { validator.call("test123", options) } - .to raise_error(CMDx::ValidationError, "cannot contain numbers") - end - end - end - - context "with both :with and :without options" do - let(:options) { { with: /\A[a-zA-Z]+\z/, without: /[A-Z]{2,}/ } } - - context "when value matches :with and does not match :without" do - it "does not raise error for valid values" do - expect { validator.call("Hello", options) }.not_to raise_error - expect { validator.call("world", options) }.not_to raise_error - expect { validator.call("Test", options) }.not_to raise_error - end - end - - context "when value does not match :with pattern" do - it "raises ValidationError" do - expect { validator.call("hello123", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("test_case", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "when value matches :without pattern" do - it "raises ValidationError for forbidden patterns" do - expect { validator.call("HELLO", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("TEST", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "when value fails both conditions" do - it "raises ValidationError" do - expect { validator.call("HELLO123", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "with custom message" do - let(:options) do - { - with: /\A[a-z]+\z/, - without: /test/, - message: "must be lowercase letters without 'test'" - } - end - - it "uses custom message when validation fails" do - expect { validator.call("testing", options) } - .to raise_error(CMDx::ValidationError, "must be lowercase letters without 'test'") - end - end - end - - context "without any pattern options" do - let(:options) { {} } - - it "always raises ValidationError" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - context "with custom message" do - let(:options) { { message: "no pattern specified" } } - - it "uses custom message" do - expect { validator.call("test", options) } - .to raise_error(CMDx::ValidationError, "no pattern specified") - end - end - end - - context "with complex regex patterns" do - context "when validating phone numbers" do - let(:options) { { with: /\A\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\z/ } } - - it "validates various phone number formats" do - valid_numbers = [ - "123-456-7890", - "(123) 456-7890", - "123.456.7890", - "1234567890", - "+1-123-456-7890" - ] - - valid_numbers.each do |number| - expect { validator.call(number, options) }.not_to raise_error - end - end - - it "rejects invalid phone number formats" do - invalid_numbers = %w[ - 123-45-6789 - abc-def-ghij - 123-456-78901 - ] - - invalid_numbers.each do |number| - expect { validator.call(number, options) } - .to raise_error(CMDx::ValidationError) - end - end - end - - context "when validating hexadecimal colors" do - let(:options) { { with: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/ } } - - it "validates hex color codes" do - expect { validator.call("#FF0000", options) }.not_to raise_error - expect { validator.call("#fff", options) }.not_to raise_error - expect { validator.call("#123abc", options) }.not_to raise_error - end - - it "rejects invalid hex color codes" do - expect { validator.call("FF0000", options) } - .to raise_error(CMDx::ValidationError) - expect { validator.call("#gg0000", options) } - .to raise_error(CMDx::ValidationError) - end - end - end - - context "with string patterns" do - let(:options) { { with: "test" } } - - it "treats string patterns as regex" do - expect { validator.call("testing", options) }.not_to raise_error - expect { validator.call("retest", options) }.not_to raise_error - end - - it "raises error when string pattern not found" do - expect { validator.call("hello", options) } - .to raise_error(CMDx::ValidationError) - end - end - - context "with edge case values" do - let(:options) { { with: /\A.+\z/ } } - - it "handles empty strings" do - expect { validator.call("", { with: /\A.*\z/ }) }.not_to raise_error - expect { validator.call("", options) } - .to raise_error(CMDx::ValidationError) - end - - it "handles very long strings" do - long_string = "a" * 10_000 - expect { validator.call(long_string, options) }.not_to raise_error - end - end - end -end diff --git a/spec/cmdx/validators/inclusion_spec.rb b/spec/cmdx/validators/inclusion_spec.rb deleted file mode 100644 index 54350069f..000000000 --- a/spec/cmdx/validators/inclusion_spec.rb +++ /dev/null @@ -1,359 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Inclusion, type: :unit do - subject(:validator) { described_class } - - describe ".call" do - context "with array inclusions" do - context "when using :in option" do - let(:options) { { in: %w[admin user guest] } } - - context "when value is included" do - it "does not raise error for included values" do - expect { validator.call("admin", options) }.not_to raise_error - expect { validator.call("user", options) }.not_to raise_error - expect { validator.call("guest", options) }.not_to raise_error - end - end - - context "when value is not included" do - it "raises ValidationError with default message" do - expect { validator.call("root", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "admin", "user", "guest"') - end - - it "raises ValidationError for any non-included value" do - %w[root system moderator].each do |excluded_value| - expect { validator.call(excluded_value, options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "admin", "user", "guest"') - end - end - end - - context "with custom :of_message" do - let(:options) { { in: %w[active inactive], of_message: "must be a valid status" } } - - it "uses custom message" do - expect { validator.call("pending", options) } - .to raise_error(CMDx::ValidationError, "must be a valid status") - end - end - - context "with custom :message" do - let(:options) { { in: %w[red green blue], message: "invalid color" } } - - it "uses custom message" do - expect { validator.call("yellow", options) } - .to raise_error(CMDx::ValidationError, "invalid color") - end - end - - context "with message interpolation" do - let(:options) { { in: %w[small medium large], of_message: "size must be %s" } } - - it "interpolates values into custom message" do - expect { validator.call("extra_large", options) } - .to raise_error(CMDx::ValidationError, 'size must be "small", "medium", "large"') - end - end - end - - context "when using :within option" do - let(:options) { { within: %w[draft published] } } - - context "when value is included" do - it "does not raise error" do - expect { validator.call("draft", options) }.not_to raise_error - expect { validator.call("published", options) }.not_to raise_error - end - end - - context "when value is not included" do - it "raises ValidationError with default message" do - expect { validator.call("archived", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "draft", "published"') - end - end - end - - context "with different data types" do - context "with integers" do - let(:options) { { in: [1, 2, 3] } } - - it "includes integer values" do - expect { validator.call(2, options) }.not_to raise_error - end - - it "raises error for non-included integers" do - expect { validator.call(4, options) } - .to raise_error(CMDx::ValidationError, "must be one of: 1, 2, 3") - end - end - - context "with symbols" do - let(:options) { { in: %i[pending active completed] } } - - it "includes symbol values" do - expect { validator.call(:pending, options) }.not_to raise_error - end - - it "raises error for non-included symbols" do - expect { validator.call(:cancelled, options) } - .to raise_error(CMDx::ValidationError, "must be one of: :pending, :active, :completed") - end - end - - context "with mixed types" do - let(:options) { { in: ["string", 42, :symbol] } } - - it "includes string value" do - expect { validator.call("string", options) }.not_to raise_error - end - - it "includes integer value" do - expect { validator.call(42, options) }.not_to raise_error - end - - it "includes symbol value" do - expect { validator.call(:symbol, options) }.not_to raise_error - end - - it "raises error for non-included values" do - expect { validator.call("other", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "string", 42, :symbol') - end - end - end - - context "with case-sensitive comparison" do - let(:options) { { in: %w[Admin ROOT] } } - - it "is case sensitive" do - expect { validator.call("Admin", options) }.not_to raise_error - expect { validator.call("ROOT", options) }.not_to raise_error - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "Admin", "ROOT"') - expect { validator.call("root", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "Admin", "ROOT"') - end - end - end - - context "with range inclusions" do - context "with integer range" do - let(:options) { { in: (1..10) } } - - context "when value is within range" do - it "does not raise error for values in range" do - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(1, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - end - end - - context "when value is outside range" do - it "raises ValidationError with default message" do - expect { validator.call(0, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - expect { validator.call(11, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - expect { validator.call(-5, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - end - - context "with custom :in_message" do - let(:options) { { in: (18..65), in_message: "age must be valid" } } - - it "uses custom message" do - expect { validator.call(17, options) } - .to raise_error(CMDx::ValidationError, "age must be valid") - end - end - - context "with custom :within_message" do - let(:options) { { within: (1..5), within_message: "must be in allowed range" } } - - it "uses custom message" do - expect { validator.call(6, options) } - .to raise_error(CMDx::ValidationError, "must be in allowed range") - end - end - - context "with message interpolation" do - let(:options) { { in: (1..10), in_message: "must be between %s and %s" } } - - it "interpolates min and max into custom message" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "must be between 1 and 10") - end - end - end - - context "with exclusive range" do - let(:options) { { in: (1...10) } } - - it "includes values within range but not the end" do - expect { validator.call(9, options) }.not_to raise_error - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - end - - context "with string range" do - let(:options) { { in: ("a".."z") } } - - it "includes values within string range" do - expect { validator.call("m", options) }.not_to raise_error - expect { validator.call("A", options) } - .to raise_error(CMDx::ValidationError, "must be within a and z") - end - end - - context "with date range" do - let(:start_date) { Date.new(2023, 1, 1) } - let(:end_date) { Date.new(2023, 12, 31) } - let(:options) { { in: (start_date..end_date) } } - - it "includes dates within range" do - test_date = Date.new(2023, 6, 15) - expect { validator.call(test_date, options) }.not_to raise_error - end - - it "raises error for dates outside range" do - expect { validator.call(Date.new(2022, 12, 31), options) } - .to raise_error(CMDx::ValidationError, "must be within #{start_date} and #{end_date}") - expect { validator.call(Date.new(2024, 1, 1), options) } - .to raise_error(CMDx::ValidationError, "must be within #{start_date} and #{end_date}") - end - end - end - - context "with edge cases" do - context "when inclusion list is empty" do - let(:options) { { in: [] } } - - it "raises error for any value" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - expect { validator.call(123, options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - expect { validator.call(nil, options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - end - end - - context "when inclusion list is nil" do - let(:options) { { in: nil } } - - it "raises error for any value" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - expect { validator.call(123, options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - end - end - - context "when testing nil value" do - let(:options) { { in: [nil, "null"] } } - - it "includes nil when explicitly included" do - expect { validator.call(nil, options) }.not_to raise_error - end - - it "raises error for nil when not in inclusion list" do - expect { validator.call(nil, { in: ["other"] }) } - .to raise_error(CMDx::ValidationError, 'must be one of: "other"') - end - end - - context "with object comparison using ===" do - let(:regex_pattern) { /admin/ } - let(:options) { { in: [regex_pattern] } } - - it "uses === for comparison with regex" do - expect { validator.call("admin_user", options) }.not_to raise_error - expect { validator.call("user", options) } - .to raise_error(CMDx::ValidationError) - end - end - - context "when no inclusion option provided" do - let(:options) { {} } - - it "raises error for any value" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - end - end - - context "with both :in and :within options" do - let(:options) { { in: %w[admin], within: %w[root] } } - - it "uses :in option when both are present" do - expect { validator.call("admin", options) }.not_to raise_error - expect { validator.call("root", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "admin"') - end - end - end - - context "with custom message priority" do - context "when multiple message options are provided" do - let(:options) do - { - in: %w[test], - message: "generic message", - of_message: "specific of message" - } - end - - it "prioritizes of_message over message for arrays" do - expect { validator.call("invalid", options) } - .to raise_error(CMDx::ValidationError, "specific of message") - end - end - - context "with range and multiple message options" do - let(:options) do - { - in: (1..5), - message: "generic message", - in_message: "specific in message", - within_message: "specific within message" - } - end - - it "prioritizes in_message over within_message and message for ranges" do - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "specific in message") - end - end - end - - context "with internationalization" do - it "calls Locale.t for default array inclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.inclusion.of", - values: '"valid"' - ).and_return("localized message") - - expect { validator.call("invalid", { in: %w[valid] }) } - .to raise_error(CMDx::ValidationError, "localized message") - end - - it "calls Locale.t for default range inclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.inclusion.within", - min: 1, - max: 10 - ).and_return("localized range message") - - expect { validator.call(15, { in: (1..10) }) } - .to raise_error(CMDx::ValidationError, "localized range message") - end - end - end -end diff --git a/spec/cmdx/validators/length_spec.rb b/spec/cmdx/validators/length_spec.rb deleted file mode 100644 index 8a6d296d5..000000000 --- a/spec/cmdx/validators/length_spec.rb +++ /dev/null @@ -1,431 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Length, type: :unit do - subject(:validator) { described_class } - - describe ".call" do - context "with :within option" do - let(:options) { { within: (3..10) } } - - context "when value length is within range" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("test", options) }.not_to raise_error - expect { validator.call("1234567890", options) }.not_to raise_error - end - end - - context "when value length is outside range" do - it "raises ValidationError for lengths below minimum" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 10") - end - - it "raises ValidationError for lengths above maximum" do - expect { validator.call("12345678901", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 10") - end - end - - context "with custom :within_message" do - let(:options) { { within: (5..15), within_message: "must be between %s and %s characters" } } - - it "uses custom message with interpolation" do - expect { validator.call("abc", options) } - .to raise_error(CMDx::ValidationError, "must be between 5 and 15 characters") - end - end - - context "with custom :message" do - let(:options) { { within: (2..5), message: "invalid length" } } - - it "uses custom message" do - expect { validator.call("toolong", options) } - .to raise_error(CMDx::ValidationError, "invalid length") - end - end - end - - context "with :not_within option" do - let(:options) { { not_within: (5..8) } } - - context "when value length is outside forbidden range" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("123456789", options) }.not_to raise_error - end - end - - context "when value length is within forbidden range" do - it "raises ValidationError for forbidden lengths" do - expect { validator.call("12345", options) } - .to raise_error(CMDx::ValidationError, "length must not be within 5 and 8") - - expect { validator.call("123456", options) } - .to raise_error(CMDx::ValidationError, "length must not be within 5 and 8") - end - end - - context "with custom :not_within_message" do - let(:options) { { not_within: (3..6), not_within_message: "cannot be %s-%s chars" } } - - it "uses custom message with interpolation" do - expect { validator.call("test", options) } - .to raise_error(CMDx::ValidationError, "cannot be 3-6 chars") - end - end - end - - context "with :in option" do - let(:options) { { in: (2..7) } } - - context "when value length is in range" do - it "does not raise error for valid lengths" do - expect { validator.call("ab", options) }.not_to raise_error - expect { validator.call("1234567", options) }.not_to raise_error - end - end - - context "when value length is outside range" do - it "raises ValidationError for invalid lengths" do - expect { validator.call("a", options) } - .to raise_error(CMDx::ValidationError, "length must be within 2 and 7") - - expect { validator.call("12345678", options) } - .to raise_error(CMDx::ValidationError, "length must be within 2 and 7") - end - end - - context "with custom :in_message" do - let(:options) { { in: (1..3), in_message: "should be %s to %s long" } } - - it "uses custom message with interpolation" do - expect { validator.call("toolong", options) } - .to raise_error(CMDx::ValidationError, "should be 1 to 3 long") - end - end - end - - context "with :not_in option" do - let(:options) { { not_in: (4..6) } } - - context "when value length is outside forbidden range" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("1234567", options) }.not_to raise_error - end - end - - context "when value length is in forbidden range" do - it "raises ValidationError for forbidden lengths" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must not be within 4 and 6") - end - end - - context "with custom :not_in_message" do - let(:options) { { not_in: (2..4), not_in_message: "forbidden range %s-%s" } } - - it "uses custom message with interpolation" do - expect { validator.call("abc", options) } - .to raise_error(CMDx::ValidationError, "forbidden range 2-4") - end - end - end - - context "with :min and :max options" do - let(:options) { { min: 3, max: 8 } } - - context "when value length is within bounds" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("test", options) }.not_to raise_error - expect { validator.call("12345678", options) }.not_to raise_error - end - end - - context "when value length is outside bounds" do - it "raises ValidationError for lengths below minimum" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 8") - end - - it "raises ValidationError for lengths above maximum" do - expect { validator.call("123456789", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 8") - end - end - end - - context "with :min option only" do - let(:options) { { min: 5 } } - - context "when value length meets minimum" do - it "does not raise error for valid lengths" do - expect { validator.call("12345", options) }.not_to raise_error - expect { validator.call("123456789", options) }.not_to raise_error - end - end - - context "when value length is below minimum" do - it "raises ValidationError" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must be at least 5") - end - end - - context "with custom :min_message" do - let(:options) { { min: 3, min_message: "too short, needs %s+ chars" } } - - it "uses custom message with interpolation" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "too short, needs 3+ chars") - end - end - end - - context "with :max option only" do - let(:options) { { max: 6 } } - - context "when value length is within maximum" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("123456", options) }.not_to raise_error - end - end - - context "when value length exceeds maximum" do - it "raises ValidationError" do - expect { validator.call("1234567", options) } - .to raise_error(CMDx::ValidationError, "length must be at most 6") - end - end - - context "with custom :max_message" do - let(:options) { { max: 4, max_message: "too long, max %s chars" } } - - it "uses custom message with interpolation" do - expect { validator.call("12345", options) } - .to raise_error(CMDx::ValidationError, "too long, max 4 chars") - end - end - end - - context "with :is option" do - let(:options) { { is: 5 } } - - context "when value length matches exactly" do - it "does not raise error for exact length" do - expect { validator.call("12345", options) }.not_to raise_error - expect { validator.call("hello", options) }.not_to raise_error - end - end - - context "when value length does not match" do - it "raises ValidationError for shorter values" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must be 5") - end - - it "raises ValidationError for longer values" do - expect { validator.call("123456", options) } - .to raise_error(CMDx::ValidationError, "length must be 5") - end - end - - context "with custom :is_message" do - let(:options) { { is: 3, is_message: "must be exactly %s characters" } } - - it "uses custom message with interpolation" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "must be exactly 3 characters") - end - end - end - - context "with :is_not option" do - let(:options) { { is_not: 4 } } - - context "when value length is not the forbidden length" do - it "does not raise error for different lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("12345", options) }.not_to raise_error - end - end - - context "when value length matches forbidden length" do - it "raises ValidationError" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must not be 4") - end - end - - context "with custom :is_not_message" do - let(:options) { { is_not: 2, is_not_message: "cannot be %s chars long" } } - - it "uses custom message with interpolation" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "cannot be 2 chars long") - end - end - end - - context "with custom message priority" do - context "when multiple message options are provided for :within" do - let(:options) do - { - within: (2..5), - message: "generic message", - within_message: "specific within message" - } - end - - it "prioritizes specific message over generic" do - expect { validator.call("a", options) } - .to raise_error(CMDx::ValidationError, "specific within message") - end - end - - context "when multiple message options are provided for :min" do - let(:options) do - { - min: 3, - message: "generic message", - min_message: "specific min message" - } - end - - it "prioritizes specific message over generic" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "specific min message") - end - end - end - - context "with edge cases" do - context "when value is empty string" do - it "validates length correctly" do - expect { validator.call("", { min: 1 }) } - .to raise_error(CMDx::ValidationError, "length must be at least 1") - - expect { validator.call("", { is: 0 }) }.not_to raise_error - - expect { validator.call("", { max: 5 }) }.not_to raise_error - end - end - - context "when value is array" do - it "validates array length" do - expect { validator.call([1, 2, 3], { min: 2 }) }.not_to raise_error - expect { validator.call([1], { min: 2 }) } - .to raise_error(CMDx::ValidationError, "length must be at least 2") - end - end - - context "when value is hash" do - it "validates hash length" do - expect { validator.call({ a: 1, b: 2 }, { max: 3 }) }.not_to raise_error - expect { validator.call({ a: 1, b: 2, c: 3, d: 4 }, { max: 3 }) } - .to raise_error(CMDx::ValidationError, "length must be at most 3") - end - end - - context "when range has equal bounds" do - let(:options) { { within: (5..5) } } - - it "validates single-value range correctly" do - expect { validator.call("12345", options) }.not_to raise_error - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must be within 5 and 5") - end - end - end - - context "with invalid options" do - it "raises ArgumentError for unknown options" do - expect { validator.call("test", { unknown: true }) } - .to raise_error(ArgumentError, "unknown length validator options given") - end - - it "raises ArgumentError for empty options" do - expect { validator.call("test", {}) } - .to raise_error(ArgumentError, "unknown length validator options given") - end - end - - context "with internationalization" do - it "calls Locale.t for default within message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.within", - min: 3, - max: 5 - ).and_return("localized within message") - - expect { validator.call("a", { within: (3..5) }) } - .to raise_error(CMDx::ValidationError, "localized within message") - end - - it "calls Locale.t for default not_within message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.not_within", - min: 3, - max: 5 - ).and_return("localized not_within message") - - expect { validator.call("test", { not_within: (3..5) }) } - .to raise_error(CMDx::ValidationError, "localized not_within message") - end - - it "calls Locale.t for default min message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.min", - min: 3 - ).and_return("localized min message") - - expect { validator.call("ab", { min: 3 }) } - .to raise_error(CMDx::ValidationError, "localized min message") - end - - it "calls Locale.t for default max message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.max", - max: 3 - ).and_return("localized max message") - - expect { validator.call("toolong", { max: 3 }) } - .to raise_error(CMDx::ValidationError, "localized max message") - end - - it "calls Locale.t for default is message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.is", - is: 5 - ).and_return("localized is message") - - expect { validator.call("ab", { is: 5 }) } - .to raise_error(CMDx::ValidationError, "localized is message") - end - - it "calls Locale.t for default is_not message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.is_not", - is_not: 4 - ).and_return("localized is_not message") - - expect { validator.call("test", { is_not: 4 }) } - .to raise_error(CMDx::ValidationError, "localized is_not message") - end - - context "when custom message is provided without interpolation" do - it "does not call string interpolation for custom message" do - custom_message = "fixed custom message" - - expect { validator.call("a", { min: 3, min_message: custom_message }) } - .to raise_error(CMDx::ValidationError, custom_message) - end - end - end - end -end diff --git a/spec/cmdx/validators/numeric_spec.rb b/spec/cmdx/validators/numeric_spec.rb deleted file mode 100644 index 51909b0a6..000000000 --- a/spec/cmdx/validators/numeric_spec.rb +++ /dev/null @@ -1,379 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Numeric, type: :unit do - subject(:validator) { described_class } - - describe ".call" do - context "with within option" do - let(:options) { { within: 1..10 } } - - context "when value is within range" do - it "does not raise error for values within range" do - expect { validator.call(1, options) }.not_to raise_error - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - end - end - - context "when value is not within range" do - it "raises ValidationError with default message for below range" do - expect { validator.call(0, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - - it "raises ValidationError with default message for above range" do - expect { validator.call(11, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - end - - context "with custom within_message" do - let(:options) { { within: 1..10, within_message: "value must be between %s and %s" } } - - it "uses custom message with interpolation" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "value must be between 1 and 10") - end - end - - context "with custom message" do - let(:options) { { within: 1..10, message: "invalid range" } } - - it "uses custom message without interpolation" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "invalid range") - end - end - end - - context "with in option" do - let(:options) { { in: 5..15 } } - - context "when value is in range" do - it "does not raise error for values in range" do - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - end - end - - context "when value is not in range" do - it "raises ValidationError with default message" do - expect { validator.call(4, options) } - .to raise_error(CMDx::ValidationError, "must be within 5 and 15") - end - end - - context "with custom in_message" do - let(:options) { { in: 5..15, in_message: "must be from %s to %s" } } - - it "uses custom in_message with interpolation" do - expect { validator.call(20, options) } - .to raise_error(CMDx::ValidationError, "must be from 5 to 15") - end - end - end - - context "with not_within option" do - let(:options) { { not_within: 5..10 } } - - context "when value is not within excluded range" do - it "does not raise error for values outside range" do - expect { validator.call(4, options) }.not_to raise_error - expect { validator.call(11, options) }.not_to raise_error - expect { validator.call(1, options) }.not_to raise_error - expect { validator.call(100, options) }.not_to raise_error - end - end - - context "when value is within excluded range" do - it "raises ValidationError with default message" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "must not be within 5 and 10") - expect { validator.call(7, options) } - .to raise_error(CMDx::ValidationError, "must not be within 5 and 10") - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "must not be within 5 and 10") - end - end - - context "with custom not_within_message" do - let(:options) { { not_within: 5..10, not_within_message: "cannot be between %s and %s" } } - - it "uses custom not_within_message with interpolation" do - expect { validator.call(8, options) } - .to raise_error(CMDx::ValidationError, "cannot be between 5 and 10") - end - end - end - - context "with not_in option" do - let(:options) { { not_in: 20..30 } } - - context "when value is not in excluded range" do - it "does not raise error for values outside range" do - expect { validator.call(19, options) }.not_to raise_error - expect { validator.call(31, options) }.not_to raise_error - end - end - - context "when value is in excluded range" do - it "raises ValidationError with default message" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "must not be within 20 and 30") - end - end - - context "with custom not_in_message" do - let(:options) { { not_in: 20..30, not_in_message: "value forbidden between %s and %s" } } - - it "uses custom not_in_message with interpolation" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "value forbidden between 20 and 30") - end - end - end - - context "with min and max options" do - let(:options) { { min: 5, max: 15 } } - - context "when value is between min and max" do - it "does not raise error for values in range" do - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - end - end - - context "when value is below min" do - it "raises ValidationError with within message" do - expect { validator.call(4, options) } - .to raise_error(CMDx::ValidationError, "must be within 5 and 15") - end - end - - context "when value is above max" do - it "raises ValidationError with within message" do - expect { validator.call(16, options) } - .to raise_error(CMDx::ValidationError, "must be within 5 and 15") - end - end - end - - context "with min option only" do - let(:options) { { min: 10 } } - - context "when value meets minimum" do - it "does not raise error for values at or above minimum" do - expect { validator.call(10, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - expect { validator.call(100, options) }.not_to raise_error - end - end - - context "when value is below minimum" do - it "raises ValidationError with min message" do - expect { validator.call(9, options) } - .to raise_error(CMDx::ValidationError, "must be at least 10") - end - end - - context "with custom min_message" do - let(:options) { { min: 10, min_message: "cannot be less than %s" } } - - it "uses custom min_message with interpolation" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "cannot be less than 10") - end - end - end - - context "with max option only" do - let(:options) { { max: 20 } } - - context "when value meets maximum" do - it "does not raise error for values at or below maximum" do - expect { validator.call(20, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - expect { validator.call(1, options) }.not_to raise_error - end - end - - context "when value exceeds maximum" do - it "raises ValidationError with max message" do - expect { validator.call(21, options) } - .to raise_error(CMDx::ValidationError, "must be at most 20") - end - end - - context "with custom max_message" do - let(:options) { { max: 20, max_message: "cannot exceed %s" } } - - it "uses custom max_message with interpolation" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "cannot exceed 20") - end - end - end - - context "with is option" do - let(:options) { { is: 42 } } - - context "when value equals expected value" do - it "does not raise error for exact match" do - expect { validator.call(42, options) }.not_to raise_error - end - end - - context "when value does not equal expected value" do - it "raises ValidationError with is message" do - expect { validator.call(41, options) } - .to raise_error(CMDx::ValidationError, "must be 42") - expect { validator.call(43, options) } - .to raise_error(CMDx::ValidationError, "must be 42") - end - end - - context "with custom is_message" do - let(:options) { { is: 42, is_message: "value must equal %s" } } - - it "uses custom is_message with interpolation" do - expect { validator.call(50, options) } - .to raise_error(CMDx::ValidationError, "value must equal 42") - end - end - end - - context "with is_not option" do - let(:options) { { is_not: 13 } } - - context "when value does not equal forbidden value" do - it "does not raise error for different values" do - expect { validator.call(12, options) }.not_to raise_error - expect { validator.call(14, options) }.not_to raise_error - expect { validator.call(100, options) }.not_to raise_error - end - end - - context "when value equals forbidden value" do - it "raises ValidationError with is_not message" do - expect { validator.call(13, options) } - .to raise_error(CMDx::ValidationError, "must not be 13") - end - end - - context "with custom is_not_message" do - let(:options) { { is_not: 13, is_not_message: "cannot be %s" } } - - it "uses custom is_not_message with interpolation" do - expect { validator.call(13, options) } - .to raise_error(CMDx::ValidationError, "cannot be 13") - end - end - end - - context "with decimal values" do - context "with within option" do - let(:options) { { within: 1.5..10.5 } } - - it "validates decimal values correctly" do - expect { validator.call(1.5, options) }.not_to raise_error - expect { validator.call(5.7, options) }.not_to raise_error - expect { validator.call(10.5, options) }.not_to raise_error - - expect { validator.call(1.4, options) } - .to raise_error(CMDx::ValidationError, "must be within 1.5 and 10.5") - expect { validator.call(10.6, options) } - .to raise_error(CMDx::ValidationError, "must be within 1.5 and 10.5") - end - end - end - - context "with negative values" do - context "with min option" do - let(:options) { { min: -10 } } - - it "validates negative values correctly" do - expect { validator.call(-10, options) }.not_to raise_error - expect { validator.call(-5, options) }.not_to raise_error - expect { validator.call(0, options) }.not_to raise_error - - expect { validator.call(-11, options) } - .to raise_error(CMDx::ValidationError, "must be at least -10") - end - end - end - - context "with unknown options" do - it "raises ArgumentError for unrecognized options" do - expect { validator.call(5, { unknown: "option" }) } - .to raise_error(ArgumentError, "unknown numeric validator options given") - end - end - - context "with empty options" do - it "raises ArgumentError for empty options hash" do - expect { validator.call(5, {}) } - .to raise_error(ArgumentError, "unknown numeric validator options given") - end - end - - context "with global custom message" do - context "when using within option" do - let(:options) { { within: 1..10, message: "global error message" } } - - it "uses global message when specific message not provided" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "global error message") - end - end - - context "when using min option" do - let(:options) { { min: 10, message: "global min error" } } - - it "uses global message for min validation" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "global min error") - end - end - - context "when using max option" do - let(:options) { { max: 10, message: "global max error" } } - - it "uses global message for max validation" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "global max error") - end - end - - context "when using is option" do - let(:options) { { is: 42, message: "global is error" } } - - it "uses global message for is validation" do - expect { validator.call(50, options) } - .to raise_error(CMDx::ValidationError, "global is error") - end - end - - context "when using is_not option" do - let(:options) { { is_not: 13, message: "global is_not error" } } - - it "uses global message for is_not validation" do - expect { validator.call(13, options) } - .to raise_error(CMDx::ValidationError, "global is_not error") - end - end - - context "when using not_within option" do - let(:options) { { not_within: 5..10, message: "global not_within error" } } - - it "uses global message for not_within validation" do - expect { validator.call(7, options) } - .to raise_error(CMDx::ValidationError, "global not_within error") - end - end - end - end -end diff --git a/spec/cmdx/validators/presence_spec.rb b/spec/cmdx/validators/presence_spec.rb deleted file mode 100644 index 2bb389dbd..000000000 --- a/spec/cmdx/validators/presence_spec.rb +++ /dev/null @@ -1,165 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Validators::Presence, type: :unit do - subject(:validator) { described_class } - - describe ".call" do - context "when value is present" do - context "with string values" do - it "does not raise error for non-whitespace strings" do - expect { validator.call("hello") }.not_to raise_error - expect { validator.call("a") }.not_to raise_error - expect { validator.call("123") }.not_to raise_error - end - - it "does not raise error for strings with mixed whitespace and content" do - expect { validator.call(" hello ") }.not_to raise_error - expect { validator.call("\thello\n") }.not_to raise_error - expect { validator.call(" a ") }.not_to raise_error - end - end - - context "with objects responding to empty?" do - it "does not raise error for non-empty arrays" do - expect { validator.call([1, 2, 3]) }.not_to raise_error - expect { validator.call(["a"]) }.not_to raise_error - end - - it "does not raise error for non-empty hashes" do - expect { validator.call({ a: 1 }) }.not_to raise_error - expect { validator.call({ "key" => "value" }) }.not_to raise_error - end - - it "does not raise error for non-empty string-like objects" do - string_obj = Object.new - def string_obj.empty? = false - expect { validator.call(string_obj) }.not_to raise_error - end - end - - context "with objects not responding to empty?" do - it "does not raise error for non-nil values" do - expect { validator.call(42) }.not_to raise_error - expect { validator.call(true) }.not_to raise_error - expect { validator.call(false) }.not_to raise_error - expect { validator.call(0) }.not_to raise_error - end - - it "does not raise error for objects" do - expect { validator.call(Object.new) }.not_to raise_error - expect { validator.call(Date.today) }.not_to raise_error - end - end - end - - context "when value is not present" do - context "with string values" do - it "raises ValidationError for empty strings" do - expect { validator.call("") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - - it "raises ValidationError for whitespace-only strings" do - expect { validator.call(" ") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - expect { validator.call("\t\n\r ") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end - - context "with objects responding to empty?" do - it "raises ValidationError for empty arrays" do - expect { validator.call([]) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - - it "raises ValidationError for empty hashes" do - expect { validator.call({}) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - - it "raises ValidationError for empty string-like objects" do - empty_obj = Object.new - def empty_obj.empty? = true - expect { validator.call(empty_obj) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end - - context "with nil values" do - it "raises ValidationError for nil" do - expect { validator.call(nil) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end - end - - context "with custom message option" do - let(:custom_message) { "is required" } - let(:options) { { message: custom_message } } - - it "uses custom message for empty strings" do - expect { validator.call("", options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for whitespace-only strings" do - expect { validator.call(" ", options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for empty arrays" do - expect { validator.call([], options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for nil values" do - expect { validator.call(nil, options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "does not raise error for present values" do - expect { validator.call("hello", options) }.not_to raise_error - expect { validator.call([1], options) }.not_to raise_error - end - end - - context "without options" do - it "does not raise error when no options provided" do - expect { validator.call("hello") }.not_to raise_error - end - - it "raises error with default message when no options provided" do - expect { validator.call("") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end - - context "with non-hash options" do - it "ignores non-hash options and uses default message" do - expect { validator.call("", "invalid_options") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - expect { validator.call("", 123) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end - - context "with edge cases" do - it "handles zero correctly (not empty)" do - expect { validator.call(0) }.not_to raise_error - end - - it "handles false correctly (not empty)" do - expect { validator.call(false) }.not_to raise_error - end - - it "handles objects not responding to empty?" do - custom_obj = Object.new - - expect { validator.call(custom_obj) }.not_to raise_error - end - end - end -end diff --git a/spec/cmdx/workflow_spec.rb b/spec/cmdx/workflow_spec.rb deleted file mode 100644 index a65a5abe8..000000000 --- a/spec/cmdx/workflow_spec.rb +++ /dev/null @@ -1,352 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Workflow, type: :unit do - let(:workflow_class) { create_workflow_class(name: "TestWorkflow") } - let(:workflow) { workflow_class.new } - let(:context_hash) { { executed: [] } } - let(:workflow_with_context) { workflow_class.new(context_hash) } - - describe "module inclusion" do - it "extends the class with ClassMethods" do - expect(workflow_class).to respond_to(:pipeline) - expect(workflow_class).to respond_to(:task) - expect(workflow_class).to respond_to(:tasks) - end - - it "includes workflow functionality in the instance" do - expect(workflow).to respond_to(:work) - end - end - - describe "ExecutionGroup" do - subject(:execution_group) { CMDx::Workflow::ExecutionGroup.new(tasks, options) } - - let(:tasks) { [create_successful_task] } - let(:options) { { if: true } } - - it "is a Struct with tasks and options" do - expect(execution_group.tasks).to eq(tasks) - expect(execution_group.options).to eq(options) - end - end - - describe "ClassMethods" do - describe "#method_added" do - context "when redefining work method" do - it "raises an error" do - expect do - workflow_class.class_eval do - def work - "custom work" - end - end - end.to raise_error(RuntimeError, /cannot redefine.*#work method/) - end - end - - context "when adding other methods" do - it "allows normal method definition" do - expect do - workflow_class.class_eval do - def custom_method - "allowed" - end - end - end.not_to raise_error - - expect(workflow_class.new).to respond_to(:custom_method) - end - end - end - - describe "#pipeline" do - it "initializes as empty array" do - expect(workflow_class.pipeline).to eq([]) - end - - it "memoizes the pipeline" do - groups = workflow_class.pipeline - - expect(workflow_class.pipeline).to be(groups) - end - end - - describe "#tasks" do - let(:task1) { create_successful_task(name: "Task1") } - let(:task2) { create_successful_task(name: "Task2") } - let(:options) { { if: true, breakpoints: [:failure] } } - - context "with valid CMDx::Task classes" do - it "adds execution group to pipeline" do - workflow_class.tasks(task1, task2, **options) - - expect(workflow_class.pipeline.size).to eq(1) - - group = workflow_class.pipeline.first - - expect(group.tasks).to eq([task1, task2]) - expect(group.options).to eq(options) - end - - it "supports multiple task declarations" do - workflow_class.tasks(task1, **options) - workflow_class.tasks(task2, if: false) - - expect(workflow_class.pipeline.size).to eq(2) - expect(workflow_class.pipeline[0].tasks).to eq([task1]) - expect(workflow_class.pipeline[1].tasks).to eq([task2]) - end - end - - context "with invalid task types" do - it "raises TypeError for non-Task classes" do - expect do - workflow_class.tasks(String, Integer) - end.to raise_error(TypeError, "must be a CMDx::Task") - end - - it "raises TypeError for regular objects" do - expect do - workflow_class.tasks("not a task") - end.to raise_error(TypeError, "must be a CMDx::Task") - end - end - - context "with mixed valid and invalid tasks" do - it "raises TypeError when any task is invalid" do - expect do - workflow_class.tasks(task1, String, task2) - end.to raise_error(TypeError, "must be a CMDx::Task") - end - end - end - - describe "#subtasks" do - let(:task1) { create_successful_task(name: "SubTask1") } - let(:task2) { create_successful_task(name: "SubTask2") } - let(:task3) { create_successful_task(name: "SubTask3") } - - it "returns empty array when pipeline is empty" do - expect(workflow_class.subtasks).to eq([]) - end - - it "returns tasks from a single execution group" do - workflow_class.tasks(task1, task2) - - expect(workflow_class.subtasks).to eq([task1, task2]) - end - - it "returns tasks flattened across multiple execution groups" do - workflow_class.tasks(task1) - workflow_class.tasks(task2, task3) - - expect(workflow_class.subtasks).to eq([task1, task2, task3]) - end - end - end - - describe "#work" do - let(:task1) { create_successful_task(name: "Task1") } - let(:task2) { create_successful_task(name: "Task2") } - let(:task3) { create_successful_task(name: "Task3") } - - before do - workflow_class.class_eval do - settings workflow_breakpoints: [] - end - end - - context "with single execution group" do - before do - workflow_class.tasks(task1, task2, task3) - end - - it "executes all tasks in sequence" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end - end - - context "with multiple execution groups" do - before do - workflow_class.tasks(task1) - workflow_class.tasks(task2, task3) - end - - it "executes all groups in sequence" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end - end - - context "with conditional execution" do - before do - workflow_class.tasks(task1, if: true) - workflow_class.tasks(task2, if: false) - workflow_class.tasks(task3, unless: false) - end - - it "only executes tasks when conditions are met" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success]) - end - end - - context "with breakpoints in group options" do - let(:failing_task) { create_failing_task(name: "FailingTask") } - - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: [:failed]) - end - - it "stops execution when task status matches breakpoint" do - expect { workflow_with_context.work }.to raise_error(CMDx::FailFault) - - expect(workflow_with_context.context.executed).to eq([:success]) - end - end - - context "with breakpoints in class settings" do - before do - workflow_class.class_eval do - settings workflow_breakpoints: [:skipped] - end - - workflow_class.tasks(task1, task2, task3) - end - - it "executes all tasks when no task matches breakpoints" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end - end - - context "with group breakpoints overriding class breakpoints" do - before do - workflow_class.class_eval do - settings workflow_breakpoints: [:skipped] - end - - workflow_class.tasks(task1, task2, task3, breakpoints: [:failed]) - end - - it "uses group breakpoints instead of class breakpoints" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end - end - - context "when breakpoints is nil" do - before do - workflow_class.class_eval do - settings workflow_breakpoints: [:failed] - end - - workflow_class.tasks(task1, task2, task3, breakpoints: nil) - end - - it "uses class-level breakpoints" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end - end - - context "with different breakpoint types" do - let(:failing_task) { create_nested_task(strategy: :throw, status: :failure) } - - context "when breakpoints is a single symbol" do - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: :failed) - end - - it "converts single breakpoint to array" do - expect(workflow_with_context).to receive(:throw!) - - workflow_with_context.work - end - end - - context "when breakpoints is a string" do - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: "failed") - end - - it "converts string breakpoint to array and compares as string" do - expect(workflow_with_context).to receive(:throw!) - - workflow_with_context.work - end - end - - context "when breakpoints contains duplicates" do - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: [:failed, :failed, "failed"]) - end - - it "removes duplicates and converts to strings" do - expect(workflow_with_context).to receive(:throw!) - - workflow_with_context.work - end - end - end - - context "when task status does not match breakpoints" do - let(:failing_task) { create_nested_task(strategy: :throw, status: :failure) } - - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: [:skipped]) - end - - it "continues execution" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success]) - end - end - - context "when execution group condition evaluates to false" do - before do - workflow_class.tasks(task1, if: false) - workflow_class.tasks(task2, unless: true) - workflow_class.tasks(task3) - end - - it "skips groups that do not meet conditions" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq([:success]) - end - end - - context "with complex conditional scenarios" do - before do - workflow_class.tasks(task1, if: true) - workflow_class.tasks(task2, if: false) - workflow_class.tasks(task3, unless: false) - end - - it "evaluates conditions against workflow instance" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success]) - end - end - - context "with empty execution groups" do - it "completes without executing any tasks" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq([]) - end - end - end -end diff --git a/spec/cmdx_spec.rb b/spec/cmdx_spec.rb deleted file mode 100644 index 556b4372d..000000000 --- a/spec/cmdx_spec.rb +++ /dev/null @@ -1,216 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx do - after do - described_class.reset_configuration! - end - - describe ".configuration" do - it "returns a Configuration instance" do - expect(described_class.configuration).to be_a(CMDx::Configuration) - end - - it "returns the same instance on subsequent calls" do - first_call = described_class.configuration - second_call = described_class.configuration - - expect(first_call).to be(second_call) - end - - context "when @configuration is already set" do - let(:custom_config) { CMDx::Configuration.new } - - before do - described_class.instance_variable_set(:@configuration, custom_config) - end - - after do - # Skip the automatic reset for this test - described_class.instance_variable_set(:@configuration, nil) - end - - it "returns the existing configuration" do - expect(described_class.configuration).to be(custom_config) - end - - it "reuses the existing instance" do - first_config = described_class.configuration - second_config = described_class.configuration - - expect(first_config).to be(second_config) - expect(first_config).to be(custom_config) - end - end - - context "when @configuration is nil" do - before do - described_class.instance_variable_set(:@configuration, nil) - end - - it "creates and returns a new Configuration instance" do - expect(described_class.configuration).to be_a(CMDx::Configuration) - end - end - end - - describe ".configure" do - it "yields the configuration object" do - expect { |b| described_class.configure(&b) }.to yield_with_args(CMDx::Configuration) - end - - it "returns the configuration object" do - result = described_class.configure { nil } - - expect(result).to be(described_class.configuration) - end - - it "allows configuration modification within the block" do - custom_breakpoints = %w[custom_failed timeout] - - described_class.configure do |config| - config.task_breakpoints = custom_breakpoints - end - - expect(described_class.configuration.task_breakpoints).to eq(custom_breakpoints) - end - - it "passes the same configuration instance to the block" do - original_config = described_class.configuration - - described_class.configure do |config| - expect(config).to be(original_config) - end - end - - context "without a block" do - it "raises ArgumentError with descriptive message" do - expect { described_class.configure }.to raise_error(ArgumentError, "block required") - end - end - - context "with multiple configuration changes" do - let(:custom_logger) { Logger.new($stderr, progname: "test") } - let(:custom_breakpoints) { %w[error timeout] } - - it "applies all changes correctly" do - described_class.configure do |config| - config.task_breakpoints = custom_breakpoints - config.logger = custom_logger - end - - config = described_class.configuration - expect(config.task_breakpoints).to eq(custom_breakpoints) - expect(config.logger).to eq(custom_logger) - end - end - end - - describe ".reset_configuration!" do - let(:custom_logger) { Logger.new($stderr, progname: "test") } - let(:custom_breakpoints) { %w[custom_failed] } - - before do - # Modify the configuration first - described_class.configure do |config| - config.task_breakpoints = custom_breakpoints - config.workflow_breakpoints = custom_breakpoints - config.logger = custom_logger - end - end - - after do - # Skip the automatic reset for these tests since we're testing reset - described_class.instance_variable_set(:@configuration, nil) - end - - it "creates a new Configuration instance" do - original_config = described_class.configuration - described_class.reset_configuration! - expect(described_class.configuration).not_to be(original_config) - expect(described_class.configuration).to be_a(CMDx::Configuration) - end - - it "resets all breakpoints to default values" do - described_class.reset_configuration! - config = described_class.configuration - - expect(config.task_breakpoints).to eq(%w[failed]) - expect(config.workflow_breakpoints).to eq(%w[failed]) - end - - it "resets logger to default configuration" do - described_class.reset_configuration! - logger = described_class.configuration.logger - - expect(logger.progname).to eq("cmdx") - expect(logger.formatter).to be_a(CMDx::LogFormatters::Line) - expect(logger.level).to eq(Logger::INFO) - end - - it "resets all registries to their default state" do - described_class.reset_configuration! - config = described_class.configuration - - expect(config.middlewares.registry).to be_empty - expect(config.callbacks.registry).to be_empty - expect(config.coercions.registry.keys).to include( - :array, :string, :integer, :boolean, :float, :hash - ) - expect(config.validators.registry.keys).to include( - :presence, :format, :inclusion, :exclusion, :length, :numeric - ) - end - - it "clears the memoized @configuration variable" do - old_object_id = described_class.configuration.object_id - described_class.reset_configuration! - - expect(described_class.configuration.object_id).not_to eq(old_object_id) - end - - context "when called multiple times" do - it "creates a fresh instance each time" do - described_class.reset_configuration! - first_reset = described_class.configuration - - described_class.reset_configuration! - second_reset = described_class.configuration - - expect(first_reset).not_to be(second_reset) - end - end - - context "when configuration is not modified" do - it "still creates a new instance" do - original_config = described_class.configuration - described_class.reset_configuration! - - expect(described_class.configuration).not_to be(original_config) - end - end - - context "when configuration has custom registries" do - let(:custom_middleware) { CMDx::MiddlewareRegistry.new } - let(:custom_callback) { CMDx::CallbackRegistry.new } - - before do - described_class.configure do |config| - config.middlewares = custom_middleware - config.callbacks = custom_callback - end - end - - it "resets to new default registry instances" do - described_class.reset_configuration! - config = described_class.configuration - - expect(config.middlewares).not_to be(custom_middleware) - expect(config.callbacks).not_to be(custom_callback) - expect(config.middlewares).to be_a(CMDx::MiddlewareRegistry) - expect(config.callbacks).to be_a(CMDx::CallbackRegistry) - end - end - end -end diff --git a/spec/integration/tasks/attributes_spec.rb b/spec/integration/tasks/attributes_spec.rb deleted file mode 100644 index f02acf7d5..000000000 --- a/spec/integration/tasks/attributes_spec.rb +++ /dev/null @@ -1,482 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task attributes", type: :feature do - context "when defining" do - context "without options" do - context "with no inputs" do - it "fails due to missing input" do - task = create_task_class do - attribute :plain_optional_attr - attributes :plain_required_attr, required: true - required :required_attr - optional :optional_attr - - def work - context.attrs = [plain_optional_attr, plain_required_attr, required_attr, optional_attr] - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "plain_required_attr must be accessible via the context source method. required_attr must be accessible via the context source method", - messages: { - plain_required_attr: ["must be accessible via the context source method"], - required_attr: ["must be accessible via the context source method"] - } - } - ) - end - end - - context "with minimum inputs" do - it "returns attributes defined as methods" do - task = create_task_class do - attribute :plain_optional_attr - attributes :plain_required_attr, required: true - required :required_attr - optional :optional_attr - - def work - context.attrs = [plain_optional_attr, plain_required_attr, required_attr, optional_attr] - end - end - - result = task.execute( - plain_required_attr: "plain_required", - required_attr: "required" - ) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil, "plain_required", "required", nil]) - end - end - - context "with maximum inputs" do - it "returns attributes defined as methods" do - task = create_task_class do - attribute :plain_optional_attr - attributes :plain_required_attr, required: true - required :required_attr - optional :optional_attr - - def work - context.attrs = [plain_optional_attr, plain_required_attr, required_attr, optional_attr] - end - end - - result = task.execute( - plain_optional_attr: "plain_optional", - plain_required_attr: "plain_required", - required_attr: "required", - optional_attr: "optional" - ) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: %w[plain_optional plain_required required optional]) - end - end - end - - context "with source options" do - context "when source doesnt exist" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, source: :not_a_method - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_attr delegates to undefined method not_a_method", - messages: { raw_attr: ["delegates to undefined method not_a_method"] } - } - ) - end - end - end - - context "with type options" do - context "when attribute is optional" do - context "when cannot be coerced into type" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, type: :integer - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil]) - end - end - - context "when cannot be coerced into any type" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, types: %i[float integer] - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil]) - end - end - - context "when value can be coerced" do - it "coerces the value into the type" do - task = create_task_class do - attribute :raw_attr, type: :integer - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [123]) - end - end - end - - context "when attribute is required" do - context "when cannot be coerced into type" do - it "fails with coercion error message" do - task = create_task_class do - required :raw_attr, type: :hash do - required :raw_key, type: :integer - end - - def work = nil - end - - result = task.execute(raw_attr: { raw_key: nil }) - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_key could not coerce into an integer", - messages: { raw_key: ["could not coerce into an integer"] } - } - ) - end - end - - context "when cannot be coerced into any type" do - it "fails with coercion error message" do - task = create_task_class do - required :raw_attr, type: :hash do - required :raw_key, type: %i[float integer] - end - - def work = nil - end - - result = task.execute(raw_attr: { raw_key: nil }) - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_key could not coerce into one of: float, integer", - messages: { raw_key: ["could not coerce into one of: float, integer"] } - } - ) - end - end - - context "when nested attribute is missing" do - it "fails with coercion error message" do - task = create_task_class do - required :raw_attr, type: :hash do - optional :raw_key, type: :integer - end - - def work - context.attrs = [raw_key] - end - end - - result = task.execute(raw_attr: {}) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil]) - end - end - - context "when value can be coerced" do - it "coerces the value into the type" do - task = create_task_class do - required :raw_attr, type: :hash do - required :raw_key, type: :integer - end - - def work - context.attrs = [raw_key] - end - end - - result = task.execute(raw_attr: { raw_key: "123" }) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [123]) - end - end - end - end - - context "with default option" do - context "when derived value is nil" do - it "returns the default value" do - task = create_task_class do - attribute :raw_attr, default: 987 - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [987]) - end - end - - context "when derived value is not nil" do - it "returns the derived value" do - task = create_task_class do - attribute :raw_attr, default: 987 - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - - context "when the default value cannot be coerced into the type" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, type: :integer, default: "abc" - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_attr could not coerce into an integer", - messages: { raw_attr: ["could not coerce into an integer"] } - } - ) - end - end - end - - context "with transformation options" do - context "when value is not valid" do - it "succeeds but does not transform the value" do - task = create_task_class do - attribute :raw_attr, transform: :downcase - - def work - context.attr = raw_attr - end - end - - result = task.execute(raw_attr: 123) - - expect(result).to be_successful - expect(result).to have_matching_context(attr: 123) - end - end - - context "when value is valid" do - it "succeeds and transforms the value" do - task = create_task_class do - attribute :raw_attr, transform: :upcase - - def work - context.attr = raw_attr - end - end - - result = task.execute(raw_attr: "abc") - - expect(result).to be_successful - expect(result).to have_matching_context(attr: "ABC") - end - end - end - - context "with validation options" do - context "when value is not valid" do - it "fails with validation error message" do - task = create_task_class do - attribute :raw_attr, format: { with: /^\d+$/ } - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_attr is an invalid format", - messages: { raw_attr: ["is an invalid format"] } - } - ) - end - end - end - - context "with naming options" do - context "when prefix is set" do - it "prefixes the attribute name" do - task = create_task_class do - attribute :raw_attr, prefix: :prefix_ - - def work - context.attrs = [prefix_raw_attr] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - - context "when suffix is set" do - it "suffixes the attribute name" do - task = create_task_class do - attribute :raw_attr, suffix: :_suffix - - def work - context.attrs = [raw_attr_suffix] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - - context "when as is set" do - it "sets the attribute name" do - task = create_task_class do - attribute :raw_attr, as: :raw_attr_as - - def work - context.attrs = [raw_attr_as] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - end - end - - context "when undefining" do - it "raises a NameError" do - task = create_task_class do - attribute :raw_attr - - remove_attribute :raw_attr - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to have_failed( - reason: start_with( - if RubyVersion.min?(3.4) - "[NameError] undefined local variable or method 'raw_attr' for an instance of" - else - "[NameError] undefined local variable or method `raw_attr' for" - end - ), - cause: be_a(NameError) - ) - end - end - - context "when inheriting" do - it "assumes the parents attributes" do - parent_task = create_task_class(name: "ParentTask") do - required :parent_attr - - def work = nil - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - optional :child_attr - - def work - context.executed ||= [] - context.executed << parent_attr - context.executed << child_attr - end - end - - result = child_task.execute(parent_attr: "parent123", child_attr: "child456") - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %w[parent123 child456]) - end - end -end diff --git a/spec/integration/tasks/callbacks_spec.rb b/spec/integration/tasks/callbacks_spec.rb deleted file mode 100644 index 13f72c117..000000000 --- a/spec/integration/tasks/callbacks_spec.rb +++ /dev/null @@ -1,536 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task callbacks", type: :feature do - context "when defining lifecycle callbacks" do - context "with before_validation" do - it "executes before attribute validation" do - task = create_task_class do - before_validation :track_validation - - required :value - - def work - context.executed = true - end - - private - - def track_validation - (context.callbacks ||= []) << :before_validation - end - end - - result = task.execute(value: "test") - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: [:before_validation], executed: true) - end - end - - context "with before_execution" do - it "executes before the work method" do - task = create_task_class do - before_execution :setup_data - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_execution work]) - end - end - - context "with on_complete" do - it "executes after successful completion" do - task = create_task_class do - on_complete :track_complete - - def work - (context.callbacks ||= []) << :work - end - - private - - def track_complete - (context.callbacks ||= []) << :on_complete - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[work on_complete]) - end - end - - context "with on_interrupted" do - it "executes after interruption" do - task = create_task_class do - on_interrupted :track_interrupted - - def work - fail!("Test failure") - end - - private - - def track_interrupted - (context.callbacks ||= []) << :on_interrupted - end - end - - result = task.execute - - expect(result).to have_failed(reason: "Test failure") - expect(result).to have_matching_context(callbacks: [:on_interrupted]) - end - end - - context "with on_executed" do - it "executes after any outcome" do - success_task = create_task_class do - on_executed :track_executed - - def work - (context.callbacks ||= []) << :success - end - - private - - def track_executed - (context.callbacks ||= []) << :on_executed - end - end - - failed_task = create_task_class do - on_executed :track_executed - - def work - fail!("Test") - end - - private - - def track_executed - (context.callbacks ||= []) << :on_executed - end - end - - success_result = success_task.execute - failed_result = failed_task.execute - - expect(success_result).to be_successful - expect(success_result).to have_matching_context(callbacks: %i[success on_executed]) - - expect(failed_result).to have_failed(reason: "Test") - expect(failed_result).to have_matching_context(callbacks: [:on_executed]) - end - end - - context "with on_success" do - it "executes only on success" do - task = create_task_class do - on_success :track_success - - def work - (context.callbacks ||= []) << :work - end - - private - - def track_success - (context.callbacks ||= []) << :on_success - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[work on_success]) - end - - it "does not execute on failure" do - task = create_task_class do - on_success :track_success - - def work - fail!("Test") - end - - private - - def track_success - (context.callbacks ||= []) << :on_success - end - end - - result = task.execute - - expect(result).to have_failed(reason: "Test") - expect(result).to have_empty_context - end - end - - context "with on_skipped" do - it "executes only when skipped" do - task = create_task_class do - on_skipped :track_skipped - - def work - skip!("Test skip") - end - - private - - def track_skipped - (context.callbacks ||= []) << :on_skipped - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "Test skip") - expect(result).to have_matching_context(callbacks: [:on_skipped]) - end - end - - context "with on_failed" do - it "executes only on failure" do - task = create_task_class do - on_failed :track_failed - - def work - fail!("Test failure") - end - - private - - def track_failed - (context.callbacks ||= []) << :on_failed - end - end - - result = task.execute - - expect(result).to have_failed(reason: "Test failure") - expect(result).to have_matching_context(callbacks: [:on_failed]) - end - end - - context "with on_good" do - it "executes on success" do - task = create_task_class do - on_good :track_good - - def work - (context.callbacks ||= []) << :work - end - - private - - def track_good - (context.callbacks ||= []) << :on_good - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[work on_good]) - end - - it "executes when skipped" do - task = create_task_class do - on_good :track_good - - def work - skip!("Test") - end - - private - - def track_good - (context.callbacks ||= []) << :on_good - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "Test") - expect(result).to have_matching_context(callbacks: [:on_good]) - end - end - - context "with on_bad" do - it "executes when skipped" do - task = create_task_class do - on_bad :track_bad - - def work - skip!("Test") - end - - private - - def track_bad - (context.callbacks ||= []) << :on_bad - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "Test") - expect(result).to have_matching_context(callbacks: [:on_bad]) - end - - it "executes on failure" do - task = create_task_class do - on_bad :track_bad - - def work - fail!("Test") - end - - private - - def track_bad - (context.callbacks ||= []) << :on_bad - end - end - - result = task.execute - - expect(result).to have_failed(reason: "Test") - expect(result).to have_matching_context(callbacks: [:on_bad]) - end - end - end - - context "when using proc or lambda callbacks" do - it "executes proc callbacks" do - task = create_task_class do - before_execution proc { (context.callbacks ||= []) << :before_proc } - on_complete proc { (context.callbacks ||= []) << :complete_proc } - - def work = nil - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_proc complete_proc]) - end - - it "executes lambda callbacks" do - task = create_task_class do - before_execution -> { (context.callbacks ||= []) << :before_lambda } - on_complete -> { (context.callbacks ||= []) << :complete_lambda } - - def work = nil - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_lambda complete_lambda]) - end - - it "executes class-based callbacks" do - setup_callback = Class.new do - def call(task) - (task.context.callbacks ||= []) << :before_class - end - end - - complete_callback = Class.new do - def call(task) - (task.context.callbacks ||= []) << :complete_class - end - end - - task = create_task_class do - before_execution setup_callback.new - on_complete complete_callback.new - - def work = nil - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_class complete_class]) - end - end - - context "when using conditional callbacks" do - it "executes callbacks with if condition" do - task = create_task_class do - before_execution :setup_data, if: :should_setup? - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - - def should_setup? - context.enable_setup == true - end - end - - enabled_result = task.execute(enable_setup: true) - disabled_result = task.execute(enable_setup: false) - - expect(enabled_result).to be_successful - expect(enabled_result).to have_matching_context(callbacks: %i[before_execution work]) - - expect(disabled_result).to be_successful - expect(disabled_result).to have_matching_context(callbacks: [:work]) - end - - it "executes callbacks with unless condition" do - task = create_task_class do - before_execution :setup_data, unless: :skip_setup? - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - - def skip_setup? - context.skip_setup == true - end - end - - enabled_result = task.execute(skip_setup: false) - disabled_result = task.execute(skip_setup: true) - - expect(enabled_result).to be_successful - expect(enabled_result).to have_matching_context(callbacks: %i[before_execution work]) - - expect(disabled_result).to be_successful - expect(disabled_result).to have_matching_context(callbacks: [:work]) - end - - it "executes callbacks with combined if and unless" do - task = create_task_class do - before_execution :setup_data, if: :should_setup?, unless: :override_setup? - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - - def should_setup? - context.enable_setup == true - end - - def override_setup? - context.override == true - end - end - - both_true = task.execute(enable_setup: true, override: true) - if_true_unless_false = task.execute(enable_setup: true, override: false) - - expect(both_true).to be_successful - expect(both_true).to have_matching_context(callbacks: [:work]) - - expect(if_true_unless_false).to be_successful - expect(if_true_unless_false).to have_matching_context(callbacks: %i[before_execution work]) - end - end - - context "when executing multiple callbacks" do - it "executes callbacks in declaration order (FIFO)" do - task = create_task_class do - before_execution :first_setup - before_execution :second_setup - on_complete :first_complete - on_complete :second_complete - - def work - (context.callbacks ||= []) << :work - end - - private - - def first_setup - (context.callbacks ||= []) << :first_setup - end - - def second_setup - (context.callbacks ||= []) << :second_setup - end - - def first_complete - (context.callbacks ||= []) << :first_complete - end - - def second_complete - (context.callbacks ||= []) << :second_complete - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context( - callbacks: %i[first_setup second_setup work first_complete second_complete] - ) - end - end - - context "when removing callbacks" do - it "removes symbol callbacks" do - parent_task = create_task_class(name: "ParentTask") do - before_execution :setup_data - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - end - - child_task = create_task_class(base: parent_task, name: "ChildTask") do - deregister :callback, :before_execution, :setup_data - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: [:work]) - end - end -end diff --git a/spec/integration/tasks/execution_spec.rb b/spec/integration/tasks/execution_spec.rb deleted file mode 100644 index ed459ca28..000000000 --- a/spec/integration/tasks/execution_spec.rb +++ /dev/null @@ -1,815 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task execution", type: :feature do - context "when non-bang" do - subject(:result) { task.execute } - - context "when simple task" do - context "when successful" do - let(:task) { create_successful_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "when skipping" do - let(:task) { create_skipping_task } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_failing_task } - - it "returns failure" do - expect(result).to have_failed - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_erroring_task } - - it "returns failure" do - expect(result).to have_failed( - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::TestError) - ) - expect(result).to have_empty_context - end - end - end - - context "with nested tasks" do - context "when swallowing" do - context "when successful" do - let(:task) { create_nested_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(status: :skipped) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(status: :failure) } - - it "returns failure" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when erroring" do - let(:task) { create_nested_task(status: :error) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - end - - context "when throwing" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw, status: :skipped) } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - end - - context "when raising" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::TestError), - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - end - - context "when throw to raise" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw_raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::FailFault), - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - end - - context "when raise to throw" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise_throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::FailFault), - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - end - end - end - - context "when bang" do - subject(:result) { task.execute! } - - context "when simple task" do - context "when successful" do - let(:task) { create_successful_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "when skipping" do - let(:task) { create_skipping_task } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_failing_task } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_erroring_task } - - it "raise a CMDx::TestError" do - expect { result }.to raise_error(CMDx::TestError, "borked error") - end - end - end - - context "with nested tasks" do - context "when swallowing" do - context "when successful" do - let(:task) { create_nested_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(status: :skipped) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(status: :failure) } - - it "returns failure" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when erroring" do - let(:task) { create_nested_task(status: :error) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - end - - context "when throwing" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw, status: :skipped) } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw, status: :error) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end - end - end - - context "when raising" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise, status: :error) } - - it "raise a CMDx::TestError" do - expect { result }.to raise_error(CMDx::TestError, "borked error") - end - end - end - - context "when throw to raise" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw_raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :error) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end - end - end - - context "when raise to throw" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise_throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :error) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end - end - end - end - end - - context "when inheriting" do - context "when assuming the work method" do - it "captures the execution order" do - parent_task = create_task_class(name: "ParentTask") do - def work = (context.executed ||= []) << :parent - end - child_task = create_task_class(base: parent_task, name: "ChildTask") - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[parent]) - end - end - - context "when overriding the work method" do - it "captures the execution order" do - parent_task = create_task_class(name: "ParentTask") do - def work = (context.executed ||= []) << :parent - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - def work = (context.executed ||= []) << :child - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[child]) - end - end - - context "when super-ing the work method" do - it "captures the execution order" do - parent_task = create_task_class(name: "ParentTask") do - def work = (context.executed ||= []) << :parent - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - def work - super - (context.executed ||= []) << :child - end - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[parent child]) - end - end - end - - context "when using block" do - context "when class method" do - let(:task) { create_successful_task } - - it "yields the result" do - task.execute do |result| - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - end - - context "when instance method" do - let(:task) { create_successful_task.new } - - it "yields the result" do - task.execute do |result| - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - end - end - - describe "durability" do - context "with any exception" do - it "retries the task n times after first issue without rerunning the middlewares" do - counter = instance_double("counter", incr: nil) - - task = create_task_class do - settings retries: 2 - register :middleware, CMDx::Middlewares::Correlate, id: proc { - counter.incr - "abc-123" - } - - def work - context.retries ||= 0 - context.retries += 1 - raise CMDx::TestError, "borked error" unless self.class.settings.retries < context.retries - - (context.executed ||= []) << :success - end - end - - expect(counter).to receive(:incr).once - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(retries: 3, executed: %i[success]) - expect(result.retries).to eq(2) - end - end - - context "with a specific exception" do - it "skips retries if the exception is not in the retry_on setting" do - counter = instance_double("counter", incr: nil) - - task = create_task_class do - settings retries: 2, retry_on: RuntimeError - register :middleware, CMDx::Middlewares::Correlate, id: proc { - counter.incr - "abc-123" - } - - def work - context.retries ||= 0 - context.retries += 1 - raise CMDx::TestError, "borked error" unless self.class.settings.retries < context.retries - - (context.executed ||= []) << :success - end - end - - expect(counter).to receive(:incr).once - - result = task.execute - - expect(result).to have_failed( - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::TestError) - ) - expect(result).to have_matching_context(retries: 1) - expect(result).to have_matching_metadata(source: :exception) - end - end - end - - describe "rollback" do - context "when rollback is configured" do - it "calls rollback on failure" do - task = create_task_class do - settings rollback_on: :failed - - def work - fail!("something went wrong") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute - - expect(result).to have_failed(reason: "something went wrong") - expect(result).to be_rolled_back - expect(result.context.rolled_back).to eq([:yes]) - end - - it "calls rollback on skip if configured" do - task = create_task_class do - settings rollback_on: %i[failed skipped] - - def work - skip!("skipping") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "skipping") - expect(result).to be_rolled_back - expect(result.context.rolled_back).to eq([:yes]) - end - end - - context "when rollback is explicitly disabled" do - it "does not call rollback" do - task = create_task_class do - settings rollback_on: [] - - def work - fail!("something went wrong") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute - - expect(result).to have_failed(reason: "something went wrong") - expect(result).not_to be_rolled_back - expect(result.context.rolled_back).to be_nil - end - end - - context "when rollback is configured but status does not match" do - it "does not call rollback" do - task = create_task_class do - settings rollback_on: %i[failed] - - def work - skip!("skipping") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "skipping") - expect(result).not_to be_rolled_back - expect(result.context.rolled_back).to be_nil - end - end - end - - describe "dry run" do - context "when enabled" do - it "allows conditional logic execution" do - task = create_task_class do - def work - context.result = dry_run? ? "mocked" : "real" - end - end - - result = task.execute(dry_run: true) - - expect(result).to be_successful - expect(result).to be_dry_run - expect(result.context.result).to eq("mocked") - end - end - - context "when disabled" do - it "executes real logic" do - task = create_task_class do - def work - context.result = dry_run? ? "mocked" : "real" - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).not_to be_dry_run - expect(result.context.result).to eq("real") - end - end - end -end diff --git a/spec/integration/tasks/handlers_spec.rb b/spec/integration/tasks/handlers_spec.rb deleted file mode 100644 index b561b0c8a..000000000 --- a/spec/integration/tasks/handlers_spec.rb +++ /dev/null @@ -1,332 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task handlers", type: :feature do - context "when using status-based handlers" do - it "executes on_success handler" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:success) { handled << :on_success } - - expect(handled).to eq([:on_success]) - end - - it "executes on_failed handler" do - task = create_failing_task(reason: "Test failure") - - result = task.execute - handled = [] - - result.on(:failed) { handled << :on_failed } - - expect(handled).to eq([:on_failed]) - end - - it "executes on_skipped handler" do - task = create_skipping_task(reason: "Test skip") - - result = task.execute - handled = [] - - result.on(:skipped) { handled << :on_skipped } - - expect(handled).to eq([:on_skipped]) - end - - it "only executes the matching handler" do - success_task = create_successful_task - failed_task = create_failing_task - - success_result = success_task.execute - failed_result = failed_task.execute - - success_handled = [] - failed_handled = [] - - success_result - .on(:success) { success_handled << :success } - .on(:failed) { success_handled << :failed } - .on(:skipped) { success_handled << :skipped } - - failed_result - .on(:success) { failed_handled << :success } - .on(:failed) { failed_handled << :failed } - .on(:skipped) { failed_handled << :skipped } - - expect(success_handled).to eq([:success]) - expect(failed_handled).to eq([:failed]) - end - end - - context "when using state-based handlers" do - it "executes on_complete handler" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:complete) { handled << :on_complete } - - expect(handled).to eq([:on_complete]) - end - - it "executes on_interrupted handler" do - task = create_failing_task - - result = task.execute - handled = [] - - result.on(:interrupted) { handled << :on_interrupted } - - expect(handled).to eq([:on_interrupted]) - end - - it "only executes the matching state handler" do - complete_task = create_successful_task - interrupted_task = create_failing_task - - complete_result = complete_task.execute - interrupted_result = interrupted_task.execute - - complete_handled = [] - interrupted_handled = [] - - complete_result - .on(:complete) { complete_handled << :complete } - .on(:interrupted) { complete_handled << :interrupted } - - interrupted_result - .on(:complete) { interrupted_handled << :complete } - .on(:interrupted) { interrupted_handled << :interrupted } - - expect(complete_handled).to eq([:complete]) - expect(interrupted_handled).to eq([:interrupted]) - end - end - - context "when using outcome-based handlers" do - it "executes on_good for success" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:good) { handled << :on_good } - - expect(handled).to eq([:on_good]) - end - - it "executes on_good for skipped" do - task = create_skipping_task - - result = task.execute - handled = [] - - result.on(:good) { handled << :on_good } - - expect(handled).to eq([:on_good]) - end - - it "does not execute on_good for failed" do - task = create_failing_task - - result = task.execute - handled = [] - - result.on(:good) { handled << :on_good } - - expect(handled).to be_empty - end - - it "executes on_bad for skipped" do - task = create_skipping_task - - result = task.execute - handled = [] - - result.on(:bad) { handled << :on_bad } - - expect(handled).to eq([:on_bad]) - end - - it "executes on_bad for failed" do - task = create_failing_task - - result = task.execute - handled = [] - - result.on(:bad) { handled << :on_bad } - - expect(handled).to eq([:on_bad]) - end - - it "does not execute on_bad for success" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:bad) { handled << :on_bad } - - expect(handled).to be_empty - end - end - - context "when chaining handlers" do - it "allows method chaining" do - task = create_successful_task - - result = task.execute - handled = [] - - result - .on(:success) { handled << :success } - .on(:complete) { handled << :complete } - .on(:good) { handled << :good } - - expect(handled).to eq(%i[success complete good]) - end - - it "chains handlers regardless of outcome" do - task = create_failing_task - - result = task.execute - handled = [] - - result - .on(:success) { handled << :success } - .on(:failed) { handled << :failed } - .on(:complete) { handled << :complete } - .on(:interrupted) { handled << :interrupted } - .on(:good) { handled << :good } - .on(:bad) { handled << :bad } - - expect(handled).to eq(%i[failed interrupted bad]) - end - end - - context "when accessing result data in handlers" do - it "provides access to result in handler" do - task = create_task_class do - def work - context.value = 42 - end - end - - result = task.execute - captured_value = nil - - result.on(:success) { |r| captured_value = r.context.value } - - expect(captured_value).to eq(42) - end - - it "provides access to metadata in handler" do - task = create_task_class do - def work - fail!("Test failure", error_code: "TEST.FAILED", retry_count: 3) - end - end - - result = task.execute - captured_metadata = nil - - result.on(:failed) { |r| captured_metadata = r.metadata } - - expect(captured_metadata).to include(error_code: "TEST.FAILED", retry_count: 3) - end - - it "provides access to task in handler" do - task = create_task_class(name: "TestTask") do - def work = nil - end - - result = task.execute - captured_task_class = nil - - result.on(:success) { |r| captured_task_class = r.task.class.name } - - expect(captured_task_class).to match(/TestTask/) - end - end - - context "when using handlers for control flow" do - it "uses handlers for conditional logic" do - success_task = create_successful_task - failed_task = create_failing_task - - success_outcome = nil - failed_outcome = nil - - success_task - .execute - .on(:success) { success_outcome = "success_path" } - .on(:failed) { success_outcome = "failure_path" } - - failed_task - .execute - .on(:success) { failed_outcome = "success_path" } - .on(:failed) { failed_outcome = "failure_path" } - - expect(success_outcome).to eq("success_path") - expect(failed_outcome).to eq("failure_path") - end - - it "uses handlers for side effects" do - task = create_task_class do - def work - context.value = 100 - end - end - - notifications = [] - - task - .execute - .on(:success) { |r| notifications << "Processed: #{r.context.value}" } - .on(:complete) { notifications << "Completed" } - - expect(notifications).to eq(["Processed: 100", "Completed"]) - end - end - - context "when combining with block execution" do - it "allows using both block and handlers" do - task = create_successful_task - - block_executed = false - handler_executed = false - captured_result = nil - - task.execute do |r| - block_executed = true - captured_result = r - end - - captured_result.on(:success) { handler_executed = true } - - expect(block_executed).to be(true) - expect(handler_executed).to be(true) - end - end - - context "when using handlers for cleanup" do - it "executes cleanup on any outcome" do - success_task = create_successful_task - failed_task = create_failing_task - skipped_task = create_skipping_task - - cleanup_count = 0 - - success_task.execute.on(:good) { cleanup_count += 1 } - failed_task.execute.on(:bad) { cleanup_count += 1 } - skipped_task.execute.on(:bad) { cleanup_count += 1 } - - expect(cleanup_count).to eq(3) - end - end -end diff --git a/spec/integration/tasks/middlewares_spec.rb b/spec/integration/tasks/middlewares_spec.rb deleted file mode 100644 index b9e79f696..000000000 --- a/spec/integration/tasks/middlewares_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task middlewares", type: :feature do - context "when using correlate middleware" do - it "assigns a correlation ID to the result metadata" do - task = create_successful_task do - register :middleware, CMDx::Middlewares::Correlate, id: proc { "abc-123" } - end - - result = task.execute - - expect(result.metadata[:correlation_id]).to eq("abc-123") - end - - it "resuses the correlation ID from the outer task" do - inner_task = create_successful_task(name: "InnerTask") do - register :middleware, CMDx::Middlewares::Correlate, id: proc { "abc-456" } - end - outer_task = create_task_class(name: "OuterTask") do - register :middleware, CMDx::Middlewares::Correlate, id: proc { "abc-123" } - end - outer_task.define_method(:work) { context.inner_result = inner_task.execute(context) } - - outer_result = outer_task.execute - inner_result = outer_result.context.inner_result - - expect(inner_result.metadata[:correlation_id]).to eq("abc-123") - expect(outer_result.metadata[:correlation_id]).to eq("abc-123") - end - end - - context "when using runtime middleware" do - it "assigns the runtime to the result metadata" do - task = create_successful_task do - register :middleware, CMDx::Middlewares::Runtime - end - - result = task.execute - - expect(result.metadata[:runtime]).to be_a(Integer) - end - end - - context "when using timeout middleware" do - it "raises a failure fault" do - task = create_task_class do - register :middleware, CMDx::Middlewares::Timeout, seconds: 0.001 - end - task.define_method(:work) { sleep(0.002) } - - expect { task.execute }.to raise_error(CMDx::FailFault, "[CMDx::TimeoutError] execution exceeded 0.001 seconds") - end - end -end diff --git a/spec/integration/tasks/returns_spec.rb b/spec/integration/tasks/returns_spec.rb deleted file mode 100644 index ec0ec9256..000000000 --- a/spec/integration/tasks/returns_spec.rb +++ /dev/null @@ -1,346 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task returns", type: :feature do - context "when declaring" do - context "with no returns" do - it "does not validate returns" do - task = create_task_class do - def work - (context.executed ||= []) << :success - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "with single return" do - context "when return is present" do - it "returns success" do - task = create_task_class do - returns :user - - def work - context.user = "John" - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John") - end - end - - context "when return is missing" do - it "returns failure" do - task = create_task_class do - returns :user - - def work - (context.executed ||= []) << :success - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "user must be set in the context", - messages: { user: ["must be set in the context"] } - } - ) - end - end - end - - context "with multiple returns" do - context "when all returns are present" do - it "returns success" do - task = create_task_class do - returns :user, :token - - def work - context.user = "John" - context.token = "abc123" - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John", token: "abc123") - end - end - - context "when some returns are missing" do - it "returns failure with all missing returns" do - task = create_task_class do - returns :user, :token - - def work - context.user = "John" - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "token must be set in the context", - messages: { token: ["must be set in the context"] } - } - ) - end - end - - context "when all returns are missing" do - it "returns failure with all missing returns" do - task = create_task_class do - returns :user, :token - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "user must be set in the context. token must be set in the context", - messages: { - user: ["must be set in the context"], - token: ["must be set in the context"] - } - } - ) - end - end - end - end - - context "when task skips" do - it "does not validate returns" do - task = create_task_class do - returns :user - - def work - skip!("not needed") - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "not needed") - end - end - - context "when task fails" do - it "does not validate returns" do - task = create_task_class do - returns :user - - def work - fail!("something went wrong") - end - end - - result = task.execute - - expect(result).to have_failed(reason: "something went wrong") - end - end - - context "when using bang execution" do - context "when return is missing" do - it "raises a CMDx::FailFault" do - task = create_task_class do - returns :user - - def work = nil - end - - expect { task.execute! }.to raise_error(CMDx::FailFault, "Invalid") - end - end - - context "when return is present" do - it "returns success" do - task = create_task_class do - returns :user - - def work - context.user = "John" - end - end - - result = task.execute! - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John") - end - end - end - - context "when inheriting" do - it "inherits parent returns" do - parent_task = create_task_class(name: "ParentTask") do - returns :user - - def work - context.user = "John" - end - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - returns :token - - def work - super - context.token = "abc123" - end - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John", token: "abc123") - end - - it "fails when inherited return is missing" do - parent_task = create_task_class(name: "ParentTask") do - returns :user - - def work - context.user = "John" - end - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - returns :token - end - - result = child_task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "token must be set in the context", - messages: { token: ["must be set in the context"] } - } - ) - end - end - - context "when removing returns" do - it "removes inherited returns" do - parent_task = create_task_class(name: "ParentTask") do - returns :user, :token - - def work - context.user = "John" - context.token = "abc123" - end - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - remove_return :token - - def work - context.user = "John" - end - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John") - end - end - - context "with attributes and returns" do - context "when attribute validation fails" do - it "does not validate returns" do - task = create_task_class do - required :name - returns :user - - def work - context.user = name - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "name must be accessible via the context source method", - messages: { name: ["must be accessible via the context source method"] } - } - ) - end - end - - context "when attribute validation passes but return is missing" do - it "fails due to missing return" do - task = create_task_class do - required :name - returns :user - - def work = nil - end - - result = task.execute(name: "John") - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "user must be set in the context", - messages: { user: ["must be set in the context"] } - } - ) - end - end - - context "when both attribute and return are valid" do - it "returns success" do - task = create_task_class do - required :name - returns :user - - def work - context.user = { name: name } - end - end - - result = task.execute(name: "John") - - expect(result).to be_successful - expect(result).to have_matching_context(user: { name: "John" }) - end - end - end -end diff --git a/spec/integration/workflows/breakpoints_spec.rb b/spec/integration/workflows/breakpoints_spec.rb deleted file mode 100644 index 53c009e43..000000000 --- a/spec/integration/workflows/breakpoints_spec.rb +++ /dev/null @@ -1,190 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Workflow breakpoints", type: :feature do - context "with default breakpoints" do - it "continues on skipped tasks" do - task1 = create_successful_task(name: "Task1") - task2 = create_skipping_task(name: "Task2", reason: "Skipped") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(4) - expect(result.chain.results[2].skipped?).to be(true) - end - - it "halts on failed tasks" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to have_failed(reason: "Failed", outcome: "interrupted") - expect(result.chain.results.size).to eq(3) - expect(result.chain.results[2].failed?).to be(true) - end - end - - context "with custom workflow breakpoints" do - it "halts on both skipped and failed" do - task1 = create_successful_task(name: "Task1") - task2 = create_skipping_task(name: "Task2", reason: "Skipped") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - settings(workflow_breakpoints: %w[skipped failed]) - - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to have_skipped(reason: "Skipped") - expect(result.chain.results.size).to eq(3) - end - - it "never halts with empty breakpoints" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - task3 = create_skipping_task(name: "Task3", reason: "Skipped") - task4 = create_successful_task(name: "Task4") - - workflow = create_workflow_class do - settings(workflow_breakpoints: []) - - task task1 - task task2 - task task3 - task task4 - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(5) - end - end - - context "with group-level breakpoints" do - it "applies different breakpoints to different groups" do - critical_task1 = create_successful_task(name: "CriticalTask1") - critical_task2 = create_skipping_task(name: "CriticalTask2", reason: "Skipped") - optional_task1 = create_successful_task(name: "OptionalTask1") - optional_task2 = create_failing_task(name: "OptionalTask2", reason: "Failed") - - workflow = create_workflow_class do - tasks critical_task1, - critical_task2, - workflow_breakpoints: %w[skipped failed] - - tasks optional_task1, - optional_task2, - breakpoints: [] - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(5) - end - - it "respects group breakpoints independently" do - group1_task1 = create_successful_task(name: "Group1Task1") - group1_task2 = create_skipping_task(name: "Group1Task2", reason: "Skipped") - group2_task1 = create_successful_task(name: "Group2Task1") - group2_task2 = create_skipping_task(name: "Group2Task2", reason: "Skipped") - - workflow = create_workflow_class do - tasks group1_task1, - group1_task2, - breakpoints: %w[skipped] - - tasks group2_task1, - group2_task2, - breakpoints: [] - end - - result = workflow.new.execute - - expect(result).to have_skipped(reason: "Skipped") - expect(result.chain.results.size).to eq(3) - end - end - - context "when propagating failures" do - it "throws failures from nested tasks" do - inner_task = create_failing_task(name: "InnerTask", reason: "Inner failure") - - outer_task = create_task_class(name: "OuterTask") do - define_method(:work) do - result = inner_task.execute - throw!(result) if result.failed? - end - end - - workflow = create_workflow_class do - task outer_task - end - - result = workflow.new.execute - - expect(result).to have_failed(reason: "Inner failure", outcome: "interrupted") - expect(result.threw_failure?).to be(false) - end - end - - context "with bang execution" do - it "raises on workflow failure" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - - workflow = create_workflow_class do - task task1 - task task2 - end - - expect { workflow.new.execute(raise: true) }.to raise_error(CMDx::FailFault) - end - end - - context "when continuing after failures" do - it "executes all tasks when breakpoints are empty" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - settings(workflow_breakpoints: []) - - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(4) - expect(result.chain.results[2].failed?).to be(true) - expect(result.chain.results[3].success?).to be(true) - end - end -end diff --git a/spec/integration/workflows/conditionals_spec.rb b/spec/integration/workflows/conditionals_spec.rb deleted file mode 100644 index fa7c571e2..000000000 --- a/spec/integration/workflows/conditionals_spec.rb +++ /dev/null @@ -1,184 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Workflow conditionals", type: :feature do - context "when using if conditionals" do - it "executes task when condition is true" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, if: :should_execute? - - def should_execute? - context.execute_task == true - end - end - - enabled_result = workflow.execute(execute_task: true) - disabled_result = workflow.execute(execute_task: false) - - expect(enabled_result).to be_successful - expect(enabled_result.chain.results.size).to eq(2) - - expect(disabled_result).to be_successful - expect(disabled_result.chain.results.size).to eq(1) - end - - it "uses proc for if condition" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, if: -> { context.enabled == true } - end - - enabled_result = workflow.execute(enabled: true) - disabled_result = workflow.execute(enabled: false) - - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) - end - - it "uses lambda for if condition" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, if: proc { context.enabled == true } - end - - enabled_result = workflow.execute(enabled: true) - disabled_result = workflow.execute(enabled: false) - - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) - end - end - - context "when using unless conditionals" do - it "executes task when condition is false" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, unless: :should_skip? - - def should_skip? - context.skip_task == true - end - end - - enabled_result = workflow.execute(skip_task: false) - disabled_result = workflow.execute(skip_task: true) - - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) - end - - it "uses proc for unless condition" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, unless: -> { context.disabled == true } - end - - enabled_result = workflow.execute(disabled: false) - disabled_result = workflow.execute(disabled: true) - - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) - end - end - - context "when combining if and unless" do - it "requires both conditions to be satisfied" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, - if: :should_execute?, - unless: :should_skip? - - def should_execute? - context.enabled == true - end - - def should_skip? - context.override == true - end - end - - both_satisfied = workflow.execute(enabled: true, override: false) - if_false = workflow.execute(enabled: false, override: false) - unless_false = workflow.execute(enabled: true, override: true) - - expect(both_satisfied.chain.results.size).to eq(2) - expect(if_false.chain.results.size).to eq(1) - expect(unless_false.chain.results.size).to eq(1) - end - end - - context "when using conditionals with groups" do - it "applies condition to all tasks in group" do - task1 = create_successful_task(name: "Task1") - task2 = create_successful_task(name: "Task2") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - tasks task1, task2, task3, if: :group_enabled? - - def group_enabled? - context.enable_group == true - end - end - - enabled_result = workflow.execute(enable_group: true) - disabled_result = workflow.execute(enable_group: false) - - expect(enabled_result.chain.results.size).to eq(4) - expect(disabled_result.chain.results.size).to eq(1) - end - end - - context "when conditionals affect execution flow" do - it "skips tasks based on runtime conditions" do - setup_task = create_task_class(name: "SetupTask") do - def work - context.setup_complete = true - end - end - conditional_task = create_successful_task(name: "ConditionalTask") - final_task = create_successful_task(name: "FinalTask") - - workflow = create_workflow_class do - task setup_task - task conditional_task, if: proc { context.setup_complete == true } - task final_task - end - - result = workflow.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(4) - expect(result).to have_matching_context(setup_complete: true) - end - end - - context "when using complex conditional logic" do - it "evaluates complex conditions" do - task1 = create_successful_task(name: "Task1") - - workflow = create_workflow_class do - task task1, if: proc { - context.env == "production" && context.feature_enabled == true - } - end - - prod_enabled = workflow.execute(env: "production", feature_enabled: true) - prod_disabled = workflow.execute(env: "production", feature_enabled: false) - dev_enabled = workflow.execute(env: "development", feature_enabled: true) - - expect(prod_enabled.chain.results.size).to eq(2) - expect(prod_disabled.chain.results.size).to eq(1) - expect(dev_enabled.chain.results.size).to eq(1) - end - end -end diff --git a/spec/integration/workflows/execution_spec.rb b/spec/integration/workflows/execution_spec.rb deleted file mode 100644 index dbe4485af..000000000 --- a/spec/integration/workflows/execution_spec.rb +++ /dev/null @@ -1,105 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Workflow execution", type: :feature do - context "when non-blocking" do - subject(:result) { workflow.execute } - - context "when successful" do - let(:workflow) { create_successful_workflow } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success inner middle outer success]) - end - end - - context "when skipping" do - let(:workflow) { create_skipping_workflow } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success success]) - end - end - - context "when failing" do - let(:workflow) { create_failing_workflow } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 2, - class: start_with("OuterTask") - ), - caused_failure: hash_including( - index: 4, - class: start_with("InnerTask") - ) - ) - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "when erroring" do - let(:workflow) { create_erroring_workflow } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::FailFault), - threw_failure: hash_including( - index: 2, - class: start_with("OuterTask") - ), - caused_failure: hash_including( - index: 4, - class: start_with("InnerTask") - ) - ) - expect(result).to have_matching_context(executed: %i[success]) - end - end - end - - context "when blocking" do - subject(:result) { workflow.execute! } - - context "when successful" do - let(:workflow) { create_successful_workflow } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success inner middle outer success]) - end - end - - context "when skipping" do - let(:workflow) { create_skipping_workflow } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success success]) - end - end - - context "when failing" do - let(:workflow) { create_failing_workflow } - - it "returns failure" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:workflow) { create_erroring_workflow } - - it "returns failure" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end - end - end -end diff --git a/spec/support/config/i18n.rb b/spec/support/config/i18n.rb deleted file mode 100644 index 5f364cb8c..000000000 --- a/spec/support/config/i18n.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -require "i18n" - -locales = Dir[CMDx.gem_path.join("lib/locales/*.yml")] - -I18n.load_path += locales -I18n.available_locales = locales.map { |path| File.basename(path, ".yml").to_sym } -I18n.enforce_available_locales = true -I18n.reload! - -I18n.default_locale = :en -I18n.locale = :en diff --git a/spec/support/helpers/ruby_version.rb b/spec/support/helpers/ruby_version.rb deleted file mode 100644 index 71c1a27dd..000000000 --- a/spec/support/helpers/ruby_version.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module RubyVersion - - extend self - - def version - @version ||= Gem::Version.new(RUBY_VERSION) - end - - def min?(min) - version >= Gem::Version.new(min.to_s) - end - - def max?(max) - version <= Gem::Version.new(max.to_s) - end - -end diff --git a/spec/support/helpers/task_builders.rb b/spec/support/helpers/task_builders.rb deleted file mode 100644 index d566c8e48..000000000 --- a/spec/support/helpers/task_builders.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -module CMDx - - TestError = Class.new(StandardError) - - module Testing - module TaskBuilders - - def mock_settings(**attrs) - settings = CMDx::Settings.allocate - attrs.each { |k, v| settings.public_send(:"#{k}=", v) } - settings - end - - # Base - - def create_task_class(base: CMDx::Task, name: "AnonymousTask", &) - task_class = Class.new(base) - task_class.define_singleton_method(:name) { @name ||= name.to_s + rand(9999).to_s.rjust(4, "0") } - task_class.class_eval(&) if block_given? - task_class - end - - # Simple - - def create_successful_task(base: CMDx::Task, name: "SuccessfulTask", &) - task_class = create_task_class(base:, name:) - task_class.class_eval(&) if block_given? - task_class.define_method(:work) { (context.executed ||= []) << :success } - task_class - end - - def create_skipping_task(base: CMDx::Task, name: "SkippingTask", reason: nil, **metadata, &) - task_class = create_task_class(base:, name:) - task_class.class_eval(&) if block_given? - task_class.define_method(:work) do - skip!(reason, **metadata) - (context.executed ||= []) << :skipped - end - task_class - end - - def create_failing_task(base: CMDx::Task, name: "FailingTask", reason: nil, **metadata, &) - task_class = create_task_class(base:, name:) - task_class.class_eval(&) if block_given? - task_class.define_method(:work) do - fail!(reason, **metadata) - (context.executed ||= []) << :failed - end - task_class - end - - def create_erroring_task(base: CMDx::Task, name: "ErroringTask", reason: nil, **_metadata, &) - task_class = create_task_class(base:, name:) - task_class.class_eval(&) if block_given? - task_class.define_method(:work) do - raise TestError, reason || "borked error" - (context.executed ||= []) << :errored # rubocop:disable Lint/UnreachableCode - end - task_class - end - - # Nested - - def create_nested_task(base: CMDx::Task, strategy: :swallow, status: :success, reason: nil, **metadata, &) - inner_task = create_task_class(base:, name: "InnerTask") - inner_task.class_eval(&) if block_given? - inner_task.define_method(:work) do - case status - when :success then (context.executed ||= []) << :inner - when :skipped then skip!(reason, **metadata) - when :failure then fail!(reason, **metadata) - when :error then raise TestError, reason || "borked error" - else raise "unknown status #{status}" - end - end - - middle_task = create_task_class(base:, name: "MiddleTask") - middle_task.class_eval(&) if block_given? - middle_task.define_method(:work) do - case strategy - when :swallow then inner_task.execute(context) - when :throw, :raise_throw then throw!(inner_task.execute(context)) - when :raise, :throw_raise then inner_task.execute!(context) - else raise "unknown strategy #{strategy}" - end - - (context.executed ||= []) << :middle - end - - outer_task = create_task_class(base:, name: "OuterTask") - outer_task.class_eval(&) if block_given? - outer_task.define_method(:work) do - case strategy - when :swallow then middle_task.execute(context) - when :throw, :throw_raise then throw!(middle_task.execute(context)) - when :raise, :raise_throw then middle_task.execute!(context) - else raise "unknown strategy #{strategy}" - end - - (context.executed ||= []) << :outer - end - outer_task - end - - end - end - -end diff --git a/spec/support/helpers/workflow_builders.rb b/spec/support/helpers/workflow_builders.rb deleted file mode 100644 index 4ca5d36c8..000000000 --- a/spec/support/helpers/workflow_builders.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Testing - module WorkflowBuilders - - # Base - - def create_workflow_class(base: CMDx::Task, name: "AnonymousWorkflow", &) - workflow_class = Class.new(base) - workflow_class.include(CMDx::Workflow) - workflow_class.define_singleton_method(:name) { @name ||= name.to_s + rand(9999).to_s.rjust(4, "0") } - workflow_class.class_eval(&) if block_given? - workflow_class - end - - # Simple - - def create_successful_workflow(base: CMDx::Task, name: "SuccessfulWorkflow", &block) - task1 = create_successful_task(base:, name: "SuccessfulTask1") - task2 = create_nested_task(base:, strategy: :throw, status: :success) - task3 = create_successful_task(base:, name: "SuccessfulTask3") - - create_workflow_class(base:, name:) do - tasks task1, task2, task3 - - class_eval(&block) if block_given? - end - end - - def create_skipping_workflow(base: CMDx::Task, name: "SkippingWorkflow", &block) - pre_skip_task = create_successful_task(base:, name: "PreSkipTask") - skipping_task = create_nested_task(base:, strategy: :throw, status: :skipped) - post_skip_task = create_successful_task(base:, name: "PostSkipTask") - - create_workflow_class(base:, name:) do - tasks pre_skip_task, skipping_task, post_skip_task - - class_eval(&block) if block_given? - end - end - - def create_failing_workflow(base: CMDx::Task, name: "FailingWorkflow", &block) - pre_fail_task = create_successful_task(base:, name: "PreFailTask") - failing_task = create_nested_task(base:, strategy: :throw, status: :failure) - post_fail_task = create_successful_task(base:, name: "PostFailTask") - - create_workflow_class(base:, name:) do - tasks pre_fail_task, failing_task, post_fail_task - - class_eval(&block) if block_given? - end - end - - def create_erroring_workflow(base: CMDx::Task, name: "ErroringWorkflow", &block) - pre_error_task = create_successful_task(base:, name: "PreErrorTask") - erroring_task = create_nested_task(base:, strategy: :raise, status: :error) - post_error_task = create_successful_task(base:, name: "PostErrorTask") - - create_workflow_class(base:, name:) do - tasks pre_error_task, erroring_task, post_error_task - - class_eval(&block) if block_given? - end - end - - end - end -end From 6056e96c87493538f175cd34ab077e676fb9d300 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Fri, 10 Apr 2026 00:43:11 -0400 Subject: [PATCH 3/4] remove old files --- cmdx.gemspec | 1 - lib/cmdx.rb | 90 ++++++++++---------- lib/generators/cmdx/install_generator.rb | 35 -------- lib/generators/cmdx/locale_generator.rb | 39 --------- lib/generators/cmdx/task_generator.rb | 57 ------------- lib/generators/cmdx/templates/install.rb | 65 -------------- lib/generators/cmdx/templates/task.rb.tt | 10 --- lib/generators/cmdx/templates/workflow.rb.tt | 8 -- lib/generators/cmdx/workflow_generator.rb | 57 ------------- lib/locales/af.yml | 55 ------------ lib/locales/ar.yml | 55 ------------ lib/locales/az.yml | 55 ------------ lib/locales/be.yml | 55 ------------ lib/locales/bg.yml | 55 ------------ lib/locales/bn.yml | 55 ------------ lib/locales/bs.yml | 55 ------------ lib/locales/ca.yml | 55 ------------ lib/locales/cnr.yml | 55 ------------ lib/locales/cs.yml | 55 ------------ lib/locales/cy.yml | 55 ------------ lib/locales/da.yml | 55 ------------ lib/locales/de.yml | 55 ------------ lib/locales/dz.yml | 55 ------------ lib/locales/el.yml | 55 ------------ lib/locales/en.yml | 55 ------------ lib/locales/eo.yml | 55 ------------ lib/locales/es.yml | 55 ------------ lib/locales/et.yml | 55 ------------ lib/locales/eu.yml | 55 ------------ lib/locales/fa.yml | 55 ------------ lib/locales/fi.yml | 55 ------------ lib/locales/fr.yml | 55 ------------ lib/locales/fy.yml | 55 ------------ lib/locales/gd.yml | 55 ------------ lib/locales/gl.yml | 55 ------------ lib/locales/he.yml | 55 ------------ lib/locales/hi.yml | 55 ------------ lib/locales/hr.yml | 55 ------------ lib/locales/hu.yml | 55 ------------ lib/locales/hy.yml | 55 ------------ lib/locales/id.yml | 55 ------------ lib/locales/is.yml | 55 ------------ lib/locales/it.yml | 55 ------------ lib/locales/ja.yml | 55 ------------ lib/locales/ka.yml | 55 ------------ lib/locales/kk.yml | 55 ------------ lib/locales/km.yml | 55 ------------ lib/locales/kn.yml | 55 ------------ lib/locales/ko.yml | 55 ------------ lib/locales/lb.yml | 55 ------------ lib/locales/lo.yml | 55 ------------ lib/locales/lt.yml | 55 ------------ lib/locales/lv.yml | 55 ------------ lib/locales/mg.yml | 55 ------------ lib/locales/mk.yml | 55 ------------ lib/locales/ml.yml | 55 ------------ lib/locales/mn.yml | 55 ------------ lib/locales/mr-IN.yml | 55 ------------ lib/locales/ms.yml | 55 ------------ lib/locales/nb.yml | 55 ------------ lib/locales/ne.yml | 55 ------------ lib/locales/nl.yml | 55 ------------ lib/locales/nn.yml | 55 ------------ lib/locales/oc.yml | 55 ------------ lib/locales/or.yml | 55 ------------ lib/locales/pa.yml | 55 ------------ lib/locales/pl.yml | 55 ------------ lib/locales/pt.yml | 55 ------------ lib/locales/rm.yml | 55 ------------ lib/locales/ro.yml | 55 ------------ lib/locales/ru.yml | 55 ------------ lib/locales/sc.yml | 55 ------------ lib/locales/sk.yml | 55 ------------ lib/locales/sl.yml | 55 ------------ lib/locales/sq.yml | 55 ------------ lib/locales/sr.yml | 55 ------------ lib/locales/st.yml | 55 ------------ lib/locales/sv.yml | 55 ------------ lib/locales/sw.yml | 55 ------------ lib/locales/ta.yml | 55 ------------ lib/locales/te.yml | 55 ------------ lib/locales/th.yml | 55 ------------ lib/locales/tl.yml | 55 ------------ lib/locales/tr.yml | 55 ------------ lib/locales/tt.yml | 55 ------------ lib/locales/ug.yml | 55 ------------ lib/locales/uk.yml | 55 ------------ lib/locales/ur.yml | 55 ------------ lib/locales/uz.yml | 55 ------------ lib/locales/vi.yml | 55 ------------ lib/locales/wo.yml | 55 ------------ lib/locales/zh-CN.yml | 55 ------------ lib/locales/zh-HK.yml | 55 ------------ lib/locales/zh-TW.yml | 55 ------------ lib/locales/zh-YUE.yml | 55 ------------ 95 files changed, 45 insertions(+), 5047 deletions(-) delete mode 100644 lib/generators/cmdx/install_generator.rb delete mode 100644 lib/generators/cmdx/locale_generator.rb delete mode 100644 lib/generators/cmdx/task_generator.rb delete mode 100644 lib/generators/cmdx/templates/install.rb delete mode 100644 lib/generators/cmdx/templates/task.rb.tt delete mode 100644 lib/generators/cmdx/templates/workflow.rb.tt delete mode 100644 lib/generators/cmdx/workflow_generator.rb delete mode 100644 lib/locales/af.yml delete mode 100644 lib/locales/ar.yml delete mode 100644 lib/locales/az.yml delete mode 100644 lib/locales/be.yml delete mode 100644 lib/locales/bg.yml delete mode 100644 lib/locales/bn.yml delete mode 100644 lib/locales/bs.yml delete mode 100644 lib/locales/ca.yml delete mode 100644 lib/locales/cnr.yml delete mode 100644 lib/locales/cs.yml delete mode 100644 lib/locales/cy.yml delete mode 100644 lib/locales/da.yml delete mode 100644 lib/locales/de.yml delete mode 100644 lib/locales/dz.yml delete mode 100644 lib/locales/el.yml delete mode 100644 lib/locales/en.yml delete mode 100644 lib/locales/eo.yml delete mode 100644 lib/locales/es.yml delete mode 100644 lib/locales/et.yml delete mode 100644 lib/locales/eu.yml delete mode 100644 lib/locales/fa.yml delete mode 100644 lib/locales/fi.yml delete mode 100644 lib/locales/fr.yml delete mode 100644 lib/locales/fy.yml delete mode 100644 lib/locales/gd.yml delete mode 100644 lib/locales/gl.yml delete mode 100644 lib/locales/he.yml delete mode 100644 lib/locales/hi.yml delete mode 100644 lib/locales/hr.yml delete mode 100644 lib/locales/hu.yml delete mode 100644 lib/locales/hy.yml delete mode 100644 lib/locales/id.yml delete mode 100644 lib/locales/is.yml delete mode 100644 lib/locales/it.yml delete mode 100644 lib/locales/ja.yml delete mode 100644 lib/locales/ka.yml delete mode 100644 lib/locales/kk.yml delete mode 100644 lib/locales/km.yml delete mode 100644 lib/locales/kn.yml delete mode 100644 lib/locales/ko.yml delete mode 100644 lib/locales/lb.yml delete mode 100644 lib/locales/lo.yml delete mode 100644 lib/locales/lt.yml delete mode 100644 lib/locales/lv.yml delete mode 100644 lib/locales/mg.yml delete mode 100644 lib/locales/mk.yml delete mode 100644 lib/locales/ml.yml delete mode 100644 lib/locales/mn.yml delete mode 100644 lib/locales/mr-IN.yml delete mode 100644 lib/locales/ms.yml delete mode 100644 lib/locales/nb.yml delete mode 100644 lib/locales/ne.yml delete mode 100644 lib/locales/nl.yml delete mode 100644 lib/locales/nn.yml delete mode 100644 lib/locales/oc.yml delete mode 100644 lib/locales/or.yml delete mode 100644 lib/locales/pa.yml delete mode 100644 lib/locales/pl.yml delete mode 100644 lib/locales/pt.yml delete mode 100644 lib/locales/rm.yml delete mode 100644 lib/locales/ro.yml delete mode 100644 lib/locales/ru.yml delete mode 100644 lib/locales/sc.yml delete mode 100644 lib/locales/sk.yml delete mode 100644 lib/locales/sl.yml delete mode 100644 lib/locales/sq.yml delete mode 100644 lib/locales/sr.yml delete mode 100644 lib/locales/st.yml delete mode 100644 lib/locales/sv.yml delete mode 100644 lib/locales/sw.yml delete mode 100644 lib/locales/ta.yml delete mode 100644 lib/locales/te.yml delete mode 100644 lib/locales/th.yml delete mode 100644 lib/locales/tl.yml delete mode 100644 lib/locales/tr.yml delete mode 100644 lib/locales/tt.yml delete mode 100644 lib/locales/ug.yml delete mode 100644 lib/locales/uk.yml delete mode 100644 lib/locales/ur.yml delete mode 100644 lib/locales/uz.yml delete mode 100644 lib/locales/vi.yml delete mode 100644 lib/locales/wo.yml delete mode 100644 lib/locales/zh-CN.yml delete mode 100644 lib/locales/zh-HK.yml delete mode 100644 lib/locales/zh-TW.yml delete mode 100644 lib/locales/zh-YUE.yml diff --git a/cmdx.gemspec b/cmdx.gemspec index 37efaac03..cf6ac9d6c 100644 --- a/cmdx.gemspec +++ b/cmdx.gemspec @@ -56,7 +56,6 @@ Gem::Specification.new do |spec| spec.add_dependency "bigdecimal" spec.add_dependency "logger" - spec.add_dependency "zeitwerk" spec.add_development_dependency "bundler" spec.add_development_dependency "cmdx-rspec" diff --git a/lib/cmdx.rb b/lib/cmdx.rb index 89a4f8bcf..4b342e9a7 100644 --- a/lib/cmdx.rb +++ b/lib/cmdx.rb @@ -7,73 +7,73 @@ require "logger" require "pathname" require "securerandom" -require "set" require "time" require "timeout" -require "yaml" -require "zeitwerk" + +require_relative "cmdx/version" +require_relative "cmdx/errors" +require_relative "cmdx/messages" +require_relative "cmdx/callable" +require_relative "cmdx/context" +require_relative "cmdx/error_set" +require_relative "cmdx/result" +require_relative "cmdx/chain" +require_relative "cmdx/coercions" +require_relative "cmdx/validators" +require_relative "cmdx/attribute" +require_relative "cmdx/attribute_set" +require_relative "cmdx/configuration" +require_relative "cmdx/settings" +require_relative "cmdx/callbacks" +require_relative "cmdx/middleware_stack" +require_relative "cmdx/returns" +require_relative "cmdx/task" +require_relative "cmdx/workflow" +require_relative "cmdx/log_entry" +require_relative "cmdx/log_formatters/line" +require_relative "cmdx/log_formatters/json" +require_relative "cmdx/log_formatters/key_value" +require_relative "cmdx/log_formatters/logstash" +require_relative "cmdx/log_formatters/raw" +require_relative "cmdx/middlewares/timeout" +require_relative "cmdx/middlewares/correlate" +require_relative "cmdx/middlewares/runtime" module CMDx - # @rbs EMPTY_ARRAY: Array[untyped] EMPTY_ARRAY = [].freeze private_constant :EMPTY_ARRAY - # @rbs EMPTY_HASH: Hash[untyped, untyped] EMPTY_HASH = {}.freeze private_constant :EMPTY_HASH - # @rbs EMPTY_STRING: String EMPTY_STRING = "" private_constant :EMPTY_STRING extend self - # Returns the path to the CMDx gem. - # - # @return [Pathname] the path to the CMDx gem - # - # @example - # CMDx.gem_path # => Pathname.new("/path/to/cmdx") - # - # @rbs return: Pathname + # @return [Pathname] def gem_path @gem_path ||= Pathname.new(__dir__).parent end -end - -# Set up Zeitwerk loader for the CMDx gem -loader = Zeitwerk::Loader.for_gem -loader.inflector.inflect("cmdx" => "CMDx", "json" => "JSON") -loader.ignore("#{__dir__}/cmdx/configuration") -loader.ignore("#{__dir__}/cmdx/exception") -loader.ignore("#{__dir__}/cmdx/fault") -loader.ignore("#{__dir__}/cmdx/railtie") -loader.ignore("#{__dir__}/generators") -loader.ignore("#{__dir__}/locales") -loader.setup + # @return [CMDx::Configuration] + def configuration + @configuration ||= Configuration.new + end -# Pre-load configuration to make module methods available -# This is acceptable since configuration is fundamental to the framework -require_relative "cmdx/configuration" + # @yield [CMDx::Configuration] + def configure + yield(configuration) + end -# Pre-load exceptions to make them available at the top level -# This ensures CMDx::Error and its descendants are always available -require_relative "cmdx/exception" + # Reset global configuration to defaults. + # @return [void] + def reset_configuration! + @configuration = Configuration.new + end -# Pre-load fault classes to make them available at the top level -# This ensures CMDx::FailFault and CMDx::SkipFault are always available -require_relative "cmdx/fault" + # @return [#call, nil] custom message resolver for i18n integration + attr_accessor :message_resolver -# Conditionally load Rails components if Rails is available -if defined?(Rails::Generators) - require_relative "generators/cmdx/install_generator" - require_relative "generators/cmdx/locale_generator" - require_relative "generators/cmdx/task_generator" - require_relative "generators/cmdx/workflow_generator" end - -# Load the Railtie last after everything else is required so we don't -# need to load any CMDx components when we use this Railtie. -require_relative "cmdx/railtie" if defined?(Rails::Railtie) diff --git a/lib/generators/cmdx/install_generator.rb b/lib/generators/cmdx/install_generator.rb deleted file mode 100644 index 5a0c8a714..000000000 --- a/lib/generators/cmdx/install_generator.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Cmdx - # Generates CMDx initializer file for Rails applications - # - # This generator creates a configuration initializer that sets up global - # CMDx settings for the Rails application. It copies a pre-configured - # initializer template to the standard Rails initializers directory. - class InstallGenerator < Rails::Generators::Base - - source_root File.expand_path("templates", __dir__) - - desc "Creates CMDx initializer with global configuration settings" - - # Copies the CMDx initializer template to the Rails application - # - # Creates a new initializer file at `config/initializers/cmdx.rb` containing - # the default CMDx configuration settings. This allows applications to - # customize global CMDx behavior through the standard Rails configuration - # pattern. - # - # @return [void] - # - # @example Basic usage - # rails generate cmdx:install - # - # @example Custom initializer location - # generator.copy_initializer_file - # # => Creates config/initializers/cmdx.rb - def copy_initializer_file - copy_file("install.rb", "config/initializers/cmdx.rb") - end - - end -end diff --git a/lib/generators/cmdx/locale_generator.rb b/lib/generators/cmdx/locale_generator.rb deleted file mode 100644 index 3db52087f..000000000 --- a/lib/generators/cmdx/locale_generator.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Cmdx - # Generates CMDx locale files for Rails applications - # - # Rails generator that copies CMDx locale files into the application's - # config/locales directory. This allows applications to customize and extend - # the default CMDx locale files. - class LocaleGenerator < Rails::Generators::Base - - source_root File.expand_path("../../locales", __dir__) - - desc "Copies the locale with the given ISO 639 code" - - argument :locale, type: :string, default: "en", banner: "locale: en, es, fr, etc" - - # Copies the locale template to the Rails application - # - # Copies the specified locale file from the gem's locales directory to the - # application's config/locales directory. If the locale file doesn't exist - # in the gem, the generator will fail gracefully. - # - # @return [void] - # - # @example Copy default (English) locale file - # generator = Cmdx::LocaleGenerator.new(["en"]) - # generator.copy_locale_files - # # => Creates config/locales/en.yml - # - # @example Copy Spanish locale file - # generator = Cmdx::LocaleGenerator.new(["es"]) - # generator.copy_locale_files - # # => Creates config/locales/es.yml - def copy_locale_files - copy_file("#{locale}.yml", "config/locales/#{locale}.yml") - end - - end -end diff --git a/lib/generators/cmdx/task_generator.rb b/lib/generators/cmdx/task_generator.rb deleted file mode 100644 index 3cb908ff7..000000000 --- a/lib/generators/cmdx/task_generator.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -module Cmdx - # Generates CMDx task files for Rails applications - # - # This generator creates task classes that inherit from either ApplicationTask - # (if defined) or CMDx::Task. It generates the task file in the standard - # Rails tasks directory structure. - class TaskGenerator < Rails::Generators::NamedBase - - source_root File.expand_path("templates", __dir__) - - desc "Creates a task with the given NAME" - - # Copies the task template to the Rails application - # - # Creates a new task file at `app/tasks/[class_path]/[file_name].rb` using - # the task template. The file is placed in the standard Rails tasks directory - # structure, maintaining proper namespacing if the task is nested. - # - # @return [void] - # - # @example Basic usage - # rails generate cmdx:task UserRegistration - # # => Creates app/tasks/user_registration.rb - # - # @example Nested task - # rails generate cmdx:task Admin::UserManagement - # # => Creates app/tasks/admin/user_management.rb - def copy_files - path = File.join("app/tasks", class_path, "#{file_name}.rb") - template("task.rb.tt", path) - end - - private - - # Determines the appropriate parent class name for the generated task - # - # Attempts to use ApplicationTask if it exists in the application, otherwise - # falls back to CMDx::Task. This allows applications to define their own - # base task class while maintaining compatibility. - # - # @return [Class] The parent class for the generated task - # - # @example - # parent_class_name # => ApplicationTask - # - # @example Fallback behavior - # parent_class_name # => CMDx::Task - def parent_class_name - ApplicationTask - rescue NameError - CMDx::Task - end - - end -end diff --git a/lib/generators/cmdx/templates/install.rb b/lib/generators/cmdx/templates/install.rb deleted file mode 100644 index 9ebadffa8..000000000 --- a/lib/generators/cmdx/templates/install.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -CMDx.configure do |config| - # Task breakpoint configuration - controls when execute! raises faults - # See https://github.com/drexed/cmdx/blob/main/docs/outcomes/statuses.md for more details - # - # Available statuses: "success", "skipped", "failed" - # If set to an empty array, task will never halt - config.task_breakpoints = %w[failed] - - # Workflow breakpoint configuration - controls when workflows stop execution - # When a task returns these statuses, subsequent workflow tasks won't execute - # See https://github.com/drexed/cmdx/blob/main/docs/workflows.md for more details - # - # Available statuses: "success", "skipped", "failed" - # If set to an empty array, workflow will never halt - config.workflow_breakpoints = %w[failed] - - # Logger configuration - choose from multiple formatters - # See https://github.com/drexed/cmdx/blob/main/docs/logging.md for more details - # - # Available formatters: - # - CMDx::LogFormatters::Json - # - CMDx::LogFormatters::KeyValue - # - CMDx::LogFormatters::Line - # - CMDx::LogFormatters::Logstash - # - CMDx::LogFormatters::Raw - config.logger = Logger.new( - $stdout, - progname: "cmdx", - formatter: CMDx::LogFormatters::Line.new, - level: Logger::INFO - ) - - # Rollback configuration - controls which statuses trigger task rollback - # See https://github.com/drexed/cmdx/blob/main/docs/outcomes/statuses.md for more details - # - # Available statuses: "success", "skipped", "failed" - # If set to an empty array, task will never rollback - config.rollback_on = %w[failed] - - # Default locale configuration - used for built-in translation lookups - # Must match the basename of a YAML file in lib/locales/ (e.g. "en", "es", "ja") - # config.default_locale = "en" - - # Backtrace configuration - controls whether to log backtraces on faults and exceptions - # https://github.com/drexed/cmdx/blob/main/docs/configuration.md#backtraces - # config.backtrace = false - # config.backtrace_cleaner = nil - - # Exception handler configuration - called when non-fault exceptions are raised - # https://github.com/drexed/cmdx/blob/main/docs/configuration.md#exception-handlers - # config.exception_handler = nil - - # Dump context configuration - include context data in hash representation output - # https://github.com/drexed/cmdx/blob/main/docs/configuration.md#dump-context - # config.dump_context = false - - # Additional global configurations - automatically applied to all tasks - # - # Middlewares - https://github.com/drexed/cmdx/blob/main/docs/middlewares.md - # Callbacks - https://github.com/drexed/cmdx/blob/main/docs/callbacks.md - # Coercions - https://github.com/drexed/cmdx/blob/main/docs/attributes/coercions.md - # Validations - https://github.com/drexed/cmdx/blob/main/docs/attributes/validations.md -end diff --git a/lib/generators/cmdx/templates/task.rb.tt b/lib/generators/cmdx/templates/task.rb.tt deleted file mode 100644 index b0f698e46..000000000 --- a/lib/generators/cmdx/templates/task.rb.tt +++ /dev/null @@ -1,10 +0,0 @@ -<% module_namespacing do -%> - class <%= class_name %> < <%= parent_class_name %> - - def work - # Your logic here... - # Docs: https://github.com/drexed/cmdx - end - - end -<% end -%> diff --git a/lib/generators/cmdx/templates/workflow.rb.tt b/lib/generators/cmdx/templates/workflow.rb.tt deleted file mode 100644 index baa989f27..000000000 --- a/lib/generators/cmdx/templates/workflow.rb.tt +++ /dev/null @@ -1,8 +0,0 @@ -<% module_namespacing do -%> - class <%= class_name %> < <%= parent_class_name %> - include CMDx::Workflow - - tasks Task1, Task2 - # Docs: https://github.com/drexed/cmdx/docs/workflows.md - end -<% end -%> diff --git a/lib/generators/cmdx/workflow_generator.rb b/lib/generators/cmdx/workflow_generator.rb deleted file mode 100644 index 9c77e7707..000000000 --- a/lib/generators/cmdx/workflow_generator.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -module Cmdx - # Generates CMDx workflow files for Rails applications - # - # This generator creates task classes that inherit from either ApplicationTask - # (if defined) or CMDx::Task. It generates the task file in the standard - # Rails tasks directory structure. - class WorkflowGenerator < Rails::Generators::NamedBase - - source_root File.expand_path("templates", __dir__) - - desc "Creates a workflow with the given NAME" - - # Copies the task template to the Rails application - # - # Creates a new task file at `app/tasks/[class_path]/[file_name].rb` using - # the task template. The file is placed in the standard Rails tasks directory - # structure, maintaining proper namespacing if the task is nested. - # - # @return [void] - # - # @example Basic usage - # rails generate cmdx:workflow SendNotifications - # # => Creates app/tasks/send_notifications.rb - # - # @example Nested task - # rails generate cmdx:workflow Admin::SendNotifications - # # => Creates app/tasks/admin/send_notifications.rb - def copy_files - path = File.join("app/tasks", class_path, "#{file_name}.rb") - template("workflow.rb.tt", path) - end - - private - - # Determines the appropriate parent class name for the generated task - # - # Attempts to use ApplicationTask if it exists in the application, otherwise - # falls back to CMDx::Task. This allows applications to define their own - # base task class while maintaining compatibility. - # - # @return [Class] The parent class for the generated task - # - # @example - # parent_class_name # => ApplicationTask - # - # @example Fallback behavior - # parent_class_name # => CMDx::Task - def parent_class_name - ApplicationTask - rescue NameError - CMDx::Task - end - - end -end diff --git a/lib/locales/af.yml b/lib/locales/af.yml deleted file mode 100644 index b9f013330..000000000 --- a/lib/locales/af.yml +++ /dev/null @@ -1,55 +0,0 @@ -af: - cmdx: - attributes: - required: "moet toeganklik wees via die %{method} bronmetode" - undefined: "delegeer na ongedefinieerde metode %{method}" - coercions: - into_a: "kon nie na %{type} omskep word nie" - into_an: "kon nie na %{type} omskep word nie" - into_any: "kon nie na een van: %{types} omskep word nie" - unknown: "onbekende %{type} omskep tipe" - returns: - missing: "moet in die konteks gestel word" - faults: - invalid: "Ongeldig" - unspecified: "Ongespesifiseer" - reasons: - unspecified: "Ongespesifiseer" - types: - array: "skikking" - big_decimal: "groot desimale" - boolean: "booleaanse" - complex: "kompleks" - date_time: "datum en tyd" - date: "datum" - float: "drywende punt" - hash: "hash" - integer: "heelgetal" - rational: "rasionaal" - string: "string" - symbol: "simbool" - time: "tyd" - validators: - absence: "moet leeg wees" - exclusion: - of: "moet nie een van wees nie: %{values}" - within: "moet nie binne %{min} en %{max} wees nie" - format: "is 'n ongeldige formaat" - inclusion: - of: "moet een van wees: %{values}" - within: "moet binne %{min} en %{max} wees" - length: - is: "lengte moet %{is} wees" - is_not: "lengte moet nie %{is_not} wees nie" - min: "lengte moet ten minste %{min} wees" - max: "lengte kan maksimum %{max} wees" - not_within: "lengte moet nie binne %{min} en %{max} wees nie" - within: "lengte moet binne %{min} en %{max} wees" - numeric: - is: "moet %{is} wees" - is_not: "moet nie %{is_not} wees nie" - min: "moet ten minste %{min} wees" - max: "kan maksimum %{max} wees" - not_within: "moet nie binne %{min} en %{max} wees nie" - within: "moet binne %{min} en %{max} wees" - presence: "kan nie leeg wees nie" diff --git a/lib/locales/ar.yml b/lib/locales/ar.yml deleted file mode 100644 index 84d286397..000000000 --- a/lib/locales/ar.yml +++ /dev/null @@ -1,55 +0,0 @@ -ar: - cmdx: - attributes: - required: "يجب أن يكون قابلاً للوصول عبر طريقة المصدر %{method}" - undefined: "يفوض إلى طريقة غير معرفة %{method}" - coercions: - into_a: "لا يمكن تحويله إلى %{type}" - into_an: "لا يمكن تحويله إلى %{type}" - into_any: "لا يمكن تحويله إلى واحد من: %{types}" - unknown: "نوع تحويل %{type} غير معروف" - returns: - missing: "يجب تعيينه في السياق" - faults: - invalid: "غير صالح" - unspecified: "غير محدد" - reasons: - unspecified: "غير محدد" - types: - array: "مصفوفة" - big_decimal: "عدد عشري كبير" - boolean: "منطقي" - complex: "مركب" - date_time: "التاريخ والوقت" - date: "التاريخ" - float: "رقم عشري" - hash: "جدول تجزئة" - integer: "عدد صحيح" - rational: "عدد نسبي" - string: "سلسلة نصية" - symbol: "رمز" - time: "الوقت" - validators: - absence: "يجب أن يكون فارغاً" - exclusion: - of: "يجب ألا يكون واحداً من: %{values}" - within: "يجب ألا يكون بين %{min} و %{max}" - format: "تنسيق غير صالح" - inclusion: - of: "يجب أن يكون واحداً من: %{values}" - within: "يجب أن يكون بين %{min} و %{max}" - length: - is: "يجب أن يكون الطول %{is}" - is_not: "يجب ألا يكون الطول %{is_not}" - min: "يجب أن يكون الطول على الأقل %{min}" - max: "يجب أن يكون الطول على الأكثر %{max}" - not_within: "يجب ألا يكون الطول بين %{min} و %{max}" - within: "يجب أن يكون الطول بين %{min} و %{max}" - numeric: - is: "يجب أن يكون %{is}" - is_not: "يجب ألا يكون %{is_not}" - min: "يجب أن يكون على الأقل %{min}" - max: "يجب أن يكون على الأكثر %{max}" - not_within: "يجب ألا يكون بين %{min} و %{max}" - within: "يجب أن يكون بين %{min} و %{max}" - presence: "لا يمكن أن يكون فارغاً" diff --git a/lib/locales/az.yml b/lib/locales/az.yml deleted file mode 100644 index b6cc9e6a9..000000000 --- a/lib/locales/az.yml +++ /dev/null @@ -1,55 +0,0 @@ -az: - cmdx: - attributes: - required: "%{method} mənbə metodu vasitəsilə əlçatan olmalıdır" - undefined: "müəyyən edilməmiş metoda %{method} təyin edir" - coercions: - into_a: "%{type} tipinə çevrilə bilmədi" - into_an: "%{type} tipinə çevrilə bilmədi" - into_any: "aşağıdakılardan birinə çevrilə bilmədi: %{types}" - unknown: "naməlum %{type} çevrilmə tipi" - returns: - missing: "kontekstdə təyin edilməlidir" - faults: - invalid: "Etibarsız" - unspecified: "Göstərilməyib" - reasons: - unspecified: "Göstərilməyib" - types: - array: "massiv" - big_decimal: "böyük onluq" - boolean: "məntiqi" - complex: "mürəkkəb" - date_time: "tarix və vaxt" - date: "tarix" - float: "üzən nöqtə" - hash: "haş" - integer: "tam ədəd" - rational: "rasional" - string: "sətir" - symbol: "simvol" - time: "vaxt" - validators: - absence: "boş olmalıdır" - exclusion: - of: "aşağıdakılardan biri ola bilməz: %{values}" - within: "%{min} və %{max} arasında ola bilməz" - format: "etibarsız formatdır" - inclusion: - of: "aşağıdakılardan biri olmalıdır: %{values}" - within: "%{min} və %{max} arasında olmalıdır" - length: - is: "uzunluq %{is} olmalıdır" - is_not: "uzunluq %{is_not} ola bilməz" - min: "uzunluq ən azı %{min} olmalıdır" - max: "uzunluq ən çoxu %{max} ola bilər" - not_within: "uzunluq %{min} və %{max} arasında ola bilməz" - within: "uzunluq %{min} və %{max} arasında olmalıdır" - numeric: - is: "%{is} olmalıdır" - is_not: "%{is_not} ola bilməz" - min: "ən azı %{min} olmalıdır" - max: "ən çoxu %{max} ola bilər" - not_within: "%{min} və %{max} arasında ola bilməz" - within: "%{min} və %{max} arasında olmalıdır" - presence: "boş ola bilməz" diff --git a/lib/locales/be.yml b/lib/locales/be.yml deleted file mode 100644 index 58847f660..000000000 --- a/lib/locales/be.yml +++ /dev/null @@ -1,55 +0,0 @@ -be: - cmdx: - attributes: - required: "павінен быць даступны праз зыходны метад %{method}" - undefined: "дэлегуе невызначанаму метаду %{method}" - coercions: - into_a: "не ўдалося пераўтварыць у %{type}" - into_an: "не ўдалося пераўтварыць у %{type}" - into_any: "не ўдалося пераўтварыць у адзін з: %{types}" - unknown: "невядомы тып пераўтварэння %{type}" - returns: - missing: "павінна быць усталявана ў кантэксце" - faults: - invalid: "Няправільныя" - unspecified: "Не паказана" - reasons: - unspecified: "Не паказана" - types: - array: "масіў" - big_decimal: "вялікае дзесятковае лік" - boolean: "лагічны" - complex: "камплексны" - date_time: "дата і час" - date: "дата" - float: "лік з плаваючай коскай" - hash: "хэш" - integer: "цэлы лік" - rational: "рацыянальны" - string: "радок" - symbol: "сімвал" - time: "час" - validators: - absence: "павінна быць пустым" - exclusion: - of: "не можа быць адным з: %{values}" - within: "не можа быць паміж %{min} і %{max}" - format: "з'яўляецца несапраўдным фарматам" - inclusion: - of: "павінна быць адным з: %{values}" - within: "павінна быць паміж %{min} і %{max}" - length: - is: "даўжыня павінна быць %{is}" - is_not: "даўжыня не можа быць %{is_not}" - min: "даўжыня павінна быць не менш %{min}" - max: "даўжыня можа быць не больш %{max}" - not_within: "даўжыня не можа быць паміж %{min} і %{max}" - within: "даўжыня павінна быць паміж %{min} і %{max}" - numeric: - is: "павінна быць %{is}" - is_not: "не можа быць %{is_not}" - min: "павінна быць не менш %{min}" - max: "можа быць не больш %{max}" - not_within: "не можа быць паміж %{min} і %{max}" - within: "павінна быць паміж %{min} і %{max}" - presence: "не можа быць пустым" diff --git a/lib/locales/bg.yml b/lib/locales/bg.yml deleted file mode 100644 index 0aa6729cd..000000000 --- a/lib/locales/bg.yml +++ /dev/null @@ -1,55 +0,0 @@ -bg: - cmdx: - attributes: - required: "трябва да е достъпен чрез изходния метод %{method}" - undefined: "делегира към недефиниран метод %{method}" - coercions: - into_a: "не може да бъде преобразуван в %{type}" - into_an: "не може да бъде преобразуван в %{type}" - into_any: "не може да бъде преобразуван в един от: %{types}" - unknown: "неизвестен тип преобразуване %{type}" - returns: - missing: "трябва да бъде зададено в контекста" - faults: - invalid: "Невалидни" - unspecified: "Не е посочена" - reasons: - unspecified: "Не е посочена" - types: - array: "масив" - big_decimal: "голямо десетично число" - boolean: "булев" - complex: "комплексен" - date_time: "дата и час" - date: "дата" - float: "число с плаваща запетая" - hash: "хеш" - integer: "цяло число" - rational: "рационално" - string: "низ" - symbol: "символ" - time: "час" - validators: - absence: "трябва да бъде празно" - exclusion: - of: "не може да бъде един от: %{values}" - within: "не може да бъде между %{min} и %{max}" - format: "е невалиден формат" - inclusion: - of: "трябва да бъде един от: %{values}" - within: "трябва да бъде между %{min} и %{max}" - length: - is: "дължината трябва да бъде %{is}" - is_not: "дължината не може да бъде %{is_not}" - min: "дължината трябва да бъде поне %{min}" - max: "дължината може да бъде най-много %{max}" - not_within: "дължината не може да бъде между %{min} и %{max}" - within: "дължината трябва да бъде между %{min} и %{max}" - numeric: - is: "трябва да бъде %{is}" - is_not: "не може да бъде %{is_not}" - min: "трябва да бъде поне %{min}" - max: "може да бъде най-много %{max}" - not_within: "не може да бъде между %{min} и %{max}" - within: "трябва да бъде между %{min} и %{max}" - presence: "не може да бъде празно" diff --git a/lib/locales/bn.yml b/lib/locales/bn.yml deleted file mode 100644 index d7b7213e3..000000000 --- a/lib/locales/bn.yml +++ /dev/null @@ -1,55 +0,0 @@ -bn: - cmdx: - attributes: - required: "%{method} সোর্স মেথডের মাধ্যমে অ্যাক্সেসযোগ্য হতে হবে" - undefined: "অসংজ্ঞায়িত পদ্ধতিতে %{method} অর্পণ করে" - coercions: - into_a: "%{type} এ রূপান্তর করা যায়নি" - into_an: "%{type} এ রূপান্তর করা যায়নি" - into_any: "নিম্নলিখিতগুলির মধ্যে একটিতে রূপান্তর করা যায়নি: %{types}" - unknown: "অজানা %{type} রূপান্তর প্রকার" - returns: - missing: "প্রসঙ্গে সেট করতে হবে" - faults: - invalid: "অবৈধ" - unspecified: "অনির্দিষ্ট" - reasons: - unspecified: "অনির্দিষ্ট" - types: - array: "অ্যারে" - big_decimal: "বড় দশমিক" - boolean: "বুলিয়ান" - complex: "জটিল" - date_time: "তারিখ এবং সময়" - date: "তারিখ" - float: "ভাসমান বিন্দু" - hash: "হ্যাশ" - integer: "পূর্ণসংখ্যা" - rational: "মূলদ" - string: "স্ট্রিং" - symbol: "প্রতীক" - time: "সময়" - validators: - absence: "খালি হতে হবে" - exclusion: - of: "নিম্নলিখিতগুলির মধ্যে একটি হতে পারবে না: %{values}" - within: "%{min} এবং %{max} এর মধ্যে থাকতে পারবে না" - format: "একটি অবৈধ ফরম্যাট" - inclusion: - of: "নিম্নলিখিতগুলির মধ্যে একটি হতে হবে: %{values}" - within: "%{min} এবং %{max} এর মধ্যে থাকতে হবে" - length: - is: "দৈর্ঘ্য %{is} হতে হবে" - is_not: "দৈর্ঘ্য %{is_not} হতে পারবে না" - min: "দৈর্ঘ্য কমপক্ষে %{min} হতে হবে" - max: "দৈর্ঘ্য সর্বোচ্চ %{max} হতে পারবে" - not_within: "দৈর্ঘ্য %{min} এবং %{max} এর মধ্যে থাকতে পারবে না" - within: "দৈর্ঘ্য %{min} এবং %{max} এর মধ্যে থাকতে হবে" - numeric: - is: "%{is} হতে হবে" - is_not: "%{is_not} হতে পারবে না" - min: "কমপক্ষে %{min} হতে হবে" - max: "সর্বোচ্চ %{max} হতে পারবে" - not_within: "%{min} এবং %{max} এর মধ্যে থাকতে পারবে না" - within: "%{min} এবং %{max} এর মধ্যে থাকতে হবে" - presence: "খালি হতে পারবে না" diff --git a/lib/locales/bs.yml b/lib/locales/bs.yml deleted file mode 100644 index d2f49f1dd..000000000 --- a/lib/locales/bs.yml +++ /dev/null @@ -1,55 +0,0 @@ -bs: - cmdx: - attributes: - required: "mora biti dostupan preko izvorne metode %{method}" - undefined: "delegira nedefiniranoj metodi %{method}" - coercions: - into_a: "nije mogao biti pretvoren u %{type}" - into_an: "nije mogao biti pretvoren u %{type}" - into_any: "nije mogao biti pretvoren u jedan od: %{types}" - unknown: "nepoznati tip pretvorbe %{type}" - returns: - missing: "mora biti postavljeno u kontekstu" - faults: - invalid: "Neispravni" - unspecified: "Nije naveden" - reasons: - unspecified: "Nije naveden" - types: - array: "niz" - big_decimal: "veliki decimalni broj" - boolean: "logički" - complex: "kompleksan" - date_time: "datum i vrijeme" - date: "datum" - float: "broj s pomičnom točkom" - hash: "hash" - integer: "cijeli broj" - rational: "racionalan" - string: "niz znakova" - symbol: "simbol" - time: "vrijeme" - validators: - absence: "mora biti prazno" - exclusion: - of: "ne smije biti jedan od: %{values}" - within: "ne smije biti između %{min} i %{max}" - format: "je nevažeći format" - inclusion: - of: "mora biti jedan od: %{values}" - within: "mora biti između %{min} i %{max}" - length: - is: "dužina mora biti %{is}" - is_not: "dužina ne smije biti %{is_not}" - min: "dužina mora biti najmanje %{min}" - max: "dužina može biti najviše %{max}" - not_within: "dužina ne smije biti između %{min} i %{max}" - within: "dužina mora biti između %{min} i %{max}" - numeric: - is: "mora biti %{is}" - is_not: "ne smije biti %{is_not}" - min: "mora biti najmanje %{min}" - max: "može biti najviše %{max}" - not_within: "ne smije biti između %{min} i %{max}" - within: "mora biti između %{min} i %{max}" - presence: "ne može biti prazno" diff --git a/lib/locales/ca.yml b/lib/locales/ca.yml deleted file mode 100644 index 7d0a6fdb7..000000000 --- a/lib/locales/ca.yml +++ /dev/null @@ -1,55 +0,0 @@ -ca: - cmdx: - attributes: - required: "ha de ser accessible mitjançant el mètode font %{method}" - undefined: "delega al mètode no definit %{method}" - coercions: - into_a: "no es va poder convertir a %{type}" - into_an: "no es va poder convertir a %{type}" - into_any: "no es va poder convertir a un de: %{types}" - unknown: "tipus de conversió %{type} desconegut" - returns: - missing: "ha de ser establert en el context" - faults: - invalid: "No vàlides" - unspecified: "No especificat" - reasons: - unspecified: "No especificat" - types: - array: "array" - big_decimal: "decimal gran" - boolean: "booleà" - complex: "complex" - date_time: "data i hora" - date: "data" - float: "punt flotant" - hash: "hash" - integer: "enter" - rational: "racional" - string: "cadena" - symbol: "símbol" - time: "temps" - validators: - absence: "ha d'estar buit" - exclusion: - of: "no ha de ser un de: %{values}" - within: "no ha d'estar entre %{min} i %{max}" - format: "és un format no vàlid" - inclusion: - of: "ha de ser un de: %{values}" - within: "ha d'estar entre %{min} i %{max}" - length: - is: "la longitud ha de ser %{is}" - is_not: "la longitud no ha de ser %{is_not}" - min: "la longitud ha de ser almenys %{min}" - max: "la longitud ha de ser com a màxim %{max}" - not_within: "la longitud no ha d'estar entre %{min} i %{max}" - within: "la longitud ha d'estar entre %{min} i %{max}" - numeric: - is: "ha de ser %{is}" - is_not: "no ha de ser %{is_not}" - min: "ha de ser almenys %{min}" - max: "ha de ser com a màxim %{max}" - not_within: "no ha d'estar entre %{min} i %{max}" - within: "ha d'estar entre %{min} i %{max}" - presence: "no pot estar buit" diff --git a/lib/locales/cnr.yml b/lib/locales/cnr.yml deleted file mode 100644 index 69dd05ef9..000000000 --- a/lib/locales/cnr.yml +++ /dev/null @@ -1,55 +0,0 @@ -cnr: - cmdx: - attributes: - required: "mora biti dostupan preko izvorne metode %{method}" - undefined: "delegira na nedefinisani metod %{method}" - coercions: - into_a: "nije mogao konvertovati u %{type}" - into_an: "nije mogao konvertovati u %{type}" - into_any: "nije mogao konvertovati u jedan od: %{types}" - unknown: "nepoznati %{type} tip konverzije" - returns: - missing: "mora biti postavljeno u kontekstu" - faults: - invalid: "Neispravni" - unspecified: "Nije dat" - reasons: - unspecified: "Nije dat" - types: - array: "niz" - big_decimal: "veliki decimalni" - boolean: "bulovski" - complex: "kompleksan" - date_time: "datum i vrijeme" - date: "datum" - float: "pokretna tačka" - hash: "hash" - integer: "cijeli broj" - rational: "racionalan" - string: "string" - symbol: "simbol" - time: "vrijeme" - validators: - absence: "mora biti prazan" - exclusion: - of: "ne smije biti jedan od: %{values}" - within: "ne smije biti između %{min} i %{max}" - format: "je nevažeći format" - inclusion: - of: "mora biti jedan od: %{values}" - within: "mora biti između %{min} i %{max}" - length: - is: "dužina mora biti %{is}" - is_not: "dužina ne smije biti %{is_not}" - min: "dužina mora biti najmanje %{min}" - max: "dužina može biti najviše %{max}" - not_within: "dužina ne smije biti između %{min} i %{max}" - within: "dužina mora biti između %{min} i %{max}" - numeric: - is: "mora biti %{is}" - is_not: "ne smije biti %{is_not}" - min: "mora biti najmanje %{min}" - max: "može biti najviše %{max}" - not_within: "ne smije biti između %{min} i %{max}" - within: "mora biti između %{min} i %{max}" - presence: "ne može biti prazan" diff --git a/lib/locales/cs.yml b/lib/locales/cs.yml deleted file mode 100644 index f91d7e9af..000000000 --- a/lib/locales/cs.yml +++ /dev/null @@ -1,55 +0,0 @@ -cs: - cmdx: - attributes: - required: "musí být přístupné přes zdrojovou metodu %{method}" - undefined: "deleguje na nedefinovanou metodu %{method}" - coercions: - into_a: "nelze převést na %{type}" - into_an: "nelze převést na %{type}" - into_any: "nelze převést na jeden z: %{types}" - unknown: "neznámý typ převodu %{type}" - returns: - missing: "musí být nastaveno v kontextu" - faults: - invalid: "Neplatné" - unspecified: "Neurčeno" - reasons: - unspecified: "Neurčeno" - types: - array: "pole" - big_decimal: "velké desetinné číslo" - boolean: "logický" - complex: "komplexní" - date_time: "datum a čas" - date: "datum" - float: "desetinné číslo" - hash: "hash" - integer: "celé číslo" - rational: "racionální" - string: "řetězec" - symbol: "symbol" - time: "čas" - validators: - absence: "musí být prázdné" - exclusion: - of: "nesmí být jedním z: %{values}" - within: "nesmí být mezi %{min} a %{max}" - format: "je neplatný formát" - inclusion: - of: "musí být jedním z: %{values}" - within: "musí být mezi %{min} a %{max}" - length: - is: "délka musí být %{is}" - is_not: "délka nesmí být %{is_not}" - min: "délka musí být alespoň %{min}" - max: "délka může být nejvýše %{max}" - not_within: "délka nesmí být mezi %{min} a %{max}" - within: "délka musí být mezi %{min} a %{max}" - numeric: - is: "musí být %{is}" - is_not: "nesmí být %{is_not}" - min: "musí být alespoň %{min}" - max: "může být nejvýše %{max}" - not_within: "nesmí být mezi %{min} a %{max}" - within: "musí být mezi %{min} a %{max}" - presence: "nesmí být prázdné" diff --git a/lib/locales/cy.yml b/lib/locales/cy.yml deleted file mode 100644 index 773896731..000000000 --- a/lib/locales/cy.yml +++ /dev/null @@ -1,55 +0,0 @@ -cy: - cmdx: - attributes: - required: "rhaid bod yn hygyrch trwy'r dull ffynhonnell %{method}" - undefined: "delegio i'r dull amhendant %{method}" - coercions: - into_a: "ni allwyd ei drawsnewid i %{type}" - into_an: "ni allwyd ei drawsnewid i %{type}" - into_any: "ni allwyd ei drawsnewid i un o: %{types}" - unknown: "math anhysbys o drawsnewid %{type}" - returns: - missing: "rhaid ei osod yn y cyd-destun" - faults: - invalid: "Annilys" - unspecified: "Anhebgor" - reasons: - unspecified: "Anhebgor" - types: - array: "arae" - big_decimal: "degol mawr" - boolean: "booleaidd" - complex: "cymhleth" - date_time: "dyddiad ac amser" - date: "dyddiad" - float: "rhif pwynt arnofio" - hash: "hash" - integer: "cyfanrif" - rational: "rhesymegol" - string: "llinyn" - symbol: "symbol" - time: "amser" - validators: - absence: "rhaid iddo fod yn wag" - exclusion: - of: "ni all fod yn un o: %{values}" - within: "ni all fod rhwng %{min} a %{max}" - format: "yn fformat annilys" - inclusion: - of: "rhaid iddo fod yn un o: %{values}" - within: "rhaid iddo fod rhwng %{min} a %{max}" - length: - is: "rhaid i'r hyd fod yn %{is}" - is_not: "ni all y hyd fod yn %{is_not}" - min: "rhaid i'r hyd fod o leiaf %{min}" - max: "gall y hyd fod yn fwyaf %{max}" - not_within: "ni all y hyd fod rhwng %{min} a %{max}" - within: "rhaid i'r hyd fod rhwng %{min} a %{max}" - numeric: - is: "rhaid iddo fod yn %{is}" - is_not: "ni all fod yn %{is_not}" - min: "rhaid iddo fod o leiaf %{min}" - max: "gall fod yn fwyaf %{max}" - not_within: "ni all fod rhwng %{min} a %{max}" - within: "rhaid iddo fod rhwng %{min} a %{max}" - presence: "ni all fod yn wag" diff --git a/lib/locales/da.yml b/lib/locales/da.yml deleted file mode 100644 index 3cd27300f..000000000 --- a/lib/locales/da.yml +++ /dev/null @@ -1,55 +0,0 @@ -da: - cmdx: - attributes: - required: "skal være tilgængelig via %{method} kildemetoden" - undefined: "delegerer til udefineret metode %{method}" - coercions: - into_a: "kunne ikke konverteres til %{type}" - into_an: "kunne ikke konverteres til %{type}" - into_any: "kunne ikke konverteres til en af: %{types}" - unknown: "ukendt %{type} konverteringstype" - returns: - missing: "skal være sat i konteksten" - faults: - invalid: "Ugyldige" - unspecified: "Uspecificeret" - reasons: - unspecified: "Uspecificeret" - types: - array: "array" - big_decimal: "stort decimaltal" - boolean: "boolesk" - complex: "kompleks" - date_time: "dato og tid" - date: "dato" - float: "flydetal" - hash: "hash" - integer: "heltal" - rational: "rationelt" - string: "streng" - symbol: "symbol" - time: "tid" - validators: - absence: "skal være tom" - exclusion: - of: "må ikke være en af: %{values}" - within: "må ikke være mellem %{min} og %{max}" - format: "er et ugyldigt format" - inclusion: - of: "skal være en af: %{values}" - within: "skal være mellem %{min} og %{max}" - length: - is: "længden skal være %{is}" - is_not: "længden må ikke være %{is_not}" - min: "længden skal være mindst %{min}" - max: "længden må højst være %{max}" - not_within: "længden må ikke være mellem %{min} og %{max}" - within: "længden skal være mellem %{min} og %{max}" - numeric: - is: "skal være %{is}" - is_not: "må ikke være %{is_not}" - min: "skal være mindst %{min}" - max: "må højst være %{max}" - not_within: "må ikke være mellem %{min} og %{max}" - within: "skal være mellem %{min} og %{max}" - presence: "må ikke være tom" diff --git a/lib/locales/de.yml b/lib/locales/de.yml deleted file mode 100644 index d51786122..000000000 --- a/lib/locales/de.yml +++ /dev/null @@ -1,55 +0,0 @@ -de: - cmdx: - attributes: - required: "muss über die Quellmethode %{method} zugänglich sein" - undefined: "delegiert an undefinierte Methode %{method}" - coercions: - into_a: "konnte nicht in %{type} umgewandelt werden" - into_an: "konnte nicht in %{type} umgewandelt werden" - into_any: "konnte nicht in einen von: %{types} umgewandelt werden" - unknown: "unbekannter %{type} Umwandlungstyp" - returns: - missing: "muss im Kontext gesetzt sein" - faults: - invalid: "Ungültig" - unspecified: "Nicht angegeben" - reasons: - unspecified: "Nicht angegeben" - types: - array: "Array" - big_decimal: "große Dezimalzahl" - boolean: "Boolean" - complex: "komplex" - date_time: "Datum und Zeit" - date: "Datum" - float: "Gleitkommazahl" - hash: "Hash" - integer: "Ganzzahl" - rational: "rational" - string: "Zeichenkette" - symbol: "Symbol" - time: "Zeit" - validators: - absence: "muss leer sein" - exclusion: - of: "darf nicht einer von sein: %{values}" - within: "darf nicht zwischen %{min} und %{max} liegen" - format: "ist ein ungültiges Format" - inclusion: - of: "muss einer von sein: %{values}" - within: "muss zwischen %{min} und %{max} liegen" - length: - is: "Länge muss %{is} sein" - is_not: "Länge darf nicht %{is_not} sein" - min: "Länge muss mindestens %{min} sein" - max: "Länge darf höchstens %{max} sein" - not_within: "Länge darf nicht zwischen %{min} und %{max} liegen" - within: "Länge muss zwischen %{min} und %{max} liegen" - numeric: - is: "muss %{is} sein" - is_not: "darf nicht %{is_not} sein" - min: "muss mindestens %{min} sein" - max: "darf höchstens %{max} sein" - not_within: "darf nicht zwischen %{min} und %{max} liegen" - within: "muss zwischen %{min} und %{max} liegen" - presence: "darf nicht leer sein" diff --git a/lib/locales/dz.yml b/lib/locales/dz.yml deleted file mode 100644 index 00f22dfbb..000000000 --- a/lib/locales/dz.yml +++ /dev/null @@ -1,55 +0,0 @@ -dz: - cmdx: - attributes: - required: "%{method} གི་འབྱུང་ཁུངས་ཐབས་ལམ་བརྒྱུད་དེ་འཛུལ་སྤྱོད་འབད་ཚུགསཔ་དགོ།" - undefined: "ཚད་མ་ཆག་པའི་ཐབས་ལམ་ %{method} ལ་ཆ་རྐྱེན་བྱེད།" - coercions: - into_a: "%{type} ལ་བསྒྱུར་མ་ཐུབ།" - into_an: "%{type} ལ་བསྒྱུར་མ་ཐུབ།" - into_any: "འདི་ཚོའི་ནང་ནས་གཅིག་ལ་བསྒྱུར་མ་ཐུབ། %{types}" - unknown: "མ་ཤེས་པའི་ %{type} བསྒྱུར་པའི་རིགས།" - returns: - missing: "སྐབས་དོན་ནང་བཀོད་དགོས" - faults: - invalid: "ཆ་རྐྱེན་མི་ཆོག" - unspecified: "མ་བཟུང་།" - reasons: - unspecified: "མ་བཟུང་།" - types: - array: "ཐིག་ཕྲེང་།" - big_decimal: "ཆེ་བའི་བཅུ་ཆ།" - boolean: "བུལ།" - complex: "ཆག་ཆག་ཅན།" - date_time: "ཟླ་ཚེས་དང་དུས་ཚིགས།" - date: "ཟླ་ཚེས།" - float: "གཡོ་བའི་ཐིག་ཕྲེང་།" - hash: "ཧཱཤ།" - integer: "ཆ་ཆ་མེད་པའི་ཨང་།" - rational: "རིག་པ་ཅན།" - string: "ཡིག་ཚགས།" - symbol: "མཚན་མ།" - time: "དུས་ཚིགས།" - validators: - absence: "སྟོང་པོ་ཆོག་པ་ཆེད་དགོས།" - exclusion: - of: "འདི་ཚོའི་ནང་ནས་གཅིག་མི་ཆོག %{values}" - within: "%{min} དང་ %{max} ཡབ་ཡུམ་ནས་མི་ཆོག" - format: "ཆ་རྐྱེན་མི་ཆོག་པའི་རྣམ་པ།" - inclusion: - of: "འདི་ཚོའི་ནང་ནས་གཅིག་ཆོག་པ་ཆེད་དགོས། %{values}" - within: "%{min} དང་ %{max} ཡབ་ཡུམ་ནས་ཆོག་པ་ཆེད་དགོས།" - length: - is: "རིང་ཚད་ %{is} ཆོག་པ་ཆེད་དགོས།" - is_not: "རིང་ཚད་ %{is_not} མི་ཆོག" - min: "རིང་ཚད་ཆུང་ཤོས་ %{min} ཆོག་པ་ཆེད་དགོས།" - max: "རིང་ཚད་ཆེ་ཤོས་ %{max} ཆོག" - not_within: "རིང་ཚད་ %{min} དང་ %{max} ཡབ་ཡུམ་ནས་མི་ཆོག" - within: "རིང་ཚད་ %{min} དང་ %{max} ཡབ་ཡུམ་ནས་ཆོག་པ་ཆེད་དགོས།" - numeric: - is: "%{is} ཆོག་པ་ཆེད་དགོས།" - is_not: "%{is_not} མི་ཆོག" - min: "ཆུང་ཤོས་ %{min} ཆོག་པ་ཆེད་དགོས།" - max: "ཆེ་ཤོས་ %{max} ཆོག" - not_within: "%{min} དང་ %{max} ཡབ་ཡུམ་ནས་མི་ཆོག" - within: "%{min} དང་ %{max} ཡབ་ཡུམ་ནས་ཆོག་པ་ཆེད་དགོས།" - presence: "སྟོང་པོ་མི་ཆོག" diff --git a/lib/locales/el.yml b/lib/locales/el.yml deleted file mode 100644 index d95b4c2e0..000000000 --- a/lib/locales/el.yml +++ /dev/null @@ -1,55 +0,0 @@ -el: - cmdx: - attributes: - required: "πρέπει να είναι προσβάσιμο μέσω της μεθόδου πηγής %{method}" - undefined: "αντιπροσωπεύει απροσδιόριστη μέθοδο %{method}" - coercions: - into_a: "δεν μπόρεσε να μετατραπεί σε %{type}" - into_an: "δεν μπόρεσε να μετατραπεί σε %{type}" - into_any: "δεν μπόρεσε να μετατραπεί σε ένα από: %{types}" - unknown: "άγνωστος τύπος μετατροπής %{type}" - returns: - missing: "πρέπει να οριστεί στο πλαίσιο" - faults: - invalid: "Μη έγκυρες" - unspecified: "Απροσδιόριστο" - reasons: - unspecified: "Απροσδιόριστο" - types: - array: "πίνακας" - big_decimal: "μεγάλος δεκαδικός" - boolean: "δυαδικός" - complex: "μιγαδικός" - date_time: "ημερομηνία και ώρα" - date: "ημερομηνία" - float: "αριθμός κινητής υποδιαστολής" - hash: "κατακερματισμός" - integer: "ακέραιος" - rational: "ρητός" - string: "συμβολοσειρά" - symbol: "σύμβολο" - time: "ώρα" - validators: - absence: "πρέπει να είναι κενό" - exclusion: - of: "δεν πρέπει να είναι ένα από: %{values}" - within: "δεν πρέπει να είναι μεταξύ %{min} και %{max}" - format: "είναι μη έγκυρη μορφή" - inclusion: - of: "πρέπει να είναι ένα από: %{values}" - within: "πρέπει να είναι μεταξύ %{min} και %{max}" - length: - is: "το μήκος πρέπει να είναι %{is}" - is_not: "το μήκος δεν πρέπει να είναι %{is_not}" - min: "το μήκος πρέπει να είναι τουλάχιστον %{min}" - max: "το μήκος πρέπει να είναι το πολύ %{max}" - not_within: "το μήκος δεν πρέπει να είναι μεταξύ %{min} και %{max}" - within: "το μήκος πρέπει να είναι μεταξύ %{min} και %{max}" - numeric: - is: "πρέπει να είναι %{is}" - is_not: "δεν πρέπει να είναι %{is_not}" - min: "πρέπει να είναι τουλάχιστον %{min}" - max: "πρέπει να είναι το πολύ %{max}" - not_within: "δεν πρέπει να είναι μεταξύ %{min} και %{max}" - within: "πρέπει να είναι μεταξύ %{min} και %{max}" - presence: "δεν μπορεί να είναι κενό" diff --git a/lib/locales/en.yml b/lib/locales/en.yml deleted file mode 100644 index a84bc5b44..000000000 --- a/lib/locales/en.yml +++ /dev/null @@ -1,55 +0,0 @@ -en: - cmdx: - attributes: - required: "must be accessible via the %{method} source method" - undefined: "delegates to undefined method %{method}" - coercions: - into_a: "could not coerce into a %{type}" - into_an: "could not coerce into an %{type}" - into_any: "could not coerce into one of: %{types}" - unknown: "unknown %{type} coercion type" - faults: - invalid: "Invalid" - unspecified: "Unspecified" - reasons: - unspecified: "Unspecified" - returns: - missing: "must be set in the context" - types: - array: "array" - big_decimal: "big decimal" - boolean: "boolean" - complex: "complex" - date_time: "date time" - date: "date" - float: "float" - hash: "hash" - integer: "integer" - rational: "rational" - string: "string" - symbol: "symbol" - time: "time" - validators: - absence: "must be empty" - exclusion: - of: "must not be one of: %{values}" - within: "must not be within %{min} and %{max}" - format: "is an invalid format" - inclusion: - of: "must be one of: %{values}" - within: "must be within %{min} and %{max}" - length: - is: "length must be %{is}" - is_not: "length must not be %{is_not}" - min: "length must be at least %{min}" - max: "length must be at most %{max}" - not_within: "length must not be within %{min} and %{max}" - within: "length must be within %{min} and %{max}" - numeric: - is: "must be %{is}" - is_not: "must not be %{is_not}" - min: "must be at least %{min}" - max: "must be at most %{max}" - not_within: "must not be within %{min} and %{max}" - within: "must be within %{min} and %{max}" - presence: "cannot be empty" diff --git a/lib/locales/eo.yml b/lib/locales/eo.yml deleted file mode 100644 index 55d848eac..000000000 --- a/lib/locales/eo.yml +++ /dev/null @@ -1,55 +0,0 @@ -eo: - cmdx: - attributes: - required: "devas esti alirebla per la fontmetodo %{method}" - undefined: "delegas al nedifinita metodo %{method}" - coercions: - into_a: "ne povis esti konvertita al %{type}" - into_an: "ne povis esti konvertita al %{type}" - into_any: "ne povis esti konvertita al unu el: %{types}" - unknown: "nekonata %{type} konverta tipo" - returns: - missing: "devas esti agordita en la kunteksto" - faults: - invalid: "Nevalidaj" - unspecified: "Nespecifita" - reasons: - unspecified: "Nespecifita" - types: - array: "tabelo" - big_decimal: "granda dekuma" - boolean: "bulea" - complex: "kompleksa" - date_time: "dato kaj tempo" - date: "dato" - float: "flosanta punkto" - hash: "hash" - integer: "entjero" - rational: "racia" - string: "ĉeno" - symbol: "simbolo" - time: "tempo" - validators: - absence: "devas esti malplena" - exclusion: - of: "ne devas esti unu el: %{values}" - within: "ne devas esti inter %{min} kaj %{max}" - format: "estas nevalida formato" - inclusion: - of: "devas esti unu el: %{values}" - within: "devas esti inter %{min} kaj %{max}" - length: - is: "longo devas esti %{is}" - is_not: "longo ne devas esti %{is_not}" - min: "longo devas esti almenaŭ %{min}" - max: "longo povas esti maksimume %{max}" - not_within: "longo ne devas esti inter %{min} kaj %{max}" - within: "longo devas esti inter %{min} kaj %{max}" - numeric: - is: "devas esti %{is}" - is_not: "ne devas esti %{is_not}" - min: "devas esti almenaŭ %{min}" - max: "povas esti maksimume %{max}" - not_within: "ne devas esti inter %{min} kaj %{max}" - within: "devas esti inter %{min} kaj %{max}" - presence: "ne povas esti malplena" diff --git a/lib/locales/es.yml b/lib/locales/es.yml deleted file mode 100644 index 5f134888b..000000000 --- a/lib/locales/es.yml +++ /dev/null @@ -1,55 +0,0 @@ -es: - cmdx: - attributes: - required: "debe ser accesible a través del método de origen %{method}" - undefined: "delega a método indefinido %{method}" - coercions: - into_a: "no se pudo convertir en un %{type}" - into_an: "no se pudo convertir en un %{type}" - into_any: "no se pudo convertir en uno de: %{types}" - unknown: "tipo de conversión %{type} desconocido" - returns: - missing: "debe estar establecido en el contexto" - faults: - invalid: "Inválidas" - unspecified: "No especificada" - reasons: - unspecified: "No especificada" - types: - array: "array" - big_decimal: "decimal grande" - boolean: "booleano" - complex: "complejo" - date_time: "fecha y hora" - date: "fecha" - float: "flotante" - hash: "hash" - integer: "entero" - rational: "racional" - string: "cadena" - symbol: "símbolo" - time: "tiempo" - validators: - absence: "debe estar vacío" - exclusion: - of: "no debe ser uno de: %{values}" - within: "no debe estar dentro de %{min} y %{max}" - format: "es un formato inválido" - inclusion: - of: "debe ser uno de: %{values}" - within: "debe estar dentro de %{min} y %{max}" - length: - is: "la longitud debe ser %{is}" - is_not: "la longitud no debe ser %{is_not}" - min: "la longitud debe ser al menos %{min}" - max: "la longitud debe ser como máximo %{max}" - not_within: "la longitud no debe estar dentro de %{min} y %{max}" - within: "la longitud debe estar dentro de %{min} y %{max}" - numeric: - is: "debe ser %{is}" - is_not: "no debe ser %{is_not}" - min: "debe ser al menos %{min}" - max: "debe ser como máximo %{max}" - not_within: "no debe estar dentro de %{min} y %{max}" - within: "debe estar dentro de %{min} y %{max}" - presence: "no puede estar vacío" diff --git a/lib/locales/et.yml b/lib/locales/et.yml deleted file mode 100644 index 7dfb9ac2b..000000000 --- a/lib/locales/et.yml +++ /dev/null @@ -1,55 +0,0 @@ -et: - cmdx: - attributes: - required: "peab olema juurdepääsetav lähtemeetodi %{method} kaudu" - undefined: "delegeerib määramata meetodile %{method}" - coercions: - into_a: "ei õnnestunud teisendada %{type} tüübiks" - into_an: "ei õnnestunud teisendada %{type} tüübiks" - into_any: "ei õnnestunud teisendada üheks järgmistest: %{types}" - unknown: "tundmatu %{type} teisendustüüp" - returns: - missing: "peab olema kontekstis määratud" - faults: - invalid: "Kehtetud" - unspecified: "Määramata" - reasons: - unspecified: "Määramata" - types: - array: "massiiv" - big_decimal: "suur kümnendmurd" - boolean: "loogiline" - complex: "kompleksne" - date_time: "kuupäev ja kellaaeg" - date: "kuupäev" - float: "ujukomaarv" - hash: "räsi" - integer: "täisarv" - rational: "ratsionaalne" - string: "string" - symbol: "sümbol" - time: "aeg" - validators: - absence: "peab olema tühi" - exclusion: - of: "ei tohi olla üks järgmistest: %{values}" - within: "ei tohi olla %{min} ja %{max} vahel" - format: "on kehtetu vorming" - inclusion: - of: "peab olema üks järgmistest: %{values}" - within: "peab olema %{min} ja %{max} vahel" - length: - is: "pikkus peab olema %{is}" - is_not: "pikkus ei tohi olla %{is_not}" - min: "pikkus peab olema vähemalt %{min}" - max: "pikkus võib olla maksimaalselt %{max}" - not_within: "pikkus ei tohi olla %{min} ja %{max} vahel" - within: "pikkus peab olema %{min} ja %{max} vahel" - numeric: - is: "peab olema %{is}" - is_not: "ei tohi olla %{is_not}" - min: "peab olema vähemalt %{min}" - max: "võib olla maksimaalselt %{max}" - not_within: "ei tohi olla %{min} ja %{max} vahel" - within: "peab olema %{min} ja %{max} vahel" - presence: "ei tohi olla tühi" diff --git a/lib/locales/eu.yml b/lib/locales/eu.yml deleted file mode 100644 index e24a2fe73..000000000 --- a/lib/locales/eu.yml +++ /dev/null @@ -1,55 +0,0 @@ -eu: - cmdx: - attributes: - required: "eskuragarri egon behar du %{method} iturburu-metodoaren bidez" - undefined: "metodo zehaztugabea %{method} delegatzen du" - coercions: - into_a: "ezin izan da %{type} ra bihurtu" - into_an: "ezin izan da %{type} ra bihurtu" - into_any: "ezin izan da honetako batera bihurtu: %{types}" - unknown: "%{type} bihurtze mota ezezaguna" - returns: - missing: "testuinguruan ezarrita egon behar du" - faults: - invalid: "Baliogabeak" - unspecified: "Zehaztu gabe" - reasons: - unspecified: "Zehaztu gabe" - types: - array: "array" - big_decimal: "hamartar handia" - boolean: "boolearra" - complex: "konplexua" - date_time: "data eta ordua" - date: "data" - float: "puntu mugikorra" - hash: "hash" - integer: "zenbaki osoa" - rational: "arrazoizkoa" - string: "katea" - symbol: "ikurra" - time: "ordua" - validators: - absence: "hutsik izan behar du" - exclusion: - of: "ezin da honetako bat izan: %{values}" - within: "ezin da %{min} eta %{max} artean izan" - format: "formatu baliogabea da" - inclusion: - of: "honetako bat izan behar du: %{values}" - within: "%{min} eta %{max} artean izan behar du" - length: - is: "luzera %{is} izan behar du" - is_not: "luzera ezin da %{is_not} izan" - min: "luzera gutxienez %{min} izan behar du" - max: "luzera gehienez %{max} izan daiteke" - not_within: "luzera ezin da %{min} eta %{max} artean izan" - within: "luzera %{min} eta %{max} artean izan behar du" - numeric: - is: "%{is} izan behar du" - is_not: "ezin da %{is_not} izan" - min: "gutxienez %{min} izan behar du" - max: "gehienez %{max} izan daiteke" - not_within: "ezin da %{min} eta %{max} artean izan" - within: "%{min} eta %{max} artean izan behar du" - presence: "ezin da hutsik izan" diff --git a/lib/locales/fa.yml b/lib/locales/fa.yml deleted file mode 100644 index e3ca777ef..000000000 --- a/lib/locales/fa.yml +++ /dev/null @@ -1,55 +0,0 @@ -fa: - cmdx: - attributes: - required: "باید از طریق متد منبع %{method} قابل دسترسی باشد" - undefined: "به متد تعریف نشده %{method} واگذار می‌کند" - coercions: - into_a: "نمی‌تواند به %{type} تبدیل شود" - into_an: "نمی‌تواند به %{type} تبدیل شود" - into_any: "نمی‌تواند به یکی از اینها تبدیل شود: %{types}" - unknown: "نوع تبدیل %{type} ناشناخته" - returns: - missing: "باید در زمینه تنظیم شود" - faults: - invalid: "نامعتبر" - unspecified: "نامشخص" - reasons: - unspecified: "نامشخص" - types: - array: "آرایه" - big_decimal: "اعشاری بزرگ" - boolean: "بولین" - complex: "مرکب" - date_time: "تاریخ و زمان" - date: "تاریخ" - float: "نقطه شناور" - hash: "هش" - integer: "عدد صحیح" - rational: "گویا" - string: "رشته" - symbol: "نماد" - time: "زمان" - validators: - absence: "باید خالی باشد" - exclusion: - of: "نباید یکی از اینها باشد: %{values}" - within: "نباید بین %{min} و %{max} باشد" - format: "فرمت نامعتبر است" - inclusion: - of: "باید یکی از اینها باشد: %{values}" - within: "باید بین %{min} و %{max} باشد" - length: - is: "طول باید %{is} باشد" - is_not: "طول نباید %{is_not} باشد" - min: "طول باید حداقل %{min} باشد" - max: "طول می‌تواند حداکثر %{max} باشد" - not_within: "طول نباید بین %{min} و %{max} باشد" - within: "طول باید بین %{min} و %{max} باشد" - numeric: - is: "باید %{is} باشد" - is_not: "نباید %{is_not} باشد" - min: "باید حداقل %{min} باشد" - max: "می‌تواند حداکثر %{max} باشد" - not_within: "نباید بین %{min} و %{max} باشد" - within: "باید بین %{min} و %{max} باشد" - presence: "نمی‌تواند خالی باشد" diff --git a/lib/locales/fi.yml b/lib/locales/fi.yml deleted file mode 100644 index 15b7f70a4..000000000 --- a/lib/locales/fi.yml +++ /dev/null @@ -1,55 +0,0 @@ -fi: - cmdx: - attributes: - required: "täytyy olla saavutettavissa lähdemetodin %{method} kautta" - undefined: "delegoi määrittelemättömälle metodille %{method}" - coercions: - into_a: "ei voitu muuntaa tyypiksi %{type}" - into_an: "ei voitu muuntaa tyypiksi %{type}" - into_any: "ei voitu muuntaa yhdeksi seuraavista: %{types}" - unknown: "tuntematon %{type} muunnostyyppi" - returns: - missing: "on asetettava kontekstissa" - faults: - invalid: "Virheelliset" - unspecified: "Määrittämätön" - reasons: - unspecified: "Määrittämätön" - types: - array: "taulukko" - big_decimal: "iso desimaali" - boolean: "totuusarvo" - complex: "kompleksi" - date_time: "päivämäärä ja aika" - date: "päivämäärä" - float: "liukuluku" - hash: "hajautustaulu" - integer: "kokonaisluku" - rational: "rationaaliluku" - string: "merkkijono" - symbol: "symboli" - time: "aika" - validators: - absence: "täytyy olla tyhjä" - exclusion: - of: "ei saa olla yksi seuraavista: %{values}" - within: "ei saa olla välillä %{min} ja %{max}" - format: "on virheellinen muoto" - inclusion: - of: "täytyy olla yksi seuraavista: %{values}" - within: "täytyy olla välillä %{min} ja %{max}" - length: - is: "pituuden täytyy olla %{is}" - is_not: "pituuden ei saa olla %{is_not}" - min: "pituuden täytyy olla vähintään %{min}" - max: "pituuden täytyy olla enintään %{max}" - not_within: "pituuden ei saa olla välillä %{min} ja %{max}" - within: "pituuden täytyy olla välillä %{min} ja %{max}" - numeric: - is: "täytyy olla %{is}" - is_not: "ei saa olla %{is_not}" - min: "täytyy olla vähintään %{min}" - max: "täytyy olla enintään %{max}" - not_within: "ei saa olla välillä %{min} ja %{max}" - within: "täytyy olla välillä %{min} ja %{max}" - presence: "ei saa olla tyhjä" diff --git a/lib/locales/fr.yml b/lib/locales/fr.yml deleted file mode 100644 index 8a474762a..000000000 --- a/lib/locales/fr.yml +++ /dev/null @@ -1,55 +0,0 @@ -fr: - cmdx: - attributes: - required: "doit être accessible via la méthode source %{method}" - undefined: "délègue à une méthode non définie %{method}" - coercions: - into_a: "n'a pas pu être converti en %{type}" - into_an: "n'a pas pu être converti en %{type}" - into_any: "n'a pas pu être converti en l'un de : %{types}" - unknown: "type de conversion %{type} inconnu" - returns: - missing: "doit être défini dans le contexte" - faults: - invalid: "Invalides" - unspecified: "Non spécifié" - reasons: - unspecified: "Non spécifié" - types: - array: "tableau" - big_decimal: "grand décimal" - boolean: "booléen" - complex: "complexe" - date_time: "date et heure" - date: "date" - float: "flottant" - hash: "hash" - integer: "entier" - rational: "rationnel" - string: "chaîne" - symbol: "symbole" - time: "heure" - validators: - absence: "doit être vide" - exclusion: - of: "ne doit pas être l'un de : %{values}" - within: "ne doit pas être dans %{min} et %{max}" - format: "est un format invalide" - inclusion: - of: "doit être l'un de : %{values}" - within: "doit être dans %{min} et %{max}" - length: - is: "la longueur doit être %{is}" - is_not: "la longueur ne doit pas être %{is_not}" - min: "la longueur doit être au moins %{min}" - max: "la longueur doit être au plus %{max}" - not_within: "la longueur ne doit pas être dans %{min} et %{max}" - within: "la longueur doit être dans %{min} et %{max}" - numeric: - is: "doit être %{is}" - is_not: "ne doit pas être %{is_not}" - min: "doit être au moins %{min}" - max: "doit être au plus %{max}" - not_within: "ne doit pas être dans %{min} et %{max}" - within: "doit être dans %{min} et %{max}" - presence: "ne peut pas être vide" diff --git a/lib/locales/fy.yml b/lib/locales/fy.yml deleted file mode 100644 index 8fef47ddb..000000000 --- a/lib/locales/fy.yml +++ /dev/null @@ -1,55 +0,0 @@ -fy: - cmdx: - attributes: - required: "moat tagonklik wêze fia de boarnemetoade %{method}" - undefined: "delegearret nei ûndefiniearre metoade %{method}" - coercions: - into_a: "koe net nei %{type} konvertearje" - into_an: "koe net nei %{type} konvertearje" - into_any: "koe net nei ien fan: %{types} konvertearje" - unknown: "ûnbekende %{type} konverzje type" - returns: - missing: "moat ynsteld wurde yn de kontekst" - faults: - invalid: "Ûnjildige" - unspecified: "Net spesifisearre" - reasons: - unspecified: "Net spesifisearre" - types: - array: "array" - big_decimal: "grutte desimaal" - boolean: "booleaansk" - complex: "kompleks" - date_time: "datum en tiid" - date: "datum" - float: "driuwpunt" - hash: "hash" - integer: "hielgetal" - rational: "rasjonaal" - string: "string" - symbol: "symboal" - time: "tiid" - validators: - absence: "moat leech wêze" - exclusion: - of: "moat net ien fan wêze: %{values}" - within: "moat net binnen %{min} en %{max} wêze" - format: "is in ûnjildich formaat" - inclusion: - of: "moat ien fan wêze: %{values}" - within: "moat binnen %{min} en %{max} wêze" - length: - is: "lingte moat %{is} wêze" - is_not: "lingte moat net %{is_not} wêze" - min: "lingte moat op syn minst %{min} wêze" - max: "lingte kin maksimaal %{max} wêze" - not_within: "lingte moat net binnen %{min} en %{max} wêze" - within: "lingte moat binnen %{min} en %{max} wêze" - numeric: - is: "moat %{is} wêze" - is_not: "moat net %{is_not} wêze" - min: "moat op syn minst %{min} wêze" - max: "kin maksimaal %{max} wêze" - not_within: "moat net binnen %{min} en %{max} wêze" - within: "moat binnen %{min} en %{max} wêze" - presence: "kin net leech wêze" diff --git a/lib/locales/gd.yml b/lib/locales/gd.yml deleted file mode 100644 index cef9ebeae..000000000 --- a/lib/locales/gd.yml +++ /dev/null @@ -1,55 +0,0 @@ -gd: - cmdx: - attributes: - required: "feumaidh e bhith ruigsinneach tron mhodh thùsail %{method}" - undefined: "deileigeas gu modh neo-mhìneachaidh %{method}" - coercions: - into_a: "cha b' urrainn tionndadh gu %{type}" - into_an: "cha b' urrainn tionndadh gu %{type}" - into_any: "cha b' urrainn tionndadh gu aon de: %{types}" - unknown: "seòrsa tionndaidh %{type} neo-aithnichte" - returns: - missing: "feumaidh e bhith air a shuidheachadh sa cho-theacsa" - faults: - invalid: "Neo-dhligheach" - unspecified: "Neo-mhìneachaidh" - reasons: - unspecified: "Neo-mhìneachaidh" - types: - array: "sreath" - big_decimal: "deicheamh mòr" - boolean: "booleach" - complex: "iom-fhillte" - date_time: "ceann-latha agus àm" - date: "ceann-latha" - float: "puing air itealaich" - hash: "hash" - integer: "àireamh shlàn" - rational: "reusanta" - string: "sreang" - symbol: "samhla" - time: "àm" - validators: - absence: "feumaidh e a bhith falamh" - exclusion: - of: "chan fhaod a bhith aon de: %{values}" - within: "chan fhaod a bhith taobh a-staigh %{min} agus %{max}" - format: "tha e na chruth neo-dhligheach" - inclusion: - of: "feumaidh e a bhith aon de: %{values}" - within: "feumaidh e a bhith taobh a-staigh %{min} agus %{max}" - length: - is: "feumaidh an fhad a bhith %{is}" - is_not: "chan fhaod an fhad a bhith %{is_not}" - min: "feumaidh an fhad a bhith co-dhiù %{min}" - max: "faodaidh an fhad a bhith suas ri %{max}" - not_within: "chan fhaod an fhad a bhith taobh a-staigh %{min} agus %{max}" - within: "feumaidh an fhad a bhith taobh a-staigh %{min} agus %{max}" - numeric: - is: "feumaidh e a bhith %{is}" - is_not: "chan fhaod e a bhith %{is_not}" - min: "feumaidh e a bhith co-dhiù %{min}" - max: "faodaidh e a bhith suas ri %{max}" - not_within: "chan fhaod e a bhith taobh a-staigh %{min} agus %{max}" - within: "feumaidh e a bhith taobh a-staigh %{min} agus %{max}" - presence: "chan fhaod e a bhith falamh" diff --git a/lib/locales/gl.yml b/lib/locales/gl.yml deleted file mode 100644 index 0fe418951..000000000 --- a/lib/locales/gl.yml +++ /dev/null @@ -1,55 +0,0 @@ -gl: - cmdx: - attributes: - required: "debe ser accesible a través do método de orixe %{method}" - undefined: "delega ao método non definido %{method}" - coercions: - into_a: "non se puido converter a %{type}" - into_an: "non se puido converter a %{type}" - into_any: "non se puido converter a un de: %{types}" - unknown: "tipo de conversión %{type} descoñecido" - returns: - missing: "debe estar establecido no contexto" - faults: - invalid: "Non válidas" - unspecified: "Non especificada" - reasons: - unspecified: "Non especificada" - types: - array: "array" - big_decimal: "decimal grande" - boolean: "booleano" - complex: "complexo" - date_time: "data e hora" - date: "data" - float: "punto flotante" - hash: "hash" - integer: "enteiro" - rational: "racional" - string: "cadea" - symbol: "símbolo" - time: "tempo" - validators: - absence: "debe estar baleiro" - exclusion: - of: "non debe ser un de: %{values}" - within: "non debe estar entre %{min} e %{max}" - format: "é un formato non válido" - inclusion: - of: "debe ser un de: %{values}" - within: "debe estar entre %{min} e %{max}" - length: - is: "a lonxitude debe ser %{is}" - is_not: "a lonxitude non debe ser %{is_not}" - min: "a lonxitude debe ser polo menos %{min}" - max: "a lonxitude pode ser como máximo %{max}" - not_within: "a lonxitude non debe estar entre %{min} e %{max}" - within: "a lonxitude debe estar entre %{min} e %{max}" - numeric: - is: "debe ser %{is}" - is_not: "non debe ser %{is_not}" - min: "debe ser polo menos %{min}" - max: "pode ser como máximo %{max}" - not_within: "non debe estar entre %{min} e %{max}" - within: "debe estar entre %{min} e %{max}" - presence: "non pode estar baleiro" diff --git a/lib/locales/he.yml b/lib/locales/he.yml deleted file mode 100644 index 308682119..000000000 --- a/lib/locales/he.yml +++ /dev/null @@ -1,55 +0,0 @@ -he: - cmdx: - attributes: - required: "חייב להיות נגיש דרך מתודת המקור %{method}" - undefined: "מאציל לשיטה לא מוגדרת %{method}" - coercions: - into_a: "לא ניתן להמיר ל-%{type}" - into_an: "לא ניתן להמיר ל-%{type}" - into_any: "לא ניתן להמיר לאחד מ: %{types}" - unknown: "סוג המרה %{type} לא ידוע" - returns: - missing: "חייב להיות מוגדר בהקשר" - faults: - invalid: "לא תקינים" - unspecified: "לא מוגדר" - reasons: - unspecified: "לא מוגדר" - types: - array: "מערך" - big_decimal: "עשרוני גדול" - boolean: "בוליאני" - complex: "מורכב" - date_time: "תאריך ושעה" - date: "תאריך" - float: "מספר נקודה צפה" - hash: "גיבוב" - integer: "מספר שלם" - rational: "רציונלי" - string: "מחרוזת" - symbol: "סמל" - time: "זמן" - validators: - absence: "חייב להיות ריק" - exclusion: - of: "לא יכול להיות אחד מ: %{values}" - within: "לא יכול להיות בין %{min} ו-%{max}" - format: "הוא פורמט לא תקין" - inclusion: - of: "חייב להיות אחד מ: %{values}" - within: "חייב להיות בין %{min} ו-%{max}" - length: - is: "האורך חייב להיות %{is}" - is_not: "האורך לא יכול להיות %{is_not}" - min: "האורך חייב להיות לפחות %{min}" - max: "האורך יכול להיות לכל היותר %{max}" - not_within: "האורך לא יכול להיות בין %{min} ו-%{max}" - within: "האורך חייב להיות בין %{min} ו-%{max}" - numeric: - is: "חייב להיות %{is}" - is_not: "לא יכול להיות %{is_not}" - min: "חייב להיות לפחות %{min}" - max: "יכול להיות לכל היותר %{max}" - not_within: "לא יכול להיות בין %{min} ו-%{max}" - within: "חייב להיות בין %{min} ו-%{max}" - presence: "לא יכול להיות ריק" diff --git a/lib/locales/hi.yml b/lib/locales/hi.yml deleted file mode 100644 index fab3bc9f4..000000000 --- a/lib/locales/hi.yml +++ /dev/null @@ -1,55 +0,0 @@ -hi: - cmdx: - attributes: - required: "%{method} स्रोत विधि के माध्यम से सुलभ होना चाहिए" - undefined: "अपरिभाषित विधि %{method} को सौंपता है" - coercions: - into_a: "%{type} में परिवर्तित नहीं किया जा सका" - into_an: "%{type} में परिवर्तित नहीं किया जा सका" - into_any: "निम्नलिखित में से किसी में परिवर्तित नहीं किया जा सका: %{types}" - unknown: "अज्ञात %{type} रूपांतरण प्रकार" - returns: - missing: "संदर्भ में सेट होना चाहिए" - faults: - invalid: "अमान्य" - unspecified: "अनिर्दिष्ट" - reasons: - unspecified: "अनिर्दिष्ट" - types: - array: "सरणी" - big_decimal: "बड़ा दशमलव" - boolean: "बूलियन" - complex: "जटिल" - date_time: "दिनांक और समय" - date: "दिनांक" - float: "फ्लोट" - hash: "हैश" - integer: "पूर्णांक" - rational: "परिमेय" - string: "स्ट्रिंग" - symbol: "प्रतीक" - time: "समय" - validators: - absence: "खाली होना चाहिए" - exclusion: - of: "निम्नलिखित में से कोई नहीं होना चाहिए: %{values}" - within: "%{min} और %{max} के बीच नहीं होना चाहिए" - format: "एक अमान्य प्रारूप है" - inclusion: - of: "निम्नलिखित में से कोई होना चाहिए: %{values}" - within: "%{min} और %{max} के बीच होना चाहिए" - length: - is: "लंबाई %{is} होनी चाहिए" - is_not: "लंबाई %{is_not} नहीं होनी चाहिए" - min: "लंबाई कम से कम %{min} होनी चाहिए" - max: "लंबाई अधिकतम %{max} होनी चाहिए" - not_within: "लंबाई %{min} और %{max} के बीच नहीं होनी चाहिए" - within: "लंबाई %{min} और %{max} के बीच होनी चाहिए" - numeric: - is: "%{is} होना चाहिए" - is_not: "%{is_not} नहीं होना चाहिए" - min: "कम से कम %{min} होना चाहिए" - max: "अधिकतम %{max} होना चाहिए" - not_within: "%{min} और %{max} के बीच नहीं होना चाहिए" - within: "%{min} और %{max} के बीच होना चाहिए" - presence: "खाली नहीं हो सकता" diff --git a/lib/locales/hr.yml b/lib/locales/hr.yml deleted file mode 100644 index 14834bb7f..000000000 --- a/lib/locales/hr.yml +++ /dev/null @@ -1,55 +0,0 @@ -hr: - cmdx: - attributes: - required: "mora biti dostupan putem izvorne metode %{method}" - undefined: "delegira nedefiniranoj metodi %{method}" - coercions: - into_a: "nije mogao biti pretvoren u %{type}" - into_an: "nije mogao biti pretvoren u %{type}" - into_any: "nije mogao biti pretvoren u jedan od: %{types}" - unknown: "nepoznati tip pretvorbe %{type}" - returns: - missing: "mora biti postavljeno u kontekstu" - faults: - invalid: "Neispravni" - unspecified: "Nije naveden" - reasons: - unspecified: "Nije naveden" - types: - array: "niz" - big_decimal: "veliki decimalni broj" - boolean: "logički" - complex: "kompleksan" - date_time: "datum i vrijeme" - date: "datum" - float: "broj s pomičnom točkom" - hash: "hash" - integer: "cijeli broj" - rational: "racionalan" - string: "niz znakova" - symbol: "simbol" - time: "vrijeme" - validators: - absence: "mora biti prazno" - exclusion: - of: "ne smije biti jedan od: %{values}" - within: "ne smije biti između %{min} i %{max}" - format: "je nevažeći format" - inclusion: - of: "mora biti jedan od: %{values}" - within: "mora biti između %{min} i %{max}" - length: - is: "duljina mora biti %{is}" - is_not: "duljina ne smije biti %{is_not}" - min: "duljina mora biti najmanje %{min}" - max: "duljina može biti najviše %{max}" - not_within: "duljina ne smije biti između %{min} i %{max}" - within: "duljina mora biti između %{min} i %{max}" - numeric: - is: "mora biti %{is}" - is_not: "ne smije biti %{is_not}" - min: "mora biti najmanje %{min}" - max: "može biti najviše %{max}" - not_within: "ne smije biti između %{min} i %{max}" - within: "mora biti između %{min} i %{max}" - presence: "ne može biti prazno" diff --git a/lib/locales/hu.yml b/lib/locales/hu.yml deleted file mode 100644 index 7324dc720..000000000 --- a/lib/locales/hu.yml +++ /dev/null @@ -1,55 +0,0 @@ -hu: - cmdx: - attributes: - required: "hozzáférhetőnek kell lennie a(z) %{method} forrásmetóduson keresztül" - undefined: "delegál a nem definiált metódusra %{method}" - coercions: - into_a: "nem sikerült %{type} típusra konvertálni" - into_an: "nem sikerült %{type} típusra konvertálni" - into_any: "nem sikerült konvertálni a következők egyikére: %{types}" - unknown: "ismeretlen %{type} konverziós típus" - returns: - missing: "be kell állítani a kontextusban" - faults: - invalid: "Érvénytelen" - unspecified: "Nincs megadva" - reasons: - unspecified: "Nincs megadva" - types: - array: "tömb" - big_decimal: "nagy decimális" - boolean: "logikai" - complex: "komplex" - date_time: "dátum és idő" - date: "dátum" - float: "lebegőpontos" - hash: "hash" - integer: "egész szám" - rational: "racionális" - string: "karakterlánc" - symbol: "szimbólum" - time: "idő" - validators: - absence: "üresnek kell lennie" - exclusion: - of: "nem lehet egyike a következőknek: %{values}" - within: "nem lehet %{min} és %{max} között" - format: "érvénytelen formátum" - inclusion: - of: "egyike kell legyen a következőknek: %{values}" - within: "%{min} és %{max} között kell lennie" - length: - is: "a hossz %{is} kell legyen" - is_not: "a hossz nem lehet %{is_not}" - min: "a hossz legalább %{min} kell legyen" - max: "a hossz legfeljebb %{max} lehet" - not_within: "a hossz nem lehet %{min} és %{max} között" - within: "a hossz %{min} és %{max} között kell legyen" - numeric: - is: "%{is} kell legyen" - is_not: "nem lehet %{is_not}" - min: "legalább %{min} kell legyen" - max: "legfeljebb %{max} lehet" - not_within: "nem lehet %{min} és %{max} között" - within: "%{min} és %{max} között kell lennie" - presence: "nem lehet üres" diff --git a/lib/locales/hy.yml b/lib/locales/hy.yml deleted file mode 100644 index 8a3d66504..000000000 --- a/lib/locales/hy.yml +++ /dev/null @@ -1,55 +0,0 @@ -hy: - cmdx: - attributes: - required: "պետք է հասանելի լինի %{method} ելակետային մեթոդի միջոցով" - undefined: "անցնում է չսահմանված մեթոդին %{method}" - coercions: - into_a: "չհաջողվեց վերածել %{type} տիպի" - into_an: "չհաջողվեց վերածել %{type} տիպի" - into_any: "չհաջողվեց վերածել հետևյալներից մեկի: %{types}" - unknown: "անհայտ %{type} փոխակերպման տիպ" - returns: - missing: "պետք է սահմանված լինի համատեքստում" - faults: - invalid: "Անվավեր" - unspecified: "Չնշված" - reasons: - unspecified: "Չնշված" - types: - array: "զանգված" - big_decimal: "մեծ տասնորդական" - boolean: "տրամաբանական" - complex: "կոմպլեքս" - date_time: "ամսաթիվ և ժամանակ" - date: "ամսաթիվ" - float: "լողացող կետով թիվ" - hash: "հեշ" - integer: "ամբողջ թիվ" - rational: "ռացիոնալ" - string: "տող" - symbol: "սիմվոլ" - time: "ժամանակ" - validators: - absence: "պdelays delays delays delays delays է delays delaysdelays delays delays delays" - exclusion: - of: "չի կարող լինել հետևյալներից մեկը: %{values}" - within: "չի կարող լինել %{min} և %{max} միջև" - format: "անվավեր ձևաչափ է" - inclusion: - of: "պետք է լինի հետևյալներից մեկը: %{values}" - within: "պետք է լինի %{min} և %{max} միջև" - length: - is: "երկարությունը պետք է լինի %{is}" - is_not: "երկարությունը չի կարող լինել %{is_not}" - min: "երկարությունը պետք է լինի առնվազն %{min}" - max: "երկարությունը կարող է լինել առավելագույնը %{max}" - not_within: "երկարությունը չի կարող լինել %{min} և %{max} միջև" - within: "երկարությունը պետք է լինի %{min} և %{max} միջև" - numeric: - is: "պետք է լինի %{is}" - is_not: "չի կարող լինել %{is_not}" - min: "պետք է լինի առնվազն %{min}" - max: "կարող է լինել առավելագույնը %{max}" - not_within: "չի կարող լինել %{min} և %{max} միջև" - within: "պետք է լինի %{min} և %{max} միջև" - presence: "չի կարող դատարկ լինել" diff --git a/lib/locales/id.yml b/lib/locales/id.yml deleted file mode 100644 index e5af15c8c..000000000 --- a/lib/locales/id.yml +++ /dev/null @@ -1,55 +0,0 @@ -id: - cmdx: - attributes: - required: "harus dapat diakses melalui metode sumber %{method}" - undefined: "mendelegasikan ke metode yang tidak terdefinisi %{method}" - coercions: - into_a: "tidak dapat dikonversi ke %{type}" - into_an: "tidak dapat dikonversi ke %{type}" - into_any: "tidak dapat dikonversi ke salah satu dari: %{types}" - unknown: "jenis konversi %{type} yang tidak dikenal" - returns: - missing: "harus diatur dalam konteks" - faults: - invalid: "Tidak valid" - unspecified: "Tidak ditentukan" - reasons: - unspecified: "Tidak ditentukan" - types: - array: "array" - big_decimal: "desimal besar" - boolean: "boolean" - complex: "kompleks" - date_time: "tanggal dan waktu" - date: "tanggal" - float: "titik mengambang" - hash: "hash" - integer: "bilangan bulat" - rational: "rasional" - string: "string" - symbol: "simbol" - time: "waktu" - validators: - absence: "harus kosong" - exclusion: - of: "tidak boleh salah satu dari: %{values}" - within: "tidak boleh di antara %{min} dan %{max}" - format: "adalah format yang tidak valid" - inclusion: - of: "harus salah satu dari: %{values}" - within: "harus di antara %{min} dan %{max}" - length: - is: "panjang harus %{is}" - is_not: "panjang tidak boleh %{is_not}" - min: "panjang minimal harus %{min}" - max: "panjang maksimal %{max}" - not_within: "panjang tidak boleh di antara %{min} dan %{max}" - within: "panjang harus di antara %{min} dan %{max}" - numeric: - is: "harus %{is}" - is_not: "tidak boleh %{is_not}" - min: "minimal harus %{min}" - max: "maksimal %{max}" - not_within: "tidak boleh di antara %{min} dan %{max}" - within: "harus di antara %{min} dan %{max}" - presence: "tidak boleh kosong" diff --git a/lib/locales/is.yml b/lib/locales/is.yml deleted file mode 100644 index f4998c4a2..000000000 --- a/lib/locales/is.yml +++ /dev/null @@ -1,55 +0,0 @@ -is: - cmdx: - attributes: - required: "verður að vera aðgengilegt í gegnum grunnaðferðina %{method}" - undefined: "fela óskilgreindri aðferð %{method}" - coercions: - into_a: "gat ekki breytt í %{type}" - into_an: "gat ekki breytt í %{type}" - into_any: "gat ekki breytt í einn af: %{types}" - unknown: "óþekktur %{type} breytingargerð" - returns: - missing: "verður að vera stillt í samhenginu" - faults: - invalid: "Ógild" - unspecified: "Ótilgreind" - reasons: - unspecified: "Ótilgreind" - types: - array: "fylki" - big_decimal: "stór tugabrot" - boolean: "rökvísindi" - complex: "flókið" - date_time: "dagsetning og tími" - date: "dagsetning" - float: "kommutala" - hash: "hash" - integer: "heiltala" - rational: "ræða" - string: "strengur" - symbol: "tákn" - time: "tími" - validators: - absence: "verður að vera tómt" - exclusion: - of: "má ekki vera einn af: %{values}" - within: "má ekki vera á milli %{min} og %{max}" - format: "er ógild form" - inclusion: - of: "verður að vera einn af: %{values}" - within: "verður að vera á milli %{min} og %{max}" - length: - is: "lengdin verður að vera %{is}" - is_not: "lengdin má ekki vera %{is_not}" - min: "lengdin verður að vera að minnsta kosti %{min}" - max: "lengdin má að hámarki vera %{max}" - not_within: "lengdin má ekki vera á milli %{min} og %{max}" - within: "lengdin verður að vera á milli %{min} og %{max}" - numeric: - is: "verður að vera %{is}" - is_not: "má ekki vera %{is_not}" - min: "verður að vera að minnsta kosti %{min}" - max: "má að hámarki vera %{max}" - not_within: "má ekki vera á milli %{min} og %{max}" - within: "verður að vera á milli %{min} og %{max}" - presence: "má ekki vera tómur" diff --git a/lib/locales/it.yml b/lib/locales/it.yml deleted file mode 100644 index ffde4ea09..000000000 --- a/lib/locales/it.yml +++ /dev/null @@ -1,55 +0,0 @@ -it: - cmdx: - attributes: - required: "deve essere accessibile tramite il metodo di origine %{method}" - undefined: "delega al metodo non definito %{method}" - coercions: - into_a: "non è stato possibile convertire in %{type}" - into_an: "non è stato possibile convertire in %{type}" - into_any: "non è stato possibile convertire in uno di: %{types}" - unknown: "tipo di conversione %{type} sconosciuto" - returns: - missing: "deve essere impostato nel contesto" - faults: - invalid: "Non validi" - unspecified: "Non specificato" - reasons: - unspecified: "Non specificato" - types: - array: "array" - big_decimal: "decimale grande" - boolean: "booleano" - complex: "complesso" - date_time: "data e ora" - date: "data" - float: "virgola mobile" - hash: "hash" - integer: "intero" - rational: "razionale" - string: "stringa" - symbol: "simbolo" - time: "tempo" - validators: - absence: "deve essere vuoto" - exclusion: - of: "non deve essere uno di: %{values}" - within: "non deve essere tra %{min} e %{max}" - format: "è un formato non valido" - inclusion: - of: "deve essere uno di: %{values}" - within: "deve essere tra %{min} e %{max}" - length: - is: "la lunghezza deve essere %{is}" - is_not: "la lunghezza non deve essere %{is_not}" - min: "la lunghezza deve essere almeno %{min}" - max: "la lunghezza deve essere al massimo %{max}" - not_within: "la lunghezza non deve essere tra %{min} e %{max}" - within: "la lunghezza deve essere tra %{min} e %{max}" - numeric: - is: "deve essere %{is}" - is_not: "non deve essere %{is_not}" - min: "deve essere almeno %{min}" - max: "deve essere al massimo %{max}" - not_within: "non deve essere tra %{min} e %{max}" - within: "deve essere tra %{min} e %{max}" - presence: "non può essere vuoto" diff --git a/lib/locales/ja.yml b/lib/locales/ja.yml deleted file mode 100644 index 217cef673..000000000 --- a/lib/locales/ja.yml +++ /dev/null @@ -1,55 +0,0 @@ -ja: - cmdx: - attributes: - required: "%{method} ソースメソッド経由でアクセスできる必要があります" - undefined: "未定義のメソッド %{method} に委譲します" - coercions: - into_a: "%{type} に変換できませんでした" - into_an: "%{type} に変換できませんでした" - into_any: "次のいずれかに変換できませんでした: %{types}" - unknown: "不明な %{type} 変換タイプ" - returns: - missing: "コンテキストに設定する必要があります" - faults: - invalid: "無効" - unspecified: "未指定" - reasons: - unspecified: "未指定" - types: - array: "配列" - big_decimal: "大きな小数" - boolean: "ブール値" - complex: "複素数" - date_time: "日時" - date: "日付" - float: "浮動小数点数" - hash: "ハッシュ" - integer: "整数" - rational: "有理数" - string: "文字列" - symbol: "シンボル" - time: "時刻" - validators: - absence: "空でなければなりません" - exclusion: - of: "次のいずれかであってはなりません: %{values}" - within: "%{min} と %{max} の間であってはなりません" - format: "無効な形式です" - inclusion: - of: "次のいずれかである必要があります: %{values}" - within: "%{min} と %{max} の間である必要があります" - length: - is: "長さは %{is} である必要があります" - is_not: "長さは %{is_not} であってはなりません" - min: "長さは少なくとも %{min} である必要があります" - max: "長さは最大 %{max} である必要があります" - not_within: "長さは %{min} と %{max} の間であってはなりません" - within: "長さは %{min} と %{max} の間である必要があります" - numeric: - is: "%{is} である必要があります" - is_not: "%{is_not} であってはなりません" - min: "少なくとも %{min} である必要があります" - max: "最大 %{max} である必要があります" - not_within: "%{min} と %{max} の間であってはなりません" - within: "%{min} と %{max} の間である必要があります" - presence: "空であってはなりません" diff --git a/lib/locales/ka.yml b/lib/locales/ka.yml deleted file mode 100644 index d682aba21..000000000 --- a/lib/locales/ka.yml +++ /dev/null @@ -1,55 +0,0 @@ -ka: - cmdx: - attributes: - required: "ხელმისაწვდომი უნდა იყოს %{method} წყაროს მეთოდის საშუალებით" - undefined: "აღწერს განუსაზღვრელ მეთოდს %{method}" - coercions: - into_a: "ვერ გადაიყვანა %{type} ტიპად" - into_an: "ვერ გადაიყვანა %{type} ტიპად" - into_any: "ვერ გადაიყვანა ერთ-ერთად: %{types}" - unknown: "უცნობი %{type} კონვერტაციის ტიპი" - returns: - missing: "კონტექსტში უნდა იყოს დაყენებული" - faults: - invalid: "არასწორი" - unspecified: "მითითებული არ არის" - reasons: - unspecified: "მითითებული არ არის" - types: - array: "მასივი" - big_decimal: "დიდი ათწილადი" - boolean: "ლოგიკური" - complex: "კომპლექსური" - date_time: "თარიღი და დრო" - date: "თარიღი" - float: "მცურავი წერტილის რიცხვი" - hash: "ჰეში" - integer: "მთელი რიცხვი" - rational: "რაციონალური" - string: "სტრიქონი" - symbol: "სიმბოლო" - time: "დრო" - validators: - absence: "უნდა იყოს ცარიელი" - exclusion: - of: "არ უნდა იყოს ერთ-ერთი: %{values}" - within: "არ უნდა იყოს %{min} და %{max} შორის" - format: "არის არასწორი ფორმატი" - inclusion: - of: "უნდა იყოს ერთ-ერთი: %{values}" - within: "უნდა იყოს %{min} და %{max} შორის" - length: - is: "სიგრძე უნდა იყოს %{is}" - is_not: "სიგრძე არ უნდა იყოს %{is_not}" - min: "სიგრძე უნდა იყოს მინიმუმ %{min}" - max: "სიგრძე უნდა იყოს მაქსიმუმ %{max}" - not_within: "სიგრძე არ უნდა იყოს %{min} და %{max} შორის" - within: "სიგრძე უნდა იყოს %{min} და %{max} შორის" - numeric: - is: "უნდა იყოს %{is}" - is_not: "არ უნდა იყოს %{is_not}" - min: "უნდა იყოს მინიმუმ %{min}" - max: "უნდა იყოს მაქსიმუმ %{max}" - not_within: "არ უნდა იყოს %{min} და %{max} შორის" - within: "უნდა იყოს %{min} და %{max} შორის" - presence: "არ შეიძლება იყოს ცარიელი" diff --git a/lib/locales/kk.yml b/lib/locales/kk.yml deleted file mode 100644 index 084461280..000000000 --- a/lib/locales/kk.yml +++ /dev/null @@ -1,55 +0,0 @@ -kk: - cmdx: - attributes: - required: "%{method} бастапқы әдісі арқылы қолжетімді болуы керек" - undefined: "анықталмаған %{method} әдісіне тапсырады" - coercions: - into_a: "%{type} түріне айналдыра алмады" - into_an: "%{type} түріне айналдыра алмады" - into_any: "бұлардың біріне айналдыра алмады: %{types}" - unknown: "белгісіз %{type} айналдыру түрі" - returns: - missing: "контексте орнатылуы керек" - faults: - invalid: "Жарамсыз" - unspecified: "Көрсетілмеген" - reasons: - unspecified: "Көрсетілмеген" - types: - array: "массив" - big_decimal: "үлкен ондық" - boolean: "буль" - complex: "күрделі" - date_time: "күні мен уақыты" - date: "күні" - float: "өзек нүктесі" - hash: "хэш" - integer: "бүтін сан" - rational: "рационал" - string: "жол" - symbol: "символ" - time: "уақыт" - validators: - absence: "бос болуы керек" - exclusion: - of: "бұлардың бірі болмасын: %{values}" - within: "%{min} және %{max} арасында болмасын" - format: "дұрыс емес формат" - inclusion: - of: "бұлардың бірі болуы керек: %{values}" - within: "%{min} және %{max} арасында болуы керек" - length: - is: "ұзындығы %{is} болуы керек" - is_not: "ұзындығы %{is_not} болмасын" - min: "ұзындығы кемінде %{min} болуы керек" - max: "ұзындығы ең көбінде %{max} бола алады" - not_within: "ұзындығы %{min} және %{max} арасында болмасын" - within: "ұзындығы %{min} және %{max} арасында болуы керек" - numeric: - is: "%{is} болуы керек" - is_not: "%{is_not} болмасын" - min: "кемінде %{min} болуы керек" - max: "ең көбінде %{max} бола алады" - not_within: "%{min} және %{max} арасында болмасын" - within: "%{min} және %{max} арасында болуы керек" - presence: "бос болмауы керек" diff --git a/lib/locales/km.yml b/lib/locales/km.yml deleted file mode 100644 index 20485192f..000000000 --- a/lib/locales/km.yml +++ /dev/null @@ -1,55 +0,0 @@ -km: - cmdx: - attributes: - required: "ត្រូវតែអាចចូលប្រើបានតាមរយៈវិធីសាស្ត្រប្រភព %{method}" - undefined: "ប្រគល់ភារកិច្ចទៅឱ្យវិធីសាស្ត្រដែលមិនបានកំណត់ %{method}" - coercions: - into_a: "មិនអាចបំប្លែងទៅជា %{type} បានទេ" - into_an: "មិនអាចបំប្លែងទៅជា %{type} បានទេ" - into_any: "មិនអាចបំប្លែងទៅជាមួយក្នុងចំណោម: %{types} បានទេ" - unknown: "ប្រភេទបំប្លែង %{type} ដែលមិនស្គាល់" - returns: - missing: "ត្រូវតែកំណត់ក្នុងបរិបទ" - faults: - invalid: "មិនត្រឹមត្រូវ" - unspecified: "មិនបានបញ្ជាក់" - reasons: - unspecified: "មិនបានបញ្ជាក់" - types: - array: "អារេ" - big_decimal: "ទសភាគធំ" - boolean: "ប៊ូលីន" - complex: "ស្មុគស្មាញ" - date_time: "កាលបរិច្ឆេទ និងពេលវេលា" - date: "កាលបរិច្ឆេទ" - float: "ចំណុចអណ្តែត" - hash: "ហាស" - integer: "ចំនួនគត់" - rational: "ចំនួនវិជ្ជមាន" - string: "ខ្សែអក្សរ" - symbol: "និមិត្តសញ្ញា" - time: "ពេលវេលា" - validators: - absence: "ត្រូវតែទទេរ" - exclusion: - of: "មិនត្រូវតែជាមួយក្នុងចំណោម: %{values} ទេ" - within: "មិនត្រូវតែនៅចន្លោះ %{min} និង %{max} ទេ" - format: "ជាទម្រង់មិនត្រឹមត្រូវ" - inclusion: - of: "ត្រូវតែជាមួយក្នុងចំណោម: %{values}" - within: "ត្រូវតែនៅចន្លោះ %{min} និង %{max}" - length: - is: "ប្រវែងត្រូវតែ %{is}" - is_not: "ប្រវែងមិនត្រូវតែ %{is_not} ទេ" - min: "ប្រវែងត្រូវតែយ៉ាងតិច %{min}" - max: "ប្រវែងអាចជាអតិបរមា %{max}" - not_within: "ប្រវែងមិនត្រូវតែនៅចន្លោះ %{min} និង %{max} ទេ" - within: "ប្រវែងត្រូវតែនៅចន្លោះ %{min} និង %{max}" - numeric: - is: "ត្រូវតែ %{is}" - is_not: "មិនត្រូវតែ %{is_not} ទេ" - min: "ត្រូវតែយ៉ាងតិច %{min}" - max: "អាចជាអតិបរមា %{max}" - not_within: "មិនត្រូវតែនៅចន្លោះ %{min} និង %{max} ទេ" - within: "ត្រូវតែនៅចន្លោះ %{min} និង %{max}" - presence: "មិនអាចទទេរបានទេ" diff --git a/lib/locales/kn.yml b/lib/locales/kn.yml deleted file mode 100644 index da924e206..000000000 --- a/lib/locales/kn.yml +++ /dev/null @@ -1,55 +0,0 @@ -kn: - cmdx: - attributes: - required: "%{method} ಮೂಲ ವಿಧಾನದ ಮೂಲಕ ಪ್ರವೇಶಿಸಬಹುದಾಗಿರಬೇಕು" - undefined: "ವ್ಯಾಖ್ಯಾನಿಸದ ವಿಧಾನಕ್ಕೆ %{method} ನಿಯೋಜಿಸುತ್ತದೆ" - coercions: - into_a: "%{type} ಗೆ ಪರಿವರ್ತಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ" - into_an: "%{type} ಗೆ ಪರಿವರ್ತಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ" - into_any: "ಈ ಕೆಳಗಿನವುಗಳಲ್ಲಿ ಒಂದಕ್ಕೆ ಪರಿವರ್ತಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ: %{types}" - unknown: "ಅಜ್ಞಾತ %{type} ಪರಿವರ್ತನೆ ಪ್ರಕಾರ" - returns: - missing: "ಸಂದರ್ಭದಲ್ಲಿ ಹೊಂದಿಸಬೇಕು" - faults: - invalid: "ಅಮಾನ್ಯ" - unspecified: "ನಿರ್ದಿಷ್ಟವಾಗಿಲ್ಲ" - reasons: - unspecified: "ನಿರ್ದಿಷ್ಟವಾಗಿಲ್ಲ" - types: - array: "ಅರೇ" - big_decimal: "ದೊಡ್ಡ ದಶಮಾಂಶ" - boolean: "ಬೂಲಿಯನ್" - complex: "ಸಂಕೀರ್ಣ" - date_time: "ದಿನಾಂಕ ಮತ್ತು ಸಮಯ" - date: "ದಿನಾಂಕ" - float: "ತೇಲುವ ಬಿಂದು" - hash: "ಹ್ಯಾಶ್" - integer: "ಪೂರ್ಣಾಂಕ" - rational: "ತರ್ಕಬದ್ಧ" - string: "ಸ್ಟ್ರಿಂಗ್" - symbol: "ಚಿಹ್ನೆ" - time: "ಸಮಯ" - validators: - absence: "ಖಾಲಿಯಾಗಿರಬೇಕು" - exclusion: - of: "ಈ ಕೆಳಗಿನವುಗಳಲ್ಲಿ ಒಂದಾಗಿರಬಾರದು: %{values}" - within: "%{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬಾರದು" - format: "ಅಮಾನ್ಯ ಫಾರ್ಮ್ಯಾಟ್ ಆಗಿದೆ" - inclusion: - of: "ಈ ಕೆಳಗಿನವುಗಳಲ್ಲಿ ಒಂದಾಗಿರಬೇಕು: %{values}" - within: "%{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬೇಕು" - length: - is: "ಉದ್ದ %{is} ಆಗಿರಬೇಕು" - is_not: "ಉದ್ದ %{is_not} ಆಗಿರಬಾರದು" - min: "ಉದ್ದ ಕನಿಷ್ಠ %{min} ಆಗಿರಬೇಕು" - max: "ಉದ್ದ ಗರಿಷ್ಠ %{max} ಆಗಿರಬಹುದು" - not_within: "ಉದ್ದ %{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬಾರದು" - within: "ಉದ್ದ %{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬೇಕು" - numeric: - is: "%{is} ಆಗಿರಬೇಕು" - is_not: "%{is_not} ಆಗಿರಬಾರದು" - min: "ಕನಿಷ್ಠ %{min} ಆಗಿರಬೇಕು" - max: "ಗರಿಷ್ಠ %{max} ಆಗಿರಬಹುದು" - not_within: "%{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬಾರದು" - within: "%{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬೇಕು" - presence: "ಖಾಲಿಯಾಗಿರಬಾರದು" diff --git a/lib/locales/ko.yml b/lib/locales/ko.yml deleted file mode 100644 index f3b29e685..000000000 --- a/lib/locales/ko.yml +++ /dev/null @@ -1,55 +0,0 @@ -ko: - cmdx: - attributes: - required: "%{method} 소스 메서드를 통해 액세스할 수 있어야 합니다" - undefined: "정의되지 않은 메서드 %{method}에 위임합니다" - coercions: - into_a: "%{type}로 변환할 수 없습니다" - into_an: "%{type}로 변환할 수 없습니다" - into_any: "다음 중 하나로 변환할 수 없습니다: %{types}" - unknown: "알 수 없는 %{type} 변환 타입" - returns: - missing: "컨텍스트에 설정되어야 합니다" - faults: - invalid: "잘못된" - unspecified: "지정되지 않음" - reasons: - unspecified: "지정되지 않음" - types: - array: "배열" - big_decimal: "큰 소수" - boolean: "불린" - complex: "복소수" - date_time: "날짜 시간" - date: "날짜" - float: "부동 소수점" - hash: "해시" - integer: "정수" - rational: "유리수" - string: "문자열" - symbol: "심볼" - time: "시간" - validators: - absence: "비어있어야 합니다" - exclusion: - of: "다음 중 하나가 아니어야 합니다: %{values}" - within: "%{min}과 %{max} 사이에 있으면 안 됩니다" - format: "잘못된 형식입니다" - inclusion: - of: "다음 중 하나여야 합니다: %{values}" - within: "%{min}과 %{max} 사이에 있어야 합니다" - length: - is: "길이는 %{is}여야 합니다" - is_not: "길이는 %{is_not}이 아니어야 합니다" - min: "길이는 최소 %{min}이어야 합니다" - max: "길이는 최대 %{max}여야 합니다" - not_within: "길이는 %{min}과 %{max} 사이에 있으면 안 됩니다" - within: "길이는 %{min}과 %{max} 사이에 있어야 합니다" - numeric: - is: "%{is}여야 합니다" - is_not: "%{is_not}이 아니어야 합니다" - min: "최소 %{min}이어야 합니다" - max: "최대 %{max}여야 합니다" - not_within: "%{min}과 %{max} 사이에 있으면 안 됩니다" - within: "%{min}과 %{max} 사이에 있어야 합니다" - presence: "비어있을 수 없습니다" diff --git a/lib/locales/lb.yml b/lib/locales/lb.yml deleted file mode 100644 index 3d6bb96b8..000000000 --- a/lib/locales/lb.yml +++ /dev/null @@ -1,55 +0,0 @@ -lb: - cmdx: - attributes: - required: "muss iwwer d'Quellmethod %{method} zougänglech sinn" - undefined: "delegéiert un onbestëmmte Methode %{method}" - coercions: - into_a: "konnt net an %{type} ëmwandelen" - into_an: "konnt net an %{type} ëmwandelen" - into_any: "konnt net an ee vun dësen ëmwandelen: %{types}" - unknown: "onbekannte %{type} Ëmwandlungstyp" - returns: - missing: "muss am Kontext gesat sinn" - faults: - invalid: "Ongëlteg" - unspecified: "Net spezifizéiert" - reasons: - unspecified: "Net spezifizéiert" - types: - array: "Array" - big_decimal: "Grouss Dezimal" - boolean: "Boolean" - complex: "Komplex" - date_time: "Datum an Zäit" - date: "Datum" - float: "Flottpunkt" - hash: "Hash" - integer: "Ganzzuel" - rational: "Rational" - string: "String" - symbol: "Symbol" - time: "Zäit" - validators: - absence: "muss eidel sinn" - exclusion: - of: "däerf net ee vun dësen sinn: %{values}" - within: "däerf net tëscht %{min} an %{max} sinn" - format: "ass e net valide Format" - inclusion: - of: "muss ee vun dësen sinn: %{values}" - within: "muss tëscht %{min} an %{max} sinn" - length: - is: "Längt muss %{is} sinn" - is_not: "Längt däerf net %{is_not} sinn" - min: "Längt muss mindestens %{min} sinn" - max: "Längt ka maximal %{max} sinn" - not_within: "Längt däerf net tëscht %{min} an %{max} sinn" - within: "Längt muss tëscht %{min} an %{max} sinn" - numeric: - is: "muss %{is} sinn" - is_not: "däerf net %{is_not} sinn" - min: "muss mindestens %{min} sinn" - max: "ka maximal %{max} sinn" - not_within: "däerf net tëscht %{min} an %{max} sinn" - within: "muss tëscht %{min} an %{max} sinn" - presence: "däerf net eidel sinn" diff --git a/lib/locales/lo.yml b/lib/locales/lo.yml deleted file mode 100644 index 290aecc61..000000000 --- a/lib/locales/lo.yml +++ /dev/null @@ -1,55 +0,0 @@ -lo: - cmdx: - attributes: - required: "ຕ້ອງສາມາດເຂົ້າເຖິງໄດ້ຜ່ານວິທີການແຫຼ່ງ %{method}" - undefined: "ມອບໝາຍໃຫ້ກັບວິທີທີ່ບໍ່ໄດ້ກຳນົດ %{method}" - coercions: - into_a: "ບໍ່ສາມາດປ່ຽນເປັນ %{type} ໄດ້" - into_an: "ບໍ່ສາມາດປ່ຽນເປັນ %{type} ໄດ້" - into_any: "ບໍ່ສາມາດປ່ຽນເປັນອັນໃດອັນໜຶ່ງໃນ: %{types} ໄດ້" - unknown: "ປະເພດການປ່ຽນ %{type} ທີ່ບໍ່ຮູ້" - returns: - missing: "ຕ້ອງຖືກກໍານົດໃນບໍລິບົດ" - faults: - invalid: "ບໍ່ຖືກຕ້ອງ" - unspecified: "ບໍ່ໄດ້ກຳນົດ" - reasons: - unspecified: "ບໍ່ໄດ້ກຳນົດ" - types: - array: "ອາເຣ" - big_decimal: "ທົດສະນິຍະສູງ" - boolean: "ບູລຽນ" - complex: "ສັບສົນ" - date_time: "ວັນທີ ແລະ ເວລາ" - date: "ວັນທີ" - float: "ຈຸດລອຍ" - hash: "ແຮຊ" - integer: "ຈຳນວນເຕັມ" - rational: "ຈຳນວນປົກກະຕິ" - string: "ສະຕຣິງ" - symbol: "ສັນຍະລັກ" - time: "ເວລາ" - validators: - absence: "ຕ້ອງເປັນຄ່າວ່າງ" - exclusion: - of: "ບໍ່ຕ້ອງເປັນອັນໃດອັນໜຶ່ງໃນ: %{values}" - within: "ບໍ່ຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - format: "ເປັນຮູບແບບທີ່ບໍ່ຖືກຕ້ອງ" - inclusion: - of: "ຕ້ອງເປັນອັນໃດອັນໜຶ່ງໃນ: %{values}" - within: "ຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - length: - is: "ຄວາມຍາວຕ້ອງເປັນ %{is}" - is_not: "ຄວາມຍາວບໍ່ຕ້ອງເປັນ %{is_not}" - min: "ຄວາມຍາວຕ້ອງຢ່າງໜ້ອຍ %{min}" - max: "ຄວາມຍາວສາມາດສູງສຸດ %{max}" - not_within: "ຄວາມຍາວບໍ່ຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - within: "ຄວາມຍາວຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - numeric: - is: "ຕ້ອງເປັນ %{is}" - is_not: "ບໍ່ຕ້ອງເປັນ %{is_not}" - min: "ຕ້ອງຢ່າງໜ້ອຍ %{min}" - max: "ສາມາດສູງສຸດ %{max}" - not_within: "ບໍ່ຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - within: "ຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - presence: "ບໍ່ສາມາດເປັນຄ່າວ່າງໄດ້" diff --git a/lib/locales/lt.yml b/lib/locales/lt.yml deleted file mode 100644 index cc2c54d8c..000000000 --- a/lib/locales/lt.yml +++ /dev/null @@ -1,55 +0,0 @@ -lt: - cmdx: - attributes: - required: "turi būti pasiekiamas per šaltinio metodą %{method}" - undefined: "deleguoja neapibrėžtą metodą %{method}" - coercions: - into_a: "nepavyko konvertuoti į %{type}" - into_an: "nepavyko konvertuoti į %{type}" - into_any: "nepavyko konvertuoti į vieną iš: %{types}" - unknown: "nežinomas %{type} konvertavimo tipas" - returns: - missing: "turi būti nustatyta kontekste" - faults: - invalid: "Neteisingi" - unspecified: "Nenurodyta" - reasons: - unspecified: "Nenurodyta" - types: - array: "masyvas" - big_decimal: "didelis dešimtainis" - boolean: "loginis" - complex: "kompleksinis" - date_time: "data ir laikas" - date: "data" - float: "slankusis kablelis" - hash: "maišos lentelė" - integer: "sveikasis skaičius" - rational: "racionalusis" - string: "eilutė" - symbol: "simbolis" - time: "laikas" - validators: - absence: "turi būti tuščias" - exclusion: - of: "neturi būti vienas iš: %{values}" - within: "neturi būti tarp %{min} ir %{max}" - format: "yra netinkamas formatas" - inclusion: - of: "turi būti vienas iš: %{values}" - within: "turi būti tarp %{min} ir %{max}" - length: - is: "ilgis turi būti %{is}" - is_not: "ilgis neturi būti %{is_not}" - min: "ilgis turi būti bent %{min}" - max: "ilgis gali būti daugiausiai %{max}" - not_within: "ilgis neturi būti tarp %{min} ir %{max}" - within: "ilgis turi būti tarp %{min} ir %{max}" - numeric: - is: "turi būti %{is}" - is_not: "neturi būti %{is_not}" - min: "turi būti bent %{min}" - max: "gali būti daugiausiai %{max}" - not_within: "neturi būti tarp %{min} ir %{max}" - within: "turi būti tarp %{min} ir %{max}" - presence: "negali būti tuščias" diff --git a/lib/locales/lv.yml b/lib/locales/lv.yml deleted file mode 100644 index e6519d3bd..000000000 --- a/lib/locales/lv.yml +++ /dev/null @@ -1,55 +0,0 @@ -lv: - cmdx: - attributes: - required: "jābūt pieejamam, izmantojot avota metodi %{method}" - undefined: "deleģē nedefinētai metodei %{method}" - coercions: - into_a: "nevarēja konvertēt uz %{type}" - into_an: "nevarēja konvertēt uz %{type}" - into_any: "nevarēja konvertēt uz vienu no: %{types}" - unknown: "nezināms %{type} konvertēšanas tips" - returns: - missing: "jābūt iestatītam kontekstā" - faults: - invalid: "Nederīgi" - unspecified: "Nav norādīts" - reasons: - unspecified: "Nav norādīts" - types: - array: "masīvs" - big_decimal: "liels decimāls" - boolean: "loģiskais" - complex: "komplekss" - date_time: "datums un laiks" - date: "datums" - float: "peldošā komata skaitlis" - hash: "hash" - integer: "vesels skaitlis" - rational: "racionāls" - string: "virkne" - symbol: "simbols" - time: "laiks" - validators: - absence: "jābūt tukšam" - exclusion: - of: "nedrīkst būt viens no: %{values}" - within: "nedrīkst būt starp %{min} un %{max}" - format: "ir nederīgs formāts" - inclusion: - of: "jābūt vienam no: %{values}" - within: "jābūt starp %{min} un %{max}" - length: - is: "garumam jābūt %{is}" - is_not: "garumam nedrīkst būt %{is_not}" - min: "garumam jābūt vismaz %{min}" - max: "garumam drīkst būt maksimāli %{max}" - not_within: "garumam nedrīkst būt starp %{min} un %{max}" - within: "garumam jābūt starp %{min} un %{max}" - numeric: - is: "jābūt %{is}" - is_not: "nedrīkst būt %{is_not}" - min: "jābūt vismaz %{min}" - max: "drīkst būt maksimāli %{max}" - not_within: "nedrīkst būt starp %{min} un %{max}" - within: "jābūt starp %{min} un %{max}" - presence: "nedrīkst būt tukšs" diff --git a/lib/locales/mg.yml b/lib/locales/mg.yml deleted file mode 100644 index e76e26537..000000000 --- a/lib/locales/mg.yml +++ /dev/null @@ -1,55 +0,0 @@ -mg: - cmdx: - attributes: - required: "tsy maintsy azo idirana amin'ny alalan'ny fomba loharano %{method}" - undefined: "ny delegasiona amin'ny fomba tsy voafaritra %{method}" - coercions: - into_a: "tsy afaka ovaina ho %{type}" - into_an: "tsy afaka ovaina ho %{type}" - into_any: "tsy afaka ovaina ho iray amin'ireo: %{types}" - unknown: "karazana fovana %{type} tsy fantatra" - returns: - missing: "tsy maintsy voatondro ao anatin ny contexte" - faults: - invalid: "Tsy mety" - unspecified: "Tsy voafaritra" - reasons: - unspecified: "Tsy voafaritra" - types: - array: "array" - big_decimal: "desimala lehibe" - boolean: "boolean" - complex: "sarotra" - date_time: "daty sy ora" - date: "daty" - float: "teboka mitsingevana" - hash: "hash" - integer: "isa feno" - rational: "isa ara-tsaina" - string: "string" - symbol: "mariky" - time: "ora" - validators: - absence: "tsy maintsy ho banga" - exclusion: - of: "tsy maintsy ho iray amin'ireo: %{values}" - within: "tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - format: "dia endrika tsy manan-kery" - inclusion: - of: "tsy maintsy ho iray amin'ireo: %{values}" - within: "tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - length: - is: "ny halavany tsy maintsy %{is}" - is_not: "ny halavany tsy maintsy %{is_not}" - min: "ny halavany tsy maintsy farafahakeliny %{min}" - max: "ny halavany afaka ho ambony indrindra %{max}" - not_within: "ny halavany tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - within: "ny halavany tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - numeric: - is: "tsy maintsy %{is}" - is_not: "tsy maintsy %{is_not}" - min: "tsy maintsy farafahakeliny %{min}" - max: "afaka ho ambony indrindra %{max}" - not_within: "tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - within: "tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - presence: "tsy afaka ho banga" diff --git a/lib/locales/mk.yml b/lib/locales/mk.yml deleted file mode 100644 index 70b702673..000000000 --- a/lib/locales/mk.yml +++ /dev/null @@ -1,55 +0,0 @@ -mk: - cmdx: - attributes: - required: "мора да биде достапно преку изворниот метод %{method}" - undefined: "делегира на недефиниран метод %{method}" - coercions: - into_a: "не можеше да се конвертира во %{type}" - into_an: "не можеше да се конвертира во %{type}" - into_any: "не можеше да се конвертира во еден од: %{types}" - unknown: "непознат тип на конверзија %{type}" - returns: - missing: "мора да биде поставено во контекстот" - faults: - invalid: "Невалидни" - unspecified: "Не е дадена" - reasons: - unspecified: "Не е дадена" - types: - array: "низа" - big_decimal: "голем децимален" - boolean: "логички" - complex: "комплексен" - date_time: "датум и време" - date: "датум" - float: "број со подвижна запирка" - hash: "хаш" - integer: "цел број" - rational: "рационален" - string: "низа" - symbol: "симбол" - time: "време" - validators: - absence: "мора да биде празно" - exclusion: - of: "не смее да биде еден од: %{values}" - within: "не смее да биде помеѓу %{min} и %{max}" - format: "е невалиден формат" - inclusion: - of: "мора да биде еден од: %{values}" - within: "мора да биде помеѓу %{min} и %{max}" - length: - is: "должината мора да биде %{is}" - is_not: "должината не смее да биде %{is_not}" - min: "должината мора да биде најмалку %{min}" - max: "должината може да биде најмногу %{max}" - not_within: "должината не смее да биде помеѓу %{min} и %{max}" - within: "должината мора да биде помеѓу %{min} и %{max}" - numeric: - is: "мора да биде %{is}" - is_not: "не смее да биде %{is_not}" - min: "мора да биде најмалку %{min}" - max: "може да биде најмногу %{max}" - not_within: "не смее да биде помеѓу %{min} и %{max}" - within: "мора да биде помеѓу %{min} и %{max}" - presence: "не смее да биде празно" diff --git a/lib/locales/ml.yml b/lib/locales/ml.yml deleted file mode 100644 index 0fc1ce192..000000000 --- a/lib/locales/ml.yml +++ /dev/null @@ -1,55 +0,0 @@ -ml: - cmdx: - attributes: - required: "%{method} സോഴ്സ് രീതി വഴി ആക്സസ് ചെയ്യാൻ കഴിയണം" - undefined: "നിർവചിക്കപ്പെടാത്ത മെത്തഡിലേക്ക് %{method} ഡെലഗേറ്റ് ചെയ്യുന്നു" - coercions: - into_a: "%{type} ആയി പരിവർത്തനം ചെയ്യാൻ കഴിയില്ല" - into_an: "%{type} ആയി പരിവർത്തനം ചെയ്യാൻ കഴിയില്ല" - into_any: "ഇനിപ്പറയുന്നവയിൽ ഒന്നായി പരിവർത്തനം ചെയ്യാൻ കഴിയില്ല: %{types}" - unknown: "അജ്ഞാതമായ %{type} പരിവർത്തന തരം" - returns: - missing: "സന്ദർഭത്തിൽ സജ്ജീകരിച്ചിരിക്കണം" - faults: - invalid: "അസാധുവായ" - unspecified: "നിർവചിക്കപ്പെടാത്ത" - reasons: - unspecified: "നിർവചിക്കപ്പെടാത്ത" - types: - array: "അറേ" - big_decimal: "വലിയ ദശാംശം" - boolean: "ബൂളിയൻ" - complex: "സങ്കീർണ്ണം" - date_time: "തീയതിയും സമയവും" - date: "തീയതി" - float: "ഫ്ലോട്ടിംഗ് പോയിന്റ്" - hash: "ഹാഷ്" - integer: "പൂർണ്ണസംഖ്യ" - rational: "ഭിന്നകം" - string: "സ്ട്രിംഗ്" - symbol: "ചിഹ്നം" - time: "സമയം" - validators: - absence: "ശൂന്യമായിരിക്കണം" - exclusion: - of: "ഇനിപ്പറയുന്നവയിൽ ഒന്നാകാൻ പാടില്ല: %{values}" - within: "%{min} ഉം %{max} ഉം തമ്മിൽ ആകാൻ പാടില്ല" - format: "അസാധുവായ ഫോർമാറ്റ് ആണ്" - inclusion: - of: "ഇനിപ്പറയുന്നവയിൽ ഒന്നായിരിക്കണം: %{values}" - within: "%{min} ഉം %{max} ഉം തമ്മിൽ ആയിരിക്കണം" - length: - is: "നീളം %{is} ആയിരിക്കണം" - is_not: "നീളം %{is_not} ആകാൻ പാടില്ല" - min: "നീളം കുറഞ്ഞത് %{min} ആയിരിക്കണം" - max: "നീളം കൂടിയത് %{max} ആകാം" - not_within: "നീളം %{min} ഉം %{max} ഉം തമ്മിൽ ആകാൻ പാടില്ല" - within: "നീളം %{min} ഉം %{max} ഉം തമ്മിൽ ആയിരിക്കണം" - numeric: - is: "%{is} ആയിരിക്കണം" - is_not: "%{is_not} ആകാൻ പാടില്ല" - min: "കുറഞ്ഞത് %{min} ആയിരിക്കണം" - max: "കൂടിയത് %{max} ആകാം" - not_within: "%{min} ഉം %{max} ഉം തമ്മിൽ ആകാൻ പാടില്ല" - within: "%{min} ഉം %{max} ഉം തമ്മിൽ ആയിരിക്കണം" - presence: "ശൂന്യമാകാൻ പാടില്ല" diff --git a/lib/locales/mn.yml b/lib/locales/mn.yml deleted file mode 100644 index b59e72742..000000000 --- a/lib/locales/mn.yml +++ /dev/null @@ -1,55 +0,0 @@ -mn: - cmdx: - attributes: - required: "%{method} эх сурвалжийн аргаар хандах боломжтой байх ёстой" - undefined: "тодорхойлогдоогүй аргад %{method} даалгавар өгнө" - coercions: - into_a: "%{type} болгон хөрвүүлэх боломжгүй байна" - into_an: "%{type} болгон хөрвүүлэх боломжгүй байна" - into_any: "дараах зүйлсийн аль нэг болгон хөрвүүлэх боломжгүй: %{types}" - unknown: "үл мэдэгдэх %{type} хөрвүүлэлтийн төрөл" - returns: - missing: "контекст дотор тохируулагдсан байх ёстой" - faults: - invalid: "Буруу" - unspecified: "Тодорхойлогдоогүй" - reasons: - unspecified: "Тодорхойлогдоогүй" - types: - array: "массив" - big_decimal: "том аравтын" - boolean: "булийн" - complex: "цогц" - date_time: "огноо ба цаг" - date: "огноо" - float: "хөвөгч цэг" - hash: "хэш" - integer: "бүхэл тоо" - rational: "рационал" - string: "мөр" - symbol: "тэмдэг" - time: "цаг" - validators: - absence: "хоосон байх ёстой" - exclusion: - of: "дараах зүйлсийн аль нэг байж болохгүй: %{values}" - within: "%{min} ба %{max} хооронд байж болохгүй" - format: "хүчингүй формат юм" - inclusion: - of: "дараах зүйлсийн аль нэг байх ёстой: %{values}" - within: "%{min} ба %{max} хооронд байх ёстой" - length: - is: "урт %{is} байх ёстой" - is_not: "урт %{is_not} байж болохгүй" - min: "урт хамгийн багадаа %{min} байх ёстой" - max: "урт хамгийн ихдээ %{max} байж болно" - not_within: "урт %{min} ба %{max} хооронд байж болохгүй" - within: "урт %{min} ба %{max} хооронд байх ёстой" - numeric: - is: "%{is} байх ёстой" - is_not: "%{is_not} байж болохгүй" - min: "хамгийн багадаа %{min} байх ёстой" - max: "хамгийн ихдээ %{max} байж болно" - not_within: "%{min} ба %{max} хооронд байж болохгүй" - within: "%{min} ба %{max} хооронд байх ёстой" - presence: "хоосон байж болохгүй" diff --git a/lib/locales/mr-IN.yml b/lib/locales/mr-IN.yml deleted file mode 100644 index 92448596b..000000000 --- a/lib/locales/mr-IN.yml +++ /dev/null @@ -1,55 +0,0 @@ -mr-IN: - cmdx: - attributes: - required: "%{method} स्रोत पद्धतीद्वारे प्रवेश करण्यायोग्य असणे आवश्यक आहे" - undefined: "अपरिभाषित पद्धतीला %{method} नियुक्त करते" - coercions: - into_a: "%{type} मध्ये रूपांतर करता आले नाही" - into_an: "%{type} मध्ये रूपांतर करता आले नाही" - into_any: "खालीलपैकी एकामध्ये रूपांतर करता आले नाही: %{types}" - unknown: "अज्ञात %{type} रूपांतरण प्रकार" - returns: - missing: "संदर्भात सेट केले पाहिजे" - faults: - invalid: "अवैध" - unspecified: "निर्दिष्ट नाही" - reasons: - unspecified: "निर्दिष्ट नाही" - types: - array: "अॅरे" - big_decimal: "मोठे दशांश" - boolean: "बुलियन" - complex: "जटिल" - date_time: "तारीख आणि वेळ" - date: "तारीख" - float: "फ्लोटिंग पॉइंट" - hash: "हॅश" - integer: "पूर्णांक" - rational: "परिमेय" - string: "स्ट्रिंग" - symbol: "चिन्ह" - time: "वेळ" - validators: - absence: "रिक्त असणे आवश्यक आहे" - exclusion: - of: "खालीलपैकी एक असू शकत नाही: %{values}" - within: "%{min} आणि %{max} दरम्यान असू शकत नाही" - format: "अवैध स्वरूप आहे" - inclusion: - of: "खालीलपैकी एक असणे आवश्यक आहे: %{values}" - within: "%{min} आणि %{max} दरम्यान असणे आवश्यक आहे" - length: - is: "लांबी %{is} असणे आवश्यक आहे" - is_not: "लांबी %{is_not} असू शकत नाही" - min: "लांबी किमान %{min} असणे आवश्यक आहे" - max: "लांबी कमाल %{max} असू शकते" - not_within: "लांबी %{min} आणि %{max} दरम्यान असू शकत नाही" - within: "लांबी %{min} आणि %{max} दरम्यान असणे आवश्यक आहे" - numeric: - is: "%{is} असणे आवश्यक आहे" - is_not: "%{is_not} असू शकत नाही" - min: "किमान %{min} असणे आवश्यक आहे" - max: "कमाल %{max} असू शकते" - not_within: "%{min} आणि %{max} दरम्यान असू शकत नाही" - within: "%{min} आणि %{max} दरम्यान असणे आवश्यक आहे" - presence: "रिक्त असू शकत नाही" diff --git a/lib/locales/ms.yml b/lib/locales/ms.yml deleted file mode 100644 index 421f04987..000000000 --- a/lib/locales/ms.yml +++ /dev/null @@ -1,55 +0,0 @@ -ms: - cmdx: - attributes: - required: "mesti boleh diakses melalui kaedah sumber %{method}" - undefined: "delegasi kepada kaedah yang tidak ditakrifkan %{method}" - coercions: - into_a: "tidak boleh ditukar kepada %{type}" - into_an: "tidak boleh ditukar kepada %{type}" - into_any: "tidak boleh ditukar kepada salah satu daripada: %{types}" - unknown: "jenis penukaran %{type} yang tidak diketahui" - returns: - missing: "mesti ditetapkan dalam konteks" - faults: - invalid: "Tidak sah" - unspecified: "Tidak ditentukan" - reasons: - unspecified: "Tidak ditentukan" - types: - array: "array" - big_decimal: "perpuluhan besar" - boolean: "boolean" - complex: "kompleks" - date_time: "tarikh dan masa" - date: "tarikh" - float: "titik terapung" - hash: "hash" - integer: "integer" - rational: "rasional" - string: "rentetan" - symbol: "simbol" - time: "masa" - validators: - absence: "mesti kosong" - exclusion: - of: "tidak boleh menjadi salah satu daripada: %{values}" - within: "tidak boleh berada di antara %{min} dan %{max}" - format: "adalah format yang tidak sah" - inclusion: - of: "mesti menjadi salah satu daripada: %{values}" - within: "mesti berada di antara %{min} dan %{max}" - length: - is: "panjang mesti %{is}" - is_not: "panjang tidak boleh %{is_not}" - min: "panjang mesti sekurang-kurangnya %{min}" - max: "panjang boleh paling banyak %{max}" - not_within: "panjang tidak boleh berada di antara %{min} dan %{max}" - within: "panjang mesti berada di antara %{min} dan %{max}" - numeric: - is: "mesti %{is}" - is_not: "tidak boleh %{is_not}" - min: "mesti sekurang-kurangnya %{min}" - max: "boleh paling banyak %{max}" - not_within: "tidak boleh berada di antara %{min} dan %{max}" - within: "mesti berada di antara %{min} dan %{max}" - presence: "tidak boleh kosong" diff --git a/lib/locales/nb.yml b/lib/locales/nb.yml deleted file mode 100644 index 901ce4d3b..000000000 --- a/lib/locales/nb.yml +++ /dev/null @@ -1,55 +0,0 @@ -nb: - cmdx: - attributes: - required: "må være tilgjengelig via kildemetoden %{method}" - undefined: "delegerer til udefinert metode %{method}" - coercions: - into_a: "kunne ikke konverteres til %{type}" - into_an: "kunne ikke konverteres til %{type}" - into_any: "kunne ikke konverteres til en av: %{types}" - unknown: "ukjent %{type} konverteringstype" - returns: - missing: "må være satt i konteksten" - faults: - invalid: "Ugyldig" - unspecified: "Uspesifisert" - reasons: - unspecified: "Uspesifisert" - types: - array: "array" - big_decimal: "stort desimaltall" - boolean: "boolsk" - complex: "kompleks" - date_time: "dato og tid" - date: "dato" - float: "flyttall" - hash: "hash" - integer: "heltall" - rational: "rasjonalt" - string: "streng" - symbol: "symbol" - time: "tid" - validators: - absence: "må være tom" - exclusion: - of: "må ikke være en av: %{values}" - within: "må ikke være mellom %{min} og %{max}" - format: "er et ugyldig format" - inclusion: - of: "må være en av: %{values}" - within: "må være mellom %{min} og %{max}" - length: - is: "lengden må være %{is}" - is_not: "lengden må ikke være %{is_not}" - min: "lengden må være minst %{min}" - max: "lengden må høyst være %{max}" - not_within: "lengden må ikke være mellom %{min} og %{max}" - within: "lengden må være mellom %{min} og %{max}" - numeric: - is: "må være %{is}" - is_not: "må ikke være %{is_not}" - min: "må være minst %{min}" - max: "må høyst være %{max}" - not_within: "må ikke være mellom %{min} og %{max}" - within: "må være mellom %{min} og %{max}" - presence: "må ikke være tom" diff --git a/lib/locales/ne.yml b/lib/locales/ne.yml deleted file mode 100644 index 39ef4ec8f..000000000 --- a/lib/locales/ne.yml +++ /dev/null @@ -1,55 +0,0 @@ -ne: - cmdx: - attributes: - required: "%{method} स्रोत विधि मार्फत पहुँचयोग्य हुनुपर्छ" - undefined: "अपरिभाषित विधिलाई %{method} नियुक्त गर्दछ" - coercions: - into_a: "%{type} मा रूपान्तरण गर्न सकिएन" - into_an: "%{type} मा रूपान्तरण गर्न सकिएन" - into_any: "निम्न मध्ये एउटामा रूपान्तरण गर्न सकिएन: %{types}" - unknown: "अज्ञात %{type} रूपान्तरण प्रकार" - returns: - missing: "सन्दर्भमा सेट गरिएको हुनुपर्छ" - faults: - invalid: "अवैध" - unspecified: "निर्दिष्ट नभएको" - reasons: - unspecified: "निर्दिष्ट नभएको" - types: - array: "एरे" - big_decimal: "ठूलो दशमलव" - boolean: "बुलियन" - complex: "जटिल" - date_time: "मिति र समय" - date: "मिति" - float: "फ्लोटिङ पोइन्ट" - hash: "ह्यास" - integer: "पूर्णांक" - rational: "परिमेय" - string: "स्ट्रिङ" - symbol: "प्रतीक" - time: "समय" - validators: - absence: "खाली हुनुपर्छ" - exclusion: - of: "निम्न मध्ये एउटा हुन सक्दैन: %{values}" - within: "%{min} र %{max} बीचमा हुन सक्दैन" - format: "अमान्य फारम्याट हो" - inclusion: - of: "निम्न मध्ये एउटा हुनुपर्छ: %{values}" - within: "%{min} र %{max} बीचमा हुनुपर्छ" - length: - is: "लम्बाइ %{is} हुनुपर्छ" - is_not: "लम्बाइ %{is_not} हुन सक्दैन" - min: "लम्बाइ कम्तिमा %{min} हुनुपर्छ" - max: "लम्बाइ अधिकतम %{max} हुन सक्छ" - not_within: "लम्बाइ %{min} र %{max} बीचमा हुन सक्दैन" - within: "लम्बाइ %{min} र %{max} बीचमा हुनुपर्छ" - numeric: - is: "%{is} हुनुपर्छ" - is_not: "%{is_not} हुन सक्दैन" - min: "कम्तिमा %{min} हुनुपर्छ" - max: "अधिकतम %{max} हुन सक्छ" - not_within: "%{min} र %{max} बीचमा हुन सक्दैन" - within: "%{min} र %{max} बीचमा हुनुपर्छ" - presence: "खाली हुन सक्दैन" diff --git a/lib/locales/nl.yml b/lib/locales/nl.yml deleted file mode 100644 index c63d4f0bb..000000000 --- a/lib/locales/nl.yml +++ /dev/null @@ -1,55 +0,0 @@ -nl: - cmdx: - attributes: - required: "moet toegankelijk zijn via de bronmethode %{method}" - undefined: "delegeert naar ongedefinieerde methode %{method}" - coercions: - into_a: "kon niet worden geconverteerd naar %{type}" - into_an: "kon niet worden geconverteerd naar %{type}" - into_any: "kon niet worden geconverteerd naar een van: %{types}" - unknown: "onbekend %{type} conversietype" - returns: - missing: "moet ingesteld zijn in de context" - faults: - invalid: "Ongeldig" - unspecified: "Niet gespecificeerd" - reasons: - unspecified: "Niet gespecificeerd" - types: - array: "array" - big_decimal: "grote decimaal" - boolean: "booleaans" - complex: "complex" - date_time: "datum en tijd" - date: "datum" - float: "zwevend punt" - hash: "hash" - integer: "geheel getal" - rational: "rationaal" - string: "tekenreeks" - symbol: "symbool" - time: "tijd" - validators: - absence: "moet leeg zijn" - exclusion: - of: "mag niet een van zijn: %{values}" - within: "mag niet tussen %{min} en %{max} liggen" - format: "is een ongeldig formaat" - inclusion: - of: "moet een van zijn: %{values}" - within: "moet tussen %{min} en %{max} liggen" - length: - is: "lengte moet %{is} zijn" - is_not: "lengte mag niet %{is_not} zijn" - min: "lengte moet minimaal %{min} zijn" - max: "lengte mag maximaal %{max} zijn" - not_within: "lengte mag niet tussen %{min} en %{max} liggen" - within: "lengte moet tussen %{min} en %{max} liggen" - numeric: - is: "moet %{is} zijn" - is_not: "mag niet %{is_not} zijn" - min: "moet minimaal %{min} zijn" - max: "mag maximaal %{max} zijn" - not_within: "mag niet tussen %{min} en %{max} liggen" - within: "moet tussen %{min} en %{max} liggen" - presence: "mag niet leeg zijn" diff --git a/lib/locales/nn.yml b/lib/locales/nn.yml deleted file mode 100644 index bd70f81e2..000000000 --- a/lib/locales/nn.yml +++ /dev/null @@ -1,55 +0,0 @@ -nn: - cmdx: - attributes: - required: "må vere tilgjengeleg via kjeldemetoden %{method}" - undefined: "delegerer til udefinert metode %{method}" - coercions: - into_a: "kunne ikkje konvertere til %{type}" - into_an: "kunne ikkje konvertere til %{type}" - into_any: "kunne ikkje konvertere til ein av: %{types}" - unknown: "ukjend %{type} konverteringstype" - returns: - missing: "må vere sett i konteksten" - faults: - invalid: "Ugyldig" - unspecified: "Uspesifisert" - reasons: - unspecified: "Uspesifisert" - types: - array: "array" - big_decimal: "stor desimal" - boolean: "boolean" - complex: "kompleks" - date_time: "dato og tid" - date: "dato" - float: "flyttal" - hash: "hash" - integer: "heiltal" - rational: "rasjonal" - string: "streng" - symbol: "symbol" - time: "tid" - validators: - absence: "må vere tom" - exclusion: - of: "må ikkje vere ein av: %{values}" - within: "må ikkje vere innanfor %{min} og %{max}" - format: "er eit ugyldig format" - inclusion: - of: "må vere ein av: %{values}" - within: "må vere innanfor %{min} og %{max}" - length: - is: "lengda må vere %{is}" - is_not: "lengda må ikkje vere %{is_not}" - min: "lengda må vere minst %{min}" - max: "lengda kan vere maksimalt %{max}" - not_within: "lengda må ikkje vere innanfor %{min} og %{max}" - within: "lengda må vere innanfor %{min} og %{max}" - numeric: - is: "må vere %{is}" - is_not: "må ikkje vere %{is_not}" - min: "må vere minst %{min}" - max: "kan vere maksimalt %{max}" - not_within: "må ikkje vere innanfor %{min} og %{max}" - within: "må vere innanfor %{min} og %{max}" - presence: "kan ikkje vere tom" diff --git a/lib/locales/oc.yml b/lib/locales/oc.yml deleted file mode 100644 index fa683b566..000000000 --- a/lib/locales/oc.yml +++ /dev/null @@ -1,55 +0,0 @@ -oc: - cmdx: - attributes: - required: "deu èsser accessible via lo metòde font %{method}" - undefined: "delegar a la metòda non definida %{method}" - coercions: - into_a: "poguèt pas èsser convertit en %{type}" - into_an: "poguèt pas èsser convertit en %{type}" - into_any: "poguèt pas èsser convertit en un de: %{types}" - unknown: "tipe de conversion %{type} desconegut" - returns: - missing: "deu èsser definit dins lo contèxt" - faults: - invalid: "Invalidas" - unspecified: "Non especificada" - reasons: - unspecified: "Non especificada" - types: - array: "array" - big_decimal: "decimal grand" - boolean: "booleen" - complex: "complexe" - date_time: "data e ora" - date: "data" - float: "punt flotant" - hash: "hash" - integer: "entier" - rational: "racional" - string: "cadena" - symbol: "simbol" - time: "ora" - validators: - absence: "deu èsser void" - exclusion: - of: "deu pas èsser un de: %{values}" - within: "deu pas èsser entre %{min} e %{max}" - format: "es un format invalid" - inclusion: - of: "deu èsser un de: %{values}" - within: "deu èsser entre %{min} e %{max}" - length: - is: "la longor deu èsser %{is}" - is_not: "la longor deu pas èsser %{is_not}" - min: "la longor deu èsser almens %{min}" - max: "la longor pòt èsser al màger %{max}" - not_within: "la longor deu pas èsser entre %{min} e %{max}" - within: "la longor deu èsser entre %{min} e %{max}" - numeric: - is: "deu èsser %{is}" - is_not: "deu pas èsser %{is_not}" - min: "deu èsser almens %{min}" - max: "pòt èsser al màger %{max}" - not_within: "deu pas èsser entre %{min} e %{max}" - within: "deu èsser entre %{min} e %{max}" - presence: "pòt pas èsser void" diff --git a/lib/locales/or.yml b/lib/locales/or.yml deleted file mode 100644 index cfe3b21c6..000000000 --- a/lib/locales/or.yml +++ /dev/null @@ -1,55 +0,0 @@ -or: - cmdx: - attributes: - required: "%{method} ଉତ୍ସ ପଦ୍ଧତି ମାଧ୍ୟମରେ ଉପଲବ୍ଧ ହେବା ଆବଶ୍ୟକ" - undefined: "ଅପରିଭାଷିତ ପଦ୍ଧତିକୁ %{method} ନିୟୋଜିତ କରେ" - coercions: - into_a: "%{type} ରେ ପରିବର୍ତ୍ତନ କରି ପାରିଲା ନାହିଁ" - into_an: "%{type} ରେ ପରିବର୍ତ୍ତନ କରି ପାରିଲା ନାହିଁ" - into_any: "ନିମ୍ନଲିଖିତ ମଧ୍ୟରୁ ଗୋଟିଏରେ ପରିବର୍ତ୍ତନ କରି ପାରିଲା ନାହିଁ: %{types}" - unknown: "ଅଜ୍ଞାତ %{type} ପରିବର୍ତ୍ତନ ପ୍ରକାର" - returns: - missing: "ପ୍ରସଙ୍ଗରେ ସେଟ୍ ହୋଇଥିବା ଆବଶ୍ୟକ" - faults: - invalid: "ଅବୈଧ" - unspecified: "ଅନିର୍ଦ୍ଦିଷ୍ଟ" - reasons: - unspecified: "ଅନିର୍ଦ୍ଦିଷ୍ଟ" - types: - array: "ଏରେ" - big_decimal: "ବଡ଼ ଦଶମିକ" - boolean: "ବୁଲିଆନ" - complex: "ଜଟିଳ" - date_time: "ତାରିଖ ଏବଂ ସମୟ" - date: "ତାରିଖ" - float: "ଫ୍ଲୋଟିଂ ପଏଣ୍ଟ" - hash: "ହାସ" - integer: "ପୂର୍ଣ୍ଣସଂଖ୍ୟା" - rational: "ପରିମେୟ" - string: "ସ୍ତ୍ରିଂ" - symbol: "ପ୍ରତୀକ" - time: "ସମୟ" - validators: - absence: "ଖାଲି ହୋଇଥିବା ଆବଶ୍ୟକ" - exclusion: - of: "ନିମ୍ନଲିଖିତ ମଧ୍ୟରୁ ଗୋଟିଏ ହୋଇପାରିବ ନାହିଁ: %{values}" - within: "%{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇପାରିବ ନାହିଁ" - format: "ଏକ ଅବୈଧ ଫର୍ମାଟ" - inclusion: - of: "ନିମ୍ନଲିଖିତ ମଧ୍ୟରୁ ଗୋଟିଏ ହୋଇଥିବା ଆବଶ୍ୟକ: %{values}" - within: "%{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇଥିବା ଆବଶ୍ୟକ" - length: - is: "ଦୈର୍ଘ୍ୟ %{is} ହୋଇଥିବା ଆବଶ୍ୟକ" - is_not: "ଦୈର୍ଘ୍ୟ %{is_not} ହୋଇପାରିବ ନାହିଁ" - min: "ଦୈର୍ଘ୍ୟ ଅତିକମ %{min} ହୋଇଥିବା ଆବଶ୍ୟକ" - max: "ଦୈର୍ଘ୍ୟ ସର୍ବାଧିକ %{max} ହୋଇପାରିବ" - not_within: "ଦୈର୍ଘ୍ୟ %{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇପାରିବ ନାହିଁ" - within: "ଦୈର୍ଘ୍ୟ %{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇଥିବା ଆବଶ୍ୟକ" - numeric: - is: "%{is} ହୋଇଥିବା ଆବଶ୍ୟକ" - is_not: "%{is_not} ହୋଇପାରିବ ନାହିଁ" - min: "ଅତିକମ %{min} ହୋଇଥିବା ଆବଶ୍ୟକ" - max: "ସର୍ବାଧିକ %{max} ହୋଇପାରିବ" - not_within: "%{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇପାରିବ ନାହିଁ" - within: "%{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇଥିବା ଆବଶ୍ୟକ" - presence: "ଖାଲି ହୋଇପାରିବ ନାହିଁ" diff --git a/lib/locales/pa.yml b/lib/locales/pa.yml deleted file mode 100644 index c1da4f027..000000000 --- a/lib/locales/pa.yml +++ /dev/null @@ -1,55 +0,0 @@ -pa: - cmdx: - attributes: - required: "%{method} ਸਰੋਤ ਵਿਧੀ ਰਾਹੀਂ ਪਹੁੰਚਯੋਗ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - undefined: "ਅਪਰਿਭਾਸ਼ਿਤ ਵਿਧੀ ਨੂੰ %{method} ਨਿਰਧਾਰਤ ਕਰਦਾ ਹੈ" - coercions: - into_a: "%{type} ਵਿੱਚ ਬਦਲਿਆ ਨਹੀਂ ਜਾ ਸਕਿਆ" - into_an: "%{type} ਵਿੱਚ ਬਦਲਿਆ ਨਹੀਂ ਜਾ ਸਕਿਆ" - into_any: "ਹੇਠਾਂ ਦਿੱਤੇ ਗਏ ਵਿੱਚੋਂ ਕਿਸੇ ਇੱਕ ਵਿੱਚ ਬਦਲਿਆ ਨਹੀਂ ਜਾ ਸਕਿਆ: %{types}" - unknown: "ਅਣਜਾਣ %{type} ਬਦਲਾਅ ਕਿਸਮ" - returns: - missing: "ਸੰਦਰਭ ਵਿੱਚ ਸੈੱਟ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - faults: - invalid: "ਅਵੈਧ" - unspecified: "ਅਨਿਰਧਾਰਿਤ" - reasons: - unspecified: "ਅਨਿਰਧਾਰਿਤ" - types: - array: "ਐਰੇ" - big_decimal: "ਵੱਡਾ ਦਸ਼ਮਲਵ" - boolean: "ਬੂਲੀਅਨ" - complex: "ਜਟਿਲ" - date_time: "ਤਾਰੀਖ ਅਤੇ ਸਮਾਂ" - date: "ਤਾਰੀਖ" - float: "ਫਲੋਟਿੰਗ ਪੁਆਇੰਟ" - hash: "ਹੈਸ਼" - integer: "ਪੂਰਨ ਅੰਕ" - rational: "ਪਰਿਮੇਯ" - string: "ਸਟ੍ਰਿੰਗ" - symbol: "ਚਿੰਨ੍ਹ" - time: "ਸਮਾਂ" - validators: - absence: "ਖਾਲੀ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - exclusion: - of: "ਹੇਠਾਂ ਦਿੱਤੇ ਗਏ ਵਿੱਚੋਂ ਕੋਈ ਇੱਕ ਨਹੀਂ ਹੋ ਸਕਦਾ: %{values}" - within: "%{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਨਹੀਂ ਹੋ ਸਕਦਾ" - format: "ਇੱਕ ਅਵੈਧ ਫਾਰਮੈਟ ਹੈ" - inclusion: - of: "ਹੇਠਾਂ ਦਿੱਤੇ ਗਏ ਵਿੱਚੋਂ ਕੋਈ ਇੱਕ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ: %{values}" - within: "%{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - length: - is: "ਲੰਬਾਈ %{is} ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ" - is_not: "ਲੰਬਾਈ %{is_not} ਨਹੀਂ ਹੋ ਸਕਦੀ" - min: "ਲੰਬਾਈ ਘੱਟੋ ਘੱਟ %{min} ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ" - max: "ਲੰਬਾਈ ਵੱਧ ਤੋਂ ਵੱਧ %{max} ਹੋ ਸਕਦੀ ਹੈ" - not_within: "ਲੰਬਾਈ %{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਨਹੀਂ ਹੋ ਸਕਦੀ" - within: "ਲੰਬਾਈ %{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ" - numeric: - is: "%{is} ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - is_not: "%{is_not} ਨਹੀਂ ਹੋ ਸਕਦਾ" - min: "ਘੱਟੋ ਘੱਟ %{min} ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - max: "ਵੱਧ ਤੋਂ ਵੱਧ %{max} ਹੋ ਸਕਦਾ ਹੈ" - not_within: "%{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਨਹੀਂ ਹੋ ਸਕਦਾ" - within: "%{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - presence: "ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ" diff --git a/lib/locales/pl.yml b/lib/locales/pl.yml deleted file mode 100644 index 014d19d5e..000000000 --- a/lib/locales/pl.yml +++ /dev/null @@ -1,55 +0,0 @@ -pl: - cmdx: - attributes: - required: "musi być dostępny przez metodę źródłową %{method}" - undefined: "deleguje do niezdefiniowanej metody %{method}" - coercions: - into_a: "nie można przekonwertować na %{type}" - into_an: "nie można przekonwertować na %{type}" - into_any: "nie można przekonwertować na jeden z: %{types}" - unknown: "nieznany typ konwersji %{type}" - returns: - missing: "musi być ustawione w kontekście" - faults: - invalid: "Nieprawidłowe" - unspecified: "Nieokreślone" - reasons: - unspecified: "Nieokreślone" - types: - array: "tablica" - big_decimal: "duża liczba dziesiętna" - boolean: "logiczny" - complex: "zespolony" - date_time: "data i czas" - date: "data" - float: "liczba zmiennoprzecinkowa" - hash: "hash" - integer: "liczba całkowita" - rational: "wymierny" - string: "ciąg znaków" - symbol: "symbol" - time: "czas" - validators: - absence: "musi być puste" - exclusion: - of: "nie może być jednym z: %{values}" - within: "nie może być między %{min} a %{max}" - format: "jest nieprawidłowym formatem" - inclusion: - of: "musi być jednym z: %{values}" - within: "musi być między %{min} a %{max}" - length: - is: "długość musi być %{is}" - is_not: "długość nie może być %{is_not}" - min: "długość musi być co najmniej %{min}" - max: "długość może być co najwyżej %{max}" - not_within: "długość nie może być między %{min} a %{max}" - within: "długość musi być między %{min} a %{max}" - numeric: - is: "musi być %{is}" - is_not: "nie może być %{is_not}" - min: "musi być co najmniej %{min}" - max: "może być co najwyżej %{max}" - not_within: "nie może być między %{min} a %{max}" - within: "musi być między %{min} a %{max}" - presence: "nie może być puste" diff --git a/lib/locales/pt.yml b/lib/locales/pt.yml deleted file mode 100644 index e6fd08631..000000000 --- a/lib/locales/pt.yml +++ /dev/null @@ -1,55 +0,0 @@ -pt: - cmdx: - attributes: - required: "deve ser acessível através do método de origem %{method}" - undefined: "delega para método não definido %{method}" - coercions: - into_a: "não foi possível converter em %{type}" - into_an: "não foi possível converter em %{type}" - into_any: "não foi possível converter em um de: %{types}" - unknown: "tipo de conversão %{type} desconhecido" - returns: - missing: "deve estar definido no contexto" - faults: - invalid: "Inválidas" - unspecified: "Não especificada" - reasons: - unspecified: "Não especificada" - types: - array: "array" - big_decimal: "decimal grande" - boolean: "booleano" - complex: "complexo" - date_time: "data e hora" - date: "data" - float: "ponto flutuante" - hash: "hash" - integer: "inteiro" - rational: "racional" - string: "string" - symbol: "símbolo" - time: "tempo" - validators: - absence: "deve estar vazio" - exclusion: - of: "não deve ser um de: %{values}" - within: "não deve estar entre %{min} e %{max}" - format: "é um formato inválido" - inclusion: - of: "deve ser um de: %{values}" - within: "deve estar entre %{min} e %{max}" - length: - is: "o comprimento deve ser %{is}" - is_not: "o comprimento não deve ser %{is_not}" - min: "o comprimento deve ser pelo menos %{min}" - max: "o comprimento deve ser no máximo %{max}" - not_within: "o comprimento não deve estar entre %{min} e %{max}" - within: "o comprimento deve estar entre %{min} e %{max}" - numeric: - is: "deve ser %{is}" - is_not: "não deve ser %{is_not}" - min: "deve ser pelo menos %{min}" - max: "deve ser no máximo %{max}" - not_within: "não deve estar entre %{min} e %{max}" - within: "deve estar entre %{min} e %{max}" - presence: "não pode estar vazio" diff --git a/lib/locales/rm.yml b/lib/locales/rm.yml deleted file mode 100644 index 7413a4b0e..000000000 --- a/lib/locales/rm.yml +++ /dev/null @@ -1,55 +0,0 @@ -rm: - cmdx: - attributes: - required: "stue esser accessibel via la metoda da funtauna %{method}" - undefined: "delega a la metoda betg definida %{method}" - coercions: - into_a: "na pudeva betg vegnir convertì en %{type}" - into_an: "na pudeva betg vegnir convertì en %{type}" - into_any: "na pudeva betg vegnir convertì en in da: %{types}" - unknown: "tip da conversiun %{type} nunenconuschent" - returns: - missing: "sto esser definì en il context" - faults: - invalid: "Nunvalidas" - unspecified: "Betg specificà" - reasons: - unspecified: "Betg specificà" - types: - array: "array" - big_decimal: "decimal grond" - boolean: "booleic" - complex: "complex" - date_time: "data ed ura" - date: "data" - float: "numer cun punt flotant" - hash: "hash" - integer: "entir" - rational: "razional" - string: "chadaina" - symbol: "simbol" - time: "ura" - validators: - absence: "stoscha esser vid" - exclusion: - of: "na dastga betg esser in da: %{values}" - within: "na dastga betg esser tranter %{min} e %{max}" - format: "è in format nunvalid" - inclusion: - of: "stoscha esser in da: %{values}" - within: "stoscha esser tranter %{min} e %{max}" - length: - is: "la lunghezza stoscha esser %{is}" - is_not: "la lunghezza na dastga betg esser %{is_not}" - min: "la lunghezza stoscha esser almain %{min}" - max: "la lunghezza po esser al pli %{max}" - not_within: "la lunghezza na dastga betg esser tranter %{min} e %{max}" - within: "la lunghezza stoscha esser tranter %{min} e %{max}" - numeric: - is: "stoscha esser %{is}" - is_not: "na dastga betg esser %{is_not}" - min: "stoscha esser almain %{min}" - max: "po esser al pli %{max}" - not_within: "na dastga betg esser tranter %{min} e %{max}" - within: "stoscha esser tranter %{min} e %{max}" - presence: "na po betg esser vid" diff --git a/lib/locales/ro.yml b/lib/locales/ro.yml deleted file mode 100644 index bb548215c..000000000 --- a/lib/locales/ro.yml +++ /dev/null @@ -1,55 +0,0 @@ -ro: - cmdx: - attributes: - required: "trebuie să fie accesibil prin metoda sursă %{method}" - undefined: "delegează la metoda nedefinită %{method}" - coercions: - into_a: "nu a putut fi convertit în %{type}" - into_an: "nu a putut fi convertit în %{type}" - into_any: "nu a putut fi convertit în unul din: %{types}" - unknown: "tip de conversie %{type} necunoscut" - returns: - missing: "trebuie setat în context" - faults: - invalid: "Invalide" - unspecified: "Nespecificat" - reasons: - unspecified: "Nespecificat" - types: - array: "array" - big_decimal: "decimal mare" - boolean: "boolean" - complex: "complex" - date_time: "dată și oră" - date: "dată" - float: "număr cu virgulă mobilă" - hash: "hash" - integer: "întreg" - rational: "rațional" - string: "șir" - symbol: "simbol" - time: "timp" - validators: - absence: "trebuie să fie gol" - exclusion: - of: "nu trebuie să fie unul din: %{values}" - within: "nu trebuie să fie între %{min} și %{max}" - format: "este un format invalid" - inclusion: - of: "trebuie să fie unul din: %{values}" - within: "trebuie să fie între %{min} și %{max}" - length: - is: "lungimea trebuie să fie %{is}" - is_not: "lungimea nu trebuie să fie %{is_not}" - min: "lungimea trebuie să fie cel puțin %{min}" - max: "lungimea trebuie să fie cel mult %{max}" - not_within: "lungimea nu trebuie să fie între %{min} și %{max}" - within: "lungimea trebuie să fie între %{min} și %{max}" - numeric: - is: "trebuie să fie %{is}" - is_not: "nu trebuie să fie %{is_not}" - min: "trebuie să fie cel puțin %{min}" - max: "trebuie să fie cel mult %{max}" - not_within: "nu trebuie să fie între %{min} și %{max}" - within: "trebuie să fie între %{min} și %{max}" - presence: "nu poate fi gol" diff --git a/lib/locales/ru.yml b/lib/locales/ru.yml deleted file mode 100644 index ba74f01de..000000000 --- a/lib/locales/ru.yml +++ /dev/null @@ -1,55 +0,0 @@ -ru: - cmdx: - attributes: - required: "должно быть доступно через исходный метод %{method}" - undefined: "делегирует неопределенному методу %{method}" - coercions: - into_a: "не удалось преобразовать в %{type}" - into_an: "не удалось преобразовать в %{type}" - into_any: "не удалось преобразовать в один из: %{types}" - unknown: "неизвестный тип преобразования %{type}" - returns: - missing: "должно быть установлено в контексте" - faults: - invalid: "Неверные" - unspecified: "Не указано" - reasons: - unspecified: "Не указано" - types: - array: "массив" - big_decimal: "большое десятичное число" - boolean: "логический" - complex: "комплексный" - date_time: "дата и время" - date: "дата" - float: "число с плавающей точкой" - hash: "хэш" - integer: "целое число" - rational: "рациональное" - string: "строка" - symbol: "символ" - time: "время" - validators: - absence: "должен быть пустым" - exclusion: - of: "не должен быть одним из: %{values}" - within: "не должен быть между %{min} и %{max}" - format: "неверный формат" - inclusion: - of: "должен быть одним из: %{values}" - within: "должен быть между %{min} и %{max}" - length: - is: "длина должна быть %{is}" - is_not: "длина не должна быть %{is_not}" - min: "длина должна быть не менее %{min}" - max: "длина должна быть не более %{max}" - not_within: "длина не должна быть между %{min} и %{max}" - within: "длина должна быть между %{min} и %{max}" - numeric: - is: "должен быть %{is}" - is_not: "не должен быть %{is_not}" - min: "должен быть не менее %{min}" - max: "должен быть не более %{max}" - not_within: "не должен быть между %{min} и %{max}" - within: "должен быть между %{min} и %{max}" - presence: "не может быть пустым" diff --git a/lib/locales/sc.yml b/lib/locales/sc.yml deleted file mode 100644 index 2ada6cc7e..000000000 --- a/lib/locales/sc.yml +++ /dev/null @@ -1,55 +0,0 @@ -sc: - cmdx: - attributes: - required: "depet èssere atzessìbile pro mèdiu de su mètodu de orìgine %{method}" - undefined: "delegat a su metodu non definidu %{method}" - coercions: - into_a: "no s'est podidu cunvertire in %{type}" - into_an: "no s'est podidu cunvertire in %{type}" - into_any: "no s'est podidu cunvertire in unu de: %{types}" - unknown: "tipu de cunversione %{type} disconnotu" - returns: - missing: "depet èssere impostadu in su cuntestu" - faults: - invalid: "Non vàlidos" - unspecified: "Non specificadu" - reasons: - unspecified: "Non specificadu" - types: - array: "array" - big_decimal: "decimale mannu" - boolean: "booleanu" - complex: "cumpressu" - date_time: "data e ora" - date: "data" - float: "puntu flotante" - hash: "hash" - integer: "interu" - rational: "razionale" - string: "corda" - symbol: "sìmbulu" - time: "ora" - validators: - absence: "deve èssere bòidu" - exclusion: - of: "non deve èssere unu de: %{values}" - within: "non deve èssere intre %{min} e %{max}" - format: "est unu formatu non vàlidu" - inclusion: - of: "deve èssere unu de: %{values}" - within: "deve èssere intre %{min} e %{max}" - length: - is: "sa longària deve èssere %{is}" - is_not: "sa longària non deve èssere %{is_not}" - min: "sa longària deve èssere a su mancu %{min}" - max: "sa longària potet èssere a su màssimu %{max}" - not_within: "sa longària non deve èssere intre %{min} e %{max}" - within: "sa longària deve èssere intre %{min} e %{max}" - numeric: - is: "deve èssere %{is}" - is_not: "non deve èssere %{is_not}" - min: "deve èssere a su mancu %{min}" - max: "potet èssere a su màssimu %{max}" - not_within: "non deve èssere intre %{min} e %{max}" - within: "deve èssere intre %{min} e %{max}" - presence: "non potet èssere bòidu" diff --git a/lib/locales/sk.yml b/lib/locales/sk.yml deleted file mode 100644 index 863a56b9c..000000000 --- a/lib/locales/sk.yml +++ /dev/null @@ -1,55 +0,0 @@ -sk: - cmdx: - attributes: - required: "musí byť prístupné cez zdrojovú metódu %{method}" - undefined: "deleguje na nedefinovanú metódu %{method}" - coercions: - into_a: "nepodarilo sa previesť na %{type}" - into_an: "nepodarilo sa previesť na %{type}" - into_any: "nepodarilo sa previesť na jeden z: %{types}" - unknown: "neznámy typ konverzie %{type}" - returns: - missing: "musí byť nastavené v kontexte" - faults: - invalid: "Neplatné" - unspecified: "Nešpecifikované" - reasons: - unspecified: "Nešpecifikované" - types: - array: "pole" - big_decimal: "veľké desatinné číslo" - boolean: "logický" - complex: "komplexný" - date_time: "dátum a čas" - date: "dátum" - float: "desatinné číslo" - hash: "hash" - integer: "celé číslo" - rational: "racionálny" - string: "reťazec" - symbol: "symbol" - time: "čas" - validators: - absence: "musí byť prázdne" - exclusion: - of: "nesmie byť jedným z: %{values}" - within: "nesmie byť medzi %{min} a %{max}" - format: "je neplatný formát" - inclusion: - of: "musí byť jedným z: %{values}" - within: "musí byť medzi %{min} a %{max}" - length: - is: "dĺžka musí byť %{is}" - is_not: "dĺžka nesmie byť %{is_not}" - min: "dĺžka musí byť aspoň %{min}" - max: "dĺžka môže byť najviac %{max}" - not_within: "dĺžka nesmie byť medzi %{min} a %{max}" - within: "dĺžka musí byť medzi %{min} a %{max}" - numeric: - is: "musí byť %{is}" - is_not: "nesmie byť %{is_not}" - min: "musí byť aspoň %{min}" - max: "môže byť najviac %{max}" - not_within: "nesmie byť medzi %{min} a %{max}" - within: "musí byť medzi %{min} a %{max}" - presence: "nesmie byť prázdne" diff --git a/lib/locales/sl.yml b/lib/locales/sl.yml deleted file mode 100644 index 59345377e..000000000 --- a/lib/locales/sl.yml +++ /dev/null @@ -1,55 +0,0 @@ -sl: - cmdx: - attributes: - required: "mora biti dostopno prek izvorne metode %{method}" - undefined: "delegira nedefinirani metodi %{method}" - coercions: - into_a: "ni bilo mogoče pretvoriti v %{type}" - into_an: "ni bilo mogoče pretvoriti v %{type}" - into_any: "ni bilo mogoče pretvoriti v enega od: %{types}" - unknown: "neznan tip pretvorbe %{type}" - returns: - missing: "mora biti nastavljeno v kontekstu" - faults: - invalid: "Neveljavni" - unspecified: "Nedoločeno" - reasons: - unspecified: "Nedoločeno" - types: - array: "matrika" - big_decimal: "veliko decimalno število" - boolean: "logično" - complex: "kompleksno" - date_time: "datum in čas" - date: "datum" - float: "število s plavajočo vejico" - hash: "hash" - integer: "celo število" - rational: "racionalno" - string: "niz" - symbol: "simbol" - time: "čas" - validators: - absence: "mora biti prazno" - exclusion: - of: "ne sme biti eden od: %{values}" - within: "ne sme biti med %{min} in %{max}" - format: "je neveljaven format" - inclusion: - of: "mora biti eden od: %{values}" - within: "mora biti med %{min} in %{max}" - length: - is: "dolžina mora biti %{is}" - is_not: "dolžina ne sme biti %{is_not}" - min: "dolžina mora biti vsaj %{min}" - max: "dolžina lahko znaša največ %{max}" - not_within: "dolžina ne sme biti med %{min} in %{max}" - within: "dolžina mora biti med %{min} in %{max}" - numeric: - is: "mora biti %{is}" - is_not: "ne sme biti %{is_not}" - min: "mora biti vsaj %{min}" - max: "lahko znaša največ %{max}" - not_within: "ne sme biti med %{min} in %{max}" - within: "mora biti med %{min} in %{max}" - presence: "ne sme biti prazno" diff --git a/lib/locales/sq.yml b/lib/locales/sq.yml deleted file mode 100644 index 375e077cc..000000000 --- a/lib/locales/sq.yml +++ /dev/null @@ -1,55 +0,0 @@ -sq: - cmdx: - attributes: - required: "duhet të jetë i qasshëm përmes metodës së burimit %{method}" - undefined: "delegon metodën e padefinuar %{method}" - coercions: - into_a: "nuk mund të konvertohet në %{type}" - into_an: "nuk mund të konvertohet në %{type}" - into_any: "nuk mund të konvertohet në një nga: %{types}" - unknown: "tip i panjohur konvertimi %{type}" - returns: - missing: "duhet të vendoset në kontekst" - faults: - invalid: "Të pavlefshme" - unspecified: "E papërcaktuar" - reasons: - unspecified: "E papërcaktuar" - types: - array: "array" - big_decimal: "decimal i madh" - boolean: "boolean" - complex: "kompleks" - date_time: "data dhe koha" - date: "data" - float: "numër me pikë lundruese" - hash: "hash" - integer: "numër i plotë" - rational: "racional" - string: "varg" - symbol: "simbol" - time: "koha" - validators: - absence: "duhet të jetë bosh" - exclusion: - of: "nuk duhet të jetë një nga: %{values}" - within: "nuk duhet të jetë mes %{min} dhe %{max}" - format: "është një format i pavlefshëm" - inclusion: - of: "duhet të jetë një nga: %{values}" - within: "duhet të jetë mes %{min} dhe %{max}" - length: - is: "gjatësia duhet të jetë %{is}" - is_not: "gjatësia nuk duhet të jetë %{is_not}" - min: "gjatësia duhet të jetë të paktën %{min}" - max: "gjatësia duhet të jetë më së shumti %{max}" - not_within: "gjatësia nuk duhet të jetë mes %{min} dhe %{max}" - within: "gjatësia duhet të jetë mes %{min} dhe %{max}" - numeric: - is: "duhet të jetë %{is}" - is_not: "nuk duhet të jetë %{is_not}" - min: "duhet të jetë të paktën %{min}" - max: "duhet të jetë më së shumti %{max}" - not_within: "nuk duhet të jetë mes %{min} dhe %{max}" - within: "duhet të jetë mes %{min} dhe %{max}" - presence: "nuk mund të jetë bosh" diff --git a/lib/locales/sr.yml b/lib/locales/sr.yml deleted file mode 100644 index 6f6d42a58..000000000 --- a/lib/locales/sr.yml +++ /dev/null @@ -1,55 +0,0 @@ -sr: - cmdx: - attributes: - required: "mora biti dostupan preko izvorne metode %{method}" - undefined: "делегира недефинисаној методи %{method}" - coercions: - into_a: "није могао да се конвертује у %{type}" - into_an: "није могао да се конвертује у %{type}" - into_any: "није могао да се конвертује у један од: %{types}" - unknown: "непознат тип конверзије %{type}" - returns: - missing: "мора бити постављено у контексту" - faults: - invalid: "Неисправни" - unspecified: "Није наведен" - reasons: - unspecified: "Није наведен" - types: - array: "низ" - big_decimal: "велики децимални број" - boolean: "логички" - complex: "комплексан" - date_time: "датум и време" - date: "датум" - float: "број са покретном зарезом" - hash: "хаш" - integer: "цео број" - rational: "рационалан" - string: "низ знакова" - symbol: "симбол" - time: "време" - validators: - absence: "мора бити празно" - exclusion: - of: "не сме бити један од: %{values}" - within: "не сме бити између %{min} и %{max}" - format: "је неважећи формат" - inclusion: - of: "мора бити један од: %{values}" - within: "мора бити између %{min} и %{max}" - length: - is: "дужина мора бити %{is}" - is_not: "дужина не сме бити %{is_not}" - min: "дужина мора бити најмање %{min}" - max: "дужина може бити највише %{max}" - not_within: "дужина не сме бити између %{min} и %{max}" - within: "дужина мора бити између %{min} и %{max}" - numeric: - is: "мора бити %{is}" - is_not: "не сме бити %{is_not}" - min: "мора бити најмање %{min}" - max: "може бити највише %{max}" - not_within: "не сме бити између %{min} и %{max}" - within: "мора бити између %{min} и %{max}" - presence: "не може бити празно" diff --git a/lib/locales/st.yml b/lib/locales/st.yml deleted file mode 100644 index 9cfafcf79..000000000 --- a/lib/locales/st.yml +++ /dev/null @@ -1,55 +0,0 @@ -st: - cmdx: - attributes: - required: "e tlameha ho fihlelleha ka mokhoa oa mohloli oa %{method}" - undefined: "e fana ka boikarabello ho mokgwa o sa hlaloswang %{method}" - coercions: - into_a: "ha e ka fetolelwa ho %{type}" - into_an: "ha e ka fetolelwa ho %{type}" - into_any: "ha e ka fetolelwa ho e mong oa: %{types}" - unknown: "mofuta o sa tsejoang oa phetoho %{type}" - returns: - missing: "e tlameha ho hlophisetswa ka moelelo" - faults: - invalid: "Tse sa sebetseng" - unspecified: "Ha e hlalosoe" - reasons: - unspecified: "Ha e hlalosoe" - types: - array: "array" - big_decimal: "decimal e kgolo" - boolean: "boolean" - complex: "e rarahaneng" - date_time: "letsatsi le nako" - date: "letsatsi" - float: "nomoro ea ho fapana" - hash: "hash" - integer: "nomoro e feletseng" - rational: "e rarahaneng" - string: "lentsoe" - symbol: "mokhoa" - time: "nako" - validators: - absence: "e tlameha ho ba e se na letho" - exclusion: - of: "ha e tlameha ho ba e mong oa: %{values}" - within: "ha e tlameha ho ba pakeng tsa %{min} le %{max}" - format: "ke foromo e sa sebetseng" - inclusion: - of: "e tlameha ho ba e mong oa: %{values}" - within: "e tlameha ho ba pakeng tsa %{min} le %{max}" - length: - is: "botelele bo tlameha ho ba %{is}" - is_not: "botelele ha bo tlameha ho ba %{is_not}" - min: "botelele bo tlameha ho ba bonyane %{min}" - max: "botelele bo ka ba ka ho fetisisa %{max}" - not_within: "botelele ha bo tlameha ho ba pakeng tsa %{min} le %{max}" - within: "botelele bo tlameha ho ba pakeng tsa %{min} le %{max}" - numeric: - is: "e tlameha ho ba %{is}" - is_not: "ha e tlameha ho ba %{is_not}" - min: "e tlameha ho ba bonyane %{min}" - max: "e ka ba ka ho fetisisa %{max}" - not_within: "ha e tlameha ho ba pakeng tsa %{min} le %{max}" - within: "e tlameha ho ba pakeng tsa %{min} le %{max}" - presence: "ha e ka ba e se na letho" diff --git a/lib/locales/sv.yml b/lib/locales/sv.yml deleted file mode 100644 index 8a3e831fe..000000000 --- a/lib/locales/sv.yml +++ /dev/null @@ -1,55 +0,0 @@ -sv: - cmdx: - attributes: - required: "måste vara tillgänglig via källmetoden %{method}" - undefined: "delegerar till odefinierad metod %{method}" - coercions: - into_a: "kunde inte konverteras till %{type}" - into_an: "kunde inte konverteras till %{type}" - into_any: "kunde inte konverteras till en av: %{types}" - unknown: "okänd %{type} konverteringstyp" - returns: - missing: "måste vara satt i kontexten" - faults: - invalid: "Ogiltiga" - unspecified: "Ospecificerat" - reasons: - unspecified: "Ospecificerat" - types: - array: "array" - big_decimal: "stort decimaltal" - boolean: "boolesk" - complex: "komplext" - date_time: "datum och tid" - date: "datum" - float: "flyttal" - hash: "hash" - integer: "heltal" - rational: "rationellt" - string: "sträng" - symbol: "symbol" - time: "tid" - validators: - absence: "måste vara tom" - exclusion: - of: "får inte vara en av: %{values}" - within: "får inte vara mellan %{min} och %{max}" - format: "är ett ogiltigt format" - inclusion: - of: "måste vara en av: %{values}" - within: "måste vara mellan %{min} och %{max}" - length: - is: "längden måste vara %{is}" - is_not: "längden får inte vara %{is_not}" - min: "längden måste vara minst %{min}" - max: "längden får högst vara %{max}" - not_within: "längden får inte vara mellan %{min} och %{max}" - within: "längden måste vara mellan %{min} och %{max}" - numeric: - is: "måste vara %{is}" - is_not: "får inte vara %{is_not}" - min: "måste vara minst %{min}" - max: "får högst vara %{max}" - not_within: "får inte vara mellan %{min} och %{max}" - within: "måste vara mellan %{min} och %{max}" - presence: "får inte vara tom" diff --git a/lib/locales/sw.yml b/lib/locales/sw.yml deleted file mode 100644 index 2c7eb1dc4..000000000 --- a/lib/locales/sw.yml +++ /dev/null @@ -1,55 +0,0 @@ -sw: - cmdx: - attributes: - required: "lazima ipatikane kupitia mbinu ya chanzo ya %{method}" - undefined: "inakabidhi kwa njia isiyofafanuliwa %{method}" - coercions: - into_a: "haikuweza kubadilishwa kuwa %{type}" - into_an: "haikuweza kubadilishwa kuwa %{type}" - into_any: "haikuweza kubadilishwa kuwa moja ya: %{types}" - unknown: "aina isiyojulikana ya mabadiliko %{type}" - returns: - missing: "lazima iwekwe katika muktadha" - faults: - invalid: "Zisizofaa" - unspecified: "Haijaspecifikwa" - reasons: - unspecified: "Haijaspecifikwa" - types: - array: "safu" - big_decimal: "desimali kubwa" - boolean: "booleani" - complex: "tata" - date_time: "tarehe na wakati" - date: "tarehe" - float: "nambari ya sehemu" - hash: "hash" - integer: "nambari kamili" - rational: "ya busara" - string: "mstari" - symbol: "ishara" - time: "wakati" - validators: - absence: "lazima iwe tupu" - exclusion: - of: "haipaswi kuwa moja ya: %{values}" - within: "haipaswi kuwa kati ya %{min} na %{max}" - format: "ni muundo batili" - inclusion: - of: "lazima iwe moja ya: %{values}" - within: "lazima iwe kati ya %{min} na %{max}" - length: - is: "urefu lazima uwe %{is}" - is_not: "urefu haupaswi kuwa %{is_not}" - min: "urefu lazima uwe angalau %{min}" - max: "urefu unaweza kuwa zaidi ya %{max}" - not_within: "urefu haupaswi kuwa kati ya %{min} na %{max}" - within: "urefu lazima uwe kati ya %{min} na %{max}" - numeric: - is: "lazima iwe %{is}" - is_not: "haipaswi kuwa %{is_not}" - min: "lazima iwe angalau %{min}" - max: "inaweza kuwa zaidi ya %{max}" - not_within: "haipaswi kuwa kati ya %{min} na %{max}" - within: "lazima iwe kati ya %{min} na %{max}" - presence: "haiwezi kuwa tupu" diff --git a/lib/locales/ta.yml b/lib/locales/ta.yml deleted file mode 100644 index 349655d8c..000000000 --- a/lib/locales/ta.yml +++ /dev/null @@ -1,55 +0,0 @@ -ta: - cmdx: - attributes: - required: "%{method} மூல முறை மூலம் அணுகக்கூடியதாக இருக்க வேண்டும்" - undefined: "வரையறுக்கப்படாத முறைக்கு %{method} ஒப்படைக்கிறது" - coercions: - into_a: "%{type} ஆக மாற்ற முடியவில்லை" - into_an: "%{type} ஆக மாற்ற முடியவில்லை" - into_any: "பின்வருவனவற்றில் ஒன்றாக மாற்ற முடியவில்லை: %{types}" - unknown: "தெரியாத %{type} மாற்ற வகை" - returns: - missing: "சூழலில் அமைக்கப்பட வேண்டும்" - faults: - invalid: "தவறான" - unspecified: "குறிப்பிடப்படாத" - reasons: - unspecified: "குறிப்பிடப்படாத" - types: - array: "வரிசை" - big_decimal: "பெரிய தசமம்" - boolean: "பூலியன்" - complex: "சிக்கலான" - date_time: "தேதி மற்றும் நேரம்" - date: "தேதி" - float: "மிதக்கும் புள்ளி" - hash: "ஹாஷ்" - integer: "முழு எண்" - rational: "விகிதமுறு" - string: "சரம்" - symbol: "குறியீடு" - time: "நேரம்" - validators: - absence: "வெறுமையாக இருக்க வேண்டும்" - exclusion: - of: "பின்வருவனவற்றில் ஒன்றாக இருக்கக்கூடாது: %{values}" - within: "%{min} மற்றும் %{max} இடையே இருக்கக்கூடாது" - format: "தவறான வடிவமாகும்" - inclusion: - of: "பின்வருவனவற்றில் ஒன்றாக இருக்க வேண்டும்: %{values}" - within: "%{min} மற்றும் %{max} இடையே இருக்க வேண்டும்" - length: - is: "நீளம் %{is} ஆக இருக்க வேண்டும்" - is_not: "நீளம் %{is_not} ஆக இருக்கக்கூடாது" - min: "நீளம் குறைந்தபட்சம் %{min} ஆக இருக்க வேண்டும்" - max: "நீளம் அதிகபட்சம் %{max} ஆக இருக்கலாம்" - not_within: "நீளம் %{min} மற்றும் %{max} இடையே இருக்கக்கூடாது" - within: "நீளம் %{min} மற்றும் %{max} இடையே இருக்க வேண்டும்" - numeric: - is: "%{is} ஆக இருக்க வேண்டும்" - is_not: "%{is_not} ஆக இருக்கக்கூடாது" - min: "குறைந்தபட்சம் %{min} ஆக இருக்க வேண்டும்" - max: "அதிகபட்சம் %{max} ஆக இருக்கலாம்" - not_within: "%{min} மற்றும் %{max} இடையே இருக்கக்கூடாது" - within: "%{min} மற்றும் %{max} இடையே இருக்க வேண்டும்" - presence: "வெறுமையாக இருக்கக்கூடாது" diff --git a/lib/locales/te.yml b/lib/locales/te.yml deleted file mode 100644 index 085707f01..000000000 --- a/lib/locales/te.yml +++ /dev/null @@ -1,55 +0,0 @@ -te: - cmdx: - attributes: - required: "%{method} మూల పద్ధతి ద్వారా యాక్సెస్ చేయగలగాలి" - undefined: "నిర్వచించబడని పద్ధతికి %{method} అప్పగిస్తుంది" - coercions: - into_a: "%{type} గా మార్చలేకపోయింది" - into_an: "%{type} గా మార్చలేకపోయింది" - into_any: "ఈ క్రింది వాటిలో ఒకటిగా మార్చలేకపోయింది: %{types}" - unknown: "తెలియని %{type} మార్పు రకం" - returns: - missing: "సందర్భంలో సెట్ చేయబడాలి" - faults: - invalid: "చెల్లని" - unspecified: "నిర్దిష్టం కాదు" - reasons: - unspecified: "నిర్దిష్టం కాదు" - types: - array: "అరే" - big_decimal: "పెద్ద దశాంశం" - boolean: "బూలియన్" - complex: "సంక్లిష్టం" - date_time: "తేదీ మరియు సమయం" - date: "తేదీ" - float: "తేలే బిందువు" - hash: "హాష్" - integer: "పూర్ణాంకం" - rational: "భిన్నరాశి" - string: "స్ట్రింగ్" - symbol: "చిహ్నం" - time: "సమయం" - validators: - absence: "ఖాళీగా ఉండాలి" - exclusion: - of: "ఈ క్రింది వాటిలో ఒకటి కాకూడదు: %{values}" - within: "%{min} మరియు %{max} మధ్య ఉండకూడదు" - format: "చెల్లని ఫార్మాట్" - inclusion: - of: "ఈ క్రింది వాటిలో ఒకటి అయి ఉండాలి: %{values}" - within: "%{min} మరియు %{max} మధ్య ఉండాలి" - length: - is: "పొడవు %{is} అయి ఉండాలి" - is_not: "పొడవు %{is_not} కాకూడదు" - min: "పొడవు కనీసం %{min} అయి ఉండాలి" - max: "పొడవు గరిష్టంగా %{max} అయి ఉండవచ్చు" - not_within: "పొడవు %{min} మరియు %{max} మధ్య ఉండకూడదు" - within: "పొడవు %{min} మరియు %{max} మధ్య ఉండాలి" - numeric: - is: "%{is} అయి ఉండాలి" - is_not: "%{is_not} కాకూడదు" - min: "కనీసం %{min} అయి ఉండాలి" - max: "గరిష్టంగా %{max} అయి ఉండవచ్చు" - not_within: "%{min} మరియు %{max} మధ్య ఉండకూడదు" - within: "%{min} మరియు %{max} మధ్య ఉండాలి" - presence: "ఖాళీగా ఉండకూడదు" diff --git a/lib/locales/th.yml b/lib/locales/th.yml deleted file mode 100644 index ee1a346ce..000000000 --- a/lib/locales/th.yml +++ /dev/null @@ -1,55 +0,0 @@ -th: - cmdx: - attributes: - required: "ต้องสามารถเข้าถึงได้ผ่านวิธีแหล่งที่มา %{method}" - undefined: "มอบหมายให้กับเมธอดที่ไม่ได้กำหนด %{method}" - coercions: - into_a: "ไม่สามารถแปลงเป็น %{type} ได้" - into_an: "ไม่สามารถแปลงเป็น %{type} ได้" - into_any: "ไม่สามารถแปลงเป็นหนึ่งใน: %{types} ได้" - unknown: "ประเภทการแปลง %{type} ที่ไม่รู้จัก" - returns: - missing: "ต้องถูกกำหนดในบริบท" - faults: - invalid: "ไม่ถูกต้อง" - unspecified: "ไม่ระบุ" - reasons: - unspecified: "ไม่ระบุ" - types: - array: "อาร์เรย์" - big_decimal: "ทศนิยมขนาดใหญ่" - boolean: "บูลีน" - complex: "จำนวนเชิงซ้อน" - date_time: "วันที่และเวลา" - date: "วันที่" - float: "จำนวนทศนิยม" - hash: "แฮช" - integer: "จำนวนเต็ม" - rational: "จำนวนตรรกยะ" - string: "สตริง" - symbol: "สัญลักษณ์" - time: "เวลา" - validators: - absence: "ต้องเป็นค่าว่าง" - exclusion: - of: "ต้องไม่ใช่หนึ่งใน: %{values}" - within: "ต้องไม่อยู่ระหว่าง %{min} และ %{max}" - format: "เป็นรูปแบบที่ไม่ถูกต้อง" - inclusion: - of: "ต้องเป็นหนึ่งใน: %{values}" - within: "ต้องอยู่ระหว่าง %{min} และ %{max}" - length: - is: "ความยาวต้องเป็น %{is}" - is_not: "ความยาวต้องไม่เป็น %{is_not}" - min: "ความยาวต้องอย่างน้อย %{min}" - max: "ความยาวต้องไม่เกิน %{max}" - not_within: "ความยาวต้องไม่อยู่ระหว่าง %{min} และ %{max}" - within: "ความยาวต้องอยู่ระหว่าง %{min} และ %{max}" - numeric: - is: "ต้องเป็น %{is}" - is_not: "ต้องไม่เป็น %{is_not}" - min: "ต้องอย่างน้อย %{min}" - max: "ต้องไม่เกิน %{max}" - not_within: "ต้องไม่อยู่ระหว่าง %{min} และ %{max}" - within: "ต้องอยู่ระหว่าง %{min} และ %{max}" - presence: "ไม่สามารถเป็นค่าว่างได้" diff --git a/lib/locales/tl.yml b/lib/locales/tl.yml deleted file mode 100644 index 53e8567a1..000000000 --- a/lib/locales/tl.yml +++ /dev/null @@ -1,55 +0,0 @@ -tl: - cmdx: - attributes: - required: "dapat ma-access sa pamamagitan ng pamamaraang pinagmulan ng %{method}" - undefined: "nag-delegate sa hindi tinukoy na method %{method}" - coercions: - into_a: "hindi ma-convert sa %{type}" - into_an: "hindi ma-convert sa %{type}" - into_any: "hindi ma-convert sa isa sa: %{types}" - unknown: "hindi kilalang %{type} conversion type" - returns: - missing: "dapat itakda sa konteksto" - faults: - invalid: "Hindi wasto" - unspecified: "Hindi tinukoy" - reasons: - unspecified: "Hindi tinukoy" - types: - array: "array" - big_decimal: "malaking decimal" - boolean: "boolean" - complex: "complex" - date_time: "petsa at oras" - date: "petsa" - float: "floating point" - hash: "hash" - integer: "integer" - rational: "rational" - string: "string" - symbol: "symbol" - time: "oras" - validators: - absence: "dapat na walang laman" - exclusion: - of: "hindi dapat na isa sa: %{values}" - within: "hindi dapat na nasa pagitan ng %{min} at %{max}" - format: "ay hindi wastong format" - inclusion: - of: "dapat na isa sa: %{values}" - within: "dapat na nasa pagitan ng %{min} at %{max}" - length: - is: "ang haba ay dapat na %{is}" - is_not: "ang haba ay hindi dapat na %{is_not}" - min: "ang haba ay dapat na hindi bababa sa %{min}" - max: "ang haba ay dapat na hindi hihigit sa %{max}" - not_within: "ang haba ay hindi dapat na nasa pagitan ng %{min} at %{max}" - within: "ang haba ay dapat na nasa pagitan ng %{min} at %{max}" - numeric: - is: "dapat na %{is}" - is_not: "hindi dapat na %{is_not}" - min: "dapat na hindi bababa sa %{min}" - max: "dapat na hindi hihigit sa %{max}" - not_within: "hindi dapat na nasa pagitan ng %{min} at %{max}" - within: "dapat na nasa pagitan ng %{min} at %{max}" - presence: "hindi maaaring walang laman" diff --git a/lib/locales/tr.yml b/lib/locales/tr.yml deleted file mode 100644 index 90838332b..000000000 --- a/lib/locales/tr.yml +++ /dev/null @@ -1,55 +0,0 @@ -tr: - cmdx: - attributes: - required: "%{method} kaynak yöntemi ile erişilebilir olmalıdır" - undefined: "tanımlanmamış metoda %{method} devreder" - coercions: - into_a: "%{type} türüne dönüştürülemedi" - into_an: "%{type} türüne dönüştürülemedi" - into_any: "şunlardan birine dönüştürülemedi: %{types}" - unknown: "bilinmeyen %{type} dönüştürme türü" - returns: - missing: "bağlamda ayarlanmış olmalıdır" - faults: - invalid: "Geçersiz" - unspecified: "Belirtilmemiş" - reasons: - unspecified: "Belirtilmemiş" - types: - array: "dizi" - big_decimal: "büyük ondalık" - boolean: "boole" - complex: "karmaşık" - date_time: "tarih ve saat" - date: "tarih" - float: "kayan nokta" - hash: "hash" - integer: "tam sayı" - rational: "rasyonel" - string: "dize" - symbol: "sembol" - time: "zaman" - validators: - absence: "boş olmalıdır" - exclusion: - of: "şunlardan biri olmamalıdır: %{values}" - within: "%{min} ve %{max} arasında olmamalıdır" - format: "geçersiz bir formattır" - inclusion: - of: "şunlardan biri olmalıdır: %{values}" - within: "%{min} ve %{max} arasında olmalıdır" - length: - is: "uzunluk %{is} olmalıdır" - is_not: "uzunluk %{is_not} olmamalıdır" - min: "uzunluk en az %{min} olmalıdır" - max: "uzunluk en fazla %{max} olmalıdır" - not_within: "uzunluk %{min} ve %{max} arasında olmamalıdır" - within: "uzunluk %{min} ve %{max} arasında olmalıdır" - numeric: - is: "%{is} olmalıdır" - is_not: "%{is_not} olmamalıdır" - min: "en az %{min} olmalıdır" - max: "en fazla %{max} olmalıdır" - not_within: "%{min} ve %{max} arasında olmamalıdır" - within: "%{min} ve %{max} arasında olmalıdır" - presence: "boş olamaz" diff --git a/lib/locales/tt.yml b/lib/locales/tt.yml deleted file mode 100644 index e0fd56089..000000000 --- a/lib/locales/tt.yml +++ /dev/null @@ -1,55 +0,0 @@ -tt: - cmdx: - attributes: - required: "%{method} чыганак методы аша керү мөмкинлеге булырга тиеш" - undefined: "билгеләнмәгән %{method} методына тапшыра" - coercions: - into_a: "%{type} төренә күчерә алмады" - into_an: "%{type} төренә күчерә алмады" - into_any: "боларның берсенә күчерә алмады: %{types}" - unknown: "билгесез %{type} күчерү төре" - returns: - missing: "контекстта урнаштырылган булырга тиеш" - faults: - invalid: "Дөрес булмаган" - unspecified: "Күрсәтелмәгән" - reasons: - unspecified: "Күрсәтелмәгән" - types: - array: "массив" - big_decimal: "зур унарлы" - boolean: "буль" - complex: "комплекс" - date_time: "дата һәм вакыт" - date: "дата" - float: "өзәк ноктасы" - hash: "хэш" - integer: "бөтен сан" - rational: "рациональ" - string: "юл" - symbol: "символ" - time: "вакыт" - validators: - absence: "буш булырга тиеш" - exclusion: - of: "боларның берсе булмасын: %{values}" - within: "%{min} һәм %{max} арасында булмасын" - format: "дөрес булмаган формат" - inclusion: - of: "боларның берсе булырга тиеш: %{values}" - within: "%{min} һәм %{max} арасында булырга тиеш" - length: - is: "озынлыгы %{is} булырга тиеш" - is_not: "озынлыгы %{is_not} булмасын" - min: "озынлыгы ким дигәндә %{min} булырга тиеш" - max: "озынлыгы иң күбесеннә %{max} була ала" - not_within: "озынлыгы %{min} һәм %{max} арасында булмасын" - within: "озынлыгы %{min} һәм %{max} арасында булырга тиеш" - numeric: - is: "%{is} булырга тиеш" - is_not: "%{is_not} булмасын" - min: "ким дигәндә %{min} булырга тиеш" - max: "иң күбесеннә %{max} була ала" - not_within: "%{min} һәм %{max} арасында булмасын" - within: "%{min} һәм %{max} арасында булырга тиеш" - presence: "буш була алмас" diff --git a/lib/locales/ug.yml b/lib/locales/ug.yml deleted file mode 100644 index abd833ca1..000000000 --- a/lib/locales/ug.yml +++ /dev/null @@ -1,55 +0,0 @@ -ug: - cmdx: - attributes: - required: "%{method} مەنبە ئۇسۇلى ئارقىلىق زىيارەت قىلىنىشى كېرەك" - undefined: "ئېنىقلانمىغان %{method} مېتودىغا ۋەكەتلىك قىلىدۇ" - coercions: - into_a: "%{type} غا ئايلاندۇرالمايدۇ" - into_an: "%{type} غا ئايلاندۇرالمايدۇ" - into_any: "بۇلارنىڭ بىرىگە ئايلاندۇرالمايدۇ: %{types}" - unknown: "نامەلۇم %{type} ئايلاندۇرۇش تىپى" - returns: - missing: "مەزمۇندا بېكىتىلىشى كېرەك" - faults: - invalid: "ئىناۋەتسىز" - unspecified: "بەلگىلەنمىگەن" - reasons: - unspecified: "بەلگىلەنمىگەن" - types: - array: "تىزىملىك" - big_decimal: "چوڭ ئونلۇق" - boolean: "بۇل" - complex: "مۇرەككەپ" - date_time: "چېسلا ۋە ۋاقىت" - date: "چېسلا" - float: "سىيرىلغۇ نۇقتا" - hash: "خەش" - integer: "پۈتۈن سان" - rational: "راسىيونال" - string: "تەر" - symbol: "بەلگە" - time: "ۋاقىت" - validators: - absence: "بوش بولۇشى كېرەك" - exclusion: - of: "بۇلارنىڭ بىرى بولماسلىقى كېرەك: %{values}" - within: "%{min} ۋە %{max} ئارىسىدا بولماسلىقى كېرەك" - format: "ئىناۋەتسىز فورمات" - inclusion: - of: "بۇلارنىڭ بىرى بولۇشى كېرەك: %{values}" - within: "%{min} ۋە %{max} ئارىسىدا بولۇشى كېرەك" - length: - is: "ئۇزۇنلۇقى %{is} بولۇشى كېرەك" - is_not: "ئۇزۇنلۇقى %{is_not} بولماسلىقى كېرەك" - min: "ئۇزۇنلۇقى ئەڭ ئاز %{min} بولۇشى كېرەك" - max: "ئۇزۇنلۇقى ئەڭ كۆپ بولغاندا %{max} بولالايدۇ" - not_within: "ئۇزۇنلۇقى %{min} ۋە %{max} ئارىسىدا بولماسلىقى كېرەك" - within: "ئۇزۇنلۇقى %{min} ۋە %{max} ئارىسىدا بولۇشى كېرەك" - numeric: - is: "%{is} بولۇشى كېرەك" - is_not: "%{is_not} بولماسلىقى كېرەك" - min: "ئەڭ ئاز %{min} بولۇشى كېرەك" - max: "ئەڭ كۆپ بولغاندا %{max} بولالايدۇ" - not_within: "%{min} ۋە %{max} ئارىسىدا بولماسلىقى كېرەك" - within: "%{min} ۋە %{max} ئارىسىدا بولۇشى كېرەك" - presence: "بوش بولالمايدۇ" diff --git a/lib/locales/uk.yml b/lib/locales/uk.yml deleted file mode 100644 index c9124a406..000000000 --- a/lib/locales/uk.yml +++ /dev/null @@ -1,55 +0,0 @@ -uk: - cmdx: - attributes: - required: "має бути доступним через вихідний метод %{method}" - undefined: "делегує невизначеному методу %{method}" - coercions: - into_a: "не вдалося перетворити на %{type}" - into_an: "не вдалося перетворити на %{type}" - into_any: "не вдалося перетворити на один з: %{types}" - unknown: "невідомий тип перетворення %{type}" - returns: - missing: "повинно бути встановлено в контексті" - faults: - invalid: "Невірні" - unspecified: "Не вказано" - reasons: - unspecified: "Не вказано" - types: - array: "масив" - big_decimal: "велике десяткове число" - boolean: "логічний" - complex: "комплексний" - date_time: "дата та час" - date: "дата" - float: "число з плаваючою комою" - hash: "хеш" - integer: "ціле число" - rational: "раціональне" - string: "рядок" - symbol: "символ" - time: "час" - validators: - absence: "має бути порожнім" - exclusion: - of: "не може бути одним з: %{values}" - within: "не може бути між %{min} та %{max}" - format: "є недійсним форматом" - inclusion: - of: "має бути одним з: %{values}" - within: "має бути між %{min} та %{max}" - length: - is: "довжина має бути %{is}" - is_not: "довжина не може бути %{is_not}" - min: "довжина має бути не менше %{min}" - max: "довжина має бути не більше %{max}" - not_within: "довжина не може бути між %{min} та %{max}" - within: "довжина має бути між %{min} та %{max}" - numeric: - is: "має бути %{is}" - is_not: "не може бути %{is_not}" - min: "має бути не менше %{min}" - max: "має бути не більше %{max}" - not_within: "не може бути між %{min} та %{max}" - within: "має бути між %{min} та %{max}" - presence: "не може бути порожнім" diff --git a/lib/locales/ur.yml b/lib/locales/ur.yml deleted file mode 100644 index b28fa94f9..000000000 --- a/lib/locales/ur.yml +++ /dev/null @@ -1,55 +0,0 @@ -ur: - cmdx: - attributes: - required: "%{method} ماخذ طریقہ کار کے ذریعے قابل رسائی ہونا ضروری ہے" - undefined: "غیر متعین طریقہ %{method} کو تفویض کرتا ہے" - coercions: - into_a: "%{type} میں تبدیل نہیں کر سکا" - into_an: "%{type} میں تبدیل نہیں کر سکا" - into_any: "ان میں سے کسی ایک میں تبدیل نہیں کر سکا: %{types}" - unknown: "نامعلوم %{type} تبدیلی کی قسم" - returns: - missing: "سیاق و سباق میں سیٹ ہونا ضروری ہے" - faults: - invalid: "غلط" - unspecified: "غیر متعین" - reasons: - unspecified: "غیر متعین" - types: - array: "آرے" - big_decimal: "بڑا اعشاری" - boolean: "بولین" - complex: "پیچیدہ" - date_time: "تاریخ اور وقت" - date: "تاریخ" - float: "شناور نقطہ" - hash: "ہیش" - integer: "مکمل عدد" - rational: "ناطق" - string: "سٹرنگ" - symbol: "علامت" - time: "وقت" - validators: - absence: "خالی ہونا ضروری ہے" - exclusion: - of: "ان میں سے کوئی ایک نہیں ہونا چاہیے: %{values}" - within: "%{min} اور %{max} کے درمیان نہیں ہونا چاہیے" - format: "ایک غلط فارمیٹ ہے" - inclusion: - of: "ان میں سے کوئی ایک ہونا ضروری ہے: %{values}" - within: "%{min} اور %{max} کے درمیان ہونا ضروری ہے" - length: - is: "لمبائی %{is} ہونی چاہیے" - is_not: "لمبائی %{is_not} نہیں ہونی چاہیے" - min: "لمبائی کم از کم %{min} ہونی چاہیے" - max: "لمبائی زیادہ سے زیادہ %{max} ہو سکتی ہے" - not_within: "لمبائی %{min} اور %{max} کے درمیان نہیں ہونی چاہیے" - within: "لمبائی %{min} اور %{max} کے درمیان ہونی چاہیے" - numeric: - is: "%{is} ہونا ضروری ہے" - is_not: "%{is_not} نہیں ہونا چاہیے" - min: "کم از کم %{min} ہونا ضروری ہے" - max: "زیادہ سے زیادہ %{max} ہو سکتا ہے" - not_within: "%{min} اور %{max} کے درمیان نہیں ہونا چاہیے" - within: "%{min} اور %{max} کے درمیان ہونا ضروری ہے" - presence: "خالی نہیں ہو سکتا" diff --git a/lib/locales/uz.yml b/lib/locales/uz.yml deleted file mode 100644 index e317c5b18..000000000 --- a/lib/locales/uz.yml +++ /dev/null @@ -1,55 +0,0 @@ -uz: - cmdx: - attributes: - required: "%{method} manba usuli orqali kirish imkoniyatiga ega bo'lishi kerak" - undefined: "aniqlanmagan %{method} metodiga topshiradi" - coercions: - into_a: "%{type} ga o'tkazib bo'lmadi" - into_an: "%{type} ga o'tkazib bo'lmadi" - into_any: "bularning biriga o'tkazib bo'lmadi: %{types}" - unknown: "noma'lum %{type} o'tkazish turi" - returns: - missing: "kontekstda belgilangan bolishi kerak" - faults: - invalid: "Noto'g'ri" - unspecified: "Ko'rsatilmagan" - reasons: - unspecified: "Ko'rsatilmagan" - types: - array: "massiv" - big_decimal: "katta o'nlik" - boolean: "mantiqiy" - complex: "murakkab" - date_time: "sana va vaqt" - date: "sana" - float: "suzuvchi nuqta" - hash: "xesh" - integer: "butun son" - rational: "ratsional" - string: "qator" - symbol: "belgi" - time: "vaqt" - validators: - absence: "bo'sh bo'lishi kerak" - exclusion: - of: "bularning biri bo'lmasligi kerak: %{values}" - within: "%{min} va %{max} oralig'ida bo'lmasligi kerak" - format: "noto'g'ri format" - inclusion: - of: "bularning biri bo'lishi kerak: %{values}" - within: "%{min} va %{max} oralig'ida bo'lishi kerak" - length: - is: "uzunligi %{is} bo'lishi kerak" - is_not: "uzunligi %{is_not} bo'lmasligi kerak" - min: "uzunligi kamida %{min} bo'lishi kerak" - max: "uzunligi ko'pi bilan %{max} bo'lishi mumkin" - not_within: "uzunligi %{min} va %{max} oralig'ida bo'lmasligi kerak" - within: "uzunligi %{min} va %{max} oralig'ida bo'lishi kerak" - numeric: - is: "%{is} bo'lishi kerak" - is_not: "%{is_not} bo'lmasligi kerak" - min: "kamida %{min} bo'lishi kerak" - max: "ko'pi bilan %{max} bo'lishi mumkin" - not_within: "%{min} va %{max} oralig'ida bo'lmasligi kerak" - within: "%{min} va %{max} oralig'ida bo'lishi kerak" - presence: "bo'sh bo'lishi mumkin emas" diff --git a/lib/locales/vi.yml b/lib/locales/vi.yml deleted file mode 100644 index d53558c25..000000000 --- a/lib/locales/vi.yml +++ /dev/null @@ -1,55 +0,0 @@ -vi: - cmdx: - attributes: - required: "phải có thể truy cập thông qua phương thức nguồn %{method}" - undefined: "ủy quyền cho phương thức không xác định %{method}" - coercions: - into_a: "không thể chuyển đổi thành %{type}" - into_an: "không thể chuyển đổi thành %{type}" - into_any: "không thể chuyển đổi thành một trong: %{types}" - unknown: "kiểu chuyển đổi %{type} không xác định" - returns: - missing: "phải được thiết lập trong ngữ cảnh" - faults: - invalid: "Không hợp lệ" - unspecified: "Không xác định" - reasons: - unspecified: "Không xác định" - types: - array: "mảng" - big_decimal: "số thập phân lớn" - boolean: "boolean" - complex: "phức" - date_time: "ngày và giờ" - date: "ngày" - float: "số thực" - hash: "bảng băm" - integer: "số nguyên" - rational: "số hữu tỷ" - string: "chuỗi" - symbol: "ký hiệu" - time: "thời gian" - validators: - absence: "phải để trống" - exclusion: - of: "không được là một trong: %{values}" - within: "không được nằm giữa %{min} và %{max}" - format: "là định dạng không hợp lệ" - inclusion: - of: "phải là một trong: %{values}" - within: "phải nằm giữa %{min} và %{max}" - length: - is: "độ dài phải là %{is}" - is_not: "độ dài không được là %{is_not}" - min: "độ dài phải ít nhất là %{min}" - max: "độ dài phải nhiều nhất là %{max}" - not_within: "độ dài không được nằm giữa %{min} và %{max}" - within: "độ dài phải nằm giữa %{min} và %{max}" - numeric: - is: "phải là %{is}" - is_not: "không được là %{is_not}" - min: "phải ít nhất là %{min}" - max: "phải nhiều nhất là %{max}" - not_within: "không được nằm giữa %{min} và %{max}" - within: "phải nằm giữa %{min} và %{max}" - presence: "không thể để trống" diff --git a/lib/locales/wo.yml b/lib/locales/wo.yml deleted file mode 100644 index 8fae3dabe..000000000 --- a/lib/locales/wo.yml +++ /dev/null @@ -1,55 +0,0 @@ -wo: - cmdx: - attributes: - required: "dafa war a man a dugg ci mbirum %{method} bi" - undefined: "dafa yokkute ci xam-xam bu ñu waxul %{method}" - coercions: - into_a: "duñu ko man a soppi ci %{type}" - into_an: "duñu ko man a soppi ci %{type}" - into_any: "duñu ko man a soppi ci benn ci: %{types}" - unknown: "xam-xam bu ñu waxul bu soppi %{type}" - returns: - missing: "war na nekk ci contexte bi" - faults: - invalid: "Ñu waxul" - unspecified: "Duñu wax" - reasons: - unspecified: "Duñu wax" - types: - array: "array" - big_decimal: "desimal bu góor" - boolean: "booleen" - complex: "kompleks" - date_time: "bëccëg ak jamono" - date: "bëccëg" - float: "xayma bu dox" - hash: "hash" - integer: "xayma bu mat" - rational: "rasyonel" - string: "xel" - symbol: "simbol" - time: "jamono" - validators: - absence: "dafa war a nekk lu nekk" - exclusion: - of: "duñu war a nekk benn ci: %{values}" - within: "duñu war a nekk ci digg %{min} ak %{max}" - format: "dafa nekk format bu ñu waxul" - inclusion: - of: "dafa war a nekk benn ci: %{values}" - within: "dafa war a nekk ci digg %{min} ak %{max}" - length: - is: "gudd gi dafa war a nekk %{is}" - is_not: "gudd gi duñu war a nekk %{is_not}" - min: "gudd gi dafa war a nekk gën %{min}" - max: "gudd gi dafa man a nekk gën %{max}" - not_within: "gudd gi duñu war a nekk ci digg %{min} ak %{max}" - within: "gudd gi dafa war a nekk ci digg %{min} ak %{max}" - numeric: - is: "dafa war a nekk %{is}" - is_not: "duñu war a nekk %{is_not}" - min: "dafa war a nekk gën %{min}" - max: "dafa man a nekk gën %{max}" - not_within: "duñu war a nekk ci digg %{min} ak %{max}" - within: "dafa war a nekk ci digg %{min} ak %{max}" - presence: "duñu man a nekk lu nekk" diff --git a/lib/locales/zh-CN.yml b/lib/locales/zh-CN.yml deleted file mode 100644 index 328febb66..000000000 --- a/lib/locales/zh-CN.yml +++ /dev/null @@ -1,55 +0,0 @@ -zh-CN: - cmdx: - attributes: - required: "必须通过 %{method} 源方法访问" - undefined: "委托给未定义的方法 %{method}" - coercions: - into_a: "无法转换为 %{type}" - into_an: "无法转换为 %{type}" - into_any: "无法转换为以下之一: %{types}" - unknown: "未知的 %{type} 转换类型" - returns: - missing: "必须在上下文中设置" - faults: - invalid: "无效" - unspecified: "未指定" - reasons: - unspecified: "未指定" - types: - array: "数组" - big_decimal: "大十进制数" - boolean: "布尔值" - complex: "复数" - date_time: "日期时间" - date: "日期" - float: "浮点数" - hash: "哈希" - integer: "整数" - rational: "有理数" - string: "字符串" - symbol: "符号" - time: "时间" - validators: - absence: "必须为空" - exclusion: - of: "不能是以下之一: %{values}" - within: "不能在 %{min} 和 %{max} 之间" - format: "格式无效" - inclusion: - of: "必须是以下之一: %{values}" - within: "必须在 %{min} 和 %{max} 之间" - length: - is: "长度必须是 %{is}" - is_not: "长度不能是 %{is_not}" - min: "长度必须至少为 %{min}" - max: "长度最多为 %{max}" - not_within: "长度不能在 %{min} 和 %{max} 之间" - within: "长度必须在 %{min} 和 %{max} 之间" - numeric: - is: "必须是 %{is}" - is_not: "不能是 %{is_not}" - min: "必须至少为 %{min}" - max: "最多为 %{max}" - not_within: "不能在 %{min} 和 %{max} 之间" - within: "必须在 %{min} 和 %{max} 之间" - presence: "不能为空" diff --git a/lib/locales/zh-HK.yml b/lib/locales/zh-HK.yml deleted file mode 100644 index 346f89590..000000000 --- a/lib/locales/zh-HK.yml +++ /dev/null @@ -1,55 +0,0 @@ -zh-HK: - cmdx: - attributes: - required: "必須透過 %{method} 來源方法存取" - undefined: "委託給未定義的方法 %{method}" - coercions: - into_a: "無法強制轉換成 %{type}" - into_an: "無法強制轉換成 %{type}" - into_any: "無法強制轉換成其中一個:%{types}" - unknown: "未知的 %{type} 強制轉換類型" - returns: - missing: "必須在上下文中設定" - faults: - invalid: "無效" - unspecified: "未指定" - reasons: - unspecified: "未指定" - types: - array: "陣列" - big_decimal: "大十進位" - boolean: "布林值" - complex: "複數" - date_time: "日期和時間" - date: "日期" - float: "浮點數" - hash: "雜湊" - integer: "整數" - rational: "有理數" - string: "字串" - symbol: "符號" - time: "時間" - validators: - absence: "必須是空白" - exclusion: - of: "不可以是其中一個:%{values}" - within: "不可以在 %{min} 和 %{max} 之間" - format: "是無效的格式" - inclusion: - of: "必須是其中一個:%{values}" - within: "必須在 %{min} 和 %{max} 之間" - length: - is: "長度必須是 %{is}" - is_not: "長度不可以是 %{is_not}" - min: "長度至少必須是 %{min}" - max: "長度最多可以是 %{max}" - not_within: "長度不可以在 %{min} 和 %{max} 之間" - within: "長度必須在 %{min} 和 %{max} 之間" - numeric: - is: "必須是 %{is}" - is_not: "不可以是 %{is_not}" - min: "至少必須是 %{min}" - max: "最多可以是 %{max}" - not_within: "不可以在 %{min} 和 %{max} 之間" - within: "必須在 %{min} 和 %{max} 之間" - presence: "不可以是空白" diff --git a/lib/locales/zh-TW.yml b/lib/locales/zh-TW.yml deleted file mode 100644 index 5e4ab1510..000000000 --- a/lib/locales/zh-TW.yml +++ /dev/null @@ -1,55 +0,0 @@ -zh-TW: - cmdx: - attributes: - required: "必須透過 %{method} 來源方法存取" - undefined: "委託給未定義的方法 %{method}" - coercions: - into_a: "無法強制轉換成 %{type}" - into_an: "無法強制轉換成 %{type}" - into_any: "無法強制轉換成其中一個:%{types}" - unknown: "未知的 %{type} 強制轉換類型" - returns: - missing: "必須在上下文中設定" - faults: - invalid: "無效" - unspecified: "未指定" - reasons: - unspecified: "未指定" - types: - array: "陣列" - big_decimal: "大十進位" - boolean: "布林值" - complex: "複數" - date_time: "日期和時間" - date: "日期" - float: "浮點數" - hash: "雜湊" - integer: "整數" - rational: "有理數" - string: "字串" - symbol: "符號" - time: "時間" - validators: - absence: "必須是空白" - exclusion: - of: "不可以是其中一個:%{values}" - within: "不可以在 %{min} 和 %{max} 之間" - format: "是無效的格式" - inclusion: - of: "必須是其中一個:%{values}" - within: "必須在 %{min} 和 %{max} 之間" - length: - is: "長度必須是 %{is}" - is_not: "長度不可以是 %{is_not}" - min: "長度至少必須是 %{min}" - max: "長度最多可以是 %{max}" - not_within: "長度不可以在 %{min} 和 %{max} 之間" - within: "長度必須在 %{min} 和 %{max} 之間" - numeric: - is: "必須是 %{is}" - is_not: "不可以是 %{is_not}" - min: "至少必須是 %{min}" - max: "最多可以是 %{max}" - not_within: "不可以在 %{min} 和 %{max} 之間" - within: "必須在 %{min} 和 %{max} 之間" - presence: "不可以是空白" diff --git a/lib/locales/zh-YUE.yml b/lib/locales/zh-YUE.yml deleted file mode 100644 index 09cc747c9..000000000 --- a/lib/locales/zh-YUE.yml +++ /dev/null @@ -1,55 +0,0 @@ -zh-YUE: - cmdx: - attributes: - required: "必須透過 %{method} 來源方法存取" - undefined: "委託畀未定義嘅方法 %{method}" - coercions: - into_a: "無法強制轉換成 %{type}" - into_an: "無法強制轉換成 %{type}" - into_any: "無法強制轉換成其中一個:%{types}" - unknown: "未知嘅 %{type} 強制轉換類型" - returns: - missing: "必須喺上下文入面設定" - faults: - invalid: "無效" - unspecified: "未指定" - reasons: - unspecified: "未指定" - types: - array: "陣列" - big_decimal: "大十進位" - boolean: "布林值" - complex: "複數" - date_time: "日期同時間" - date: "日期" - float: "浮點數" - hash: "雜湊" - integer: "整數" - rational: "有理數" - string: "字串" - symbol: "符號" - time: "時間" - validators: - absence: "必須係空白" - exclusion: - of: "唔可以係其中一個:%{values}" - within: "唔可以喺 %{min} 同 %{max} 之間" - format: "係無效嘅格式" - inclusion: - of: "必須係其中一個:%{values}" - within: "必須喺 %{min} 同 %{max} 之間" - length: - is: "長度必須係 %{is}" - is_not: "長度唔可以係 %{is_not}" - min: "長度至少必須係 %{min}" - max: "長度最多可以係 %{max}" - not_within: "長度唔可以喺 %{min} 同 %{max} 之間" - within: "長度必須喺 %{min} 同 %{max} 之間" - numeric: - is: "必須係 %{is}" - is_not: "唔可以係 %{is_not}" - min: "至少必須係 %{min}" - max: "最多可以係 %{max}" - not_within: "唔可以喺 %{min} 同 %{max} 之間" - within: "必須喺 %{min} 同 %{max} 之間" - presence: "唔可以係空白" From f2f66d49e7541e220a574802c8aeba12708aa6b8 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Fri, 10 Apr 2026 09:17:07 -0400 Subject: [PATCH 4/4] Take 2 --- .rspec | 1 + Gemfile.lock | 3 +- lib/cmdx/attribute.rb | 194 +++++++++++ lib/cmdx/attribute_set.rb | 110 ++++++ lib/cmdx/callable.rb | 74 ++++ lib/cmdx/callbacks.rb | 145 ++++++++ lib/cmdx/chain.rb | 113 ++++++ lib/cmdx/coercions.rb | 196 +++++++++++ lib/cmdx/configuration.rb | 47 +++ lib/cmdx/context.rb | 164 +++++++++ lib/cmdx/error_set.rb | 72 ++++ lib/cmdx/errors.rb | 89 +++++ lib/cmdx/log_entry.rb | 81 +++++ lib/cmdx/log_formatters/json.rb | 29 ++ lib/cmdx/log_formatters/key_value.rb | 28 ++ lib/cmdx/log_formatters/line.rb | 16 + lib/cmdx/log_formatters/logstash.rb | 34 ++ lib/cmdx/log_formatters/raw.rb | 16 + lib/cmdx/messages.rb | 67 ++++ lib/cmdx/middleware_stack.rb | 103 ++++++ lib/cmdx/middlewares/correlate.rb | 85 +++++ lib/cmdx/middlewares/runtime.rb | 33 ++ lib/cmdx/middlewares/timeout.rb | 45 +++ lib/cmdx/result.rb | 304 ++++++++++++++++ lib/cmdx/returns.rb | 65 ++++ lib/cmdx/settings.rb | 126 +++++++ lib/cmdx/task.rb | 384 +++++++++++++++++++++ lib/cmdx/validators.rb | 206 +++++++++++ lib/cmdx/version.rb | 5 +- lib/cmdx/workflow.rb | 141 ++++++++ spec/cmdx/callable_spec.rb | 72 ++++ spec/cmdx/chain_spec.rb | 70 ++++ spec/cmdx/coercions_spec.rb | 105 ++++++ spec/cmdx/configuration_spec.rb | 40 +++ spec/cmdx/context_spec.rb | 107 ++++++ spec/cmdx/error_set_spec.rb | 55 +++ spec/cmdx/errors_spec.rb | 35 ++ spec/cmdx/log_formatters/json_spec.rb | 21 ++ spec/cmdx/log_formatters/key_value_spec.rb | 12 + spec/cmdx/log_formatters/line_spec.rb | 12 + spec/cmdx/log_formatters/logstash_spec.rb | 15 + spec/cmdx/log_formatters/raw_spec.rb | 10 + spec/cmdx/messages_spec.rb | 25 ++ spec/cmdx/middlewares/correlate_spec.rb | 44 +++ spec/cmdx/middlewares/runtime_spec.rb | 22 ++ spec/cmdx/result_spec.rb | 129 +++++++ spec/cmdx/settings_spec.rb | 65 ++++ spec/cmdx/task_spec.rb | 338 ++++++++++++++++++ spec/cmdx/validators_spec.rb | 115 ++++++ spec/cmdx/workflow_spec.rb | 179 ++++++++++ spec/spec_helper.rb | 18 - 51 files changed, 4441 insertions(+), 24 deletions(-) create mode 100644 lib/cmdx/attribute.rb create mode 100644 lib/cmdx/attribute_set.rb create mode 100644 lib/cmdx/callable.rb create mode 100644 lib/cmdx/callbacks.rb create mode 100644 lib/cmdx/chain.rb create mode 100644 lib/cmdx/coercions.rb create mode 100644 lib/cmdx/configuration.rb create mode 100644 lib/cmdx/context.rb create mode 100644 lib/cmdx/error_set.rb create mode 100644 lib/cmdx/errors.rb create mode 100644 lib/cmdx/log_entry.rb create mode 100644 lib/cmdx/log_formatters/json.rb create mode 100644 lib/cmdx/log_formatters/key_value.rb create mode 100644 lib/cmdx/log_formatters/line.rb create mode 100644 lib/cmdx/log_formatters/logstash.rb create mode 100644 lib/cmdx/log_formatters/raw.rb create mode 100644 lib/cmdx/messages.rb create mode 100644 lib/cmdx/middleware_stack.rb create mode 100644 lib/cmdx/middlewares/correlate.rb create mode 100644 lib/cmdx/middlewares/runtime.rb create mode 100644 lib/cmdx/middlewares/timeout.rb create mode 100644 lib/cmdx/result.rb create mode 100644 lib/cmdx/returns.rb create mode 100644 lib/cmdx/settings.rb create mode 100644 lib/cmdx/task.rb create mode 100644 lib/cmdx/validators.rb create mode 100644 lib/cmdx/workflow.rb create mode 100644 spec/cmdx/callable_spec.rb create mode 100644 spec/cmdx/chain_spec.rb create mode 100644 spec/cmdx/coercions_spec.rb create mode 100644 spec/cmdx/configuration_spec.rb create mode 100644 spec/cmdx/context_spec.rb create mode 100644 spec/cmdx/error_set_spec.rb create mode 100644 spec/cmdx/errors_spec.rb create mode 100644 spec/cmdx/log_formatters/json_spec.rb create mode 100644 spec/cmdx/log_formatters/key_value_spec.rb create mode 100644 spec/cmdx/log_formatters/line_spec.rb create mode 100644 spec/cmdx/log_formatters/logstash_spec.rb create mode 100644 spec/cmdx/log_formatters/raw_spec.rb create mode 100644 spec/cmdx/messages_spec.rb create mode 100644 spec/cmdx/middlewares/correlate_spec.rb create mode 100644 spec/cmdx/middlewares/runtime_spec.rb create mode 100644 spec/cmdx/result_spec.rb create mode 100644 spec/cmdx/settings_spec.rb create mode 100644 spec/cmdx/task_spec.rb create mode 100644 spec/cmdx/validators_spec.rb create mode 100644 spec/cmdx/workflow_spec.rb diff --git a/.rspec b/.rspec index 4c5e21d8f..7dd514d8b 100644 --- a/.rspec +++ b/.rspec @@ -2,3 +2,4 @@ --color --format progress --order random +--require spec_helper diff --git a/Gemfile.lock b/Gemfile.lock index 497a87f37..5a95d2ad9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,9 @@ PATH remote: . specs: - cmdx (1.21.0) + cmdx (2.0.0) bigdecimal logger - zeitwerk GEM remote: https://rubygems.org/ diff --git a/lib/cmdx/attribute.rb b/lib/cmdx/attribute.rb new file mode 100644 index 000000000..19c176e8d --- /dev/null +++ b/lib/cmdx/attribute.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +module CMDx + # Defines a single task attribute with its full processing pipeline: + # source resolution -> coercion -> transformation -> validation. + class Attribute + + RESERVED_NAMES = %i[ + id context ctx result res errors work execute execute! rollback + skip! fail! success! throw! dry_run? logger class freeze frozen? + ].to_set.freeze + + VALIDATOR_KEYS = %i[presence absence format inclusion exclusion length numeric].freeze + OPTION_KEYS = %i[required default type source transform as prefix suffix description desc if unless].freeze + COERCION_OPTION_KEYS = %i[strptime precision].freeze + + attr_reader :name, :options, :children + + # @param name [Symbol] + # @param options [Hash] + # @param children [CMDx::AttributeSet, nil] + def initialize(name, options = {}, children: nil) + @name = name.to_sym + @options = options.freeze + @children = children + end + + # @return [Boolean] + def required? + !!@options[:required] + end + + # @return [Symbol] the method name for the accessor + def accessor_name + return @options[:as].to_sym if @options[:as] + + base = @name.to_s + prefix = @options[:prefix] + suffix = @options[:suffix] + + prefix = prefix == true ? "context_" : prefix.to_s if prefix + suffix = suffix == true ? "_context" : suffix.to_s if suffix + + :"#{prefix}#{base}#{suffix}" + end + + # @return [String, nil] + def description + @options[:description] || @options[:desc] + end + + # Process this attribute for a given task: resolve source, coerce, + # transform, validate. Returns the final value and appends any + # errors to the provided ErrorSet. + # + # @param task [CMDx::Task] + # @param error_set [CMDx::ErrorSet] + # @param task_coercions [Hash, nil] + # @param task_validators [Hash, nil] + # @return [Object] the processed value + def process(task, error_set, task_coercions: nil, task_validators: nil) + value = resolve_source(task) + value = apply_default(task, value) + + if required_for?(task) && value.nil? + error_set.add(@name, Messages.resolve("attribute.required")) + return value + end + + value = coerce(value, error_set, task_coercions: task_coercions) + return value if error_set.for?(@name) + + value = transform(task, value) + + validate(value, task, error_set, task_validators: task_validators) + + if @children && value.is_a?(Hash) && !error_set.for?(@name) + process_children(task, value, error_set, + task_coercions: task_coercions, + task_validators: task_validators) + end + + value + end + + # @return [Hash] introspection schema + def to_schema + schema = { required: required? } + schema[:types] = Array(@options[:type]) if @options[:type] + schema[:default] = @options[:default] if @options.key?(:default) + schema[:description] = description if description + schema[:source] = @options[:source] if @options[:source] + + VALIDATOR_KEYS.each do |key| + schema[key] = @options[key] if @options.key?(key) + end + + schema[:children] = @children.schema if @children + + schema + end + + private + + def resolve_source(task) + source = @options[:source] + + case source + when nil, :context + task.context[@name] + when Symbol + src = Callable.resolve(source, task) + src.respond_to?(:[]) ? src[@name] : src + else + Callable.resolve(source, task) + end + end + + def apply_default(task, value) + return value unless value.nil? && @options.key?(:default) + + default = @options[:default] + case default + when Symbol then Callable.resolve(default, task) + when Proc then default.call + else default + end + end + + def required_for?(task) + return false unless required? + + req_if = @options[:if] + req_unless = @options[:unless] + + return !Callable.evaluate(req_unless, task) if req_unless + return Callable.evaluate(req_if, task) if req_if + + true + end + + def coerce(value, error_set, task_coercions: nil) + return value unless @options[:type] && !value.nil? + + Coercions.coerce(@options[:type], value, @options, task_registry: task_coercions) + rescue CoercionError => e + error_set.add(@name, e.message) + value + end + + def transform(task, value) + return value unless @options[:transform] && !value.nil? + + Callable.resolve(@options[:transform], task, value) + end + + def validate(value, task, error_set, task_validators: nil) + return if value.nil? && !required? + + VALIDATOR_KEYS.each do |key| + next unless @options.key?(key) + + msg = Validators.validate(key, value, @options[key], + task: task, task_registry: task_validators) + error_set.add(@name, msg) if msg + end + + custom_validators(value, task, error_set, task_validators: task_validators) + end + + def custom_validators(value, task, error_set, task_validators: nil) + @options.each do |key, opts| + next if VALIDATOR_KEYS.include?(key) + next if OPTION_KEYS.include?(key) + next if COERCION_OPTION_KEYS.include?(key) + + msg = Validators.validate(key, value, opts, + task: task, task_registry: task_validators) + error_set.add(@name, msg) if msg + end + end + + def process_children(task, parent_value, error_set, task_coercions: nil, task_validators: nil) + @children.each_attribute do |child_attr| + child_value = parent_value[child_attr.name] + child_attr.process(task, error_set, + task_coercions: task_coercions, + task_validators: task_validators) + task.context[child_attr.name] = child_value if child_value + end + end + + end +end diff --git a/lib/cmdx/attribute_set.rb b/lib/cmdx/attribute_set.rb new file mode 100644 index 000000000..71a8d6221 --- /dev/null +++ b/lib/cmdx/attribute_set.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module CMDx + # Ordered collection of Attribute objects. Managed at the class level on Task, + # inherited via the `inherited` hook with deep duplication. + class AttributeSet + + def initialize + @attributes = {} + end + + def initialize_copy(source) + super + @attributes = source.instance_variable_get(:@attributes).transform_values do |attr| + Attribute.new(attr.name, attr.options.dup, + children: attr.children&.dup) + end + end + + # Define one or more attributes. + # + # @param names [Array] + # @param options [Hash] + # @param block [Proc] for nesting child attributes + # @return [void] + def define(*names, **options, &block) + names.each do |name| + sym = name.to_sym + + raise ArgumentError, Messages.resolve("attribute.reserved") if Attribute::RESERVED_NAMES.include?(sym) + + children = nil + if block + children = AttributeSet.new + children.instance_eval(&block) + end + + @attributes[sym] = Attribute.new(sym, options, children: children) + end + end + + # Remove an attribute and its children. + # + # @param name [Symbol] + # @return [void] + def remove(name) + @attributes.delete(name.to_sym) + end + + # @return [Integer] + def size + @attributes.size + end + + # @return [Boolean] + def empty? + @attributes.empty? + end + + # @param name [Symbol] + # @return [CMDx::Attribute, nil] + def [](name) + @attributes[name.to_sym] + end + + # Iterate over attributes in definition order. + # @yield [CMDx::Attribute] + def each_attribute(&) + @attributes.each_value(&) + end + + # Process all attributes for a task, returning processed values and errors. + # + # @param task [CMDx::Task] + # @param task_coercions [Hash, nil] + # @param task_validators [Hash, nil] + # @return [Hash] processed attribute values + def process(task, task_coercions: nil, task_validators: nil) + values = {} + error_set = task.errors + + each_attribute do |attr| + values[attr.name] = attr.process(task, error_set, + task_coercions: task_coercions, + task_validators: task_validators) + end + + values + end + + # @return [Hash] full attribute schema for introspection + def schema + @attributes.transform_values(&:to_schema) + end + + # Define accessor methods on the target class for all attributes. + # + # @param klass [Class] + # @return [void] + def define_accessors(klass) + each_attribute do |attr| + method_name = attr.accessor_name + attr_name = attr.name + + klass.define_method(method_name) { @__attributes__[attr_name] } unless klass.method_defined?(method_name) + end + end + + end +end diff --git a/lib/cmdx/callable.rb b/lib/cmdx/callable.rb new file mode 100644 index 000000000..8a3c7abc4 --- /dev/null +++ b/lib/cmdx/callable.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module CMDx + # Utility module for resolving callable forms used across the framework. + # Supports: Symbol (method on receiver), Proc/Lambda, Class/Module (.call), + # and instances responding to #call. + module Callable + + # Pre-resolve a non-symbol callable to a Proc at registration time. + # Symbols are returned as-is (they need an instance to resolve). + # + # @param callable [Symbol, Proc, Class, #call] the callable to wrap + # @return [Symbol, Proc] a symbol or a proc ready to call + def self.wrap(callable) + case callable + when Symbol, Proc then callable + when Class + if callable.instance_method(:initialize).arity.zero? + instance = callable.new + ->(*args, **kw, &blk) { instance.call(*args, **kw, &blk) } + else + ->(*args, **kw, &blk) { callable.call(*args, **kw, &blk) } + end + when Module + ->(*args, **kw, &blk) { callable.call(*args, **kw, &blk) } + else + if callable.respond_to?(:call) + ->(*args, **kw, &blk) { callable.call(*args, **kw, &blk) } + else + callable + end + end + end + + # Resolve and invoke a callable at runtime. + # + # @param callable [Symbol, Proc, Class, #call] the callable to invoke + # @param receiver [Object] the object for symbol resolution (method receiver) + # @param args [Array] positional arguments + # @param kwargs [Hash] keyword arguments + # @param block [Proc] optional block to pass through + # @return [Object] the result of the call + def self.resolve(callable, receiver, ...) + case callable + when Symbol then receiver.send(callable, ...) + when Proc then callable.call(...) + else + if callable.respond_to?(:call) + callable.call(...) + else + callable + end + end + end + + # Evaluate a condition (if/unless) in the context of a receiver. + # + # @param condition [Symbol, Proc, Class, #call, nil] the condition + # @param receiver [Object] the object for symbol resolution + # @return [Boolean] + def self.condition_met?(condition, receiver) + return true if condition.nil? + + !!resolve(condition, receiver) + end + + class << self + + alias evaluate condition_met? + + end + + end +end diff --git a/lib/cmdx/callbacks.rb b/lib/cmdx/callbacks.rb new file mode 100644 index 000000000..4834499dc --- /dev/null +++ b/lib/cmdx/callbacks.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module CMDx + # Class-level callback registry with lifecycle hooks. + # Mixed into Task to provide before/after execution callbacks. + module Callbacks + + TYPES = %i[ + before_validation before_execution + on_complete on_interrupted on_executed + on_success on_skipped on_failed + on_good on_bad + ].freeze + + STATUS_MAP = { + on_complete: ->(_r) { true }, + on_interrupted: ->(_r) { true }, + on_executed: ->(_r) { true }, + on_success: lambda(&:success?), + on_skipped: lambda(&:skipped?), + on_failed: lambda(&:failed?), + on_good: lambda(&:good?), + on_bad: lambda(&:bad?) + }.freeze + + STATE_MAP = { + on_complete: lambda(&:complete?), + on_interrupted: lambda(&:interrupted?), + on_executed: lambda(&:executed?) + }.freeze + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + + def inherited(subclass) + super + subclass.instance_variable_set(:@callbacks, deep_dup_callbacks) + end + + # @return [Hash] + def callback_registry + @callback_registry ||= Hash.new { |h, k| h[k] = [] } + end + + TYPES.each do |type| + define_method(type) do |*callables, **conditions| + callables.each do |cb| + callback_registry[type] << { callable: Callable.wrap(cb), conditions: conditions } + end + end + end + + # Remove a callback. + # + # @param type [Symbol] + # @param callable_to_remove [Symbol, Class] + # @return [void] + def deregister_callback(type, callable_to_remove) + return unless @callbacks&.key?(type) + + @callbacks[type].reject! do |entry| + c = entry[:callable] + c == callable_to_remove || + (c.is_a?(Symbol) && c == callable_to_remove) || + (callable_to_remove.is_a?(Class) && c.is_a?(callable_to_remove)) + end + end + + private + + def deep_dup_callbacks + return Hash.new { |h, k| h[k] = [] } unless instance_variable_defined?(:@callbacks) + + @callbacks.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(type, entries), dup| + dup[type] = entries.map(&:dup) + end + end + + end + + private + + # Run before-type callbacks. + def run_before_callbacks(type) + entries = merged_callbacks(type) + entries.each do |entry| + next unless conditions_met?(entry[:conditions]) + + Callable.resolve(entry[:callable], self) + end + end + + # Run after-type callbacks (state/status-based). + def run_after_callbacks + run_state_callbacks + run_status_callbacks + end + + def run_state_callbacks + %i[on_complete on_interrupted on_executed].each do |type| + state_check = Callbacks::STATE_MAP[type] + next unless state_check&.call(result) + + merged_callbacks(type).each do |entry| + next unless conditions_met?(entry[:conditions]) + + Callable.resolve(entry[:callable], self) + end + end + end + + def run_status_callbacks + %i[on_success on_skipped on_failed on_good on_bad].each do |type| + status_check = Callbacks::STATUS_MAP[type] + next unless status_check&.call(result) + + merged_callbacks(type).each do |entry| + next unless conditions_met?(entry[:conditions]) + + Callable.resolve(entry[:callable], self) + end + end + end + + def merged_callbacks(type) + global = CMDx.configuration.callbacks[type] || [] + task_level = self.class.callback_registry[type] || [] + global + task_level + end + + def conditions_met?(conditions) + return true if conditions.empty? + + return false if conditions.key?(:if) && !Callable.evaluate(conditions[:if], self) + + return false if conditions.key?(:unless) && Callable.evaluate(conditions[:unless], self) + + true + end + + end +end diff --git a/lib/cmdx/chain.rb b/lib/cmdx/chain.rb new file mode 100644 index 000000000..8c85ecd87 --- /dev/null +++ b/lib/cmdx/chain.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module CMDx + # Thread/fiber-local execution trace that tracks related task results. + class Chain + + extend Forwardable + + FIBER_STORAGE = Fiber.respond_to?(:[]) + UUID_V7 = SecureRandom.respond_to?(:uuid_v7) + + STORAGE_KEY = :cmdx_chain + private_constant :STORAGE_KEY + + attr_reader :id, :results + + def_delegators :@results, :size, :first, :last, :each + + def initialize + @id = UUID_V7 ? SecureRandom.uuid_v7 : SecureRandom.uuid + @results = [] + @depth = 0 + end + + # Get or create the current fiber/thread-local chain. + # + # @return [CMDx::Chain] + def self.current + if FIBER_STORAGE + Fiber[STORAGE_KEY] + else + Thread.current[STORAGE_KEY] + end + end + + # Set the current chain. + # + # @param chain [CMDx::Chain, nil] + # @return [void] + def self.current=(chain) + if FIBER_STORAGE + Fiber[STORAGE_KEY] = chain + else + Thread.current[STORAGE_KEY] = chain + end + end + + # Clear the current fiber/thread-local chain. + # + # @return [void] + def self.clear + self.current = nil + end + + # Add a result to the chain. + # + # @param result [CMDx::Result] + # @return [void] + def add(result) + result.index = @results.size + result.chain = self + @results << result + end + + # Track nesting depth for outermost-task detection. + # + # @return [Integer] + def enter + @depth += 1 + end + + # @return [Integer] + def exit + @depth -= 1 + end + + # @return [Boolean] + def outermost? + @depth.zero? + end + + # @return [Boolean] + def dry_run? + first&.dry_run? || false + end + + # Delegates state from the first (outermost) result. + # @return [String, nil] + def state + first&.state + end + + # @return [String, nil] + def status + first&.status + end + + # @return [String, nil] + def outcome + first&.outcome + end + + def freeze + @results.freeze + super + end + + def inspect + "#<#{self.class} id=#{@id} results=#{@results.size}>" + end + + end +end diff --git a/lib/cmdx/coercions.rb b/lib/cmdx/coercions.rb new file mode 100644 index 000000000..4cfad967d --- /dev/null +++ b/lib/cmdx/coercions.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +module CMDx + # Module-level registry of type coercions. Each coercion converts a value + # to a target type, raising CoercionError on failure. + module Coercions + + BOOLEAN_TRUE = %w[true yes on 1 t y].freeze + BOOLEAN_FALSE = %w[false no off 0 f n].freeze + + @registry = {} + + class << self + + # @return [Hash] + attr_reader :registry + + # Register a custom coercion. + # + # @param name [Symbol] + # @param callable [Proc, #call] + # @return [void] + def register(name, callable) + @registry[name.to_sym] = Callable.wrap(callable) + end + + # @param name [Symbol] + # @return [void] + def deregister(name) + @registry.delete(name.to_sym) + end + + # Coerce a value to the given type(s). + # + # @param types [Symbol, Array] one or more type names + # @param value [Object] the value to coerce + # @param options [Hash] coercion options (e.g., strptime, precision) + # @param task_registry [Hash, nil] per-task coercion overrides + # @return [Object] the coerced value + # @raise [CMDx::CoercionError] if coercion fails + def coerce(types, value, options = {}, task_registry: nil) + type_list = Array(types) + return value if value.nil? + + type_list.each do |type| + coercer = (task_registry && task_registry[type]) || @registry[type] + raise CoercionError, "Unknown coercion type: #{type}" unless coercer + + begin + return coercer.call(value, options) + rescue CoercionError + next + end + end + + raise CoercionError, Messages.resolve("coercion.single", type: type_list.first) if type_list.size == 1 + + raise CoercionError, Messages.resolve("coercion.multi", types: type_list.join(", ")) + end + + end + + # -- Built-in coercions -- + + register(:array, lambda { |value, _options| + case value + when Array then value + when String + begin + parsed = JSON.parse(value) + parsed.is_a?(Array) ? parsed : [parsed] + rescue JSON::ParserError + [value] + end + else [value] + end + }) + + register(:big_decimal, lambda { |value, options| + begin + result = BigDecimal(value.to_s) + result = result.round(options[:precision]) if options&.dig(:precision) + result + rescue ArgumentError, TypeError + raise CoercionError, Messages.resolve("coercion.single", type: :big_decimal) + end + }) + + register(:boolean, lambda { |value, _options| + str = value.to_s.downcase.strip + return true if BOOLEAN_TRUE.include?(str) + return false if BOOLEAN_FALSE.include?(str) + + raise CoercionError, Messages.resolve("coercion.single", type: :boolean) + }) + + register(:complex, lambda { |value, _options| + begin + Complex(value) + rescue ArgumentError, TypeError + raise CoercionError, Messages.resolve("coercion.single", type: :complex) + end + }) + + register(:date, lambda { |value, options| + begin + if options&.dig(:strptime) + Date.strptime(value.to_s, options[:strptime]) + else + value.is_a?(Date) ? value : Date.parse(value.to_s) + end + rescue ArgumentError, TypeError + raise CoercionError, Messages.resolve("coercion.single", type: :date) + end + }) + + register(:datetime, lambda { |value, options| + begin + if options&.dig(:strptime) + DateTime.strptime(value.to_s, options[:strptime]) + else + value.is_a?(DateTime) ? value : DateTime.parse(value.to_s) + end + rescue ArgumentError, TypeError + raise CoercionError, Messages.resolve("coercion.single", type: :datetime) + end + }) + + register(:float, lambda { |value, _options| + begin + Float(value) + rescue ArgumentError, TypeError + raise CoercionError, Messages.resolve("coercion.single", type: :float) + end + }) + + register(:hash, lambda { |value, _options| + case value + when Hash then value + when String + begin + parsed = JSON.parse(value) + parsed.is_a?(Hash) ? parsed : raise(CoercionError, Messages.resolve("coercion.single", type: :hash)) + rescue JSON::ParserError + raise CoercionError, Messages.resolve("coercion.single", type: :hash) + end + else + raise CoercionError, Messages.resolve("coercion.single", type: :hash) unless value.respond_to?(:to_h) + + value.to_h + + end + }) + + register(:integer, lambda { |value, _options| + begin + Integer(value) + rescue ArgumentError, TypeError + raise CoercionError, Messages.resolve("coercion.single", type: :integer) + end + }) + + register(:rational, lambda { |value, _options| + begin + Rational(value) + rescue ArgumentError, TypeError, ZeroDivisionError + raise CoercionError, Messages.resolve("coercion.single", type: :rational) + end + }) + + register(:string, lambda { |value, _options| + value.to_s + }) + + register(:symbol, lambda { |value, _options| + begin + value.to_s.to_sym + rescue NoMethodError + raise CoercionError, Messages.resolve("coercion.single", type: :symbol) + end + }) + + register(:time, lambda { |value, options| + begin + if options&.dig(:strptime) + Time.strptime(value.to_s, options[:strptime]) + else + value.is_a?(Time) ? value : Time.parse(value.to_s) + end + rescue ArgumentError, TypeError + raise CoercionError, Messages.resolve("coercion.single", type: :time) + end + }) + + end +end diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb new file mode 100644 index 000000000..a07127f37 --- /dev/null +++ b/lib/cmdx/configuration.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module CMDx + # Global configuration with sensible defaults. + # Holds framework-wide settings and global middleware/callback registries. + class Configuration + + attr_accessor :task_breakpoints, :workflow_breakpoints, :rollback_on, + :dump_context, :freeze_results, + :backtrace, :backtrace_cleaner, :exception_handler, + :logger + + def initialize + @task_breakpoints = %w[failed] + @workflow_breakpoints = %w[failed] + @rollback_on = %w[failed] + @dump_context = false + @freeze_results = true + @backtrace = false + @backtrace_cleaner = nil + @exception_handler = nil + @logger = default_logger + @middlewares = [] + @callbacks = Hash.new { |h, k| h[k] = [] } + end + + # -- Global middleware registry -- + + # @return [Array] + attr_reader :middlewares + + # -- Global callback registry -- + + # @return [Hash] + attr_reader :callbacks + + private + + def default_logger + log = Logger.new($stdout) + log.level = Logger::INFO + log.progname = "cmdx" + log + end + + end +end diff --git a/lib/cmdx/context.rb b/lib/cmdx/context.rb new file mode 100644 index 000000000..d86b2796a --- /dev/null +++ b/lib/cmdx/context.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module CMDx + # Data container for task inputs, intermediate values, and outputs. + # Wraps a Hash internally for fast lookups with dynamic method access. + class Context + + # Build a Context from various input types. + # + # @param input [Hash, Context, Result, Task, nil] the input to build from + # @return [CMDx::Context] + # @raise [ArgumentError] if input can't be converted to a hash + def self.build(input = nil) + case input + when nil then new + when Context then input.frozen? ? new(input.to_h) : input + when Hash then new(input) + else + if input.respond_to?(:context) + ctx = input.context + ctx.frozen? ? new(ctx.to_h) : ctx + elsif input.respond_to?(:to_h) + new(input.to_h) + else + raise ArgumentError, "Cannot build Context from #{input.class}" + end + end + end + + def initialize(data = {}) + @data = {} + data.each { |k, v| @data[k.to_sym] = v } + end + + # @return [Object, nil] + def [](key) + @data[key.to_sym] + end + + # @return [Object] + def []=(key, value) + @data[key.to_sym] = value + end + + # @param key [Symbol, String] + # @param default [Object] + # @return [Object] + def fetch(key, ...) + @data.fetch(key.to_sym, ...) + end + + # @param keys [Array] + # @return [Object, nil] + def dig(*keys) + @data.dig(*keys.map { |k| k.is_a?(String) ? k.to_sym : k }) + end + + # @return [Boolean] + def key?(key) + @data.key?(key.to_sym) + end + + # Fetch existing value or store and return the default. + # + # @param key [Symbol, String] + # @param default [Object] + # @return [Object] + def fetch_or_store(key, default = nil) + sym = key.to_sym + return @data[sym] if @data.key?(sym) + + @data[sym] = default + end + + # @param other [Hash] + # @return [self] + def merge!(other) + other.each { |k, v| @data[k.to_sym] = v } + self + end + + # @param key [Symbol, String] + # @return [Object, nil] + def delete!(key) + @data.delete(key.to_sym) + end + + # @return [self] + def clear! + @data.clear + self + end + + # @yield [key, value] + def each(&) + @data.each(&) + end + + # @yield [key, value] + # @return [Array] + def map(&) + @data.map(&) + end + + # @return [Hash] a duplicate of the internal data + def to_h + @data.dup + end + alias to_hash to_h + + # @return [Boolean] + def empty? + @data.empty? + end + + # @return [Integer] + def size + @data.size + end + + # @return [Array] + def keys + @data.keys + end + + # @return [Array] + def values + @data.values + end + + def freeze + @data.freeze + super + end + + def inspect + "#<#{self.class} #{@data.inspect}>" + end + + private + + def respond_to_missing?(name, include_private = false) + writer = name.to_s.end_with?("=") + key = writer ? name.to_s.chomp("=").to_sym : name.to_sym + writer || @data.key?(key) || super + end + + def method_missing(name, *args) + method_name = name.to_s + + if method_name.end_with?("=") + key = method_name.chomp("=").to_sym + @data[key] = args.first + elsif @data.key?(name) + @data[name] + elsif args.empty? + nil + else + super + end + end + + end +end diff --git a/lib/cmdx/error_set.rb b/lib/cmdx/error_set.rb new file mode 100644 index 000000000..f716f18a7 --- /dev/null +++ b/lib/cmdx/error_set.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module CMDx + # Collection of validation error messages keyed by attribute name. + class ErrorSet + + def initialize + @errors = {} + end + + # @param attribute [Symbol] the attribute name + # @param message [String] the error message + # @return [void] + def add(attribute, message) + (@errors[attribute.to_sym] ||= []) << message + end + + # @return [Boolean] + def any? + !@errors.empty? + end + + # @return [Boolean] + def empty? + @errors.empty? + end + + # @return [Integer] number of attributes with errors + def size + @errors.size + end + + # @param attribute [Symbol] + # @return [Boolean] + def for?(attribute) + @errors.key?(attribute.to_sym) + end + + # @return [Hash>] + def to_h + @errors.dup + end + + # @return [Hash>] messages prefixed with attribute name + def full_messages + @errors.each_with_object({}) do |(attr, msgs), hash| + hash[attr] = msgs.map { |m| "#{attr} #{m}" } + end + end + + # @return [String] all full messages joined by ". " + def to_s + full_messages.values.flatten.join(". ") + end + + # Clear all errors. + # @return [void] + def clear + @errors.clear + end + + def freeze + @errors.freeze + super + end + + def inspect + "#<#{self.class} #{@errors.inspect}>" + end + + end +end diff --git a/lib/cmdx/errors.rb b/lib/cmdx/errors.rb new file mode 100644 index 000000000..2dddc8b01 --- /dev/null +++ b/lib/cmdx/errors.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module CMDx + + # @abstract Base class for all CMDx exceptions. + class Error < StandardError; end + + Exception = Error + + # @abstract Raised when a type coercion fails. + class CoercionError < Error; end + + # @abstract Raised when a deprecated task is executed with `deprecate: :raise`. + class DeprecationError < Error; end + + # @abstract Raised when a task is executed without defining a `work` method. + class UndefinedMethodError < Error; end + + # @abstract Raised when a custom validator rejects a value. + class ValidationError < Error; end + + # @abstract Base class for execution interruptions raised by `execute!`. + # Carries a `result` with full execution context. + class Fault < Error + + attr_reader :result + + def initialize(message = nil, result: nil) + @result = result + super(message) + end + + # @return [CMDx::Task, nil] + def task + result&.task + end + + # @return [CMDx::Context, nil] + def context + result&.context + end + + # @return [CMDx::Chain, nil] + def chain + result&.chain + end + + # Matches faults originating from specific task classes. + # @example + # rescue CMDx::FailFault.for?(MyTask, OtherTask) => e + # @param classes [Array] task classes to match + # @return [Module] module with `===` defined for rescue matching + def self.for?(*classes) + fault_class = self + matcher = Object.new + matcher.define_singleton_method(:===) do |fault| + fault.is_a?(fault_class) && fault.task && classes.any? { |klass| fault.task.is_a?(klass) } + end + matcher + end + + # Matches faults using custom block logic. + # @example + # rescue CMDx::Fault.matches? { |f| f.context.amount > 1000 } => e + # @yield [fault] block that receives the fault and returns truthy/falsy + # @return [Object] object with `===` defined for rescue matching + def self.matches?(&) + fault_class = self + matcher = Object.new + matcher.define_singleton_method(:===) do |fault| + fault.is_a?(fault_class) && yield(fault) + end + matcher + end + + end + + # @abstract Raised when a task is skipped via `skip!` during `execute!`. + class SkipFault < Fault; end + + # @abstract Raised when a task fails via `fail!`, validation errors, + # or exceptions during `execute!`. + class FailFault < Fault; end + + # @abstract Raised when a task exceeds its timeout limit. + # Inherits from Interrupt so `rescue StandardError` won't catch it. + class TimeoutError < Interrupt; end + +end diff --git a/lib/cmdx/log_entry.rb b/lib/cmdx/log_entry.rb new file mode 100644 index 000000000..faf0eb737 --- /dev/null +++ b/lib/cmdx/log_entry.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module CMDx + # Builds structured log data from a Result and dispatches to the logger. + module LogEntry + + # Log a task result. + # + # @param result [CMDx::Result] + # @param settings [CMDx::Settings] + # @return [void] + def self.log(result, settings) + logger = settings.logger || CMDx.configuration.logger + return unless logger + + data = build(result, settings) + level = settings.log_level || :info + formatter = settings.log_formatter + + if formatter + logger.add(severity_level(level), formatter.call(data)) + else + logger.public_send(level, "cmdx") { data.inspect } + end + end + + # Build the structured log data hash. + # + # @param result [CMDx::Result] + # @param settings [CMDx::Settings] + # @return [Hash] + def self.build(result, settings) + task = result.task + is_workflow = task.class.respond_to?(:workflow_tasks) + + data = { + index: result.index, + chain_id: result.chain&.id, + type: is_workflow ? "Workflow" : "Task", + tags: settings.tags, + class: task.class.name, + dry_run: result.dry_run?, + id: task.id, + state: result.state, + status: result.status, + outcome: result.outcome, + metadata: result.metadata + } + + if result.interrupted? + data[:reason] = result.reason + data[:cause] = result.cause + data[:rolled_back] = result.rolled_back? + end + + data[:retries] = result.retries if result.retried? + + data[:context] = result.context.to_h if settings.dump_context + + if result.failed? && result.metadata[:threw_from] + data[:threw_failure] = result.metadata[:threw_from] + data[:caused_failure] = result.metadata[:caused_by] + end + + data + end + + # @param level [Symbol] + # @return [Integer] + def self.severity_level(level) + case level.to_sym + when :debug then Logger::DEBUG + when :warn then Logger::WARN + when :error then Logger::ERROR + when :fatal then Logger::FATAL + else Logger::INFO + end + end + + end +end diff --git a/lib/cmdx/log_formatters/json.rb b/lib/cmdx/log_formatters/json.rb new file mode 100644 index 000000000..2a320ef82 --- /dev/null +++ b/lib/cmdx/log_formatters/json.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module CMDx + module LogFormatters + # JSON log formatter for structured logging systems. + class Json + + # @param data [Hash] structured log data + # @return [String] + def call(data) + sanitized = deep_serialize(data) + JSON.generate(sanitized) + end + + private + + def deep_serialize(obj) + case obj + when Hash then obj.transform_values { |v| deep_serialize(v) } + when Array then obj.map { |v| deep_serialize(v) } + when ::Exception then "#{obj.class}: #{obj.message}" + when Symbol then obj.to_s + else obj + end + end + + end + end +end diff --git a/lib/cmdx/log_formatters/key_value.rb b/lib/cmdx/log_formatters/key_value.rb new file mode 100644 index 000000000..a1d1b57ab --- /dev/null +++ b/lib/cmdx/log_formatters/key_value.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module CMDx + module LogFormatters + # Key=value pairs formatter for log parsing tools. + class KeyValue + + # @param data [Hash] structured log data + # @return [String] + def call(data) + data.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ") + end + + private + + def format_value(value) + case value + when String then "\"#{value}\"" + when Hash then "\"{#{value.map { |k, v| "#{k}: #{v}" }.join(', ')}}\"" + when Array then "\"[#{value.join(', ')}]\"" + when nil then "nil" + else value.to_s + end + end + + end + end +end diff --git a/lib/cmdx/log_formatters/line.rb b/lib/cmdx/log_formatters/line.rb new file mode 100644 index 000000000..5f35a6e0e --- /dev/null +++ b/lib/cmdx/log_formatters/line.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module CMDx + module LogFormatters + # Default single-line log formatter. + class Line + + # @param data [Hash] structured log data + # @return [String] + def call(data) + data.map { |k, v| "#{k}: #{v.inspect}" }.join(", ") + end + + end + end +end diff --git a/lib/cmdx/log_formatters/logstash.rb b/lib/cmdx/log_formatters/logstash.rb new file mode 100644 index 000000000..eaab8d318 --- /dev/null +++ b/lib/cmdx/log_formatters/logstash.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module CMDx + module LogFormatters + # Logstash-compatible JSON formatter with @version and @timestamp. + class Logstash + + # @param data [Hash] structured log data + # @return [String] + def call(data) + entry = { + "@version" => "1", + "@timestamp" => Time.now.utc.iso8601(6), + "progname" => "cmdx", + "message" => deep_serialize(data) + } + JSON.generate(entry) + end + + private + + def deep_serialize(obj) + case obj + when Hash then obj.transform_keys(&:to_s).transform_values { |v| deep_serialize(v) } + when Array then obj.map { |v| deep_serialize(v) } + when ::Exception then "#{obj.class}: #{obj.message}" + when Symbol then obj.to_s + else obj + end + end + + end + end +end diff --git a/lib/cmdx/log_formatters/raw.rb b/lib/cmdx/log_formatters/raw.rb new file mode 100644 index 000000000..181f56eec --- /dev/null +++ b/lib/cmdx/log_formatters/raw.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module CMDx + module LogFormatters + # Minimal raw formatter -- outputs inspect representation only. + class Raw + + # @param data [Hash] structured log data + # @return [String] + def call(data) + data.inspect + end + + end + end +end diff --git a/lib/cmdx/messages.rb b/lib/cmdx/messages.rb new file mode 100644 index 000000000..0c30b43a5 --- /dev/null +++ b/lib/cmdx/messages.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module CMDx + # Centralized registry of all error/validation message templates. + # English-only in core; i18n is opt-in via `CMDx.message_resolver`. + module Messages + + TEMPLATES = { + "coercion.single" => "could not coerce into %s", + "coercion.multi" => "could not coerce into one of: %s", + + "attribute.required" => "is required", + "attribute.reserved" => "conflicts with a reserved method name", + + "validation.presence" => "can't be blank", + "validation.absence" => "must be blank", + "validation.format" => "is invalid", + "validation.inclusion.in" => "is not included in the list", + "validation.inclusion.within" => "is not included in the range %s", + "validation.exclusion.in" => "is reserved", + "validation.exclusion.within" => "is within the restricted range %s", + "validation.length.min" => "is too short (minimum is %s characters)", + "validation.length.max" => "is too long (maximum is %s characters)", + "validation.length.is" => "is the wrong length (should be %s characters)", + "validation.length.is_not" => "must not be %s characters", + "validation.length.within" => "length is not within range %s", + "validation.length.not_within" => "length must not be within range %s", + "validation.numeric.min" => "must be greater than or equal to %s", + "validation.numeric.max" => "must be less than or equal to %s", + "validation.numeric.is" => "must be equal to %s", + "validation.numeric.is_not" => "must not be equal to %s", + "validation.numeric.within" => "is not within range %s", + "validation.numeric.not_within" => "must not be within range %s", + + "return.missing" => "must be set in the context", + + "halt.unspecified" => "Unspecified", + "halt.invalid" => "Invalid", + + "deprecation.prohibited" => "%s usage prohibited", + "deprecation.warning" => "DEPRECATED: %s - migrate to replacement or discontinue use", + + "task.undefined_work" => "%s must define a `work` method", + "task.workflow_work_defined" => "%s must not define a `work` method when using Workflow", + + "middleware.no_yield" => "Middleware failed to yield execution", + + "timeout.exceeded" => "execution exceeded %s seconds" + }.freeze + + # Resolve a message template with optional interpolations. + # + # @param key [String] the message key + # @param interpolations [Hash] values to interpolate into the template + # @return [String] + def self.resolve(key, **interpolations) + resolver = CMDx.message_resolver + return resolver.call(key, **interpolations) if resolver + + template = TEMPLATES.fetch(key, key) + return template if interpolations.empty? + + format(template, **interpolations) + end + + end +end diff --git a/lib/cmdx/middleware_stack.rb b/lib/cmdx/middleware_stack.rb new file mode 100644 index 000000000..5b96d8f05 --- /dev/null +++ b/lib/cmdx/middleware_stack.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module CMDx + # Class-level middleware stack with onion-model execution. + # Cached per class; invalidated on register/deregister. + module MiddlewareStack + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + + def inherited(subclass) + super + subclass.instance_variable_set(:@middleware_stack, middleware_stack.dup) + subclass.instance_variable_set(:@middleware_chain, nil) + end + + # @return [Array] + def middleware_stack + @middleware_stack ||= [] + end + + # Register a middleware. + # + # @param klass_or_proc [Class, Proc, #call] + # @param options [Hash] + # @option options [Integer] :at insertion position + # @return [void] + def register_middleware(klass_or_proc, **options) + at = options.delete(:at) + entry = { callable: Callable.wrap(klass_or_proc), options: options } + + if at + middleware_stack.insert(at, entry) + else + middleware_stack << entry + end + + @middleware_chain = nil + end + + # Shortcut for register_middleware. + def middleware(klass_or_proc, **options) + register_middleware(klass_or_proc, **options) + end + + # Remove a middleware. + # + # @param klass [Class] + # @return [void] + def deregister_middleware(klass) + middleware_stack.reject! do |entry| + entry[:callable] == klass || entry[:callable].is_a?(klass) + rescue StandardError + false + end + @middleware_chain = nil + end + + # Build and cache the middleware execution chain. + # @api private + def middleware_chain + @middleware_chain ||= build_middleware_chain + end + + private + + def build_middleware_chain + global_stack = CMDx.configuration.middlewares + full_stack = global_stack + middleware_stack + full_stack.reverse + end + + end + + private + + # Execute the middleware chain around the given block. + def run_middleware_chain(&core) + chain = self.class.middleware_chain + return yield if chain.empty? + + execute_next = chain.reduce(core) do |next_fn, entry| + callable = entry[:callable] + options = entry[:options] + lambda { + yielded = false + result = Callable.resolve(callable, self, self, options) do + yielded = true + next_fn.call + end + self.result.fail!(Messages.resolve("middleware.no_yield")) unless yielded + result + } + end + + execute_next.call + end + + end +end diff --git a/lib/cmdx/middlewares/correlate.rb b/lib/cmdx/middlewares/correlate.rb new file mode 100644 index 000000000..7b2ad3f5c --- /dev/null +++ b/lib/cmdx/middlewares/correlate.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module CMDx + module Middlewares + # Adds correlation IDs for distributed tracing. + # Thread/fiber-safe via the same storage mechanism as Chain. + class Correlate + + STORAGE_KEY = :cmdx_correlation_id + private_constant :STORAGE_KEY + + class << self + + # @return [String, nil] + def id + if Chain::FIBER_STORAGE + Fiber[STORAGE_KEY] + else + Thread.current[STORAGE_KEY] + end + end + + # @param value [String, nil] + def id=(value) + if Chain::FIBER_STORAGE + Fiber[STORAGE_KEY] = value + else + Thread.current[STORAGE_KEY] = value + end + end + + # Scoped block with a specific correlation ID. + # Restores the previous ID after the block completes. + # + # @param correlation_id [String] + # @yield + def use(correlation_id) + previous = id + self.id = correlation_id + yield + ensure + self.id = previous + end + + # Clear the current correlation ID. + def clear + self.id = nil + end + + end + + def call(task, options = {}) + return yield if options.key?(:if) && !Callable.evaluate(options[:if], task) + + return yield if options.key?(:unless) && Callable.evaluate(options[:unless], task) + + correlation_id = resolve_id(task, options) + self.class.id ||= correlation_id + + result = yield + result.metadata[:correlation_id] = self.class.id if result + result + end + + private + + def resolve_id(task, options) + value = options[:id] + case value + when nil then self.class.id || generate_uuid + when String then value + when Symbol then task.send(value) + when Proc then value.call(task) + else + value.respond_to?(:call) ? value.call(task) : generate_uuid + end + end + + def generate_uuid + Chain::UUID_V7 ? SecureRandom.uuid_v7 : SecureRandom.uuid + end + + end + end +end diff --git a/lib/cmdx/middlewares/runtime.rb b/lib/cmdx/middlewares/runtime.rb new file mode 100644 index 000000000..50d54e72a --- /dev/null +++ b/lib/cmdx/middlewares/runtime.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module CMDx + module Middlewares + # Tracks task execution time using a monotonic clock. + class Runtime + + def call(task, options = {}) + return yield if options.key?(:if) && !Callable.evaluate(options[:if], task) + + return yield if options.key?(:unless) && Callable.evaluate(options[:unless], task) + + started_at = Time.now.utc + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + result = yield + + end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + ended_at = Time.now.utc + runtime_ms = ((end_time - start_time) * 1000).round + + if result + result.metadata[:started_at] = started_at.iso8601 + result.metadata[:ended_at] = ended_at.iso8601 + result.metadata[:runtime] = runtime_ms + end + + result + end + + end + end +end diff --git a/lib/cmdx/middlewares/timeout.rb b/lib/cmdx/middlewares/timeout.rb new file mode 100644 index 000000000..be5f1a6d1 --- /dev/null +++ b/lib/cmdx/middlewares/timeout.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module CMDx + module Middlewares + # Prevents tasks from exceeding a time limit. + # Raises CMDx::TimeoutError (inherits Interrupt, not StandardError). + class Timeout + + DEFAULT_SECONDS = 3 + + def call(task, options = {}, &) + seconds = resolve_seconds(task, options) + + return yield if options.key?(:if) && !Callable.evaluate(options[:if], task) + + return yield if options.key?(:unless) && Callable.evaluate(options[:unless], task) + + begin + ::Timeout.timeout(seconds, CMDx::TimeoutError, &) + rescue CMDx::TimeoutError => e + task.result.fail!( + Messages.resolve("timeout.exceeded", seconds: seconds), + limit: seconds + ) + task.result.instance_variable_set(:@cause, e) + task.result + end + end + + private + + def resolve_seconds(task, options) + value = options[:seconds] || DEFAULT_SECONDS + case value + when Numeric then value + when Symbol then task.send(value) + when Proc then value.call(task) + else + value.respond_to?(:call) ? value.call(task) : DEFAULT_SECONDS + end + end + + end + end +end diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb new file mode 100644 index 000000000..2e3c93b6a --- /dev/null +++ b/lib/cmdx/result.rb @@ -0,0 +1,304 @@ +# frozen_string_literal: true + +module CMDx + # Immutable execution outcome. Exposes state, status, context, metadata, + # and chain information for every task execution. + class Result + + STATES = %w[initialized executing complete interrupted].freeze + STATUSES = %w[success skipped failed].freeze + + STATE_INITIALIZED = "initialized" + STATE_EXECUTING = "executing" + STATE_COMPLETE = "complete" + STATE_INTERRUPTED = "interrupted" + + STATUS_SUCCESS = "success" + STATUS_SKIPPED = "skipped" + STATUS_FAILED = "failed" + + HANDLER_MAP = { + success: [STATUS_SUCCESS].freeze, + skipped: [STATUS_SKIPPED].freeze, + failed: [STATUS_FAILED].freeze, + complete: [STATE_COMPLETE].freeze, + interrupted: [STATE_INTERRUPTED].freeze, + executed: [STATE_COMPLETE, STATE_INTERRUPTED].freeze, + good: [STATUS_SUCCESS, STATUS_SKIPPED].freeze, + ok: [STATUS_SUCCESS, STATUS_SKIPPED].freeze, + bad: [STATUS_SKIPPED, STATUS_FAILED].freeze + }.freeze + + attr_accessor :index, :chain + attr_reader :task, :context, :state, :status, :reason, :cause, :metadata, :retries + + def initialize(task:, context:) + @task = task + @context = context + @chain = nil + @state = STATE_INITIALIZED + @status = STATUS_SUCCESS + @reason = nil + @cause = nil + @metadata = {} + @index = 0 + @retries = 0 + @rolled_back = false + @strict = false + end + + # -- State predicates -- + + # @return [Boolean] + def initialized? + @state == STATE_INITIALIZED + end + + # @return [Boolean] + def executing? + @state == STATE_EXECUTING + end + + # @return [Boolean] + def complete? + @state == STATE_COMPLETE + end + + # @return [Boolean] + def interrupted? + @state == STATE_INTERRUPTED + end + + # @return [Boolean] true if complete or interrupted + def executed? + complete? || interrupted? + end + + # -- Status predicates -- + + # @return [Boolean] + def success? + @status == STATUS_SUCCESS + end + + # @return [Boolean] + def skipped? + @status == STATUS_SKIPPED + end + + # @return [Boolean] + def failed? + @status == STATUS_FAILED + end + + # @return [Boolean] true if success or skipped + def good? + success? || skipped? + end + alias ok? good? + + # @return [Boolean] true if skipped or failed + def bad? + skipped? || failed? + end + + # @return [String] unified outcome + def outcome + complete? ? STATUS_SUCCESS : @status + end + + # @return [Boolean] + def strict? + @strict + end + + # @return [Boolean] + def retried? + @retries.positive? + end + + # @return [Boolean] + def rolled_back? + @rolled_back + end + + # @return [Boolean] + def dry_run? + !!context[:dry_run] + end + + # -- State transitions (internal) -- + + # @api private + def transition_to_executing! + @state = STATE_EXECUTING + end + + # @api private + def transition_to_complete! + @state = STATE_COMPLETE + end + + # @api private + def transition_to_interrupted! + @state = STATE_INTERRUPTED + end + + # @api private + def skip!(reason = nil, **meta) + return if @status == STATUS_SKIPPED + + raise "Cannot transition from #{@status} to skipped" if @status != STATUS_SUCCESS + + @status = STATUS_SKIPPED + @state = STATE_INTERRUPTED + @reason = reason || Messages.resolve("halt.unspecified") + @cause = SkipFault.new(@reason, result: self) + @metadata.merge!(meta) + end + + # @api private + def fail!(reason = nil, **meta) + return if @status == STATUS_FAILED + + raise "Cannot transition from #{@status} to failed" if @status != STATUS_SUCCESS + + @status = STATUS_FAILED + @state = STATE_INTERRUPTED + @reason = reason || Messages.resolve("halt.unspecified") + @cause = FailFault.new(@reason, result: self) + @metadata.merge!(meta) + end + + # @api private + def succeed!(reason = nil, **meta) + raise "Cannot annotate success when status is #{@status}" unless @status == STATUS_SUCCESS + + @reason = reason + @metadata.merge!(meta) + end + + # @api private + def fail_from_exception!(exception, backtrace_opt: false, backtrace_cleaner: nil) + @status = STATUS_FAILED + @state = STATE_INTERRUPTED + @reason = "[#{exception.class}] #{exception.message}" + @cause = exception + + return unless backtrace_opt && exception.respond_to?(:backtrace) && exception.backtrace + + bt = exception.backtrace + bt = backtrace_cleaner.call(bt) if backtrace_cleaner + @metadata[:backtrace] = bt + end + + # @api private + def throw!(other_result, **meta) + raise TypeError, "Expected a CMDx::Result, got #{other_result.class}" unless other_result.is_a?(Result) + return if other_result.success? + + @status = other_result.status + @state = STATE_INTERRUPTED + @reason = other_result.reason + @cause = other_result.cause + @metadata.merge!(meta) + @metadata[:threw_from] = other_result.task&.class&.name + @metadata[:caused_by] = other_result.metadata[:caused_by] || other_result.task&.class&.name + end + + # @api private + def increment_retries! + @retries += 1 + end + + # @api private + def mark_rolled_back! + @rolled_back = true + end + + # @api private + def mark_strict! + @strict = true + end + + # @api private + + # -- Chain analysis -- + + # @return [CMDx::Result, nil] the result that originally caused the failure + def caused_failure + return nil unless failed? && chain + + chain.results.find { |r| r.failed? && r.metadata[:caused_by].nil? && r != self } + end + + # @return [CMDx::Result, nil] the result that threw/propagated the failure + def threw_failure + return nil unless failed? && metadata[:threw_from] + + chain&.results&.find { |r| r.task&.class&.name == metadata[:threw_from] } + end + + # @return [Boolean] + def caused_failure? + failed? && metadata[:caused_by].nil? && metadata[:threw_from].nil? + end + + # @return [Boolean] + def threw_failure? + failed? && metadata[:threw_from].present? + rescue StandardError + failed? && !metadata[:threw_from].nil? + end + + # @return [Boolean] + def thrown_failure? + failed? && !metadata[:caused_by].nil? + end + + # -- Handlers -- + + # Chain handler for specific outcomes. + # + # @param type [Symbol] :success, :failed, :skipped, :complete, :interrupted, :executed, :good, :bad + # @yield [self] + # @return [self] + def on(type, &block) + raise ArgumentError, "on requires a block" unless block + + matchers = HANDLER_MAP.fetch(type) { raise ArgumentError, "Unknown handler type: #{type}" } + + yield(self) if matchers.include?(@state) || matchers.include?(@status) + + self + end + + # -- Pattern matching -- + + # @return [Array] [state, status, reason, metadata, context] + def deconstruct + [@state, @status, @reason, @metadata, @context] + end + + # @return [Hash] + def deconstruct_keys(keys) + h = { + state: @state, status: @status, reason: @reason, + metadata: @metadata, context: @context, + success: success?, failed: failed?, skipped: skipped?, + good: good?, bad: bad? + } + keys ? h.slice(*keys) : h + end + + def freeze + @metadata.freeze + super + end + + def inspect + "#<#{self.class} state=#{@state} status=#{@status} reason=#{@reason.inspect}>" + end + + end +end diff --git a/lib/cmdx/returns.rb b/lib/cmdx/returns.rb new file mode 100644 index 000000000..d3c4be4d0 --- /dev/null +++ b/lib/cmdx/returns.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module CMDx + # Output contract validation. Declares expected context keys that must + # be present after successful task execution. + module Returns + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + + def inherited(subclass) + super + subclass.instance_variable_set(:@returns, returns_keys.dup) + end + + # Declare expected return keys. + # + # @param keys [Array] + # @return [void] + def returns(*keys) + returns_keys.concat(keys.map(&:to_sym)) + end + + # Remove inherited return declarations. + # + # @param keys [Array] + # @return [void] + def remove_returns(*keys) + keys.each { |k| returns_keys.delete(k.to_sym) } + end + + # @return [Array] + def returns_keys + @returns_keys ||= [] + end + + end + + private + + # Validate that all declared returns are present in the context. + # Only runs when the result is still successful. + # + # @return [void] + def validate_returns! + return unless result.success? + + all_keys = self.class.returns_keys + (self.class.task_settings.returns_keys || []) + return if all_keys.empty? + + all_keys.uniq.each do |key| + errors.add(key, Messages.resolve("return.missing")) unless context.key?(key) + end + + return if errors.empty? + + result.fail!(Messages.resolve("halt.invalid"), + errors: { full_message: errors.to_s, messages: errors.to_h }) + end + + end +end diff --git a/lib/cmdx/settings.rb b/lib/cmdx/settings.rb new file mode 100644 index 000000000..3d1d4714c --- /dev/null +++ b/lib/cmdx/settings.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module CMDx + # Per-task class-level configuration that inherits from parent and + # falls back to global Configuration. Merges on subsequent calls. + class Settings + + KEYS = %i[ + task_breakpoints workflow_breakpoints breakpoints + rollback_on dump_context freeze_results + backtrace backtrace_cleaner exception_handler logger + retries retry_on retry_jitter + tags deprecate + log_level log_formatter + returns + ].freeze + + attr_reader :overrides + + def initialize(parent: nil) + @parent = parent + @overrides = {} + end + + def initialize_copy(source) + super + @overrides = source.overrides.dup + end + + # Merge settings. Last-write-wins per key. + # + # @param options [Hash] + # @return [self] + def merge!(options) + options.each do |key, value| + @overrides[key.to_sym] = value + end + self + end + + # Resolve a setting: task-level -> parent -> global config. + # + # @param key [Symbol] + # @return [Object] + def [](key) + sym = key.to_sym + return @overrides[sym] if @overrides.key?(sym) + return @parent[sym] if @parent + + config = CMDx.configuration + config.respond_to?(sym) ? config.public_send(sym) : nil + end + + # Convenience accessors for common settings. + + def task_breakpoints + self[:breakpoints] || self[:task_breakpoints] + end + + def workflow_breakpoints + self[:breakpoints] || self[:workflow_breakpoints] + end + + def rollback_on + self[:rollback_on] + end + + def retries + self[:retries] || 0 + end + + def retry_on + self[:retry_on] || [StandardError] + end + + def retry_jitter + self[:retry_jitter] || 0 + end + + def tags + self[:tags] || [] + end + + def deprecate + self[:deprecate] + end + + def log_level + self[:log_level] || :info + end + + def log_formatter + self[:log_formatter] + end + + def logger + self[:logger] + end + + def freeze_results + val = self[:freeze_results] + val.nil? || val + end + + def backtrace + self[:backtrace] || false + end + + def backtrace_cleaner + self[:backtrace_cleaner] + end + + def exception_handler + self[:exception_handler] + end + + def dump_context + self[:dump_context] || false + end + + def returns_keys + self[:returns] || [] + end + + end +end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb new file mode 100644 index 000000000..561357fdb --- /dev/null +++ b/lib/cmdx/task.rb @@ -0,0 +1,384 @@ +# frozen_string_literal: true + +module CMDx + # Base class for all CMDx tasks. Inherit and define a `work` method. + # + # @example + # class Greet < CMDx::Task + # required :name, type: :string, presence: true + # + # def work + # context.greeting = "Hello, #{name}!" + # end + # end + class Task + + include Callbacks + include MiddlewareStack + include Returns + + attr_reader :context, :result, :errors, :id + + alias ctx context + alias res result + + class << self + + def inherited(subclass) + super + subclass.instance_variable_set(:@attribute_set, attribute_set.dup) + subclass.instance_variable_set(:@task_settings, nil) + subclass.instance_variable_set(:@coercion_registry, coercion_registry.dup) + subclass.instance_variable_set(:@validator_registry, validator_registry.dup) + end + + # -- Execution -- + + # Execute the task, always returning a Result. + # + # @param args [Hash] + # @yield [CMDx::Result] + # @return [CMDx::Result] + def execute(args = {}, &block) + task = new(args) + task.execute + result = task.result + block&.call(result) + result + end + + # Execute the task, raising Fault on failure/skip. + # + # @param args [Hash] + # @yield [CMDx::Result] + # @return [CMDx::Result] + # @raise [CMDx::FailFault, CMDx::SkipFault] + def execute!(args = {}, &block) + task = new(args) + task.execute! + result = task.result + block&.call(result) + result + end + + # -- Attribute DSL -- + + # @return [CMDx::AttributeSet] + def attribute_set + @attribute_set ||= AttributeSet.new + end + + # rubocop:disable Style/ArgumentsForwarding, Naming/BlockForwarding + def attribute(*names, **options, &block) + attribute_set.define(*names, **options, &block) + attribute_set.define_accessors(self) + end + alias attributes attribute + + def optional(*names, **options, &block) + attribute(*names, **options, &block) + end + + def required(*names, **options, &block) + attribute(*names, required: true, **options, &block) + end + # rubocop:enable Style/ArgumentsForwarding, Naming/BlockForwarding + + def remove_attribute(*names) + names.each { |n| attribute_set.remove(n) } + end + alias remove_attributes remove_attribute + + # @return [Hash] + def attributes_schema + attribute_set.schema + end + + # -- Settings -- + + # @return [CMDx::Settings] + def task_settings + @task_settings ||= begin + parent = superclass.respond_to?(:task_settings) ? superclass.task_settings : nil + Settings.new(parent: parent) + end + end + + def settings(**options) + task_settings.merge!(options) + end + + # -- Per-task registries -- + + def coercion_registry + @coercion_registry ||= {} + end + + def validator_registry + @validator_registry ||= {} + end + + # Unified register API. + # + # @param type [Symbol] :middleware, :coercion, :validator, :callback + def register(type, *args, **options) + case type + when :middleware + register_middleware(args.first, **options) + when :coercion + name, callable = args + coercion_registry[name.to_sym] = Callable.wrap(callable) + when :validator + name, callable = args + validator_registry[name.to_sym] = Callable.wrap(callable) + when :callback + cb_type, *callables = args + callables.each do |cb| + callback_registry[cb_type.to_sym] << { callable: Callable.wrap(cb), conditions: options } + end + end + end + + # Unified deregister API. + def deregister(type, *args) + case type + when :middleware + deregister_middleware(args.first) + when :coercion + coercion_registry.delete(args.first.to_sym) + when :validator + validator_registry.delete(args.first.to_sym) + when :callback + cb_type, callable = args + deregister_callback(cb_type, callable) + end + end + + end + + def initialize(args = {}) + @id = Chain::UUID_V7 ? SecureRandom.uuid_v7 : SecureRandom.uuid + @context = Context.build(args) + @result = Result.new(task: self, context: @context) + @errors = ErrorSet.new + @__attributes__ = {} + @executed = false + end + + # Non-bang execution. + def execute + raise_if_executed! + @executed = true + + begin + check_deprecation! + check_work_defined! + setup_chain! + + run_middleware_chain { execute_core } + rescue StandardError => e + handle_exception(e) + ensure + finalize! + end + end + + # Bang execution -- raises Fault on failure/skip. + def execute! + result.mark_strict! + execute + + breakpoints = Array(self.class.task_settings.task_breakpoints) + + if breakpoints.include?(result.status) + raise result.cause if result.cause.is_a?(Fault) + + fault_class = result.skipped? ? SkipFault : FailFault + raise fault_class.new(result.reason, result: result) + end + + result + end + + # Override this method with your business logic. + def work + raise UndefinedMethodError, Messages.resolve("task.undefined_work", task: self.class.name) + end + + # Override for undo logic. + def rollback; end + + # @return [Boolean] + def dry_run? + !!context[:dry_run] + end + + # @return [Logger] + def logger + self.class.task_settings.logger || CMDx.configuration.logger + end + + # -- Halt methods -- + + def skip!(reason = nil, **metadata) + result.skip!(reason, **metadata) + end + + def fail!(reason = nil, **metadata) + result.fail!(reason, **metadata) + end + + def success!(reason = nil, **metadata) + result.succeed!(reason, **metadata) + end + + def throw!(other_result, **metadata) + result.throw!(other_result, **metadata) + end + + private + + def execute_core + result.transition_to_executing! + + run_before_callbacks(:before_validation) + validate_attributes! + return result unless result.success? + + run_execution_with_retries + result + end + + def run_execution_with_retries + max_retries = self.class.task_settings.retries + retry_on = self.class.task_settings.retry_on + retry_jitter = self.class.task_settings.retry_jitter + + begin + run_before_callbacks(:before_execution) + work if result.success? + validate_returns! if result.success? + rescue *retry_on => e + raise e unless result.retries < max_retries + + result.increment_retries! + errors.clear + apply_jitter(retry_jitter, result.retries) + retry + end + + maybe_rollback! + run_after_callbacks + end + + def validate_attributes! + return if self.class.attribute_set.empty? + + @__attributes__ = self.class.attribute_set.process( + self, + task_coercions: self.class.coercion_registry.empty? ? nil : self.class.coercion_registry, + task_validators: self.class.validator_registry.empty? ? nil : self.class.validator_registry + ) + + return if errors.empty? + + result.fail!(Messages.resolve("halt.invalid"), + errors: { full_message: errors.to_s, messages: errors.to_h }) + end + + def maybe_rollback! + rollback_statuses = Array(self.class.task_settings.rollback_on) + return unless rollback_statuses.include?(result.status) + + rollback + result.mark_rolled_back! + end + + def apply_jitter(jitter, attempt) + delay = case jitter + when Numeric then jitter * attempt + when Symbol then send(jitter, attempt) + when Proc then jitter.call(attempt) + else + jitter.respond_to?(:call) ? Callable.resolve(jitter, self, self, attempt) : 0 + end + sleep(delay) if delay.is_a?(Numeric) && delay.positive? + end + + def handle_exception(exception) + return if result.interrupted? + + settings = self.class.task_settings + result.fail_from_exception!(exception, + backtrace_opt: settings.backtrace, + backtrace_cleaner: settings.backtrace_cleaner) + + handler = settings.exception_handler + Callable.resolve(handler, self, self, exception) if handler + end + + def finalize! + result.transition_to_complete! if result.executing? + + chain = Chain.current + if chain + chain.exit + if chain.outermost? + freeze_all! if self.class.task_settings.freeze_results + Chain.clear + end + end + + log_result! + end + + def freeze_all! + chain = result.chain + return unless chain + + chain.results.each do |r| + r.freeze + r.context.freeze + end + chain.freeze + end + + def setup_chain! + chain = Chain.current || Chain.new.tap { |c| Chain.current = c } + chain.enter + chain.add(result) + end + + def check_deprecation! + dep = self.class.task_settings.deprecate + return unless dep + + mode = Callable.resolve(dep, self) + mode = :log if mode == true + + case mode.to_s + when "raise" + raise DeprecationError, Messages.resolve("deprecation.prohibited", task: self.class.name) + when "log" + logger.warn(Messages.resolve("deprecation.warning", task: self.class.name)) + when "warn" + Warning.warn("[#{self.class.name}] #{Messages.resolve('deprecation.warning', task: self.class.name)}\n") + end + end + + def check_work_defined! + # Subclasses that include Workflow define work automatically + end + + def raise_if_executed! + raise "Task has already been executed" if @executed + end + + def log_result! + LogEntry.log(result, self.class.task_settings) + rescue StandardError + # Never let logging failures break task execution + end + + end +end diff --git a/lib/cmdx/validators.rb b/lib/cmdx/validators.rb new file mode 100644 index 000000000..ea62edfc3 --- /dev/null +++ b/lib/cmdx/validators.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +module CMDx + # Module-level registry of attribute validators. Each validator checks a value + # against options, raising ValidationError on failure. + module Validators + + @registry = {} + + class << self + + # @return [Hash] + attr_reader :registry + + # Register a custom validator. + # + # @param name [Symbol] + # @param callable [Proc, #call] + # @return [void] + def register(name, callable) + @registry[name.to_sym] = Callable.wrap(callable) + end + + # @param name [Symbol] + # @return [void] + def deregister(name) + @registry.delete(name.to_sym) + end + + # Run a named validator. + # + # @param name [Symbol] the validator name + # @param value [Object] the value to validate + # @param options [Hash, true] validator options + # @param task [Object] the task instance (for condition evaluation) + # @param task_registry [Hash, nil] per-task validator overrides + # @return [String, nil] error message or nil if valid + def validate(name, value, options, task: nil, task_registry: nil) + validator = (task_registry && task_registry[name]) || @registry[name] + return nil unless validator + + opts = options.is_a?(Hash) ? options : {} + + return nil if opts[:allow_nil] && value.nil? + + return nil if opts.key?(:if) && !Callable.evaluate(opts[:if], task || value) + + return nil if opts.key?(:unless) && Callable.evaluate(opts[:unless], task || value) + + begin + validator.call(value, opts) + nil + rescue ValidationError => e + opts[:message] || e.message + end + end + + end + + # -- Built-in validators -- + + register(:presence, lambda { |value, _options = {}| + blank = value.nil? || (value.respond_to?(:empty?) && value.empty?) || + (value.is_a?(String) && value.strip.empty?) + raise ValidationError, Messages.resolve("validation.presence") if blank + }) + + register(:absence, lambda { |value, _options = {}| + present = !value.nil? && !(value.respond_to?(:empty?) && value.empty?) && + !(value.is_a?(String) && value.strip.empty?) + raise ValidationError, Messages.resolve("validation.absence") if present + }) + + register(:format, lambda { |value, options = {}| + return if value.nil? + + pattern = options.is_a?(Regexp) ? options : (options[:with] || options[:regexp]) + anti_pattern = options.is_a?(Hash) ? options[:without] : nil + + raise ValidationError, Messages.resolve("validation.format") if pattern && !value.to_s.match?(pattern) + + raise ValidationError, Messages.resolve("validation.format") if anti_pattern && value.to_s.match?(anti_pattern) + }) + + register(:inclusion, lambda { |value, options = {}| + return if value.nil? + + collection = options[:in] || options[:within] + return unless collection + + unless collection.include?(value) + if collection.is_a?(Range) + raise ValidationError, + options[:within_message] || options[:in_message] || + Messages.resolve("validation.inclusion.within", range: collection) + else + raise ValidationError, + options[:of_message] || Messages.resolve("validation.inclusion.in") + end + end + }) + + register(:exclusion, lambda { |value, options = {}| + return if value.nil? + + collection = options[:in] || options[:within] + return unless collection + + if collection.include?(value) + if collection.is_a?(Range) + raise ValidationError, + options[:within_message] || options[:in_message] || + Messages.resolve("validation.exclusion.within", range: collection) + else + raise ValidationError, + options[:of_message] || Messages.resolve("validation.exclusion.in") + end + end + }) + + register(:length, lambda { |value, options = {}| + return if value.nil? + return unless value.respond_to?(:length) + + len = value.length + + range = options[:within] || options[:in] + if range && !range.include?(len) + raise ValidationError, + options[:within_message] || options[:in_message] || + Messages.resolve("validation.length.within", range: range) + end + + not_range = options[:not_within] || options[:not_in] + if not_range && not_range.include?(len) + raise ValidationError, + options[:not_within_message] || options[:not_in_message] || + Messages.resolve("validation.length.not_within", range: not_range) + end + + if options[:min] && len < options[:min] + raise ValidationError, + options[:min_message] || Messages.resolve("validation.length.min", count: options[:min]) + end + + if options[:max] && len > options[:max] + raise ValidationError, + options[:max_message] || Messages.resolve("validation.length.max", count: options[:max]) + end + + if options[:is] && len != options[:is] + raise ValidationError, + options[:is_message] || Messages.resolve("validation.length.is", count: options[:is]) + end + + if options[:is_not] && len == options[:is_not] + raise ValidationError, + options[:is_not_message] || Messages.resolve("validation.length.is_not", count: options[:is_not]) + end + }) + + register(:numeric, lambda { |value, options = {}| + return if value.nil? + + num = begin + value.is_a?(Numeric) ? value : Float(value) + rescue StandardError + nil + end + return unless num + + range = options[:within] || options[:in] + if range && !range.include?(num) + raise ValidationError, + options[:within_message] || Messages.resolve("validation.numeric.within", range: range) + end + + not_range = options[:not_within] || options[:not_in] + if not_range && not_range.include?(num) + raise ValidationError, + options[:not_within_message] || Messages.resolve("validation.numeric.not_within", range: not_range) + end + + if options[:min] && num < options[:min] + raise ValidationError, + options[:min_message] || Messages.resolve("validation.numeric.min", count: options[:min]) + end + + if options[:max] && num > options[:max] + raise ValidationError, + options[:max_message] || Messages.resolve("validation.numeric.max", count: options[:max]) + end + + if options[:is] && num != options[:is] + raise ValidationError, + options[:is_message] || Messages.resolve("validation.numeric.is", count: options[:is]) + end + + if options[:is_not] && num == options[:is_not] + raise ValidationError, + options[:is_not_message] || Messages.resolve("validation.numeric.is_not", count: options[:is_not]) + end + }) + + end +end diff --git a/lib/cmdx/version.rb b/lib/cmdx/version.rb index b07955686..ccaa16be9 100644 --- a/lib/cmdx/version.rb +++ b/lib/cmdx/version.rb @@ -2,9 +2,6 @@ module CMDx - # @return [String] the version of the CMDx gem - # - # @rbs return: String - VERSION = "1.21.0" + VERSION = "2.0.0" end diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb new file mode 100644 index 000000000..258c6c877 --- /dev/null +++ b/lib/cmdx/workflow.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module CMDx + # Compose multiple tasks into sequential or parallel pipelines. + # Include in a Task subclass -- do NOT define a `work` method. + # + # @example + # class OnboardUser < CMDx::Task + # include CMDx::Workflow + # + # task CreateProfile + # task SetupPreferences + # task SendWelcome, if: :email_enabled? + # end + module Workflow + + def self.included(base) + base.extend(ClassMethods) + base.instance_variable_set(:@workflow_tasks, []) + + base.define_method(:work) do + self.class.workflow_tasks.each do |group| + break unless result.success? || !should_break?(group) + + if group[:strategy] == :parallel + execute_parallel(group[:entries]) + else + execute_sequential(group[:entries]) + end + end + end + end + + module ClassMethods + + def inherited(subclass) + super + subclass.instance_variable_set(:@workflow_tasks, workflow_tasks.map(&:dup)) + end + + # @return [Array] + def workflow_tasks + @workflow_tasks ||= [] + end + + # Declare one or more tasks to run sequentially. + # + # @param task_classes [Array] + # @param options [Hash] :if, :unless, :breakpoints, :strategy + def task(*task_classes, **options) + strategy = options.delete(:strategy) || :sequential + entries = task_classes.map do |klass| + { + task_class: klass, + if: options[:if], + unless: options[:unless] + } + end + + workflow_tasks << { + entries: entries, + strategy: strategy, + breakpoints: options[:breakpoints] + } + end + alias tasks task + + end + + private + + def execute_sequential(entries) + entries.each do |entry| + break unless result.success? || !should_break_entry?(entry) + next unless should_run?(entry) + + run_workflow_task(entry[:task_class]) + end + end + + def execute_parallel(entries) + runnable = entries.select { |e| should_run?(e) } + return if runnable.empty? + + threads = runnable.map do |entry| + ctx_copy = Context.build(context.to_h) + Thread.new(entry, ctx_copy) do |ent, ctx| + ent[:task_class].execute(ctx) + end + end + + threads.each do |thread| + task_result = thread.value + context.merge!(task_result.context.to_h) + + result.throw!(task_result) if task_result.failed? + end + end + + def run_workflow_task(task_class) + task_result = task_class.execute(context) + + maybe_rollback_workflow_task(task_class, task_result) + + return unless task_result.failed? || (task_result.skipped? && should_break_on_skip?) + + result.throw!(task_result) + end + + def maybe_rollback_workflow_task(_task_class, task_result) + rollback_statuses = Array(self.class.task_settings.rollback_on) + return unless rollback_statuses.include?(task_result.status) + + task_result.task.rollback if task_result.task.respond_to?(:rollback) + task_result.mark_rolled_back! + end + + def should_run?(entry) + return false if entry[:if] && !Callable.evaluate(entry[:if], self) + + return false if entry[:unless] && Callable.evaluate(entry[:unless], self) + + true + end + + def should_break?(group) + breakpoints = group[:breakpoints] || Array(self.class.task_settings.workflow_breakpoints) + breakpoints.include?(result.status) + end + + def should_break_entry?(_entry) + should_break?({ breakpoints: nil }) + end + + def should_break_on_skip? + breakpoints = Array(self.class.task_settings.workflow_breakpoints) + breakpoints.include?("skipped") + end + + end +end diff --git a/spec/cmdx/callable_spec.rb b/spec/cmdx/callable_spec.rb new file mode 100644 index 000000000..58af9b94e --- /dev/null +++ b/spec/cmdx/callable_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Callable do + describe ".wrap" do + it "returns symbols as-is" do + expect(described_class.wrap(:my_method)).to eq(:my_method) + end + + it "returns procs as-is" do + prc = -> { 42 } + expect(described_class.wrap(prc)).to equal(prc) + end + + it "wraps a class into a proc" do + klass = Class.new do + def call(x) + x * 2 + end + end + + wrapped = described_class.wrap(klass) + expect(wrapped).to be_a(Proc) + expect(wrapped.call(5)).to eq(10) + end + + it "wraps a callable instance" do + obj = Object.new + def obj.call(x); x + 1; end + + wrapped = described_class.wrap(obj) + expect(wrapped.call(4)).to eq(5) + end + end + + describe ".resolve" do + it "resolves a symbol as a method on receiver" do + receiver = Object.new + def receiver.greet; "hello"; end + + expect(described_class.resolve(:greet, receiver)).to eq("hello") + end + + it "resolves a proc" do + expect(described_class.resolve(-> { 42 }, nil)).to eq(42) + end + + it "resolves a callable object" do + obj = Object.new + def obj.call; "called"; end + + expect(described_class.resolve(obj, nil)).to eq("called") + end + end + + describe ".evaluate" do + it "returns true for nil condition" do + expect(described_class.evaluate(nil, nil)).to be(true) + end + + it "evaluates a proc condition" do + expect(described_class.evaluate(-> { true }, nil)).to be(true) + expect(described_class.evaluate(-> { false }, nil)).to be(false) + end + + it "evaluates a symbol condition" do + receiver = Object.new + def receiver.active?; true; end + + expect(described_class.evaluate(:active?, receiver)).to be(true) + end + end +end diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb new file mode 100644 index 000000000..05bb9cdc2 --- /dev/null +++ b/spec/cmdx/chain_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Chain do + after { described_class.clear } + + describe ".current / .current= / .clear" do + it "manages thread-local chain" do + expect(described_class.current).to be_nil + + chain = described_class.new + described_class.current = chain + expect(described_class.current).to equal(chain) + + described_class.clear + expect(described_class.current).to be_nil + end + end + + describe "#add" do + it "adds results with sequential indices" do + chain = described_class.new + task = instance_double("CMDx::Task", class: Class.new) + + r1 = CMDx::Result.new(task: task, context: CMDx::Context.new) + r2 = CMDx::Result.new(task: task, context: CMDx::Context.new) + + chain.add(r1) + chain.add(r2) + + expect(chain.size).to eq(2) + expect(r1.index).to eq(0) + expect(r2.index).to eq(1) + expect(r1.chain).to equal(chain) + end + end + + describe "#id" do + it "generates a UUID" do + chain = described_class.new + expect(chain.id).to match(/\A[0-9a-f-]+\z/) + end + end + + describe "depth tracking" do + it "tracks enter/exit for outermost detection" do + chain = described_class.new + expect(chain).to be_outermost + + chain.enter + expect(chain).not_to be_outermost + + chain.exit + expect(chain).to be_outermost + end + end + + describe "delegation" do + it "delegates state/status from first result" do + chain = described_class.new + task = instance_double("CMDx::Task", class: Class.new) + r = CMDx::Result.new(task: task, context: CMDx::Context.new) + r.transition_to_executing! + r.transition_to_complete! + chain.add(r) + + expect(chain.state).to eq("complete") + expect(chain.status).to eq("success") + end + end +end diff --git a/spec/cmdx/coercions_spec.rb b/spec/cmdx/coercions_spec.rb new file mode 100644 index 000000000..f2d1cfb2f --- /dev/null +++ b/spec/cmdx/coercions_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Coercions do + describe ":integer" do + it "coerces string to integer" do + expect(described_class.coerce(:integer, "42")).to eq(42) + end + + it "handles hex" do + expect(described_class.coerce(:integer, "0xFF")).to eq(255) + end + + it "raises on invalid input" do + expect { described_class.coerce(:integer, "abc") }.to raise_error(CMDx::CoercionError) + end + end + + describe ":float" do + it "coerces string to float" do + expect(described_class.coerce(:float, "3.14")).to eq(3.14) + end + end + + describe ":string" do + it "coerces to string" do + expect(described_class.coerce(:string, 123)).to eq("123") + end + end + + describe ":boolean" do + it "coerces truthy strings" do + %w[true yes on 1 t y].each do |val| + expect(described_class.coerce(:boolean, val)).to be(true) + end + end + + it "coerces falsy strings" do + %w[false no off 0 f n].each do |val| + expect(described_class.coerce(:boolean, val)).to be(false) + end + end + + it "raises on unknown" do + expect { described_class.coerce(:boolean, "maybe") }.to raise_error(CMDx::CoercionError) + end + end + + describe ":symbol" do + it "coerces string to symbol" do + expect(described_class.coerce(:symbol, "hello")).to eq(:hello) + end + end + + describe ":array" do + it "wraps non-arrays" do + expect(described_class.coerce(:array, "val")).to eq(["val"]) + end + + it "parses JSON arrays" do + expect(described_class.coerce(:array, "[1,2,3]")).to eq([1, 2, 3]) + end + end + + describe ":hash" do + it "parses JSON hashes" do + expect(described_class.coerce(:hash, '{"a":1}')).to eq("a" => 1) + end + + it "raises on non-hash" do + expect { described_class.coerce(:hash, "not json") }.to raise_error(CMDx::CoercionError) + end + end + + describe ":big_decimal" do + it "coerces to BigDecimal" do + result = described_class.coerce(:big_decimal, "123.456") + expect(result).to be_a(BigDecimal) + end + end + + describe ":date" do + it "parses date string" do + result = described_class.coerce(:date, "2024-01-23") + expect(result).to eq(Date.new(2024, 1, 23)) + end + end + + describe "fallback chain" do + it "tries types in order" do + result = described_class.coerce([:float, :string], "3.14") + expect(result).to eq(3.14) + end + + it "falls through to next type on failure" do + result = described_class.coerce([:integer, :string], "abc") + expect(result).to eq("abc") + end + end + + describe "nil passthrough" do + it "returns nil without coercing" do + expect(described_class.coerce(:integer, nil)).to be_nil + end + end +end diff --git a/spec/cmdx/configuration_spec.rb b/spec/cmdx/configuration_spec.rb new file mode 100644 index 000000000..ef7973931 --- /dev/null +++ b/spec/cmdx/configuration_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Configuration do + subject(:config) { described_class.new } + + it "has sensible defaults" do + expect(config.task_breakpoints).to eq(%w[failed]) + expect(config.workflow_breakpoints).to eq(%w[failed]) + expect(config.rollback_on).to eq(%w[failed]) + expect(config.dump_context).to be(false) + expect(config.freeze_results).to be(true) + expect(config.backtrace).to be(false) + expect(config.exception_handler).to be_nil + expect(config.logger).to be_a(Logger) + end + + it "is configurable" do + config.task_breakpoints = %w[skipped failed] + expect(config.task_breakpoints).to eq(%w[skipped failed]) + end + + describe "CMDx.configure" do + it "yields configuration" do + CMDx.configure do |c| + c.dump_context = true + end + + expect(CMDx.configuration.dump_context).to be(true) + end + end + + describe "CMDx.reset_configuration!" do + it "resets to defaults" do + CMDx.configuration.dump_context = true + CMDx.reset_configuration! + CMDx.configuration.logger = Logger.new(nil) + expect(CMDx.configuration.dump_context).to be(false) + end + end +end diff --git a/spec/cmdx/context_spec.rb b/spec/cmdx/context_spec.rb new file mode 100644 index 000000000..dda02bca6 --- /dev/null +++ b/spec/cmdx/context_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Context do + describe ".build" do + it "builds from nil" do + ctx = described_class.build(nil) + expect(ctx).to be_a(described_class) + expect(ctx).to be_empty + end + + it "builds from a hash" do + ctx = described_class.build(name: "Alice", age: 30) + expect(ctx[:name]).to eq("Alice") + expect(ctx[:age]).to eq(30) + end + + it "normalizes string keys to symbols" do + ctx = described_class.build("name" => "Bob") + expect(ctx[:name]).to eq("Bob") + end + + it "builds from an existing context" do + original = described_class.build(x: 1) + ctx = described_class.build(original) + expect(ctx).to equal(original) + end + + it "duplicates a frozen context" do + original = described_class.build(x: 1) + original.freeze + ctx = described_class.build(original) + expect(ctx).not_to equal(original) + expect(ctx[:x]).to eq(1) + end + + it "raises for unsupported input" do + expect { described_class.build(42) }.to raise_error(ArgumentError, /Cannot build Context/) + end + end + + describe "access" do + subject(:ctx) { described_class.build(name: "Alice") } + + it "supports method-style access" do + expect(ctx.name).to eq("Alice") + end + + it "supports hash-style access" do + expect(ctx[:name]).to eq("Alice") + end + + it "returns nil for undefined keys" do + expect(ctx.missing_key).to be_nil + end + + it "supports method-style assignment" do + ctx.age = 25 + expect(ctx[:age]).to eq(25) + end + + it "supports hash-style assignment" do + ctx[:role] = "admin" + expect(ctx.role).to eq("admin") + end + + it "supports fetch with default" do + expect(ctx.fetch(:missing, "default")).to eq("default") + end + + it "supports fetch_or_store" do + expect(ctx.fetch_or_store(:counter, 0)).to eq(0) + ctx[:counter] = 5 + expect(ctx.fetch_or_store(:counter, 0)).to eq(5) + end + + it "supports key?" do + expect(ctx.key?(:name)).to be(true) + expect(ctx.key?(:nope)).to be(false) + end + + it "supports merge!" do + ctx.merge!(x: 1, y: 2) + expect(ctx[:x]).to eq(1) + expect(ctx[:y]).to eq(2) + end + + it "supports delete!" do + ctx.delete!(:name) + expect(ctx[:name]).to be_nil + end + + it "to_h returns a dup" do + hash = ctx.to_h + hash[:name] = "modified" + expect(ctx[:name]).to eq("Alice") + end + end + + describe "#freeze" do + it "prevents further modification" do + ctx = described_class.build(x: 1) + ctx.freeze + expect(ctx).to be_frozen + expect { ctx[:y] = 2 }.to raise_error(FrozenError) + end + end +end diff --git a/spec/cmdx/error_set_spec.rb b/spec/cmdx/error_set_spec.rb new file mode 100644 index 000000000..8240b4985 --- /dev/null +++ b/spec/cmdx/error_set_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::ErrorSet do + subject(:errors) { described_class.new } + + it "starts empty" do + expect(errors).to be_empty + expect(errors).not_to be_any + expect(errors.size).to eq(0) + end + + describe "#add" do + it "adds errors for an attribute" do + errors.add(:email, "is required") + errors.add(:email, "is invalid") + errors.add(:name, "is too short") + + expect(errors.size).to eq(2) + expect(errors).to be_any + expect(errors.for?(:email)).to be(true) + expect(errors.for?(:name)).to be(true) + expect(errors.for?(:phone)).to be(false) + end + end + + describe "#to_h" do + it "returns a hash of errors" do + errors.add(:email, "is required") + expect(errors.to_h).to eq(email: ["is required"]) + end + end + + describe "#full_messages" do + it "prefixes each message with the attribute name" do + errors.add(:email, "is required") + expect(errors.full_messages).to eq(email: ["email is required"]) + end + end + + describe "#to_s" do + it "joins all full messages" do + errors.add(:email, "is required") + errors.add(:name, "is too short") + expect(errors.to_s).to eq("email is required. name is too short") + end + end + + describe "#clear" do + it "removes all errors" do + errors.add(:x, "bad") + errors.clear + expect(errors).to be_empty + end + end +end diff --git a/spec/cmdx/errors_spec.rb b/spec/cmdx/errors_spec.rb new file mode 100644 index 000000000..64c37bc7c --- /dev/null +++ b/spec/cmdx/errors_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.describe "CMDx exception hierarchy" do + it "Error inherits from StandardError" do + expect(CMDx::Error.superclass).to eq(StandardError) + end + + it "Exception is an alias for Error" do + expect(CMDx::Exception).to eq(CMDx::Error) + end + + it "Fault carries result data" do + result = instance_double("CMDx::Result", task: nil, context: nil, chain: nil) + fault = CMDx::FailFault.new("bad", result: result) + + expect(fault.message).to eq("bad") + expect(fault.result).to equal(result) + end + + it "TimeoutError inherits from Interrupt" do + expect(CMDx::TimeoutError.superclass).to eq(Interrupt) + end + + describe "Fault.for?" do + it "matches faults from specific task classes" do + task_class = Class.new(CMDx::Task) + task = task_class.new + result = CMDx::Result.new(task: task, context: CMDx::Context.new) + fault = CMDx::FailFault.new("bad", result: result) + + matcher = CMDx::FailFault.for?(task_class) + expect(matcher === fault).to be(true) + end + end +end diff --git a/spec/cmdx/log_formatters/json_spec.rb b/spec/cmdx/log_formatters/json_spec.rb new file mode 100644 index 000000000..8da3d7c58 --- /dev/null +++ b/spec/cmdx/log_formatters/json_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::LogFormatters::Json do + subject(:formatter) { described_class.new } + + it "outputs valid JSON" do + data = { state: "complete", status: "success", metadata: {} } + output = formatter.call(data) + parsed = JSON.parse(output) + expect(parsed["state"]).to eq("complete") + expect(parsed["status"]).to eq("success") + end + + it "serializes exceptions" do + data = { cause: RuntimeError.new("boom") } + output = formatter.call(data) + parsed = JSON.parse(output) + expect(parsed["cause"]).to include("boom") + expect(parsed["cause"]).to include("RuntimeError") + end +end diff --git a/spec/cmdx/log_formatters/key_value_spec.rb b/spec/cmdx/log_formatters/key_value_spec.rb new file mode 100644 index 000000000..c5b00b05c --- /dev/null +++ b/spec/cmdx/log_formatters/key_value_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::LogFormatters::KeyValue do + subject(:formatter) { described_class.new } + + it "formats as key=value pairs" do + data = { state: "complete", count: 5 } + output = formatter.call(data) + expect(output).to include('state="complete"') + expect(output).to include("count=5") + end +end diff --git a/spec/cmdx/log_formatters/line_spec.rb b/spec/cmdx/log_formatters/line_spec.rb new file mode 100644 index 000000000..60b051015 --- /dev/null +++ b/spec/cmdx/log_formatters/line_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::LogFormatters::Line do + subject(:formatter) { described_class.new } + + it "formats data as key-value pairs" do + data = { state: "complete", status: "success" } + output = formatter.call(data) + expect(output).to include("state:") + expect(output).to include("status:") + end +end diff --git a/spec/cmdx/log_formatters/logstash_spec.rb b/spec/cmdx/log_formatters/logstash_spec.rb new file mode 100644 index 000000000..c93886f05 --- /dev/null +++ b/spec/cmdx/log_formatters/logstash_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::LogFormatters::Logstash do + subject(:formatter) { described_class.new } + + it "outputs logstash-compatible JSON" do + data = { state: "complete" } + output = formatter.call(data) + parsed = JSON.parse(output) + expect(parsed).to have_key("@version") + expect(parsed).to have_key("@timestamp") + expect(parsed["progname"]).to eq("cmdx") + expect(parsed["message"]["state"]).to eq("complete") + end +end diff --git a/spec/cmdx/log_formatters/raw_spec.rb b/spec/cmdx/log_formatters/raw_spec.rb new file mode 100644 index 000000000..9a6fe6288 --- /dev/null +++ b/spec/cmdx/log_formatters/raw_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::LogFormatters::Raw do + subject(:formatter) { described_class.new } + + it "outputs inspect representation" do + data = { x: 1 } + expect(formatter.call(data)).to eq(data.inspect) + end +end diff --git a/spec/cmdx/messages_spec.rb b/spec/cmdx/messages_spec.rb new file mode 100644 index 000000000..81e5bee97 --- /dev/null +++ b/spec/cmdx/messages_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Messages do + describe ".resolve" do + it "resolves a known template" do + expect(described_class.resolve("attribute.required")).to eq("is required") + end + + it "interpolates values" do + msg = described_class.resolve("coercion.single", type: :integer) + expect(msg).to eq("could not coerce into integer") + end + + it "returns the key for unknown templates" do + expect(described_class.resolve("unknown.key")).to eq("unknown.key") + end + + it "delegates to custom resolver when set" do + CMDx.message_resolver = ->(key, **opts) { "custom: #{key}" } + expect(described_class.resolve("any.key")).to eq("custom: any.key") + ensure + CMDx.message_resolver = nil + end + end +end diff --git a/spec/cmdx/middlewares/correlate_spec.rb b/spec/cmdx/middlewares/correlate_spec.rb new file mode 100644 index 000000000..b27bfcd69 --- /dev/null +++ b/spec/cmdx/middlewares/correlate_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Middlewares::Correlate do + before { CMDx.configuration.freeze_results = false } + + after { described_class.clear } + + it "adds a correlation ID" do + klass = Class.new(CMDx::Task) do + register :middleware, CMDx::Middlewares::Correlate + + def work; end + end + + result = klass.execute + expect(result.metadata[:correlation_id]).to be_a(String) + expect(result.metadata[:correlation_id]).not_to be_empty + end + + it "reuses existing correlation ID" do + klass = Class.new(CMDx::Task) do + register :middleware, CMDx::Middlewares::Correlate + + def work; end + end + + described_class.use("my-trace-id") do + result = klass.execute + expect(result.metadata[:correlation_id]).to eq("my-trace-id") + end + end + + describe ".use" do + it "scopes and restores the correlation ID" do + described_class.id = "outer" + + described_class.use("inner") do + expect(described_class.id).to eq("inner") + end + + expect(described_class.id).to eq("outer") + end + end +end diff --git a/spec/cmdx/middlewares/runtime_spec.rb b/spec/cmdx/middlewares/runtime_spec.rb new file mode 100644 index 000000000..33e901c5f --- /dev/null +++ b/spec/cmdx/middlewares/runtime_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Middlewares::Runtime do + before { CMDx.configuration.freeze_results = false } + + it "records runtime metadata" do + klass = Class.new(CMDx::Task) do + register :middleware, CMDx::Middlewares::Runtime + + def work + context.done = true + end + end + + result = klass.execute + expect(result).to be_success + expect(result.metadata).to have_key(:runtime) + expect(result.metadata).to have_key(:started_at) + expect(result.metadata).to have_key(:ended_at) + expect(result.metadata[:runtime]).to be_a(Integer) + end +end diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb new file mode 100644 index 000000000..e955ed3b1 --- /dev/null +++ b/spec/cmdx/result_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Result do + let(:context) { CMDx::Context.build(x: 1) } + let(:task) { instance_double("CMDx::Task", class: Class.new) } + subject(:result) { described_class.new(task: task, context: context) } + + describe "initial state" do + it "starts as initialized/success" do + expect(result).to be_initialized + expect(result).to be_success + expect(result).not_to be_executed + end + end + + describe "state transitions" do + it "transitions to executing" do + result.transition_to_executing! + expect(result).to be_executing + end + + it "transitions to complete" do + result.transition_to_executing! + result.transition_to_complete! + expect(result).to be_complete + expect(result).to be_executed + end + end + + describe "#skip!" do + it "sets status to skipped and state to interrupted" do + result.skip!("not needed") + expect(result).to be_skipped + expect(result).to be_interrupted + expect(result.reason).to eq("not needed") + expect(result.cause).to be_a(CMDx::SkipFault) + end + + it "is idempotent" do + result.skip!("first") + expect { result.skip!("second") }.not_to raise_error + end + + it "cannot transition from failed" do + result.fail!("bad") + expect { result.skip!("nope") }.to raise_error(RuntimeError) + end + end + + describe "#fail!" do + it "sets status to failed and state to interrupted" do + result.fail!("broken", code: 500) + expect(result).to be_failed + expect(result).to be_interrupted + expect(result.reason).to eq("broken") + expect(result.metadata[:code]).to eq(500) + expect(result.cause).to be_a(CMDx::FailFault) + end + end + + describe "#good? / #bad?" do + it "success is good and not bad" do + expect(result).to be_good + expect(result).not_to be_bad + end + + it "skipped is both good and bad" do + result.skip! + expect(result).to be_good + expect(result).to be_bad + end + + it "failed is bad and not good" do + result.fail! + expect(result).to be_bad + expect(result).not_to be_good + end + end + + describe "#on" do + it "executes the block for matching status" do + called = false + result.on(:success) { called = true } + expect(called).to be(true) + end + + it "skips block for non-matching status" do + called = false + result.on(:failed) { called = true } + expect(called).to be(false) + end + + it "is chainable" do + values = [] + result.on(:success) { values << :s }.on(:failed) { values << :f } + expect(values).to eq([:s]) + end + + it "raises without a block" do + expect { result.on(:success) }.to raise_error(ArgumentError) + end + end + + describe "pattern matching" do + it "supports array deconstruction" do + case result + in ["initialized", "success", *] + matched = true + end + expect(matched).to be(true) + end + + it "supports hash deconstruction" do + case result + in { state: "initialized", status: "success" } + matched = true + end + expect(matched).to be(true) + end + end + + describe "#dry_run?" do + it "returns true when context has dry_run" do + ctx = CMDx::Context.build(dry_run: true) + r = described_class.new(task: task, context: ctx) + expect(r).to be_dry_run + end + end +end diff --git a/spec/cmdx/settings_spec.rb b/spec/cmdx/settings_spec.rb new file mode 100644 index 000000000..e1dbe99ea --- /dev/null +++ b/spec/cmdx/settings_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Settings do + subject(:settings) { described_class.new } + + describe "#merge!" do + it "stores overrides" do + settings.merge!(retries: 3, tags: ["important"]) + expect(settings[:retries]).to eq(3) + expect(settings[:tags]).to eq(["important"]) + end + + it "last-write-wins" do + settings.merge!(retries: 1) + settings.merge!(retries: 5) + expect(settings[:retries]).to eq(5) + end + end + + describe "#[]" do + it "falls back to global config" do + CMDx.configuration.dump_context = true + expect(settings[:dump_context]).to be(true) + end + + it "overrides win over global" do + CMDx.configuration.dump_context = true + settings.merge!(dump_context: false) + expect(settings[:dump_context]).to be(false) + end + end + + describe "parent chain" do + it "inherits from parent settings" do + parent = described_class.new + parent.merge!(retries: 3) + + child = described_class.new(parent: parent) + expect(child[:retries]).to eq(3) + end + + it "child override wins over parent" do + parent = described_class.new + parent.merge!(retries: 3) + + child = described_class.new(parent: parent) + child.merge!(retries: 1) + expect(child[:retries]).to eq(1) + end + end + + describe "convenience methods" do + it "provides defaults for retries" do + expect(settings.retries).to eq(0) + end + + it "provides defaults for tags" do + expect(settings.tags).to eq([]) + end + + it "provides defaults for log_level" do + expect(settings.log_level).to eq(:info) + end + end +end diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb new file mode 100644 index 000000000..d9d6767d5 --- /dev/null +++ b/spec/cmdx/task_spec.rb @@ -0,0 +1,338 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Task do + before { CMDx.configuration.freeze_results = false } + + describe ".execute" do + it "executes a minimal task" do + klass = Class.new(described_class) do + def work + context.done = true + end + end + + result = klass.execute + expect(result).to be_success + expect(result.context.done).to be(true) + end + + it "raises UndefinedMethodError without work" do + klass = Class.new(described_class) + result = klass.execute + expect(result).to be_failed + expect(result.reason).to include("must define a `work` method") + end + + it "accepts a block" do + klass = Class.new(described_class) do + def work; end + end + + yielded = nil + klass.execute { |r| yielded = r } + expect(yielded).to be_success + end + end + + describe ".execute!" do + it "raises FailFault on failure" do + klass = Class.new(described_class) do + def work + fail!("broken") + end + end + + expect { klass.execute! }.to raise_error(CMDx::FailFault, "broken") + end + + it "raises SkipFault on skip when configured" do + klass = Class.new(described_class) do + settings(task_breakpoints: %w[skipped failed]) + + def work + skip!("not needed") + end + end + + expect { klass.execute! }.to raise_error(CMDx::SkipFault, "not needed") + end + + it "returns result on success" do + klass = Class.new(described_class) do + def work + context.x = 1 + end + end + + result = klass.execute! + expect(result).to be_success + end + end + + describe "attributes" do + it "defines required attributes with coercion" do + klass = Class.new(described_class) do + required :count, type: :integer + + def work + context.doubled = count * 2 + end + end + + result = klass.execute(count: "5") + expect(result).to be_success + expect(result.context.doubled).to eq(10) + end + + it "fails on missing required attributes" do + klass = Class.new(described_class) do + required :name + end + + result = klass.execute + expect(result).to be_failed + expect(result.reason).to eq("Invalid") + end + + it "allows optional attributes to be nil" do + klass = Class.new(described_class) do + optional :note + + def work + context.has_note = !note.nil? + end + end + + result = klass.execute + expect(result).to be_success + expect(result.context.has_note).to be(false) + end + + it "applies defaults" do + klass = Class.new(described_class) do + optional :role, default: "member" + + def work + context.used_role = role + end + end + + result = klass.execute + expect(result.context.used_role).to eq("member") + end + + it "validates attributes" do + klass = Class.new(described_class) do + required :email, presence: true + end + + result = klass.execute(email: "") + expect(result).to be_failed + end + + it "applies transformations" do + klass = Class.new(described_class) do + required :tag, transform: ->(v) { v.to_s.downcase.strip } + + def work + context.clean_tag = tag + end + end + + result = klass.execute(tag: " HELLO ") + expect(result.context.clean_tag).to eq("hello") + end + end + + describe "halt methods" do + it "skip! sets result to skipped" do + klass = Class.new(described_class) do + def work + skip!("done already") + end + end + + result = klass.execute + expect(result).to be_skipped + expect(result.reason).to eq("done already") + end + + it "fail! sets result to failed with metadata" do + klass = Class.new(described_class) do + def work + fail!("bad input", code: 422) + end + end + + result = klass.execute + expect(result).to be_failed + expect(result.metadata[:code]).to eq(422) + end + + it "success! annotates success" do + klass = Class.new(described_class) do + def work + success!("imported 42 records", count: 42) + end + end + + result = klass.execute + expect(result).to be_success + expect(result.reason).to eq("imported 42 records") + expect(result.metadata[:count]).to eq(42) + end + end + + describe "exception handling" do + it "captures exceptions as failed results" do + klass = Class.new(described_class) do + def work + raise StandardError, "boom" + end + end + + result = klass.execute + expect(result).to be_failed + expect(result.reason).to include("boom") + end + end + + describe "dry_run?" do + it "detects dry run mode" do + klass = Class.new(described_class) do + def work + context.mode = dry_run? ? "dry" : "live" + end + end + + result = klass.execute(dry_run: true) + expect(result.context.mode).to eq("dry") + expect(result).to be_dry_run + end + end + + describe "rollback" do + it "calls rollback on failure" do + klass = Class.new(described_class) do + def work + fail!("bad") + end + + def rollback + context.rolled_back = true + end + end + + result = klass.execute + expect(result).to be_rolled_back + expect(result.context.rolled_back).to be(true) + end + end + + describe "retries" do + it "retries on configured exceptions" do + attempts = 0 + klass = Class.new(described_class) do + settings(retries: 2, retry_on: [RuntimeError]) + + define_method(:work) do + attempts += 1 + raise RuntimeError, "fail" if attempts < 3 + + context.done = true + end + end + + result = klass.execute + expect(result).to be_success + expect(result).to be_retried + expect(result.retries).to eq(2) + end + end + + describe "single-use" do + it "raises on double execution" do + klass = Class.new(described_class) do + def work; end + end + + task = klass.new + task.execute + expect { task.execute }.to raise_error(RuntimeError, /already been executed/) + end + end + + describe "inheritance" do + it "inherits attributes from parent" do + parent = Class.new(described_class) do + required :base_id + end + + child = Class.new(parent) do + required :child_name + + def work + context.ids = "#{base_id}-#{child_name}" + end + end + + result = child.execute(base_id: "A", child_name: "B") + expect(result).to be_success + expect(result.context.ids).to eq("A-B") + end + end + + describe "returns" do + it "passes when returns are set" do + klass = Class.new(described_class) do + returns :token + + def work + context.token = "abc" + end + end + + expect(klass.execute).to be_success + end + + it "fails when returns are missing" do + klass = Class.new(described_class) do + returns :token + + def work; end + end + + result = klass.execute + expect(result).to be_failed + expect(result.metadata[:errors][:messages]).to have_key(:token) + end + end + + describe "callbacks" do + it "runs before_execution and on_success" do + klass = Class.new(described_class) do + before_execution :setup + on_success :mark_done + + def work + context.worked = true + end + + private + + def setup + context.setup = true + end + + def mark_done + context.done = true + end + end + + result = klass.execute + expect(result.context.setup).to be(true) + expect(result.context.worked).to be(true) + expect(result.context.done).to be(true) + end + end +end diff --git a/spec/cmdx/validators_spec.rb b/spec/cmdx/validators_spec.rb new file mode 100644 index 000000000..91fcfd69e --- /dev/null +++ b/spec/cmdx/validators_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Validators do + describe ":presence" do + it "passes for present value" do + expect(described_class.validate(:presence, "hello", true)).to be_nil + end + + it "fails for nil" do + expect(described_class.validate(:presence, nil, true)).to be_a(String) + end + + it "fails for empty string" do + expect(described_class.validate(:presence, "", true)).to be_a(String) + end + + it "fails for whitespace-only string" do + expect(described_class.validate(:presence, " ", true)).to be_a(String) + end + end + + describe ":absence" do + it "passes for nil" do + expect(described_class.validate(:absence, nil, true)).to be_nil + end + + it "fails for present value" do + expect(described_class.validate(:absence, "hello", true)).to be_a(String) + end + end + + describe ":format" do + it "passes when matching pattern" do + expect(described_class.validate(:format, "abc", { with: /\A[a-z]+\z/ })).to be_nil + end + + it "fails when not matching pattern" do + expect(described_class.validate(:format, "123", { with: /\A[a-z]+\z/ })).to be_a(String) + end + + it "passes with shorthand regexp" do + expect(described_class.validate(:format, "abc", /\A[a-z]+\z/)).to be_nil + end + end + + describe ":inclusion" do + it "passes when included" do + expect(described_class.validate(:inclusion, "a", { in: %w[a b c] })).to be_nil + end + + it "fails when not included" do + expect(described_class.validate(:inclusion, "z", { in: %w[a b c] })).to be_a(String) + end + + it "works with ranges" do + expect(described_class.validate(:inclusion, 5, { in: 1..10 })).to be_nil + expect(described_class.validate(:inclusion, 15, { in: 1..10 })).to be_a(String) + end + end + + describe ":exclusion" do + it "passes when not excluded" do + expect(described_class.validate(:exclusion, "z", { in: %w[a b c] })).to be_nil + end + + it "fails when excluded" do + expect(described_class.validate(:exclusion, "a", { in: %w[a b c] })).to be_a(String) + end + end + + describe ":length" do + it "validates minimum" do + expect(described_class.validate(:length, "ab", { min: 3 })).to be_a(String) + expect(described_class.validate(:length, "abc", { min: 3 })).to be_nil + end + + it "validates maximum" do + expect(described_class.validate(:length, "abcde", { max: 3 })).to be_a(String) + expect(described_class.validate(:length, "abc", { max: 3 })).to be_nil + end + + it "validates exact" do + expect(described_class.validate(:length, "ab", { is: 3 })).to be_a(String) + expect(described_class.validate(:length, "abc", { is: 3 })).to be_nil + end + + it "validates range" do + expect(described_class.validate(:length, "a", { within: 2..5 })).to be_a(String) + expect(described_class.validate(:length, "abc", { within: 2..5 })).to be_nil + end + end + + describe ":numeric" do + it "validates min" do + expect(described_class.validate(:numeric, 5, { min: 10 })).to be_a(String) + expect(described_class.validate(:numeric, 15, { min: 10 })).to be_nil + end + + it "validates max" do + expect(described_class.validate(:numeric, 15, { max: 10 })).to be_a(String) + expect(described_class.validate(:numeric, 5, { max: 10 })).to be_nil + end + + it "validates range" do + expect(described_class.validate(:numeric, 15, { within: 1..10 })).to be_a(String) + expect(described_class.validate(:numeric, 5, { within: 1..10 })).to be_nil + end + end + + describe "allow_nil" do + it "skips validation when value is nil and allow_nil is true" do + expect(described_class.validate(:presence, nil, { allow_nil: true })).to be_nil + end + end +end diff --git a/spec/cmdx/workflow_spec.rb b/spec/cmdx/workflow_spec.rb new file mode 100644 index 000000000..b6d99fb8f --- /dev/null +++ b/spec/cmdx/workflow_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +RSpec.describe CMDx::Workflow do + before { CMDx.configuration.freeze_results = false } + + let(:step1) do + Class.new(CMDx::Task) do + def self.name; "Step1"; end + + def work + context.steps = (context.steps || []) << "step1" + end + end + end + + let(:step2) do + Class.new(CMDx::Task) do + def self.name; "Step2"; end + + def work + context.steps = (context.steps || []) << "step2" + end + end + end + + let(:failing_step) do + Class.new(CMDx::Task) do + def self.name; "FailingStep"; end + + def work + fail!("step failed") + end + end + end + + let(:skipping_step) do + Class.new(CMDx::Task) do + def self.name; "SkippingStep"; end + + def work + skip!("not needed") + end + end + end + + describe "sequential execution" do + it "runs tasks in order" do + s1, s2 = step1, step2 + workflow = Class.new(CMDx::Task) do + include CMDx::Workflow + task s1 + task s2 + end + + result = workflow.execute + expect(result).to be_success + expect(result.context.steps).to eq(%w[step1 step2]) + end + end + + describe "failure propagation" do + it "stops on first failure" do + s1, fs, s2 = step1, failing_step, step2 + workflow = Class.new(CMDx::Task) do + include CMDx::Workflow + task s1 + task fs + task s2 + end + + result = workflow.execute + expect(result).to be_failed + expect(result.context.steps).to eq(%w[step1]) + end + end + + describe "skip behavior" do + it "continues past skips by default" do + s1, ss, s2 = step1, skipping_step, step2 + workflow = Class.new(CMDx::Task) do + include CMDx::Workflow + task s1 + task ss + task s2 + end + + result = workflow.execute + expect(result.context.steps).to eq(%w[step1 step2]) + end + + it "halts on skip when configured" do + s1, ss, s2 = step1, skipping_step, step2 + workflow = Class.new(CMDx::Task) do + include CMDx::Workflow + settings(workflow_breakpoints: %w[skipped failed]) + task s1 + task ss + task s2 + end + + result = workflow.execute + expect(result.context.steps).to eq(%w[step1]) + end + end + + describe "conditionals" do + it "skips tasks when condition is false" do + s1, s2 = step1, step2 + workflow = Class.new(CMDx::Task) do + include CMDx::Workflow + task s1, if: -> { false } + task s2 + end + + result = workflow.execute + expect(result.context.steps).to eq(%w[step2]) + end + + it "runs tasks when condition is true" do + s1, s2 = step1, step2 + workflow = Class.new(CMDx::Task) do + include CMDx::Workflow + task s1, if: -> { true } + task s2 + end + + result = workflow.execute + expect(result.context.steps).to eq(%w[step1 step2]) + end + end + + describe "parallel execution" do + it "runs tasks in parallel" do + s1 = Class.new(CMDx::Task) do + def self.name; "ParaStep1"; end + + def work + context.para1 = true + end + end + + s2 = Class.new(CMDx::Task) do + def self.name; "ParaStep2"; end + + def work + context.para2 = true + end + end + + workflow = Class.new(CMDx::Task) do + include CMDx::Workflow + tasks s1, s2, strategy: :parallel + end + + result = workflow.execute + expect(result).to be_success + expect(result.context.para1).to be(true) + expect(result.context.para2).to be(true) + end + end + + describe "inheritance" do + it "inherits workflow tasks" do + s1, s2 = step1, step2 + parent = Class.new(CMDx::Task) do + include CMDx::Workflow + task s1 + end + + child = Class.new(parent) do + task s2 + end + + result = child.execute + expect(result).to be_success + expect(result.context.steps).to eq(%w[step1 step2]) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ad31e20b0..a5dcb91d2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,23 +6,10 @@ require "rspec" require "cmdx" -require "cmdx/rspec" - -spec_path = Pathname.new(File.expand_path("../spec", File.dirname(__FILE__))) - -%w[config helpers].each do |dir| - Dir.glob(spec_path.join("support/#{dir}/**/*.rb")) - .sort_by { |f| [f.split("/").size, f] } - .each { |f| load(f) } -end RSpec.configure do |config| config.shared_context_metadata_behavior = :apply_to_host_groups - - # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" - - # Disable RSpec exposing methods globally on Module and main config.disable_monkey_patching! config.define_derived_metadata do |meta| @@ -33,14 +20,9 @@ c.syntax = :expect end - config.include CMDx::RSpec::Helpers - config.include CMDx::Testing::TaskBuilders - config.include CMDx::Testing::WorkflowBuilders - config.before do CMDx.reset_configuration! CMDx.configuration.logger = Logger.new(nil) - CMDx.configuration.freeze_results = false CMDx::Chain.clear CMDx::Middlewares::Correlate.clear end