1use std::collections::HashMap;
12use std::sync::{Mutex, OnceLock};
13
14use crate::ast::Sexp;
15use crate::error::{LispError, Result};
16
17pub trait TataraDomain: Sized {
19 const KEYWORD: &'static str;
21
22 fn compile_from_args(args: &[Sexp]) -> Result<Self>;
24
25 fn compile_from_sexp(form: &Sexp) -> Result<Self> {
27 let list = form.as_list().ok_or_else(|| LispError::Compile {
28 form: Self::KEYWORD.to_string(),
29 message: "expected list form".into(),
30 })?;
31 let head = list
32 .first()
33 .and_then(|s| s.as_symbol())
34 .ok_or_else(|| LispError::Compile {
35 form: Self::KEYWORD.to_string(),
36 message: "missing head symbol".into(),
37 })?;
38 if head != Self::KEYWORD {
39 return Err(LispError::Compile {
40 form: Self::KEYWORD.to_string(),
41 message: format!("expected ({} ...), got ({} ...)", Self::KEYWORD, head),
42 });
43 }
44 Self::compile_from_args(&list[1..])
45 }
46}
47
48pub type Kwargs<'a> = HashMap<String, &'a Sexp>;
51
52pub fn parse_kwargs(args: &[Sexp]) -> Result<Kwargs<'_>> {
53 let mut kw = HashMap::new();
54 let mut i = 0;
55 while i + 1 < args.len() {
56 let key = args[i].as_keyword().ok_or_else(|| LispError::Compile {
57 form: "kwargs".into(),
58 message: format!("expected keyword at position {i}"),
59 })?;
60 kw.insert(key.to_string(), &args[i + 1]);
61 i += 2;
62 }
63 if i < args.len() {
64 return Err(LispError::OddKwargs);
65 }
66 Ok(kw)
67}
68
69pub fn required<'a>(kw: &'a Kwargs<'_>, key: &str) -> Result<&'a Sexp> {
70 kw.get(key).copied().ok_or_else(|| LispError::Compile {
71 form: format!(":{key}"),
72 message: "required but not provided".into(),
73 })
74}
75
76fn type_err(key: &str, expected: &str) -> LispError {
77 LispError::Compile {
78 form: format!(":{key}"),
79 message: format!("expected {expected}"),
80 }
81}
82
83pub fn extract_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<&'a str> {
84 required(kw, key)?
85 .as_string()
86 .ok_or_else(|| type_err(key, "string"))
87}
88
89pub fn extract_optional_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<Option<&'a str>> {
90 match kw.get(key) {
91 None => Ok(None),
92 Some(v) => match v.as_string() {
93 Some(s) => Ok(Some(s)),
94 None => Err(type_err(key, "string")),
95 },
96 }
97}
98
99pub fn extract_string_list(kw: &Kwargs<'_>, key: &str) -> Result<Vec<String>> {
100 let v = kw.get(key).copied();
101 let Some(v) = v else {
102 return Ok(vec![]);
103 };
104 let list = v
105 .as_list()
106 .ok_or_else(|| type_err(key, "list of strings"))?;
107 list.iter()
108 .map(|s| {
109 s.as_string()
110 .map(String::from)
111 .ok_or_else(|| type_err(key, "list of strings"))
112 })
113 .collect()
114}
115
116pub fn extract_int(kw: &Kwargs<'_>, key: &str) -> Result<i64> {
117 required(kw, key)?
118 .as_int()
119 .ok_or_else(|| type_err(key, "int"))
120}
121
122pub fn extract_optional_int(kw: &Kwargs<'_>, key: &str) -> Result<Option<i64>> {
123 match kw.get(key) {
124 None => Ok(None),
125 Some(v) => v.as_int().map(Some).ok_or_else(|| type_err(key, "int")),
126 }
127}
128
129pub fn extract_float(kw: &Kwargs<'_>, key: &str) -> Result<f64> {
130 required(kw, key)?
131 .as_float()
132 .ok_or_else(|| type_err(key, "number"))
133}
134
135pub fn extract_optional_float(kw: &Kwargs<'_>, key: &str) -> Result<Option<f64>> {
136 match kw.get(key) {
137 None => Ok(None),
138 Some(v) => v
139 .as_float()
140 .map(Some)
141 .ok_or_else(|| type_err(key, "number")),
142 }
143}
144
145pub fn extract_bool(kw: &Kwargs<'_>, key: &str) -> Result<bool> {
146 required(kw, key)?
147 .as_bool()
148 .ok_or_else(|| type_err(key, "bool"))
149}
150
151pub fn extract_optional_bool(kw: &Kwargs<'_>, key: &str) -> Result<Option<bool>> {
152 match kw.get(key) {
153 None => Ok(None),
154 Some(v) => v.as_bool().map(Some).ok_or_else(|| type_err(key, "bool")),
155 }
156}
157
158pub struct DomainHandler {
164 pub keyword: &'static str,
165 pub compile: fn(args: &[Sexp]) -> Result<serde_json::Value>,
166}
167
168static REGISTRY: OnceLock<Mutex<HashMap<&'static str, DomainHandler>>> = OnceLock::new();
169
170fn registry() -> &'static Mutex<HashMap<&'static str, DomainHandler>> {
171 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
172}
173
174pub fn register<T>()
177where
178 T: TataraDomain + serde::Serialize,
179{
180 let handler = DomainHandler {
181 keyword: T::KEYWORD,
182 compile: |args| {
183 let v = T::compile_from_args(args)?;
184 serde_json::to_value(&v).map_err(|e| LispError::Compile {
185 form: T::KEYWORD.to_string(),
186 message: format!("serialize: {e}"),
187 })
188 },
189 };
190 registry().lock().unwrap().insert(T::KEYWORD, handler);
191}
192
193pub fn lookup(keyword: &str) -> Option<DomainHandler> {
195 let reg = registry().lock().unwrap();
196 reg.get(keyword).map(|h| DomainHandler {
197 keyword: h.keyword,
198 compile: h.compile,
199 })
200}
201
202pub fn registered_keywords() -> Vec<&'static str> {
204 registry().lock().unwrap().keys().copied().collect()
205}
206
207pub trait RenderableDomain {
225 const API_VERSION: &'static str;
228 const KIND: &'static str;
230 const NAME_FIELD: &'static str = "name";
234}
235
236#[derive(Clone, Copy, Debug)]
238pub struct RenderHandler {
239 pub keyword: &'static str,
240 pub api_version: &'static str,
241 pub kind: &'static str,
242 pub name_field: &'static str,
243}
244
245static RENDER_REGISTRY: OnceLock<Mutex<HashMap<&'static str, RenderHandler>>> = OnceLock::new();
246
247fn render_registry() -> &'static Mutex<HashMap<&'static str, RenderHandler>> {
248 RENDER_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
249}
250
251pub fn register_render<T>()
254where
255 T: TataraDomain + RenderableDomain,
256{
257 let handler = RenderHandler {
258 keyword: T::KEYWORD,
259 api_version: T::API_VERSION,
260 kind: T::KIND,
261 name_field: T::NAME_FIELD,
262 };
263 render_registry().lock().unwrap().insert(T::KEYWORD, handler);
264}
265
266#[must_use]
268pub fn lookup_render(keyword: &str) -> Option<RenderHandler> {
269 render_registry().lock().unwrap().get(keyword).copied()
270}
271
272#[must_use]
274pub fn registered_render_keywords() -> Vec<&'static str> {
275 render_registry().lock().unwrap().keys().copied().collect()
276}
277
278pub trait DocumentedDomain {
289 const DOCSTRING: &'static str;
292 const FIELD_DOCS: &'static [(&'static str, &'static str)];
297}
298
299#[derive(Clone, Copy, Debug)]
301pub struct DocHandler {
302 pub keyword: &'static str,
303 pub docstring: &'static str,
304 pub field_docs: &'static [(&'static str, &'static str)],
305}
306
307static DOC_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DocHandler>>> = OnceLock::new();
308
309fn doc_registry() -> &'static Mutex<HashMap<&'static str, DocHandler>> {
310 DOC_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
311}
312
313pub fn register_doc<T>()
315where
316 T: TataraDomain + DocumentedDomain,
317{
318 let handler = DocHandler {
319 keyword: T::KEYWORD,
320 docstring: T::DOCSTRING,
321 field_docs: T::FIELD_DOCS,
322 };
323 doc_registry().lock().unwrap().insert(T::KEYWORD, handler);
324}
325
326#[must_use]
328pub fn lookup_doc(keyword: &str) -> Option<DocHandler> {
329 doc_registry().lock().unwrap().get(keyword).copied()
330}
331
332#[must_use]
334pub fn registered_doc_keywords() -> Vec<&'static str> {
335 doc_registry().lock().unwrap().keys().copied().collect()
336}
337
338pub trait DependentDomain {
354 const DEPENDS_ON: &'static [&'static str];
359}
360
361#[derive(Clone, Copy, Debug)]
363pub struct DepsHandler {
364 pub keyword: &'static str,
365 pub depends_on: &'static [&'static str],
366}
367
368static DEPS_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DepsHandler>>> = OnceLock::new();
369
370fn deps_registry() -> &'static Mutex<HashMap<&'static str, DepsHandler>> {
371 DEPS_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
372}
373
374pub fn register_deps<T>()
376where
377 T: TataraDomain + DependentDomain,
378{
379 let handler = DepsHandler {
380 keyword: T::KEYWORD,
381 depends_on: T::DEPENDS_ON,
382 };
383 deps_registry().lock().unwrap().insert(T::KEYWORD, handler);
384}
385
386#[must_use]
388pub fn lookup_deps(keyword: &str) -> Option<DepsHandler> {
389 deps_registry().lock().unwrap().get(keyword).copied()
390}
391
392#[must_use]
394pub fn registered_deps_keywords() -> Vec<&'static str> {
395 deps_registry().lock().unwrap().keys().copied().collect()
396}
397
398pub trait SchematicDomain {
409 const SCHEMA_JSON: &'static str;
415}
416
417#[derive(Clone, Copy, Debug)]
418pub struct SchemaHandler {
419 pub keyword: &'static str,
420 pub schema_json: &'static str,
421}
422
423static SCHEMA_REGISTRY: OnceLock<Mutex<HashMap<&'static str, SchemaHandler>>> = OnceLock::new();
424
425fn schema_registry() -> &'static Mutex<HashMap<&'static str, SchemaHandler>> {
426 SCHEMA_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
427}
428
429pub fn register_schema<T>()
430where
431 T: TataraDomain + SchematicDomain,
432{
433 let handler = SchemaHandler {
434 keyword: T::KEYWORD,
435 schema_json: T::SCHEMA_JSON,
436 };
437 schema_registry().lock().unwrap().insert(T::KEYWORD, handler);
438}
439
440#[must_use]
441pub fn lookup_schema(keyword: &str) -> Option<SchemaHandler> {
442 schema_registry().lock().unwrap().get(keyword).copied()
443}
444
445#[must_use]
446pub fn registered_schema_keywords() -> Vec<&'static str> {
447 schema_registry().lock().unwrap().keys().copied().collect()
448}
449
450pub trait AttestableDomain {
461 const ATTESTATION_NAMESPACE: &'static str;
468}
469
470#[derive(Clone, Copy, Debug)]
471pub struct AttestHandler {
472 pub keyword: &'static str,
473 pub namespace: &'static str,
474}
475
476static ATTEST_REGISTRY: OnceLock<Mutex<HashMap<&'static str, AttestHandler>>> = OnceLock::new();
477
478fn attest_registry() -> &'static Mutex<HashMap<&'static str, AttestHandler>> {
479 ATTEST_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
480}
481
482pub fn register_attest<T>()
483where
484 T: TataraDomain + AttestableDomain,
485{
486 let handler = AttestHandler {
487 keyword: T::KEYWORD,
488 namespace: T::ATTESTATION_NAMESPACE,
489 };
490 attest_registry().lock().unwrap().insert(T::KEYWORD, handler);
491}
492
493#[must_use]
494pub fn lookup_attest(keyword: &str) -> Option<AttestHandler> {
495 attest_registry().lock().unwrap().get(keyword).copied()
496}
497
498#[must_use]
499pub fn registered_attest_keywords() -> Vec<&'static str> {
500 attest_registry().lock().unwrap().keys().copied().collect()
501}
502
503#[must_use]
514pub fn attest_value(namespace: &str, value: &serde_json::Value) -> String {
515 let canonical = serde_json::to_string(value).unwrap_or_default();
516 let mut hasher = blake3::Hasher::new();
517 hasher.update(namespace.as_bytes());
518 hasher.update(b":");
519 hasher.update(canonical.as_bytes());
520 hasher.finalize().to_hex().to_string()
521}
522
523pub trait ValidatedDomain {
538 fn validate_value(_value: &serde_json::Value) -> std::result::Result<(), String> {
543 Ok(())
544 }
545}
546
547#[derive(Clone, Copy)]
549pub struct ValidateHandler {
550 pub keyword: &'static str,
551 pub validate: fn(&serde_json::Value) -> std::result::Result<(), String>,
552}
553
554impl std::fmt::Debug for ValidateHandler {
555 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
556 f.debug_struct("ValidateHandler")
557 .field("keyword", &self.keyword)
558 .field("validate", &"<fn>")
559 .finish()
560 }
561}
562
563static VALIDATE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, ValidateHandler>>> = OnceLock::new();
564
565fn validate_registry() -> &'static Mutex<HashMap<&'static str, ValidateHandler>> {
566 VALIDATE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
567}
568
569pub fn register_validate<T>()
570where
571 T: TataraDomain + ValidatedDomain,
572{
573 let handler = ValidateHandler {
574 keyword: T::KEYWORD,
575 validate: <T as ValidatedDomain>::validate_value,
576 };
577 validate_registry().lock().unwrap().insert(T::KEYWORD, handler);
578}
579
580#[must_use]
581pub fn lookup_validate(keyword: &str) -> Option<ValidateHandler> {
582 validate_registry().lock().unwrap().get(keyword).copied()
583}
584
585#[must_use]
586pub fn registered_validate_keywords() -> Vec<&'static str> {
587 validate_registry().lock().unwrap().keys().copied().collect()
588}
589
590#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
609pub enum RolloutStrategy {
610 Immediate,
612 Recreate,
615 RollingUpdate,
618 BlueGreen,
623 Canary,
626}
627
628pub trait LifecycleProtocol {
629 const STRATEGY: RolloutStrategy;
631 const DRAIN_SECONDS: u32 = 30;
634}
635
636#[derive(Clone, Copy, Debug)]
637pub struct LifecycleHandler {
638 pub keyword: &'static str,
639 pub strategy: RolloutStrategy,
640 pub drain_seconds: u32,
641}
642
643static LIFECYCLE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, LifecycleHandler>>> =
644 OnceLock::new();
645
646fn lifecycle_registry() -> &'static Mutex<HashMap<&'static str, LifecycleHandler>> {
647 LIFECYCLE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
648}
649
650pub fn register_lifecycle<T>()
651where
652 T: TataraDomain + LifecycleProtocol,
653{
654 let handler = LifecycleHandler {
655 keyword: T::KEYWORD,
656 strategy: T::STRATEGY,
657 drain_seconds: T::DRAIN_SECONDS,
658 };
659 lifecycle_registry().lock().unwrap().insert(T::KEYWORD, handler);
660}
661
662#[must_use]
663pub fn lookup_lifecycle(keyword: &str) -> Option<LifecycleHandler> {
664 lifecycle_registry().lock().unwrap().get(keyword).copied()
665}
666
667#[must_use]
668pub fn registered_lifecycle_keywords() -> Vec<&'static str> {
669 lifecycle_registry().lock().unwrap().keys().copied().collect()
670}
671
672#[macro_export]
704macro_rules! capability_layer {
705 (
706 trait $Trait:ident,
707 handler $Handler:ident,
708 static $REGISTRY:ident,
709 registry_fn $registry_fn:ident,
710 register $register:ident,
711 lookup $lookup:ident,
712 list $list:ident,
713 consts {
714 $(const $CONST:ident: $ty:ty => field $field:ident),* $(,)?
715 } $(,)?
716 ) => {
717 pub trait $Trait {
718 $(const $CONST: $ty;)*
719 }
720
721 #[derive(Clone, Copy, Debug)]
722 pub struct $Handler {
723 pub keyword: &'static str,
724 $(pub $field: $ty,)*
725 }
726
727 static $REGISTRY: ::std::sync::OnceLock<
728 ::std::sync::Mutex<::std::collections::HashMap<&'static str, $Handler>>
729 > = ::std::sync::OnceLock::new();
730
731 fn $registry_fn() -> &'static ::std::sync::Mutex<
732 ::std::collections::HashMap<&'static str, $Handler>
733 > {
734 $REGISTRY.get_or_init(|| {
735 ::std::sync::Mutex::new(::std::collections::HashMap::new())
736 })
737 }
738
739 pub fn $register<T>()
740 where
741 T: $crate::domain::TataraDomain + $Trait,
742 {
743 let handler = $Handler {
744 keyword: T::KEYWORD,
745 $($field: T::$CONST,)*
746 };
747 $registry_fn().lock().unwrap().insert(T::KEYWORD, handler);
748 }
749
750 #[must_use]
751 pub fn $lookup(keyword: &str) -> Option<$Handler> {
752 $registry_fn().lock().unwrap().get(keyword).copied()
753 }
754
755 #[must_use]
756 pub fn $list() -> Vec<&'static str> {
757 $registry_fn().lock().unwrap().keys().copied().collect()
758 }
759 };
760}
761
762capability_layer! {
771 trait CompliantDomain,
772 handler ComplianceHandler,
773 static COMPLIANCE_REGISTRY,
774 registry_fn compliance_registry,
775 register register_compliance,
776 lookup lookup_compliance,
777 list registered_compliance_keywords,
778 consts {
779 const FRAMEWORKS: &'static [&'static str] => field frameworks,
780 const CONTROLS: &'static [&'static str] => field controls,
781 }
782}
783
784capability_layer! {
792 trait ObservableDomain,
793 handler ObservabilityHandler,
794 static OBSERVABILITY_REGISTRY,
795 registry_fn observability_registry,
796 register register_observability,
797 lookup lookup_observability,
798 list registered_observability_keywords,
799 consts {
800 const METRIC_PREFIX: &'static str => field metric_prefix,
801 const LOG_LABELS: &'static [&'static str] => field log_labels,
802 }
803}
804
805capability_layer! {
813 trait HelpDomain,
814 handler HelpHandler,
815 static HELP_REGISTRY,
816 registry_fn help_registry,
817 register register_help,
818 lookup lookup_help,
819 list registered_help_keywords,
820 consts {
821 const MNEMONIC: &'static str => field mnemonic,
822 const EXAMPLES: &'static [&'static str] => field examples,
823 }
824}
825
826capability_layer! {
834 trait StableDomain,
835 handler StabilityHandler,
836 static STABILITY_REGISTRY,
837 registry_fn stability_registry,
838 register register_stability,
839 lookup lookup_stability,
840 list registered_stability_keywords,
841 consts {
842 const STABILITY: &'static str => field stability,
843 const SINCE_VERSION: &'static str => field since_version,
844 }
845}
846
847#[macro_export]
869macro_rules! impl_default_capabilities {
870 ($Spec:ty) => {
871 impl $crate::domain::DependentDomain for $Spec {
879 const DEPENDS_ON: &'static [&'static str] = &[];
880 }
881 impl $crate::domain::ValidatedDomain for $Spec {}
883 impl $crate::domain::LifecycleProtocol for $Spec {
885 const STRATEGY: $crate::domain::RolloutStrategy =
886 $crate::domain::RolloutStrategy::Immediate;
887 }
888 impl $crate::domain::CompliantDomain for $Spec {
890 const FRAMEWORKS: &'static [&'static str] = &[];
891 const CONTROLS: &'static [&'static str] = &[];
892 }
893 impl $crate::domain::ObservableDomain for $Spec {
895 const METRIC_PREFIX: &'static str = "";
896 const LOG_LABELS: &'static [&'static str] = &[];
897 }
898 impl $crate::domain::HelpDomain for $Spec {
900 const MNEMONIC: &'static str = "";
901 const EXAMPLES: &'static [&'static str] = &[];
902 }
903 impl $crate::domain::StableDomain for $Spec {
906 const STABILITY: &'static str = "stable";
907 const SINCE_VERSION: &'static str = "0.1.0";
908 }
909 };
910}
911
912#[macro_export]
920macro_rules! register_all_capabilities {
921 ($Spec:ty) => {
922 $crate::domain::register::<$Spec>();
923 $crate::domain::register_doc::<$Spec>();
924 $crate::domain::register_deps::<$Spec>();
925 $crate::domain::register_validate::<$Spec>();
926 $crate::domain::register_lifecycle::<$Spec>();
927 $crate::domain::register_compliance::<$Spec>();
928 $crate::domain::register_observability::<$Spec>();
929 $crate::domain::register_help::<$Spec>();
930 $crate::domain::register_stability::<$Spec>();
931 };
932}
933
934use crate::ast::Atom;
941use serde_json::Value as JValue;
942
943pub fn sexp_to_json(s: &Sexp) -> JValue {
953 match s {
954 Sexp::Nil => JValue::Null,
955 Sexp::Atom(Atom::Symbol(s)) => JValue::String(s.clone()),
956 Sexp::Atom(Atom::Keyword(s)) => JValue::String(format!(":{s}")),
957 Sexp::Atom(Atom::Str(s)) => JValue::String(s.clone()),
958 Sexp::Atom(Atom::Int(n)) => JValue::Number((*n).into()),
959 Sexp::Atom(Atom::Float(n)) => serde_json::Number::from_f64(*n)
960 .map(JValue::Number)
961 .unwrap_or(JValue::Null),
962 Sexp::Atom(Atom::Bool(b)) => JValue::Bool(*b),
963 Sexp::List(items) => {
964 if is_kwargs_list(items) {
965 let mut map = serde_json::Map::with_capacity(items.len() / 2);
966 let mut i = 0;
967 while i + 1 < items.len() {
968 if let Some(k) = items[i].as_keyword() {
969 map.insert(kebab_to_camel(k), sexp_to_json(&items[i + 1]));
970 i += 2;
971 } else {
972 break;
973 }
974 }
975 JValue::Object(map)
976 } else {
977 JValue::Array(items.iter().map(sexp_to_json).collect())
978 }
979 }
980 Sexp::Quote(inner)
981 | Sexp::Quasiquote(inner)
982 | Sexp::Unquote(inner)
983 | Sexp::UnquoteSplice(inner) => sexp_to_json(inner),
984 }
985}
986
987pub fn json_to_sexp(v: &JValue) -> Sexp {
990 match v {
991 JValue::Null => Sexp::Nil,
992 JValue::Bool(b) => Sexp::boolean(*b),
993 JValue::Number(n) => {
994 if let Some(i) = n.as_i64() {
995 Sexp::int(i)
996 } else if let Some(f) = n.as_f64() {
997 Sexp::float(f)
998 } else {
999 Sexp::int(0)
1000 }
1001 }
1002 JValue::String(s) => Sexp::string(s.clone()),
1003 JValue::Array(items) => Sexp::List(items.iter().map(json_to_sexp).collect()),
1004 JValue::Object(map) => {
1005 let mut out = Vec::with_capacity(map.len() * 2);
1006 for (k, v) in map {
1007 out.push(Sexp::keyword(camel_to_kebab(k)));
1008 out.push(json_to_sexp(v));
1009 }
1010 Sexp::List(out)
1011 }
1012 }
1013}
1014
1015fn is_kwargs_list(items: &[Sexp]) -> bool {
1016 !items.is_empty()
1017 && items.len() % 2 == 0
1018 && items.iter().step_by(2).all(|s| s.as_keyword().is_some())
1019}
1020
1021fn kebab_to_camel(s: &str) -> String {
1023 let mut out = String::with_capacity(s.len());
1024 let mut upper = false;
1025 for c in s.chars() {
1026 if c == '-' {
1027 upper = true;
1028 } else if upper {
1029 out.extend(c.to_uppercase());
1030 upper = false;
1031 } else {
1032 out.push(c);
1033 }
1034 }
1035 out
1036}
1037
1038fn camel_to_kebab(s: &str) -> String {
1040 let mut out = String::with_capacity(s.len() + 2);
1041 for (i, c) in s.chars().enumerate() {
1042 if c.is_uppercase() && i > 0 {
1043 out.push('-');
1044 out.extend(c.to_lowercase());
1045 } else {
1046 out.push(c);
1047 }
1048 }
1049 out
1050}
1051
1052pub fn rewrite_typed<T, F>(input: T, rewrite: F) -> Result<T>
1066where
1067 T: TataraDomain + serde::Serialize,
1068 F: FnOnce(Sexp) -> Result<Sexp>,
1069{
1070 let json = serde_json::to_value(&input).map_err(|e| LispError::Compile {
1071 form: T::KEYWORD.to_string(),
1072 message: format!("serialize {}: {e}", T::KEYWORD),
1073 })?;
1074 let sexp = json_to_sexp(&json);
1075 let rewritten = rewrite(sexp)?;
1076 let args = match rewritten {
1077 Sexp::List(items) => items,
1078 other => {
1079 return Err(LispError::Compile {
1080 form: T::KEYWORD.to_string(),
1081 message: format!("rewriter must return a list; got {other}"),
1082 })
1083 }
1084 };
1085 T::compile_from_args(&args)
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091 use crate::reader::read;
1092 use serde::Serialize;
1093 use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
1094
1095 #[derive(DeriveTataraDomain, Serialize, Debug, PartialEq)]
1098 #[tatara(keyword = "defmonitor")]
1099 struct MonitorSpec {
1100 name: String,
1101 query: String,
1102 threshold: f64,
1103 window_seconds: Option<i64>,
1104 tags: Vec<String>,
1105 enabled: Option<bool>,
1106 }
1107
1108 #[test]
1109 fn derive_emits_correct_keyword() {
1110 assert_eq!(MonitorSpec::KEYWORD, "defmonitor");
1111 }
1112
1113 #[test]
1114 fn derive_compiles_full_form() {
1115 let forms = read(
1116 r#"(defmonitor
1117 :name "prom-up"
1118 :query "up{job='prometheus'}"
1119 :threshold 0.99
1120 :window-seconds 300
1121 :tags ("prod" "observability")
1122 :enabled #t)"#,
1123 )
1124 .unwrap();
1125 let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
1126 assert_eq!(
1127 spec,
1128 MonitorSpec {
1129 name: "prom-up".into(),
1130 query: "up{job='prometheus'}".into(),
1131 threshold: 0.99,
1132 window_seconds: Some(300),
1133 tags: vec!["prod".into(), "observability".into()],
1134 enabled: Some(true),
1135 }
1136 );
1137 }
1138
1139 #[test]
1140 fn derive_accepts_missing_optionals() {
1141 let forms = read(r#"(defmonitor :name "x" :query "q" :threshold 0.5)"#).unwrap();
1142 let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
1143 assert_eq!(spec.name, "x");
1144 assert!(spec.window_seconds.is_none());
1145 assert!(spec.enabled.is_none());
1146 assert!(spec.tags.is_empty());
1147 }
1148
1149 #[test]
1150 fn derive_errors_on_missing_required() {
1151 let forms = read(r#"(defmonitor :name "x" :query "q")"#).unwrap();
1152 assert!(MonitorSpec::compile_from_sexp(&forms[0]).is_err());
1153 }
1154
1155 #[test]
1156 fn derive_errors_on_wrong_head() {
1157 let forms = read(r#"(not-a-monitor :name "x")"#).unwrap();
1158 let err = MonitorSpec::compile_from_sexp(&forms[0]).unwrap_err();
1159 assert!(format!("{err}").contains("expected (defmonitor"));
1160 }
1161
1162 #[test]
1163 fn registry_dispatches_by_keyword() {
1164 register::<MonitorSpec>();
1165 assert!(registered_keywords().contains(&"defmonitor"));
1166 let handler = lookup("defmonitor").expect("registered");
1167 assert_eq!(handler.keyword, "defmonitor");
1168 let forms = read(r#"(ignored :name "prom" :query "q" :threshold 0.5)"#).unwrap();
1169 let args = forms[0].as_list().unwrap();
1170 let json = (handler.compile)(&args[1..]).unwrap();
1171 assert_eq!(json["name"], "prom");
1172 assert_eq!(json["query"], "q");
1173 assert_eq!(json["threshold"], 0.5);
1174 }
1175}