#![allow(
clippy::expect_used,
reason = "clippy.toml exempts test modules, and an integration test is a separate crate it cannot reach"
)]
use assert_cmd::Command;
use predicates::prelude::*;
fn reserve() -> Command {
let mut command = Command::cargo_bin("reserve").expect("the binary is built for the test run");
command.env_remove("RESERVE_CONCURRENCY");
command.env_remove("RESERVE_TIMEOUT");
command.env_remove("RESERVE_WIDTH");
command.env_remove("RESERVE_NO_INPUT");
command.env_remove("COLUMNS");
command.env_remove("LINES");
command.env("TERM", "xterm-256color");
command.env("NO_COLOR", "1");
for marker in [
"CI",
"GITHUB_ACTIONS",
"CONTINUOUS_INTEGRATION",
"GITLAB_CI",
"BUILDKITE",
"TEAMCITY_VERSION",
"TF_BUILD",
] {
command.env_remove(marker);
}
for forced in ["FORCE_COLOR", "CLICOLOR", "CLICOLOR_FORCE", "RUST_LOG"] {
command.env_remove(forced);
}
for proxy in ["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"] {
command.env_remove(proxy);
}
let home = home_for_tests();
command.env("HOME", &home);
command.env("XDG_CONFIG_HOME", home.join("config"));
command.env("XDG_CACHE_HOME", home.join("cache"));
command.env("XDG_DATA_HOME", home.join("data"));
command.env("USERPROFILE", &home);
command.env("APPDATA", home.join("config"));
command.env("LOCALAPPDATA", home.join("cache"));
command.current_dir(&home);
command
}
fn home_for_tests() -> std::path::PathBuf {
use std::sync::OnceLock;
static HOME: OnceLock<tempfile::TempDir> = OnceLock::new();
HOME.get_or_init(|| tempfile::tempdir().expect("a temporary home"))
.path()
.to_path_buf()
}
#[test]
fn help_goes_to_stdout_and_exits_zero() {
reserve()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("Check one or more names"))
.stdout(predicate::str::contains("EXAMPLES:"));
}
#[test]
fn version_goes_to_stdout_and_exits_zero() {
reserve()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn every_subcommand_answers_its_own_help() {
for command in ["groups", "extensions", "config", "doctor", "completions"] {
reserve().args([command, "--help"]).assert().success();
}
}
#[test]
fn an_unknown_flag_is_a_usage_error() {
reserve()
.arg("--not-a-real-flag")
.assert()
.code(2)
.stderr(predicate::str::contains("--not-a-real-flag"));
}
#[test]
fn a_missing_name_is_refused_before_any_lookup_starts() {
reserve()
.assert()
.code(2)
.stderr(predicate::str::contains("name.list_empty"));
}
#[test]
fn an_unattended_run_never_waits_for_an_answer() {
for extra in [vec!["--no-input"], vec!["--json"], vec![]] {
let mut command = reserve();
command.args(&extra);
command
.timeout(std::time::Duration::from_secs(20))
.assert()
.code(2)
.stderr(predicate::str::contains("name.list_empty"));
}
reserve()
.env("CI", "true")
.timeout(std::time::Duration::from_secs(20))
.assert()
.code(2)
.stderr(predicate::str::contains("name.list_empty"));
}
#[test]
fn conflicting_flags_are_refused_by_the_parser() {
reserve()
.args(["--no-input", "--interactive", "example"])
.assert()
.code(2);
}
#[test]
fn an_unknown_group_names_the_value_and_carries_its_error_id() {
reserve()
.args(["extensions", "--group", "definitely-not-a-group"])
.assert()
.code(2)
.stderr(predicate::str::contains("group.unknown"));
}
#[test]
fn the_machine_readable_mode_emits_one_json_document_on_stdout() {
let output = reserve()
.args(["groups", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let parsed: serde_json::Value =
serde_json::from_slice(&output).expect("the json mode emits one complete document");
assert!(
parsed.as_array().is_some_and(|rows| !rows.is_empty()),
"the group listing is a non-empty array"
);
}
#[test]
fn a_piped_run_carries_no_escape_codes_and_no_progress_line() {
let output = reserve()
.args(["extensions", "--group", "popular"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains('\u{1b}'),
"piped output must stay parseable, with no styling"
);
assert!(
!output.stderr.contains(&b'\r'),
"the progress line must never render when stderr is not a terminal"
);
}
#[test]
fn the_boolean_environment_fallback_accepts_how_a_script_spells_true() {
for spelling in ["1", "true", "yes", "on"] {
reserve()
.env("RESERVE_NO_INPUT", spelling)
.args(["groups"])
.assert()
.success();
}
}
#[test]
fn the_completion_script_is_written_for_every_shell_offered() {
for shell in ["bash", "elvish", "fish", "powershell", "zsh"] {
reserve()
.args(["completions", shell])
.assert()
.success()
.stdout(predicate::str::contains("reserve"));
}
}
#[test]
fn the_diagnostic_command_reports_in_both_forms() {
reserve().arg("doctor").assert().success();
let output = reserve()
.args(["doctor", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let parsed: serde_json::Value =
serde_json::from_slice(&output).expect("the diagnostic json mode parses");
assert!(parsed.get("version").is_some(), "the build is identified");
}
#[test]
fn the_resolved_settings_are_reported_with_where_each_came_from() {
reserve()
.args(["config", "show"])
.assert()
.success()
.stdout(predicate::str::contains("timeout"))
.stdout(predicate::str::contains("built-in default"));
reserve()
.env("RESERVE_TIMEOUT", "60")
.args(["config", "show"])
.assert()
.success()
.stdout(predicate::str::contains("60"))
.stdout(predicate::str::contains("environment"));
reserve()
.args(["config", "show", "--width", "100"])
.assert()
.success()
.stdout(predicate::str::contains("100"))
.stdout(predicate::str::contains("flag"));
reserve().args(["config", "path"]).assert().success();
}
#[test]
fn the_diagnostic_names_where_the_registry_list_comes_from() {
reserve()
.arg("doctor")
.assert()
.success()
.stdout(predicate::str::contains("Network"))
.stdout(predicate::str::contains("data.iana.org"));
let output = reserve()
.args(["doctor", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let parsed: serde_json::Value =
serde_json::from_slice(&output).expect("the diagnostic json mode parses");
assert!(
parsed.get("network").is_some(),
"the json form carries the network block the help promises"
);
assert!(
parsed.get("paths").is_some(),
"the json form carries the paths the text form prints"
);
}
#[test]
fn append_cannot_start_a_write_on_its_own() {
reserve()
.args(["--append", "example", "--tld", "com"])
.assert()
.code(2)
.stderr(predicate::str::contains("append"));
}
#[test]
fn cautious_composes_with_a_pacing_flag_instead_of_refusing_it() {
reserve()
.args(["extensions", "--group", "popular"])
.env("RESERVE_CONCURRENCY", "8")
.assert()
.success();
}
#[test]
fn a_setting_reports_the_source_that_really_supplied_it() {
reserve()
.env("RESERVE_TIMEOUT", "45")
.args(["config", "show"])
.assert()
.success()
.stdout(predicate::str::contains("45").and(predicate::str::contains("environment")));
reserve()
.env("RESERVE_TIMEOUT", "45")
.args(["--timeout", "7", "config", "show"])
.assert()
.success()
.stdout(predicate::str::contains("7").and(predicate::str::contains("flag")));
}
#[test]
fn an_exported_preference_never_stands_in_for_a_flag() {
reserve()
.env("RESERVE_NO_INPUT", "1")
.args(["--interactive", "groups"])
.assert()
.success();
}
#[test]
fn the_machine_readable_flag_reaches_a_subcommand_from_either_side() {
for argv in [
vec!["--json", "groups"],
vec!["groups", "--json"],
vec!["--json", "doctor"],
vec!["doctor", "--json"],
vec!["--json", "config", "show"],
vec!["config", "show", "--json"],
] {
let output = reserve()
.args(&argv)
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let first = stdout.trim_start().chars().next().unwrap_or(' ');
assert!(
first == '[' || first == '{',
"{argv:?} did not produce JSON, it produced: {}",
stdout.lines().next().unwrap_or_default()
);
}
}
#[test]
fn a_page_past_the_end_never_counts_backwards() {
let output = reserve()
.args(["extensions", "--group", "classic", "--page", "9"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let footer = stdout
.lines()
.find(|line| line.contains("extensions ·"))
.unwrap_or_default();
let range = footer.split_whitespace().next().unwrap_or_default();
let (first, last) = range.split_once('-').unwrap_or(("0", "0"));
let first: usize = first.parse().unwrap_or(0);
let last: usize = last.parse().unwrap_or(0);
assert!(
first <= last,
"the footer read `{range}`, which counts backwards"
);
}
#[test]
fn a_byte_order_mark_does_not_make_the_first_name_look_reshaped() {
let dir = tempfile::tempdir().expect("a temporary directory");
let list = dir.path().join("names.txt");
std::fs::write(&list, "\u{feff}example\n").expect("the list is written");
reserve()
.args(["--names-from"])
.arg(&list)
.args(["extensions", "--group", "classic"])
.assert()
.success()
.stderr(predicate::str::contains("is not a name a registry can hold").not());
}
#[test]
fn a_bad_selection_flag_is_refused_whether_or_not_the_target_carries_a_dot() {
for target in ["shop", "shop.com"] {
reserve()
.args([target, "--group", "definitely-not-a-group", "--no-input"])
.assert()
.code(2)
.stderr(predicate::str::contains("group.unknown"));
}
}
const OFFLINE_FLAGS: &[&[&str]] = &[
&["--group", "popular"],
&["--tld", "com,net"],
&["--exclude", "com"],
&[
"--group",
"everything",
"--search",
"bank",
"--include-restricted",
],
&["--industry", "finance"],
&["--region", "south-asia"],
&["--cctld"],
&["--depth", "second"],
&["--group", "everything", "--depth", "third"],
&["--length", "2-3"],
&["--include-restricted"],
&["--sort", "name"],
&["--sort", "popularity"],
&["--sort", "length"],
&["--sort", "name", "--order", "asc"],
&["--sort", "name", "--order", "desc"],
&["--page", "2"],
&["--page-size", "5"],
&["--all-pages"],
&["--json"],
&["--color", "never"],
&["--color", "always"],
&["--width", "60"],
&["--no-input"],
&["-v"],
&["-q"],
];
#[test]
fn every_offline_flag_is_honoured_by_the_built_binary() {
for flag in OFFLINE_FLAGS {
let mut argv = vec!["extensions"];
argv.extend_from_slice(flag);
let outcome = reserve().args(&argv).output().expect("the binary runs");
assert!(
outcome.status.success(),
"`reserve {}` failed: {}",
argv.join(" "),
String::from_utf8_lossy(&outcome.stderr)
);
assert!(
!outcome.stdout.is_empty(),
"`reserve {}` produced nothing",
argv.join(" ")
);
}
}
#[test]
fn a_flag_before_the_subcommand_and_one_after_it_are_both_honoured() {
let both = reserve()
.args([
"--group",
"everything",
"extensions",
"--cctld",
"--sort",
"name",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let after_only = reserve()
.args([
"extensions",
"--group",
"everything",
"--cctld",
"--sort",
"name",
])
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(
String::from_utf8_lossy(&both),
String::from_utf8_lossy(&after_only),
"a flag before the subcommand must reach it exactly as one after it does"
);
}
#[test]
fn a_flag_given_on_both_sides_of_the_subcommand_lets_the_nearer_one_win() {
let output = reserve()
.args([
"--sort",
"popularity",
"extensions",
"--sort",
"name",
"--group",
"classic",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let rendered = String::from_utf8_lossy(&output);
let first = rendered.lines().nth(1).unwrap_or_default();
assert!(
first.starts_with("biz"),
"the flag nearer the subcommand decides, so this should be alphabetical: {first}"
);
}
#[test]
fn a_directory_that_cannot_be_written_reports_the_io_class() {
let dir = tempfile::tempdir().expect("a temporary directory");
let blocked = dir.path().join("not-a-directory");
std::fs::write(&blocked, "").expect("a plain file sits where the directory would go");
reserve()
.args(["example", "--tld", "com", "--no-input", "--out"])
.arg(blocked.join("results"))
.assert()
.code(5)
.stderr(predicate::str::contains("output.unwritable"));
}
#[test]
fn a_dry_run_asked_for_json_answers_in_json_and_names_the_file_already_there() {
let dir = tempfile::tempdir().expect("a temporary directory");
std::fs::write(dir.path().join("example-unknown.json"), "").expect("an earlier run's file");
let run = reserve()
.args([
"example",
"--tld",
"com,net",
"--dry-run",
"--json",
"--no-input",
"--out",
])
.arg(dir.path())
.assert()
.success();
let body = String::from_utf8(run.get_output().stdout.clone()).expect("utf-8 on stdout");
let plan: serde_json::Value = serde_json::from_str(&body).expect("--json must answer in JSON");
assert_eq!(plan["lookups"], 2);
assert_eq!(plan["names"][0], "example");
assert_eq!(plan["asked_any_registry"], false);
assert_eq!(
plan["writes"].as_array().expect("a list of files").len(),
3,
"all three result files are named"
);
assert_eq!(
plan["already_there"]
.as_array()
.expect("a list of files")
.len(),
1,
"the file left by the earlier run is called out"
);
}
#[test]
fn every_subcommand_that_prints_data_answers_in_json_both_ways() {
for argv in [
vec!["groups", "--json"],
vec!["--json", "groups"],
vec!["extensions", "--json"],
vec!["--json", "extensions"],
vec!["config", "show", "--json"],
vec!["--json", "config", "show"],
vec!["config", "path", "--json"],
vec!["--json", "config", "path"],
] {
let run = reserve().args(&argv).assert().success();
let body = run.get_output().stdout.clone();
serde_json::from_slice::<serde_json::Value>(&body)
.unwrap_or_else(|_| panic!("{argv:?} must answer in JSON"));
}
}
#[test]
fn an_extension_list_read_from_standard_input_reaches_the_run() {
let planned = reserve()
.args([
"example",
"--tlds-from",
"-",
"--no-input",
"--dry-run",
"--json",
])
.write_stdin("io\ndev\n# a comment\n\n")
.assert()
.success();
let plan: serde_json::Value =
serde_json::from_slice(&planned.get_output().stdout).expect("a JSON plan");
let extensions: Vec<&str> = plan["extensions"]
.as_array()
.expect("a list")
.iter()
.map(|value| value.as_str().expect("a string"))
.collect();
assert_eq!(extensions, ["io", "dev"], "the list is read once and used");
assert_eq!(plan["lookups"], 2);
}
#[test]
fn a_flag_typed_after_a_subcommand_beats_the_same_flag_typed_before_it() {
let run = reserve()
.args([
"--sort",
"name",
"extensions",
"--json",
"--all-pages",
"--sort",
"popularity",
])
.assert()
.success();
let listed: serde_json::Value =
serde_json::from_slice(&run.get_output().stdout).expect("a JSON array");
let first = listed[0]["suffix"].as_str().expect("a suffix");
assert_eq!(
first, "com",
"the sort typed after the subcommand decides the order"
);
}
#[test]
fn a_directory_that_cannot_be_written_is_refused_before_the_sweep_even_with_append() {
let dir = tempfile::tempdir().expect("a temporary directory");
let blocked = dir.path().join("not-a-directory");
std::fs::write(&blocked, "").expect("a plain file sits where the directory would go");
for extra in [vec!["--append"], Vec::new()] {
let mut argv = vec![
"example",
"--tld",
"com",
"--no-input",
"--registry-servers",
"/nonexistent/servers.json",
];
argv.extend(extra.iter().copied());
argv.push("--out");
reserve()
.args(&argv)
.arg(blocked.join("results"))
.assert()
.code(5)
.stderr(predicate::str::contains("output.unwritable"))
.stderr(predicate::str::contains("file.unreadable").not());
}
}
#[cfg(unix)]
#[test]
fn a_directory_that_exists_but_cannot_be_written_is_refused_before_the_sweep() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temporary directory");
let out = dir.path().join("readonly");
std::fs::create_dir(&out).expect("the directory is made");
std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o555))
.expect("it is made read-only");
let run = reserve()
.args([
"example",
"--tld",
"com",
"--no-input",
"--registry-servers",
"/nonexistent/servers.json",
"--out",
])
.arg(&out)
.assert()
.code(5);
let complaint = String::from_utf8(run.get_output().stderr.clone()).expect("utf-8");
assert!(
complaint.contains("output.unwritable"),
"a read-only directory has to be refused before the engine is built: {complaint}"
);
let _ = std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o755));
}
#[test]
fn a_results_directory_the_tool_makes_is_for_its_owner_only() {
let dir = tempfile::tempdir().expect("a temporary directory");
let out = dir.path().join("results");
reserve()
.args([
"example",
"--tld",
"com",
"--no-input",
"--dry-run",
"--out",
])
.arg(&out)
.assert()
.success();
assert!(!out.exists(), "a dry run must not create anything");
reserve()
.args([
"example",
"--tld",
"com",
"--no-input",
"--registry-servers",
"/nonexistent/servers.json",
"--out",
])
.arg(&out)
.assert()
.code(2);
assert!(
out.is_dir(),
"the real run proves the directory before it reaches the network"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&out)
.expect("it is there")
.permissions()
.mode();
assert_eq!(
mode & 0o077,
0,
"the file names alone say what was searched for, so nobody else may read them"
);
}
}
#[test]
fn something_that_is_not_a_file_in_the_way_is_not_offered_a_merge() {
let dir = tempfile::tempdir().expect("a temporary directory");
std::fs::create_dir(dir.path().join("example-available.txt")).expect("a directory in the way");
reserve()
.args(["example", "--tld", "com", "--no-input", "--out"])
.arg(dir.path())
.assert()
.code(5)
.stderr(predicate::str::contains("not a plain file"))
.stderr(predicate::str::contains("--append").not());
}
#[test]
fn the_screen_format_and_the_file_format_can_be_chosen_apart() {
let dir = tempfile::tempdir().expect("a temporary directory");
let planned = reserve()
.args([
"example",
"--tld",
"com",
"--no-input",
"--dry-run",
"--save-json",
"--out",
])
.arg(dir.path())
.assert()
.success();
let text = String::from_utf8(planned.get_output().stdout.clone()).expect("utf-8");
assert!(
text.contains("Dry run"),
"--save-json must leave the screen as a table"
);
assert!(
text.contains("example-available.json"),
"and it must decide the file format: {text}"
);
reserve()
.args(["example", "--tld", "com", "--no-input", "--save-json"])
.assert()
.code(2)
.stderr(predicate::str::contains("--out"));
}
#[test]
fn a_shell_window_size_does_not_reach_a_pipe() {
let narrow = reserve()
.env("COLUMNS", "30")
.args(["extensions", "--page-size", "3"])
.assert()
.success();
let body = String::from_utf8(narrow.get_output().stdout.clone()).expect("utf-8");
assert!(
!body.contains('\u{2026}'),
"a value was cut for a reader that is not a terminal:\n{body}"
);
let asked = reserve()
.args(["extensions", "--page-size", "3", "--width", "30"])
.assert()
.success();
let body = String::from_utf8(asked.get_output().stdout.clone()).expect("utf-8");
for line in body.lines() {
assert!(
line.chars().count() <= 30,
"--width is what the reader asked for, so it still applies: {line}"
);
}
}
#[test]
fn a_name_or_a_group_key_quoted_back_carries_no_escape_and_no_reversing_mark() {
for argv in [
vec!["--group", "te\u{202e}ch\u{1b}[2K", "example", "--no-input"],
vec!["--tld", "te\u{202e}ch\u{1b}[2K", "example", "--no-input"],
vec!["\u{202e}bad\u{1b}[2Kname", "--tld", "com", "--no-input"],
vec![
"--industry",
"no\u{202e}pe\u{1b}[2K",
"example",
"--no-input",
],
vec!["--region", "no\u{202e}pe\u{1b}[2K", "example", "--no-input"],
] {
let run = reserve().args(&argv).assert().failure();
let complaint = String::from_utf8(run.get_output().stderr.clone()).expect("utf-8");
assert!(
!complaint.contains('\u{202e}'),
"{argv:?} echoed a mark that reverses the line: {complaint:?}"
);
assert!(
!complaint.contains('\u{1b}'),
"{argv:?} echoed an escape the terminal would act on: {complaint:?}"
);
}
}
#[test]
fn a_setting_the_run_would_clamp_is_reported_clamped() {
let shown = reserve()
.args(["--cautious", "--rate", "500", "-c", "100", "config", "show"])
.assert()
.success();
let text = String::from_utf8(shown.get_output().stdout.clone()).expect("utf-8");
for (key, wrong) in [("rate", "500"), ("concurrency", "100")] {
let line = text
.lines()
.find(|line| line.trim_start().starts_with(key))
.unwrap_or_else(|| panic!("`{key}` has to be reported"));
assert!(
!line.contains(wrong),
"`{key}` reports {wrong}, which the cautious setting would lower: {line}"
);
}
let planned = reserve()
.args([
"example",
"--tld",
"com",
"--no-input",
"--cautious",
"--rate",
"500",
"--dry-run",
"--json",
])
.assert()
.success();
let plan: serde_json::Value =
serde_json::from_slice(&planned.get_output().stdout).expect("a JSON plan");
assert_ne!(
plan["per_second"].as_u64(),
Some(500),
"the dry run must not promise a pace the sweep will not keep"
);
}
#[test]
fn a_length_no_label_can_have_is_refused_where_it_was_typed() {
for bad in ["0", "0-0", "999", "64", "1-999"] {
reserve()
.args(["example", "--tld", "com", "--no-input", "--length", bad])
.assert()
.code(2)
.stderr(predicate::str::contains("filter.invalid"));
}
for good in ["2", "3", "2-4", "2-"] {
reserve()
.args(["example", "--no-input", "--dry-run", "--length", good])
.assert()
.success();
}
for spaced in ["-4", "-3"] {
reserve()
.args(["example", "--no-input", "--dry-run", "--length", spaced])
.assert()
.success();
}
reserve()
.args(["example", "--no-input", "--dry-run", "--length=-4"])
.assert()
.success();
}
#[test]
fn a_number_outside_its_range_is_refused_where_it_was_typed() {
for (flag, value) in [
("--width", "0"),
("--rate", "99999"),
("--timeout", "0"),
("--page", "0"),
("--concurrency", "5000"),
("--per-registry", "0"),
] {
reserve()
.args([
"example",
"--tld",
"com",
"--no-input",
"--dry-run",
flag,
value,
])
.assert()
.code(2)
.stderr(predicate::str::contains("is not in"));
}
}
#[test]
fn a_bad_environment_value_names_the_variable_that_carries_it() {
for (name, value, wanted) in [
("RESERVE_TIMEOUT", "abc", "not a whole number"),
("RESERVE_WIDTH", "0", "--width takes 1 to 10000"),
(
"RESERVE_CONCURRENCY",
"5000",
"--concurrency takes 1 to 1024",
),
] {
let run = reserve().env(name, value).args(["groups"]).assert().code(2);
let complaint = String::from_utf8(run.get_output().stderr.clone()).expect("utf-8");
assert!(
complaint.contains(name) && complaint.contains(wanted),
"{name}={value} has to name itself: {complaint}"
);
}
}
#[test]
fn a_piped_run_carries_no_colour_unless_it_was_asked_for() {
let plain = reserve()
.env_remove("NO_COLOR")
.args(["extensions", "--page-size", "2"])
.assert()
.success();
let body = String::from_utf8(plain.get_output().stdout.clone()).expect("utf-8");
assert!(
!body.contains('\u{1b}'),
"output that is not going to a terminal must carry no escape codes: {body:?}"
);
for forced in ["CLICOLOR_FORCE", "FORCE_COLOR"] {
let coloured = reserve()
.env_remove("NO_COLOR")
.env(forced, "1")
.args(["extensions", "--page-size", "2"])
.assert()
.success();
let body = String::from_utf8(coloured.get_output().stdout.clone()).expect("utf-8");
assert!(
body.contains('\u{1b}'),
"{forced} asks for colour through a pipe, so it has to arrive"
);
}
let asked = reserve()
.env_remove("NO_COLOR")
.args(["extensions", "--page-size", "2", "--color", "always"])
.assert()
.success();
let body = String::from_utf8(asked.get_output().stdout.clone()).expect("utf-8");
assert!(body.contains('\u{1b}'), "--color always means always");
}
#[test]
fn browsing_the_catalog_reaches_every_extension_not_just_the_popular_ones() {
let listed = reserve()
.args(["extensions", "--json", "--all-pages"])
.assert()
.success();
let all: serde_json::Value =
serde_json::from_slice(&listed.get_output().stdout).expect("a JSON array");
let all = all.as_array().expect("a JSON array").len();
let popular = reserve()
.args(["extensions", "--json", "--all-pages", "--group", "popular"])
.assert()
.success();
let popular: serde_json::Value =
serde_json::from_slice(&popular.get_output().stdout).expect("a JSON array");
let popular = popular.as_array().expect("a JSON array").len();
assert!(
all > popular * 4,
"browsing must show the catalog ({all}), not the short popular list ({popular})"
);
for needle in ["bd", "bangladesh", "south-asia"] {
reserve()
.args(["extensions", "--search", needle])
.assert()
.success()
.stdout(predicate::str::contains("Bangladesh"));
}
}
#[test]
fn a_sweep_with_nothing_named_still_checks_only_the_popular_list() {
let listed = reserve()
.args(["extensions", "--json", "--all-pages", "--group", "popular"])
.assert()
.success();
let popular: serde_json::Value =
serde_json::from_slice(&listed.get_output().stdout).expect("a JSON array");
let popular = popular.as_array().expect("a JSON array").len();
let planned = reserve()
.args(["example", "--dry-run", "--json", "--no-input"])
.assert()
.success();
let plan: serde_json::Value =
serde_json::from_slice(&planned.get_output().stdout).expect("a JSON plan");
assert_eq!(
plan["lookups"].as_u64(),
Some(popular as u64),
"a sweep must not widen to the whole catalog"
);
}
#[test]
fn a_result_file_already_there_is_refused_before_any_lookup_is_spent() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("example-available.txt"), "").expect("the earlier run's file");
reserve()
.args([
"example",
"--tld",
"com",
"--no-input",
"--registry-servers",
"/nonexistent/servers.json",
"--out",
])
.arg(dir.path())
.assert()
.code(5)
.stderr(predicate::str::contains("output.unwritable"))
.stderr(predicate::str::contains("already exists"))
.stderr(predicate::str::contains("file.unreadable").not());
}
#[test]
fn both_list_flags_cannot_read_standard_input_at_once() {
reserve()
.args(["--names-from", "-", "--tlds-from", "-", "--no-input"])
.write_stdin("example\n")
.assert()
.code(2)
.stderr(predicate::str::contains("standard input"));
}
#[test]
fn every_declared_conflict_is_refused_by_the_built_binary() {
for argv in [
vec!["example", "--full", "--details"],
vec!["example", "--full", "--responder"],
vec!["example", "--full", "--dns"],
vec!["example", "--full", "--where-to-buy"],
vec!["extensions", "--all-pages", "--page", "2"],
vec!["example", "--save", "--out", "results"],
vec!["example", "--no-input", "--interactive"],
vec!["example", "--append"],
] {
reserve().args(&argv).assert().code(2);
}
}
#[test]
fn the_manual_answers_for_the_tool_and_for_one_command() {
reserve()
.args(["man"])
.assert()
.success()
.stdout(predicate::str::contains(".TH"));
reserve()
.args(["man", "extensions"])
.assert()
.success()
.stdout(predicate::str::contains(".TH reserve-extensions"));
reserve()
.args(["man", "config", "show"])
.assert()
.success()
.stdout(predicate::str::contains(".TH reserve-config-show"));
reserve()
.args(["man", "config", "nope"])
.assert()
.code(2)
.stderr(predicate::str::contains("show, path"));
reserve()
.args(["man"])
.assert()
.success()
.stdout(predicate::str::contains("RESERVE_PLAIN"))
.stdout(predicate::str::contains("EXIT CODES"));
reserve()
.args(["man", "not-a-command"])
.assert()
.code(2)
.stderr(predicate::str::contains("is not a usable command"))
.stderr(predicate::str::contains("code:"))
.stderr(predicate::str::contains("groups, extensions"));
reserve().args(["man", "--help"]).assert().success();
}
#[test]
fn a_dry_run_says_what_would_happen_and_asks_no_registry() {
let dir = tempfile::tempdir().expect("a temporary directory");
reserve()
.args([
"example",
"--tld",
"com,net,org",
"--dry-run",
"--no-input",
"--out",
])
.arg(dir.path())
.assert()
.success()
.stdout(
predicate::str::contains("Dry run")
.and(predicate::str::contains("lookups"))
.and(predicate::str::contains("3"))
.and(predicate::str::contains("pacing"))
.and(predicate::str::contains("example-available.txt"))
.and(predicate::str::contains("example-taken.txt"))
.and(predicate::str::contains("example-unknown.txt"))
.and(predicate::str::contains("no registry was asked")),
);
assert!(
std::fs::read_dir(dir.path())
.expect("the directory reads")
.next()
.is_none(),
"a dry run must not write a single file"
);
}
#[test]
fn a_dry_run_needs_no_network_even_for_a_whole_group() {
reserve()
.args([
"example",
"--group",
"everything",
"--dry-run",
"--no-input",
])
.timeout(std::time::Duration::from_secs(20))
.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}