use std::path::{Path, PathBuf};
use std::process::{Command, Output};
const BIN: &str = env!("CARGO_BIN_EXE_hop");
struct Run {
code: i32,
stdout: String,
stderr: String,
}
impl From<Output> for Run {
fn from(output: Output) -> Self {
Run {
code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
}
}
fn hop(config: &Path, args: &[&str]) -> Run {
Command::new(BIN)
.arg("-C")
.arg(config)
.args(args)
.env("SPARCLI_NO_TTY", "1")
.env("NO_COLOR", "1")
.env_remove("HOP_CONFIG")
.output()
.expect("the hop binary runs")
.into()
}
fn hop_bare(args: &[&str]) -> Run {
Command::new(BIN)
.args(args)
.env("SPARCLI_NO_TTY", "1")
.env("NO_COLOR", "1")
.env_remove("HOP_CONFIG")
.output()
.expect("the hop binary runs")
.into()
}
fn config_with(name: &str, body: &str) -> (PathBuf, PathBuf) {
let dir = std::env::temp_dir().join(format!("hop-cli-test-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir is creatable");
let path = dir.join("config.toml");
std::fs::write(&path, body).expect("config is writable");
(path, dir)
}
#[test]
fn help_and_version_succeed_on_stdout() {
let help = hop_bare(&["--help"]);
assert_eq!(help.code, 0);
assert!(help.stderr.is_empty(), "help must not touch stderr");
assert!(help.stdout.contains("Usage:"));
assert!(help.stdout.contains("Examples:"));
assert!(help.stdout.contains("Exit codes:"));
let version = hop_bare(&["--version"]);
assert_eq!(version.code, 0);
assert!(version.stdout.starts_with("hop "));
assert!(version.stderr.is_empty());
}
#[test]
fn an_unknown_option_is_a_usage_error_that_leaves_stdout_empty() {
let run = hop_bare(&["--definitely-not-an-option"]);
assert_eq!(run.code, 2, "usage errors exit 2");
assert!(run.stdout.is_empty(), "stdout carries payload only");
assert!(
run.stderr.contains("--help"),
"point at the help, not dump it"
);
}
#[test]
fn an_unknown_slug_reports_a_greppable_error_line() {
let (config, _dir) = config_with("unknown-slug", "");
let run = hop(&config, &["no_such_slug_here"]);
assert_eq!(run.code, 2);
assert!(run.stdout.is_empty());
assert!(
run.stderr.starts_with("hop: error: "),
"a boxed panel cannot be grepped; got {:?}",
run.stderr
);
assert!(run.stderr.contains("no_such_slug_here"));
}
#[test]
fn an_empty_store_keeps_stdout_clean_and_puts_the_hint_on_stderr() {
let (config, _dir) = config_with("empty-list", "");
let run = hop(&config, &["list"]);
assert_eq!(run.code, 0);
assert!(run.stdout.is_empty(), "got stdout: {:?}", run.stdout);
assert!(run.stderr.contains("No entries yet"));
}
#[test]
fn list_json_writes_nothing_but_the_json_document_to_stdout() {
let (config, _dir) = config_with(
"json",
"[[repos]]\npath = \"/code/hop\"\nslug = \"hop\"\n",
);
let run = hop(&config, &["list", "--json"]);
assert_eq!(run.code, 0);
assert!(run.stderr.is_empty(), "JSON runs stay silent on stderr");
let parsed: serde_json::Value =
serde_json::from_str(&run.stdout).expect("stdout is valid JSON");
assert_eq!(parsed[0]["slug"], "hop");
assert_eq!(parsed[0]["path"], "/code/hop");
}
#[test]
fn doctor_exits_non_zero_and_lists_the_problems_on_stdout() {
let (config, _dir) = config_with(
"doctor",
"[[repos]]\npath = \"/nowhere/at/all\"\nslug = \"gone\"\n",
);
let run = hop(&config, &["doctor"]);
assert_eq!(run.code, 1, "a failed check must not report success");
assert!(run.stdout.contains("/nowhere/at/all"));
assert!(run.stderr.starts_with("hop: error: "));
let (clean_config, _clean_dir) = config_with("doctor-clean", "");
let clean = hop(&clean_config, &["doctor"]);
assert_eq!(clean.code, 0);
}
#[test]
fn scan_without_a_terminal_fails_with_usage_and_names_the_flag_to_use() {
let (config, dir) = config_with("scan-no-tty", "");
let repo = dir.join("a-repo");
std::fs::create_dir_all(repo.join(".git")).expect("fake repo is creatable");
let run = hop(&config, &["scan", dir.to_str().unwrap()]);
assert_eq!(run.code, 2, "no terminal to prompt on is a usage error");
assert!(run.stdout.is_empty());
assert!(
run.stderr.contains("--yes"),
"the message must name the option that supplies the answer; got {:?}",
run.stderr
);
}
#[test]
fn scan_yes_imports_without_a_terminal_and_keeps_the_report_off_stdout() {
let (config, dir) = config_with("scan-yes", "");
std::fs::create_dir_all(dir.join("a-repo").join(".git"))
.expect("fake repo is creatable");
let run = hop(&config, &["scan", dir.to_str().unwrap(), "--yes"]);
assert_eq!(run.code, 0);
assert!(run.stdout.is_empty(), "the confirmation is not payload");
assert!(run.stderr.contains("Added"));
let listed = hop(&config, &["list", "--json"]);
let parsed: serde_json::Value =
serde_json::from_str(&listed.stdout).expect("valid JSON");
assert_eq!(parsed.as_array().expect("an array").len(), 1);
}
#[test]
fn scan_dry_run_prints_the_paths_as_payload_and_changes_nothing() {
let (config, dir) = config_with("scan-dry", "");
std::fs::create_dir_all(dir.join("a-repo").join(".git"))
.expect("fake repo is creatable");
let run = hop(&config, &["scan", dir.to_str().unwrap(), "--dry-run"]);
assert_eq!(run.code, 0);
assert!(run.stdout.contains("a-repo"), "the paths are the payload");
let listed = hop(&config, &["list", "--json"]);
let parsed: serde_json::Value =
serde_json::from_str(&listed.stdout).expect("valid JSON");
assert!(
parsed.as_array().expect("an array").is_empty(),
"--dry-run must not write anything"
);
}
#[test]
fn show_config_prints_the_path_and_still_answers_to_its_old_name() {
let (config, _dir) = config_with("show-config", "");
let run = hop(&config, &["show-config"]);
assert_eq!(run.code, 0);
assert_eq!(run.stdout.trim(), config.display().to_string());
assert!(run.stderr.is_empty());
let legacy = hop(&config, &["config-path"]);
assert_eq!(legacy.code, 0);
assert_eq!(legacy.stdout, run.stdout);
}
#[test]
fn completions_and_man_are_machine_readable_and_unstyled() {
let completions = hop_bare(&["completions", "zsh"]);
assert_eq!(completions.code, 0);
assert!(completions.stdout.contains("#compdef hop"));
assert!(!completions.stdout.contains('\u{1b}'), "no escape codes");
let man = hop_bare(&["man"]);
assert_eq!(man.code, 0);
assert!(man.stdout.contains(".TH"), "roff output");
}
#[test]
fn the_readme_documents_every_command_and_option() {
let readme = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"),
)
.expect("the README is readable");
let help = hop_bare(&["--help"]).stdout;
for command in [
"add",
"scan",
"doctor",
"list",
"show-config",
"completions",
"man",
] {
assert!(
help.contains(command),
"`{command}` is missing from the help output"
);
assert!(
readme.contains(&format!("hop {command}")),
"`hop {command}` is missing from the README's command list"
);
}
for option in [
"--config",
"--fetch",
"--no-fetch",
"--cached",
"--demo",
"--verbose",
"--quiet",
"--no-color",
"--json",
"--yes",
"--dry-run",
"--depth",
"--nested",
] {
assert!(
readme.contains(option),
"`{option}` is missing from the README"
);
}
assert!(
readme.contains("| 130 |"),
"README lacks the exit-code table"
);
assert!(help.contains("130"), "help lacks the exit-code list");
}