1use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Error, Result};
12use crate::mock::MockDecl;
13
14pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct OneharnessConfig {
52 #[serde(default = "default_oneharness_bin")]
56 pub bin: String,
57 #[serde(default = "default_judge_harness")]
60 pub judge_harness: String,
61 #[serde(default = "default_timeout_secs")]
63 pub timeout_secs: u64,
64 #[serde(default = "default_true")]
69 pub history: bool,
70 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct CommandConfig {
97 pub command: Vec<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "kind", rename_all = "lowercase")]
104pub enum ProviderConfig {
105 Oneharness(OneharnessConfig),
107 Command(CommandConfig),
109}
110
111impl Default for ProviderConfig {
112 fn default() -> Self {
113 ProviderConfig::Oneharness(OneharnessConfig::default())
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum ApiVendor {
121 Anthropic,
123 Openai,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct ApiJudgeConfig {
138 pub vendor: ApiVendor,
140 #[serde(default)]
144 pub api_key_env: Option<String>,
145 #[serde(default)]
148 pub base_url: Option<String>,
149 #[serde(default = "default_api_timeout_secs")]
151 pub timeout_secs: u64,
152 #[serde(default = "default_curl_bin")]
154 pub curl_bin: String,
155 #[serde(default = "default_true")]
161 pub strict_json: bool,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(tag = "kind", rename_all = "lowercase")]
169pub enum JudgeConfig {
170 Api(ApiJudgeConfig),
172}
173
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[serde(default, deny_unknown_fields)]
177pub struct Config {
178 pub provider: ProviderConfig,
180 pub platforms: Vec<String>,
182 pub models: Vec<String>,
185 pub judge_model: String,
188 pub max_turns: u32,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub judge: Option<JudgeConfig>,
195 #[serde(default)]
200 pub mocks: Vec<MockDecl>,
201 #[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#[derive(Debug, Clone, Default)]
224pub struct Overrides {
225 pub command_provider: Option<Vec<String>>,
227 pub oneharness_bin: Option<String>,
229 pub judge_harness: Option<String>,
231 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 pub mocks: Vec<MockDecl>,
240 pub spy: bool,
242}
243
244impl Config {
245 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 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 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 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 #[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 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 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 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 assert_eq!(oh.timeout_secs, 120);
502 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 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 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 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}