1use serde::{Deserialize, Serialize};
2use tracing::{debug, instrument};
3
4use crate::error::Result;
5use crate::lookup::{LookupResult, SmartLookup};
6use crate::status::{StatusClient, StatusResponse};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DomainDiff {
11 pub domain_a: String,
12 pub domain_b: String,
13 pub registration: RegistrationDiff,
14 pub dns: DnsDiff,
15 pub ssl: SslDiff,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RegistrationDiff {
21 pub registrar: (Option<String>, Option<String>),
22 pub organization: (Option<String>, Option<String>),
23 pub created: (Option<String>, Option<String>),
24 pub expires: (Option<String>, Option<String>),
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct DnsDiff {
30 pub a_records: (Vec<String>, Vec<String>),
31 pub nameservers: (Vec<String>, Vec<String>),
32 pub resolves: (bool, bool),
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SslDiff {
38 pub issuer: (Option<String>, Option<String>),
39 pub valid_until: (Option<String>, Option<String>),
40 pub days_remaining: (Option<i64>, Option<i64>),
41 pub is_valid: (Option<bool>, Option<bool>),
42}
43
44pub struct DomainDiffer {
46 lookup: SmartLookup,
47 status_client: StatusClient,
48}
49
50impl Default for DomainDiffer {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl DomainDiffer {
57 pub fn new() -> Self {
58 Self {
59 lookup: SmartLookup::new(),
60 status_client: StatusClient::new(),
61 }
62 }
63
64 #[instrument(skip(self), fields(domain_a = %domain_a, domain_b = %domain_b))]
68 pub async fn diff(&self, domain_a: &str, domain_b: &str) -> Result<DomainDiff> {
69 let domain_a = crate::validation::normalize_domain(domain_a)?;
70 let domain_b = crate::validation::normalize_domain(domain_b)?;
71
72 let (lookup_a, lookup_b, status_a, status_b) = tokio::join!(
73 self.lookup.lookup(&domain_a),
74 self.lookup.lookup(&domain_b),
75 self.status_client.check(&domain_a),
76 self.status_client.check(&domain_b),
77 );
78
79 let registration = build_registration_diff(lookup_a.ok().as_ref(), lookup_b.ok().as_ref());
80 let dns = build_dns_diff(status_a.as_ref().ok(), status_b.as_ref().ok());
81 let ssl = build_ssl_diff(status_a.as_ref().ok(), status_b.as_ref().ok());
82
83 debug!("Domain diff complete");
84
85 Ok(DomainDiff {
86 domain_a,
87 domain_b,
88 registration,
89 dns,
90 ssl,
91 })
92 }
93}
94
95fn build_registration_diff(a: Option<&LookupResult>, b: Option<&LookupResult>) -> RegistrationDiff {
96 let registrar_a = a.and_then(|r| r.registrar());
97 let registrar_b = b.and_then(|r| r.registrar());
98 let org_a = a.and_then(|r| r.organization());
99 let org_b = b.and_then(|r| r.organization());
100
101 let (expires_a, created_a) = extract_dates(a);
102 let (expires_b, created_b) = extract_dates(b);
103
104 RegistrationDiff {
105 registrar: (registrar_a, registrar_b),
106 organization: (org_a, org_b),
107 created: (created_a, created_b),
108 expires: (expires_a, expires_b),
109 }
110}
111
112fn extract_dates(result: Option<&LookupResult>) -> (Option<String>, Option<String>) {
114 result
115 .map(|r| {
116 let (exp, _) = r.expiration_info();
117 let created = match r {
118 LookupResult::Rdap { data, .. } => data
119 .creation_date()
120 .map(|d| d.format("%Y-%m-%d").to_string()),
121 LookupResult::Whois { data, .. } => {
122 data.creation_date.map(|d| d.format("%Y-%m-%d").to_string())
123 }
124 _ => None,
125 };
126 (exp.map(|d| d.format("%Y-%m-%d").to_string()), created)
127 })
128 .unwrap_or((None, None))
129}
130
131fn build_dns_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> DnsDiff {
132 let (a_records_a, ns_a, resolves_a) = a
133 .and_then(|s| s.dns_resolution.as_ref())
134 .map(|d| (d.a_records.clone(), d.nameservers.clone(), d.resolves))
135 .unwrap_or((vec![], vec![], false));
136
137 let (a_records_b, ns_b, resolves_b) = b
138 .and_then(|s| s.dns_resolution.as_ref())
139 .map(|d| (d.a_records.clone(), d.nameservers.clone(), d.resolves))
140 .unwrap_or((vec![], vec![], false));
141
142 DnsDiff {
143 a_records: (a_records_a, a_records_b),
144 nameservers: (ns_a, ns_b),
145 resolves: (resolves_a, resolves_b),
146 }
147}
148
149fn build_ssl_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> SslDiff {
150 let (issuer_a, valid_until_a, days_a, is_valid_a) = a
151 .and_then(|s| s.certificate.as_ref())
152 .map(|c| {
153 (
154 Some(c.issuer.clone()),
155 Some(c.valid_until.format("%Y-%m-%d").to_string()),
156 Some(c.days_until_expiry),
157 Some(c.is_valid),
158 )
159 })
160 .unwrap_or((None, None, None, None));
161
162 let (issuer_b, valid_until_b, days_b, is_valid_b) = b
163 .and_then(|s| s.certificate.as_ref())
164 .map(|c| {
165 (
166 Some(c.issuer.clone()),
167 Some(c.valid_until.format("%Y-%m-%d").to_string()),
168 Some(c.days_until_expiry),
169 Some(c.is_valid),
170 )
171 })
172 .unwrap_or((None, None, None, None));
173
174 SslDiff {
175 issuer: (issuer_a, issuer_b),
176 valid_until: (valid_until_a, valid_until_b),
177 days_remaining: (days_a, days_b),
178 is_valid: (is_valid_a, is_valid_b),
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn test_domain_diff_serialization() {
188 let diff = DomainDiff {
189 domain_a: "example.com".to_string(),
190 domain_b: "google.com".to_string(),
191 registration: RegistrationDiff {
192 registrar: (Some("IANA".to_string()), Some("MarkMonitor".to_string())),
193 organization: (None, Some("Google LLC".to_string())),
194 created: (
195 Some("1995-08-14".to_string()),
196 Some("1997-09-15".to_string()),
197 ),
198 expires: (
199 Some("2026-08-13".to_string()),
200 Some("2028-09-14".to_string()),
201 ),
202 },
203 dns: DnsDiff {
204 a_records: (
205 vec!["93.184.216.34".to_string()],
206 vec!["142.250.185.46".to_string()],
207 ),
208 nameservers: (
209 vec!["a.iana-servers.net".to_string()],
210 vec!["ns1.google.com".to_string()],
211 ),
212 resolves: (true, true),
213 },
214 ssl: SslDiff {
215 issuer: (
216 Some("DigiCert Inc".to_string()),
217 Some("Google Trust Services".to_string()),
218 ),
219 valid_until: (
220 Some("2025-03-01".to_string()),
221 Some("2025-02-15".to_string()),
222 ),
223 days_remaining: (Some(89), Some(75)),
224 is_valid: (Some(true), Some(true)),
225 },
226 };
227
228 let json = serde_json::to_string(&diff).unwrap();
229 assert!(json.contains("example.com"));
230 assert!(json.contains("google.com"));
231 assert!(json.contains("IANA"));
232 assert!(json.contains("MarkMonitor"));
233 }
234
235 #[test]
236 fn test_build_registration_diff_both_none() {
237 let diff = build_registration_diff(None, None);
238 assert!(diff.registrar.0.is_none());
239 assert!(diff.registrar.1.is_none());
240 assert!(diff.organization.0.is_none());
241 assert!(diff.organization.1.is_none());
242 assert!(diff.created.0.is_none());
243 assert!(diff.created.1.is_none());
244 assert!(diff.expires.0.is_none());
245 assert!(diff.expires.1.is_none());
246 }
247
248 #[test]
249 fn test_build_dns_diff_both_none() {
250 let diff = build_dns_diff(None, None);
251 assert!(diff.a_records.0.is_empty());
252 assert!(diff.a_records.1.is_empty());
253 assert!(diff.nameservers.0.is_empty());
254 assert!(diff.nameservers.1.is_empty());
255 assert!(!diff.resolves.0);
256 assert!(!diff.resolves.1);
257 }
258
259 #[test]
260 fn test_build_ssl_diff_both_none() {
261 let diff = build_ssl_diff(None, None);
262 assert!(diff.issuer.0.is_none());
263 assert!(diff.issuer.1.is_none());
264 assert!(diff.valid_until.0.is_none());
265 assert!(diff.valid_until.1.is_none());
266 assert!(diff.days_remaining.0.is_none());
267 assert!(diff.days_remaining.1.is_none());
268 assert!(diff.is_valid.0.is_none());
269 assert!(diff.is_valid.1.is_none());
270 }
271
272 #[test]
273 fn test_domain_differ_default() {
274 let differ = DomainDiffer::default();
275 let _ = differ;
277 }
278
279 #[test]
280 fn test_extract_dates_none() {
281 let (exp, created) = extract_dates(None);
282 assert!(exp.is_none());
283 assert!(created.is_none());
284 }
285}