Skip to main content

clasp_rules/
rule.rs

1//! Rule definitions and types
2
3use clasp_core::{SignalType, Value};
4use serde::{Deserialize, Serialize};
5use std::time::Duration;
6
7/// A rule definition that triggers actions based on signal changes.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Rule {
10    /// Unique rule identifier
11    pub id: String,
12    /// Human-readable name
13    pub name: String,
14    /// Whether the rule is active
15    pub enabled: bool,
16    /// What triggers the rule
17    pub trigger: Trigger,
18    /// Additional conditions that must be true for the rule to fire
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub conditions: Vec<Condition>,
21    /// Actions to execute when the rule fires
22    pub actions: Vec<RuleAction>,
23    /// Minimum time between firings (None = no cooldown)
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub cooldown: Option<Duration>,
26}
27
28/// What causes a rule to evaluate
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub enum Trigger {
31    /// Fires when a parameter matching the pattern changes
32    OnChange { pattern: String },
33    /// Fires when a parameter crosses a threshold
34    OnThreshold {
35        address: String,
36        #[serde(default, skip_serializing_if = "Option::is_none")]
37        above: Option<f64>,
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        below: Option<f64>,
40    },
41    /// Fires when an event matching the pattern is published
42    OnEvent { pattern: String },
43    /// Fires periodically
44    OnInterval { seconds: u64 },
45}
46
47/// A condition that must be true for a rule to fire
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Condition {
50    /// Address to check
51    pub address: String,
52    /// Comparison operator
53    pub op: CompareOp,
54    /// Value to compare against
55    pub value: Value,
56}
57
58/// Comparison operators for conditions
59#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
60pub enum CompareOp {
61    /// Equal
62    Eq,
63    /// Not equal
64    Ne,
65    /// Greater than
66    Gt,
67    /// Greater than or equal
68    Gte,
69    /// Less than
70    Lt,
71    /// Less than or equal
72    Lte,
73}
74
75impl CompareOp {
76    /// Evaluate the comparison
77    pub fn evaluate(&self, left: &Value, right: &Value) -> bool {
78        match (left, right) {
79            (Value::Float(a), Value::Float(b)) => match self {
80                CompareOp::Eq => (a - b).abs() < f64::EPSILON,
81                CompareOp::Ne => (a - b).abs() >= f64::EPSILON,
82                CompareOp::Gt => a > b,
83                CompareOp::Gte => a >= b,
84                CompareOp::Lt => a < b,
85                CompareOp::Lte => a <= b,
86            },
87            (Value::Int(a), Value::Int(b)) => match self {
88                CompareOp::Eq => a == b,
89                CompareOp::Ne => a != b,
90                CompareOp::Gt => a > b,
91                CompareOp::Gte => a >= b,
92                CompareOp::Lt => a < b,
93                CompareOp::Lte => a <= b,
94            },
95            (Value::Bool(a), Value::Bool(b)) => match self {
96                CompareOp::Eq => a == b,
97                CompareOp::Ne => a != b,
98                _ => false,
99            },
100            (Value::String(a), Value::String(b)) => match self {
101                CompareOp::Eq => a == b,
102                CompareOp::Ne => a != b,
103                _ => false,
104            },
105            _ => false,
106        }
107    }
108}
109
110/// An action to execute when a rule fires
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub enum RuleAction {
113    /// Set a parameter to a specific value
114    Set { address: String, value: Value },
115    /// Publish an event
116    Publish {
117        address: String,
118        signal: SignalType,
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        value: Option<Value>,
121    },
122    /// Copy the trigger's value to another address, with optional transform
123    SetFromTrigger {
124        address: String,
125        #[serde(default)]
126        transform: Transform,
127    },
128    /// Delay before the next action in the sequence
129    Delay { milliseconds: u64 },
130}
131
132/// Value transformations for SetFromTrigger
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134pub enum Transform {
135    /// Pass through unchanged
136    #[default]
137    Identity,
138    /// Linear scale: output = input * scale + offset
139    Scale { scale: f64, offset: f64 },
140    /// Clamp to range
141    Clamp { min: f64, max: f64 },
142    /// Convert to boolean: true if input > threshold
143    Threshold { value: f64 },
144    /// Invert within range: output = max - (input - min)
145    Invert { min: f64, max: f64 },
146}
147
148impl Transform {
149    /// Apply the transform to a value
150    pub fn apply(&self, value: &Value) -> Value {
151        match self {
152            Transform::Identity => value.clone(),
153            Transform::Scale { scale, offset } => match value {
154                Value::Float(f) => Value::Float(f * scale + offset),
155                Value::Int(i) => Value::Float(*i as f64 * scale + offset),
156                _ => value.clone(),
157            },
158            Transform::Clamp { min, max } => match value {
159                Value::Float(f) => Value::Float(f.clamp(*min, *max)),
160                Value::Int(i) => Value::Float((*i as f64).clamp(*min, *max)),
161                _ => value.clone(),
162            },
163            Transform::Threshold { value: threshold } => match value {
164                Value::Float(f) => Value::Bool(*f > *threshold),
165                Value::Int(i) => Value::Bool(*i as f64 > *threshold),
166                _ => value.clone(),
167            },
168            Transform::Invert { min, max } => match value {
169                Value::Float(f) => Value::Float(max - (f - min)),
170                Value::Int(i) => Value::Float(max - (*i as f64 - min)),
171                _ => value.clone(),
172            },
173        }
174    }
175}
176
177impl Trigger {
178    /// Get the pattern this trigger listens to
179    pub fn pattern(&self) -> Option<&str> {
180        match self {
181            Trigger::OnChange { pattern } => Some(pattern),
182            Trigger::OnThreshold { address, .. } => Some(address),
183            Trigger::OnEvent { pattern } => Some(pattern),
184            Trigger::OnInterval { .. } => None,
185        }
186    }
187
188    /// Check if this trigger matches a given address and signal type
189    pub fn matches(&self, address: &str, signal_type: SignalType) -> bool {
190        match self {
191            Trigger::OnChange { pattern } => {
192                signal_type == SignalType::Param
193                    && clasp_core::address::glob_match(pattern, address)
194            }
195            Trigger::OnThreshold { address: addr, .. } => {
196                signal_type == SignalType::Param && addr == address
197            }
198            Trigger::OnEvent { pattern } => {
199                signal_type == SignalType::Event
200                    && clasp_core::address::glob_match(pattern, address)
201            }
202            Trigger::OnInterval { .. } => false,
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_transform_identity() {
213        let t = Transform::Identity;
214        assert_eq!(t.apply(&Value::Float(0.5)), Value::Float(0.5));
215    }
216
217    #[test]
218    fn test_transform_scale() {
219        let t = Transform::Scale {
220            scale: 2.0,
221            offset: 1.0,
222        };
223        assert_eq!(t.apply(&Value::Float(0.5)), Value::Float(2.0));
224    }
225
226    #[test]
227    fn test_transform_clamp() {
228        let t = Transform::Clamp { min: 0.0, max: 1.0 };
229        assert_eq!(t.apply(&Value::Float(1.5)), Value::Float(1.0));
230        assert_eq!(t.apply(&Value::Float(-0.5)), Value::Float(0.0));
231        assert_eq!(t.apply(&Value::Float(0.5)), Value::Float(0.5));
232    }
233
234    #[test]
235    fn test_transform_threshold() {
236        let t = Transform::Threshold { value: 0.5 };
237        assert_eq!(t.apply(&Value::Float(0.8)), Value::Bool(true));
238        assert_eq!(t.apply(&Value::Float(0.3)), Value::Bool(false));
239    }
240
241    #[test]
242    fn test_transform_invert() {
243        let t = Transform::Invert { min: 0.0, max: 1.0 };
244        assert_eq!(t.apply(&Value::Float(0.25)), Value::Float(0.75));
245        assert_eq!(t.apply(&Value::Float(0.0)), Value::Float(1.0));
246    }
247
248    #[test]
249    fn test_compare_op() {
250        assert!(CompareOp::Gt.evaluate(&Value::Float(1.0), &Value::Float(0.5)));
251        assert!(!CompareOp::Gt.evaluate(&Value::Float(0.5), &Value::Float(1.0)));
252        assert!(CompareOp::Eq.evaluate(&Value::Int(42), &Value::Int(42)));
253        assert!(CompareOp::Ne.evaluate(
254            &Value::String("a".to_string()),
255            &Value::String("b".to_string())
256        ));
257    }
258
259    #[test]
260    fn test_trigger_matches() {
261        let trigger = Trigger::OnChange {
262            pattern: "/lights/**".to_string(),
263        };
264        assert!(trigger.matches("/lights/room1/brightness", SignalType::Param));
265        assert!(!trigger.matches("/audio/volume", SignalType::Param));
266        assert!(!trigger.matches("/lights/room1/brightness", SignalType::Event));
267    }
268
269    #[test]
270    fn test_threshold_trigger_matches() {
271        let trigger = Trigger::OnThreshold {
272            address: "/sensor/temp".to_string(),
273            above: Some(30.0),
274            below: None,
275        };
276        assert!(trigger.matches("/sensor/temp", SignalType::Param));
277        assert!(!trigger.matches("/sensor/humidity", SignalType::Param));
278    }
279}