1use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Error, Result};
12
13fn default_oneharness_bin() -> String {
14 "oneharness".to_string()
15}
16
17fn default_judge_harness() -> String {
18 "claude-code".to_string()
19}
20
21fn default_timeout_secs() -> u64 {
22 120
23}
24
25fn default_api_timeout_secs() -> u64 {
26 60
27}
28
29fn default_curl_bin() -> String {
30 "curl".to_string()
31}
32
33fn default_true() -> bool {
34 true
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct OneharnessConfig {
42 #[serde(default = "default_oneharness_bin")]
44 pub bin: String,
45 #[serde(default = "default_judge_harness")]
48 pub judge_harness: String,
49 #[serde(default = "default_timeout_secs")]
51 pub timeout_secs: u64,
52}
53
54impl Default for OneharnessConfig {
55 fn default() -> Self {
56 Self {
57 bin: default_oneharness_bin(),
58 judge_harness: default_judge_harness(),
59 timeout_secs: default_timeout_secs(),
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct CommandConfig {
70 pub command: Vec<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(tag = "kind", rename_all = "lowercase")]
77pub enum ProviderConfig {
78 Oneharness(OneharnessConfig),
80 Command(CommandConfig),
82}
83
84impl Default for ProviderConfig {
85 fn default() -> Self {
86 ProviderConfig::Oneharness(OneharnessConfig::default())
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "lowercase")]
93pub enum ApiVendor {
94 Anthropic,
96 Openai,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct ApiJudgeConfig {
111 pub vendor: ApiVendor,
113 #[serde(default)]
117 pub api_key_env: Option<String>,
118 #[serde(default)]
121 pub base_url: Option<String>,
122 #[serde(default = "default_api_timeout_secs")]
124 pub timeout_secs: u64,
125 #[serde(default = "default_curl_bin")]
127 pub curl_bin: String,
128 #[serde(default = "default_true")]
134 pub strict_json: bool,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(tag = "kind", rename_all = "lowercase")]
142pub enum JudgeConfig {
143 Api(ApiJudgeConfig),
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(default, deny_unknown_fields)]
150pub struct Config {
151 pub provider: ProviderConfig,
153 pub platforms: Vec<String>,
155 pub models: Vec<String>,
158 pub judge_model: String,
161 pub max_turns: u32,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub judge: Option<JudgeConfig>,
168}
169
170impl Default for Config {
171 fn default() -> Self {
172 Self {
173 provider: ProviderConfig::default(),
174 platforms: vec!["claude-code".to_string()],
175 models: vec!["claude-opus-4-8".to_string()],
176 judge_model: String::new(),
177 max_turns: 8,
178 judge: None,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Default)]
185pub struct Overrides {
186 pub command_provider: Option<Vec<String>>,
188 pub oneharness_bin: Option<String>,
190 pub judge_harness: Option<String>,
192 pub timeout_secs: Option<u64>,
194 pub platforms: Vec<String>,
195 pub models: Vec<String>,
196 pub judge_model: Option<String>,
197 pub max_turns: Option<u32>,
198}
199
200impl Config {
201 pub fn load(path: &Path) -> Result<Self> {
209 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
210 path: path.to_path_buf(),
211 source,
212 })?;
213 let config: Config = serde_yaml::from_str(&text).map_err(|source| Error::Yaml {
214 path: path.to_path_buf(),
215 source,
216 })?;
217 config.validate()?;
218 Ok(config)
219 }
220
221 pub fn load_or_default(path: &Path) -> Result<Self> {
226 if path.is_file() {
227 Self::load(path)
228 } else {
229 Ok(Self::default())
230 }
231 }
232
233 pub fn apply_overrides(&mut self, overrides: Overrides) -> Result<()> {
238 if let Some(command) = overrides.command_provider {
239 self.provider = ProviderConfig::Command(CommandConfig { command });
240 } else if let ProviderConfig::Oneharness(oh) = &mut self.provider {
241 if let Some(bin) = overrides.oneharness_bin {
242 oh.bin = bin;
243 }
244 if let Some(judge_harness) = overrides.judge_harness {
245 oh.judge_harness = judge_harness;
246 }
247 if let Some(timeout) = overrides.timeout_secs {
248 oh.timeout_secs = timeout;
249 }
250 }
251 if !overrides.platforms.is_empty() {
252 self.platforms = overrides.platforms;
253 }
254 if !overrides.models.is_empty() {
255 self.models = overrides.models;
256 }
257 if let Some(judge) = overrides.judge_model {
258 self.judge_model = judge;
259 }
260 if let Some(max_turns) = overrides.max_turns {
261 self.max_turns = max_turns;
262 }
263 self.validate()
264 }
265
266 #[must_use]
269 pub fn effective_judge_model(&self) -> &str {
270 if self.judge_model.is_empty() {
271 self.models.first().map_or("", String::as_str)
272 } else {
273 &self.judge_model
274 }
275 }
276
277 pub fn validate(&self) -> Result<()> {
283 match &self.provider {
284 ProviderConfig::Oneharness(oh) => {
285 if oh.bin.trim().is_empty() {
286 return Err(Error::Invalid(
287 "config `provider.bin` must name the oneharness binary".into(),
288 ));
289 }
290 if oh.judge_harness.trim().is_empty() {
291 return Err(Error::Invalid(
292 "config `provider.judge_harness` must name a harness".into(),
293 ));
294 }
295 if oh.timeout_secs == 0 {
296 return Err(Error::Invalid(
297 "config `provider.timeout_secs` must be at least 1".into(),
298 ));
299 }
300 }
301 ProviderConfig::Command(c) => {
302 if c.command.is_empty() {
303 return Err(Error::Invalid(
304 "config `provider.command` must name a command".into(),
305 ));
306 }
307 }
308 }
309 if self.platforms.is_empty() {
310 return Err(Error::Invalid(
311 "config `platforms` must list at least one harness platform".into(),
312 ));
313 }
314 if self.models.is_empty() {
315 return Err(Error::Invalid(
316 "config `models` must list at least one model".into(),
317 ));
318 }
319 if self.max_turns == 0 {
320 return Err(Error::Invalid(
321 "config `max_turns` must be at least 1".into(),
322 ));
323 }
324 if let Some(JudgeConfig::Api(api)) = &self.judge {
325 if api.timeout_secs == 0 {
326 return Err(Error::Invalid(
327 "config `judge.timeout_secs` must be at least 1".into(),
328 ));
329 }
330 if api.curl_bin.trim().is_empty() {
331 return Err(Error::Invalid(
332 "config `judge.curl_bin` must name the curl binary".into(),
333 ));
334 }
335 }
336 Ok(())
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn defaults_are_valid_and_use_oneharness() {
346 let config = Config::default();
347 config.validate().unwrap();
348 assert!(matches!(config.provider, ProviderConfig::Oneharness(_)));
349 }
350
351 #[test]
352 fn command_override_switches_provider() {
353 let mut config = Config::default();
354 config
355 .apply_overrides(Overrides {
356 command_provider: Some(vec!["fake".into()]),
357 ..Default::default()
358 })
359 .unwrap();
360 assert_eq!(
361 config.provider,
362 ProviderConfig::Command(CommandConfig {
363 command: vec!["fake".into()]
364 })
365 );
366 }
367
368 #[test]
369 fn oneharness_bin_override_applies() {
370 let mut config = Config::default();
371 config
372 .apply_overrides(Overrides {
373 oneharness_bin: Some("/tmp/oneharness".into()),
374 ..Default::default()
375 })
376 .unwrap();
377 let ProviderConfig::Oneharness(oh) = &config.provider else {
378 panic!("expected oneharness provider");
379 };
380 assert_eq!(oh.bin, "/tmp/oneharness");
381 }
382
383 #[test]
384 fn parses_command_provider_yaml() {
385 let yaml = "provider:\n kind: command\n command: [\"prov\", \"--flag\"]\n";
386 let config: Config = serde_yaml::from_str(yaml).unwrap();
387 assert_eq!(
388 config.provider,
389 ProviderConfig::Command(CommandConfig {
390 command: vec!["prov".into(), "--flag".into()]
391 })
392 );
393 }
394
395 #[test]
396 fn parses_oneharness_provider_yaml() {
397 let yaml = "provider:\n kind: oneharness\n bin: oh\n judge_harness: codex\n";
398 let config: Config = serde_yaml::from_str(yaml).unwrap();
399 let ProviderConfig::Oneharness(oh) = &config.provider else {
400 panic!("expected oneharness provider");
401 };
402 assert_eq!(oh.bin, "oh");
403 assert_eq!(oh.judge_harness, "codex");
404 assert_eq!(oh.timeout_secs, 120);
406 }
407
408 #[test]
409 fn judge_model_falls_back_to_first_model() {
410 let config = Config::default();
411 assert_eq!(config.effective_judge_model(), "claude-opus-4-8");
412 }
413
414 #[test]
415 fn empty_models_is_invalid() {
416 let mut config = Config::default();
417 config.models.clear();
418 assert!(config.validate().is_err());
419 }
420
421 #[test]
422 fn parses_api_judge_config() {
423 let yaml = "\
424provider:\n kind: oneharness\njudge:\n kind: api\n vendor: anthropic\n timeout_secs: 30\n";
425 let config: Config = serde_yaml::from_str(yaml).unwrap();
426 let Some(JudgeConfig::Api(api)) = &config.judge else {
427 panic!("expected an api judge");
428 };
429 assert_eq!(api.vendor, ApiVendor::Anthropic);
430 assert_eq!(api.timeout_secs, 30);
431 assert_eq!(api.curl_bin, "curl");
433 assert!(api.api_key_env.is_none());
434 assert!(api.strict_json, "strict JSON is on by default");
435 config.validate().unwrap();
436 }
437
438 #[test]
439 fn api_judge_zero_timeout_is_invalid() {
440 let yaml = "judge:\n kind: api\n vendor: openai\n timeout_secs: 0\n";
441 let config: Config = serde_yaml::from_str(yaml).unwrap();
442 assert!(config.validate().is_err());
443 }
444
445 #[test]
446 fn default_config_has_no_judge_override() {
447 assert!(Config::default().judge.is_none());
448 }
449
450 fn config_file(tag: &str, yaml: &str) -> std::path::PathBuf {
452 use std::sync::atomic::{AtomicU64, Ordering};
453 static N: AtomicU64 = AtomicU64::new(0);
454 let dir = std::env::temp_dir().join(format!(
455 "skilltest-config-{}-{tag}-{}",
456 std::process::id(),
457 N.fetch_add(1, Ordering::Relaxed)
458 ));
459 std::fs::create_dir_all(&dir).unwrap();
460 let path = dir.join("skilltest.yaml");
461 std::fs::write(&path, yaml).unwrap();
462 path
463 }
464
465 #[test]
466 fn load_reads_and_validates_a_file() {
467 let path = config_file(
468 "load",
469 "provider:\n kind: command\n command: [\"prov\"]\nplatforms: [demo]\nmodels: [m]\n",
470 );
471 let config = Config::load(&path).unwrap();
472 assert_eq!(config.platforms, vec!["demo".to_string()]);
473 assert!(matches!(config.provider, ProviderConfig::Command(_)));
474 }
475
476 #[test]
477 fn load_missing_file_is_io_error() {
478 let path = std::env::temp_dir().join(format!("skilltest-none-{}.yaml", std::process::id()));
479 assert!(matches!(Config::load(&path), Err(Error::Io { .. })));
480 }
481
482 #[test]
483 fn load_malformed_yaml_is_yaml_error() {
484 let path = config_file("bad", "platforms: [unterminated\n");
485 assert!(matches!(Config::load(&path), Err(Error::Yaml { .. })));
486 }
487
488 #[test]
489 fn load_inconsistent_config_is_invalid_error() {
490 let path = config_file(
492 "inconsistent",
493 "provider:\n kind: command\n command: []\n",
494 );
495 assert!(matches!(Config::load(&path), Err(Error::Invalid(_))));
496 }
497
498 #[test]
499 fn load_or_default_returns_default_when_absent() {
500 let path =
501 std::env::temp_dir().join(format!("skilltest-absent-{}.yaml", std::process::id()));
502 let config = Config::load_or_default(&path).unwrap();
503 assert_eq!(config, Config::default());
504 }
505
506 #[test]
507 fn load_or_default_loads_when_present() {
508 let path = config_file("present", "platforms: [a, b]\nmodels: [m]\n");
509 let config = Config::load_or_default(&path).unwrap();
510 assert_eq!(config.platforms, vec!["a".to_string(), "b".to_string()]);
511 }
512
513 #[test]
514 fn overrides_apply_judge_harness_timeout_and_run_fields() {
515 let mut config = Config::default();
516 config
517 .apply_overrides(Overrides {
518 judge_harness: Some("codex".into()),
519 timeout_secs: Some(45),
520 platforms: vec!["p1".into(), "p2".into()],
521 models: vec!["mod".into()],
522 judge_model: Some("haiku".into()),
523 max_turns: Some(3),
524 ..Default::default()
525 })
526 .unwrap();
527 let ProviderConfig::Oneharness(oh) = &config.provider else {
528 panic!("still oneharness");
529 };
530 assert_eq!(oh.judge_harness, "codex");
531 assert_eq!(oh.timeout_secs, 45);
532 assert_eq!(config.platforms, vec!["p1".to_string(), "p2".to_string()]);
533 assert_eq!(config.models, vec!["mod".to_string()]);
534 assert_eq!(config.judge_model, "haiku");
535 assert_eq!(config.max_turns, 3);
536 }
537
538 #[test]
539 fn effective_judge_model_prefers_explicit_judge_model() {
540 let config = Config {
541 judge_model: "haiku".into(),
542 ..Config::default()
543 };
544 assert_eq!(config.effective_judge_model(), "haiku");
545 }
546
547 #[test]
548 fn validate_rejects_blank_oneharness_fields() {
549 let mut config = Config::default();
550 if let ProviderConfig::Oneharness(oh) = &mut config.provider {
551 oh.bin = " ".into();
552 }
553 assert!(config.validate().is_err());
554
555 let mut config = Config::default();
556 if let ProviderConfig::Oneharness(oh) = &mut config.provider {
557 oh.judge_harness = "".into();
558 }
559 assert!(config.validate().is_err());
560
561 let mut config = Config::default();
562 if let ProviderConfig::Oneharness(oh) = &mut config.provider {
563 oh.timeout_secs = 0;
564 }
565 assert!(config.validate().is_err());
566 }
567
568 #[test]
569 fn validate_rejects_empty_platforms_and_zero_max_turns() {
570 let mut config = Config::default();
571 config.platforms.clear();
572 assert!(config.validate().is_err());
573
574 let config = Config {
575 max_turns: 0,
576 ..Config::default()
577 };
578 assert!(config.validate().is_err());
579 }
580
581 #[test]
582 fn validate_rejects_blank_api_judge_curl_bin() {
583 let yaml = "judge:\n kind: api\n vendor: anthropic\n curl_bin: \" \"\n";
584 let config: Config = serde_yaml::from_str(yaml).unwrap();
585 assert!(config.validate().is_err());
586 }
587
588 #[test]
589 fn config_round_trips_through_yaml() {
590 let config = Config {
591 judge: Some(JudgeConfig::Api(ApiJudgeConfig {
592 vendor: ApiVendor::Openai,
593 api_key_env: Some("X".into()),
594 base_url: None,
595 timeout_secs: 30,
596 curl_bin: "curl".into(),
597 strict_json: false,
598 })),
599 ..Config::default()
600 };
601 let yaml = serde_yaml::to_string(&config).unwrap();
602 let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
603 assert_eq!(parsed, config);
604 }
605}