Skip to main content

skilltest_core/
config.rs

1//! Configuration: which provider runs skills, the default platforms and models a
2//! run fans out across, and the model used for natural-language evals.
3//!
4//! Config is loaded from a YAML file (default `skilltest.yaml`) and then refined
5//! by CLI overrides (see [`Config::apply_overrides`]).
6
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Error, Result};
12use crate::mock::MockDecl;
13
14/// Environment variable the SDKs set to the `oneharness` binary they bundle (the
15/// `oneharness-cli` PyPI/npm package installed alongside the SDK), so a run
16/// resolves oneharness with no `PATH` setup. It is only a *default*: an explicit
17/// config `provider.bin` (or the `--oneharness-bin` flag) still wins.
18pub const ONEHARNESS_BIN_ENV: &str = "SKILLTEST_ONEHARNESS_BIN";
19
20fn default_oneharness_bin() -> String {
21    match std::env::var(ONEHARNESS_BIN_ENV) {
22        Ok(bin) if !bin.trim().is_empty() => bin,
23        _ => "oneharness".to_string(),
24    }
25}
26
27fn default_judge_harness() -> String {
28    "claude-code".to_string()
29}
30
31fn default_timeout_secs() -> u64 {
32    120
33}
34
35fn default_api_timeout_secs() -> u64 {
36    60
37}
38
39fn default_curl_bin() -> String {
40    "curl".to_string()
41}
42
43fn default_true() -> bool {
44    true
45}
46
47/// Settings for the default [`oneharness`](https://github.com/nickderobertis/oneharness)
48/// provider, which runs each prompt on a harness via `oneharness run`.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct OneharnessConfig {
52    /// The `oneharness` binary. When unset, defaults to `$SKILLTEST_ONEHARNESS_BIN`
53    /// (the path the SDKs point at their bundled `oneharness-cli`), else
54    /// `oneharness` resolved on `PATH`.
55    #[serde(default = "default_oneharness_bin")]
56    pub bin: String,
57    /// The harness used for evals and the simulated user (kept independent of the
58    /// harness under test, so the evaluator does not vary with the matrix).
59    #[serde(default = "default_judge_harness")]
60    pub judge_harness: String,
61    /// Per-call timeout passed through to `oneharness run --timeout`.
62    #[serde(default = "default_timeout_secs")]
63    pub timeout_secs: u64,
64    /// Record each skill run to oneharness's run history (`oneharness run
65    /// --history`), so a past run can be reviewed later with `oneharness
66    /// history`. On by default. The judge and simulated-user calls are never
67    /// recorded — only the skill under test.
68    #[serde(default = "default_true")]
69    pub history: bool,
70    /// Directory the shared run history is written to (passed as `oneharness run
71    /// --history-dir`). Defaults to a single centralized location reused across
72    /// every skilltest invocation (see
73    /// [`crate::provider::default_history_dir`]), so past runs accumulate in one
74    /// reviewable place instead of scattering per project.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub history_dir: Option<String>,
77}
78
79impl Default for OneharnessConfig {
80    fn default() -> Self {
81        Self {
82            bin: default_oneharness_bin(),
83            judge_harness: default_judge_harness(),
84            timeout_secs: default_timeout_secs(),
85            history: true,
86            history_dir: None,
87        }
88    }
89}
90
91/// Settings for a custom provider command speaking the JSON-lines protocol (see
92/// `docs/protocol.md`). Used by the bundled `skilltest-fake-provider` and any
93/// provider you write yourself.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct CommandConfig {
97    /// The provider command as an argv vector.
98    pub command: Vec<String>,
99}
100
101/// Which provider backs a run.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "kind", rename_all = "lowercase")]
104pub enum ProviderConfig {
105    /// Run skills through `oneharness` (the default).
106    Oneharness(OneharnessConfig),
107    /// Run a custom command speaking the JSON-lines protocol.
108    Command(CommandConfig),
109}
110
111impl Default for ProviderConfig {
112    fn default() -> Self {
113        ProviderConfig::Oneharness(OneharnessConfig::default())
114    }
115}
116
117/// Which model vendor's API the direct-API judge talks to.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum ApiVendor {
121    /// Anthropic Messages API (`POST /v1/messages`).
122    Anthropic,
123    /// OpenAI Chat Completions API (`POST /v1/chat/completions`).
124    Openai,
125}
126
127/// Settings for judging evals and the simulated user with a direct model API
128/// call instead of running them through a harness. This trades the harness's
129/// auth-portability for a single fast HTTP round trip per judge call (no
130/// agent-loop cold start), with normalized token usage surfaced into the report.
131///
132/// The judge *model* is the run's `judge_model` (it must be a valid API model
133/// id for the chosen `vendor`, e.g. `claude-opus-4-8` or `gpt-4o`); only the
134/// transport is configured here.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct ApiJudgeConfig {
138    /// Which vendor's API to call.
139    pub vendor: ApiVendor,
140    /// Environment variable holding the API key. Defaults to `ANTHROPIC_API_KEY`
141    /// or `OPENAI_API_KEY` by vendor. The key is read at run time and never
142    /// stored in config.
143    #[serde(default)]
144    pub api_key_env: Option<String>,
145    /// Override the API endpoint (e.g. a proxy or an OpenAI-compatible gateway).
146    /// Defaults to the vendor's standard endpoint.
147    #[serde(default)]
148    pub base_url: Option<String>,
149    /// Per-call timeout in seconds, passed to `curl --max-time`.
150    #[serde(default = "default_api_timeout_secs")]
151    pub timeout_secs: u64,
152    /// The `curl` binary (resolved on `PATH`).
153    #[serde(default = "default_curl_bin")]
154    pub curl_bin: String,
155    /// Constrain the judge's verdict to the `{value, reason}` JSON schema via the
156    /// vendor's structured-outputs feature (Anthropic `output_config.format`,
157    /// OpenAI `response_format: json_schema`). On by default — it removes a class
158    /// of judge-parse fragility. Turn it off for a model/endpoint that doesn't
159    /// support structured outputs (the tolerant `{…}` extraction still applies).
160    #[serde(default = "default_true")]
161    pub strict_json: bool,
162}
163
164/// How evals and the simulated user are judged, independent of the provider that
165/// runs the skill. Absent (the default) means the run's provider judges too
166/// (e.g. the oneharness `judge_harness`).
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(tag = "kind", rename_all = "lowercase")]
169pub enum JudgeConfig {
170    /// Judge with a direct model API call (see [`ApiJudgeConfig`]).
171    Api(ApiJudgeConfig),
172}
173
174/// The full configuration for a run.
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[serde(default, deny_unknown_fields)]
177pub struct Config {
178    /// The provider that executes skills and evals.
179    pub provider: ProviderConfig,
180    /// Harness platforms a case runs on (e.g. `claude-code`, `codex`).
181    pub platforms: Vec<String>,
182    /// Models a case runs on (must be valid for the chosen harness, e.g.
183    /// `sonnet`/`haiku` for `claude-code`).
184    pub models: Vec<String>,
185    /// Model used for natural-language evals and the simulated user. Falls back
186    /// to the first entry of `models` when empty.
187    pub judge_model: String,
188    /// Default cap on assistant turns for multi-turn cases. A case may lower it.
189    pub max_turns: u32,
190    /// Optional judge backend that overrides how evals and the simulated user are
191    /// scored, independent of the skill-running provider. When `None`, the
192    /// provider judges (e.g. the oneharness `judge_harness`).
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub judge: Option<JudgeConfig>,
195    /// Shared mock/spy declarations, prepended to every case's own `mocks`
196    /// (first match wins, so these shadow the case's). Populated by the CLI's
197    /// `--mocks <file>` — how the SDKs deliver code-level mocks — or written
198    /// here directly for suite-wide rules.
199    #[serde(default)]
200    pub mocks: Vec<MockDecl>,
201    /// Force the mock/spy observation channel on for every run, even for cases
202    /// that declare no `mocks` (the CLI's `--spy`; how SDK spies get records).
203    #[serde(default)]
204    pub spy: bool,
205}
206
207impl Default for Config {
208    fn default() -> Self {
209        Self {
210            provider: ProviderConfig::default(),
211            platforms: vec!["claude-code".to_string()],
212            models: vec!["claude-opus-4-8".to_string()],
213            judge_model: String::new(),
214            max_turns: 8,
215            judge: None,
216            mocks: Vec::new(),
217            spy: false,
218        }
219    }
220}
221
222/// CLI-supplied overrides. `None`/empty fields leave the config value in place.
223#[derive(Debug, Clone, Default)]
224pub struct Overrides {
225    /// If set, switch to a [`ProviderConfig::Command`] with this argv.
226    pub command_provider: Option<Vec<String>>,
227    /// Override the `oneharness` binary (only applies to the oneharness provider).
228    pub oneharness_bin: Option<String>,
229    /// Override the judge harness (only applies to the oneharness provider).
230    pub judge_harness: Option<String>,
231    /// Override the per-call timeout (only applies to the oneharness provider).
232    pub timeout_secs: Option<u64>,
233    pub platforms: Vec<String>,
234    pub models: Vec<String>,
235    pub judge_model: Option<String>,
236    pub max_turns: Option<u32>,
237    /// Mock/spy declarations prepended before any config-level ones (the CLI's
238    /// `--mocks` file / SDK-passed mocks).
239    pub mocks: Vec<MockDecl>,
240    /// Force the observation channel on (the CLI's `--spy`).
241    pub spy: bool,
242}
243
244impl Config {
245    /// Load configuration from `path`. The standard config filename is
246    /// `skilltest.yaml`.
247    ///
248    /// # Errors
249    /// [`Error::Io`] if the file cannot be read, [`Error::Yaml`] if it does not
250    /// parse, and [`Error::Invalid`] if it parses but is internally
251    /// inconsistent (see [`Config::validate`]).
252    pub fn load(path: &Path) -> Result<Self> {
253        let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
254            path: path.to_path_buf(),
255            source,
256        })?;
257        let config: Config = serde_yaml::from_str(&text).map_err(|source| Error::Yaml {
258            path: path.to_path_buf(),
259            source,
260        })?;
261        config.validate()?;
262        Ok(config)
263    }
264
265    /// Load `path` if it exists, otherwise return [`Config::default`].
266    ///
267    /// # Errors
268    /// Same as [`Config::load`] when the file is present but invalid.
269    pub fn load_or_default(path: &Path) -> Result<Self> {
270        if path.is_file() {
271            Self::load(path)
272        } else {
273            Ok(Self::default())
274        }
275    }
276
277    /// Apply CLI overrides in place, then re-validate.
278    ///
279    /// # Errors
280    /// [`Error::Invalid`] if the merged configuration is inconsistent.
281    pub fn apply_overrides(&mut self, overrides: Overrides) -> Result<()> {
282        if let Some(command) = overrides.command_provider {
283            self.provider = ProviderConfig::Command(CommandConfig { command });
284        } else if let ProviderConfig::Oneharness(oh) = &mut self.provider {
285            if let Some(bin) = overrides.oneharness_bin {
286                oh.bin = bin;
287            }
288            if let Some(judge_harness) = overrides.judge_harness {
289                oh.judge_harness = judge_harness;
290            }
291            if let Some(timeout) = overrides.timeout_secs {
292                oh.timeout_secs = timeout;
293            }
294        }
295        if !overrides.platforms.is_empty() {
296            self.platforms = overrides.platforms;
297        }
298        if !overrides.models.is_empty() {
299            self.models = overrides.models;
300        }
301        if let Some(judge) = overrides.judge_model {
302            self.judge_model = judge;
303        }
304        if let Some(max_turns) = overrides.max_turns {
305            self.max_turns = max_turns;
306        }
307        if !overrides.mocks.is_empty() {
308            // Override mocks come first: first match wins, so the most local
309            // declaration (CLI/SDK) shadows a config-level one.
310            let mut mocks = overrides.mocks;
311            mocks.append(&mut self.mocks);
312            self.mocks = mocks;
313        }
314        self.spy = self.spy || overrides.spy;
315        self.validate()
316    }
317
318    /// The model used for evals and the simulated user: `judge_model` if set,
319    /// otherwise the first configured model.
320    #[must_use]
321    pub fn effective_judge_model(&self) -> &str {
322        if self.judge_model.is_empty() {
323            self.models.first().map_or("", String::as_str)
324        } else {
325            &self.judge_model
326        }
327    }
328
329    /// Check internal consistency.
330    ///
331    /// # Errors
332    /// [`Error::Invalid`] when the provider is misconfigured or no
333    /// platform/model is set.
334    pub fn validate(&self) -> Result<()> {
335        match &self.provider {
336            ProviderConfig::Oneharness(oh) => {
337                if oh.bin.trim().is_empty() {
338                    return Err(Error::Invalid(
339                        "config `provider.bin` must name the oneharness binary".into(),
340                    ));
341                }
342                if oh.judge_harness.trim().is_empty() {
343                    return Err(Error::Invalid(
344                        "config `provider.judge_harness` must name a harness".into(),
345                    ));
346                }
347                if oh.timeout_secs == 0 {
348                    return Err(Error::Invalid(
349                        "config `provider.timeout_secs` must be at least 1".into(),
350                    ));
351                }
352            }
353            ProviderConfig::Command(c) => {
354                if c.command.is_empty() {
355                    return Err(Error::Invalid(
356                        "config `provider.command` must name a command".into(),
357                    ));
358                }
359            }
360        }
361        if self.platforms.is_empty() {
362            return Err(Error::Invalid(
363                "config `platforms` must list at least one harness platform".into(),
364            ));
365        }
366        if self.models.is_empty() {
367            return Err(Error::Invalid(
368                "config `models` must list at least one model".into(),
369            ));
370        }
371        if self.max_turns == 0 {
372            return Err(Error::Invalid(
373                "config `max_turns` must be at least 1".into(),
374            ));
375        }
376        if let Some(JudgeConfig::Api(api)) = &self.judge {
377            if api.timeout_secs == 0 {
378                return Err(Error::Invalid(
379                    "config `judge.timeout_secs` must be at least 1".into(),
380                ));
381            }
382            if api.curl_bin.trim().is_empty() {
383                return Err(Error::Invalid(
384                    "config `judge.curl_bin` must name the curl binary".into(),
385                ));
386            }
387        }
388        for (i, decl) in self.mocks.iter().enumerate() {
389            let label = decl.name.clone().unwrap_or_else(|| format!("#{i}"));
390            decl.validate(&format!("config mock `{label}`"))?;
391        }
392        Ok(())
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn defaults_are_valid_and_use_oneharness() {
402        let config = Config::default();
403        config.validate().unwrap();
404        assert!(matches!(config.provider, ProviderConfig::Oneharness(_)));
405    }
406
407    #[test]
408    fn command_override_switches_provider() {
409        let mut config = Config::default();
410        config
411            .apply_overrides(Overrides {
412                command_provider: Some(vec!["fake".into()]),
413                ..Default::default()
414            })
415            .unwrap();
416        assert_eq!(
417            config.provider,
418            ProviderConfig::Command(CommandConfig {
419                command: vec!["fake".into()]
420            })
421        );
422    }
423
424    #[test]
425    fn oneharness_bin_override_applies() {
426        let mut config = Config::default();
427        config
428            .apply_overrides(Overrides {
429                oneharness_bin: Some("/tmp/oneharness".into()),
430                ..Default::default()
431            })
432            .unwrap();
433        let ProviderConfig::Oneharness(oh) = &config.provider else {
434            panic!("expected oneharness provider");
435        };
436        assert_eq!(oh.bin, "/tmp/oneharness");
437    }
438
439    #[test]
440    fn oneharness_bin_defaults_to_env_when_set() {
441        // The SDKs export SKILLTEST_ONEHARNESS_BIN to their bundled oneharness;
442        // a config that omits `bin` picks it up. (nextest isolates each test in
443        // its own process, so this env write does not leak to other tests.)
444        std::env::set_var(ONEHARNESS_BIN_ENV, "/opt/bundled/oneharness");
445        let yaml = "provider:\n  kind: oneharness\n  judge_harness: codex\n";
446        let config: Config = serde_yaml::from_str(yaml).unwrap();
447        let ProviderConfig::Oneharness(oh) = &config.provider else {
448            panic!("expected oneharness provider");
449        };
450        assert_eq!(oh.bin, "/opt/bundled/oneharness");
451        std::env::remove_var(ONEHARNESS_BIN_ENV);
452    }
453
454    #[test]
455    fn explicit_bin_wins_over_env_default() {
456        // An explicit `provider.bin` is a present field, so serde never calls the
457        // env-aware default — the user's choice is honored over the bundled one.
458        std::env::set_var(ONEHARNESS_BIN_ENV, "/opt/bundled/oneharness");
459        let yaml = "provider:\n  kind: oneharness\n  bin: /custom/oneharness\n";
460        let config: Config = serde_yaml::from_str(yaml).unwrap();
461        let ProviderConfig::Oneharness(oh) = &config.provider else {
462            panic!("expected oneharness provider");
463        };
464        assert_eq!(oh.bin, "/custom/oneharness");
465        std::env::remove_var(ONEHARNESS_BIN_ENV);
466    }
467
468    #[test]
469    fn oneharness_bin_defaults_to_path_name_without_env() {
470        std::env::remove_var(ONEHARNESS_BIN_ENV);
471        let yaml = "provider:\n  kind: oneharness\n";
472        let config: Config = serde_yaml::from_str(yaml).unwrap();
473        let ProviderConfig::Oneharness(oh) = &config.provider else {
474            panic!("expected oneharness provider");
475        };
476        assert_eq!(oh.bin, "oneharness");
477    }
478
479    #[test]
480    fn parses_command_provider_yaml() {
481        let yaml = "provider:\n  kind: command\n  command: [\"prov\", \"--flag\"]\n";
482        let config: Config = serde_yaml::from_str(yaml).unwrap();
483        assert_eq!(
484            config.provider,
485            ProviderConfig::Command(CommandConfig {
486                command: vec!["prov".into(), "--flag".into()]
487            })
488        );
489    }
490
491    #[test]
492    fn parses_oneharness_provider_yaml() {
493        let yaml = "provider:\n  kind: oneharness\n  bin: oh\n  judge_harness: codex\n";
494        let config: Config = serde_yaml::from_str(yaml).unwrap();
495        let ProviderConfig::Oneharness(oh) = &config.provider else {
496            panic!("expected oneharness provider");
497        };
498        assert_eq!(oh.bin, "oh");
499        assert_eq!(oh.judge_harness, "codex");
500        // Unspecified fields fall back to defaults.
501        assert_eq!(oh.timeout_secs, 120);
502        // History recording is on by default, with the centralized default dir.
503        assert!(oh.history);
504        assert!(oh.history_dir.is_none());
505    }
506
507    #[test]
508    fn parses_oneharness_history_overrides() {
509        let yaml =
510            "provider:\n  kind: oneharness\n  history: false\n  history_dir: /shared/history\n";
511        let config: Config = serde_yaml::from_str(yaml).unwrap();
512        let ProviderConfig::Oneharness(oh) = &config.provider else {
513            panic!("expected oneharness provider");
514        };
515        assert!(!oh.history);
516        assert_eq!(oh.history_dir.as_deref(), Some("/shared/history"));
517        config.validate().unwrap();
518    }
519
520    #[test]
521    fn judge_model_falls_back_to_first_model() {
522        let config = Config::default();
523        assert_eq!(config.effective_judge_model(), "claude-opus-4-8");
524    }
525
526    #[test]
527    fn empty_models_is_invalid() {
528        let mut config = Config::default();
529        config.models.clear();
530        assert!(config.validate().is_err());
531    }
532
533    #[test]
534    fn parses_api_judge_config() {
535        let yaml = "\
536provider:\n  kind: oneharness\njudge:\n  kind: api\n  vendor: anthropic\n  timeout_secs: 30\n";
537        let config: Config = serde_yaml::from_str(yaml).unwrap();
538        let Some(JudgeConfig::Api(api)) = &config.judge else {
539            panic!("expected an api judge");
540        };
541        assert_eq!(api.vendor, ApiVendor::Anthropic);
542        assert_eq!(api.timeout_secs, 30);
543        // Unspecified fields fall back to defaults.
544        assert_eq!(api.curl_bin, "curl");
545        assert!(api.api_key_env.is_none());
546        assert!(api.strict_json, "strict JSON is on by default");
547        config.validate().unwrap();
548    }
549
550    #[test]
551    fn api_judge_zero_timeout_is_invalid() {
552        let yaml = "judge:\n  kind: api\n  vendor: openai\n  timeout_secs: 0\n";
553        let config: Config = serde_yaml::from_str(yaml).unwrap();
554        assert!(config.validate().is_err());
555    }
556
557    #[test]
558    fn default_config_has_no_judge_override() {
559        assert!(Config::default().judge.is_none());
560    }
561
562    /// Write `yaml` to a unique temp file and return its path.
563    fn config_file(tag: &str, yaml: &str) -> std::path::PathBuf {
564        use std::sync::atomic::{AtomicU64, Ordering};
565        static N: AtomicU64 = AtomicU64::new(0);
566        let dir = std::env::temp_dir().join(format!(
567            "skilltest-config-{}-{tag}-{}",
568            std::process::id(),
569            N.fetch_add(1, Ordering::Relaxed)
570        ));
571        std::fs::create_dir_all(&dir).unwrap();
572        let path = dir.join("skilltest.yaml");
573        std::fs::write(&path, yaml).unwrap();
574        path
575    }
576
577    #[test]
578    fn load_reads_and_validates_a_file() {
579        let path = config_file(
580            "load",
581            "provider:\n  kind: command\n  command: [\"prov\"]\nplatforms: [demo]\nmodels: [m]\n",
582        );
583        let config = Config::load(&path).unwrap();
584        assert_eq!(config.platforms, vec!["demo".to_string()]);
585        assert!(matches!(config.provider, ProviderConfig::Command(_)));
586    }
587
588    #[test]
589    fn load_missing_file_is_io_error() {
590        let path = std::env::temp_dir().join(format!("skilltest-none-{}.yaml", std::process::id()));
591        assert!(matches!(Config::load(&path), Err(Error::Io { .. })));
592    }
593
594    #[test]
595    fn load_malformed_yaml_is_yaml_error() {
596        let path = config_file("bad", "platforms: [unterminated\n");
597        assert!(matches!(Config::load(&path), Err(Error::Yaml { .. })));
598    }
599
600    #[test]
601    fn load_inconsistent_config_is_invalid_error() {
602        // Parses fine, but an empty command provider fails validation.
603        let path = config_file(
604            "inconsistent",
605            "provider:\n  kind: command\n  command: []\n",
606        );
607        assert!(matches!(Config::load(&path), Err(Error::Invalid(_))));
608    }
609
610    #[test]
611    fn load_or_default_returns_default_when_absent() {
612        let path =
613            std::env::temp_dir().join(format!("skilltest-absent-{}.yaml", std::process::id()));
614        let config = Config::load_or_default(&path).unwrap();
615        assert_eq!(config, Config::default());
616    }
617
618    #[test]
619    fn load_or_default_loads_when_present() {
620        let path = config_file("present", "platforms: [a, b]\nmodels: [m]\n");
621        let config = Config::load_or_default(&path).unwrap();
622        assert_eq!(config.platforms, vec!["a".to_string(), "b".to_string()]);
623    }
624
625    #[test]
626    fn overrides_apply_judge_harness_timeout_and_run_fields() {
627        let mut config = Config::default();
628        config
629            .apply_overrides(Overrides {
630                judge_harness: Some("codex".into()),
631                timeout_secs: Some(45),
632                platforms: vec!["p1".into(), "p2".into()],
633                models: vec!["mod".into()],
634                judge_model: Some("haiku".into()),
635                max_turns: Some(3),
636                ..Default::default()
637            })
638            .unwrap();
639        let ProviderConfig::Oneharness(oh) = &config.provider else {
640            panic!("still oneharness");
641        };
642        assert_eq!(oh.judge_harness, "codex");
643        assert_eq!(oh.timeout_secs, 45);
644        assert_eq!(config.platforms, vec!["p1".to_string(), "p2".to_string()]);
645        assert_eq!(config.models, vec!["mod".to_string()]);
646        assert_eq!(config.judge_model, "haiku");
647        assert_eq!(config.max_turns, 3);
648    }
649
650    #[test]
651    fn effective_judge_model_prefers_explicit_judge_model() {
652        let config = Config {
653            judge_model: "haiku".into(),
654            ..Config::default()
655        };
656        assert_eq!(config.effective_judge_model(), "haiku");
657    }
658
659    #[test]
660    fn validate_rejects_blank_oneharness_fields() {
661        let mut config = Config::default();
662        if let ProviderConfig::Oneharness(oh) = &mut config.provider {
663            oh.bin = "  ".into();
664        }
665        assert!(config.validate().is_err());
666
667        let mut config = Config::default();
668        if let ProviderConfig::Oneharness(oh) = &mut config.provider {
669            oh.judge_harness = "".into();
670        }
671        assert!(config.validate().is_err());
672
673        let mut config = Config::default();
674        if let ProviderConfig::Oneharness(oh) = &mut config.provider {
675            oh.timeout_secs = 0;
676        }
677        assert!(config.validate().is_err());
678    }
679
680    #[test]
681    fn validate_rejects_empty_platforms_and_zero_max_turns() {
682        let mut config = Config::default();
683        config.platforms.clear();
684        assert!(config.validate().is_err());
685
686        let config = Config {
687            max_turns: 0,
688            ..Config::default()
689        };
690        assert!(config.validate().is_err());
691    }
692
693    #[test]
694    fn validate_rejects_blank_api_judge_curl_bin() {
695        let yaml = "judge:\n  kind: api\n  vendor: anthropic\n  curl_bin: \"  \"\n";
696        let config: Config = serde_yaml::from_str(yaml).unwrap();
697        assert!(config.validate().is_err());
698    }
699
700    #[test]
701    fn config_round_trips_through_yaml() {
702        let config = Config {
703            judge: Some(JudgeConfig::Api(ApiJudgeConfig {
704                vendor: ApiVendor::Openai,
705                api_key_env: Some("X".into()),
706                base_url: None,
707                timeout_secs: 30,
708                curl_bin: "curl".into(),
709                strict_json: false,
710            })),
711            ..Config::default()
712        };
713        let yaml = serde_yaml::to_string(&config).unwrap();
714        let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
715        assert_eq!(parsed, config);
716    }
717}