use std::fmt;
use crate::domain::{Availability, DomainName, Tld};
use crate::error::{Error, Result};
#[cfg(feature = "async")]
use crate::client::AsyncWhoisClient;
#[cfg(feature = "blocking")]
use crate::client::WhoisClient;
pub const DEFAULT_POPULAR_TLDS: [&str; 4] = ["com", "net", "org", "info"];
#[derive(Debug)]
pub struct CheckReport {
entries: Vec<(String, Result<Availability>)>,
}
impl CheckReport {
pub fn new(entries: Vec<(String, Result<Availability>)>) -> Self {
CheckReport { entries }
}
pub fn entries(&self) -> &[(String, Result<Availability>)] {
&self.entries
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn available(&self) -> Vec<&str> {
self.with_availability(Availability::is_available)
}
pub fn registered(&self) -> Vec<&str> {
self.with_availability(Availability::is_registered)
}
pub fn obtainable(&self) -> Vec<&str> {
self.with_availability(Availability::is_obtainable)
}
pub fn failures(&self) -> Vec<(&str, &Error)> {
self.entries
.iter()
.filter_map(|(name, outcome)| {
outcome.as_ref().err().map(|error| (name.as_str(), error))
})
.collect()
}
pub fn get(&self, name: &str) -> Option<&Result<Availability>> {
self.entries
.iter()
.find(|(candidate, _)| candidate == name)
.map(|(_, outcome)| outcome)
}
fn with_availability(&self, predicate: impl Fn(Availability) -> bool) -> Vec<&str> {
self.entries
.iter()
.filter(|(_, outcome)| outcome.as_ref().is_ok_and(|value| predicate(*value)))
.map(|(name, _)| name.as_str())
.collect()
}
}
impl fmt::Display for CheckReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let width = self
.entries
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or(0);
for (index, (name, outcome)) in self.entries.iter().enumerate() {
if index > 0 {
writeln!(f)?;
}
match outcome {
Ok(availability) => write!(f, "{name:<width$} {availability}")?,
Err(error) => write!(f, "{name:<width$} error: {error}")?,
}
}
Ok(())
}
}
impl IntoIterator for CheckReport {
type Item = (String, Result<Availability>);
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
fn expand(input: &str, popular: &[Tld]) -> Vec<String> {
let trimmed = input.trim().trim_matches('.');
if trimmed.is_empty() {
return vec![input.to_string()];
}
if trimmed.contains('.') {
return vec![trimmed.to_lowercase()];
}
popular
.iter()
.map(|tld| format!("{}.{}", trimmed.to_lowercase(), tld.ascii()))
.collect()
}
fn parse_tlds<I, S>(tlds: I) -> Result<Vec<Tld>>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let parsed: Vec<Tld> = tlds
.into_iter()
.map(|raw| Tld::parse(raw.as_ref()))
.collect::<Result<_>>()?;
if parsed.is_empty() {
return Err(Error::Definitions(
"the popular TLD list must not be empty".to_string(),
));
}
Ok(parsed)
}
fn default_tlds() -> Vec<Tld> {
DEFAULT_POPULAR_TLDS
.iter()
.map(|tld| Tld::parse(tld).expect("the built-in popular TLDs are valid"))
.collect()
}
#[cfg(feature = "blocking")]
#[derive(Debug, Clone)]
pub struct Checker {
client: WhoisClient,
popular: Vec<Tld>,
}
#[cfg(feature = "blocking")]
impl Checker {
pub fn new(client: WhoisClient) -> Self {
Checker {
client,
popular: default_tlds(),
}
}
pub fn with_popular_tlds<I, S>(mut self, tlds: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.popular = parse_tlds(tlds)?;
Ok(self)
}
pub fn popular_tlds(&self) -> &[Tld] {
&self.popular
}
pub fn client(&self) -> &WhoisClient {
&self.client
}
pub fn check<I, S>(&self, names: I) -> CheckReport
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut entries: Vec<(String, Result<Availability>)> = Vec::new();
let mut seen: Vec<String> = Vec::new();
for input in names {
for candidate in expand(input.as_ref(), &self.popular) {
if seen.contains(&candidate) {
continue;
}
seen.push(candidate.clone());
let outcome = self
.client
.lookup(&candidate)
.map(|lookup| lookup.availability());
entries.push((candidate, outcome));
}
}
CheckReport::new(entries)
}
pub fn check_one(&self, name: &str) -> Result<Availability> {
let name = DomainName::parse(name)?;
Ok(self.client.lookup_name(&name)?.availability())
}
}
#[cfg(feature = "async")]
#[derive(Debug, Clone)]
pub struct AsyncChecker {
client: AsyncWhoisClient,
popular: Vec<Tld>,
}
#[cfg(feature = "async")]
impl AsyncChecker {
pub fn new(client: AsyncWhoisClient) -> Self {
AsyncChecker {
client,
popular: default_tlds(),
}
}
pub fn with_popular_tlds<I, S>(mut self, tlds: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.popular = parse_tlds(tlds)?;
Ok(self)
}
pub fn popular_tlds(&self) -> &[Tld] {
&self.popular
}
pub fn client(&self) -> &AsyncWhoisClient {
&self.client
}
pub async fn check<I, S>(&self, names: I) -> CheckReport
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut candidates: Vec<String> = Vec::new();
for input in names {
for candidate in expand(input.as_ref(), &self.popular) {
if !candidates.contains(&candidate) {
candidates.push(candidate);
}
}
}
let entries = self
.client
.lookup_many(candidates)
.await
.into_iter()
.map(|(name, outcome)| (name, outcome.map(|lookup| lookup.availability())))
.collect();
CheckReport::new(entries)
}
pub async fn check_one(&self, name: &str) -> Result<Availability> {
let name = DomainName::parse(name)?;
Ok(self.client.lookup_name(&name).await?.availability())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_name_with_a_suffix_is_checked_as_given() {
let popular = default_tlds();
assert_eq!(expand("example.com", &popular), ["example.com"]);
assert_eq!(expand(" Example.CO.UK. ", &popular), ["example.co.uk"]);
}
#[test]
fn a_bare_name_is_expanded_across_the_popular_suffixes() {
let popular = default_tlds();
assert_eq!(
expand("monovm", &popular),
["monovm.com", "monovm.net", "monovm.org", "monovm.info"]
);
assert_eq!(expand("MonoVM", &popular)[0], "monovm.com");
}
#[test]
fn a_custom_suffix_list_is_used_instead() {
let popular = parse_tlds([".IO", "co.uk"]).unwrap();
assert_eq!(expand("monovm", &popular), ["monovm.io", "monovm.co.uk"]);
}
#[test]
fn empty_input_is_passed_through_to_be_rejected_downstream() {
let popular = default_tlds();
assert_eq!(expand("", &popular), [""]);
assert_eq!(expand(" ", &popular), [" "]);
}
#[test]
fn an_empty_suffix_list_is_rejected() {
assert!(parse_tlds(Vec::<String>::new()).is_err());
assert!(parse_tlds(["-nope"]).is_err());
}
#[test]
fn a_report_answers_the_questions_callers_ask() {
let report = CheckReport::new(vec![
("free.com".into(), Ok(Availability::Available)),
("taken.com".into(), Ok(Availability::Registered)),
("pricey.com".into(), Ok(Availability::Premium)),
("held.com".into(), Ok(Availability::Reserved)),
(
"broken.com".into(),
Err(Error::Timeout {
server: "whois.example".into(),
elapsed: std::time::Duration::ZERO,
}),
),
]);
assert_eq!(report.len(), 5);
assert_eq!(report.available(), ["free.com"]);
assert_eq!(report.registered(), ["taken.com"]);
assert_eq!(report.obtainable(), ["free.com", "pricey.com"]);
assert_eq!(report.failures().len(), 1);
assert_eq!(report.failures()[0].0, "broken.com");
}
#[test]
fn a_report_can_be_looked_up_by_name() {
let report = CheckReport::new(vec![("a.com".into(), Ok(Availability::Available))]);
assert!(matches!(
report.get("a.com"),
Some(Ok(Availability::Available))
));
assert!(report.get("absent.com").is_none());
}
#[test]
fn a_report_renders_as_an_aligned_table() {
let report = CheckReport::new(vec![
("short.com".into(), Ok(Availability::Available)),
("much-longer-name.com".into(), Ok(Availability::Registered)),
]);
let rendered = report.to_string();
let lines: Vec<&str> = rendered.lines().collect();
assert_eq!(lines.len(), 2, "{rendered}");
let offset = |line: &str, verdict: &str| line.find(verdict).expect("verdict present");
assert_eq!(
offset(lines[0], "available"),
offset(lines[1], "registered"),
"{rendered}"
);
assert!(lines[0].starts_with("short.com"), "{rendered}");
}
#[test]
fn an_empty_report_renders_without_panicking() {
let report = CheckReport::new(Vec::new());
assert!(report.is_empty());
assert_eq!(report.to_string(), "");
assert!(report.available().is_empty());
}
#[cfg(feature = "blocking")]
mod blocking {
use super::*;
use crate::client::WhoisClient;
use crate::registry::JsonRegistry;
use crate::transport::MockTransport;
fn checker(transport: MockTransport) -> Checker {
let registry = JsonRegistry::from_json(
r#"{"registries":[
{"tlds":["com","net","org","info","ir"],"whois":"registry.test",
"available":["No match for"]}
]}"#,
"test",
)
.unwrap();
Checker::new(
WhoisClient::builder()
.registry(registry)
.transport(transport)
.build()
.unwrap(),
)
}
#[test]
fn a_bare_name_produces_one_result_per_popular_suffix() {
let transport = MockTransport::answering("No match for \"X\"");
let report = checker(transport.clone()).check(["monovm"]);
assert_eq!(report.len(), 4);
assert_eq!(
report.available(),
["monovm.com", "monovm.net", "monovm.org", "monovm.info"]
);
assert_eq!(transport.call_count(), 4);
}
#[test]
fn duplicates_are_checked_once() {
let transport = MockTransport::answering("No match for \"X\"");
let report = checker(transport.clone()).check(["a.com", "a.com", "A.COM"]);
assert_eq!(report.len(), 1, "{:?}", report.entries());
assert_eq!(transport.call_count(), 1);
}
#[test]
fn a_bad_name_is_an_entry_with_an_error_not_a_lost_row() {
let transport = MockTransport::answering("No match for \"X\"");
let report = checker(transport).check(["good.com", "bad.unknown"]);
assert_eq!(report.len(), 2);
assert_eq!(report.available(), ["good.com"]);
assert_eq!(report.failures().len(), 1);
}
#[test]
fn a_custom_suffix_list_is_honoured() {
let transport = MockTransport::answering("No match for \"X\"");
let report = checker(transport)
.with_popular_tlds([".io", "com"])
.unwrap()
.check(["monovm"]);
assert_eq!(report.len(), 2);
assert!(report.get("monovm.io").is_some());
}
#[test]
fn check_one_does_not_expand() {
let transport = MockTransport::answering("No match for \"X\"");
let checker = checker(transport.clone());
assert_eq!(checker.check_one("a.com").unwrap(), Availability::Available);
assert_eq!(transport.call_count(), 1);
assert!(checker.check_one("monovm").is_err());
}
}
#[cfg(feature = "async")]
mod asynchronous {
use super::*;
use crate::client::AsyncWhoisClient;
use crate::registry::JsonRegistry;
use crate::transport::MockTransport;
fn checker(transport: MockTransport) -> AsyncChecker {
let registry = JsonRegistry::from_json(
r#"{"registries":[
{"tlds":["com","net","org","info"],"whois":"registry.test",
"available":["No match for"]}
]}"#,
"test",
)
.unwrap();
AsyncChecker::new(
AsyncWhoisClient::builder()
.registry(registry)
.transport(transport)
.build()
.unwrap(),
)
}
#[tokio::test]
async fn a_bare_name_is_expanded_and_checked_concurrently() {
let transport = MockTransport::answering("No match for \"X\"");
let report = checker(transport.clone()).check(["monovm"]).await;
assert_eq!(report.len(), 4);
assert_eq!(report.available().len(), 4);
assert_eq!(transport.call_count(), 4);
}
#[tokio::test]
async fn results_keep_the_expansion_order() {
let transport = MockTransport::answering("No match for \"X\"");
let report = checker(transport).check(["monovm"]).await;
let names: Vec<&str> = report.entries().iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(
names,
["monovm.com", "monovm.net", "monovm.org", "monovm.info"]
);
}
}
}