flowengine 0.1.1 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50117723ca62a9851b0c8091e2619132d6e2f849e62b765dadd5c0d0d5c601ad
4
- data.tar.gz: 0024ae08e3d6068d0ed913067368ddd4764a83f9ffe1f44e4d9bdcef4f544757
3
+ metadata.gz: 758428572c105952cb5cf538db6ff0307b60c2a5cbf6187344664d2d063d680a
4
+ data.tar.gz: e4b3db255b71a7faa3f54f024d96440dbf62aaa383d4a01d96ac5bff58517bcb
5
5
  SHA512:
6
- metadata.gz: 8d3e5eb92cf044bed6e4a5408ed70452dc755234030e3a39785714864c09d2ba804592cca29005f304dab0fab95058f142602f0239e2f68ffa9826ef15bd748d
7
- data.tar.gz: f889c3914f22d5a6cb4f439e202c318819e007a692f973bef4a288c4c4bbb34995aa65bf88bd5ddc799ee3ed684ae41066617300d7b3e67efe4e62437717149d
6
+ metadata.gz: 6ebe4ebeaf8f47846fe4e831457de894a43e0e544c0f99453ecb9f6d1d535edcef2f8e5f83fc866984e731c865d1e0b6b8ecc3aa46e64b220fb44942441f3491
7
+ data.tar.gz: 6447ed169da61924456f6f7007b6373ad8f42cc03a667aa3fff7a39198a42b458811e477401c2df385a588466cb85b783ffc8cbf6e187d6a8b2cd8b81cc28112
data/README.md CHANGED
@@ -8,10 +8,12 @@ This gem does not have any UI or an interactive component. It is used as the fou
8
8
 
9
9
  The simplest way to see this in action is to use the companion gem [`flowengine-cli`](https://rubygems.org/gems/flowengine-cli), which, given the flow DSL will walk the user through the questioniare according to the DSL flow definition, but using terminal UI and ASCII-based flow.
10
10
 
11
- A declarative flow engine for building rules-driven wizards and intake forms in pure Ruby.
11
+ **A slightly different explanation is that it offere a declarative flow engine for building rules-driven wizards and intake forms in pure Ruby.**
12
12
 
13
- FlowEngine lets you define multi-step flows as **directed graphs** with **conditional branching**, evaluate transitions using an **AST-based rule system**, and collect structured answers through a **stateful runtime engine** — all without framework dependencies.
13
+ > [!NOTE]
14
+ > FlowEngine lets you define multi-step flows as **directed graphs** with **conditional branching**, evaluate transitions using an **AST-based rule system**, and collect structured answers through a **stateful runtime engine** — all without framework dependencies.
14
15
 
16
+ > [!CAUTION]
15
17
  > **This is not a form builder.** It's a *Form Definition Engine* that separates flow logic, data schema, and UI rendering into independent concerns.
16
18
 
17
19
  ## Installation
data/Rakefile CHANGED
@@ -26,7 +26,7 @@ end
26
26
  task build: :permissions
27
27
 
28
28
  YARD::Rake::YardocTask.new(:doc) do |t|
29
- t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt]
29
+ t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt CHANGELOG.md]
30
30
  t.options.unshift("--title", '"FlowEngine — DSL + AST for buildiong complex flows in Ruby."')
31
31
  t.after = -> { exec("open doc/index.html") } if RUBY_PLATFORM =~ /darwin/
32
32
  end
data/justfile CHANGED
@@ -36,6 +36,11 @@ format:
36
36
  lint:
37
37
  @bundle exec rubocop
38
38
 
39
+ # Generates library documentation into ./doc folder and opens the browser
40
+ doc:
41
+ @bundle exec rake doc
42
+ @open ./doc/index.html
43
+
39
44
 
40
45
  check-all: lint test
41
46
 
@@ -1,9 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # Immutable, versionable flow graph: maps step ids to {Node} objects and defines the entry point.
5
+ # Built by {DSL::FlowBuilder}; consumed by {Engine} for navigation.
6
+ #
7
+ # @attr_reader start_step_id [Symbol] id of the first step in the flow
8
+ # @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node (read-only)
4
9
  class Definition
5
10
  attr_reader :start_step_id, :steps
