1use 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#[derive(Debug, Clone)]
15pub struct PendingAction {
16 pub rule_id: String,
18 pub action: RuleAction,
20 pub origin: String,
22}
23
24pub struct RulesEngine {
26 rules: HashMap<String, Rule>,
28 last_fired: HashMap<String, Instant>,
30 evaluating: Vec<String>,
32}
33
34impl RulesEngine {
35 pub fn new() -> Self {
37 Self {
38 rules: HashMap::new(),
39 last_fired: HashMap::new(),
40 evaluating: Vec::new(),
41 }
42 }
43
44 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 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 pub fn get_rule(&self, id: &str) -> Option<&Rule> {
68 self.rules.get(id)
69 }
70
71 pub fn rules(&self) -> impl Iterator<Item = &Rule> {
73 self.rules.values()
74 }
75
76 pub fn len(&self) -> usize {
78 self.rules.len()
79 }
80
81 pub fn is_empty(&self) -> bool {
83 self.rules.is_empty()
84 }
85
86 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 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 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 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 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 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 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 let conditions_met = rule.conditions.iter().all(|cond| {
174 if let Some(current) = state_lookup(&cond.address) {
175 cond.op.evaluate(¤t, &cond.value)
176 } else {
177 false
178 }
179 });
180
181 if !conditions_met {
182 continue;
183 }
184
185 self.evaluating.push(rule_id.clone());
187
188 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 self.last_fired.insert(rule_id.clone(), now);
211
212 self.evaluating.retain(|id| id != &rule_id);
214 }
215
216 actions
217 }
218
219 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 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 let conditions_met = rule.conditions.iter().all(|cond| {
246 if let Some(current) = state_lookup(&cond.address) {
247 cond.op.evaluate(¤t, &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 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 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 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 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 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 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 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 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 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 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 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 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 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 let actions = engine.evaluate_interval("conditional_interval", |_| None);
706 assert!(actions.is_empty());
707
708 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}