cmdx 1.21.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -1
  3. data/README.md +37 -24
  4. data/lib/cmdx/.DS_Store +0 -0
  5. data/lib/cmdx/callbacks.rb +179 -0
  6. data/lib/cmdx/chain.rb +78 -175
  7. data/lib/cmdx/coercions/array.rb +19 -33
  8. data/lib/cmdx/coercions/big_decimal.rb +12 -29
  9. data/lib/cmdx/coercions/boolean.rb +25 -45
  10. data/lib/cmdx/coercions/coerce.rb +32 -0
  11. data/lib/cmdx/coercions/complex.rb +12 -27
  12. data/lib/cmdx/coercions/date.rb +29 -33
  13. data/lib/cmdx/coercions/date_time.rb +29 -33
  14. data/lib/cmdx/coercions/float.rb +8 -29
  15. data/lib/cmdx/coercions/hash.rb +17 -43
  16. data/lib/cmdx/coercions/integer.rb +8 -32
  17. data/lib/cmdx/coercions/rational.rb +12 -33
  18. data/lib/cmdx/coercions/string.rb +6 -24
  19. data/lib/cmdx/coercions/symbol.rb +12 -26
  20. data/lib/cmdx/coercions/time.rb +31 -35
  21. data/lib/cmdx/coercions.rb +174 -0
  22. data/lib/cmdx/configuration.rb +45 -237
  23. data/lib/cmdx/context.rb +264 -243
  24. data/lib/cmdx/deprecation.rb +67 -0
  25. data/lib/cmdx/deprecators/error.rb +22 -0
  26. data/lib/cmdx/deprecators/log.rb +22 -0
  27. data/lib/cmdx/deprecators/warn.rb +21 -0
  28. data/lib/cmdx/deprecators.rb +101 -0
  29. data/lib/cmdx/errors.rb +145 -79
  30. data/lib/cmdx/executors/fiber.rb +42 -0
  31. data/lib/cmdx/executors/thread.rb +36 -0
  32. data/lib/cmdx/executors.rb +95 -0
  33. data/lib/cmdx/fault.rb +85 -78
  34. data/lib/cmdx/i18n_proxy.rb +104 -0
  35. data/lib/cmdx/input.rb +294 -0
  36. data/lib/cmdx/inputs.rb +218 -0
  37. data/lib/cmdx/log_formatters/json.rb +9 -20
  38. data/lib/cmdx/log_formatters/key_value.rb +10 -21
  39. data/lib/cmdx/log_formatters/line.rb +7 -19
  40. data/lib/cmdx/log_formatters/logstash.rb +8 -21
  41. data/lib/cmdx/log_formatters/raw.rb +8 -20
  42. data/lib/cmdx/logger_proxy.rb +30 -0
  43. data/lib/cmdx/mergers/deep_merge.rb +23 -0
  44. data/lib/cmdx/mergers/last_write_wins.rb +23 -0
  45. data/lib/cmdx/mergers/no_merge.rb +20 -0
  46. data/lib/cmdx/mergers.rb +95 -0
  47. data/lib/cmdx/middlewares.rb +128 -0
  48. data/lib/cmdx/output.rb +115 -0
  49. data/lib/cmdx/outputs.rb +66 -0
  50. data/lib/cmdx/pipeline.rb +144 -131
  51. data/lib/cmdx/railtie.rb +10 -36
  52. data/lib/cmdx/result.rb +247 -524
  53. data/lib/cmdx/retriers/bounded_random.rb +24 -0
  54. data/lib/cmdx/retriers/decorrelated_jitter.rb +28 -0
  55. data/lib/cmdx/retriers/exponential.rb +23 -0
  56. data/lib/cmdx/retriers/fibonacci.rb +39 -0
  57. data/lib/cmdx/retriers/full_random.rb +23 -0
  58. data/lib/cmdx/retriers/half_random.rb +24 -0
  59. data/lib/cmdx/retriers/linear.rb +23 -0
  60. data/lib/cmdx/retriers.rb +106 -0
  61. data/lib/cmdx/retry.rb +117 -138
  62. data/lib/cmdx/runtime.rb +251 -0
  63. data/lib/cmdx/settings.rb +68 -200
  64. data/lib/cmdx/signal.rb +165 -0
  65. data/lib/cmdx/task.rb +443 -343
  66. data/lib/cmdx/telemetry.rb +108 -0
  67. data/lib/cmdx/util.rb +73 -0
  68. data/lib/cmdx/validators/absence.rb +10 -39
  69. data/lib/cmdx/validators/exclusion.rb +33 -52
  70. data/lib/cmdx/validators/format.rb +19 -49
  71. data/lib/cmdx/validators/inclusion.rb +33 -54
  72. data/lib/cmdx/validators/length.rb +125 -127
  73. data/lib/cmdx/validators/numeric.rb +123 -123
  74. data/lib/cmdx/validators/presence.rb +10 -39
  75. data/lib/cmdx/validators/validate.rb +31 -0
  76. data/lib/cmdx/validators.rb +161 -0
  77. data/lib/cmdx/version.rb +2 -4
  78. data/lib/cmdx/workflow.rb +71 -96
  79. data/lib/cmdx.rb +111 -42
  80. data/lib/generators/cmdx/install_generator.rb +7 -17
  81. data/lib/generators/cmdx/task_generator.rb +12 -29
  82. data/lib/generators/cmdx/templates/install.rb +120 -48
  83. data/lib/generators/cmdx/templates/task.rb.tt +1 -1
  84. data/lib/generators/cmdx/templates/workflow.rb.tt +1 -2
  85. data/lib/generators/cmdx/workflow_generator.rb +12 -29
  86. data/lib/locales/en.yml +8 -7
  87. data/mkdocs.yml +25 -23
  88. metadata +39 -138
  89. data/lib/cmdx/attribute.rb +0 -440
  90. data/lib/cmdx/attribute_registry.rb +0 -185
  91. data/lib/cmdx/attribute_value.rb +0 -252
  92. data/lib/cmdx/callback_registry.rb +0 -169
  93. data/lib/cmdx/coercion_registry.rb +0 -138
  94. data/lib/cmdx/deprecator.rb +0 -77
  95. data/lib/cmdx/exception.rb +0 -46
  96. data/lib/cmdx/executor.rb +0 -378
  97. data/lib/cmdx/identifier.rb +0 -30
  98. data/lib/cmdx/locale.rb +0 -78
  99. data/lib/cmdx/middleware_registry.rb +0 -148
  100. data/lib/cmdx/middlewares/correlate.rb +0 -140
  101. data/lib/cmdx/middlewares/runtime.rb +0 -77
  102. data/lib/cmdx/middlewares/timeout.rb +0 -78
  103. data/lib/cmdx/parallelizer.rb +0 -100
  104. data/lib/cmdx/utils/call.rb +0 -53
  105. data/lib/cmdx/utils/condition.rb +0 -71
  106. data/lib/cmdx/utils/format.rb +0 -82
  107. data/lib/cmdx/utils/normalize.rb +0 -52
  108. data/lib/cmdx/utils/wrap.rb +0 -38
  109. data/lib/cmdx/validator_registry.rb +0 -143
  110. data/lib/generators/cmdx/locale_generator.rb +0 -39
  111. data/lib/locales/af.yml +0 -55
  112. data/lib/locales/ar.yml +0 -55
  113. data/lib/locales/az.yml +0 -55
  114. data/lib/locales/be.yml +0 -55
  115. data/lib/locales/bg.yml +0 -55
  116. data/lib/locales/bn.yml +0 -55
  117. data/lib/locales/bs.yml +0 -55
  118. data/lib/locales/ca.yml +0 -55
  119. data/lib/locales/cnr.yml +0 -55
  120. data/lib/locales/cs.yml +0 -55
  121. data/lib/locales/cy.yml +0 -55
  122. data/lib/locales/da.yml +0 -55
  123. data/lib/locales/de.yml +0 -55
  124. data/lib/locales/dz.yml +0 -55
  125. data/lib/locales/el.yml +0 -55
  126. data/lib/locales/eo.yml +0 -55
  127. data/lib/locales/es.yml +0 -55
  128. data/lib/locales/et.yml +0 -55
  129. data/lib/locales/eu.yml +0 -55
  130. data/lib/locales/fa.yml +0 -55
  131. data/lib/locales/fi.yml +0 -55
  132. data/lib/locales/fr.yml +0 -55
  133. data/lib/locales/fy.yml +0 -55
  134. data/lib/locales/gd.yml +0 -55
  135. data/lib/locales/gl.yml +0 -55
  136. data/lib/locales/he.yml +0 -55
  137. data/lib/locales/hi.yml +0 -55
  138. data/lib/locales/hr.yml +0 -55
  139. data/lib/locales/hu.yml +0 -55
  140. data/lib/locales/hy.yml +0 -55
  141. data/lib/locales/id.yml +0 -55
  142. data/lib/locales/is.yml +0 -55
  143. data/lib/locales/it.yml +0 -55
  144. data/lib/locales/ja.yml +0 -55
  145. data/lib/locales/ka.yml +0 -55
  146. data/lib/locales/kk.yml +0 -55
  147. data/lib/locales/km.yml +0 -55
  148. data/lib/locales/kn.yml +0 -55
  149. data/lib/locales/ko.yml +0 -55
  150. data/lib/locales/lb.yml +0 -55
  151. data/lib/locales/lo.yml +0 -55
  152. data/lib/locales/lt.yml +0 -55
  153. data/lib/locales/lv.yml +0 -55
  154. data/lib/locales/mg.yml +0 -55
  155. data/lib/locales/mk.yml +0 -55
  156. data/lib/locales/ml.yml +0 -55
  157. data/lib/locales/mn.yml +0 -55
  158. data/lib/locales/mr-IN.yml +0 -55
  159. data/lib/locales/ms.yml +0 -55
  160. data/lib/locales/nb.yml +0 -55
  161. data/lib/locales/ne.yml +0 -55
  162. data/lib/locales/nl.yml +0 -55
  163. data/lib/locales/nn.yml +0 -55
  164. data/lib/locales/oc.yml +0 -55
  165. data/lib/locales/or.yml +0 -55
  166. data/lib/locales/pa.yml +0 -55
  167. data/lib/locales/pl.yml +0 -55
  168. data/lib/locales/pt.yml +0 -55
  169. data/lib/locales/rm.yml +0 -55
  170. data/lib/locales/ro.yml +0 -55
  171. data/lib/locales/ru.yml +0 -55
  172. data/lib/locales/sc.yml +0 -55
  173. data/lib/locales/sk.yml +0 -55
  174. data/lib/locales/sl.yml +0 -55
  175. data/lib/locales/sq.yml +0 -55
  176. data/lib/locales/sr.yml +0 -55
  177. data/lib/locales/st.yml +0 -55
  178. data/lib/locales/sv.yml +0 -55
  179. data/lib/locales/sw.yml +0 -55
  180. data/lib/locales/ta.yml +0 -55
  181. data/lib/locales/te.yml +0 -55
  182. data/lib/locales/th.yml +0 -55
  183. data/lib/locales/tl.yml +0 -55
  184. data/lib/locales/tr.yml +0 -55
  185. data/lib/locales/tt.yml +0 -55
  186. data/lib/locales/ug.yml +0 -55
  187. data/lib/locales/uk.yml +0 -55
  188. data/lib/locales/ur.yml +0 -55
  189. data/lib/locales/uz.yml +0 -55
  190. data/lib/locales/vi.yml +0 -55
  191. data/lib/locales/wo.yml +0 -55
  192. data/lib/locales/zh-CN.yml +0 -55
  193. data/lib/locales/zh-HK.yml +0 -55
  194. data/lib/locales/zh-TW.yml +0 -55
  195. data/lib/locales/zh-YUE.yml +0 -55
