Skip to main content

seer_core/
config.rs

1//! Configuration file support for Seer.
2//!
3//! Loads settings from `~/.seer/config.toml` (a missing or malformed file
4//! falls back to built-in defaults; values are clamped to safe ranges).
5
6use std::path::PathBuf;
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10use tracing::debug;
11
12/// Seer configuration loaded from `~/.seer/config.toml`.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(default)]
15pub struct SeerConfig {
16    /// Default output format ("human", "json", "yaml")
17    pub output_format: String,
18    /// Default DNS nameserver spec. Accepts a bare IP/hostname with an
19    /// optional port (UDP, e.g. `"8.8.8.8"`, `"dns.google"`,
20    /// `"[2001:4860:4860::8888]:53"`), `tls://host[:port]` for DNS over TLS
21    /// (default port 853, e.g. `"tls://1.1.1.1"`), or
22    /// `https://host[:port][/path]` for DNS over HTTPS (default port 443,
23    /// default path `/dns-query`, e.g. `"https://cloudflare-dns.com/dns-query"`).
24    /// Parsed by [`crate::dns::NameserverSpec`]; invalid specs surface as an
25    /// error on first use rather than at load time.
26    pub nameserver: Option<String>,
27    /// Timeout settings
28    pub timeouts: TimeoutConfig,
29    /// Bulk operation settings
30    pub bulk: BulkConfig,
31    /// Watchlist settings (`seer watch`)
32    pub watch: WatchConfig,
33    /// TUI settings (`seer tui`)
34    pub tui: TuiConfig,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(default)]
39pub struct TimeoutConfig {
40    /// WHOIS query timeout in seconds
41    pub whois_secs: u64,
42    /// RDAP query timeout in seconds
43    pub rdap_secs: u64,
44    /// DNS query timeout in seconds
45    pub dns_secs: u64,
46    /// HTTP/SSL check timeout in seconds
47    pub http_secs: u64,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(default)]
52pub struct BulkConfig {
53    /// Default concurrency for bulk operations
54    pub concurrency: usize,
55    /// Rate limit delay in milliseconds between operations
56    pub rate_limit_ms: u64,
57}
58
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60#[serde(default)]
61pub struct WatchConfig {
62    /// Webhook URL the `seer watch` check-all report is POSTed to as JSON.
63    /// `None` (the default) disables delivery; the CLI `--webhook` flag
64    /// overrides this value.
65    pub webhook_url: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(default)]
70pub struct TuiConfig {
71    /// TUI color theme name. Known names: "frappe" (default) and "latte";
72    /// read through [`SeerConfig::tui_theme`], which falls back to "frappe"
73    /// on anything unrecognized so config parsing stays tolerant.
74    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            // Matches the RDAP client's own DEFAULT_TIMEOUT (15s) and the
103            // documented value; the client uses 15s, so the default must too.
104            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    /// Returns the path to the config file (`~/.seer/config.toml`).
122    pub fn config_path() -> Option<PathBuf> {
123        dirs::home_dir().map(|home| home.join(".seer").join("config.toml"))
124    }
125
126    /// Loads config from `~/.seer/config.toml`, falling back to defaults if not found.
127    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    /// Parses config file content, falling back to defaults on malformed
146    /// TOML. The failure is reported on stderr in addition to `tracing` —
147    /// at the default log level a `warn!` is invisible, and `seer config`
148    /// would then display defaults as though they came from the file.
149    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                // Config loads several times per process (startup + per-command
158                // and per-client construction); warn on stderr only once — the
159                // CLI always loads first at startup, so the warning also lands
160                // before the TUI enters its alternate screen.
161                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    /// Clamp loaded values into sane ranges. Without this, a user
175    /// (accidentally or maliciously) setting `bulk.concurrency = 0` would
176    /// hand `Semaphore::new(0)` to every bulk operation and block forever;
177    /// `bulk.concurrency = 10000` would spawn thousands of concurrent
178    /// connections. Timeouts of `0` would error every network call
179    /// immediately. The bounds are per-protocol (a config file can't bypass
180    /// them): concurrency 1–50; whois/rdap timeouts 1–300s; dns 1–60s;
181    /// http 1–120s.
182    fn clamped(mut self) -> Self {
183        self.bulk.concurrency = self.bulk.concurrency.clamp(1, 50);
184        // A multi-day per-operation delay would stall bulk runs indefinitely;
185        // cap at 60s (0 is valid — no inter-operation delay).
186        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    /// Returns the WHOIS timeout as a Duration.
195    pub fn whois_timeout(&self) -> Duration {
196        Duration::from_secs(self.timeouts.whois_secs)
197    }
198
199    /// Returns the RDAP timeout as a Duration.
200    pub fn rdap_timeout(&self) -> Duration {
201        Duration::from_secs(self.timeouts.rdap_secs)
202    }
203
204    /// Returns the DNS timeout as a Duration.
205    pub fn dns_timeout(&self) -> Duration {
206        Duration::from_secs(self.timeouts.dns_secs)
207    }
208
209    /// Returns the HTTP timeout as a Duration.
210    pub fn http_timeout(&self) -> Duration {
211        Duration::from_secs(self.timeouts.http_secs)
212    }
213
214    /// Returns the inter-operation bulk rate-limit delay as a Duration.
215    pub fn bulk_rate_limit(&self) -> Duration {
216        Duration::from_millis(self.bulk.rate_limit_ms)
217    }
218
219    /// Returns the configured TUI theme, clamped to a known canonical name.
220    ///
221    /// Accepts "frappe" / "frappé" / "latte" (case-insensitive, trimmed);
222    /// anything else falls back to "frappe" so a typo'd config degrades to
223    /// the default look instead of erroring. Keep the accepted set in sync
224    /// with the TUI's `Theme::from_name` (seer-cli/src/tui/theme.rs).
225    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    /// Generates a default config file content as TOML.
233    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); // default
271        assert_eq!(config.bulk.concurrency, 20);
272    }
273
274    #[test]
275    fn nameserver_accepts_dot_doh_specs_opaquely() {
276        // The nameserver key is an opaque spec string: DoT/DoH forms load
277        // unchanged and are validated by NameserverSpec::parse on first use,
278        // not at config-load time.
279        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        // Both a syntax error and a type error must yield defaults, not a panic.
303        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        // Forward-compat: a config written by a newer seer (or with a typo'd
313        // extra key) must not hard-fail — no deny_unknown_fields.
314        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        // parse_or_default must apply the same clamping load() always did.
323        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        // Config parsing stays tolerant: an unknown theme loads unchanged but
369        // the accessor clamps it to the default instead of erroring.
370        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        // default_toml must serialize the new [watch]/[tui] tables without
378        // erroring (webhook_url = None is omitted by the toml serializer).
379        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        // An unbounded rate-limit delay (once wired into the bulk executor)
388        // would stall every operation for the configured duration; clamp it.
389        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}