1use serde::{Deserialize, Serialize};
9use tatara_lisp::DeriveTataraDomain;
10
11pub mod prelude {
12 pub use super::{AlertPolicySpec, MonitorSpec, NotifySpec, Severity};
13}
14
15#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone, PartialEq)]
28#[serde(rename_all = "camelCase")]
29#[tatara(keyword = "defmonitor")]
30pub struct MonitorSpec {
31 pub name: String,
32 pub query: String,
33 pub threshold: f64,
34 pub window_seconds: Option<i64>,
35 #[serde(default)]
36 pub tags: Vec<String>,
37 pub enabled: Option<bool>,
38}
39
40#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone, PartialEq)]
42#[serde(rename_all = "camelCase")]
43#[tatara(keyword = "defnotify")]
44pub struct NotifySpec {
45 pub name: String,
46 pub channel: String,
47 pub target: String,
48 pub severity: Option<String>,
49}
50
51#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
56pub enum Severity {
57 Info,
58 Warning,
59 Critical,
60 Page,
61}
62
63#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
66#[serde(rename_all = "camelCase")]
67pub struct EscalationStep {
68 pub notify_ref: String,
69 pub wait_minutes: Option<i64>,
70 pub severity: Severity,
71}
72
73#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone, PartialEq)]
92#[serde(rename_all = "camelCase")]
93#[tatara(keyword = "defalertpolicy")]
94pub struct AlertPolicySpec {
95 pub name: String,
96 pub monitor_ref: String,
97 pub severity: Severity,
98 pub mute_minutes: Option<f64>,
99 pub mute_on_deploy: Option<bool>,
100 #[serde(default)]
101 pub labels: Vec<String>,
102 #[serde(default)]
103 pub escalations: Vec<EscalationStep>,
104}
105
106pub fn register_all() {
109 tatara_lisp::domain::register::<MonitorSpec>();
110 tatara_lisp::domain::register::<NotifySpec>();
111 tatara_lisp::domain::register::<AlertPolicySpec>();
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use tatara_lisp::{domain::TataraDomain, read};
118
119 #[test]
120 fn monitor_round_trips() {
121 let forms = read(
122 r#"(defmonitor
123 :name "prom-up"
124 :query "up{job='prometheus'}"
125 :threshold 0.99
126 :window-seconds 300
127 :tags ("prod" "observability")
128 :enabled #t)"#,
129 )
130 .unwrap();
131 let m = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
132 assert_eq!(m.name, "prom-up");
133 assert_eq!(m.threshold, 0.99);
134 assert_eq!(m.window_seconds, Some(300));
135 assert_eq!(
136 m.tags,
137 vec!["prod".to_string(), "observability".to_string()]
138 );
139 assert_eq!(m.enabled, Some(true));
140 }
141
142 #[test]
143 fn notify_minimal() {
144 let forms =
145 read(r##"(defnotify :name "oncall" :channel "slack" :target "#alerts")"##).unwrap();
146 let n = NotifySpec::compile_from_sexp(&forms[0]).unwrap();
147 assert_eq!(n.name, "oncall");
148 assert_eq!(n.channel, "slack");
149 assert_eq!(n.target, "#alerts");
150 assert!(n.severity.is_none());
151 }
152
153 #[test]
154 fn alert_policy_with_enum_and_nested_vec() {
155 let forms = read(
157 r#"(defalertpolicy
158 :name "prod-outage"
159 :monitor-ref "prometheus-up"
160 :severity Critical
161 :mute-minutes 30.0
162 :mute-on-deploy #t
163 :labels ("prod" "pager")
164 :escalations (
165 (:notify-ref "oncall" :wait-minutes 0 :severity Page)
166 (:notify-ref "slack-alerts" :wait-minutes 5 :severity Warning)))"#,
167 )
168 .unwrap();
169 let p = AlertPolicySpec::compile_from_sexp(&forms[0]).unwrap();
170 assert_eq!(p.name, "prod-outage");
171 assert_eq!(p.severity, Severity::Critical);
172 assert_eq!(p.mute_minutes, Some(30.0));
173 assert_eq!(p.mute_on_deploy, Some(true));
174 assert_eq!(p.labels, vec!["prod".to_string(), "pager".to_string()]);
175 assert_eq!(p.escalations.len(), 2);
176 assert_eq!(p.escalations[0].notify_ref, "oncall");
177 assert_eq!(p.escalations[0].severity, Severity::Page);
178 assert_eq!(p.escalations[1].wait_minutes, Some(5));
179 assert_eq!(p.escalations[1].severity, Severity::Warning);
180 }
181
182 #[test]
183 fn alert_policy_defaults() {
184 let forms = read(
185 r#"(defalertpolicy
186 :name "basic"
187 :monitor-ref "x"
188 :severity Info)"#,
189 )
190 .unwrap();
191 let p = AlertPolicySpec::compile_from_sexp(&forms[0]).unwrap();
192 assert_eq!(p.severity, Severity::Info);
193 assert!(p.mute_minutes.is_none());
194 assert!(p.labels.is_empty());
195 assert!(p.escalations.is_empty());
196 }
197
198 #[test]
199 fn register_all_populates_registry() {
200 register_all();
201 let kws = tatara_lisp::domain::registered_keywords();
202 assert!(kws.contains(&"defmonitor"));
203 assert!(kws.contains(&"defnotify"));
204 assert!(kws.contains(&"defalertpolicy"));
205 }
206
207 #[test]
215 fn monitor_rejects_missing_required_name() {
216 let forms = read(r#"(defmonitor :query "up{job='x'}" :threshold 0.5)"#).unwrap();
219 let err = MonitorSpec::compile_from_sexp(&forms[0]).unwrap_err();
220 assert!(
221 format!("{err:?}").to_lowercase().contains("name")
222 || format!("{err:?}").to_lowercase().contains("missing")
223 || format!("{err:?}").to_lowercase().contains("required"),
224 "unexpected error: {err:?}"
225 );
226 }
227
228 #[test]
229 fn monitor_rejects_missing_required_query() {
230 let forms = read(r#"(defmonitor :name "x" :threshold 0.5)"#).unwrap();
231 assert!(MonitorSpec::compile_from_sexp(&forms[0]).is_err());
232 }
233
234 #[test]
235 fn monitor_rejects_missing_required_threshold() {
236 let forms = read(r#"(defmonitor :name "x" :query "y")"#).unwrap();
237 assert!(MonitorSpec::compile_from_sexp(&forms[0]).is_err());
238 }
239
240 #[test]
241 fn notify_rejects_missing_required_channel() {
242 let forms = read(r##"(defnotify :name "oncall" :target "#alerts")"##).unwrap();
243 assert!(NotifySpec::compile_from_sexp(&forms[0]).is_err());
244 }
245
246 #[test]
247 fn alert_policy_rejects_unknown_severity() {
248 let forms = read(r#"(defalertpolicy :name "x" :monitor-ref "m" :severity Fatal)"#).unwrap();
252 assert!(AlertPolicySpec::compile_from_sexp(&forms[0]).is_err());
253 }
254
255 #[test]
256 fn monitor_rejects_typoed_keyword() {
257 let forms =
261 read(r#"(defmonitor :name "x" :query "q" :threshold 0.5 :tthreshold 0.99)"#).unwrap();
262 let err = MonitorSpec::compile_from_sexp(&forms[0]).unwrap_err();
263 let msg = format!("{err}");
264 assert!(msg.contains("tthreshold"), "must name the typo: {msg}");
265 assert!(
266 msg.contains("unknown keyword"),
267 "must label the failure: {msg}"
268 );
269 }
270
271 #[test]
272 fn alert_policy_rejects_typoed_keyword() {
273 let forms = read(
276 r#"(defalertpolicy :name "x" :monitor-ref "m" :severity Info :wrong-field "oops")"#,
277 )
278 .unwrap();
279 let err = AlertPolicySpec::compile_from_sexp(&forms[0]).unwrap_err();
280 assert!(
281 format!("{err}").contains("wrong-field"),
282 "must name the offending keyword, got: {err}"
283 );
284 }
285
286 #[test]
287 fn alert_policy_rejects_integer_in_severity_slot() {
288 let forms = read(r#"(defalertpolicy :name "x" :monitor-ref "m" :severity 42)"#).unwrap();
293 assert!(AlertPolicySpec::compile_from_sexp(&forms[0]).is_err());
294 }
295
296 #[test]
299 fn monitor_defaults_tags_to_empty_and_window_enabled_to_none() {
300 let forms = read(r#"(defmonitor :name "x" :query "y" :threshold 0.1)"#).unwrap();
306 let m = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
307 assert_eq!(m.name, "x");
308 assert_eq!(m.query, "y");
309 assert!(m.tags.is_empty());
310 assert!(m.window_seconds.is_none());
311 assert!(m.enabled.is_none());
312 }
313
314 #[test]
315 fn monitor_explicit_empty_tags_list_parses() {
316 let forms = read(r#"(defmonitor :name "x" :query "y" :threshold 0.5 :tags ())"#).unwrap();
319 let m = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
320 assert!(m.tags.is_empty());
321 }
322
323 #[test]
324 fn alert_policy_empty_escalations_parses() {
325 let forms = read(
329 r#"(defalertpolicy
330 :name "p"
331 :monitor-ref "m"
332 :severity Info
333 :escalations ())"#,
334 )
335 .unwrap();
336 let p = AlertPolicySpec::compile_from_sexp(&forms[0]).unwrap();
337 assert!(p.escalations.is_empty());
338 }
339
340 #[test]
343 fn kebab_case_keywords_map_to_snake_case_rust_fields() {
344 let forms = read(
350 r#"(defmonitor
351 :name "x"
352 :query "q"
353 :threshold 0.0
354 :window-seconds 42)"#,
355 )
356 .unwrap();
357 let m = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
358 assert_eq!(m.window_seconds, Some(42));
360 }
361
362 #[test]
363 fn alert_policy_nested_kebab_case_works() {
364 let forms = read(
368 r#"(defalertpolicy
369 :name "p"
370 :monitor-ref "m"
371 :severity Info
372 :escalations (
373 (:notify-ref "slack" :wait-minutes 10 :severity Warning)))"#,
374 )
375 .unwrap();
376 let p = AlertPolicySpec::compile_from_sexp(&forms[0]).unwrap();
377 assert_eq!(p.escalations.len(), 1);
378 assert_eq!(p.escalations[0].notify_ref, "slack");
379 assert_eq!(p.escalations[0].wait_minutes, Some(10));
380 }
381
382 #[test]
385 fn register_all_is_idempotent() {
386 register_all();
390 register_all();
391 let kws = tatara_lisp::domain::registered_keywords();
392 let monitor_count = kws.iter().filter(|k| **k == "defmonitor").count();
393 assert_eq!(monitor_count, 1, "registry should dedupe re-registrations");
394 }
395
396 #[test]
397 fn prelude_re_exports_the_documented_types() {
398 let _sev: super::prelude::Severity = super::prelude::Severity::Info;
403 let _m: Option<super::prelude::MonitorSpec> = None;
404 let _n: Option<super::prelude::NotifySpec> = None;
405 let _p: Option<super::prelude::AlertPolicySpec> = None;
406 }
407
408 #[test]
409 fn rewrite_typed_end_to_end() {
410 use tatara_lisp::ast::{Atom, Sexp};
411 use tatara_lisp::domain::rewrite_typed;
412
413 let m0 = MonitorSpec {
414 name: "prom-up".into(),
415 query: "up{j='x'}".into(),
416 threshold: 0.95,
417 window_seconds: Some(60),
418 tags: vec!["prod".into()],
419 enabled: Some(true),
420 };
421
422 let m1 = rewrite_typed(m0, |sexp| {
424 let mut items = match sexp {
425 Sexp::List(xs) => xs,
426 other => {
427 return Err(tatara_lisp::LispError::Compile {
428 form: "rewrite".into(),
429 message: format!("expected kwargs list, got {other}"),
430 })
431 }
432 };
433 let mut i = 0;
435 while i + 1 < items.len() {
436 if items[i].as_keyword() == Some("threshold") {
437 if let Sexp::Atom(Atom::Float(n)) = &items[i + 1] {
438 items[i + 1] = Sexp::float(n + 0.04);
439 }
440 }
441 i += 2;
442 }
443 Ok(Sexp::List(items))
444 })
445 .unwrap();
446
447 assert!((m1.threshold - 0.99).abs() < 1e-9);
450 assert_eq!(m1.name, "prom-up");
451 assert_eq!(m1.tags, vec!["prod".to_string()]);
452 }
453}