use tower_rules::{CompoundOp, FederationPolicy, Predicate, RuleId, Trigger, TowerRule};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum RuleError {
#[error("field {path}: {reason}")]
Field { path: String, reason: String },
}
impl RuleError {
fn field(path: impl Into<String>, reason: impl Into<String>) -> Self {
RuleError::Field { path: path.into(), reason: reason.into() }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RuleWarning {
pub path: String,
pub message: String,
}
impl RuleWarning {
fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
RuleWarning { path: path.into(), message: message.into() }
}
}
pub fn validate(rule: &TowerRule) -> Result<Vec<RuleWarning>, RuleError> {
let mut warnings = Vec::new();
validate_id(&rule.id)?;
validate_predicate(&rule.predicate, "predicate")?;
validate_trigger(&rule.trigger, &mut warnings)?;
validate_federation_vs_predicate(rule, &mut warnings);
Ok(warnings)
}
fn validate_id(id: &RuleId) -> Result<(), RuleError> {
let s = &id.0;
if s.is_empty() {
return Err(RuleError::field("id", "rule ID must not be empty"));
}
if !s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return Err(RuleError::field(
"id",
"rule ID must be kebab-case (lowercase alphanumeric and hyphens only)",
));
}
if s.starts_with('-') || s.ends_with('-') {
return Err(RuleError::field(
"id",
"rule ID must not start or end with a hyphen",
));
}
Ok(())
}
fn validate_predicate(pred: &Predicate, path: &str) -> Result<(), RuleError> {
match pred {
Predicate::EventMatch { fields, .. } => {
for (i, ff) in fields.iter().enumerate() {
if ff.path.is_empty() {
return Err(RuleError::field(
format!("{path}.fields[{i}].path"),
"field path must not be empty",
));
}
}
}
Predicate::ScryerHealth { .. } => {}
Predicate::Compound { op, children } => {
if children.is_empty() {
return Err(RuleError::field(
format!("{path}.children"),
"Compound predicate must have at least one child",
));
}
if *op == CompoundOp::Not && children.len() != 1 {
return Err(RuleError::field(
format!("{path}.children"),
format!("Not requires exactly 1 child, got {}", children.len()),
));
}
for (i, child) in children.iter().enumerate() {
validate_predicate(child, &format!("{path}.children[{i}]"))?;
}
}
}
Ok(())
}
fn validate_trigger(trigger: &Trigger, warnings: &mut Vec<RuleWarning>) -> Result<(), RuleError> {
match trigger {
Trigger::AgentDispatch { agent_class, prompt_template, .. } => {
if agent_class.0.is_empty() {
return Err(RuleError::field(
"trigger.agent_class",
"agent class ref must not be empty",
));
}
if prompt_template.0.is_empty() {
return Err(RuleError::field(
"trigger.prompt_template",
"prompt template must not be empty",
));
}
if !prompt_template.0.contains("{{event}}") && !prompt_template.0.contains("{{events}}") {
warnings.push(RuleWarning::new(
"trigger.prompt_template",
"prompt template does not reference {{event}} or {{events}}; agent will run without event context",
));
}
}
Trigger::Notification { channels, .. } => {
if channels.is_empty() {
return Err(RuleError::field(
"trigger.channels",
"at least one notification channel must be specified",
));
}
}
Trigger::YubabaAction { target, .. } => {
if target.0.is_empty() {
return Err(RuleError::field(
"trigger.target",
"yubaba action target must not be empty",
));
}
}
}
Ok(())
}
fn validate_federation_vs_predicate(rule: &TowerRule, warnings: &mut Vec<RuleWarning>) {
if !matches!(rule.federation, FederationPolicy::LocalOnly) {
if matches!(rule.predicate, Predicate::ScryerHealth { .. }) {
warnings.push(RuleWarning::new(
"federation",
"ScryerHealth predicates watch per-machine scryer; federation is unlikely to be useful here",
));
}
}
}
#[cfg(test)]
mod tests {
use tower_rules::*;
use super::*;
fn minimal_rule(predicate: Predicate) -> TowerRule {
TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("test-rule".into()),
name: "test".into(),
predicate,
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Info,
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
}
}
#[test]
fn valid_rule_no_warnings() {
let rule = minimal_rule(Predicate::ScryerHealth {
signal: HealthSignal::YubabaRaftQuorumLost,
});
let warnings = validate(&rule).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn empty_id_is_error() {
let mut rule = minimal_rule(Predicate::ScryerHealth {
signal: HealthSignal::IngestionLag,
});
rule.id = RuleId("".into());
let err = validate(&rule).unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
#[test]
fn uppercase_id_is_error() {
let mut rule = minimal_rule(Predicate::ScryerHealth {
signal: HealthSignal::IngestionLag,
});
rule.id = RuleId("MyRule".into());
let err = validate(&rule).unwrap_err();
assert!(err.to_string().contains("kebab-case"));
}
#[test]
fn leading_hyphen_id_is_error() {
let mut rule = minimal_rule(Predicate::ScryerHealth {
signal: HealthSignal::IngestionLag,
});
rule.id = RuleId("-rule".into());
let err = validate(&rule).unwrap_err();
assert!(err.to_string().contains("must not start or end with a hyphen"));
}
#[test]
fn empty_notification_channels_is_error() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("test-rule".into()),
name: "test".into(),
predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
trigger: Trigger::Notification {
channels: vec![],
severity: Severity::Info,
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let err = validate(&rule).unwrap_err();
assert!(err.to_string().contains("at least one notification channel"));
}
#[test]
fn empty_agent_class_is_error() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("dispatch-rule".into()),
name: "test".into(),
predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
trigger: Trigger::AgentDispatch {
agent_class: AgentClassRef("".into()),
prompt_template: PromptTemplate("fix: {{event}}".into()),
placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let err = validate(&rule).unwrap_err();
assert!(err.to_string().contains("agent class ref must not be empty"));
}
#[test]
fn agent_dispatch_without_event_ref_warns() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("dispatch-rule".into()),
name: "test".into(),
predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
trigger: Trigger::AgentDispatch {
agent_class: AgentClassRef("gnome/fixer".into()),
prompt_template: PromptTemplate("Please fix things.".into()),
placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let warnings = validate(&rule).unwrap();
assert!(warnings.iter().any(|w| w.path == "trigger.prompt_template"));
}
#[test]
fn scryer_health_with_mesh_wide_federation_warns() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("health-mesh".into()),
name: "test".into(),
predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Warning,
},
debounce_ms: None,
federation: FederationPolicy::MeshWide,
enabled: true,
};
let warnings = validate(&rule).unwrap();
assert!(warnings.iter().any(|w| w.path == "federation"));
}
}