ralph-coder 0.2.1

An agentic code generation CLI powered by multiple LLM backends
Documentation
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct Config {
    #[serde(default)]
    pub defaults: DefaultsConfig,
    #[serde(default)]
    pub providers: ProvidersConfig,
    #[serde(default)]
    pub search: SearchConfig,
    #[serde(default)]
    pub guardrails: GuardrailsConfig,
    #[serde(default)]
    pub whitelisted_commands: WhitelistedCommandsConfig,
    #[serde(default)]
    pub session: SessionConfig,
    #[serde(default)]
    pub compaction: CompactionConfig,
    #[serde(default)]
    pub checkpoints: CheckpointsConfig,
    #[serde(default)]
    pub testing: TestingConfig,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DefaultsConfig {
    pub provider: Option<String>,
    pub max_turns: u32,
    pub workspace: String,
    pub confirm_overwrites: bool,
}

impl Default for DefaultsConfig {
    fn default() -> Self {
        Self {
            provider: None,
            max_turns: 20,
            workspace: ".".to_string(),
            confirm_overwrites: true,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProvidersConfig {
    pub anthropic: Option<ProviderConfig>,
    pub openai: Option<ProviderConfig>,
    pub deepseek: Option<ProviderConfig>,
    pub gemini: Option<ProviderConfig>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig {
    pub model: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SearchConfig {
    pub brave_api_key_env: String,
    pub serp_api_key_env: String,
}

impl Default for SearchConfig {
    fn default() -> Self {
        Self {
            brave_api_key_env: "BRAVE_API_KEY".to_string(),
            serp_api_key_env: "SERP_API_KEY".to_string(),
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GuardrailsConfig {
    pub blocked_commands: Vec<String>,
    pub env_allowlist: Vec<String>,
    pub max_tokens_per_session: u64,
}

impl Default for GuardrailsConfig {
    fn default() -> Self {
        Self {
            blocked_commands: vec![
                "rm -rf".to_string(),
                "sudo".to_string(),
                "curl | bash".to_string(),
                "wget | sh".to_string(),
                "curl|bash".to_string(),
                "wget|sh".to_string(),
            ],
            env_allowlist: vec![
                "HOME".to_string(),
                "PATH".to_string(),
                "CARGO_HOME".to_string(),
                "RUSTUP_HOME".to_string(),
                "TERM".to_string(),
                "SHELL".to_string(),
            ],
            max_tokens_per_session: 200_000,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WhitelistedCommandsConfig {
    pub build: Vec<String>,
    pub test: Vec<String>,
}

impl Default for WhitelistedCommandsConfig {
    fn default() -> Self {
        Self {
            build: vec![
                "cargo build".to_string(),
                "npm run build".to_string(),
                "make".to_string(),
                "go build".to_string(),
            ],
            test: vec![
                "cargo test".to_string(),
                "npm test".to_string(),
                "pytest".to_string(),
                "go test".to_string(),
            ],
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionConfig {
    pub auto_resume: bool,
    pub sessions_dir: Option<String>,
    pub clean_after_days: u32,
}

impl Default for SessionConfig {
    fn default() -> Self {
        Self {
            auto_resume: true,
            sessions_dir: None,
            clean_after_days: 30,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CompactionConfig {
    pub enabled: bool,
    /// Fire compaction when estimated tokens exceed this threshold.
    pub compact_at_tokens: u64,
    /// After compaction, target this many tokens in the new window.
    pub compact_to_tokens: u64,
    pub keep_recent_turns: usize,
}

impl Default for CompactionConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            compact_at_tokens: 40_000,
            compact_to_tokens: 20_000,
            keep_recent_turns: 10,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CheckpointsConfig {
    pub auto_checkpoint_before_destructive: bool,
    /// Commit checkpoint files to git (on the current branch). Default: false.
    pub git_commit: bool,
    /// Only use git commits when modified_files exceeds this count;
    /// below the threshold the existing file-snapshot mechanism is used.
    pub git_commit_threshold: usize,
}

impl Default for CheckpointsConfig {
    fn default() -> Self {
        Self {
            auto_checkpoint_before_destructive: false,
            git_commit: false,
            git_commit_threshold: 5,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TestingConfig {
    /// Automatically run tests after every turn that modifies files.
    pub auto_test: bool,
    /// Test command. Empty string = auto-detect from project type.
    pub cmd: String,
    /// Maximum consecutive test-fix retries before aborting.
    pub max_retries: u32,
}

impl Default for TestingConfig {
    fn default() -> Self {
        Self {
            auto_test: false,
            cmd: String::new(),
            max_retries: 3,
        }
    }
}

impl TestingConfig {
    /// Returns the command to run, or `None` if auto-test is disabled or
    /// no command can be determined (neither configured nor detectable).
    pub fn resolved_cmd(&self, workspace: &Path) -> Option<String> {
        if !self.auto_test {
            return None;
        }
        if !self.cmd.is_empty() {
            return Some(self.cmd.clone());
        }
        infer_test_cmd(workspace)
    }
}

/// Detect the test command from project files found in `workspace`.
pub fn infer_test_cmd(workspace: &Path) -> Option<String> {
    if workspace.join("Cargo.toml").exists() {
        return Some("cargo test".to_string());
    }
    if workspace.join("go.mod").exists() {
        return Some("go test ./...".to_string());
    }
    if workspace.join("pytest.ini").exists()
        || workspace.join("pyproject.toml").exists()
        || workspace.join("setup.py").exists()
    {
        return Some("pytest".to_string());
    }
    if workspace.join("package.json").exists() {
        return Some("npm test".to_string());
    }
    None
}

impl Config {
    /// Load config, merging global (~/.ralph/config.toml) with workspace (.ralph.toml).
    /// Workspace config takes precedence.
    pub fn load(workspace: &Path) -> Result<Self> {
        let global = load_global_config().unwrap_or_default();
        let local = load_workspace_config(workspace).unwrap_or_default();
        Ok(merge_configs(global, local))
    }

    pub fn sessions_dir(&self) -> PathBuf {
        if let Some(ref dir) = self.session.sessions_dir {
            let expanded = dir.replacen("~", &home_dir_str(), 1);
            PathBuf::from(expanded)
        } else {
            ralph_data_dir().join("sessions")
        }
    }
}

fn load_global_config() -> Option<Config> {
    let path = ralph_data_dir().join("config.toml");
    load_toml_config(&path)
}

fn load_workspace_config(workspace: &Path) -> Option<Config> {
    let path = workspace.join(".ralph.toml");
    load_toml_config(&path)
}

fn load_toml_config(path: &Path) -> Option<Config> {
    if !path.exists() {
        return None;
    }
    let contents = std::fs::read_to_string(path)
        .with_context(|| format!("reading {}", path.display()))
        .ok()?;
    toml::from_str(&contents)
        .with_context(|| format!("parsing {}", path.display()))
        .ok()
}

/// Deep merge: local fields override global where set.
fn merge_configs(base: Config, overlay: Config) -> Config {
    Config {
        defaults: DefaultsConfig {
            provider: overlay.defaults.provider.or(base.defaults.provider),
            max_turns: if overlay.defaults.max_turns != DefaultsConfig::default().max_turns {
                overlay.defaults.max_turns
            } else {
                base.defaults.max_turns
            },
            workspace: overlay.defaults.workspace,
            confirm_overwrites: overlay.defaults.confirm_overwrites,
        },
        providers: ProvidersConfig {
            anthropic: overlay.providers.anthropic.or(base.providers.anthropic),
            openai: overlay.providers.openai.or(base.providers.openai),
            deepseek: overlay.providers.deepseek.or(base.providers.deepseek),
            gemini: overlay.providers.gemini.or(base.providers.gemini),
        },
        search: overlay.search,
        guardrails: overlay.guardrails,
        whitelisted_commands: overlay.whitelisted_commands,
        session: overlay.session,
        compaction: overlay.compaction,
        checkpoints: overlay.checkpoints,
        testing: overlay.testing,
    }
}

pub fn ralph_data_dir() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".ralph")
}

fn home_dir_str() -> String {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .to_string_lossy()
        .to_string()
}