Skip to main content

recursive/
config_file.rs

1//! Config file support: ~/.recursive/config.toml
2//!
3//! Priority chain: CLI flag > env var > config file > hardcoded default.
4//! The config file is optional — if absent, we gracefully fall back.
5
6use crate::error::{Error, Result};
7use crate::permissions::PermissionMode;
8use crate::permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
9use serde::Deserialize;
10use std::path::{Path, PathBuf};
11
12/// Return the path to the global config file: ~/.recursive/config.toml.
13/// Returns None if the home directory cannot be determined.
14///
15/// Honours `RECURSIVE_HOME` for tests (matching `paths::user_data_dir`)
16/// before falling back to `dirs::home_dir()`. Without the
17/// `RECURSIVE_HOME` short-circuit, tests that pin HOME via
18/// `PinnedHome` still race with tests that mutate `HOME` directly
19/// (without holding the env lock) on macOS, where `dirs::home_dir`
20/// can re-resolve through `getpwuid_r` mid-test.
21pub 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/// Top-level deserialized structure of config.toml.
29#[derive(Debug, Default, Deserialize)]
30pub struct FileConfig {
31    pub provider: Option<ProviderSection>,
32    pub agent: Option<AgentSection>,
33    /// Optional `[permissions]` section. When present, restricts which
34    /// tools the agent may invoke. Schema mirrors
35    /// [`crate::permissions::PermissionsConfig`]. See g140.
36    pub permissions: Option<PermissionsSection>,
37}
38
39/// [provider] section.
40#[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    /// Preset id from the bundled `providers.toml` (e.g. `"deepseek"`).
48    /// When set, `Config::from_env` uses it as the base for `type` / `api_base` /
49    /// `model` / `api_key`; explicit fields above still win. Resolved at load
50    /// time — not persisted back via `set_value`.
51    #[serde(default)]
52    pub preset: Option<String>,
53}
54
55/// [agent] section.
56#[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/// [permissions] section. Wire-compatible with
64/// [`crate::permissions::PermissionsConfig`] but lives here so config
65/// loading does not couple to that crate.
66#[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    /// Tools that require plan mode before use.
75    #[serde(default)]
76    pub plan: Vec<String>,
77    /// Default permission mode. Accepts both old and new format names.
78    /// New: "default", "acceptEdits", "bypassPermissions", "dontAsk", "plan".
79    /// Old: "allow" (→ default), "deny" (→ dontAsk), "interactive" (→ dontAsk).
80    /// The "plan" variant can also be an object `{prePlanMode, bypassAvailable}`.
81    #[serde(default)]
82    pub mode: Option<PermissionMode>,
83}
84
85impl FileConfig {
86    /// Load from the default path (~/.recursive/config.toml).
87    /// Returns Ok(None) if the file doesn't exist.
88    /// Returns Err if the file exists but is malformed.
89    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    /// Load from an explicit path.
98    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
110/// Write a dotted key=value to ~/.recursive/config.toml.
111/// Supports dotted keys like "provider.model", "agent.max_steps".
112/// Creates the file and parent directory if needed.
113pub 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    // Ensure directory exists
119    if let Some(parent) = path.parent() {
120        std::fs::create_dir_all(parent).map_err(Error::Io)?;
121    }
122
123    // Read existing or start fresh
124    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    // Parse dotted key "provider.model" → table["provider"]["model"]
133    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
160/// Convert a string to the appropriate TOML value type.
161fn 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
173/// Load layered permissions from user config and project config.
174///
175/// Resolution order (highest priority first):
176/// 1. Session layer (empty, filled at runtime via Goal 196)
177/// 2. Project layer (`.recursive/config.toml` in the workspace)
178/// 3. User layer (`~/.recursive/config.toml`)
179///
180/// The Session layer is always present (even if empty) so that runtime
181/// rule updates can be written to it.
182pub fn load_layered_permissions(workspace: &Path) -> LayeredPermissionsConfig {
183    let mut config = LayeredPermissionsConfig::default();
184
185    // User layer (lowest priority)
186    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    // Project layer (medium priority)
198    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    // Session layer (highest priority) — always present, empty by default
204    config.layers.push(PermissionLayer {
205        source: RuleSource::Session,
206        ..Default::default()
207    });
208
209    config
210}
211
212/// Load a single permission layer from a TOML file, if it exists.
213///
214/// Returns `None` if the file doesn't exist or can't be parsed.
215fn 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    // Parse as FileConfig first so we only extract the [permissions] section.
221    // This avoids picking up unrelated sections (e.g. [provider]) as empty defaults.
222    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        // Other fields are absent — preset resolution happens in Config::from_env.
306        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        // The dotted-key writer must handle the new `provider.preset` field.
315        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        // Manually invoke the same dotted-key logic the public set_value uses,
321        // since set_value writes to ~/.recursive/config.toml via HOME.
322        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        // Temporarily override the path resolution by writing directly
348        std::fs::create_dir_all(tmp.path()).unwrap();
349        // We'll test the write logic manually since config_file_path() uses HOME
350        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        // Verify
367        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        // Pin HOME (under env_lock) so a concurrent test can't interleave
383        // its own HOME mutation and have its User layer leak into the
384        // "exactly one Session layer" assertion.
385        let _pin = crate::test_util::PinnedHome::new(&fake_home);
386        let config = load_layered_permissions(&workspace);
387
388        // Session layer is always present
389        assert!(config
390            .layers
391            .iter()
392            .any(|l| l.source == RuleSource::Session));
393        // Even with no config files, we get only the session layer
394        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        // Create user config
405        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        // Create project config
417        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        // Override home dir for the test
429        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        // Restore home
435        if let Some(h) = old_home {
436            std::env::set_var("HOME", h);
437        } else {
438            std::env::remove_var("HOME");
439        }
440
441        // Should have 3 layers: User, Project, Session
442        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        // User layer has allow/deny
448        assert_eq!(config.layers[0].allow, vec!["read_file"]);
449        assert_eq!(config.layers[0].deny, vec!["run_shell"]);
450
451        // Project layer has allow/interactive
452        assert_eq!(config.layers[1].allow, vec!["write_file"]);
453        assert_eq!(config.layers[1].interactive, vec!["delete_file"]);
454    }
455}