rust_rule_engine/engine/
agenda.rs

1use crate::engine::rule::Rule;
2use std::collections::{HashMap, HashSet};
3
4/// Manages agenda groups for workflow control
5#[derive(Debug, Clone)]
6pub struct AgendaManager {
7    /// Currently active agenda group
8    active_group: String,
9    /// Stack of focused agenda groups
10    focus_stack: Vec<String>,
11    /// Groups that have been activated for lock-on-active tracking
12    activated_groups: HashSet<String>,
13    /// Rules fired per agenda group activation (for lock-on-active)
14    fired_rules_per_activation: HashMap<String, HashSet<String>>,
15}
16
17impl Default for AgendaManager {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl AgendaManager {
24    /// Create a new agenda manager with "MAIN" as default group
25    pub fn new() -> Self {
26        Self {
27            active_group: "MAIN".to_string(),
28            focus_stack: vec!["MAIN".to_string()],
29            activated_groups: HashSet::new(),
30            fired_rules_per_activation: HashMap::new(),
31        }
32    }
33
34    /// Set focus to a specific agenda group
35    pub fn set_focus(&mut self, group: &str) {
36        let group = group.to_string();
37
38        // Remove from stack if already exists
39        self.focus_stack.retain(|g| g != &group);
40
41        // Add to top of stack
42        self.focus_stack.push(group.clone());
43        self.active_group = group.clone();
44
45        // Mark group as activated
46        self.activated_groups.insert(group.clone());
47
48        // Clear fired rules for new activation
49        self.fired_rules_per_activation
50            .insert(group, HashSet::new());
51    }
52
53    /// Get the currently active agenda group
54    pub fn get_active_group(&self) -> &str {
55        &self.active_group
56    }
57
58    /// Check if a rule should be evaluated based on agenda group
59    pub fn should_evaluate_rule(&self, rule: &Rule) -> bool {
60        match &rule.agenda_group {
61            Some(group) => group == &self.active_group,
62            None => self.active_group == "MAIN", // Rules without group go to MAIN
63        }
64    }
65
66    /// Check if a rule can fire considering lock-on-active
67    pub fn can_fire_rule(&self, rule: &Rule) -> bool {
68        if !rule.lock_on_active {
69            return true;
70        }
71
72        let main_group = "MAIN".to_string();
73        let group = rule.agenda_group.as_ref().unwrap_or(&main_group);
74
75        // If group hasn't been activated yet, rule can fire
76        if !self.activated_groups.contains(group) {
77            return true;
78        }
79
80        // Check if rule has already fired in this activation
81        if let Some(fired_rules) = self.fired_rules_per_activation.get(group) {
82            !fired_rules.contains(&rule.name)
83        } else {
84            true
85        }
86    }
87
88    /// Mark a rule as fired for lock-on-active tracking
89    pub fn mark_rule_fired(&mut self, rule: &Rule) {
90        if rule.lock_on_active {
91            let main_group = "MAIN".to_string();
92            let group = rule.agenda_group.as_ref().unwrap_or(&main_group);
93
94            // Ensure group is activated
95            self.activated_groups.insert(group.clone());
96
97            // Track fired rule
98            self.fired_rules_per_activation
99                .entry(group.clone())
100                .or_default()
101                .insert(rule.name.clone());
102        }
103    }
104
105    /// Pop the focus stack (return to previous agenda group)
106    pub fn pop_focus(&mut self) -> Option<String> {
107        if self.focus_stack.len() > 1 {
108            self.focus_stack.pop();
109            if let Some(previous) = self.focus_stack.last() {
110                self.active_group = previous.clone();
111                Some(previous.clone())
112            } else {
113                None
114            }
115        } else {
116            None
117        }
118    }
119
120    /// Clear all focus and return to MAIN
121    pub fn clear_focus(&mut self) {
122        self.focus_stack.clear();
123        self.focus_stack.push("MAIN".to_string());
124        self.active_group = "MAIN".to_string();
125    }
126
127    /// Get all agenda groups with rules
128    pub fn get_agenda_groups(&self, rules: &[Rule]) -> Vec<String> {
129        let mut groups = HashSet::new();
130        groups.insert("MAIN".to_string());
131
132        for rule in rules {
133            if let Some(group) = &rule.agenda_group {
134                groups.insert(group.clone());
135            }
136        }
137
138        groups.into_iter().collect()
139    }
140
141    /// Filter rules by current agenda group
142    pub fn filter_rules<'a>(&self, rules: &'a [Rule]) -> Vec<&'a Rule> {
143        rules
144            .iter()
145            .filter(|rule| self.should_evaluate_rule(rule))
146            .collect()
147    }
148
149    /// Reset for new execution cycle
150    pub fn reset_cycle(&mut self) {
151        // For lock-on-active, we DON'T clear fired rules until agenda group changes
152        // Only clear for rules that are not lock-on-active
153        // This is different from activation groups which reset per cycle
154    }
155}
156
157/// Manages activation groups for mutually exclusive rule execution
158#[derive(Debug, Clone)]
159pub struct ActivationGroupManager {
160    /// Groups that have had a rule fire (only one rule per group can fire)
161    fired_groups: HashSet<String>,
162}
163
164impl Default for ActivationGroupManager {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl ActivationGroupManager {
171    /// Create a new activation group manager
172    pub fn new() -> Self {
173        Self {
174            fired_groups: HashSet::new(),
175        }
176    }
177
178    /// Check if a rule can fire based on activation group constraints
179    pub fn can_fire(&self, rule: &Rule) -> bool {
180        if let Some(group) = &rule.activation_group {
181            !self.fired_groups.contains(group)
182        } else {
183            true // Rules without activation group can always fire
184        }
185    }
186
187    /// Mark that a rule has fired, preventing other rules in same activation group
188    pub fn mark_fired(&mut self, rule: &Rule) {
189        if let Some(group) = &rule.activation_group {
190            self.fired_groups.insert(group.clone());
191        }
192    }
193
194    /// Reset for new execution cycle
195    pub fn reset_cycle(&mut self) {
196        self.fired_groups.clear();
197    }
198
199    /// Get all activation groups
200    pub fn get_activation_groups(&self, rules: &[Rule]) -> Vec<String> {
201        rules
202            .iter()
203            .filter_map(|rule| rule.activation_group.clone())
204            .collect::<HashSet<_>>()
205            .into_iter()
206            .collect()
207    }
208
209    /// Check if any rule in an activation group has fired
210    pub fn has_group_fired(&self, group: &str) -> bool {
211        self.fired_groups.contains(group)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::engine::rule::{Condition, ConditionGroup, Rule};
219    use crate::types::{Operator, Value};
220
221    fn create_dummy_condition() -> ConditionGroup {
222        let condition = Condition {
223            field: "test".to_string(),
224            operator: Operator::Equal,
225            value: Value::Boolean(true),
226        };
227        ConditionGroup::single(condition)
228    }
229
230    #[test]
231    fn test_agenda_manager_basic() {
232        let mut manager = AgendaManager::new();
233        assert_eq!(manager.get_active_group(), "MAIN");
234
235        manager.set_focus("validation");
236        assert_eq!(manager.get_active_group(), "validation");
237
238        manager.set_focus("processing");
239        assert_eq!(manager.get_active_group(), "processing");
240
241        manager.pop_focus();
242        assert_eq!(manager.get_active_group(), "validation");
243    }
244
245    #[test]
246    fn test_agenda_manager_rule_filtering() {
247        let mut manager = AgendaManager::new();
248
249        let rule1 = Rule::new("Rule1".to_string(), create_dummy_condition(), vec![])
250            .with_agenda_group("validation".to_string());
251        let rule2 = Rule::new("Rule2".to_string(), create_dummy_condition(), vec![]);
252
253        // Initially in MAIN, only rule2 should be evaluated
254        let rules = vec![rule1.clone(), rule2.clone()];
255        let filtered = manager.filter_rules(&rules);
256        assert_eq!(filtered.len(), 1);
257        assert_eq!(filtered[0].name, "Rule2");
258
259        // Switch to validation, only rule1 should be evaluated
260        manager.set_focus("validation");
261        let filtered = manager.filter_rules(&rules);
262        assert_eq!(filtered.len(), 1);
263        assert_eq!(filtered[0].name, "Rule1");
264    }
265
266    #[test]
267    fn test_activation_group_manager() {
268        let mut manager = ActivationGroupManager::new();
269
270        let rule1 = Rule::new("Rule1".to_string(), create_dummy_condition(), vec![])
271            .with_activation_group("discount".to_string());
272        let rule2 = Rule::new("Rule2".to_string(), create_dummy_condition(), vec![])
273            .with_activation_group("discount".to_string());
274
275        // Both rules can fire initially
276        assert!(manager.can_fire(&rule1));
277        assert!(manager.can_fire(&rule2));
278
279        // After rule1 fires, rule2 cannot fire
280        manager.mark_fired(&rule1);
281        assert!(!manager.can_fire(&rule2));
282        assert!(manager.has_group_fired("discount"));
283
284        // After reset, both can fire again
285        manager.reset_cycle();
286        assert!(manager.can_fire(&rule1));
287        assert!(manager.can_fire(&rule2));
288    }
289
290    #[test]
291    fn test_lock_on_active() {
292        let mut manager = AgendaManager::new();
293
294        let rule = Rule::new("TestRule".to_string(), create_dummy_condition(), vec![])
295            .with_lock_on_active(true);
296
297        // Rule can fire initially (MAIN group not activated yet)
298        assert!(manager.can_fire_rule(&rule));
299
300        // Mark rule as fired - this should activate MAIN group and track the rule
301        manager.mark_rule_fired(&rule);
302
303        // Now rule cannot fire again in the same activation
304        assert!(!manager.can_fire_rule(&rule));
305
306        // After cycle reset, still cannot fire (lock-on-active persists until group change)
307        manager.reset_cycle();
308        assert!(!manager.can_fire_rule(&rule));
309
310        // After switching to different group and back, rule can fire again
311        manager.set_focus("validation");
312        manager.set_focus("MAIN");
313        assert!(manager.can_fire_rule(&rule));
314    }
315}