use crate::config::error::LoadError;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
pub struct PerRepoProviders {
#[serde(default)]
pub roles: BTreeMap<String, RoleAssignment>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct RoleAssignment {
pub provider: String,
pub model: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
}
const LEGACY_KEYS: &[&str] = &["providers", "models"];
impl PerRepoProviders {
pub fn parse(raw: &str, path: &Path) -> Result<Self, LoadError> {
let doc: serde_yaml_ng::Value =
serde_yaml_ng::from_str(raw).map_err(|source| LoadError::Yaml {
path: path.to_path_buf(),
source,
})?;
if let Some(map) = doc.as_mapping() {
for legacy in LEGACY_KEYS {
if map.contains_key(*legacy) {
return Err(LoadError::Invalid {
path: path.to_path_buf(),
key: (*legacy).to_string(),
message: format!(
"{legacy:?} block belongs in the global \
<harness-root>/models.yaml; the per-repo file must \
only carry the 'roles:' section (ARCH §4.1)",
),
});
}
}
}
let roles_value = doc
.as_mapping()
.and_then(|m| m.get("roles"))
.cloned()
.unwrap_or(serde_yaml_ng::Value::Null);
let roles: BTreeMap<String, RoleAssignment> = if roles_value.is_null() {
BTreeMap::new()
} else {
serde_yaml_ng::from_value(roles_value).map_err(|source| LoadError::Yaml {
path: path.to_path_buf(),
source,
})?
};
Ok(Self { roles })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(raw: &str) -> Result<PerRepoProviders, LoadError> {
PerRepoProviders::parse(raw, Path::new("<commit>:providers.yaml"))
}
const ROLES_ONLY: &str = r#"
roles:
worker:
provider: anthropic
model: claude-sonnet-5
tools: [bash, read_file]
compactor:
provider: anthropic
model: claude-haiku-4-5
"#;
#[test]
fn parses_roles_only() {
let p = parse(ROLES_ONLY).unwrap();
assert_eq!(p.roles.len(), 2);
assert_eq!(p.roles["worker"].provider, "anthropic");
assert_eq!(p.roles["worker"].model, "claude-sonnet-5");
assert_eq!(p.roles["worker"].tools, vec!["bash", "read_file"]);
assert!(p.roles["compactor"].tools.is_empty());
}
#[test]
fn missing_roles_section_loads_empty() {
let p = parse("# nothing yet\n").unwrap();
assert!(p.roles.is_empty());
}
#[test]
fn rejects_legacy_providers_block() {
let yaml = r#"
providers:
anthropic:
endpoint: https://api.anthropic.com
auth: { type: api_key, env: ANTHROPIC_API_KEY }
roles:
worker:
provider: anthropic
model: claude-sonnet-5
"#;
let err = parse(yaml).unwrap_err();
match err {
LoadError::Invalid { key, message, .. } => {
assert_eq!(key, "providers");
assert!(message.contains("harness-root"));
}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn rejects_legacy_models_block() {
let yaml = r#"
models:
claude-sonnet-5:
provider: anthropic
model_id: claude-sonnet-5
capabilities: []
context_window: 1000
"#;
let err = parse(yaml).unwrap_err();
match err {
LoadError::Invalid { key, .. } => assert_eq!(key, "models"),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn rejects_first_legacy_block_seen() {
let yaml = r#"
providers:
anthropic:
endpoint: x
auth: { type: api_key, env: K }
models:
m: { provider: anthropic, model_id: m, capabilities: [], context_window: 1 }
roles: {}
"#;
let err = parse(yaml).unwrap_err();
match err {
LoadError::Invalid { key, .. } => assert_eq!(key, "providers"),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn surfaces_yaml_parse_errors() {
let err = parse("not: [valid: yaml").unwrap_err();
assert!(matches!(err, LoadError::Yaml { .. }));
}
#[test]
fn non_map_roles_section_surfaces_yaml_error() {
let err = parse("roles: [not, a, map]\n").unwrap_err();
assert!(matches!(err, LoadError::Yaml { .. }));
}
#[test]
fn malformed_role_entry_surfaces_yaml_error() {
let yaml = r#"
roles:
worker:
provider: anthropic
"#;
let err = parse(yaml).unwrap_err();
assert!(matches!(err, LoadError::Yaml { .. }));
}
#[test]
fn top_level_non_mapping_loads_empty_roles() {
let p = parse("\"a string\"\n").unwrap();
assert!(p.roles.is_empty());
}
}