1use tower_rules::{CompoundOp, FederationPolicy, Predicate, TowerRule};
9
10use crate::scryer_filter::{FieldSet, ScryerFilter};
11
12#[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
29pub 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 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
111use 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 assert!(err.to_string().contains("children[0]"));
314 }
315}