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,
pub compact_at_tokens: u64,
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,
pub git_commit: bool,
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 {
pub auto_test: bool,
pub cmd: String,
pub max_retries: u32,
}
impl Default for TestingConfig {
fn default() -> Self {
Self {
auto_test: false,
cmd: String::new(),
max_retries: 3,
}
}
}
impl TestingConfig {
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)
}
}
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 {
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()
}
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()
}