use std::collections::HashMap;
use crate::capabilities::Dependency;
use crate::config::{
CapabilityConfig, Config, ConfigId, LauncherConfig, ModelConfig, ProviderConfig,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum RefKind {
Launcher,
Capability,
Model,
Provider,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Problem {
NotConfigured,
UnknownType { type_name: String },
MissingDependency { config_key: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ValidationError {
pub(crate) target: (RefKind, String),
pub(crate) problem: Problem,
pub(crate) referrer: Option<(RefKind, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DanglingRef {
pub(crate) kind: RefKind,
pub(crate) instance_id: String,
pub(crate) reason: String,
}
pub(crate) fn validate_ref(
kind: RefKind,
id: &str,
config: &Config,
) -> Result<(), ValidationError> {
validate(kind, id, config, None)
}
pub(crate) fn find_dangling(kind: RefKind, config: &Config) -> Vec<DanglingRef> {
config_entries(config, kind)
.into_iter()
.filter_map(|entry| {
let id = entry.config_id();
validate_ref(kind, id, config).err().map(|e| DanglingRef {
kind,
instance_id: id.to_string(),
reason: e.to_string(),
})
})
.collect()
}
pub(crate) fn dependents(kind: RefKind, id: &str, config: &Config) -> Vec<(RefKind, String)> {
let mut found: Vec<(RefKind, String)> = [
RefKind::Launcher,
RefKind::Capability,
RefKind::Model,
RefKind::Provider,
]
.into_iter()
.flat_map(|referrer_kind| {
config_entries(config, referrer_kind)
.into_iter()
.map(move |entry| (referrer_kind, entry))
})
.filter(|(_, entry)| {
entry.refs().is_ok_and(|refs| {
refs.iter()
.any(|(target_kind, target_id)| *target_kind == kind && *target_id == id)
})
})
.map(|(referrer_kind, entry)| (referrer_kind, entry.config_id().to_string()))
.collect();
found.sort_by(|a, b| a.0.to_string().cmp(&b.0.to_string()).then(a.1.cmp(&b.1)));
found
}
pub(crate) fn type_name<'a>(kind: RefKind, id: &str, config: &'a Config) -> Option<&'a str> {
config_entry(config, kind, id).map(Validatable::type_name)
}
impl std::fmt::Display for RefKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
RefKind::Launcher => "launcher",
RefKind::Capability => "capability",
RefKind::Model => "model",
RefKind::Provider => "provider",
};
f.write_str(s)
}
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (kind, id) = &self.target;
match &self.referrer {
Some((referrer_kind, referrer_id)) => write!(
f,
"{referrer_kind} '{referrer_id}' depends on {kind} '{id}', which "
)?,
None => write!(f, "{kind} '{id}' ")?,
}
match &self.problem {
Problem::NotConfigured => write!(f, "is not configured"),
Problem::UnknownType { type_name } => {
write!(f, "has an unknown {kind} type '{type_name}'")
}
Problem::MissingDependency { config_key } => {
write!(f, "is missing required dependency '{config_key}'")
}
}
}
}
impl std::error::Error for ValidationError {}
trait Validatable: ConfigId {
fn type_name(&self) -> &str;
fn type_is_registered(&self) -> bool;
fn refs(&self) -> Result<Vec<(RefKind, &str)>, Problem>;
}
fn config_entry<'a>(config: &'a Config, kind: RefKind, id: &str) -> Option<&'a dyn Validatable> {
match kind {
RefKind::Launcher => lookup(&config.launchers, id),
RefKind::Capability => lookup(&config.capabilities, id),
RefKind::Model => lookup(&config.models, id),
RefKind::Provider => lookup(&config.providers, id),
}
}
fn config_entries(config: &Config, kind: RefKind) -> Vec<&dyn Validatable> {
match kind {
RefKind::Launcher => erase(&config.launchers),
RefKind::Capability => erase(&config.capabilities),
RefKind::Model => erase(&config.models),
RefKind::Provider => erase(&config.providers),
}
}
fn lookup<'a, T: Validatable>(
map: &'a HashMap<String, T>,
id: &str,
) -> Option<&'a dyn Validatable> {
map.get(id).map(|entry| entry as &dyn Validatable)
}
fn erase<T: Validatable>(map: &HashMap<String, T>) -> Vec<&dyn Validatable> {
map.values()
.map(|entry| entry as &dyn Validatable)
.collect()
}
fn validate(
kind: RefKind,
id: &str,
config: &Config,
referrer: Option<(RefKind, &str)>,
) -> Result<(), ValidationError> {
let entry = config_entry(config, kind, id)
.ok_or_else(|| err(kind, id, Problem::NotConfigured, referrer))?;
if !entry.type_is_registered() {
return Err(err(
kind,
id,
Problem::UnknownType {
type_name: entry.type_name().to_string(),
},
referrer,
));
}
let refs = entry
.refs()
.map_err(|problem| err(kind, id, problem, referrer))?;
for (target_kind, target_id) in refs {
validate(target_kind, target_id, config, Some((kind, id)))?;
}
Ok(())
}
impl Validatable for LauncherConfig {
fn type_name(&self) -> &str {
&self.launcher_type
}
fn type_is_registered(&self) -> bool {
crate::launchers::LAUNCHER_REGISTRY
.get(&self.launcher_type)
.is_some()
}
fn refs(&self) -> Result<Vec<(RefKind, &str)>, Problem> {
Ok(self
.enabled_capabilities
.iter()
.map(|id| (RefKind::Capability, id.as_str()))
.collect())
}
}
impl Validatable for CapabilityConfig {
fn type_name(&self) -> &str {
&self.capability_type
}
fn type_is_registered(&self) -> bool {
crate::capabilities::CAPABILITY_REGISTRY
.get(&self.capability_type)
.is_some()
}
fn refs(&self) -> Result<Vec<(RefKind, &str)>, Problem> {
let metadata = crate::capabilities::CAPABILITY_REGISTRY
.get(&self.capability_type)
.ok_or_else(|| Problem::UnknownType {
type_name: self.capability_type.clone(),
})?;
dependency_refs(&self.config, &metadata.dependencies)
}
}
impl Validatable for ModelConfig {
fn type_name(&self) -> &str {
&self.model_type
}
fn type_is_registered(&self) -> bool {
crate::models::MODEL_REGISTRY
.get(&self.model_type)
.is_some()
}
fn refs(&self) -> Result<Vec<(RefKind, &str)>, Problem> {
Ok(vec![(RefKind::Provider, &self.provider_id)])
}
}
impl Validatable for ProviderConfig {
fn type_name(&self) -> &str {
&self.provider_type
}
fn type_is_registered(&self) -> bool {
crate::providers::PROVIDER_REGISTRY
.get(&self.provider_type)
.is_some()
}
fn refs(&self) -> Result<Vec<(RefKind, &str)>, Problem> {
Ok(Vec::new())
}
}
fn dependency_refs<'a>(
capability_config: &'a serde_json::Value,
dependencies: &[Dependency],
) -> Result<Vec<(RefKind, &'a str)>, Problem> {
let mut refs = Vec::new();
for dependency in dependencies {
let (kind, config_key, required) = match dependency {
Dependency::Model {
config_key,
required,
..
} => (RefKind::Model, config_key, *required),
Dependency::Provider {
config_key,
required,
..
} => (RefKind::Provider, config_key, *required),
Dependency::ExternalTool { .. } => continue,
};
let id = capability_config
.get(config_key)
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if id.is_empty() {
if required {
return Err(Problem::MissingDependency {
config_key: config_key.clone(),
});
}
continue;
}
refs.push((kind, id));
}
Ok(refs)
}
fn err(
kind: RefKind,
id: &str,
problem: Problem,
referrer: Option<(RefKind, &str)>,
) -> ValidationError {
ValidationError {
target: (kind, id.to_string()),
problem,
referrer: referrer.map(|(k, i)| (k, i.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capabilities::{ModelRequirement, ShellCommandRequirement};
fn provider(id: &str, provider_type: &str) -> ProviderConfig {
ProviderConfig {
provider_id: id.to_string(),
provider_type: provider_type.to_string(),
config: serde_json::json!({}),
}
}
fn model(id: &str, model_type: &str, provider_id: Option<&str>) -> ModelConfig {
ModelConfig {
model_id: id.to_string(),
model_type: model_type.to_string(),
provider_id: provider_id.unwrap_or("ollama").to_string(),
variant: None,
config: serde_json::json!({}),
}
}
fn capability(id: &str, capability_type: &str, model_id: &str) -> CapabilityConfig {
CapabilityConfig {
capability_id: id.to_string(),
capability_type: capability_type.to_string(),
config: serde_json::json!({ "model_id": model_id }),
}
}
fn launcher(id: &str, launcher_type: &str, enabled: &[&str]) -> LauncherConfig {
LauncherConfig {
launcher_id: id.to_string(),
launcher_type: launcher_type.to_string(),
enabled_capabilities: enabled.iter().map(|s| s.to_string()).collect(),
config: serde_json::json!({}),
}
}
fn healthy() -> Config {
let mut config = Config::default();
config
.providers
.insert("p1".into(), provider("p1", "ollama"));
config
.models
.insert("m1".into(), model("m1", "custom", Some("p1")));
config
.capabilities
.insert("chat".into(), capability("chat", "agent-model", "m1"));
config
.launchers
.insert("claude".into(), launcher("claude", "claude", &["chat"]));
config
}
#[test]
fn healthy_instance_of_each_kind_passes() {
let config = healthy();
for (kind, id) in [
(RefKind::Provider, "p1"),
(RefKind::Model, "m1"),
(RefKind::Capability, "chat"),
(RefKind::Launcher, "claude"),
] {
assert!(
validate_ref(kind, id, &config).is_ok(),
"{kind} '{id}' should validate"
);
}
}
#[test]
fn unconfigured_instance_of_each_kind_fails() {
let config = healthy();
for kind in [
RefKind::Provider,
RefKind::Model,
RefKind::Capability,
RefKind::Launcher,
] {
let err = validate_ref(kind, "nope", &config).expect_err("should fail");
assert_eq!(err.problem, Problem::NotConfigured);
assert_eq!(err.target, (kind, "nope".to_string()));
assert_eq!(err.referrer, None);
}
}
#[test]
fn dangling_instance_of_each_kind_fails_while_the_healthy_one_passes() {
let mut config = healthy();
config
.models
.insert("m-broken".into(), model("m-broken", "custom", Some("gone")));
config.capabilities.insert(
"cap-broken".into(),
capability("cap-broken", "agent-model", "gone"),
);
config.launchers.insert(
"launcher-broken".into(),
launcher("launcher-broken", "claude", &["gone"]),
);
assert!(validate_ref(RefKind::Model, "m1", &config).is_ok());
assert!(validate_ref(RefKind::Model, "m-broken", &config).is_err());
assert!(validate_ref(RefKind::Capability, "chat", &config).is_ok());
assert!(validate_ref(RefKind::Capability, "cap-broken", &config).is_err());
assert!(validate_ref(RefKind::Launcher, "claude", &config).is_ok());
assert!(validate_ref(RefKind::Launcher, "launcher-broken", &config).is_err());
}
#[test]
fn walk_recurses_from_launcher_to_the_missing_provider() {
let mut config = healthy();
config.providers.remove("p1");
let err = validate_ref(RefKind::Launcher, "claude", &config).expect_err("should fail");
assert_eq!(err.target, (RefKind::Provider, "p1".to_string()));
assert_eq!(err.problem, Problem::NotConfigured);
assert_eq!(err.referrer, Some((RefKind::Model, "m1".to_string())));
}
#[test]
fn error_names_the_capability_holding_a_missing_model() {
let mut config = healthy();
config.models.remove("m1");
let err = validate_ref(RefKind::Launcher, "claude", &config).expect_err("should fail");
assert_eq!(err.target, (RefKind::Model, "m1".to_string()));
assert_eq!(
err.referrer,
Some((RefKind::Capability, "chat".to_string()))
);
assert_eq!(
err.to_string(),
"capability 'chat' depends on model 'm1', which is not configured"
);
}
#[test]
fn an_unknown_type_name_fails_for_each_kind() {
let mut config = healthy();
config
.providers
.insert("p-bad".into(), provider("p-bad", "not-a-provider"));
config
.models
.insert("m-bad".into(), model("m-bad", "not-a-model", Some("p1")));
config.capabilities.insert(
"cap-bad".into(),
capability("cap-bad", "not-a-capability", "m1"),
);
config.launchers.insert(
"launcher-bad".into(),
launcher("launcher-bad", "not-a-launcher", &[]),
);
for (kind, id, type_name) in [
(RefKind::Provider, "p-bad", "not-a-provider"),
(RefKind::Model, "m-bad", "not-a-model"),
(RefKind::Capability, "cap-bad", "not-a-capability"),
(RefKind::Launcher, "launcher-bad", "not-a-launcher"),
] {
let err = validate_ref(kind, id, &config).expect_err("should fail");
assert_eq!(
err.problem,
Problem::UnknownType {
type_name: type_name.to_string()
},
"{kind} '{id}'"
);
}
}
#[test]
fn an_optional_dependency_contributes_a_ref_only_when_it_holds_an_id() {
let optional = |key: &str| {
vec![Dependency::Model {
config_key: key.to_string(),
requirement: ModelRequirement::default(),
resolved_id: None,
required: false,
}]
};
assert_eq!(
dependency_refs(&serde_json::json!({}), &optional("model_id")),
Ok(vec![])
);
assert_eq!(
dependency_refs(
&serde_json::json!({ "model_id": "" }),
&optional("model_id")
),
Ok(vec![])
);
assert_eq!(
dependency_refs(
&serde_json::json!({ "model_id": "m1" }),
&optional("model_id")
),
Ok(vec![(RefKind::Model, "m1")])
);
assert_eq!(
dependency_refs(
&serde_json::json!({ "model_id": "gone" }),
&optional("model_id")
),
Ok(vec![(RefKind::Model, "gone")])
);
}
#[test]
fn an_absent_required_dependency_is_a_missing_dependency() {
let required = vec![Dependency::Model {
config_key: "model_id".to_string(),
requirement: ModelRequirement::default(),
resolved_id: None,
required: true,
}];
assert_eq!(
dependency_refs(&serde_json::json!({}), &required),
Err(Problem::MissingDependency {
config_key: "model_id".to_string()
})
);
let rendered = err(
RefKind::Capability,
"cap",
Problem::MissingDependency {
config_key: "model_id".to_string(),
},
None,
);
assert_eq!(
rendered.to_string(),
"capability 'cap' is missing required dependency 'model_id'"
);
}
#[test]
fn a_capabilitys_own_problem_names_the_instance_that_reached_it() {
let mut config = healthy();
config
.capabilities
.insert("chat".into(), capability("chat", "agent-model", ""));
let err = validate_ref(RefKind::Launcher, "claude", &config).expect_err("should fail");
assert_eq!(err.target, (RefKind::Capability, "chat".to_string()));
assert_eq!(
err.problem,
Problem::MissingDependency {
config_key: "model_id".to_string()
}
);
assert_eq!(
err.referrer,
Some((RefKind::Launcher, "claude".to_string()))
);
assert_eq!(
err.to_string(),
"launcher 'claude' depends on capability 'chat', \
which is missing required dependency 'model_id'"
);
}
#[test]
fn an_external_tool_dependency_is_not_a_config_reference() {
let deps = vec![Dependency::ExternalTool {
requirement: ShellCommandRequirement {
command: "ffmpeg".to_string(),
},
required: true,
}];
assert_eq!(dependency_refs(&serde_json::json!({}), &deps), Ok(vec![]));
}
#[test]
fn find_dangling_returns_exactly_the_broken_instances_of_a_kind() {
let mut config = healthy();
config.models.insert(
"m-no-provider".into(),
model("m-no-provider", "custom", None),
);
config
.models
.insert("m-gone".into(), model("m-gone", "custom", Some("gone")));
config
.models
.insert("m-bad-type".into(), model("m-bad-type", "nope", Some("p1")));
let mut broken: Vec<String> = find_dangling(RefKind::Model, &config)
.into_iter()
.map(|d| d.instance_id)
.collect();
broken.sort();
assert_eq!(broken, ["m-bad-type", "m-gone", "m-no-provider"]);
assert!(find_dangling(RefKind::Provider, &config).is_empty());
assert_eq!(find_dangling(RefKind::Capability, &config).len(), 0);
}
#[test]
fn find_dangling_returns_nothing_for_a_healthy_config() {
let config = healthy();
for kind in [
RefKind::Provider,
RefKind::Model,
RefKind::Capability,
RefKind::Launcher,
] {
assert!(find_dangling(kind, &config).is_empty(), "{kind}");
}
}
#[test]
fn find_dangling_only_reports_the_kind_it_was_asked_about() {
let mut config = healthy();
config.providers.remove("p1");
for (kind, expected) in [
(RefKind::Provider, Vec::<&str>::new()),
(RefKind::Model, vec!["m1"]),
(RefKind::Capability, vec!["chat"]),
(RefKind::Launcher, vec!["claude"]),
] {
let found: Vec<String> = find_dangling(kind, &config)
.into_iter()
.map(|d| d.instance_id)
.collect();
assert_eq!(found, expected, "{kind}");
assert!(find_dangling(kind, &config).iter().all(|d| d.kind == kind));
}
}
#[test]
fn dependents_are_the_instances_pointing_at_the_target() {
let config = healthy();
assert_eq!(
dependents(RefKind::Provider, "p1", &config),
vec![(RefKind::Model, "m1".to_string())]
);
assert_eq!(
dependents(RefKind::Model, "m1", &config),
vec![(RefKind::Capability, "chat".to_string())]
);
assert_eq!(
dependents(RefKind::Capability, "chat", &config),
vec![(RefKind::Launcher, "claude".to_string())]
);
assert!(dependents(RefKind::Launcher, "claude", &config).is_empty());
assert!(dependents(RefKind::Model, "gone", &config).is_empty());
}
#[test]
fn dependents_lists_every_referrer_of_one_target() {
let mut config = healthy();
config
.capabilities
.insert("second".into(), capability("second", "agent-model", "m1"));
assert_eq!(
dependents(RefKind::Model, "m1", &config),
vec![
(RefKind::Capability, "chat".to_string()),
(RefKind::Capability, "second".to_string()),
]
);
}
#[test]
fn find_dangling_reports_the_rendered_reason() {
let mut config = healthy();
config.providers.remove("p1");
let dangling = find_dangling(RefKind::Model, &config);
assert_eq!(dangling.len(), 1);
assert_eq!(dangling[0].kind, RefKind::Model);
assert_eq!(dangling[0].instance_id, "m1");
assert_eq!(
dangling[0].reason,
"model 'm1' depends on provider 'p1', which is not configured"
);
}
}