6
11
 
12
+ # @param start_step_id [Symbol] id of the initial step
13
+ # @param nodes [Hash<Symbol, Node>] all steps keyed by id
14
+ # @raise [DefinitionError] if start_step_id is not present in nodes
7
15
  def initialize(start_step_id:, nodes:)
8
16
  @start_step_id = start_step_id
9
17
  @steps = nodes.freeze
@@ -11,14 +19,19 @@ module FlowEngine
11
19
  freeze
12
20
  end
13
21
 
22
+ # @return [Node] the node for the start step
14
23
  def start_step
15
24
  step(start_step_id)
16
25
  end
17
26
 
27
+ # @param id [Symbol] step id
28
+ # @return [Node] the node for that step
29
+ # @raise [UnknownStepError] if id is not in steps
18
30
  def step(id)
19
31
  steps.fetch(id) { raise UnknownStepError, "Unknown step: #{id.inspect}" }
20
32
  end
21
33
 
34
+ # @return [Array<Symbol>] all step ids in the definition
22
35
  def step_ids
23
36
  steps.keys
24
37
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module FlowEngine
4
4
  module DSL
5
+ # Builds a {Definition} from the declarative DSL used in {FlowEngine.define}.
6
+ # Provides {#start} and {#step}; each step block is evaluated by {StepBuilder} with {RuleHelpers}.
5
7
  class FlowBuilder
6
8
  include RuleHelpers
7
9
 
@@ -10,16 +12,27 @@ module FlowEngine
10
12
  @nodes = {}
11
13
  end
12
14
 
15
+ # Sets the entry step id for the flow.
16
+ #
17
+ # @param step_id [Symbol] id of the first step
13
18
  def start(step_id)
14
19
  @start_step_id = step_id
15
20
  end
16
21
 
22
+ # Defines one step by id; the block is evaluated in a {StepBuilder} context.
23
+ #
24
+ # @param id [Symbol] step id
25
+ # @yield block evaluated in {StepBuilder} (type, question, options, transition, etc.)
17
26
  def step(id, &)
18
27
  builder = StepBuilder.new
19
28
  builder.instance_eval(&)
20
29
  @nodes[id] = builder.build(id)
21
30
  end
22
31
 
32
+ # Produces the frozen {Definition} from the accumulated start and steps.
33
+ #
34
+ # @return [Definition]
35
+ # @raise [DefinitionError] if start was not set or no steps were defined
23
36
  def build
24
37
  raise DefinitionError, "No start step defined" if @start_step_id.nil?
25
38
  raise DefinitionError, "No steps defined" if @nodes.empty?
@@ -2,31 +2,53 @@
2
2
 
3
3
  module FlowEngine
4
4
  module DSL
5
+ # Factory methods for rule objects, included in {FlowBuilder} and {StepBuilder}.
6
+ # Use these inside step blocks for transition conditions and visible_if.
5
7
  module RuleHelpers
8
+ # @param field [Symbol] answer key (step id)
9
+ # @param value [Object] value to check for in the array
10
+ # @return [Rules::Contains]
6
11
  def contains(field, value)
7
12
  Rules::Contains.new(field, value)
8
13
  end
9
14
 
15
+ # @param field [Symbol] answer key
16
+ # @param value [Object] expected value
17
+ # @return [Rules::Equals]
10
18
  def equals(field, value)
11
19
  Rules::Equals.new(field, value)
12
20
  end
13
21
 
22
+ # @param field [Symbol] answer key (value coerced to integer for comparison)
23
+ # @param value [Integer] threshold
24
+ # @return [Rules::GreaterThan]
14
25
  def greater_than(field, value)
15
26
  Rules::GreaterThan.new(field, value)
16
27
  end
17
28
 
29
+ # @param field [Symbol] answer key (value coerced to integer for comparison)
30
+ # @param value [Integer] threshold
31
+ # @return [Rules::LessThan]
18
32
  def less_than(field, value)
19
33
  Rules::LessThan.new(field, value)
20
34
  end
21
35
 
36
+ # @param field [Symbol] answer key
37
+ # @return [Rules::NotEmpty]
22
38
  def not_empty(field)
23
39
  Rules::NotEmpty.new(field)
