1use tower_rules::{CompoundOp, FederationPolicy, Predicate, RuleId, Trigger, TowerRule};
8
9#[derive(Debug, Clone, PartialEq, thiserror::Error)]
11pub enum RuleError {
12 #[error("field {path}: {reason}")]
13 Field { path: String, reason: String },
14}
15
16impl RuleError {
17 fn field(path: impl Into<String>, reason: impl Into<String>) -> Self {
18 RuleError::Field { path: path.into(), reason: reason.into() }
19 }
20}
21
22#[derive(Debug, Clone, PartialEq)]
24pub struct RuleWarning {
25 pub path: String,
26 pub message: String,
27}
28
29impl RuleWarning {
30 fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
31 RuleWarning { path: path.into(), message: message.into() }
32 }
33}
34
35pub fn validate(rule: &TowerRule) -> Result<Vec<RuleWarning>, RuleError> {
45 let mut warnings = Vec::new();
46
47 validate_id(&rule.id)?;
48 validate_predicate(&rule.predicate, "predicate")?;
49 validate_trigger(&rule.trigger, &mut warnings)?;
50 validate_federation_vs_predicate(rule, &mut warnings);
51
52 Ok(warnings)
53}
54
55fn validate_id(id: &RuleId) -> Result<(), RuleError> {
56 let s = &id.0;
57 if s.is_empty() {
58 return Err(RuleError::field("id", "rule ID must not be empty"));
59 }
60 if !s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
62 return Err(RuleError::field(
63 "id",
64 "rule ID must be kebab-case (lowercase alphanumeric and hyphens only)",
65 ));
66 }
67 if s.starts_with('-') || s.ends_with('-') {
68 return Err(RuleError::field(
69 "id",
70 "rule ID must not start or end with a hyphen",
71 ));
72 }
73 Ok(())
74}
75
76fn validate_predicate(pred: &Predicate, path: &str) -> Result<(), RuleError> {
77 match pred {
78 Predicate::EventMatch { fields, .. } => {
79 for (i, ff) in fields.iter().enumerate() {
80 if ff.path.is_empty() {
81 return Err(RuleError::field(
82 format!("{path}.fields[{i}].path"),
83 "field path must not be empty",
84 ));
85 }
86 }
87 }
88 Predicate::ScryerHealth { .. } => {}
89 Predicate::Compound { op, children } => {
90 if children.is_empty() {
91 return Err(RuleError::field(
92 format!("{path}.children"),
93 "Compound predicate must have at least one child",
94 ));
95 }
96 if *op == CompoundOp::Not && children.len() != 1 {
97 return Err(RuleError::field(
98 format!("{path}.children"),
99 format!("Not requires exactly 1 child, got {}", children.len()),
100 ));
101 }
102 for (i, child) in children.iter().enumerate() {
103 validate_predicate(child, &format!("{path}.children[{i}]"))?;
104 }
105 }
106 }
107 Ok(())
108}
109
110fn validate_trigger(trigger: &Trigger, warnings: &mut Vec<RuleWarning>) -> Result<(), RuleError> {
111 match trigger {
112 Trigger::AgentDispatch { agent_class, prompt_template, .. } => {
113 if agent_class.0.is_empty() {
114 return Err(RuleError::field(
115 "trigger.agent_class",
116 "agent class ref must not be empty",
117 ));
118 }
119 if prompt_template.0.is_empty() {
120 return Err(RuleError::field(
121 "trigger.prompt_template",
122 "prompt template must not be empty",
123 ));
124 }
125 if !prompt_template.0.contains("{{event}}") && !prompt_template.0.contains("{{events}}") {
128 warnings.push(RuleWarning::new(
129 "trigger.prompt_template",
130 "prompt template does not reference {{event}} or {{events}}; agent will run without event context",
131 ));
132 }
133 }
134 Trigger::Notification { channels, .. } => {
135 if channels.is_empty() {
136 return Err(RuleError::field(
137 "trigger.channels",
138 "at least one notification channel must be specified",
139 ));
140 }
141 }
142 Trigger::YubabaAction { target, .. } => {
143 if target.0.is_empty() {
144 return Err(RuleError::field(
145 "trigger.target",
146 "yubaba action target must not be empty",
147 ));
148 }
149 }
150 }
151 Ok(())
152}
153
154fn validate_federation_vs_predicate(rule: &TowerRule, warnings: &mut Vec<RuleWarning>) {
155 if !matches!(rule.federation, FederationPolicy::LocalOnly) {
158 if matches!(rule.predicate, Predicate::ScryerHealth { .. }) {
159 warnings.push(RuleWarning::new(
160 "federation",
161 "ScryerHealth predicates watch per-machine scryer; federation is unlikely to be useful here",
162 ));
163 }
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use tower_rules::*;
170 use super::*;
171
172 fn minimal_rule(predicate: Predicate) -> TowerRule {
173 TowerRule {
174 schema_version: SchemaVersion::V1,
175 id: RuleId("test-rule".into()),
176 name: "test".into(),
177 predicate,
178 trigger: Trigger::Notification {
179 channels: vec![NotificationChannel::DesktopBadge],
180 severity: Severity::Info,
181 },
182 debounce_ms: None,
183 federation: FederationPolicy::LocalOnly,
184 enabled: true,
185 }
186 }
187
188 #[test]
189 fn valid_rule_no_warnings() {
190 let rule = minimal_rule(Predicate::ScryerHealth {
191 signal: HealthSignal::YubabaRaftQuorumLost,
192 });
193 let warnings = validate(&rule).unwrap();
194 assert!(warnings.is_empty());
195 }
196
197 #[test]
198 fn empty_id_is_error() {
199 let mut rule = minimal_rule(Predicate::ScryerHealth {
200 signal: HealthSignal::IngestionLag,
201 });
202 rule.id = RuleId("".into());
203 let err = validate(&rule).unwrap_err();
204 assert!(err.to_string().contains("must not be empty"));
205 }
206
207 #[test]
208 fn uppercase_id_is_error() {
209 let mut rule = minimal_rule(Predicate::ScryerHealth {
210 signal: HealthSignal::IngestionLag,
211 });
212 rule.id = RuleId("MyRule".into());
213 let err = validate(&rule).unwrap_err();
214 assert!(err.to_string().contains("kebab-case"));
215 }
216
217 #[test]
218 fn leading_hyphen_id_is_error() {
219 let mut rule = minimal_rule(Predicate::ScryerHealth {
220 signal: HealthSignal::IngestionLag,
221 });
222 rule.id = RuleId("-rule".into());
223 let err = validate(&rule).unwrap_err();
224 assert!(err.to_string().contains("must not start or end with a hyphen"));
225 }
226
227 #[test]
228 fn empty_notification_channels_is_error() {
229 let rule = TowerRule {
230 schema_version: SchemaVersion::V1,
231 id: RuleId("test-rule".into()),
232 name: "test".into(),
233 predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
234 trigger: Trigger::Notification {
235 channels: vec![],
236 severity: Severity::Info,
237 },
238 debounce_ms: None,
239 federation: FederationPolicy::LocalOnly,
240 enabled: true,
241 };
242 let err = validate(&rule).unwrap_err();
243 assert!(err.to_string().contains("at least one notification channel"));
244 }
245
246 #[test]
247 fn empty_agent_class_is_error() {
248 let rule = TowerRule {
249 schema_version: SchemaVersion::V1,
250 id: RuleId("dispatch-rule".into()),
251 name: "test".into(),
252 predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
253 trigger: Trigger::AgentDispatch {
254 agent_class: AgentClassRef("".into()),
255 prompt_template: PromptTemplate("fix: {{event}}".into()),
256 placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
257 },
258 debounce_ms: None,
259 federation: FederationPolicy::LocalOnly,
260 enabled: true,
261 };
262 let err = validate(&rule).unwrap_err();
263 assert!(err.to_string().contains("agent class ref must not be empty"));
264 }
265
266 #[test]
267 fn agent_dispatch_without_event_ref_warns() {
268 let rule = TowerRule {
269 schema_version: SchemaVersion::V1,
270 id: RuleId("dispatch-rule".into()),
271 name: "test".into(),
272 predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
273 trigger: Trigger::AgentDispatch {
274 agent_class: AgentClassRef("gnome/fixer".into()),
275 prompt_template: PromptTemplate("Please fix things.".into()),
276 placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
277 },
278 debounce_ms: None,
279 federation: FederationPolicy::LocalOnly,
280 enabled: true,
281 };
282 let warnings = validate(&rule).unwrap();
283 assert!(warnings.iter().any(|w| w.path == "trigger.prompt_template"));
284 }
285
286 #[test]
287 fn scryer_health_with_mesh_wide_federation_warns() {
288 let rule = TowerRule {
289 schema_version: SchemaVersion::V1,
290 id: RuleId("health-mesh".into()),
291 name: "test".into(),
292 predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
293 trigger: Trigger::Notification {
294 channels: vec![NotificationChannel::DesktopBadge],
295 severity: Severity::Warning,
296 },
297 debounce_ms: None,
298 federation: FederationPolicy::MeshWide,
299 enabled: true,
300 };
301 let warnings = validate(&rule).unwrap();
302 assert!(warnings.iter().any(|w| w.path == "federation"));
303 }
304}