inquirex 0.5.0 → 0.6.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/README.md +119 -0
  4. data/docs/badges/coverage_badge.svg +2 -2
  5. data/examples/03_send_email_actions.rb +97 -0
  6. data/lib/inquirex/accumulator.rb +29 -0
  7. data/lib/inquirex/actions/action.rb +68 -0
  8. data/lib/inquirex/actions/base.rb +41 -0
  9. data/lib/inquirex/actions/custom.rb +31 -0
  10. data/lib/inquirex/actions/outbox.rb +57 -0
  11. data/lib/inquirex/actions/runner.rb +52 -0
  12. data/lib/inquirex/actions/send_email.rb +174 -0
  13. data/lib/inquirex/actions/template.rb +95 -0
  14. data/lib/inquirex/actions/webhook.rb +139 -0
  15. data/lib/inquirex/actions.rb +57 -0
  16. data/lib/inquirex/answers.rb +13 -0
  17. data/lib/inquirex/completion_metadata.rb +82 -0
  18. data/lib/inquirex/definition.rb +67 -5
  19. data/lib/inquirex/dsl/action_builder.rb +53 -0
  20. data/lib/inquirex/dsl/flow_builder.rb +49 -6
  21. data/lib/inquirex/engine/state_serializer.rb +6 -4
  22. data/lib/inquirex/engine.rb +97 -5
  23. data/lib/inquirex/errors.rb +5 -0
  24. data/lib/inquirex/graph/mermaid_exporter.rb +2 -0
  25. data/lib/inquirex/node.rb +2 -0
  26. data/lib/inquirex/rules/all.rb +12 -0
  27. data/lib/inquirex/rules/any.rb +11 -0
  28. data/lib/inquirex/rules/base.rb +6 -0
  29. data/lib/inquirex/rules/contains.rb +12 -0
  30. data/lib/inquirex/rules/equals.rb +12 -0
  31. data/lib/inquirex/rules/greater_than.rb +12 -0
  32. data/lib/inquirex/rules/less_than.rb +12 -0
  33. data/lib/inquirex/rules/not_empty.rb +12 -0
  34. data/lib/inquirex/validation/adapter.rb +1 -0
  35. data/lib/inquirex/validation/null_adapter.rb +5 -0
  36. data/lib/inquirex/version.rb +1 -1
  37. data/lib/inquirex/widget_registry.rb +1 -0
  38. data/lib/inquirex.rb +13 -0
  39. metadata +30 -4
@@ -13,7 +13,14 @@ module Inquirex
13
13
  # @attr_reader start_step_id [Symbol] id of the first step in the flow
14
14
  # @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node
15
15
  class Definition
16
- attr_reader :id, :version, :meta, :start_step_id, :steps, :accumulators
16
+ attr_reader :id,
17
+ :version,
18
+ :meta,
19
+ :start_step_id,
20
+ :steps,
21
+ :accumulators,
22
+ :actions,
23
+ :allowed_domains
17
24
 
18
25
  # @param start_step_id [Symbol] id of the initial step
19
26
  # @param nodes [Hash<Symbol, Node>] all steps keyed by id
@@ -21,14 +28,20 @@ module Inquirex
21
28
  # @param version [String] semver
22
29
  # @param meta [Hash] frontend metadata
23
30
  # @param accumulators [Hash<Symbol, Accumulator>] named running totals
31
+ # @param actions [Array<Actions::Action>] post-completion actions, in order
32
+ # @param allowed_domains [Array<String>] hosts outbound effects (webhook)
33
+ # may send answers to; "example.com" exact, "*.example.com" subdomains
24
34
  # @raise [Errors::DefinitionError] if start_step_id is not present in nodes
25
- def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {}, accumulators: {})
35
+ def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {},
36
+ accumulators: {}, actions: [], allowed_domains: [])
26
37
  @id = id
27
38
  @version = version
28
39
  @meta = meta.freeze
