1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct LlmConfig {
6 pub provider: String,
7 pub model: String,
8 pub api_key_env: String,
9 #[serde(default)]
12 pub base_url: Option<String>,
13}
14
15impl LlmConfig {
16 pub fn resolve_api_key(&self) -> Option<String> {
17 std::env::var(&self.api_key_env).ok()
18 }
19}
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct DashboardConfig {
26 #[serde(default)]
28 pub host: Option<String>,
29 #[serde(default)]
30 pub port: Option<u16>,
31 #[serde(default)]
34 pub token: Option<String>,
35}
36
37impl DashboardConfig {
38 pub fn host_or_default(&self) -> &str {
39 self.host.as_deref().unwrap_or("127.0.0.1")
40 }
41
42 pub fn port_or_default(&self) -> u16 {
43 self.port.unwrap_or(7777)
44 }
45
46 pub fn is_loopback(&self) -> bool {
47 let host = self.host_or_default();
50 host == "localhost"
51 || host
52 .parse::<std::net::IpAddr>()
53 .is_ok_and(|ip| ip.is_loopback())
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Config {
59 pub database_path: PathBuf,
60 #[serde(default)]
61 pub llm: Option<LlmConfig>,
62 #[serde(default)]
63 pub dashboard: DashboardConfig,
64}
65
66pub fn research_dir() -> PathBuf {
68 dirs::home_dir()
69 .unwrap_or_else(|| PathBuf::from("."))
70 .join(".research")
71}
72
73pub fn default_db_path() -> PathBuf {
74 research_dir().join("research.db")
75}
76
77pub fn default_config_path() -> PathBuf {
78 research_dir().join("config.toml")
79}
80
81impl Default for Config {
82 fn default() -> Self {
83 Self {
84 database_path: default_db_path(),
85 llm: None,
86 dashboard: DashboardConfig::default(),
87 }
88 }
89}
90
91impl Config {
92 pub fn load(path: &Path) -> anyhow::Result<Self> {
93 if path.exists() {
94 let content = std::fs::read_to_string(path)?;
95 let config: Config = toml::from_str(&content)?;
96 Ok(config)
97 } else {
98 let config = Self::default();
99 config.save(path)?;
100 Ok(config)
101 }
102 }
103
104 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
105 if let Some(parent) = path.parent() {
106 std::fs::create_dir_all(parent)?;
107 }
108 std::fs::write(path, config_template(self))?;
109 Ok(())
110 }
111}
112
113fn toml_str(s: &str) -> String {
116 toml::Value::String(s.to_string()).to_string()
117}
118
119pub fn config_template(cfg: &Config) -> String {
124 let mut out = String::new();
125 out.push_str("# research-agent configuration. Re-run `research init` to edit interactively.\n");
126 out.push_str(&format!(
127 "\ndatabase_path = {}\n",
128 toml_str(&cfg.database_path.to_string_lossy())
129 ));
130
131 out.push_str("\n# LLM used for gap analysis and report generation. Without this section\n");
132 out.push_str("# those commands return placeholder text instead of real analysis.\n");
133 match &cfg.llm {
134 Some(llm) => {
135 out.push_str("[llm]\n");
136 out.push_str("# \"anthropic\" or an OpenAI-compatible provider id\n");
137 out.push_str(&format!("provider = {}\n", toml_str(&llm.provider)));
138 out.push_str(&format!("model = {}\n", toml_str(&llm.model)));
139 out.push_str("# env var holding the API key; the key itself is never stored here\n");
140 out.push_str(&format!("api_key_env = {}\n", toml_str(&llm.api_key_env)));
141 match &llm.base_url {
142 Some(url) => out.push_str(&format!("base_url = {}\n", toml_str(url))),
143 None => out.push_str("# base_url = \"https://api.example.com/v1\" # for OpenAI-compatible endpoints without a known default\n"),
144 }
145 }
146 None => {
147 for line in [
148 "# [llm]",
149 "# provider = \"anthropic\" # \"anthropic\" or an OpenAI-compatible provider id (openai, deepseek, openrouter, ollama, ...)",
150 "# model = \"claude-sonnet-4-6\"",
151 "# api_key_env = \"ANTHROPIC_API_KEY\" # env var holding the API key; the key itself is never stored here",
152 "# base_url = \"https://api.example.com/v1\" # for OpenAI-compatible endpoints without a known default",
153 ] {
154 out.push_str(line);
155 out.push('\n');
156 }
157 }
158 }
159
160 out.push_str("\n# Web dashboard (`research dashboard`). Loopback-only and port 7777 by\n");
161 out.push_str("# default; a non-loopback host requires a token.\n");
162 let d = &cfg.dashboard;
163 if d.host.is_some() || d.port.is_some() || d.token.is_some() {
164 out.push_str("[dashboard]\n");
165 if let Some(host) = &d.host {
166 out.push_str(&format!("host = {}\n", toml_str(host)));
167 }
168 if let Some(port) = d.port {
169 out.push_str(&format!("port = {port}\n"));
170 }
171 if let Some(token) = &d.token {
172 out.push_str(&format!("token = {}\n", toml_str(token)));
173 }
174 } else {
175 for line in [
176 "# [dashboard]",
177 "# host = \"127.0.0.1\" # bind address; use 0.0.0.0 for LAN access (token required)",
178 "# port = 7777",
179 "# token = \"...\" # shared secret; sent as Authorization: Bearer or dashboard_token cookie",
180 ] {
181 out.push_str(line);
182 out.push('\n');
183 }
184 }
185 out
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use tempfile::TempDir;
192
193 #[test]
194 fn loopback_check_parses_the_host_instead_of_prefix_matching() {
195 let host = |h: &str| DashboardConfig {
196 host: Some(h.into()),
197 ..Default::default()
198 };
199 assert!(host("127.0.0.1").is_loopback());
200 assert!(host("127.0.0.5").is_loopback());
201 assert!(host("localhost").is_loopback());
202 assert!(host("::1").is_loopback());
203 assert!(
204 DashboardConfig::default().is_loopback(),
205 "default binds loopback"
206 );
207 assert!(!host("127.0.0.1.evil.com").is_loopback());
210 assert!(!host("0.0.0.0").is_loopback());
211 assert!(!host("192.168.1.10").is_loopback());
212 }
213
214 #[test]
215 fn default_db_is_under_home_research() {
216 let path = default_db_path();
217 assert!(
218 path.ends_with(".research/research.db") || path.to_string_lossy().contains(".research")
219 );
220 }
221
222 #[test]
223 fn roundtrip_save_load() {
224 let dir = TempDir::new().unwrap();
225 let path = dir.path().join("config.toml");
226 let config = Config {
227 database_path: PathBuf::from("/tmp/test.db"),
228 llm: None,
229 dashboard: Default::default(),
230 };
231 config.save(&path).unwrap();
232 let loaded = Config::load(&path).unwrap();
233 assert_eq!(loaded.database_path, config.database_path);
234 }
235
236 #[test]
237 fn load_creates_default_when_missing() {
238 let dir = TempDir::new().unwrap();
239 let path = dir.path().join("missing.toml");
240 let config = Config::load(&path).unwrap();
241 assert!(config.database_path.ends_with("research.db"));
242 assert!(path.exists());
243 }
244
245 #[test]
246 fn llm_config_roundtrip() {
247 let dir = TempDir::new().unwrap();
248 let path = dir.path().join("config.toml");
249 let config = Config {
250 database_path: PathBuf::from("research.db"),
251 llm: Some(LlmConfig {
252 provider: "anthropic".into(),
253 model: "claude-sonnet-4-6".into(),
254 api_key_env: "ANTHROPIC_API_KEY".into(),
255 base_url: None,
256 }),
257 dashboard: Default::default(),
258 };
259 config.save(&path).unwrap();
260 let loaded = Config::load(&path).unwrap();
261 let llm = loaded.llm.unwrap();
262 assert_eq!(llm.provider, "anthropic");
263 assert_eq!(llm.model, "claude-sonnet-4-6");
264 assert_eq!(llm.api_key_env, "ANTHROPIC_API_KEY");
265 }
266
267 #[test]
268 fn config_without_llm_section_loads_ok() {
269 let dir = TempDir::new().unwrap();
270 let path = dir.path().join("config.toml");
271 std::fs::write(&path, "database_path = \"research.db\"\n").unwrap();
272 let config = Config::load(&path).unwrap();
273 assert!(config.llm.is_none());
274 }
275
276 #[test]
277 fn malformed_config_errors_instead_of_falling_back() {
278 let dir = TempDir::new().unwrap();
279 let path = dir.path().join("config.toml");
280 std::fs::write(&path, "database_path = \n [llm\n").unwrap();
281 assert!(Config::load(&path).is_err());
282 }
283
284 #[test]
285 fn default_template_roundtrips_with_optional_keys_commented() {
286 let text = config_template(&Config::default());
287 let parsed: Config = toml::from_str(&text).unwrap();
288 assert_eq!(parsed.database_path, default_db_path());
289 assert!(parsed.llm.is_none());
290 for key in [
293 "provider",
294 "model",
295 "api_key_env",
296 "base_url",
297 "host",
298 "port",
299 "token",
300 ] {
301 assert!(
302 text.contains(&format!("# {key}")),
303 "missing commented key: {key}"
304 );
305 }
306 }
307
308 #[test]
309 fn set_sections_render_live_and_parse_back() {
310 let config = Config {
311 database_path: PathBuf::from("/tmp/r.db"),
312 llm: Some(LlmConfig {
313 provider: "anthropic".into(),
314 model: "claude-sonnet-4-6".into(),
315 api_key_env: "ANTHROPIC_API_KEY".into(),
316 base_url: None,
317 }),
318 dashboard: Default::default(),
319 };
320 let text = config_template(&config);
321 let parsed: Config = toml::from_str(&text).unwrap();
322 let llm = parsed.llm.unwrap();
323 assert_eq!(llm.provider, "anthropic");
324 assert_eq!(llm.api_key_env, "ANTHROPIC_API_KEY");
325 assert!(text.contains("provider = \"anthropic\""));
327 }
328
329 #[test]
330 fn legacy_search_section_is_ignored_not_fatal() {
331 let text = r#"
335database_path = "/tmp/legacy.db"
336
337[search]
338provider = "local"
339model = "BGESmallENV15"
340openai_api_key_env = "OPENAI_API_KEY"
341embed_batch_size = 16
342embed_memory_budget_mb = 1024
343"#;
344 let parsed: Config = toml::from_str(text).unwrap();
345 assert_eq!(parsed.database_path, PathBuf::from("/tmp/legacy.db"));
346 }
347
348 #[test]
349 fn template_quotes_paths_safely() {
350 let config = Config {
351 database_path: PathBuf::from("/home/u/space dir/r.db"),
352 ..Config::default()
353 };
354 let text = config_template(&config);
355 let parsed: Config = toml::from_str(&text).unwrap();
356 assert_eq!(
357 parsed.database_path,
358 PathBuf::from("/home/u/space dir/r.db")
359 );
360 }
361}