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}