1use std::fmt;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5
6pub(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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(tag = "reason", rename_all = "kebab-case")]
52pub enum Reason {
53 NoService,
54 RateLimited,
55 ServerError {
57 status: u16,
58 },
59 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 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 #[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 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}