24
40
  end
25
41
 
42
+ # Logical AND of multiple rules.
43
+ # @param rules [Array<Rules::Base>] rules to combine
44
+ # @return [Rules::All]
26
45
  def all(*rules)
27
46
  Rules::All.new(*rules)
28
47
  end
29
48
 
49
+ # Logical OR of multiple rules.
50
+ # @param rules [Array<Rules::Base>] rules to combine
51
+ # @return [Rules::Any]
30
52
  def any(*rules)
31
53
  Rules::Any.new(*rules)
32
54
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module FlowEngine
4
4
  module DSL
5
+ # Builds a single {Node} from step DSL (type, question, options, transitions, visibility).
6
+ # Used by {FlowBuilder#step}; includes {RuleHelpers} for transition/visibility conditions.
5
7
  class StepBuilder
6
8
  include RuleHelpers
7
9
 
@@ -15,34 +17,55 @@ module FlowEngine
15
17
  @decorations = nil
16
18
  end
17
19
 
20
+ # Sets the step/input type (e.g. :multi_select, :number_matrix).
21
+ # @param value [Symbol]
18
22
  def type(value)
19
23
  @type = value
20
24
  end
21
25
 
26
+ # Sets the prompt/label for the step.
27
+ # @param text [String]
22
28
  def question(text)
23
29
  @question = text
24
30
  end
25
31
 
32
+ # Sets the list of options for multi-select or choice steps.
33
+ # @param list [Array]
26
34
  def options(list)
27
35
  @options = list
28
36
  end
29
37
 
38
+ # Sets the list of field names for matrix-style steps (e.g. number_matrix).
39
+ # @param list [Array]
30
40
  def fields(list)
31
41
  @fields = list
32
42
  end
33
43
 
44
+ # Optional UI decorations (opaque to the engine).
45
+ # @param decorations [Object]
34
46
  def decorations(decorations)
35
47
  @decorations = decorations
36
48
  end
37
49
 
50
+ # Adds a conditional transition to another step. First matching transition wins.
51
+ #
52
+ # @param to [Symbol] target step id
53
+ # @param if_rule [Rules::Base, nil] condition (nil = unconditional)
38
54
  def transition(to:, if_rule: nil)
39
55
  @transitions << Transition.new(target: to, rule: if_rule)
40
56
  end
41
57
 
58
+ # Sets the visibility rule for this step (DAG mode: step shown only when rule is true).
59
+ #
60
+ # @param rule [Rules::Base]
42
61
  def visible_if(rule)
43
62
  @visibility_rule = rule
44
63
  end
45
64
 
65
+ # Builds the {Node} for the given step id from accumulated attributes.
66
+ #
67
+ # @param id [Symbol] step id
68
+ # @return [Node]
46
69
  def build(id)
