Skip to main content

recon_cli/
config.rs

1use anyhow::{anyhow, Context, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4
5#[derive(Deserialize, Default)]
6pub struct ReconConfig {
7    pub netstatus: Option<NetstatusConfig>,
8    pub editor: Option<EditorConfig>,
9    pub ai: Option<AiConfig>,
10    #[serde(default)]
11    pub sampledata: HashMap<String, SampleDataConfig>,
12}
13
14#[derive(Deserialize, Default, Debug, Clone)]
15pub struct AiConfig {
16    #[serde(default)]
17    pub default_backend: Option<String>,
18    #[serde(default)]
19    pub default_model: Option<String>,
20    #[serde(default)]
21    pub timeout_secs: Option<u64>,
22    #[serde(default)]
23    pub backends: HashMap<String, AiBackendConfig>,
24}
25
26#[derive(Deserialize, Default, Debug, Clone)]
27pub struct AiBackendConfig {
28    /// argv for user-defined backends. Empty for built-in backends
29    /// (`claude`, `codex`, `gemini`) where the entry only carries
30    /// `model` and `system` overrides.
31    #[serde(default)]
32    pub cmd: Vec<String>,
33    #[serde(default)]
34    pub model: Option<String>,
35    #[serde(default)]
36    pub model_flag: Option<String>,
37    #[serde(default)]
38    pub system_flag: Option<String>,
39}
40
41#[derive(Deserialize, Default, Debug)]
42pub struct EditorConfig {
43    #[serde(default)]
44    pub default: Option<String>,
45    #[serde(default)]
46    pub aliases: HashMap<String, String>,
47}
48
49#[derive(Deserialize, Default, Debug, Clone)]
50pub struct SampleDataConfig {
51    #[serde(default)]
52    pub mode: Option<String>,
53    #[serde(default)]
54    pub default_format: Option<String>,
55    #[serde(default)]
56    pub count: Option<u32>,
57    #[serde(default)]
58    pub description: Option<String>,
59    #[serde(default)]
60    pub urls: HashMap<String, String>,
61    #[serde(default)]
62    pub headers: Vec<String>,
63    #[serde(default)]
64    pub basic_auth: Option<String>,
65}
66
67#[derive(Deserialize, Default)]
68pub struct NetstatusConfig {
69    #[serde(default)]
70    pub ip_sources: Vec<String>,
71    #[serde(default)]
72    pub dns_lookup_domains: Vec<String>,
73    #[serde(default)]
74    pub probes: Vec<String>,
75    #[serde(default)]
76    pub dns_hijack_checks: Vec<DnsHijackCheck>,
77}
78
79#[derive(Deserialize, Clone)]
80pub struct DnsHijackCheck {
81    pub server: String,
82    pub domain: String,
83    pub expected: String,
84}
85
86impl NetstatusConfig {
87    /// Returns an error if the config is internally inconsistent.
88    pub fn validate(&self) -> Result<()> {
89        let has_dns_probe = self.probes.iter().any(|p| p.starts_with("dns://"));
90        if has_dns_probe && self.dns_lookup_domains.is_empty() {
91            return Err(anyhow!(
92                "dns:// probes require at least one entry in dns_lookup_domains"
93            ));
94        }
95        Ok(())
96    }
97}
98
99/// Loads ~/.recon/config.toml. Returns an error if the file is missing or invalid.
100pub fn load() -> Result<ReconConfig> {
101    let path = config_path();
102    let text = std::fs::read_to_string(&path).with_context(|| {
103        format!(
104            "Cannot read config file: {}\n\
105             Create it with a [netstatus] section — see: recon --help netstatus",
106            path.display()
107        )
108    })?;
109    let config: ReconConfig =
110        toml::from_str(&text).map_err(|e| anyhow!("Failed to parse config file: {}", e))?;
111    Ok(config)
112}
113
114fn config_path() -> std::path::PathBuf {
115    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
116    std::path::PathBuf::from(home).join(".recon").join("config.toml")
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_parse_valid_config() {
125        let toml_str = r#"
126[netstatus]
127ip_sources = ["https://api.ipify.org", "https://ifconfig.me/ip"]
128dns_lookup_domains = ["example.com"]
129probes = ["https://www.google.com", "ping://8.8.8.8"]
130"#;
131        let config: ReconConfig = toml::from_str(toml_str).unwrap();
132        let ns = config.netstatus.unwrap();
133        assert_eq!(ns.ip_sources.len(), 2);
134        assert_eq!(ns.dns_lookup_domains, vec!["example.com"]);
135        assert_eq!(ns.probes.len(), 2);
136        assert!(ns.dns_hijack_checks.is_empty());
137    }
138
139    #[test]
140    fn test_parse_dns_hijack_checks() {
141        let toml_str = r#"
142[netstatus]
143ip_sources = []
144dns_lookup_domains = ["example.com"]
145probes = []
146
147[[netstatus.dns_hijack_checks]]
148server = "8.8.8.8"
149domain = "example.com"
150expected = "93.184.216.34"
151
152[[netstatus.dns_hijack_checks]]
153server = "1.1.1.1"
154domain = "example.com"
155expected = "93.184.216.34"
156"#;
157        let config: ReconConfig = toml::from_str(toml_str).unwrap();
158        let ns = config.netstatus.unwrap();
159        assert_eq!(ns.dns_hijack_checks.len(), 2);
160        assert_eq!(ns.dns_hijack_checks[0].server, "8.8.8.8");
161        assert_eq!(ns.dns_hijack_checks[1].server, "1.1.1.1");
162    }
163
164    #[test]
165    fn test_validate_dns_probe_requires_lookup_domains() {
166        let config = NetstatusConfig {
167            dns_lookup_domains: vec![],
168            probes: vec!["dns://8.8.8.8".to_string()],
169            ..Default::default()
170        };
171        assert!(config.validate().is_err());
172    }
173
174    #[test]
175    fn test_validate_passes_when_no_dns_probes() {
176        let config = NetstatusConfig {
177            dns_lookup_domains: vec![],
178            probes: vec!["https://www.google.com".to_string()],
179            ..Default::default()
180        };
181        assert!(config.validate().is_ok());
182    }
183
184    #[test]
185    fn test_parse_editor_config() {
186        let toml_str = r#"
187[editor]
188default = "zed"
189
190[editor.aliases]
191mycode = "code --new-window"
192altzed = "zed --dev"
193"#;
194        let config: ReconConfig = toml::from_str(toml_str).unwrap();
195        let editor = config.editor.expect("editor section should parse");
196        assert_eq!(editor.default.as_deref(), Some("zed"));
197        assert_eq!(
198            editor.aliases.get("mycode").map(String::as_str),
199            Some("code --new-window"),
200        );
201        assert_eq!(
202            editor.aliases.get("altzed").map(String::as_str),
203            Some("zed --dev"),
204        );
205    }
206
207    #[test]
208    fn test_editor_config_all_optional() {
209        let toml_str = r#"
210[editor]
211"#;
212        let config: ReconConfig = toml::from_str(toml_str).unwrap();
213        let editor = config.editor.expect("editor section should parse");
214        assert!(editor.default.is_none());
215        assert!(editor.aliases.is_empty());
216    }
217
218    #[test]
219    fn test_editor_section_missing_is_none() {
220        let toml_str = r#"
221[netstatus]
222ip_sources = []
223dns_lookup_domains = []
224probes = []
225"#;
226        let config: ReconConfig = toml::from_str(toml_str).unwrap();
227        assert!(config.editor.is_none());
228    }
229
230    #[test]
231    fn test_parse_sampledata_full_entry() {
232        let toml_str = r#"
233[sampledata.customer]
234mode = "bulk"
235default_format = "json"
236count = 25
237description = "Customer profiles"
238urls.json = "https://api.example.com/users?limit={{count}}"
239urls.csv  = "https://api.example.com/users.csv?n={{count}}"
240headers = ["Authorization: Bearer xxx", "X-Tenant: acme"]
241basic_auth = "alice:secret"
242"#;
243        let config: ReconConfig = toml::from_str(toml_str).unwrap();
244        let s = config.sampledata.get("customer").expect("present");
245        assert_eq!(s.mode.as_deref(), Some("bulk"));
246        assert_eq!(s.default_format.as_deref(), Some("json"));
247        assert_eq!(s.count, Some(25));
248        assert_eq!(s.description.as_deref(), Some("Customer profiles"));
249        assert_eq!(s.urls.len(), 2);
250        assert_eq!(
251            s.urls.get("json").map(String::as_str),
252            Some("https://api.example.com/users?limit={{count}}"),
253        );
254        assert_eq!(s.headers.len(), 2);
255        assert_eq!(s.basic_auth.as_deref(), Some("alice:secret"));
256    }
257
258    #[test]
259    fn test_parse_sampledata_minimal_entry() {
260        let toml_str = r#"
261[sampledata.foo]
262default_format = "json"
263urls.json = "https://example.com/foo"
264"#;
265        let config: ReconConfig = toml::from_str(toml_str).unwrap();
266        let s = config.sampledata.get("foo").expect("present");
267        assert!(s.mode.is_none());
268        assert_eq!(s.default_format.as_deref(), Some("json"));
269        assert!(s.count.is_none());
270        assert!(s.headers.is_empty());
271        assert!(s.basic_auth.is_none());
272    }
273
274    #[test]
275    fn test_sampledata_missing_is_empty_map() {
276        let toml_str = r#"
277[netstatus]
278ip_sources = []
279dns_lookup_domains = []
280probes = []
281"#;
282        let config: ReconConfig = toml::from_str(toml_str).unwrap();
283        assert!(config.sampledata.is_empty());
284    }
285
286    #[test]
287    fn test_parse_ai_config_full() {
288        let toml_str = r#"
289[ai]
290default_backend = "claude"
291default_model = "sonnet"
292timeout_secs = 90
293
294[ai.backends.claude]
295model = "claude-sonnet-4-5"
296
297[ai.backends.my-llm]
298cmd = ["my-llm-cli", "--print"]
299model_flag = "--model"
300system_flag = "--system"
301"#;
302        let config: ReconConfig = toml::from_str(toml_str).unwrap();
303        let ai = config.ai.expect("ai section should parse");
304        assert_eq!(ai.default_backend.as_deref(), Some("claude"));
305        assert_eq!(ai.default_model.as_deref(), Some("sonnet"));
306        assert_eq!(ai.timeout_secs, Some(90));
307
308        let claude = ai.backends.get("claude").expect("claude backend");
309        assert_eq!(claude.model.as_deref(), Some("claude-sonnet-4-5"));
310        assert!(claude.cmd.is_empty());
311
312        let custom = ai.backends.get("my-llm").expect("my-llm backend");
313        assert_eq!(custom.cmd, vec!["my-llm-cli", "--print"]);
314        assert_eq!(custom.model_flag.as_deref(), Some("--model"));
315        assert_eq!(custom.system_flag.as_deref(), Some("--system"));
316    }
317
318    #[test]
319    fn test_parse_ai_config_all_optional() {
320        let toml_str = r#"
321[ai]
322"#;
323        let config: ReconConfig = toml::from_str(toml_str).unwrap();
324        let ai = config.ai.expect("ai section");
325        assert!(ai.default_backend.is_none());
326        assert!(ai.default_model.is_none());
327        assert!(ai.timeout_secs.is_none());
328        assert!(ai.backends.is_empty());
329    }
330
331    #[test]
332    fn test_ai_section_missing_is_none() {
333        let toml_str = r#"
334[netstatus]
335ip_sources = []
336dns_lookup_domains = []
337probes = []
338"#;
339        let config: ReconConfig = toml::from_str(toml_str).unwrap();
340        assert!(config.ai.is_none());
341    }
342}