Skip to main content

domain_check_lib/
error.rs

1//! Error handling for domain checking operations.
2//!
3//! This module defines a comprehensive error type that covers all the different
4//! ways domain checking can fail, from network issues to invalid input.
5
6use std::fmt;
7
8/// Main error type for domain checking operations.
9///
10/// This enum covers all possible failure modes in the domain checking process,
11/// providing detailed context for debugging and user-friendly error messages.
12#[derive(Debug, Clone)]
13pub enum DomainCheckError {
14    /// Invalid domain name format
15    InvalidDomain { domain: String, reason: String },
16
17    /// Network-related errors (connection, timeout, etc.)
18    NetworkError {
19        message: String,
20        source: Option<String>,
21    },
22
23    /// RDAP protocol specific errors
24    RdapError {
25        domain: String,
26        message: String,
27        status_code: Option<u16>,
28    },
29
30    /// WHOIS protocol specific errors
31    WhoisError { domain: String, message: String },
32
33    /// Bootstrap registry lookup failures
34    BootstrapError { tld: String, message: String },
35
36    /// JSON parsing errors for RDAP responses
37    ParseError {
38        message: String,
39        content: Option<String>,
40    },
41
42    /// Configuration errors (invalid settings, etc.)
43    ConfigError { message: String },
44
45    /// File I/O errors when reading domain lists
46    FileError { path: String, message: String },
47
48    /// Timeout errors when operations take too long
49    Timeout {
50        operation: String,
51        duration: std::time::Duration,
52    },
53
54    /// Rate limiting errors when servers reject requests
55    RateLimited {
56        service: String,
57        message: String,
58        retry_after: Option<std::time::Duration>,
59    },
60
61    /// Invalid pattern syntax in domain generation
62    InvalidPattern { pattern: String, reason: String },
63
64    /// Generic internal errors that don't fit other categories
65    Internal { message: String },
66}
67
68impl DomainCheckError {
69    /// Create a new invalid domain error.
70    pub fn invalid_domain<D: Into<String>, R: Into<String>>(domain: D, reason: R) -> Self {
71        Self::InvalidDomain {
72            domain: domain.into(),
73            reason: reason.into(),
74        }
75    }
76
77    /// Create a new network error.
78    pub fn network<M: Into<String>>(message: M) -> Self {
79        Self::NetworkError {
80            message: message.into(),
81            source: None,
82        }
83    }
84
85    /// Create a new network error with source information.
86    pub fn network_with_source<M: Into<String>, S: Into<String>>(message: M, source: S) -> Self {
87        Self::NetworkError {
88            message: message.into(),
89            source: Some(source.into()),
90        }
91    }
92
93    /// Create a new RDAP error.
94    pub fn rdap<D: Into<String>, M: Into<String>>(domain: D, message: M) -> Self {
95        Self::RdapError {
96            domain: domain.into(),
97            message: message.into(),
98            status_code: None,
99        }
100    }
101
102    /// Create a new RDAP error with HTTP status code.
103    pub fn rdap_with_status<D: Into<String>, M: Into<String>>(
104        domain: D,
105        message: M,
106        status_code: u16,
107    ) -> Self {
108        Self::RdapError {
109            domain: domain.into(),
110            message: message.into(),
111            status_code: Some(status_code),
112        }
113    }
114
115    /// Create a new WHOIS error.
116    pub fn whois<D: Into<String>, M: Into<String>>(domain: D, message: M) -> Self {
117        Self::WhoisError {
118            domain: domain.into(),
119            message: message.into(),
120        }
121    }
122
123    /// Create a new bootstrap error.
124    pub fn bootstrap<T: Into<String>, M: Into<String>>(tld: T, message: M) -> Self {
125        Self::BootstrapError {
126            tld: tld.into(),
127            message: message.into(),
128        }
129    }
130
131    /// Create a new timeout error.
132    pub fn timeout<O: Into<String>>(operation: O, duration: std::time::Duration) -> Self {
133        Self::Timeout {
134            operation: operation.into(),
135            duration,
136        }
137    }
138
139    /// Create a new invalid pattern error.
140    pub fn invalid_pattern<P: Into<String>, R: Into<String>>(pattern: P, reason: R) -> Self {
141        Self::InvalidPattern {
142            pattern: pattern.into(),
143            reason: reason.into(),
144        }
145    }
146
147    /// Create a new internal error.
148    pub fn internal<M: Into<String>>(message: M) -> Self {
149        Self::Internal {
150            message: message.into(),
151        }
152    }
153
154    /// Create a new file error.
155    pub fn file_error<P: Into<String>, M: Into<String>>(path: P, message: M) -> Self {
156        Self::FileError {
157            path: path.into(),
158            message: message.into(),
159        }
160    }
161
162    /// Check if this error indicates the domain is definitely available.
163    ///
164    /// Some error conditions (like NXDOMAIN) actually indicate availability.
165    pub fn indicates_available(&self) -> bool {
166        match self {
167            Self::RdapError {
168                status_code: Some(404),
169                ..
170            } => true,
171            Self::WhoisError { message, .. } => {
172                let msg = message.to_lowercase();
173                msg.contains("not found")
174                    || msg.contains("no match")
175                    || msg.contains("no data found")
176                    || msg.contains("domain available")
177            }
178            _ => false,
179        }
180    }
181
182    /// Check if this error suggests the operation should be retried.
183    pub fn is_retryable(&self) -> bool {
184        matches!(
185            self,
186            Self::NetworkError { .. }
187                | Self::Timeout { .. }
188                | Self::RateLimited { .. }
189                | Self::RdapError {
190                    status_code: Some(500..=599),
191                    ..
192                }
193        )
194        // InvalidPattern is not retryable — it's a user input error
195    }
196}
197
198impl fmt::Display for DomainCheckError {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        match self {
201            Self::InvalidDomain { domain, reason } => {
202                write!(f, "❌ '{}' is not a valid domain name: {}\n   💡 Try something like 'example.com' or use a different domain", domain, reason)
203            }
204            Self::NetworkError { message, source } => {
205                if message.to_lowercase().contains("connection") || message.to_lowercase().contains("connect") {
206                    write!(f, "🌐 Cannot connect to the internet\n   💡 Please check your network connection and try again")
207                } else if message.to_lowercase().contains("timeout") {
208                    write!(f, "⏱️ Request timed out\n   💡 Your internet connection may be slow. Try again or check fewer domains at once")
209                } else {
210                    let _ = source; // source captured for Debug/error chain, not user-facing
211                    write!(f, "🌐 Network error: {}\n   💡 Please check your internet connection", message)
212                }
213            }
214            Self::RdapError { domain, message, status_code } => {
215                match status_code {
216                    Some(404) => write!(f, "🔍 {}: RDAP returned no data, verifying via WHOIS", domain),
217                    Some(429) => write!(f, "⏳ {}: Registry is rate limiting requests\n   💡 Please wait a moment and try again", domain),
218                    Some(500..=599) => write!(f, "⚠️ {}: Registry server is temporarily unavailable\n   💡 Trying backup method...", domain),
219                    Some(code) => write!(f, "⚠️ {}: Registry returned error (HTTP {})\n   💡 This domain registry may be temporarily unavailable", domain, code),
220                    None => write!(f, "⚠️ {}: {}\n   💡 Trying alternative checking method...", domain, message),
221                }
222            }
223            Self::WhoisError { domain, message } => {
224                if message.to_lowercase().contains("not found") || message.to_lowercase().contains("no match") {
225                    write!(f, "✅ {}: Domain appears to be available", domain)
226                } else if message.to_lowercase().contains("rate limit") || message.to_lowercase().contains("too many") {
227                    write!(f, "⏳ {}: WHOIS server is rate limiting requests\n   💡 Please wait a moment and try again", domain)
228                } else if message.to_lowercase().contains("whois") && message.to_lowercase().contains("not found") {
229                    write!(f, "⚠️ {}: WHOIS command not found on this system\n   💡 Please install whois or use online domain checkers", domain)
230                } else {
231                    write!(f, "⚠️ {}: WHOIS lookup failed\n   💡 This may indicate the domain is available or the server is busy", domain)
232                }
233            }
234            Self::BootstrapError { tld, message: _ } => {
235                write!(f, "❓ Unknown domain extension '.{}'\n   💡 This TLD may not support automated checking. Try manually checking with a registrar", tld)
236            }
237            Self::ParseError { message: _, content: _ } => {
238                write!(f, "⚠️ Unable to understand server response\n   💡 The domain registry may be experiencing issues. Please try again later")
239            }
240            Self::ConfigError { message } => {
241                write!(f, "⚙️ Configuration error: {}\n   💡 Please check your command line arguments or configuration file values", message)
242            }
243            Self::FileError { path, message } => {
244                if message.to_lowercase().contains("not found") || message.to_lowercase().contains("no such file") {
245                    write!(f, "📁 File not found: {}\n   💡 Please check the file path and make sure the file exists", path)
246                } else if message.to_lowercase().contains("permission") {
247                    write!(f, "🔒 Permission denied: {}\n   💡 Please check file permissions or try running with appropriate access", path)
248                } else if message.to_lowercase().contains("no valid domains") {
249                    write!(f, "📄 No valid domains found in: {}\n   💡 Make sure the file contains domain names (one per line) and check the format", path)
250                } else {
251                    write!(f, "📁 File error ({}): {}\n   💡 Please check the file and try again", path, message)
252                }
253            }
254            Self::Timeout { operation, duration } => {
255                write!(f, "⏱️ Operation timed out after {:?}: {}\n   💡 Try reducing the number of domains or check your internet connection", duration, operation)
256            }
257            Self::RateLimited { service, message, retry_after } => {
258                match retry_after {
259                    Some(retry) => write!(f, "⏳ Rate limited by {}: {}\n   💡 Please wait {:?} and try again", service, message, retry),
260                    None => write!(f, "⏳ Rate limited by {}: {}\n   💡 Please wait a moment and try again", service, message),
261                }
262            }
263            Self::InvalidPattern { pattern, reason } => {
264                write!(f, "⚙️ Invalid pattern '{}': {}\n   💡 Supported: \\w (letters+hyphen), \\d (digits), ? (alphanumeric), literal characters", pattern, reason)
265            }
266            Self::Internal { message } => {
267                write!(f, "🔧 Internal error: {}\n   💡 This is unexpected. Please try again or report this issue", message)
268            }
269        }
270    }
271}
272
273impl std::error::Error for DomainCheckError {}
274
275// Implement From conversions for common error types
276impl From<reqwest::Error> for DomainCheckError {
277    fn from(err: reqwest::Error) -> Self {
278        if err.is_timeout() {
279            Self::timeout("HTTP request", std::time::Duration::from_secs(30))
280        } else if err.is_connect() {
281            Self::network_with_source("Connection failed", err.to_string())
282        } else {
283            Self::network_with_source("HTTP request failed", err.to_string())
284        }
285    }
286}
287
288impl From<serde_json::Error> for DomainCheckError {
289    fn from(err: serde_json::Error) -> Self {
290        Self::ParseError {
291            message: format!("JSON parsing failed: {}", err),
292            content: None,
293        }
294    }
295}
296
297impl From<std::io::Error> for DomainCheckError {
298    fn from(err: std::io::Error) -> Self {
299        Self::Internal {
300            message: format!("I/O error: {}", err),
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    // ── Constructor tests ───────────────────────────────────────────────
310
311    #[test]
312    fn test_invalid_domain_constructor() {
313        let err = DomainCheckError::invalid_domain("bad!", "contains special characters");
314        match err {
315            DomainCheckError::InvalidDomain { domain, reason } => {
316                assert_eq!(domain, "bad!");
317                assert_eq!(reason, "contains special characters");
318            }
319            _ => panic!("wrong variant"),
320        }
321    }
322
323    #[test]
324    fn test_network_constructor() {
325        let err = DomainCheckError::network("connection refused");
326        match err {
327            DomainCheckError::NetworkError { message, source } => {
328                assert_eq!(message, "connection refused");
329                assert!(source.is_none());
330            }
331            _ => panic!("wrong variant"),
332        }
333    }
334
335    #[test]
336    fn test_network_with_source_constructor() {
337        let err = DomainCheckError::network_with_source("failed", "dns lookup error");
338        match err {
339            DomainCheckError::NetworkError { message, source } => {
340                assert_eq!(message, "failed");
341                assert_eq!(source, Some("dns lookup error".to_string()));
342            }
343            _ => panic!("wrong variant"),
344        }
345    }
346
347    #[test]
348    fn test_rdap_constructor() {
349        let err = DomainCheckError::rdap("test.com", "lookup failed");
350        match err {
351            DomainCheckError::RdapError {
352                domain,
353                message,
354                status_code,
355            } => {
356                assert_eq!(domain, "test.com");
357                assert_eq!(message, "lookup failed");
358                assert!(status_code.is_none());
359            }
360            _ => panic!("wrong variant"),
361        }
362    }
363
364    #[test]
365    fn test_rdap_with_status_constructor() {
366        let err = DomainCheckError::rdap_with_status("test.com", "not found", 404);
367        match err {
368            DomainCheckError::RdapError {
369                domain,
370                message,
371                status_code,
372            } => {
373                assert_eq!(domain, "test.com");
374                assert_eq!(message, "not found");
375                assert_eq!(status_code, Some(404));
376            }
377            _ => panic!("wrong variant"),
378        }
379    }
380
381    #[test]
382    fn test_whois_constructor() {
383        let err = DomainCheckError::whois("test.com", "server unreachable");
384        match err {
385            DomainCheckError::WhoisError { domain, message } => {
386                assert_eq!(domain, "test.com");
387                assert_eq!(message, "server unreachable");
388            }
389            _ => panic!("wrong variant"),
390        }
391    }
392
393    #[test]
394    fn test_bootstrap_constructor() {
395        let err = DomainCheckError::bootstrap("xyz", "no endpoint found");
396        match err {
397            DomainCheckError::BootstrapError { tld, message } => {
398                assert_eq!(tld, "xyz");
399                assert_eq!(message, "no endpoint found");
400            }
401            _ => panic!("wrong variant"),
402        }
403    }
404
405    #[test]
406    fn test_timeout_constructor() {
407        let err = DomainCheckError::timeout("RDAP lookup", std::time::Duration::from_secs(10));
408        match err {
409            DomainCheckError::Timeout {
410                operation,
411                duration,
412            } => {
413                assert_eq!(operation, "RDAP lookup");
414                assert_eq!(duration, std::time::Duration::from_secs(10));
415            }
416            _ => panic!("wrong variant"),
417        }
418    }
419
420    #[test]
421    fn test_internal_constructor() {
422        let err = DomainCheckError::internal("unexpected state");
423        match err {
424            DomainCheckError::Internal { message } => {
425                assert_eq!(message, "unexpected state");
426            }
427            _ => panic!("wrong variant"),
428        }
429    }
430
431    #[test]
432    fn test_file_error_constructor() {
433        let err = DomainCheckError::file_error("/tmp/domains.txt", "permission denied");
434        match err {
435            DomainCheckError::FileError { path, message } => {
436                assert_eq!(path, "/tmp/domains.txt");
437                assert_eq!(message, "permission denied");
438            }
439            _ => panic!("wrong variant"),
440        }
441    }
442
443    #[test]
444    fn test_invalid_pattern_constructor() {
445        let err = DomainCheckError::invalid_pattern("test\\x", "unknown escape");
446        match err {
447            DomainCheckError::InvalidPattern { pattern, reason } => {
448                assert_eq!(pattern, "test\\x");
449                assert_eq!(reason, "unknown escape");
450            }
451            _ => panic!("wrong variant"),
452        }
453    }
454
455    // ── indicates_available ─────────────────────────────────────────────
456
457    #[test]
458    fn test_rdap_404_indicates_available() {
459        let err = DomainCheckError::rdap_with_status("test.com", "not found", 404);
460        assert!(err.indicates_available());
461    }
462
463    #[test]
464    fn test_rdap_200_not_available() {
465        let err = DomainCheckError::rdap_with_status("test.com", "ok", 200);
466        assert!(!err.indicates_available());
467    }
468
469    #[test]
470    fn test_rdap_no_status_not_available() {
471        let err = DomainCheckError::rdap("test.com", "generic error");
472        assert!(!err.indicates_available());
473    }
474
475    #[test]
476    fn test_whois_not_found_indicates_available() {
477        let err = DomainCheckError::whois("test.com", "No match for domain NOT FOUND");
478        assert!(err.indicates_available());
479    }
480
481    #[test]
482    fn test_whois_no_data_found_indicates_available() {
483        let err = DomainCheckError::whois("test.com", "No Data Found");
484        assert!(err.indicates_available());
485    }
486
487    #[test]
488    fn test_whois_domain_available_indicates_available() {
489        let err = DomainCheckError::whois("test.com", "Domain Available for registration");
490        assert!(err.indicates_available());
491    }
492
493    #[test]
494    fn test_whois_rate_limit_not_available() {
495        let err = DomainCheckError::whois("test.com", "rate limited");
496        assert!(!err.indicates_available());
497    }
498
499    #[test]
500    fn test_network_error_not_available() {
501        let err = DomainCheckError::network("connection refused");
502        assert!(!err.indicates_available());
503    }
504
505    #[test]
506    fn test_timeout_not_available() {
507        let err = DomainCheckError::timeout("test", std::time::Duration::from_secs(5));
508        assert!(!err.indicates_available());
509    }
510
511    #[test]
512    fn test_invalid_pattern_not_available() {
513        let err = DomainCheckError::invalid_pattern("bad", "reason");
514        assert!(!err.indicates_available());
515    }
516
517    // ── is_retryable ────────────────────────────────────────────────────
518
519    #[test]
520    fn test_network_error_is_retryable() {
521        let err = DomainCheckError::network("connection refused");
522        assert!(err.is_retryable());
523    }
524
525    #[test]
526    fn test_network_with_source_is_retryable() {
527        let err = DomainCheckError::network_with_source("failed", "dns");
528        assert!(err.is_retryable());
529    }
530
531    #[test]
532    fn test_timeout_is_retryable() {
533        let err = DomainCheckError::timeout("test", std::time::Duration::from_secs(5));
534        assert!(err.is_retryable());
535    }
536
537    #[test]
538    fn test_rate_limited_is_retryable() {
539        let err = DomainCheckError::RateLimited {
540            service: "RDAP".to_string(),
541            message: "too many requests".to_string(),
542            retry_after: Some(std::time::Duration::from_secs(30)),
543        };
544        assert!(err.is_retryable());
545    }
546
547    #[test]
548    fn test_rdap_500_is_retryable() {
549        let err = DomainCheckError::rdap_with_status("test.com", "server error", 500);
550        assert!(err.is_retryable());
551    }
552
553    #[test]
554    fn test_rdap_502_is_retryable() {
555        let err = DomainCheckError::rdap_with_status("test.com", "bad gateway", 502);
556        assert!(err.is_retryable());
557    }
558
559    #[test]
560    fn test_rdap_599_is_retryable() {
561        let err = DomainCheckError::rdap_with_status("test.com", "error", 599);
562        assert!(err.is_retryable());
563    }
564
565    #[test]
566    fn test_rdap_403_not_retryable() {
567        let err = DomainCheckError::rdap_with_status("test.com", "forbidden", 403);
568        assert!(!err.is_retryable());
569    }
570
571    #[test]
572    fn test_rdap_404_not_retryable() {
573        let err = DomainCheckError::rdap_with_status("test.com", "not found", 404);
574        assert!(!err.is_retryable());
575    }
576
577    #[test]
578    fn test_config_error_not_retryable() {
579        let err = DomainCheckError::ConfigError {
580            message: "bad config".to_string(),
581        };
582        assert!(!err.is_retryable());
583    }
584
585    #[test]
586    fn test_invalid_domain_not_retryable() {
587        let err = DomainCheckError::invalid_domain("bad", "too short");
588        assert!(!err.is_retryable());
589    }
590
591    #[test]
592    fn test_invalid_pattern_not_retryable() {
593        let err = DomainCheckError::invalid_pattern("bad", "reason");
594        assert!(!err.is_retryable());
595    }
596
597    #[test]
598    fn test_file_error_not_retryable() {
599        let err = DomainCheckError::file_error("/tmp/x", "not found");
600        assert!(!err.is_retryable());
601    }
602
603    #[test]
604    fn test_bootstrap_error_not_retryable() {
605        let err = DomainCheckError::bootstrap("xyz", "no endpoint");
606        assert!(!err.is_retryable());
607    }
608
609    #[test]
610    fn test_internal_error_not_retryable() {
611        let err = DomainCheckError::internal("unexpected");
612        assert!(!err.is_retryable());
613    }
614
615    // ── Display for every variant ───────────────────────────────────────
616
617    #[test]
618    fn test_display_invalid_domain() {
619        let err = DomainCheckError::invalid_domain("x", "too short");
620        let msg = format!("{}", err);
621        assert!(msg.contains("x"));
622        assert!(msg.contains("too short"));
623    }
624
625    #[test]
626    fn test_display_network_connection_error() {
627        let err = DomainCheckError::network("connection refused");
628        let msg = format!("{}", err);
629        assert!(msg.contains("connect"));
630    }
631
632    #[test]
633    fn test_display_network_timeout_error() {
634        let err = DomainCheckError::network("request timeout");
635        let msg = format!("{}", err);
636        assert!(msg.contains("timed out") || msg.contains("timeout"));
637    }
638
639    #[test]
640    fn test_display_network_generic() {
641        let err = DomainCheckError::network("something broke");
642        let msg = format!("{}", err);
643        assert!(msg.contains("something broke"));
644    }
645
646    #[test]
647    fn test_display_rdap_404() {
648        let err = DomainCheckError::rdap_with_status("avail.com", "not found", 404);
649        let msg = format!("{}", err);
650        assert!(msg.contains("avail.com"));
651        assert!(msg.contains("RDAP returned no data"));
652        assert!(msg.contains("WHOIS"));
653    }
654
655    #[test]
656    fn test_display_rdap_429() {
657        let err = DomainCheckError::rdap_with_status("test.com", "rate limited", 429);
658        let msg = format!("{}", err);
659        assert!(msg.contains("rate limiting"));
660    }
661
662    #[test]
663    fn test_display_rdap_5xx() {
664        let err = DomainCheckError::rdap_with_status("test.com", "error", 503);
665        let msg = format!("{}", err);
666        assert!(msg.contains("temporarily unavailable"));
667    }
668
669    #[test]
670    fn test_display_rdap_other_status() {
671        let err = DomainCheckError::rdap_with_status("test.com", "weird", 418);
672        let msg = format!("{}", err);
673        assert!(msg.contains("418"));
674    }
675
676    #[test]
677    fn test_display_rdap_no_status() {
678        let err = DomainCheckError::rdap("test.com", "lookup failed");
679        let msg = format!("{}", err);
680        assert!(msg.contains("lookup failed"));
681    }
682
683    #[test]
684    fn test_display_whois_not_found() {
685        let err = DomainCheckError::whois("test.com", "not found");
686        let msg = format!("{}", err);
687        assert!(msg.contains("available"));
688    }
689
690    #[test]
691    fn test_display_whois_rate_limit() {
692        let err = DomainCheckError::whois("test.com", "too many requests");
693        let msg = format!("{}", err);
694        assert!(msg.contains("rate limiting"));
695    }
696
697    #[test]
698    fn test_display_whois_generic() {
699        let err = DomainCheckError::whois("test.com", "server error");
700        let msg = format!("{}", err);
701        assert!(msg.contains("WHOIS lookup failed"));
702    }
703
704    #[test]
705    fn test_display_bootstrap_error() {
706        let err = DomainCheckError::bootstrap("xyz", "no endpoint");
707        let msg = format!("{}", err);
708        assert!(msg.contains(".xyz"));
709    }
710
711    #[test]
712    fn test_display_parse_error() {
713        let err = DomainCheckError::ParseError {
714            message: "bad json".to_string(),
715            content: None,
716        };
717        let msg = format!("{}", err);
718        assert!(msg.contains("server response"));
719    }
720
721    #[test]
722    fn test_display_config_error() {
723        let err = DomainCheckError::ConfigError {
724            message: "invalid value".to_string(),
725        };
726        let msg = format!("{}", err);
727        assert!(msg.contains("invalid value"));
728    }
729
730    #[test]
731    fn test_display_file_not_found() {
732        let err = DomainCheckError::file_error("/tmp/x.txt", "no such file");
733        let msg = format!("{}", err);
734        assert!(msg.contains("not found") || msg.contains("no such file"));
735    }
736
737    #[test]
738    fn test_display_file_permission() {
739        let err = DomainCheckError::file_error("/tmp/x.txt", "permission denied");
740        let msg = format!("{}", err);
741        assert!(msg.contains("Permission denied") || msg.contains("permission"));
742    }
743
744    #[test]
745    fn test_display_file_no_valid_domains() {
746        let err = DomainCheckError::file_error("/tmp/x.txt", "no valid domains found");
747        let msg = format!("{}", err);
748        assert!(msg.contains("No valid domains"));
749    }
750
751    #[test]
752    fn test_display_file_generic() {
753        let err = DomainCheckError::file_error("/tmp/x.txt", "corrupt data");
754        let msg = format!("{}", err);
755        assert!(msg.contains("corrupt data"));
756    }
757
758    #[test]
759    fn test_display_timeout() {
760        let err = DomainCheckError::timeout("RDAP", std::time::Duration::from_secs(5));
761        let msg = format!("{}", err);
762        assert!(msg.contains("timed out"));
763        assert!(msg.contains("RDAP"));
764    }
765
766    #[test]
767    fn test_display_rate_limited_with_retry() {
768        let err = DomainCheckError::RateLimited {
769            service: "RDAP".to_string(),
770            message: "slow down".to_string(),
771            retry_after: Some(std::time::Duration::from_secs(30)),
772        };
773        let msg = format!("{}", err);
774        assert!(msg.contains("RDAP"));
775        assert!(msg.contains("30"));
776    }
777
778    #[test]
779    fn test_display_rate_limited_without_retry() {
780        let err = DomainCheckError::RateLimited {
781            service: "WHOIS".to_string(),
782            message: "slow down".to_string(),
783            retry_after: None,
784        };
785        let msg = format!("{}", err);
786        assert!(msg.contains("WHOIS"));
787        assert!(msg.contains("wait a moment"));
788    }
789
790    #[test]
791    fn test_display_invalid_pattern() {
792        let err = DomainCheckError::invalid_pattern("test\\x", "unknown escape sequence '\\x'");
793        let msg = format!("{}", err);
794        assert!(msg.contains("test\\x"));
795        assert!(msg.contains("\\w")); // hint
796        assert!(msg.contains("\\d")); // hint
797    }
798
799    #[test]
800    fn test_display_internal() {
801        let err = DomainCheckError::internal("lock poisoned");
802        let msg = format!("{}", err);
803        assert!(msg.contains("lock poisoned"));
804        assert!(msg.contains("Internal error") || msg.contains("unexpected"));
805    }
806
807    // ── From conversions ────────────────────────────────────────────────
808
809    #[test]
810    fn test_from_serde_json_error() {
811        let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
812        let err: DomainCheckError = json_err.into();
813        match err {
814            DomainCheckError::ParseError { message, content } => {
815                assert!(message.contains("JSON parsing failed"));
816                assert!(content.is_none());
817            }
818            _ => panic!("expected ParseError, got {:?}", err),
819        }
820    }
821
822    #[test]
823    fn test_from_io_error() {
824        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
825        let err: DomainCheckError = io_err.into();
826        match err {
827            DomainCheckError::Internal { message } => {
828                assert!(message.contains("I/O error"));
829                assert!(message.contains("file missing"));
830            }
831            _ => panic!("expected Internal, got {:?}", err),
832        }
833    }
834
835    // ── std::error::Error trait ─────────────────────────────────────────
836
837    #[test]
838    fn test_error_trait_implemented() {
839        let err = DomainCheckError::network("test");
840        // Verify it can be used as a trait object
841        let _: &dyn std::error::Error = &err;
842    }
843}