yah-tower 0.8.20

yah-tower — rule-driven monitor on scryer. Rule parser/compiler, ScryerFilter, and dispatch engine.
Documentation
//! Round-trip tests: parse YAML fixture → compile → match against events.
//!
//! Each scenario: one rule file from tests/fixtures/, a set of probe events,
//! and an expected match count. Rate predicates are NOT tested here (rate
//! requires the supervisor's sliding window; tested in F3).

use std::collections::HashMap;

use tower::event::{Event, EventScope, Level};
use tower::rules::{compiler, parse};
use tower_rules::*;

fn parse_fixture(name: &str) -> TowerRule {
    let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
    let src = std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("could not read fixture {path}: {e}"));
    parse::parse_rule_yaml(&src)
        .unwrap_or_else(|e| panic!("could not parse fixture {name}: {e}"))
}

fn health_event(signal: HealthSignal, seq: u64) -> Event {
    Event::health(signal, seq)
}

fn service_event(
    ident: &str,
    level: Level,
    target: &str,
    fields: HashMap<String, serde_json::Value>,
    seq: u64,
) -> Event {
    Event {
        scope: EventScope::Service(MeshIdent(ident.into())),
        level,
        target: target.into(),
        msg: String::new(),
        fields,
        seq,
    }
}

// ── yubaba-quorum-loss fixture ────────────────────────────────────────────────

#[test]
fn yubaba_quorum_loss_matches_quorum_signal() {
    let rule = parse_fixture("yubaba-quorum-loss.yaml");
    let filter = compiler::compile(&rule).unwrap();

    let events = [
        health_event(HealthSignal::YubabaRaftQuorumLost, 1),  // matches scryer_health arm
        health_event(HealthSignal::YubabaRaftQuorumLost, 2),  // matches scryer_health arm
        health_event(HealthSignal::IngestionLag, 3),           // wrong signal — no match
        service_event("yubaba.local", Level::Error, "raft.quorum_lost", HashMap::new(), 4), // matches event_match arm
    ];

    let matches: Vec<_> = events.iter().filter(|e| filter.matches(e)).collect();
    // 2 scryer_health + 1 event_match = 3 (fixture uses Compound(Or)).
    assert_eq!(matches.len(), 3, "should match 2 health events + 1 service event via Compound(Or)");
}

#[test]
fn yubaba_quorum_loss_does_not_match_service_events() {
    let rule = parse_fixture("yubaba-quorum-loss.yaml");
    let filter = compiler::compile(&rule).unwrap();

    let non_health_events = [
        service_event("yubaba.local", Level::Error, "raft.term_change", HashMap::new(), 1),
        service_event("yubaba.local", Level::Warn, "mesh.peer_timeout", HashMap::new(), 2),
    ];

    let matches: Vec<_> = non_health_events.iter().filter(|e| filter.matches(e)).collect();
    assert_eq!(matches.len(), 0);
}

// ── db-error-burst fixture ────────────────────────────────────────────────────

#[test]
fn db_error_burst_matches_correct_service_and_level() {
    let rule = parse_fixture("db-error-burst.yaml");
    let filter = compiler::compile(&rule).unwrap();

    let events = [
        // match: correct service, error level, database.* target
        service_event("noisetable-db.pdx", Level::Error, "database.query_slow", HashMap::new(), 1),
        service_event("noisetable-db.pdx", Level::Error, "database.connection_failed", HashMap::new(), 2),
        // no match: wrong service
        service_event("noisetable-api.pdx", Level::Error, "database.query_slow", HashMap::new(), 3),
        // no match: level below error
        service_event("noisetable-db.pdx", Level::Warn, "database.query_slow", HashMap::new(), 4),
        // no match: target doesn't match database.*
        service_event("noisetable-db.pdx", Level::Error, "redis.timeout", HashMap::new(), 5),
    ];

    let matches: Vec<_> = events.iter().filter(|e| filter.matches(e)).collect();
    assert_eq!(matches.len(), 2, "expected 2 matches");
}

#[test]
fn db_error_burst_rate_predicate_stored_but_not_blocking() {
    // Rate predicates in the fixture don't block individual event matches —
    // the supervisor (F3) evaluates the sliding window. Individual events still
    // match the non-rate constraints.
    let rule = parse_fixture("db-error-burst.yaml");
    let filter = compiler::compile(&rule).unwrap();

    let event = service_event(
        "noisetable-db.pdx",
        Level::Error,
        "database.query_slow",
        HashMap::new(),
        1,
    );
    // Individual event matches (rate gate is supervisor's job, not filter's)
    assert!(filter.matches(&event));
}

// ── compound-or fixture ───────────────────────────────────────────────────────

