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 #[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 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
99pub 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}