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}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(default)]
60pub struct BulkConfig {
61 pub concurrency: usize,
63 pub rate_limit_ms: u64,
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68#[serde(default)]
69pub struct WatchConfig {
70 pub webhook_url: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(default)]
78pub struct TuiConfig {
79 pub theme: String,
83}
84
85impl Default for TuiConfig {
86 fn default() -> Self {
87 Self {
88 theme: "frappe".to_string(),
89 }
90 }
91}
92
93impl Default for SeerConfig {
94 fn default() -> Self {
95 Self {
96 output_format: "human".to_string(),
97 nameserver: None,
98 timeouts: TimeoutConfig::default(),
99 bulk: BulkConfig::default(),
100 watch: WatchConfig::default(),
101 tui: TuiConfig::default(),
102 }
103 }
104}
105
106impl Default for TimeoutConfig {
107 fn default() -> Self {
108 Self {
109 whois_secs: 15,
110 rdap_secs: 15,
113 dns_secs: 5,
114 http_secs: 10,
115 ct_secs: 30,
116 }
117 }
118}
119
120impl Default for BulkConfig {
121 fn default() -> Self {
122 Self {
123 concurrency: 10,
124 rate_limit_ms: 100,
125 }
126 }
127}
128
129impl SeerConfig {
130 pub fn config_path() -> Option<PathBuf> {
132 std::env::home_dir().map(|home| home.join(".seer").join("config.toml"))
133 }
134
135 pub fn load() -> Self {
137 let Some(path) = Self::config_path() else {
138 return Self::default();
139 };
140
141 if !path.exists() {
142 return Self::default();
143 }
144
145 match std::fs::read_to_string(&path) {
146 Ok(content) => Self::parse_or_default(&content, &path),
147 Err(e) => {
148 debug!(?path, error = %e, "Could not read config, using defaults");
149 Self::default()
150 }
151 }
152 }
153
154 fn parse_or_default(content: &str, path: &std::path::Path) -> Self {
159 match toml::from_str::<SeerConfig>(content) {
160 Ok(config) => {
161 debug!(?path, "Loaded config");
162 config.clamped()
163 }
164 Err(e) => {
165 tracing::warn!(?path, error = %e, "Failed to parse config, using defaults");
166 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
171 WARN_ONCE.call_once(|| {
172 eprintln!(
173 "Warning: ignoring malformed config file {} (using defaults):\n{}",
174 path.display(),
175 e
176 );
177 });
178 Self::default()
179 }
180 }
181 }
182
183 fn clamped(mut self) -> Self {
192 self.bulk.concurrency = self.bulk.concurrency.clamp(1, 50);
193 self.bulk.rate_limit_ms = self.bulk.rate_limit_ms.min(60_000);
196 self.timeouts.whois_secs = self.timeouts.whois_secs.clamp(1, 300);
197 self.timeouts.rdap_secs = self.timeouts.rdap_secs.clamp(1, 300);
198 self.timeouts.dns_secs = self.timeouts.dns_secs.clamp(1, 60);
199 self.timeouts.http_secs = self.timeouts.http_secs.clamp(1, 120);
200 self.timeouts.ct_secs = self.timeouts.ct_secs.clamp(1, MAX_CT_SECS);
201 self
202 }
203
204 pub fn whois_timeout(&self) -> Duration {
206 Duration::from_secs(self.timeouts.whois_secs)
207 }
208
209 pub fn rdap_timeout(&self) -> Duration {
211 Duration::from_secs(self.timeouts.rdap_secs)
212 }
213
214 pub fn dns_timeout(&self) -> Duration {
216 Duration::from_secs(self.timeouts.dns_secs)
217 }
218
219 pub fn http_timeout(&self) -> Duration {
221 Duration::from_secs(self.timeouts.http_secs)
222 }
223
224 pub fn ct_timeout(&self) -> Duration {
226 Duration::from_secs(self.timeouts.ct_secs)
227 }
228
229 pub fn bulk_rate_limit(&self) -> Duration {
231 Duration::from_millis(self.bulk.rate_limit_ms)
232 }
233
234 pub fn tui_theme(&self) -> &'static str {
241 match self.tui.theme.trim().to_lowercase().as_str() {
242 "latte" => "latte",
243 _ => "frappe",
244 }
245 }
246
247 pub fn default_toml() -> String {
249 toml::to_string_pretty(&Self::default()).unwrap_or_else(|_| String::new())
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn test_default_config() {
259 let config = SeerConfig::default();
260 assert_eq!(config.output_format, "human");
261 assert_eq!(config.timeouts.whois_secs, 15);
262 assert_eq!(config.timeouts.rdap_secs, 15);
263 assert_eq!(config.timeouts.dns_secs, 5);
264 assert_eq!(config.bulk.concurrency, 10);
265 }
266
267 #[test]
268 fn test_parse_config_toml() {
269 let toml_str = r#"
270output_format = "json"
271nameserver = "1.1.1.1"
272
273[timeouts]
274whois_secs = 20
275rdap_secs = 45
276
277[bulk]
278concurrency = 20
279"#;
280 let config: SeerConfig = toml::from_str(toml_str).unwrap();
281 assert_eq!(config.output_format, "json");
282 assert_eq!(config.nameserver, Some("1.1.1.1".to_string()));
283 assert_eq!(config.timeouts.whois_secs, 20);
284 assert_eq!(config.timeouts.rdap_secs, 45);
285 assert_eq!(config.timeouts.dns_secs, 5); assert_eq!(config.bulk.concurrency, 20);
287 }
288
289 #[test]
290 fn nameserver_accepts_dot_doh_specs_opaquely() {
291 let config: SeerConfig = toml::from_str(r#"nameserver = "tls://1.1.1.1""#).unwrap();
295 assert_eq!(config.nameserver.as_deref(), Some("tls://1.1.1.1"));
296 }
297
298 #[test]
299 fn test_default_toml_roundtrip() {
300 let toml_str = SeerConfig::default_toml();
301 let config: SeerConfig = toml::from_str(&toml_str).unwrap();
302 assert_eq!(config.output_format, "human");
303 }
304
305 #[test]
306 fn test_timeout_durations() {
307 let config = SeerConfig::default();
308 assert_eq!(config.whois_timeout(), Duration::from_secs(15));
309 assert_eq!(config.rdap_timeout(), Duration::from_secs(15));
310 assert_eq!(config.dns_timeout(), Duration::from_secs(5));
311 assert_eq!(config.http_timeout(), Duration::from_secs(10));
312 assert_eq!(config.ct_timeout(), Duration::from_secs(30));
313 }
314
315 #[test]
316 fn ct_timeout_parses_and_is_clamped() {
317 let path = std::path::Path::new("~/.seer/config.toml");
318 let config = SeerConfig::parse_or_default("[timeouts]\nct_secs = 45\n", path);
319 assert_eq!(config.ct_timeout(), Duration::from_secs(45));
320 let config = SeerConfig::parse_or_default("[timeouts]\nct_secs = 0\n", path);
321 assert_eq!(config.ct_timeout(), Duration::from_secs(1));
322 let config = SeerConfig::parse_or_default("[timeouts]\nct_secs = 9999\n", path);
323 assert_eq!(config.ct_timeout(), Duration::from_secs(MAX_CT_SECS));
324 }
325
326 #[test]
327 fn malformed_toml_falls_back_to_defaults() {
328 let path = std::path::Path::new("~/.seer/config.toml");
329 for content in ["output_format = [not toml", "output_format = 42"] {
331 let config = SeerConfig::parse_or_default(content, path);
332 assert_eq!(config.output_format, "human");
333 assert_eq!(config.bulk.concurrency, 10);
334 }
335 }
336
337 #[test]
338 fn unknown_keys_are_tolerated() {
339 let path = std::path::Path::new("~/.seer/config.toml");
342 let config =
343 SeerConfig::parse_or_default("output_format = \"json\"\nfuture_option = true\n", path);
344 assert_eq!(config.output_format, "json");
345 }
346
347 #[test]
348 fn valid_content_is_clamped_through_parse_seam() {
349 let path = std::path::Path::new("~/.seer/config.toml");
351 let config = SeerConfig::parse_or_default("[bulk]\nconcurrency = 10000\n", path);
352 assert_eq!(config.bulk.concurrency, 50);
353 }
354
355 #[test]
356 fn watch_webhook_url_parses_and_defaults_to_none() {
357 let config: SeerConfig =
358 toml::from_str("[watch]\nwebhook_url = \"https://hooks.example.com/seer\"\n").unwrap();
359 assert_eq!(
360 config.watch.webhook_url.as_deref(),
361 Some("https://hooks.example.com/seer")
362 );
363
364 let config = SeerConfig::default();
365 assert_eq!(config.watch.webhook_url, None);
366 }
367
368 #[test]
369 fn tui_theme_parses_and_defaults_to_frappe() {
370 let config: SeerConfig = toml::from_str("[tui]\ntheme = \"latte\"\n").unwrap();
371 assert_eq!(config.tui.theme, "latte");
372 assert_eq!(config.tui_theme(), "latte");
373
374 let config = SeerConfig::default();
375 assert_eq!(config.tui.theme, "frappe");
376 assert_eq!(config.tui_theme(), "frappe");
377 }
378
379 #[test]
380 fn tui_theme_accessor_is_case_insensitive_and_trims() {
381 for raw in ["Latte", "LATTE", " latte "] {
382 let mut config = SeerConfig::default();
383 config.tui.theme = raw.to_string();
384 assert_eq!(config.tui_theme(), "latte", "input: {raw:?}");
385 }
386 for raw in ["Frappe", "frappé", "FRAPPÉ"] {
387 let mut config = SeerConfig::default();
388 config.tui.theme = raw.to_string();
389 assert_eq!(config.tui_theme(), "frappe", "input: {raw:?}");
390 }
391 }
392
393 #[test]
394 fn tui_theme_unknown_name_falls_back_to_frappe() {
395 let config: SeerConfig = toml::from_str("[tui]\ntheme = \"nord\"\n").unwrap();
398 assert_eq!(config.tui.theme, "nord");
399 assert_eq!(config.tui_theme(), "frappe");
400 }
401
402 #[test]
403 fn new_sections_survive_default_toml_roundtrip() {
404 let toml_str = SeerConfig::default_toml();
407 let config: SeerConfig = toml::from_str(&toml_str).unwrap();
408 assert_eq!(config.tui.theme, "frappe");
409 assert_eq!(config.watch.webhook_url, None);
410 }
411
412 #[test]
413 fn clamp_bounds_rate_limit_ms() {
414 let mut config = SeerConfig::default();
417 config.bulk.rate_limit_ms = 10_000_000;
418 let config = config.clamped();
419 assert!(config.bulk.rate_limit_ms <= 60_000);
420 }
421}