yah-tower 0.8.20

yah-tower — rule-driven monitor on scryer. Rule parser/compiler, ScryerFilter, and dispatch engine.
Documentation
//! Rule compiler: `TowerRule` predicate → `ScryerFilter`.
//!
//! The compiler translates the structured `Predicate` tree into the
//! `ScryerFilter` shape that is handed to `scryer.subscribe`. Predicates are
//! pushed down to where events are produced; compound predicates are preserved
//! as boolean nodes that scryer evaluates server-side.

use tower_rules::{CompoundOp, FederationPolicy, Predicate, TowerRule};

use crate::scryer_filter::{FieldSet, ScryerFilter};

/// Error produced when a predicate cannot be compiled into a valid scryer filter.
///
/// Mirrors the `ShapeError` pattern from `workload-spec/src/validate.rs`:
/// each error is field-pathed so the authoring UI can highlight exactly where
/// the problem is.
#[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() }
    }
}

/// Compile a `TowerRule` into a `ScryerFilter` ready for `scryer.subscribe`.
///
/// The rule's `federation` policy is threaded into every `Fields` leaf so
/// scryer can fan out to the right peers when opening the subscription.
///
/// Returns `Err(CompileError)` for structurally invalid predicates — e.g. a
/// `Not` compound with more than one child. Field-level syntax errors (bad
/// regex patterns) are also surfaced here rather than deferred to runtime.
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 } => {
            // Validate regex patterns eagerly so compile-time errors are
            // surfaced before the rule is loaded into the supervisor.
            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(),
                ))),
            }
        }
    }
}

// ── validation helpers ────────────────────────────────────────────────────────

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();
        // Error path should be prefixed with the compound child index
        assert!(err.to_string().contains("children[0]"));
    }
}