47
70
  Node.new(
48
71
  id: id,
@@ -5,6 +5,8 @@ require_relative "dsl/step_builder"
5
5
  require_relative "dsl/flow_builder"
6
6
 
7
7
  module FlowEngine
8
+ # Namespace for the declarative flow DSL: {FlowBuilder} builds a {Definition} from blocks,
9
+ # {StepBuilder} builds individual {Node}s, and {RuleHelpers} provide rule factory methods.
8
10
  module DSL
9
11
  end
10
12
  end
@@ -1,9 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # Runtime session that drives flow navigation: holds definition, answers, and current step.
5
+ # Validates each answer via an optional {Validation::Adapter}, then advances using node transitions.
6
+ #
7
+ # @attr_reader definition [Definition] immutable flow definition
8
+ # @attr_reader answers [Hash] step_id => value (mutable as user answers)
9
+ # @attr_reader history [Array<Symbol>] ordered list of step ids visited (including current)
10
+ # @attr_reader current_step_id [Symbol, nil] current step id, or nil when flow is finished
4
11
  class Engine
5
12
  attr_reader :definition, :answers, :history, :current_step_id
6
13
 
14
+ # @param definition [Definition] the flow to run
15
+ # @param validator [Validation::Adapter] validator for step answers (default: {Validation::NullAdapter})
7
16
  def initialize(definition, validator: Validation::NullAdapter.new)
8
17
  @definition = definition
9
18
  @answers = {}
@@ -13,16 +22,23 @@ module FlowEngine
13
22
  @history << @current_step_id
14
23
  end
15
24
 
25
+ # @return [Node, nil] current step node, or nil if flow is finished
16
26
  def current_step
17
27
  return nil if finished?
18
28
 
19
29
  definition.step(@current_step_id)
20
30
  end
21
31
 
32
+ # @return [Boolean] true when there is no current step (flow ended)
22
33
  def finished?
23
34
  @current_step_id.nil?
24
35
  end
25
36
 
37
+ # Submits an answer for the current step, validates it, stores it, and advances to the next step.
38
+ #
39
+ # @param value [Object] user's answer for the current step
40
+ # @raise [AlreadyFinishedError] if the flow has already finished
41
+ # @raise [ValidationError] if the validator rejects the value
26
42
  def answer(value)
27
43
  raise AlreadyFinishedError, "Flow is already finished" if finished?
28
44
 
@@ -33,6 +49,9 @@ module FlowEngine
33
49
  advance_step
34
50
  end
35
51
 
52
+ # Serializable state for persistence or resumption.
53
+ #
54
+ # @return [Hash] current_step_id, answers, and history (string/symbol keys as stored)
36
55
  def to_state
37
56
  {
38
57
  current_step_id: @current_step_id,
@@ -41,6 +60,12 @@ module FlowEngine
41
60
  }
42
61
  end
43
62
 
63
+ # Rebuilds an engine from a previously saved state (e.g. from DB or session).
64
+ #
65
+ # @param definition [Definition] same definition used when state was captured
66
+ # @param state_hash [Hash] hash with :current_step_id, :answers, :history (keys may be strings)
67
+ # @param validator [Validation::Adapter] validator to use (default: NullAdapter)
68
+ # @return [Engine] restored engine instance
44
69
  def self.from_state(definition, state_hash, validator: Validation::NullAdapter.new)
45
70
  state = symbolize_state(state_hash)
46
71
  engine = allocate
@@ -48,6 +73,10 @@ module FlowEngine
48
73
  engine
49
74
  end
50
75
 
76
+ # Normalizes a state hash so step ids and history entries are symbols; answers keys are symbols.
77
+ #
78
+ # @param hash [Hash] raw state (e.g. from JSON)
79
+ # @return [Hash] symbolized state
51
80
  def self.symbolize_state(hash)
52
81
  return hash unless hash.is_a?(Hash)
53
82
 
@@ -66,6 +95,8 @@ module FlowEngine
66
95
  end
67
96
  end
68
97
 
98
+ # @param answers [Hash] answers map (keys may be strings)
99
+ # @return [Hash] same map with symbol keys
69
100
  def self.symbolize_answers(answers)
70
101
  return {} unless answers.is_a?(Hash)
71
102
 
@@ -1,10 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # Base exception for all flowengine errors.
4
5
  class Error < StandardError; end
6
+
7
+ # Raised when a flow definition is invalid (e.g. missing start step, unknown step reference).
5
8
  class DefinitionError < Error; end
9
+
10
+ # Raised when navigating to or requesting a step id that does not exist in the definition.
6
11
  class UnknownStepError < Error; end
12
+
13
+ # Base exception for runtime engine errors (e.g. validation, already finished).
7
14
  class EngineError < Error; end
15
+
16
+ # Raised when {Engine#answer} is called after the flow has already finished.
8
17
  class AlreadyFinishedError < EngineError; end
18
+
19
+ # Raised when the validator rejects the user's answer for the current step.
9
20
  class ValidationError < EngineError; end
10
21
  end
@@ -1,13 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # Evaluates rule AST nodes against a given answer context.
5
+ # Used by transitions and visibility checks; rule objects implement {Rules::Base#evaluate}.
6
+ #
7
+ # @attr_reader answers [Hash] current answer state (step_id => value)
4
8
  class Evaluator
5
9
  attr_reader :answers
6
10
 
11
+ # @param answers [Hash] answer context to evaluate rules against
7
12
  def initialize(answers)
8
13
  @answers = answers
9
14
  end
10
15
 
16
+ # Evaluates a single rule (or returns true if rule is nil).
17
+ #
18
+ # @param rule [Rules::Base, nil] rule to evaluate
19
+ # @return [Boolean] result of rule#evaluate(answers), or true when rule is nil
11
20
  def evaluate(rule)
12
21
  return true if rule.nil?
13
22
 
@@ -2,15 +2,22 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Graph
5
+ # Exports a flow {Definition} to Mermaid flowchart syntax for visualization.
6
+ # Nodes are labeled with truncated question text; edges show condition labels when present.
5
7
  class MermaidExporter
8
+ # Maximum characters for node labels in the diagram (longer text is truncated with "...").
6
9
  MAX_LABEL_LENGTH = 50
7
10
 
8
11
  attr_reader :definition
9
12
 
13
+ # @param definition [Definition] flow to export
10
14
  def initialize(definition)
11
15
  @definition = definition
12
16
  end
13
17
 
18
+ # Generates Mermaid flowchart TD (top-down) source.
19
+ #
20
+ # @return [String] Mermaid diagram source (e.g. for Mermaid.js or docs)
14
21
  def export
15
22
  lines = ["flowchart TD"]
16
23
 
@@ -1,9 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # A single step in the flow: question metadata, input config, and conditional transitions.
5
+ # Used by {Engine} to determine the next step and by UI/export to render the step.
6
+ #
7
+ # @attr_reader id [Symbol] unique step identifier
8
+ # @attr_reader type [Symbol] input type (e.g. :multi_select, :number_matrix)
9
+ # @attr_reader question [String] prompt text for the step
10
+ # @attr_reader options [Array, nil] choices for multi_select; nil for other types
11
+ # @attr_reader fields [Array, nil] field names for number_matrix etc.; nil otherwise
12
+ # @attr_reader transitions [Array<Transition>] ordered list of conditional next-step rules
13
+ # @attr_reader visibility_rule [Rules::Base, nil] rule controlling whether this node is visible (DAG mode)
4
14
  class Node
5
15
  attr_reader :id, :type, :question, :options, :fields, :transitions, :visibility_rule
6
16
 
17
+ # @param id [Symbol] step id
18
+ # @param type [Symbol] step/input type
19
+ # @param question [String] label/prompt
20
+ # @param decorations [Object, nil] optional UI decorations (not used by engine)
21
+ # @param options [Array, nil] option list for multi_select
22
+ # @param fields [Array, nil] field list for matrix-style steps
23
+ # @param transitions [Array<Transition>] conditional next-step transitions (default: [])
24
+ # @param visibility_rule [Rules::Base, nil] optional rule for visibility (default: always visible)
7
25
  def initialize(id:, # rubocop:disable Metrics/ParameterLists
8
26
  type:,
9
27
  question:,
@@ -23,11 +41,19 @@ module FlowEngine
23
41
  freeze
24
42
  end
25
43
 
44
+ # Resolves the next step id from current answers by evaluating transitions in order.
45
+ #
46
+ # @param answers [Hash] current answer state (step_id => value)
47
+ # @return [Symbol, nil] id of the next step, or nil if no transition matches (flow end)
26
48
  def next_step_id(answers)
27
49
  match = transitions.find { |t| t.applies?(answers) }
28
50
  match&.target
29
51
  end
30
52
 
53
+ # Whether this node should be considered visible given current answers (for DAG/visibility).
54
+ #
55
+ # @param answers [Hash] current answer state
56
+ # @return [Boolean] true if no visibility_rule, else result of rule evaluation
31
57
  def visible?(answers)
32
58
  return true if visibility_rule.nil?
33
59
 
@@ -2,19 +2,26 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Composite rule: logical AND of multiple sub-rules. All must be true.
6
+ #
7
+ # @attr_reader rules [Array<Base>] sub-rules to evaluate
5
8
  class All < Base
6
9
  attr_reader :rules
7
10
 
11
+ # @param rules [Array<Base>] one or more rules (arrays are flattened)
8
12
  def initialize(*rules)
9
13
  super()
10
14
  @rules = rules.flatten.freeze
11
15
  freeze
12
16
  end
13
17
 
18
+ # @param answers [Hash] current answers
19
+ # @return [Boolean] true if every sub-rule evaluates to true
14
20
  def evaluate(answers)
15
21
  rules.all? { |rule| rule.evaluate(answers) }
16
22
  end
17
23
 
24
+ # @return [String] e.g. "(rule1 AND rule2 AND rule3)"
18
25
  def to_s
19
26
  "(#{rules.join(" AND ")})"
20
27
  end
@@ -2,19 +2,26 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Composite rule: logical OR of multiple sub-rules. At least one must be true.
6
+ #
7
+ # @attr_reader rules [Array<Base>] sub-rules to evaluate
5
8
  class Any < Base
6
9
  attr_reader :rules
7
10
 
11
+ # @param rules [Array<Base>] one or more rules (arrays are flattened)
8
12
  def initialize(*rules)
9
13
  super()
10
14
  @rules = rules.flatten.freeze
11
15
  freeze
12
16
  end
13
17
 
18
+ # @param answers [Hash] current answers
19
+ # @return [Boolean] true if any sub-rule evaluates to true
14
20
  def evaluate(answers)
15
21
  rules.any? { |rule| rule.evaluate(answers) }
16
22
  end
17
23
 
24
+ # @return [String] e.g. "(rule1 OR rule2 OR rule3)"
18
25
  def to_s
19
26
  "(#{rules.join(" OR ")})"
20
27
  end
@@ -2,11 +2,20 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Abstract base for rule AST nodes. Subclasses implement {#evaluate} and {#to_s}
6
+ # for use in transitions and visibility conditions.
5
7
  class Base
8
+ # Evaluates the rule against the current answer context.
9
+ #
10
+ # @param _answers [Hash] step_id => value
11
+ # @return [Boolean]
6
12
  def evaluate(_answers)
7
13
  raise NotImplementedError, "#{self.class}#evaluate must be implemented"
8
14
  end
9
15
 
16
+ # Human-readable representation (e.g. for graph labels).
17
+ #
18
+ # @return [String]
10
19
  def to_s
11
20
  raise NotImplementedError, "#{self.class}#to_s must be implemented"
12
21
  end
@@ -2,9 +2,16 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Rule: the answer for the given field (as an array) includes the given value.
6
+ # Used for multi-select steps (e.g. "BusinessOwnership in earnings").
7
+ #
8
+ # @attr_reader field [Symbol] answer key (step id)
9
+ # @attr_reader value [Object] value that must be present in the array
5
10
  class Contains < Base
6
11
  attr_reader :field, :value
7
12
 
13
+ # @param field [Symbol] answer key
14
+ # @param value [Object] value to check for
8
15
  def initialize(field, value)
9
16
  super()
10
17
  @field = field
@@ -12,10 +19,13 @@ module FlowEngine
12
19
  freeze
13
20
  end
14
21
 
22
+ # @param answers [Hash] current answers
23
+ # @return [Boolean] true if answers[field] (as array) includes value
15
24
  def evaluate(answers)
16
25
  Array(answers[field]).include?(value)
17
26
  end
18
27
 
28
+ # @return [String] e.g. "BusinessOwnership in earnings"
19
29
  def to_s
20
30
  "#{value} in #{field}"
21
31
  end
@@ -2,9 +2,15 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Rule: the answer for the given field equals the given value.
6
+ #
7
+ # @attr_reader field [Symbol] answer key
8
+ # @attr_reader value [Object] expected value
5
9
  class Equals < Base
6
10
  attr_reader :field, :value
7
11
 
12
+ # @param field [Symbol] answer key
13
+ # @param value [Object] expected value
8
14
  def initialize(field, value)
9
15
  super()
10
16
  @field = field
@@ -12,10 +18,13 @@ module FlowEngine
12
18
  freeze
13
19
  end
14
20
 
21
+ # @param answers [Hash] current answers
22
+ # @return [Boolean] true if answers[field] == value
15
23
  def evaluate(answers)
16
24
  answers[field] == value
17
25
  end
18
26
 
27
+ # @return [String] e.g. "marital_status == Married"
19
28
  def to_s
20
29
  "#{field} == #{value}"
21
30
  end
@@ -2,9 +2,15 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Rule: the answer for the given field (coerced to integer) is greater than the threshold.
6
+ #
7
+ # @attr_reader field [Symbol] answer key
8
+ # @attr_reader value [Integer] threshold
5
9
  class GreaterThan < Base
6
10
  attr_reader :field, :value
7
11
 
12
+ # @param field [Symbol] answer key
13
+ # @param value [Integer] threshold
8
14
  def initialize(field, value)
9
15
  super()
10
16
  @field = field
@@ -12,10 +18,13 @@ module FlowEngine
12
18
  freeze
13
19
  end
14
20
 
21
+ # @param answers [Hash] current answers (field value is coerced with to_i)
22
+ # @return [Boolean] true if answers[field].to_i > value
15
23
  def evaluate(answers)
16
24
  answers[field].to_i > value
17
25
  end
18
26
 
27
+ # @return [String] e.g. "business_income > 100000"
19
28
  def to_s
20
29
  "#{field} > #{value}"
21
30
  end
@@ -2,9 +2,15 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Rule: the answer for the given field (coerced to integer) is less than the threshold.
6
+ #
7
+ # @attr_reader field [Symbol] answer key
8
+ # @attr_reader value [Integer] threshold
5
9
  class LessThan < Base
6
10
  attr_reader :field, :value
7
11
 
12
+ # @param field [Symbol] answer key
13
+ # @param value [Integer] threshold
8
14
  def initialize(field, value)
9
15
  super()
10
16
  @field = field
@@ -12,10 +18,13 @@ module FlowEngine
12
18
  freeze
13
19
  end
14
20
 
21
+ # @param answers [Hash] current answers (field value is coerced with to_i)
22
+ # @return [Boolean] true if answers[field].to_i < value
15
23
  def evaluate(answers)
16
24
  answers[field].to_i < value
17
25
  end
18
26
 
27
+ # @return [String] e.g. "age < 18"
19
28
  def to_s
20
29
  "#{field} < #{value}"
21
30
  end
@@ -2,15 +2,21 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Rules
5
+ # Rule: the answer for the given field is present and not empty (nil or empty? => false).
6
+ #
7
+ # @attr_reader field [Symbol] answer key
5
8
  class NotEmpty < Base
6
9
  attr_reader :field
7
10
 
11
+ # @param field [Symbol] answer key
8
12
  def initialize(field)
9
13
  super()
10
14
  @field = field
11
15
  freeze
12
16
  end
13
17
 
18
+ # @param answers [Hash] current answers
19
+ # @return [Boolean] false if nil or empty, true otherwise
14
20
  def evaluate(answers)
15
21
  val = answers[field]
16
22
  return false if val.nil?
@@ -19,6 +25,7 @@ module FlowEngine
19
25
  true
20
26
  end
21
27
 
28
+ # @return [String] e.g. "name is not empty"
22
29
  def to_s
23
30
  "#{field} is not empty"
24
31
  end
@@ -1,19 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
+ # A single edge from one step to another, optionally guarded by a rule.
5
+ # Transitions are evaluated in order; the first whose rule is true determines the next step.
6
+ #
7
+ # @attr_reader target [Symbol] id of the step to go to when this transition applies
8
+ # @attr_reader rule [Rules::Base, nil] condition; nil means unconditional (always applies)
4
9
  class Transition
5
10
  attr_reader :target, :rule
6
11
 
12
+ # @param target [Symbol] next step id
13
+ # @param rule [Rules::Base, nil] optional condition (nil = always)
7
14
  def initialize(target:, rule: nil)
8
15
  @target = target
9
16
  @rule = rule
10
17
  freeze
11
18
  end
12
19
 
20
+ # Human-readable label for the condition (e.g. for graph export).
21
+ #
22
+ # @return [String] rule#to_s or "always" when rule is nil
13
23
  def condition_label
14
24
  rule ? rule.to_s : "always"
15
25
  end
16
26
 
27
+ # Whether this transition should be taken given current answers.
28
+ #
29
+ # @param answers [Hash] current answer state
30
+ # @return [Boolean] true if rule is nil or rule evaluates to true
17
31
  def applies?(answers)
18
32
  return true if rule.nil?
19
33
 
@@ -2,21 +2,34 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Validation
5
+ # Result of validating a step answer: either valid or a list of error messages.
6
+ #
7
+ # @attr_reader errors [Array<String>] validation error messages (empty when valid)
5
8
  class Result
6
9
  attr_reader :errors
7
10
 
11
+ # @param valid [Boolean] whether the input passed validation
12
+ # @param errors [Array<String>] error messages (default: [])
8
13
  def initialize(valid:, errors: [])
9
14
  @valid = valid
10
15
  @errors = errors.freeze
11
16
  freeze
12
17
  end
13
18
 
19
+ # @return [Boolean] true if validation passed
14
20
  def valid?
15
21
  @valid
16
22
  end
17
23
  end
18
24
 
25
+ # Abstract adapter for step-level validation. Implement {#validate} to plug in
26
+ # dry-validation, JSON Schema, or other validators; the engine does not depend on a specific one.
19
27
  class Adapter
28
+ # Validates the user's input for the given step.
29
+ #
30
+ # @param _node [Node] the current step (for schema/constraints)
31
+ # @param _input [Object] the value submitted by the user
32
+ # @return [Result] valid: true/false and optional errors list
20
33
  def validate(_node, _input)
21
34
  raise NotImplementedError, "#{self.class}#validate must be implemented"
22
35
  end
@@ -2,7 +2,11 @@
2
2
 
3
3
  module FlowEngine
4
4
  module Validation
5
+ # No-op validator: always accepts any input. Used by default when no validation adapter is given.
5
6
  class NullAdapter < Adapter
7
+ # @param _node [Node] ignored
8
+ # @param _input [Object] ignored
9
+ # @return [Result] always valid with no errors
6
10
  def validate(_node, _input)
7
11
  Result.new(valid: true, errors: [])
8
12
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
- VERSION = "0.1.1"
4
+ # Semantic version of the flowengine gem (major.minor.patch).
5
+ VERSION = "0.1.2"
5
6
  end
data/lib/flowengine.rb CHANGED
@@ -20,13 +20,46 @@ require_relative "flowengine/engine"
20
20
  require_relative "flowengine/dsl"
21
21
  require_relative "flowengine/graph/mermaid_exporter"
22
22
 
23
+ # Declarative flow definition and execution engine for wizards, intake forms, and
24
+ # multi-step decision graphs. Separates flow logic, data schema, and UI rendering.
25
+ #
26
+ # @example Define and run a flow
27
+ # definition = FlowEngine.define do
28
+ # start :earnings
29
+ # step :earnings do
30
+ # type :multi_select
31
+ # question "What are your main earnings?"
32
+ # options %w[W2 1099 BusinessOwnership]
33
+ # transition to: :business_details, if: contains(:earnings, "BusinessOwnership")
34
+ # end
35
+ # step :business_details do
36
+ # type :number_matrix
37
+ # question "How many business types?"
38
+ # fields %w[RealEstate SCorp CCorp]
39
+ # end
40
+ # end
41
+ # engine = FlowEngine::Engine.new(definition)
42
+ # engine.answer(["W2", "BusinessOwnership"])
43
+ # engine.current_step_id # => :business_details
44
+ #
23
45
  module FlowEngine
46
+ # Builds an immutable {Definition} from the declarative DSL block.
47
+ #
48
+ # @yield context of {DSL::FlowBuilder} (start, step, and rule helpers)
49
+ # @return [Definition] frozen flow definition with start step and nodes
50
+ # @raise [DefinitionError] if no start step or no steps are defined
24
51
  def self.define(&)
25
52
  builder = DSL::FlowBuilder.new
26
53
  builder.instance_eval(&)
27
54
  builder.build
28
55
  end
29
56
 
57
+ # Evaluates a string of DSL code and returns the resulting definition.
58
+ # Intended for loading flow definitions from files or stored text.
59
+ #
60
+ # @param text [String] Ruby source containing FlowEngine.define { ... }
61
+ # @return [Definition] the definition produced by evaluating the DSL
62
+ # @raise [DefinitionError] on syntax or evaluation errors
30
63
  def self.load_dsl(text)
31
64
  # rubocop:disable Security/Eval
32
65
  eval(text, TOPLEVEL_BINDING.dup, "(dsl)", 1)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flowengine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul