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 #[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> {
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}