use crate::error::DomainCheckError;
use crate::forsale::{ForSaleFailures, ForSaleLookup};
use crate::protocols::{RdapClient, WhoisClient};
use crate::types::{CheckConfig, CheckMethod, DomainResult};
use crate::utils::validate_domain;
use futures_util::stream::{Stream, StreamExt};
use std::net::IpAddr;
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::Semaphore;
async fn check_availability_concurrent(
domain: &str,
rdap_client: &RdapClient,
whois_client: &WhoisClient,
config: &CheckConfig,
) -> Result<DomainResult, DomainCheckError> {
validate_domain(domain)?;
match rdap_client.check_domain(domain).await {
Ok(result) => Ok(filter_result_info(config, result)),
Err(rdap_error) => {
if config.enable_whois_fallback {
let whois_result = whois_client.check_domain(domain).await;
match whois_result {
Ok(whois_result) => Ok(filter_result_info(config, whois_result)),
Err(whois_error) => {
if rdap_404_is_final(&rdap_error, &whois_error)
|| (rdap_error.indicates_available()
&& whois_error.indicates_available())
{
Ok(DomainResult {
domain: domain.to_string(),
available: Some(true),
info: None,
check_duration: None,
method_used: CheckMethod::Rdap,
error_message: None,
for_sale: None,
})
}
else if whois_error.indicates_available() {
Ok(DomainResult {
domain: domain.to_string(),
available: Some(true),
info: None,
check_duration: None,
method_used: CheckMethod::Whois,
error_message: None,
for_sale: None,
})
}
else if matches!(rdap_error, DomainCheckError::BootstrapError { .. })
|| matches!(
whois_error,
DomainCheckError::BootstrapError { .. }
| DomainCheckError::WhoisError { .. }
)
|| rdap_error.indicates_available()
{
let reason = whois_failure_reason(&whois_error);
Ok(DomainResult {
domain: domain.to_string(),
available: None, info: None,
check_duration: None,
method_used: CheckMethod::Unknown,
error_message: Some(format!(
"Unable to verify — RDAP inconclusive; WHOIS: {reason}"
)),
for_sale: None,
})
} else {
Err(rdap_error)
}
}
}
} else {
if rdap_error.indicates_available() {
Ok(DomainResult {
domain: domain.to_string(),
available: Some(true),
info: None,
check_duration: None,
method_used: CheckMethod::Rdap,
error_message: Some(
"RDAP 404 (unverified — WHOIS fallback disabled)".to_string(),
),
for_sale: None,
})
} else {
Err(rdap_error)
}
}
}
}
}
fn rdap_404_is_final(rdap_error: &DomainCheckError, whois_error: &DomainCheckError) -> bool {
rdap_error.indicates_available() && crate::protocols::whois::is_no_whois_service(whois_error)
}
fn whois_failure_reason(error: &DomainCheckError) -> String {
match error {
DomainCheckError::WhoisError { message, .. } => message.clone(),
DomainCheckError::Timeout { duration, .. } => {
format!("timed out after {}s", duration.as_secs())
}
_ => "unavailable".to_string(),
}
}
fn filter_result_info(config: &CheckConfig, mut result: DomainResult) -> DomainResult {
if !config.detailed_info {
result.info = None;
}
result
}
async fn check_single_domain_concurrent(
domain: &str,
rdap_client: &RdapClient,
whois_client: &WhoisClient,
config: &CheckConfig,
for_sale: &ForSaleLookup,
) -> Result<DomainResult, DomainCheckError> {
match check_availability_concurrent(domain, rdap_client, whois_client, config).await {
Ok(mut result) => {
for_sale.annotate(&mut result).await;
Ok(result)
}
Err(error) => for_sale.annotate_error(domain, error).await,
}
}
#[derive(Clone)]
pub struct DomainChecker {
config: CheckConfig,
rdap_client: RdapClient,
whois_client: WhoisClient,
for_sale: ForSaleLookup,
dns_server: Option<IpAddr>,
}
impl DomainChecker {
pub fn new() -> Self {
let config = CheckConfig::default();
let rdap_client = RdapClient::with_config(config.rdap_timeout, config.enable_bootstrap)
.expect("Failed to create RDAP client");
let whois_client = WhoisClient::with_timeout(config.whois_timeout);
let for_sale = ForSaleLookup::new(&config, None);
Self {
config,
rdap_client,
whois_client,
for_sale,
dns_server: None,
}
}
pub fn with_config(config: CheckConfig) -> Self {
let rdap_client = RdapClient::with_config(config.rdap_timeout, config.enable_bootstrap)
.expect("Failed to create RDAP client");
let whois_client = WhoisClient::with_timeout(config.whois_timeout);
let for_sale = ForSaleLookup::new(&config, None);
Self {
config,
rdap_client,
whois_client,
for_sale,
dns_server: None,
}
}
pub async fn check_domain(&self, domain: &str) -> Result<DomainResult, DomainCheckError> {
check_single_domain_concurrent(
domain,
&self.rdap_client,
&self.whois_client,
&self.config,
&self.for_sale,
)
.await
}
pub async fn check_domains(
&self,
domains: &[String],
) -> Result<Vec<DomainResult>, DomainCheckError> {
if domains.is_empty() {
return Ok(Vec::new());
}
let semaphore = Arc::new(Semaphore::new(self.config.concurrency));
let mut handles = Vec::new();
for (index, domain) in domains.iter().enumerate() {
let domain = domain.clone();
let semaphore = Arc::clone(&semaphore);
let rdap_client = self.rdap_client.clone();
let whois_client = self.whois_client.clone();
let config = self.config.clone();
let for_sale = self.for_sale.clone();
let handle = tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
let result = check_single_domain_concurrent(
&domain,
&rdap_client,
&whois_client,
&config,
&for_sale,
)
.await;
(index, result)
});
handles.push(handle);
}
let mut indexed_results = Vec::new();
for handle in handles {
match handle.await {
Ok((index, result)) => indexed_results.push((index, result)),
Err(e) => {
return Err(DomainCheckError::internal(format!(
"Concurrent task failed: {}",
e
)));
}
}
}
indexed_results.sort_by_key(|(index, _)| *index);
let results = indexed_results
.into_iter()
.map(|(index, result)| match result {
Ok(domain_result) => domain_result,
Err(e) => DomainResult {
domain: domains[index].clone(),
available: None,
info: None,
check_duration: None,
method_used: CheckMethod::Unknown,
error_message: Some(e.to_string()),
for_sale: None,
},
})
.collect();
Ok(results)
}
pub fn check_domains_stream(
&self,
domains: &[String],
) -> Pin<Box<dyn Stream<Item = Result<DomainResult, DomainCheckError>> + Send + '_>> {
let domains = domains.to_vec();
let semaphore = Arc::new(Semaphore::new(self.config.concurrency));
let stream = futures_util::stream::iter(domains)
.map(move |domain| {
let semaphore = Arc::clone(&semaphore);
let rdap_client = self.rdap_client.clone();
let whois_client = self.whois_client.clone();
let config = self.config.clone();
let for_sale = self.for_sale.clone();
async move {
let _permit = semaphore.acquire().await.unwrap();
check_single_domain_concurrent(
&domain,
&rdap_client,
&whois_client,
&config,
&for_sale,
)
.await
}
})
.buffer_unordered(self.config.concurrency);
Box::pin(stream)
}
pub async fn check_domains_from_file(
&self,
file_path: &str,
) -> Result<Vec<DomainResult>, DomainCheckError> {
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
let path = Path::new(file_path);
if !path.exists() {
return Err(DomainCheckError::file_error(file_path, "File not found"));
}
let file = File::open(path).map_err(|e| {
DomainCheckError::file_error(file_path, format!("Cannot open file: {}", e))
})?;
let reader = BufReader::new(file);
let mut domains = Vec::new();
let mut line_num = 0;
for line in reader.lines() {
line_num += 1;
match line {
Ok(line) => {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let domain_part = trimmed.split('#').next().unwrap_or("").trim();
if !domain_part.is_empty() && domain_part.len() >= 2 {
domains.push(domain_part.to_string());
}
}
Err(e) => {
return Err(DomainCheckError::file_error(
file_path,
format!("Error reading line {}: {}", line_num, e),
));
}
}
}
if domains.is_empty() {
return Err(DomainCheckError::file_error(
file_path,
"No valid domains found in file",
));
}
self.check_domains(&domains).await
}
pub fn config(&self) -> &CheckConfig {
&self.config
}
pub fn for_sale_enabled(&self) -> bool {
self.for_sale.is_active()
}
pub fn with_dns_server(mut self, server: IpAddr) -> Self {
self.dns_server = Some(server);
self.for_sale = ForSaleLookup::new(&self.config, self.dns_server);
self
}
pub fn for_sale_failures(&self) -> Option<ForSaleFailures> {
self.for_sale.failures()
}
pub fn set_config(&mut self, config: CheckConfig) {
self.rdap_client = RdapClient::with_config(config.rdap_timeout, config.enable_bootstrap)
.expect("Failed to recreate RDAP client");
self.whois_client = WhoisClient::with_timeout(config.whois_timeout);
self.for_sale = ForSaleLookup::new(&config, self.dns_server);
self.config = config;
}
}
impl Default for DomainChecker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn whois_timeout_reason_is_kept() {
let timeout = DomainCheckError::timeout("WHOIS query", std::time::Duration::from_secs(5));
assert!(whois_failure_reason(&timeout).contains("timed out"));
let refused = DomainCheckError::whois("x.ch", "WHOIS server refused the query");
assert_eq!(
whois_failure_reason(&refused),
"WHOIS server refused the query"
);
}
#[test]
fn rdap_404_is_final_only_when_registry_has_no_whois() {
let not_found = DomainCheckError::rdap_with_status("x.dev", "not found", 404);
let no_whois = crate::protocols::whois::no_whois_service_error("x.dev");
assert!(rdap_404_is_final(¬_found, &no_whois));
let refused = DomainCheckError::whois("x.moe", "WHOIS server refused the query");
assert!(!rdap_404_is_final(¬_found, &refused));
let timeout = DomainCheckError::timeout("WHOIS query", std::time::Duration::from_secs(5));
assert!(!rdap_404_is_final(¬_found, &timeout));
let server_error = DomainCheckError::rdap_with_status("x.dev", "boom", 500);
assert!(!rdap_404_is_final(&server_error, &no_whois));
}
use crate::types::DomainInfo;
use std::time::Duration;
#[test]
fn test_domain_checker_new() {
let checker = DomainChecker::new();
assert_eq!(checker.config().concurrency, 20);
assert!(checker.config().enable_whois_fallback);
assert!(checker.config().enable_bootstrap);
assert!(!checker.config().detailed_info);
}
#[test]
fn test_domain_checker_default() {
let checker = DomainChecker::default();
assert_eq!(checker.config().concurrency, 20);
}
#[test]
fn test_domain_checker_with_config() {
let config = CheckConfig::default()
.with_concurrency(50)
.with_timeout(Duration::from_secs(10))
.with_detailed_info(true)
.with_whois_fallback(false);
let checker = DomainChecker::with_config(config);
assert_eq!(checker.config().concurrency, 50);
assert_eq!(checker.config().timeout, Duration::from_secs(10));
assert!(checker.config().detailed_info);
assert!(!checker.config().enable_whois_fallback);
}
#[test]
fn for_sale_enabled_false_when_not_requested() {
assert!(!DomainChecker::new().for_sale_enabled());
}
#[test]
fn test_config_accessor() {
let checker = DomainChecker::new();
let config = checker.config();
assert_eq!(config.concurrency, 20);
}
#[test]
fn test_set_config() {
let mut checker = DomainChecker::new();
assert_eq!(checker.config().concurrency, 20);
let new_config = CheckConfig::default().with_concurrency(75);
checker.set_config(new_config);
assert_eq!(checker.config().concurrency, 75);
}
#[test]
fn test_filter_result_info_removes_when_disabled() {
let checker = DomainChecker::new(); let result = DomainResult {
domain: "test.com".to_string(),
available: Some(false),
info: Some(DomainInfo {
registrar: Some("Test Registrar".to_string()),
..Default::default()
}),
check_duration: None,
method_used: CheckMethod::Rdap,
error_message: None,
for_sale: None,
};
let filtered = filter_result_info(&checker.config, result);
assert!(filtered.info.is_none());
}
#[test]
fn test_filter_result_info_preserves_when_enabled() {
let config = CheckConfig::default().with_detailed_info(true);
let checker = DomainChecker::with_config(config);
let result = DomainResult {
domain: "test.com".to_string(),
available: Some(false),
info: Some(DomainInfo {
registrar: Some("Test Registrar".to_string()),
..Default::default()
}),
check_duration: None,
method_used: CheckMethod::Rdap,
error_message: None,
for_sale: None,
};
let filtered = filter_result_info(&checker.config, result);
assert!(filtered.info.is_some());
assert_eq!(
filtered.info.unwrap().registrar,
Some("Test Registrar".to_string())
);
}
#[test]
fn test_filter_result_info_no_info_noop() {
let checker = DomainChecker::new();
let result = DomainResult {
domain: "test.com".to_string(),
available: Some(true),
info: None,
check_duration: None,
method_used: CheckMethod::Rdap,
error_message: None,
for_sale: None,
};
let filtered = filter_result_info(&checker.config, result);
assert!(filtered.info.is_none());
assert_eq!(filtered.available, Some(true));
}
#[tokio::test]
async fn test_check_domains_empty_list() {
let checker = DomainChecker::new();
let results = checker.check_domains(&[]).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn test_check_domains_from_nonexistent_file() {
let checker = DomainChecker::new();
let result = checker
.check_domains_from_file("/tmp/nonexistent_file_xyz_987.txt")
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn test_check_domains_from_empty_file() {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "# just a comment").unwrap();
writeln!(f).unwrap();
f.flush().unwrap();
let checker = DomainChecker::new();
let result = checker
.check_domains_from_file(f.path().to_str().unwrap())
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No valid domains"));
}
#[tokio::test]
async fn test_check_domains_from_file_parses_correctly() {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "# Header comment").unwrap();
writeln!(f, "example.com").unwrap();
writeln!(f).unwrap();
writeln!(f, "test.org # inline comment").unwrap();
writeln!(f, " ").unwrap();
writeln!(f, "short").unwrap(); f.flush().unwrap();
let checker = DomainChecker::new();
let result = checker
.check_domains_from_file(f.path().to_str().unwrap())
.await;
if let Err(e) = &result {
assert!(
!e.to_string().contains("No valid domains"),
"File should have valid domains"
);
}
}
#[tokio::test]
async fn test_no_whois_fallback_with_indicates_available_error() {
let config = CheckConfig::default().with_whois_fallback(false);
let checker = DomainChecker::with_config(config);
let rdap_error =
DomainCheckError::rdap_with_status("test.example", "RDAP returned 404", 404);
assert!(
rdap_error.indicates_available(),
"RDAP 404 should indicate available"
);
assert!(
!checker.config().enable_whois_fallback,
"WHOIS fallback should be disabled for this test"
);
}
}