Skip to main content

seer_core/
config.rs

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