Skip to main content

domain_check_lib/protocols/
registry.rs

1//! Domain registry mappings and IANA bootstrap functionality.
2//!
3//! This module provides mappings from TLDs to their corresponding RDAP endpoints,
4//! as well as dynamic discovery through the IANA bootstrap registry.
5
6use crate::error::DomainCheckError;
7use std::collections::{HashMap, HashSet};
8use std::sync::Mutex;
9use std::time::{Duration, Instant};
10
11/// Bootstrap registry cache for discovered RDAP endpoints and WHOIS servers.
12///
13/// This cache stores RDAP endpoints from the IANA bootstrap registry and
14/// WHOIS server mappings discovered via IANA referral queries.
15struct BootstrapCache {
16    /// TLD -> RDAP endpoint URL (from IANA bootstrap)
17    rdap_endpoints: HashMap<String, String>,
18    /// TLD -> WHOIS server hostname (from IANA referral)
19    whois_servers: HashMap<String, String>,
20    /// TLDs known to have no RDAP endpoint (negative cache)
21    no_rdap: HashSet<String>,
22    /// Whether the full IANA bootstrap has been fetched
23    rdap_loaded: bool,
24    /// When the full bootstrap was last fetched
25    last_fetch: Option<Instant>,
26}
27
28/// Bootstrap cache TTL: 24 hours (RDAP endpoints rarely change)
29const BOOTSTRAP_TTL: Duration = Duration::from_secs(24 * 3600);
30
31impl BootstrapCache {
32    fn new() -> Self {
33        Self {
34            rdap_endpoints: HashMap::new(),
35            whois_servers: HashMap::new(),
36            no_rdap: HashSet::new(),
37            rdap_loaded: false,
38            last_fetch: None,
39        }
40    }
41
42    fn is_stale(&self) -> bool {
43        match self.last_fetch {
44            Some(t) => t.elapsed() > BOOTSTRAP_TTL,
45            None => true,
46        }
47    }
48}
49
50// Global bootstrap cache using lazy_static
51lazy_static::lazy_static! {
52    static ref BOOTSTRAP_CACHE: Mutex<BootstrapCache> = Mutex::new(BootstrapCache::new());
53}
54
55/// Get the built-in RDAP registry mappings.
56///
57/// This function returns a map of TLD strings to their corresponding RDAP endpoint URLs.
58/// These mappings are based on known registry endpoints and are updated periodically.
59///
60/// # Returns
61///
62/// A HashMap mapping TLD strings (like "com", "org") to RDAP endpoint base URLs.
63pub fn get_rdap_registry_map() -> HashMap<&'static str, &'static str> {
64    HashMap::from([
65        // Popular gTLDs (Generic Top-Level Domains)
66        ("com", "https://rdap.verisign.com/com/v1/domain/"),
67        ("net", "https://rdap.verisign.com/net/v1/domain/"),
68        (
69            "org",
70            "https://rdap.publicinterestregistry.org/rdap/domain/",
71        ),
72        ("info", "https://rdap.identitydigital.services/rdap/domain/"),
73        ("biz", "https://rdap.nic.biz/domain/"),
74        // Google TLDs (updated: rdap.nic.google no longer exists)
75        ("app", "https://pubapi.registry.google/rdap/domain/"),
76        ("dev", "https://pubapi.registry.google/rdap/domain/"),
77        ("page", "https://pubapi.registry.google/rdap/domain/"),
78        // CentralNic managed gTLDs
79        ("xyz", "https://rdap.centralnic.com/xyz/domain/"),
80        ("tech", "https://rdap.centralnic.com/tech/domain/"),
81        ("online", "https://rdap.centralnic.com/online/domain/"),
82        ("site", "https://rdap.centralnic.com/site/domain/"),
83        ("website", "https://rdap.centralnic.com/website/domain/"),
84        // Other popular gTLDs
85        ("blog", "https://rdap.blog.fury.ca/rdap/domain/"),
86        ("shop", "https://rdap.gmoregistry.net/rdap/domain/"),
87        // Identity Digital managed TLDs
88        ("ai", "https://rdap.identitydigital.services/rdap/domain/"), // Anguilla
89        ("io", "https://rdap.identitydigital.services/rdap/domain/"), // British Indian Ocean Territory
90        ("me", "https://rdap.identitydigital.services/rdap/domain/"), // Montenegro
91        ("zone", "https://rdap.identitydigital.services/rdap/domain/"),
92        (
93            "digital",
94            "https://rdap.identitydigital.services/rdap/domain/",
95        ),
96        // Country Code TLDs (ccTLDs) with working RDAP endpoints
97        ("us", "https://rdap.nic.us/domain/"), // United States
98        ("uk", "https://rdap.nominet.uk/domain/"), // United Kingdom
99        ("de", "https://rdap.denic.de/domain/"), // Germany
100        ("ca", "https://rdap.ca.fury.ca/rdap/domain/"), // Canada
101        ("au", "https://rdap.cctld.au/rdap/domain/"), // Australia
102        ("fr", "https://rdap.nic.fr/domain/"), // France
103        ("nl", "https://rdap.sidn.nl/domain/"), // Netherlands
104        ("br", "https://rdap.registro.br/domain/"), // Brazil
105        ("in", "https://rdap.nixiregistry.in/rdap/domain/"), // India
106        // Verisign managed ccTLDs
107        ("tv", "https://rdap.nic.tv/domain/"), // Tuvalu
108        ("cc", "https://tld-rdap.verisign.com/cc/v1/domain/"), // Cocos Islands
109        // Specialty TLDs
110        ("cloud", "https://rdap.registry.cloud/rdap/domain/"),
111        // NOTE: co, eu, it, jp, es, cn removed — their RDAP endpoints are
112        // defunct and no working alternatives found. These TLDs will fall
113        // through to WHOIS fallback, which handles them correctly.
114    ])
115}
116
117/// Get all TLDs that we have RDAP endpoints for.
118///
119/// Returns the union of hardcoded registry keys and bootstrap cache keys,
120/// deduplicated and sorted alphabetically.
121///
122/// # Returns
123///
124/// Vector of TLD strings (e.g., ["com", "org", "net", ...]) sorted alphabetically.
125pub fn get_all_known_tlds() -> Vec<String> {
126    let registry = get_rdap_registry_map();
127    let mut tld_set: HashSet<String> = registry.keys().map(|k| k.to_string()).collect();
128
129    // Include bootstrap cache entries
130    if let Ok(cache) = BOOTSTRAP_CACHE.lock() {
131        for tld in cache.rdap_endpoints.keys() {
132            tld_set.insert(tld.clone());
133        }
134    }
135
136    let mut tlds: Vec<String> = tld_set.into_iter().collect();
137    tlds.sort(); // Consistent ordering for user experience
138    tlds
139}
140
141/// Get predefined TLD presets for common use cases.
142///
143/// This function provides curated TLD lists for common scenarios.
144/// For custom preset support, use `get_preset_tlds_with_custom()`.
145///
146/// # Arguments
147///
148/// * `preset` - The preset name ("startup", "enterprise", "country")
149///
150/// # Returns
151///
152/// Optional vector of TLD strings, None if preset doesn't exist.
153///
154/// # Examples
155///
156/// ```rust
157/// use domain_check_lib::get_preset_tlds;
158///
159/// let startup_tlds = get_preset_tlds("startup").unwrap();
160/// assert!(startup_tlds.contains(&"io".to_string()));
161/// ```
162pub fn get_preset_tlds(preset: &str) -> Option<Vec<String>> {
163    let tlds: Option<Vec<&str>> = match preset.to_lowercase().as_str() {
164        "startup" => Some(vec!["com", "org", "io", "ai", "tech", "app", "dev", "xyz"]),
165        "enterprise" => Some(vec!["com", "org", "net", "info", "biz", "us"]),
166        "country" => Some(vec!["us", "uk", "de", "fr", "ca", "au", "br", "in", "nl"]),
167        "popular" => Some(vec![
168            "com", "net", "org", "io", "ai", "app", "dev", "tech", "me", "co", "xyz",
169        ]),
170        "classic" => Some(vec!["com", "net", "org", "info", "biz"]),
171        "tech" => Some(vec![
172            "io",
173            "ai",
174            "app",
175            "dev",
176            "tech",
177            "cloud",
178            "software",
179            "digital",
180            "codes",
181            "systems",
182            "network",
183            "solutions",
184        ]),
185        "creative" => Some(vec![
186            "design",
187            "art",
188            "studio",
189            "media",
190            "photography",
191            "film",
192            "music",
193            "gallery",
194            "graphics",
195            "ink",
196        ]),
197        "ecommerce" | "shopping" => Some(vec![
198            "shop", "store", "market", "sale", "deals", "shopping", "buy", "bargains",
199        ]),
200        "finance" => Some(vec![
201            "finance",
202            "capital",
203            "fund",
204            "money",
205            "investments",
206            "insurance",
207            "tax",
208            "exchange",
209            "trading",
210        ]),
211        "web" => Some(vec![
212            "web", "site", "website", "online", "blog", "page", "wiki", "host", "email",
213        ]),
214        "trendy" => Some(vec![
215            "xyz", "online", "site", "top", "icu", "fun", "space", "click", "website", "life",
216            "world", "live", "today",
217        ]),
218        _ => None,
219    };
220    tlds.map(|v| v.into_iter().map(|s| s.to_string()).collect())
221}
222
223/// Get predefined TLD presets with custom preset support.
224///
225/// This function checks custom presets first, then falls back to built-in presets.
226///
227/// # Arguments
228///
229/// * `preset` - The preset name to look up
230/// * `custom_presets` - Optional custom presets from config files
231///
232/// # Returns
233///
234/// Optional vector of TLD strings, None if preset doesn't exist.
235///
236/// # Examples
237///
238/// ```rust
239/// use std::collections::HashMap;
240/// use domain_check_lib::get_preset_tlds_with_custom;
241///
242/// let mut custom = HashMap::new();
243/// custom.insert("my_preset".to_string(), vec!["com".to_string(), "dev".to_string()]);
244///
245/// let tlds = get_preset_tlds_with_custom("my_preset", Some(&custom)).unwrap();
246/// assert_eq!(tlds, vec!["com", "dev"]);
247/// ```
248pub fn get_preset_tlds_with_custom(
249    preset: &str,
250    custom_presets: Option<&std::collections::HashMap<String, Vec<String>>>,
251) -> Option<Vec<String>> {
252    let preset_lower = preset.to_lowercase();
253
254    // 1. Check custom presets first (highest precedence)
255    if let Some(custom_map) = custom_presets {
256        // Check both original case and lowercase
257        if let Some(custom_tlds) = custom_map
258            .get(preset)
259            .or_else(|| custom_map.get(&preset_lower))
260        {
261            return Some(custom_tlds.clone());
262        }
263    }
264
265    // 2. Fall back to built-in presets
266    get_preset_tlds(&preset_lower)
267}
268
269/// Get available preset names.
270///
271/// Useful for CLI help text and validation.
272///
273/// # Returns
274///
275/// Vector of available preset names.
276pub fn get_available_presets() -> Vec<&'static str> {
277    vec![
278        "classic",
279        "country",
280        "creative",
281        "ecommerce",
282        "enterprise",
283        "finance",
284        "popular",
285        "startup",
286        "tech",
287        "trendy",
288        "web",
289    ]
290}
291
292/// Validate that all TLDs in a preset have hardcoded RDAP endpoints.
293///
294/// Returns true only if every TLD has a hardcoded RDAP endpoint in the
295/// built-in registry. TLDs covered by bootstrap or WHOIS fallback will
296/// return false here but still work at runtime.
297///
298/// # Arguments
299///
300/// * `preset_tlds` - TLD list to validate
301///
302/// # Returns
303///
304/// True if all TLDs have hardcoded RDAP endpoints, false otherwise.
305#[allow(dead_code)]
306pub fn validate_preset_tlds(preset_tlds: &[String]) -> bool {
307    let registry = get_rdap_registry_map();
308    preset_tlds
309        .iter()
310        .all(|tld| registry.contains_key(tld.as_str()))
311}
312
313/// Look up RDAP endpoint for a given TLD.
314///
315/// Lookup flow:
316/// 1. Check hardcoded registry (32 TLDs) — instant, offline fallback
317/// 2. Check bootstrap cache hit — O(1) HashMap lookup
318/// 3. Check negative cache (no_rdap set) — skip network if TLD known to lack RDAP
319/// 4. If cache empty or stale (24h): call fetch_full_bootstrap(), re-check
320/// 5. If still not found after full fetch: add TLD to no_rdap set, return error
321///
322/// # Arguments
323///
324/// * `tld` - The top-level domain to look up (e.g., "com", "org")
325/// * `use_bootstrap` - Whether to use IANA bootstrap for unknown TLDs
326///
327/// # Returns
328///
329/// The RDAP endpoint URL if found, or an error if not available.
330pub async fn get_rdap_endpoint(tld: &str, use_bootstrap: bool) -> Result<String, DomainCheckError> {
331    let tld_lower = tld.to_lowercase();
332
333    // 1. Check built-in registry (instant, offline)
334    let registry = get_rdap_registry_map();
335    if let Some(endpoint) = registry.get(tld_lower.as_str()) {
336        return Ok(endpoint.to_string());
337    }
338
339    // 2-3. Check bootstrap cache and negative cache
340    {
341        let cache = BOOTSTRAP_CACHE
342            .lock()
343            .map_err(|_| DomainCheckError::internal("Failed to acquire bootstrap cache lock"))?;
344
345        // Check positive cache (not stale)
346        if !cache.is_stale() {
347            if let Some(endpoint) = cache.rdap_endpoints.get(&tld_lower) {
348                return Ok(endpoint.clone());
349            }
350        }
351
352        // Check negative cache (TLD known to have no RDAP)
353        if cache.no_rdap.contains(&tld_lower) && !cache.is_stale() {
354            return Err(DomainCheckError::bootstrap(
355                &tld_lower,
356                "TLD has no known RDAP endpoint",
357            ));
358        }
359    }
360
361    // 4. If bootstrap enabled, fetch full bootstrap and re-check
362    if use_bootstrap {
363        // Fetch if cache is empty or stale
364        let needs_fetch = {
365            let cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
366                DomainCheckError::internal("Failed to acquire bootstrap cache lock")
367            })?;
368            !cache.rdap_loaded || cache.is_stale()
369        };
370
371        if needs_fetch {
372            fetch_full_bootstrap().await?;
373        }
374
375        // Re-check after fetch
376        let cache = BOOTSTRAP_CACHE
377            .lock()
378            .map_err(|_| DomainCheckError::internal("Failed to acquire bootstrap cache lock"))?;
379
380        if let Some(endpoint) = cache.rdap_endpoints.get(&tld_lower) {
381            return Ok(endpoint.clone());
382        }
383
384        // 5. Still not found — add to negative cache and return error
385        drop(cache);
386        {
387            let mut cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
388                DomainCheckError::internal("Failed to acquire bootstrap cache lock")
389            })?;
390            cache.no_rdap.insert(tld_lower.clone());
391        }
392
393        Err(DomainCheckError::bootstrap(
394            &tld_lower,
395            "TLD not found in IANA bootstrap registry",
396        ))
397    } else {
398        Err(DomainCheckError::bootstrap(
399            &tld_lower,
400            "No known RDAP endpoint and bootstrap disabled",
401        ))
402    }
403}
404
405/// Fetch the full IANA bootstrap registry and populate the cache.
406///
407/// Instead of fetching per-TLD, this downloads the complete IANA RDAP bootstrap
408/// JSON and parses all service entries at once. Much more efficient for bulk
409/// operations and provides coverage for ~1,180 TLDs.
410async fn fetch_full_bootstrap() -> Result<(), DomainCheckError> {
411    const BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json";
412
413    let client = reqwest::Client::builder()
414        .timeout(Duration::from_secs(10))
415        .build()
416        .map_err(|e| {
417            DomainCheckError::network_with_source("Failed to create HTTP client", e.to_string())
418        })?;
419
420    let response = client.get(BOOTSTRAP_URL).send().await.map_err(|e| {
421        DomainCheckError::bootstrap("*", format!("Failed to fetch bootstrap registry: {}", e))
422    })?;
423
424    if !response.status().is_success() {
425        return Err(DomainCheckError::bootstrap(
426            "*",
427            format!("Bootstrap registry returned HTTP {}", response.status()),
428        ));
429    }
430
431    let json: serde_json::Value = response.json().await.map_err(|e| {
432        DomainCheckError::bootstrap("*", format!("Failed to parse bootstrap JSON: {}", e))
433    })?;
434
435    // Validate structure
436    let services = json
437        .get("services")
438        .and_then(|s| s.as_array())
439        .ok_or_else(|| {
440            DomainCheckError::bootstrap(
441                "*",
442                "Invalid bootstrap JSON: missing or invalid 'services' array",
443            )
444        })?;
445
446    let mut endpoints: HashMap<String, String> = HashMap::new();
447
448    for service in services {
449        if let Some(service_array) = service.as_array() {
450            if service_array.len() >= 2 {
451                // Get the endpoint URL(s)
452                let url = service_array[1]
453                    .as_array()
454                    .and_then(|urls| urls.first())
455                    .and_then(|u| u.as_str());
456
457                if let Some(url) = url {
458                    let endpoint = format!("{}/domain/", url.trim_end_matches('/'));
459
460                    // Get all TLDs served by this endpoint
461                    if let Some(tlds) = service_array[0].as_array() {
462                        for t in tlds {
463                            if let Some(tld_str) = t.as_str() {
464                                endpoints.insert(tld_str.to_lowercase(), endpoint.clone());
465                            }
466                        }
467                    }
468                }
469            }
470        }
471    }
472
473    // Update cache atomically
474    let mut cache = BOOTSTRAP_CACHE
475        .lock()
476        .map_err(|_| DomainCheckError::internal("Failed to acquire bootstrap cache lock"))?;
477
478    cache.rdap_endpoints = endpoints;
479    cache.rdap_loaded = true;
480    cache.last_fetch = Some(Instant::now());
481    cache.no_rdap.clear(); // Reset negative cache on fresh fetch
482
483    Ok(())
484}
485
486/// Pre-warm the bootstrap cache by fetching the full IANA registry.
487///
488/// Call this before bulk operations (e.g., `--all` mode) to ensure all ~1,180
489/// TLDs are available without per-TLD network requests.
490///
491/// This is safe to call multiple times — subsequent calls are no-ops if the
492/// cache is still fresh (within the 24-hour TTL).
493pub async fn initialize_bootstrap() -> Result<(), DomainCheckError> {
494    let needs_fetch = {
495        let cache = BOOTSTRAP_CACHE
496            .lock()
497            .map_err(|_| DomainCheckError::internal("Failed to acquire bootstrap cache lock"))?;
498        !cache.rdap_loaded || cache.is_stale()
499    };
500
501    if needs_fetch {
502        fetch_full_bootstrap().await?;
503    }
504
505    Ok(())
506}
507
508/// Cache a discovered WHOIS server for a TLD.
509pub fn cache_whois_server(tld: &str, server: &str) -> Result<(), DomainCheckError> {
510    let mut cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
511        DomainCheckError::internal("Failed to acquire bootstrap cache lock for writing")
512    })?;
513
514    cache
515        .whois_servers
516        .insert(tld.to_lowercase(), server.to_string());
517    Ok(())
518}
519
520/// Look up a cached WHOIS server for a TLD.
521///
522/// Checks the bootstrap cache for a previously discovered WHOIS server.
523/// If not cached, the caller should use `discover_whois_server()` from
524/// the whois module and cache the result.
525///
526/// # Arguments
527///
528/// * `tld` - The TLD to look up (e.g., "com", "co")
529///
530/// # Returns
531///
532/// The WHOIS server hostname if cached, or None.
533pub fn get_cached_whois_server(tld: &str) -> Option<String> {
534    let cache = BOOTSTRAP_CACHE.lock().ok()?;
535    let server = cache.whois_servers.get(&tld.to_lowercase())?;
536    if server.is_empty() {
537        None // Empty string means "no server found" (negative cache)
538    } else {
539        Some(server.clone())
540    }
541}
542
543/// Check if a TLD has been negatively cached for WHOIS (no server found).
544pub fn is_whois_negatively_cached(tld: &str) -> bool {
545    if let Ok(cache) = BOOTSTRAP_CACHE.lock() {
546        matches!(cache.whois_servers.get(&tld.to_lowercase()), Some(s) if s.is_empty())
547    } else {
548        false
549    }
550}
551
552/// Get the WHOIS server for a TLD, using cache with IANA referral discovery fallback.
553///
554/// Lookup flow:
555/// 1. Check cache for previously discovered server
556/// 2. If miss and not negatively cached, discover via IANA referral
557/// 3. Cache result (empty string for "no server found" to avoid re-querying)
558///
559/// # Arguments
560///
561/// * `tld` - The TLD to look up
562///
563/// # Returns
564///
565/// The WHOIS server hostname, or None if no server exists for this TLD.
566pub async fn get_whois_server(tld: &str) -> Option<String> {
567    let tld_lower = tld.to_lowercase();
568
569    // Check positive cache
570    if let Some(server) = get_cached_whois_server(&tld_lower) {
571        return Some(server);
572    }
573
574    // Check negative cache
575    if is_whois_negatively_cached(&tld_lower) {
576        return None;
577    }
578
579    // Discover via IANA referral
580    match crate::protocols::whois::discover_whois_server(&tld_lower).await {
581        Some(server) => {
582            let _ = cache_whois_server(&tld_lower, &server);
583            Some(server)
584        }
585        None => {
586            // Cache empty string as negative result
587            let _ = cache_whois_server(&tld_lower, "");
588            None
589        }
590    }
591}
592
593/// Extract TLD from a domain name.
594///
595/// Handles both simple TLDs (example.com -> "com") and multi-level TLDs
596/// (example.co.uk -> "co.uk", though this function will return "uk").
597///
598/// # Arguments
599///
600/// * `domain` - The domain name to extract TLD from
601///
602/// # Returns
603///
604/// The TLD string, or an error if the domain format is invalid.
605pub fn extract_tld(domain: &str) -> Result<String, DomainCheckError> {
606    let parts: Vec<&str> = domain.split('.').collect();
607
608    if parts.len() < 2 {
609        return Err(DomainCheckError::invalid_domain(
610            domain,
611            "Domain must contain at least one dot",
612        ));
613    }
614
615    // Return the last part as TLD
616    // Note: This is simplified and doesn't handle multi-level TLDs like .co.uk
617    // For production use, consider using a library like publicsuffix
618    Ok(parts.last().unwrap().to_lowercase())
619}
620
621/// Clear the bootstrap cache (useful for testing).
622#[allow(dead_code)]
623pub fn clear_bootstrap_cache() -> Result<(), DomainCheckError> {
624    let mut cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
625        DomainCheckError::internal("Failed to acquire bootstrap cache lock for clearing")
626    })?;
627
628    cache.rdap_endpoints.clear();
629    cache.whois_servers.clear();
630    cache.no_rdap.clear();
631    cache.rdap_loaded = false;
632    cache.last_fetch = None;
633    Ok(())
634}
635
636/// Get bootstrap cache statistics (useful for debugging).
637#[allow(dead_code)]
638pub fn get_bootstrap_cache_stats() -> Result<(usize, bool), DomainCheckError> {
639    let cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
640        DomainCheckError::internal("Failed to acquire bootstrap cache lock for stats")
641    })?;
642
643    Ok((cache.rdap_endpoints.len(), cache.is_stale()))
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn test_extract_tld() {
652        assert_eq!(extract_tld("example.com").unwrap(), "com");
653        assert_eq!(extract_tld("test.org").unwrap(), "org");
654        assert_eq!(extract_tld("sub.example.com").unwrap(), "com");
655        assert!(extract_tld("invalid").is_err());
656        assert!(extract_tld("").is_err());
657    }
658
659    #[test]
660    fn test_registry_map_contains_common_tlds() {
661        let registry = get_rdap_registry_map();
662        assert!(registry.contains_key("com"));
663        assert!(registry.contains_key("org"));
664        assert!(registry.contains_key("net"));
665        assert!(registry.contains_key("io"));
666    }
667
668    #[tokio::test]
669    async fn test_get_rdap_endpoint_builtin() {
670        let endpoint = get_rdap_endpoint("com", false).await.unwrap();
671        assert!(endpoint.contains("verisign.com"));
672    }
673
674    #[tokio::test]
675    async fn test_get_rdap_endpoint_unknown_no_bootstrap() {
676        let result = get_rdap_endpoint("unknowntld123", false).await;
677        assert!(result.is_err());
678    }
679
680    #[test]
681    fn test_all_endpoints_are_valid_https_urls() {
682        let registry = get_rdap_registry_map();
683        for (tld, endpoint) in &registry {
684            assert!(
685                endpoint.starts_with("https://"),
686                "Endpoint for '{}' must use HTTPS: {}",
687                tld,
688                endpoint
689            );
690            assert!(
691                endpoint.ends_with("/domain/"),
692                "Endpoint for '{}' must end with /domain/: {}",
693                tld,
694                endpoint
695            );
696        }
697    }
698
699    #[test]
700    fn test_bootstrap_cache_new() {
701        let cache = BootstrapCache::new();
702        assert!(!cache.rdap_loaded);
703        assert!(cache.last_fetch.is_none());
704        assert!(cache.rdap_endpoints.is_empty());
705        assert!(cache.whois_servers.is_empty());
706        assert!(cache.no_rdap.is_empty());
707        assert!(cache.is_stale());
708    }
709
710    #[test]
711    fn test_whois_server_caching() {
712        clear_bootstrap_cache().unwrap();
713
714        // Cache a server
715        cache_whois_server("com", "whois.verisign-grs.com").unwrap();
716        assert_eq!(
717            get_cached_whois_server("com"),
718            Some("whois.verisign-grs.com".to_string())
719        );
720
721        // Cache negative result
722        cache_whois_server("fake", "").unwrap();
723        assert_eq!(get_cached_whois_server("fake"), None);
724        assert!(is_whois_negatively_cached("fake"));
725
726        clear_bootstrap_cache().unwrap();
727    }
728}
729
730#[cfg(test)]
731mod preset_tests {
732    use super::*;
733
734    #[test]
735    fn test_get_all_known_tlds() {
736        let tlds = get_all_known_tlds();
737
738        // Should have our expected core TLDs
739        assert!(tlds.len() >= 30);
740        assert!(tlds.contains(&"com".to_string()));
741        assert!(tlds.contains(&"org".to_string()));
742        assert!(tlds.contains(&"io".to_string()));
743        assert!(tlds.contains(&"ai".to_string()));
744
745        // Should be sorted for consistent UX
746        let mut sorted_tlds = tlds.clone();
747        sorted_tlds.sort();
748        assert_eq!(tlds, sorted_tlds);
749    }
750
751    #[test]
752    fn test_startup_preset() {
753        let tlds = get_preset_tlds("startup").unwrap();
754
755        assert_eq!(tlds.len(), 8);
756        assert!(tlds.contains(&"com".to_string()));
757        assert!(tlds.contains(&"io".to_string()));
758        assert!(tlds.contains(&"ai".to_string()));
759        assert!(tlds.contains(&"tech".to_string()));
760
761        // Case insensitive
762        assert_eq!(get_preset_tlds("STARTUP"), get_preset_tlds("startup"));
763    }
764
765    #[test]
766    fn test_enterprise_preset() {
767        let tlds = get_preset_tlds("enterprise").unwrap();
768
769        assert_eq!(tlds.len(), 6);
770        assert!(tlds.contains(&"com".to_string()));
771        assert!(tlds.contains(&"org".to_string()));
772        assert!(tlds.contains(&"biz".to_string()));
773    }
774
775    #[test]
776    fn test_country_preset() {
777        let tlds = get_preset_tlds("country").unwrap();
778
779        assert_eq!(tlds.len(), 9);
780        assert!(tlds.contains(&"us".to_string()));
781        assert!(tlds.contains(&"uk".to_string()));
782        assert!(tlds.contains(&"de".to_string()));
783        assert!(tlds.contains(&"nl".to_string()));
784    }
785
786    #[test]
787    fn test_invalid_preset() {
788        assert!(get_preset_tlds("invalid").is_none());
789        assert!(get_preset_tlds("").is_none());
790    }
791
792    #[test]
793    fn test_available_presets() {
794        let presets = get_available_presets();
795        assert_eq!(presets.len(), 11);
796        assert!(presets.contains(&"startup"));
797        assert!(presets.contains(&"enterprise"));
798        assert!(presets.contains(&"country"));
799        assert!(presets.contains(&"popular"));
800        assert!(presets.contains(&"classic"));
801        assert!(presets.contains(&"tech"));
802        assert!(presets.contains(&"creative"));
803        assert!(presets.contains(&"ecommerce"));
804        assert!(presets.contains(&"finance"));
805        assert!(presets.contains(&"web"));
806        assert!(presets.contains(&"trendy"));
807    }
808
809    #[test]
810    fn test_validate_preset_tlds() {
811        // Core presets (startup, enterprise, country, popular, classic) should
812        // have hardcoded RDAP endpoints for offline operation
813        let core_presets = ["startup", "enterprise", "country", "classic"];
814        for preset_name in &core_presets {
815            let tlds = get_preset_tlds(preset_name).unwrap();
816            assert!(
817                validate_preset_tlds(&tlds),
818                "Core preset '{}' contains TLDs without hardcoded RDAP endpoints",
819                preset_name
820            );
821        }
822    }
823
824    #[test]
825    fn test_all_presets_non_empty() {
826        for preset_name in get_available_presets() {
827            let tlds = get_preset_tlds(preset_name).unwrap();
828            assert!(
829                !tlds.is_empty(),
830                "Preset '{}' should not be empty",
831                preset_name
832            );
833        }
834    }
835
836    #[test]
837    fn test_ecommerce_alias() {
838        assert_eq!(get_preset_tlds("ecommerce"), get_preset_tlds("shopping"));
839    }
840
841    #[test]
842    fn test_preset_tlds_subset_of_known() {
843        // Only validate core presets against hardcoded TLDs
844        // (extended presets require bootstrap which isn't available in unit tests)
845        let core_presets = ["startup", "enterprise", "country", "classic"];
846        let all_tlds = get_all_known_tlds();
847
848        for preset_name in &core_presets {
849            let preset_tlds = get_preset_tlds(preset_name).unwrap();
850            for tld in preset_tlds {
851                assert!(
852                    all_tlds.contains(&tld),
853                    "Preset '{}' contains unknown TLD: {}",
854                    preset_name,
855                    tld
856                );
857            }
858        }
859    }
860}