29
40
  @start_step_id = start_step_id.to_sym
30
41
  @steps = nodes.freeze
31
42
  @accumulators = accumulators.freeze
43
+ @actions = actions.freeze
44
+ @allowed_domains = normalize_domains(allowed_domains)
32
45
  validate!
33
46
  freeze
34
47
  end
@@ -50,6 +63,25 @@ module Inquirex
50
63
  @steps.keys
51
64
  end
52
65
 
66
+ # Whether a host is covered by the allowed_domains declaration.
67
+ # "example.com" matches that host exactly; "*.example.com" matches any
68
+ # subdomain but not the apex. Matching is case-insensitive; an empty
69
+ # allowlist allows nothing.
70
+ #
71
+ # @example With allowed_domains ["*.example.com"]
72
+ # definition.allowed_host?("api.example.com") # => true
73
+ # definition.allowed_host?("API.EXAMPLE.COM") # => true (case-insensitive)
74
+ # definition.allowed_host?("example.com") # => false (wildcard excludes the apex)
75
+ #
76
+ # @param host [String]
77
+ # @return [Boolean]
78
+ def allowed_host?(host)
79
+ target = host.to_s.downcase
80
+ @allowed_domains.any? do |entry|
81
+ entry.start_with?("*.") ? target.end_with?(entry[1..]) : target == entry
82
+ end
83
+ end
84
+
53
85
  # Serializes the definition to a JSON string.
54
86
  # Lambdas (default procs, compute blocks) are silently stripped.
55
87
  #
@@ -66,6 +98,7 @@ module Inquirex
66
98
  hash["id"] = @id if @id
67
99
  hash["version"] = @version
68
100
  hash["meta"] = @meta unless @meta.empty?
101
+ hash["allowed_domains"] = @allowed_domains unless @allowed_domains.empty?
69
102
  hash["start"] = @start_step_id.to_s
70
103
  unless @accumulators.empty?
71
104
  hash["accumulators"] = @accumulators.each_with_object({}) do |(name, acc), h|
@@ -73,6 +106,8 @@ module Inquirex
73
106
  end
74
107
  end
75
108
  hash["steps"] = @steps.transform_keys(&:to_s).transform_values(&:to_h)
109
+ serializable_actions = @actions.select(&:serializable?)
110
+ hash["actions"] = serializable_actions.map(&:to_h) unless serializable_actions.empty?
76
111
  hash
77
112
  end
78
113
 
@@ -97,6 +132,8 @@ module Inquirex
97
132
  start = hash["start"] || hash[:start]
98
133
  steps_data = hash["steps"] || hash[:steps] || {}
99
134
  acc_data = hash["accumulators"] || hash[:accumulators] || {}
135
+ actions_data = hash["actions"] || hash[:actions] || []
136
+ domains = hash["allowed_domains"] || hash[:allowed_domains] || []
100
137
 
101
138
  nodes = steps_data.each_with_object({}) do |(step_id, step_hash), acc|
102
139
  sym_id = step_id.to_sym
@@ -108,15 +145,40 @@ module Inquirex
108
145
  h[sym] = Accumulator.from_h(sym, entry)
109
146
  end
110
147
 
111
- new(start_step_id: start, nodes:, id:, version:, meta:, accumulators:)
148
+ actions = actions_data.map { |entry| Actions::Action.from_h(entry) }
149
+
150
+ new(start_step_id: start,
151
+ nodes:,
152
+ id:,
153
+ version:,
154
+ meta:,
155
+ accumulators:,
156
+ actions:,
157
+ allowed_domains: domains)
112
158
  end
113
159
 
114
160
  private
115
161
 
116
162
  def validate!
117
- return if @steps.key?(@start_step_id)
163
+ unless @steps.key?(@start_step_id)
164
+ raise Errors::DefinitionError, "Start step #{@start_step_id.inspect} not found in steps"
165
+ end
118
166
 
