Skip to main content

tower/rules/
compiler.rs

1//! Rule compiler: `TowerRule` predicate → `ScryerFilter`.
2//!
3//! The compiler translates the structured `Predicate` tree into the
4//! `ScryerFilter` shape that is handed to `scryer.subscribe`. Predicates are
5//! pushed down to where events are produced; compound predicates are preserved
6//! as boolean nodes that scryer evaluates server-side.
7
8use tower_rules::{CompoundOp, FederationPolicy, Predicate, TowerRule};
9
10use crate::scryer_filter::{FieldSet, ScryerFilter};
11
12/// Error produced when a predicate cannot be compiled into a valid scryer filter.
13///
14/// Mirrors the `ShapeError` pattern from `workload-spec/src/validate.rs`:
15/// each error is field-pathed so the authoring UI can highlight exactly where
16/// the problem is.
17#[derive(Debug, Clone, PartialEq, thiserror::Error)]
18pub enum CompileError {
19    #[error("field {path}: {reason}")]
20    Field { path: String, reason: String },
21}
22
23impl CompileError {
24    fn field(path: impl Into<String>, reason: impl Into<String>) -> Self {
25        CompileError::Field { path: path.into(), reason: reason.into() }
26    }
27}
28
29/// Compile a `TowerRule` into a `ScryerFilter` ready for `scryer.subscribe`.
30///
31/// The rule's `federation` policy is threaded into every `Fields` leaf so
32/// scryer can fan out to the right peers when opening the subscription.
33///
34/// Returns `Err(CompileError)` for structurally invalid predicates — e.g. a
35/// `Not` compound with more than one child. Field-level syntax errors (bad
36/// regex patterns) are also surfaced here rather than deferred to runtime.
37pub fn compile(rule: &TowerRule) -> Result<ScryerFilter, CompileError> {
38    compile_predicate(&rule.predicate, &rule.federation)
39}
40
41fn compile_predicate(
42    pred: &Predicate,
43    federation: &FederationPolicy,
44) -> Result<ScryerFilter, CompileError> {
45    match pred {
46        Predicate::EventMatch { scope, level, target, fields, rate } => {
47            // Validate regex patterns eagerly so compile-time errors are
48            // surfaced before the rule is loaded into the supervisor.
49            if let Some(t) = target {
50                validate_string_filter(t, "predicate.target")?;
51            }
52            for (i, ff) in fields.iter().enumerate() {
53                validate_field_filter(ff, &format!("predicate.fields[{i}]"))?;
54            }
55
56            Ok(ScryerFilter::Fields(FieldSet {
57                scope: scope.clone(),
58                level: *level,
59                target: target.clone(),
60                fields: fields.clone(),
61                rate: rate.clone(),
62                federation: federation.clone(),
63            }))
64        }
65
66        Predicate::ScryerHealth { signal } => Ok(ScryerFilter::HealthSignal(*signal)),
67
68        Predicate::Compound { op, children } => {
69            if children.is_empty() {
70                return Err(CompileError::field(
71                    "predicate.children",
72                    "Compound predicate must have at least one child",
73                ));
74            }
75            if *op == CompoundOp::Not && children.len() != 1 {
76                return Err(CompileError::field(
77                    "predicate.children",
78                    format!(
79                        "Not requires exactly 1 child, got {}",
80                        children.len()
81                    ),
82                ));
83            }
84
85            let compiled: Result<Vec<_>, _> = children
86                .iter()
87                .enumerate()
88                .map(|(i, child)| {
89                    compile_predicate(child, federation).map_err(|e| match e {
90                        CompileError::Field { path, reason } => CompileError::Field {
91                            path: format!("predicate.children[{i}].{path}"),
92                            reason,
93                        },
94                    })
95                })
96                .collect();
97
98            let compiled = compiled?;
99
100            match op {
101                CompoundOp::And => Ok(ScryerFilter::And(compiled)),
102                CompoundOp::Or => Ok(ScryerFilter::Or(compiled)),
103                CompoundOp::Not => Ok(ScryerFilter::Not(Box::new(
104                    compiled.into_iter().next().unwrap(),
105                ))),
106            }
107        }
108    }
109}
110
111// ── validation helpers ────────────────────────────────────────────────────────
112
113use tower_rules::{FieldFilter, FieldOp, StringFilter};
114
115fn validate_string_filter(sf: &StringFilter, path: &str) -> Result<(), CompileError> {
116    match sf {
117        StringFilter::Regex { pattern } => {
118            regex::Regex::new(pattern).map_err(|e| {
119                CompileError::field(path, format!("invalid regex: {e}"))
120            })?;
121        }
122        StringFilter::Glob { .. } | StringFilter::Exact { .. } => {}
123    }
124    Ok(())
125}
126
127fn validate_field_filter(ff: &FieldFilter, path: &str) -> Result<(), CompileError> {
128    if ff.path.is_empty() {
129        return Err(CompileError::field(
130            format!("{path}.path"),
131            "field path must not be empty",
132        ));
133    }
134    if let FieldOp::Matches { filter } = &ff.op {
135        validate_string_filter(filter, &format!("{path}.op.filter"))?;
136    }
137    Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142    use tower_rules::*;
143    use super::*;
144    use crate::scryer_filter::ScryerFilter;
145
146    fn local_rule(predicate: Predicate) -> TowerRule {
147        TowerRule {
148            schema_version: SchemaVersion::V1,
149            id: RuleId("test".into()),
150            name: "test rule".into(),
151            predicate,
152            trigger: Trigger::Notification {
153                channels: vec![NotificationChannel::DesktopBadge],
154                severity: Severity::Info,
155            },
156            debounce_ms: None,
157            federation: FederationPolicy::LocalOnly,
158            enabled: true,
159        }
160    }
161
162    #[test]
163    fn compile_event_match_any_scope() {
164        let rule = local_rule(Predicate::EventMatch {
165            scope: ScopeFilter::Any,
166            level: None,
167            target: None,
168            fields: vec![],
169            rate: None,
170        });
171        let filter = compile(&rule).unwrap();
172        assert!(matches!(filter, ScryerFilter::Fields(_)));
173    }
174
175    #[test]
176    fn compile_event_match_with_level_and_target() {
177        let rule = local_rule(Predicate::EventMatch {
178            scope: ScopeFilter::Service { ident: Some(MeshIdent("yubaba.local".into())) },
179            level: Some(LevelFilter::Error),
180            target: Some(StringFilter::Glob { pattern: "raft.*".into() }),
181            fields: vec![],
182            rate: None,
183        });
184        let filter = compile(&rule).unwrap();
185        assert!(matches!(filter, ScryerFilter::Fields(_)));
186    }
187
188    #[test]
189    fn compile_scryer_health() {
190        let rule = local_rule(Predicate::ScryerHealth {
191            signal: HealthSignal::YubabaRaftQuorumLost,
192        });
193        let filter = compile(&rule).unwrap();
194        assert!(
195            matches!(filter, ScryerFilter::HealthSignal(HealthSignal::YubabaRaftQuorumLost))
196        );
197    }
198
199    #[test]
200    fn compile_compound_and() {
201        let rule = local_rule(Predicate::Compound {
202            op: CompoundOp::And,
203            children: vec![
204                Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
205                Predicate::EventMatch {
206                    scope: ScopeFilter::Any,
207                    level: Some(LevelFilter::Warn),
208                    target: None,
209                    fields: vec![],
210                    rate: None,
211                },
212            ],
213        });
214        let filter = compile(&rule).unwrap();
215        assert!(matches!(filter, ScryerFilter::And(_)));
216    }
217
218    #[test]
219    fn compile_compound_or() {
220        let rule = local_rule(Predicate::Compound {
221            op: CompoundOp::Or,
222            children: vec![
223                Predicate::ScryerHealth { signal: HealthSignal::RingOverflow },
224                Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
225            ],
226        });
227        let filter = compile(&rule).unwrap();
228        assert!(matches!(filter, ScryerFilter::Or(_)));
229    }
230
231    #[test]
232    fn compile_compound_not_single_child() {
233        let rule = local_rule(Predicate::Compound {
234            op: CompoundOp::Not,
235            children: vec![Predicate::EventMatch {
236                scope: ScopeFilter::Forge { id: None },
237                level: None,
238                target: None,
239                fields: vec![],
240                rate: None,
241            }],
242        });
243        let filter = compile(&rule).unwrap();
244        assert!(matches!(filter, ScryerFilter::Not(_)));
245    }
246
247    #[test]
248    fn compile_compound_not_too_many_children_is_error() {
249        let rule = local_rule(Predicate::Compound {
250            op: CompoundOp::Not,
251            children: vec![
252                Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
253                Predicate::ScryerHealth { signal: HealthSignal::RingOverflow },
254            ],
255        });
256        let err = compile(&rule).unwrap_err();
257        assert!(err.to_string().contains("Not requires exactly 1 child"));
258    }
259
260    #[test]
261    fn compile_compound_empty_children_is_error() {
262        let rule = local_rule(Predicate::Compound {
263            op: CompoundOp::And,
264            children: vec![],
265        });
266        let err = compile(&rule).unwrap_err();
267        assert!(err.to_string().contains("at least one child"));
268    }
269
270    #[test]
271    fn compile_invalid_regex_is_error() {
272        let rule = local_rule(Predicate::EventMatch {
273            scope: ScopeFilter::Any,
274            level: None,
275            target: Some(StringFilter::Regex { pattern: "[invalid".into() }),
276            fields: vec![],
277            rate: None,
278        });
279        let err = compile(&rule).unwrap_err();
280        assert!(err.to_string().contains("invalid regex"));
281    }
282
283    #[test]
284    fn compile_empty_field_path_is_error() {
285        let rule = local_rule(Predicate::EventMatch {
286            scope: ScopeFilter::Any,
287            level: None,
288            target: None,
289            fields: vec![FieldFilter {
290                path: "".into(),
291                op: FieldOp::Exists,
292            }],
293            rate: None,
294        });
295        let err = compile(&rule).unwrap_err();
296        assert!(err.to_string().contains("field path must not be empty"));
297    }
298
299    #[test]
300    fn compile_nested_compound_field_path_prefixed() {
301        let rule = local_rule(Predicate::Compound {
302            op: CompoundOp::And,
303            children: vec![Predicate::EventMatch {
304                scope: ScopeFilter::Any,
305                level: None,
306                target: Some(StringFilter::Regex { pattern: "[bad".into() }),
307                fields: vec![],
308                rate: None,
309            }],
310        });
311        let err = compile(&rule).unwrap_err();
312        // Error path should be prefixed with the compound child index
313        assert!(err.to_string().contains("children[0]"));
314    }
315}