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) => {
58                // Domain exists in RDAP - check status
59                let statuses: Vec<String> = response.status.clone();
60                let is_redemption = statuses
61                    .iter()
62                    .any(|s| s.contains("redemption") || s.contains("pending delete"));
63
64                if is_redemption {
65                    return Ok(AvailabilityResult {
66                        domain,
67                        available: false,
68                        confidence: "medium".to_string(),
69                        method: "rdap".to_string(),
70                        details: Some("Domain is in redemption/pending delete period".to_string()),
71                    });
72                }
73
74                Ok(AvailabilityResult {
75                    domain,
76                    available: false,
77                    confidence: "high".to_string(),
78                    method: "rdap".to_string(),
79                    details: Some(format!(
80                        "Domain is registered (status: {})",
81                        statuses.join(", ")
82                    )),
83                })
84            }
85            Err(rdap_err) => {
86                debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS");
87                match self.whois_client.lookup(&domain).await {
88                    Ok(whois_response) => {
89                        if whois_response.is_available() {
90                            Ok(AvailabilityResult {
91                                domain,
92                                available: true,
93                                confidence: "high".to_string(),
94                                method: "whois".to_string(),
95                                details: Some(
96                                    "WHOIS indicates domain is not registered".to_string(),
97                                ),
98                            })
99                        } else {
100                            Ok(AvailabilityResult {
101                                domain,
102                                available: false,
103                                confidence: "high".to_string(),
104                                method: "whois".to_string(),
105                                details: whois_response
106                                    .registrar
107                                    .map(|r| format!("Registered with {}", r)),
108                            })
109                        }
110                    }
111                    Err(whois_err) => {
112                        // Both failed - domain might be available or queries blocked
113                        let whois_msg = whois_err.to_string().to_lowercase();
114                        let likely_available = whois_msg.contains("no match")
115                            || whois_msg.contains("not found")
116                            || whois_msg.contains("no data found")
117                            || whois_msg.contains("no entries found");
118
119                        if likely_available {
120                            Ok(AvailabilityResult {
121                                domain,
122                                available: true,
123                                confidence: "medium".to_string(),
124                                method: "whois_error".to_string(),
125                                details: Some(
126                                    "WHOIS server indicates no matching records".to_string(),
127                                ),
128                            })
129                        } else {
130                            // Both queries failed with non-"not found" errors.
131                            // We genuinely don't know — could be registered, could be
132                            // blocked by the registrar, or servers could be down.
133                            // Default to available=false to avoid misleading the user
134                            // into thinking they can register a domain that's actually taken.
135                            let rdap_detail = rdap_err.to_string();
136                            let whois_detail = whois_err.to_string();
137                            Ok(AvailabilityResult {
138                                domain,
139                                available: false,
140                                confidence: "none".to_string(),
141                                method: "inconclusive".to_string(),
142                                details: Some(format!(
143                                    "Could not determine availability. RDAP: {}. WHOIS: {}",
144                                    rdap_detail, whois_detail
145                                )),
146                            })
147                        }
148                    }
149                }
150            }
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_availability_result_serialization() {
161        let result = AvailabilityResult {
162            domain: "example.com".to_string(),
163            available: false,
164            confidence: "high".to_string(),
165            method: "rdap".to_string(),
166            details: Some("Domain is registered".to_string()),
167        };
168        let json = serde_json::to_string(&result).unwrap();
169        assert!(json.contains("\"available\":false"));
170        assert!(json.contains("\"confidence\":\"high\""));
171    }
172}