119
- raise Errors::DefinitionError, "Start step #{@start_step_id.inspect} not found in steps"
167
+ @actions.each do |action|
168
+ action.effects.each { |effect| effect.validate_against(self) }
169
+ end
170
+ end
171
+
172
+ def normalize_domains(domains)
173
+ domains.map do |entry|
174
+ domain = entry.to_s.strip.downcase
175
+ if domain.empty? || domain == "*" || domain.match?(%r{[/\s:@]})
176
+ raise Errors::DefinitionError,
177
+ "allowed_domains entries must be bare domains like \"example.com\" " \
178
+ "or \"*.example.com\", got #{entry.inspect}"
179
+ end
180
+ domain
181
+ end.freeze
120
182
  end
121
183
  end
122
184
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module DSL
5
+ # Builds an Actions::Action from an `action` DSL block. Every effect verb
6
+ # registered in Inquirex::Actions (send_email, plus anything host gems
7
+ # register) is available as a method automatically; `run` wraps arbitrary
8
+ # Ruby in an Actions::Custom effect.
9
+ #
10
+ # action :admin_alert do
11
+ # send_email to: "admin@example.com", subject: "New lead: {{name}}",
12
+ # html: "{{answers_summary}}"
13
+ # run { |answers, outbox| Metrics.count(:lead, answers.to_flat_h) }
14
+ # end
15
+ class ActionBuilder
16
+ def initialize
17
+ @effects = []
18
+ end
19
+
20
+ # Escape hatch: arbitrary server-side Ruby. Stripped from JSON.
21
+ #
22
+ # @yield [answers, outbox]
23
+ def run(&block)
24
+ raise Errors::DefinitionError, "run requires a block" unless block
25
+
26
+ @effects << Actions::Custom.new(block)
27
+ end
28
+
29
+ # Registered effect verbs (send_email, ...) resolve dynamically so that
30
+ # newly registered effect types become DSL words without core changes.
31
+ def method_missing(name, *args, **params, &)
32
+ return super unless Actions.registered?(name)
33
+
34
+ raise Errors::DefinitionError, "#{name} takes keyword arguments only" unless args.empty?
35
+
36
+ @effects << Actions.lookup(name).new(**params)
37
+ end
38
+
39
+ def respond_to_missing?(name, include_private = false)
40
+ Actions.registered?(name) || super
41
+ end
42
+
43
+ # @param id [Symbol]
44
+ # @param rule [Rules::Base, nil]
45
+ # @return [Actions::Action]
46
+ def build(id, rule: nil)
47
+ raise Errors::DefinitionError, "action #{id.inspect} declares no effects" if @effects.empty?
48
+
49
+ Actions::Action.new(id:, effects: @effects, rule:)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -16,6 +16,18 @@ module Inquirex
16
16
  @nodes = {}
17
17
  @meta = {}
18
18
  @accumulators = {}
19
+ @actions = []
20
+ @allowed_domains = []
21
+ end
22
+
23
+ # Declares the domains outbound effects (webhook) may send answers to.
24
+ # Conventionally the first declaration in a definition, so the flow's
25
+ # egress surface is auditable at a glance. "example.com" matches that
26
+ # host exactly; "*.example.com" matches its subdomains.
27
+ #
28
+ # @param domains [Array<String>]
29
+ def allowed_domains(*domains)
30
+ @allowed_domains.concat(domains.flatten)
19
31
  end
20
32
 
21
33
  # Declares a named running total the flow accumulates into as answers come in.
@@ -104,6 +116,35 @@ module Inquirex
104
116
  add_step(id, :confirm, &)
105
117
  end
106
118
 
