use std::path::PathBuf;
use serde::Deserialize;
use super::router::DEFAULT_PREFERENCE;
pub const DEFAULT_MAX_ITERATIONS: u32 = 8;
pub const DEFAULT_MAX_SESSIONS: usize = 200;
pub const DEFAULT_MAX_SESSION_AGE_DAYS: u64 = 30;
pub const DEFAULT_MAX_REPLAY_EVENTS: usize = 2_000;
pub const DEFAULT_APPROVAL_PATCH_BYTES: usize = 512 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoderConfig {
pub engine_preference: Vec<String>,
pub keep_workspace_on_failure: bool,
pub default_max_iterations: u32,
pub model: Option<String>,
pub approval_patch_bytes: usize,
pub max_replay_events: usize,
pub max_session_wall_secs: u64,
pub max_agent_build_wall_secs: u64,
pub max_check_timeout_secs: u64,
pub max_sessions: usize,
pub max_session_age_days: u64,
}
impl CoderConfig {
pub fn session_retention(&self) -> super::session::SessionRetention {
super::session::SessionRetention {
max_sessions: self.max_sessions,
max_age_days: self.max_session_age_days,
}
}
}
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,
model: None,
approval_patch_bytes: DEFAULT_APPROVAL_PATCH_BYTES,
max_replay_events: DEFAULT_MAX_REPLAY_EVENTS,
max_session_wall_secs: super::budget::DEFAULT_SESSION_WALL_SECS,
max_agent_build_wall_secs: super::shell_tool::MAX_SHELL_TIMEOUT_SECS,
max_check_timeout_secs: super::shell_tool::MAX_SHELL_TIMEOUT_SECS,
max_sessions: DEFAULT_MAX_SESSIONS,
max_session_age_days: DEFAULT_MAX_SESSION_AGE_DAYS,
}
}
}
#[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>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
approval_patch_bytes: Option<usize>,
#[serde(default)]
max_replay_events: Option<usize>,
#[serde(default)]
max_session_wall_secs: Option<u64>,
#[serde(default)]
max_agent_build_wall_secs: Option<u64>,
#[serde(default)]
max_check_timeout_secs: Option<u64>,
#[serde(default)]
max_sessions: Option<usize>,
#[serde(default)]
max_session_age_days: Option<u64>,
}
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),
model: coder.model.filter(|m| !m.trim().is_empty()),
approval_patch_bytes: coder
.approval_patch_bytes
.filter(|n| *n > 0)
.unwrap_or(defaults.approval_patch_bytes),
max_replay_events: coder
.max_replay_events
.unwrap_or(defaults.max_replay_events),
max_session_wall_secs: coder
.max_session_wall_secs
.unwrap_or(defaults.max_session_wall_secs),
max_agent_build_wall_secs: coder
.max_agent_build_wall_secs
.unwrap_or(defaults.max_agent_build_wall_secs),
max_check_timeout_secs: coder
.max_check_timeout_secs
.filter(|n| *n > 0)
.unwrap_or(defaults.max_check_timeout_secs),
max_sessions: coder.max_sessions.unwrap_or(defaults.max_sessions),
max_session_age_days: coder
.max_session_age_days
.unwrap_or(defaults.max_session_age_days),
}
}
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(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PinSource {
Request,
Config,
}
pub fn session_model<'a>(
request: Option<&'a str>,
config: Option<&'a str>,
) -> Option<(&'a str, PinSource)> {
match request.map(str::trim).filter(|m| !m.is_empty()) {
Some(m) => Some((m, PinSource::Request)),
None => config.map(|m| (m, PinSource::Config)),
}
}
#[cfg(test)]
pub(crate) fn config_env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
pub fn config_path() -> Result<PathBuf, String> {
if let Some(path) = std::env::var_os("CAR_CODER_CONFIG") {
tracing::info!(
path = ?path,
"coder config path overridden by CAR_CODER_CONFIG env"
);
return Ok(PathBuf::from(path));
}
let root = car_home::root()
.ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
Ok(root.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 replay_cap_is_read_and_zero_means_unlimited() {
assert_eq!(
CoderConfig::parse_toml("").max_replay_events,
DEFAULT_MAX_REPLAY_EVENTS
);
assert_eq!(
CoderConfig::parse_toml("[coder]\nmax_replay_events = 37\n").max_replay_events,
37
);
assert_eq!(
CoderConfig::parse_toml("[coder]\nmax_replay_events = 0\n").max_replay_events,
0
);
}
#[test]
fn retention_knobs_are_read_and_zero_means_unlimited() {
let c = CoderConfig::parse_toml("");
assert_eq!(c.max_sessions, DEFAULT_MAX_SESSIONS);
assert_eq!(c.max_session_age_days, DEFAULT_MAX_SESSION_AGE_DAYS);
let c = CoderConfig::parse_toml(
r#"
[coder]
max_sessions = 25
max_session_age_days = 7
"#,
);
assert_eq!(c.session_retention().max_sessions, 25);
assert_eq!(c.session_retention().max_age_days, 7);
let c = CoderConfig::parse_toml(
r#"
[coder]
max_sessions = 0
max_session_age_days = 0
"#,
);
assert_eq!(c.max_sessions, 0);
assert_eq!(c.max_session_age_days, 0);
}
#[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 the_check_timeout_ceiling_defaults_to_the_shell_ceiling_and_zero_is_unset() {
use super::super::shell_tool::MAX_SHELL_TIMEOUT_SECS;
assert_eq!(
CoderConfig::default().max_check_timeout_secs,
MAX_SHELL_TIMEOUT_SECS
);
assert_eq!(
CoderConfig::parse_toml("").max_check_timeout_secs,
MAX_SHELL_TIMEOUT_SECS
);
let c = CoderConfig::parse_toml("[coder]\nmax_check_timeout_secs = 1800\n");
assert_eq!(c.max_check_timeout_secs, 1800);
let c = CoderConfig::parse_toml("[coder]\nmax_check_timeout_secs = 0\n");
assert_eq!(c.max_check_timeout_secs, MAX_SHELL_TIMEOUT_SECS);
assert_eq!(
CoderConfig::parse_toml("[coder]\nmax_session_wall_secs = 0\n").max_session_wall_secs,
0,
"the sibling knob keeps 0 == unlimited"
);
let agent = CoderConfig::parse_toml("[coder]\nmax_agent_build_wall_secs = 45\n");
assert_eq!(agent.max_agent_build_wall_secs, 45);
assert_eq!(
CoderConfig::parse_toml("[coder]\nmax_agent_build_wall_secs = 0\n")
.max_agent_build_wall_secs,
0,
"the agent-build knob keeps 0 == unlimited"
);
}
#[test]
fn the_request_pin_wins_and_a_blank_one_falls_through_to_this_file() {
assert_eq!(
session_model(Some(" gpt-5.6 "), Some("parslee/reasoning")),
Some(("gpt-5.6", PinSource::Request))
);
assert_eq!(
session_model(Some(" "), Some("parslee/reasoning")),
Some(("parslee/reasoning", PinSource::Config))
);
assert_eq!(
session_model(None, Some("parslee/reasoning")),
Some(("parslee/reasoning", PinSource::Config))
);
assert_eq!(session_model(None, None), None);
assert_eq!(session_model(Some(""), None), None);
assert_eq!(
session_model(None, Some(" gpt-5.6 ")),
Some((" gpt-5.6 ", PinSource::Config))
);
}
#[test]
fn model_pin_parses_and_blank_is_unset() {
assert_eq!(CoderConfig::default().model, None);
let c = CoderConfig::parse_toml("[coder]\nmodel = \"parslee/reasoning\"\n");
assert_eq!(c.model.as_deref(), Some("parslee/reasoning"));
let c = CoderConfig::parse_toml("[coder]\nmodel = \" \"\n");
assert_eq!(c.model, None);
}
#[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);
}
}