Skip to main content

research_agent/
config.rs

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    /// OpenAI-compatible base URL (e.g. https://api.openai.com/v1). Optional —
10    /// providers with a known default can leave it unset.
11    #[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/// `[dashboard]` section: how the web dashboard is served. Binding to a
22/// non-loopback host requires a token — without one every request would be
23/// readable (and writable) by the whole network.
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct DashboardConfig {
26    /// Bind address: "127.0.0.1" (default) or "0.0.0.0" for LAN access.
27    #[serde(default)]
28    pub host: Option<String>,
29    #[serde(default)]
30    pub port: Option<u16>,
31    /// Shared secret. Requests must present it via `Authorization: Bearer`
32    /// or the `dashboard_token` cookie.
33    #[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        // Parse, don't prefix-match: "127.0.0.1.evil.com" is a DNS name, not
48        // the loopback address, and must not be trusted as one.
49        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
66/// Returns `~/.research` on all platforms (Windows: `C:\Users\<user>\.research`).
67pub 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
113/// Quote a string as a TOML value (keeps user paths with quotes/backslashes
114/// valid).
115fn toml_str(s: &str) -> String {
116    toml::Value::String(s.to_string()).to_string()
117}
118
119/// Render the config as a fully documented template: `database_path` is
120/// always live, and every key of the optional `[llm]` section
121/// appears — set values as live TOML, unset ones commented out with their
122/// defaults — so the file alone shows everything that is configurable.
123pub 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        // A DNS name that merely starts with the loopback literal is not
208        // loopback; treating it as one would waive the token requirement.
209        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        // The template exists to document every configurable key, so unset
291        // sections must still be visible as comments.
292        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        // Values that are set render live, not as comments.
326        assert!(text.contains("provider = \"anthropic\""));
327    }
328
329    #[test]
330    fn legacy_search_section_is_ignored_not_fatal() {
331        // Configs written before embeddings were removed still carry a
332        // [search] section. Parsing must skip it rather than fail — otherwise
333        // every existing install breaks on upgrade.
334        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}