119
+ # Declares a named post-completion action: effects (send_email, run, ...)
120
+ # executed server-side after the flow finishes, with the collected
121
+ # answers. Runs in declaration order; gate with a serializable rule via
122
+ # the if: option.
123
+ #
124
+ # @example Email the collected answers when business income was selected
125
+ # action :notify_sales, if: Rules::Contains.new(:income_types, "Business") do
126
+ # send_email to: "sales@example.com",
127
+ # subject: "New lead: {{name}}",
128
+ # html: "{{answers_summary}}"
129
+ # end
130
+ #
131
+ # @param id [Symbol] action identifier
132
+ # @param opts [Hash] only if: is recognized — a Rules::Base gate
133
+ # @yield block evaluated in ActionBuilder (send_email, run, ...)
134
+ def action(id, **opts, &block)
135
+ rule = opts.delete(:if)
136
+ raise Errors::DefinitionError, "Unknown action options: #{opts.keys.inspect}" unless opts.empty?
137
+
138
+ sym = id.to_sym
139
+ if @actions.any? { |a| a.id == sym }
140
+ raise Errors::DefinitionError, "Duplicate action id: #{sym.inspect}"
141
+ end
142
+
143
+ builder = ActionBuilder.new
144
+ builder.instance_eval(&block) if block
145
+ @actions << builder.build(sym, rule:)
146
+ end
147
+
107
148
  # Produces the frozen Definition.
108
149
  #
109
150
  # @return [Definition]
@@ -113,12 +154,14 @@ module Inquirex
113
154
  raise Errors::DefinitionError, "No steps defined" if @nodes.empty?
114
155
 
115
156
  Definition.new(
116
- start_step_id: @start_step_id,
117
- nodes: @nodes,
118
- id: @flow_id,
119
- version: @flow_version,
120
- meta: @meta,
121
- accumulators: @accumulators
157
+ start_step_id: @start_step_id,
158
+ nodes: @nodes,
159
+ id: @flow_id,
160
+ version: @flow_version,
161
+ meta: @meta,
162
+ accumulators: @accumulators,
163
+ actions: @actions,
164
+ allowed_domains: @allowed_domains
122
165
  )
123
166
  end
124
167
 
@@ -5,11 +5,13 @@ module Inquirex
5
5
  # Handles state serialization for Engine persistence (DB, session store, etc.).
6
6
  # Normalizes string-keyed hashes (from JSON round-trips) to symbol-keyed hashes.
7
7
  module StateSerializer
8
+ # Per-key normalizers applied by .symbolize_state; unlisted keys pass through unchanged.
8
9
  SYMBOLIZERS = {
9
- current_step_id: ->(v) { v&.to_sym },
10
- history: ->(v) { Array(v).map { |e| e&.to_sym } },
11
- answers: ->(v) { symbolize_answers(v) },
12
- totals: ->(v) { symbolize_answers(v) }
10
+ current_step_id: ->(v) { v&.to_sym },
11
+ history: ->(v) { Array(v).map { |e| e&.to_sym } },
12
+ answers: ->(v) { symbolize_answers(v) },
13
+ totals: ->(v) { symbolize_answers(v) },
14
+ completion_metadata: ->(v) { symbolize_answers(v) }
13
15
  }.freeze
14
16
 
15
17
  # Normalizes a state hash so step ids and history entries are symbols.
@@ -12,6 +12,23 @@ module Inquirex
12
12
  class Engine
13
13
  attr_reader :definition, :answers, :history, :current_step_id, :totals
14
14
 
15
+ # Metadata describing how and where the flow was completed. Rendering
16
+ # front-ends (TTY, web, chat widget) attach a rich version from an
17
+ # after_completion hook; when no hook provides one, the engine stamps a
18
+ # minimal core version the moment the flow finishes. Only :engine and
19
+ # :engine_version are required members; everything else is free-form.
20
+ #
21
+ # @return [CompletionMetadata, nil] nil until the flow finishes
22
+ attr_accessor :completion_metadata
23
+
24
+ # Exceptions raised by after_completion hooks, in the order they were
25
+ # raised. Hooks are isolated from one another, so a raising hook is
26
+ # recorded here rather than propagated — callers that care can inspect
27
+ # this after the flow finishes. Empty when every hook succeeded.
28
+ #
29
+ # @return [Array<StandardError>]
30
+ attr_reader :completion_hook_errors
31
+
15
32
  # @param definition [Definition] the flow to run
