mod contract;
use assert_cmd::Command;
use clap::CommandFactory;
use proc_cli::cli::Cli;
use proc_cli::core::filters::strip_verbatim_prefix;
use serde_json::Value;
#[cfg(unix)]
use std::io::Write;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
const MISSING_PID: &str = "99999999";
fn proc_cmd() -> Command {
let mut cmd = Command::cargo_bin("proc").unwrap();
cmd.env("NO_COLOR", "1");
cmd
}
fn run_json(args: &[&str]) -> (Value, i32) {
let output = proc_cmd().args(args).output().unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let json: Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout is not a single JSON document ({e}):\n{stdout}"));
let code = output.status.code().unwrap();
contract::check(&json, code, args);
(json, code)
}
fn run_text(args: &[&str]) -> (String, String, i32) {
let output = proc_cmd().args(args).output().unwrap();
(
String::from_utf8(output.stdout).unwrap(),
String::from_utf8(output.stderr).unwrap(),
output.status.code().unwrap(),
)
}
fn listener_path() -> PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop();
path.pop();
path.push("examples");
path.push(format!("listener{}", std::env::consts::EXE_SUFFIX));
assert!(path.exists(), "build the example first: {}", path.display());
path
}
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn nobody() -> String {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
format!("nobody-{}-{n}x", std::process::id())
}
fn free_port() -> u16 {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let start = 20_000 + std::process::id() as usize % 10_000;
loop {
let port = start + NEXT.fetch_add(1, Ordering::SeqCst);
assert!(port < 32_768, "no unused port below the ephemeral range");
if std::net::TcpListener::bind(("0.0.0.0", port as u16)).is_ok() {
return port as u16;
}
}
}
fn spawn_copy(copy: &Path, args: &[&str], dir: &Path) -> Child {
let mut attempts = 0;
loop {
let spawned = std::process::Command::new(copy)
.args(args)
.current_dir(dir)
.stdout(Stdio::piped())
.stdin(Stdio::null())
.spawn();
match spawned {
Ok(child) => return child,
Err(e) if attempts < 100 && is_text_file_busy(&e) => {
attempts += 1;
std::thread::sleep(std::time::Duration::from_millis(20));
}
Err(e) => panic!("spawn the listener copy: {e}"),
}
}
}
#[cfg(unix)]
fn is_text_file_busy(e: &std::io::Error) -> bool {
e.raw_os_error() == Some(libc::ETXTBSY)
}
#[cfg(not(unix))]
fn is_text_file_busy(_: &std::io::Error) -> bool {
false
}
fn same_place(a: &str, b: &str) -> bool {
let canon = |p: &str| {
let path = std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p));
strip_verbatim_prefix(&path.to_string_lossy()).to_string()
};
canon(a) == canon(b)
}
struct Owned {
child: Child,
name: String,
port: u16,
_tmp: tempfile::TempDir,
dir: PathBuf,
raw_dir: PathBuf,
}
impl Owned {
fn listener() -> Self {
Self::listener_with_args(&[])
}
fn listener_with_args(args: &[&str]) -> Self {
let tmp = tempfile::tempdir().unwrap();
let raw_dir = tmp.path().to_path_buf();
let dir = raw_dir.canonicalize().unwrap();
let dir = PathBuf::from(strip_verbatim_prefix(&dir.to_string_lossy()));
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let name = format!("pci{}n{n}x", std::process::id());
let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
std::fs::copy(listener_path(), raw_dir.join(&file)).expect("copy the listener example");
let mut child = spawn_copy(&dir.join(&file), args, &raw_dir);
let mut line = String::new();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let read = stdout.read_line(&mut line);
let port = read
.ok()
.and_then(|_| line.trim().strip_prefix("PORT "))
.and_then(|p| p.parse().ok());
let Some(port) = port else {
let _ = child.kill();
let _ = child.wait();
panic!("the listener printed {line:?} instead of `PORT <n>`");
};
Owned {
child,
name,
port,
_tmp: tmp,
dir,
raw_dir,
}
}
fn pid(&self) -> u32 {
self.child.id()
}
fn pid_arg(&self) -> String {
self.pid().to_string()
}
fn port_arg(&self) -> String {
format!(":{}", self.port)
}
fn dir(&self) -> String {
self.dir.to_string_lossy().into_owned()
}
fn raw_dir(&self) -> String {
self.raw_dir.to_string_lossy().into_owned()
}
fn file_name(&self) -> String {
format!("{}{}", self.name, std::env::consts::EXE_SUFFIX)
}
fn exe(&self) -> String {
self.dir
.join(self.file_name())
.to_string_lossy()
.into_owned()
}
fn what_proc_saw(&self) -> String {
let out = proc_cmd()
.args(["info", &self.pid_arg(), "--json"])
.output()
.unwrap();
format!(
"proc info {} --json (raw dir {}, canonical dir {}):\n{}{}",
self.pid(),
self.raw_dir.display(),
self.dir.display(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
}
impl Drop for Owned {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[cfg(unix)]
struct Parented {
sh: Child,
listener_pid: u32,
port: u16,
_tmp: tempfile::TempDir,
}
#[cfg(unix)]
impl Parented {
fn start() -> Self {
let tmp = tempfile::tempdir().unwrap();
let listener = listener_path();
let listener = listener.to_str().unwrap();
assert!(!listener.contains('\''), "cannot quote {listener}");
let mut sh = std::process::Command::new("sh")
.current_dir(tmp.path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("spawn sh");
let mut stdin = sh.stdin.take().unwrap();
writeln!(stdin, "'{listener}' & echo CHILD $!; wait").expect("write the script to sh");
drop(stdin);
let stdout = BufReader::new(sh.stdout.take().unwrap());
let (mut listener_pid, mut port) = (None, None);
for line in stdout.lines().take(2) {
let line = line.unwrap_or_default();
if let Some(pid) = line.strip_prefix("CHILD ") {
listener_pid = pid.trim().parse().ok();
} else if let Some(p) = line.strip_prefix("PORT ") {
port = p.trim().parse().ok();
}
}
let (Some(listener_pid), Some(port)) = (listener_pid, port) else {
let _ = sh.kill();
let _ = sh.wait();
panic!("sh printed no `CHILD <pid>` and `PORT <n>`");
};
Parented {
sh,
listener_pid,
port,
_tmp: tmp,
}
}
fn sh_pid(&self) -> u32 {
self.sh.id()
}
}
#[cfg(unix)]
impl Drop for Parented {
fn drop(&mut self) {
unsafe {
libc::kill(self.listener_pid as libc::pid_t, libc::SIGKILL);
}
let _ = self.sh.kill();
let _ = self.sh.wait();
}
}
fn line_with<'a>(text: &'a str, needle: &str) -> &'a str {
text.lines()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("no line holds {needle:?}:\n{text}"))
}
fn target_line(text: &str) -> &str {
text.lines()
.find(|l| l.ends_with("← target"))
.unwrap_or_else(|| panic!("no target line in:\n{text}"))
}
fn line_after<'a>(text: &'a str, line: &str) -> &'a str {
let mut lines = text.lines();
lines.find(|l| *l == line);
lines.next().expect("a line after the target")
}
fn indent(line: &str) -> usize {
line.len() - line.trim_start_matches(' ').len()
}
#[test]
fn why_a_port_names_the_port_and_marks_the_process_holding_it() {
let owned = Owned::listener();
let header = format!("✓ Port {} (TCP):\n\n", owned.port);
let (mut out, mut stderr, mut code) = (String::new(), String::new(), -1);
for _ in 0..50 {
(out, stderr, code) = run_text(&["why", &owned.port_arg()]);
let has_dir = out
.lines()
.zip(out.lines().skip(1))
.any(|(t, d)| t.ends_with("← target") && d.trim_start().starts_with("dir: "));
if out.starts_with(&header) && has_dir {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert_eq!(code, 0, "{out}{stderr}\n{}", owned.what_proc_saw());
assert_eq!(stderr, "");
assert!(out.starts_with(&header), "{out}\n{}", owned.what_proc_saw());
let target = target_line(&out);
assert!(
target.contains(&format!("{} [{}]", owned.file_name(), owned.pid())),
"{target}\n{}",
owned.what_proc_saw()
);
assert!(target.contains("└── "), "{target}");
let after_pid = target.split_once(&format!("[{}]", owned.pid())).unwrap().1;
assert!(
after_pid.trim_end_matches("← target").trim().is_empty()
|| after_pid.trim_start().starts_with("up "),
"{target}"
);
let dir_line = line_after(&out, target);
assert_eq!(indent(dir_line), indent(target) + 4, "{out}");
let dir = dir_line.trim().strip_prefix("dir: ").unwrap();
assert!(
same_place(dir, &owned.dir()),
"{dir}\n{}",
owned.what_proc_saw()
);
}
#[test]
fn why_a_pid_names_the_process_and_not_a_port() {
let owned = Owned::listener();
let (out, _, code) = run_text(&["why", &owned.pid_arg()]);
assert_eq!(code, 0);
assert!(
out.starts_with(&format!("✓ Ancestry for '{}':\n\n", owned.pid())),
"{out}"
);
assert!(!out.contains("Port "), "{out}");
assert!(target_line(&out).contains(&owned.file_name()));
}
#[test]
fn why_a_name_traces_every_process_with_that_name() {
let owned = Owned::listener();
let (out, _, code) = run_text(&["why", &owned.name]);
assert_eq!(code, 0);
assert!(
out.starts_with(&format!("✓ Ancestry for '{}':\n\n", owned.name)),
"{out}"
);
assert!(target_line(&out).contains(&format!("[{}]", owned.pid())));
}
#[test]
fn why_a_name_is_the_whole_name_and_cmd_widens_it_to_the_command_line() {
let word = nobody();
let owned = Owned::listener_with_args(&[&word]);
let part = &owned.name[..owned.name.len() - 1];
for text in [part, word.as_str()] {
let (out, err, code) = run_text(&["why", text]);
assert_eq!(code, 2, "{text}: {out}{err}");
}
let (out, err, _) = run_text(&["why", &word]);
assert!(
format!("{out}{err}").contains(&format!(
"1 process has '{word}' in its command line (add --cmd to match those)"
)),
"{out}{err}"
);
for text in [part, word.as_str()] {
let (out, _, code) = run_text(&["why", text, "--cmd"]);
assert_eq!(code, 0, "{text}: {out}");
assert!(
target_line(&out).contains(&format!("[{}]", owned.pid())),
"{out}"
);
}
}
#[test]
fn why_draws_the_test_process_above_the_listener_it_started() {
let owned = Owned::listener();
let (out, _, _) = run_text(&["why", &owned.pid_arg()]);
let me = format!("[{}]", std::process::id());
let parent = line_with(&out, &me);
let target = target_line(&out);
assert!(
out.find(parent).unwrap() < out.find(target).unwrap(),
"{out}"
);
assert_eq!(indent(target), indent(parent) + 4, "{out}");
assert!(target.trim_start().starts_with("└── "), "{target}");
assert!(!parent.contains("← target"), "{parent}");
}
#[cfg(unix)]
#[test]
fn why_draws_the_shell_between_the_test_and_the_listener() {
let parented = Parented::start();
let (out, _, code) = run_text(&["why", &parented.listener_pid.to_string()]);
assert_eq!(code, 0, "{out}");
let sh = line_with(&out, &format!("[{}]", parented.sh_pid()));
let target = target_line(&out);
let me = line_with(&out, &format!("[{}]", std::process::id()));
assert!(
target.contains(&format!("[{}]", parented.listener_pid)),
"{target}"
);
assert_eq!(indent(sh), indent(me) + 4, "{out}");
assert_eq!(indent(target), indent(sh) + 4, "{out}");
assert!(out.find(me).unwrap() < out.find(sh).unwrap(), "{out}");
assert!(out.find(sh).unwrap() < out.find(target).unwrap(), "{out}");
}
#[cfg(unix)]
#[test]
fn why_a_port_traces_a_listener_started_by_a_shell() {
let parented = Parented::start();
let (json, code) = run_json(&["why", &format!(":{}", parented.port), "--json"]);
assert_eq!(code, 0, "{json}");
let result = &json["results"][0];
assert_eq!(result["process"]["pid"], parented.listener_pid);
let ancestry = result["ancestry"].as_array().unwrap();
let pids: Vec<u64> = ancestry
.iter()
.map(|a| a["pid"].as_u64().unwrap())
.collect();
let n = pids.len();
assert!(n >= 3, "{json}");
assert_eq!(
pids[n - 3..],
[
u64::from(std::process::id()),
u64::from(parented.sh_pid()),
u64::from(parented.listener_pid)
],
"{json}"
);
let targets: Vec<bool> = ancestry
.iter()
.map(|a| a["is_target"].as_bool().unwrap())
.collect();
assert_eq!(targets.iter().filter(|t| **t).count(), 1, "{json}");
assert!(targets[n - 1], "{json}");
}
#[cfg(unix)]
#[test]
fn why_marks_a_stopped_process_with_its_own_symbol() {
let owned = Owned::listener();
unsafe {
libc::kill(owned.pid() as libc::pid_t, libc::SIGSTOP);
}
let mut out = String::new();
for _ in 0..50 {
out = run_text(&["why", &owned.pid_arg()]).0;
if target_line(&out).contains('◐') {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(target_line(&out).contains('◐'), "{out}");
}
#[test]
fn why_several_targets_prints_each_under_its_own_header() {
let owned = Owned::listener();
let targets = format!("{},{}", owned.pid(), owned.port_arg());
let (out, _, code) = run_text(&["why", &targets]);
assert_eq!(code, 0);
let ancestry = out
.find(&format!("✓ Ancestry for '{}':", owned.pid()))
.expect("the PID's header");
let port = out
.find(&format!("✓ Port {} (TCP):", owned.port))
.expect("the port's header");
assert!(ancestry < port, "{out}");
let between = &out[ancestry..port];
assert!(between.ends_with("\n\n"), "{between:?}");
assert_eq!(
out.lines().filter(|l| l.ends_with("← target")).count(),
2,
"{out}"
);
}
#[test]
fn why_takes_several_targets_as_separate_arguments() {
let owned = Owned::listener();
let (pid, port) = (owned.pid_arg(), owned.port_arg());
let (out, _, code) = run_text(&["why", &pid, &port]);
assert_eq!(code, 0);
let ancestry = out
.find(&format!("✓ Ancestry for '{pid}':"))
.expect("the PID's header");
let port_header = out
.find(&format!("✓ Port {} (TCP):", owned.port))
.expect("the port's header");
assert!(ancestry < port_header, "{out}");
let (separate, code) = run_json(&["why", &pid, &port, MISSING_PID, "--json"]);
assert_eq!(code, 0);
assert_eq!(separate["count"], 2, "{separate}");
assert_eq!(separate["results"][0]["target"], pid);
assert_eq!(separate["results"][1]["target"], port);
let comma = format!("{pid},{port},{MISSING_PID}");
let (joined, _) = run_json(&["why", &comma, "--json"]);
assert_eq!(joined["count"], 2, "{joined}");
let tail = format!("{port},{MISSING_PID}");
let (mixed, _) = run_json(&["why", &pid, &tail, "--json"]);
assert_eq!(mixed["count"], 2, "{mixed}");
assert_eq!(mixed["results"][1]["target"], port);
}
#[test]
fn why_with_a_target_that_splits_into_nothing_is_invalid_input() {
let (json, code) = run_json(&["why", ",", ",,", "--json"]);
assert_eq!(code, 4, "{json}");
assert_eq!(json["reason"], "invalid_input", "{json}");
let error = json["error"].as_str().unwrap();
assert!(error.contains("a target can't be empty"), "{error}");
}
#[test]
fn why_warns_about_a_target_that_is_gone_and_still_answers_for_the_rest() {
let owned = Owned::listener();
let targets = format!("{MISSING_PID},{}", owned.pid());
let (out, stderr, code) = run_text(&["why", &targets]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr.lines().count(), 1, "{stderr}");
assert!(stderr.starts_with("note: comma-separated targets are deprecated"));
assert!(
out.contains(&format!("⚠ No process found matching '{MISSING_PID}'")),
"{out}"
);
assert!(target_line(&out).contains(&format!("[{}]", owned.pid())));
}
#[test]
fn why_a_target_nothing_matches_warns_and_exits_not_found() {
let (out, stderr, code) = run_text(&["why", MISSING_PID]);
assert_eq!(code, 2);
assert_eq!(
out,
format!(
"⚠ No process found matching '{MISSING_PID}'\n Try: proc list to list all processes\n"
)
);
assert_eq!(stderr, "");
}
#[test]
fn why_a_port_nobody_listens_on_warns_and_exits_not_found() {
let port = free_port();
let (out, stderr, code) = run_text(&["why", &format!(":{port}")]);
assert_eq!(code, 2);
assert!(
out.contains(&format!("⚠ No process listening on port {port}")),
"{out}"
);
assert_eq!(stderr, "");
}
#[test]
fn why_with_every_target_missing_warns_for_each_in_the_order_given() {
let port = free_port();
let (out, _, code) = run_text(&["why", &format!(":{port},{MISSING_PID}")]);
assert_eq!(code, 2);
let port_warning = out
.find("No process listening on port")
.expect("port warning");
let pid_warning = out.find("No process found matching").expect("pid warning");
assert!(port_warning < pid_warning, "{out}");
}
#[test]
fn why_json_a_port_carries_the_port_and_the_listener() {
let owned = Owned::listener();
let (mut json, mut code) = (Value::Null, -1);
for _ in 0..50 {
(json, code) = run_json(&["why", &owned.port_arg(), "--json"]);
let result = &json["results"][0];
if code == 0
&& result["port"] == owned.port
&& result["ports"][0]["port"] == owned.port
&& result["process"]["cwd"].is_string()
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert_eq!(code, 0, "{json}\n{}", owned.what_proc_saw());
assert_eq!(json["action"], "why");
assert_eq!(json["success"], true);
assert_eq!(json["count"], 1);
let result = &json["results"][0];
assert_eq!(result["target"], owned.port_arg());
assert_eq!(result["port"], owned.port);
assert_eq!(result["protocol"], "tcp");
assert_eq!(result["process"]["pid"], owned.pid());
assert_eq!(result["process"]["name"], owned.file_name());
assert!(
same_place(result["process"]["cwd"].as_str().unwrap(), &owned.dir()),
"{json}\n{}",
owned.what_proc_saw()
);
let ports: Vec<u64> = result["ports"]
.as_array()
.unwrap()
.iter()
.map(|p| p["port"].as_u64().unwrap())
.collect();
assert_eq!(ports, vec![u64::from(owned.port)], "{json}");
let ancestry = result["ancestry"].as_array().unwrap();
let last = ancestry.last().unwrap();
assert_eq!(last["pid"], owned.pid());
assert_eq!(last["is_target"], true);
let parent = &ancestry[ancestry.len() - 2];
assert_eq!(parent["pid"], std::process::id());
assert_eq!(parent["is_target"], false);
}
#[test]
fn why_json_a_pid_has_no_port_fields() {
let owned = Owned::listener();
let (json, code) = run_json(&["why", &owned.pid_arg(), "--json"]);
assert_eq!(code, 0);
let result = &json["results"][0];
assert!(result.get("port").is_none(), "{json}");
assert!(result.get("protocol").is_none(), "{json}");
assert_eq!(result["ports"][0]["port"], owned.port, "{json}");
}
#[test]
fn why_json_with_one_target_gone_answers_for_the_rest() {
let owned = Owned::listener();
let targets = format!("{},{MISSING_PID}", owned.pid());
let (json, code) = run_json(&["why", &targets, "--json"]);
assert_eq!(code, 0);
assert_eq!(json["success"], true);
assert_eq!(json["count"], 1);
assert_eq!(json["results"][0]["process"]["pid"], owned.pid());
}
#[test]
fn why_json_with_every_target_missing_is_the_first_error() {
let port = free_port();
let (json, code) = run_json(&["why", &format!("{MISSING_PID},:{port}"), "--json"]);
assert_eq!(code, 2);
assert_eq!(json["action"], "why");
assert_eq!(json["success"], false);
assert_eq!(json["exit_code"], 2);
assert_eq!(
json["error"],
format!(
"No process found matching '{MISSING_PID}'\n Try: proc list to list all processes"
)
);
assert!(json.get("needs_confirmation").is_none(), "{json}");
}
#[test]
fn info_prints_what_is_known_about_the_process() {
let owned = Owned::listener();
let (json, _) = run_json(&["info", &owned.pid_arg(), "--json"]);
let known = &json["processes"][0];
let (out, stderr, code) = run_text(&["info", &owned.pid_arg()]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr, "");
let field = |label: &str| {
line_with(&out, label)
.trim()
.strip_prefix(label)
.unwrap()
.trim()
.to_string()
};
assert_eq!(
out.lines().next().unwrap(),
format!("✓ Process {}", owned.pid())
);
assert_eq!(field("Name:"), owned.file_name());
assert_eq!(field("PID:"), owned.pid_arg());
assert!(
same_place(&field("Directory:"), &owned.dir()),
"{out}\n{}",
owned.what_proc_saw()
);
assert!(same_place(&field("Path:"), &owned.exe()), "{out}");
assert_eq!(field("Parent PID:"), std::process::id().to_string());
if let Some(user) = known["user"].as_str() {
assert_eq!(field("User:"), user);
}
assert!(field("CPU:").ends_with('%'), "{out}");
assert!(!field("Memory:").is_empty());
assert!(!field("Uptime:").is_empty());
assert!(!field("Status:").is_empty());
assert!(!out.contains("Command:"), "{out}");
assert!(out.ends_with("\n\n"), "{out:?}");
}
#[test]
fn info_verbose_adds_the_command_line() {
let owned = Owned::listener();
for flag in ["-v", "--verbose"] {
let (out, _, code) = run_text(&["info", &owned.pid_arg(), flag]);
assert_eq!(code, 0);
let command = line_with(&out, "Command:");
assert!(command.contains(&owned.file_name()), "{flag}: {command}");
}
}
#[test]
fn info_a_port_and_its_pid_show_the_process_once() {
let owned = Owned::listener();
let (out, _, code) = run_text(&["info", &owned.port_arg(), &owned.pid_arg()]);
assert_eq!(code, 0);
assert_eq!(out.matches("✓ Process").count(), 1, "{out}");
let (json, _) = run_json(&["info", &owned.port_arg(), &owned.pid_arg(), "--json"]);
assert_eq!(json["found_count"], 1);
assert_eq!(json["not_found_count"], 0);
}
#[test]
fn info_warns_about_the_targets_it_did_not_find_and_still_shows_the_rest() {
let owned = Owned::listener();
let gone = free_port();
let targets = format!("{},{MISSING_PID},:{gone}", owned.pid());
let (out, stderr, code) = run_text(&["info", &targets]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr.lines().count(), 1, "{stderr}");
assert!(stderr.starts_with("note: comma-separated targets are deprecated"));
assert_eq!(out.matches("✓ Process").count(), 1, "{out}");
assert!(
out.trim_end()
.ends_with(&format!("⚠ Not found: {MISSING_PID}, :{gone}")),
"{out}"
);
let (json, code) = run_json(&["info", &targets, "--json"]);
assert_eq!(code, 0);
assert_eq!(json["success"], true);
assert_eq!(json["found_count"], 1);
assert_eq!(json["not_found_count"], 2);
assert_eq!(
json["not_found"],
serde_json::json!([MISSING_PID, format!(":{gone}")])
);
}
#[test]
fn info_takes_several_targets_as_separate_arguments() {
let first = Owned::listener();
let second = Owned::listener();
let gone = free_port();
let (a, b) = (first.pid_arg(), second.pid_arg());
let missing_port = format!(":{gone}");
let (out, stderr, code) = run_text(&["info", &a, &b, MISSING_PID, &missing_port]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(out.matches("✓ Process").count(), 2, "{out}");
assert!(
out.trim_end()
.ends_with(&format!("⚠ Not found: {MISSING_PID}, :{gone}")),
"{out}"
);
let (json, code) = run_json(&["info", &a, &b, MISSING_PID, &missing_port, "--json"]);
assert_eq!(code, 0);
assert_eq!(json["found_count"], 2, "{json}");
assert_eq!(json["not_found_count"], 2, "{json}");
assert_eq!(
json["not_found"],
serde_json::json!([MISSING_PID, missing_port]),
"{json}"
);
let tail = format!("{b},{MISSING_PID}");
let (mixed, _) = run_json(&["info", &a, &tail, "--json"]);
assert_eq!(mixed["found_count"], 2, "{mixed}");
assert_eq!(mixed["not_found"], serde_json::json!([MISSING_PID]));
}
#[test]
fn info_shows_a_pid_given_twice_once() {
let owned = Owned::listener();
let pid = owned.pid_arg();
let both = format!("{pid},{pid}");
for args in [
["info", &pid, &pid, "--json"],
["info", &pid, &both, "--json"],
] {
let (json, code) = run_json(&args);
assert_eq!(code, 0, "{json}");
assert_eq!(json["found_count"], 1, "{json}");
}
}
#[test]
fn info_with_nothing_found_for_several_targets_names_each_one_in_the_list() {
let (out, _, code) = run_text(&["info", MISSING_PID, "99999998"]);
assert_eq!(code, 2);
assert_eq!(out, format!("⚠ Not found: {MISSING_PID}, 99999998\n"));
let (json, code) = run_json(&["info", MISSING_PID, "99999998", "--json"]);
assert_eq!(code, 2);
assert_eq!(
json["not_found"],
serde_json::json!([MISSING_PID, "99999998"])
);
}
#[test]
fn info_with_nothing_found_warns_and_exits_not_found() {
let (out, stderr, code) = run_text(&["info", MISSING_PID]);
assert_eq!(code, 2);
assert_eq!(out, format!("⚠ Not found: {MISSING_PID}\n"));
assert_eq!(stderr, "");
let (json, code) = run_json(&["info", MISSING_PID, "--json"]);
assert_eq!(code, 2);
assert_eq!(json["success"], false);
assert_eq!(json["found_count"], 0);
assert_eq!(json["not_found"], serde_json::json!([MISSING_PID]));
}
#[test]
fn info_in_keeps_a_process_running_in_the_directory_and_drops_one_that_is_not() {
let owned = Owned::listener();
let (out, _, code) = run_text(&["info", &owned.pid_arg(), "--in", &owned.raw_dir()]);
assert_eq!(code, 0, "{out}\n{}", owned.what_proc_saw());
assert_eq!(out.matches("✓ Process").count(), 1, "{out}");
let elsewhere = tempfile::tempdir().unwrap();
let (out, _, code) = run_text(&[
"info",
&owned.pid_arg(),
"-i",
elsewhere.path().to_str().unwrap(),
]);
assert_eq!(code, 2, "{out}");
assert!(!out.contains("✓ Process"), "{out}");
}
#[test]
fn info_name_is_the_whole_name_and_cmd_widens_it_to_the_command_line() {
let word = nobody();
let owned = Owned::listener_with_args(&[&word]);
let part = &owned.name[..owned.name.len() - 1];
for name in [owned.name.clone(), owned.name.to_uppercase()] {
let (json, code) = run_json(&["info", &name, "--json"]);
assert_eq!(code, 0, "{json}");
assert_eq!(json["processes"][0]["pid"], owned.pid(), "{json}");
}
for text in [part, word.as_str()] {
let (json, code) = run_json(&["info", text, "--json"]);
assert_eq!(code, 2, "{json}");
assert_eq!(json["not_found"][0], text, "{json}");
}
let (out, err, code) = run_text(&["info", &word]);
assert_eq!(code, 2, "{out}{err}");
assert!(
format!("{out}{err}").contains(&format!(
"Not found: {word}\n 1 process has '{word}' in its command line (add --cmd to match those)"
)),
"{out}{err}"
);
for text in [part, word.as_str()] {
let (json, code) = run_json(&["info", text, "--cmd", "--json"]);
assert_eq!(code, 0, "{json}");
assert!(
json["processes"]
.as_array()
.unwrap()
.iter()
.any(|p| p["pid"] == owned.pid()),
"{json}"
);
}
}
#[test]
fn info_by_keeps_a_process_with_that_name_and_drops_one_without() {
let owned = Owned::listener();
let (out, _, code) = run_text(&["info", &owned.pid_arg(), "--by", &owned.name]);
assert_eq!(code, 0);
assert_eq!(out.matches("✓ Process").count(), 1, "{out}");
let part = &owned.name[..owned.name.len() - 1];
let (_, _, code) = run_text(&["info", &owned.pid_arg(), "--by", part]);
assert_eq!(code, 2);
let (out, _, code) = run_text(&["info", &owned.pid_arg(), "--by", part, "--cmd"]);
assert_eq!(code, 0, "{out}");
assert_eq!(out.matches("✓ Process").count(), 1, "{out}");
let (json, code) = run_json(&["info", &owned.pid_arg(), "-b", &nobody(), "--json"]);
assert_eq!(code, 2);
assert_eq!(json["found_count"], 0);
assert_eq!(json["not_found_count"], 0);
}
#[test]
fn proc_alone_prints_the_orientation_screen() {
let (out, stderr, code) = run_text(&[]);
assert_eq!(code, 0);
assert_eq!(stderr, "");
assert!(
out.starts_with("proc — semantic process management\n"),
"{out}"
);
assert!(out.contains("A target is the same everywhere: :port, PID, or name."));
for example in [
"proc on :3000",
"proc in .",
"proc by node",
"proc kill :3000",
] {
assert!(out.contains(example), "{example} missing:\n{out}");
}
assert!(out.contains("Common: on · in · by · ports · info · kill · stop · free · why"));
assert!(out.contains("proc --help"));
assert!(out.contains("https://proc-cli.com"));
}
#[test]
fn the_orientation_screen_counts_the_commands_there_are() {
let commands = Cli::command()
.get_subcommands()
.filter(|c| !c.is_hide_set() && c.get_name() != "help")
.count();
let (out, _, _) = run_text(&[]);
assert!(
out.contains(&format!("all {commands} commands")),
"{commands} commands, but the screen says:\n{out}"
);
}
#[test]
fn the_commands_the_orientation_screen_shows_are_real_commands() {
let (out, _, _) = run_text(&[]);
let names: Vec<String> = Cli::command()
.get_subcommands()
.map(|c| c.get_name().to_string())
.collect();
let common = line_with(&out, "Common:");
for word in common.trim_start_matches(" Common:").split('·') {
let word = word.trim();
assert!(names.iter().any(|n| n == word), "{word} is not a command");
}
}
#[test]
fn help_and_version_print_and_exit_cleanly() {
let (out, stderr, code) = run_text(&["--help"]);
assert_eq!(code, 0);
assert_eq!(stderr, "");
assert!(
out.contains("Commands:") && out.contains("Examples:"),
"{out}"
);
let (out, _, code) = run_text(&["--version"]);
assert_eq!(code, 0);
let line = out.lines().next().unwrap_or("");
let version = line.strip_prefix("proc ").unwrap_or(line);
assert!(
version == env!("CARGO_PKG_VERSION")
|| version.starts_with(&format!("{}-dev (", env!("CARGO_PKG_VERSION"))),
"{out}"
);
assert_eq!(version, proc_cli::VERSION);
}
const FOOTER_FACTS: [&str; 9] = [
"Targets:",
":3000 node Several: separate arguments",
"The process name, the file name of its executable or of argv[0],",
"ignoring case (like pkill -x); 'node' is not 'Claude Helper'",
"Any part of the name or the full command line (like pkill -f)",
"Not a name: use --cmd, or proc for <file>",
"Not expanded by proc, but by your shell if unquoted: use --cmd",
"Deprecated, to be removed; prints a note on stderr",
"put it after the targets, or write --in=DIR",
];
#[test]
fn the_help_ends_with_labelled_sections() {
let (out, _, code) = run_text(&["--help"]);
assert_eq!(code, 0);
for fact in FOOTER_FACTS {
assert!(out.contains(fact), "{fact:?} is not in\n{out}");
}
for header in ["Targets:", "Names:", "Notes:", "More:"] {
assert!(
out.lines().any(|l| l.starts_with(header)),
"no {header} section in\n{out}"
);
}
assert!(out
.trim_end()
.ends_with("More: https://github.com/yazeed/proc-cli"));
let footer = &out[out.find("\nTargets:").unwrap()..];
for line in footer.lines() {
assert!(line.chars().count() <= 80, "{line:?} is wider than 80");
}
assert!(!out.contains('\u{1b}'), "{out:?}");
let (short, _, code) = run_text(&["-h"]);
assert_eq!(code, 0);
assert!(short.contains("Targets:\n :3000"), "{short}");
assert!(short.contains("More: proc --help for examples"), "{short}");
assert!(!short.contains("Names:"), "{short}");
}
#[test]
fn the_footer_headers_use_clap_header_style_and_only_when_colour_is_on() {
let command = Cli::command();
let header = command.get_styles().get_header();
let literal = command.get_styles().get_literal();
assert_eq!(
format!("{:?}", command.get_styles()),
format!("{:?}", proc_cli::cli::STYLES)
);
let forced = Command::cargo_bin("proc")
.unwrap()
.env_remove("NO_COLOR")
.env("CLICOLOR_FORCE", "1")
.arg("--help")
.output()
.unwrap();
let out = String::from_utf8(forced.stdout).unwrap();
for title in ["Usage:", "Targets:", "Names:", "Notes:", "More:"] {
assert!(
out.contains(&format!("{header}{title}{header:#}")),
"{title} is not drawn in clap's header style:\n{out:?}"
);
}
assert!(
out.contains(&format!("{literal}--cmd{literal:#}")),
"{out:?}"
);
let plain = Command::cargo_bin("proc")
.unwrap()
.env("NO_COLOR", "1")
.env("CLICOLOR_FORCE", "1")
.arg("--help")
.output()
.unwrap();
assert!(!String::from_utf8(plain.stdout).unwrap().contains('\u{1b}'));
}
#[test]
fn the_man_page_and_completions_have_no_colour_codes() {
for args in [
&["manpage"][..],
&["completions", "bash"][..],
&["completions", "zsh"][..],
&["completions", "fish"][..],
] {
let output = Command::cargo_bin("proc")
.unwrap()
.env_remove("NO_COLOR")
.env("CLICOLOR_FORCE", "1")
.args(args)
.output()
.unwrap();
let out = String::from_utf8(output.stdout).unwrap();
assert!(!out.contains('\u{1b}'), "{args:?} has an escape code");
assert!(!out.is_empty(), "{args:?}");
}
}
#[test]
fn completions_print_a_script_for_each_shell() {
for (shell, marker) in [
("bash", "complete -F _proc"),
("zsh", "#compdef proc"),
("fish", "complete -c proc"),
("powershell", "Register-ArgumentCompleter"),
("elvish", "edit:completion"),
] {
let (out, stderr, code) = run_text(&["completions", shell]);
assert_eq!(code, 0, "{shell}: {stderr}");
assert!(out.contains(marker), "{shell}: no {marker:?} in\n{out}");
assert!(out.contains("stuck"), "{shell}: a command is missing");
}
}
#[test]
fn completions_for_an_unknown_shell_is_a_usage_error() {
let (out, stderr, code) = run_text(&["completions", "nushell"]);
assert_eq!(code, 4);
assert_eq!(out, "");
assert!(
stderr.starts_with("error: invalid value 'nushell'"),
"{stderr}"
);
assert!(stderr.contains("bash"), "the possible values: {stderr}");
assert!(!stderr.contains("did you mean"), "{stderr}");
}
#[test]
fn manpage_prints_the_man_page() {
let (out, stderr, code) = run_text(&["manpage"]);
assert_eq!(code, 0, "{stderr}");
assert!(out.contains(".TH proc 1"), "{out}");
assert!(out.contains(".SH NAME"), "{out}");
assert!(out.contains("proc\\-why"), "the why command: {out}");
}
#[test]
fn an_unknown_command_is_a_usage_error() {
let (out, stderr, code) = run_text(&["lisst"]);
assert_eq!(code, 4);
assert_eq!(out, "");
assert!(
stderr.starts_with("error: unrecognized subcommand 'lisst'"),
"{stderr}"
);
assert!(!stderr.contains("a similar"), "{stderr}");
assert!(
stderr.ends_with("\n tip: did you mean 'list' or 'info'?\n"),
"{stderr}"
);
let usage = format!("Usage: proc{} [COMMAND]", std::env::consts::EXE_SUFFIX);
assert!(stderr.contains(&usage), "{stderr}");
}
#[test]
fn an_unknown_command_with_json_is_a_json_error_naming_the_input() {
let (json, code) = run_json(&["lisst", "--json"]);
assert_eq!(code, 4);
assert_eq!(json["action"], "lisst");
assert_eq!(json["success"], false);
assert_eq!(json["exit_code"], 4);
assert_eq!(
json["error"],
"unrecognized subcommand 'lisst'\n Try: did you mean 'list' or 'info'?"
);
assert!(json.get("needs_confirmation").is_none(), "{json}");
}
#[test]
fn a_command_alias_is_named_by_the_command_in_json_errors() {
let (json, code) = run_json(&["ps", "--bogus", "--json"]);
assert_eq!(code, 4);
assert_eq!(json["action"], "list");
}
#[test]
fn a_misspelled_flag_gets_a_suggestion() {
let (out, stderr, code) = run_text(&["list", "--verbse"]);
assert_eq!(code, 4);
assert_eq!(out, "");
assert!(
stderr.starts_with("error: unexpected argument '--verbse' found"),
"{stderr}"
);
assert!(
stderr.ends_with("\n tip: did you mean '--verbose'?\n"),
"{stderr}"
);
assert_eq!(stderr.matches("tip:").count(), 1, "{stderr}");
let (_, stderr, code) = run_text(&["list", "--cpu"]);
assert_eq!(code, 4);
assert!(
stderr.ends_with("\n tip: did you mean '--min-cpu'?\n"),
"{stderr}"
);
}
#[test]
fn a_misspelled_flag_with_json_puts_the_suggestion_in_the_error() {
let (json, code) = run_json(&["list", "--cpu", "--json"]);
assert_eq!(code, 4);
assert_eq!(json["action"], "list");
assert_eq!(json["success"], false);
assert_eq!(json["exit_code"], 4);
assert_eq!(
json["error"],
"unexpected argument '--cpu' found\n Try: did you mean '--min-cpu'?"
);
}
#[test]
fn a_flag_like_nothing_gets_no_suggestion() {
let (_, stderr, code) = run_text(&["list", "--qqqqqqqqqq"]);
assert_eq!(code, 4);
assert!(
stderr.starts_with("error: unexpected argument '--qqqqqqqqqq' found"),
"{stderr}"
);
assert!(!stderr.contains("did you mean"), "{stderr}");
let (json, _) = run_json(&["list", "--qqqqqqqqqq", "--json"]);
assert_eq!(json["error"], "unexpected argument '--qqqqqqqqqq' found");
}
#[test]
fn a_usage_error_before_any_command_is_named_proc_in_json() {
let (json, code) = run_json(&["--bogus", "--json"]);
assert_eq!(code, 4);
assert_eq!(json["action"], "proc");
assert_eq!(json["error"], "unexpected argument '--bogus' found");
}
#[test]
fn a_target_that_is_not_a_port_is_a_usage_error() {
let (out, stderr, code) = run_text(&["why", ":70000"]);
assert_eq!(code, 4);
assert_eq!(out, "");
assert!(
stderr.contains("':70000' is not a port; ports go from :1 to :65535"),
"{stderr}"
);
let (json, code) = run_json(&["info", ":0", "--json"]);
assert_eq!(code, 4);
assert_eq!(json["action"], "info");
assert!(
json["error"]
.as_str()
.unwrap()
.contains("':0' is not a port"),
"{json}"
);
}
#[test]
fn json_is_asked_for_by_the_long_flag_the_short_flag_or_a_cluster() {
for args in [
&["info", "--bogus", "--json"][..],
&["info", "--bogus", "-j"],
&["info", "--bogus", "-vj"],
&["kill", "--bogus", "-yj"],
] {
let (json, code) = run_json(args);
assert_eq!(code, 4, "{args:?}");
assert_eq!(json["success"], false, "{args:?}");
}
}
#[test]
fn json_is_not_asked_for_by_a_flag_that_only_looks_like_it() {
for args in [
&["info", "-bj", "--bogus"][..],
&["info", "-v", "--bogus"],
&["info", "--bogus", "--", "--json"],
&["info", "--bogus", "--jsonx"],
] {
let (out, stderr, code) = run_text(args);
assert_eq!(code, 4, "{args:?}");
assert_eq!(out, "", "{args:?}");
assert!(stderr.starts_with("error: "), "{args:?}: {stderr}");
}
}
#[test]
fn a_cluster_ending_in_a_value_flag_does_not_ask_for_json() {
for letter in ["b", "d", "i", "l", "n", "p", "r", "s", "S", "t"] {
let cluster = format!("-{letter}j");
let (out, stderr, code) = run_text(&["info", &cluster, "--bogus"]);
assert_eq!(code, 4, "{cluster}");
assert_eq!(out, "", "{cluster}: asked for JSON");
assert!(stderr.starts_with("error: "), "{cluster}: {stderr}");
}
}
#[test]
fn a_command_that_fails_with_json_prints_the_error_as_json() {
let (json, code) = run_json(&["why", &format!(":{}", free_port()), "--json"]);
assert_eq!(code, 2);
assert_eq!(json["action"], "why");
assert_eq!(json["success"], false);
assert_eq!(json["exit_code"], 2);
assert!(
json["error"]
.as_str()
.unwrap()
.starts_with("No process listening on port "),
"{json}"
);
assert!(json.get("needs_confirmation").is_none(), "{json}");
assert!(json.get("processes").is_none(), "{json}");
}
#[test]
fn a_command_that_fails_without_json_prints_the_error_on_stderr() {
let mut owned = Owned::listener();
let (out, stderr, code) = run_text(&["stop", &owned.pid_arg()]);
assert_eq!(code, 4);
assert_eq!(out, "");
assert!(
stderr.starts_with("stop asks before changing processes"),
"{stderr}"
);
assert!(!stderr.contains("Invalid input"), "{stderr}");
assert!(
owned.child.try_wait().unwrap().is_none(),
"the listener was stopped"
);
}
#[test]
fn a_missing_yes_with_json_lists_the_processes_that_would_change() {
let mut owned = Owned::listener();
let (json, code) = run_json(&["stop", &owned.pid_arg(), "--json"]);
assert_eq!(code, 4);
assert_eq!(json["action"], "stop");
assert_eq!(json["success"], false);
assert_eq!(json["exit_code"], 4);
assert_eq!(json["needs_confirmation"], true);
assert_eq!(json["processes"][0]["pid"], owned.pid());
assert!(json["error"].as_str().unwrap().contains("--yes"), "{json}");
assert_eq!(json["reason"], "needs_confirmation", "{json}");
assert!(
json["error"]
.as_str()
.unwrap()
.starts_with("stop changes processes"),
"{json}"
);
assert!(
owned.child.try_wait().unwrap().is_none(),
"the listener was stopped"
);
}
#[test]
fn a_failure_the_command_already_reported_prints_nothing_more() {
let (out, stderr, code) = run_text(&["info", MISSING_PID]);
assert_eq!(code, 2);
assert!(!out.is_empty());
assert_eq!(stderr, "");
}