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
28#[derive(Debug, Clone)]
30pub struct AvailabilityChecker {
31 rdap_client: RdapClient,
32 whois_client: WhoisClient,
33}
34
35impl Default for AvailabilityChecker {
36 fn default() -> Self {
37 Self::new()
38 }
39}
40
41impl AvailabilityChecker {
42 pub fn new() -> Self {
43 Self {
44 rdap_client: RdapClient::new(),
45 whois_client: WhoisClient::new(),
46 }
47 }
48
49 #[instrument(skip(self), fields(domain = %domain))]
51 pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
52 let domain = crate::validation::normalize_domain(domain)?;
53 debug!(domain = %domain, "Checking domain availability");
54
55 match self.rdap_client.lookup_domain(&domain).await {
57 Ok(response) => Ok(decide_from_rdap(&domain, response)),
58 Err(rdap_err) => {
59 debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS");
60 let whois_result = self.whois_client.lookup(&domain).await;
61 Ok(decide_fallback(&domain, &rdap_err, whois_result))
62 }
63 }
64 }
65}
66
67fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
71 let statuses: Vec<String> = response.status.clone();
72 let is_redemption = statuses
73 .iter()
74 .any(|s| s.contains("redemption") || s.contains("pending delete"));
75
76 if is_redemption {
77 return AvailabilityResult {
78 domain: domain.to_string(),
79 available: false,
80 confidence: "medium".to_string(),
81 method: "rdap".to_string(),
82 details: Some("Domain is in redemption/pending delete period".to_string()),
83 };
84 }
85
86 AvailabilityResult {
87 domain: domain.to_string(),
88 available: false,
89 confidence: "high".to_string(),
90 method: "rdap".to_string(),
91 details: Some(format!(
92 "Domain is registered (status: {})",
93 statuses.join(", ")
94 )),
95 }
96}
97
98fn decide_fallback(
101 domain: &str,
102 rdap_err: &crate::error::SeerError,
103 whois_result: Result<crate::whois::WhoisResponse>,
104) -> AvailabilityResult {
105 match whois_result {
106 Ok(whois_response) => {
107 if whois_response.is_available() {
108 AvailabilityResult {
109 domain: domain.to_string(),
110 available: true,
111 confidence: "high".to_string(),
112 method: "whois".to_string(),
113 details: Some("WHOIS indicates domain is not registered".to_string()),
114 }
115 } else {
116 AvailabilityResult {
117 domain: domain.to_string(),
118 available: false,
119 confidence: "high".to_string(),
120 method: "whois".to_string(),
121 details: whois_response
122 .registrar
123 .map(|r| format!("Registered with {}", r)),
124 }
125 }
126 }
127 Err(whois_err) => {
128 let whois_msg = whois_err.to_string().to_lowercase();
130 let likely_available = whois_msg.contains("no match")
131 || whois_msg.contains("not found")
132 || whois_msg.contains("no data found")
133 || whois_msg.contains("no entries found");
134
135 if likely_available {
136 AvailabilityResult {
137 domain: domain.to_string(),
138 available: true,
139 confidence: "medium".to_string(),
140 method: "whois_error".to_string(),
141 details: Some("WHOIS server indicates no matching records".to_string()),
142 }
143 } else {
144 AvailabilityResult {
150 domain: domain.to_string(),
151 available: false,
152 confidence: "none".to_string(),
153 method: "inconclusive".to_string(),
154 details: Some(format!(
155 "Could not determine availability. RDAP: {}. WHOIS: {}",
156 rdap_err, whois_err
157 )),
158 }
159 }
160 }
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::error::SeerError;
168 use crate::rdap::RdapResponse;
169 use crate::whois::WhoisResponse;
170
171 #[test]
172 fn test_availability_result_serialization() {
173 let result = AvailabilityResult {
174 domain: "example.com".to_string(),
175 available: false,
176 confidence: "high".to_string(),
177 method: "rdap".to_string(),
178 details: Some("Domain is registered".to_string()),
179 };
180 let json = serde_json::to_string(&result).unwrap();
181 assert!(json.contains("\"available\":false"));
182 assert!(json.contains("\"confidence\":\"high\""));
183 }
184
185 fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
197 WhoisResponse {
198 domain: "example.test".to_string(),
199 registrar: registrar.map(str::to_string),
200 registrant: None,
201 organization: None,
202 registrant_email: None,
203 registrant_phone: None,
204 registrant_address: None,
205 registrant_country: None,
206 admin_name: None,
207 admin_organization: None,
208 admin_email: None,
209 admin_phone: None,
210 tech_name: None,
211 tech_organization: None,
212 tech_email: None,
213 tech_phone: None,
214 creation_date: None,
215 expiration_date: None,
216 updated_date: None,
217 nameservers: vec![],
218 status: vec![],
219 dnssec: None,
220 whois_server: "whois.test".to_string(),
221 raw_response: raw.to_string(),
222 }
223 }
224
225 fn rdap_with(statuses: &[&str]) -> RdapResponse {
226 RdapResponse {
227 status: statuses.iter().map(|s| s.to_string()).collect(),
228 ldh_name: Some("example.test".to_string()),
229 ..Default::default()
230 }
231 }
232
233 #[test]
236 fn rdap_success_registered_marks_taken_high_confidence() {
237 let rdap = rdap_with(&["active"]);
238 let r = decide_from_rdap("example.test", rdap);
239 assert!(!r.available, "registered domain must be marked taken");
240 assert_eq!(r.confidence, "high");
241 assert_eq!(r.method, "rdap");
242 assert!(
243 r.details.as_deref().unwrap().contains("active"),
244 "details should include status list"
245 );
246 }
247
248 #[test]
249 fn rdap_success_empty_status_marks_taken_high_confidence() {
250 let rdap = rdap_with(&[]);
253 let r = decide_from_rdap("example.test", rdap);
254 assert!(!r.available);
255 assert_eq!(r.confidence, "high");
256 assert_eq!(r.method, "rdap");
257 }
258
259 #[test]
260 fn rdap_success_redemption_period_marks_taken_medium_confidence() {
261 let rdap = rdap_with(&["redemption period"]);
262 let r = decide_from_rdap("example.test", rdap);
263 assert!(!r.available, "redemption period still means taken");
264 assert_eq!(r.confidence, "medium", "redemption drops confidence");
265 assert_eq!(r.method, "rdap");
266 assert!(r.details.as_deref().unwrap().contains("redemption"));
267 }
268
269 #[test]
270 fn rdap_success_pending_delete_marks_taken_medium_confidence() {
271 let rdap = rdap_with(&["pending delete"]);
272 let r = decide_from_rdap("example.test", rdap);
273 assert!(!r.available);
274 assert_eq!(r.confidence, "medium");
275 assert!(r.details.as_deref().unwrap().contains("redemption"));
276 }
277
278 #[test]
281 fn rdap_fail_whois_says_available_high_confidence() {
282 let whois = whois_with("No match for \"example.test\".\n", None);
285 let rdap_err = SeerError::RdapError("404 not found".to_string());
286 let r = decide_fallback("example.test", &rdap_err, Ok(whois));
287 assert!(r.available, "WHOIS 'no match' must mark available");
288 assert_eq!(r.confidence, "high");
289 assert_eq!(r.method, "whois");
290 }
291
292 #[test]
293 fn rdap_fail_whois_says_registered_high_confidence() {
294 let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
295 let rdap_err = SeerError::RdapError("404 not found".to_string());
296 let r = decide_fallback("example.test", &rdap_err, Ok(whois));
297 assert!(!r.available);
298 assert_eq!(r.confidence, "high");
299 assert_eq!(r.method, "whois");
300 assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
301 }
302
303 #[test]
304 fn rdap_fail_whois_registered_without_registrar_no_detail() {
305 let whois = whois_with("Domain Name: example.test\n", None);
308 let rdap_err = SeerError::RdapError("404".to_string());
309 let r = decide_fallback("example.test", &rdap_err, Ok(whois));
310 assert!(!r.available);
311 assert_eq!(r.confidence, "high");
312 assert!(
313 r.details.is_none(),
314 "no registrar means no details string, got: {:?}",
315 r.details
316 );
317 }
318
319 #[test]
322 fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
323 let rdap_err = SeerError::RdapError("500".to_string());
324 let whois_err =
325 SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
326 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
327 assert!(
328 r.available,
329 "whois error containing 'no match' is available"
330 );
331 assert_eq!(r.confidence, "medium");
332 assert_eq!(r.method, "whois_error");
333 }
334
335 #[test]
336 fn rdap_fail_whois_error_not_found_marks_available_medium() {
337 let rdap_err = SeerError::RdapError("500".to_string());
338 let whois_err = SeerError::WhoisError("Domain not found".to_string());
339 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
340 assert!(r.available);
341 assert_eq!(r.confidence, "medium");
342 assert_eq!(r.method, "whois_error");
343 }
344
345 #[test]
346 fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
347 let rdap_err = SeerError::RdapError("no".to_string());
348 let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
349 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
350 assert!(r.available);
351 assert_eq!(r.confidence, "medium");
352 }
353
354 #[test]
355 fn rdap_fail_whois_error_no_entries_marks_available_medium() {
356 let rdap_err = SeerError::RdapError("no".to_string());
357 let whois_err =
358 SeerError::WhoisError("No entries found for the selected source".to_string());
359 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
360 assert!(r.available);
361 assert_eq!(r.confidence, "medium");
362 }
363
364 #[test]
365 fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
366 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
367 let whois_err = SeerError::Timeout("whois timed out".to_string());
368 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
369 assert!(
370 !r.available,
371 "inconclusive means NOT available (fail-safe default)"
372 );
373 assert_eq!(r.confidence, "none");
374 assert_eq!(r.method, "inconclusive");
375 assert!(r.details.as_deref().unwrap().contains("RDAP:"));
376 assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
377 }
378
379 #[test]
380 fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
381 let rdap_err = SeerError::RdapError("connection refused".to_string());
382 let whois_err = SeerError::WhoisError(
383 "failed to connect to whois.example: connection refused".to_string(),
384 );
385 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
386 assert!(!r.available);
387 assert_eq!(r.confidence, "none");
388 assert_eq!(r.method, "inconclusive");
389 }
390
391 #[test]
392 fn rdap_fail_whois_error_case_insensitive_not_found() {
393 let rdap_err = SeerError::RdapError("500".to_string());
396 let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
397 let r = decide_fallback("example.test", &rdap_err, Err(whois_err));
398 assert!(r.available, "'NOT FOUND' should classify as available");
399 assert_eq!(r.confidence, "medium");
400 }
401}