inquirex 0.2.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.
- checksums.yaml +7 -0
- data/.relaxed_rubocop.yml +153 -0
- data/.rub +0 -0
- data/.ruby-version +1 -0
- data/.secrets.baseline +127 -0
- data/Brewfile +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +289 -0
- data/Rakefile +36 -0
- data/exe/inquirex +3 -0
- data/justfile +38 -0
- data/lefthook.yml +35 -0
- data/lib/inquirex/answers.rb +133 -0
- data/lib/inquirex/definition.rb +109 -0
- data/lib/inquirex/dsl/flow_builder.rb +107 -0
- data/lib/inquirex/dsl/rule_helpers.rb +57 -0
- data/lib/inquirex/dsl/step_builder.rb +109 -0
- data/lib/inquirex/dsl.rb +9 -0
- data/lib/inquirex/engine/state_serializer.rb +36 -0
- data/lib/inquirex/engine.rb +128 -0
- data/lib/inquirex/errors.rb +30 -0
- data/lib/inquirex/evaluator.rb +26 -0
- data/lib/inquirex/graph/mermaid_exporter.rb +55 -0
- data/lib/inquirex/node.rb +192 -0
- data/lib/inquirex/rules/all.rb +33 -0
- data/lib/inquirex/rules/any.rb +33 -0
- data/lib/inquirex/rules/base.rb +52 -0
- data/lib/inquirex/rules/contains.rb +36 -0
- data/lib/inquirex/rules/equals.rb +35 -0
- data/lib/inquirex/rules/greater_than.rb +35 -0
- data/lib/inquirex/rules/less_than.rb +35 -0
- data/lib/inquirex/rules/not_empty.rb +37 -0
- data/lib/inquirex/transition.rb +63 -0
- data/lib/inquirex/validation/adapter.rb +36 -0
- data/lib/inquirex/validation/null_adapter.rb +13 -0
- data/lib/inquirex/version.rb +5 -0
- data/lib/inquirex.rb +105 -0
- data/sig/inquirex.rbs +4 -0
- metadata +91 -0
data/lefthook.yml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
output:
|
|
2
|
+
- summary
|
|
3
|
+
- failure
|
|
4
|
+
|
|
5
|
+
pre-commit:
|
|
6
|
+
parallel: true
|
|
7
|
+
jobs:
|
|
8
|
+
- name: lint
|
|
9
|
+
run: bundle exec rubocop -c .rubocop.yml {staged_files}
|
|
10
|
+
glob: "*.{rb,Gemfile}"
|
|
11
|
+
stage_fixed: true
|
|
12
|
+
|
|
13
|
+
- name: check for conflict markers and whitespace issues
|
|
14
|
+
run: git --no-pager diff --check
|
|
15
|
+
|
|
16
|
+
# If tests take >1 second, move this (or just the long-running tests) to pre-push.
|
|
17
|
+
- name: run tests
|
|
18
|
+
run: just test
|
|
19
|
+
|
|
20
|
+
- name: fix rubocop formatting issues
|
|
21
|
+
run: bundle exec rubocop -a {staged_files}
|
|
22
|
+
glob: "*.{rb,Gemfile,gemspec}"
|
|
23
|
+
stage_fixed: true
|
|
24
|
+
|
|
25
|
+
- name: spell check
|
|
26
|
+
run: codespell {staged_files}
|
|
27
|
+
glob: "*.{rb,md,gemspec}"
|
|
28
|
+
|
|
29
|
+
- name: format markdown
|
|
30
|
+
run: mdformat {staged_files}
|
|
31
|
+
glob: "*.md"
|
|
32
|
+
stage_fixed: true
|
|
33
|
+
|
|
34
|
+
- name: scan for secrets
|
|
35
|
+
run: detect-secrets-hook --baseline .secrets.baseline
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Inquirex
|
|
6
|
+
# Structured wrapper around the answers collected during flow execution.
|
|
7
|
+
# Provides dot-notation access, bracket access, dig with dot-separated keys,
|
|
8
|
+
# and serialization helpers.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# answers = Inquirex::Answers.new({ filing_status: "single", business: { count: 3 } })
|
|
12
|
+
# answers.filing_status # => "single"
|
|
13
|
+
# answers[:filing_status] # => "single"
|
|
14
|
+
# answers.business.count # => 3 (nested dot-notation)
|
|
15
|
+
# answers.dig("business.count") # => 3
|
|
16
|
+
# answers.to_flat_h # => { "filing_status" => "single", "business.count" => 3 }
|
|
17
|
+
class Answers
|
|
18
|
+
# @param data [Hash] flat or nested hash of answers (symbol or string keys)
|
|
19
|
+
def initialize(data = {})
|
|
20
|
+
@data = deep_symbolize(data).freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Bracket access (symbol or string key).
|
|
24
|
+
#
|
|
25
|
+
# @param key [Symbol, String]
|
|
26
|
+
# @return [Object, Answers, nil]
|
|
27
|
+
def [](key)
|
|
28
|
+
wrap(@data[key.to_sym])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Dot-notation access.
|
|
32
|
+
#
|
|
33
|
+
# @raise [NoMethodError] if the key does not exist (like a real method would)
|
|
34
|
+
def method_missing(name, *args)
|
|
35
|
+
return super if args.any? || !@data.key?(name)
|
|
36
|
+
|
|
37
|
+
wrap(@data[name])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def respond_to_missing?(name, include_private = false)
|
|
41
|
+
@data.key?(name) || super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Dig using dot-separated key string or sequential keys.
|
|
45
|
+
#
|
|
46
|
+
# @param keys [Array<String, Symbol>] path components or a single dot-separated string
|
|
47
|
+
# @return [Object, nil]
|
|
48
|
+
def dig(*keys)
|
|
49
|
+
parts = keys.length == 1 ? keys.first.to_s.split(".").map(&:to_sym) : keys.map(&:to_sym)
|
|
50
|
+
parts.reduce(@data) do |current, key|
|
|
51
|
+
return nil unless current.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
current[key]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Hash] nested hash with symbol keys
|
|
58
|
+
def to_h
|
|
59
|
+
@data.dup
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Hash] flat hash with string dot-notation keys
|
|
63
|
+
def to_flat_h
|
|
64
|
+
flatten_hash(@data)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [String] JSON representation
|
|
68
|
+
def to_json(*)
|
|
69
|
+
JSON.generate(stringify_keys(@data))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def ==(other)
|
|
74
|
+
case other
|
|
75
|
+
when Answers then @data == other.to_h
|
|
76
|
+
when Hash then @data == deep_symbolize(other)
|
|
77
|
+
else false
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Boolean] true when no answers have been collected
|
|
82
|
+
def empty?
|
|
83
|
+
@data.empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Number of top-level answer keys.
|
|
87
|
+
def size
|
|
88
|
+
@data.size
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Merge another hash or Answers into this one (returns new Answers instance).
|
|
92
|
+
#
|
|
93
|
+
# @param other [Hash, Answers]
|
|
94
|
+
# @return [Answers]
|
|
95
|
+
def merge(other)
|
|
96
|
+
other_data = other.is_a?(Answers) ? other.to_h : deep_symbolize(other)
|
|
97
|
+
Answers.new(@data.merge(other_data))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def inspect
|
|
101
|
+
"#<Inquirex::Answers #{@data.inspect}>"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def wrap(value)
|
|
107
|
+
value.is_a?(Hash) ? Answers.new(value) : value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def deep_symbolize(hash)
|
|
111
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
112
|
+
result[k.to_sym] = v.is_a?(Hash) ? deep_symbolize(v) : v
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def stringify_keys(hash)
|
|
117
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
118
|
+
result[k.to_s] = v.is_a?(Hash) ? stringify_keys(v) : v
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def flatten_hash(hash, prefix = nil)
|
|
123
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
124
|
+
full_key = prefix ? "#{prefix}.#{k}" : k.to_s
|
|
125
|
+
if v.is_a?(Hash)
|
|
126
|
+
result.merge!(flatten_hash(v, full_key))
|
|
127
|
+
else
|
|
128
|
+
result[full_key] = v
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Inquirex
|
|
6
|
+
# Immutable, versionable flow graph. Maps step ids to Node objects and defines the entry point.
|
|
7
|
+
# Carries optional metadata (id, version, title, subtitle, brand) for the JS widget.
|
|
8
|
+
# Supports JSON round-trip serialization; lambdas are stripped on serialization.
|
|
9
|
+
#
|
|
10
|
+
# @attr_reader id [String, nil] flow identifier (e.g. "tax-intake-2025")
|
|
11
|
+
# @attr_reader version [String] semver string (default: "1.0.0")
|
|
12
|
+
# @attr_reader meta [Hash] title, subtitle, brand info for the frontend
|
|
13
|
+
# @attr_reader start_step_id [Symbol] id of the first step in the flow
|
|
14
|
+
# @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node
|
|
15
|
+
class Definition
|
|
16
|
+
attr_reader :id, :version, :meta, :start_step_id, :steps
|
|
17
|
+
|
|
18
|
+
# @param start_step_id [Symbol] id of the initial step
|
|
19
|
+
# @param nodes [Hash<Symbol, Node>] all steps keyed by id
|
|
20
|
+
# @param id [String, nil] flow identifier
|
|
21
|
+
# @param version [String] semver
|
|
22
|
+
# @param meta [Hash] frontend metadata
|
|
23
|
+
# @raise [Errors::DefinitionError] if start_step_id is not present in nodes
|
|
24
|
+
def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {})
|
|
25
|
+
@id = id
|
|
26
|
+
@version = version
|
|
27
|
+
@meta = meta.freeze
|
|
28
|
+
@start_step_id = start_step_id.to_sym
|
|
29
|
+
@steps = nodes.freeze
|
|
30
|
+
validate!
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Node] the node for the start step
|
|
35
|
+
def start_step
|
|
36
|
+
step(@start_step_id)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param id [Symbol] step id
|
|
40
|
+
# @return [Node] the node for that step
|
|
41
|
+
# @raise [Errors::UnknownStepError] if id is not in steps
|
|
42
|
+
def step(id)
|
|
43
|
+
@steps.fetch(id.to_sym) { raise Errors::UnknownStepError, "Unknown step: #{id.inspect}" }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Array<Symbol>] all step ids
|
|
47
|
+
def step_ids
|
|
48
|
+
@steps.keys
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Serializes the definition to a JSON string.
|
|
52
|
+
# Lambdas (default procs, compute blocks) are silently stripped.
|
|
53
|
+
#
|
|
54
|
+
# @return [String] JSON representation
|
|
55
|
+
def to_json(*)
|
|
56
|
+
JSON.generate(to_h)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Serializes to a plain Hash.
|
|
60
|
+
#
|
|
61
|
+
# @return [Hash]
|
|
62
|
+
def to_h
|
|
63
|
+
hash = {}
|
|
64
|
+
hash["id"] = @id if @id
|
|
65
|
+
hash["version"] = @version
|
|
66
|
+
hash["meta"] = @meta unless @meta.empty?
|
|
67
|
+
hash["start"] = @start_step_id.to_s
|
|
68
|
+
hash["steps"] = @steps.transform_keys(&:to_s).transform_values(&:to_h)
|
|
69
|
+
hash
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Deserializes a Definition from a JSON string.
|
|
73
|
+
#
|
|
74
|
+
# @param json [String] JSON representation
|
|
75
|
+
# @return [Definition]
|
|
76
|
+
def self.from_json(json)
|
|
77
|
+
from_h(JSON.parse(json))
|
|
78
|
+
rescue JSON::ParserError => e
|
|
79
|
+
raise Errors::SerializationError, "Invalid JSON: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Deserializes a Definition from a plain Hash.
|
|
83
|
+
#
|
|
84
|
+
# @param hash [Hash] definition attributes (string or symbol keys)
|
|
85
|
+
# @return [Definition]
|
|
86
|
+
def self.from_h(hash)
|
|
87
|
+
id = hash["id"] || hash[:id]
|
|
88
|
+
version = hash["version"] || hash[:version] || "1.0.0"
|
|
89
|
+
meta = hash["meta"] || hash[:meta] || {}
|
|
90
|
+
start = hash["start"] || hash[:start]
|
|
91
|
+
steps_data = hash["steps"] || hash[:steps] || {}
|
|
92
|
+
|
|
93
|
+
nodes = steps_data.each_with_object({}) do |(step_id, step_hash), acc|
|
|
94
|
+
sym_id = step_id.to_sym
|
|
95
|
+
acc[sym_id] = Node.from_h(sym_id, step_hash)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
new(start_step_id: start, nodes:, id:, version:, meta:)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def validate!
|
|
104
|
+
return if @steps.key?(@start_step_id)
|
|
105
|
+
|
|
106
|
+
raise Errors::DefinitionError, "Start step #{@start_step_id.inspect} not found in steps"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module DSL
|
|
5
|
+
# Builds a Definition from the declarative DSL block used in Inquirex.define.
|
|
6
|
+
# Provides start, meta, and all verb methods (ask, say, header, btw, warning, confirm).
|
|
7
|
+
class FlowBuilder
|
|
8
|
+
include RuleHelpers
|
|
9
|
+
|
|
10
|
+
# @param id [String, nil] optional flow identifier
|
|
11
|
+
# @param version [String] semver
|
|
12
|
+
def initialize(id: nil, version: "1.0.0")
|
|
13
|
+
@flow_id = id
|
|
14
|
+
@flow_version = version
|
|
15
|
+
@start_step_id = nil
|
|
16
|
+
@nodes = {}
|
|
17
|
+
@meta = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Sets the entry step id for the flow.
|
|
21
|
+
#
|
|
22
|
+
# @param step_id [Symbol]
|
|
23
|
+
def start(step_id)
|
|
24
|
+
@start_step_id = step_id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Sets frontend metadata: title, subtitle, and brand information.
|
|
28
|
+
#
|
|
29
|
+
# @param title [String, nil]
|
|
30
|
+
# @param subtitle [String, nil]
|
|
31
|
+
# @param brand [Hash, nil] e.g. { name: "Acme", color: "#2563eb" }
|
|
32
|
+
def meta(title: nil, subtitle: nil, brand: nil)
|
|
33
|
+
@meta[:title] = title if title
|
|
34
|
+
@meta[:subtitle] = subtitle if subtitle
|
|
35
|
+
@meta[:brand] = brand if brand
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Defines a question step that collects typed input from the user.
|
|
39
|
+
#
|
|
40
|
+
# @param id [Symbol] step id
|
|
41
|
+
# @yield block evaluated in StepBuilder (type, question, options, transition, etc.)
|
|
42
|
+
def ask(id, &)
|
|
43
|
+
add_step(id, :ask, &)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Defines an informational message step (no input collected).
|
|
47
|
+
#
|
|
48
|
+
# @param id [Symbol] step id
|
|
49
|
+
def say(id, &)
|
|
50
|
+
add_step(id, :say, &)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Defines a section heading step (no input collected).
|
|
54
|
+
#
|
|
55
|
+
# @param id [Symbol] step id
|
|
56
|
+
def header(id, &)
|
|
57
|
+
add_step(id, :header, &)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Defines an admonition or sidebar note step (no input collected).
|
|
61
|
+
#
|
|
62
|
+
# @param id [Symbol] step id
|
|
63
|
+
def btw(id, &)
|
|
64
|
+
add_step(id, :btw, &)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Defines an important alert step (no input collected).
|
|
68
|
+
#
|
|
69
|
+
# @param id [Symbol] step id
|
|
70
|
+
def warning(id, &)
|
|
71
|
+
add_step(id, :warning, &)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Defines a yes/no confirmation gate (collects a boolean answer).
|
|
75
|
+
#
|
|
76
|
+
# @param id [Symbol] step id
|
|
77
|
+
def confirm(id, &)
|
|
78
|
+
add_step(id, :confirm, &)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Produces the frozen Definition.
|
|
82
|
+
#
|
|
83
|
+
# @return [Definition]
|
|
84
|
+
# @raise [Errors::DefinitionError] if start was not set or no steps were defined
|
|
85
|
+
def build
|
|
86
|
+
raise Errors::DefinitionError, "No start step defined" if @start_step_id.nil?
|
|
87
|
+
raise Errors::DefinitionError, "No steps defined" if @nodes.empty?
|
|
88
|
+
|
|
89
|
+
Definition.new(
|
|
90
|
+
start_step_id: @start_step_id,
|
|
91
|
+
nodes: @nodes,
|
|
92
|
+
id: @flow_id,
|
|
93
|
+
version: @flow_version,
|
|
94
|
+
meta: @meta
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def add_step(id, verb, &block)
|
|
101
|
+
builder = StepBuilder.new(verb)
|
|
102
|
+
builder.instance_eval(&block) if block
|
|
103
|
+
@nodes[id.to_sym] = builder.build(id)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module DSL
|
|
5
|
+
# Factory methods for rule objects. Included in FlowBuilder and StepBuilder
|
|
6
|
+
# so these helpers are available inside flow/step blocks.
|
|
7
|
+
module RuleHelpers
|
|
8
|
+
# @param field [Symbol] answer key (step id)
|
|
9
|
+
# @param value [Object] value to check for in the array answer
|
|
10
|
+
# @return [Rules::Contains]
|
|
11
|
+
def contains(field, value)
|
|
12
|
+
Rules::Contains.new(field, value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param field [Symbol] answer key
|
|
16
|
+
# @param value [Object] expected exact value
|
|
17
|
+
# @return [Rules::Equals]
|
|
18
|
+
def equals(field, value)
|
|
19
|
+
Rules::Equals.new(field, value)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param field [Symbol] answer key (value coerced to integer)
|
|
23
|
+
# @param value [Integer] threshold
|
|
24
|
+
# @return [Rules::GreaterThan]
|
|
25
|
+
def greater_than(field, value)
|
|
26
|
+
Rules::GreaterThan.new(field, value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param field [Symbol] answer key (value coerced to integer)
|
|
30
|
+
# @param value [Integer] threshold
|
|
31
|
+
# @return [Rules::LessThan]
|
|
32
|
+
def less_than(field, value)
|
|
33
|
+
Rules::LessThan.new(field, value)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param field [Symbol] answer key
|
|
37
|
+
# @return [Rules::NotEmpty]
|
|
38
|
+
def not_empty(field)
|
|
39
|
+
Rules::NotEmpty.new(field)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Logical AND: all rules must be true.
|
|
43
|
+
# @param rules [Array<Rules::Base>]
|
|
44
|
+
# @return [Rules::All]
|
|
45
|
+
def all(*rules)
|
|
46
|
+
Rules::All.new(*rules)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Logical OR: at least one rule must be true.
|
|
50
|
+
# @param rules [Array<Rules::Base>]
|
|
51
|
+
# @return [Rules::Any]
|
|
52
|
+
def any(*rules)
|
|
53
|
+
Rules::Any.new(*rules)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module DSL
|
|
5
|
+
# Builds a single Node from a step DSL block. Used by FlowBuilder for each verb
|
|
6
|
+
# (ask, say, header, btw, warning, confirm). Includes RuleHelpers for transition
|
|
7
|
+
# conditions and skip_if expressions.
|
|
8
|
+
class StepBuilder
|
|
9
|
+
include RuleHelpers
|
|
10
|
+
|
|
11
|
+
def initialize(verb)
|
|
12
|
+
@verb = verb
|
|
13
|
+
@type = nil
|
|
14
|
+
@question = nil
|
|
15
|
+
@text = nil
|
|
16
|
+
@options = nil
|
|
17
|
+
@transitions = []
|
|
18
|
+
@skip_if = nil
|
|
19
|
+
@default = nil
|
|
20
|
+
@compute = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Sets the input data type for :ask steps.
|
|
24
|
+
# @param value [Symbol] one of Node::TYPES
|
|
25
|
+
def type(value)
|
|
26
|
+
@type = value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Sets the prompt/question text for collecting steps.
|
|
30
|
+
# @param text [String]
|
|
31
|
+
def question(text)
|
|
32
|
+
@question = text
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Sets the display text for non-collecting steps (say/header/btw/warning).
|
|
36
|
+
# @param content [String]
|
|
37
|
+
def text(content)
|
|
38
|
+
@text = content
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Sets the list of options for :enum or :multi_enum steps.
|
|
42
|
+
# Accepts an Array (option keys) or a Hash (key => label).
|
|
43
|
+
# @param list [Array, Hash]
|
|
44
|
+
def options(list)
|
|
45
|
+
@options = list
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Adds a conditional transition. First matching transition wins.
|
|
49
|
+
#
|
|
50
|
+
# @param to [Symbol] target step id
|
|
51
|
+
# @param if_rule [Rules::Base, nil] condition (nil = unconditional)
|
|
52
|
+
# @param requires_server [Boolean] whether this transition needs server round-trip
|
|
53
|
+
def transition(to:, if_rule: nil, requires_server: false)
|
|
54
|
+
@transitions << Transition.new(target: to, rule: if_rule, requires_server:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sets a rule that skips this step entirely when true.
|
|
58
|
+
# The step is omitted from the user's path and no answer is recorded.
|
|
59
|
+
#
|
|
60
|
+
# @param rule [Rules::Base]
|
|
61
|
+
def skip_if(rule)
|
|
62
|
+
@skip_if = rule
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Sets a default value for this step (shown pre-filled; user can change it).
|
|
66
|
+
# Can be a static value or a proc receiving collected answers so far.
|
|
67
|
+
#
|
|
68
|
+
# @param value [Object, Proc]
|
|
69
|
+
def default(value = nil, &block)
|
|
70
|
+
@default = block || value
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Registers a compute block: auto-calculates a value from answers, not shown to user.
|
|
74
|
+
# The computed value is stored server-side only and stripped from JSON serialization.
|
|
75
|
+
#
|
|
76
|
+
# @yield [Answers] block receiving answers, must return the computed value
|
|
77
|
+
def compute(&block)
|
|
78
|
+
@compute = block
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Builds the Node for the given step id.
|
|
82
|
+
#
|
|
83
|
+
# @param id [Symbol]
|
|
84
|
+
# @return [Node]
|
|
85
|
+
def build(id)
|
|
86
|
+
Node.new(
|
|
87
|
+
id:,
|
|
88
|
+
verb: @verb,
|
|
89
|
+
type: resolve_type,
|
|
90
|
+
question: @question,
|
|
91
|
+
text: @text,
|
|
92
|
+
options: @options,
|
|
93
|
+
transitions: @transitions,
|
|
94
|
+
skip_if: @skip_if,
|
|
95
|
+
default: @default
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def resolve_type
|
|
102
|
+
# :confirm is sugar for :ask with type :boolean
|
|
103
|
+
return :boolean if @verb == :confirm && @type.nil?
|
|
104
|
+
|
|
105
|
+
@type
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/inquirex/dsl.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
# Namespace for the declarative flow DSL.
|
|
5
|
+
# FlowBuilder builds a Definition from blocks; StepBuilder builds individual Nodes;
|
|
6
|
+
# RuleHelpers provides rule factory methods available inside flow and step blocks.
|
|
7
|
+
module DSL
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
class Engine
|
|
5
|
+
# Handles state serialization for Engine persistence (DB, session store, etc.).
|
|
6
|
+
# Normalizes string-keyed hashes (from JSON round-trips) to symbol-keyed hashes.
|
|
7
|
+
module StateSerializer
|
|
8
|
+
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
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
# Normalizes a state hash so step ids and history entries are symbols.
|
|
15
|
+
#
|
|
16
|
+
# @param hash [Hash] raw state (may have string keys from JSON)
|
|
17
|
+
# @return [Hash] normalized state with symbol keys
|
|
18
|
+
def self.symbolize_state(hash)
|
|
19
|
+
return hash unless hash.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
22
|
+
sym_key = key.to_sym
|
|
23
|
+
result[sym_key] = SYMBOLIZERS.fetch(sym_key, ->(v) { v }).call(value)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param answers [Hash] answers map (keys may be strings)
|
|
28
|
+
# @return [Hash] same map with symbol keys
|
|
29
|
+
def self.symbolize_answers(answers)
|
|
30
|
+
return {} unless answers.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
answers.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|