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