data/lib/cmdx/fault.rb CHANGED
@@ -1,109 +1,116 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
-
5
- # Base fault class for handling task execution failures and interruptions.
4
+ # Exception raised by `execute!` (strict mode) when a task fails. Carries
5
+ # the originating {Result} (deepest in any propagation chain) and exposes
6
+ # `task`, `signal`, `context`, and `chain` as delegators. The backtrace is
7
+ # cleaned through the configured `backtrace_cleaner` when present.
6
8
  #
7
- # Faults represent error conditions that occur during task execution, providing
8
- # a structured way to handle and categorize different types of failures.
9
- # Each fault contains a reference to the result object that caused the fault.
9
+ # Use {.for?} or {.matches?} to build matcher subclasses suitable for
10
+ # `rescue` clauses.
10
11
  class Fault < Error
11
12
 
12
- extend Forwardable
13
-
14
- # Returns the result that caused this fault.
15
- #
16
- # @return [Result] The result instance
17
- #
18
- # @example
19
- # fault.result.reason # => "Validation failed"
20
- #
21
- # @rbs @result: Result
22
- attr_reader :result
23
-
24
- def_delegators :result, :task, :context, :chain
25
-
26
- # Initialize a new fault with the given result.
27
- #
28
- # @param result [Result] the result object that caused this fault
29
- #
30
- # @raise [ArgumentError] if result is nil or invalid
31
- #
32
- # @example
33
- # fault = Fault.new(task_result)
34
- # fault.result.reason # => "Task validation failed"
35
- #
36
- # @rbs (Result result) -> void
37
- def initialize(result)
38
- @result = result
39
-
40
- super(result.reason)
41
- end
42
-
43
13
  class << self