16
33
  # @param validator [Validation::Adapter] optional (default: NullAdapter)
17
34
  def initialize(definition, validator: Validation::NullAdapter.new)
@@ -21,6 +38,9 @@ module Inquirex
21
38
  @current_step_id = definition.start_step_id
22
39
  @validator = validator
23
40
  @totals = init_totals
41
+ @completion_metadata = nil
42
+ @after_completion_hooks = []
43
+ @completion_hook_errors = []
24
44
  @history << @current_step_id
25
45
  skip_display_steps_if_needed
26
46
  end
@@ -101,18 +121,62 @@ module Inquirex
101
121
  @answers
102
122
  end
103
123
 
124
+ # Registers a hook to run when the flow finishes. The block receives the
125
+ # engine; front-ends typically use it to attach a rich
126
+ # completion_metadata (host, user, ips, terminal, ...). Optional — after
127
+ # all hooks run, the engine fills in a minimal CompletionMetadata when
128
+ # none of them provided one. Registering on an already-finished engine
129
+ # invokes the block immediately.
130
+ #
131
+ # Any number of hooks may be registered; they run in registration order.
132
+ # Each is isolated from the others — a hook that raises a StandardError
133
+ # has it recorded in #completion_hook_errors, and the remaining hooks
134
+ # still run. Non-StandardError exceptions (Interrupt, SignalException)
135
+ # propagate, as they should.
136
+ #
137
+ # @example Stamp renderer-specific completion metadata
138
+ # engine.after_completion do |eng|
139
+ # eng.completion_metadata = Inquirex::CompletionMetadata.new(
140
+ # engine: "inquirex-tty", engine_version: "0.5.0", hostname: Socket.gethostname
141
+ # )
142
+ # end
143
+ #
144
+ # @yield [Engine] the engine, at completion time
145
+ # @return [Engine] self
146
+ def after_completion(&block)
147
+ raise ArgumentError, "after_completion requires a block" unless block
148
+
149
+ @after_completion_hooks << block
150
+ if finished?
151
+ invoke_completion_hook(block)
152
+ ensure_completion_metadata
153
+ end
154
+ self
155
+ end
156
+
104
157
  # Serializable state snapshot for persistence or resumption.
105
158
  #
106
159
  # @return [Hash]
107
160
  def to_state
108
161
  {
109
- current_step_id: @current_step_id,
110
- answers: @answers,
111
- history: @history,
112
- totals: @totals
162
+ current_step_id: @current_step_id,
163
+ answers: @answers,
164
+ history: @history,
165
+ totals: @totals,
166
+ completion_metadata: @completion_metadata&.to_h
113
167
  }
114
168
  end
115
169
 
170
+ # The collected answers with the completion metadata (when a renderer
171
+ # attached one) merged in under the :completion_metadata key.
172
+ #
173
+ # @return [Hash]
174
+ def answers_with_metadata
175
+ return @answers if @completion_metadata.nil?
176
+
177
+ @answers.merge(completion_metadata: @completion_metadata.to_h)
178
+ end
179
+
116
180
  # Rebuilds an Engine from a previously saved state.
117
181
  #
118
182
  # @param definition [Definition] same definition used when state was captured
@@ -135,6 +199,9 @@ module Inquirex
135
199
  @answers = state[:answers] || {}
136
200
  @history = state[:history] || []
137
201
  @totals = state[:totals] || init_totals
202
+ @completion_metadata = CompletionMetadata.from_h(state[:completion_metadata])
203
+ @after_completion_hooks = []
204
+ @completion_hook_errors = []
138
205
  end
139
206
 
140
207
  def init_totals
@@ -154,12 +221,37 @@ module Inquirex
154
221
  node = @definition.step(@current_step_id)
