1use std::path::PathBuf;
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10use tracing::debug;
11
12pub(crate) const MAX_CT_SECS: u64 = 120;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(default)]
19pub struct SeerConfig {
20 pub output_format: String,
22 pub nameserver: Option<String>,
31 pub timeouts: TimeoutConfig,
33 pub bulk: BulkConfig,
35 pub watch: WatchConfig,
37 pub tui: TuiConfig,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(default)]
43pub struct TimeoutConfig {
44 pub whois_secs: u64,
46 pub rdap_secs: u64,
48 pub dns_secs: u64,
50 pub http_secs: u64,
52 pub ct_secs: u64,
56 pub audit_secs: u64,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct BulkConfig {
64 pub concurrency: usize,
66 pub rate_limit_ms: u64,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71#[serde(default)]
72pub struct WatchConfig {
73 pub webhook_url: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(default)]
81pub struct TuiConfig {
82 pub theme: String,
86}
87
88impl Default for TuiConfig {
89 fn default() -> Self {
90 Self {
91 theme: "frappe".to_string(),
92 }
93 }
94}
95
96impl Default for SeerConfig {
97 fn default() -> Self {
98 Self {
99 output_format: "human".to_string(),
100 nameserver: None,
101 timeouts: TimeoutConfig::default(),
102 bulk: BulkConfig::default(),
103 watch: WatchConfig::default(),
104 tui: TuiConfig::default(),
105 }
106 }
107}
108
109impl Default for TimeoutConfig {
110 fn default() -> Self {
111 Self {
112 whois_secs: 15,
113 rdap_secs: 15,
116 dns_secs: 5,
117 http_secs: 10,
118 ct_secs: 30,
119 audit_secs: 60,
120 }
121 }
122}
123
124impl Default for BulkConfig {
125 fn default() -> Self {
126 Self {
127 concurrency: 10,
128 rate_limit_ms: 100,
129 }
130 }
131}
132
133impl SeerConfig {
134 pub fn config_path() -> Option<PathBuf> {
136 std::env::home_dir().map(|home| home.join(".seer").join("config.toml"))
137 }
138
139 pub fn load() -> Self {
141 let Some(path) = Self::config_path() else {
142 return Self::default();
143 };
144
145 if !path.exists() {
146 return Self::default();
147 }
148
149 match std::fs::read_to_string(&path) {
150 Ok(content) => Self::parse_or_default(&content, &path),
151 Err(e) => {
152 debug!(?path, error = %e, "Could not read config, using defaults");
153 Self::default()
154 }
155 }
156 }
157
158 fn parse_or_default(content: &str, path: &std::path::Path) -> Self {
163 match toml::from_str::<SeerConfig>(content) {
164 Ok(config) => {
165 debug!(?path, "Loaded config");
166 config.clamped()
167 }
168 Err(e) => {
169 tracing::warn!(?path, error = %e, "Failed to parse config, using defaults");
170 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
175 WARN_ONCE.call_once(|| {
176 eprintln!(
177 "Warning: ignoring malformed config file {} (using defaults):\n{}",
178 path.display(),
179 e
180 );
181 });
182 Self::default()
183 }
184 }
185 }
186
187 fn clamped(mut self) -> Self {
196 self.bulk.concurrency = self.bulk.concurrency.clamp(1, 50);
197 self.bulk.rate_limit_ms = self.bulk.rate_limit_ms.min(60_000);
200 self.timeouts.whois_secs = self.timeouts.whois_secs.clamp(1, 300);
201 self.timeouts.rdap_secs = self.timeouts.rdap_secs.clamp(1, 300);
202 self.timeouts.dns_secs = self.timeouts.dns_secs.clamp(1, 60);
203 self.timeouts.http_secs = self.timeouts.http_secs.clamp(1, 120);
204 self.timeouts.ct_secs = self.timeouts.ct_secs.clamp(1, MAX_CT_SECS);
205 self.timeouts.audit_secs = self.timeouts.audit_secs.clamp(10, 300);
206 self
207 }
208
209 pub fn whois_timeout(&self) -> Duration {
211 Duration::from_secs(self.timeouts.whois_secs)
212 }
213
214 pub fn rdap_timeout(&self) -> Duration {
216 Duration::from_secs(self.timeouts.rdap_secs)
217 }
218
219 pub fn dns_timeout(&self) -> Duration {
221 Duration::from_secs(self.timeouts.dns_secs)
222 }
223
224 pub fn http_timeout(&self) -> Duration {
226 Duration::from_secs(self.timeouts.http_secs)
227 }
228
229 pub fn ct_timeout(&self) -> Duration {
231 Duration::from_secs(self.timeouts.ct_secs)
232 }
233
234 pub fn audit_timeout(&self) -> Duration {
236 Duration::from_secs(self.timeouts.audit_secs)
237 }
238
239 pub fn bulk_rate_limit(&self) -> Duration {
241 Duration::from_millis(self.bulk.rate_limit_ms)
242 }
243
244 pub fn tui_theme(&self) -> &'static str {
251 match self.tui.theme.trim().to_lowercase().as_str() {
252 "latte" => "latte",
253 _ => "frappe",
254 }
255 }
256
257 pub fn default_toml() -> String {
259 toml::to_string_pretty(&Self::default()).unwrap_or_else(|_| String::new())
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn test_default_config() {
269 let config = SeerConfig::default();
270 assert_eq!(config.output_format, "human");
271 assert_eq!(config.timeouts.whois_secs, 15);
272 assert_eq!(config.timeouts.rdap_secs, 15);
273 assert_eq!(config.timeouts.dns_secs, 5);
274 assert_eq!(config.bulk.concurrency, 10);
275 }
276
277 #[test]
278 fn test_parse_config_toml() {
279 let toml_str = r#"
280output_format = "json"
281nameserver = "1.1.1.1"
282
283[timeouts]
284whois_secs = 20
285rdap_secs = 45
286
287[bulk]
288concurrency = 20
289"#;
290 let config: SeerConfig = toml::from_str(toml_str).unwrap();
291 assert_eq!(config.output_format, "json");
292 assert_eq!(config.nameserver, Some("1.1.1.1".to_string()));
293 assert_eq!(config.timeouts.whois_secs, 20);
294 assert_eq!(config.timeouts.rdap_secs, 45);
295 assert_eq!(config.timeouts.dns_secs, 5); assert_eq!(config.bulk.concurrency, 20);
297 }
298
299 #[test]
300 fn nameserver_accepts_dot_doh_specs_opaquely() {
301 let config: SeerConfig = toml::from_str(r#"nameserver = "tls://1.1.1.1""#).unwrap();
305 assert_eq!(config.nameserver.as_deref(), Some("tls://1.1.1.1"));
306 }
307
308 #[test]
309 fn test_default_toml_roundtrip() {
310 let toml_str = SeerConfig::default_toml();
311 let config: SeerConfig = toml::from_str(&toml_str).unwrap();
312 assert_eq!(config.output_format, "human");
313 }
314
315 #[test]
316 fn test_timeout_durations() {
317 let config = SeerConfig::default();
318 assert_eq!(config.whois_timeout(), Duration::from_secs(15));
319 assert_eq!(config.rdap_timeout(), Duration::from_secs(15));
320 assert_eq!(config.dns_timeout(), Duration::from_secs(5));
321 assert_eq!(config.http_timeout(), Duration::from_secs(10));
322 assert_eq!(config.ct_timeout(), Duration::from_secs(30));
323 }
324
325 #[test]
326 fn audit_timeout_defaults_and_is_clamped() {
327 let path = std::path::Path::new("~/.seer/config.toml");
328 assert_eq!(
329 SeerConfig::default().audit_timeout(),
330 Duration::from_secs(60)
331 );
332 let config = SeerConfig::parse_or_default("[timeouts]\naudit_secs = 90\n", path);
333 assert_eq!(config.audit_timeout(), Duration::from_secs(90));
334 let config = SeerConfig::parse_or_default("[timeouts]\naudit_secs = 1\n", path);
335 assert_eq!(config.audit_timeout(), Duration::from_secs(10));
336 let config = SeerConfig::parse_or_default("[timeouts]\naudit_secs = 9999\n", path);
337 assert_eq!(config.audit_timeout(), Duration::from_secs(300));
338 }
339
340 #[test]
341 fn ct_timeout_parses_and_is_clamped() {
342 let path = std::path::Path::new("~/.seer/config.toml");
343 let config = SeerConfig::parse_or_default("[timeouts]\nct_secs = 45\n", path);
344 assert_eq!(config.ct_timeout(), Duration::from_secs(45));
345 let config = SeerConfig::parse_or_default("[timeouts]\nct_secs = 0\n", path);
346 assert_eq!(config.ct_timeout(), Duration::from_secs(1));
347 let config = SeerConfig::parse_or_default("[timeouts]\nct_secs = 9999\n", path);
348 assert_eq!(config.ct_timeout(), Duration::from_secs(MAX_CT_SECS));
349 }
350
351 #[test]
352 fn malformed_toml_falls_back_to_defaults() {
353 let path = std::path::Path::new("~/.seer/config.toml");
354 for content in ["output_format = [not toml", "output_format = 42"] {
356 let config = SeerConfig::parse_or_default(content, path);
357 assert_eq!(config.output_format, "human");
358 assert_eq!(config.bulk.concurrency, 10);
359 }
360 }
361
362 #[test]
363 fn unknown_keys_are_tolerated() {
364 let path = std::path::Path::new("~/.seer/config.toml");
367 let config =
368 SeerConfig::parse_or_default("output_format = \"json\"\nfuture_option = true\n", path);
369 assert_eq!(config.output_format, "json");
370 }
371
372 #[test]
373 fn valid_content_is_clamped_through_parse_seam() {
374 let path = std::path::Path::new("~/.seer/config.toml");
376 let config = SeerConfig::parse_or_default("[bulk]\nconcurrency = 10000\n", path);
377 assert_eq!(config.bulk.concurrency, 50);
378 }
379
380 #[test]
381 fn watch_webhook_url_parses_and_defaults_to_none() {
382 let config: SeerConfig =
383 toml::from_str("[watch]\nwebhook_url = \"https://hooks.example.com/seer\"\n").unwrap();
384 assert_eq!(
385 config.watch.webhook_url.as_deref(),
386 Some("https://hooks.example.com/seer")
387 );
388
389 let config = SeerConfig::default();
390 assert_eq!(config.watch.webhook_url, None);
391 }
392
393 #[test]
394 fn tui_theme_parses_and_defaults_to_frappe() {
395 let config: SeerConfig = toml::from_str("[tui]\ntheme = \"latte\"\n").unwrap();
396 assert_eq!(config.tui.theme, "latte");
397 assert_eq!(config.tui_theme(), "latte");
398
399 let config = SeerConfig::default();
400 assert_eq!(config.tui.theme, "frappe");
401 assert_eq!(config.tui_theme(), "frappe");
402 }
403
404 #[test]
405 fn tui_theme_accessor_is_case_insensitive_and_trims() {
406 for raw in ["Latte", "LATTE", " latte "] {
407 let mut config = SeerConfig::default();
408 config.tui.theme = raw.to_string();
409 assert_eq!(config.tui_theme(), "latte", "input: {raw:?}");
410 }
411 for raw in ["Frappe", "frappé", "FRAPPÉ"] {
412 let mut config = SeerConfig::default();
413 config.tui.theme = raw.to_string();
414 assert_eq!(config.tui_theme(), "frappe", "input: {raw:?}");
415 }
416 }
417
418 #[test]
419 fn tui_theme_unknown_name_falls_back_to_frappe() {
420 let config: SeerConfig = toml::from_str("[tui]\ntheme = \"nord\"\n").unwrap();
423 assert_eq!(config.tui.theme, "nord");
424 assert_eq!(config.tui_theme(), "frappe");
425 }
426
427 #[test]
428 fn new_sections_survive_default_toml_roundtrip() {
429 let toml_str = SeerConfig::default_toml();
432 let config: SeerConfig = toml::from_str(&toml_str).unwrap();
433 assert_eq!(config.tui.theme, "frappe");
434 assert_eq!(config.watch.webhook_url, None);
435 }
436
437 #[test]
438 fn clamp_bounds_rate_limit_ms() {
439 let mut config = SeerConfig::default();
442 config.bulk.rate_limit_ms = 10_000_000;
443 let config = config.clamped();
444 assert!(config.bulk.rate_limit_ms <= 60_000);
445 }
446}