domain_check_lib/
checker.rs

1//! Main domain checker implementation.
2//!
3//! This module provides the primary `DomainChecker` struct that orchestrates
4//! domain availability checking using RDAP, WHOIS, and bootstrap protocols.
5
6use crate::error::DomainCheckError;
7use crate::protocols::{RdapClient, WhoisClient};
8use crate::types::{CheckConfig, CheckMethod, DomainResult};
9use crate::utils::validate_domain;
10use futures::stream::{Stream, StreamExt};
11use std::pin::Pin;
12use std::sync::Arc;
13use tokio::sync::Semaphore;
14
15/// Check a single domain using the provided clients (for concurrent processing).
16///
17/// This is a helper function that implements the same logic as `check_domain`
18/// but works with cloned client instances for concurrent execution.
19async fn check_single_domain_concurrent(
20    domain: &str,
21    rdap_client: &RdapClient,
22    whois_client: &WhoisClient,
23    config: &CheckConfig,
24) -> Result<DomainResult, DomainCheckError> {
25    // Validate domain format first
26    validate_domain(domain)?;
27
28    // Try RDAP first
29    match rdap_client.check_domain(domain).await {
30        Ok(result) => {
31            // RDAP succeeded, filter info based on configuration
32            let mut filtered_result = result;
33            if !config.detailed_info {
34                filtered_result.info = None;
35            }
36            Ok(filtered_result)
37        }
38        Err(rdap_error) => {
39            // RDAP failed, try WHOIS fallback if enabled
40            if config.enable_whois_fallback {
41                match whois_client.check_domain(domain).await {
42                    Ok(whois_result) => {
43                        let mut filtered_result = whois_result;
44                        if !config.detailed_info {
45                            filtered_result.info = None;
46                        }
47                        Ok(filtered_result)
48                    }
49                    Err(whois_error) => {
50                        // Both RDAP and WHOIS failed, determine best response
51
52                        // Check if either error indicates the domain is available
53                        if rdap_error.indicates_available() || whois_error.indicates_available() {
54                            Ok(DomainResult {
55                                domain: domain.to_string(),
56                                available: Some(true),
57                                info: None,
58                                check_duration: None,
59                                method_used: CheckMethod::Rdap,
60                                error_message: None,
61                            })
62                        }
63                        // Check if it's an unknown TLD or truly ambiguous case
64                        else if matches!(rdap_error, DomainCheckError::BootstrapError { .. })
65                            || matches!(whois_error, DomainCheckError::BootstrapError { .. })
66                            || whois_error
67                                .to_string()
68                                .contains("Unable to determine domain status")
69                        {
70                            // Return unknown status for invalid TLDs or ambiguous cases
71                            Ok(DomainResult {
72                                domain: domain.to_string(),
73                                available: None, // Unknown status
74                                info: None,
75                                check_duration: None,
76                                method_used: CheckMethod::Unknown,
77                                error_message: Some(
78                                    "Unknown TLD or unable to determine status".to_string(),
79                                ),
80                            })
81                        } else {
82                            // Return the RDAP error as it's usually more informative
83                            Err(rdap_error)
84                        }
85                    }
86                }
87            } else {
88                // No fallback enabled, return RDAP error
89                Err(rdap_error)
90            }
91        }
92    }
93}
94
95/// Main domain checker that coordinates availability checking operations.
96///
97/// The `DomainChecker` handles all aspects of domain checking including:
98/// - Protocol selection (RDAP vs WHOIS)
99/// - Concurrent processing
100/// - Error handling and retries
101/// - Result formatting
102///
103/// # Example
104///
105/// ```rust,no_run
106/// use domain_check_lib::{DomainChecker, CheckConfig};
107///
108/// #[tokio::main]
109/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
110///     let checker = DomainChecker::new();
111///     let result = checker.check_domain("example.com").await?;
112///     println!("Available: {:?}", result.available);
113///     Ok(())
114/// }
115/// ```
116#[derive(Clone)]
117pub struct DomainChecker {
118    /// Configuration settings for this checker instance
119    config: CheckConfig,
120    /// RDAP client for modern domain checking
121    rdap_client: RdapClient,
122    /// WHOIS client for fallback domain checking
123    whois_client: WhoisClient,
124}
125
126impl DomainChecker {
127    /// Create a new domain checker with default configuration.
128    ///
129    /// Default settings:
130    /// - Concurrency: 10
131    /// - Timeout: 5 seconds
132    /// - WHOIS fallback: enabled
133    /// - Bootstrap: disabled
134    /// - Detailed info: disabled
135    pub fn new() -> Self {
136        let config = CheckConfig::default();
137        let rdap_client = RdapClient::with_config(config.rdap_timeout, config.enable_bootstrap)
138            .expect("Failed to create RDAP client");
139        let whois_client = WhoisClient::with_timeout(config.whois_timeout);
140
141        Self {
142            config,
143            rdap_client,
144            whois_client,
145        }
146    }
147
148    /// Create a new domain checker with custom configuration.
149    ///
150    /// # Example
151    ///
152    /// ```rust
153    /// use domain_check_lib::{DomainChecker, CheckConfig};
154    /// use std::time::Duration;
155    ///
156    /// let config = CheckConfig::default()
157    ///     .with_concurrency(20)
158    ///     .with_timeout(Duration::from_secs(10))
159    ///     .with_detailed_info(true);
160    ///     
161    /// let checker = DomainChecker::with_config(config);
162    /// ```
163    pub fn with_config(config: CheckConfig) -> Self {
164        let rdap_client = RdapClient::with_config(config.rdap_timeout, config.enable_bootstrap)
165            .expect("Failed to create RDAP client");
166        let whois_client = WhoisClient::with_timeout(config.whois_timeout);
167
168        Self {
169            config,
170            rdap_client,
171            whois_client,
172        }
173    }
174
175    /// Check availability of a single domain.
176    ///
177    /// This is the most basic operation - check one domain and return the result.
178    /// The domain should be a fully qualified domain name (e.g., "example.com").
179    ///
180    /// The checking process:
181    /// 1. Validates the domain format
182    /// 2. Attempts RDAP check first (modern protocol)
183    /// 3. Falls back to WHOIS if RDAP fails and fallback is enabled
184    /// 4. Returns comprehensive result with timing and method information
185    ///
186    /// # Arguments
187    ///
188    /// * `domain` - The domain name to check (e.g., "example.com")
189    ///
190    /// # Returns
191    ///
192    /// A `DomainResult` containing availability status and optional details.
193    ///
194    /// # Errors
195    ///
196    /// Returns `DomainCheckError` if:
197    /// - The domain name is invalid
198    /// - Network errors occur
199    /// - All checking methods fail
200    pub async fn check_domain(&self, domain: &str) -> Result<DomainResult, DomainCheckError> {
201        // Validate domain format first
202        validate_domain(domain)?;
203
204        // Try RDAP first
205        match self.rdap_client.check_domain(domain).await {
206            Ok(result) => {
207                // RDAP succeeded, filter info based on configuration
208                Ok(self.filter_result_info(result))
209            }
210            Err(rdap_error) => {
211                // RDAP failed, try WHOIS fallback if enabled
212                if self.config.enable_whois_fallback {
213                    match self.whois_client.check_domain(domain).await {
214                        Ok(whois_result) => Ok(self.filter_result_info(whois_result)),
215                        Err(whois_error) => {
216                            // Both RDAP and WHOIS failed, determine best response
217
218                            // Check if either error indicates the domain is available
219                            if rdap_error.indicates_available() || whois_error.indicates_available()
220                            {
221                                Ok(DomainResult {
222                                    domain: domain.to_string(),
223                                    available: Some(true),
224                                    info: None,
225                                    check_duration: None,
226                                    method_used: CheckMethod::Rdap,
227                                    error_message: None,
228                                })
229                            }
230                            // Check if it's an unknown TLD or truly ambiguous case
231                            else if matches!(rdap_error, DomainCheckError::BootstrapError { .. })
232                                || matches!(whois_error, DomainCheckError::BootstrapError { .. })
233                                || whois_error
234                                    .to_string()
235                                    .contains("Unable to determine domain status")
236                            {
237                                // Return unknown status for invalid TLDs or ambiguous cases
238                                Ok(DomainResult {
239                                    domain: domain.to_string(),
240                                    available: None, // Unknown status
241                                    info: None,
242                                    check_duration: None,
243                                    method_used: CheckMethod::Unknown,
244                                    error_message: Some(
245                                        "Unknown TLD or unable to determine status".to_string(),
246                                    ),
247                                })
248                            } else {
249                                // Return the most informative error
250                                Err(rdap_error)
251                            }
252                        }
253                    }
254                } else {
255                    // No fallback enabled, return RDAP error
256                    Err(rdap_error)
257                }
258            }
259        }
260    }
261
262    /// Filter domain result info based on configuration.
263    ///
264    /// If detailed_info is disabled, removes the info field to keep results clean.
265    fn filter_result_info(&self, mut result: DomainResult) -> DomainResult {
266        if !self.config.detailed_info {
267            result.info = None;
268        }
269        result
270    }
271
272    /// Check availability of multiple domains concurrently.
273    ///
274    /// This method processes all domains in parallel according to the
275    /// concurrency setting, then returns all results at once.
276    ///
277    /// # Arguments
278    ///
279    /// * `domains` - Slice of domain names to check
280    ///
281    /// # Returns
282    ///
283    /// Vector of `DomainResult` in the same order as input domains.
284    ///
285    /// # Example
286    ///
287    /// ```rust,no_run
288    /// use domain_check_lib::DomainChecker;
289    ///
290    /// #[tokio::main]
291    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
292    ///     let checker = DomainChecker::new();
293    ///     let domains = vec!["example.com".to_string(), "google.com".to_string(), "test.org".to_string()];
294    ///     let results = checker.check_domains(&domains).await?;
295    ///     
296    ///     for result in results {
297    ///         println!("{}: {:?}", result.domain, result.available);
298    ///     }
299    ///     Ok(())
300    /// }
301    /// ```
302    pub async fn check_domains(
303        &self,
304        domains: &[String],
305    ) -> Result<Vec<DomainResult>, DomainCheckError> {
306        if domains.is_empty() {
307            return Ok(Vec::new());
308        }
309
310        // Create semaphore to limit concurrent operations
311        let semaphore = Arc::new(Semaphore::new(self.config.concurrency));
312        let mut handles = Vec::new();
313
314        // Spawn concurrent tasks for each domain
315        for (index, domain) in domains.iter().enumerate() {
316            let domain = domain.clone();
317            let semaphore = Arc::clone(&semaphore);
318
319            // Clone the checker components we need
320            let rdap_client = self.rdap_client.clone();
321            let whois_client = self.whois_client.clone();
322            let config = self.config.clone();
323
324            let handle = tokio::spawn(async move {
325                // Acquire semaphore permit
326                let _permit = semaphore.acquire().await.unwrap();
327
328                // Check this domain
329                let result =
330                    check_single_domain_concurrent(&domain, &rdap_client, &whois_client, &config)
331                        .await;
332
333                // Return with original index to maintain order
334                (index, result)
335            });
336
337            handles.push(handle);
338        }
339
340        // Wait for all tasks to complete and collect results
341        let mut indexed_results = Vec::new();
342        for handle in handles {
343            match handle.await {
344                Ok((index, result)) => indexed_results.push((index, result)),
345                Err(e) => {
346                    return Err(DomainCheckError::internal(format!(
347                        "Concurrent task failed: {}",
348                        e
349                    )));
350                }
351            }
352        }
353
354        // Sort by original index to maintain input order
355        indexed_results.sort_by_key(|(index, _)| *index);
356
357        // Extract results, converting errors to DomainResult with error info
358        let results = indexed_results
359            .into_iter()
360            .map(|(_, result)| match result {
361                Ok(domain_result) => domain_result,
362                Err(e) => DomainResult {
363                    domain: domains[0].clone(), // We'll fix this in the concurrent function
364                    available: None,
365                    info: None,
366                    check_duration: None,
367                    method_used: CheckMethod::Unknown,
368                    error_message: Some(e.to_string()),
369                },
370            })
371            .collect();
372
373        Ok(results)
374    }
375
376    /// Check domains and return results as a stream.
377    ///
378    /// This method yields results as they become available, which is useful
379    /// for real-time updates or when processing large numbers of domains.
380    /// Results are returned in the order they complete, not input order.
381    ///
382    /// # Arguments
383    ///
384    /// * `domains` - Slice of domain names to check
385    ///
386    /// # Returns
387    ///
388    /// A stream that yields `DomainResult` items as they complete.
389    ///
390    /// # Example
391    ///
392    /// ```rust,no_run
393    /// use domain_check_lib::DomainChecker;
394    /// use futures::StreamExt;
395    ///
396    /// #[tokio::main]
397    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
398    ///     let checker = DomainChecker::new();
399    ///     let domains = vec!["example.com".to_string(), "google.com".to_string()];
400    ///     
401    ///     let mut stream = checker.check_domains_stream(&domains);
402    ///     while let Some(result) = stream.next().await {
403    ///         match result {
404    ///             Ok(domain_result) => println!("✓ {}: {:?}", domain_result.domain, domain_result.available),
405    ///             Err(e) => println!("✗ Error: {}", e),
406    ///         }
407    ///     }
408    ///     Ok(())
409    /// }
410    /// ```
411    pub fn check_domains_stream(
412        &self,
413        domains: &[String],
414    ) -> Pin<Box<dyn Stream<Item = Result<DomainResult, DomainCheckError>> + Send + '_>> {
415        let domains = domains.to_vec();
416        let semaphore = Arc::new(Semaphore::new(self.config.concurrency));
417
418        // Create stream of futures
419        let stream = futures::stream::iter(domains)
420            .map(move |domain| {
421                let semaphore = Arc::clone(&semaphore);
422                let rdap_client = self.rdap_client.clone();
423                let whois_client = self.whois_client.clone();
424                let config = self.config.clone();
425
426                async move {
427                    // Acquire semaphore permit
428                    let _permit = semaphore.acquire().await.unwrap();
429
430                    // Check domain
431                    check_single_domain_concurrent(&domain, &rdap_client, &whois_client, &config)
432                        .await
433                }
434            })
435            // Buffer unordered allows concurrent execution while maintaining the stream interface
436            .buffer_unordered(self.config.concurrency);
437
438        Box::pin(stream)
439    }
440
441    /// Read domain names from a file and check their availability.
442    ///
443    /// The file should contain one domain name per line. Empty lines and
444    /// lines starting with '#' are ignored as comments.
445    ///
446    /// # Arguments
447    ///
448    /// * `file_path` - Path to the file containing domain names
449    ///
450    /// # Returns
451    ///
452    /// Vector of `DomainResult` for all valid domains in the file.
453    ///
454    /// # Errors
455    ///
456    /// Returns `DomainCheckError` if:
457    /// - The file cannot be read
458    /// - The file contains too many domains (over limit)
459    /// - No valid domains are found in the file
460    pub async fn check_domains_from_file(
461        &self,
462        file_path: &str,
463    ) -> Result<Vec<DomainResult>, DomainCheckError> {
464        use std::fs::File;
465        use std::io::{BufRead, BufReader};
466        use std::path::Path;
467
468        // Check if file exists
469        let path = Path::new(file_path);
470        if !path.exists() {
471            return Err(DomainCheckError::file_error(file_path, "File not found"));
472        }
473
474        // Read domains from file
475        let file = File::open(path).map_err(|e| {
476            DomainCheckError::file_error(file_path, format!("Cannot open file: {}", e))
477        })?;
478
479        let reader = BufReader::new(file);
480        let mut domains = Vec::new();
481        let mut line_num = 0;
482
483        for line in reader.lines() {
484            line_num += 1;
485            match line {
486                Ok(line) => {
487                    let trimmed = line.trim();
488
489                    // Skip empty lines and comments
490                    if trimmed.is_empty() || trimmed.starts_with('#') {
491                        continue;
492                    }
493
494                    // Handle inline comments
495                    let domain_part = trimmed.split('#').next().unwrap_or("").trim();
496                    if !domain_part.is_empty() && domain_part.len() >= 2 {
497                        domains.push(domain_part.to_string());
498                    }
499                }
500                Err(e) => {
501                    return Err(DomainCheckError::file_error(
502                        file_path,
503                        format!("Error reading line {}: {}", line_num, e),
504                    ));
505                }
506            }
507        }
508
509        if domains.is_empty() {
510            return Err(DomainCheckError::file_error(
511                file_path,
512                "No valid domains found in file",
513            ));
514        }
515
516        // Check domains using existing concurrent logic
517        self.check_domains(&domains).await
518    }
519
520    /// Get the current configuration for this checker.
521    pub fn config(&self) -> &CheckConfig {
522        &self.config
523    }
524
525    /// Update the configuration for this checker.
526    ///
527    /// This allows modifying settings like concurrency or timeout
528    /// after the checker has been created. Note that this will recreate
529    /// the internal protocol clients with the new settings.
530    pub fn set_config(&mut self, config: CheckConfig) {
531        // Recreate clients with new configuration
532        self.rdap_client = RdapClient::with_config(config.rdap_timeout, config.enable_bootstrap)
533            .expect("Failed to recreate RDAP client");
534        self.whois_client = WhoisClient::with_timeout(config.whois_timeout);
535        self.config = config;
536    }
537}
538
539impl Default for DomainChecker {
540    fn default() -> Self {
541        Self::new()
542    }
543}