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    pub fn load() -> Self {
50        let Some(path) = Self::path() else {
51            return Self::default();
52        };
53        if !path.exists() {
54            return Self::default();
55        }
56        match std::fs::read_to_string(&path) {
57            Ok(content) => toml::from_str(&content).unwrap_or_default(),
58            Err(_) => Self::default(),
59        }
60    }
61
62    /// Persists the watchlist to disk.
63    pub fn save(&self) -> Result<()> {
64        let path = Self::path()
65            .ok_or_else(|| SeerError::ConfigError("Cannot determine home directory".to_string()))?;
66        if let Some(parent) = path.parent() {
67            std::fs::create_dir_all(parent).map_err(|e| SeerError::ConfigError(e.to_string()))?;
68        }
69        let content =
70            toml::to_string_pretty(self).map_err(|e| SeerError::ConfigError(e.to_string()))?;
71        std::fs::write(&path, content).map_err(|e| SeerError::ConfigError(e.to_string()))?;
72        Ok(())
73    }
74
75    /// Adds a domain to the watchlist. Returns `Ok(true)` if the domain was newly added.
76    pub fn add(&mut self, domain: &str) -> Result<bool> {
77        let domain = crate::validation::normalize_domain(domain)?;
78        if self.domains.contains(&domain) {
79            return Ok(false);
80        }
81        self.domains.push(domain);
82        self.domains.sort();
83        Ok(true)
84    }
85
86    /// Removes a domain from the watchlist. Returns `true` if the domain was present.
87    pub fn remove(&mut self, domain: &str) -> bool {
88        let domain =
89            crate::validation::normalize_domain(domain).unwrap_or_else(|_| domain.to_lowercase());
90        let len_before = self.domains.len();
91        self.domains.retain(|d| d != &domain);
92        self.domains.len() < len_before
93    }
94}
95
96/// Checks all given domains concurrently and produces a [`WatchReport`].
97pub async fn check_watchlist(domains: &[String]) -> WatchReport {
98    use futures::stream::{self, StreamExt};
99
100    let client = StatusClient::new();
101
102    let results: Vec<WatchResult> = stream::iter(domains)
103        .map(|domain| {
104            let client = &client;
105            async move {
106                let mut watch_result = WatchResult {
107                    domain: domain.clone(),
108                    ssl_days_remaining: None,
109                    domain_days_remaining: None,
110                    registrar: None,
111                    http_status: None,
112                    issues: vec![],
113                };
114
115                match client.check(domain).await {
116                    Ok(status) => {
117                        watch_result.http_status = status.http_status;
118
119                        if let Some(ref cert) = status.certificate {
120                            watch_result.ssl_days_remaining = Some(cert.days_until_expiry);
121                            if cert.days_until_expiry < 30 {
122                                watch_result.issues.push(format!(
123                                    "SSL expires in {} days",
124                                    cert.days_until_expiry
125                                ));
126                            }
127                            if !cert.is_valid {
128                                watch_result
129                                    .issues
130                                    .push("SSL certificate invalid".to_string());
131                            }
132                        }
133
134                        if let Some(ref exp) = status.domain_expiration {
135                            watch_result.domain_days_remaining = Some(exp.days_until_expiry);
136                            watch_result.registrar = exp.registrar.clone();
137                            if exp.days_until_expiry < 90 {
138                                watch_result.issues.push(format!(
139                                    "Domain expires in {} days",
140                                    exp.days_until_expiry
141                                ));
142                            }
143                        }
144
145                        if let Some(status_code) = status.http_status {
146                            if !(200..300).contains(&status_code) {
147                                watch_result
148                                    .issues
149                                    .push(format!("HTTP status {}", status_code));
150                            }
151                        }
152                    }
153                    Err(e) => {
154                        watch_result.issues.push(format!("Check failed: {}", e));
155                    }
156                }
157
158                watch_result
159            }
160        })
161        .buffer_unordered(10)
162        .collect()
163        .await;
164
165    let total = results.len();
166    let critical = results
167        .iter()
168        .filter(|r| {
169            r.issues.iter().any(|i| {
170                i.contains("invalid") || i.contains("failed") || {
171                    // Check for expiry under 30 days
172                    if let Some(ssl) = r.ssl_days_remaining {
173                        if ssl < 30 {
174                            return true;
175                        }
176                    }
177                    if let Some(dom) = r.domain_days_remaining {
178                        if dom < 30 {
179                            return true;
180                        }
181                    }
182                    false
183                }
184            })
185        })
186        .count();
187    let warnings = results.iter().filter(|r| !r.issues.is_empty()).count();
188
189    WatchReport {
190        checked_at: Utc::now(),
191        results,
192        total,
193        warnings,
194        critical,
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_watchlist_default() {
204        let wl = Watchlist::default();
205        assert!(wl.domains.is_empty());
206    }
207
208    #[test]
209    fn test_watchlist_add_remove() {
210        let mut wl = Watchlist::default();
211        assert!(wl.add("example.com").unwrap());
212        assert!(!wl.add("example.com").unwrap()); // duplicate
213        assert_eq!(wl.domains.len(), 1);
214
215        assert!(wl.add("test.org").unwrap());
216        assert_eq!(wl.domains.len(), 2);
217        // Should be sorted
218        assert_eq!(wl.domains[0], "example.com");
219        assert_eq!(wl.domains[1], "test.org");
220
221        assert!(wl.remove("example.com"));
222        assert!(!wl.remove("example.com")); // already removed
223        assert_eq!(wl.domains.len(), 1);
224    }
225
226    #[test]
227    fn test_watchlist_add_normalizes_case() {
228        let mut wl = Watchlist::default();
229        wl.add("EXAMPLE.COM").unwrap();
230        assert_eq!(wl.domains[0], "example.com");
231    }
232
233    #[test]
234    fn test_watchlist_serialization() {
235        let mut wl = Watchlist::default();
236        wl.add("a.com").unwrap();
237        wl.add("b.org").unwrap();
238        let toml_str = toml::to_string_pretty(&wl).unwrap();
239        assert!(toml_str.contains("a.com"));
240        assert!(toml_str.contains("b.org"));
241
242        let parsed: Watchlist = toml::from_str(&toml_str).unwrap();
243        assert_eq!(parsed.domains.len(), 2);
244    }
245
246    #[test]
247    fn test_watch_result_serialization() {
248        let result = WatchResult {
249            domain: "example.com".to_string(),
250            ssl_days_remaining: Some(45),
251            domain_days_remaining: Some(120),
252            registrar: Some("Test Registrar".to_string()),
253            http_status: Some(200),
254            issues: vec![],
255        };
256        let json = serde_json::to_string(&result).unwrap();
257        assert!(json.contains("example.com"));
258        assert!(json.contains("45"));
259    }
260}