Skip to main content

seer_core/
availability.rs

1//! Domain availability checking.
2//!
3//! Determines if a domain is available for registration by interpreting
4//! WHOIS/RDAP "not found" responses.
5
6use serde::{Deserialize, Serialize};
7use tracing::{debug, instrument};
8
9use crate::error::Result;
10use crate::rdap::RdapClient;
11use crate::whois::WhoisClient;
12
13/// Result of a domain availability check.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct AvailabilityResult {
16    /// The domain that was checked.
17    pub domain: String,
18    /// Whether the domain appears to be available for registration.
19    pub available: bool,
20    /// Confidence level of the result ("high", "medium", "low").
21    pub confidence: String,
22    /// How availability was determined.
23    pub method: String,
24    /// Additional details about the check.
25    pub details: Option<String>,
26}
27
28/// Checks domain availability by attempting lookups and interpreting failures.
29#[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    /// Check if a domain is available for registration.
50    #[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        // Try RDAP first - it gives structured error responses.
56        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
67/// Pure decision function: build an `AvailabilityResult` from a successful
68/// RDAP lookup. Extracted from `check()` so the decision matrix can be
69/// table-tested without a network stack.
70fn 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
98/// Pure decision function: build an `AvailabilityResult` when RDAP failed
99/// and WHOIS is the fallback. Extracted from `check()` for table-testing.
100fn 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            // Both failed - domain might be available or queries blocked
129            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                // Both queries failed with non-"not found" errors.
145                // We genuinely don't know — could be registered, could be
146                // blocked by the registrar, or servers could be down.
147                // Default to available=false to avoid misleading the user
148                // into thinking they can register a domain that's actually taken.
149                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    // ------------------------------------------------------------------
186    // M11: Decision matrix coverage for `check()`.
187    //
188    // Tests the pure decision helpers — `decide_from_rdap` and
189    // `decide_fallback` — that were extracted from `check()` for
190    // hermetic testing. Each case asserts (available, confidence, method)
191    // against a realistic input shape.
192    // ------------------------------------------------------------------
193
194    /// Small helper to build an empty WhoisResponse with the given fields
195    /// populated; used to keep the test table concise.
196    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    // --- RDAP success branches ---------------------------------------
234
235    #[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        // Some RDAP servers return 200 with no status array populated; the
251        // existence of the object still means the domain is registered.
252        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    // --- WHOIS fallback branches -------------------------------------
279
280    #[test]
281    fn rdap_fail_whois_says_available_high_confidence() {
282        // is_available() reads raw_response and looks for the patterns
283        // that every TLD uses to signal unregistered.
284        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        // Corner case: has_core_data is false but not-available, so the
306        // details string is None (registrar field is None).
307        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    // --- Both-fail branches ------------------------------------------
320
321    #[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        // The real code lowercases before matching; verify the Uppercase
394        // form still classifies correctly.
395        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}