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            rdap_secs: 30,
63            dns_secs: 5,
64            http_secs: 10,
65        }
66    }
67}
68
69impl Default for BulkConfig {
70    fn default() -> Self {
71        Self {
72            concurrency: 10,
73            rate_limit_ms: 100,
74        }
75    }
76}
77
78impl SeerConfig {
79    /// Returns the path to the config file (`~/.seer/config.toml`).
80    pub fn config_path() -> Option<PathBuf> {
81        dirs::home_dir().map(|home| home.join(".seer").join("config.toml"))
82    }
83
84    /// Loads config from `~/.seer/config.toml`, falling back to defaults if not found.
85    pub fn load() -> Self {
86        let Some(path) = Self::config_path() else {
87            return Self::default();
88        };
89
90        if !path.exists() {
91            return Self::default();
92        }
93
94        match std::fs::read_to_string(&path) {
95            Ok(content) => match toml::from_str::<SeerConfig>(&content) {
96                Ok(config) => {
97                    debug!(?path, "Loaded config");
98                    config.clamped()
99                }
100                Err(e) => {
101                    tracing::warn!(?path, error = %e, "Failed to parse config, using defaults");
102                    Self::default()
103                }
104            },
105            Err(e) => {
106                debug!(?path, error = %e, "Could not read config, using defaults");
107                Self::default()
108            }
109        }
110    }
111
112    /// Clamp loaded values into sane ranges. Without this, a user
113    /// (accidentally or maliciously) setting `bulk.concurrency = 0` would
114    /// hand `Semaphore::new(0)` to every bulk operation and block forever;
115    /// `bulk.concurrency = 10000` would spawn thousands of concurrent
116    /// connections. Timeouts of `0` would error every network call
117    /// immediately. Apply the same bounds the public API enforces so a
118    /// config file can't bypass them.
119    fn clamped(mut self) -> Self {
120        self.bulk.concurrency = self.bulk.concurrency.clamp(1, 50);
121        self.timeouts.whois_secs = self.timeouts.whois_secs.clamp(1, 300);
122        self.timeouts.rdap_secs = self.timeouts.rdap_secs.clamp(1, 300);
123        self.timeouts.dns_secs = self.timeouts.dns_secs.clamp(1, 60);
124        self.timeouts.http_secs = self.timeouts.http_secs.clamp(1, 120);
125        self
126    }
127
128    /// Returns the WHOIS timeout as a Duration.
129    pub fn whois_timeout(&self) -> Duration {
130        Duration::from_secs(self.timeouts.whois_secs)
131    }
132
133    /// Returns the RDAP timeout as a Duration.
134    pub fn rdap_timeout(&self) -> Duration {
135        Duration::from_secs(self.timeouts.rdap_secs)
136    }
137
138    /// Returns the DNS timeout as a Duration.
139    pub fn dns_timeout(&self) -> Duration {
140        Duration::from_secs(self.timeouts.dns_secs)
141    }
142
143    /// Returns the HTTP timeout as a Duration.
144    pub fn http_timeout(&self) -> Duration {
145        Duration::from_secs(self.timeouts.http_secs)
146    }
147
148    /// Generates a default config file content as TOML.
149    pub fn default_toml() -> String {
150        toml::to_string_pretty(&Self::default()).unwrap_or_else(|_| String::new())
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_default_config() {
160        let config = SeerConfig::default();
161        assert_eq!(config.output_format, "human");
162        assert_eq!(config.timeouts.whois_secs, 15);
163        assert_eq!(config.timeouts.rdap_secs, 30);
164        assert_eq!(config.timeouts.dns_secs, 5);
165        assert_eq!(config.bulk.concurrency, 10);
166    }
167
168    #[test]
169    fn test_parse_config_toml() {
170        let toml_str = r#"
171output_format = "json"
172nameserver = "1.1.1.1"
173
174[timeouts]
175whois_secs = 20
176rdap_secs = 45
177
178[bulk]
179concurrency = 20
180"#;
181        let config: SeerConfig = toml::from_str(toml_str).unwrap();
182        assert_eq!(config.output_format, "json");
183        assert_eq!(config.nameserver, Some("1.1.1.1".to_string()));
184        assert_eq!(config.timeouts.whois_secs, 20);
185        assert_eq!(config.timeouts.rdap_secs, 45);
186        assert_eq!(config.timeouts.dns_secs, 5); // default
187        assert_eq!(config.bulk.concurrency, 20);
188    }
189
190    #[test]
191    fn test_default_toml_roundtrip() {
192        let toml_str = SeerConfig::default_toml();
193        let config: SeerConfig = toml::from_str(&toml_str).unwrap();
194        assert_eq!(config.output_format, "human");
195    }
196
197    #[test]
198    fn test_timeout_durations() {
199        let config = SeerConfig::default();
200        assert_eq!(config.whois_timeout(), Duration::from_secs(15));
201        assert_eq!(config.rdap_timeout(), Duration::from_secs(30));
202        assert_eq!(config.dns_timeout(), Duration::from_secs(5));
203        assert_eq!(config.http_timeout(), Duration::from_secs(10));
204    }
205}