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 serde::Deserialize;
7use std::path::{Path, PathBuf};
8use crate::error::{Error, Result};
9
10/// Return the path to the global config file: ~/.recursive/config.toml.
11/// Returns None if the home directory cannot be determined.
12pub fn config_file_path() -> Option<PathBuf> {
13    dirs::home_dir().map(|h| h.join(".recursive").join("config.toml"))
14}
15
16/// Top-level deserialized structure of config.toml.
17#[derive(Debug, Default, Deserialize)]
18pub struct FileConfig {
19    pub provider: Option<ProviderSection>,
20    pub agent: Option<AgentSection>,
21}
22
23/// [provider] section.
24#[derive(Debug, Deserialize)]
25pub struct ProviderSection {
26    #[serde(rename = "type")]
27    pub provider_type: Option<String>,
28    pub api_key: Option<String>,
29    pub api_base: Option<String>,
30    pub model: Option<String>,
31}
32
33/// [agent] section.
34#[derive(Debug, Deserialize)]
35pub struct AgentSection {
36    pub max_steps: Option<usize>,
37    pub temperature: Option<f64>,
38    pub shell_timeout_secs: Option<u64>,
39}
40
41impl FileConfig {
42    /// Load from the default path (~/.recursive/config.toml).
43    /// Returns Ok(None) if the file doesn't exist.
44    /// Returns Err if the file exists but is malformed.
45    pub fn load() -> Result<Option<Self>> {
46        let path = match config_file_path() {
47            Some(p) => p,
48            None => return Ok(None),
49        };
50        Self::load_from(&path)
51    }
52
53    /// Load from an explicit path.
54    pub fn load_from(path: &Path) -> Result<Option<Self>> {
55        if !path.exists() {
56            return Ok(None);
57        }
58        let content = std::fs::read_to_string(path).map_err(Error::Io)?;
59        let config: FileConfig = toml::from_str(&content).map_err(|e| Error::Config {
60            message: format!("failed to parse config file {}: {}", path.display(), e),
61        })?;
62        Ok(Some(config))
63    }
64}
65
66/// Write a dotted key=value to ~/.recursive/config.toml.
67/// Supports dotted keys like "provider.model", "agent.max_steps".
68/// Creates the file and parent directory if needed.
69pub fn set_value(key: &str, value: &str) -> Result<()> {
70    let path = config_file_path()
71        .ok_or_else(|| Error::Config {
72            message: "cannot determine home directory".into(),
73        })?;
74
75    // Ensure directory exists
76    if let Some(parent) = path.parent() {
77        std::fs::create_dir_all(parent).map_err(Error::Io)?;
78    }
79
80    // Read existing or start fresh
81    let content = if path.exists() {
82        std::fs::read_to_string(&path).map_err(Error::Io)?
83    } else {
84        String::new()
85    };
86
87    let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
88
89    // Parse dotted key "provider.model" → table["provider"]["model"]
90    let parts: Vec<&str> = key.splitn(2, '.').collect();
91    match parts.as_slice() {
92        [section, field] => {
93            let table = doc
94                .entry(*section)
95                .or_insert_with(|| toml::Value::Table(toml::Table::new()));
96            if let toml::Value::Table(t) = table {
97                t.insert(field.to_string(), smart_value(value));
98            }
99        }
100        [field] => {
101            doc.insert(field.to_string(), smart_value(value));
102        }
103        _ => return Err(Error::Config {
104            message: format!("invalid key format: {key}"),
105        }),
106    }
107
108    let toml_str = toml::to_string_pretty(&doc).map_err(|e| Error::Config {
109        message: format!("failed to serialize config: {}", e),
110    })?;
111    std::fs::write(&path, toml_str).map_err(Error::Io)?;
112    Ok(())
113}
114
115/// Convert a string to the appropriate TOML value type.
116fn smart_value(s: &str) -> toml::Value {
117    if let Ok(i) = s.parse::<i64>() {
118        toml::Value::Integer(i)
119    } else if let Ok(f) = s.parse::<f64>() {
120        toml::Value::Float(f)
121    } else if s == "true" || s == "false" {
122        toml::Value::Boolean(s == "true")
123    } else {
124        toml::Value::String(s.to_string())
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn load_returns_none_when_missing() {
134        let result = FileConfig::load_from(Path::new("/nonexistent/path.toml")).unwrap();
135        assert!(result.is_none());
136    }
137
138    #[test]
139    fn load_parses_valid_toml() {
140        let tmp = tempfile::NamedTempFile::new().unwrap();
141        std::fs::write(
142            tmp.path(),
143            r#"
144[provider]
145type = "openai"
146api_key = "sk-test"
147api_base = "https://api.deepseek.com"
148model = "deepseek-chat"
149
150[agent]
151max_steps = 64
152temperature = 0.5
153"#,
154        )
155        .unwrap();
156
157        let config = FileConfig::load_from(tmp.path()).unwrap();
158        assert!(config.is_some());
159        let c = config.unwrap();
160        let p = c.provider.unwrap();
161        assert_eq!(p.provider_type.as_deref(), Some("openai"));
162        assert_eq!(p.api_key.as_deref(), Some("sk-test"));
163        assert_eq!(p.api_base.as_deref(), Some("https://api.deepseek.com"));
164        assert_eq!(p.model.as_deref(), Some("deepseek-chat"));
165        let a = c.agent.unwrap();
166        assert_eq!(a.max_steps, Some(64));
167        assert_eq!(a.temperature, Some(0.5));
168    }
169
170    #[test]
171    fn load_errors_on_malformed() {
172        let tmp = tempfile::NamedTempFile::new().unwrap();
173        std::fs::write(tmp.path(), "this is [[[not valid toml").unwrap();
174        let result = FileConfig::load_from(tmp.path());
175        assert!(result.is_err());
176    }
177
178    #[test]
179    fn smart_value_parses_types() {
180        assert_eq!(smart_value("42"), toml::Value::Integer(42));
181        assert_eq!(smart_value("0.5"), toml::Value::Float(0.5));
182        assert_eq!(smart_value("true"), toml::Value::Boolean(true));
183        assert_eq!(
184            smart_value("hello"),
185            toml::Value::String("hello".into())
186        );
187    }
188
189    #[test]
190    fn set_value_creates_file_and_writes() {
191        let tmp = tempfile::tempdir().unwrap();
192        let path = tmp.path().join("config.toml");
193
194        // Temporarily override the path resolution by writing directly
195        std::fs::create_dir_all(tmp.path()).unwrap();
196        // We'll test the write logic manually since config_file_path() uses HOME
197        let content = String::new();
198        let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
199
200        let parts: Vec<&str> = "provider.model".splitn(2, '.').collect();
201        if let [section, field] = parts.as_slice() {
202            let table = doc
203                .entry(*section)
204                .or_insert_with(|| toml::Value::Table(toml::Table::new()));
205            if let toml::Value::Table(t) = table {
206                t.insert(field.to_string(), smart_value("deepseek-chat"));
207            }
208        }
209
210        let output = toml::to_string_pretty(&doc).unwrap();
211        std::fs::write(&path, &output).unwrap();
212
213        // Verify
214        let loaded = FileConfig::load_from(&path).unwrap().unwrap();
215        assert_eq!(
216            loaded.provider.unwrap().model.as_deref(),
217            Some("deepseek-chat")
218        );
219    }
220}