1use std::path::PathBuf;
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use tracing::debug;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default)]
14pub struct SeerConfig {
15 pub output_format: String,
17 pub nameserver: Option<String>,
26 pub timeouts: TimeoutConfig,
28 pub bulk: BulkConfig,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(default)]
34pub struct TimeoutConfig {
35 pub whois_secs: u64,
37 pub rdap_secs: u64,
39 pub dns_secs: u64,
41 pub http_secs: u64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(default)]
47pub struct BulkConfig {
48 pub concurrency: usize,
50 pub rate_limit_ms: u64,
52}
53
54impl Default for SeerConfig {
55 fn default() -> Self {
56 Self {
57 output_format: "human".to_string(),
58 nameserver: None,
59 timeouts: TimeoutConfig::default(),
60 bulk: BulkConfig::default(),
61 }
62 }
63}
64
65impl Default for TimeoutConfig {
66 fn default() -> Self {
67 Self {
68 whois_secs: 15,
69 rdap_secs: 15,
72 dns_secs: 5,
73 http_secs: 10,
74 }
75 }
76}
77
78impl Default for BulkConfig {
79 fn default() -> Self {
80 Self {
81 concurrency: 10,
82 rate_limit_ms: 100,
83 }
84 }
85}
86
87impl SeerConfig {
88 pub fn config_path() -> Option<PathBuf> {
90 dirs::home_dir().map(|home| home.join(".seer").join("config.toml"))
91 }
92
93 pub fn load() -> Self {
95 let Some(path) = Self::config_path() else {
96 return Self::default();
97 };
98
99 if !path.exists() {
100 return Self::default();
101 }
102
103 match std::fs::read_to_string(&path) {
104 Ok(content) => match toml::from_str::<SeerConfig>(&content) {
105 Ok(config) => {
106 debug!(?path, "Loaded config");
107 config.clamped()
108 }
109 Err(e) => {
110 tracing::warn!(?path, error = %e, "Failed to parse config, using defaults");
111 Self::default()
112 }
113 },
114 Err(e) => {
115 debug!(?path, error = %e, "Could not read config, using defaults");
116 Self::default()
117 }
118 }
119 }
120
121 fn clamped(mut self) -> Self {
130 self.bulk.concurrency = self.bulk.concurrency.clamp(1, 50);
131 self.bulk.rate_limit_ms = self.bulk.rate_limit_ms.min(60_000);
134 self.timeouts.whois_secs = self.timeouts.whois_secs.clamp(1, 300);
135 self.timeouts.rdap_secs = self.timeouts.rdap_secs.clamp(1, 300);
136 self.timeouts.dns_secs = self.timeouts.dns_secs.clamp(1, 60);
137 self.timeouts.http_secs = self.timeouts.http_secs.clamp(1, 120);
138 self
139 }
140
141 pub fn whois_timeout(&self) -> Duration {
143 Duration::from_secs(self.timeouts.whois_secs)
144 }
145
146 pub fn rdap_timeout(&self) -> Duration {
148 Duration::from_secs(self.timeouts.rdap_secs)
149 }
150
151 pub fn dns_timeout(&self) -> Duration {
153 Duration::from_secs(self.timeouts.dns_secs)
154 }
155
156 pub fn http_timeout(&self) -> Duration {
158 Duration::from_secs(self.timeouts.http_secs)
159 }
160
161 pub fn bulk_rate_limit(&self) -> Duration {
163 Duration::from_millis(self.bulk.rate_limit_ms)
164 }
165
166 pub fn default_toml() -> String {
168 toml::to_string_pretty(&Self::default()).unwrap_or_else(|_| String::new())
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn test_default_config() {
178 let config = SeerConfig::default();
179 assert_eq!(config.output_format, "human");
180 assert_eq!(config.timeouts.whois_secs, 15);
181 assert_eq!(config.timeouts.rdap_secs, 15);
182 assert_eq!(config.timeouts.dns_secs, 5);
183 assert_eq!(config.bulk.concurrency, 10);
184 }
185
186 #[test]
187 fn test_parse_config_toml() {
188 let toml_str = r#"
189output_format = "json"
190nameserver = "1.1.1.1"
191
192[timeouts]
193whois_secs = 20
194rdap_secs = 45
195
196[bulk]
197concurrency = 20
198"#;
199 let config: SeerConfig = toml::from_str(toml_str).unwrap();
200 assert_eq!(config.output_format, "json");
201 assert_eq!(config.nameserver, Some("1.1.1.1".to_string()));
202 assert_eq!(config.timeouts.whois_secs, 20);
203 assert_eq!(config.timeouts.rdap_secs, 45);
204 assert_eq!(config.timeouts.dns_secs, 5); assert_eq!(config.bulk.concurrency, 20);
206 }
207
208 #[test]
209 fn nameserver_accepts_dot_doh_specs_opaquely() {
210 let config: SeerConfig = toml::from_str(r#"nameserver = "tls://1.1.1.1""#).unwrap();
214 assert_eq!(config.nameserver.as_deref(), Some("tls://1.1.1.1"));
215 }
216
217 #[test]
218 fn test_default_toml_roundtrip() {
219 let toml_str = SeerConfig::default_toml();
220 let config: SeerConfig = toml::from_str(&toml_str).unwrap();
221 assert_eq!(config.output_format, "human");
222 }
223
224 #[test]
225 fn test_timeout_durations() {
226 let config = SeerConfig::default();
227 assert_eq!(config.whois_timeout(), Duration::from_secs(15));
228 assert_eq!(config.rdap_timeout(), Duration::from_secs(15));
229 assert_eq!(config.dns_timeout(), Duration::from_secs(5));
230 assert_eq!(config.http_timeout(), Duration::from_secs(10));
231 }
232
233 #[test]
234 fn clamp_bounds_rate_limit_ms() {
235 let mut config = SeerConfig::default();
238 config.bulk.rate_limit_ms = 10_000_000;
239 let config = config.clamped();
240 assert!(config.bulk.rate_limit_ms <= 60_000);
241 }
242}