use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::mock::MockDecl;
fn default_oneharness_bin() -> String {
"oneharness".to_string()
}
fn default_judge_harness() -> String {
"claude-code".to_string()
}
fn default_timeout_secs() -> u64 {
120
}
fn default_api_timeout_secs() -> u64 {
60
}
fn default_curl_bin() -> String {
"curl".to_string()
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OneharnessConfig {
#[serde(default = "default_oneharness_bin")]
pub bin: String,
#[serde(default = "default_judge_harness")]
pub judge_harness: String,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
}
impl Default for OneharnessConfig {
fn default() -> Self {
Self {
bin: default_oneharness_bin(),
judge_harness: default_judge_harness(),
timeout_secs: default_timeout_secs(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommandConfig {
pub command: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum ProviderConfig {
Oneharness(OneharnessConfig),
Command(CommandConfig),
}
impl Default for ProviderConfig {
fn default() -> Self {
ProviderConfig::Oneharness(OneharnessConfig::default())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApiVendor {
Anthropic,
Openai,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApiJudgeConfig {
pub vendor: ApiVendor,
#[serde(default)]
pub api_key_env: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default = "default_api_timeout_secs")]
pub timeout_secs: u64,
#[serde(default = "default_curl_bin")]
pub curl_bin: String,
#[serde(default = "default_true")]
pub strict_json: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum JudgeConfig {
Api(ApiJudgeConfig),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub provider: ProviderConfig,
pub platforms: Vec<String>,
pub models: Vec<String>,
pub judge_model: String,
pub max_turns: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub judge: Option<JudgeConfig>,
#[serde(default)]
pub mocks: Vec<MockDecl>,
#[serde(default)]
pub spy: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
provider: ProviderConfig::default(),
platforms: vec!["claude-code".to_string()],
models: vec!["claude-opus-4-8".to_string()],
judge_model: String::new(),
max_turns: 8,
judge: None,
mocks: Vec::new(),
spy: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Overrides {
pub command_provider: Option<Vec<String>>,
pub oneharness_bin: Option<String>,
pub judge_harness: Option<String>,
pub timeout_secs: Option<u64>,
pub platforms: Vec<String>,
pub models: Vec<String>,
pub judge_model: Option<String>,
pub max_turns: Option<u32>,
pub mocks: Vec<MockDecl>,
pub spy: bool,
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
let config: Config = serde_yaml::from_str(&text).map_err(|source| Error::Yaml {
path: path.to_path_buf(),
source,
})?;
config.validate()?;
Ok(config)
}
pub fn load_or_default(path: &Path) -> Result<Self> {
if path.is_file() {
Self::load(path)
} else {
Ok(Self::default())
}
}
pub fn apply_overrides(&mut self, overrides: Overrides) -> Result<()> {
if let Some(command) = overrides.command_provider {
self.provider = ProviderConfig::Command(CommandConfig { command });
} else if let ProviderConfig::Oneharness(oh) = &mut self.provider {
if let Some(bin) = overrides.oneharness_bin {
oh.bin = bin;
}
if let Some(judge_harness) = overrides.judge_harness {
oh.judge_harness = judge_harness;
}
if let Some(timeout) = overrides.timeout_secs {
oh.timeout_secs = timeout;
}
}
if !overrides.platforms.is_empty() {
self.platforms = overrides.platforms;
}
if !overrides.models.is_empty() {
self.models = overrides.models;
}
if let Some(judge) = overrides.judge_model {
self.judge_model = judge;
}
if let Some(max_turns) = overrides.max_turns {
self.max_turns = max_turns;
}
if !overrides.mocks.is_empty() {
let mut mocks = overrides.mocks;
mocks.append(&mut self.mocks);
self.mocks = mocks;
}
self.spy = self.spy || overrides.spy;
self.validate()
}
#[must_use]
pub fn effective_judge_model(&self) -> &str {
if self.judge_model.is_empty() {
self.models.first().map_or("", String::as_str)
} else {
&self.judge_model
}
}
pub fn validate(&self) -> Result<()> {
match &self.provider {
ProviderConfig::Oneharness(oh) => {
if oh.bin.trim().is_empty() {
return Err(Error::Invalid(
"config `provider.bin` must name the oneharness binary".into(),
));
}
if oh.judge_harness.trim().is_empty() {
return Err(Error::Invalid(
"config `provider.judge_harness` must name a harness".into(),
));
}
if oh.timeout_secs == 0 {
return Err(Error::Invalid(
"config `provider.timeout_secs` must be at least 1".into(),
));
}
}
ProviderConfig::Command(c) => {
if c.command.is_empty() {
return Err(Error::Invalid(
"config `provider.command` must name a command".into(),
));
}
}
}
if self.platforms.is_empty() {
return Err(Error::Invalid(
"config `platforms` must list at least one harness platform".into(),
));
}
if self.models.is_empty() {
return Err(Error::Invalid(
"config `models` must list at least one model".into(),
));
}
if self.max_turns == 0 {
return Err(Error::Invalid(
"config `max_turns` must be at least 1".into(),
));
}
if let Some(JudgeConfig::Api(api)) = &self.judge {
if api.timeout_secs == 0 {
return Err(Error::Invalid(
"config `judge.timeout_secs` must be at least 1".into(),
));
}
if api.curl_bin.trim().is_empty() {
return Err(Error::Invalid(
"config `judge.curl_bin` must name the curl binary".into(),
));
}
}
for (i, decl) in self.mocks.iter().enumerate() {
let label = decl.name.clone().unwrap_or_else(|| format!("#{i}"));
decl.validate(&format!("config mock `{label}`"))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_valid_and_use_oneharness() {
let config = Config::default();
config.validate().unwrap();
assert!(matches!(config.provider, ProviderConfig::Oneharness(_)));
}
#[test]
fn command_override_switches_provider() {
let mut config = Config::default();
config
.apply_overrides(Overrides {
command_provider: Some(vec!["fake".into()]),
..Default::default()
})
.unwrap();
assert_eq!(
config.provider,
ProviderConfig::Command(CommandConfig {
command: vec!["fake".into()]
})
);
}
#[test]
fn oneharness_bin_override_applies() {
let mut config = Config::default();
config
.apply_overrides(Overrides {
oneharness_bin: Some("/tmp/oneharness".into()),
..Default::default()
})
.unwrap();
let ProviderConfig::Oneharness(oh) = &config.provider else {
panic!("expected oneharness provider");
};
assert_eq!(oh.bin, "/tmp/oneharness");
}
#[test]
fn parses_command_provider_yaml() {
let yaml = "provider:\n kind: command\n command: [\"prov\", \"--flag\"]\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.provider,
ProviderConfig::Command(CommandConfig {
command: vec!["prov".into(), "--flag".into()]
})
);
}
#[test]
fn parses_oneharness_provider_yaml() {
let yaml = "provider:\n kind: oneharness\n bin: oh\n judge_harness: codex\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
let ProviderConfig::Oneharness(oh) = &config.provider else {
panic!("expected oneharness provider");
};
assert_eq!(oh.bin, "oh");
assert_eq!(oh.judge_harness, "codex");
assert_eq!(oh.timeout_secs, 120);
}
#[test]
fn judge_model_falls_back_to_first_model() {
let config = Config::default();
assert_eq!(config.effective_judge_model(), "claude-opus-4-8");
}
#[test]
fn empty_models_is_invalid() {
let mut config = Config::default();
config.models.clear();
assert!(config.validate().is_err());
}
#[test]
fn parses_api_judge_config() {
let yaml = "\
provider:\n kind: oneharness\njudge:\n kind: api\n vendor: anthropic\n timeout_secs: 30\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
let Some(JudgeConfig::Api(api)) = &config.judge else {
panic!("expected an api judge");
};
assert_eq!(api.vendor, ApiVendor::Anthropic);
assert_eq!(api.timeout_secs, 30);
assert_eq!(api.curl_bin, "curl");
assert!(api.api_key_env.is_none());
assert!(api.strict_json, "strict JSON is on by default");
config.validate().unwrap();
}
#[test]
fn api_judge_zero_timeout_is_invalid() {
let yaml = "judge:\n kind: api\n vendor: openai\n timeout_secs: 0\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn default_config_has_no_judge_override() {
assert!(Config::default().judge.is_none());
}
fn config_file(tag: &str, yaml: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"skilltest-config-{}-{tag}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("skilltest.yaml");
std::fs::write(&path, yaml).unwrap();
path
}
#[test]
fn load_reads_and_validates_a_file() {
let path = config_file(
"load",
"provider:\n kind: command\n command: [\"prov\"]\nplatforms: [demo]\nmodels: [m]\n",
);
let config = Config::load(&path).unwrap();
assert_eq!(config.platforms, vec!["demo".to_string()]);
assert!(matches!(config.provider, ProviderConfig::Command(_)));
}
#[test]
fn load_missing_file_is_io_error() {
let path = std::env::temp_dir().join(format!("skilltest-none-{}.yaml", std::process::id()));
assert!(matches!(Config::load(&path), Err(Error::Io { .. })));
}
#[test]
fn load_malformed_yaml_is_yaml_error() {
let path = config_file("bad", "platforms: [unterminated\n");
assert!(matches!(Config::load(&path), Err(Error::Yaml { .. })));
}
#[test]
fn load_inconsistent_config_is_invalid_error() {
let path = config_file(
"inconsistent",
"provider:\n kind: command\n command: []\n",
);
assert!(matches!(Config::load(&path), Err(Error::Invalid(_))));
}
#[test]
fn load_or_default_returns_default_when_absent() {
let path =
std::env::temp_dir().join(format!("skilltest-absent-{}.yaml", std::process::id()));
let config = Config::load_or_default(&path).unwrap();
assert_eq!(config, Config::default());
}
#[test]
fn load_or_default_loads_when_present() {
let path = config_file("present", "platforms: [a, b]\nmodels: [m]\n");
let config = Config::load_or_default(&path).unwrap();
assert_eq!(config.platforms, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn overrides_apply_judge_harness_timeout_and_run_fields() {
let mut config = Config::default();
config
.apply_overrides(Overrides {
judge_harness: Some("codex".into()),
timeout_secs: Some(45),
platforms: vec!["p1".into(), "p2".into()],
models: vec!["mod".into()],
judge_model: Some("haiku".into()),
max_turns: Some(3),
..Default::default()
})
.unwrap();
let ProviderConfig::Oneharness(oh) = &config.provider else {
panic!("still oneharness");
};
assert_eq!(oh.judge_harness, "codex");
assert_eq!(oh.timeout_secs, 45);
assert_eq!(config.platforms, vec!["p1".to_string(), "p2".to_string()]);
assert_eq!(config.models, vec!["mod".to_string()]);
assert_eq!(config.judge_model, "haiku");
assert_eq!(config.max_turns, 3);
}
#[test]
fn effective_judge_model_prefers_explicit_judge_model() {
let config = Config {
judge_model: "haiku".into(),
..Config::default()
};
assert_eq!(config.effective_judge_model(), "haiku");
}
#[test]
fn validate_rejects_blank_oneharness_fields() {
let mut config = Config::default();
if let ProviderConfig::Oneharness(oh) = &mut config.provider {
oh.bin = " ".into();
}
assert!(config.validate().is_err());
let mut config = Config::default();
if let ProviderConfig::Oneharness(oh) = &mut config.provider {
oh.judge_harness = "".into();
}
assert!(config.validate().is_err());
let mut config = Config::default();
if let ProviderConfig::Oneharness(oh) = &mut config.provider {
oh.timeout_secs = 0;
}
assert!(config.validate().is_err());
}
#[test]
fn validate_rejects_empty_platforms_and_zero_max_turns() {
let mut config = Config::default();
config.platforms.clear();
assert!(config.validate().is_err());
let config = Config {
max_turns: 0,
..Config::default()
};
assert!(config.validate().is_err());
}
#[test]
fn validate_rejects_blank_api_judge_curl_bin() {
let yaml = "judge:\n kind: api\n vendor: anthropic\n curl_bin: \" \"\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn config_round_trips_through_yaml() {
let config = Config {
judge: Some(JudgeConfig::Api(ApiJudgeConfig {
vendor: ApiVendor::Openai,
api_key_env: Some("X".into()),
base_url: None,
timeout_secs: 30,
curl_bin: "curl".into(),
strict_json: false,
})),
..Config::default()
};
let yaml = serde_yaml::to_string(&config).unwrap();
let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed, config);
}
}