car-server-core 0.25.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Operator config for the coder, loaded from `~/.car/coder.toml`.
//!
//! A small, tolerant config the operator can drop next to the coder state dir
//! to tune three knobs without recompiling:
//!
//! ```toml
//! [coder]
//! engine_preference = ["claude-code", "codex", "gemini"]  # foreman/external delegation order
//! keep_workspace_on_failure = false                        # keep the worktree for postmortem
//! default_max_iterations = 8
//! ```
//!
//! Loading is deliberately forgiving: a missing file, a missing `[coder]`
//! table, or any missing key falls back to the documented default. A malformed
//! file is logged and treated as absent rather than panicking — the daemon must
//! always boot. `CAR_CODER_CONFIG` overrides the path for tests and embedders,
//! mirroring `CAR_CODER_STATE_DIR`.

use std::path::PathBuf;

use serde::Deserialize;

use super::router::DEFAULT_PREFERENCE;

/// Default loop iteration cap when neither the RPC nor the config specifies one.
pub const DEFAULT_MAX_ITERATIONS: u32 = 8;

/// Resolved coder operator config. Every field is populated (defaults applied),
/// so callers never deal with `Option`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoderConfig {
    /// External/foreman delegation order: the first ready CLI in this list
    /// wins. Defaults to [`DEFAULT_PREFERENCE`].
    pub engine_preference: Vec<String>,
    /// Keep the throwaway worktree when a session ends `Failed`, so the operator
    /// can inspect it for a postmortem. Defaults to `false` (drop it).
    pub keep_workspace_on_failure: bool,
    /// Default loop iteration cap when `coder.start` omits `max_iterations`.
    /// Defaults to [`DEFAULT_MAX_ITERATIONS`].
    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,
        }
    }
}

/// The raw on-disk shape. Every field is optional so a partial file is valid;
/// missing fields fall back to [`CoderConfig::default`].
#[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 {
    /// Resolve `engine_preference` to the `&str` slice [`resolve_engine`] wants.
    ///
    /// [`resolve_engine`]: super::router::resolve_engine
    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 {
            // An explicitly-empty list would mean "no preference at all"; treat
            // it as unset and fall back to the default order so the operator
            // can't accidentally disable all delegation by writing `[]`.
            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
                // 0 iterations is a footgun (the loop never runs); ignore it.
                .filter(|n| *n > 0)
                .unwrap_or(defaults.default_max_iterations),
        }
    }

    /// Parse a TOML string. Tolerant: a malformed document yields defaults
    /// rather than an error.
    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()
            }
        }
    }

    /// Load from `path`. A missing file → defaults; an unreadable or malformed
    /// file → defaults (logged). Never panics.
    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()
            }
        }
    }

    /// Load from the resolved config path ([`config_path`]).
    pub fn load() -> Self {
        match config_path() {
            Ok(path) => Self::load_from(&path),
            Err(_) => Self::default(),
        }
    }
}

/// Where the operator config lives. `CAR_CODER_CONFIG` overrides for tests and
/// embedders (mirroring `CAR_CODER_STATE_DIR`); otherwise `~/.car/coder.toml`.
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() {
        // Empty file → every default.
        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);

        // Empty [coder] table → still defaults.
        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() {
        // Only one key set; the rest 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",
        );
        // Both footguns fall back to defaults rather than disabling the feature.
        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);
    }
}