Skip to main content

tower/rules/
parse.rs

1//! Rule file parser: YAML and JSON → `TowerRule`.
2//!
3//! Operators author rules as YAML in `.yah/cloud/rules/<name>.yaml`. JSON is
4//! accepted for machine-generated rules (agents, desktop). A tiny DSL authoring
5//! surface is deferred to tower-2.
6
7use tower_rules::TowerRule;
8
9/// Error produced when a rule file cannot be parsed.
10#[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
19/// Parse a YAML rule definition into a `TowerRule`.
20///
21/// Operators write rules in `.yah/cloud/rules/<name>.yaml`. The YAML must
22/// deserialize into a valid `TowerRule`; use `validate` afterwards to catch
23/// semantic errors (e.g. missing agent_class on an AgentDispatch trigger).
24pub fn parse_rule_yaml(src: &str) -> Result<TowerRule, ParseError> {
25    Ok(serde_yaml::from_str(src)?)
26}
27
28/// Parse a JSON rule definition into a `TowerRule`.
29///
30/// Used for machine-generated rules (agent output, desktop form-builder
31/// backend). Semantically equivalent to YAML parsing.
32pub 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}