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                    // Use the sanitized error projection so this string —
170                    // which flows into JSON / CSV / MCP output paths —
171                    // never carries raw ANSI escapes or internal IPs from
172                    // a third-party WHOIS/RDAP server's error message.
173                    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    // ------------------------------------------------------------------
223    // M11: Decision matrix coverage for `check()`.
224    //
225    // Tests the pure decision helpers — `decide_from_rdap` and
226    // `decide_fallback` — that were extracted from `check()` for
227    // hermetic testing. Each case asserts (available, confidence, method)
228    // against a realistic input shape.
229    // ------------------------------------------------------------------
230
231    /// Small helper to build an empty WhoisResponse with the given fields
232    /// populated; used to keep the test table concise.
233    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    // --- RDAP success branches ---------------------------------------
271
272    #[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        // Some RDAP servers return 200 with no status array populated; the
288        // existence of the object still means the domain is registered.
289        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    // --- WHOIS fallback branches -------------------------------------
316
317    #[test]
318    fn rdap_fail_whois_says_available_high_confidence() {
319        // is_available() reads raw_response and looks for the patterns
320        // that every TLD uses to signal unregistered.
321        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        // Corner case: has_core_data is false but not-available, so the
343        // details string is None (registrar field is None).
344        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    // --- Both-fail branches ------------------------------------------
357
358    #[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        // The real code lowercases before matching; verify the Uppercase
431        // form still classifies correctly.
432        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}