use std::cell::{Cell, RefCell};
use std::future::Future;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use reserve_core::{
Catalog, Engine, Error, ExitClass, Family, Filter, LengthRule, PacingLimits, Page, Settings,
Sort, SortDirection, SortKey, SourcePolicy, Suffix, SweepPlan, Tally,
};
use crate::cli::{
Cli, Command, ConfigAction, FamilyArg, ListingArgs, SelectArgs, ShellArg, SortFieldArg,
SortOrderArg, SourceArg,
};
use crate::context::{Context, emit};
use crate::output::detail::Sections;
use crate::output::{self, Palette};
use crate::progress::Progress;
pub(crate) async fn run(args: Cli, clock: crate::lifecycle::Clock) -> ExitClass {
let quiet_progress = args.output.json || args.global.no_input;
let context = Context::new(args.global.color, args.global.width, quiet_progress);
let outcome = tokio::select! {
biased;
() = interrupted() => {
let palette = Palette::new(context.color.stderr);
let _ = writeln!(
std::io::stderr(),
"{} stopping; nothing was left half-written",
palette.warning("interrupted:")
);
return ExitClass::Interrupted;
}
outcome = dispatch(&args, &context, clock) => outcome,
};
match outcome {
Ok(class) => class,
Err(error) => {
report_error(&error, &context);
error.exit_class()
}
}
}
async fn interrupted() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut terminate = signal(SignalKind::terminate()).ok();
let mut hangup = signal(SignalKind::hangup()).ok();
match (terminate.as_mut(), hangup.as_mut()) {
(Some(term), Some(hup)) => {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
_ = hup.recv() => {}
}
}
_ => {
let _ = tokio::signal::ctrl_c().await;
}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
#[derive(Debug, Default)]
struct Watch {
done: Cell<usize>,
latest: RefCell<String>,
}
impl Watch {
fn reset(&self) {
self.done.set(0);
self.latest.borrow_mut().clear();
}
fn answered(&self, name: &str) {
self.done.set(self.done.get().saturating_add(1));
let mut latest = self.latest.borrow_mut();
latest.clear();
latest.push_str(name);
}
fn starting(&self, name: &str) {
let mut latest = self.latest.borrow_mut();
latest.clear();
latest.push_str(name);
}
}
async fn watched<T>(progress: &mut Progress, work: impl Future<Output = T>, watch: &Watch) -> T {
let mut ticker = tokio::time::interval(Duration::from_millis(80));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
tokio::pin!(work);
loop {
tokio::select! {
outcome = &mut work => return outcome,
_ = ticker.tick() => progress.tick(watch.done.get(), &watch.latest.borrow()),
}
}
}
async fn dispatch(
args: &Cli,
context: &Context,
clock: crate::lifecycle::Clock,
) -> 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, args, context, 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, clock).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,
clock: crate::lifecycle::Clock,
) -> Result<ExitClass, Error> {
let notes = Palette::new(context.color.stderr);
crate::lifecycle::opening(context, &clock, notes);
let mut plan = crate::plan::Plan::from_args(args);
if plan.names.trim().is_empty()
&& plan.names_from.trim().is_empty()
&& can_prompt(args, context)
&& let Some(asked) = ask_for_names(notes)
{
plan.names = asked.join(",");
}
let mut picked_suffixes: Option<Vec<Suffix>> = None;
if wants_picker(args, &plan, context) {
let signals = crate::tui::watch_for_signals();
let opened = plan.clone();
let picked = tokio::task::block_in_place(|| crate::tui::pick(catalog, opened));
signals.abort();
match picked.map_err(|source| Error::OutputUnwritable {
target: "terminal".to_owned(),
source,
})? {
Some(chosen) if !chosen.suffixes.is_empty() => {
plan = chosen.plan;
picked_suffixes = Some(chosen.suffixes);
}
_ => {
let _ = writeln!(
std::io::stderr(),
"{} no extensions were picked, so nothing was checked. Pass --group or --tld to skip the picker.",
notes.dim("stopped:")
);
return Ok(ExitClass::Interrupted);
}
}
}
let mut entries = crate::plan::Plan::split_list(&plan.names);
if !plan.names_from.trim().is_empty() {
let path = PathBuf::from(plan.names_from.trim());
let from_file = crate::files::read_list(&path)
.map_err(|source| Error::FileUnreadable { path, source })?;
entries.extend(from_file);
}
let (names, domains) = split_targets(&collect_names(&entries, notes)?);
let suffixes: Vec<Suffix> = if names.is_empty() {
Vec::new()
} else if let Some(chosen) = picked_suffixes {
chosen
} 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 plan.cautious {
PacingLimits::cautious()
} else {
PacingLimits::default()
};
if let Some(concurrency) = crate::plan::Plan::number::<usize>(&plan.concurrency) {
pacing.total_concurrency = concurrency.clamp(1, MAX_CONCURRENCY);
}
if let Some(per_registry) = crate::plan::Plan::number::<usize>(&plan.per_registry) {
pacing.per_registry = Some(per_registry.clamp(1, MAX_CONCURRENCY));
}
if let Some(rate) = crate::plan::Plan::number::<u32>(&plan.rate) {
pacing.rate = Some(rate.clamp(1, MAX_RATE));
}
let settings = Settings {
pacing,
timeout: Duration::from_secs(
crate::plan::Plan::number::<u64>(&plan.timeout)
.unwrap_or(10)
.clamp(1, MAX_TIMEOUT_SECS),
),
cache_path: crate::context::cache_file("registry-services.json"),
refresh: plan.refresh,
registry_servers: path_setting(&plan.registry_servers),
text_servers: path_setting(&plan.text_servers),
replace_servers: plan.servers_replace,
allow_referrals: plan.referral,
source_policy: match plan.source {
SourceArg::Registry => SourcePolicy::Registry,
SourceArg::Text => SourcePolicy::Text,
SourceArg::Dns => SourcePolicy::Dns,
SourceArg::Auto => SourcePolicy::Auto,
},
};
let watch = Watch::default();
let engine = {
let mut opening = Progress::start("reaching the registry list", None, context);
let built = watched(&mut opening, Engine::build(settings), &watch).await;
opening.finish();
built?
};
let planned = names.len().saturating_mul(suffixes.len());
let mut sweeping = Progress::start("checking", Some(planned), context);
let mut findings = watched(
&mut sweeping,
engine.sweep(&names, &suffixes, |finding| {
watch.answered(&finding.domain);
}),
&watch,
)
.await;
sweeping.finish();
if !domains.is_empty() {
watch.reset();
let mut exact = Progress::start("checking exact names", Some(domains.len()), context);
for domain in &domains {
watch.starting(domain);
let checked = watched(&mut exact, engine.check_domain(catalog, domain), &watch).await;
match checked {
Some(finding) => findings.push(finding),
None => findings.push(reserve_core::Finding::unrecognized(domain)),
}
watch.done.set(watch.done.get().saturating_add(1));
}
exact.finish();
}
findings.sort_by(|a, b| a.domain.cmp(&b.domain));
let tally = Tally::of(&findings);
let sections = Sections {
registration: plan.details,
responder: plan.responder,
dns: plan.dns,
where_to_buy: plan.where_to_buy,
};
let shown: Vec<&reserve_core::Finding> = findings
.iter()
.filter(|f| !plan.available_only || f.is_available())
.collect();
if plan.json {
emit_json(&shown)?;
} else {
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() {
watch.reset();
let mut detailing = Progress::start("gathering detail", Some(shown.len()), context);
for finding in &shown {
watch.starting(&finding.domain);
let dns = if sections.dns {
Some(watched(&mut detailing, engine.dns_records(&finding.domain), &watch).await)
} else {
None
};
watch.done.set(watch.done.get().saturating_add(1));
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));
detailing.suspend(|| {
emit(|out| {
out.write_all(header.as_bytes())?;
out.write_all(block.as_bytes())
})
.map_err(stdout_error)
})?;
}
}
detailing.finish();
}
}
if plan.save {
let dir = if plan.out.trim().is_empty() {
PathBuf::from(".")
} else {
PathBuf::from(plan.out.trim())
};
let all: Vec<&reserve_core::Finding> = findings.iter().collect();
let written = crate::files::write_results(&dir, &all, plan.save_as_json, plan.append)
.map_err(|source| Error::OutputUnwritable {
target: dir.display().to_string(),
source,
})?;
for file in &written.files {
let shown = std::path::absolute(file).map_or_else(
|_| file.display().to_string(),
|full| shorten_home(&full.display().to_string()),
);
let line = format!("{} {shown}", notes.dim("wrote"));
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",
notes.warning("note:"),
paused.host,
paused.refusals,
paused.remaining_wait.as_secs()
);
let _ = writeln!(std::io::stderr(), "{note}");
}
if let Some(stalled) = crate::lifecycle::diagnose(&findings) {
crate::lifecycle::report_stall(stalled, notes);
}
crate::lifecycle::closing(context, &clock, findings.len(), notes);
Ok(if tally.has_available() {
ExitClass::Success
} else {
ExitClass::NothingAvailable
})
}
fn show_config(
action: &ConfigAction,
args: &Cli,
context: &Context,
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} {}\n", shorten_home(&value)));
}
out
}
ConfigAction::Show { json } => {
let rows = resolved_settings(args, context);
if *json {
let rows: Vec<_> = rows
.iter()
.map(|row| {
serde_json::json!({
"key": row.key,
"value": row.value,
"source": row.source,
})
})
.collect();
return emit_json(&rows);
}
let mut out = String::new();
out.push_str(&format!("{}\n", palette.heading("Resolved settings")));
for row in &rows {
out.push_str(&format!(
" {:<18} {:<14} {}\n",
row.key,
row.value,
palette.dim(row.source)
));
}
out
}
};
emit(|out| out.write_all(text.as_bytes())).map_err(stdout_error)?;
Ok(ExitClass::Success)
}
fn proxy_in_use() -> String {
for key in ["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"] {
if std::env::var_os(key).is_some_and(|value| !value.is_empty()) {
return format!("set through {key}");
}
}
"none".to_owned()
}
struct SettingRow {
key: &'static str,
value: String,
source: &'static str,
}
fn resolved_settings(args: &Cli, context: &Context) -> Vec<SettingRow> {
fn chosen(given: Option<String>, env: &str, fallback: String) -> (String, &'static str) {
match given {
Some(value) if std::env::var_os(env).is_some() => (value, "environment"),
Some(value) => (value, "flag"),
None => (fallback, "built-in default"),
}
}
let mut rows = Vec::new();
let mut push = |key, (value, source)| rows.push(SettingRow { key, value, source });
push(
"concurrency",
chosen(
args.pacing.concurrency.map(|n| n.to_string()),
"RESERVE_CONCURRENCY",
if args.pacing.cautious { "8" } else { "24" }.to_owned(),
),
);
push(
"timeout",
chosen(
args.pacing.timeout.map(|n| n.to_string()),
"RESERVE_TIMEOUT",
"10".to_owned(),
),
);
push(
"width",
chosen(
args.global.width.map(|n| n.to_string()),
"RESERVE_WIDTH",
context
.terminal
.width
.map_or_else(|| "unset".to_owned(), |n| n.to_string()),
),
);
push(
"no_input",
if args.global.no_input {
(
"true".to_owned(),
if std::env::var_os("RESERVE_NO_INPUT").is_some() {
"environment"
} else {
"flag"
},
)
} else {
("false".to_owned(), "built-in default")
},
);
push(
"per_registry",
chosen(
args.pacing.per_registry.map(|n| n.to_string()),
"",
"published allowance".to_owned(),
),
);
push(
"rate",
chosen(
args.pacing.rate.map(|n| n.to_string()),
"",
"published allowance".to_owned(),
),
);
push(
"source",
setting_of(format!("{:?}", args.pacing.source).to_lowercase(), "auto"),
);
push(
"sort",
setting_of(format!("{:?}", args.view.sort).to_lowercase(), "popularity"),
);
push(
"color",
setting_of(format!("{:?}", args.global.color).to_lowercase(), "auto"),
);
push(
"include_restricted",
setting_of(args.select.include_restricted.to_string(), "false"),
);
rows
}
fn setting_of(value: String, default: &str) -> (String, &'static str) {
let source = if value == default {
"built-in default"
} else {
"flag"
};
(value, source)
}
fn shorten_home(path: &str) -> String {
let Some(home) = std::env::var_os("HOME").and_then(|h| h.into_string().ok()) else {
return path.to_owned();
};
if home.is_empty() {
return path.to_owned();
}
match path.strip_prefix(&home) {
Some(rest) => format!("~{rest}"),
None => path.to_owned(),
}
}
const MAX_CONCURRENCY: usize = 1024;
const MAX_RATE: u32 = 10_000;
const MAX_TIMEOUT_SECS: u64 = 3600;
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,
},
"network": {
"registry_list": reserve_core::BOOTSTRAP_URL,
"cached_list": shorten_home(&crate::context::cache_file("registry-services.json").display().to_string()),
"proxy": proxy_in_use(),
},
"paths": crate::context::paths()
.into_iter()
.map(|(label, value)| {
(label.to_owned(), serde_json::Value::String(shorten_home(&value)))
})
.collect::<serde_json::Map<_, _>>(),
});
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("Network")));
text.push_str(&format!(
" registry list {}\n",
reserve_core::BOOTSTRAP_URL
));
let cached = crate::context::cache_file("registry-services.json");
text.push_str(&format!(
" cached list {}\n",
if cached.exists() {
shorten_home(&cached.display().to_string())
} else {
"not downloaded yet".to_owned()
}
));
text.push_str(&format!(" proxy {}\n", proxy_in_use()));
text.push_str(&format!("\n{}\n", palette.heading("Paths")));
for (label, value) in crate::context::paths() {
text.push_str(&format!(" {label:<12} {}\n", shorten_home(&value)));
}
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: crate::plan::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 path_setting(value: &str) -> Option<PathBuf> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(PathBuf::from(trimmed))
}
}
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 ask_for_names(palette: Palette) -> Option<Vec<String>> {
use std::io::BufRead as _;
const ATTEMPTS: usize = 3;
let mut stderr = std::io::stderr();
let stdin = std::io::stdin();
let mut line = String::new();
for attempt in 1..=ATTEMPTS {
let _ = write!(
stderr,
"{} ",
palette.accent("Which name would you like to check?")
);
let _ = stderr.flush();
line.clear();
match stdin.lock().read_line(&mut line) {
Ok(0) => {
let _ = writeln!(stderr);
return None;
}
Ok(_) => {}
Err(_) => return None,
}
let entries: Vec<String> = line
.split(',')
.map(|part| part.trim().to_owned())
.filter(|part| !part.is_empty())
.collect();
if !entries.is_empty() {
return Some(entries);
}
if attempt < ATTEMPTS {
let _ = writeln!(
stderr,
" {}",
palette.dim("Type a name such as `example`, or press Ctrl-C to leave.")
);
}
}
let _ = writeln!(
stderr,
" {}",
palette.dim("No name given after three tries.")
);
None
}
fn can_prompt(args: &Cli, context: &Context) -> bool {
!args.global.no_input
&& !args.output.json
&& !context.terminal.is_ci
&& context.terminal.stdin_is_tty
&& context.terminal.stderr_is_tty
}
fn wants_picker(args: &Cli, plan: &crate::plan::Plan, context: &Context) -> bool {
if args.global.no_input
|| context.terminal.is_ci
|| !context.terminal.stdin_is_tty
|| !context.terminal.stderr_is_tty
{
return false;
}
if args.global.interactive {
return true;
}
if !args.select.group.is_empty()
|| !args.select.tld.is_empty()
|| args.select.tlds_from.is_some()
{
return false;
}
needs_an_extension(plan)
}
fn needs_an_extension(plan: &crate::plan::Plan) -> bool {
if !plan.names_from.trim().is_empty() {
return true;
}
let entries = crate::plan::Plan::split_list(&plan.names);
entries.is_empty() || entries.iter().any(|entry| !entry.contains('.'))
}
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], notes: Palette) -> Result<Vec<String>, Error> {
let mut names = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut rewrites: Vec<(String, String)> = Vec::new();
for entry in raw {
for part in entry.split(',') {
let trimmed = part.trim();
if trimmed.is_empty() {
continue;
}
let resolved = reserve_core::normalize_name(trimmed)?;
if resolved.rewritten {
rewrites.push((trimmed.to_owned(), resolved.name.clone()));
}
if seen.insert(resolved.name.clone()) {
names.push(resolved.name);
}
}
}
for (typed, checked) in &rewrites {
let _ = writeln!(
std::io::stderr(),
"{} `{typed}` is not a name a registry can hold, so `{checked}` is being checked",
notes.dim("note:")
);
}
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,
}
}
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, Palette::new(false)).unwrap();
assert_eq!(names, vec!["one", "two", "three"]);
}
#[test]
fn no_name_at_all_is_a_usage_error() {
assert!(matches!(
collect_names(&[], Palette::new(false)),
Err(Error::NameListEmpty)
));
let blanks = vec![" ".to_owned(), ",".to_owned()];
assert!(matches!(
collect_names(&blanks, Palette::new(false)),
Err(Error::NameListEmpty)
));
}
fn context_with_terminal(is_interactive: bool) -> Context {
context_for(is_interactive, false)
}
fn context_for(is_interactive: bool, is_ci: bool) -> Context {
Context {
color: crate::context::ColorPolicy::resolve(crate::cli::ColorArg::Never),
terminal: crate::context::TerminalInfo {
stdin_is_tty: is_interactive,
stdout_is_tty: is_interactive,
stderr_is_tty: is_interactive,
width: Some(100),
height: Some(30),
is_ci,
},
quiet_progress: false,
decorate: true,
wide_glyphs: true,
}
}
fn picker_wanted(argv: &[&str], context: &Context) -> bool {
let args = Cli::parse_from(argv);
let plan = crate::plan::Plan::from_args(&args);
wants_picker(&args, &plan, context)
}
#[test]
fn the_picker_never_opens_on_a_build_agent_even_with_a_terminal_attached() {
assert!(
!picker_wanted(&["reserve", "example"], &context_for(true, true)),
"a job that allocates a pty would otherwise block until it timed out"
);
}
#[test]
fn the_picker_never_opens_without_someone_at_the_terminal() {
assert!(!picker_wanted(
&["reserve", "example"],
&context_with_terminal(false)
));
}
#[test]
fn the_picker_never_opens_when_told_to_take_no_input() {
assert!(!picker_wanted(
&["reserve", "example", "--no-input"],
&context_with_terminal(true)
));
}
#[test]
fn naming_extensions_skips_the_picker() {
let terminal = context_with_terminal(true);
assert!(!picker_wanted(
&["reserve", "example", "--tld", "com"],
&terminal
));
assert!(!picker_wanted(
&["reserve", "example", "--group", "tech"],
&terminal
));
}
#[test]
fn naming_nothing_at_a_terminal_opens_the_picker() {
assert!(picker_wanted(
&["reserve", "example"],
&context_with_terminal(true)
));
}
#[test]
fn a_name_that_already_carries_its_extension_skips_the_picker() {
let terminal = context_with_terminal(true);
assert!(
!picker_wanted(&["reserve", "docs.bd"], &terminal),
"there is no extension left to pick for a name that has one"
);
assert!(
!picker_wanted(&["reserve", "docs.bd", "example.com"], &terminal),
"several full domains are still all full domains"
);
assert!(
picker_wanted(&["reserve", "docs.bd", "example"], &terminal),
"one bare name is enough to need the picker"
);
assert!(
picker_wanted(&["reserve", "docs.bd", "--interactive"], &terminal),
"asking for the picker still opens it"
);
}
#[test]
fn a_file_of_names_still_opens_the_picker_because_it_has_not_been_read_yet() {
assert!(
picker_wanted(
&["reserve", "--names-from", "names.txt"],
&context_with_terminal(true)
),
"the file may hold a bare name, and being wrong the safe way costs one screen"
);
}
#[test]
fn asking_for_it_opens_the_picker_even_with_extensions_named() {
assert!(picker_wanted(
&["reserve", "example", "--tld", "com", "--interactive"],
&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_resolved_setting_carries_a_value_and_a_real_source() {
let args = Cli::parse_from(["reserve", "example"]);
let rows = resolved_settings(&args, &context_with_terminal(true));
assert!(!rows.is_empty());
for row in &rows {
assert!(!row.key.is_empty());
assert!(!row.value.is_empty(), "{} has no value", row.key);
assert!(
["flag", "environment", "built-in default"].contains(&row.source),
"{} reported the source {}",
row.key,
row.source
);
}
}
#[test]
fn a_flag_is_reported_as_a_flag_rather_than_a_default() {
let args = Cli::parse_from(["reserve", "example", "--timeout", "45"]);
let rows = resolved_settings(&args, &context_with_terminal(true));
let timeout = rows
.iter()
.find(|row| row.key == "timeout")
.expect("timeout is reported");
assert_eq!(timeout.value, "45", "the value the run will actually use");
assert_ne!(timeout.source, "built-in default");
}
#[test]
fn the_home_prefix_is_folded_so_a_pasted_path_carries_no_login_name() {
let home = std::env::var("HOME").unwrap_or_default();
if home.is_empty() {
return;
}
assert_eq!(
shorten_home(&format!("{home}/.cache/reserve")),
"~/.cache/reserve"
);
assert_eq!(shorten_home("/etc/reserve"), "/etc/reserve");
}
}