Skip to main content

recon_cli/
config.rs

1use anyhow::{anyhow, 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 the layered config (`/etc/recon/config.toml` + `~/.recon/config.toml`).
100/// Both layers are optional; an empty config is the default. Returns an error
101/// only when a file that exists fails to read or parse.
102pub fn load() -> Result<ReconConfig> {
103    let opts = crate::config_resolver::global();
104    let value = crate::config_resolver::load_layered("config.toml", &opts)
105        .map_err(|e| anyhow!("{e}"))?;
106    let config: ReconConfig = value
107        .try_into()
108        .map_err(|e| anyhow!("Failed to parse merged config: {e}"))?;
109    Ok(config)
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_parse_valid_config() {
118        let toml_str = r#"
119[netstatus]
120ip_sources = ["https://api.ipify.org", "https://ifconfig.me/ip"]
121dns_lookup_domains = ["example.com"]
122probes = ["https://www.google.com", "ping://8.8.8.8"]
123"#;
124        let config: ReconConfig = toml::from_str(toml_str).unwrap();
125        let ns = config.netstatus.unwrap();
126        assert_eq!(ns.ip_sources.len(), 2);
127        assert_eq!(ns.dns_lookup_domains, vec!["example.com"]);
128        assert_eq!(ns.probes.len(), 2);
129        assert!(ns.dns_hijack_checks.is_empty());
130    }
131
132    #[test]
133    fn test_parse_dns_hijack_checks() {
134        let toml_str = r#"
135[netstatus]
136ip_sources = []
137dns_lookup_domains = ["example.com"]
138probes = []
139
140[[netstatus.dns_hijack_checks]]
141server = "8.8.8.8"
142domain = "example.com"
143expected = "93.184.216.34"
144
145[[netstatus.dns_hijack_checks]]
146server = "1.1.1.1"
147domain = "example.com"
148expected = "93.184.216.34"
149"#;
150        let config: ReconConfig = toml::from_str(toml_str).unwrap();
151        let ns = config.netstatus.unwrap();
152        assert_eq!(ns.dns_hijack_checks.len(), 2);
153        assert_eq!(ns.dns_hijack_checks[0].server, "8.8.8.8");
154        assert_eq!(ns.dns_hijack_checks[1].server, "1.1.1.1");
155    }
156
157    #[test]
158    fn test_validate_dns_probe_requires_lookup_domains() {
159        let config = NetstatusConfig {
160            dns_lookup_domains: vec![],
161            probes: vec!["dns://8.8.8.8".to_string()],
162            ..Default::default()
163        };
164        assert!(config.validate().is_err());
165    }
166
167    #[test]
168    fn test_validate_passes_when_no_dns_probes() {
169        let config = NetstatusConfig {
170            dns_lookup_domains: vec![],
171            probes: vec!["https://www.google.com".to_string()],
172            ..Default::default()
173        };
174        assert!(config.validate().is_ok());
175    }
176
177    #[test]
178    fn test_parse_editor_config() {
179        let toml_str = r#"
180[editor]
181default = "zed"
182
183[editor.aliases]
184mycode = "code --new-window"
185altzed = "zed --dev"
186"#;
187        let config: ReconConfig = toml::from_str(toml_str).unwrap();
188        let editor = config.editor.expect("editor section should parse");
189        assert_eq!(editor.default.as_deref(), Some("zed"));
190        assert_eq!(
191            editor.aliases.get("mycode").map(String::as_str),
192            Some("code --new-window"),
193        );
194        assert_eq!(
195            editor.aliases.get("altzed").map(String::as_str),
196            Some("zed --dev"),
197        );
198    }
199
200    #[test]
201    fn test_editor_config_all_optional() {
202        let toml_str = r#"
203[editor]
204"#;
205        let config: ReconConfig = toml::from_str(toml_str).unwrap();
206        let editor = config.editor.expect("editor section should parse");
207        assert!(editor.default.is_none());
208        assert!(editor.aliases.is_empty());
209    }
210
211    #[test]
212    fn test_editor_section_missing_is_none() {
213        let toml_str = r#"
214[netstatus]
215ip_sources = []
216dns_lookup_domains = []
217probes = []
218"#;
219        let config: ReconConfig = toml::from_str(toml_str).unwrap();
220        assert!(config.editor.is_none());
221    }
222
223    #[test]
224    fn test_parse_sampledata_full_entry() {
225        let toml_str = r#"
226[sampledata.customer]
227mode = "bulk"
228default_format = "json"
229count = 25
230description = "Customer profiles"
231urls.json = "https://api.example.com/users?limit={{count}}"
232urls.csv  = "https://api.example.com/users.csv?n={{count}}"
233headers = ["Authorization: Bearer xxx", "X-Tenant: acme"]
234basic_auth = "alice:secret"
235"#;
236        let config: ReconConfig = toml::from_str(toml_str).unwrap();
237        let s = config.sampledata.get("customer").expect("present");
238        assert_eq!(s.mode.as_deref(), Some("bulk"));
239        assert_eq!(s.default_format.as_deref(), Some("json"));
240        assert_eq!(s.count, Some(25));
241        assert_eq!(s.description.as_deref(), Some("Customer profiles"));
242        assert_eq!(s.urls.len(), 2);
243        assert_eq!(
244            s.urls.get("json").map(String::as_str),
245            Some("https://api.example.com/users?limit={{count}}"),
246        );
247        assert_eq!(s.headers.len(), 2);
248        assert_eq!(s.basic_auth.as_deref(), Some("alice:secret"));
249    }
250
251    #[test]
252    fn test_parse_sampledata_minimal_entry() {
253        let toml_str = r#"
254[sampledata.foo]
255default_format = "json"
256urls.json = "https://example.com/foo"
257"#;
258        let config: ReconConfig = toml::from_str(toml_str).unwrap();
259        let s = config.sampledata.get("foo").expect("present");
260        assert!(s.mode.is_none());
261        assert_eq!(s.default_format.as_deref(), Some("json"));
262        assert!(s.count.is_none());
263        assert!(s.headers.is_empty());
264        assert!(s.basic_auth.is_none());
265    }
266
267    #[test]
268    fn test_sampledata_missing_is_empty_map() {
269        let toml_str = r#"
270[netstatus]
271ip_sources = []
272dns_lookup_domains = []
273probes = []
274"#;
275        let config: ReconConfig = toml::from_str(toml_str).unwrap();
276        assert!(config.sampledata.is_empty());
277    }
278
279    #[test]
280    fn test_parse_ai_config_full() {
281        let toml_str = r#"
282[ai]
283default_backend = "claude"
284default_model = "sonnet"
285timeout_secs = 90
286
287[ai.backends.claude]
288model = "claude-sonnet-4-5"
289
290[ai.backends.my-llm]
291cmd = ["my-llm-cli", "--print"]
292model_flag = "--model"
293system_flag = "--system"
294"#;
295        let config: ReconConfig = toml::from_str(toml_str).unwrap();
296        let ai = config.ai.expect("ai section should parse");
297        assert_eq!(ai.default_backend.as_deref(), Some("claude"));
298        assert_eq!(ai.default_model.as_deref(), Some("sonnet"));
299        assert_eq!(ai.timeout_secs, Some(90));
300
301        let claude = ai.backends.get("claude").expect("claude backend");
302        assert_eq!(claude.model.as_deref(), Some("claude-sonnet-4-5"));
303        assert!(claude.cmd.is_empty());
304
305        let custom = ai.backends.get("my-llm").expect("my-llm backend");
306        assert_eq!(custom.cmd, vec!["my-llm-cli", "--print"]);
307        assert_eq!(custom.model_flag.as_deref(), Some("--model"));
308        assert_eq!(custom.system_flag.as_deref(), Some("--system"));
309    }
310
311    #[test]
312    fn test_parse_ai_config_all_optional() {
313        let toml_str = r#"
314[ai]
315"#;
316        let config: ReconConfig = toml::from_str(toml_str).unwrap();
317        let ai = config.ai.expect("ai section");
318        assert!(ai.default_backend.is_none());
319        assert!(ai.default_model.is_none());
320        assert!(ai.timeout_secs.is_none());
321        assert!(ai.backends.is_empty());
322    }
323
324    #[test]
325    fn test_ai_section_missing_is_none() {
326        let toml_str = r#"
327[netstatus]
328ip_sources = []
329dns_lookup_domains = []
330probes = []
331"#;
332        let config: ReconConfig = toml::from_str(toml_str).unwrap();
333        assert!(config.ai.is_none());
334    }
335}