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