1use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Error, Result};
12use crate::mock::MockDecl;
13
14fn default_oneharness_bin() -> String {
15 "oneharness".to_string()
16}
17
18fn default_judge_harness() -> String {
19 "claude-code".to_string()
20}
21
22fn default_timeout_secs() -> u64 {
23 120
24}
25
26fn default_api_timeout_secs() -> u64 {
27 60
28}
29
30fn default_curl_bin() -> String {
31 "curl".to_string()
32}
33
34fn default_true() -> bool {
35 true
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct OneharnessConfig {
43 #[serde(default = "default_oneharness_bin")]
45 pub bin: String,
46 #[serde(default = "default_judge_harness")]
49 pub judge_harness: String,
50 #[serde(default = "default_timeout_secs")]
52 pub timeout_secs: u64,
53 #[serde(default = "default_true")]
58 pub history: bool,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub history_dir: Option<String>,
66}
67
68impl Default for OneharnessConfig {
69 fn default() -> Self {
70 Self {
71 bin: default_oneharness_bin(),
72 judge_harness: default_judge_harness(),
73 timeout_secs: default_timeout_secs(),
74 history: true,
75 history_dir: None,
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct CommandConfig {
86 pub command: Vec<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(tag = "kind", rename_all = "lowercase")]
93pub enum ProviderConfig {
94 Oneharness(OneharnessConfig),
96 Command(CommandConfig),
98}
99
100impl Default for ProviderConfig {
101 fn default() -> Self {
102 ProviderConfig::Oneharness(OneharnessConfig::default())
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "lowercase")]
109pub enum ApiVendor {
110 Anthropic,
112 Openai,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct ApiJudgeConfig {
127 pub vendor: ApiVendor,
129 #[serde(default)]
133 pub api_key_env: Option<String>,
134 #[serde(default)]
137 pub base_url: Option<String>,
138 #[serde(default = "default_api_timeout_secs")]
140 pub timeout_secs: u64,
141 #[serde(default = "default_curl_bin")]
143 pub curl_bin: String,
144 #[serde(default = "default_true")]
150 pub strict_json: bool,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(tag = "kind", rename_all = "lowercase")]
158pub enum JudgeConfig {
159 Api(ApiJudgeConfig),
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165#[serde(default, deny_unknown_fields)]
166pub struct Config {
167 pub provider: ProviderConfig,
169 pub platforms: Vec<String>,
171 pub models: Vec<String>,
174 pub judge_model: String,
177 pub max_turns: u32,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub judge: Option<JudgeConfig>,
184 #[serde(default)]
189 pub mocks: Vec<MockDecl>,
190 #[serde(default)]
193 pub spy: bool,
194}
195
196impl Default for Config {
197 fn default() -> Self {
198 Self {
199 provider: ProviderConfig::default(),
200 platforms: vec!["claude-code".to_string()],
201 models: vec!["claude-opus-4-8".to_string()],
202 judge_model: String::new(),
203 max_turns: 8,
204 judge: None,
205 mocks: Vec::new(),
206 spy: false,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Default)]
213pub struct Overrides {
214 pub command_provider: Option<Vec<String>>,
216 pub oneharness_bin: Option<String>,
218 pub judge_harness: Option<String>,
220 pub timeout_secs: Option<u64>,
222 pub platforms: Vec<String>,
223 pub models: Vec<String>,
224 pub judge_model: Option<String>,
225 pub max_turns: Option<u32>,
226 pub mocks: Vec<MockDecl>,
229 pub spy: bool,
231}
232
233impl Config {
234 pub fn load(path: &Path) -> Result<Self> {
242 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
243 path: path.to_path_buf(),
244 source,
245 })?;
246 let config: Config = serde_yaml::from_str(&text).map_err(|source| Error::Yaml {
247 path: path.to_path_buf(),
248 source,
249 })?;
250 config.validate()?;
251 Ok(config)
252 }
253
254 pub fn load_or_default(path: &Path) -> Result<Self> {
259 if path.is_file() {
260 Self::load(path)
261 } else {
262 Ok(Self::default())
263 }
264 }
265
266 pub fn apply_overrides(&mut self, overrides: Overrides) -> Result<()> {
271 if let Some(command) = overrides.command_provider {
272 self.provider = ProviderConfig::Command(CommandConfig { command });
273 } else if let ProviderConfig::Oneharness(oh) = &mut self.provider {
274 if let Some(bin) = overrides.oneharness_bin {
275 oh.bin = bin;
276 }
277 if let Some(judge_harness) = overrides.judge_harness {
278 oh.judge_harness = judge_harness;
279 }
280 if let Some(timeout) = overrides.timeout_secs {
281 oh.timeout_secs = timeout;
282 }
283 }
284 if !overrides.platforms.is_empty() {
285 self.platforms = overrides.platforms;
286 }
287 if !overrides.models.is_empty() {
288 self.models = overrides.models;
289 }
290 if let Some(judge) = overrides.judge_model {
291 self.judge_model = judge;
292 }
293 if let Some(max_turns) = overrides.max_turns {
294 self.max_turns = max_turns;
295 }
296 if !overrides.mocks.is_empty() {
297 let mut mocks = overrides.mocks;
300 mocks.append(&mut self.mocks);
301 self.mocks = mocks;
302 }
303 self.spy = self.spy || overrides.spy;
304 self.validate()
305 }
306
307 #[must_use]
310 pub fn effective_judge_model(&self) -> &str {
311 if self.judge_model.is_empty() {
312 self.models.first().map_or("", String::as_str)
313 } else {
314 &self.judge_model
315 }
316 }
317
318 pub fn validate(&self) -> Result<()> {
324 match &self.provider {
325 ProviderConfig::Oneharness(oh) => {
326 if oh.bin.trim().is_empty() {
327 return Err(Error::Invalid(
328 "config `provider.bin` must name the oneharness binary".into(),
329 ));
330 }
331 if oh.judge_harness.trim().is_empty() {
332 return Err(Error::Invalid(
333 "config `provider.judge_harness` must name a harness".into(),
334 ));
335 }
336 if oh.timeout_secs == 0 {
337 return Err(Error::Invalid(
338 "config `provider.timeout_secs` must be at least 1".into(),
339 ));
340 }
341 }
342 ProviderConfig::Command(c) => {
343 if c.command.is_empty() {
344 return Err(Error::Invalid(
345 "config `provider.command` must name a command".into(),
346 ));
347 }
348 }
349 }
350 if self.platforms.is_empty() {
351 return Err(Error::Invalid(
352 "config `platforms` must list at least one harness platform".into(),
353 ));
354 }
355 if self.models.is_empty() {
356 return Err(Error::Invalid(
357 "config `models` must list at least one model".into(),
358 ));
359 }
360 if self.max_turns == 0 {
361 return Err(Error::Invalid(
362 "config `max_turns` must be at least 1".into(),
363 ));
364 }
365 if let Some(JudgeConfig::Api(api)) = &self.judge {
366 if api.timeout_secs == 0 {
367 return Err(Error::Invalid(
368 "config `judge.timeout_secs` must be at least 1".into(),
369 ));
370 }
371 if api.curl_bin.trim().is_empty() {
372 return Err(Error::Invalid(
373 "config `judge.curl_bin` must name the curl binary".into(),
374 ));
375 }
376 }
377 for (i, decl) in self.mocks.iter().enumerate() {
378 let label = decl.name.clone().unwrap_or_else(|| format!("#{i}"));
379 decl.validate(&format!("config mock `{label}`"))?;
380 }
381 Ok(())
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn defaults_are_valid_and_use_oneharness() {
391 let config = Config::default();
392 config.validate().unwrap();
393 assert!(matches!(config.provider, ProviderConfig::Oneharness(_)));
394 }
395
396 #[test]
397 fn command_override_switches_provider() {
398 let mut config = Config::default();
399 config
400 .apply_overrides(Overrides {
401 command_provider: Some(vec!["fake".into()]),
402 ..Default::default()
403 })
404 .unwrap();
405 assert_eq!(
406 config.provider,
407 ProviderConfig::Command(CommandConfig {
408 command: vec!["fake".into()]
409 })
410 );
411 }
412
413 #[test]
414 fn oneharness_bin_override_applies() {
415 let mut config = Config::default();
416 config
417 .apply_overrides(Overrides {
418 oneharness_bin: Some("/tmp/oneharness".into()),
419 ..Default::default()
420 })
421 .unwrap();
422 let ProviderConfig::Oneharness(oh) = &config.provider else {
423 panic!("expected oneharness provider");
424 };
425 assert_eq!(oh.bin, "/tmp/oneharness");
426 }
427
428 #[test]
429 fn parses_command_provider_yaml() {
430 let yaml = "provider:\n kind: command\n command: [\"prov\", \"--flag\"]\n";
431 let config: Config = serde_yaml::from_str(yaml).unwrap();
432 assert_eq!(
433 config.provider,
434 ProviderConfig::Command(CommandConfig {
435 command: vec!["prov".into(), "--flag".into()]
436 })
437 );
438 }
439
440 #[test]
441 fn parses_oneharness_provider_yaml() {
442 let yaml = "provider:\n kind: oneharness\n bin: oh\n judge_harness: codex\n";
443 let config: Config = serde_yaml::from_str(yaml).unwrap();
444 let ProviderConfig::Oneharness(oh) = &config.provider else {
445 panic!("expected oneharness provider");
446 };
447 assert_eq!(oh.bin, "oh");
448 assert_eq!(oh.judge_harness, "codex");
449 assert_eq!(oh.timeout_secs, 120);
451 assert!(oh.history);
453 assert!(oh.history_dir.is_none());
454 }
455
456 #[test]
457 fn parses_oneharness_history_overrides() {
458 let yaml =
459 "provider:\n kind: oneharness\n history: false\n history_dir: /shared/history\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!(!oh.history);
465 assert_eq!(oh.history_dir.as_deref(), Some("/shared/history"));
466 config.validate().unwrap();
467 }
468
469 #[test]
470 fn judge_model_falls_back_to_first_model() {
471 let config = Config::default();
472 assert_eq!(config.effective_judge_model(), "claude-opus-4-8");
473 }
474
475 #[test]
476 fn empty_models_is_invalid() {
477 let mut config = Config::default();
478 config.models.clear();
479 assert!(config.validate().is_err());
480 }
481
482 #[test]
483 fn parses_api_judge_config() {
484 let yaml = "\
485provider:\n kind: oneharness\njudge:\n kind: api\n vendor: anthropic\n timeout_secs: 30\n";
486 let config: Config = serde_yaml::from_str(yaml).unwrap();
487 let Some(JudgeConfig::Api(api)) = &config.judge else {
488 panic!("expected an api judge");
489 };
490 assert_eq!(api.vendor, ApiVendor::Anthropic);
491 assert_eq!(api.timeout_secs, 30);
492 assert_eq!(api.curl_bin, "curl");
494 assert!(api.api_key_env.is_none());
495 assert!(api.strict_json, "strict JSON is on by default");
496 config.validate().unwrap();
497 }
498
499 #[test]
500 fn api_judge_zero_timeout_is_invalid() {
501 let yaml = "judge:\n kind: api\n vendor: openai\n timeout_secs: 0\n";
502 let config: Config = serde_yaml::from_str(yaml).unwrap();
503 assert!(config.validate().is_err());
504 }
505
506 #[test]
507 fn default_config_has_no_judge_override() {
508 assert!(Config::default().judge.is_none());
509 }
510
511 fn config_file(tag: &str, yaml: &str) -> std::path::PathBuf {
513 use std::sync::atomic::{AtomicU64, Ordering};
514 static N: AtomicU64 = AtomicU64::new(0);
515 let dir = std::env::temp_dir().join(format!(
516 "skilltest-config-{}-{tag}-{}",
517 std::process::id(),
518 N.fetch_add(1, Ordering::Relaxed)
519 ));
520 std::fs::create_dir_all(&dir).unwrap();
521 let path = dir.join("skilltest.yaml");
522 std::fs::write(&path, yaml).unwrap();
523 path
524 }
525
526 #[test]
527 fn load_reads_and_validates_a_file() {
528 let path = config_file(
529 "load",
530 "provider:\n kind: command\n command: [\"prov\"]\nplatforms: [demo]\nmodels: [m]\n",
531 );
532 let config = Config::load(&path).unwrap();
533 assert_eq!(config.platforms, vec!["demo".to_string()]);
534 assert!(matches!(config.provider, ProviderConfig::Command(_)));
535 }
536
537 #[test]
538 fn load_missing_file_is_io_error() {
539 let path = std::env::temp_dir().join(format!("skilltest-none-{}.yaml", std::process::id()));
540 assert!(matches!(Config::load(&path), Err(Error::Io { .. })));
541 }
542
543 #[test]
544 fn load_malformed_yaml_is_yaml_error() {
545 let path = config_file("bad", "platforms: [unterminated\n");
546 assert!(matches!(Config::load(&path), Err(Error::Yaml { .. })));
547 }
548
549 #[test]
550 fn load_inconsistent_config_is_invalid_error() {
551 let path = config_file(
553 "inconsistent",
554 "provider:\n kind: command\n command: []\n",
555 );
556 assert!(matches!(Config::load(&path), Err(Error::Invalid(_))));
557 }
558
559 #[test]
560 fn load_or_default_returns_default_when_absent() {
561 let path =
562 std::env::temp_dir().join(format!("skilltest-absent-{}.yaml", std::process::id()));
563 let config = Config::load_or_default(&path).unwrap();
564 assert_eq!(config, Config::default());
565 }
566
567 #[test]
568 fn load_or_default_loads_when_present() {
569 let path = config_file("present", "platforms: [a, b]\nmodels: [m]\n");
570 let config = Config::load_or_default(&path).unwrap();
571 assert_eq!(config.platforms, vec!["a".to_string(), "b".to_string()]);
572 }
573
574 #[test]
575 fn overrides_apply_judge_harness_timeout_and_run_fields() {
576 let mut config = Config::default();
577 config
578 .apply_overrides(Overrides {
579 judge_harness: Some("codex".into()),
580 timeout_secs: Some(45),
581 platforms: vec!["p1".into(), "p2".into()],
582 models: vec!["mod".into()],
583 judge_model: Some("haiku".into()),
584 max_turns: Some(3),
585 ..Default::default()
586 })
587 .unwrap();
588 let ProviderConfig::Oneharness(oh) = &config.provider else {
589 panic!("still oneharness");
590 };
591 assert_eq!(oh.judge_harness, "codex");
592 assert_eq!(oh.timeout_secs, 45);
593 assert_eq!(config.platforms, vec!["p1".to_string(), "p2".to_string()]);
594 assert_eq!(config.models, vec!["mod".to_string()]);
595 assert_eq!(config.judge_model, "haiku");
596 assert_eq!(config.max_turns, 3);
597 }
598
599 #[test]
600 fn effective_judge_model_prefers_explicit_judge_model() {
601 let config = Config {
602 judge_model: "haiku".into(),
603 ..Config::default()
604 };
605 assert_eq!(config.effective_judge_model(), "haiku");
606 }
607
608 #[test]
609 fn validate_rejects_blank_oneharness_fields() {
610 let mut config = Config::default();
611 if let ProviderConfig::Oneharness(oh) = &mut config.provider {
612 oh.bin = " ".into();
613 }
614 assert!(config.validate().is_err());
615
616 let mut config = Config::default();
617 if let ProviderConfig::Oneharness(oh) = &mut config.provider {
618 oh.judge_harness = "".into();
619 }
620 assert!(config.validate().is_err());
621
622 let mut config = Config::default();
623 if let ProviderConfig::Oneharness(oh) = &mut config.provider {
624 oh.timeout_secs = 0;
625 }
626 assert!(config.validate().is_err());
627 }
628
629 #[test]
630 fn validate_rejects_empty_platforms_and_zero_max_turns() {
631 let mut config = Config::default();
632 config.platforms.clear();
633 assert!(config.validate().is_err());
634
635 let config = Config {
636 max_turns: 0,
637 ..Config::default()
638 };
639 assert!(config.validate().is_err());
640 }
641
642 #[test]
643 fn validate_rejects_blank_api_judge_curl_bin() {
644 let yaml = "judge:\n kind: api\n vendor: anthropic\n curl_bin: \" \"\n";
645 let config: Config = serde_yaml::from_str(yaml).unwrap();
646 assert!(config.validate().is_err());
647 }
648
649 #[test]
650 fn config_round_trips_through_yaml() {
651 let config = Config {
652 judge: Some(JudgeConfig::Api(ApiJudgeConfig {
653 vendor: ApiVendor::Openai,
654 api_key_env: Some("X".into()),
655 base_url: None,
656 timeout_secs: 30,
657 curl_bin: "curl".into(),
658 strict_json: false,
659 })),
660 ..Config::default()
661 };
662 let yaml = serde_yaml::to_string(&config).unwrap();
663 let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
664 assert_eq!(parsed, config);
665 }
666}