1use std::path::PathBuf;
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use tracing::debug;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default)]
14pub struct SeerConfig {
15 pub output_format: String,
17 pub nameserver: Option<String>,
26 pub timeouts: TimeoutConfig,
28 pub bulk: BulkConfig,
30 pub watch: WatchConfig,
32 pub tui: TuiConfig,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(default)]
38pub struct TimeoutConfig {
39 pub whois_secs: u64,
41 pub rdap_secs: u64,
43 pub dns_secs: u64,
45 pub http_secs: u64,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(default)]
51pub struct BulkConfig {
52 pub concurrency: usize,
54 pub rate_limit_ms: u64,
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59#[serde(default)]
60pub struct WatchConfig {
61 pub webhook_url: Option<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(default)]
69pub struct TuiConfig {
70 pub theme: String,
74}
75
76impl Default for TuiConfig {
77 fn default() -> Self {
78 Self {
79 theme: "frappe".to_string(),
80 }
81 }
82}
83
84impl Default for SeerConfig {
85 fn default() -> Self {
86 Self {
87 output_format: "human".to_string(),
88 nameserver: None,
89 timeouts: TimeoutConfig::default(),
90 bulk: BulkConfig::default(),
91 watch: WatchConfig::default(),
92 tui: TuiConfig::default(),
93 }
94 }
95}
96
97impl Default for TimeoutConfig {
98 fn default() -> Self {
99 Self {
100 whois_secs: 15,
101 rdap_secs: 15,
104 dns_secs: 5,
105 http_secs: 10,
106 }
107 }
108}
109
110impl Default for BulkConfig {
111 fn default() -> Self {
112 Self {
113 concurrency: 10,
114 rate_limit_ms: 100,
115 }
116 }
117}
118
119impl SeerConfig {
120 pub fn config_path() -> Option<PathBuf> {
122 dirs::home_dir().map(|home| home.join(".seer").join("config.toml"))
123 }
124
125 pub fn load() -> Self {
127 let Some(path) = Self::config_path() else {
128 return Self::default();
129 };
130
131 if !path.exists() {
132 return Self::default();
133 }
134
135 match std::fs::read_to_string(&path) {
136 Ok(content) => Self::parse_or_default(&content, &path),
137 Err(e) => {
138 debug!(?path, error = %e, "Could not read config, using defaults");
139 Self::default()
140 }
141 }
142 }
143
144 fn parse_or_default(content: &str, path: &std::path::Path) -> Self {
149 match toml::from_str::<SeerConfig>(content) {
150 Ok(config) => {
151 debug!(?path, "Loaded config");
152 config.clamped()
153 }
154 Err(e) => {
155 tracing::warn!(?path, error = %e, "Failed to parse config, using defaults");
156 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
161 WARN_ONCE.call_once(|| {
162 eprintln!(
163 "Warning: ignoring malformed config file {} (using defaults):\n{}",
164 path.display(),
165 e
166 );
167 });
168 Self::default()
169 }
170 }
171 }
172
173 fn clamped(mut self) -> Self {
182 self.bulk.concurrency = self.bulk.concurrency.clamp(1, 50);
183 self.bulk.rate_limit_ms = self.bulk.rate_limit_ms.min(60_000);
186 self.timeouts.whois_secs = self.timeouts.whois_secs.clamp(1, 300);
187 self.timeouts.rdap_secs = self.timeouts.rdap_secs.clamp(1, 300);
188 self.timeouts.dns_secs = self.timeouts.dns_secs.clamp(1, 60);
189 self.timeouts.http_secs = self.timeouts.http_secs.clamp(1, 120);
190 self
191 }
192
193 pub fn whois_timeout(&self) -> Duration {
195 Duration::from_secs(self.timeouts.whois_secs)
196 }
197
198 pub fn rdap_timeout(&self) -> Duration {
200 Duration::from_secs(self.timeouts.rdap_secs)
201 }
202
203 pub fn dns_timeout(&self) -> Duration {
205 Duration::from_secs(self.timeouts.dns_secs)
206 }
207
208 pub fn http_timeout(&self) -> Duration {
210 Duration::from_secs(self.timeouts.http_secs)
211 }
212
213 pub fn bulk_rate_limit(&self) -> Duration {
215 Duration::from_millis(self.bulk.rate_limit_ms)
216 }
217
218 pub fn tui_theme(&self) -> &'static str {
225 match self.tui.theme.trim().to_lowercase().as_str() {
226 "latte" => "latte",
227 _ => "frappe",
228 }
229 }
230
231 pub fn default_toml() -> String {
233 toml::to_string_pretty(&Self::default()).unwrap_or_else(|_| String::new())
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn test_default_config() {
243 let config = SeerConfig::default();
244 assert_eq!(config.output_format, "human");
245 assert_eq!(config.timeouts.whois_secs, 15);
246 assert_eq!(config.timeouts.rdap_secs, 15);
247 assert_eq!(config.timeouts.dns_secs, 5);
248 assert_eq!(config.bulk.concurrency, 10);
249 }
250
251 #[test]
252 fn test_parse_config_toml() {
253 let toml_str = r#"
254output_format = "json"
255nameserver = "1.1.1.1"
256
257[timeouts]
258whois_secs = 20
259rdap_secs = 45
260
261[bulk]
262concurrency = 20
263"#;
264 let config: SeerConfig = toml::from_str(toml_str).unwrap();
265 assert_eq!(config.output_format, "json");
266 assert_eq!(config.nameserver, Some("1.1.1.1".to_string()));
267 assert_eq!(config.timeouts.whois_secs, 20);
268 assert_eq!(config.timeouts.rdap_secs, 45);
269 assert_eq!(config.timeouts.dns_secs, 5); assert_eq!(config.bulk.concurrency, 20);
271 }
272
273 #[test]
274 fn nameserver_accepts_dot_doh_specs_opaquely() {
275 let config: SeerConfig = toml::from_str(r#"nameserver = "tls://1.1.1.1""#).unwrap();
279 assert_eq!(config.nameserver.as_deref(), Some("tls://1.1.1.1"));
280 }
281
282 #[test]
283 fn test_default_toml_roundtrip() {
284 let toml_str = SeerConfig::default_toml();
285 let config: SeerConfig = toml::from_str(&toml_str).unwrap();
286 assert_eq!(config.output_format, "human");
287 }
288
289 #[test]
290 fn test_timeout_durations() {
291 let config = SeerConfig::default();
292 assert_eq!(config.whois_timeout(), Duration::from_secs(15));
293 assert_eq!(config.rdap_timeout(), Duration::from_secs(15));
294 assert_eq!(config.dns_timeout(), Duration::from_secs(5));
295 assert_eq!(config.http_timeout(), Duration::from_secs(10));
296 }
297
298 #[test]
299 fn malformed_toml_falls_back_to_defaults() {
300 let path = std::path::Path::new("~/.seer/config.toml");
301 for content in ["output_format = [not toml", "output_format = 42"] {
303 let config = SeerConfig::parse_or_default(content, path);
304 assert_eq!(config.output_format, "human");
305 assert_eq!(config.bulk.concurrency, 10);
306 }
307 }
308
309 #[test]
310 fn unknown_keys_are_tolerated() {
311 let path = std::path::Path::new("~/.seer/config.toml");
314 let config =
315 SeerConfig::parse_or_default("output_format = \"json\"\nfuture_option = true\n", path);
316 assert_eq!(config.output_format, "json");
317 }
318
319 #[test]
320 fn valid_content_is_clamped_through_parse_seam() {
321 let path = std::path::Path::new("~/.seer/config.toml");
323 let config = SeerConfig::parse_or_default("[bulk]\nconcurrency = 10000\n", path);
324 assert_eq!(config.bulk.concurrency, 50);
325 }
326
327 #[test]
328 fn watch_webhook_url_parses_and_defaults_to_none() {
329 let config: SeerConfig =
330 toml::from_str("[watch]\nwebhook_url = \"https://hooks.example.com/seer\"\n").unwrap();
331 assert_eq!(
332 config.watch.webhook_url.as_deref(),
333 Some("https://hooks.example.com/seer")
334 );
335
336 let config = SeerConfig::default();
337 assert_eq!(config.watch.webhook_url, None);
338 }
339
340 #[test]
341 fn tui_theme_parses_and_defaults_to_frappe() {
342 let config: SeerConfig = toml::from_str("[tui]\ntheme = \"latte\"\n").unwrap();
343 assert_eq!(config.tui.theme, "latte");
344 assert_eq!(config.tui_theme(), "latte");
345
346 let config = SeerConfig::default();
347 assert_eq!(config.tui.theme, "frappe");
348 assert_eq!(config.tui_theme(), "frappe");
349 }
350
351 #[test]
352 fn tui_theme_accessor_is_case_insensitive_and_trims() {
353 for raw in ["Latte", "LATTE", " latte "] {
354 let mut config = SeerConfig::default();
355 config.tui.theme = raw.to_string();
356 assert_eq!(config.tui_theme(), "latte", "input: {raw:?}");
357 }
358 for raw in ["Frappe", "frappé", "FRAPPÉ"] {
359 let mut config = SeerConfig::default();
360 config.tui.theme = raw.to_string();
361 assert_eq!(config.tui_theme(), "frappe", "input: {raw:?}");
362 }
363 }
364
365 #[test]
366 fn tui_theme_unknown_name_falls_back_to_frappe() {
367 let config: SeerConfig = toml::from_str("[tui]\ntheme = \"nord\"\n").unwrap();
370 assert_eq!(config.tui.theme, "nord");
371 assert_eq!(config.tui_theme(), "frappe");
372 }
373
374 #[test]
375 fn new_sections_survive_default_toml_roundtrip() {
376 let toml_str = SeerConfig::default_toml();
379 let config: SeerConfig = toml::from_str(&toml_str).unwrap();
380 assert_eq!(config.tui.theme, "frappe");
381 assert_eq!(config.watch.webhook_url, None);
382 }
383
384 #[test]
385 fn clamp_bounds_rate_limit_ms() {
386 let mut config = SeerConfig::default();
389 config.bulk.rate_limit_ms = 10_000_000;
390 let config = config.clamped();
391 assert!(config.bulk.rate_limit_ms <= 60_000);
392 }
393}