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 {
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 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 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 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
96pub 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 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()); assert_eq!(wl.domains.len(), 1);
214
215 assert!(wl.add("test.org").unwrap());
216 assert_eq!(wl.domains.len(), 2);
217 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")); 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}