use tower_rules::{CompoundOp, FederationPolicy, Predicate, TowerRule};
use crate::scryer_filter::{FieldSet, ScryerFilter};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum CompileError {
#[error("field {path}: {reason}")]
Field { path: String, reason: String },
}
impl CompileError {
fn field(path: impl Into<String>, reason: impl Into<String>) -> Self {
CompileError::Field { path: path.into(), reason: reason.into() }
}
}
pub fn compile(rule: &TowerRule) -> Result<ScryerFilter, CompileError> {
compile_predicate(&rule.predicate, &rule.federation)
}
fn compile_predicate(
pred: &Predicate,
federation: &FederationPolicy,
) -> Result<ScryerFilter, CompileError> {
match pred {
Predicate::EventMatch { scope, level, target, fields, rate } => {
if let Some(t) = target {
validate_string_filter(t, "predicate.target")?;
}
for (i, ff) in fields.iter().enumerate() {
validate_field_filter(ff, &format!("predicate.fields[{i}]"))?;
}
Ok(ScryerFilter::Fields(FieldSet {
scope: scope.clone(),
level: *level,
target: target.clone(),
fields: fields.clone(),
rate: rate.clone(),
federation: federation.clone(),
}))
}
Predicate::ScryerHealth { signal } => Ok(ScryerFilter::HealthSignal(*signal)),
Predicate::Compound { op, children } => {
if children.is_empty() {
return Err(CompileError::field(
"predicate.children",
"Compound predicate must have at least one child",
));
}
if *op == CompoundOp::Not && children.len() != 1 {
return Err(CompileError::field(
"predicate.children",
format!(
"Not requires exactly 1 child, got {}",
children.len()
),
));
}
let compiled: Result<Vec<_>, _> = children
.iter()
.enumerate()
.map(|(i, child)| {
compile_predicate(child, federation).map_err(|e| match e {
CompileError::Field { path, reason } => CompileError::Field {
path: format!("predicate.children[{i}].{path}"),
reason,
},
})
})
.collect();
let compiled = compiled?;
match op {
CompoundOp::And => Ok(ScryerFilter::And(compiled)),
CompoundOp::Or => Ok(ScryerFilter::Or(compiled)),
CompoundOp::Not => Ok(ScryerFilter::Not(Box::new(
compiled.into_iter().next().unwrap(),
))),
}
}
}
}
use tower_rules::{FieldFilter, FieldOp, StringFilter};
fn validate_string_filter(sf: &StringFilter, path: &str) -> Result<(), CompileError> {
match sf {
StringFilter::Regex { pattern } => {
regex::Regex::new(pattern).map_err(|e| {
CompileError::field(path, format!("invalid regex: {e}"))
})?;
}
StringFilter::Glob { .. } | StringFilter::Exact { .. } => {}
}
Ok(())
}
fn validate_field_filter(ff: &FieldFilter, path: &str) -> Result<(), CompileError> {
if ff.path.is_empty() {
return Err(CompileError::field(
format!("{path}.path"),
"field path must not be empty",
));
}
if let FieldOp::Matches { filter } = &ff.op {
validate_string_filter(filter, &format!("{path}.op.filter"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use tower_rules::*;
use super::*;
use crate::scryer_filter::ScryerFilter;
fn local_rule(predicate: Predicate) -> TowerRule {
TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("test".into()),
name: "test rule".into(),
predicate,
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Info,
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
}
}
#[test]
fn compile_event_match_any_scope() {
let rule = local_rule(Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
});
let filter = compile(&rule).unwrap();
assert!(matches!(filter, ScryerFilter::Fields(_)));
}
#[test]
fn compile_event_match_with_level_and_target() {
let rule = local_rule(Predicate::EventMatch {
scope: ScopeFilter::Service { ident: Some(MeshIdent("yubaba.local".into())) },
level: Some(LevelFilter::Error),
target: Some(StringFilter::Glob { pattern: "raft.*".into() }),
fields: vec![],
rate: None,
});
let filter = compile(&rule).unwrap();
assert!(matches!(filter, ScryerFilter::Fields(_)));
}
#[test]
fn compile_scryer_health() {
let rule = local_rule(Predicate::ScryerHealth {
signal: HealthSignal::YubabaRaftQuorumLost,
});
let filter = compile(&rule).unwrap();
assert!(
matches!(filter, ScryerFilter::HealthSignal(HealthSignal::YubabaRaftQuorumLost))
);
}
#[test]
fn compile_compound_and() {
let rule = local_rule(Predicate::Compound {
op: CompoundOp::And,
children: vec![
Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
Predicate::EventMatch {
scope: ScopeFilter::Any,
level: Some(LevelFilter::Warn),
target: None,
fields: vec![],
rate: None,
},
],
});
let filter = compile(&rule).unwrap();
assert!(matches!(filter, ScryerFilter::And(_)));
}
#[test]
fn compile_compound_or() {
let rule = local_rule(Predicate::Compound {
op: CompoundOp::Or,
children: vec![
Predicate::ScryerHealth { signal: HealthSignal::RingOverflow },
Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
],
});
let filter = compile(&rule).unwrap();
assert!(matches!(filter, ScryerFilter::Or(_)));
}
#[test]
fn compile_compound_not_single_child() {
let rule = local_rule(Predicate::Compound {
op: CompoundOp::Not,
children: vec![Predicate::EventMatch {
scope: ScopeFilter::Forge { id: None },
level: None,
target: None,
fields: vec![],
rate: None,
}],
});
let filter = compile(&rule).unwrap();
assert!(matches!(filter, ScryerFilter::Not(_)));
}
#[test]
fn compile_compound_not_too_many_children_is_error() {
let rule = local_rule(Predicate::Compound {
op: CompoundOp::Not,
children: vec![
Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
Predicate::ScryerHealth { signal: HealthSignal::RingOverflow },
],
});
let err = compile(&rule).unwrap_err();
assert!(err.to_string().contains("Not requires exactly 1 child"));
}
#[test]
fn compile_compound_empty_children_is_error() {
let rule = local_rule(Predicate::Compound {
op: CompoundOp::And,
children: vec![],
});
let err = compile(&rule).unwrap_err();
assert!(err.to_string().contains("at least one child"));
}
#[test]
fn compile_invalid_regex_is_error() {
let rule = local_rule(Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: Some(StringFilter::Regex { pattern: "[invalid".into() }),
fields: vec![],
rate: None,
});
let err = compile(&rule).unwrap_err();
assert!(err.to_string().contains("invalid regex"));
}
#[test]
fn compile_empty_field_path_is_error() {
let rule = local_rule(Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![FieldFilter {
path: "".into(),
op: FieldOp::Exists,
}],
rate: None,
});
let err = compile(&rule).unwrap_err();
assert!(err.to_string().contains("field path must not be empty"));
}
#[test]
fn compile_nested_compound_field_path_prefixed() {
let rule = local_rule(Predicate::Compound {
op: CompoundOp::And,
children: vec![Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: Some(StringFilter::Regex { pattern: "[bad".into() }),
fields: vec![],
rate: None,
}],
});
let err = compile(&rule).unwrap_err();
assert!(err.to_string().contains("children[0]"));
}
}