1use tower_rules::TowerRule;
8
9#[derive(Debug, thiserror::Error)]
11pub enum ParseError {
12 #[error("YAML parse error: {0}")]
13 Yaml(#[from] serde_yaml::Error),
14
15 #[error("JSON parse error: {0}")]
16 Json(#[from] serde_json::Error),
17}
18
19pub fn parse_rule_yaml(src: &str) -> Result<TowerRule, ParseError> {
25 Ok(serde_yaml::from_str(src)?)
26}
27
28pub fn parse_rule_json(src: &str) -> Result<TowerRule, ParseError> {
33 Ok(serde_json::from_str(src)?)
34}
35
36#[cfg(test)]
37mod tests {
38 use tower_rules::*;
39 use super::*;
40
41 #[test]
42 fn parse_yaml_scryer_health_rule() {
43 let yaml = r#"
44schema_version: V1
45id: yubaba-quorum-loss
46name: "Yubaba raft quorum loss"
47predicate:
48 type: scryer_health
49 signal: yubaba_raft_quorum_lost
50trigger:
51 type: notification
52 channels:
53 - type: desktop_badge
54 severity: critical
55debounce_ms: 30000
56federation:
57 type: local_only
58enabled: true
59"#;
60 let rule = parse_rule_yaml(yaml).unwrap();
61 assert_eq!(rule.id, RuleId("yubaba-quorum-loss".into()));
62 assert!(matches!(
63 rule.predicate,
64 Predicate::ScryerHealth { signal: HealthSignal::YubabaRaftQuorumLost }
65 ));
66 assert!(matches!(
67 rule.trigger,
68 Trigger::Notification { severity: Severity::Critical, .. }
69 ));
70 assert_eq!(rule.debounce_ms, Some(30_000));
71 }
72
73 #[test]
74 fn parse_yaml_event_match_rule() {
75 let yaml = r#"
76schema_version: V1
77id: db-error-burst
78name: "Database error burst"
79predicate:
80 type: event_match
81 scope:
82 type: service
83 ident: "noisetable-db.pdx"
84 level: error
85 target:
86 type: glob
87 pattern: "database.*"
88 rate:
89 min_count: 5
90 window_ms: 60000
91trigger:
92 type: notification
93 channels:
94 - type: desktop_badge
95 severity: critical
96federation:
97 type: local_only
98enabled: true
99"#;
100 let rule = parse_rule_yaml(yaml).unwrap();
101 assert_eq!(rule.id, RuleId("db-error-burst".into()));
102 assert!(matches!(
103 &rule.predicate,
104 Predicate::EventMatch { level: Some(LevelFilter::Error), .. }
105 ));
106 }
107
108 #[test]
109 fn parse_yaml_compound_and_rule() {
110 let yaml = r#"
111schema_version: V1
112id: compound-test
113name: "Compound test"
114predicate:
115 type: compound
116 op: and
117 children:
118 - type: scryer_health
119 signal: yubaba_raft_quorum_lost
120 - type: event_match
121 scope:
122 type: any
123 level: warn
124trigger:
125 type: notification
126 channels:
127 - type: desktop_badge
128 severity: warning
129federation:
130 type: local_only
131enabled: false
132"#;
133 let rule = parse_rule_yaml(yaml).unwrap();
134 assert!(matches!(rule.predicate, Predicate::Compound { op: CompoundOp::And, .. }));
135 assert!(!rule.enabled);
136 }
137
138 #[test]
139 fn parse_json_rule() {
140 let json = r#"{
141 "schema_version": "V1",
142 "id": "json-rule",
143 "name": "JSON rule",
144 "predicate": {"type": "scryer_health", "signal": "ingestion_lag"},
145 "trigger": {
146 "type": "notification",
147 "channels": [{"type": "desktop_badge"}],
148 "severity": "warning"
149 },
150 "federation": {"type": "local_only"},
151 "enabled": true
152 }"#;
153 let rule = parse_rule_json(json).unwrap();
154 assert_eq!(rule.id, RuleId("json-rule".into()));
155 assert!(matches!(
156 rule.predicate,
157 Predicate::ScryerHealth { signal: HealthSignal::IngestionLag }
158 ));
159 }
160
161 #[test]
162 fn parse_yaml_invalid_syntax_errors() {
163 let bad = "predicate: {type: [}";
164 assert!(parse_rule_yaml(bad).is_err());
165 }
166
167 #[test]
168 fn parse_yaml_yubaba_action_trigger() {
169 let yaml = r#"
170schema_version: V1
171id: restart-on-crash
172name: "Restart crashed workload"
173predicate:
174 type: event_match
175 scope:
176 type: service
177 ident: "noisetable-api.pdx"
178 level: error
179 target:
180 type: exact
181 value: "service.crash"
182trigger:
183 type: yubaba_action
184 kind:
185 type: restart_workload
186 target: "noisetable-api.pdx"
187federation:
188 type: local_only
189enabled: true
190"#;
191 let rule = parse_rule_yaml(yaml).unwrap();
192 assert!(matches!(rule.trigger, Trigger::YubabaAction { .. }));
193 }
194
195 #[test]
196 fn parse_yaml_agent_dispatch_trigger() {
197 let yaml = r#"
198schema_version: V1
199id: db-repair-dispatch
200name: "Dispatch DB repair agent"
201predicate:
202 type: scryer_health
203 signal: yubaba_raft_quorum_lost
204trigger:
205 type: agent_dispatch
206 agent_class: "gnome/db-repair"
207 prompt_template: "DB issue detected: {{event}}"
208 placement:
209 location:
210 kind: local
211 runtime: native
212federation:
213 type: local_only
214enabled: false
215"#;
216 let rule = parse_rule_yaml(yaml).unwrap();
217 assert!(matches!(rule.trigger, Trigger::AgentDispatch { .. }));
218 }
219}