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!(
170 "Could not determine availability. RDAP: {}. WHOIS: {}",
171 rdap_err, whois_err
172 )),
173 }
174 }
175 }
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::error::SeerError;
183 use crate::rdap::RdapResponse;
184 use crate::whois::WhoisResponse;
185
186 #[test]
187 fn verdict_matrix() {
188 let make = |available, confidence: &str| AvailabilityResult {
189 domain: "example.test".to_string(),
190 available,
191 confidence: confidence.to_string(),
192 method: "whois".to_string(),
193 details: None,
194 };
195 assert_eq!(make(true, "high").verdict(), "available");
196 assert_eq!(make(true, "medium").verdict(), "likely_available");
197 assert_eq!(make(false, "high").verdict(), "registered");
198 assert_eq!(make(false, "medium").verdict(), "likely_registered");
199 assert_eq!(make(false, "none").verdict(), "unknown");
200 assert_eq!(make(true, "low").verdict(), "unknown");
201 }
202
203 #[test]
204 fn test_availability_result_serialization() {
205 let result = AvailabilityResult {
206 domain: "example.com".to_string(),
207 available: false,
208 confidence: "high".to_string(),
209 method: "rdap".to_string(),
210 details: Some("Domain is registered".to_string()),
211 };
212 let json = serde_json::to_string(&result).unwrap();
213 assert!(json.contains("\"available\":false"));
214 assert!(json.contains("\"confidence\":\"high\""));
215 }
216
217 fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
229 WhoisResponse {
230 domain: "example.test".to_string(),
231 registrar: registrar.map(str::to_string),
232 registrant: None,
233 organization: None,
234 registrant_email: None,
235 registrant_phone: None,
236 registrant_address: None,
237 registrant_country: None,
238 admin_name: None,
239 admin_organization: None,
240 admin_email: None,
241 admin_phone: None,
242 tech_name: None,
243 tech_organization: None,
244 tech_email: None,
245 tech_phone: None,
246 creation_date: None,
247 expiration_date: None,
248 updated_date: None,
249 nameservers: vec![],
250 status: vec![],
251 dnssec: None,
252 whois_server: "whois.test".to_string(),
253 raw_response: raw.to_string(),
254 }
255 }
256
257 fn rdap_with(statuses: &[&str]) -> RdapResponse {
258 RdapResponse {
259 status: statuses.iter().map(|s| s.to_string()).collect(),
260 ldh_name: Some("example.test".to_string()),
261 ..Default::default()
262 }
263 }
264
265 #[test]
268 fn rdap_success_registered_marks_taken_high_confidence() {
269 let rdap = rdap_with(&["active"]);
270 let r = decide_from_rdap("example.test", rdap);
271 assert!(!r.available, "registered domain must be marked taken");
272 assert_eq!(r.confidence, "high");
273 assert_eq!(r.method, "rdap");
274 assert!(
275 r.details.as_deref().unwrap().contains("active"),
276 "details should include status list"
277 );
278 }
279
280 #[test]
281 fn rdap_success_empty_status_marks_taken_high_confidence() {
282 let rdap = rdap_with(&[]);
285 let r = decide_from_rdap("example.test", rdap);
286 assert!(!r.available);
287 assert_eq!(r.confidence, "high");
288 assert_eq!(r.method, "rdap");
289 }
290
291 #[test]
292 fn rdap_success_redemption_period_marks_taken_medium_confidence() {
293 let rdap = rdap_with(&["redemption period"]);
294 let r = decide_from_rdap("example.test", rdap);
295 assert!(!r.available, "redemption period still means taken");
296 assert_eq!(r.confidence, "medium", "redemption drops confidence");
297 assert_eq!(r.method, "rdap");
298 assert!(r.details.as_deref().unwrap().contains("redemption"));
299 }
300
301 #[test]
302 fn rdap_success_pending_delete_marks_taken_medium_confidence() {
303 let rdap = rdap_with(&["pending delete"]);
304 let r = decide_from_rdap("example.test", rdap);
305 assert!(!r.available);
306 assert_eq!(r.confidence, "medium");
307 assert!(r.details.as_deref().unwrap().contains("redemption"));
308 }
309
310 #[test]
313 fn rdap_fail_whois_says_available_high_confidence() {
314 let whois = whois_with("No match for \"example.test\".\n", None);
317 let rdap_err = SeerError::RdapError("404 not found".to_string());
318 let r = decide_fallback("example.test", &rdap_err, Ok(whois));
319 assert!(r.available, "WHOIS 'no match' must mark available");
320 assert_eq!(r.confidence, "high");
321 assert_eq!(r.method, "whois");
322 }
323
324 #[test]
325 fn rdap_fail_whois_says_registered_high_confidence() {
326 let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
327 let rdap_err = SeerError::RdapError("404 not found".to_string());
328 let r = decide_fallback("example.test", &rdap_err, Ok(whois));
329 assert!(!r.available);
330 assert_eq!(r.confidence, "high");
331 assert_eq!(r.method, "whois");
332 assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
333 }
334
335 #[test]
336 fn rdap_fail_whois_registered_without_registrar_no_detail() {
337 let whois = whois_with("Domain Name: example.test\n", None);
340 let rdap_err = SeerError::RdapError("404".to_string());
341 let r = decide_fallback("example.test", &rdap_err, Ok(whois));
342 assert!(!r.available);
343 assert_eq!(r.confidence, "high");
344 assert!(
345 r.details.is_none(),
346 "no registrar means no details string, got: {:?}",
347 r.details
348 );
349 }
350
351 #[test]
354 fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
355 let rdap_err = SeerError::RdapError("500".to_string());
356 let whois_err =
357 SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
358 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
359 assert!(
360 r.available,
361 "whois error containing 'no match' is available"
362 );
363 assert_eq!(r.confidence, "medium");
364 assert_eq!(r.method, "whois_error");
365 }
366
367 #[test]
368 fn rdap_fail_whois_error_not_found_marks_available_medium() {
369 let rdap_err = SeerError::RdapError("500".to_string());
370 let whois_err = SeerError::WhoisError("Domain not found".to_string());
371 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
372 assert!(r.available);
373 assert_eq!(r.confidence, "medium");
374 assert_eq!(r.method, "whois_error");
375 }
376
377 #[test]
378 fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
379 let rdap_err = SeerError::RdapError("no".to_string());
380 let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
381 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
382 assert!(r.available);
383 assert_eq!(r.confidence, "medium");
384 }
385
386 #[test]
387 fn rdap_fail_whois_error_no_entries_marks_available_medium() {
388 let rdap_err = SeerError::RdapError("no".to_string());
389 let whois_err =
390 SeerError::WhoisError("No entries found for the selected source".to_string());
391 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
392 assert!(r.available);
393 assert_eq!(r.confidence, "medium");
394 }
395
396 #[test]
397 fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
398 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
399 let whois_err = SeerError::Timeout("whois timed out".to_string());
400 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
401 assert!(
402 !r.available,
403 "inconclusive means NOT available (fail-safe default)"
404 );
405 assert_eq!(r.confidence, "none");
406 assert_eq!(r.method, "inconclusive");
407 assert!(r.details.as_deref().unwrap().contains("RDAP:"));
408 assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
409 }
410
411 #[test]
412 fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
413 let rdap_err = SeerError::RdapError("connection refused".to_string());
414 let whois_err = SeerError::WhoisError(
415 "failed to connect to whois.example: connection refused".to_string(),
416 );
417 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
418 assert!(!r.available);
419 assert_eq!(r.confidence, "none");
420 assert_eq!(r.method, "inconclusive");
421 }
422
423 #[test]
424 fn rdap_fail_whois_error_case_insensitive_not_found() {
425 let rdap_err = SeerError::RdapError("500".to_string());
428 let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
429 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
430 assert!(r.available, "'NOT FOUND' should classify as available");
431 assert_eq!(r.confidence, "medium");
432 }
433}