philiprehberger-rule_engine 0.1.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1531d969d2e58539f6f31038b21c7f7e8f3c4fe4f95c6eadb13d4161c9ad7c88
4
- data.tar.gz: 292d0874c8a64d66a491a0e066a2ceac66c3e65dffad26089b4a6d667ff7370e
3
+ metadata.gz: 48759336967f009c48193bd19ba3abc3edf2c45712e21f18bbc3382b50ce2c19
4
+ data.tar.gz: 2894eca7b2c22440eb52c0920ae3edcb1642ada7193668f3e2d01213c6d0f560
5
5
  SHA512:
6
- metadata.gz: f247a8f155ab5d05f061ae5d67f77ce87696f74aee2753f3dccc6e21fac594772e9999cf40fbda28b53e96c8a6fcc6456b04821086ad4f43ff02ca9efa277f17
7
- data.tar.gz: 9866f9b3968839a76851a77e8575faf3a3d0892c527ce10e7e505dd14fb790877f5c9d4386210df59f09f3eec958eddffcab12fc80a82b8bb407b5f85b9db836
6
+ metadata.gz: 0f1df77fa3a9caa8dc480eeb6cdb9c2010806f2b57108b8c4fde06b60830c853387918d225d48584d56cca89aeb2e790eee245ef0081debe6f9b8af723b3fa47
7
+ data.tar.gz: 9cd80a35da5092a44dd170965cd508faed0d3aba1d25a4005a91af5da037dff4a2e41cf9e54cb4995533e7240f4dd47155c5c26259d495d238f0eb41fd8bb9ee
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-03-28
11
+
12
+ ### Added
13
+ - `engine.to_h` and `RuleEngine.from_h` for rule configuration serialization
14
+ - Composite condition helpers: `all?`, `any?`, `none?` for readable logic
15
+ - `engine.stats` for per-rule execution statistics with timing
16
+ - Dynamic rule management: `add_rule`, `remove_rule`, `disable_rule`, `enable_rule`
17
+ - `engine.chain(*rule_names)` for sequential rule pipelines
18
+
10
19
  ## [0.1.2] - 2026-03-24
11
20
 
12
21
  ### Fixed
data/README.md CHANGED
@@ -2,7 +2,12 @@
2
2
 
