1use std::path::PathBuf;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Result, SeerError};
12use crate::status::StatusClient;
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct Watchlist {
17 #[serde(default)]
18 pub domains: Vec<String>,
19}
20
21#[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#[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 pub fn path() -> Option<PathBuf> {
45 dirs::home_dir().map(|h| h.join(".seer").join("watchlist.toml"))
46 }
47
48 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 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 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 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 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
128pub 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 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()); assert_eq!(wl.domains.len(), 1);
246
247 assert!(wl.add("test.org").unwrap());
248 assert_eq!(wl.domains.len(), 2);
249 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")); 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 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 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}