Skip to main content

seer_core/
watchlist.rs

1//! Domain watchlist for monitoring expiration and health.
2//!
3//! Loads a list of domains from `~/.seer/watchlist.toml` and checks their
4//! SSL certificates, domain expiration, and HTTP status.
5
6use std::path::PathBuf;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Result, SeerError};
12use crate::status::StatusClient;
13
14/// Persistent list of domains to monitor.
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct Watchlist {
17    #[serde(default)]
18    pub domains: Vec<String>,
19}
20
21/// Status result for a single watched domain.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WatchResult {
24    pub domain: String,
25    pub ssl_days_remaining: Option<i64>,
26    pub domain_days_remaining: Option<i64>,
27    pub registrar: Option<String>,
28    pub http_status: Option<u16>,
29    pub issues: Vec<String>,
30}
31
32/// Aggregated report from checking all watched domains.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct WatchReport {
35    pub checked_at: DateTime<Utc>,
36    pub results: Vec<WatchResult>,
37    pub total: usize,
38    pub warnings: usize,
39    pub critical: usize,
40}
41
42impl Watchlist {
43    /// Returns the path to the watchlist file (`~/.seer/watchlist.toml`).
44    pub fn path() -> Option<PathBuf> {
45        dirs::home_dir().map(|h| h.join(".seer").join("watchlist.toml"))
46    }
47
48    /// Loads the watchlist from disk, returning an empty list on any failure.
49    ///
50    /// When the file exists but fails to parse, it is renamed to
51    /// `<path>.corrupt` (preserving the user's data for recovery/forensics)
52    /// and a warning is logged — previously the file was silently
53    /// overwritten on the next save, dropping the user's watchlist.
54    pub fn load() -> Self {
55        let Some(path) = Self::path() else {
56            return Self::default();
57        };
58        Self::load_from_path(&path)
59    }
60
61    /// Like [`Self::load`] but reads from an explicit path. Split out so
62    /// tests can exercise the corrupt-file handling without depending on
63    /// the real `~/.seer/watchlist.toml` location.
64    pub(crate) fn load_from_path(path: &std::path::Path) -> Self {
65        if !path.exists() {
66            return Self::default();
67        }
68        match std::fs::read_to_string(path) {
69            Ok(content) => match toml::from_str::<Watchlist>(&content) {
70                Ok(w) => w,
71                Err(e) => {
72                    let backup = path.with_extension("corrupt");
73                    if let Err(rename_err) = std::fs::rename(path, &backup) {
74                        tracing::error!(
75                            path = %path.display(),
76                            error = %rename_err,
77                            "failed to back up corrupt watchlist",
78                        );
79                    } else {
80                        tracing::warn!(
81                            path = %path.display(),
82                            backup = %backup.display(),
83                            error = %e,
84                            "watchlist file corrupt; moved to backup",
85                        );
86                    }
87                    Watchlist::default()
88                }
89            },
90            Err(_) => Self::default(),
91        }
92    }
93
94    /// Persists the watchlist to disk.
95    pub fn save(&self) -> Result<()> {
96        let path = Self::path()
97            .ok_or_else(|| SeerError::ConfigError("Cannot determine home directory".to_string()))?;
98        if let Some(parent) = path.parent() {
99            std::fs::create_dir_all(parent).map_err(|e| SeerError::ConfigError(e.to_string()))?;
100        }
101        let content =
102            toml::to_string_pretty(self).map_err(|e| SeerError::ConfigError(e.to_string()))?;
103        std::fs::write(&path, content).map_err(|e| SeerError::ConfigError(e.to_string()))?;
104        Ok(())
105    }
106
107    /// Adds a domain to the watchlist. Returns `Ok(true)` if the domain was newly added.
108    pub fn add(&mut self, domain: &str) -> Result<bool> {
109        let domain = crate::validation::normalize_domain(domain)?;
110        if self.domains.contains(&domain) {
111            return Ok(false);
112        }
113        self.domains.push(domain);
114        self.domains.sort();
115        Ok(true)
116    }
117
118    /// Removes a domain from the watchlist. Returns `true` if the domain was present.
119    pub fn remove(&mut self, domain: &str) -> bool {
120        let domain =
121            crate::validation::normalize_domain(domain).unwrap_or_else(|_| domain.to_lowercase());
122        let len_before = self.domains.len();
123        self.domains.retain(|d| d != &domain);
124        self.domains.len() < len_before
125    }
126}
127
128/// Checks all given domains concurrently and produces a [`WatchReport`].
129pub async fn check_watchlist(domains: &[String]) -> WatchReport {
130    use futures::stream::{self, StreamExt};
131
132    let client = StatusClient::new();
133
134    let results: Vec<WatchResult> = stream::iter(domains)
135        .map(|domain| {
136            let client = &client;
137            async move {
138                let mut watch_result = WatchResult {
139                    domain: domain.clone(),
140                    ssl_days_remaining: None,
141                    domain_days_remaining: None,
142                    registrar: None,
143                    http_status: None,
144                    issues: vec![],
145                };
146
147                match client.check(domain).await {
148                    Ok(status) => {
149                        watch_result.http_status = status.http_status;
150
151                        if let Some(ref cert) = status.certificate {
152                            watch_result.ssl_days_remaining = Some(cert.days_until_expiry);
153                            if cert.days_until_expiry < 30 {
154                                watch_result.issues.push(format!(
155                                    "SSL expires in {} days",
156                                    cert.days_until_expiry
157                                ));
158                            }
159                            if !cert.is_valid {
160                                watch_result
161                                    .issues
162                                    .push("SSL certificate invalid".to_string());
163                            }
164                        }
165
166                        if let Some(ref exp) = status.domain_expiration {
167                            watch_result.domain_days_remaining = Some(exp.days_until_expiry);
168                            watch_result.registrar = exp.registrar.clone();
169                            if exp.days_until_expiry < 90 {
170                                watch_result.issues.push(format!(
171                                    "Domain expires in {} days",
172                                    exp.days_until_expiry
173                                ));
174                            }
175                        }
176
177                        if let Some(status_code) = status.http_status {
178                            if !(200..300).contains(&status_code) {
179                                watch_result
180                                    .issues
181                                    .push(format!("HTTP status {}", status_code));
182                            }
183                        }
184                    }
185                    Err(e) => {
186                        watch_result.issues.push(format!("Check failed: {}", e));
187                    }
188                }
189
190                watch_result
191            }
192        })
193        .buffer_unordered(10)
194        .collect()
195        .await;
196
197    let total = results.len();
198    let critical = results
199        .iter()
200        .filter(|r| {
201            r.issues.iter().any(|i| {
202                i.contains("invalid") || i.contains("failed") || {
203                    // Check for expiry under 30 days
204                    if let Some(ssl) = r.ssl_days_remaining {
205                        if ssl < 30 {
206                            return true;
207                        }
208                    }
209                    if let Some(dom) = r.domain_days_remaining {
210                        if dom < 30 {
211                            return true;
212                        }
213                    }
214                    false
215                }
216            })
217        })
218        .count();
219    let warnings = results.iter().filter(|r| !r.issues.is_empty()).count();
220
221    WatchReport {
222        checked_at: Utc::now(),
223        results,
224        total,
225        warnings,
226        critical,
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_watchlist_default() {
236        let wl = Watchlist::default();
237        assert!(wl.domains.is_empty());
238    }
239
240    #[test]
241    fn test_watchlist_add_remove() {
242        let mut wl = Watchlist::default();
243        assert!(wl.add("example.com").unwrap());
244        assert!(!wl.add("example.com").unwrap()); // duplicate
245        assert_eq!(wl.domains.len(), 1);
246
247        assert!(wl.add("test.org").unwrap());
248        assert_eq!(wl.domains.len(), 2);
249        // Should be sorted
250        assert_eq!(wl.domains[0], "example.com");
251        assert_eq!(wl.domains[1], "test.org");
252
253        assert!(wl.remove("example.com"));
254        assert!(!wl.remove("example.com")); // already removed
255        assert_eq!(wl.domains.len(), 1);
256    }
257
258    #[test]
259    fn test_watchlist_add_normalizes_case() {
260        let mut wl = Watchlist::default();
261        wl.add("EXAMPLE.COM").unwrap();
262        assert_eq!(wl.domains[0], "example.com");
263    }
264
265    #[test]
266    fn test_watchlist_serialization() {
267        let mut wl = Watchlist::default();
268        wl.add("a.com").unwrap();
269        wl.add("b.org").unwrap();
270        let toml_str = toml::to_string_pretty(&wl).unwrap();
271        assert!(toml_str.contains("a.com"));
272        assert!(toml_str.contains("b.org"));
273
274        let parsed: Watchlist = toml::from_str(&toml_str).unwrap();
275        assert_eq!(parsed.domains.len(), 2);
276    }
277
278    /// Creates a unique temporary file path for a load-from-disk test.
279    fn unique_temp_watchlist_path(tag: &str) -> PathBuf {
280        let mut dir = std::env::temp_dir();
281        dir.push(format!(
282            "seer-watchlist-test-{}-{}",
283            tag,
284            std::process::id()
285        ));
286        let _ = std::fs::create_dir_all(&dir);
287        dir.push("watchlist.toml");
288        dir
289    }
290
291    #[test]
292    fn load_from_path_returns_default_and_backs_up_corrupt_file() {
293        let path = unique_temp_watchlist_path("corrupt");
294        let backup = path.with_extension("corrupt");
295
296        let _ = std::fs::remove_file(&path);
297        let _ = std::fs::remove_file(&backup);
298
299        // TOML parsers reject stray garbage on the value side of `=`.
300        std::fs::write(&path, b"domains = not-an-array-\n").expect("seed corrupt watchlist file");
301
302        let loaded = Watchlist::load_from_path(&path);
303        assert!(
304            loaded.domains.is_empty(),
305            "corrupt watchlist must load as empty default"
306        );
307        assert!(
308            !path.exists(),
309            "original corrupt file should have been renamed away"
310        );
311        assert!(
312            backup.exists(),
313            "backup .corrupt file should exist at {}",
314            backup.display()
315        );
316
317        let _ = std::fs::remove_file(&backup);
318        if let Some(parent) = path.parent() {
319            let _ = std::fs::remove_dir_all(parent);
320        }
321    }
322
323    #[test]
324    fn load_from_path_returns_default_when_missing() {
325        let path = unique_temp_watchlist_path("missing");
326        let _ = std::fs::remove_file(&path);
327
328        let loaded = Watchlist::load_from_path(&path);
329        assert!(loaded.domains.is_empty());
330
331        if let Some(parent) = path.parent() {
332            let _ = std::fs::remove_dir_all(parent);
333        }
334    }
335
336    #[test]
337    fn test_watch_result_serialization() {
338        let result = WatchResult {
339            domain: "example.com".to_string(),
340            ssl_days_remaining: Some(45),
341            domain_days_remaining: Some(120),
342            registrar: Some("Test Registrar".to_string()),
343            http_status: Some(200),
344            issues: vec![],
345        };
346        let json = serde_json::to_string(&result).unwrap();
347        assert!(json.contains("example.com"));
348        assert!(json.contains("45"));
349    }
350}