Skip to main content

tatara_domains/
lib.rs

1//! Example typed domains authored as Lisp forms.
2//!
3//! Each type in this crate derives `TataraDomain`, which auto-generates the
4//! Lisp → Rust compile function. Binaries that want to accept these domains
5//! via Lisp call `tatara_domains::register_all()` at startup, after which
6//! `tatara_lisp::domain::lookup(keyword)` resolves any registered form.
7
8use serde::{Deserialize, Serialize};
9use tatara_lisp::DeriveTataraDomain;
10
11pub mod prelude {
12    pub use super::{AlertPolicySpec, MonitorSpec, NotifySpec, Severity};
13}
14
15// ── basic demo (String, numbers, bool, Option, Vec<String>) ──────
16
17/// A Prometheus-style alert monitor — the canonical tiny demo domain.
18///
19/// ```lisp
20/// (defmonitor :name "prom-up"
21///             :query "up{job='prometheus'}"
22///             :threshold 0.99
23///             :window-seconds 300
24///             :tags ("prod" "observability")
25///             :enabled #t)
26/// ```
27#[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/// A notification config — proves multiple types coexist in the registry.
41#[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// ── richer demo: enum + nested struct + Vec<struct> ──────────────
52
53/// A standalone enum — proves the derive's serde-Deserialize fallthrough.
54/// In Lisp this appears as a bare symbol: `:severity High`.
55#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
56pub enum Severity {
57    Info,
58    Warning,
59    Critical,
60    Page,
61}
62
63/// An escalation step — nested struct referenced inside `AlertPolicySpec`.
64/// In Lisp: `(:notify-ref "oncall" :wait-minutes 5 :severity Page)`.
65#[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/// Composite alerting policy — exercises every derive kind at once:
74///   - `String`, `f64`, `Option<f64>`, `Option<bool>`  (basic kinds)
75///   - `Severity` enum                                  (Deserialize fallthrough)
76///   - `Option<String>`, `Vec<String>`                  (basic containers)
77///   - `Vec<EscalationStep>`                            (Vec-of-nested fallthrough)
78///
79/// ```lisp
80/// (defalertpolicy
81///   :name "prod-outage"
82///   :monitor-ref "prometheus-up"
83///   :severity Critical
84///   :mute-minutes 30
85///   :mute-on-deploy #t
86///   :labels ("prod" "pager")
87///   :escalations (
88///     (:notify-ref "oncall" :wait-minutes 0 :severity Page)
89///     (:notify-ref "slack-alerts" :wait-minutes 5 :severity Warning)))
90/// ```
91#[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
106/// Register every domain in this crate with the global dispatcher.
107/// Call once per binary, typically near the top of `main`.
108pub 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        // Exercises: bare-symbol enum, Vec of nested structs, Option, Vec<String>.
156        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    // ── Error paths (derive-generated error handling) ────────────────
208    //
209    // compile_from_sexp returns Result. Before these tests, only the
210    // happy paths were exercised — a regression in the derive's
211    // "missing-required-field" / "unknown-variant" handling could
212    // accept malformed Lisp silently.
213
214    #[test]
215    fn monitor_rejects_missing_required_name() {
216        // `name` is required (no #[serde(default)]). Omitting it must
217        // produce an error, not a default-filled MonitorSpec.
218        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        // Severity has variants Info / Warning / Critical / Page. "Fatal"
249        // isn't one — the serde-Deserialize fallthrough in the derive
250        // must reject it.
251        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        // Typed-entry invariant (THEORY.md §II.1.1) — a misspelled keyword
258        // (`:tthreshold` instead of `:threshold`) must error, not parse
259        // silently with `threshold` defaulted/missing.
260        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        // Same strictness applies to every derive site, including ones with
274        // enum + nested-Vec fields.
275        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        // serde's enum Deserialize accepts both bare symbols and
289        // strings for unit variants, so both `Critical` and "Critical"
290        // work. What it MUST reject is non-string/non-symbol payloads
291        // like integers, which can't identify a variant.
292        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    // ── Default / optional behaviour ──────────────────────────────────
297
298    #[test]
299    fn monitor_defaults_tags_to_empty_and_window_enabled_to_none() {
300        // `tags` has #[serde(default)] → empty Vec. Option fields are
301        // None by default. Pin the exact empty/None payload so a future
302        // derive refactor that starts inserting Some("") or vec![""]
303        // (a plausible regression in deserialization fallback)
304        // surfaces here.
305        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        // `:tags ()` must parse to an empty Vec, not error out on the
317        // empty list.
318        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        // Explicit `:escalations ()` parses to empty Vec — distinct
326        // from omission, which also yields empty via
327        // #[serde(default)] — both paths must reach the same shape.
328        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    // ── camelCase ↔ kebab-case conversion ─────────────────────────────
341
342    #[test]
343    fn kebab_case_keywords_map_to_snake_case_rust_fields() {
344        // Every spec in this crate uses `#[serde(rename_all =
345        // "camelCase")]`, and the Lisp convention is kebab-case. The
346        // derive normalizes kebab → camelCase before serde
347        // deserializes. Pin both translations via fields whose Rust
348        // name differs from their keyword form.
349        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        // `:window-seconds` → struct field `window_seconds`
359        assert_eq!(m.window_seconds, Some(42));
360    }
361
362    #[test]
363    fn alert_policy_nested_kebab_case_works() {
364        // Nested EscalationStep has `:notify-ref` and `:wait-minutes`
365        // — kebab-case conversion must propagate into the Vec<Nested>
366        // fallthrough code path.
367        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    // ── Registry ──────────────────────────────────────────────────────
383
384    #[test]
385    fn register_all_is_idempotent() {
386        // register_all() may be called in each test binary; the
387        // registry must tolerate repeat inserts without blowing up or
388        // producing duplicate keywords.
389        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        // The prelude promises these four names. A rename upstream
399        // would break every downstream binary that wrote
400        // `use tatara_domains::prelude::*;` — pin the names here by
401        // constructing a minimum value of each.
402        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        // Lisp-level rewrite: bump threshold by looking at the kwargs list.
423        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            // Walk keyword/value pairs; bump :threshold.
434            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        // Rust re-validated the rewritten Sexp — we know the result is a
448        // well-typed MonitorSpec with the new threshold.
449        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}