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.
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
@@ -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