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<()> {
112 let path = Self::path()
113 .ok_or_else(|| SeerError::ConfigError("Cannot determine home directory".to_string()))?;
114 self.save_to_path(&path)
115 }
116
117 pub(crate) fn save_to_path(&self, path: &std::path::Path) -> Result<()> {
120 let content =
121 toml::to_string_pretty(self).map_err(|e| SeerError::ConfigError(e.to_string()))?;
122 crate::fsutil::write_atomic_owner_only(path, &content, "toml")
123 }
124
125 pub fn add(&mut self, domain: &str) -> Result<bool> {
127 let domain = crate::validation::normalize_domain(domain)?;
128 if self.domains.contains(&domain) {
129 return Ok(false);
130 }
131 self.domains.push(domain);
132 self.domains.sort();
133 Ok(true)
134 }
135
136 pub fn remove(&mut self, domain: &str) -> bool {
138 let domain =
139 crate::validation::normalize_domain(domain).unwrap_or_else(|_| domain.to_lowercase());
140 let len_before = self.domains.len();
141 self.domains.retain(|d| d != &domain);
142 self.domains.len() < len_before
143 }
144}
145
146const EXPIRY_CRITICAL_DAYS: i64 = 30;
148const DOMAIN_EXPIRY_WARN_DAYS: i64 = 90;
153const SSL_INVALID_ISSUE: &str = "SSL certificate invalid";
156const CHECK_FAILED_PREFIX: &str = "Check failed:";
157
158fn result_is_critical(r: &WatchResult) -> bool {
164 let bad_ssl = r
165 .ssl_days_remaining
166 .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
167 let bad_domain = r
168 .domain_days_remaining
169 .is_some_and(|d| d < EXPIRY_CRITICAL_DAYS);
170 let bad_issue = r
174 .issues
175 .iter()
176 .any(|i| i == SSL_INVALID_ISSUE || i.starts_with(CHECK_FAILED_PREFIX));
177 bad_ssl || bad_domain || bad_issue
178}
179
180pub async fn check_watchlist(domains: &[String]) -> WatchReport {
182 use futures::stream::{self, StreamExt};
183
184 let client = std::sync::Arc::new(StatusClient::new());
190
191 let results: Vec<WatchResult> = stream::iter(domains.iter().cloned())
192 .map(|domain| {
193 let client = client.clone();
194 async move {
195 let mut watch_result = WatchResult {
196 domain: domain.clone(),
197 ssl_days_remaining: None,
198 domain_days_remaining: None,
199 registrar: None,
200 http_status: None,
201 issues: vec![],
202 };
203
204 match client.check(&domain).await {
205 Ok(status) => {
206 watch_result.http_status = status.http_status;
207
208 if let Some(ref cert) = status.certificate {
209 watch_result.ssl_days_remaining = Some(cert.days_until_expiry);
210 if cert.days_until_expiry < EXPIRY_CRITICAL_DAYS {
211 watch_result.issues.push(format!(
212 "SSL expires in {} days",
213 cert.days_until_expiry
214 ));
215 }
216 if !cert.is_valid {
217 watch_result.issues.push(SSL_INVALID_ISSUE.to_string());
218 }
219 }
220
221 if let Some(ref exp) = status.domain_expiration {
222 watch_result.domain_days_remaining = Some(exp.days_until_expiry);
223 watch_result.registrar = exp.registrar.clone();
224 if exp.days_until_expiry < DOMAIN_EXPIRY_WARN_DAYS {
225 watch_result.issues.push(format!(
226 "Domain expires in {} days",
227 exp.days_until_expiry
228 ));
229 }
230 }
231
232 if let Some(status_code) = status.http_status {
233 if !(200..300).contains(&status_code) {
234 watch_result
235 .issues
236 .push(format!("HTTP status {}", status_code));
237 }
238 }
239 }
240 Err(e) => {
241 watch_result
242 .issues
243 .push(format!("{} {}", CHECK_FAILED_PREFIX, e));
244 }
245 }
246
247 watch_result
248 }
249 })
250 .buffer_unordered(10)
251 .collect()
252 .await;
253
254 let total = results.len();
255 let critical = results.iter().filter(|r| result_is_critical(r)).count();
260 let warnings = results.iter().filter(|r| !r.issues.is_empty()).count();
261
262 WatchReport {
263 checked_at: Utc::now(),
264 results,
265 total,
266 warnings,
267 critical,
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn test_watchlist_default() {
277 let wl = Watchlist::default();
278 assert!(wl.domains.is_empty());
279 }
280
281 #[test]
282 fn test_watchlist_add_remove() {
283 let mut wl = Watchlist::default();
284 assert!(wl.add("example.com").unwrap());
285 assert!(!wl.add("example.com").unwrap()); assert_eq!(wl.domains.len(), 1);
287
288 assert!(wl.add("test.org").unwrap());
289 assert_eq!(wl.domains.len(), 2);
290 assert_eq!(wl.domains[0], "example.com");
292 assert_eq!(wl.domains[1], "test.org");
293
294 assert!(wl.remove("example.com"));
295 assert!(!wl.remove("example.com")); assert_eq!(wl.domains.len(), 1);
297 }
298
299 #[test]
300 fn test_watchlist_add_normalizes_case() {
301 let mut wl = Watchlist::default();
302 wl.add("EXAMPLE.COM").unwrap();
303 assert_eq!(wl.domains[0], "example.com");
304 }
305
306 #[test]
307 fn test_watchlist_serialization() {
308 let mut wl = Watchlist::default();
309 wl.add("a.com").unwrap();
310 wl.add("b.org").unwrap();
311 let toml_str = toml::to_string_pretty(&wl).unwrap();
312 assert!(toml_str.contains("a.com"));
313 assert!(toml_str.contains("b.org"));
314
315 let parsed: Watchlist = toml::from_str(&toml_str).unwrap();
316 assert_eq!(parsed.domains.len(), 2);
317 }
318
319 fn unique_temp_watchlist_path(tag: &str) -> PathBuf {
321 let mut dir = std::env::temp_dir();
322 dir.push(format!(
323 "seer-watchlist-test-{}-{}",
324 tag,
325 std::process::id()
326 ));
327 let _ = std::fs::create_dir_all(&dir);
328 dir.push("watchlist.toml");
329 dir
330 }
331
332 #[test]
333 fn load_from_path_returns_default_and_backs_up_corrupt_file() {
334 let path = unique_temp_watchlist_path("corrupt");
335 let backup = path.with_extension("corrupt");
336
337 let _ = std::fs::remove_file(&path);
338 let _ = std::fs::remove_file(&backup);
339
340 std::fs::write(&path, b"domains = not-an-array-\n").expect("seed corrupt watchlist file");
342
343 let loaded = Watchlist::load_from_path(&path);
344 assert!(
345 loaded.domains.is_empty(),
346 "corrupt watchlist must load as empty default"
347 );
348 assert!(
349 !path.exists(),
350 "original corrupt file should have been renamed away"
351 );
352 assert!(
353 backup.exists(),
354 "backup .corrupt file should exist at {}",
355 backup.display()
356 );
357
358 let _ = std::fs::remove_file(&backup);
359 if let Some(parent) = path.parent() {
360 let _ = std::fs::remove_dir_all(parent);
361 }
362 }
363
364 #[test]
365 fn load_from_path_returns_default_when_missing() {
366 let path = unique_temp_watchlist_path("missing");
367 let _ = std::fs::remove_file(&path);
368
369 let loaded = Watchlist::load_from_path(&path);
370 assert!(loaded.domains.is_empty());
371
372 if let Some(parent) = path.parent() {
373 let _ = std::fs::remove_dir_all(parent);
374 }
375 }
376
377 #[test]
378 fn concurrent_saves_do_not_corrupt_the_file() {
379 let path = unique_temp_watchlist_path("concurrent");
385 let _ = std::fs::remove_file(&path);
386
387 let mut a = Watchlist::default();
388 a.add("a.example").unwrap();
389 let mut b = Watchlist::default();
390 for i in 0..100 {
391 b.add(&format!("b{i}.example")).unwrap();
392 }
393
394 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
395 let spawn_saver =
396 |wl: Watchlist, path: PathBuf, barrier: std::sync::Arc<std::sync::Barrier>| {
397 std::thread::spawn(move || {
398 for _ in 0..50 {
399 barrier.wait();
400 wl.save_to_path(&path).expect("concurrent save failed");
401 }
402 })
403 };
404 let ta = spawn_saver(a, path.clone(), barrier.clone());
405 let tb = spawn_saver(b, path.clone(), barrier);
406 ta.join().expect("thread A panicked");
407 tb.join().expect("thread B panicked");
408
409 let content = std::fs::read_to_string(&path).expect("saved file exists");
411 toml::from_str::<Watchlist>(&content)
412 .expect("concurrently saved watchlist must parse (no torn rename)");
413
414 if let Some(parent) = path.parent() {
415 let _ = std::fs::remove_dir_all(parent);
416 }
417 }
418
419 fn result_with(ssl: Option<i64>, domain: Option<i64>, issues: &[&str]) -> WatchResult {
420 WatchResult {
421 domain: "x.test".to_string(),
422 ssl_days_remaining: ssl,
423 domain_days_remaining: domain,
424 registrar: None,
425 http_status: Some(200),
426 issues: issues.iter().map(|s| s.to_string()).collect(),
427 }
428 }
429
430 #[test]
431 fn critical_uses_explicit_expiry_bands() {
432 assert!(!result_is_critical(&result_with(
435 None,
436 Some(60),
437 &["Domain expires in 60 days"]
438 )));
439 assert!(result_is_critical(&result_with(
440 None,
441 Some(20),
442 &["Domain expires in 20 days"]
443 )));
444 assert!(result_is_critical(&result_with(Some(10), None, &[])));
446 assert!(result_is_critical(&result_with(
447 Some(200),
448 None,
449 &["SSL certificate invalid"]
450 )));
451 assert!(result_is_critical(&result_with(
453 None,
454 None,
455 &["Check failed: connection refused"]
456 )));
457 }
458
459 #[test]
460 fn critical_predicate_is_structured_not_freetext() {
461 let r = result_with(
465 Some(200),
466 Some(200),
467 &["Note: a prior validation failed last week"],
468 );
469 assert!(
470 !result_is_critical(&r),
471 "free-text 'failed' must not trip the critical predicate"
472 );
473 }
474
475 #[test]
476 fn test_watch_result_serialization() {
477 let result = WatchResult {
478 domain: "example.com".to_string(),
479 ssl_days_remaining: Some(45),
480 domain_days_remaining: Some(120),
481 registrar: Some("Test Registrar".to_string()),
482 http_status: Some(200),
483 issues: vec![],
484 };
485 let json = serde_json::to_string(&result).unwrap();
486 assert!(json.contains("example.com"));
487 assert!(json.contains("45"));
488 }
489}