use crate::model::{Method, PromiseSpec, PromiseType, Requires};
use indexmap::IndexMap;
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum RegistryError {
#[error("read {0}: {1}")]
Io(PathBuf, String),
#[error("invalid registry at {0}: missing version or promises")]
Invalid(PathBuf),
#[error("parse {0}: {1}")]
Parse(PathBuf, String),
#[error("promise {0}: unknown standing method {1}")]
UnknownStandingMethod(String, String),
}
pub struct Registry {
pub version: String,
pub promises: IndexMap<String, PromiseSpec>,
pub(crate) requires_keyed: std::collections::HashSet<String>,
}
pub struct Env {
pub cxpak: bool,
pub trace: bool,
}
fn method_from_str(s: &str) -> Option<Method> {
serde_yaml::from_str::<Method>(s).ok()
}
pub fn load(
registry_path: &Path,
overrides_path: Option<&Path>,
) -> Result<Registry, RegistryError> {
let text = std::fs::read_to_string(registry_path)
.map_err(|e| RegistryError::Io(registry_path.into(), e.to_string()))?;
let raw: serde_yaml::Value = serde_yaml::from_str(&text)
.map_err(|e| RegistryError::Parse(registry_path.into(), e.to_string()))?;
let version = raw
.get("version")
.and_then(|v| v.as_str())
.map(str::to_string);
let promises_node = raw.get("promises");
let (version, promises_node) = match (version, promises_node) {
(Some(v), Some(p)) if p.is_mapping() => (v, p),
_ => return Err(RegistryError::Invalid(registry_path.into())),
};
let mut promises = IndexMap::new();
let mut requires_keyed = std::collections::HashSet::new();
for (k, spec_node) in promises_node.as_mapping().unwrap() {
let id = k.as_str().unwrap_or_default().to_string();
if spec_node.get("requires").is_some() {
requires_keyed.insert(id.clone());
}
let mut spec: PromiseSpec = serde_yaml::from_value(spec_node.clone())
.map_err(|e| RegistryError::Parse(registry_path.into(), format!("{id}: {e}")))?;
spec.id = id.clone();
spec.enabled = true;
if spec.promise_type == PromiseType::Standing {
spec.method = Some(method_from_str(&spec.method_raw).ok_or_else(|| {
RegistryError::UnknownStandingMethod(id.clone(), spec.method_raw.clone())
})?);
}
promises.insert(id, spec);
}
if let Some(op) = overrides_path {
apply_overrides(&mut promises, op);
}
Ok(Registry {
version,
promises,
requires_keyed,
})
}
pub fn apply_overrides(promises: &mut IndexMap<String, PromiseSpec>, path: &Path) {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
eprintln!("[registry] failed to load {}: {e}", path.display());
return;
}
};
let root: serde_yaml::Value = match serde_yaml::from_str(&text) {
Ok(v) => v,
Err(e) => {
eprintln!("[registry] parse {}: {e}", path.display());
return;
}
};
let Some(overrides) = root.get("overrides").and_then(|o| o.as_mapping()) else {
return;
};
for (k, patch) in overrides {
let Some(id) = k.as_str() else { continue };
let Some(spec) = promises.get_mut(id) else {
continue;
};
if patch.get("enabled").and_then(|v| v.as_bool()) == Some(false) {
spec.enabled = false;
}
if let Some(t) = patch.get("threshold").and_then(|v| v.as_i64()) {
spec.threshold = Some(t);
}
}
}
pub fn available<'a>(reg: &'a Registry, env: &Env) -> Vec<&'a PromiseSpec> {
let mut out = Vec::new();
for spec in reg.promises.values() {
if !spec.enabled {
continue;
}
if !reg.requires_keyed.contains(&spec.id) {
continue;
}
match &spec.requires {
None => out.push(spec),
Some(Requires::One(r)) if r == "cxpak" && env.cxpak => out.push(spec),
Some(Requires::One(_)) => {}
Some(Requires::List(rs)) => {
let met = rs.iter().all(|r| match r.as_str() {
"cxpak" => env.cxpak,
"trace" => env.trace,
_ => false,
});
if met {
out.push(spec);
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_missing_version() {
let dir = std::env::temp_dir().join("reg_test_novers");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("r.yaml");
std::fs::write(&p, "promises: {}\n").unwrap();
assert!(matches!(load(&p, None), Err(RegistryError::Invalid(_))));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_unknown_standing_method() {
let dir = std::env::temp_dir().join("reg_test_badmethod");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("r.yaml");
std::fs::write(
&p,
"version: \"1\"\npromises:\n x:\n type: standing\n method: bogus\n",
)
.unwrap();
assert!(matches!(
load(&p, None),
Err(RegistryError::UnknownStandingMethod(_, _))
));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn applies_overrides_disable_and_threshold() {
let dir = std::env::temp_dir().join("reg_test_overrides");
std::fs::create_dir_all(&dir).unwrap();
let rp = dir.join("r.yaml");
std::fs::write(&rp,
"version: \"1\"\npromises:\n a:\n type: standing\n method: grep\n pattern: x\n requires: null\n b:\n type: standing\n method: output_length\n requires: null\n").unwrap();
let op = dir.join("overrides.yaml");
std::fs::write(
&op,
"overrides:\n a:\n enabled: false\n b:\n threshold: 42\n",
)
.unwrap();
let reg = load(&rp, Some(&op)).unwrap();
assert!(!reg.promises["a"].enabled);
assert_eq!(reg.promises["b"].threshold, Some(42));
let active: Vec<_> = available(
®,
&Env {
cxpak: false,
trace: false,
},
)
.iter()
.map(|s| s.id.clone())
.collect();
assert!(!active.contains(&"a".to_string()) && active.contains(&"b".to_string()));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_overrides_file_is_silent() {
let dir = std::env::temp_dir().join("reg_test_missing_ovr");
std::fs::create_dir_all(&dir).unwrap();
let rp = dir.join("r.yaml");
std::fs::write(
&rp,
"version: \"1\"\npromises:\n a:\n type: standing\n method: output_length\n requires: null\n",
)
.unwrap();
let bogus = std::path::Path::new("/nonexistent/overrides.yaml");
assert!(load(&rp, Some(bogus)).is_ok());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn available_excludes_keyless_requires() {
let dir = std::env::temp_dir().join("reg_test_keyless");
std::fs::create_dir_all(&dir).unwrap();
let rp = dir.join("r.yaml");
std::fs::write(
&rp,
concat!(
"version: \"1\"\n",
"promises:\n",
" with_null:\n type: standing\n method: output_length\n requires: null\n",
" keyless:\n type: standing\n method: output_length\n",
" also_null:\n type: standing\n method: output_length\n requires: null\n",
),
)
.unwrap();
let reg = load(&rp, None).unwrap();
let active: Vec<String> = available(
®,
&Env {
cxpak: false,
trace: false,
},
)
.iter()
.map(|s| s.id.clone())
.collect();
assert!(
active.contains(&"with_null".to_string()),
"requires: null must be included"
);
assert!(
active.contains(&"also_null".to_string()),
"requires: null must be included"
);
assert!(
!active.contains(&"keyless".to_string()),
"keyless requires must be excluded"
);
std::fs::remove_dir_all(&dir).ok();
}
}