1use crate::error::{Error, Result};
7use serde::Deserialize;
8use std::path::{Path, PathBuf};
9
10pub fn config_file_path() -> Option<PathBuf> {
13 dirs::home_dir().map(|h| h.join(".recursive").join("config.toml"))
14}
15
16#[derive(Debug, Default, Deserialize)]
18pub struct FileConfig {
19 pub provider: Option<ProviderSection>,
20 pub agent: Option<AgentSection>,
21}
22
23#[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#[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 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 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
66pub fn set_value(key: &str, value: &str) -> Result<()> {
70 let path = config_file_path().ok_or_else(|| Error::Config {
71 message: "cannot determine home directory".into(),
72 })?;
73
74 if let Some(parent) = path.parent() {
76 std::fs::create_dir_all(parent).map_err(Error::Io)?;
77 }
78
79 let content = if path.exists() {
81 std::fs::read_to_string(&path).map_err(Error::Io)?
82 } else {
83 String::new()
84 };
85
86 let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
87
88 let parts: Vec<&str> = key.splitn(2, '.').collect();
90 match parts.as_slice() {
91 [section, field] => {
92 let table = doc
93 .entry(*section)
94 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
95 if let toml::Value::Table(t) = table {
96 t.insert(field.to_string(), smart_value(value));
97 }
98 }
99 [field] => {
100 doc.insert(field.to_string(), smart_value(value));
101 }
102 _ => {
103 return Err(Error::Config {
104 message: format!("invalid key format: {key}"),
105 })
106 }
107 }
108
109 let toml_str = toml::to_string_pretty(&doc).map_err(|e| Error::Config {
110 message: format!("failed to serialize config: {}", e),
111 })?;
112 std::fs::write(&path, toml_str).map_err(Error::Io)?;
113 Ok(())
114}
115
116fn smart_value(s: &str) -> toml::Value {
118 if let Ok(i) = s.parse::<i64>() {
119 toml::Value::Integer(i)
120 } else if let Ok(f) = s.parse::<f64>() {
121 toml::Value::Float(f)
122 } else if s == "true" || s == "false" {
123 toml::Value::Boolean(s == "true")
124 } else {
125 toml::Value::String(s.to_string())
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn load_returns_none_when_missing() {
135 let result = FileConfig::load_from(Path::new("/nonexistent/path.toml")).unwrap();
136 assert!(result.is_none());
137 }
138
139 #[test]
140 fn load_parses_valid_toml() {
141 let tmp = tempfile::NamedTempFile::new().unwrap();
142 std::fs::write(
143 tmp.path(),
144 r#"
145[provider]
146type = "openai"
147api_key = "sk-test"
148api_base = "https://api.deepseek.com"
149model = "deepseek-chat"
150
151[agent]
152max_steps = 64
153temperature = 0.5
154"#,
155 )
156 .unwrap();
157
158 let config = FileConfig::load_from(tmp.path()).unwrap();
159 assert!(config.is_some());
160 let c = config.unwrap();
161 let p = c.provider.unwrap();
162 assert_eq!(p.provider_type.as_deref(), Some("openai"));
163 assert_eq!(p.api_key.as_deref(), Some("sk-test"));
164 assert_eq!(p.api_base.as_deref(), Some("https://api.deepseek.com"));
165 assert_eq!(p.model.as_deref(), Some("deepseek-chat"));
166 let a = c.agent.unwrap();
167 assert_eq!(a.max_steps, Some(64));
168 assert_eq!(a.temperature, Some(0.5));
169 }
170
171 #[test]
172 fn load_errors_on_malformed() {
173 let tmp = tempfile::NamedTempFile::new().unwrap();
174 std::fs::write(tmp.path(), "this is [[[not valid toml").unwrap();
175 let result = FileConfig::load_from(tmp.path());
176 assert!(result.is_err());
177 }
178
179 #[test]
180 fn smart_value_parses_types() {
181 assert_eq!(smart_value("42"), toml::Value::Integer(42));
182 assert_eq!(smart_value("0.5"), toml::Value::Float(0.5));
183 assert_eq!(smart_value("true"), toml::Value::Boolean(true));
184 assert_eq!(smart_value("hello"), toml::Value::String("hello".into()));
185 }
186
187 #[test]
188 fn set_value_creates_file_and_writes() {
189 let tmp = tempfile::tempdir().unwrap();
190 let path = tmp.path().join("config.toml");
191
192 std::fs::create_dir_all(tmp.path()).unwrap();
194 let content = String::new();
196 let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
197
198 let parts: Vec<&str> = "provider.model".splitn(2, '.').collect();
199 if let [section, field] = parts.as_slice() {
200 let table = doc
201 .entry(*section)
202 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
203 if let toml::Value::Table(t) = table {
204 t.insert(field.to_string(), smart_value("deepseek-chat"));
205 }
206 }
207
208 let output = toml::to_string_pretty(&doc).unwrap();
209 std::fs::write(&path, &output).unwrap();
210
211 let loaded = FileConfig::load_from(&path).unwrap().unwrap();
213 assert_eq!(
214 loaded.provider.unwrap().model.as_deref(),
215 Some("deepseek-chat")
216 );
217 }
218}