use reserve_core::{Depth, Filter, LengthRule, Sort, SortDirection, SortKey, Suffix};
use crate::cli::{Cli, DepthArg, SortFieldArg, SortOrderArg, SourceArg};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Setting {
Names,
NamesFrom,
Restricted,
CountryOnly,
Depth,
Length,
Exclude,
Industry,
Region,
Sort,
Order,
Source,
Cautious,
Timeout,
Concurrency,
PerRegistry,
Rate,
Refresh,
Referral,
RegistryServers,
TextServers,
ServersReplace,
AvailableOnly,
Details,
Responder,
Dns,
WhereToBuy,
Save,
Out,
Append,
SaveAsJson,
}
impl Setting {
pub(crate) const ALL: &'static [Self] = &[
Self::Names,
Self::NamesFrom,
Self::Restricted,
Self::CountryOnly,
Self::Depth,
Self::Length,
Self::Exclude,
Self::Industry,
Self::Region,
Self::Sort,
Self::Order,
Self::Source,
Self::Cautious,
Self::Timeout,
Self::Concurrency,
Self::PerRegistry,
Self::Rate,
Self::Refresh,
Self::Referral,
Self::RegistryServers,
Self::TextServers,
Self::ServersReplace,
Self::AvailableOnly,
Self::Details,
Self::Responder,
Self::Dns,
Self::WhereToBuy,
Self::Save,
Self::Out,
Self::Append,
Self::SaveAsJson,
];
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Names => "names to check",
Self::NamesFrom => "read names from a file",
Self::Restricted => "include restricted zones",
Self::CountryOnly => "country extensions only",
Self::Depth => "extension depth",
Self::Length => "extension length",
Self::Exclude => "leave out",
Self::Industry => "industry",
Self::Region => "region",
Self::Sort => "order the list by",
Self::Order => "direction",
Self::Source => "where answers come from",
Self::Cautious => "go slowly",
Self::Timeout => "seconds per request",
Self::Concurrency => "lookups in flight",
Self::PerRegistry => "in flight per registry",
Self::Rate => "requests per second",
Self::Refresh => "re-download the server list",
Self::Referral => "ask IANA when unknown",
Self::RegistryServers => "your own registry server list",
Self::TextServers => "your own text server list",
Self::ServersReplace => "replace the built-in lists",
Self::AvailableOnly => "show only what is free",
Self::Details => "show registration detail",
Self::Responder => "show which server answered",
Self::Dns => "show DNS records",
Self::WhereToBuy => "show where to buy",
Self::Save => "write results to files",
Self::Out => "write into",
Self::Append => "merge with existing files",
Self::SaveAsJson => "save the files as JSON",
}
}
pub(crate) const fn is_typed(self) -> bool {
matches!(
self,
Self::Names
| Self::NamesFrom
| Self::Length
| Self::Exclude
| Self::Industry
| Self::Region
| Self::Timeout
| Self::Concurrency
| Self::PerRegistry
| Self::Rate
| Self::RegistryServers
| Self::TextServers
| Self::Out
)
}
}
const DEPTHS: [DepthArg; 3] = [DepthArg::Any, DepthArg::Second, DepthArg::Third];
const SORTS: [SortFieldArg; 3] = [
SortFieldArg::Popularity,
SortFieldArg::Name,
SortFieldArg::Length,
];
const SOURCES: [SourceArg; 4] = [
SourceArg::Auto,
SourceArg::Registry,
SourceArg::Text,
SourceArg::Dns,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Plan {
pub names: String,
pub names_from: String,
pub restricted: bool,
pub country_only: bool,
pub depth: DepthArg,
pub length: String,
pub exclude: String,
pub industry: String,
pub region: String,
pub sort: SortFieldArg,
pub order: Option<SortOrderArg>,
pub source: SourceArg,
pub cautious: bool,
pub timeout: String,
pub concurrency: String,
pub per_registry: String,
pub rate: String,
pub refresh: bool,
pub referral: bool,
pub registry_servers: String,
pub text_servers: String,
pub servers_replace: bool,
pub json: bool,
pub save_as_json: bool,
pub available_only: bool,
pub details: bool,
pub responder: bool,
pub dns: bool,
pub where_to_buy: bool,
pub save: bool,
pub out: String,
pub append: bool,
}
impl Plan {
pub(crate) fn from_args(args: &Cli) -> Self {
let joined = |values: &[String]| values.join(",");
Self {
names: joined(&args.names),
names_from: args.names_from.clone().unwrap_or_default(),
restricted: args.select.include_restricted,
country_only: args.select.cctld,
depth: args.select.depth,
length: args.select.length.clone().unwrap_or_default(),
exclude: joined(&args.select.exclude),
industry: joined(&args.select.industry),
region: joined(&args.select.region),
sort: args.view.sort,
order: args.view.order,
source: args.pacing.source,
cautious: args.pacing.cautious,
timeout: args
.pacing
.timeout
.map(|n| n.to_string())
.unwrap_or_default(),
concurrency: args
.pacing
.concurrency
.map(|n| n.to_string())
.unwrap_or_default(),
per_registry: args
.pacing
.per_registry
.map(|n| n.to_string())
.unwrap_or_default(),
rate: args.pacing.rate.map(|n| n.to_string()).unwrap_or_default(),
refresh: args.pacing.refresh,
referral: !args.pacing.no_referral,
registry_servers: args.pacing.registry_servers.clone().unwrap_or_default(),
text_servers: args.pacing.text_servers.clone().unwrap_or_default(),
servers_replace: args.pacing.servers_replace,
json: args.output.json,
save_as_json: args.output.json,
available_only: args.output.available_only,
details: args.output.details || args.output.full,
responder: args.output.responder || args.output.full,
dns: args.output.dns || args.output.full,
where_to_buy: args.output.where_to_buy || args.output.full,
save: args.output.save || args.output.out.is_some(),
out: args.output.out.clone().unwrap_or_default(),
append: args.output.append,
}
}
pub(crate) fn shown(&self, setting: Setting) -> String {
let yes_no = |on: bool| if on { "yes" } else { "no" }.to_owned();
let or_default = |value: &str, fallback: &str| {
if value.trim().is_empty() {
fallback.to_owned()
} else {
value.to_owned()
}
};
match setting {
Setting::Names => or_default(&self.names, "none yet"),
Setting::NamesFrom => or_default(&self.names_from, "no file"),
Setting::Restricted => yes_no(self.restricted),
Setting::CountryOnly => yes_no(self.country_only),
Setting::Depth => match self.depth {
DepthArg::Any => "any".to_owned(),
DepthArg::Second => "plain, such as com".to_owned(),
DepthArg::Third => "multi-label, such as co.uk".to_owned(),
},
Setting::Length => or_default(&self.length, "any"),
Setting::Exclude => or_default(&self.exclude, "nothing"),
Setting::Industry => or_default(&self.industry, "any"),
Setting::Region => or_default(&self.region, "any"),
Setting::Sort => match self.sort {
SortFieldArg::Popularity => "how widely used".to_owned(),
SortFieldArg::Name => "name".to_owned(),
SortFieldArg::Length => "length".to_owned(),
},
Setting::Order => match self.order {
None => "whatever reads best".to_owned(),
Some(SortOrderArg::Asc) => "lowest first".to_owned(),
Some(SortOrderArg::Desc) => "highest first".to_owned(),
},
Setting::Source => match self.source {
SourceArg::Auto => "registry, then DNS".to_owned(),
SourceArg::Registry => "registry only".to_owned(),
SourceArg::Text => "text service only".to_owned(),
SourceArg::Dns => "DNS only".to_owned(),
},
Setting::Cautious => yes_no(self.cautious),
Setting::Timeout => or_default(&self.timeout, "10"),
Setting::Concurrency => or_default(&self.concurrency, "24"),
Setting::PerRegistry => or_default(&self.per_registry, "what the registry allows"),
Setting::Rate => or_default(&self.rate, "what the registry allows"),
Setting::Refresh => yes_no(self.refresh),
Setting::Referral => yes_no(self.referral),
Setting::RegistryServers => or_default(&self.registry_servers, "the built-in list"),
Setting::TextServers => or_default(&self.text_servers, "the built-in list"),
Setting::ServersReplace => yes_no(self.servers_replace),
Setting::AvailableOnly => yes_no(self.available_only),
Setting::Details => yes_no(self.details),
Setting::Responder => yes_no(self.responder),
Setting::Dns => yes_no(self.dns),
Setting::WhereToBuy => yes_no(self.where_to_buy),
Setting::Save => yes_no(self.save),
Setting::Out => or_default(&self.out, "the current directory"),
Setting::Append => yes_no(self.append),
Setting::SaveAsJson => yes_no(self.save_as_json),
}
}
pub(crate) fn cycle(&mut self, setting: Setting, forward: bool) {
fn step<T: Copy + PartialEq>(list: &[T], current: T, forward: bool) -> T {
let at = list.iter().position(|item| *item == current).unwrap_or(0);
let len = list.len();
let next = if forward {
at.saturating_add(1) % len
} else {
(at + len.saturating_sub(1)) % len
};
list.get(next).copied().unwrap_or(current)
}
match setting {
Setting::Restricted => self.restricted = !self.restricted,
Setting::CountryOnly => self.country_only = !self.country_only,
Setting::Depth => self.depth = step(&DEPTHS, self.depth, forward),
Setting::Sort => self.sort = step(&SORTS, self.sort, forward),
Setting::Order => {
let list = [None, Some(SortOrderArg::Asc), Some(SortOrderArg::Desc)];
self.order = step(&list, self.order, forward);
}
Setting::Source => self.source = step(&SOURCES, self.source, forward),
Setting::Cautious => self.cautious = !self.cautious,
Setting::Refresh => self.refresh = !self.refresh,
Setting::Referral => self.referral = !self.referral,
Setting::ServersReplace => self.servers_replace = !self.servers_replace,
Setting::AvailableOnly => self.available_only = !self.available_only,
Setting::Details => self.details = !self.details,
Setting::Responder => self.responder = !self.responder,
Setting::Dns => self.dns = !self.dns,
Setting::WhereToBuy => self.where_to_buy = !self.where_to_buy,
Setting::Save => self.save = !self.save,
Setting::Append => self.append = !self.append,
Setting::SaveAsJson => self.save_as_json = !self.save_as_json,
_ => {}
}
}
fn typed_mut(&mut self, setting: Setting) -> Option<&mut String> {
match setting {
Setting::Names => Some(&mut self.names),
Setting::NamesFrom => Some(&mut self.names_from),
Setting::Length => Some(&mut self.length),
Setting::Exclude => Some(&mut self.exclude),
Setting::Industry => Some(&mut self.industry),
Setting::Region => Some(&mut self.region),
Setting::Timeout => Some(&mut self.timeout),
Setting::Concurrency => Some(&mut self.concurrency),
Setting::PerRegistry => Some(&mut self.per_registry),
Setting::Rate => Some(&mut self.rate),
Setting::RegistryServers => Some(&mut self.registry_servers),
Setting::TextServers => Some(&mut self.text_servers),
Setting::Out => Some(&mut self.out),
_ => None,
}
}
pub(crate) fn type_into(&mut self, setting: Setting, letter: char) {
if letter.is_control() {
return;
}
let digits_only = matches!(
setting,
Setting::Timeout | Setting::Concurrency | Setting::PerRegistry | Setting::Rate
);
if digits_only && !letter.is_ascii_digit() {
return;
}
if let Some(field) = self.typed_mut(setting) {
const ROOM: usize = 256;
if field.chars().count() < ROOM {
field.push(letter);
}
}
}
pub(crate) fn backspace(&mut self, setting: Setting) {
if let Some(field) = self.typed_mut(setting) {
field.pop();
}
}
pub(crate) fn filter(&self) -> Filter {
Filter {
search: None,
depth: depth_of(self.depth),
length: self.length.trim().parse::<LengthRule>().ok(),
country_codes_only: self.country_only,
registrable_only: !self.restricted,
industries: Self::split_list(&self.industry),
regions: Self::split_list(&self.region),
exclude: Self::split_list(&self.exclude)
.iter()
.filter_map(|raw| Suffix::parse(raw).ok())
.collect(),
}
}
pub(crate) fn sort(&self) -> Sort {
let key = match self.sort {
SortFieldArg::Name => SortKey::Name,
SortFieldArg::Popularity => SortKey::Popularity,
SortFieldArg::Length => SortKey::Length,
};
let direction = match self.order {
Some(SortOrderArg::Asc) => SortDirection::Ascending,
Some(SortOrderArg::Desc) => SortDirection::Descending,
None => key.natural_direction(),
};
Sort::new(key, direction)
}
pub(crate) fn split_list(value: &str) -> Vec<String> {
value
.split(',')
.map(|part| part.trim().to_owned())
.filter(|part| !part.is_empty())
.collect()
}
pub(crate) fn number<T: std::str::FromStr>(value: &str) -> Option<T> {
value.trim().parse().ok()
}
}
pub(crate) const fn depth_of(arg: DepthArg) -> Depth {
match arg {
DepthArg::Any => Depth::Any,
DepthArg::Second => Depth::Second,
DepthArg::Third => Depth::Third,
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser as _;
fn plan() -> Plan {
Plan::from_args(&Cli::parse_from(["reserve", "example"]))
}
#[test]
fn every_setting_has_a_label_and_a_readable_value() {
let plan = plan();
for setting in Setting::ALL {
assert!(!setting.label().is_empty());
assert!(
!plan.shown(*setting).is_empty(),
"{} shows nothing",
setting.label()
);
}
}
#[test]
fn every_row_can_be_changed_by_one_of_the_two_ways_of_changing_a_row() {
let mut plan = plan();
for setting in Setting::ALL {
let before = plan.shown(*setting);
if setting.is_typed() {
plan.type_into(*setting, '7');
assert_ne!(
plan.shown(*setting),
before,
"{} takes no typing",
setting.label()
);
plan.backspace(*setting);
} else {
plan.cycle(*setting, true);
assert_ne!(
plan.shown(*setting),
before,
"{} does not cycle",
setting.label()
);
}
}
}
#[test]
fn a_list_cycles_all_the_way_round_in_both_directions() {
let mut plan = plan();
let start = plan.shown(Setting::Source);
for _ in 0..SOURCES.len() {
plan.cycle(Setting::Source, true);
}
assert_eq!(plan.shown(Setting::Source), start, "forward wraps");
for _ in 0..SOURCES.len() {
plan.cycle(Setting::Source, false);
}
assert_eq!(plan.shown(Setting::Source), start, "backward wraps too");
}
#[test]
fn a_number_row_refuses_anything_that_is_not_a_number() {
let mut plan = plan();
for letter in ['a', '-', '.', ' ', '!'] {
plan.type_into(Setting::Timeout, letter);
}
assert!(plan.timeout.is_empty(), "letters never reach a number row");
plan.type_into(Setting::Timeout, '4');
plan.type_into(Setting::Timeout, '5');
assert_eq!(plan.timeout, "45");
assert_eq!(Plan::number::<u64>(&plan.timeout), Some(45));
}
#[test]
fn a_typed_row_cannot_be_grown_without_end() {
let mut plan = plan();
for _ in 0..1000 {
plan.type_into(Setting::Names, 'a');
}
assert!(plan.names.chars().count() <= 256);
}
#[test]
fn the_plan_opens_showing_what_the_command_line_already_asked_for() {
let args = Cli::parse_from([
"reserve",
"alpha",
"beta",
"--cctld",
"--source",
"text",
"--timeout",
"30",
"--details",
]);
let plan = Plan::from_args(&args);
assert_eq!(plan.names, "alpha,beta");
assert!(plan.country_only);
assert_eq!(plan.source, SourceArg::Text);
assert_eq!(plan.timeout, "30");
assert!(plan.details);
assert!(plan.referral, "referrals are on unless turned off");
}
#[test]
fn asking_for_everything_turns_on_each_extra_section() {
let plan = Plan::from_args(&Cli::parse_from(["reserve", "example", "--full"]));
assert!(plan.details && plan.responder && plan.dns && plan.where_to_buy);
}
#[test]
fn the_settings_the_picker_offers_cover_everything_that_shapes_a_run() {
let args = Cli::parse_from([
"reserve",
"example",
"--names-from",
"names.txt",
"--registry-servers",
"rdap.json",
"--text-servers",
"text.json",
"--servers-replace",
"--json",
]);
let plan = Plan::from_args(&args);
assert_eq!(plan.names_from, "names.txt");
assert_eq!(plan.registry_servers, "rdap.json");
assert_eq!(plan.text_servers, "text.json");
assert!(plan.servers_replace);
assert!(plan.json);
}
#[test]
fn the_picker_can_change_the_saved_file_format_without_changing_the_screen() {
let mut plan = plan();
assert!(!plan.json && !plan.save_as_json);
plan.cycle(Setting::SaveAsJson, true);
assert!(plan.save_as_json, "the files become JSON");
assert!(
!plan.json,
"the screen keeps its table; nothing in the picker can paint JSON over it"
);
}
#[test]
fn the_command_line_asking_for_json_still_asks_for_it_on_both() {
let plan = Plan::from_args(&Cli::parse_from(["reserve", "example", "--json"]));
assert!(plan.json, "stdout is machine readable");
assert!(plan.save_as_json, "and so are the saved files");
}
#[test]
fn no_setting_the_picker_offers_can_silence_the_report() {
for setting in Setting::ALL {
let mut plan = plan();
if setting.is_typed() {
plan.type_into(*setting, '7');
} else {
plan.cycle(*setting, true);
}
assert!(
!plan.json,
"{} turned the screen machine-readable",
setting.label()
);
}
}
#[test]
fn the_selection_rows_build_the_same_filter_the_command_line_builds() {
let mut plan = plan();
plan.country_only = true;
plan.restricted = true;
plan.exclude = "uk, xn--p1ai".to_owned();
plan.industry = "tech".to_owned();
plan.region = "south-asia".to_owned();
plan.length = "2-3".to_owned();
let filter = plan.filter();
assert!(filter.country_codes_only);
assert!(!filter.registrable_only, "restricted zones are let back in");
assert_eq!(filter.exclude.len(), 2, "both excluded suffixes parse");
assert_eq!(filter.industries, ["tech"]);
assert_eq!(filter.regions, ["south-asia"]);
assert!(filter.length.is_some(), "a length rule is read");
}
#[test]
fn a_half_typed_value_is_ignored_rather_than_emptying_the_list() {
let mut plan = plan();
plan.length = "-".to_owned();
plan.exclude = ".".to_owned();
let filter = plan.filter();
assert!(filter.length.is_none(), "not yet a rule, so no rule");
assert!(filter.exclude.is_empty(), "not yet a suffix, so no suffix");
plan.length = "2-".to_owned();
assert!(
plan.filter().length.is_some(),
"an open-ended length is a finished rule, not a half-typed one"
);
}
#[test]
fn a_list_written_by_hand_is_split_the_way_the_command_line_splits_it() {
assert_eq!(
Plan::split_list(" com , net ,, org "),
["com", "net", "org"]
);
assert!(Plan::split_list(" ").is_empty());
}
}