use std::collections::HashMap;
use tower::event::{Event, EventScope, Level};
use tower::simulate::{DebounceDecision, RenderedTrigger, SimulateOptions, simulate};
use tower::triggers::{AgentDispatchSpec, YubabaCommand};
use tower_rules::*;
fn svc(ident: &str, level: Level, target: &str, seq: u64) -> Event {
Event {
scope: EventScope::Service(MeshIdent(ident.into())),
level,
target: target.into(),
msg: String::new(),
fields: HashMap::new(),
seq,
}
}
fn health(signal: HealthSignal, seq: u64) -> Event {
Event::health(signal, seq)
}
fn notification_rule(id: &str) -> TowerRule {
TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId(id.into()),
name: format!("{id} rule"),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: Some(LevelFilter::Error),
target: None,
fields: vec![],
rate: None,
},
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Critical,
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
}
}
fn default_opts() -> SimulateOptions {
SimulateOptions::default()
}
#[test]
fn empty_events_returns_empty_report() {
let rule = notification_rule("empty");
let report = simulate(&rule, &[], &default_opts()).unwrap();
assert_eq!(report.total_events, 0);
assert_eq!(report.filter_matches, 0);
assert_eq!(report.would_fire, 0);
assert!(report.matches.is_empty());
}
#[test]
fn non_matching_events_produce_no_matches() {
let rule = notification_rule("no-match");
let events = [
svc("api.pdx", Level::Info, "request.ok", 1),
svc("api.pdx", Level::Warn, "request.slow", 2),
];
let report = simulate(&rule, &events, &default_opts()).unwrap();
assert_eq!(report.total_events, 2);
assert_eq!(report.filter_matches, 0);
assert_eq!(report.would_fire, 0);
}
#[test]
fn matching_events_all_fire_without_debounce() {
let rule = notification_rule("all-fire");
let events = [
svc("svc.a", Level::Error, "crash", 1),
svc("svc.b", Level::Error, "crash", 2),
svc("svc.c", Level::Error, "crash", 3),
];
let report = simulate(&rule, &events, &default_opts()).unwrap();
assert_eq!(report.total_events, 3);
assert_eq!(report.filter_matches, 3);
assert_eq!(report.would_fire, 3);
assert_eq!(report.would_suppress, 0);
for m in &report.matches {
assert_eq!(m.decision, DebounceDecision::WouldFire);
}
}
#[test]
fn mixed_matching_and_non_matching() {
let rule = notification_rule("mixed");
let events = [
svc("svc", Level::Info, "ok", 1),
svc("svc", Level::Error, "boom", 2),
svc("svc", Level::Warn, "slow", 3),
svc("svc", Level::Error, "crash", 4),
];
let report = simulate(&rule, &events, &default_opts()).unwrap();
assert_eq!(report.total_events, 4);
assert_eq!(report.filter_matches, 2);
assert_eq!(report.would_fire, 2);
}
#[test]
fn debounce_suppresses_events_within_window() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("debounce".into()),
name: "debounce rule".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
},
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Warning,
},
debounce_ms: Some(5_000),
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events = [
svc("svc", Level::Error, "e1", 1),
svc("svc", Level::Error, "e2", 2), svc("svc", Level::Error, "e3", 3), ];
let opts = SimulateOptions { event_spacing_ms: 1_000, context_window: 5 };
let report = simulate(&rule, &events, &opts).unwrap();
assert_eq!(report.filter_matches, 3);
assert_eq!(report.would_fire, 1);
assert_eq!(report.would_suppress, 2);
assert_eq!(report.matches[0].decision, DebounceDecision::WouldFire);
assert!(matches!(
report.matches[1].decision,
DebounceDecision::WouldSuppress { debounce_remaining_ms: 4_000 }
));
assert!(matches!(
report.matches[2].decision,
DebounceDecision::WouldSuppress { debounce_remaining_ms: 3_000 }
));
}
#[test]
fn debounce_fires_again_after_window_expires() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("debounce-expire".into()),
name: "debounce expire".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
},
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Info,
},
debounce_ms: Some(2_000),
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events = [
svc("svc", Level::Error, "e1", 1),
svc("svc", Level::Error, "e2", 2), svc("svc", Level::Error, "e3", 3), ];
let opts = SimulateOptions { event_spacing_ms: 3_000, context_window: 5 };
let report = simulate(&rule, &events, &opts).unwrap();
assert_eq!(report.would_fire, 3);
assert_eq!(report.would_suppress, 0);
}
#[test]
fn zero_spacing_causes_all_subsequent_to_suppress_with_any_debounce() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("zero-spacing".into()),
name: "zero spacing".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
},
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Info,
},
debounce_ms: Some(1),
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events = [
svc("svc", Level::Error, "e1", 1),
svc("svc", Level::Error, "e2", 2),
];
let opts = SimulateOptions { event_spacing_ms: 0, context_window: 5 };
let report = simulate(&rule, &events, &opts).unwrap();
assert_eq!(report.would_fire, 1);
assert_eq!(report.would_suppress, 1);
}
#[test]
fn rate_predicate_suppresses_until_threshold_met() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("rate".into()),
name: "rate rule".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: Some(RatePredicate { min_count: 3, window_ms: 10_000 }),
},
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Warning,
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events = [
svc("svc", Level::Error, "e1", 1),
svc("svc", Level::Error, "e2", 2),
svc("svc", Level::Error, "e3", 3), svc("svc", Level::Error, "e4", 4), ];
let opts = SimulateOptions { event_spacing_ms: 1_000, context_window: 5 };
let report = simulate(&rule, &events, &opts).unwrap();
assert_eq!(report.filter_matches, 4);
assert_eq!(report.would_fire, 2); assert_eq!(report.would_suppress, 2);
assert!(matches!(
report.matches[0].decision,
DebounceDecision::BelowRateThreshold { seen: 1, required: 3 }
));
assert!(matches!(
report.matches[1].decision,
DebounceDecision::BelowRateThreshold { seen: 2, required: 3 }
));
assert_eq!(report.matches[2].decision, DebounceDecision::WouldFire);
assert_eq!(report.matches[3].decision, DebounceDecision::WouldFire);
}
#[test]
fn rate_window_resets_after_expiry() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("rate-expire".into()),
name: "rate expire".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: Some(RatePredicate { min_count: 2, window_ms: 1_000 }),
},
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Info,
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events = [
svc("svc", Level::Error, "e1", 1),
svc("svc", Level::Error, "e2", 2), svc("svc", Level::Error, "e3", 3), ];
let opts = SimulateOptions { event_spacing_ms: 2_000, context_window: 5 };
let report = simulate(&rule, &events, &opts).unwrap();
assert_eq!(report.would_fire, 0);
assert_eq!(report.would_suppress, 3);
}
#[test]
fn notification_trigger_renders_event_json_and_scope() {
let rule = notification_rule("notif");
let events = [svc("api.pdx", Level::Error, "crash", 1)];
let report = simulate(&rule, &events, &default_opts()).unwrap();
let m = &report.matches[0];
assert_eq!(m.decision, DebounceDecision::WouldFire);
let RenderedTrigger::Notification(payload) = &m.rendered_trigger else {
panic!("expected Notification trigger");
};
assert_eq!(payload.matched_scope, "service:api.pdx");
assert!(payload.event_json.contains("crash"), "event json should contain target");
assert_eq!(payload.severity, Severity::Critical);
}
#[test]
fn agent_dispatch_trigger_renders_prompt_with_delimiters() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("agent".into()),
name: "agent rule".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
},
trigger: Trigger::AgentDispatch {
agent_class: AgentClassRef("gnome/fixer".into()),
prompt_template: PromptTemplate("Investigate: {{event}}".into()),
placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events = [svc("db.pdx", Level::Error, "db.crash", 1)];
let report = simulate(&rule, &events, &default_opts()).unwrap();
let RenderedTrigger::AgentDispatch(AgentDispatchSpec { agent_class, prompt, placement }) =
&report.matches[0].rendered_trigger
else {
panic!("expected AgentDispatch trigger");
};
assert_eq!(agent_class.0, "gnome/fixer");
assert_eq!(
*placement,
TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
);
assert!(prompt.contains("=== BEGIN TOWER MATCHED EVENT"));
assert!(prompt.contains("=== END TOWER MATCHED EVENT"));
assert!(prompt.contains("db.crash"), "event target should appear in prompt");
}
#[test]
fn yubaba_action_restart_renders_correct_command() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("yubaba".into()),
name: "yubaba rule".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
},
trigger: Trigger::YubabaAction {
kind: YubabaActionKind::RestartWorkload,
target: MeshIdent("api.pdx".into()),
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events = [svc("api.pdx", Level::Error, "oom", 1)];
let report = simulate(&rule, &events, &default_opts()).unwrap();
let RenderedTrigger::YubabaAction(cmd) = &report.matches[0].rendered_trigger else {
panic!("expected YubabaAction trigger");
};
assert!(matches!(cmd, YubabaCommand::RestartWorkload { target } if target.0 == "api.pdx"));
}
#[test]
fn yubaba_action_scale_renders_correct_command() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("scale".into()),
name: "scale rule".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
},
trigger: Trigger::YubabaAction {
kind: YubabaActionKind::Scale { replicas: 4 },
target: MeshIdent("worker.pdx".into()),
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events = [svc("worker.pdx", Level::Warn, "load_high", 1)];
let report = simulate(&rule, &events, &default_opts()).unwrap();
let RenderedTrigger::YubabaAction(cmd) = &report.matches[0].rendered_trigger else {
panic!("expected YubabaAction trigger");
};
assert!(matches!(
cmd,
YubabaCommand::Scale { target, replicas: 4 } if target.0 == "worker.pdx"
));
}
#[test]
fn first_match_has_empty_context() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("ctx".into()),
name: "ctx rule".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
},
trigger: Trigger::AgentDispatch {
agent_class: AgentClassRef("gnome/fixer".into()),
prompt_template: PromptTemplate("ctx: {{context}}".into()),
placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events = [svc("svc", Level::Error, "e1", 1)];
let report = simulate(&rule, &events, &default_opts()).unwrap();
let RenderedTrigger::AgentDispatch(AgentDispatchSpec { prompt, .. }) =
&report.matches[0].rendered_trigger
else {
panic!("expected AgentDispatch");
};
assert!(
prompt.contains("[]"),
"first match should have empty context: {prompt}"
);
}
#[test]
fn subsequent_matches_accumulate_context() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("ctx2".into()),
name: "ctx2 rule".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
},
trigger: Trigger::AgentDispatch {
agent_class: AgentClassRef("gnome/fixer".into()),
prompt_template: PromptTemplate("ctx: {{context}}".into()),
placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events = [
svc("svc", Level::Error, "e1", 1),
svc("svc", Level::Error, "e2", 2),
svc("svc", Level::Error, "e3", 3),
];
let report = simulate(&rule, &events, &default_opts()).unwrap();
assert_eq!(report.matches.len(), 3);
let RenderedTrigger::AgentDispatch(AgentDispatchSpec { prompt: p2, .. }) =
&report.matches[1].rendered_trigger
else {
panic!()
};
assert!(p2.contains("e1"), "second match should have e1 in context");
let RenderedTrigger::AgentDispatch(AgentDispatchSpec { prompt: p3, .. }) =
&report.matches[2].rendered_trigger
else {
panic!()
};
assert!(p3.contains("e1"), "third match should have e1 in context");
assert!(p3.contains("e2"), "third match should have e2 in context");
}
#[test]
fn context_ring_bounded_by_context_window() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("ctx-bound".into()),
name: "ctx bound rule".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
},
trigger: Trigger::AgentDispatch {
agent_class: AgentClassRef("gnome/fixer".into()),
prompt_template: PromptTemplate("ctx: {{context}}".into()),
placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let events: Vec<Event> =
(1..=5).map(|i| svc("svc", Level::Error, &format!("e{i}"), i)).collect();
let opts = SimulateOptions { event_spacing_ms: 1_000, context_window: 2 };
let report = simulate(&rule, &events, &opts).unwrap();
let RenderedTrigger::AgentDispatch(AgentDispatchSpec { prompt: p5, .. }) =
&report.matches[4].rendered_trigger
else {
panic!()
};
assert!(!p5.contains("\"e1\""), "e1 should be evicted from context ring");
assert!(!p5.contains("\"e2\""), "e2 should be evicted from context ring");
assert!(p5.contains("e3") || p5.contains("e4"), "recent events should be in context");
}
#[test]
fn yubaba_quorum_loss_fixture_matches_health_events() {
let path = format!(
"{}/tests/fixtures/yubaba-quorum-loss.yaml",
env!("CARGO_MANIFEST_DIR")
);
let src = std::fs::read_to_string(&path).unwrap();
let rule = tower::rules::parse::parse_rule_yaml(&src).unwrap();
let events = [
health(HealthSignal::YubabaRaftQuorumLost, 1),
health(HealthSignal::IngestionLag, 2), health(HealthSignal::YubabaRaftQuorumLost, 3),
];
let opts = SimulateOptions { event_spacing_ms: 35_000, context_window: 5 };
let report = simulate(&rule, &events, &opts).unwrap();
assert_eq!(report.total_events, 3);
assert_eq!(report.filter_matches, 2);
assert_eq!(report.would_fire, 2);
}
#[test]
fn compile_error_returned_for_invalid_predicate() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("bad".into()),
name: "bad rule".into(),
predicate: Predicate::Compound {
op: CompoundOp::Not,
children: vec![
Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![],
rate: None,
},
Predicate::EventMatch {
scope: ScopeFilter::Any,
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 result = simulate(&rule, &[], &default_opts());
assert!(result.is_err(), "should fail with compile error for Not with 2 children");
}