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