155
222
  next_id = node.next_step_id(@answers)
156
223
  @current_step_id = next_id
157
- return unless next_id
224
+ return run_after_completion_hooks unless next_id
158
225
 
159
226
  @history << next_id
160
227
  skip_if_needed
161
228
  end
162
229
 
230
+ # Fires the moment the flow finishes: runs registered after_completion
231
+ # hooks in registration order, then guarantees completion_metadata exists
232
+ # — hooks may attach a rich version; absent that (including when the hook
233
+ # meant to supply it raised), a minimal core-stamped one is used.
234
+ def run_after_completion_hooks
235
+ @after_completion_hooks.each { |hook| invoke_completion_hook(hook) }
236
+ ensure_completion_metadata
237
+ nil
238
+ end
239
+
240
+ # Runs one hook in isolation. A StandardError is recorded rather than
241
+ # propagated so that one misbehaving hook cannot silently cancel the
242
+ # hooks registered after it, nor abort the completion of the flow itself.
243
+ def invoke_completion_hook(hook)
244
+ hook.call(self)
245
+ rescue StandardError => e
246
+ @completion_hook_errors << e
247
+ end
248
+
249
+ def ensure_completion_metadata
250
+ return if @completion_metadata
251
+
252
+ @completion_metadata = CompletionMetadata.new(engine: "inquirex", engine_version: VERSION)
253
+ end
254
+
163
255
  # Auto-skips the current step if its skip_if rule is satisfied.
164
256
  def skip_if_needed
165
257
  return if finished?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Inquirex
4
+ # Namespace for all Inquirex exception classes.
4
5
  module Errors
5
6
  # Base exception for all Inquirex errors.
6
7
  class Error < StandardError; end
@@ -26,5 +27,9 @@ module Inquirex
26
27
 
27
28
  # Raised when serializing or deserializing a Definition to/from JSON fails.
28
29
  class SerializationError < Error; end
30
+
31
+ # Raised when a post-completion action cannot execute structurally,
32
+ # e.g. send_email is used without the mail gem installed.
33
+ class ActionError < Error; end
29
34
  end
30
35
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Inquirex
4
+ # Namespace for flow graph export utilities (Mermaid, ...).
4
5
  module Graph
5
6
  # Exports a flow Definition to Mermaid flowchart syntax for visualization.
6
7
  # Node labels show verb + truncated question/text; edges show condition labels.
7
8
  class MermaidExporter
9
+ # Maximum characters of a node label before it is truncated with "...".
8
10
  MAX_LABEL_LENGTH = 50
9
11
 
10
12
  attr_reader :definition
data/lib/inquirex/node.rb CHANGED
@@ -27,7 +27,9 @@ module Inquirex
27
27
  class Node
28
28
  # Valid DSL verbs and which ones collect input from the user.
29
29
  VERBS = %i[ask say header btw warning confirm].freeze
30
+ # Verbs that collect input from the user.
30
31
  COLLECTING_VERBS = %i[ask confirm].freeze
32
+ # Verbs that only display content and auto-advance.
31
33
  DISPLAY_VERBS = %i[say header btw warning].freeze
32
34
 
33
35
  # Valid data types for :ask steps.
@@ -1,29 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Inquirex
4
+ # Namespace for rule AST nodes used in transitions and visibility conditions.
4
5
  module Rules
5
6
  # Composite rule: logical AND of multiple sub-rules. All must be true.
6
7
  class All < Base
7
8
  attr_reader :rules
8
9
 
10
+ # @param rules [Array<Rules::Base>] sub-rules that must all hold
9
11
  def initialize(*rules)
10
12
  super()
11
13
  @rules = rules.flatten.freeze
12
14
  freeze
13
15
  end
14
16
 
17
+ # True when every sub-rule evaluates true (vacuously true when empty).
18
+ #
19
+ # @param answers [Hash{Symbol => Object}] answer context, step_id => value
20
+ # @return [Boolean]
15
21
  def evaluate(answers)
