Skip to main content

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::registry::{extract_tld, get_whois_server};
8use crate::protocols::{RdapClient, WhoisClient};
9use crate::types::{CheckConfig, CheckMethod, DomainResult};
10use crate::utils::validate_domain;
11use futures_util::stream::{Stream, StreamExt};
12use std::pin::Pin;
13use std::sync::Arc;
14use tokio::sync::Semaphore;
15
16/// Check a single domain using the provided clients (for concurrent processing).
17///
18/// This is a helper function that implements the same logic as `check_domain`
19/// but works with cloned client instances for concurrent execution.
20async fn check_single_domain_concurrent(
21    domain: &str,
22    rdap_client: &RdapClient,
23    whois_client: &WhoisClient,
24    config: &CheckConfig,
25) -> Result<DomainResult, DomainCheckError> {
26    // Validate domain format first
27    validate_domain(domain)?;
28
29    // Try RDAP first
30    match rdap_client.check_domain(domain).await {
31        Ok(result) => {
32            // RDAP succeeded, filter info based on configuration
33            let mut filtered_result = result;
34            if !config.detailed_info {
35                filtered_result.info = None;
36            }
37            Ok(filtered_result)
38        }
39        Err(rdap_error) => {
40            // RDAP failed, try WHOIS fallback if enabled
41            if config.enable_whois_fallback {
42                // Discover WHOIS server for targeted query
43                let whois_result = whois_with_discovery(domain, whois_client).await;
44
45                match whois_result {
46                    Ok(whois_result) => {
47                        let mut filtered_result = whois_result;
48                        if !config.detailed_info {
49                            filtered_result.info = None;
50                        }
51                        Ok(filtered_result)
52                    }
53                    Err(whois_error) => {
54                        // Both RDAP and WHOIS failed, determine best response
55
56                        // Check if either error indicates the domain is available
57                        if rdap_error.indicates_available() || whois_error.indicates_available() {
58                            Ok(DomainResult {
59                                domain: domain.to_string(),
60                                available: Some(true),
61                                info: None,
62                                check_duration: None,
63                                method_used: CheckMethod::Rdap,
64                                error_message: None,
65                            })
66                        }
67                        // Check if it's an unknown TLD or truly ambiguous case
68                        else if matches!(rdap_error, DomainCheckError::BootstrapError { .. })
69                            || matches!(whois_error, DomainCheckError::BootstrapError { .. })
70                            || whois_error
71                                .to_string()
72                                .contains("Unable to determine domain status")
73                        {
74                            // Return unknown status for invalid TLDs or ambiguous cases
75                            Ok(DomainResult {
76                                domain: domain.to_string(),
77                                available: None, // Unknown status
78                                info: None,
79                                check_duration: None,
80                                method_used: CheckMethod::Unknown,
81                                error_message: Some(
82                                    "Unknown TLD or unable to determine status".to_string(),
83                                ),
84                            })
85                        } else {
86                            // Return the RDAP error as it's usually more informative
87                            Err(rdap_error)
88                        }
89                    }
90                }
91            } else {
92                // No fallback enabled, return RDAP error
93                Err(rdap_error)
94            }
95        }
96    }
97}
98
99/// Perform WHOIS check with server discovery for targeted queries.
100///
101/// If the TLD's authoritative WHOIS server can be discovered via IANA referral,
102/// uses `whois -h <server> <domain>` for a more reliable query. Falls back to
103/// bare `whois <domain>` otherwise.
104async fn whois_with_discovery(
105    domain: &str,
106    whois_client: &WhoisClient,
107) -> Result<DomainResult, DomainCheckError> {
108    let tld = extract_tld(domain).ok();
109    let whois_server = if let Some(ref t) = tld {
110        get_whois_server(t).await
111    } else {
112        None
113    };
114
115    if let Some(server) = whois_server {
116        whois_client.check_domain_with_server(domain, &server).await
117    } else {
118        whois_client.check_domain(domain).await
119    }
120}
121
122/// Main domain checker that coordinates availability checking operations.
123///
124/// The `DomainChecker` handles all aspects of domain checking including:
125/// - Protocol selection (RDAP vs WHOIS)
126/// - Concurrent processing
127/// - Error handling and retries
128/// - Result formatting
129///
130/// # Example
131///
132/// ```rust,no_run
133/// use domain_check_lib::{DomainChecker, CheckConfig};
134///
135/// #[tokio::main]
136/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
137///     let checker = DomainChecker::new();
138///     let result = checker.check_domain("example.com").await?;
139///     println!("Available: {:?}", result.available);
140///     Ok(())
141/// }
142/// ```
143#[derive(Clone)]
144pub struct DomainChecker {
145    /// Configuration settings for this checker instance
146    config: CheckConfig,
147    /// RDAP client for modern domain checking
148    rdap_client: RdapClient,
149    /// WHOIS client for fallback domain checking
150    whois_client: WhoisClient,
151}
152
153impl DomainChecker {
154    /// Create a new domain checker with default configuration.
155    ///
156    /// Default settings:
157    /// - Concurrency: 20
158    /// - Timeout: 5 seconds
159    /// - WHOIS fallback: enabled
160    /// - Bootstrap: enabled
161    /// - Detailed info: disabled
162    pub fn new() -> Self {
163        let config = CheckConfig::default();
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    /// Create a new domain checker with custom configuration.
176    ///
177    /// # Example
178    ///
179    /// ```rust
180    /// use domain_check_lib::{DomainChecker, CheckConfig};
181    /// use std::time::Duration;
182    ///
183    /// let config = CheckConfig::default()
184    ///     .with_concurrency(20)
185    ///     .with_timeout(Duration::from_secs(10))
186    ///     .with_detailed_info(true);
187    ///     
188    /// let checker = DomainChecker::with_config(config);
189    /// ```
190    pub fn with_config(config: CheckConfig) -> Self {
191        let rdap_client = RdapClient::with_config(config.rdap_timeout, config.enable_bootstrap)
192            .expect("Failed to create RDAP client");
193        let whois_client = WhoisClient::with_timeout(config.whois_timeout);
194
195        Self {
196            config,
197            rdap_client,
198            whois_client,
199        }
200    }
201
202    /// Check availability of a single domain.
203    ///
204    /// This is the most basic operation - check one domain and return the result.
205    /// The domain should be a fully qualified domain name (e.g., "example.com").
206    ///
207    /// The checking process:
208    /// 1. Validates the domain format
209    /// 2. Attempts RDAP check first (modern protocol)
210    /// 3. Falls back to WHOIS if RDAP fails and fallback is enabled
211    /// 4. Returns comprehensive result with timing and method information
212    ///
213    /// # Arguments
214    ///
215    /// * `domain` - The domain name to check (e.g., "example.com")
216    ///
217    /// # Returns
218    ///
219    /// A `DomainResult` containing availability status and optional details.
220    ///
221    /// # Errors
222    ///
223    /// Returns `DomainCheckError` if:
224    /// - The domain name is invalid
225    /// - Network errors occur
226    /// - All checking methods fail
227    pub async fn check_domain(&self, domain: &str) -> Result<DomainResult, DomainCheckError> {
228        // Validate domain format first
229        validate_domain(domain)?;
230
231        // Try RDAP first
232        match self.rdap_client.check_domain(domain).await {
233            Ok(result) => {
234                // RDAP succeeded, filter info based on configuration
235                Ok(self.filter_result_info(result))
236            }
237            Err(rdap_error) => {
238                // RDAP failed, try WHOIS fallback if enabled
239                if self.config.enable_whois_fallback {
240                    // Use WHOIS with server discovery for targeted queries
241                    match whois_with_discovery(domain, &self.whois_client).await {
242                        Ok(whois_result) => Ok(self.filter_result_info(whois_result)),
243                        Err(whois_error) => {
244                            // Both RDAP and WHOIS failed, determine best response
245
246                            // Check if either error indicates the domain is available
247                            if rdap_error.indicates_available() || whois_error.indicates_available()
248                            {
249                                Ok(DomainResult {
250                                    domain: domain.to_string(),
251                                    available: Some(true),
252                                    info: None,
253                                    check_duration: None,
254                                    method_used: CheckMethod::Rdap,
255                                    error_message: None,
256                                })
257                            }
258                            // Check if it's an unknown TLD or truly ambiguous case
259                            else if matches!(rdap_error, DomainCheckError::BootstrapError { .. })
260                                || matches!(whois_error, DomainCheckError::BootstrapError { .. })
261                                || whois_error
262                                    .to_string()
263                                    .contains("Unable to determine domain status")
264                            {
265                                // Return unknown status for invalid TLDs or ambiguous cases
266                                Ok(DomainResult {
267                                    domain: domain.to_string(),
268                                    available: None, // Unknown status
269                                    info: None,
270                                    check_duration: None,
271                                    method_used: CheckMethod::Unknown,
272                                    error_message: Some(
273                                        "Unknown TLD or unable to determine status".to_string(),
274                                    ),
275                                })
276                            } else {
277                                // Return the most informative error
278                                Err(rdap_error)
279                            }
280                        }
281                    }
282                } else {
283                    // No fallback enabled, return RDAP error
284                    Err(rdap_error)
285                }
286            }
287        }
288    }
289
290    /// Filter domain result info based on configuration.
291    ///
292    /// If detailed_info is disabled, removes the info field to keep results clean.
293    fn filter_result_info(&self, mut result: DomainResult) -> DomainResult {
294        if !self.config.detailed_info {
295            result.info = None;
296        }
297        result
298    }
299
300    /// Check availability of multiple domains concurrently.
301    ///
302    /// This method processes all domains in parallel according to the
303    /// concurrency setting, then returns all results at once.
304    ///
305    /// # Arguments
306    ///
307    /// * `domains` - Slice of domain names to check
308    ///
309    /// # Returns
310    ///
311    /// Vector of `DomainResult` in the same order as input domains.
312    ///
313    /// # Example
314    ///
315    /// ```rust,no_run
316    /// use domain_check_lib::DomainChecker;
317    ///
318    /// #[tokio::main]
319    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
320    ///     let checker = DomainChecker::new();
321    ///     let domains = vec!["example.com".to_string(), "google.com".to_string(), "test.org".to_string()];
322    ///     let results = checker.check_domains(&domains).await?;
323    ///     
324    ///     for result in results {
325    ///         println!("{}: {:?}", result.domain, result.available);
326    ///     }
327    ///     Ok(())
328    /// }
329    /// ```
330    pub async fn check_domains(
331        &self,
332        domains: &[String],
333    ) -> Result<Vec<DomainResult>, DomainCheckError> {
334        if domains.is_empty() {
335            return Ok(Vec::new());
336        }
337
338        // Create semaphore to limit concurrent operations
339        let semaphore = Arc::new(Semaphore::new(self.config.concurrency));
340        let mut handles = Vec::new();
341
342        // Spawn concurrent tasks for each domain
343        for (index, domain) in domains.iter().enumerate() {
344            let domain = domain.clone();
345            let semaphore = Arc::clone(&semaphore);
346
347            // Clone the checker components we need
348            let rdap_client = self.rdap_client.clone();
349            let whois_client = self.whois_client.clone();
350            let config = self.config.clone();
351
352            let handle = tokio::spawn(async move {
353                // Acquire semaphore permit
354                let _permit = semaphore.acquire().await.unwrap();
355
356                // Check this domain
357                let result =
358                    check_single_domain_concurrent(&domain, &rdap_client, &whois_client, &config)
359                        .await;
360
361                // Return with original index to maintain order
362                (index, result)
363            });
364
365            handles.push(handle);
366        }
367
368        // Wait for all tasks to complete and collect results
369        let mut indexed_results = Vec::new();
370        for handle in handles {
371            match handle.await {
372                Ok((index, result)) => indexed_results.push((index, result)),
373                Err(e) => {
374                    return Err(DomainCheckError::internal(format!(
375                        "Concurrent task failed: {}",
376                        e
377                    )));
378                }
379            }
380        }
381
382        // Sort by original index to maintain input order
383        indexed_results.sort_by_key(|(index, _)| *index);
384
385        // Extract results, converting errors to DomainResult with error info
386        let results = indexed_results
387            .into_iter()
388            .map(|(index, result)| match result {
389                Ok(domain_result) => domain_result,
390                Err(e) => DomainResult {
391                    domain: domains[index].clone(),
392                    available: None,
393                    info: None,
394                    check_duration: None,
395                    method_used: CheckMethod::Unknown,
396                    error_message: Some(e.to_string()),
397                },
398            })
399            .collect();
400
401        Ok(results)
402    }
403
404    /// Check domains and return results as a stream.
405    ///
406    /// This method yields results as they become available, which is useful
407    /// for real-time updates or when processing large numbers of domains.
408    /// Results are returned in the order they complete, not input order.
409    ///
410    /// # Arguments
411    ///
412    /// * `domains` - Slice of domain names to check
413    ///
414    /// # Returns
415    ///
416    /// A stream that yields `DomainResult` items as they complete.
417    ///
418    /// # Example
419    ///
420    /// ```rust,no_run
421    /// use domain_check_lib::DomainChecker;
422    /// use futures_util::StreamExt;
423    ///
424    /// #[tokio::main]
425    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
426    ///     let checker = DomainChecker::new();
427    ///     let domains = vec!["example.com".to_string(), "google.com".to_string()];
428    ///     
429    ///     let mut stream = checker.check_domains_stream(&domains);
430    ///     while let Some(result) = stream.next().await {
431    ///         match result {
432    ///             Ok(domain_result) => println!("✓ {}: {:?}", domain_result.domain, domain_result.available),
433    ///             Err(e) => println!("✗ Error: {}", e),
434    ///         }
435    ///     }
436    ///     Ok(())
437    /// }
438    /// ```
439    pub fn check_domains_stream(
440        &self,
441        domains: &[String],
442    ) -> Pin<Box<dyn Stream<Item = Result<DomainResult, DomainCheckError>> + Send + '_>> {
443        let domains = domains.to_vec();
444        let semaphore = Arc::new(Semaphore::new(self.config.concurrency));
445
446        // Create stream of futures
447        let stream = futures_util::stream::iter(domains)
448            .map(move |domain| {
449                let semaphore = Arc::clone(&semaphore);
450                let rdap_client = self.rdap_client.clone();
451                let whois_client = self.whois_client.clone();
452                let config = self.config.clone();
453
454                async move {
455                    // Acquire semaphore permit
456                    let _permit = semaphore.acquire().await.unwrap();
457
458                    // Check domain
459                    check_single_domain_concurrent(&domain, &rdap_client, &whois_client, &config)
460                        .await
461                }
462            })
463            // Buffer unordered allows concurrent execution while maintaining the stream interface
464            .buffer_unordered(self.config.concurrency);
465
466        Box::pin(stream)
467    }
468
469    /// Read domain names from a file and check their availability.
470    ///
471    /// The file should contain one domain name per line. Empty lines and
472    /// lines starting with '#' are ignored as comments.
473    ///
474    /// # Arguments
475    ///
476    /// * `file_path` - Path to the file containing domain names
477    ///
478    /// # Returns
479    ///
480    /// Vector of `DomainResult` for all valid domains in the file.
481    ///
482    /// # Errors
483    ///
484    /// Returns `DomainCheckError` if:
485    /// - The file cannot be read
486    /// - The file contains too many domains (over limit)
487    /// - No valid domains are found in the file
488    pub async fn check_domains_from_file(
489        &self,
490        file_path: &str,
491    ) -> Result<Vec<DomainResult>, DomainCheckError> {
492        use std::fs::File;
493        use std::io::{BufRead, BufReader};
494        use std::path::Path;
495
496        // Check if file exists
497        let path = Path::new(file_path);
498        if !path.exists() {
499            return Err(DomainCheckError::file_error(file_path, "File not found"));
500        }
501
502        // Read domains from file
503        let file = File::open(path).map_err(|e| {
504            DomainCheckError::file_error(file_path, format!("Cannot open file: {}", e))
505        })?;
506
507        let reader = BufReader::new(file);
508        let mut domains = Vec::new();
509        let mut line_num = 0;
510
511        for line in reader.lines() {
512            line_num += 1;
513            match line {
514                Ok(line) => {
515                    let trimmed = line.trim();
516
517                    // Skip empty lines and comments
518                    if trimmed.is_empty() || trimmed.starts_with('#') {
519                        continue;
520                    }
521
522                    // Handle inline comments
523                    let domain_part = trimmed.split('#').next().unwrap_or("").trim();
524                    if !domain_part.is_empty() && domain_part.len() >= 2 {
525                        domains.push(domain_part.to_string());
526                    }
527                }
528                Err(e) => {
529                    return Err(DomainCheckError::file_error(
530                        file_path,
531                        format!("Error reading line {}: {}", line_num, e),
532                    ));
533                }
534            }
535        }
536
537        if domains.is_empty() {
538            return Err(DomainCheckError::file_error(
539                file_path,
540                "No valid domains found in file",
541            ));
542        }
543
544        // Check domains using existing concurrent logic
545        self.check_domains(&domains).await
546    }
547
548    /// Get the current configuration for this checker.
549    pub fn config(&self) -> &CheckConfig {
550        &self.config
551    }
552
553    /// Update the configuration for this checker.
554    ///
555    /// This allows modifying settings like concurrency or timeout
556    /// after the checker has been created. Note that this will recreate
557    /// the internal protocol clients with the new settings.
558    pub fn set_config(&mut self, config: CheckConfig) {
559        // Recreate clients with new configuration
560        self.rdap_client = RdapClient::with_config(config.rdap_timeout, config.enable_bootstrap)
561            .expect("Failed to recreate RDAP client");
562        self.whois_client = WhoisClient::with_timeout(config.whois_timeout);
563        self.config = config;
564    }
565}
566
567impl Default for DomainChecker {
568    fn default() -> Self {
569        Self::new()
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::types::DomainInfo;
577    use std::time::Duration;
578
579    // ── DomainChecker creation ──────────────────────────────────────────
580
581    #[test]
582    fn test_domain_checker_new() {
583        let checker = DomainChecker::new();
584        assert_eq!(checker.config().concurrency, 20);
585        assert!(checker.config().enable_whois_fallback);
586        assert!(checker.config().enable_bootstrap);
587        assert!(!checker.config().detailed_info);
588    }
589
590    #[test]
591    fn test_domain_checker_default() {
592        let checker = DomainChecker::default();
593        assert_eq!(checker.config().concurrency, 20);
594    }
595
596    #[test]
597    fn test_domain_checker_with_config() {
598        let config = CheckConfig::default()
599            .with_concurrency(50)
600            .with_timeout(Duration::from_secs(10))
601            .with_detailed_info(true)
602            .with_whois_fallback(false);
603
604        let checker = DomainChecker::with_config(config);
605        assert_eq!(checker.config().concurrency, 50);
606        assert_eq!(checker.config().timeout, Duration::from_secs(10));
607        assert!(checker.config().detailed_info);
608        assert!(!checker.config().enable_whois_fallback);
609    }
610
611    // ── config() and set_config() ───────────────────────────────────────
612
613    #[test]
614    fn test_config_accessor() {
615        let checker = DomainChecker::new();
616        let config = checker.config();
617        assert_eq!(config.concurrency, 20);
618    }
619
620    #[test]
621    fn test_set_config() {
622        let mut checker = DomainChecker::new();
623        assert_eq!(checker.config().concurrency, 20);
624
625        let new_config = CheckConfig::default().with_concurrency(75);
626        checker.set_config(new_config);
627        assert_eq!(checker.config().concurrency, 75);
628    }
629
630    // ── filter_result_info ──────────────────────────────────────────────
631
632    #[test]
633    fn test_filter_result_info_removes_when_disabled() {
634        let checker = DomainChecker::new(); // detailed_info = false by default
635        let result = DomainResult {
636            domain: "test.com".to_string(),
637            available: Some(false),
638            info: Some(DomainInfo {
639                registrar: Some("Test Registrar".to_string()),
640                ..Default::default()
641            }),
642            check_duration: None,
643            method_used: CheckMethod::Rdap,
644            error_message: None,
645        };
646
647        let filtered = checker.filter_result_info(result);
648        assert!(filtered.info.is_none());
649    }
650
651    #[test]
652    fn test_filter_result_info_preserves_when_enabled() {
653        let config = CheckConfig::default().with_detailed_info(true);
654        let checker = DomainChecker::with_config(config);
655
656        let result = DomainResult {
657            domain: "test.com".to_string(),
658            available: Some(false),
659            info: Some(DomainInfo {
660                registrar: Some("Test Registrar".to_string()),
661                ..Default::default()
662            }),
663            check_duration: None,
664            method_used: CheckMethod::Rdap,
665            error_message: None,
666        };
667
668        let filtered = checker.filter_result_info(result);
669        assert!(filtered.info.is_some());
670        assert_eq!(
671            filtered.info.unwrap().registrar,
672            Some("Test Registrar".to_string())
673        );
674    }
675
676    #[test]
677    fn test_filter_result_info_no_info_noop() {
678        let checker = DomainChecker::new();
679        let result = DomainResult {
680            domain: "test.com".to_string(),
681            available: Some(true),
682            info: None,
683            check_duration: None,
684            method_used: CheckMethod::Rdap,
685            error_message: None,
686        };
687
688        let filtered = checker.filter_result_info(result);
689        assert!(filtered.info.is_none());
690        assert_eq!(filtered.available, Some(true));
691    }
692
693    // ── check_domains with empty list ───────────────────────────────────
694
695    #[tokio::test]
696    async fn test_check_domains_empty_list() {
697        let checker = DomainChecker::new();
698        let results = checker.check_domains(&[]).await.unwrap();
699        assert!(results.is_empty());
700    }
701
702    // ── check_domains_from_file errors ──────────────────────────────────
703
704    #[tokio::test]
705    async fn test_check_domains_from_nonexistent_file() {
706        let checker = DomainChecker::new();
707        let result = checker
708            .check_domains_from_file("/tmp/nonexistent_file_xyz_987.txt")
709            .await;
710        assert!(result.is_err());
711        assert!(result.unwrap_err().to_string().contains("not found"));
712    }
713
714    #[tokio::test]
715    async fn test_check_domains_from_empty_file() {
716        use std::io::Write;
717        let mut f = tempfile::NamedTempFile::new().unwrap();
718        writeln!(f, "# just a comment").unwrap();
719        writeln!(f).unwrap();
720        f.flush().unwrap();
721
722        let checker = DomainChecker::new();
723        let result = checker
724            .check_domains_from_file(f.path().to_str().unwrap())
725            .await;
726        assert!(result.is_err());
727        assert!(result.unwrap_err().to_string().contains("No valid domains"));
728    }
729
730    #[tokio::test]
731    async fn test_check_domains_from_file_parses_correctly() {
732        use std::io::Write;
733        let mut f = tempfile::NamedTempFile::new().unwrap();
734        writeln!(f, "# Header comment").unwrap();
735        writeln!(f, "example.com").unwrap();
736        writeln!(f).unwrap();
737        writeln!(f, "test.org  # inline comment").unwrap();
738        writeln!(f, "   ").unwrap();
739        writeln!(f, "short").unwrap(); // only 5 chars but >= 2 so it's valid
740        f.flush().unwrap();
741
742        // We can't actually check domains in tests (network), but we can
743        // verify the file parsing by checking that it doesn't error on
744        // "no valid domains" — meaning it found at least one valid domain.
745        // The actual network check will fail, but that's expected.
746        let checker = DomainChecker::new();
747        let result = checker
748            .check_domains_from_file(f.path().to_str().unwrap())
749            .await;
750        // It won't error with "No valid domains" — it will either succeed or
751        // fail on network. The file parsing itself worked.
752        if let Err(e) = &result {
753            assert!(
754                !e.to_string().contains("No valid domains"),
755                "File should have valid domains"
756            );
757        }
758    }
759}