use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use reserve_core::{
Catalog, Depth, Engine, Error, ExitClass, Family, Filter, LengthRule, PacingLimits, Page,
Settings, Sort, SortDirection, SortKey, SourcePolicy, Suffix, SweepPlan, Tally,
};
use crate::cli::{
Cli, Command, ConfigAction, DepthArg, FamilyArg, ListingArgs, SelectArgs, ShellArg,
SortFieldArg, SortOrderArg, SourceArg,
};
use crate::context::{Context, emit};
use crate::output::detail::Sections;
use crate::output::{self, Palette};
pub(crate) async fn run(args: Cli) -> ExitClass {
let context = Context::new(args.global.color, args.global.width);
match dispatch(&args, &context).await {
Ok(class) => class,
Err(error) => {
report_error(&error, &context);
error.exit_class()
}
}
}
async fn dispatch(args: &Cli, context: &Context) -> Result<ExitClass, Error> {
let palette = Palette::new(context.color.stdout);
match &args.command {
Some(Command::Groups { family, json }) => {
let catalog = Catalog::bundled()?;
show_groups(&catalog, *family, *json, palette, context)
}
Some(Command::Extensions { select, view, json }) => {
let catalog = Catalog::bundled()?;
show_extensions(&catalog, select, view, *json, context, palette)
}
Some(Command::Config { action }) => show_config(action, palette),
Some(Command::Doctor { json }) => show_doctor(*json, context, palette),
Some(Command::Completions { shell }) => show_completions(*shell),
None => {
let catalog = Catalog::bundled()?;
run_sweep(&catalog, args, context, palette).await
}
}
}
fn report_error(error: &Error, context: &Context) {
let palette = Palette::new(context.color.stderr);
let mut stderr = std::io::stderr();
let _ = writeln!(stderr, "{} {error}", palette.error("error:"));
let mut source = std::error::Error::source(error);
while let Some(cause) = source {
let _ = writeln!(stderr, " {} {cause}", palette.dim("caused by:"));
source = cause.source();
}
let remedy = error.remedy();
if !remedy.is_empty() {
let _ = writeln!(stderr, " {} {remedy}", palette.dim("try:"));
}
let _ = writeln!(stderr, " {} {}", palette.dim("code:"), error.id());
}
fn show_groups(
catalog: &Catalog,
family: Option<FamilyArg>,
json: bool,
palette: Palette,
context: &Context,
) -> Result<ExitClass, Error> {
let wanted = family.map(family_of);
if json {
let rows: Vec<_> = catalog
.groups
.iter()
.filter(|group| wanted.is_none_or(|f| group.family == f))
.map(|group| {
serde_json::json!({
"id": group.key,
"family": group.family.key(),
"title": group.title,
"summary": group.summary,
"extensions": catalog.group_size(group),
})
})
.collect();
return emit_json(&rows);
}
let text = output::groups(catalog, wanted, palette, context.fit_columns());
emit(|out| out.write_all(text.as_bytes())).map_err(stdout_error)?;
Ok(ExitClass::Success)
}
fn show_extensions(
catalog: &Catalog,
select: &SelectArgs,
view: &ListingArgs,
json: bool,
context: &Context,
palette: Palette,
) -> Result<ExitClass, Error> {
let selection = build_selection(catalog, select, view)?;
let chosen = catalog.extensions_for(&selection)?;
if json {
return emit_json(&chosen);
}
let page = resolve_page(view, context);
let text = if view.all_pages {
let whole = Page::new(1, chosen.len().max(1));
output::extensions(
&chosen,
whole,
selection.sort,
palette,
false,
context.fit_columns(),
)
} else {
output::extensions(
&chosen,
page,
selection.sort,
palette,
true,
context.fit_columns(),
)
};
emit(|out| out.write_all(text.as_bytes())).map_err(stdout_error)?;
Ok(ExitClass::Success)
}
async fn run_sweep(
catalog: &Catalog,
args: &Cli,
context: &Context,
palette: Palette,
) -> Result<ExitClass, Error> {
let mut entries = args.names.clone();
if let Some(path) = &args.names_from {
let from_file =
crate::files::read_list(Path::new(path)).map_err(|source| Error::FileUnreadable {
path: PathBuf::from(path),
source,
})?;
entries.extend(from_file);
}
let (names, domains) = split_targets(&collect_names(&entries)?);
let suffixes: Vec<Suffix> = if names.is_empty() {
Vec::new()
} else if wants_picker(args, context) {
let signals = crate::tui::watch_for_signals();
let picked = crate::tui::pick(catalog);
signals.abort();
match picked.map_err(|source| Error::OutputUnwritable {
target: "terminal".to_owned(),
source,
})? {
Some(chosen) if !chosen.is_empty() => chosen,
_ => return Ok(ExitClass::Interrupted),
}
} else {
let selection = build_selection(catalog, &args.select, &args.view)?;
catalog
.extensions_for(&selection)?
.iter()
.map(|ext| ext.suffix.clone())
.collect()
};
let mut pacing = if args.pacing.cautious {
PacingLimits::cautious()
} else {
PacingLimits::default()
};
if let Some(concurrency) = args.pacing.concurrency {
pacing.total_concurrency = concurrency.clamp(1, MAX_CONCURRENCY);
}
pacing.per_registry = args
.pacing
.per_registry
.map(|n| n.clamp(1, MAX_CONCURRENCY));
pacing.rate = args.pacing.rate.map(|n| n.clamp(1, MAX_RATE));
let settings = Settings {
pacing,
timeout: Duration::from_secs(args.pacing.timeout.unwrap_or(10).clamp(1, MAX_TIMEOUT_SECS)),
cache_path: crate::context::cache_file("registry-services.json"),
refresh: args.pacing.refresh,
registry_servers: args.pacing.registry_servers.as_deref().map(PathBuf::from),
text_servers: args.pacing.text_servers.as_deref().map(PathBuf::from),
replace_servers: args.pacing.servers_replace,
allow_referrals: !args.pacing.no_referral,
source_policy: match args.pacing.source {
SourceArg::Registry => SourcePolicy::Registry,
SourceArg::Text => SourcePolicy::Text,
SourceArg::Dns => SourcePolicy::Dns,
SourceArg::Auto => SourcePolicy::Auto,
},
};
let engine = Engine::build(settings).await?;
let mut findings = engine.sweep(&names, &suffixes).await;
for domain in &domains {
match engine.check_domain(catalog, domain).await {
Some(finding) => findings.push(finding),
None => findings.push(reserve_core::Finding::unrecognized(domain)),
}
}
findings.sort_by(|a, b| a.domain.cmp(&b.domain));
let tally = Tally::of(&findings);
let sections = if args.output.full {
Sections::full()
} else {
Sections {
registration: args.output.details,
responder: args.output.responder,
dns: args.output.dns,
where_to_buy: args.output.where_to_buy,
}
};
if args.output.json {
emit_json(&findings)?;
} else {
let shown: Vec<&reserve_core::Finding> = findings
.iter()
.filter(|f| !args.output.available_only || f.is_available())
.collect();
let text = output::findings(&shown, tally, palette, context.fit_columns());
emit(|out| out.write_all(text.as_bytes())).map_err(stdout_error)?;
if sections.any_enabled() {
for finding in &shown {
let dns = if sections.dns {
Some(engine.dns_records(&finding.domain).await)
} else {
None
};
let block = output::detail::render(
finding,
sections,
dns.as_ref(),
palette,
context.fit_columns(),
);
if !block.is_empty() {
let header = format!("\n{}\n", palette.accent(&finding.domain));
emit(|out| {
out.write_all(header.as_bytes())?;
out.write_all(block.as_bytes())
})
.map_err(stdout_error)?;
}
}
}
}
if args.output.save || args.output.out.is_some() || args.output.append {
let dir = args
.output
.out
.as_deref()
.map_or_else(|| PathBuf::from("."), PathBuf::from);
let all: Vec<&reserve_core::Finding> = findings.iter().collect();
let written = crate::files::write_results(&dir, &all, args.output.json, args.output.append)
.map_err(|source| Error::OutputUnwritable {
target: dir.display().to_string(),
source,
})?;
for file in &written.files {
let line = format!("{} {}", palette.dim("wrote"), file.display());
let _ = writeln!(std::io::stderr(), "{line}");
}
for file in &written.replaced {
let line = format!(
"{} replaced {}; pass --append to merge instead",
palette.warning("note:"),
file.display()
);
let _ = writeln!(std::io::stderr(), "{line}");
}
}
for paused in engine.paused().await {
let note = format!(
"{} {} stopped answering after {} refusals; {}s left before it is tried again",
palette.warning("note:"),
paused.host,
paused.refusals,
paused.remaining_wait.as_secs()
);
let _ = writeln!(std::io::stderr(), "{note}");
}
Ok(if tally.has_available() {
ExitClass::Success
} else {
ExitClass::NothingAvailable
})
}
fn show_config(action: &ConfigAction, palette: Palette) -> Result<ExitClass, Error> {
let text = match action {
ConfigAction::Path => {
let mut out = String::new();
out.push_str(&format!("{}\n", palette.heading("Paths")));
for (label, value) in crate::context::paths() {
out.push_str(&format!(" {label:<8} {value}\n"));
}
out
}
ConfigAction::Show { json } => {
if *json {
let rows: Vec<_> = SETTINGS
.iter()
.map(|(key, kind, default)| {
serde_json::json!({
"key": key,
"type": kind,
"value": default,
"source": "built-in default",
})
})
.collect();
return emit_json(&rows);
}
let mut out = String::new();
out.push_str(&format!("{}\n", palette.heading("Resolved settings")));
for (key, _, default) in SETTINGS {
out.push_str(&format!(
" {key:<18} {default:<12} {}\n",
palette.dim("built-in default")
));
}
out
}
};
emit(|out| out.write_all(text.as_bytes())).map_err(stdout_error)?;
Ok(ExitClass::Success)
}
const MAX_CONCURRENCY: usize = 1024;
const MAX_RATE: u32 = 10_000;
const MAX_TIMEOUT_SECS: u64 = 3600;
const SETTINGS: &[(&str, &str, &str)] = &[
("version", "integer", "1"),
("sort", "string", "popularity"),
("order", "string", "desc"),
("page_size", "integer", "terminal height"),
("concurrency", "integer", "24"),
("timeout", "integer", "10"),
("color", "string", "auto"),
("include_restricted", "boolean", "false"),
];
fn show_doctor(json: bool, context: &Context, palette: Palette) -> Result<ExitClass, Error> {
let catalog = Catalog::bundled()?;
let terminal = context.terminal;
if json {
let report = serde_json::json!({
"version": reserve_core::VERSION,
"user_agent": reserve_core::user_agent(),
"catalog": {
"version": catalog.version,
"generated": catalog.generated_on,
"extensions": catalog.extension_count(),
"groups": catalog.groups.len(),
},
"terminal": {
"stdin_is_tty": terminal.stdin_is_tty,
"stdout_is_tty": terminal.stdout_is_tty,
"width": terminal.width,
"height": terminal.height,
"ci": terminal.is_ci,
},
"color": {
"stdout": context.color.stdout,
"stderr": context.color.stderr,
},
});
return emit_json(&report);
}
let mut text = String::new();
text.push_str(&format!("{}\n", palette.heading("reserve")));
text.push_str(&format!(" version {}\n", reserve_core::VERSION));
text.push_str(&format!(" user agent {}\n", reserve_core::user_agent()));
text.push_str(&format!("\n{}\n", palette.heading("Catalog")));
text.push_str(&format!(" schema {}\n", catalog.version));
text.push_str(&format!(" assembled {}\n", catalog.generated_on));
text.push_str(&format!(" extensions {}\n", catalog.extension_count()));
text.push_str(&format!(" groups {}\n", catalog.groups.len()));
text.push_str(&format!("\n{}\n", palette.heading("Terminal")));
text.push_str(&format!(" stdout tty {}\n", terminal.stdout_is_tty));
text.push_str(&format!(" stdin tty {}\n", terminal.stdin_is_tty));
text.push_str(&format!(
" size {}\n",
match (terminal.width, terminal.height) {
(Some(w), Some(h)) => format!("{w} by {h}"),
_ => "unknown".to_owned(),
}
));
text.push_str(&format!(" automated {}\n", terminal.is_ci));
text.push_str(&format!(" color {}\n", context.color.stdout));
text.push_str(&format!("\n{}\n", palette.heading("Paths")));
for (label, value) in crate::context::paths() {
text.push_str(&format!(" {label:<12} {value}\n"));
}
emit(|out| out.write_all(text.as_bytes())).map_err(stdout_error)?;
Ok(ExitClass::Success)
}
fn show_completions(shell: ShellArg) -> Result<ExitClass, Error> {
use clap::CommandFactory;
let target = match shell {
ShellArg::Bash => clap_complete::Shell::Bash,
ShellArg::Elvish => clap_complete::Shell::Elvish,
ShellArg::Fish => clap_complete::Shell::Fish,
ShellArg::PowerShell => clap_complete::Shell::PowerShell,
ShellArg::Zsh => clap_complete::Shell::Zsh,
};
let mut script = Vec::new();
clap_complete::generate(target, &mut Cli::command(), "reserve", &mut script);
emit(|out| out.write_all(&script)).map_err(stdout_error)?;
Ok(ExitClass::Success)
}
fn build_selection(
catalog: &Catalog,
select: &SelectArgs,
view: &ListingArgs,
) -> Result<SweepPlan, Error> {
let mut groups = select.group.clone();
let mut extensions = Vec::new();
for raw in &select.tld {
extensions.push(Suffix::parse(raw)?);
}
if let Some(path) = &select.tlds_from {
let from_file =
crate::files::read_list(Path::new(path)).map_err(|source| Error::FileUnreadable {
path: PathBuf::from(path),
source,
})?;
for raw in from_file {
extensions.push(Suffix::parse(&raw)?);
}
}
if groups.is_empty() && extensions.is_empty() {
groups.push("popular".to_owned());
}
let mut exclude = Vec::new();
for raw in &select.exclude {
exclude.push(Suffix::parse(raw)?);
}
let length = match &select.length {
Some(spec) => Some(spec.parse::<LengthRule>()?),
None => None,
};
for key in &select.industry {
if !catalog.industry_keys().iter().any(|k| k == key) {
return Err(Error::GroupUnknown {
name: key.clone(),
closest_groups: catalog.closest_group_keys(key),
});
}
}
for key in &select.region {
if !catalog.region_keys().iter().any(|k| k == key) {
return Err(Error::GroupUnknown {
name: key.clone(),
closest_groups: catalog.closest_group_keys(key),
});
}
}
let filter = Filter {
search: select.search.clone(),
depth: depth_of(select.depth),
length,
country_codes_only: select.cctld,
registrable_only: !select.include_restricted,
industries: select.industry.clone(),
regions: select.region.clone(),
exclude,
};
Ok(SweepPlan {
group_keys: groups,
extensions,
filter,
sort: sort_of(view),
})
}
fn resolve_page(view: &ListingArgs, context: &Context) -> Page {
let size = view
.page_size
.unwrap_or_else(|| context.terminal.page_rows());
Page::new(view.page, size)
}
fn wants_picker(args: &Cli, context: &Context) -> bool {
if args.global.no_input || !context.terminal.stdin_is_tty || !context.terminal.stderr_is_tty {
return false;
}
if args.global.interactive {
return true;
}
args.select.group.is_empty() && args.select.tld.is_empty() && args.select.tlds_from.is_none()
}
fn split_targets(entries: &[String]) -> (Vec<String>, Vec<String>) {
let mut names = Vec::new();
let mut domains = Vec::new();
for entry in entries {
if entry.contains('.') {
domains.push(entry.clone());
} else {
names.push(entry.clone());
}
}
(names, domains)
}
fn collect_names(raw: &[String]) -> Result<Vec<String>, Error> {
let mut names = Vec::new();
let mut seen = std::collections::HashSet::new();
for entry in raw {
for part in entry.split(',') {
let trimmed = part.trim();
if trimmed.is_empty() {
continue;
}
let name = reserve_core::parse_name(trimmed)?;
if seen.insert(name.clone()) {
names.push(name);
}
}
}
if names.is_empty() {
return Err(Error::NameListEmpty);
}
Ok(names)
}
fn emit_json<T: serde::Serialize>(value: &T) -> Result<ExitClass, Error> {
let rendered =
serde_json::to_string_pretty(value).map_err(|source| Error::CatalogMalformed {
source: Box::new(source),
})?;
emit(|out| {
out.write_all(rendered.as_bytes())?;
out.write_all(b"\n")
})
.map_err(stdout_error)?;
Ok(ExitClass::Success)
}
fn stdout_error(source: std::io::Error) -> Error {
Error::OutputUnwritable {
target: "stdout".to_owned(),
source,
}
}
const fn family_of(arg: FamilyArg) -> Family {
match arg {
FamilyArg::Industry => Family::Industry,
FamilyArg::Region => Family::Region,
FamilyArg::Popularity => Family::Popularity,
FamilyArg::Curated => Family::Curated,
}
}
const fn depth_of(arg: DepthArg) -> Depth {
match arg {
DepthArg::Any => Depth::Any,
DepthArg::Second => Depth::Second,
DepthArg::Third => Depth::Third,
}
}
fn sort_of(view: &ListingArgs) -> Sort {
let key = match view.sort {
SortFieldArg::Name => SortKey::Name,
SortFieldArg::Popularity => SortKey::Popularity,
SortFieldArg::Length => SortKey::Length,
};
let direction = match view.order {
Some(SortOrderArg::Asc) => SortDirection::Ascending,
Some(SortOrderArg::Desc) => SortDirection::Descending,
None => key.natural_direction(),
};
Sort::new(key, direction)
}
#[cfg(test)]
mod tests {
use clap::Parser as _;
use super::*;
fn catalog() -> Catalog {
Catalog::bundled().expect("bundled catalog")
}
#[test]
fn names_are_split_on_commas_trimmed_and_deduplicated() {
let raw = vec![
"one, two".to_owned(),
"two".to_owned(),
" three ".to_owned(),
];
let names = collect_names(&raw).unwrap();
assert_eq!(names, vec!["one", "two", "three"]);
}
#[test]
fn no_name_at_all_is_a_usage_error() {
assert!(matches!(collect_names(&[]), Err(Error::NameListEmpty)));
let blanks = vec![" ".to_owned(), ",".to_owned()];
assert!(matches!(collect_names(&blanks), Err(Error::NameListEmpty)));
}
fn context_with_terminal(is_interactive: bool) -> Context {
let mut context = Context::new(crate::cli::ColorArg::Never, None);
context.terminal.stdin_is_tty = is_interactive;
context.terminal.stderr_is_tty = is_interactive;
context
}
#[test]
fn the_picker_never_opens_without_someone_at_the_terminal() {
let args = Cli::parse_from(["reserve", "example"]);
assert!(!wants_picker(&args, &context_with_terminal(false)));
}
#[test]
fn the_picker_never_opens_when_told_to_take_no_input() {
let args = Cli::parse_from(["reserve", "example", "--no-input"]);
assert!(!wants_picker(&args, &context_with_terminal(true)));
}
#[test]
fn naming_extensions_skips_the_picker() {
let args = Cli::parse_from(["reserve", "example", "--tld", "com"]);
assert!(!wants_picker(&args, &context_with_terminal(true)));
let args = Cli::parse_from(["reserve", "example", "--group", "tech"]);
assert!(!wants_picker(&args, &context_with_terminal(true)));
}
#[test]
fn naming_nothing_at_a_terminal_opens_the_picker() {
let args = Cli::parse_from(["reserve", "example"]);
assert!(wants_picker(&args, &context_with_terminal(true)));
}
#[test]
fn asking_for_it_opens_the_picker_even_with_extensions_named() {
let args = Cli::parse_from(["reserve", "example", "--tld", "com", "--interactive"]);
assert!(wants_picker(&args, &context_with_terminal(true)));
}
#[test]
fn a_name_with_a_dot_is_treated_as_a_full_domain() {
let entries = vec![
"example".to_owned(),
"apple.com".to_owned(),
"shop.co.uk".to_owned(),
];
let (names, domains) = split_targets(&entries);
assert_eq!(names, vec!["example"]);
assert_eq!(domains, vec!["apple.com", "shop.co.uk"]);
}
#[test]
fn a_run_of_only_bare_names_has_nothing_exact() {
let (names, domains) = split_targets(&["one".to_owned(), "two".to_owned()]);
assert_eq!(names.len(), 2);
assert!(domains.is_empty());
}
#[test]
fn choosing_nothing_falls_back_to_the_popular_group() {
let selection =
build_selection(&catalog(), &SelectArgs::default(), &ListingArgs::default()).unwrap();
assert_eq!(selection.group_keys, vec!["popular".to_owned()]);
}
#[test]
fn restricted_zones_are_dropped_unless_asked_for() {
let default =
build_selection(&catalog(), &SelectArgs::default(), &ListingArgs::default()).unwrap();
assert!(default.filter.registrable_only);
let including = SelectArgs {
include_restricted: true,
..SelectArgs::default()
};
let wide = build_selection(&catalog(), &including, &ListingArgs::default()).unwrap();
assert!(!wide.filter.registrable_only);
}
#[test]
fn an_unknown_industry_is_refused_with_a_suggestion() {
let select = SelectArgs {
industry: vec!["tec".to_owned()],
..SelectArgs::default()
};
let outcome = build_selection(&catalog(), &select, &ListingArgs::default());
assert!(matches!(outcome, Err(Error::GroupUnknown { .. })));
}
#[test]
fn a_bad_extension_is_refused_at_selection_time() {
let select = SelectArgs {
tld: vec!["..".to_owned()],
..SelectArgs::default()
};
let outcome = build_selection(&catalog(), &select, &ListingArgs::default());
assert!(matches!(outcome, Err(Error::ExtensionInvalid { .. })));
}
#[test]
fn the_sort_direction_defaults_to_what_reads_best_for_the_field() {
let by_length = ListingArgs {
sort: SortFieldArg::Length,
..ListingArgs::default()
};
assert_eq!(sort_of(&by_length).direction, SortDirection::Ascending);
let by_popularity = ListingArgs {
sort: SortFieldArg::Popularity,
..ListingArgs::default()
};
assert_eq!(sort_of(&by_popularity).direction, SortDirection::Descending);
}
#[test]
fn an_explicit_order_overrides_the_natural_one() {
let view = ListingArgs {
sort: SortFieldArg::Length,
order: Some(SortOrderArg::Desc),
..ListingArgs::default()
};
assert_eq!(sort_of(&view).direction, SortDirection::Descending);
}
#[test]
fn every_documented_setting_has_a_name_type_and_default() {
for (key, kind, default) in SETTINGS {
assert!(!key.is_empty());
assert!(!kind.is_empty());
assert!(!default.is_empty());
}
}
}