use std::io::{self, Write};
use std::process::ExitCode;
use std::time::Duration;
use clap::{ArgAction, Parser, ValueEnum};
use monovm_whois::client::Preference;
use monovm_whois::{Checker, Error, ReferralPolicy, WhoisClient};
#[derive(Debug, Parser)]
#[command(
name = "monovm-whois",
version,
about,
long_about = None,
after_help = "A domain whose lookup could not be answered exits non-zero. \
That is deliberate: an unanswered query is not the same as a free domain."
)]
struct Cli {
#[arg(required = true, value_name = "DOMAIN")]
domains: Vec<String>,
#[arg(short, long, value_delimiter = ',', default_value = "com,net,org,info")]
tlds: Vec<String>,
#[arg(short, long, action = ArgAction::SetTrue)]
record: bool,
#[arg(long, action = ArgAction::SetTrue)]
raw: bool,
#[arg(short, long, action = ArgAction::SetTrue)]
explain: bool,
#[arg(long, action = ArgAction::SetTrue)]
json: bool,
#[arg(long, value_enum, default_value_t = Protocol::Whois)]
protocol: Protocol,
#[arg(long, value_name = "SECONDS", default_value_t = 5.0)]
connect_timeout: f64,
#[arg(long, value_name = "SECONDS", default_value_t = 10.0)]
read_timeout: f64,
#[arg(long, value_name = "SECONDS", default_value_t = 1.0)]
throttle: f64,
#[arg(long, value_name = "N", default_value_t = 3)]
attempts: u32,
#[arg(long, action = ArgAction::SetTrue)]
no_referrals: bool,
#[arg(long, action = ArgAction::SetTrue)]
list_tlds: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Protocol {
Whois,
Rdap,
WhoisOnly,
RdapOnly,
}
impl From<Protocol> for Preference {
fn from(protocol: Protocol) -> Self {
match protocol {
Protocol::Whois => Preference::Whois,
Protocol::Rdap => Preference::Rdap,
Protocol::WhoisOnly => Preference::WhoisOnly,
Protocol::RdapOnly => Preference::RdapOnly,
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(code) => code,
Err(error) => {
eprintln!("monovm-whois: {error}");
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> Result<ExitCode, Error> {
let client = WhoisClient::builder()
.prefer(cli.protocol.into())
.connect_timeout(seconds(cli.connect_timeout))
.read_timeout(seconds(cli.read_timeout))
.throttle_per_host(seconds(cli.throttle))
.retry(monovm_whois::transport::RetryPolicy::fixed(
cli.attempts.max(1),
Duration::from_millis(250),
))
.referrals(if cli.no_referrals {
ReferralPolicy::NONE
} else {
ReferralPolicy::DEFAULT
})
.build()?;
if cli.list_tlds {
return list_tlds(&client);
}
if cli.explain {
return explain(&client, &cli);
}
if cli.record || cli.raw {
return details(&client, &cli);
}
availability(&client, &cli)
}
fn seconds(value: f64) -> Duration {
Duration::from_secs_f64(value.max(0.001))
}
fn list_tlds(client: &WhoisClient) -> Result<ExitCode, Error> {
let tlds = client.supported_tlds();
let mut out = io::BufWriter::new(io::stdout().lock());
for tld in &tlds {
if writeln!(out, "{tld}").is_err() {
return Ok(ExitCode::SUCCESS);
}
}
let _ = writeln!(out, "\n{} suffixes", tlds.len());
Ok(ExitCode::SUCCESS)
}
fn availability(client: &WhoisClient, cli: &Cli) -> Result<ExitCode, Error> {
let checker = Checker::new(client.clone()).with_popular_tlds(&cli.tlds)?;
let report = checker.check(&cli.domains);
if cli.json {
let entries: Vec<serde_json::Value> = report
.entries()
.iter()
.map(|(domain, outcome)| match outcome {
Ok(availability) => serde_json::json!({
"domain": domain,
"availability": availability.as_str(),
}),
Err(error) => serde_json::json!({
"domain": domain,
"error": error.to_string(),
}),
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries).unwrap());
} else {
println!("{report}");
}
Ok(exit_code(report.failures().is_empty()))
}
fn details(client: &WhoisClient, cli: &Cli) -> Result<ExitCode, Error> {
let mut all_ok = true;
let mut documents = Vec::new();
for input in &cli.domains {
match client.lookup(input) {
Ok(lookup) => {
if cli.json {
let mut document = serde_json::json!({
"domain": lookup.domain.as_ascii(),
"tld": lookup.tld.ascii(),
"availability": lookup.availability().as_str(),
"confidence": lookup.verdict.confidence.as_str(),
"rule": lookup.verdict.rule,
"because": lookup.verdict.because,
"consulted": lookup
.consulted()
.iter()
.map(|endpoint| endpoint.address())
.collect::<Vec<_>>(),
});
if cli.record {
document["record"] = serde_json::to_value(&lookup.record).unwrap();
}
if cli.raw {
document["raw"] = serde_json::Value::String(lookup.raw_text());
}
documents.push(document);
} else {
println!("=== {} ({}) ===", lookup.domain, lookup.availability());
if cli.record {
match &lookup.record {
Some(record) => println!("{record}"),
None => println!("(no registration record)"),
}
}
if cli.raw {
let text = lookup.raw_text();
println!(
"{}",
if text.trim().is_empty() {
"(empty response)"
} else {
text.trim()
}
);
}
println!();
}
}
Err(error) => {
all_ok = false;
if cli.json {
documents
.push(serde_json::json!({ "domain": input, "error": error.to_string() }));
} else {
eprintln!("=== {input} ===\nerror: {error}\n");
}
}
}
}
if cli.json {
println!("{}", serde_json::to_string_pretty(&documents).unwrap());
}
Ok(exit_code(all_ok))
}
fn explain(client: &WhoisClient, cli: &Cli) -> Result<ExitCode, Error> {
let mut all_ok = true;
for input in &cli.domains {
match client.explain(input) {
Ok(explanation) => print!("{explanation}"),
Err(error) => {
all_ok = false;
eprintln!("{input}: {error}");
}
}
println!();
}
Ok(exit_code(all_ok))
}
fn exit_code(ok: bool) -> ExitCode {
if ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn the_command_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn a_bare_domain_parses() {
let cli = Cli::parse_from(["monovm-whois", "example.com"]);
assert_eq!(cli.domains, ["example.com"]);
assert_eq!(cli.tlds, ["com", "net", "org", "info"]);
assert!(!cli.json);
}
#[test]
fn tlds_accept_a_comma_separated_list() {
let cli = Cli::parse_from(["monovm-whois", "monovm", "--tlds", "com,ir,co.uk"]);
assert_eq!(cli.tlds, ["com", "ir", "co.uk"]);
}
#[test]
fn several_domains_are_accepted() {
let cli = Cli::parse_from(["monovm-whois", "a.com", "b.net", "c"]);
assert_eq!(cli.domains.len(), 3);
}
#[test]
fn the_output_flags_parse() {
let cli = Cli::parse_from(["monovm-whois", "a.com", "--record", "--raw", "--json"]);
assert!(cli.record && cli.raw && cli.json);
}
#[test]
fn protocol_maps_onto_the_library_preference() {
for (flag, expected) in [
("whois", Preference::Whois),
("rdap", Preference::Rdap),
("whois-only", Preference::WhoisOnly),
("rdap-only", Preference::RdapOnly),
] {
let cli = Cli::parse_from(["monovm-whois", "a.com", "--protocol", flag]);
assert_eq!(Preference::from(cli.protocol), expected, "for {flag}");
}
}
#[test]
fn timeouts_are_clamped_above_zero() {
assert!(seconds(0.0) > Duration::ZERO);
assert_eq!(seconds(2.5), Duration::from_millis(2500));
}
#[test]
fn at_least_one_domain_is_required() {
assert!(Cli::try_parse_from(["monovm-whois"]).is_err());
}
#[test]
fn exit_code_reflects_whether_lookups_were_answered() {
assert_eq!(
format!("{:?}", exit_code(true)),
format!("{:?}", ExitCode::SUCCESS)
);
assert_eq!(
format!("{:?}", exit_code(false)),
format!("{:?}", ExitCode::FAILURE)
);
}
}