test: cover the store and the exit codes before moving any of it
This commit is contained in:
262
tests/link_store.rs
Normal file
262
tests/link_store.rs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
// what the store is for: a path a sandbox should not read living somewhere it
|
||||||
|
// cannot. every case here is one that was once wrong.
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use support::Case;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_path_moves_into_the_store_and_reads_back_through_the_link() {
|
||||||
|
let case = Case::new("roundtrip");
|
||||||
|
case.write(".env", b"SECRET=1\n");
|
||||||
|
case.write("secrets/token", b"tok\n");
|
||||||
|
case.gitignore(".env\nsecrets/\n");
|
||||||
|
|
||||||
|
case.ahab(&["link", "add", ".env", "secrets"]).ok();
|
||||||
|
|
||||||
|
assert!(case.is_symlink(".env"));
|
||||||
|
assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n");
|
||||||
|
assert_eq!(fs::read(case.path("secrets/token")).unwrap(), b"tok\n");
|
||||||
|
|
||||||
|
case.ahab(&["link", "check", "--exit-code"]).ok();
|
||||||
|
case.ahab(&["link", "restore", "--all"]).ok();
|
||||||
|
|
||||||
|
assert!(!case.is_symlink(".env"));
|
||||||
|
assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_refuses_a_symlink_that_already_leads_out_of_the_repository() {
|
||||||
|
let case = Case::new("launder");
|
||||||
|
fs::write(case.outside.join("key"), b"KEY\n").unwrap();
|
||||||
|
case.link(&case.path("cache"), &case.outside);
|
||||||
|
|
||||||
|
// moving the link would move the pointer and leave the contents there,
|
||||||
|
// and check would then see a link into the store and call it clean
|
||||||
|
case.ahab(&["link", "add", "cache"])
|
||||||
|
.failed()
|
||||||
|
.says("outside the repository");
|
||||||
|
|
||||||
|
case.ahab(&["link", "check", "--exit-code"]).code_is(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_reports_a_store_entry_that_leads_back_out() {
|
||||||
|
let case = Case::new("poisoned");
|
||||||
|
fs::create_dir_all(&case.store).unwrap();
|
||||||
|
fs::write(case.outside.join("key"), b"KEY\n").unwrap();
|
||||||
|
// the state an older ahab left: the store holds a way back out
|
||||||
|
case.link(&case.store.join("cache"), &case.outside);
|
||||||
|
case.link(&case.path("cache"), &case.store.join("cache"));
|
||||||
|
|
||||||
|
case.ahab(&["link", "check", "--exit-code"]).code_is(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_reports_a_tracked_symlink_leading_out() {
|
||||||
|
let case = Case::new("tracked-link");
|
||||||
|
fs::create_dir_all(case.outside.join("aws")).unwrap();
|
||||||
|
case.link(&case.path("awsdir"), &case.outside.join("aws"));
|
||||||
|
case.git(&["add", "-f", "awsdir"]);
|
||||||
|
case.git(&["commit", "-qm", "commit a symlink out"]);
|
||||||
|
|
||||||
|
// git tracks symlinks, so this is in no untracked or ignored listing
|
||||||
|
case.ahab(&["link", "check", "--exit-code"]).code_is(4);
|
||||||
|
case.ahab(&["link", "check", "--porcelain"]).says("T>");
|
||||||
|
// and add cannot fix it, so saying so is all check can do
|
||||||
|
case.ahab(&["link", "add", "awsdir"])
|
||||||
|
.failed()
|
||||||
|
.says("tracked by git");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_symlink_is_one_entry_rather_than_a_tree_to_walk() {
|
||||||
|
let case = Case::new("walk-out");
|
||||||
|
fs::create_dir_all(case.outside.join("private")).unwrap();
|
||||||
|
fs::write(case.outside.join("private/diary"), b"x\n").unwrap();
|
||||||
|
fs::write(case.outside.join(".netrc"), b"x\n").unwrap();
|
||||||
|
fs::create_dir_all(&case.store).unwrap();
|
||||||
|
case.link(&case.store.join("cache"), &case.outside);
|
||||||
|
|
||||||
|
let run = case.ahab(&["link", "list"]);
|
||||||
|
run.ok().says("cache");
|
||||||
|
// is_dir() would follow the link and enumerate what is behind it
|
||||||
|
run.silent_about(".netrc");
|
||||||
|
run.silent_about("diary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_store_inside_the_repository_is_refused_however_it_is_spelled() {
|
||||||
|
let case = Case::new("store-inside");
|
||||||
|
case.write(".env", b"SECRET=1\n");
|
||||||
|
case.gitignore(".env\n");
|
||||||
|
|
||||||
|
// named directly
|
||||||
|
case.ahab(&["link", "add", "--store", "./within", ".env"])
|
||||||
|
.failed()
|
||||||
|
.says("inside the repository");
|
||||||
|
|
||||||
|
// and reached through a symlink, which a prefix test does not catch
|
||||||
|
case.mkdir("within");
|
||||||
|
let sneaky = case.repo.parent().unwrap().join("sneaky");
|
||||||
|
case.link(&sneaky, &case.path("within"));
|
||||||
|
let run = case.ahab(&[
|
||||||
|
"link".as_ref(),
|
||||||
|
"add".as_ref(),
|
||||||
|
"--store".as_ref(),
|
||||||
|
sneaky.as_os_str(),
|
||||||
|
".env".as_ref(),
|
||||||
|
]);
|
||||||
|
run.failed().says("inside the repository");
|
||||||
|
assert!(!case.is_symlink(".env"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_relative_store_root_still_resolves_from_anywhere() {
|
||||||
|
let case = Case::new("relative-store");
|
||||||
|
case.write("sub/.env", b"SECRET=1\n");
|
||||||
|
case.gitignore("sub/.env\n");
|
||||||
|
|
||||||
|
case.ahab(&["link", "add", "--store", "../store", "sub/.env"])
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// the target is written into the symlink, so a relative one would resolve
|
||||||
|
// from the link's own directory rather than the working one
|
||||||
|
assert!(case.is_symlink("sub/.env"));
|
||||||
|
assert_eq!(fs::read(case.path("sub/.env")).unwrap(), b"SECRET=1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_store_directories_are_the_owners_alone() {
|
||||||
|
let case = Case::new("modes");
|
||||||
|
case.write("deep/nested/.env", b"SECRET=1\n");
|
||||||
|
case.gitignore("deep\n");
|
||||||
|
|
||||||
|
case.ahab(&["link", "add", "deep/nested/.env"]).ok();
|
||||||
|
|
||||||
|
for dir in [
|
||||||
|
case.store.as_path(),
|
||||||
|
&case.store.join("deep"),
|
||||||
|
&case.store.join("deep/nested"),
|
||||||
|
] {
|
||||||
|
assert_eq!(case.mode(dir), 0o700, "{}", dir.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_link_puts_the_payload_back() {
|
||||||
|
let case = Case::new("rollback");
|
||||||
|
case.write("x.env", b"SECRET=1\n");
|
||||||
|
// ahab's own temp name, but a real file: it must not be removed, and the
|
||||||
|
// payload must not be left in the store with nothing pointing at it
|
||||||
|
case.write("x.env.ahab-tmp", b"THE PROJECT OWNS THIS\n");
|
||||||
|
case.gitignore("x.env\n");
|
||||||
|
|
||||||
|
case.ahab(&["link", "add", "x.env"]).failed();
|
||||||
|
|
||||||
|
assert!(!case.is_symlink("x.env"));
|
||||||
|
assert_eq!(fs::read(case.path("x.env")).unwrap(), b"SECRET=1\n");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(case.path("x.env.ahab-tmp")).unwrap(),
|
||||||
|
b"THE PROJECT OWNS THIS\n"
|
||||||
|
);
|
||||||
|
assert!(!case.store.join("x.env").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_directory_of_symlinks_out_keeps_its_destinations() {
|
||||||
|
let case = Case::new("collapse");
|
||||||
|
fs::write(case.outside.join("a"), b"A\n").unwrap();
|
||||||
|
case.link(&case.path("bundle/one"), &case.outside.join("a"));
|
||||||
|
|
||||||
|
// collapsing to `bundle/` would drop both the destination and the code
|
||||||
|
let run = case.ahab(&["link", "check", "--porcelain"]);
|
||||||
|
run.code_is(0).says("?>").says("bundle/one");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_filename_that_is_not_utf_8_is_reported_and_moved_as_itself() {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
|
let case = Case::new("latin1");
|
||||||
|
let name = OsStr::from_bytes(b"caf\xe9.env");
|
||||||
|
fs::write(case.repo.join(name), b"SECRET=1\n").unwrap();
|
||||||
|
|
||||||
|
// the whole listing used to fail on the one entry
|
||||||
|
let run = case.ahab(&["link", "check", "-z"]);
|
||||||
|
run.code_is(0);
|
||||||
|
assert!(
|
||||||
|
run.stdout.windows(4).any(|w| w == b"caf\xe9"),
|
||||||
|
"-z must write the bytes the name actually has"
|
||||||
|
);
|
||||||
|
|
||||||
|
case.gitignore("caf\u{e9}.env\n");
|
||||||
|
case.ahab(&["link".as_ref(), "add".as_ref(), name]).ok();
|
||||||
|
assert!(
|
||||||
|
fs::symlink_metadata(case.repo.join(name))
|
||||||
|
.unwrap()
|
||||||
|
.is_symlink()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tracked_filename_that_is_not_utf_8_is_still_refused() {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
|
let case = Case::new("latin1-tracked");
|
||||||
|
let name = OsStr::from_bytes(b"caf\xe9.env");
|
||||||
|
fs::write(case.repo.join(name), b"TRACKED\n").unwrap();
|
||||||
|
case.git(&["add".as_ref(), "-f".as_ref(), name]);
|
||||||
|
case.git(&["commit", "-qm", "track a latin-1 name"]);
|
||||||
|
|
||||||
|
// asked about lossily, git answers about a path nothing has and says
|
||||||
|
// "not tracked", and a tracked file leaves the working tree
|
||||||
|
case.ahab(&["link".as_ref(), "add".as_ref(), name])
|
||||||
|
.failed()
|
||||||
|
.says("tracked by git");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!fs::symlink_metadata(case.repo.join(name))
|
||||||
|
.unwrap()
|
||||||
|
.is_symlink()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pathspec_is_asked_about_as_a_name_not_a_pattern() {
|
||||||
|
let case = Case::new("pathspec");
|
||||||
|
// git reads pathspec magic after `--` too, so this used to have git list
|
||||||
|
// every tracked file *except* the named one, which read as "it is tracked"
|
||||||
|
case.write(":!untracked.env", b"SECRET=1\n");
|
||||||
|
case.gitignore(":!untracked.env\n");
|
||||||
|
|
||||||
|
case.ahab(&["link", "add", ":!untracked.env"]).ok();
|
||||||
|
assert!(case.is_symlink(":!untracked.env"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_remotes_do_not_share_one_store_directory() {
|
||||||
|
let case = Case::new("remotes");
|
||||||
|
|
||||||
|
case.git(&[
|
||||||
|
"remote",
|
||||||
|
"set-url",
|
||||||
|
"origin",
|
||||||
|
"https://git.example.org/a/my_api",
|
||||||
|
]);
|
||||||
|
let plain = case.ahab(&["link", "list"]).out();
|
||||||
|
|
||||||
|
case.git(&[
|
||||||
|
"remote",
|
||||||
|
"set-url",
|
||||||
|
"origin",
|
||||||
|
"https://git.example.org/a/my~api",
|
||||||
|
]);
|
||||||
|
let awkward = case.ahab(&["link", "list"]).out();
|
||||||
|
|
||||||
|
assert_ne!(plain, awkward);
|
||||||
|
// the ordinary remote keeps the path it already had
|
||||||
|
assert!(plain.contains("/a/my_api"), "{plain}");
|
||||||
|
}
|
||||||
99
tests/output_io.rs
Normal file
99
tests/output_io.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// what a command reports and how it exits, including when the write fails.
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use support::Case;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn findings_are_reported_through_the_exit_code() {
|
||||||
|
let case = Case::new("findings");
|
||||||
|
case.write("loose.txt", b"x\n");
|
||||||
|
|
||||||
|
case.ahab(&["link", "check"]).ok();
|
||||||
|
case.ahab(&["link", "check", "--exit-code"]).code_is(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_argument_error_exits_two() {
|
||||||
|
let case = Case::new("usage");
|
||||||
|
|
||||||
|
case.ahab(&["link", "restore"]).code_is(2);
|
||||||
|
case.ahab(&["link", "restore", "--all", "some/path"])
|
||||||
|
.code_is(2);
|
||||||
|
case.ahab(&["postgres", "dump", "-F", "directory", "-z", "d"])
|
||||||
|
.code_is(2);
|
||||||
|
case.ahab(&["nonsense"]).code_is(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_write_that_cannot_be_delivered_is_a_failure_rather_than_a_success() {
|
||||||
|
let case = Case::new("devfull");
|
||||||
|
for n in 0..200 {
|
||||||
|
case.write(&format!("file-{n}.txt"), b"x\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// /dev/full accepts the open and fails the write. exiting 0 there would
|
||||||
|
// report a listing that was never delivered
|
||||||
|
let Ok(full) = fs::File::create("/dev/full") else {
|
||||||
|
eprintln!("skipped: this system has no /dev/full to fail a write against");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let out = std::process::Command::new(env!("CARGO_BIN_EXE_ahab"))
|
||||||
|
.current_dir(&case.repo)
|
||||||
|
.env("XDG_DATA_HOME", case.repo.join("../xdg"))
|
||||||
|
.args(["link", "check", "--exit-code", "--porcelain"])
|
||||||
|
.stdout(full)
|
||||||
|
.output()
|
||||||
|
.expect("running ahab");
|
||||||
|
|
||||||
|
assert_eq!(out.status.code(), Some(1), "{:?}", out.status);
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&out.stderr).contains("writing to stdout failed"),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_named_path_is_moved_even_when_the_reader_leaves() {
|
||||||
|
let case = Case::new("partial");
|
||||||
|
for name in ["a.env", "b.env", "c.env"] {
|
||||||
|
case.write(name, b"x\n");
|
||||||
|
}
|
||||||
|
case.gitignore("a.env\nb.env\nc.env\n");
|
||||||
|
|
||||||
|
// a closed stdout must stop the writing, not the moving: the command had
|
||||||
|
// planned to move all three and reported success for doing so
|
||||||
|
let run = case.ahab(&["link", "add", "a.env", "b.env", "c.env"]);
|
||||||
|
run.ok();
|
||||||
|
|
||||||
|
for name in ["a.env", "b.env", "c.env"] {
|
||||||
|
assert!(case.is_symlink(name), "{name} was left behind");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quiet_keeps_the_reason_a_path_failed() {
|
||||||
|
let case = Case::new("quiet");
|
||||||
|
case.write("ok.env", b"x\n");
|
||||||
|
case.gitignore("ok.env\n");
|
||||||
|
|
||||||
|
let run = case.ahab(&["-q", "link", "add", "ok.env", "missing.env"]);
|
||||||
|
run.failed()
|
||||||
|
.says("1 of 2 paths failed")
|
||||||
|
// the line saying *which* and *why* is a result, not progress
|
||||||
|
.says("missing.env");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dry_run_changes_nothing_on_disk() {
|
||||||
|
let case = Case::new("dryrun");
|
||||||
|
case.write(".env", b"SECRET=1\n");
|
||||||
|
case.gitignore(".env\n");
|
||||||
|
|
||||||
|
case.ahab(&["--dry-run", "link", "add", ".env"]).ok();
|
||||||
|
|
||||||
|
assert!(!case.is_symlink(".env"));
|
||||||
|
assert!(!case.store.join(".env").exists());
|
||||||
|
}
|
||||||
207
tests/support/mod.rs
Normal file
207
tests/support/mod.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// enough scaffolding to run ahab against a throwaway repository, hand-rolled
|
||||||
|
// rather than pulled in: the crate has no dependencies it does not need, and a
|
||||||
|
// dev-dependency is still something to trust and keep current.
|
||||||
|
|
||||||
|
// each integration test file compiles this module separately, so a helper only
|
||||||
|
// one of them needs looks unused to the others
|
||||||
|
#![allow(dead_code)]
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Output};
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::{env, fs};
|
||||||
|
|
||||||
|
// unique per case even with the suite running in parallel
|
||||||
|
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
pub struct Case {
|
||||||
|
root: PathBuf,
|
||||||
|
pub repo: PathBuf,
|
||||||
|
pub store: PathBuf,
|
||||||
|
pub outside: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Case {
|
||||||
|
// a repository with an origin remote, a store of its own, and one commit
|
||||||
|
pub fn new(label: &str) -> Self {
|
||||||
|
let root = env::temp_dir().join(format!(
|
||||||
|
"ahab-{label}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
|
||||||
|
let case = Self {
|
||||||
|
repo: root.join("repo"),
|
||||||
|
store: root
|
||||||
|
.join("xdg/ahab/git.example.org/acme/proj")
|
||||||
|
.to_path_buf(),
|
||||||
|
outside: root.join("outside"),
|
||||||
|
root,
|
||||||
|
};
|
||||||
|
|
||||||
|
for dir in [&case.repo, &case.outside] {
|
||||||
|
fs::create_dir_all(dir).expect("creating the case directories");
|
||||||
|
}
|
||||||
|
|
||||||
|
case.git(&["init", "-q", "."]);
|
||||||
|
case.git(&["config", "user.email", "test@example.org"]);
|
||||||
|
case.git(&["config", "user.name", "test"]);
|
||||||
|
case.git(&["config", "commit.gpgsign", "false"]);
|
||||||
|
case.git(&[
|
||||||
|
"remote",
|
||||||
|
"add",
|
||||||
|
"origin",
|
||||||
|
"https://git.example.org/acme/proj.git",
|
||||||
|
]);
|
||||||
|
case.write("tracked.txt", b"tracked\n");
|
||||||
|
case.git(&["add", "tracked.txt"]);
|
||||||
|
case.git(&["commit", "-qm", "init"]);
|
||||||
|
|
||||||
|
case
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn git<S: AsRef<OsStr>>(&self, args: &[S]) -> Output {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.current_dir(&self.repo)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("running git");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"git {:?} failed: {}",
|
||||||
|
args.iter()
|
||||||
|
.map(|a| a.as_ref().to_string_lossy())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ahab, in the repository, with a store nothing else shares
|
||||||
|
pub fn ahab<S: AsRef<OsStr>>(&self, args: &[S]) -> Run {
|
||||||
|
let out = Command::new(env!("CARGO_BIN_EXE_ahab"))
|
||||||
|
.current_dir(&self.repo)
|
||||||
|
.env("XDG_DATA_HOME", self.root.join("xdg"))
|
||||||
|
// the git commands ahab runs must not read the developer's own config
|
||||||
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("running ahab");
|
||||||
|
|
||||||
|
Run {
|
||||||
|
code: out.status.code(),
|
||||||
|
stdout: out.stdout,
|
||||||
|
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self, rel: &str) -> PathBuf {
|
||||||
|
self.repo.join(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write(&self, rel: &str, contents: &[u8]) {
|
||||||
|
let path = self.path(rel);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("creating a parent directory");
|
||||||
|
}
|
||||||
|
fs::write(path, contents).expect("writing a file");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mkdir(&self, rel: &str) {
|
||||||
|
fs::create_dir_all(self.path(rel)).expect("creating a directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link(&self, at: &Path, to: &Path) {
|
||||||
|
if let Some(parent) = at.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("creating a parent directory");
|
||||||
|
}
|
||||||
|
std::os::unix::fs::symlink(to, at).expect("creating a symlink");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gitignore(&self, lines: &str) {
|
||||||
|
self.write(".gitignore", lines.as_bytes());
|
||||||
|
self.git(&["add", ".gitignore"]);
|
||||||
|
self.git(&["commit", "-qm", "ignore"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_symlink(&self, rel: &str) -> bool {
|
||||||
|
fs::symlink_metadata(self.path(rel)).is_ok_and(|meta| meta.is_symlink())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mode(&self, path: &Path) -> u32 {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
fs::symlink_metadata(path)
|
||||||
|
.expect("reading a mode")
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Case {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Run {
|
||||||
|
pub code: Option<i32>,
|
||||||
|
pub stdout: Vec<u8>,
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Run {
|
||||||
|
pub fn out(&self) -> String {
|
||||||
|
String::from_utf8_lossy(&self.stdout).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ok(&self) -> &Self {
|
||||||
|
assert_eq!(self.code, Some(0), "{}", self.report());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failed(&self) -> &Self {
|
||||||
|
assert_ne!(self.code, Some(0), "{}", self.report());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn code_is(&self, want: i32) -> &Self {
|
||||||
|
assert_eq!(self.code, Some(want), "{}", self.report());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn says(&self, needle: &str) -> &Self {
|
||||||
|
assert!(
|
||||||
|
self.out().contains(needle) || self.stderr.contains(needle),
|
||||||
|
"expected {needle:?}\n{}",
|
||||||
|
self.report()
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn silent_about(&self, needle: &str) -> &Self {
|
||||||
|
assert!(
|
||||||
|
!self.out().contains(needle) && !self.stderr.contains(needle),
|
||||||
|
"did not expect {needle:?}\n{}",
|
||||||
|
self.report()
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(&self) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"exit: {:?}\n--- stdout\n{}--- stderr\n{}",
|
||||||
|
self.code,
|
||||||
|
self.out(),
|
||||||
|
self.stderr
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user