Skip to main content

reserve_core/lookup/
outcome.rs

1use std::fmt;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5
6/// @docgen Remote text reaches a terminal and a JSON file, so control bytes could rewrite the screen or hide a line.
7pub(crate) fn scrub(raw: &str) -> String {
8    raw.chars()
9        .filter(|c| !c.is_control())
10        .take(200)
11        .collect::<String>()
12        .trim()
13        .to_owned()
14}
15
16use crate::tld::Suffix;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum Source {
21    Registry,
22    Text,
23    Dns,
24}
25
26impl Source {
27    #[must_use]
28    pub const fn label(self) -> &'static str {
29        match self {
30            Self::Registry => "registry",
31            Self::Text => "text",
32            Self::Dns => "dns",
33        }
34    }
35
36    /// @docgen Only the registry settles it: DNS proves a name is taken but never free, and the text service is pattern matching.
37    #[must_use]
38    pub const fn is_authoritative(self) -> bool {
39        matches!(self, Self::Registry)
40    }
41}
42
43impl fmt::Display for Source {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(self.label())
46    }
47}
48
49/// @docgen Every one of these is reported as unknown and never folded into available.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(tag = "reason", rename_all = "kebab-case")]
52pub enum Reason {
53    NoService,
54    RateLimited,
55    Blocked,
56    TimedOut,
57    Unreachable,
58    Malformed {
59        detail: String,
60    },
61    WrongSubject {
62        answered_about: String,
63    },
64    /// @docgen The extension exists but only a closed group may register in it, so availability is not a meaningful question.
65    NotRegistrable,
66}
67
68impl Reason {
69    #[must_use]
70    pub const fn label(&self) -> &'static str {
71        match self {
72            Self::NoService => "no registry service",
73            Self::RateLimited => "rate limited",
74            Self::Blocked => "access refused",
75            Self::TimedOut => "timed out",
76            Self::Unreachable => "could not connect",
77            Self::Malformed { .. } => "answer not understood",
78            Self::WrongSubject { .. } => "answered about another name",
79            Self::NotRegistrable => "not publicly registrable",
80        }
81    }
82
83    #[must_use]
84    pub const fn is_retryable(&self) -> bool {
85        matches!(
86            self,
87            Self::RateLimited | Self::TimedOut | Self::Unreachable | Self::Blocked
88        )
89    }
90}
91
92impl fmt::Display for Reason {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        match self {
95            Self::Malformed { detail } => write!(f, "{}: {detail}", self.label()),
96            Self::WrongSubject { answered_about } => {
97                write!(f, "{}: {answered_about}", self.label())
98            }
99            other => f.write_str(other.label()),
100        }
101    }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(tag = "status", rename_all = "kebab-case")]
106pub enum Status {
107    Available,
108    Taken,
109    Unknown(Reason),
110}
111
112impl Status {
113    #[must_use]
114    pub const fn is_available(&self) -> bool {
115        matches!(self, Self::Available)
116    }
117
118    #[must_use]
119    pub const fn is_unknown(&self) -> bool {
120        matches!(self, Self::Unknown(_))
121    }
122
123    /// @docgen Meaning never rides on color alone, so the symbol carries it too.
124    #[must_use]
125    pub const fn mark(&self) -> char {
126        match self {
127            Self::Available => '+',
128            Self::Taken => '-',
129            Self::Unknown(_) => '?',
130        }
131    }
132
133    #[must_use]
134    pub const fn label(&self) -> &'static str {
135        match self {
136            Self::Available => "AVAILABLE",
137            Self::Taken => "TAKEN",
138            Self::Unknown(_) => "UNKNOWN",
139        }
140    }
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct Finding {
145    pub domain: String,
146    pub name: String,
147    pub suffix: Suffix,
148    pub status: Status,
149    pub source: Option<Source>,
150    pub elapsed: Duration,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub responder: Option<String>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub registration: Option<crate::lookup::registration::Registration>,
155}
156
157impl Finding {
158    #[must_use]
159    pub fn unknown(name: &str, suffix: &Suffix, reason: Reason, elapsed: Duration) -> Self {
160        Self {
161            domain: format!("{name}.{suffix}"),
162            name: name.to_owned(),
163            suffix: suffix.clone(),
164            status: Status::Unknown(reason),
165            source: None,
166            elapsed,
167            responder: None,
168            registration: None,
169        }
170    }
171
172    #[must_use]
173    /// @docgen A name whose extension is not in the catalog still gets a row, so it is never dropped from the table.
174    pub fn unrecognized(domain: &str) -> Self {
175        let suffix = domain.rsplit_once('.').map_or(domain, |(_, tail)| tail);
176        Self {
177            domain: domain.to_owned(),
178            name: domain.to_owned(),
179            suffix: Suffix::from_raw(suffix),
180            status: Status::Unknown(Reason::NoService),
181            source: None,
182            elapsed: Duration::ZERO,
183            responder: None,
184            registration: None,
185        }
186    }
187
188    #[must_use]
189    pub const fn is_available(&self) -> bool {
190        self.status.is_available()
191    }
192}
193
194#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
195pub struct Tally {
196    pub available: usize,
197    pub taken: usize,
198    pub unknown: usize,
199}
200
201impl Tally {
202    #[must_use]
203    pub fn of(findings: &[Finding]) -> Self {
204        let mut tally = Self::default();
205        for finding in findings {
206            match finding.status {
207                Status::Available => tally.available += 1,
208                Status::Taken => tally.taken += 1,
209                Status::Unknown(_) => tally.unknown += 1,
210            }
211        }
212        tally
213    }
214
215    #[must_use]
216    pub const fn checked(&self) -> usize {
217        self.available + self.taken + self.unknown
218    }
219
220    #[must_use]
221    pub const fn has_available(&self) -> bool {
222        self.available > 0
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn suffix() -> Suffix {
231        Suffix::parse("com").expect("com parses")
232    }
233
234    #[test]
235    fn only_the_registry_settles_the_question_on_its_own() {
236        assert!(Source::Registry.is_authoritative());
237        assert!(!Source::Dns.is_authoritative());
238        assert!(!Source::Text.is_authoritative());
239    }
240
241    #[test]
242    fn every_unanswered_lookup_is_unknown_and_never_available() {
243        let reasons = [
244            Reason::NoService,
245            Reason::RateLimited,
246            Reason::Blocked,
247            Reason::TimedOut,
248            Reason::Unreachable,
249            Reason::NotRegistrable,
250            Reason::Malformed {
251                detail: "bad json".to_owned(),
252            },
253            Reason::WrongSubject {
254                answered_about: "other.com".to_owned(),
255            },
256        ];
257        for reason in reasons {
258            let status = Status::Unknown(reason);
259            assert!(!status.is_available(), "{status:?} must not read as free");
260            assert!(status.is_unknown());
261            assert_eq!(status.mark(), '?');
262        }
263    }
264
265    #[test]
266    fn a_throttled_lookup_is_is_retryable_but_a_missing_service_is_not() {
267        assert!(Reason::RateLimited.is_retryable());
268        assert!(Reason::TimedOut.is_retryable());
269        assert!(Reason::Unreachable.is_retryable());
270        assert!(!Reason::NoService.is_retryable());
271        assert!(!Reason::NotRegistrable.is_retryable());
272        assert!(
273            !Reason::Malformed {
274                detail: String::new()
275            }
276            .is_retryable()
277        );
278    }
279
280    #[test]
281    fn the_status_mark_carries_meaning_without_color() {
282        assert_eq!(Status::Available.mark(), '+');
283        assert_eq!(Status::Taken.mark(), '-');
284        assert_eq!(Status::Unknown(Reason::TimedOut).mark(), '?');
285        assert_ne!(Status::Available.label(), Status::Taken.label());
286    }
287
288    #[test]
289    fn an_unknown_finding_reports_the_reason_in_its_text() {
290        let finding = Finding::unknown(
291            "example",
292            &suffix(),
293            Reason::RateLimited,
294            Duration::from_millis(20),
295        );
296        assert_eq!(finding.domain, "example.com");
297        assert!(!finding.is_available());
298        match finding.status {
299            Status::Unknown(reason) => assert_eq!(reason.to_string(), "rate limited"),
300            other => panic!("expected unknown, got {other:?}"),
301        }
302    }
303
304    #[test]
305    fn an_unreadable_answer_keeps_its_detail_in_the_message() {
306        let reason = Reason::Malformed {
307            detail: "truncated body".to_owned(),
308        };
309        assert!(reason.to_string().contains("truncated body"));
310    }
311
312    #[test]
313    fn the_tally_counts_each_class_and_never_double_counts() {
314        let findings = vec![
315            Finding {
316                domain: "a.com".to_owned(),
317                name: "a".to_owned(),
318                suffix: suffix(),
319                status: Status::Available,
320                source: Some(Source::Registry),
321                elapsed: Duration::ZERO,
322                responder: None,
323                registration: None,
324            },
325            Finding {
326                domain: "b.com".to_owned(),
327                name: "b".to_owned(),
328                suffix: suffix(),
329                status: Status::Taken,
330                source: Some(Source::Registry),
331                elapsed: Duration::ZERO,
332                responder: None,
333                registration: None,
334            },
335            Finding::unknown("c", &suffix(), Reason::TimedOut, Duration::ZERO),
336        ];
337
338        let tally = Tally::of(&findings);
339        assert_eq!(tally.available, 1);
340        assert_eq!(tally.taken, 1);
341        assert_eq!(tally.unknown, 1);
342        assert_eq!(tally.checked(), 3);
343        assert!(tally.has_available());
344    }
345
346    #[test]
347    fn a_run_with_no_free_names_reports_nothing_found() {
348        let tally = Tally {
349            available: 0,
350            taken: 5,
351            unknown: 2,
352        };
353        assert!(!tally.has_available());
354        assert_eq!(tally.checked(), 7);
355    }
356
357    #[test]
358    fn an_empty_run_tallies_to_zero() {
359        let tally = Tally::of(&[]);
360        assert_eq!(tally, Tally::default());
361        assert_eq!(tally.checked(), 0);
362        assert!(!tally.has_available());
363    }
364}