44
14
 
45
- # Create a fault class that matches specific task types.
46
- #
47
- # @param tasks [Array<Class>] array of task classes to match against
15
+ # Returns a matcher subclass that matches Faults whose `task` is (or
16
+ # inherits from) any of the given task classes. Suitable for use in
17
+ # `rescue`.
48
18
  #
49
- # @return [Class] a new fault class that matches the specified tasks
19
+ # @param tasks [Array<Class>] one or more Task classes
20
+ # @return [Class<Fault>] anonymous matcher subclass
21
+ # @raise [ArgumentError] when no tasks are given
50
22
  #
51
23
  # @example
52
- # Fault.for?(UserTask, AdminUserTask)
53
- # # => true if fault.task is a UserTask or AdminUserTask
54
- #
55
- # @rbs (*Class tasks) -> Class
24
+ # begin
25
+ # MyTask.execute!(ctx)
26
+ # rescue Fault.for?(ProcessOrder, ChargeCard) => fault
27
+ # Alert.for_fault(fault)
28
+ # end
56
29
  def for?(*tasks)
57
- temp_fault = Class.new(self) do
58
- def self.===(other)
59
- other.is_a?(superclass) && @tasks.any? { |task| other.task.is_a?(task) }
60
- end
61
- end
30
+ tasks = tasks.flatten
31
+ raise ArgumentError, "at least one task required" if tasks.empty?
62
32
 
