use std::path::PathBuf;
use serde::Deserialize;
use super::router::DEFAULT_PREFERENCE;
pub const DEFAULT_MAX_ITERATIONS: u32 = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoderConfig {
pub engine_preference: Vec<String>,
pub keep_workspace_on_failure: bool,
pub default_max_iterations: u32,
}
impl Default for CoderConfig {
fn default() -> Self {
Self {
engine_preference: DEFAULT_PREFERENCE.iter().map(|s| s.to_string()).collect(),
keep_workspace_on_failure: false,
default_max_iterations: DEFAULT_MAX_ITERATIONS,
}
}
}
#[derive(Debug, Default, Deserialize)]
struct RawConfigFile {
#[serde(default)]
coder: RawCoderTable,
}
#[derive(Debug, Default, Deserialize)]
struct RawCoderTable {
#[serde(default)]
engine_preference: Option<Vec<String>>,
#[serde(default)]
keep_workspace_on_failure: Option<bool>,
#[serde(default)]
default_max_iterations: Option<u32>,
}
impl CoderConfig {
pub fn preference_refs(&self) -> Vec<&str> {
self.engine_preference.iter().map(|s| s.as_str()).collect()
}
fn from_raw(raw: RawConfigFile) -> Self {
let defaults = Self::default();
let coder = raw.coder;
Self {
engine_preference: coder
.engine_preference
.filter(|p| !p.is_empty())
.unwrap_or(defaults.engine_preference),
keep_workspace_on_failure: coder
.keep_workspace_on_failure
.unwrap_or(defaults.keep_workspace_on_failure),
default_max_iterations: coder
.default_max_iterations
.filter(|n| *n > 0)
.unwrap_or(defaults.default_max_iterations),
}
}
pub fn parse_toml(text: &str) -> Self {
match toml::from_str::<RawConfigFile>(text) {
Ok(raw) => Self::from_raw(raw),
Err(e) => {
tracing::warn!("ignoring malformed ~/.car/coder.toml: {e}");
Self::default()
}
}
}
pub fn load_from(path: &std::path::Path) -> Self {
match std::fs::read_to_string(path) {
Ok(text) => Self::parse_toml(&text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
Err(e) => {
tracing::warn!("ignoring unreadable {}: {e}", path.display());
Self::default()
}
}
}
pub fn load() -> Self {
match config_path() {
Ok(path) => Self::load_from(&path),
Err(_) => Self::default(),
}
}
}
pub fn config_path() -> Result<PathBuf, String> {
if let Some(path) = std::env::var_os("CAR_CODER_CONFIG") {
return Ok(PathBuf::from(path));
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or("cannot resolve home directory (HOME/USERPROFILE unset)")?;
Ok(PathBuf::from(home).join(".car").join("coder.toml"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_keys_fall_back_to_documented_defaults() {
let c = CoderConfig::parse_toml("");
assert_eq!(c, CoderConfig::default());
assert_eq!(c.engine_preference, ["claude-code", "codex", "gemini"]);
assert!(!c.keep_workspace_on_failure);
assert_eq!(c.default_max_iterations, 8);
let c = CoderConfig::parse_toml("[coder]\n");
assert_eq!(c, CoderConfig::default());
}
#[test]
fn full_config_is_honored() {
let c = CoderConfig::parse_toml(
r#"
[coder]
engine_preference = ["codex", "claude-code"]
keep_workspace_on_failure = true
default_max_iterations = 3
"#,
);
assert_eq!(c.engine_preference, ["codex", "claude-code"]);
assert!(c.keep_workspace_on_failure);
assert_eq!(c.default_max_iterations, 3);
assert_eq!(c.preference_refs(), vec!["codex", "claude-code"]);
}
#[test]
fn partial_config_mixes_explicit_and_default() {
let c = CoderConfig::parse_toml("[coder]\nkeep_workspace_on_failure = true\n");
assert!(c.keep_workspace_on_failure);
assert_eq!(c.engine_preference, ["claude-code", "codex", "gemini"]);
assert_eq!(c.default_max_iterations, 8);
}
#[test]
fn empty_preference_and_zero_iterations_are_ignored() {
let c = CoderConfig::parse_toml(
"[coder]\nengine_preference = []\ndefault_max_iterations = 0\n",
);
assert_eq!(c.engine_preference, ["claude-code", "codex", "gemini"]);
assert_eq!(c.default_max_iterations, 8);
}
#[test]
fn malformed_toml_yields_defaults_not_panic() {
let c = CoderConfig::parse_toml("this is not = = toml [[[");
assert_eq!(c, CoderConfig::default());
}
#[test]
fn missing_file_yields_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
assert_eq!(CoderConfig::load_from(&path), CoderConfig::default());
}
#[test]
fn load_from_real_file_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("coder.toml");
std::fs::write(
&path,
"[coder]\nengine_preference = [\"gemini\"]\nkeep_workspace_on_failure = true\ndefault_max_iterations = 5\n",
)
.unwrap();
let c = CoderConfig::load_from(&path);
assert_eq!(c.engine_preference, ["gemini"]);
assert!(c.keep_workspace_on_failure);
assert_eq!(c.default_max_iterations, 5);
}
}