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