63
- temp_fault.tap { |c| c.instance_variable_set(:@tasks, tasks) }
33
+ matcher do |other|
34
+ tasks.any? { |task| other.task <= task }
35
+ end
64
36
  end
65
37
 
66
- # Create a fault class that matches based on a custom block.
67
- #
68
- # @param block [Proc] block that determines if a fault matches
38
+ # Returns a matcher subclass that matches Faults whose `result.reason`
39
+ # is equal to the given string. Suitable for use in `rescue`.
69
40
  #
70
- # @return [Class] a new fault class that matches based on the block
71
- #
72
- # @raise [ArgumentError] if no block is provided
41
+ # @param reason [String] the reason to match
42
+ # @return [Class<Fault>] anonymous matcher subclass
43
+ # @raise [ArgumentError] when no reason is given
73
44
  #
74
45
  # @example
75
- # Fault.matches? { |fault| fault.result.metadata[:critical] }
76
- # # => true if fault has critical metadata
46
+ # begin
47
+ # MyTask.execute!(ctx)
48
+ # rescue Fault.reason?("Payment failed") => fault
49
+ # Alert.for_fault(fault)
50
+ # end
51
+ def reason?(reason)
52
+ raise ArgumentError, "reason required" unless reason
53
+
54
+ matcher do |other|
55
+ other.result.reason == reason
56
+ end
57
+ end
58
+
59
+ # Returns a matcher subclass whose `===` runs `block` against the fault.
77
60
  #
78
- # @rbs () { (Fault) -> bool } -> Class
61
+ # @param block [#call] `(fault) -> Boolean` matcher body
62
+ # @yieldparam fault [Fault]
63
+ # @yieldreturn [Boolean]
64
+ # @return [Class<Fault>] anonymous matcher subclass
65
+ # @raise [ArgumentError] when no block is given
79
66
  def matches?(&block)
