1use serde::{Deserialize, Serialize};
7use tracing::{debug, instrument};
8
9use crate::error::Result;
10use crate::rdap::RdapClient;
11use crate::whois::WhoisClient;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct AvailabilityResult {
16 pub domain: String,
18 pub available: bool,
20 pub confidence: String,
22 pub method: String,
24 pub details: Option<String>,
26}
27
28impl AvailabilityResult {
29 pub fn verdict(&self) -> &'static str {
33 match (self.available, self.confidence.as_str()) {
34 (true, "high") => "available",
35 (true, "medium") => "likely_available",
36 (false, "high") => "registered",
37 (false, "medium") => "likely_registered",
38 _ => "unknown",
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct AvailabilityChecker {
46 rdap_client: RdapClient,
47 whois_client: WhoisClient,
48}
49
50impl Default for AvailabilityChecker {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl AvailabilityChecker {
57 pub fn new() -> Self {
58 Self {
59 rdap_client: RdapClient::new(),
60 whois_client: WhoisClient::new(),
61 }
62 }
63
64 #[instrument(skip(self), fields(domain = %domain))]
66 pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
67 let domain = crate::validation::normalize_domain(domain)?;
68 debug!(domain = %domain, "Checking domain availability");
69
70 match self.rdap_client.lookup_domain(&domain).await {
72 Ok(response) => Ok(decide_from_rdap(&domain, response)),
73 Err(rdap_err) => {
74 debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS");
75 let whois_result = self.whois_client.lookup(&domain).await;
76 Ok(decide_fallback(&domain, &rdap_err, whois_result))
77 }
78 }
79 }
80}
81
82fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
86 let statuses: Vec<String> = response.status.clone();
87 let is_redemption = statuses
88 .iter()
89 .any(|s| s.contains("redemption") || s.contains("pending delete"));
90
91 if is_redemption {
92 return AvailabilityResult {
93 domain: domain.to_string(),
94 available: false,
95 confidence: "medium".to_string(),
96 method: "rdap".to_string(),
97 details: Some("Domain is in redemption/pending delete period".to_string()),
98 };
99 }
100
101 AvailabilityResult {
102 domain: domain.to_string(),
103 available: false,
104 confidence: "high".to_string(),
105 method: "rdap".to_string(),
106 details: Some(format!(
107 "Domain is registered (status: {})",
108 statuses.join(", ")
109 )),
110 }
111}
112
113fn decide_fallback(
116 domain: &str,
117 rdap_err: &crate::error::SeerError,
118 whois_result: Result<crate::whois::WhoisResponse>,
119) -> AvailabilityResult {
120 match whois_result {
121 Ok(whois_response) => {
122 if whois_response.is_available() {
123 AvailabilityResult {
124 domain: domain.to_string(),
125 available: true,
126 confidence: "high".to_string(),
127 method: "whois".to_string(),
128 details: Some("WHOIS indicates domain is not registered".to_string()),
129 }
130 } else {
131 AvailabilityResult {
132 domain: domain.to_string(),
133 available: false,
134 confidence: "high".to_string(),
135 method: "whois".to_string(),
136 details: whois_response
137 .registrar
138 .map(|r| format!("Registered with {}", r)),
139 }
140 }
141 }
142 Err(whois_err) => {
143 let whois_msg = whois_err.to_string().to_lowercase();
145 let likely_available = whois_msg.contains("no match")
146 || whois_msg.contains("not found")
147 || whois_msg.contains("no data found")
148 || whois_msg.contains("no entries found");
149
150 if likely_available {
151 AvailabilityResult {
152 domain: domain.to_string(),
153 available: true,
154 confidence: "medium".to_string(),
155 method: "whois_error".to_string(),
156 details: Some("WHOIS server indicates no matching records".to_string()),
157 }
158 } else {
159 AvailabilityResult {
165 domain: domain.to_string(),
166 available: false,
167 confidence: "none".to_string(),
168 method: "inconclusive".to_string(),
169 details: Some(format!(
174 "Could not determine availability. RDAP: {}. WHOIS: {}",
175 rdap_err.sanitized_message(),
176 whois_err.sanitized_message()
177 )),
178 }
179 }
180 }
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::error::SeerError;
188 use crate::rdap::RdapResponse;
189 use crate::whois::WhoisResponse;
190
191 #[test]
192 fn verdict_matrix() {
193 let make = |available, confidence: &str| AvailabilityResult {
194 domain: "example.test".to_string(),
195 available,
196 confidence: confidence.to_string(),
197 method: "whois".to_string(),
198 details: None,
199 };
200 assert_eq!(make(true, "high").verdict(), "available");
201 assert_eq!(make(true, "medium").verdict(), "likely_available");
202 assert_eq!(make(false, "high").verdict(), "registered");
203 assert_eq!(make(false, "medium").verdict(), "likely_registered");
204 assert_eq!(make(false, "none").verdict(), "unknown");
205 assert_eq!(make(true, "low").verdict(), "unknown");
206 }
207
208 #[test]
209 fn test_availability_result_serialization() {
210 let result = AvailabilityResult {
211 domain: "example.com".to_string(),
212 available: false,
213 confidence: "high".to_string(),
214 method: "rdap".to_string(),
215 details: Some("Domain is registered".to_string()),
216 };
217 let json = serde_json::to_string(&result).unwrap();
218 assert!(json.contains("\"available\":false"));
219 assert!(json.contains("\"confidence\":\"high\""));
220 }
221
222 fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
234 WhoisResponse {
235 domain: "example.test".to_string(),
236 registrar: registrar.map(str::to_string),
237 registrant: None,
238 organization: None,
239 registrant_email: None,
240 registrant_phone: None,
241 registrant_address: None,
242 registrant_country: None,
243 admin_name: None,
244 admin_organization: None,
245 admin_email: None,
246 admin_phone: None,
247 tech_name: None,
248 tech_organization: None,
249 tech_email: None,
250 tech_phone: None,
251 creation_date: None,
252 expiration_date: None,
253 updated_date: None,
254 nameservers: vec![],
255 status: vec![],
256 dnssec: None,
257 whois_server: "whois.test".to_string(),
258 raw_response: raw.to_string(),
259 }
260 }
261
262 fn rdap_with(statuses: &[&str]) -> RdapResponse {
263 RdapResponse {
264 status: statuses.iter().map(|s| s.to_string()).collect(),
265 ldh_name: Some("example.test".to_string()),
266 ..Default::default()
267 }
268 }
269
270 #[test]
273 fn rdap_success_registered_marks_taken_high_confidence() {
274 let rdap = rdap_with(&["active"]);
275 let r = decide_from_rdap("example.test", rdap);
276 assert!(!r.available, "registered domain must be marked taken");
277 assert_eq!(r.confidence, "high");
278 assert_eq!(r.method, "rdap");
279 assert!(
280 r.details.as_deref().unwrap().contains("active"),
281 "details should include status list"
282 );
283 }
284
285 #[test]
286 fn rdap_success_empty_status_marks_taken_high_confidence() {
287 let rdap = rdap_with(&[]);
290 let r = decide_from_rdap("example.test", rdap);
291 assert!(!r.available);
292 assert_eq!(r.confidence, "high");
293 assert_eq!(r.method, "rdap");
294 }
295
296 #[test]
297 fn rdap_success_redemption_period_marks_taken_medium_confidence() {
298 let rdap = rdap_with(&["redemption period"]);
299 let r = decide_from_rdap("example.test", rdap);
300 assert!(!r.available, "redemption period still means taken");
301 assert_eq!(r.confidence, "medium", "redemption drops confidence");
302 assert_eq!(r.method, "rdap");
303 assert!(r.details.as_deref().unwrap().contains("redemption"));
304 }
305
306 #[test]
307 fn rdap_success_pending_delete_marks_taken_medium_confidence() {
308 let rdap = rdap_with(&["pending delete"]);
309 let r = decide_from_rdap("example.test", rdap);
310 assert!(!r.available);
311 assert_eq!(r.confidence, "medium");
312 assert!(r.details.as_deref().unwrap().contains("redemption"));
313 }
314
315 #[test]
318 fn rdap_fail_whois_says_available_high_confidence() {
319 let whois = whois_with("No match for \"example.test\".\n", None);
322 let rdap_err = SeerError::RdapError("404 not found".to_string());
323 let r = decide_fallback("example.test", &rdap_err, Ok(whois));
324 assert!(r.available, "WHOIS 'no match' must mark available");
325 assert_eq!(r.confidence, "high");
326 assert_eq!(r.method, "whois");
327 }
328
329 #[test]
330 fn rdap_fail_whois_says_registered_high_confidence() {
331 let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
332 let rdap_err = SeerError::RdapError("404 not found".to_string());
333 let r = decide_fallback("example.test", &rdap_err, Ok(whois));
334 assert!(!r.available);
335 assert_eq!(r.confidence, "high");
336 assert_eq!(r.method, "whois");
337 assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
338 }
339
340 #[test]
341 fn rdap_fail_whois_registered_without_registrar_no_detail() {
342 let whois = whois_with("Domain Name: example.test\n", None);
345 let rdap_err = SeerError::RdapError("404".to_string());
346 let r = decide_fallback("example.test", &rdap_err, Ok(whois));
347 assert!(!r.available);
348 assert_eq!(r.confidence, "high");
349 assert!(
350 r.details.is_none(),
351 "no registrar means no details string, got: {:?}",
352 r.details
353 );
354 }
355
356 #[test]
359 fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
360 let rdap_err = SeerError::RdapError("500".to_string());
361 let whois_err =
362 SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
363 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
364 assert!(
365 r.available,
366 "whois error containing 'no match' is available"
367 );
368 assert_eq!(r.confidence, "medium");
369 assert_eq!(r.method, "whois_error");
370 }
371
372 #[test]
373 fn rdap_fail_whois_error_not_found_marks_available_medium() {
374 let rdap_err = SeerError::RdapError("500".to_string());
375 let whois_err = SeerError::WhoisError("Domain not found".to_string());
376 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
377 assert!(r.available);
378 assert_eq!(r.confidence, "medium");
379 assert_eq!(r.method, "whois_error");
380 }
381
382 #[test]
383 fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
384 let rdap_err = SeerError::RdapError("no".to_string());
385 let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
386 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
387 assert!(r.available);
388 assert_eq!(r.confidence, "medium");
389 }
390
391 #[test]
392 fn rdap_fail_whois_error_no_entries_marks_available_medium() {
393 let rdap_err = SeerError::RdapError("no".to_string());
394 let whois_err =
395 SeerError::WhoisError("No entries found for the selected source".to_string());
396 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
397 assert!(r.available);
398 assert_eq!(r.confidence, "medium");
399 }
400
401 #[test]
402 fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
403 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
404 let whois_err = SeerError::Timeout("whois timed out".to_string());
405 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
406 assert!(
407 !r.available,
408 "inconclusive means NOT available (fail-safe default)"
409 );
410 assert_eq!(r.confidence, "none");
411 assert_eq!(r.method, "inconclusive");
412 assert!(r.details.as_deref().unwrap().contains("RDAP:"));
413 assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
414 }
415
416 #[test]
417 fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
418 let rdap_err = SeerError::RdapError("connection refused".to_string());
419 let whois_err = SeerError::WhoisError(
420 "failed to connect to whois.example: connection refused".to_string(),
421 );
422 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
423 assert!(!r.available);
424 assert_eq!(r.confidence, "none");
425 assert_eq!(r.method, "inconclusive");
426 }
427
428 #[test]
429 fn rdap_fail_whois_error_case_insensitive_not_found() {
430 let rdap_err = SeerError::RdapError("500".to_string());
433 let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
434 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
435 assert!(r.available, "'NOT FOUND' should classify as available");
436 assert_eq!(r.confidence, "medium");
437 }
438}