seer_core/
lookup.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use tracing::{debug, warn};
4
5use crate::error::Result;
6use crate::rdap::{RdapClient, RdapResponse};
7use crate::whois::{WhoisClient, WhoisResponse};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(tag = "source", rename_all = "lowercase")]
11pub enum LookupResult {
12    Rdap {
13        data: Box<RdapResponse>,
14        #[serde(skip_serializing_if = "Option::is_none")]
15        whois_fallback: Option<WhoisResponse>,
16    },
17    Whois {
18        data: WhoisResponse,
19        rdap_error: Option<String>,
20    },
21}
22
23impl LookupResult {
24    pub fn domain_name(&self) -> Option<String> {
25        match self {
26            LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
27            LookupResult::Whois { data, .. } => Some(data.domain.clone()),
28        }
29    }
30
31    pub fn registrar(&self) -> Option<String> {
32        match self {
33            LookupResult::Rdap { data, whois_fallback } => {
34                data.get_registrar().or_else(|| {
35                    whois_fallback.as_ref().and_then(|w| w.registrar.clone())
36                })
37            }
38            LookupResult::Whois { data, .. } => data.registrar.clone(),
39        }
40    }
41
42    pub fn is_rdap(&self) -> bool {
43        matches!(self, LookupResult::Rdap { .. })
44    }
45
46    pub fn is_whois(&self) -> bool {
47        matches!(self, LookupResult::Whois { .. })
48    }
49
50    /// Get expiration date and registrar info from the lookup result
51    pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
52        match self {
53            LookupResult::Rdap { data, whois_fallback } => {
54                // Try to get expiration from RDAP events
55                let expiration_date = data
56                    .events
57                    .iter()
58                    .find(|e| e.event_action == "expiration")
59                    .and_then(|e| e.parsed_date())
60                    .or_else(|| {
61                        // Fallback to WHOIS if available
62                        whois_fallback.as_ref().and_then(|w| w.expiration_date)
63                    });
64
65                let registrar = data.get_registrar().or_else(|| {
66                    whois_fallback.as_ref().and_then(|w| w.registrar.clone())
67                });
68
69                (expiration_date, registrar)
70            }
71            LookupResult::Whois { data, .. } => {
72                (data.expiration_date, data.registrar.clone())
73            }
74        }
75    }
76}
77
78#[derive(Debug, Clone)]
79pub struct SmartLookup {
80    rdap_client: RdapClient,
81    whois_client: WhoisClient,
82    prefer_rdap: bool,
83    include_fallback: bool,
84}
85
86impl Default for SmartLookup {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl SmartLookup {
93    pub fn new() -> Self {
94        Self {
95            rdap_client: RdapClient::new(),
96            whois_client: WhoisClient::new(),
97            prefer_rdap: true,
98            include_fallback: false,
99        }
100    }
101
102    /// Always try RDAP first, fall back to WHOIS on failure
103    pub fn prefer_rdap(mut self, prefer: bool) -> Self {
104        self.prefer_rdap = prefer;
105        self
106    }
107
108    /// Include WHOIS data as fallback even when RDAP succeeds (for additional fields)
109    pub fn include_fallback(mut self, include: bool) -> Self {
110        self.include_fallback = include;
111        self
112    }
113
114    pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
115        if self.prefer_rdap {
116            self.lookup_rdap_first(domain).await
117        } else {
118            self.lookup_whois_first(domain).await
119        }
120    }
121
122    async fn lookup_rdap_first(&self, domain: &str) -> Result<LookupResult> {
123        debug!(domain = %domain, "Attempting RDAP lookup first");
124
125        match self.rdap_client.lookup_domain(domain).await {
126            Ok(rdap_data) => {
127                // Check if RDAP response has meaningful data
128                if self.is_rdap_response_useful(&rdap_data) {
129                    debug!("RDAP lookup successful");
130
131                    // Optionally fetch WHOIS for additional data
132                    let whois_fallback = if self.include_fallback {
133                        match self.whois_client.lookup(domain).await {
134                            Ok(whois) => Some(whois),
135                            Err(e) => {
136                                debug!(error = %e, "WHOIS fallback failed");
137                                None
138                            }
139                        }
140                    } else {
141                        None
142                    };
143
144                    Ok(LookupResult::Rdap {
145                        data: Box::new(rdap_data),
146                        whois_fallback,
147                    })
148                } else {
149                    debug!("RDAP response lacks useful data, falling back to WHOIS");
150                    self.fallback_to_whois(domain, Some("RDAP response incomplete")).await
151                }
152            }
153            Err(e) => {
154                warn!(error = %e, "RDAP lookup failed, falling back to WHOIS");
155                self.fallback_to_whois(domain, Some(&e.to_string())).await
156            }
157        }
158    }
159
160    async fn lookup_whois_first(&self, domain: &str) -> Result<LookupResult> {
161        debug!(domain = %domain, "Attempting WHOIS lookup first");
162
163        match self.whois_client.lookup(domain).await {
164            Ok(whois_data) => {
165                Ok(LookupResult::Whois {
166                    data: whois_data,
167                    rdap_error: None,
168                })
169            }
170            Err(e) => {
171                warn!(error = %e, "WHOIS lookup failed, trying RDAP");
172                // Try RDAP as fallback
173                let rdap_data = self.rdap_client.lookup_domain(domain).await?;
174                Ok(LookupResult::Rdap {
175                    data: Box::new(rdap_data),
176                    whois_fallback: None,
177                })
178            }
179        }
180    }
181
182    async fn fallback_to_whois(&self, domain: &str, rdap_error: Option<&str>) -> Result<LookupResult> {
183        let whois_data = self.whois_client.lookup(domain).await?;
184        Ok(LookupResult::Whois {
185            data: whois_data,
186            rdap_error: rdap_error.map(String::from),
187        })
188    }
189
190    fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
191        // Check if we have at least some meaningful data
192        let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
193        let has_dates = response.events.iter().any(|e| {
194            e.event_action == "registration" || e.event_action == "expiration"
195        });
196        let has_entities = !response.entities.is_empty();
197        let has_nameservers = !response.nameservers.is_empty();
198        let has_status = !response.status.is_empty();
199
200        // Consider useful if we have the name plus at least one other piece of info
201        has_name && (has_dates || has_entities || has_nameservers || has_status)
202    }
203}