3
3
  [![Tests](https://github.com/philiprehberger/rb-rule-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-rule-engine/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-rule_engine.svg)](https://rubygems.org/gems/philiprehberger-rule_engine)
5
+ [![GitHub release](https://img.shields.io/github/v/release/philiprehberger/rb-rule-engine)](https://github.com/philiprehberger/rb-rule-engine/releases)
6
+ [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-rule-engine)](https://github.com/philiprehberger/rb-rule-engine/commits/main)
5
7
  [![License](https://img.shields.io/github/license/philiprehberger/rb-rule-engine)](LICENSE)
8
+ [![Bug Reports](https://img.shields.io/github/issues/philiprehberger/rb-rule-engine/bug)](https://github.com/philiprehberger/rb-rule-engine/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
9
+ [![Feature Requests](https://img.shields.io/github/issues/philiprehberger/rb-rule-engine/enhancement)](https://github.com/philiprehberger/rb-rule-engine/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
10
+ [![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ec6cb9)](https://github.com/sponsors/philiprehberger)
6
11
 
7
12
  Lightweight rule engine with declarative conditions and actions
8
13
 
@@ -15,7 +20,7 @@ Lightweight rule engine with declarative conditions and actions
15
20
  Add to your Gemfile:
16
21
 
17
22
  ```ruby
18
- gem "philiprehberger-rule_engine"
23
+ gem 'philiprehberger-rule_engine'
19
24
  ```
20
25
 
21
26
  Or install directly:
@@ -27,7 +32,7 @@ gem install philiprehberger-rule_engine
27
32
  ## Usage
28
33
 
29
34
  ```ruby
30
- require "philiprehberger/rule_engine"
35
+ require 'philiprehberger/rule_engine'
31
36
 
32
37
  engine = Philiprehberger::RuleEngine.new do
33
38
  rule 'discount' do
@@ -84,6 +89,100 @@ results = engine.evaluate({ tier: 'premium' })
84
89
  # => [{ rule: 'premium', result: { discount: 0.20 } }]
85
90
  ```
86
91
 
92
+ ### Composite Conditions
93
+
94
+ ```ruby
95
+ engine = Philiprehberger::RuleEngine.new do
96
+ rule 'access' do
97
+ condition { |f| all?(f[:active], any?(f[:admin], f[:moderator])) }
98
+ action { |_| 'granted' }
99
+ end
100
+
101
+ rule 'blocked' do
102
+ condition { |f| none?(f[:verified], f[:trusted]) }
103
+ action { |_| 'denied' }
104
+ end
105
+ end
106
+ ```
107
+
108
+ ### Dynamic Rule Management
109
+
110
+ ```ruby
111
+ engine = Philiprehberger::RuleEngine.new
112
+
113
+ engine.add_rule('dynamic') do
114
+ condition { |f| f[:ready] }
115
+ action { |_| 'go' }
116
+ end
117
+
118
+ engine.disable_rule('dynamic')
119
+ engine.evaluate({ ready: true }) # => []
120
+
121
+ engine.enable_rule('dynamic')
122
+ engine.evaluate({ ready: true }) # => [{ rule: 'dynamic', result: 'go' }]
123
+
124
+ engine.remove_rule('dynamic')
125
+ ```
126
+
127
+ ### Rule Chaining
128
+
129
+ ```ruby
130
+ engine = Philiprehberger::RuleEngine.new do
131
+ rule 'fetch' do
132
+ action { |_| 10 }
133
+ end
134
+
135
+ rule 'double' do
136
+ action { |f| f[:input] * 2 }
137
+ end
138
+
139
+ rule 'format' do
140
+ action { |f| "Result: #{f[:input]}" }
141
+ end
142
+ end
143
+
144
+ engine.chain('fetch', 'double', 'format')
145
+ # => 'Result: 20'
146
+ ```
147
+
148
+ ### Execution Statistics
149
+
150
+ ```ruby
151
+ engine = Philiprehberger::RuleEngine.new do
152
+ rule 'tracked' do
153
+ condition { |_| true }
154
+ action { |_| 'ok' }
155
+ end
156
+ end
157
+
158
+ 3.times { engine.evaluate({}) }
159
+
160
+ engine.stats
161
+ # => { 'tracked' => { evaluations: 3, matches: 3, executions: 3, avg_time: 0.00001, last_triggered: <Time> } }
162
+
163
+ engine.reset_stats!
164
+ ```
165
+
166
+ ### Serialization
167
+
168
+ ```ruby
169
+ engine = Philiprehberger::RuleEngine.new(mode: :first) do
170
+ rule 'alpha' do
171
+ priority 1
172
+ condition { |_| true }
173
+ action { |_| 'go' }
174
+ end
175
+ end
176
+
177
+ data = engine.to_h
178
+ # => { mode: :first, rules: [{ name: 'alpha', priority: 1, enabled: true }] }
179
+
180
+ restored = Philiprehberger::RuleEngine.from_h(data) do |r|
181
+ r.condition { |_| true }
182
+ r.action { |_| 'restored' }
183
+ end
184
+ ```
185
+
87
186
  ## API
88
187
 
89
188
  ### `Engine`
@@ -91,10 +190,19 @@ results = engine.evaluate({ tier: 'premium' })
91
190
  | Method | Description |
92
191
  |--------|-------------|
93
192
  | `.new(mode:) { }` | Create engine with rule definitions |
193
+ | `.from_h(data, &resolver)` | Reconstruct engine from serialized hash |
94
194
  | `#rule(name) { }` | Define a rule with condition and action |
95
195
  | `#evaluate(facts)` | Evaluate rules against facts |
96
196
  | `#rules` | Array of registered rules |
97
197
  | `#mode` | Current evaluation mode |
198
+ | `#to_h` | Serialize engine configuration to hash |
199
+ | `#add_rule(name) { }` | Add a rule after engine creation |
200
+ | `#remove_rule(name)` | Remove a rule by name |
201
+ | `#disable_rule(name)` | Disable a rule (skipped during evaluation) |
202
+ | `#enable_rule(name)` | Re-enable a disabled rule |
203
+ | `#chain(*rule_names)` | Execute rules sequentially as a pipeline |
204
+ | `#stats` | Per-rule execution statistics |
205
+ | `#reset_stats!` | Clear all execution statistics |
98
206
 
99
207
  ### Rule DSL
100
208
 
@@ -104,6 +212,14 @@ results = engine.evaluate({ tier: 'premium' })
104
212
  | `action { \|facts\| }` | Set the action to execute |
105
213
  | `priority(n)` | Set priority (lower runs first) |
106
214
 
215
+ ### Composite Condition Helpers
216
+
217
+ | Method | Description |
218
+ |--------|-------------|
219
+ | `all?(*conditions)` | True if all conditions are truthy |
220
+ | `any?(*conditions)` | True if any condition is truthy |
221
+ | `none?(*conditions)` | True if no conditions are truthy |
222
+
107
223
  ## Development
108
224
 
109
225
  ```bash
@@ -112,6 +228,12 @@ bundle exec rspec
112
228
  bundle exec rubocop
113
229
  ```
114
230
 
231
+ ## Support
232
+
233
+ - [Bug Reports](https://github.com/philiprehberger/rb-rule-engine/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
234
+ - [Feature Requests](https://github.com/philiprehberger/rb-rule-engine/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
235
+ - [GitHub Sponsors](https://github.com/sponsors/philiprehberger)
236
+
115
237
  ## License
116
238
 
117
- MIT
239
+ [MIT](LICENSE)
@@ -4,6 +4,8 @@ module Philiprehberger
4
4
  module RuleEngine
5
5
  # A lightweight rule engine with declarative conditions and actions.
6
6
  class Engine
7
+ include Helpers
8
+
7
9
  # @return [Array<Rule>] the registered rules
8
10
  attr_reader :rules
9
11
 
@@ -15,10 +17,11 @@ module Philiprehberger
15
17
  # @param mode [Symbol] :all to run all matching rules, :first to stop after first match
16
18
  # @yield [engine] block for defining rules using the DSL
17
19
  def initialize(mode: :all, &block)
18
- raise Error, "mode must be :all or :first" unless %i[all first].include?(mode)
20
+ raise Error, 'mode must be :all or :first' unless %i[all first].include?(mode)
19
21
 
20
22
  @rules = []
21
23
  @mode = mode
24
+ @stats = {}
22
25
  instance_eval(&block) if block
23
26
  end
24
27
 
@@ -31,27 +34,151 @@ module Philiprehberger
31
34
  r = Rule.new(name)
32
35
  r.instance_eval(&block) if block
33
36
  @rules << r
37
+ @stats[name] = new_stat_entry
34
38
  r
35
39
  end
36
40
 
41
+ # Add a rule after engine creation.
42
+ #
43
+ # @param name [String] the rule name
44
+ # @yield [rule] block for configuring the rule
45
+ # @return [Rule] the created rule
46
+ def add_rule(name, &)
47
+ rule(name, &)
48
+ end
49
+
50
+ # Remove a rule by name.
51
+ #
52
+ # @param name [String] the rule name to remove
53
+ # @return [Rule, nil] the removed rule, or nil if not found
54
+ def remove_rule(name)
55
+ index = @rules.index { |r| r.name == name }
56
+ return nil unless index
57
+
58
+ removed = @rules.delete_at(index)
59
+ @stats.delete(name)
60
+ removed
61
+ end
62
+
63
+ # Disable a rule by name (skipped during evaluation).
64
+ #
65
+ # @param name [String] the rule name to disable
66
+ # @return [void]
67
+ def disable_rule(name)
68
+ found = @rules.find { |r| r.name == name }
69
+ raise Error, "rule not found: #{name}" unless found
70
+
71
+ found.enabled = false
72
+ end
73
+
74
+ # Enable a rule by name.
75
+ #
76
+ # @param name [String] the rule name to enable
77
+ # @return [void]
78
+ def enable_rule(name)
79
+ found = @rules.find { |r| r.name == name }
80
+ raise Error, "rule not found: #{name}" unless found
81
+
82
+ found.enabled = true
83
+ end
84
+
37
85
  # Evaluate all rules against the given facts.
38
86
  #
39
87
  # @param facts [Object] the facts to evaluate
40
88
  # @return [Array<Hash>] results with :rule and :result for each matched rule
41
89
  def evaluate(facts)
42
- sorted = @rules.sort_by(&:priority)
90
+ sorted = @rules.select(&:enabled).sort_by(&:priority)
43
91
  results = []
44
92
 
45
93
  sorted.each do |r|
94
+ stat = @stats[r.name] ||= new_stat_entry
95
+ stat[:evaluations] += 1
96
+
46
97
  next unless r.matches?(facts)
47
98
 
99
+ stat[:matches] += 1
100
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
101
  result = r.execute(facts)
102
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
103
+
104
+ stat[:executions] += 1
105
+ stat[:total_time] += elapsed
106
+ stat[:avg_time] = stat[:total_time] / stat[:executions]
107
+ stat[:last_triggered] = Time.now
108
+
49
109
  results << { rule: r.name, result: result }
50
110
  break if @mode == :first
51
111
  end
52
112
 
53
113
  results
54
114
  end
115
+
116
+ # Return per-rule execution statistics.
117
+ #
118
+ # @return [Hash] stats keyed by rule name
119
+ def stats
120
+ @stats.transform_values do |s|
121
+ {
122
+ evaluations: s[:evaluations],
123
+ matches: s[:matches],
124
+ executions: s[:executions],
125
+ avg_time: s[:avg_time],
126
+ last_triggered: s[:last_triggered]
127
+ }
128
+ end
129
+ end
130
+
131
+ # Reset all execution statistics.
132
+ #
133
+ # @return [void]
134
+ def reset_stats!
135
+ @stats.each_key { |k| @stats[k] = new_stat_entry }
136
+ end
137
+
138
+ # Serialize the engine configuration to a hash.
139
+ #
140
+ # @return [Hash] engine metadata including mode and rules
141
+ def to_h
142
+ {
143
+ mode: @mode,
144
+ rules: @rules.map(&:to_h)
145
+ }
146
+ end
147
+
148
+ # Execute rules sequentially as a pipeline.
149
+ # Each rule's action result is passed as input: to the next rule.
150
+ #
151
+ # @param rule_names [Array<String>] ordered rule names to chain
152
+ # @return [Object] the final action's result
153
+ def chain(*rule_names)
154
+ chain_rules = rule_names.map do |name|
155
+ found = @rules.find { |r| r.name == name }
156
+ raise Error, "rule not found: #{name}" unless found
157
+
158
+ found
159
+ end
160
+
161
+ input = nil
162
+ chain_rules.each do |r|
163
+ facts = { input: input }
164
+ input = r.execute(facts)
165
+ end
166
+
167
+ input
168
+ end
169
+
170
+ private
171
+
172
+ def new_stat_entry
173
+ {
174
+ evaluations: 0,
175
+ matches: 0,
176
+ executions: 0,
177
+ total_time: 0.0,
178
+ avg_time: 0.0,
179
+ last_triggered: nil
180
+ }
181
+ end
55
182
  end
56
183
  end
57
184
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module RuleEngine
5
+ # Composite condition helpers for readable logic in condition blocks.
6
+ module Helpers
7
+ # Returns true if all conditions are truthy.
8
+ #
9
+ # @param conditions [Array<Boolean, Proc>] values or procs to evaluate
10
+ # @return [Boolean]
11
+ def all?(*conditions)
12
+ conditions.all? { |c| c.is_a?(Proc) ? c.call : c }
13
+ end
14
+
15
+ # Returns true if any condition is truthy.
16
+ #
17
+ # @param conditions [Array<Boolean, Proc>] values or procs to evaluate
18
+ # @return [Boolean]
19
+ def any?(*conditions)
20
+ conditions.any? { |c| c.is_a?(Proc) ? c.call : c }
21
+ end
22
+
23
+ # Returns true if no conditions are truthy.
24
+ #
25
+ # @param conditions [Array<Boolean, Proc>] values or procs to evaluate
26
+ # @return [Boolean]
27
+ def none?(*conditions)
28
+ conditions.none? { |c| c.is_a?(Proc) ? c.call : c }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -4,15 +4,21 @@ module Philiprehberger
4
4
  module RuleEngine
5
5
  # A single rule with a name, condition, action, and priority.
6
6
  class Rule
7
+ include Helpers
8
+
7
9
  # @return [String] the rule name
8
10
  attr_reader :name
9
11
 
12
+ # @return [Boolean] whether the rule is enabled
13
+ attr_accessor :enabled
14
+
10
15
  # @param name [String] the rule name
11
16
  def initialize(name)
12
17
  @name = name
13
18
  @priority = 0
14
19
  @condition = nil
15
20
  @action = nil
21
+ @enabled = true
16
22
  end
17
23
 
18
24
  # Set the condition for this rule.
@@ -50,7 +56,7 @@ module Philiprehberger
50
56
  def matches?(facts)
51
57
  return false unless @condition
52
58
 
53
- !!@condition.call(facts)
59
+ !!instance_exec(facts, &@condition)
54
60
  end
55
61
 
56
62
  # Execute the action with the given facts.
@@ -60,7 +66,18 @@ module Philiprehberger
60
66
  def execute(facts)
61
67
  return nil unless @action
62
68
 
63
- @action.call(facts)
69
+ instance_exec(facts, &@action)
70
+ end
71
+
72
+ # Serialize rule metadata to a hash.
73
+ #
74
+ # @return [Hash] rule metadata (name, priority, enabled)
75
+ def to_h
76
+ {
77
+ name: @name,
78
+ priority: @priority,
79
+ enabled: @enabled
80
+ }
64
81
  end
65
82
  end
66
83
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module RuleEngine
5
- VERSION = '0.1.2'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'rule_engine/version'
4
+ require_relative 'rule_engine/helpers'
4
5
  require_relative 'rule_engine/rule'
5
6
  require_relative 'rule_engine/engine'
6
7
 
@@ -16,5 +17,33 @@ module Philiprehberger
16
17
  def self.new(mode: :all, &block)
17
18
  Engine.new(mode: mode, &block)
18
19
  end
20
+
21
+ # Reconstruct an engine from a serialized hash.
22
+ # The resolver block maps rule names to condition/action implementations.
23
+ #
24
+ # @param data [Hash] serialized engine data from Engine#to_h
25
+ # @yield [name] block that receives a rule name and should configure the rule
26
+ # @return [Engine] the reconstructed engine
27
+ def self.from_h(data, &resolver)
28
+ raise Error, 'resolver block is required' unless resolver
29
+
30
+ mode = (data[:mode] || data['mode'] || :all).to_sym
31
+ engine = Engine.new(mode: mode)
32
+
33
+ rules = data[:rules] || data['rules'] || []
34
+ rules.each do |rule_data|
35
+ name = rule_data[:name] || rule_data['name']
36
+ priority_val = rule_data[:priority] || rule_data['priority'] || 0
37
+ enabled_val = rule_data.key?(:enabled) ? rule_data[:enabled] : rule_data.fetch('enabled', true)
38
+
39
+ r = engine.add_rule(name) do
40
+ priority priority_val
41
+ end
42
+ r.enabled = enabled_val
43
+ resolver.call(r)
44
+ end
45
+
46
+ engine
47
+ end
19
48
  end
20
49
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-rule_engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-25 00:00:00.000000000 Z
11
+ date: 2026-03-29 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A lightweight rule engine with a declarative DSL for defining conditions
14
14
  and actions. Supports priority-based ordering, first-match and all-match evaluation
@@ -24,6 +24,7 @@ files:
24
24
  - README.md
25
25
  - lib/philiprehberger/rule_engine.rb
26
26
  - lib/philiprehberger/rule_engine/engine.rb
27
+ - lib/philiprehberger/rule_engine/helpers.rb
27
28
  - lib/philiprehberger/rule_engine/rule.rb
28
29
  - lib/philiprehberger/rule_engine/version.rb
29
30
  homepage: https://github.com/philiprehberger/rb-rule-engine