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                        // 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}