16
22
  @rules.all? { |rule| rule.evaluate(answers) }
17
23
  end
18
24
 
25
+ # @return [Hash{String => Object}] wire format with nested rule hashes
19
26
  def to_h
20
27
  { "op" => "all", "rules" => @rules.map(&:to_h) }
21
28
  end
22
29
 
30
+ # @return [String] human-readable form, sub-rules joined with AND
23
31
  def to_s
24
32
  "(#{@rules.join(" AND ")})"
25
33
  end
26
34
 
35
+ # Deserializes an All rule, recursively rehydrating its sub-rules.
36
+ #
37
+ # @param hash [Hash] rule hash with string or symbol keys
38
+ # @return [All]
27
39
  def self.from_h(hash)
28
40
  raw_rules = hash["rules"] || hash[:rules] || []
29
41
  new(*raw_rules.map { |r| Base.from_h(r) })
@@ -6,24 +6,35 @@ module Inquirex
6
6
  class Any < Base
7
7
  attr_reader :rules
8
8
 
9
+ # @param rules [Array<Rules::Base>] sub-rules, at least one of which must hold
9
10
  def initialize(*rules)
10
11
  super()
11
12
  @rules = rules.flatten.freeze
12
13
  freeze
13
14
  end
14
15
 
16
+ # True when at least one sub-rule evaluates true (false when empty).
17
+ #
18
+ # @param answers [Hash{Symbol => Object}] answer context, step_id => value
19
+ # @return [Boolean]
15
20
  def evaluate(answers)
16
21
  @rules.any? { |rule| rule.evaluate(answers) }
17
22
  end
18
23
 
24
+ # @return [Hash{String => Object}] wire format with nested rule hashes
19
25
  def to_h
20
26
  { "op" => "any", "rules" => @rules.map(&:to_h) }
21
27
  end
22
28
 
29
+ # @return [String] human-readable form, sub-rules joined with OR
23
30
  def to_s
24
31
  "(#{@rules.join(" OR ")})"
25
32
  end
26
33
 
34
+ # Deserializes an Any rule, recursively rehydrating its sub-rules.
35
+ #
36
+ # @param hash [Hash] rule hash with string or symbol keys
37
+ # @return [Any]
27
38
  def self.from_h(hash)
28
39
  raw_rules = hash["rules"] || hash[:rules] || []
29
40
  new(*raw_rules.map { |r| Base.from_h(r) })
@@ -30,6 +30,12 @@ module Inquirex
30
30
 
31
31
  # Deserializes a rule from a plain Hash (e.g. parsed from JSON).
32
32
  #
33
+ # @example Rehydrate a rule from its wire format
34
+ # rule = Inquirex::Rules::Base.from_h(
35
+ # "op" => "equals", "field" => "filing_status", "value" => "single"
36
+ # )
37
+ # rule.evaluate(filing_status: "single") # => true
38
+ #
33
39
  # @param hash [Hash] rule hash with string or symbol keys
34
40
  # @return [Rules::Base] the deserialized rule
35
41
  # @raise [Inquirex::Errors::SerializationError] on unknown operator
@@ -7,6 +7,8 @@ module Inquirex
7
7
  class Contains < Base
8
8
  attr_reader :field, :value
9
9
 
10
+ # @param field [Symbol, String] step id whose answer is inspected
11
+ # @param value [Object] value the answer must include
10
12
  def initialize(field, value)
11
13
  super()
12
14
  @field = field.to_sym
@@ -14,18 +16,28 @@ module Inquirex
14
16
  freeze
15
17
  end
16
18
 
19
+ # True when the field's answer, coerced to an Array, includes the value.
20
+ #
21
+ # @param answers [Hash{Symbol => Object}] answer context, step_id => value
22
+ # @return [Boolean]
17
23
  def evaluate(answers)
