Skip to main content

ralph/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Deserialize, Serialize, Default)]
6pub struct Config {
7    #[serde(default)]
8    pub defaults: DefaultsConfig,
9    #[serde(default)]
10    pub providers: ProvidersConfig,
11    #[serde(default)]
12    pub search: SearchConfig,
13    #[serde(default)]
14    pub guardrails: GuardrailsConfig,
15    #[serde(default)]
16    pub whitelisted_commands: WhitelistedCommandsConfig,
17    #[serde(default)]
18    pub session: SessionConfig,
19    #[serde(default)]
20    pub compaction: CompactionConfig,
21    #[serde(default)]
22    pub checkpoints: CheckpointsConfig,
23    #[serde(default)]
24    pub testing: TestingConfig,
25}
26
27#[derive(Debug, Clone, Deserialize, Serialize)]
28pub struct DefaultsConfig {
29    pub provider: Option<String>,
30    pub max_turns: u32,
31    pub workspace: String,
32    pub confirm_overwrites: bool,
33}
34
35impl Default for DefaultsConfig {
36    fn default() -> Self {
37        Self {
38            provider: None,
39            max_turns: 20,
40            workspace: ".".to_string(),
41            confirm_overwrites: true,
42        }
43    }
44}
45
46#[derive(Debug, Clone, Deserialize, Serialize, Default)]
47pub struct ProvidersConfig {
48    pub anthropic: Option<ProviderConfig>,
49    pub openai: Option<ProviderConfig>,
50    pub deepseek: Option<ProviderConfig>,
51    pub gemini: Option<ProviderConfig>,
52}
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct ProviderConfig {
56    pub model: Option<String>,
57}
58
59#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct SearchConfig {
61    pub brave_api_key_env: String,
62    pub serp_api_key_env: String,
63}
64
65impl Default for SearchConfig {
66    fn default() -> Self {
67        Self {
68            brave_api_key_env: "BRAVE_API_KEY".to_string(),
69            serp_api_key_env: "SERP_API_KEY".to_string(),
70        }
71    }
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct GuardrailsConfig {
76    pub blocked_commands: Vec<String>,
77    pub env_allowlist: Vec<String>,
78    pub max_tokens_per_session: u64,
79}
80
81impl Default for GuardrailsConfig {
82    fn default() -> Self {
83        Self {
84            blocked_commands: vec![
85                "rm -rf".to_string(),
86                "sudo".to_string(),
87                "curl | bash".to_string(),
88                "wget | sh".to_string(),
89                "curl|bash".to_string(),
90                "wget|sh".to_string(),
91            ],
92            env_allowlist: vec![
93                "HOME".to_string(),
94                "PATH".to_string(),
95                "CARGO_HOME".to_string(),
96                "RUSTUP_HOME".to_string(),
97                "TERM".to_string(),
98                "SHELL".to_string(),
99            ],
100            max_tokens_per_session: 200_000,
101        }
102    }
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize)]
106pub struct WhitelistedCommandsConfig {
107    pub build: Vec<String>,
108    pub test: Vec<String>,
109}
110
111impl Default for WhitelistedCommandsConfig {
112    fn default() -> Self {
113        Self {
114            build: vec![
115                "cargo build".to_string(),
116                "npm run build".to_string(),
117                "make".to_string(),
118                "go build".to_string(),
119            ],
120            test: vec![
121                "cargo test".to_string(),
122                "npm test".to_string(),
123                "pytest".to_string(),
124                "go test".to_string(),
125            ],
126        }
127    }
128}
129
130#[derive(Debug, Clone, Deserialize, Serialize)]
131pub struct SessionConfig {
132    pub auto_resume: bool,
133    pub sessions_dir: Option<String>,
134    pub clean_after_days: u32,
135}
136
137impl Default for SessionConfig {
138    fn default() -> Self {
139        Self {
140            auto_resume: true,
141            sessions_dir: None,
142            clean_after_days: 30,
143        }
144    }
145}
146
147#[derive(Debug, Clone, Deserialize, Serialize)]
148pub struct CompactionConfig {
149    pub enabled: bool,
150    /// Fire compaction when estimated tokens exceed this threshold.
151    pub compact_at_tokens: u64,
152    /// After compaction, target this many tokens in the new window.
153    pub compact_to_tokens: u64,
154    pub keep_recent_turns: usize,
155}
156
157impl Default for CompactionConfig {
158    fn default() -> Self {
159        Self {
160            enabled: true,
161            compact_at_tokens: 40_000,
162            compact_to_tokens: 20_000,
163            keep_recent_turns: 10,
164        }
165    }
166}
167
168#[derive(Debug, Clone, Deserialize, Serialize)]
169pub struct CheckpointsConfig {
170    pub auto_checkpoint_before_destructive: bool,
171    /// Commit checkpoint files to git (on the current branch). Default: false.
172    pub git_commit: bool,
173    /// Only use git commits when modified_files exceeds this count;
174    /// below the threshold the existing file-snapshot mechanism is used.
175    pub git_commit_threshold: usize,
176}
177
178impl Default for CheckpointsConfig {
179    fn default() -> Self {
180        Self {
181            auto_checkpoint_before_destructive: false,
182            git_commit: false,
183            git_commit_threshold: 5,
184        }
185    }
186}
187
188#[derive(Debug, Clone, Deserialize, Serialize)]
189pub struct TestingConfig {
190    /// Automatically run tests after every turn that modifies files.
191    pub auto_test: bool,
192    /// Test command. Empty string = auto-detect from project type.
193    pub cmd: String,
194    /// Maximum consecutive test-fix retries before aborting.
195    pub max_retries: u32,
196}
197
198impl Default for TestingConfig {
199    fn default() -> Self {
200        Self {
201            auto_test: false,
202            cmd: String::new(),
203            max_retries: 3,
204        }
205    }
206}
207
208impl TestingConfig {
209    /// Returns the command to run, or `None` if auto-test is disabled or
210    /// no command can be determined (neither configured nor detectable).
211    pub fn resolved_cmd(&self, workspace: &Path) -> Option<String> {
212        if !self.auto_test {
213            return None;
214        }
215        if !self.cmd.is_empty() {
216            return Some(self.cmd.clone());
217        }
218        infer_test_cmd(workspace)
219    }
220}
221
222/// Detect the test command from project files found in `workspace`.
223pub fn infer_test_cmd(workspace: &Path) -> Option<String> {
224    if workspace.join("Cargo.toml").exists() {
225        return Some("cargo test".to_string());
226    }
227    if workspace.join("go.mod").exists() {
228        return Some("go test ./...".to_string());
229    }
230    if workspace.join("pytest.ini").exists()
231        || workspace.join("pyproject.toml").exists()
232        || workspace.join("setup.py").exists()
233    {
234        return Some("pytest".to_string());
235    }
236    if workspace.join("package.json").exists() {
237        return Some("npm test".to_string());
238    }
239    None
240}
241
242impl Config {
243    /// Load config, merging global (~/.ralph/config.toml) with workspace (.ralph.toml).
244    /// Workspace config takes precedence.
245    pub fn load(workspace: &Path) -> Result<Self> {
246        let global = load_global_config().unwrap_or_default();
247        let local = load_workspace_config(workspace).unwrap_or_default();
248        Ok(merge_configs(global, local))
249    }
250
251    pub fn sessions_dir(&self) -> PathBuf {
252        if let Some(ref dir) = self.session.sessions_dir {
253            let expanded = dir.replacen("~", &home_dir_str(), 1);
254            PathBuf::from(expanded)
255        } else {
256            ralph_data_dir().join("sessions")
257        }
258    }
259}
260
261fn load_global_config() -> Option<Config> {
262    let path = ralph_data_dir().join("config.toml");
263    load_toml_config(&path)
264}
265
266fn load_workspace_config(workspace: &Path) -> Option<Config> {
267    let path = workspace.join(".ralph.toml");
268    load_toml_config(&path)
269}
270
271fn load_toml_config(path: &Path) -> Option<Config> {
272    if !path.exists() {
273        return None;
274    }
275    let contents = std::fs::read_to_string(path)
276        .with_context(|| format!("reading {}", path.display()))
277        .ok()?;
278    toml::from_str(&contents)
279        .with_context(|| format!("parsing {}", path.display()))
280        .ok()
281}
282
283/// Deep merge: local fields override global where set.
284fn merge_configs(base: Config, overlay: Config) -> Config {
285    Config {
286        defaults: DefaultsConfig {
287            provider: overlay.defaults.provider.or(base.defaults.provider),
288            max_turns: if overlay.defaults.max_turns != DefaultsConfig::default().max_turns {
289                overlay.defaults.max_turns
290            } else {
291                base.defaults.max_turns
292            },
293            workspace: overlay.defaults.workspace,
294            confirm_overwrites: overlay.defaults.confirm_overwrites,
295        },
296        providers: ProvidersConfig {
297            anthropic: overlay.providers.anthropic.or(base.providers.anthropic),
298            openai: overlay.providers.openai.or(base.providers.openai),
299            deepseek: overlay.providers.deepseek.or(base.providers.deepseek),
300            gemini: overlay.providers.gemini.or(base.providers.gemini),
301        },
302        search: overlay.search,
303        guardrails: overlay.guardrails,
304        whitelisted_commands: overlay.whitelisted_commands,
305        session: overlay.session,
306        compaction: overlay.compaction,
307        checkpoints: overlay.checkpoints,
308        testing: overlay.testing,
309    }
310}
311
312pub fn ralph_data_dir() -> PathBuf {
313    dirs::home_dir()
314        .unwrap_or_else(|| PathBuf::from("."))
315        .join(".ralph")
316}
317
318fn home_dir_str() -> String {
319    dirs::home_dir()
320        .unwrap_or_else(|| PathBuf::from("."))
321        .to_string_lossy()
322        .to_string()
323}