use crate::compile::Env;
use thiserror::Error;
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ValidationError {
#[error(
"import `{0}` declared but no resource produced from it (keyword expected: `{1}`)"
)]
UnusedImport(String, String),
#[error("resource `({keyword} …)` has no matching import — declare `{crate_name}` in :imports")]
UnregisteredKeyword {
keyword: String,
crate_name: String,
},
#[error("duplicate `{keyword}` resource with id `{id}`")]
DuplicateResource { keyword: String, id: String },
#[error("{0}")]
Custom(String),
}
const KEYWORD_TO_CRATE: &[(&str, &str)] = &[
("defbpf-program", "tatara-ebpf"),
("defbpf-map", "tatara-ebpf"),
("defbpf-policy", "tatara-ebpf"),
("defgateway", "tatara-gateway-api"),
("defciliumnetworkpolicy", "tatara-cilium"),
("defpodmonitor", "tatara-prometheus-operator"),
];
#[must_use]
pub fn keyword_to_crate(keyword: &str) -> Option<&'static str> {
KEYWORD_TO_CRATE
.iter()
.find_map(|(k, v)| (*k == keyword).then_some(*v))
}
pub fn validate(env: &Env) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
let imports: std::collections::HashSet<&str> =
env.spec.imports.iter().map(String::as_str).collect();
for r in &env.resources {
let Some(crate_name) = keyword_to_crate(&r.keyword) else {
continue;
};
if !imports.contains(crate_name) {
errors.push(ValidationError::UnregisteredKeyword {
keyword: r.keyword.clone(),
crate_name: crate_name.to_string(),
});
}
}
for imp in &env.spec.imports {
let owns_at_least_one = env.resources.iter().any(|r| {
keyword_to_crate(&r.keyword).is_some_and(|c| c == imp.as_str())
});
if !owns_at_least_one {
let example = KEYWORD_TO_CRATE
.iter()
.find(|(_, c)| *c == imp.as_str())
.map(|(k, _)| (*k).to_string())
.unwrap_or_else(|| "<no keyword registered>".to_string());
errors.push(ValidationError::UnusedImport(imp.clone(), example));
}
}
let mut seen: std::collections::HashSet<(String, String)> =
std::collections::HashSet::new();
for r in &env.resources {
let Some(id) = identifier_for(r) else {
continue;
};
let key = (r.keyword.clone(), id.clone());
if !seen.insert(key) {
errors.push(ValidationError::DuplicateResource {
keyword: r.keyword.clone(),
id,
});
}
}
for r in &env.resources {
if let Some(handler) = tatara_lisp::domain::lookup_validate(&r.keyword) {
if let Err(msg) = (handler.validate)(&r.value) {
errors.push(ValidationError::Custom(format!(
"{}: {msg}",
r.keyword
)));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn identifier_for(r: &Resource) -> Option<String> {
let obj = r.value.as_object()?;
if let Some(v) = obj.get("name").and_then(|v| v.as_str()) {
return Some(v.to_string());
}
match r.keyword.as_str() {
"defgateway" => obj.get("gateway_class_name").and_then(|v| v.as_str()).map(str::to_string),
_ => None,
}
}
use crate::compile::Resource;