#[test]
fn compound_or_matches_either_signal() {
    let rule = parse_fixture("compound-or.yaml");
    let filter = compiler::compile(&rule).unwrap();

    let events = [
        health_event(HealthSignal::YubabaRaftQuorumLost, 1),
        health_event(HealthSignal::RingOverflow, 2),
        health_event(HealthSignal::IngestionLag, 3),         // not in the OR
        health_event(HealthSignal::FederationTimeout, 4),    // not in the OR
    ];

    let matches: Vec<_> = events.iter().filter(|e| filter.matches(e)).collect();
    assert_eq!(matches.len(), 2);
}

// ── field filter matching ─────────────────────────────────────────────────────

#[test]
fn event_match_with_field_filter_eq() {
    let rule = TowerRule {
        schema_version: SchemaVersion::V1,
        id: RuleId("field-eq".into()),
        name: "test".into(),
        predicate: Predicate::EventMatch {
            scope: ScopeFilter::Any,
            level: None,
            target: None,
            fields: vec![FieldFilter {
                path: "error.code".into(),
                op: FieldOp::Eq { value: serde_json::json!("PG-23505") },
            }],
            rate: None,
        },
        trigger: Trigger::Notification {
            channels: vec![NotificationChannel::DesktopBadge],
            severity: Severity::Warning,
        },
        debounce_ms: None,
        federation: FederationPolicy::LocalOnly,
        enabled: true,
    };

    let filter = compiler::compile(&rule).unwrap();

    let matching = Event {
        scope: EventScope::Service(MeshIdent("db.pdx".into())),
        level: Level::Error,
        target: "database.constraint".into(),
        msg: String::new(),
        fields: {
            let mut m = HashMap::new();
            m.insert("error".into(), serde_json::json!({"code": "PG-23505"}));
            m
        },
        seq: 1,
    };
    let non_matching = Event {
        scope: EventScope::Service(MeshIdent("db.pdx".into())),
        level: Level::Error,
        target: "database.constraint".into(),
        msg: String::new(),
        fields: {
            let mut m = HashMap::new();
            m.insert("error".into(), serde_json::json!({"code": "PG-23514"}));
            m
        },
        seq: 2,
    };

    assert!(filter.matches(&matching));
    assert!(!filter.matches(&non_matching));
}

#[test]
fn event_match_field_exists() {
    let rule = TowerRule {
        schema_version: SchemaVersion::V1,
        id: RuleId("field-exists".into()),
        name: "test".into(),
        predicate: Predicate::EventMatch {
            scope: ScopeFilter::Any,
            level: None,
            target: None,
            fields: vec![FieldFilter {
                path: "stack_trace".into(),
                op: FieldOp::Exists,
            }],
            rate: None,
        },
        trigger: Trigger::Notification {
            channels: vec![NotificationChannel::DesktopBadge],
            severity: Severity::Warning,
        },
        debounce_ms: None,
        federation: FederationPolicy::LocalOnly,
        enabled: true,
    };

    let filter = compiler::compile(&rule).unwrap();

    let with_trace = Event {
        scope: EventScope::Service(MeshIdent("api.pdx".into())),
        level: Level::Error,
        target: "service.panic".into(),
        msg: String::new(),
        fields: {
            let mut m = HashMap::new();
            m.insert("stack_trace".into(), serde_json::json!("at line 42..."));
            m
        },
        seq: 1,
    };
    let without_trace = Event {
        scope: EventScope::Service(MeshIdent("api.pdx".into())),
        level: Level::Error,
        target: "service.error".into(),
        msg: String::new(),
        fields: HashMap::new(),
        seq: 2,
    };

    assert!(filter.matches(&with_trace));
    assert!(!filter.matches(&without_trace));
}

// ── not predicate matching ────────────────────────────────────────────────────

#[test]
fn not_predicate_inverts_match() {
    let rule = TowerRule {
        schema_version: SchemaVersion::V1,
        id: RuleId("not-forge".into()),
        name: "test".into(),
        predicate: Predicate::Compound {
            op: CompoundOp::Not,
            children: vec![Predicate::EventMatch {
                scope: ScopeFilter::Forge { id: None },
                level: None,
                target: None,
                fields: vec![],
                rate: None,
            }],
        },
        trigger: Trigger::Notification {
            channels: vec![NotificationChannel::DesktopBadge],
            severity: Severity::Info,
        },
        debounce_ms: None,
        federation: FederationPolicy::LocalOnly,
        enabled: true,
    };

    let filter = compiler::compile(&rule).unwrap();

    let service_ev = Event {
        scope: EventScope::Service(MeshIdent("api.pdx".into())),
        level: Level::Info,
        target: "service.start".into(),
        msg: String::new(),
        fields: HashMap::new(),
        seq: 1,
    };
    let forge_ev = Event {
        scope: EventScope::Forge(tower_rules::ForgeId("run-123".into())),
        level: Level::Info,
        target: "forge.start".into(),
        msg: String::new(),
        fields: HashMap::new(),
        seq: 2,
    };

    assert!(filter.matches(&service_ev), "service event should match NOT(forge)");
    assert!(!filter.matches(&forge_ev), "forge event should NOT match NOT(forge)");
}