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}
54
55impl Default for OneharnessConfig {
56 fn default() -> Self {
57 Self {
58 bin: default_oneharness_bin(),
59 judge_harness: default_judge_harness(),
60 timeout_secs: default_timeout_secs(),
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct CommandConfig {
71 pub command: Vec<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(tag = "kind", rename_all = "lowercase")]
78pub enum ProviderConfig {
79 Oneharness(OneharnessConfig),
81 Command(CommandConfig),
83}
84
85impl Default for ProviderConfig {
86 fn default() -> Self {
87 ProviderConfig::Oneharness(OneharnessConfig::default())
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "lowercase")]
94pub enum ApiVendor {
95 Anthropic,
97 Openai,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct ApiJudgeConfig {
112 pub vendor: ApiVendor,
114 #[serde(default)]
118 pub api_key_env: Option<String>,
119 #[serde(default)]
122 pub base_url: Option<String>,
123 #[serde(default = "default_api_timeout_secs")]
125 pub timeout_secs: u64,
126 #[serde(default = "default_curl_bin")]
128 pub curl_bin: String,
129 #[serde(default = "default_true")]
135 pub strict_json: bool,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(tag = "kind", rename_all = "lowercase")]
143pub enum JudgeConfig {
144 Api(ApiJudgeConfig),
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(default, deny_unknown_fields)]
151pub struct Config {
152 pub provider: ProviderConfig,
154 pub platforms: Vec<String>,
156 pub models: Vec<String>,
159 pub judge_model: String,
162 pub max_turns: u32,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub judge: Option<JudgeConfig>,
169 #[serde(default)]
174 pub mocks: Vec<MockDecl>,
175 #[serde(default)]
178 pub spy: bool,
179}
180
181impl Default for Config {
182 fn default() -> Self {
183 Self {
184 provider: ProviderConfig::default(),
185 platforms: vec!["claude-code".to_string()],
186 models: vec!["claude-opus-4-8".to_string()],
187 judge_model: String::new(),
188 max_turns: 8,
189 judge: None,
190 mocks: Vec::new(),
191 spy: false,
192 }
193 }
194}
195
196#[derive(Debug, Clone, Default)]
198pub struct Overrides {
199 pub command_provider: Option<Vec<String>>,
201 pub oneharness_bin: Option<String>,
203 pub judge_harness: Option<String>,
205 pub timeout_secs: Option<u64>,
207 pub platforms: Vec<String>,
208 pub models: Vec<String>,
209 pub judge_model: Option<String>,
210 pub max_turns: Option<u32>,
211 pub mocks: Vec<MockDecl>,
214 pub spy: bool,
216}
217
218impl Config {
219 pub fn load(path: &Path) -> Result<Self> {
227 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
228 path: path.to_path_buf(),
229 source,
230 })?;
231 let config: Config = serde_yaml::from_str(&text).map_err(|source| Error::Yaml {
232 path: path.to_path_buf(),
233 source,
234 })?;
235 config.validate()?;
236 Ok(config)
237 }
238
239 pub fn load_or_default(path: &Path) -> Result<Self> {
244 if path.is_file() {
245 Self::load(path)
246 } else {
247 Ok(Self::default())
248 }
249 }
250
251 pub fn apply_overrides(&mut self, overrides: Overrides) -> Result<()> {
256 if let Some(command) = overrides.command_provider {
257 self.provider = ProviderConfig::Command(CommandConfig { command });
258 } else if let ProviderConfig::Oneharness(oh) = &mut self.provider {
259 if let Some(bin) = overrides.oneharness_bin {
260 oh.bin = bin;
261 }
262 if let Some(judge_harness) = overrides.judge_harness {
263 oh.judge_harness = judge_harness;
264 }
265 if let Some(timeout) = overrides.timeout_secs {
266 oh.timeout_secs = timeout;
267 }
268 }
269 if !overrides.platforms.is_empty() {
270 self.platforms = overrides.platforms;
271 }
272 if !overrides.models.is_empty() {
273 self.models = overrides.models;
274 }
275 if let Some(judge) = overrides.judge_model {
276 self.judge_model = judge;
277 }
278 if let Some(max_turns) = overrides.max_turns {
279 self.max_turns = max_turns;
280 }
281 if !overrides.mocks.is_empty() {
282 let mut mocks = overrides.mocks;
285 mocks.append(&mut self.mocks);
286 self.mocks = mocks;
287 }
288 self.spy = self.spy || overrides.spy;
289 self.validate()
290 }
291
292 #[must_use]
295 pub fn effective_judge_model(&self) -> &str {
296 if self.judge_model.is_empty() {
297 self.models.first().map_or("", String::as_str)
298 } else {
299 &self.judge_model
300 }
301 }
302
303 pub fn validate(&self) -> Result<()> {
309 match &self.provider {
310 ProviderConfig::Oneharness(oh) => {
311 if oh.bin.trim().is_empty() {
312 return Err(Error::Invalid(
313 "config `provider.bin` must name the oneharness binary".into(),
314 ));
315 }
316 if oh.judge_harness.trim().is_empty() {
317 return Err(Error::Invalid(
318 "config `provider.judge_harness` must name a harness".into(),
319 ));
320 }
321 if oh.timeout_secs == 0 {
322 return Err(Error::Invalid(
323 "config `provider.timeout_secs` must be at least 1".into(),
324 ));
325 }
326 }
327 ProviderConfig::Command(c) => {
328 if c.command.is_empty() {
329 return Err(Error::Invalid(
330 "config `provider.command` must name a command".into(),
331 ));
332 }
333 }
334 }
335 if self.platforms.is_empty() {
336 return Err(Error::Invalid(
337 "config `platforms` must list at least one harness platform".into(),
338 ));
339 }
340 if self.models.is_empty() {
341 return Err(Error::Invalid(
342 "config `models` must list at least one model".into(),
343 ));
344 }
345 if self.max_turns == 0 {
346 return Err(Error::Invalid(
347 "config `max_turns` must be at least 1".into(),
348 ));
349 }
350 if let Some(JudgeConfig::Api(api)) = &self.judge {
351 if api.timeout_secs == 0 {
352 return Err(Error::Invalid(
353 "config `judge.timeout_secs` must be at least 1".into(),
354 ));
355 }
356 if api.curl_bin.trim().is_empty() {
357 return Err(Error::Invalid(
358 "config `judge.curl_bin` must name the curl binary".into(),
359 ));
360 }
361 }
362 for (i, decl) in self.mocks.iter().enumerate() {
363 let label = decl.name.clone().unwrap_or_else(|| format!("#{i}"));
364 decl.validate(&format!("config mock `{label}`"))?;
365 }
366 Ok(())
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn defaults_are_valid_and_use_oneharness() {
376 let config = Config::default();
377 config.validate().unwrap();
378 assert!(matches!(config.provider, ProviderConfig::Oneharness(_)));
379 }
380
381 #[test]
382 fn command_override_switches_provider() {
383 let mut config = Config::default();
384 config
385 .apply_overrides(Overrides {
386 command_provider: Some(vec!["fake".into()]),
387 ..Default::default()
388 })
389 .unwrap();
390 assert_eq!(
391 config.provider,
392 ProviderConfig::Command(CommandConfig {
393 command: vec!["fake".into()]
394 })
395 );
396 }
397
398 #[test]
399 fn oneharness_bin_override_applies() {
400 let mut config = Config::default();
401 config
402 .apply_overrides(Overrides {
403 oneharness_bin: Some("/tmp/oneharness".into()),
404 ..Default::default()
405 })
406 .unwrap();
407 let ProviderConfig::Oneharness(oh) = &config.provider else {
408 panic!("expected oneharness provider");
409 };
410 assert_eq!(oh.bin, "/tmp/oneharness");
411 }
412
413 #[test]
414 fn parses_command_provider_yaml() {
415 let yaml = "provider:\n kind: command\n command: [\"prov\", \"--flag\"]\n";
416 let config: Config = serde_yaml::from_str(yaml).unwrap();
417 assert_eq!(
418 config.provider,
419 ProviderConfig::Command(CommandConfig {
420 command: vec!["prov".into(), "--flag".into()]
421 })
422 );
423 }
424
425 #[test]
426 fn parses_oneharness_provider_yaml() {
427 let yaml = "provider:\n kind: oneharness\n bin: oh\n judge_harness: codex\n";
428 let config: Config = serde_yaml::from_str(yaml).unwrap();
429 let ProviderConfig::Oneharness(oh) = &config.provider else {
430 panic!("expected oneharness provider");
431 };
432 assert_eq!(oh.bin, "oh");
433 assert_eq!(oh.judge_harness, "codex");
434 assert_eq!(oh.timeout_secs, 120);
436 }
437
438 #[test]
439 fn judge_model_falls_back_to_first_model() {
440 let config = Config::default();
441 assert_eq!(config.effective_judge_model(), "claude-opus-4-8");
442 }
443
444 #[test]
445 fn empty_models_is_invalid() {
446 let mut config = Config::default();
447 config.models.clear();
448 assert!(config.validate().is_err());
449 }
450
451 #[test]
452 fn parses_api_judge_config() {
453 let yaml = "\
454provider:\n kind: oneharness\njudge:\n kind: api\n vendor: anthropic\n timeout_secs: 30\n";
455 let config: Config = serde_yaml::from_str(yaml).unwrap();
456 let Some(JudgeConfig::Api(api)) = &config.judge else {
457 panic!("expected an api judge");
458 };
459 assert_eq!(api.vendor, ApiVendor::Anthropic);
460 assert_eq!(api.timeout_secs, 30);
461 assert_eq!(api.curl_bin, "curl");
463 assert!(api.api_key_env.is_none());
464 assert!(api.strict_json, "strict JSON is on by default");
465 config.validate().unwrap();
466 }
467
468 #[test]
469 fn api_judge_zero_timeout_is_invalid() {
470 let yaml = "judge:\n kind: api\n vendor: openai\n timeout_secs: 0\n";
471 let config: Config = serde_yaml::from_str(yaml).unwrap();
472 assert!(config.validate().is_err());
473 }
474
475 #[test]
476 fn default_config_has_no_judge_override() {
477 assert!(Config::default().judge.is_none());
478 }
479
480 fn config_file(tag: &str, yaml: &str) -> std::path::PathBuf {
482 use std::sync::atomic::{AtomicU64, Ordering};
483 static N: AtomicU64 = AtomicU64::new(0);
484 let dir = std::env::temp_dir().join(format!(
485 "skilltest-config-{}-{tag}-{}",
486 std::process::id(),
487 N.fetch_add(1, Ordering::Relaxed)
488 ));
489 std::fs::create_dir_all(&dir).unwrap();
490 let path = dir.join("skilltest.yaml");
491 std::fs::write(&path, yaml).unwrap();
492 path
493 }
494
495 #[test]
496 fn load_reads_and_validates_a_file() {
497 let path = config_file(
498 "load",
499 "provider:\n kind: command\n command: [\"prov\"]\nplatforms: [demo]\nmodels: [m]\n",
500 );
501 let config = Config::load(&path).unwrap();
502 assert_eq!(config.platforms, vec!["demo".to_string()]);
503 assert!(matches!(config.provider, ProviderConfig::Command(_)));
504 }
505
506 #[test]
507 fn load_missing_file_is_io_error() {
508 let path = std::env::temp_dir().join(format!("skilltest-none-{}.yaml", std::process::id()));
509 assert!(matches!(Config::load(&path), Err(Error::Io { .. })));
510 }
511
512 #[test]
513 fn load_malformed_yaml_is_yaml_error() {
514 let path = config_file("bad", "platforms: [unterminated\n");
515 assert!(matches!(Config::load(&path), Err(Error::Yaml { .. })));
516 }
517
518 #[test]
519 fn load_inconsistent_config_is_invalid_error() {
520 let path = config_file(
522 "inconsistent",
523 "provider:\n kind: command\n command: []\n",
524 );
525 assert!(matches!(Config::load(&path), Err(Error::Invalid(_))));
526 }
527
528 #[test]
529 fn load_or_default_returns_default_when_absent() {
530 let path =
531 std::env::temp_dir().join(format!("skilltest-absent-{}.yaml", std::process::id()));
532 let config = Config::load_or_default(&path).unwrap();
533 assert_eq!(config, Config::default());
534 }
535
536 #[test]
537 fn load_or_default_loads_when_present() {
538 let path = config_file("present", "platforms: [a, b]\nmodels: [m]\n");
539 let config = Config::load_or_default(&path).unwrap();
540 assert_eq!(config.platforms, vec!["a".to_string(), "b".to_string()]);
541 }
542
543 #[test]
544 fn overrides_apply_judge_harness_timeout_and_run_fields() {
545 let mut config = Config::default();
546 config
547 .apply_overrides(Overrides {
548 judge_harness: Some("codex".into()),
549 timeout_secs: Some(45),
550 platforms: vec!["p1".into(), "p2".into()],
551 models: vec!["mod".into()],
552 judge_model: Some("haiku".into()),
553 max_turns: Some(3),
554 ..Default::default()
555 })
556 .unwrap();
557 let ProviderConfig::Oneharness(oh) = &config.provider else {
558 panic!("still oneharness");
559 };
560 assert_eq!(oh.judge_harness, "codex");
561 assert_eq!(oh.timeout_secs, 45);
562 assert_eq!(config.platforms, vec!["p1".to_string(), "p2".to_string()]);
563 assert_eq!(config.models, vec!["mod".to_string()]);
564 assert_eq!(config.judge_model, "haiku");
565 assert_eq!(config.max_turns, 3);
566 }
567
568 #[test]
569 fn effective_judge_model_prefers_explicit_judge_model() {
570 let config = Config {
571 judge_model: "haiku".into(),
572 ..Config::default()
573 };
574 assert_eq!(config.effective_judge_model(), "haiku");
575 }
576
577 #[test]
578 fn validate_rejects_blank_oneharness_fields() {
579 let mut config = Config::default();
580 if let ProviderConfig::Oneharness(oh) = &mut config.provider {
581 oh.bin = " ".into();
582 }
583 assert!(config.validate().is_err());
584
585 let mut config = Config::default();
586 if let ProviderConfig::Oneharness(oh) = &mut config.provider {
587 oh.judge_harness = "".into();
588 }
589 assert!(config.validate().is_err());
590
591 let mut config = Config::default();
592 if let ProviderConfig::Oneharness(oh) = &mut config.provider {
593 oh.timeout_secs = 0;
594 }
595 assert!(config.validate().is_err());
596 }
597
598 #[test]
599 fn validate_rejects_empty_platforms_and_zero_max_turns() {
600 let mut config = Config::default();
601 config.platforms.clear();
602 assert!(config.validate().is_err());
603
604 let config = Config {
605 max_turns: 0,
606 ..Config::default()
607 };
608 assert!(config.validate().is_err());
609 }
610
611 #[test]
612 fn validate_rejects_blank_api_judge_curl_bin() {
613 let yaml = "judge:\n kind: api\n vendor: anthropic\n curl_bin: \" \"\n";
614 let config: Config = serde_yaml::from_str(yaml).unwrap();
615 assert!(config.validate().is_err());
616 }
617
618 #[test]
619 fn config_round_trips_through_yaml() {
620 let config = Config {
621 judge: Some(JudgeConfig::Api(ApiJudgeConfig {
622 vendor: ApiVendor::Openai,
623 api_key_env: Some("X".into()),
624 base_url: None,
625 timeout_secs: 30,
626 curl_bin: "curl".into(),
627 strict_json: false,
628 })),
629 ..Config::default()
630 };
631 let yaml = serde_yaml::to_string(&config).unwrap();
632 let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
633 assert_eq!(parsed, config);
634 }
635}