use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::Deserialize;
use crate::error::CliError;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Config {
#[serde(default)]
pub harness: HarnessConfig,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HarnessConfig {
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub per_kind: BTreeMap<String, String>,
}
pub fn config_path() -> Result<PathBuf, CliError> {
Ok(crate::home::root_dir()?.join("config.toml"))
}
impl Config {
pub fn load() -> Result<Self, CliError> {
Self::load_from(&config_path()?)
}
pub fn load_from(path: &std::path::Path) -> Result<Self, CliError> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => {
return Err(CliError::system(
"config_unreadable",
format!("could not read config file {}: {e}", path.display()),
));
}
};
if text.trim().is_empty() {
return Ok(Self::default());
}
let config: Self = toml::from_str(&text).map_err(|e| {
CliError::user(
"invalid_config",
format!("config file {} is not valid: {e}", path.display()),
)
})?;
for key in config.harness.per_kind.keys() {
if !octl_core::Kind::WIRE_NAMES.contains(&key.as_str()) {
return Err(CliError::user(
"invalid_config",
format!(
"config file {} has an unknown run kind '{}' in [harness.per_kind]; \
valid kinds: {}",
path.display(),
key,
octl_core::Kind::WIRE_NAMES.join(", ")
),
)
.with_invalid_value(key.clone()));
}
}
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(dir: &TempDir, body: &str) -> PathBuf {
let p = dir.path().join("config.toml");
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn missing_file_is_default() {
let dir = TempDir::new().unwrap();
let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
assert_eq!(cfg, Config::default());
}
#[test]
fn empty_file_is_default() {
let dir = TempDir::new().unwrap();
let p = write(&dir, " \n\n");
assert_eq!(Config::load_from(&p).unwrap(), Config::default());
}
#[test]
fn parses_default_and_per_kind() {
let dir = TempDir::new().unwrap();
let p = write(
&dir,
r#"
[harness]
default = "pi"
[harness.per_kind]
research = "pi"
code = "claude"
"#,
);
let cfg = Config::load_from(&p).unwrap();
assert_eq!(cfg.harness.default.as_deref(), Some("pi"));
assert_eq!(
cfg.harness.per_kind.get("research").map(String::as_str),
Some("pi")
);
assert_eq!(
cfg.harness.per_kind.get("code").map(String::as_str),
Some("claude")
);
}
#[test]
fn unknown_key_is_rejected() {
let dir = TempDir::new().unwrap();
let p = write(&dir, "[harness]\ndefualt = \"pi\"\n");
let err = Config::load_from(&p).unwrap_err();
assert_eq!(err.code, "invalid_config");
}
#[test]
fn malformed_toml_is_hard_error() {
let dir = TempDir::new().unwrap();
let p = write(&dir, "[harness\n");
let err = Config::load_from(&p).unwrap_err();
assert_eq!(err.code, "invalid_config");
}
#[test]
fn unknown_per_kind_run_kind_is_rejected() {
let dir = TempDir::new().unwrap();
let p = write(&dir, "[harness.per_kind]\nreserach = \"pi\"\n");
let err = Config::load_from(&p).unwrap_err();
assert_eq!(err.code, "invalid_config");
assert_eq!(err.invalid_value.as_deref(), Some("reserach"));
}
#[test]
fn valid_per_kind_run_kinds_pass() {
let dir = TempDir::new().unwrap();
let p = write(
&dir,
"[harness.per_kind]\nresearch = \"pi\"\ntechnical-decision = \"claude\"\n",
);
let cfg = Config::load_from(&p).unwrap();
assert_eq!(
cfg.harness.per_kind.get("research").map(String::as_str),
Some("pi")
);
}
#[test]
fn unknown_top_level_section_is_tolerated() {
let dir = TempDir::new().unwrap();
let p = write(
&dir,
"[ui]\ntheme = \"dark\"\n\n[harness]\ndefault = \"pi\"\n",
);
let cfg = Config::load_from(&p).unwrap();
assert_eq!(cfg.harness.default.as_deref(), Some("pi"));
}
}