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 Blocked,
56 TimedOut,
57 Unreachable,
58 Malformed {
59 detail: String,
60 },
61 WrongSubject {
62 answered_about: String,
63 },
64 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 #[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 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}