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 pub compact_at_tokens: u64,
152 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 pub git_commit: bool,
173 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 pub auto_test: bool,
192 pub cmd: String,
194 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 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
222pub 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 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
283fn 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}