1use std::collections::HashMap;
12use std::sync::{Mutex, OnceLock};
13
14use crate::ast::Sexp;
15use crate::error::{LispError, Result};
16
17pub trait KeywordSexp: Sized {
22 fn from_keyword(s: &str) -> Result<Self>;
24 fn to_keyword(self) -> &'static str;
26}
27
28pub trait TataraDomain: Sized {
30 const KEYWORD: &'static str;
32
33 fn compile_from_args(args: &[Sexp]) -> Result<Self>;
35
36 fn compile_from_sexp(form: &Sexp) -> Result<Self> {
38 let list = form.as_list().ok_or_else(|| LispError::Compile {
39 form: Self::KEYWORD.to_string(),
40 message: "expected list form".into(),
41 })?;
42 let head = list
43 .first()
44 .and_then(|s| s.as_symbol())
45 .ok_or_else(|| LispError::Compile {
46 form: Self::KEYWORD.to_string(),
47 message: "missing head symbol".into(),
48 })?;
49 if head != Self::KEYWORD {
50 return Err(LispError::Compile {
51 form: Self::KEYWORD.to_string(),
52 message: format!("expected ({} ...), got ({} ...)", Self::KEYWORD, head),
53 });
54 }
55 Self::compile_from_args(&list[1..])
56 }
57}
58
59pub type Kwargs<'a> = HashMap<String, &'a Sexp>;
62
63pub fn parse_kwargs(args: &[Sexp]) -> Result<Kwargs<'_>> {
64 let mut kw = HashMap::new();
65 let mut i = 0;
66 while i + 1 < args.len() {
67 let key = args[i].as_keyword().ok_or_else(|| LispError::Compile {
68 form: "kwargs".into(),
69 message: format!("expected keyword at position {i}"),
70 })?;
71 kw.insert(key.to_string(), &args[i + 1]);
72 i += 2;
73 }
74 if i < args.len() {
75 return Err(LispError::OddKwargs);
76 }
77 Ok(kw)
78}
79
80pub fn required<'a>(kw: &'a Kwargs<'_>, key: &str) -> Result<&'a Sexp> {
81 kw.get(key).copied().ok_or_else(|| LispError::Compile {
82 form: format!(":{key}"),
83 message: "required but not provided".into(),
84 })
85}
86
87fn type_err(key: &str, expected: &str) -> LispError {
88 LispError::Compile {
89 form: format!(":{key}"),
90 message: format!("expected {expected}"),
91 }
92}
93
94pub fn extract_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<&'a str> {
95 required(kw, key)?
96 .as_string()
97 .ok_or_else(|| type_err(key, "string"))
98}
99
100pub fn extract_optional_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<Option<&'a str>> {
101 match kw.get(key) {
102 None => Ok(None),
103 Some(v) => match v.as_string() {
104 Some(s) => Ok(Some(s)),
105 None => Err(type_err(key, "string")),
106 },
107 }
108}
109
110pub fn extract_string_list(kw: &Kwargs<'_>, key: &str) -> Result<Vec<String>> {
111 let v = kw.get(key).copied();
112 let Some(v) = v else {
113 return Ok(vec![]);
114 };
115 let list = v
116 .as_list()
117 .ok_or_else(|| type_err(key, "list of strings"))?;
118 list.iter()
119 .map(|s| {
120 s.as_string()
121 .map(String::from)
122 .ok_or_else(|| type_err(key, "list of strings"))
123 })
124 .collect()
125}
126
127pub fn extract_int(kw: &Kwargs<'_>, key: &str) -> Result<i64> {
128 required(kw, key)?
129 .as_int()
130 .ok_or_else(|| type_err(key, "int"))
131}
132
133pub fn extract_optional_int(kw: &Kwargs<'_>, key: &str) -> Result<Option<i64>> {
134 match kw.get(key) {
135 None => Ok(None),
136 Some(v) => v.as_int().map(Some).ok_or_else(|| type_err(key, "int")),
137 }
138}
139
140pub fn extract_float(kw: &Kwargs<'_>, key: &str) -> Result<f64> {
141 required(kw, key)?
142 .as_float()
143 .ok_or_else(|| type_err(key, "number"))
144}
145
146pub fn extract_optional_float(kw: &Kwargs<'_>, key: &str) -> Result<Option<f64>> {
147 match kw.get(key) {
148 None => Ok(None),
149 Some(v) => v
150 .as_float()
151 .map(Some)
152 .ok_or_else(|| type_err(key, "number")),
153 }
154}
155
156pub fn extract_bool(kw: &Kwargs<'_>, key: &str) -> Result<bool> {
157 required(kw, key)?
158 .as_bool()
159 .ok_or_else(|| type_err(key, "bool"))
160}
161
162pub fn extract_optional_bool(kw: &Kwargs<'_>, key: &str) -> Result<Option<bool>> {
163 match kw.get(key) {
164 None => Ok(None),
165 Some(v) => v.as_bool().map(Some).ok_or_else(|| type_err(key, "bool")),
166 }
167}
168
169#[must_use]
211pub fn suggest<'a>(needle: &str, candidates: &[&'a str]) -> Option<&'a str> {
212 let bound = suggestion_bound(needle);
213 let mut best: Option<(usize, &'a str)> = None;
214 for &candidate in candidates {
215 if candidate == needle {
216 continue;
217 }
218 let dist = levenshtein(needle, candidate);
219 if dist > bound {
220 continue;
221 }
222 match best {
223 None => best = Some((dist, candidate)),
224 Some((bd, bc)) if dist < bd || (dist == bd && candidate < bc) => {
225 best = Some((dist, candidate));
226 }
227 _ => {}
228 }
229 }
230 best.map(|(_, c)| c)
231}
232
233fn suggestion_bound(needle: &str) -> usize {
234 let n = needle.chars().count();
235 if n <= 3 {
236 1
237 } else if n <= 7 {
238 2
239 } else {
240 3
241 }
242}
243
244fn levenshtein(a: &str, b: &str) -> usize {
248 let a: Vec<char> = a.chars().collect();
249 let b: Vec<char> = b.chars().collect();
250 if a.is_empty() {
251 return b.len();
252 }
253 if b.is_empty() {
254 return a.len();
255 }
256 let mut prev: Vec<usize> = (0..=b.len()).collect();
257 let mut curr: Vec<usize> = vec![0; b.len() + 1];
258 for (i, ca) in a.iter().enumerate() {
259 curr[0] = i + 1;
260 for (j, cb) in b.iter().enumerate() {
261 let cost = usize::from(ca != cb);
262 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
263 }
264 std::mem::swap(&mut prev, &mut curr);
265 }
266 prev[b.len()]
267}
268
269pub struct DomainHandler {
275 pub keyword: &'static str,
276 pub compile: fn(args: &[Sexp]) -> Result<serde_json::Value>,
277}
278
279static REGISTRY: OnceLock<Mutex<HashMap<&'static str, DomainHandler>>> = OnceLock::new();
280
281fn registry() -> &'static Mutex<HashMap<&'static str, DomainHandler>> {
282 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
283}
284
285pub fn register<T>()
288where
289 T: TataraDomain + serde::Serialize,
290{
291 let handler = DomainHandler {
292 keyword: T::KEYWORD,
293 compile: |args| {
294 let v = T::compile_from_args(args)?;
295 serde_json::to_value(&v).map_err(|e| LispError::Compile {
296 form: T::KEYWORD.to_string(),
297 message: format!("serialize: {e}"),
298 })
299 },
300 };
301 registry().lock().unwrap().insert(T::KEYWORD, handler);
302}
303
304pub fn lookup(keyword: &str) -> Option<DomainHandler> {
306 let reg = registry().lock().unwrap();
307 reg.get(keyword).map(|h| DomainHandler {
308 keyword: h.keyword,
309 compile: h.compile,
310 })
311}
312
313pub fn registered_keywords() -> Vec<&'static str> {
315 registry().lock().unwrap().keys().copied().collect()
316}
317
318pub trait RenderableDomain {
336 const API_VERSION: &'static str;
339 const KIND: &'static str;
341 const NAME_FIELD: &'static str = "name";
345}
346
347#[derive(Clone, Copy, Debug)]
349pub struct RenderHandler {
350 pub keyword: &'static str,
351 pub api_version: &'static str,
352 pub kind: &'static str,
353 pub name_field: &'static str,
354}
355
356static RENDER_REGISTRY: OnceLock<Mutex<HashMap<&'static str, RenderHandler>>> = OnceLock::new();
357
358fn render_registry() -> &'static Mutex<HashMap<&'static str, RenderHandler>> {
359 RENDER_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
360}
361
362pub fn register_render<T>()
365where
366 T: TataraDomain + RenderableDomain,
367{
368 let handler = RenderHandler {
369 keyword: T::KEYWORD,
370 api_version: T::API_VERSION,
371 kind: T::KIND,
372 name_field: T::NAME_FIELD,
373 };
374 render_registry().lock().unwrap().insert(T::KEYWORD, handler);
375}
376
377#[must_use]
379pub fn lookup_render(keyword: &str) -> Option<RenderHandler> {
380 render_registry().lock().unwrap().get(keyword).copied()
381}
382
383#[must_use]
385pub fn registered_render_keywords() -> Vec<&'static str> {
386 render_registry().lock().unwrap().keys().copied().collect()
387}
388
389pub trait DocumentedDomain {
400 const DOCSTRING: &'static str;
403 const FIELD_DOCS: &'static [(&'static str, &'static str)];
408}
409
410#[derive(Clone, Copy, Debug)]
412pub struct DocHandler {
413 pub keyword: &'static str,
414 pub docstring: &'static str,
415 pub field_docs: &'static [(&'static str, &'static str)],
416}
417
418static DOC_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DocHandler>>> = OnceLock::new();
419
420fn doc_registry() -> &'static Mutex<HashMap<&'static str, DocHandler>> {
421 DOC_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
422}
423
424pub fn register_doc<T>()
426where
427 T: TataraDomain + DocumentedDomain,
428{
429 let handler = DocHandler {
430 keyword: T::KEYWORD,
431 docstring: T::DOCSTRING,
432 field_docs: T::FIELD_DOCS,
433 };
434 doc_registry().lock().unwrap().insert(T::KEYWORD, handler);
435}
436
437#[must_use]
439pub fn lookup_doc(keyword: &str) -> Option<DocHandler> {
440 doc_registry().lock().unwrap().get(keyword).copied()
441}
442
443#[must_use]
445pub fn registered_doc_keywords() -> Vec<&'static str> {
446 doc_registry().lock().unwrap().keys().copied().collect()
447}
448
449pub trait DependentDomain {
465 const DEPENDS_ON: &'static [&'static str];
470}
471
472#[derive(Clone, Copy, Debug)]
474pub struct DepsHandler {
475 pub keyword: &'static str,
476 pub depends_on: &'static [&'static str],
477}
478
479static DEPS_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DepsHandler>>> = OnceLock::new();
480
481fn deps_registry() -> &'static Mutex<HashMap<&'static str, DepsHandler>> {
482 DEPS_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
483}
484
485pub fn register_deps<T>()
487where
488 T: TataraDomain + DependentDomain,
489{
490 let handler = DepsHandler {
491 keyword: T::KEYWORD,
492 depends_on: T::DEPENDS_ON,
493 };
494 deps_registry().lock().unwrap().insert(T::KEYWORD, handler);
495}
496
497#[must_use]
499pub fn lookup_deps(keyword: &str) -> Option<DepsHandler> {
500 deps_registry().lock().unwrap().get(keyword).copied()
501}
502
503#[must_use]
505pub fn registered_deps_keywords() -> Vec<&'static str> {
506 deps_registry().lock().unwrap().keys().copied().collect()
507}
508
509pub trait SchematicDomain {
520 const SCHEMA_JSON: &'static str;
526}
527
528#[derive(Clone, Copy, Debug)]
529pub struct SchemaHandler {
530 pub keyword: &'static str,
531 pub schema_json: &'static str,
532}
533
534static SCHEMA_REGISTRY: OnceLock<Mutex<HashMap<&'static str, SchemaHandler>>> = OnceLock::new();
535
536fn schema_registry() -> &'static Mutex<HashMap<&'static str, SchemaHandler>> {
537 SCHEMA_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
538}
539
540pub fn register_schema<T>()
541where
542 T: TataraDomain + SchematicDomain,
543{
544 let handler = SchemaHandler {
545 keyword: T::KEYWORD,
546 schema_json: T::SCHEMA_JSON,
547 };
548 schema_registry().lock().unwrap().insert(T::KEYWORD, handler);
549}
550
551#[must_use]
552pub fn lookup_schema(keyword: &str) -> Option<SchemaHandler> {
553 schema_registry().lock().unwrap().get(keyword).copied()
554}
555
556#[must_use]
557pub fn registered_schema_keywords() -> Vec<&'static str> {
558 schema_registry().lock().unwrap().keys().copied().collect()
559}
560
561pub trait AttestableDomain {
572 const ATTESTATION_NAMESPACE: &'static str;
579}
580
581#[derive(Clone, Copy, Debug)]
582pub struct AttestHandler {
583 pub keyword: &'static str,
584 pub namespace: &'static str,
585}
586
587static ATTEST_REGISTRY: OnceLock<Mutex<HashMap<&'static str, AttestHandler>>> = OnceLock::new();
588
589fn attest_registry() -> &'static Mutex<HashMap<&'static str, AttestHandler>> {
590 ATTEST_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
591}
592
593pub fn register_attest<T>()
594where
595 T: TataraDomain + AttestableDomain,
596{
597 let handler = AttestHandler {
598 keyword: T::KEYWORD,
599 namespace: T::ATTESTATION_NAMESPACE,
600 };
601 attest_registry().lock().unwrap().insert(T::KEYWORD, handler);
602}
603
604#[must_use]
605pub fn lookup_attest(keyword: &str) -> Option<AttestHandler> {
606 attest_registry().lock().unwrap().get(keyword).copied()
607}
608
609#[must_use]
610pub fn registered_attest_keywords() -> Vec<&'static str> {
611 attest_registry().lock().unwrap().keys().copied().collect()
612}
613
614#[must_use]
625pub fn attest_value(namespace: &str, value: &serde_json::Value) -> String {
626 let canonical = serde_json::to_string(value).unwrap_or_default();
627 let mut hasher = blake3::Hasher::new();
628 hasher.update(namespace.as_bytes());
629 hasher.update(b":");
630 hasher.update(canonical.as_bytes());
631 hasher.finalize().to_hex().to_string()
632}
633
634pub trait ValidatedDomain {
649 fn validate_value(_value: &serde_json::Value) -> std::result::Result<(), String> {
654 Ok(())
655 }
656}
657
658#[derive(Clone, Copy)]
660pub struct ValidateHandler {
661 pub keyword: &'static str,
662 pub validate: fn(&serde_json::Value) -> std::result::Result<(), String>,
663}
664
665impl std::fmt::Debug for ValidateHandler {
666 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667 f.debug_struct("ValidateHandler")
668 .field("keyword", &self.keyword)
669 .field("validate", &"<fn>")
670 .finish()
671 }
672}
673
674static VALIDATE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, ValidateHandler>>> = OnceLock::new();
675
676fn validate_registry() -> &'static Mutex<HashMap<&'static str, ValidateHandler>> {
677 VALIDATE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
678}
679
680pub fn register_validate<T>()
681where
682 T: TataraDomain + ValidatedDomain,
683{
684 let handler = ValidateHandler {
685 keyword: T::KEYWORD,
686 validate: <T as ValidatedDomain>::validate_value,
687 };
688 validate_registry().lock().unwrap().insert(T::KEYWORD, handler);
689}
690
691#[must_use]
692pub fn lookup_validate(keyword: &str) -> Option<ValidateHandler> {
693 validate_registry().lock().unwrap().get(keyword).copied()
694}
695
696#[must_use]
697pub fn registered_validate_keywords() -> Vec<&'static str> {
698 validate_registry().lock().unwrap().keys().copied().collect()
699}
700
701#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
720pub enum RolloutStrategy {
721 Immediate,
723 Recreate,
726 RollingUpdate,
729 BlueGreen,
734 Canary,
737}
738
739pub trait LifecycleProtocol {
740 const STRATEGY: RolloutStrategy;
742 const DRAIN_SECONDS: u32 = 30;
745}
746
747#[derive(Clone, Copy, Debug)]
748pub struct LifecycleHandler {
749 pub keyword: &'static str,
750 pub strategy: RolloutStrategy,
751 pub drain_seconds: u32,
752}
753
754static LIFECYCLE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, LifecycleHandler>>> =
755 OnceLock::new();
756
757fn lifecycle_registry() -> &'static Mutex<HashMap<&'static str, LifecycleHandler>> {
758 LIFECYCLE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
759}
760
761pub fn register_lifecycle<T>()
762where
763 T: TataraDomain + LifecycleProtocol,
764{
765 let handler = LifecycleHandler {
766 keyword: T::KEYWORD,
767 strategy: T::STRATEGY,
768 drain_seconds: T::DRAIN_SECONDS,
769 };
770 lifecycle_registry().lock().unwrap().insert(T::KEYWORD, handler);
771}
772
773#[must_use]
774pub fn lookup_lifecycle(keyword: &str) -> Option<LifecycleHandler> {
775 lifecycle_registry().lock().unwrap().get(keyword).copied()
776}
777
778#[must_use]
779pub fn registered_lifecycle_keywords() -> Vec<&'static str> {
780 lifecycle_registry().lock().unwrap().keys().copied().collect()
781}
782
783#[macro_export]
815macro_rules! capability_layer {
816 (
817 trait $Trait:ident,
818 handler $Handler:ident,
819 static $REGISTRY:ident,
820 registry_fn $registry_fn:ident,
821 register $register:ident,
822 lookup $lookup:ident,
823 list $list:ident,
824 consts {
825 $(const $CONST:ident: $ty:ty => field $field:ident),* $(,)?
826 } $(,)?
827 ) => {
828 pub trait $Trait {
829 $(const $CONST: $ty;)*
830 }
831
832 #[derive(Clone, Copy, Debug)]
833 pub struct $Handler {
834 pub keyword: &'static str,
835 $(pub $field: $ty,)*
836 }
837
838 static $REGISTRY: ::std::sync::OnceLock<
839 ::std::sync::Mutex<::std::collections::HashMap<&'static str, $Handler>>
840 > = ::std::sync::OnceLock::new();
841
842 fn $registry_fn() -> &'static ::std::sync::Mutex<
843 ::std::collections::HashMap<&'static str, $Handler>
844 > {
845 $REGISTRY.get_or_init(|| {
846 ::std::sync::Mutex::new(::std::collections::HashMap::new())
847 })
848 }
849
850 pub fn $register<T>()
851 where
852 T: $crate::domain::TataraDomain + $Trait,
853 {
854 let handler = $Handler {
855 keyword: T::KEYWORD,
856 $($field: T::$CONST,)*
857 };
858 $registry_fn().lock().unwrap().insert(T::KEYWORD, handler);
859 }
860
861 #[must_use]
862 pub fn $lookup(keyword: &str) -> Option<$Handler> {
863 $registry_fn().lock().unwrap().get(keyword).copied()
864 }
865
866 #[must_use]
867 pub fn $list() -> Vec<&'static str> {
868 $registry_fn().lock().unwrap().keys().copied().collect()
869 }
870 };
871}
872
873capability_layer! {
882 trait CompliantDomain,
883 handler ComplianceHandler,
884 static COMPLIANCE_REGISTRY,
885 registry_fn compliance_registry,
886 register register_compliance,
887 lookup lookup_compliance,
888 list registered_compliance_keywords,
889 consts {
890 const FRAMEWORKS: &'static [&'static str] => field frameworks,
891 const CONTROLS: &'static [&'static str] => field controls,
892 }
893}
894
895capability_layer! {
903 trait ObservableDomain,
904 handler ObservabilityHandler,
905 static OBSERVABILITY_REGISTRY,
906 registry_fn observability_registry,
907 register register_observability,
908 lookup lookup_observability,
909 list registered_observability_keywords,
910 consts {
911 const METRIC_PREFIX: &'static str => field metric_prefix,
912 const LOG_LABELS: &'static [&'static str] => field log_labels,
913 }
914}
915
916capability_layer! {
924 trait HelpDomain,
925 handler HelpHandler,
926 static HELP_REGISTRY,
927 registry_fn help_registry,
928 register register_help,
929 lookup lookup_help,
930 list registered_help_keywords,
931 consts {
932 const MNEMONIC: &'static str => field mnemonic,
933 const EXAMPLES: &'static [&'static str] => field examples,
934 }
935}
936
937capability_layer! {
945 trait StableDomain,
946 handler StabilityHandler,
947 static STABILITY_REGISTRY,
948 registry_fn stability_registry,
949 register register_stability,
950 lookup lookup_stability,
951 list registered_stability_keywords,
952 consts {
953 const STABILITY: &'static str => field stability,
954 const SINCE_VERSION: &'static str => field since_version,
955 }
956}
957
958#[macro_export]
980macro_rules! impl_default_capabilities {
981 ($Spec:ty) => {
982 impl $crate::domain::DependentDomain for $Spec {
990 const DEPENDS_ON: &'static [&'static str] = &[];
991 }
992 impl $crate::domain::ValidatedDomain for $Spec {}
994 impl $crate::domain::LifecycleProtocol for $Spec {
996 const STRATEGY: $crate::domain::RolloutStrategy =
997 $crate::domain::RolloutStrategy::Immediate;
998 }
999 impl $crate::domain::CompliantDomain for $Spec {
1001 const FRAMEWORKS: &'static [&'static str] = &[];
1002 const CONTROLS: &'static [&'static str] = &[];
1003 }
1004 impl $crate::domain::ObservableDomain for $Spec {
1006 const METRIC_PREFIX: &'static str = "";
1007 const LOG_LABELS: &'static [&'static str] = &[];
1008 }
1009 impl $crate::domain::HelpDomain for $Spec {
1011 const MNEMONIC: &'static str = "";
1012 const EXAMPLES: &'static [&'static str] = &[];
1013 }
1014 impl $crate::domain::StableDomain for $Spec {
1017 const STABILITY: &'static str = "stable";
1018 const SINCE_VERSION: &'static str = "0.1.0";
1019 }
1020 };
1021}
1022
1023#[macro_export]
1031macro_rules! register_all_capabilities {
1032 ($Spec:ty) => {
1033 $crate::domain::register::<$Spec>();
1034 $crate::domain::register_doc::<$Spec>();
1035 $crate::domain::register_deps::<$Spec>();
1036 $crate::domain::register_validate::<$Spec>();
1037 $crate::domain::register_lifecycle::<$Spec>();
1038 $crate::domain::register_compliance::<$Spec>();
1039 $crate::domain::register_observability::<$Spec>();
1040 $crate::domain::register_help::<$Spec>();
1041 $crate::domain::register_stability::<$Spec>();
1042 };
1043}
1044
1045use crate::ast::Atom;
1052use serde_json::Value as JValue;
1053
1054pub fn sexp_to_json(s: &Sexp) -> JValue {
1064 match s {
1065 Sexp::Nil => JValue::Null,
1066 Sexp::Atom(Atom::Symbol(s)) => JValue::String(s.clone()),
1067 Sexp::Atom(Atom::Keyword(s)) => JValue::String(format!(":{s}")),
1068 Sexp::Atom(Atom::Str(s)) => JValue::String(s.clone()),
1069 Sexp::Atom(Atom::Int(n)) => JValue::Number((*n).into()),
1070 Sexp::Atom(Atom::Float(n)) => serde_json::Number::from_f64(*n)
1071 .map(JValue::Number)
1072 .unwrap_or(JValue::Null),
1073 Sexp::Atom(Atom::Bool(b)) => JValue::Bool(*b),
1074 Sexp::List(items) => {
1075 if is_kwargs_list(items) {
1076 let mut map = serde_json::Map::with_capacity(items.len() / 2);
1077 let mut i = 0;
1078 while i + 1 < items.len() {
1079 if let Some(k) = items[i].as_keyword() {
1080 map.insert(kebab_to_camel(k), sexp_to_json(&items[i + 1]));
1081 i += 2;
1082 } else {
1083 break;
1084 }
1085 }
1086 JValue::Object(map)
1087 } else {
1088 JValue::Array(items.iter().map(sexp_to_json).collect())
1089 }
1090 }
1091 Sexp::Quote(inner)
1092 | Sexp::Quasiquote(inner)
1093 | Sexp::Unquote(inner)
1094 | Sexp::UnquoteSplice(inner) => sexp_to_json(inner),
1095 }
1096}
1097
1098pub fn json_to_sexp(v: &JValue) -> Sexp {
1101 match v {
1102 JValue::Null => Sexp::Nil,
1103 JValue::Bool(b) => Sexp::boolean(*b),
1104 JValue::Number(n) => {
1105 if let Some(i) = n.as_i64() {
1106 Sexp::int(i)
1107 } else if let Some(f) = n.as_f64() {
1108 Sexp::float(f)
1109 } else {
1110 Sexp::int(0)
1111 }
1112 }
1113 JValue::String(s) => Sexp::string(s.clone()),
1114 JValue::Array(items) => Sexp::List(items.iter().map(json_to_sexp).collect()),
1115 JValue::Object(map) => {
1116 let mut out = Vec::with_capacity(map.len() * 2);
1117 for (k, v) in map {
1118 out.push(Sexp::keyword(camel_to_kebab(k)));
1119 out.push(json_to_sexp(v));
1120 }
1121 Sexp::List(out)
1122 }
1123 }
1124}
1125
1126fn is_kwargs_list(items: &[Sexp]) -> bool {
1127 !items.is_empty()
1128 && items.len() % 2 == 0
1129 && items.iter().step_by(2).all(|s| s.as_keyword().is_some())
1130}
1131
1132fn kebab_to_camel(s: &str) -> String {
1134 let mut out = String::with_capacity(s.len());
1135 let mut upper = false;
1136 for c in s.chars() {
1137 if c == '-' {
1138 upper = true;
1139 } else if upper {
1140 out.extend(c.to_uppercase());
1141 upper = false;
1142 } else {
1143 out.push(c);
1144 }
1145 }
1146 out
1147}
1148
1149fn camel_to_kebab(s: &str) -> String {
1151 let mut out = String::with_capacity(s.len() + 2);
1152 for (i, c) in s.chars().enumerate() {
1153 if c.is_uppercase() && i > 0 {
1154 out.push('-');
1155 out.extend(c.to_lowercase());
1156 } else {
1157 out.push(c);
1158 }
1159 }
1160 out
1161}
1162
1163pub fn rewrite_typed<T, F>(input: T, rewrite: F) -> Result<T>
1177where
1178 T: TataraDomain + serde::Serialize,
1179 F: FnOnce(Sexp) -> Result<Sexp>,
1180{
1181 let json = serde_json::to_value(&input).map_err(|e| LispError::Compile {
1182 form: T::KEYWORD.to_string(),
1183 message: format!("serialize {}: {e}", T::KEYWORD),
1184 })?;
1185 let sexp = json_to_sexp(&json);
1186 let rewritten = rewrite(sexp)?;
1187 let args = match rewritten {
1188 Sexp::List(items) => items,
1189 other => {
1190 return Err(LispError::Compile {
1191 form: T::KEYWORD.to_string(),
1192 message: format!("rewriter must return a list; got {other}"),
1193 })
1194 }
1195 };
1196 T::compile_from_args(&args)
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201 use super::*;
1202 use crate::reader::read;
1203 use serde::Serialize;
1204 use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
1205
1206 #[derive(DeriveTataraDomain, Serialize, Debug, PartialEq)]
1209 #[tatara(keyword = "defmonitor")]
1210 struct MonitorSpec {
1211 name: String,
1212 query: String,
1213 threshold: f64,
1214 window_seconds: Option<i64>,
1215 tags: Vec<String>,
1216 enabled: Option<bool>,
1217 }
1218
1219 #[test]
1220 fn derive_emits_correct_keyword() {
1221 assert_eq!(MonitorSpec::KEYWORD, "defmonitor");
1222 }
1223
1224 #[test]
1225 fn derive_compiles_full_form() {
1226 let forms = read(
1227 r#"(defmonitor
1228 :name "prom-up"
1229 :query "up{job='prometheus'}"
1230 :threshold 0.99
1231 :window-seconds 300
1232 :tags ("prod" "observability")
1233 :enabled #t)"#,
1234 )
1235 .unwrap();
1236 let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
1237 assert_eq!(
1238 spec,
1239 MonitorSpec {
1240 name: "prom-up".into(),
1241 query: "up{job='prometheus'}".into(),
1242 threshold: 0.99,
1243 window_seconds: Some(300),
1244 tags: vec!["prod".into(), "observability".into()],
1245 enabled: Some(true),
1246 }
1247 );
1248 }
1249
1250 #[test]
1251 fn derive_accepts_missing_optionals() {
1252 let forms = read(r#"(defmonitor :name "x" :query "q" :threshold 0.5)"#).unwrap();
1253 let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
1254 assert_eq!(spec.name, "x");
1255 assert!(spec.window_seconds.is_none());
1256 assert!(spec.enabled.is_none());
1257 assert!(spec.tags.is_empty());
1258 }
1259
1260 #[test]
1261 fn derive_errors_on_missing_required() {
1262 let forms = read(r#"(defmonitor :name "x" :query "q")"#).unwrap();
1263 assert!(MonitorSpec::compile_from_sexp(&forms[0]).is_err());
1264 }
1265
1266 #[test]
1267 fn derive_errors_on_wrong_head() {
1268 let forms = read(r#"(not-a-monitor :name "x")"#).unwrap();
1269 let err = MonitorSpec::compile_from_sexp(&forms[0]).unwrap_err();
1270 assert!(format!("{err}").contains("expected (defmonitor"));
1271 }
1272
1273 #[test]
1274 fn registry_dispatches_by_keyword() {
1275 register::<MonitorSpec>();
1276 assert!(registered_keywords().contains(&"defmonitor"));
1277 let handler = lookup("defmonitor").expect("registered");
1278 assert_eq!(handler.keyword, "defmonitor");
1279 let forms = read(r#"(ignored :name "prom" :query "q" :threshold 0.5)"#).unwrap();
1280 let args = forms[0].as_list().unwrap();
1281 let json = (handler.compile)(&args[1..]).unwrap();
1282 assert_eq!(json["name"], "prom");
1283 assert_eq!(json["query"], "q");
1284 assert_eq!(json["threshold"], 0.5);
1285 }
1286}