1use serde::Deserialize;
7use std::path::{Path, PathBuf};
8use crate::error::{Error, Result};
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()
71 .ok_or_else(|| Error::Config {
72 message: "cannot determine home directory".into(),
73 })?;
74
75 if let Some(parent) = path.parent() {
77 std::fs::create_dir_all(parent).map_err(Error::Io)?;
78 }
79
80 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 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
115fn 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 std::fs::create_dir_all(tmp.path()).unwrap();
196 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 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}