use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::ast::Sexp;
use crate::error::{LispError, Result};
pub trait TataraDomain: Sized {
const KEYWORD: &'static str;
fn compile_from_args(args: &[Sexp]) -> Result<Self>;
fn compile_from_sexp(form: &Sexp) -> Result<Self> {
let list = form.as_list().ok_or_else(|| LispError::Compile {
form: Self::KEYWORD.to_string(),
message: "expected list form".into(),
})?;
let head = list
.first()
.and_then(|s| s.as_symbol())
.ok_or_else(|| LispError::Compile {
form: Self::KEYWORD.to_string(),
message: "missing head symbol".into(),
})?;
if head != Self::KEYWORD {
return Err(LispError::Compile {
form: Self::KEYWORD.to_string(),
message: format!("expected ({} ...), got ({} ...)", Self::KEYWORD, head),
});
}
Self::compile_from_args(&list[1..])
}
}
pub type Kwargs<'a> = HashMap<String, &'a Sexp>;
pub fn parse_kwargs(args: &[Sexp]) -> Result<Kwargs<'_>> {
let mut kw = HashMap::new();
let mut i = 0;
while i + 1 < args.len() {
let key = args[i].as_keyword().ok_or_else(|| LispError::Compile {
form: "kwargs".into(),
message: format!("expected keyword at position {i}"),
})?;
kw.insert(key.to_string(), &args[i + 1]);
i += 2;
}
if i < args.len() {
return Err(LispError::OddKwargs);
}
Ok(kw)
}
pub fn required<'a>(kw: &'a Kwargs<'_>, key: &str) -> Result<&'a Sexp> {
kw.get(key).copied().ok_or_else(|| LispError::Compile {
form: format!(":{key}"),
message: "required but not provided".into(),
})
}
fn type_err(key: &str, expected: &str) -> LispError {
LispError::Compile {
form: format!(":{key}"),
message: format!("expected {expected}"),
}
}
pub fn extract_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<&'a str> {
required(kw, key)?
.as_string()
.ok_or_else(|| type_err(key, "string"))
}
pub fn extract_optional_string<'a>(kw: &'a Kwargs<'a>, key: &str) -> Result<Option<&'a str>> {
match kw.get(key) {
None => Ok(None),
Some(v) => match v.as_string() {
Some(s) => Ok(Some(s)),
None => Err(type_err(key, "string")),
},
}
}
pub fn extract_string_list(kw: &Kwargs<'_>, key: &str) -> Result<Vec<String>> {
let v = kw.get(key).copied();
let Some(v) = v else {
return Ok(vec![]);
};
let list = v
.as_list()
.ok_or_else(|| type_err(key, "list of strings"))?;
list.iter()
.map(|s| {
s.as_string()
.map(String::from)
.ok_or_else(|| type_err(key, "list of strings"))
})
.collect()
}
pub fn extract_int(kw: &Kwargs<'_>, key: &str) -> Result<i64> {
required(kw, key)?
.as_int()
.ok_or_else(|| type_err(key, "int"))
}
pub fn extract_optional_int(kw: &Kwargs<'_>, key: &str) -> Result<Option<i64>> {
match kw.get(key) {
None => Ok(None),
Some(v) => v.as_int().map(Some).ok_or_else(|| type_err(key, "int")),
}
}
pub fn extract_float(kw: &Kwargs<'_>, key: &str) -> Result<f64> {
required(kw, key)?
.as_float()
.ok_or_else(|| type_err(key, "number"))
}
pub fn extract_optional_float(kw: &Kwargs<'_>, key: &str) -> Result<Option<f64>> {
match kw.get(key) {
None => Ok(None),
Some(v) => v
.as_float()
.map(Some)
.ok_or_else(|| type_err(key, "number")),
}
}
pub fn extract_bool(kw: &Kwargs<'_>, key: &str) -> Result<bool> {
required(kw, key)?
.as_bool()
.ok_or_else(|| type_err(key, "bool"))
}
pub fn extract_optional_bool(kw: &Kwargs<'_>, key: &str) -> Result<Option<bool>> {
match kw.get(key) {
None => Ok(None),
Some(v) => v.as_bool().map(Some).ok_or_else(|| type_err(key, "bool")),
}
}
pub struct DomainHandler {
pub keyword: &'static str,
pub compile: fn(args: &[Sexp]) -> Result<serde_json::Value>,
}
static REGISTRY: OnceLock<Mutex<HashMap<&'static str, DomainHandler>>> = OnceLock::new();
fn registry() -> &'static Mutex<HashMap<&'static str, DomainHandler>> {
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register<T>()
where
T: TataraDomain + serde::Serialize,
{
let handler = DomainHandler {
keyword: T::KEYWORD,
compile: |args| {
let v = T::compile_from_args(args)?;
serde_json::to_value(&v).map_err(|e| LispError::Compile {
form: T::KEYWORD.to_string(),
message: format!("serialize: {e}"),
})
},
};
registry().lock().unwrap().insert(T::KEYWORD, handler);
}
pub fn lookup(keyword: &str) -> Option<DomainHandler> {
let reg = registry().lock().unwrap();
reg.get(keyword).map(|h| DomainHandler {
keyword: h.keyword,
compile: h.compile,
})
}
pub fn registered_keywords() -> Vec<&'static str> {
registry().lock().unwrap().keys().copied().collect()
}
pub trait RenderableDomain {
const API_VERSION: &'static str;
const KIND: &'static str;
const NAME_FIELD: &'static str = "name";
}
#[derive(Clone, Copy, Debug)]
pub struct RenderHandler {
pub keyword: &'static str,
pub api_version: &'static str,
pub kind: &'static str,
pub name_field: &'static str,
}
static RENDER_REGISTRY: OnceLock<Mutex<HashMap<&'static str, RenderHandler>>> = OnceLock::new();
fn render_registry() -> &'static Mutex<HashMap<&'static str, RenderHandler>> {
RENDER_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_render<T>()
where
T: TataraDomain + RenderableDomain,
{
let handler = RenderHandler {
keyword: T::KEYWORD,
api_version: T::API_VERSION,
kind: T::KIND,
name_field: T::NAME_FIELD,
};
render_registry().lock().unwrap().insert(T::KEYWORD, handler);
}
#[must_use]
pub fn lookup_render(keyword: &str) -> Option<RenderHandler> {
render_registry().lock().unwrap().get(keyword).copied()
}
#[must_use]
pub fn registered_render_keywords() -> Vec<&'static str> {
render_registry().lock().unwrap().keys().copied().collect()
}
pub trait DocumentedDomain {
const DOCSTRING: &'static str;
const FIELD_DOCS: &'static [(&'static str, &'static str)];
}
#[derive(Clone, Copy, Debug)]
pub struct DocHandler {
pub keyword: &'static str,
pub docstring: &'static str,
pub field_docs: &'static [(&'static str, &'static str)],
}
static DOC_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DocHandler>>> = OnceLock::new();
fn doc_registry() -> &'static Mutex<HashMap<&'static str, DocHandler>> {
DOC_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_doc<T>()
where
T: TataraDomain + DocumentedDomain,
{
let handler = DocHandler {
keyword: T::KEYWORD,
docstring: T::DOCSTRING,
field_docs: T::FIELD_DOCS,
};
doc_registry().lock().unwrap().insert(T::KEYWORD, handler);
}
#[must_use]
pub fn lookup_doc(keyword: &str) -> Option<DocHandler> {
doc_registry().lock().unwrap().get(keyword).copied()
}
#[must_use]
pub fn registered_doc_keywords() -> Vec<&'static str> {
doc_registry().lock().unwrap().keys().copied().collect()
}
pub trait DependentDomain {
const DEPENDS_ON: &'static [&'static str];
}
#[derive(Clone, Copy, Debug)]
pub struct DepsHandler {
pub keyword: &'static str,
pub depends_on: &'static [&'static str],
}
static DEPS_REGISTRY: OnceLock<Mutex<HashMap<&'static str, DepsHandler>>> = OnceLock::new();
fn deps_registry() -> &'static Mutex<HashMap<&'static str, DepsHandler>> {
DEPS_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_deps<T>()
where
T: TataraDomain + DependentDomain,
{
let handler = DepsHandler {
keyword: T::KEYWORD,
depends_on: T::DEPENDS_ON,
};
deps_registry().lock().unwrap().insert(T::KEYWORD, handler);
}
#[must_use]
pub fn lookup_deps(keyword: &str) -> Option<DepsHandler> {
deps_registry().lock().unwrap().get(keyword).copied()
}
#[must_use]
pub fn registered_deps_keywords() -> Vec<&'static str> {
deps_registry().lock().unwrap().keys().copied().collect()
}
pub trait SchematicDomain {
const SCHEMA_JSON: &'static str;
}
#[derive(Clone, Copy, Debug)]
pub struct SchemaHandler {
pub keyword: &'static str,
pub schema_json: &'static str,
}
static SCHEMA_REGISTRY: OnceLock<Mutex<HashMap<&'static str, SchemaHandler>>> = OnceLock::new();
fn schema_registry() -> &'static Mutex<HashMap<&'static str, SchemaHandler>> {
SCHEMA_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_schema<T>()
where
T: TataraDomain + SchematicDomain,
{
let handler = SchemaHandler {
keyword: T::KEYWORD,
schema_json: T::SCHEMA_JSON,
};
schema_registry().lock().unwrap().insert(T::KEYWORD, handler);
}
#[must_use]
pub fn lookup_schema(keyword: &str) -> Option<SchemaHandler> {
schema_registry().lock().unwrap().get(keyword).copied()
}
#[must_use]
pub fn registered_schema_keywords() -> Vec<&'static str> {
schema_registry().lock().unwrap().keys().copied().collect()
}
pub trait AttestableDomain {
const ATTESTATION_NAMESPACE: &'static str;
}
#[derive(Clone, Copy, Debug)]
pub struct AttestHandler {
pub keyword: &'static str,
pub namespace: &'static str,
}
static ATTEST_REGISTRY: OnceLock<Mutex<HashMap<&'static str, AttestHandler>>> = OnceLock::new();
fn attest_registry() -> &'static Mutex<HashMap<&'static str, AttestHandler>> {
ATTEST_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_attest<T>()
where
T: TataraDomain + AttestableDomain,
{
let handler = AttestHandler {
keyword: T::KEYWORD,
namespace: T::ATTESTATION_NAMESPACE,
};
attest_registry().lock().unwrap().insert(T::KEYWORD, handler);
}
#[must_use]
pub fn lookup_attest(keyword: &str) -> Option<AttestHandler> {
attest_registry().lock().unwrap().get(keyword).copied()
}
#[must_use]
pub fn registered_attest_keywords() -> Vec<&'static str> {
attest_registry().lock().unwrap().keys().copied().collect()
}
#[must_use]
pub fn attest_value(namespace: &str, value: &serde_json::Value) -> String {
let canonical = serde_json::to_string(value).unwrap_or_default();
let mut hasher = blake3::Hasher::new();
hasher.update(namespace.as_bytes());
hasher.update(b":");
hasher.update(canonical.as_bytes());
hasher.finalize().to_hex().to_string()
}
pub trait ValidatedDomain {
fn validate_value(_value: &serde_json::Value) -> std::result::Result<(), String> {
Ok(())
}
}
#[derive(Clone, Copy)]
pub struct ValidateHandler {
pub keyword: &'static str,
pub validate: fn(&serde_json::Value) -> std::result::Result<(), String>,
}
impl std::fmt::Debug for ValidateHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ValidateHandler")
.field("keyword", &self.keyword)
.field("validate", &"<fn>")
.finish()
}
}
static VALIDATE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, ValidateHandler>>> = OnceLock::new();
fn validate_registry() -> &'static Mutex<HashMap<&'static str, ValidateHandler>> {
VALIDATE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_validate<T>()
where
T: TataraDomain + ValidatedDomain,
{
let handler = ValidateHandler {
keyword: T::KEYWORD,
validate: <T as ValidatedDomain>::validate_value,
};
validate_registry().lock().unwrap().insert(T::KEYWORD, handler);
}
#[must_use]
pub fn lookup_validate(keyword: &str) -> Option<ValidateHandler> {
validate_registry().lock().unwrap().get(keyword).copied()
}
#[must_use]
pub fn registered_validate_keywords() -> Vec<&'static str> {
validate_registry().lock().unwrap().keys().copied().collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum RolloutStrategy {
Immediate,
Recreate,
RollingUpdate,
BlueGreen,
Canary,
}
pub trait LifecycleProtocol {
const STRATEGY: RolloutStrategy;
const DRAIN_SECONDS: u32 = 30;
}
#[derive(Clone, Copy, Debug)]
pub struct LifecycleHandler {
pub keyword: &'static str,
pub strategy: RolloutStrategy,
pub drain_seconds: u32,
}
static LIFECYCLE_REGISTRY: OnceLock<Mutex<HashMap<&'static str, LifecycleHandler>>> =
OnceLock::new();
fn lifecycle_registry() -> &'static Mutex<HashMap<&'static str, LifecycleHandler>> {
LIFECYCLE_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_lifecycle<T>()
where
T: TataraDomain + LifecycleProtocol,
{
let handler = LifecycleHandler {
keyword: T::KEYWORD,
strategy: T::STRATEGY,
drain_seconds: T::DRAIN_SECONDS,
};
lifecycle_registry().lock().unwrap().insert(T::KEYWORD, handler);
}
#[must_use]
pub fn lookup_lifecycle(keyword: &str) -> Option<LifecycleHandler> {
lifecycle_registry().lock().unwrap().get(keyword).copied()
}
#[must_use]
pub fn registered_lifecycle_keywords() -> Vec<&'static str> {
lifecycle_registry().lock().unwrap().keys().copied().collect()
}
#[macro_export]
macro_rules! capability_layer {
(
trait $Trait:ident,
handler $Handler:ident,
static $REGISTRY:ident,
registry_fn $registry_fn:ident,
register $register:ident,
lookup $lookup:ident,
list $list:ident,
consts {
$(const $CONST:ident: $ty:ty => field $field:ident),* $(,)?
} $(,)?
) => {
pub trait $Trait {
$(const $CONST: $ty;)*
}
#[derive(Clone, Copy, Debug)]
pub struct $Handler {
pub keyword: &'static str,
$(pub $field: $ty,)*
}
static $REGISTRY: ::std::sync::OnceLock<
::std::sync::Mutex<::std::collections::HashMap<&'static str, $Handler>>
> = ::std::sync::OnceLock::new();
fn $registry_fn() -> &'static ::std::sync::Mutex<
::std::collections::HashMap<&'static str, $Handler>
> {
$REGISTRY.get_or_init(|| {
::std::sync::Mutex::new(::std::collections::HashMap::new())
})
}
pub fn $register<T>()
where
T: $crate::domain::TataraDomain + $Trait,
{
let handler = $Handler {
keyword: T::KEYWORD,
$($field: T::$CONST,)*
};
$registry_fn().lock().unwrap().insert(T::KEYWORD, handler);
}
#[must_use]
pub fn $lookup(keyword: &str) -> Option<$Handler> {
$registry_fn().lock().unwrap().get(keyword).copied()
}
#[must_use]
pub fn $list() -> Vec<&'static str> {
$registry_fn().lock().unwrap().keys().copied().collect()
}
};
}
capability_layer! {
trait CompliantDomain,
handler ComplianceHandler,
static COMPLIANCE_REGISTRY,
registry_fn compliance_registry,
register register_compliance,
lookup lookup_compliance,
list registered_compliance_keywords,
consts {
const FRAMEWORKS: &'static [&'static str] => field frameworks,
const CONTROLS: &'static [&'static str] => field controls,
}
}
capability_layer! {
trait ObservableDomain,
handler ObservabilityHandler,
static OBSERVABILITY_REGISTRY,
registry_fn observability_registry,
register register_observability,
lookup lookup_observability,
list registered_observability_keywords,
consts {
const METRIC_PREFIX: &'static str => field metric_prefix,
const LOG_LABELS: &'static [&'static str] => field log_labels,
}
}
capability_layer! {
trait HelpDomain,
handler HelpHandler,
static HELP_REGISTRY,
registry_fn help_registry,
register register_help,
lookup lookup_help,
list registered_help_keywords,
consts {
const MNEMONIC: &'static str => field mnemonic,
const EXAMPLES: &'static [&'static str] => field examples,
}
}
capability_layer! {
trait StableDomain,
handler StabilityHandler,
static STABILITY_REGISTRY,
registry_fn stability_registry,
register register_stability,
lookup lookup_stability,
list registered_stability_keywords,
consts {
const STABILITY: &'static str => field stability,
const SINCE_VERSION: &'static str => field since_version,
}
}
#[macro_export]
macro_rules! impl_default_capabilities {
($Spec:ty) => {
impl $crate::domain::DependentDomain for $Spec {
const DEPENDS_ON: &'static [&'static str] = &[];
}
impl $crate::domain::ValidatedDomain for $Spec {}
impl $crate::domain::LifecycleProtocol for $Spec {
const STRATEGY: $crate::domain::RolloutStrategy =
$crate::domain::RolloutStrategy::Immediate;
}
impl $crate::domain::CompliantDomain for $Spec {
const FRAMEWORKS: &'static [&'static str] = &[];
const CONTROLS: &'static [&'static str] = &[];
}
impl $crate::domain::ObservableDomain for $Spec {
const METRIC_PREFIX: &'static str = "";
const LOG_LABELS: &'static [&'static str] = &[];
}
impl $crate::domain::HelpDomain for $Spec {
const MNEMONIC: &'static str = "";
const EXAMPLES: &'static [&'static str] = &[];
}
impl $crate::domain::StableDomain for $Spec {
const STABILITY: &'static str = "stable";
const SINCE_VERSION: &'static str = "0.1.0";
}
};
}
#[macro_export]
macro_rules! register_all_capabilities {
($Spec:ty) => {
$crate::domain::register::<$Spec>();
$crate::domain::register_doc::<$Spec>();
$crate::domain::register_deps::<$Spec>();
$crate::domain::register_validate::<$Spec>();
$crate::domain::register_lifecycle::<$Spec>();
$crate::domain::register_compliance::<$Spec>();
$crate::domain::register_observability::<$Spec>();
$crate::domain::register_help::<$Spec>();
$crate::domain::register_stability::<$Spec>();
};
}
use crate::ast::Atom;
use serde_json::Value as JValue;
pub fn sexp_to_json(s: &Sexp) -> JValue {
match s {
Sexp::Nil => JValue::Null,
Sexp::Atom(Atom::Symbol(s)) => JValue::String(s.clone()),
Sexp::Atom(Atom::Keyword(s)) => JValue::String(format!(":{s}")),
Sexp::Atom(Atom::Str(s)) => JValue::String(s.clone()),
Sexp::Atom(Atom::Int(n)) => JValue::Number((*n).into()),
Sexp::Atom(Atom::Float(n)) => serde_json::Number::from_f64(*n)
.map(JValue::Number)
.unwrap_or(JValue::Null),
Sexp::Atom(Atom::Bool(b)) => JValue::Bool(*b),
Sexp::List(items) => {
if is_kwargs_list(items) {
let mut map = serde_json::Map::with_capacity(items.len() / 2);
let mut i = 0;
while i + 1 < items.len() {
if let Some(k) = items[i].as_keyword() {
map.insert(kebab_to_camel(k), sexp_to_json(&items[i + 1]));
i += 2;
} else {
break;
}
}
JValue::Object(map)
} else {
JValue::Array(items.iter().map(sexp_to_json).collect())
}
}
Sexp::Quote(inner)
| Sexp::Quasiquote(inner)
| Sexp::Unquote(inner)
| Sexp::UnquoteSplice(inner) => sexp_to_json(inner),
}
}
pub fn json_to_sexp(v: &JValue) -> Sexp {
match v {
JValue::Null => Sexp::Nil,
JValue::Bool(b) => Sexp::boolean(*b),
JValue::Number(n) => {
if let Some(i) = n.as_i64() {
Sexp::int(i)
} else if let Some(f) = n.as_f64() {
Sexp::float(f)
} else {
Sexp::int(0)
}
}
JValue::String(s) => Sexp::string(s.clone()),
JValue::Array(items) => Sexp::List(items.iter().map(json_to_sexp).collect()),
JValue::Object(map) => {
let mut out = Vec::with_capacity(map.len() * 2);
for (k, v) in map {
out.push(Sexp::keyword(camel_to_kebab(k)));
out.push(json_to_sexp(v));
}
Sexp::List(out)
}
}
}
fn is_kwargs_list(items: &[Sexp]) -> bool {
!items.is_empty()
&& items.len() % 2 == 0
&& items.iter().step_by(2).all(|s| s.as_keyword().is_some())
}
fn kebab_to_camel(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut upper = false;
for c in s.chars() {
if c == '-' {
upper = true;
} else if upper {
out.extend(c.to_uppercase());
upper = false;
} else {
out.push(c);
}
}
out
}
fn camel_to_kebab(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
out.push('-');
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
out
}
pub fn rewrite_typed<T, F>(input: T, rewrite: F) -> Result<T>
where
T: TataraDomain + serde::Serialize,
F: FnOnce(Sexp) -> Result<Sexp>,
{
let json = serde_json::to_value(&input).map_err(|e| LispError::Compile {
form: T::KEYWORD.to_string(),
message: format!("serialize {}: {e}", T::KEYWORD),
})?;
let sexp = json_to_sexp(&json);
let rewritten = rewrite(sexp)?;
let args = match rewritten {
Sexp::List(items) => items,
other => {
return Err(LispError::Compile {
form: T::KEYWORD.to_string(),
message: format!("rewriter must return a list; got {other}"),
})
}
};
T::compile_from_args(&args)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reader::read;
use serde::Serialize;
use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
#[derive(DeriveTataraDomain, Serialize, Debug, PartialEq)]
#[tatara(keyword = "defmonitor")]
struct MonitorSpec {
name: String,
query: String,
threshold: f64,
window_seconds: Option<i64>,
tags: Vec<String>,
enabled: Option<bool>,
}
#[test]
fn derive_emits_correct_keyword() {
assert_eq!(MonitorSpec::KEYWORD, "defmonitor");
}
#[test]
fn derive_compiles_full_form() {
let forms = read(
r#"(defmonitor
:name "prom-up"
:query "up{job='prometheus'}"
:threshold 0.99
:window-seconds 300
:tags ("prod" "observability")
:enabled #t)"#,
)
.unwrap();
let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
assert_eq!(
spec,
MonitorSpec {
name: "prom-up".into(),
query: "up{job='prometheus'}".into(),
threshold: 0.99,
window_seconds: Some(300),
tags: vec!["prod".into(), "observability".into()],
enabled: Some(true),
}
);
}
#[test]
fn derive_accepts_missing_optionals() {
let forms = read(r#"(defmonitor :name "x" :query "q" :threshold 0.5)"#).unwrap();
let spec = MonitorSpec::compile_from_sexp(&forms[0]).unwrap();
assert_eq!(spec.name, "x");
assert!(spec.window_seconds.is_none());
assert!(spec.enabled.is_none());
assert!(spec.tags.is_empty());
}
#[test]
fn derive_errors_on_missing_required() {
let forms = read(r#"(defmonitor :name "x" :query "q")"#).unwrap();
assert!(MonitorSpec::compile_from_sexp(&forms[0]).is_err());
}
#[test]
fn derive_errors_on_wrong_head() {
let forms = read(r#"(not-a-monitor :name "x")"#).unwrap();
let err = MonitorSpec::compile_from_sexp(&forms[0]).unwrap_err();
assert!(format!("{err}").contains("expected (defmonitor"));
}
#[test]
fn registry_dispatches_by_keyword() {
register::<MonitorSpec>();
assert!(registered_keywords().contains(&"defmonitor"));
let handler = lookup("defmonitor").expect("registered");
assert_eq!(handler.keyword, "defmonitor");
let forms = read(r#"(ignored :name "prom" :query "q" :threshold 0.5)"#).unwrap();
let args = forms[0].as_list().unwrap();
let json = (handler.compile)(&args[1..]).unwrap();
assert_eq!(json["name"], "prom");
assert_eq!(json["query"], "q");
assert_eq!(json["threshold"], 0.5);
}
}