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
28impl AvailabilityResult {
29    /// Stable verdict string derived from `(available, confidence)`. Use this
30    /// instead of branching on `confidence` alone — a `confidence: "high"`
31    /// result can still mean "registered" when `available == false`.
32    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/// Checks domain availability by attempting lookups and interpreting failures.
44#[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    /// Check if a domain is available for registration.
65    #[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        // Try RDAP first - it gives structured error responses.
71        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
82/// Pure decision function: build an `AvailabilityResult` from a successful
83/// RDAP lookup. Extracted from `check()` so the decision matrix can be
84/// table-tested without a network stack.
85fn 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
113/// Pure decision function: build an `AvailabilityResult` when RDAP failed
114/// and WHOIS is the fallback. Extracted from `check()` for table-testing.
115fn 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            // Both failed - domain might be available or queries blocked
144            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                // Both queries failed with non-"not found" errors.
160                // We genuinely don't know — could be registered, could be
161                // blocked by the registrar, or servers could be down.
162                // Default to available=false to avoid misleading the user
163                // into thinking they can register a domain that's actually taken.
164                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    // ------------------------------------------------------------------
218    // M11: Decision matrix coverage for `check()`.
219    //
220    // Tests the pure decision helpers — `decide_from_rdap` and
221    // `decide_fallback` — that were extracted from `check()` for
222    // hermetic testing. Each case asserts (available, confidence, method)
223    // against a realistic input shape.
224    // ------------------------------------------------------------------
225
226    /// Small helper to build an empty WhoisResponse with the given fields
227    /// populated; used to keep the test table concise.
228    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    // --- RDAP success branches ---------------------------------------
266
267    #[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        // Some RDAP servers return 200 with no status array populated; the
283        // existence of the object still means the domain is registered.
284        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    // --- WHOIS fallback branches -------------------------------------
311
312    #[test]
313    fn rdap_fail_whois_says_available_high_confidence() {
314        // is_available() reads raw_response and looks for the patterns
315        // that every TLD uses to signal unregistered.
316        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        // Corner case: has_core_data is false but not-available, so the
338        // details string is None (registrar field is None).
339        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    // --- Both-fail branches ------------------------------------------
352
353    #[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        // The real code lowercases before matching; verify the Uppercase
426        // form still classifies correctly.
427        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}