Skip to main content

clasp_rules/
engine.rs

1//! Rules evaluation engine
2//!
3//! The engine evaluates rules against incoming state changes and events,
4//! producing actions that the router should execute.
5
6use clasp_core::{SignalType, Value};
7use std::collections::HashMap;
8use std::time::Instant;
9
10use crate::error::{Result, RulesError};
11use crate::rule::{Rule, RuleAction, Trigger};
12
13/// Output from rule evaluation -- an action the router should execute
14#[derive(Debug, Clone)]
15pub struct PendingAction {
16    /// The rule that produced this action
17    pub rule_id: String,
18    /// The action to execute
19    pub action: RuleAction,
20    /// Origin tag for loop prevention
21    pub origin: String,
22}
23
24/// The rules engine evaluates rules against state changes.
25pub struct RulesEngine {
26    /// Active rules
27    rules: HashMap<String, Rule>,
28    /// Last fire time for cooldown tracking
29    last_fired: HashMap<String, Instant>,
30    /// Currently evaluating rules (for loop detection)
31    evaluating: Vec<String>,
32}
33
34impl RulesEngine {
35    /// Create a new empty rules engine
36    pub fn new() -> Self {
37        Self {
38            rules: HashMap::new(),
39            last_fired: HashMap::new(),
40            evaluating: Vec::new(),
41        }
42    }
43
44    /// Add or update a rule
45    pub fn add_rule(&mut self, rule: Rule) -> Result<()> {
46        if rule.id.is_empty() {
47            return Err(RulesError::InvalidRule("rule ID cannot be empty".into()));
48        }
49        if rule.actions.is_empty() {
50            return Err(RulesError::InvalidRule(
51                "rule must have at least one action".into(),
52            ));
53        }
54        self.rules.insert(rule.id.clone(), rule);
55        Ok(())
56    }
57
58    /// Remove a rule by ID
59    pub fn remove_rule(&mut self, id: &str) -> Result<()> {
60        self.rules
61            .remove(id)
62            .map(|_| ())
63            .ok_or_else(|| RulesError::NotFound(id.to_string()))
64    }
65
66    /// Get a rule by ID
67    pub fn get_rule(&self, id: &str) -> Option<&Rule> {
68        self.rules.get(id)
69    }
70
71    /// Get all rules
72    pub fn rules(&self) -> impl Iterator<Item = &Rule> {
73        self.rules.values()
74    }
75
76    /// Number of rules
77    pub fn len(&self) -> usize {
78        self.rules.len()
79    }
80
81    /// Check if empty
82    pub fn is_empty(&self) -> bool {
83        self.rules.is_empty()
84    }
85
86    /// Enable or disable a rule
87    pub fn set_enabled(&mut self, id: &str, enabled: bool) -> Result<()> {
88        self.rules
89            .get_mut(id)
90            .map(|r| r.enabled = enabled)
91            .ok_or_else(|| RulesError::NotFound(id.to_string()))
92    }
93
94    /// Evaluate rules triggered by a state change.
95    ///
96    /// `address`: the address that changed
97    /// `value`: the new value
98    /// `signal_type`: Param or Event
99    /// `origin`: if set, skip rules that originated from this source (loop prevention)
100    /// `state_lookup`: function to look up current param values (for conditions)
101    ///
102    /// Returns actions that should be executed by the router.
103    pub fn evaluate<F>(
104        &mut self,
105        address: &str,
106        value: &Value,
107        signal_type: SignalType,
108        origin: Option<&str>,
109        state_lookup: F,
110    ) -> Vec<PendingAction>
111    where
112        F: Fn(&str) -> Option<Value>,
113    {
114        // Skip if this change came from a rule (loop prevention)
115        if let Some(origin) = origin {
116            if origin.starts_with("rule:") {
117                return vec![];
118            }
119        }
120
121        let mut actions = Vec::new();
122        let now = Instant::now();
123
124        // Collect matching rule IDs first to avoid borrow issues
125        let matching_ids: Vec<String> = self
126            .rules
127            .values()
128            .filter(|rule| rule.enabled && rule.trigger.matches(address, signal_type))
129            .map(|rule| rule.id.clone())
130            .collect();
131
132        for rule_id in matching_ids {
133            // Loop detection
134            if self.evaluating.contains(&rule_id) {
135                continue;
136            }
137
138            let rule = match self.rules.get(&rule_id) {
139                Some(r) => r,
140                None => continue,
141            };
142
143            // Cooldown check
144            if let Some(cooldown) = rule.cooldown {
145                if let Some(last) = self.last_fired.get(&rule_id) {
146                    if now.duration_since(*last) < cooldown {
147                        continue;
148                    }
149                }
150            }
151
152            // Threshold check for OnThreshold triggers
153            if let Trigger::OnThreshold { above, below, .. } = &rule.trigger {
154                let f = match value {
155                    Value::Float(f) => *f,
156                    Value::Int(i) => *i as f64,
157                    _ => continue,
158                };
159
160                let threshold_met = match (above, below) {
161                    (Some(a), Some(b)) => f > *a || f < *b,
162                    (Some(a), None) => f > *a,
163                    (None, Some(b)) => f < *b,
164                    (None, None) => true,
165                };
166
167                if !threshold_met {
168                    continue;
169                }
170            }
171
172            // Evaluate conditions
173            let conditions_met = rule.conditions.iter().all(|cond| {
174                if let Some(current) = state_lookup(&cond.address) {
175                    cond.op.evaluate(&current, &cond.value)
176                } else {
177                    false
178                }
179            });
180
181            if !conditions_met {
182                continue;
183            }
184
185            // Mark as evaluating for loop detection
186            self.evaluating.push(rule_id.clone());
187
188            // Collect actions
189            let rule_origin = format!("rule:{}", rule_id);
190            for action in &rule.actions {
191                let resolved_action = match action {
192                    RuleAction::SetFromTrigger {
193                        address: target,
194                        transform,
195                    } => RuleAction::Set {
196                        address: target.clone(),
197                        value: transform.apply(value),
198                    },
199                    other => other.clone(),
200                };
201
202                actions.push(PendingAction {
203                    rule_id: rule_id.clone(),
204                    action: resolved_action,
205                    origin: rule_origin.clone(),
206                });
207            }
208
209            // Update last fired time
210            self.last_fired.insert(rule_id.clone(), now);
211
212            // Clear evaluating
213            self.evaluating.retain(|id| id != &rule_id);
214        }
215
216        actions
217    }
218
219    /// Evaluate an interval rule by ID.
220    ///
221    /// Unlike `evaluate()`, this doesn't match on address/signal_type — it fires
222    /// the rule directly (checking enabled, cooldown, and conditions).
223    /// Returns actions that should be executed by the router.
224    pub fn evaluate_interval<F>(&mut self, rule_id: &str, state_lookup: F) -> Vec<PendingAction>
225    where
226        F: Fn(&str) -> Option<Value>,
227    {
228        let rule = match self.rules.get(rule_id) {
229            Some(r) if r.enabled => r,
230            _ => return vec![],
231        };
232
233        let now = Instant::now();
234
235        // Cooldown check
236        if let Some(cooldown) = rule.cooldown {
237            if let Some(last) = self.last_fired.get(rule_id) {
238                if now.duration_since(*last) < cooldown {
239                    return vec![];
240                }
241            }
242        }
243
244        // Evaluate conditions
245        let conditions_met = rule.conditions.iter().all(|cond| {
246            if let Some(current) = state_lookup(&cond.address) {
247                cond.op.evaluate(&current, &cond.value)
248            } else {
249                false
250            }
251        });
252
253        if !conditions_met {
254            return vec![];
255        }
256
257        let rule_origin = format!("interval:{}", rule_id);
258        let actions: Vec<PendingAction> = rule
259            .actions
260            .iter()
261            .map(|action| {
262                let resolved_action = match action {
263                    RuleAction::SetFromTrigger {
264                        address: target,
265                        transform,
266                    } => {
267                        // For interval triggers there's no trigger value, use Null
268                        RuleAction::Set {
269                            address: target.clone(),
270                            value: transform.apply(&Value::Null),
271                        }
272                    }
273                    other => other.clone(),
274                };
275                PendingAction {
276                    rule_id: rule_id.to_string(),
277                    action: resolved_action,
278                    origin: rule_origin.clone(),
279                }
280            })
281            .collect();
282
283        self.last_fired.insert(rule_id.to_string(), now);
284        actions
285    }
286
287    /// Get rule IDs that have interval triggers (for the router to schedule)
288    pub fn interval_rules(&self) -> Vec<(String, u64)> {
289        self.rules
290            .values()
291            .filter(|r| r.enabled)
292            .filter_map(|r| {
293                if let Trigger::OnInterval { seconds } = &r.trigger {
294                    Some((r.id.clone(), *seconds))
295                } else {
296                    None
297                }
298            })
299            .collect()
300    }
301}
302
303impl Default for RulesEngine {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::rule::*;
313
314    fn make_rule(id: &str, pattern: &str, target: &str, value: Value) -> Rule {
315        Rule {
316            id: id.to_string(),
317            name: format!("Test rule {}", id),
318            enabled: true,
319            trigger: Trigger::OnChange {
320                pattern: pattern.to_string(),
321            },
322            conditions: vec![],
323            actions: vec![RuleAction::Set {
324                address: target.to_string(),
325                value,
326            }],
327            cooldown: None,
328        }
329    }
330
331    #[test]
332    fn test_basic_rule_evaluation() {
333        let mut engine = RulesEngine::new();
334        engine
335            .add_rule(make_rule(
336                "r1",
337                "/sensor/motion",
338                "/lights/room1",
339                Value::Float(1.0),
340            ))
341            .unwrap();
342
343        let actions = engine.evaluate(
344            "/sensor/motion",
345            &Value::Bool(true),
346            SignalType::Param,
347            None,
348            |_| None,
349        );
350
351        assert_eq!(actions.len(), 1);
352        assert_eq!(actions[0].rule_id, "r1");
353        assert!(matches!(
354            &actions[0].action,
355            RuleAction::Set { address, value } if address == "/lights/room1" && *value == Value::Float(1.0)
356        ));
357    }
358
359    #[test]
360    fn test_pattern_matching() {
361        let mut engine = RulesEngine::new();
362        engine
363            .add_rule(make_rule("r1", "/sensor/**", "/output", Value::Bool(true)))
364            .unwrap();
365
366        // Should match
367        let actions = engine.evaluate(
368            "/sensor/motion/room1",
369            &Value::Bool(true),
370            SignalType::Param,
371            None,
372            |_| None,
373        );
374        assert_eq!(actions.len(), 1);
375
376        // Should not match
377        let actions = engine.evaluate(
378            "/lights/room1",
379            &Value::Bool(true),
380            SignalType::Param,
381            None,
382            |_| None,
383        );
384        assert!(actions.is_empty());
385    }
386
387    #[test]
388    fn test_disabled_rule() {
389        let mut engine = RulesEngine::new();
390        let mut rule = make_rule("r1", "/sensor/**", "/output", Value::Bool(true));
391        rule.enabled = false;
392        engine.add_rule(rule).unwrap();
393
394        let actions = engine.evaluate(
395            "/sensor/motion",
396            &Value::Bool(true),
397            SignalType::Param,
398            None,
399            |_| None,
400        );
401        assert!(actions.is_empty());
402    }
403
404    #[test]
405    fn test_condition_check() {
406        let mut engine = RulesEngine::new();
407        let mut rule = make_rule("r1", "/sensor/motion", "/lights/room1", Value::Float(1.0));
408        rule.conditions = vec![Condition {
409            address: "/mode".to_string(),
410            op: CompareOp::Eq,
411            value: Value::String("auto".to_string()),
412        }];
413        engine.add_rule(rule).unwrap();
414
415        // Condition met
416        let actions = engine.evaluate(
417            "/sensor/motion",
418            &Value::Bool(true),
419            SignalType::Param,
420            None,
421            |addr| {
422                if addr == "/mode" {
423                    Some(Value::String("auto".to_string()))
424                } else {
425                    None
426                }
427            },
428        );
429        assert_eq!(actions.len(), 1);
430
431        // Condition not met
432        let actions = engine.evaluate(
433            "/sensor/motion",
434            &Value::Bool(true),
435            SignalType::Param,
436            None,
437            |addr| {
438                if addr == "/mode" {
439                    Some(Value::String("manual".to_string()))
440                } else {
441                    None
442                }
443            },
444        );
445        assert!(actions.is_empty());
446    }
447
448    #[test]
449    fn test_threshold_trigger() {
450        let mut engine = RulesEngine::new();
451        engine
452            .add_rule(Rule {
453                id: "r1".to_string(),
454                name: "High temp alert".to_string(),
455                enabled: true,
456                trigger: Trigger::OnThreshold {
457                    address: "/sensor/temp".to_string(),
458                    above: Some(30.0),
459                    below: None,
460                },
461                conditions: vec![],
462                actions: vec![RuleAction::Publish {
463                    address: "/alerts/temp".to_string(),
464                    signal: SignalType::Event,
465                    value: Some(Value::String("high temperature".to_string())),
466                }],
467                cooldown: None,
468            })
469            .unwrap();
470
471        // Below threshold -- no action
472        let actions = engine.evaluate(
473            "/sensor/temp",
474            &Value::Float(25.0),
475            SignalType::Param,
476            None,
477            |_| None,
478        );
479        assert!(actions.is_empty());
480
481        // Above threshold -- fires
482        let actions = engine.evaluate(
483            "/sensor/temp",
484            &Value::Float(35.0),
485            SignalType::Param,
486            None,
487            |_| None,
488        );
489        assert_eq!(actions.len(), 1);
490    }
491
492    #[test]
493    fn test_set_from_trigger_with_transform() {
494        let mut engine = RulesEngine::new();
495        engine
496            .add_rule(Rule {
497                id: "r1".to_string(),
498                name: "Scale input".to_string(),
499                enabled: true,
500                trigger: Trigger::OnChange {
501                    pattern: "/input/fader".to_string(),
502                },
503                conditions: vec![],
504                actions: vec![RuleAction::SetFromTrigger {
505                    address: "/output/brightness".to_string(),
506                    transform: Transform::Scale {
507                        scale: 255.0,
508                        offset: 0.0,
509                    },
510                }],
511                cooldown: None,
512            })
513            .unwrap();
514
515        let actions = engine.evaluate(
516            "/input/fader",
517            &Value::Float(0.5),
518            SignalType::Param,
519            None,
520            |_| None,
521        );
522
523        assert_eq!(actions.len(), 1);
524        match &actions[0].action {
525            RuleAction::Set { value, .. } => {
526                assert_eq!(*value, Value::Float(127.5));
527            }
528            _ => panic!("expected Set action"),
529        }
530    }
531
532    #[test]
533    fn test_loop_prevention() {
534        let mut engine = RulesEngine::new();
535        engine
536            .add_rule(make_rule("r1", "/sensor/**", "/output", Value::Bool(true)))
537            .unwrap();
538
539        // Origin from a rule should be skipped
540        let actions = engine.evaluate(
541            "/sensor/motion",
542            &Value::Bool(true),
543            SignalType::Param,
544            Some("rule:r1"),
545            |_| None,
546        );
547        assert!(actions.is_empty());
548    }
549
550    #[test]
551    fn test_cooldown() {
552        let mut engine = RulesEngine::new();
553        let mut rule = make_rule("r1", "/sensor/**", "/output", Value::Bool(true));
554        rule.cooldown = Some(std::time::Duration::from_secs(60));
555        engine.add_rule(rule).unwrap();
556
557        // First evaluation fires
558        let actions = engine.evaluate(
559            "/sensor/motion",
560            &Value::Bool(true),
561            SignalType::Param,
562            None,
563            |_| None,
564        );
565        assert_eq!(actions.len(), 1);
566
567        // Second evaluation within cooldown does not fire
568        let actions = engine.evaluate(
569            "/sensor/motion",
570            &Value::Bool(true),
571            SignalType::Param,
572            None,
573            |_| None,
574        );
575        assert!(actions.is_empty());
576    }
577
578    #[test]
579    fn test_remove_rule() {
580        let mut engine = RulesEngine::new();
581        engine
582            .add_rule(make_rule("r1", "/a", "/b", Value::Null))
583            .unwrap();
584        assert_eq!(engine.len(), 1);
585
586        engine.remove_rule("r1").unwrap();
587        assert_eq!(engine.len(), 0);
588
589        assert!(engine.remove_rule("nonexistent").is_err());
590    }
591
592    #[test]
593    fn test_event_trigger() {
594        let mut engine = RulesEngine::new();
595        engine
596            .add_rule(Rule {
597                id: "r1".to_string(),
598                name: "On button press".to_string(),
599                enabled: true,
600                trigger: Trigger::OnEvent {
601                    pattern: "/buttons/**".to_string(),
602                },
603                conditions: vec![],
604                actions: vec![RuleAction::Set {
605                    address: "/lights/toggle".to_string(),
606                    value: Value::Bool(true),
607                }],
608                cooldown: None,
609            })
610            .unwrap();
611
612        // Event matches
613        let actions = engine.evaluate(
614            "/buttons/main",
615            &Value::Null,
616            SignalType::Event,
617            None,
618            |_| None,
619        );
620        assert_eq!(actions.len(), 1);
621
622        // Param change does not match event trigger
623        let actions = engine.evaluate(
624            "/buttons/main",
625            &Value::Null,
626            SignalType::Param,
627            None,
628            |_| None,
629        );
630        assert!(actions.is_empty());
631    }
632
633    #[test]
634    fn test_interval_rules() {
635        let mut engine = RulesEngine::new();
636        engine
637            .add_rule(Rule {
638                id: "heartbeat".to_string(),
639                name: "Heartbeat".to_string(),
640                enabled: true,
641                trigger: Trigger::OnInterval { seconds: 30 },
642                conditions: vec![],
643                actions: vec![RuleAction::Publish {
644                    address: "/system/heartbeat".to_string(),
645                    signal: SignalType::Event,
646                    value: None,
647                }],
648                cooldown: None,
649            })
650            .unwrap();
651
652        let intervals = engine.interval_rules();
653        assert_eq!(intervals.len(), 1);
654        assert_eq!(intervals[0], ("heartbeat".to_string(), 30));
655    }
656
657    #[test]
658    fn test_evaluate_interval() {
659        let mut engine = RulesEngine::new();
660        engine
661            .add_rule(Rule {
662                id: "heartbeat".to_string(),
663                name: "Heartbeat".to_string(),
664                enabled: true,
665                trigger: Trigger::OnInterval { seconds: 30 },
666                conditions: vec![],
667                actions: vec![RuleAction::Publish {
668                    address: "/system/heartbeat".to_string(),
669                    signal: SignalType::Event,
670                    value: None,
671                }],
672                cooldown: None,
673            })
674            .unwrap();
675
676        let actions = engine.evaluate_interval("heartbeat", |_| None);
677        assert_eq!(actions.len(), 1);
678        assert_eq!(actions[0].rule_id, "heartbeat");
679        assert!(actions[0].origin.starts_with("interval:"));
680    }
681
682    #[test]
683    fn test_evaluate_interval_with_condition() {
684        let mut engine = RulesEngine::new();
685        engine
686            .add_rule(Rule {
687                id: "conditional_interval".to_string(),
688                name: "Conditional interval".to_string(),
689                enabled: true,
690                trigger: Trigger::OnInterval { seconds: 10 },
691                conditions: vec![Condition {
692                    address: "/mode".to_string(),
693                    op: CompareOp::Eq,
694                    value: Value::String("active".to_string()),
695                }],
696                actions: vec![RuleAction::Set {
697                    address: "/output".to_string(),
698                    value: Value::Bool(true),
699                }],
700                cooldown: None,
701            })
702            .unwrap();
703
704        // Condition not met
705        let actions = engine.evaluate_interval("conditional_interval", |_| None);
706        assert!(actions.is_empty());
707
708        // Condition met
709        let actions = engine.evaluate_interval("conditional_interval", |addr| {
710            if addr == "/mode" {
711                Some(Value::String("active".to_string()))
712            } else {
713                None
714            }
715        });
716        assert_eq!(actions.len(), 1);
717    }
718
719    #[test]
720    fn test_evaluate_interval_disabled() {
721        let mut engine = RulesEngine::new();
722        let rule = Rule {
723            id: "disabled_interval".to_string(),
724            name: "Disabled".to_string(),
725            enabled: false,
726            trigger: Trigger::OnInterval { seconds: 5 },
727            conditions: vec![],
728            actions: vec![RuleAction::Set {
729                address: "/x".to_string(),
730                value: Value::Null,
731            }],
732            cooldown: None,
733        };
734        engine.add_rule(rule).unwrap();
735
736        let actions = engine.evaluate_interval("disabled_interval", |_| None);
737        assert!(actions.is_empty());
738    }
739
740    #[test]
741    fn test_evaluate_interval_nonexistent() {
742        let mut engine = RulesEngine::new();
743        let actions = engine.evaluate_interval("nonexistent", |_| None);
744        assert!(actions.is_empty());
745    }
746}