80
- raise ArgumentError, "block required" unless block_given?
67
+ raise ArgumentError, "block required" unless block
81
68
 
82
- temp_fault = Class.new(self) do
83
- def self.===(other)
84
- other.is_a?(superclass) && @block.call(other)
69
+ matcher(&block)
70
+ end
71
+
72
+ private
73
+
74
+ def matcher(&)
75
+ fault_class = self
76
+ Class.new(fault_class) do
77
+ define_singleton_method(:===) do |other|
78
+ fault_class === other && yield(other)
85
79
  end
86
80
  end
87
-
88
- temp_fault.tap { |c| c.instance_variable_set(:@block, block) }
89
81
  end
90
82
 
91
83
  end
92
84
 
93
- end
85
+ attr_reader :result
94
86
 
95
- # Fault raised when a task is intentionally skipped during execution.
96
- #
97
- # This fault occurs when a task determines it should not execute based on
98
- # its current context or conditions. Skipped tasks are not considered failures
99
- # but rather intentional bypasses of task execution logic.
100
- SkipFault = Class.new(Fault)
87
+ # @param result [Result] the failed result this Fault represents
88
+ def initialize(result)
89
+ @result = result
101
90
 
102
- # Fault raised when a task execution fails due to errors or validation failures.
103
- #
104
- # This fault occurs when a task encounters an error condition, validation failure,
105
- # or any other condition that prevents successful completion. Failed tasks indicate
106
- # that the intended operation could not be completed successfully.
107
- FailFault = Class.new(Fault)
91
+ super(I18nProxy.tr(result.reason))
108
92
 