18
24
  Array(answers[@field]).include?(@value)
19
25
  end
20
26
 
27
+ # @return [Hash{String => Object}] wire format, same shape .from_h accepts
21
28
  def to_h
22
29
  { "op" => "contains", "field" => @field.to_s, "value" => @value }
23
30
  end
24
31
 
32
+ # @return [String] human-readable form, e.g. "Business in income_types"
25
33
  def to_s
26
34
  "#{@value} in #{@field}"
27
35
  end
28
36
 
37
+ # Deserializes a Contains rule from a plain Hash.
38
+ #
39
+ # @param hash [Hash] rule hash with string or symbol keys
40
+ # @return [Contains]
29
41
  def self.from_h(hash)
30
42
  field = hash["field"] || hash[:field]
31
43
  value = hash["value"] || hash[:value]
@@ -6,6 +6,8 @@ module Inquirex
6
6
  class Equals < Base
7
7
  attr_reader :field, :value
8
8
 
9
+ # @param field [Symbol, String] step id whose answer is compared
10
+ # @param value [Object] value the answer must equal
9
11
  def initialize(field, value)
10
12
  super()
11
13
  @field = field.to_sym
@@ -13,18 +15,28 @@ module Inquirex
13
15
  freeze
14
16
  end
15
17
 
18
+ # True when the field's answer equals the value (Ruby ==).
19
+ #
20
+ # @param answers [Hash{Symbol => Object}] answer context, step_id => value
21
+ # @return [Boolean]
16
22
  def evaluate(answers)
17
23
  answers[@field] == @value
18
24
  end
19
25
 
26
+ # @return [Hash{String => Object}] wire format, same shape .from_h accepts
20
27
  def to_h
21
28
  { "op" => "equals", "field" => @field.to_s, "value" => @value }
22
29
  end
23
30
 
31
+ # @return [String] human-readable form, e.g. "filing_status == single"
24
32
  def to_s
25
33
  "#{@field} == #{@value}"
26
34
  end
27
35
 
36
+ # Deserializes an Equals rule from a plain Hash.
37
+ #
38
+ # @param hash [Hash] rule hash with string or symbol keys
39
+ # @return [Equals]
28
40
  def self.from_h(hash)
29
41
  field = hash["field"] || hash[:field]
30
42
  value = hash["value"] || hash[:value]
@@ -6,6 +6,8 @@ module Inquirex
6
6
  class GreaterThan < Base
7
7
  attr_reader :field, :value
8
8
 
9
+ # @param field [Symbol, String] step id whose answer is compared
10
+ # @param value [Integer] threshold the answer must exceed
9
11
  def initialize(field, value)
10
12
  super()
11
13
  @field = field.to_sym
@@ -13,18 +15,28 @@ module Inquirex
13
15
  freeze
14
16
  end
15
17
 
18
+ # True when the field's answer, coerced to an Integer, exceeds the threshold.
19
+ #
20
+ # @param answers [Hash{Symbol => Object}] answer context, step_id => value
21
+ # @return [Boolean]
16
22
  def evaluate(answers)
17
23
  answers[@field].to_i > @value
18
24
  end
19
25
 
26
+ # @return [Hash{String => Object}] wire format, same shape .from_h accepts
20
27
  def to_h
21
28
  { "op" => "greater_than", "field" => @field.to_s, "value" => @value }
22
29
  end
23
30
 
31
+ # @return [String] human-readable form, e.g. "dependents > 2"
24
32
  def to_s
25
33
  "#{@field} > #{@value}"
26
34
  end
27
35
 
36
+ # Deserializes a GreaterThan rule from a plain Hash.
37
+ #
38
+ # @param hash [Hash] rule hash with string or symbol keys
39
+ # @return [GreaterThan]
28
40
  def self.from_h(hash)
29
41
  field = hash["field"] || hash[:field]
30
42
  value = (hash["value"] || hash[:value]).to_i