use crate::config::{LoadError, PerRepoProviders};
use crate::prompt::{PER_REPO_PROVIDERS_FILE, SOULS_DIR};
use crate::template::GitRunner;
use crate::workspace;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum Invalid {
RoleMissing {
role: String,
agent: String,
defined: String,
},
SoulMissing { role: String, agent: String },
Config(LoadError),
Governing { branch: String, source: io::Error },
}
impl std::fmt::Display for Invalid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RoleMissing {
role,
agent,
defined,
} => write!(
f,
"role {role:?} is not defined in the providers.yaml governing agent \
{agent:?} — defined roles: {defined}"
),
Self::SoulMissing { role, agent } => write!(
f,
"role {role:?} is defined but its soul {SOULS_DIR}/{role}.md is missing from \
the config governing agent {agent:?} — a role is its `roles:` entry and its \
soul (ARCH §4.3)"
),
Self::Config(e) => write!(f, "providers.yaml: {e}"),
Self::Governing { branch, source } => {
write!(f, "governing config for {branch}: {source}")
}
}
}
}
impl std::error::Error for Invalid {}
pub fn validate(repo: &Path, branch: &str, role: &str, git: &dyn GitRunner) -> Result<(), Invalid> {
let gov = |source| Invalid::Governing {
branch: branch.to_string(),
source,
};
let commit = workspace::governing_config(repo, branch, git).map_err(gov)?;
let providers_raw =
workspace::show_control(repo, &commit, PER_REPO_PROVIDERS_FILE, git).map_err(gov)?;
let origin = PathBuf::from(format!("{commit}:{PER_REPO_PROVIDERS_FILE}"));
let providers = PerRepoProviders::parse(&providers_raw, &origin).map_err(Invalid::Config)?;
if !providers.roles.contains_key(role) {
let defined: Vec<&str> = providers.roles.keys().map(String::as_str).collect();
return Err(Invalid::RoleMissing {
role: role.to_string(),
agent: branch.to_string(),
defined: crate::name::pool(&defined),
});
}
let soul_rel = format!("{SOULS_DIR}/{role}.md");
if !workspace::control_exists(repo, &commit, &soul_rel, git) {
return Err(Invalid::SoulMissing {
role: role.to_string(),
agent: branch.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::template::RealGit;
use crate::workspace::fixture;
fn git() -> RealGit {
RealGit::new()
}
#[test]
fn a_config_role_with_its_soul_is_valid() {
let (_h, ws) = fixture::workspace();
fixture::spawn_root(&ws, "p1");
validate(&ws, "p1", "worker", &git()).unwrap();
}
#[test]
fn a_third_config_role_is_valid_zero_code() {
let (_h, ws) = fixture::workspace();
let yaml = "roles:\n worker:\n provider: anthropic\n model: sonnet\n \
verifier:\n provider: anthropic\n model: sonnet\n";
fixture::amend_config(
&ws,
&[("providers.yaml", yaml), ("souls/verifier.md", "v\n")],
);
fixture::spawn_root(&ws, "p9");
validate(&ws, "p9", "verifier", &git()).unwrap();
}
#[test]
fn a_role_absent_from_providers_is_role_missing() {
let (_h, ws) = fixture::workspace();
fixture::spawn_root(&ws, "p1");
let err = validate(&ws, "p1", "ghost", &git()).unwrap_err();
match &err {
Invalid::RoleMissing {
role,
agent,
defined,
} => {
assert_eq!(role, "ghost");
assert_eq!(agent, "p1");
assert_eq!(defined, "compactor, worker");
}
other => panic!("expected RoleMissing, got {other:?}"),
}
assert_eq!(
err.to_string(),
"role \"ghost\" is not defined in the providers.yaml governing agent \"p1\" \
— defined roles: compactor, worker"
);
}
#[test]
fn a_role_listed_without_a_soul_is_soul_missing() {
let (_h, ws) = fixture::workspace();
let yaml = "roles:\n verifier:\n provider: anthropic\n model: sonnet\n";
fixture::amend_config(&ws, &[("providers.yaml", yaml)]);
fixture::spawn_root(&ws, "p9");
let err = validate(&ws, "p9", "verifier", &git()).unwrap_err();
match &err {
Invalid::SoulMissing { role, agent } => {
assert_eq!(role, "verifier");
assert_eq!(agent, "p9");
}
other => panic!("expected SoulMissing, got {other:?}"),
}
assert_eq!(
err.to_string(),
"role \"verifier\" is defined but its soul souls/verifier.md is missing from \
the config governing agent \"p9\" — a role is its `roles:` entry and its \
soul (ARCH §4.3)"
);
}
#[test]
fn a_legacy_providers_yaml_is_config_error() {
let (_h, ws) = fixture::workspace();
fixture::amend_config(&ws, &[("providers.yaml", "providers: {}\n")]);
fixture::spawn_root(&ws, "p9");
let err = validate(&ws, "p9", "worker", &git()).unwrap_err();
assert!(matches!(err, Invalid::Config(_)), "{err:?}");
assert!(err.to_string().starts_with("providers.yaml:"));
}
#[test]
fn a_non_workspace_repo_is_a_governing_error() {
let holder = tempfile::TempDir::new().unwrap();
let err = validate(holder.path(), "p1", "worker", &git()).unwrap_err();
match &err {
Invalid::Governing { branch, .. } => assert_eq!(branch, "p1"),
other => panic!("expected Governing, got {other:?}"),
}
assert!(err.to_string().contains("governing config for p1"));
}
}