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 (e.g., "8.8.8.8")
18    pub nameserver: Option<String>,
19    /// Timeout settings
20    pub timeouts: TimeoutConfig,
21    /// Bulk operation settings
22    pub bulk: BulkConfig,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(default)]
27pub struct TimeoutConfig {
28    /// WHOIS query timeout in seconds
29    pub whois_secs: u64,
30    /// RDAP query timeout in seconds
31    pub rdap_secs: u64,
32    /// DNS query timeout in seconds
33    pub dns_secs: u64,
34    /// HTTP/SSL check timeout in seconds
35    pub http_secs: u64,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(default)]
40pub struct BulkConfig {
41    /// Default concurrency for bulk operations
42    pub concurrency: usize,
43    /// Rate limit delay in milliseconds between operations
44    pub rate_limit_ms: u64,
45}
46
47impl Default for SeerConfig {
48    fn default() -> Self {
49        Self {
50            output_format: "human".to_string(),
51            nameserver: None,
52            timeouts: TimeoutConfig::default(),
53            bulk: BulkConfig::default(),
54        }
55    }
56}
57
58impl Default for TimeoutConfig {
59    fn default() -> Self {
60        Self {
61            whois_secs: 15,
62            // Matches the RDAP client's own DEFAULT_TIMEOUT (15s) and the
63            // documented value; the client uses 15s, so the default must too.
64            rdap_secs: 15,
65            dns_secs: 5,
66            http_secs: 10,
67        }
68    }
69}
70
71impl Default for BulkConfig {
72    fn default() -> Self {
73        Self {
74            concurrency: 10,
75            rate_limit_ms: 100,
76        }
77    }
78}
79
80impl SeerConfig {
81    /// Returns the path to the config file (`~/.seer/config.toml`).
82    pub fn config_path() -> Option<PathBuf> {
83        dirs::home_dir().map(|home| home.join(".seer").join("config.toml"))
84    }
85
86    /// Loads config from `~/.seer/config.toml`, falling back to defaults if not found.
87    pub fn load() -> Self {
88        let Some(path) = Self::config_path() else {
89            return Self::default();
90        };
91
92        if !path.exists() {
93            return Self::default();
94        }
95
96        match std::fs::read_to_string(&path) {
97            Ok(content) => match toml::from_str::<SeerConfig>(&content) {
98                Ok(config) => {
99                    debug!(?path, "Loaded config");
100                    config.clamped()
101                }
102                Err(e) => {
103                    tracing::warn!(?path, error = %e, "Failed to parse config, using defaults");
104                    Self::default()
105                }
106            },
107            Err(e) => {
108                debug!(?path, error = %e, "Could not read config, using defaults");
109                Self::default()
110            }
111        }
112    }
113
114    /// Clamp loaded values into sane ranges. Without this, a user
115    /// (accidentally or maliciously) setting `bulk.concurrency = 0` would
116    /// hand `Semaphore::new(0)` to every bulk operation and block forever;
117    /// `bulk.concurrency = 10000` would spawn thousands of concurrent
118    /// connections. Timeouts of `0` would error every network call
119    /// immediately. The bounds are per-protocol (a config file can't bypass
120    /// them): concurrency 1–50; whois/rdap timeouts 1–300s; dns 1–60s;
121    /// http 1–120s.
122    fn clamped(mut self) -> Self {
123        self.bulk.concurrency = self.bulk.concurrency.clamp(1, 50);
124        // A multi-day per-operation delay would stall bulk runs indefinitely;
125        // cap at 60s (0 is valid — no inter-operation delay).
126        self.bulk.rate_limit_ms = self.bulk.rate_limit_ms.min(60_000);
127        self.timeouts.whois_secs = self.timeouts.whois_secs.clamp(1, 300);
128        self.timeouts.rdap_secs = self.timeouts.rdap_secs.clamp(1, 300);
129        self.timeouts.dns_secs = self.timeouts.dns_secs.clamp(1, 60);
130        self.timeouts.http_secs = self.timeouts.http_secs.clamp(1, 120);
131        self
132    }
133
134    /// Returns the WHOIS timeout as a Duration.
135    pub fn whois_timeout(&self) -> Duration {
136        Duration::from_secs(self.timeouts.whois_secs)
137    }
138
139    /// Returns the RDAP timeout as a Duration.
140    pub fn rdap_timeout(&self) -> Duration {
141        Duration::from_secs(self.timeouts.rdap_secs)
142    }
143
144    /// Returns the DNS timeout as a Duration.
145    pub fn dns_timeout(&self) -> Duration {
146        Duration::from_secs(self.timeouts.dns_secs)
147    }
148
149    /// Returns the HTTP timeout as a Duration.
150    pub fn http_timeout(&self) -> Duration {
151        Duration::from_secs(self.timeouts.http_secs)
152    }
153
154    /// Returns the inter-operation bulk rate-limit delay as a Duration.
155    pub fn bulk_rate_limit(&self) -> Duration {
156        Duration::from_millis(self.bulk.rate_limit_ms)
157    }
158
159    /// Generates a default config file content as TOML.
160    pub fn default_toml() -> String {
161        toml::to_string_pretty(&Self::default()).unwrap_or_else(|_| String::new())
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_default_config() {
171        let config = SeerConfig::default();
172        assert_eq!(config.output_format, "human");
173        assert_eq!(config.timeouts.whois_secs, 15);
174        assert_eq!(config.timeouts.rdap_secs, 15);
175        assert_eq!(config.timeouts.dns_secs, 5);
176        assert_eq!(config.bulk.concurrency, 10);
177    }
178
179    #[test]
180    fn test_parse_config_toml() {
181        let toml_str = r#"
182output_format = "json"
183nameserver = "1.1.1.1"
184
185[timeouts]
186whois_secs = 20
187rdap_secs = 45
188
189[bulk]
190concurrency = 20
191"#;
192        let config: SeerConfig = toml::from_str(toml_str).unwrap();
193        assert_eq!(config.output_format, "json");
194        assert_eq!(config.nameserver, Some("1.1.1.1".to_string()));
195        assert_eq!(config.timeouts.whois_secs, 20);
196        assert_eq!(config.timeouts.rdap_secs, 45);
197        assert_eq!(config.timeouts.dns_secs, 5); // default
198        assert_eq!(config.bulk.concurrency, 20);
199    }
200
201    #[test]
202    fn test_default_toml_roundtrip() {
203        let toml_str = SeerConfig::default_toml();
204        let config: SeerConfig = toml::from_str(&toml_str).unwrap();
205        assert_eq!(config.output_format, "human");
206    }
207
208    #[test]
209    fn test_timeout_durations() {
210        let config = SeerConfig::default();
211        assert_eq!(config.whois_timeout(), Duration::from_secs(15));
212        assert_eq!(config.rdap_timeout(), Duration::from_secs(15));
213        assert_eq!(config.dns_timeout(), Duration::from_secs(5));
214        assert_eq!(config.http_timeout(), Duration::from_secs(10));
215    }
216
217    #[test]
218    fn clamp_bounds_rate_limit_ms() {
219        // An unbounded rate-limit delay (once wired into the bulk executor)
220        // would stall every operation for the configured duration; clamp it.
221        let mut config = SeerConfig::default();
222        config.bulk.rate_limit_ms = 10_000_000;
223        let config = config.clamped();
224        assert!(config.bulk.rate_limit_ms <= 60_000);
225    }
226}