use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;
use std::process::Command;
use crate::collect::environment::NEVER_INHERITED;
use crate::model::types::Unreadable;
pub type Env = BTreeMap<String, String>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureKind {
Auth,
Unavailable,
Gone,
Busy,
NotInstalled,
Unstartable,
InstalledUnstartable,
Parse,
Unsupported,
UnknownFlag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunFailure {
pub kind: FailureKind,
pub program: String,
pub detail: String,
pub unreadable: Option<Unreadable>,
}
const REFUSAL: [&str; 2] = ["access denied", "error 1045"];
const NO_ANSWER: [&str; 4] = [
"unreachable",
"dial tcp",
"connection refused",
"i/o timeout",
];
const CANNOT_RUN: &str = "not yet supported";
const NOT_KNOWN: [&str; 3] = [
"error: unknown flag",
"error: unknown shorthand flag",
"error: unknown command",
];
const BD: &str = "bd";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UnderThatName {
Something,
Nothing,
Unestablished,
}
impl UnderThatName {
fn or(self, next: Self) -> Self {
match (self, next) {
(Self::Something, _) | (_, Self::Something) => Self::Something,
(Self::Unestablished, _) | (_, Self::Unestablished) => Self::Unestablished,
(Self::Nothing, Self::Nothing) => Self::Nothing,
}
}
}
fn installed(program: &str, cwd: Option<&Path>, env: &Env) -> UnderThatName {
let named = Path::new(program);
let where_the_child_looked =
|at: &Path| cwd.map_or_else(|| at.to_path_buf(), |directory| directory.join(at));
if program.contains(std::path::MAIN_SEPARATOR) {
return under_that_name(&where_the_child_looked(named));
}
env.get(PATH)
.map(std::ffi::OsString::from)
.or_else(|| std::env::var_os(PATH))
.map_or(UnderThatName::Nothing, |path| {
std::env::split_paths(&path)
.map(|at| under_that_name(&where_the_child_looked(&at).join(named)))
.fold(UnderThatName::Nothing, UnderThatName::or)
})
}
pub fn found_on_path(program: &str, cwd: Option<&Path>) -> bool {
installed(program, cwd, &Env::new()) == UnderThatName::Something
}
fn under_that_name(path: &Path) -> UnderThatName {
match std::fs::symlink_metadata(path) {
Ok(_) => UnderThatName::Something,
Err(absent)
if absent.kind() == std::io::ErrorKind::NotFound
|| absent.kind() == std::io::ErrorKind::NotADirectory =>
{
UnderThatName::Nothing
}
Err(_) => UnderThatName::Unestablished,
}
}
fn the_child_entered(cwd: Option<&Path>) -> bool {
cwd.is_none_or(|directory| {
!directory.as_os_str().is_empty()
&& under_that_name(&directory.join(std::path::Component::CurDir))
== UnderThatName::Something
})
}
fn ours_to_report(cause: &std::io::Error, cwd: Option<&Path>) -> bool {
cause.raw_os_error().is_none() || !the_child_entered(cwd)
}
const PATH: &str = "PATH";
const NO_SUCH_PANE: &str = "agent_not_found";
const PANE_BUSY: &str = "agent_not_idle";
impl RunFailure {
pub fn not_installed(program: &str, cause: impl fmt::Display) -> Self {
Self {
kind: FailureKind::NotInstalled,
program: program.to_string(),
detail: format!("{program} is not installed: {cause}"),
unreadable: None,
}
}
pub fn unstartable(program: &str, cause: impl fmt::Display) -> Self {
Self::could_not_be_started(FailureKind::Unstartable, program, cause)
}
fn could_not_be_started(kind: FailureKind, program: &str, cause: impl fmt::Display) -> Self {
Self {
kind,
program: program.to_string(),
detail: format!("{program} could not be started: {cause}"),
unreadable: None,
}
}
fn could_not_start(
program: &str,
cwd: Option<&Path>,
env: &Env,
cause: &std::io::Error,
) -> Self {
match installed(program, cwd, env) {
_ if ours_to_report(cause, cwd) => Self::unstartable(program, cause),
UnderThatName::Something => {
Self::could_not_be_started(FailureKind::InstalledUnstartable, program, cause)
}
UnderThatName::Nothing => Self::not_installed(program, cause),
UnderThatName::Unestablished => Self::unstartable(program, cause),
}
}
pub fn parse(program: &str, cause: impl fmt::Display) -> Self {
Self {
kind: FailureKind::Parse,
program: program.to_string(),
detail: format!("{program} returned output bdi cannot read: {cause}"),
unreadable: Some(Unreadable {
read: String::new(),
cause: cause.to_string(),
}),
}
}
pub fn reading(mut self, read: &str) -> Self {
if let Some(unreadable) = self.unreadable.as_mut() {
unreadable.read = read.to_string();
}
self
}
fn from_exit(program: &str, code: Option<i32>, stderr: &str) -> Self {
let said = stderr.to_lowercase();
let (kind, detail) = if REFUSAL.iter().any(|phrase| said.contains(phrase)) {
(
FailureKind::Auth,
format!("{program} was refused the tracker's credential"),
)
} else if NO_ANSWER.iter().any(|phrase| said.contains(phrase)) {
(
FailureKind::Unavailable,
format!("{program} could not reach the tracker"),
)
} else if said.contains(CANNOT_RUN) {
(
FailureKind::Unsupported,
format!("{program} cannot run that against this tracker"),
)
} else if program == BD && NOT_KNOWN.iter().any(|phrase| said.contains(phrase)) {
(
FailureKind::UnknownFlag,
format!("{program} does not know a flag bdi uses"),
)
} else if said.contains(NO_SUCH_PANE) {
(
FailureKind::Gone,
format!("{program} no longer has that pane"),
)
} else if said.contains(PANE_BUSY) {
(
FailureKind::Busy,
format!("{program} cannot read that pane while it is busy"),
)
} else {
let detail = match code {
Some(code) => format!("{program} exited {code} for a reason bdi cannot place"),
None => format!("{program} was killed by a signal"),
};
(FailureKind::Unavailable, detail)
};
Self {
kind,
program: program.to_string(),
detail,
unreadable: None,
}
}
}
impl fmt::Display for RunFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.detail)
}
}
impl std::error::Error for RunFailure {}
pub trait Runner: Sync {
fn run(
&self,
program: &str,
args: &[&str],
cwd: Option<&Path>,
env: &Env,
) -> Result<String, RunFailure>;
}
pub struct RealRunner;
impl Runner for RealRunner {
fn run(
&self,
program: &str,
args: &[&str],
cwd: Option<&Path>,
env: &Env,
) -> Result<String, RunFailure> {
let mut cmd = Command::new(program);
cmd.args(args);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
for inherited in NEVER_INHERITED {
cmd.env_remove(inherited);
}
cmd.envs(env);
let out = cmd
.output()
.map_err(|e| RunFailure::could_not_start(program, cwd, env, &e))?;
if !out.status.success() {
return Err(RunFailure::from_exit(
program,
out.status.code(),
&String::from_utf8_lossy(&out.stderr),
));
}
String::from_utf8(out.stdout).map_err(|e| RunFailure::parse(program, e))
}
}
#[cfg(test)]
pub mod testing {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Call {
pub argv: String,
pub cwd: Option<PathBuf>,
pub env: Env,
}
#[derive(Default)]
pub struct FakeRunner {
responses: HashMap<String, Result<String, RunFailure>>,
calls: Mutex<Vec<Call>>,
}
impl FakeRunner {
pub fn with(mut self, argv: &str, out: &str) -> Self {
self.responses.insert(argv.to_string(), Ok(out.to_string()));
self
}
pub fn merging(mut self, argv: &str, rows: &str) -> Self {
let standing = match self.responses.get(argv) {
Some(Ok(out)) => out.clone(),
_ => return self.with(argv, rows),
};
let joined = format!(
"[{},{}]",
standing
.trim()
.trim_start_matches('[')
.trim_end_matches(']'),
rows.trim().trim_start_matches('[').trim_end_matches(']')
);
self.responses.insert(argv.to_string(), Ok(joined));
self
}
pub fn failing(mut self, argv: &str, failure: RunFailure) -> Self {
self.responses.insert(argv.to_string(), Err(failure));
self
}
pub fn calls(&self) -> Vec<Call> {
self.calls.lock().unwrap().clone()
}
pub fn call(&self, argv: &str) -> Call {
let calls = self.calls();
let mut matching = calls.iter().filter(|c| c.argv == argv);
let found = matching.next().unwrap_or_else(|| {
let seen: Vec<&str> = calls.iter().map(|c| c.argv.as_str()).collect();
panic!("no call to `{argv}`; saw {seen:?}")
});
assert!(
matching.next().is_none(),
"`{argv}` was called more than once"
);
found.clone()
}
}
impl Runner for FakeRunner {
fn run(
&self,
program: &str,
args: &[&str],
cwd: Option<&Path>,
env: &Env,
) -> Result<String, RunFailure> {
let argv = format!("{program} {}", args.join(" "));
self.calls.lock().unwrap().push(Call {
argv: argv.clone(),
cwd: cwd.map(Path::to_path_buf),
env: env.clone(),
});
match self.responses.get(&argv) {
Some(Ok(out)) => Ok(out.clone()),
Some(Err(failure)) => Err(failure.clone()),
None => panic!("FakeRunner has no response for: {argv}"),
}
}
}
pub fn every_failure_kind() -> impl Iterator<Item = FailureKind> {
std::iter::successors(Some(FailureKind::Auth), |kind| match kind {
FailureKind::Auth => Some(FailureKind::Unavailable),
FailureKind::Unavailable => Some(FailureKind::Gone),
FailureKind::Gone => Some(FailureKind::Busy),
FailureKind::Busy => Some(FailureKind::NotInstalled),
FailureKind::NotInstalled => Some(FailureKind::Unstartable),
FailureKind::Unstartable => Some(FailureKind::InstalledUnstartable),
FailureKind::InstalledUnstartable => Some(FailureKind::Parse),
FailureKind::Parse => Some(FailureKind::Unsupported),
FailureKind::Unsupported => Some(FailureKind::UnknownFlag),
FailureKind::UnknownFlag => None,
})
}
}
#[cfg(test)]
mod tests {
use super::testing::FakeRunner;
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
const REFUSED: &str = r#"Error: failed to open database: failed to check if database "atlas" exists on server db.example.invalid:3306: Error 1045 (28000): Access denied for user 'atlas'"#;
const UNREACHABLE: &str = "Error: failed to open database: Dolt server unreachable at nosuchhost.invalid:3306: dial tcp: lookup nosuchhost.invalid: no such host";
const EMBEDDED: &str = "Error: 'bd sql' is not yet supported in embedded mode";
const NO_SUCH_FLAG: &str =
"Error: unknown shorthand flag: 'C' in -C\nUsage:\n bd list [flags]\n";
const NO_SUCH_LONG_FLAG: &str = "Error: unknown flag: --readonly";
const NO_SUCH_COMMAND: &str = r#"Error: unknown command "sql" for "bd""#;
const UNPLACED: &str = "Error: something neither bd nor herdr has been measured saying";
const NO_SUCH_PANE: &str = r#"{"error":{"code":"agent_not_found","message":"agent target wCW:nosuchpane not found"},"id":"cli:agent:read"}"#;
const PANE_BUSY: &str = r#"{"error":{"code":"agent_not_idle","message":"cannot read 8 lines while wCW:pM is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible"},"id":"cli:agent:read"}"#;
fn failing_command(stderr: &str) -> RunFailure {
RealRunner
.run(
"sh",
&["-c", "printf '%s' \"$1\" >&2; exit 1", "sh", stderr],
None,
&Env::new(),
)
.expect_err("the command exits non-zero")
}
fn nothing_holds(named: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("bdi-{named}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
let _ = std::fs::remove_file(&path);
assert!(
std::fs::symlink_metadata(&path).is_err(),
"{} is held by something, so a test naming it absent proves nothing",
path.display()
);
path
}
#[test]
fn stdout_comes_back_from_a_command_that_succeeds() {
let out = RealRunner
.run("sh", &["-c", "printf 'hello'"], None, &Env::new())
.expect("sh runs");
assert_eq!(out, "hello");
}
#[test]
fn an_overlaid_variable_replaces_the_parents_and_the_rest_is_inherited() {
let mut env = Env::new();
env.insert("HOME".to_string(), "/nowhere-in-particular".to_string());
let out = RealRunner
.run(
"sh",
&["-c", "printf '%s|%s' \"$HOME\" \"${PATH:+set}\""],
None,
&env,
)
.expect("sh runs");
assert_eq!(out, "/nowhere-in-particular|set");
}
#[test]
fn a_command_runs_in_the_directory_it_is_given() {
let out = RealRunner
.run("sh", &["-c", "pwd"], Some(Path::new("/")), &Env::new())
.expect("sh runs");
assert_eq!(out.trim(), "/");
}
#[test]
fn a_refused_credential_and_an_unreachable_server_are_told_apart() {
assert_eq!(failing_command(REFUSED).kind, FailureKind::Auth);
assert_eq!(failing_command(UNREACHABLE).kind, FailureKind::Unavailable);
}
#[test]
fn a_statement_the_tracker_cannot_run_is_told_apart_from_an_unanswered_one() {
assert_eq!(failing_command(EMBEDDED).kind, FailureKind::Unsupported);
assert_eq!(failing_command(UNREACHABLE).kind, FailureKind::Unavailable);
}
#[test]
fn a_bd_that_does_not_know_a_flag_bdi_uses_is_told_apart_from_an_unanswered_tracker() {
for said in [NO_SUCH_FLAG, NO_SUCH_LONG_FLAG, NO_SUCH_COMMAND] {
assert_eq!(
RunFailure::from_exit("bd", Some(1), said).kind,
FailureKind::UnknownFlag,
"on {said:?}"
);
}
assert_eq!(
RunFailure::from_exit("bd", Some(1), UNREACHABLE).kind,
FailureKind::Unavailable
);
}
#[test]
fn the_bd_on_path_refuses_a_flag_it_lacks_in_the_words_the_classifier_reads() {
let failure = RealRunner
.run(
"bd",
&[
"--readonly",
"list",
"--json",
"--no-such-flag-bdi-never-uses",
],
None,
&Env::new(),
)
.expect_err("cobra refuses the command line");
assert_eq!(failure.kind, FailureKind::UnknownFlag);
}
#[test]
fn cobras_words_inside_another_failure_do_not_make_it_a_bd_to_replace() {
let quoting =
r#"Error: failed to open database: no such directory "/srv/unknown command/tracker""#;
assert_eq!(
RunFailure::from_exit("bd", Some(1), quoting).kind,
FailureKind::Unavailable
);
}
#[test]
fn only_bds_refusal_of_its_command_line_is_a_bd_to_replace() {
for program in ["sh", "direnv"] {
assert_eq!(
RunFailure::from_exit(program, Some(1), NO_SUCH_COMMAND).kind,
FailureKind::Unavailable,
"from {program}"
);
}
}
#[test]
fn placing_cobras_refusal_moves_no_other_failure() {
let placed = [
(REFUSED, FailureKind::Auth),
(UNREACHABLE, FailureKind::Unavailable),
(EMBEDDED, FailureKind::Unsupported),
(NO_SUCH_PANE, FailureKind::Gone),
(PANE_BUSY, FailureKind::Busy),
(UNPLACED, FailureKind::Unavailable),
];
for (said, expected) in placed {
assert_eq!(failing_command(said).kind, expected, "on {said:?}");
}
}
#[test]
fn bds_error_text_never_survives_into_the_failure() {
let failure = failing_command(REFUSED);
let shown = format!("{failure} {failure:?}");
for secret in ["atlas", "db.example.invalid", "Access denied", "1045"] {
assert!(!shown.contains(secret), "{secret:?} survived into: {shown}");
}
}
#[test]
fn no_failure_classified_from_stderr_carries_anything_out_of_it() {
for said in [
REFUSED,
UNREACHABLE,
EMBEDDED,
NO_SUCH_PANE,
PANE_BUSY,
UNPLACED,
] {
assert!(
failing_command(said).unreadable.is_none(),
"text on stderr reached a failure's own field: {said:?}"
);
}
}
#[test]
fn an_unrecognised_failure_is_not_reported_as_a_refused_credential() {
let failure = failing_command(UNPLACED);
assert_eq!(failure.kind, FailureKind::Unavailable);
}
#[test]
fn herdrs_own_failures_are_told_apart_from_an_unreachable_herdr() {
assert_eq!(failing_command(NO_SUCH_PANE).kind, FailureKind::Gone);
assert_eq!(failing_command(PANE_BUSY).kind, FailureKind::Busy);
assert_eq!(failing_command(UNREACHABLE).kind, FailureKind::Unavailable);
}
#[test]
fn herdrs_error_json_never_survives_into_the_failure() {
for said in [NO_SUCH_PANE, PANE_BUSY] {
let failure = failing_command(said);
let shown = format!("{failure} {failure:?}");
for leaked in ["agent_not", "wCW", "cli:agent", "\"error\""] {
assert!(!shown.contains(leaked), "{leaked:?} survived into: {shown}");
}
}
}
#[test]
fn a_program_that_is_not_installed_says_nothing_is_installed() {
let empty = a_directory_holding_nothing("with-no-program-in-it");
let mut env = Env::new();
env.insert(PATH.to_string(), empty.to_string_lossy().to_string());
let failure = RealRunner
.run("bdi-no-such-program", &[], None, &env)
.expect_err("nothing by that name is on PATH");
std::fs::remove_dir_all(&empty).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::NotInstalled);
assert_eq!(failure.program, "bdi-no-such-program");
}
#[test]
fn a_program_that_is_there_and_will_not_start_is_told_apart_from_a_missing_one() {
let program =
std::env::temp_dir().join(format!("bdi-not-executable-{}", std::process::id()));
std::fs::write(&program, "#!/bin/sh\nexit 0\n").expect("the file is ours to write");
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o644))
.expect("the mode is ours to set");
let failure = RealRunner
.run(&program.to_string_lossy(), &[], None, &Env::new())
.expect_err("the file has no execute bit");
std::fs::remove_file(&program).expect("the file is ours to remove");
assert_eq!(failure.kind, FailureKind::InstalledUnstartable);
}
#[test]
fn an_empty_working_directory_is_not_one_the_child_entered() {
let empty = a_directory_holding_nothing("nothing-to-find-from-nowhere");
let mut env = Env::new();
env.insert(PATH.to_string(), empty.display().to_string());
let failure = RealRunner
.run("bdi-no-such-program", &[], Some(Path::new("")), &env)
.expect_err("an empty working directory is refused");
assert_eq!(failure.kind, FailureKind::Unstartable);
}
#[test]
fn a_directory_that_is_not_there_is_not_a_program_that_was_never_installed() {
let gone = nothing_holds("no-such-directory");
let failure = RealRunner
.run("sh", &["-c", "true"], Some(&gone), &Env::new())
.expect_err("the directory is not there");
assert_eq!(failure.kind, FailureKind::Unstartable);
}
#[test]
fn a_program_whose_interpreter_is_missing_is_not_a_program_nobody_installed() {
let (dir, program) = a_script_whose_interpreter_is_missing("by-its-path");
let failure = RealRunner
.run(&program.to_string_lossy(), &[], None, &Env::new())
.expect_err("the interpreter is not there");
std::fs::remove_dir_all(&dir).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::InstalledUnstartable);
}
#[test]
fn a_program_installed_only_on_the_childs_own_path_is_found_there() {
let (dir, _) = a_script_whose_interpreter_is_missing("by-its-name");
let mut env = Env::new();
env.insert("PATH".to_string(), dir.to_string_lossy().to_string());
let failure = RealRunner
.run("bdi-broken-interpreter", &[], None, &env)
.expect_err("the interpreter is not there");
std::fs::remove_dir_all(&dir).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::InstalledUnstartable);
}
#[test]
fn a_relative_path_entry_is_read_from_the_directory_the_child_entered() {
let (dir, _) = a_script_whose_interpreter_is_missing("by-a-relative-entry");
let under = dir.join("bin");
std::fs::create_dir_all(&under).expect("the directory is ours to make");
std::fs::rename(
dir.join("bdi-broken-interpreter"),
under.join("bdi-broken-interpreter"),
)
.expect("the file is ours to move");
let mut env = Env::new();
env.insert("PATH".to_string(), "bin".to_string());
let failure = RealRunner
.run("bdi-broken-interpreter", &[], Some(&dir), &env)
.expect_err("the interpreter is not there");
std::fs::remove_dir_all(&dir).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::InstalledUnstartable);
}
#[test]
fn a_program_whose_symlink_dangles_is_installed_and_broken() {
let program = std::env::temp_dir().join(format!("bdi-dangling-{}", std::process::id()));
let _ = std::fs::remove_file(&program);
std::os::unix::fs::symlink(nothing_holds("dangling-target"), &program)
.expect("the link is ours to make");
let failure = RealRunner
.run(&program.to_string_lossy(), &[], None, &Env::new())
.expect_err("the target is not there");
std::fs::remove_file(&program).expect("the link is ours to remove");
assert_eq!(failure.kind, FailureKind::InstalledUnstartable);
}
#[test]
fn a_spawn_no_search_could_reach_says_nothing_about_an_installation() {
let locked = an_unsearchable_directory("nothing-inside-it");
let mut env = Env::new();
env.insert(PATH.to_string(), locked.display().to_string());
let failure = RealRunner
.run("bdi-no-such-program", &[], None, &env)
.expect_err("the one directory on PATH cannot be searched");
make_searchable_again(&locked);
assert_eq!(failure.kind, FailureKind::Unstartable);
}
#[test]
fn a_call_refused_before_the_child_looked_is_not_an_absent_installation() {
let empty = a_directory_holding_nothing("nothing-to-find");
let mut env = Env::new();
env.insert(PATH.to_string(), empty.display().to_string());
let failure = RealRunner
.run("bdi-no-such-program", &["a\0b"], None, &env)
.expect_err("a NUL byte in an argument is refused before the search");
assert_eq!(failure.kind, FailureKind::Unstartable);
}
#[test]
fn a_nul_byte_in_the_environment_is_refused_before_the_child_looked_too() {
let empty = a_directory_holding_nothing("nothing-to-find-either");
let mut env = Env::new();
env.insert(PATH.to_string(), empty.display().to_string());
env.insert("BDI_NUL".to_string(), "a\0b".to_string());
let failure = RealRunner
.run("bdi-no-such-program", &[], None, &env)
.expect_err("a NUL byte in the environment is refused before the search");
assert_eq!(failure.kind, FailureKind::Unstartable);
}
#[test]
fn a_call_refused_before_the_child_looked_is_not_an_installation_that_refused() {
let dir = a_program_without_its_execute_bit("beside-a-call-we-broke");
let mut env = Env::new();
env.insert(PATH.to_string(), dir.to_string_lossy().to_string());
let failure = RealRunner
.run("bdi-without-its-execute-bit", &["a\0b"], None, &env)
.expect_err("a NUL byte in an argument is refused before the search");
std::fs::remove_dir_all(&dir).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::Unstartable);
}
#[test]
fn a_refusal_nothing_can_place_is_answered_by_the_search_that_completed() {
let empty = a_directory_holding_nothing("nothing-to-find-at-all");
let mut env = Env::new();
env.insert(PATH.to_string(), empty.display().to_string());
let far_too_long = "x".repeat(128 * 1024);
let arguments: Vec<&str> = (0..256).map(|_| far_too_long.as_str()).collect();
let failure = RealRunner
.run("bdi-no-such-program", &arguments, None, &env)
.expect_err("nothing on PATH holds it and the arguments are too long anyway");
assert_eq!(
failure.kind,
FailureKind::NotInstalled,
"the spawn answered {:?}",
failure.detail
);
}
#[test]
fn a_program_found_on_path_without_its_execute_bit_is_installed_and_broken() {
let dir = a_program_without_its_execute_bit("on-its-own");
let mut env = Env::new();
env.insert(PATH.to_string(), dir.to_string_lossy().to_string());
let failure = RealRunner
.run("bdi-without-its-execute-bit", &[], None, &env)
.expect_err("the file has no execute bit");
std::fs::remove_dir_all(&dir).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::InstalledUnstartable);
}
#[test]
fn an_unsearchable_entry_does_not_hide_a_program_another_entry_holds() {
let locked = an_unsearchable_directory("beside-one-that-answers");
let dir = a_program_without_its_execute_bit("beside-one-that-refuses");
let mut env = Env::new();
env.insert(
PATH.to_string(),
format!("{}:{}", locked.display(), dir.display()),
);
let failure = RealRunner
.run("bdi-without-its-execute-bit", &[], None, &env)
.expect_err("one entry cannot be searched and the other holds no executable");
make_searchable_again(&locked);
std::fs::remove_dir_all(&dir).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::InstalledUnstartable);
}
#[test]
fn a_directory_that_refuses_the_search_is_not_one_that_holds_nothing() {
let locked = an_unsearchable_directory("which-will-not-say");
let open = a_directory_holding_nothing("which-says-so");
let refused = under_that_name(&locked.join("bdi-no-such-program"));
let answered = under_that_name(&open.join("bdi-no-such-program"));
make_searchable_again(&locked);
std::fs::remove_dir_all(&open).expect("the directory is ours to remove");
assert_eq!(refused, UnderThatName::Unestablished);
assert_eq!(answered, UnderThatName::Nothing);
}
#[test]
fn a_working_directory_nothing_may_enter_is_not_a_search_that_found_nothing() {
let locked = an_unsearchable_directory("with-no-way-in");
let open = a_directory_holding_nothing("that-the-search-reaches");
let mut env = Env::new();
env.insert(PATH.to_string(), open.to_string_lossy().to_string());
let failure = RealRunner
.run("bdi-no-such-program", &[], Some(&locked), &env)
.expect_err("the working directory cannot be entered");
make_searchable_again(&locked);
std::fs::remove_dir_all(&open).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::Unstartable);
}
#[test]
fn an_empty_path_entry_is_the_directory_the_child_entered() {
let dir = a_program_without_its_execute_bit("named-by-an-empty-entry");
let mut env = Env::new();
env.insert(PATH.to_string(), String::new());
let failure = RealRunner
.run("bdi-without-its-execute-bit", &[], Some(&dir), &env)
.expect_err("the file has no execute bit");
std::fs::remove_dir_all(&dir).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::InstalledUnstartable);
}
#[test]
fn a_relative_path_entry_that_refuses_the_search_is_not_an_absence() {
let dir = std::env::temp_dir().join(format!("bdi-entered-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("the directory is ours to make");
let locked = dir.join("bin");
std::fs::create_dir_all(&locked).expect("the directory is ours to make");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644))
.expect("the mode is ours to set");
let mut env = Env::new();
env.insert(PATH.to_string(), "bin".to_string());
let failure = RealRunner
.run("bdi-no-such-program", &[], Some(&dir), &env)
.expect_err("the one directory on PATH cannot be searched");
make_searchable_again(&locked);
std::fs::remove_dir_all(&dir).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::Unstartable);
}
#[test]
fn a_path_entry_that_is_not_a_directory_holds_nothing_wherever_it_sits() {
let file = std::env::temp_dir().join(format!("bdi-not-a-directory-{}", std::process::id()));
std::fs::write(&file, "").expect("the file is ours to write");
let empty = a_directory_holding_nothing("beside-a-file");
for path in [
format!("{}:{}", file.display(), empty.display()),
format!("{}:{}", empty.display(), file.display()),
] {
let mut env = Env::new();
env.insert(PATH.to_string(), path.clone());
let failure = RealRunner
.run("bdi-no-such-program", &[], None, &env)
.expect_err("neither entry on PATH holds it");
assert_eq!(failure.kind, FailureKind::NotInstalled, "PATH was {path}");
}
std::fs::remove_file(&file).expect("the file is ours to remove");
}
#[test]
fn a_path_entry_the_search_cannot_resolve_establishes_nothing() {
let looping = std::env::temp_dir().join(format!("bdi-loop-a-{}", std::process::id()));
let back = std::env::temp_dir().join(format!("bdi-loop-b-{}", std::process::id()));
let _ = std::fs::remove_file(&looping);
let _ = std::fs::remove_file(&back);
std::os::unix::fs::symlink(&back, &looping).expect("the link is ours to make");
std::os::unix::fs::symlink(&looping, &back).expect("the link is ours to make");
let mut env = Env::new();
env.insert(PATH.to_string(), looping.to_string_lossy().to_string());
let failure = RealRunner
.run("bdi-no-such-program", &[], None, &env)
.expect_err("the one entry on PATH points at itself");
std::fs::remove_file(&looping).expect("the link is ours to remove");
std::fs::remove_file(&back).expect("the link is ours to remove");
assert_eq!(failure.kind, FailureKind::Unstartable);
}
fn a_program_without_its_execute_bit(named: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("bdi-no-execute-bit-{named}-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("the directory is ours to make");
let program = dir.join("bdi-without-its-execute-bit");
std::fs::write(&program, "#!/bin/sh\nexit 0\n").expect("the file is ours to write");
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o644))
.expect("the mode is ours to set");
dir
}
fn a_directory_holding_nothing(named: &str) -> PathBuf {
let empty = std::env::temp_dir().join(format!("bdi-empty-{named}-{}", std::process::id()));
std::fs::create_dir_all(&empty).expect("the directory is ours to make");
empty
}
fn an_unsearchable_directory(named: &str) -> PathBuf {
let locked =
std::env::temp_dir().join(format!("bdi-locked-{named}-{}", std::process::id()));
std::fs::create_dir_all(&locked).expect("the directory is ours to make");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644))
.expect("the mode is ours to set");
locked
}
fn make_searchable_again(locked: &Path) {
std::fs::set_permissions(locked, std::fs::Permissions::from_mode(0o755))
.expect("the mode is ours to set");
std::fs::remove_dir_all(locked).expect("the directory is ours to remove");
}
fn a_script_whose_interpreter_is_missing(named: &str) -> (PathBuf, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"bdi-broken-interpreter-{named}-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("the directory is ours to make");
let program = dir.join("bdi-broken-interpreter");
std::fs::write(&program, "#!/bdi-no-such-interpreter\nexit 0\n")
.expect("the file is ours to write");
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755))
.expect("the mode is ours to set");
(dir, program)
}
#[test]
fn a_missing_program_in_a_missing_directory_is_reported_as_the_directory() {
let gone = nothing_holds("no-such-directory-either");
let empty = a_directory_holding_nothing("with-no-program-in-it-either");
let mut env = Env::new();
env.insert(PATH.to_string(), empty.to_string_lossy().to_string());
let failure = RealRunner
.run("bdi-no-such-program", &[], Some(&gone), &env)
.expect_err("neither the directory nor the program is there");
std::fs::remove_dir_all(&empty).expect("the directory is ours to remove");
assert_eq!(failure.kind, FailureKind::Unstartable);
}
#[test]
fn the_fake_records_the_directory_and_environment_it_was_called_with() {
let runner = FakeRunner::default().with("bd whoami", "someone");
let mut env = Env::new();
env.insert("K".to_string(), "v".to_string());
runner
.run("bd", &["whoami"], Some(Path::new("/tmp/proj")), &env)
.unwrap();
let call = runner.call("bd whoami");
assert_eq!(call.cwd.as_deref(), Some(Path::new("/tmp/proj")));
assert_eq!(call.env, env);
}
}