1use crate::error::{Error, Result};
7use crate::permissions::PermissionMode;
8use crate::permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
9use serde::Deserialize;
10use std::path::{Path, PathBuf};
11
12pub fn config_file_path() -> Option<PathBuf> {
22 if let Some(custom) = std::env::var_os("RECURSIVE_HOME") {
23 return Some(PathBuf::from(custom).join(".recursive").join("config.toml"));
24 }
25 dirs::home_dir().map(|h| h.join(".recursive").join("config.toml"))
26}
27
28#[derive(Debug, Default, Deserialize)]
30pub struct FileConfig {
31 pub provider: Option<ProviderSection>,
32 pub agent: Option<AgentSection>,
33 pub permissions: Option<PermissionsSection>,
37}
38
39#[derive(Debug, Deserialize)]
41pub struct ProviderSection {
42 #[serde(rename = "type")]
43 pub provider_type: Option<String>,
44 pub api_key: Option<String>,
45 pub api_base: Option<String>,
46 pub model: Option<String>,
47 #[serde(default)]
52 pub preset: Option<String>,
53}
54
55#[derive(Debug, Deserialize)]
57pub struct AgentSection {
58 pub max_steps: Option<usize>,
59 pub temperature: Option<f64>,
60 pub shell_timeout_secs: Option<u64>,
61}
62
63#[derive(Debug, Default, Deserialize, Clone)]
67pub struct PermissionsSection {
68 #[serde(default)]
69 pub allow: Vec<String>,
70 #[serde(default)]
71 pub deny: Vec<String>,
72 #[serde(default)]
73 pub interactive: Vec<String>,
74 #[serde(default)]
76 pub plan: Vec<String>,
77 #[serde(default)]
82 pub mode: Option<PermissionMode>,
83}
84
85impl FileConfig {
86 pub fn load() -> Result<Option<Self>> {
90 let path = match config_file_path() {
91 Some(p) => p,
92 None => return Ok(None),
93 };
94 Self::load_from(&path)
95 }
96
97 pub fn load_from(path: &Path) -> Result<Option<Self>> {
99 if !path.exists() {
100 return Ok(None);
101 }
102 let content = std::fs::read_to_string(path).map_err(Error::Io)?;
103 let config: FileConfig = toml::from_str(&content).map_err(|e| Error::Config {
104 message: format!("failed to parse config file {}: {}", path.display(), e),
105 })?;
106 Ok(Some(config))
107 }
108}
109
110pub fn set_value(key: &str, value: &str) -> Result<()> {
114 let path = config_file_path().ok_or_else(|| Error::Config {
115 message: "cannot determine home directory".into(),
116 })?;
117
118 if let Some(parent) = path.parent() {
120 std::fs::create_dir_all(parent).map_err(Error::Io)?;
121 }
122
123 let content = if path.exists() {
125 std::fs::read_to_string(&path).map_err(Error::Io)?
126 } else {
127 String::new()
128 };
129
130 let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
131
132 let parts: Vec<&str> = key.splitn(2, '.').collect();
134 match parts.as_slice() {
135 [section, field] => {
136 let table = doc
137 .entry(*section)
138 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
139 if let toml::Value::Table(t) = table {
140 t.insert(field.to_string(), smart_value(value));
141 }
142 }
143 [field] => {
144 doc.insert(field.to_string(), smart_value(value));
145 }
146 _ => {
147 return Err(Error::Config {
148 message: format!("invalid key format: {key}"),
149 })
150 }
151 }
152
153 let toml_str = toml::to_string_pretty(&doc).map_err(|e| Error::Config {
154 message: format!("failed to serialize config: {}", e),
155 })?;
156 std::fs::write(&path, toml_str).map_err(Error::Io)?;
157 Ok(())
158}
159
160fn smart_value(s: &str) -> toml::Value {
162 if let Ok(i) = s.parse::<i64>() {
163 toml::Value::Integer(i)
164 } else if let Ok(f) = s.parse::<f64>() {
165 toml::Value::Float(f)
166 } else if s == "true" || s == "false" {
167 toml::Value::Boolean(s == "true")
168 } else {
169 toml::Value::String(s.to_string())
170 }
171}
172
173pub fn load_layered_permissions(workspace: &Path) -> LayeredPermissionsConfig {
183 let mut config = LayeredPermissionsConfig::default();
184
185 if let Some(home) = std::env::var("HOME")
187 .ok()
188 .map(PathBuf::from)
189 .or_else(dirs::home_dir)
190 {
191 let path = home.join(".recursive").join("config.toml");
192 if let Some(layer) = load_permission_layer(&path, RuleSource::User) {
193 config.layers.push(layer);
194 }
195 }
196
197 let project_path = workspace.join(".recursive").join("config.toml");
199 if let Some(layer) = load_permission_layer(&project_path, RuleSource::Project) {
200 config.layers.push(layer);
201 }
202
203 config.layers.push(PermissionLayer {
205 source: RuleSource::Session,
206 ..Default::default()
207 });
208
209 config
210}
211
212fn load_permission_layer(path: &Path, source: RuleSource) -> Option<PermissionLayer> {
216 if !path.exists() {
217 return None;
218 }
219 let content = std::fs::read_to_string(path).ok()?;
220 let file_config: FileConfig = toml::from_str(&content).ok()?;
223 let section = file_config.permissions?;
224 Some(PermissionLayer {
225 source,
226 allow: section.allow,
227 deny: section.deny,
228 interactive: section.interactive,
229 })
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn load_returns_none_when_missing() {
238 let result = FileConfig::load_from(Path::new("/nonexistent/path.toml")).unwrap();
239 assert!(result.is_none());
240 }
241
242 #[test]
243 fn load_parses_valid_toml() {
244 let tmp = tempfile::NamedTempFile::new().unwrap();
245 std::fs::write(
246 tmp.path(),
247 r#"
248[provider]
249type = "openai"
250api_key = "sk-test"
251api_base = "https://api.deepseek.com"
252model = "deepseek-chat"
253
254[agent]
255max_steps = 64
256temperature = 0.5
257"#,
258 )
259 .unwrap();
260
261 let config = FileConfig::load_from(tmp.path()).unwrap();
262 assert!(config.is_some());
263 let c = config.unwrap();
264 let p = c.provider.unwrap();
265 assert_eq!(p.provider_type.as_deref(), Some("openai"));
266 assert_eq!(p.api_key.as_deref(), Some("sk-test"));
267 assert_eq!(p.api_base.as_deref(), Some("https://api.deepseek.com"));
268 assert_eq!(p.model.as_deref(), Some("deepseek-chat"));
269 let a = c.agent.unwrap();
270 assert_eq!(a.max_steps, Some(64));
271 assert_eq!(a.temperature, Some(0.5));
272 }
273
274 #[test]
275 fn load_errors_on_malformed() {
276 let tmp = tempfile::NamedTempFile::new().unwrap();
277 std::fs::write(tmp.path(), "this is [[[not valid toml").unwrap();
278 let result = FileConfig::load_from(tmp.path());
279 assert!(result.is_err());
280 }
281
282 #[test]
283 fn smart_value_parses_types() {
284 assert_eq!(smart_value("42"), toml::Value::Integer(42));
285 assert_eq!(smart_value("0.5"), toml::Value::Float(0.5));
286 assert_eq!(smart_value("true"), toml::Value::Boolean(true));
287 assert_eq!(smart_value("hello"), toml::Value::String("hello".into()));
288 }
289
290 #[test]
291 fn parse_provider_section_with_preset() {
292 let tmp = tempfile::NamedTempFile::new().unwrap();
293 std::fs::write(
294 tmp.path(),
295 r#"
296[provider]
297preset = "deepseek"
298"#,
299 )
300 .unwrap();
301
302 let config = FileConfig::load_from(tmp.path()).unwrap().unwrap();
303 let p = config.provider.unwrap();
304 assert_eq!(p.preset.as_deref(), Some("deepseek"));
305 assert!(p.provider_type.is_none());
307 assert!(p.api_base.is_none());
308 assert!(p.model.is_none());
309 assert!(p.api_key.is_none());
310 }
311
312 #[test]
313 fn set_value_preset_round_trips() {
314 let tmp = tempfile::tempdir().unwrap();
316 let path = tmp.path().join("config.toml");
317 std::fs::create_dir_all(tmp.path()).unwrap();
318 std::fs::write(&path, "[provider]\nmodel = \"x\"\n").unwrap();
319
320 let content = std::fs::read_to_string(&path).unwrap();
323 let mut doc: toml::Table = content.parse().unwrap();
324 let parts: Vec<&str> = "provider.preset".splitn(2, '.').collect();
325 if let [section, field] = parts.as_slice() {
326 let table = doc
327 .entry(*section)
328 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
329 if let toml::Value::Table(t) = table {
330 t.insert(field.to_string(), smart_value("anthropic"));
331 }
332 }
333 std::fs::write(&path, toml::to_string_pretty(&doc).unwrap()).unwrap();
334
335 let loaded = FileConfig::load_from(&path).unwrap().unwrap();
336 assert_eq!(
337 loaded.provider.unwrap().preset.as_deref(),
338 Some("anthropic")
339 );
340 }
341
342 #[test]
343 fn set_value_creates_file_and_writes() {
344 let tmp = tempfile::tempdir().unwrap();
345 let path = tmp.path().join("config.toml");
346
347 std::fs::create_dir_all(tmp.path()).unwrap();
349 let content = String::new();
351 let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
352
353 let parts: Vec<&str> = "provider.model".splitn(2, '.').collect();
354 if let [section, field] = parts.as_slice() {
355 let table = doc
356 .entry(*section)
357 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
358 if let toml::Value::Table(t) = table {
359 t.insert(field.to_string(), smart_value("deepseek-chat"));
360 }
361 }
362
363 let output = toml::to_string_pretty(&doc).unwrap();
364 std::fs::write(&path, &output).unwrap();
365
366 let loaded = FileConfig::load_from(&path).unwrap().unwrap();
368 assert_eq!(
369 loaded.provider.unwrap().model.as_deref(),
370 Some("deepseek-chat")
371 );
372 }
373
374 #[test]
375 fn test_load_layered_permissions_session_layer_always_present() {
376 let tmp = tempfile::tempdir().unwrap();
377 let workspace = tmp.path().join("workspace");
378 std::fs::create_dir_all(&workspace).unwrap();
379 let fake_home = tmp.path().join("home");
380 std::fs::create_dir_all(&fake_home).unwrap();
381
382 let _pin = crate::test_util::PinnedHome::new(&fake_home);
386 let config = load_layered_permissions(&workspace);
387
388 assert!(config
390 .layers
391 .iter()
392 .any(|l| l.source == RuleSource::Session));
393 assert_eq!(config.layers.len(), 1);
395 assert_eq!(config.layers[0].source, RuleSource::Session);
396 }
397
398 #[test]
399 fn test_load_layered_permissions_loads_user_and_project() {
400 let tmp = tempfile::tempdir().unwrap();
401 let home = tmp.path().join("home");
402 let project = tmp.path().join("project");
403
404 std::fs::create_dir_all(home.join(".recursive")).unwrap();
406 std::fs::write(
407 home.join(".recursive").join("config.toml"),
408 r#"
409[permissions]
410allow = ["read_file"]
411deny = ["run_shell"]
412"#,
413 )
414 .unwrap();
415
416 std::fs::create_dir_all(project.join(".recursive")).unwrap();
418 std::fs::write(
419 project.join(".recursive").join("config.toml"),
420 r#"
421[permissions]
422allow = ["write_file"]
423interactive = ["delete_file"]
424"#,
425 )
426 .unwrap();
427
428 let old_home = std::env::var("HOME").ok();
430 std::env::set_var("HOME", &home);
431
432 let config = load_layered_permissions(&project);
433
434 if let Some(h) = old_home {
436 std::env::set_var("HOME", h);
437 } else {
438 std::env::remove_var("HOME");
439 }
440
441 assert_eq!(config.layers.len(), 3);
443 assert_eq!(config.layers[0].source, RuleSource::User);
444 assert_eq!(config.layers[1].source, RuleSource::Project);
445 assert_eq!(config.layers[2].source, RuleSource::Session);
446
447 assert_eq!(config.layers[0].allow, vec!["read_file"]);
449 assert_eq!(config.layers[0].deny, vec!["run_shell"]);
450
451 assert_eq!(config.layers[1].allow, vec!["write_file"]);
453 assert_eq!(config.layers[1].interactive, vec!["delete_file"]);
454 }
455}