Skip to main content

oxihuman_core/
rule_engine.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Simple string-based rule engine: evaluate rules against a fact map.
6
7use std::collections::HashMap;
8
9/// Condition type for a rule.
10#[derive(Debug, Clone, PartialEq)]
11pub enum Condition {
12    Equals(String, String),
13    NotEquals(String, String),
14    Contains(String, String),
15    Absent(String),
16    Present(String),
17}
18
19/// Action produced when a rule fires.
20#[derive(Debug, Clone)]
21pub struct RuleAction {
22    pub name: String,
23    pub payload: String,
24}
25
26/// A rule with conditions and actions.
27#[derive(Debug, Clone)]
28pub struct Rule {
29    pub name: String,
30    pub conditions: Vec<Condition>,
31    pub actions: Vec<RuleAction>,
32    pub priority: i32,
33    pub enabled: bool,
34}
35
36/// Rule engine evaluating rules against a fact map.
37pub struct RuleEngine {
38    rules: Vec<Rule>,
39    fire_count: u64,
40}
41
42fn eval_condition(cond: &Condition, facts: &HashMap<String, String>) -> bool {
43    match cond {
44        Condition::Equals(k, v) => facts.get(k).is_some_and(|fv| fv == v),
45        Condition::NotEquals(k, v) => facts.get(k).is_some_and(|fv| fv != v),
46        Condition::Contains(k, sub) => facts.get(k).is_some_and(|fv| fv.contains(sub.as_str())),
47        Condition::Absent(k) => !facts.contains_key(k),
48        Condition::Present(k) => facts.contains_key(k),
49    }
50}
51
52#[allow(dead_code)]
53impl RuleEngine {
54    pub fn new() -> Self {
55        RuleEngine {
56            rules: Vec::new(),
57            fire_count: 0,
58        }
59    }
60
61    pub fn add_rule(&mut self, rule: Rule) {
62        let pos = self.rules.partition_point(|r| r.priority > rule.priority);
63        self.rules.insert(pos, rule);
64    }
65
66    pub fn evaluate(&mut self, facts: &HashMap<String, String>) -> Vec<RuleAction> {
67        let mut actions = Vec::new();
68        for rule in &self.rules {
69            if !rule.enabled {
70                continue;
71            }
72            if rule.conditions.iter().all(|c| eval_condition(c, facts)) {
73                self.fire_count += 1;
74                actions.extend(rule.actions.clone());
75            }
76        }
77        actions
78    }
79
80    pub fn set_enabled(&mut self, name: &str, enabled: bool) -> bool {
81        if let Some(r) = self.rules.iter_mut().find(|r| r.name == name) {
82            r.enabled = enabled;
83            true
84        } else {
85            false
86        }
87    }
88
89    pub fn remove_rule(&mut self, name: &str) -> bool {
90        let before = self.rules.len();
91        self.rules.retain(|r| r.name != name);
92        self.rules.len() < before
93    }
94
95    pub fn rule_count(&self) -> usize {
96        self.rules.len()
97    }
98
99    pub fn enabled_count(&self) -> usize {
100        self.rules.iter().filter(|r| r.enabled).count()
101    }
102
103    pub fn fire_count(&self) -> u64 {
104        self.fire_count
105    }
106
107    pub fn clear(&mut self) {
108        self.rules.clear();
109    }
110
111    pub fn rule_names(&self) -> Vec<&str> {
112        self.rules.iter().map(|r| r.name.as_str()).collect()
113    }
114}
115
116impl Default for RuleEngine {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122pub fn new_rule_engine() -> RuleEngine {
123    RuleEngine::new()
124}
125
126pub fn make_rule(
127    name: &str,
128    conds: Vec<Condition>,
129    actions: Vec<RuleAction>,
130    priority: i32,
131) -> Rule {
132    Rule {
133        name: name.to_string(),
134        conditions: conds,
135        actions,
136        priority,
137        enabled: true,
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn facts(pairs: &[(&str, &str)]) -> HashMap<String, String> {
146        pairs
147            .iter()
148            .map(|(k, v)| (k.to_string(), v.to_string()))
149            .collect()
150    }
151
152    #[test]
153    fn equals_condition_fires() {
154        let mut engine = new_rule_engine();
155        let rule = make_rule(
156            "r1",
157            vec![Condition::Equals(
158                "status".to_string(),
159                "active".to_string(),
160            )],
161            vec![RuleAction {
162                name: "activate".to_string(),
163                payload: String::new(),
164            }],
165            0,
166        );
167        engine.add_rule(rule);
168        let f = facts(&[("status", "active")]);
169        let actions = engine.evaluate(&f);
170        assert_eq!(actions.len(), 1);
171        assert_eq!(actions[0].name, "activate");
172    }
173
174    #[test]
175    fn not_equals_fires() {
176        let mut engine = new_rule_engine();
177        let rule = make_rule(
178            "r1",
179            vec![Condition::NotEquals("x".to_string(), "bad".to_string())],
180            vec![RuleAction {
181                name: "ok".to_string(),
182                payload: String::new(),
183            }],
184            0,
185        );
186        engine.add_rule(rule);
187        let f = facts(&[("x", "good")]);
188        assert_eq!(engine.evaluate(&f).len(), 1);
189    }
190
191    #[test]
192    fn present_condition() {
193        let mut engine = new_rule_engine();
194        engine.add_rule(make_rule(
195            "r1",
196            vec![Condition::Present("key".to_string())],
197            vec![RuleAction {
198                name: "found".to_string(),
199                payload: String::new(),
200            }],
201            0,
202        ));
203        assert_eq!(engine.evaluate(&facts(&[("key", "val")])).len(), 1);
204        assert_eq!(engine.evaluate(&facts(&[])).len(), 0);
205    }
206
207    #[test]
208    fn absent_condition() {
209        let mut engine = new_rule_engine();
210        engine.add_rule(make_rule(
211            "r1",
212            vec![Condition::Absent("ghost".to_string())],
213            vec![RuleAction {
214                name: "ok".to_string(),
215                payload: String::new(),
216            }],
217            0,
218        ));
219        assert_eq!(engine.evaluate(&facts(&[])).len(), 1);
220    }
221
222    #[test]
223    fn disabled_rule_skipped() {
224        let mut engine = new_rule_engine();
225        engine.add_rule(make_rule(
226            "r1",
227            vec![],
228            vec![RuleAction {
229                name: "fire".to_string(),
230                payload: String::new(),
231            }],
232            0,
233        ));
234        engine.set_enabled("r1", false);
235        assert_eq!(engine.evaluate(&facts(&[])).len(), 0);
236    }
237
238    #[test]
239    fn fire_count_tracked() {
240        let mut engine = new_rule_engine();
241        engine.add_rule(make_rule(
242            "r1",
243            vec![],
244            vec![RuleAction {
245                name: "a".to_string(),
246                payload: String::new(),
247            }],
248            0,
249        ));
250        engine.evaluate(&facts(&[]));
251        engine.evaluate(&facts(&[]));
252        assert_eq!(engine.fire_count(), 2);
253    }
254
255    #[test]
256    fn remove_rule() {
257        let mut engine = new_rule_engine();
258        engine.add_rule(make_rule("r1", vec![], vec![], 0));
259        assert!(engine.remove_rule("r1"));
260        assert_eq!(engine.rule_count(), 0);
261    }
262
263    #[test]
264    fn priority_ordering() {
265        let mut engine = new_rule_engine();
266        engine.add_rule(make_rule(
267            "low",
268            vec![],
269            vec![RuleAction {
270                name: "low".to_string(),
271                payload: String::new(),
272            }],
273            1,
274        ));
275        engine.add_rule(make_rule(
276            "high",
277            vec![],
278            vec![RuleAction {
279                name: "high".to_string(),
280                payload: String::new(),
281            }],
282            10,
283        ));
284        let names = engine.rule_names();
285        assert_eq!(names[0], "high");
286    }
287
288    #[test]
289    fn contains_condition() {
290        let mut engine = new_rule_engine();
291        engine.add_rule(make_rule(
292            "r1",
293            vec![Condition::Contains("msg".to_string(), "err".to_string())],
294            vec![RuleAction {
295                name: "alert".to_string(),
296                payload: String::new(),
297            }],
298            0,
299        ));
300        assert_eq!(engine.evaluate(&facts(&[("msg", "some error")])).len(), 1);
301        assert_eq!(engine.evaluate(&facts(&[("msg", "ok")])).len(), 0);
302    }
303}