93
+ if (frames = result.backtrace || result.cause&.backtrace_locations)
94
+ frames = frames.map(&:to_s)
95
+ frames = task.settings.backtrace_cleaner&.call(frames) || frames
96
+ set_backtrace(frames)
97
+ end
98
+ end
99
+
100
+ # @return [Class<Task>] the failing task class
101
+ def task
102
+ @result.task
103
+ end
104
+
105
+ # @return [Context] the failed task's context
106
+ def context
107
+ @result.context
108
+ end
109
+
110
+ # @return [Chain] the chain the failed result belongs to
111
+ def chain
112
+ @result.chain
113
+ end
114
+
115
+ end
109
116
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Translation façade used internally for coercion, validator, and output
5
+ # error messages. Delegates to `I18n.translate` when the `i18n` gem is
6
+ # available; otherwise loads CMDx's bundled YAML locale file and performs
7
+ # percent-interpolation on the string itself. Results are memoized.
8
+ class I18nProxy
9
+
10
+ class << self
11
+
12
+ # @return [Array<String>] directories searched (in order) for bundled locale YAMLs
13
+ def locale_paths
14
+ @locale_paths ||= [File.expand_path("../locales", __dir__)]
15
+ end
16
+
17
+ # @param key [String, Symbol] dot-separated translation key
18
+ # @param options [Hash{Symbol => Object}] forwarded to `I18n.translate` or bundled interpolation
19
+ # @option options [Object] :default fallback when the bundled YAML lookup misses
20
+ # @option options [Hash{Symbol => Object}] extra keys interpolated via `String#%` for bundled translations
21
+ # @return [String, Object] the translated string (or the raw default value)
22
+ def translate(key, **options)
23
+ @proxy ||= new
24
+ @proxy.translate(key, **options)
25
+ end
26
+
27
+ # @param (see .translate)
28
+ # @option (see .translate)
29
+ # @return (see .translate)
30
+ def t(key, **options)
31
+ translate(key, **options)
32
+ end
33
+
34
+ # Register an additional directory containing locale YAML files. Later
35
+ # registrations take precedence over earlier ones (the most recently
36
+ # registered path's values win during deep merge). Resets the memoized
37
+ # proxy so subsequent lookups see the new path.
38
+ #
39
+ # @param path [String] absolute path to a directory of `<locale>.yml` files
40
+ # @return [Array<String>] the updated locale paths
41
+ def register(path)
42
+ locale_paths.push(path) unless locale_paths.include?(path)
43
+ @proxy = nil
44
+ locale_paths
45
+ end
46
+
47
+ # Resolves a reason string through translation, falling back to either
48
+ # the literal reason (when present) or the `cmdx.reasons.unspecified`
49
+ # default (when nil).
50
+ #
51
+ # @param reason [String, Symbol, nil] reason text or translation key
52
+ # @return [String] translated message, literal reason, or default
53
+ def tr(reason)
54
+ translate(reason || "cmdx.reasons.unspecified", default: reason)
55
+ end
56
+
57
+ end
58
+
59
+ # @param key [String, Symbol] dot-separated translation key
60
+ # @param options [Hash{Symbol => Object}] interpolation values
61
+ # @option options [Object] :default fallback when the bundled YAML lookup misses
62
+ # @option options [Hash{Symbol => Object}] extra keys interpolated via `String#%` for bundled translations
63
+ # @return [String, Object] the translated/interpolated message
64
+ def translate(key, **options)
65
+ return ::I18n.translate(key, **options) if defined?(::I18n) && ::I18n.respond_to?(:translate)
66
+
67
+ message = translation_default(key) || options[:default]
68
+
69
+ case message
70
+ when String
71
+ message % options
72
+ when NilClass
73
+ "Translation missing: #{key}"
74
+ else
75
+ message
76
+ end
77
+ end
78
+ alias t translate
79
+
80
+ private
81
+
82
+ # @param key [String, Symbol] lookup key (without locale prefix)
83
+ # @return [Object, nil] message template from bundled YAML, when present
84
+ def translation_default(key)
85
+ default_locale = CMDx.configuration.default_locale || "en"
86
+ translation_key = "#{default_locale}.#{key}"
87
+
88
+ @defaults ||= {}
89
+ return @defaults[translation_key] if @defaults.key?(translation_key)
90
+
91
+ @translations ||= {}
92
+ @translations[default_locale] ||= begin
93
+ file = "#{default_locale}.yml"
94
+ paths = self.class.locale_paths.map { |dir| File.join(dir, file) }.select { |p| File.exist?(p) }
95
+ raise LoadError, "unable to load #{default_locale} translations" if paths.empty?
96
+
97
+ paths.reduce({}) { |hash, path| hash.merge(YAML.safe_load_file(path)) }.freeze
98
+ end
99
+
100
+ @defaults[translation_key] = @translations[default_locale].dig(*translation_key.split("."))
101
+ end
102
+
103
+ end
104
+ end
data/lib/cmdx/input.rb ADDED
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # A single declared task input. Holds declaration options (`:source`,
5
+ # `:default`, `:required`, `:coerce`, validators, `:transform`, etc.) and
6
+ # owns the resolution pipeline that produces the value the task will read
7
+ # through the generated accessor.
8
+ class Input
9
+
10
+ attr_reader :name, :children
11
+
12
+ # @param name [Symbol, String] input key (symbolized)
13
+ # @param children [Array<Input>] nested child inputs resolved from this one's value
14
+ # @param options [Hash{Symbol => Object}] declaration options
15
+ # @option options [String] :description (also accepts `:desc`)
16
+ # @option options [Symbol] :as overrides the accessor name
17
+ # @option options [Boolean, String] :prefix prefix for the accessor name
18
+ # @option options [Boolean, String] :suffix suffix for the accessor name
19
+ # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from
20
+ # @option options [Object, Symbol, Proc, #call] :default
21
+ # @option options [Symbol, Proc, #call] :transform mutator applied after coercion
22
+ # @option options [Symbol, Proc, #call] :if
23
+ # @option options [Symbol, Proc, #call] :unless
24
+ # @option options [Boolean] :required
25
+ def initialize(name, children: EMPTY_ARRAY, **options)
26
+ @name = name.to_sym
27
+ @children = children.freeze
28
+ @options = options.freeze
29
+ end
30
+
31
+ # @return [String, nil]
32
+ def description
33
+ @options[:description] || @options[:desc]
34
+ end
35
+
36
+ # @return [Symbol, nil]
37
+ def as
38
+ @options[:as]
39
+ end
40
+
41
+ # @return [Boolean, String, nil]
42
+ def prefix
43
+ @options[:prefix]
44
+ end
45
+
46
+ # @return [Boolean, String, nil]
47
+ def suffix
48
+ @options[:suffix]
49
+ end
50
+
51
+ # @return [Symbol, Proc, #call]
52
+ def source
53
+ @options[:source] || :context
54
+ end
55
+
56
+ # @return [Object, Symbol, Proc, #call, nil]
57
+ def default
58
+ @options[:default]
59
+ end
60
+
61
+ # @return [Symbol, Proc, #call, nil]
62
+ def transform
63
+ @options[:transform]
64
+ end
65
+
66
+ # @return [Symbol, Proc, #call, nil]
67
+ def condition_if
68
+ @options[:if]
69
+ end
70
+
71
+ # @return [Symbol, Proc, #call, nil]
72
+ def condition_unless
73
+ @options[:unless]
74
+ end
75
+
76
+ # @return [Boolean]
77
+ def required
78
+ @options.fetch(:required, false)
79
+ end
80
+
81
+ # Computed accessor/reader method name. Uses `:as` when provided,
82
+ # otherwise combines `:prefix`, `name`, and `:suffix` around the source.
83
+ #
84
+ # @return [Symbol]
85
+ def accessor_name
86
+ return as if as
87
+
88
+ @accessor_name ||= begin
89
+ prefix_str =
90
+ case prefix
91
+ when true
92
+ "#{source}_"
93
+ when ::String
94
+ prefix
95
+ end
96
+ suffix_str =
97
+ case suffix
98
+ when true
99
+ "_#{source}"
100
+ when ::String
101
+ suffix
102
+ end
103
+
104
+ :"#{prefix_str}#{name}#{suffix_str}"
105
+ end
106
+ end
107
+
108
+ # @return [Symbol] backing ivar used by the generated reader method
109
+ def ivar_name
110
+ @ivar_name ||= :"@_input_#{accessor_name}"
111
+ end
112
+
113
+ # Evaluates required-ness against `task`, respecting `:if`/`:unless`.
114
+ # When called without a task, returns the static `:required` flag.
115
+ #
116
+ # @param task [Task, nil]
117
+ # @return [Boolean]
118
+ def required?(task = nil)
119
+ return false unless required
120
+ return true if task.nil?
121
+ return false unless Util.satisfied?(condition_if, condition_unless, task)
122
+
123
+ true
124
+ end
125
+
126
+ # Fetches + coerces + transforms + validates the value from its
127
+ # configured `:source` on `task`. Missing-but-required inputs add a
128
+ # validation error to `task.errors`. Returns `nil` when coercion or any
129
+ # validator fails (the failure message is recorded on `task.errors`).
130
+ #
131
+ # @param task [Task]
132
+ # @return [Object, nil] the resolved value (`nil` on failure)
133
+ def resolve(task)
134
+ value, key_provided = resolve_with_key(task)
135
+ run_pipeline(value, key_provided, task)
136
+ end
137
+
138
+ # Same as {#resolve} but fetches the value from `parent_value` (used for
139
+ # nested child inputs) instead of the declared `:source`.
140
+ #
141
+ # @param parent_value [#[], #key?, Object] the parent input's resolved value
142
+ # @param task [Task]
143
+ # @return [Object, nil]
144
+ def resolve_from_parent(parent_value, task)
145
+ value, key_provided = resolve_from_parent_with_key(parent_value)
146
+ run_pipeline(value, key_provided, task)
147
+ end
148
+
149
+ # @return [Hash{Symbol => Object}] serialized schema used by `inputs_schema`
150
+ def to_h
151
+ {
152
+ name: accessor_name,
153
+ description:,
154
+ required: required?,
155
+ options: @options,
156
+ children: children.map(&:to_h)
157
+ }
158
+ end
159
+
160
+ # JSON-friendly hash view. Aliases {#to_h} for conventional `as_json`
161
+ # callers (e.g. Rails).
162
+ #
163
+ # @return [Hash{Symbol => Object}]
164
+ def as_json(*)
165
+ to_h
166
+ end
167
+
168
+ # Serializes the input schema to a JSON string. Non-primitive entries in
169
+ # `:options` (Procs, arbitrary callables) emit via their stdlib `to_json`
170
+ # defaults.
171
+ #
172
+ # @param args [Array] forwarded to `Hash#to_json`
173
+ # @return [String]
174
+ def to_json(*args)
175
+ to_h.to_json(*args)
176
+ end
177
+
178
+ private
179
+
180
+ # @param value [Object] candidate after source resolution
181
+ # @param key_provided [Boolean] whether the source reported an explicit key/value pair
182
+ # @param task [Task]
183
+ # @return [Object, nil]
184
+ def run_pipeline(value, key_provided, task)
185
+ if required?(task) && !key_provided
186
+ task.errors.add(accessor_name, I18nProxy.t("cmdx.attributes.required"))
187
+ return
188
+ end
189
+
190
+ value = apply_default(task) if value.nil?
191
+ return if value.nil?
192
+
193
+ @coercions ||= task.class.coercions.extract(@options)
194
+ value = task.class.coercions.coerce(task, accessor_name, value, @coercions)
195
+ return if value.is_a?(Coercions::Failure)
196
+
197
+ value = apply_transform(value, task) if transform
198
+ @validators ||= task.class.validators.extract(@options)
199
+ task.class.validators.validate(task, accessor_name, value, @validators)
200
+ return if task.errors.for?(accessor_name)
201
+
202
+ value
203
+ end
204
+
205
+ # @param task [Task]
206
+ # @return [Array(Object, Boolean)] `[value, key_provided]`
207
+ def resolve_with_key(task)
208
+ case source
209
+ when :context
210
+ [task.context[name], task.context.key?(name)]
211
+ when Symbol
212
+ obj = task.send(source)
213
+ return [nil, false] if obj.nil?
214
+
215
+ fetch_by_name(obj)
216
+ when Proc
217
+ [task.instance_exec(&source), true]
218
+ else
219
+ return [source.call(task), true] if source.respond_to?(:call)
220
+
221
+ raise ArgumentError, "source must be a Symbol, Proc, or respond to #call"
222
+ end
223
+ end
224
+
225
+ # @param parent_value [#[], #key?, Object]
226
+ # @return [Array(Object, Boolean)] `[value, key_provided]`
227
+ def resolve_from_parent_with_key(parent_value)
228
+ return [nil, false] unless parent_value.respond_to?(:[])
229
+
230
+ fetch_by_name(parent_value)
231
+ end
232
+
233
+ # @param obj [Object] source object (`Hash`, duck-typed reader, etc.)
234
+ # @return [Array(Object, Boolean)] `[value, key_provided]`
235
+ def fetch_by_name(obj)
236
+ if obj.respond_to?(name, true)
237
+ [obj.send(name), true]
238
+ elsif obj.respond_to?(:key?)
239
+ if obj.key?(name)
240
+ [obj[name], true]
241
+ elsif obj.key?(name_str = name.to_s)
242
+ [obj[name_str], true]
243
+ else
244
+ [nil, false]
245
+ end
246
+ elsif obj.respond_to?(:[])
247
+ # Without #key? we cannot distinguish "key absent" from "value is nil",
248
+ # so an explicit nil is treated as not provided (triggers default/required).
249
+ value = obj[name] || obj[name.to_s]
250
+ [value, !value.nil?]
251
+ else
252
+ [nil, false]
253
+ end
254
+ end
255
+
256
+ # @param task [Task]
257
+ # @return [Object, nil]
258
+ def apply_default(task)
259
+ return if default.nil?
260
+
261
+ case default
262
+ when Symbol
263
+ task.send(default)
264
+ when Proc
265
+ task.instance_exec(&default)
266
+ else
267
+ return default unless default.respond_to?(:call)
268
+
269
+ default.call(task)
270
+ end
271
+ end
272
+
273
+ # @param value [Object]
274
+ # @param task [Task]
275
+ # @return [Object]
276
+ def apply_transform(value, task)
277
+ case transform
278
+ when Symbol
279
+ if value.respond_to?(transform, true)
280
+ value.send(transform)
281
+ else
282
+ task.send(transform, value)
283
+ end
284
+ when Proc
285
+ task.instance_exec(value, &transform)
286
+ else
287
+ return transform.call(value, task) if transform.respond_to?(:call)
288
+
289
+ raise ArgumentError, "transform must be a Symbol, Proc, or respond to #call"
290
+ end
291
+ end
292
+
293
+ end
294
+ end