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