end_to_end.rs
raw
//! End-to-end tests driving the real binary against a fixture cargo home.
//!
//! These run the command the way a user does — argument parsing, config
//! loading, discovery, ageing, deletion and reporting all included — rather
//! than calling into the pieces. That is the only way to check the things that
//! only exist at the seams: that `cargo grunk clean` and `cargo-grunk clean`
//! mean the same thing, that the config actually reaches the age policy, and
//! that what `--dry-run` promises is what a clean then does.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use filetime::FileTime;
use rusqlite::Connection;
use tempfile::TempDir;
const DAY: Duration = Duration::from_secs(24 * 60 * 60);
const INDEX: &str = "index.crates.io-1949cf8c6b5b557f";
const GIT_DB: &str = "badgebuilder-e22ab1d71dcafcb2";
/// The cache directory tag cargo writes into every target directory.
const CARGO_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
# This file is a cache directory tag created by cargo.\n";
/// A self-contained world: a cargo home, a tree of projects, and a config.
struct Fixture {
_tmp: TempDir,
cargo_home: PathBuf,
projects: PathBuf,
config: PathBuf,
}
impl Fixture {
fn new() -> Self {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let cargo_home = root.join("cargo-home");
let projects = root.join("projects");
fs::create_dir_all(&cargo_home).unwrap();
fs::create_dir_all(&projects).unwrap();
let fixture = Self {
cargo_home,
projects,
config: root.join("grunk.toml"),
_tmp: tmp,
};
fixture.write_config("min_age = \"3d\"\n");
fixture.init_cargo_home();
fixture
}
fn write_config(&self, contents: &str) {
fs::write(&self.config, contents).unwrap();
}
/// A cargo home holding one crate tarball, one unpacked source, and a git
/// dependency — each with a last-use timestamp we choose.
fn init_cargo_home(&self) {
let db = Connection::open(self.cargo_home.join(".global-cache")).unwrap();
db.execute_batch(include_str!("../fixtures/global-cache-schema.sql"))
.unwrap();
db.execute(
"INSERT INTO registry_index (id, name, timestamp) VALUES (2, ?1, ?2)",
rusqlite::params![INDEX, unix_now()],
)
.unwrap();
drop(db);
}
fn sql(&self) -> Connection {
Connection::open(self.cargo_home.join(".global-cache")).unwrap()
}
/// Record a crate tarball last used `age` ago.
fn crate_tarball(&self, name: &str, age: Duration) -> PathBuf {
let file = format!("{name}.crate");
let path = self
.cargo_home
.join("registry/cache")
.join(INDEX)
.join(&file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, vec![b'x'; 2048]).unwrap();
self.sql()
.execute(
"INSERT INTO registry_crate VALUES (2, ?1, 2048, ?2)",
rusqlite::params![file, unix_ago(age)],
)
.unwrap();
path
}
/// Record an unpacked source directory last used `age` ago.
fn unpacked_src(&self, name: &str, age: Duration) -> PathBuf {
let path = self.cargo_home.join("registry/src").join(INDEX).join(name);
fs::create_dir_all(&path).unwrap();
fs::write(path.join("lib.rs"), vec![b'x'; 4096]).unwrap();
self.sql()
.execute(
"INSERT INTO registry_src VALUES (2, ?1, 4096, ?2)",
rusqlite::params![name, unix_ago(age)],
)
.unwrap();
path
}
/// Record a git clone and a working tree checked out from it.
fn git_dependency(&self, db_age: Duration, checkout_age: Duration) -> (PathBuf, PathBuf) {
let db_path = self.cargo_home.join("git/db").join(GIT_DB);
let checkout = self
.cargo_home
.join("git/checkouts")
.join(GIT_DB)
.join("bb58e65");
fs::create_dir_all(&db_path).unwrap();
fs::create_dir_all(&checkout).unwrap();
fs::write(db_path.join("packed-refs"), vec![b'x'; 1024]).unwrap();
fs::write(checkout.join("lib.rs"), vec![b'x'; 1024]).unwrap();
let sql = self.sql();
sql.execute(
"INSERT INTO git_db (id, name, timestamp) VALUES (5, ?1, ?2)",
rusqlite::params![GIT_DB, unix_ago(db_age)],
)
.unwrap();
sql.execute(
"INSERT INTO git_checkout VALUES (5, 'bb58e65', 1024, ?1)",
rusqlite::params![unix_ago(checkout_age)],
)
.unwrap();
(db_path, checkout)
}
/// A managed project: built, then opted in the way a user would.
///
/// Nothing is managed by default, so a test that wants grunk to look at a
/// project has to say so — which is the whole shape of the tool.
fn project(&self, name: &str, age: Duration) -> Project {
let project = self.project_in(&self.projects.join(name), name, age);
self.manage(&project.root);
project
}
/// Opt a project in, as `cargo grunk add` does.
fn manage(&self, root: &Path) {
self.grunk_ok(&["add", root.to_str().unwrap()]);
}
/// A cargo project with a target directory last touched `age` ago, which
/// grunk does not yet know about.
///
/// The project has no dependencies, so nothing here needs the network or a
/// populated registry.
fn project_in(&self, root: &Path, name: &str, age: Duration) -> Project {
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("Cargo.toml"),
format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\n"
),
)
.unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
let target = root.join("target");
fs::create_dir_all(target.join("debug")).unwrap();
fs::write(target.join("CACHEDIR.TAG"), CARGO_TAG).unwrap();
fs::write(target.join("debug").join(name), vec![b'x'; 8192]).unwrap();
let project = Project {
root: root.to_path_buf(),
target,
};
project.age(age);
project
}
/// Run the binary the way cargo would: as `cargo grunk <args>`.
fn grunk(&self, args: &[&str]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_cargo-grunk"));
command
.arg("grunk")
.args(args)
.env("CARGO_HOME", &self.cargo_home)
.env("GRUNK_CONFIG", &self.config)
// Point the metadata lookups at the cargo that is running these
// tests, whatever CARGO_HOME now says.
.env("CARGO", env!("CARGO"))
.current_dir(&self.projects);
command.output().unwrap()
}
fn grunk_ok(&self, args: &[&str]) -> String {
let output = self.grunk(args);
assert!(
output.status.success(),
"`cargo grunk {}` failed:\n{}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn rows(&self, table: &str) -> i64 {
self.sql()
.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
row.get(0)
})
.unwrap()
}
}
struct Project {
root: PathBuf,
target: PathBuf,
}
impl Project {
/// Backdate every file in the target directory, as if last built `age` ago.
fn age(&self, age: Duration) {
let stamp = FileTime::from_system_time(SystemTime::now() - age);
for entry in walk(&self.target) {
filetime::set_file_mtime(&entry, stamp).unwrap();
}
filetime::set_file_mtime(&self.target, stamp).unwrap();
}
}
fn walk(dir: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let Ok(entries) = fs::read_dir(dir) else {
return found;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
found.extend(walk(&path));
}
found.push(path);
}
found
}
fn unix_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
fn unix_ago(age: Duration) -> i64 {
(SystemTime::now() - age)
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
#[test]
fn status_reports_what_is_reclaimable_without_touching_it() {
let fixture = Fixture::new();
let stale = fixture.project("stale", 30 * DAY);
let fresh = fixture.project("fresh", Duration::from_secs(60));
let old_crate = fixture.crate_tarball("serde-1.0.0", 30 * DAY);
let stdout = fixture.grunk_ok(&["status"]);
assert!(stdout.contains("target"), "{stdout}");
assert!(stdout.contains("registry-crate"), "{stdout}");
// The stale target is reclaimable and named; the fresh one is held back.
assert!(stdout.contains("stale"), "{stdout}");
assert!(
stdout.contains("too new") || stdout.contains("TOO NEW"),
"{stdout}"
);
assert!(stale.target.exists(), "status must not delete anything");
assert!(fresh.target.exists());
assert!(old_crate.exists());
}
#[test]
fn clean_removes_what_is_stale_and_spares_what_is_not() {
let fixture = Fixture::new();
let stale = fixture.project("stale", 30 * DAY);
let fresh = fixture.project("fresh", Duration::from_secs(60));
fixture.grunk_ok(&["clean"]);
assert!(!stale.target.exists(), "a 30-day-old target should be gone");
assert!(
fresh.target.exists(),
"a target built a minute ago is under the 3d threshold and must survive"
);
}
#[test]
fn clean_never_touches_sources() {
let fixture = Fixture::new();
let project = fixture.project("stale", 30 * DAY);
fixture.grunk_ok(&["clean"]);
assert!(!project.target.exists());
assert!(project.root.join("Cargo.toml").is_file());
assert!(project.root.join("src/main.rs").is_file());
}
#[test]
fn clean_ages_cargo_home_entries_by_cargos_own_timestamps() {
let fixture = Fixture::new();
let old = fixture.crate_tarball("old-1.0.0", 30 * DAY);
let recent = fixture.crate_tarball("recent-1.0.0", Duration::from_secs(3600));
let old_src = fixture.unpacked_src("old-1.0.0", 30 * DAY);
fixture.grunk_ok(&["clean"]);
assert!(!old.exists(), "a tarball unused for 30 days should be gone");
assert!(
recent.exists(),
"a tarball used an hour ago is under the threshold"
);
assert!(!old_src.exists());
}
#[test]
fn clean_retires_the_tracking_row_with_the_files() {
// A row left behind would tell cargo the crate is still cached, and cargo
// would go looking for a file that is no longer there.
let fixture = Fixture::new();
fixture.crate_tarball("old-1.0.0", 30 * DAY);
fixture.unpacked_src("old-1.0.0", 30 * DAY);
assert_eq!(fixture.rows("registry_crate"), 1);
fixture.grunk_ok(&["clean"]);
assert_eq!(fixture.rows("registry_crate"), 0);
assert_eq!(fixture.rows("registry_src"), 0);
// The index is never reclaimed, so its row must survive.
assert_eq!(fixture.rows("registry_index"), 1);
}
#[test]
fn a_git_clone_survives_while_a_checkout_of_it_is_in_use() {
// The clone itself has not been touched in 30 days, but a working tree from
// it was used an hour ago. Deleting the clone would take the checkout with
// it, so neither may go.
let fixture = Fixture::new();
let (db, checkout) = fixture.git_dependency(30 * DAY, Duration::from_secs(3600));
fixture.grunk_ok(&["clean"]);
assert!(db.exists(), "a clone with a live checkout must survive");
assert!(checkout.exists());
assert_eq!(fixture.rows("git_db"), 1);
assert_eq!(fixture.rows("git_checkout"), 1);
}
#[test]
fn a_git_clone_goes_once_its_checkouts_are_stale_too() {
let fixture = Fixture::new();
let (db, checkout) = fixture.git_dependency(30 * DAY, 30 * DAY);
fixture.grunk_ok(&["clean"]);
assert!(!db.exists());
assert!(!checkout.exists());
assert_eq!(fixture.rows("git_db"), 0);
assert_eq!(fixture.rows("git_checkout"), 0, "rows should cascade");
}
#[test]
fn dry_run_reports_exactly_what_a_clean_then_does() {
let fixture = Fixture::new();
let stale = fixture.project("stale", 30 * DAY);
fixture.crate_tarball("old-1.0.0", 30 * DAY);
let dry = fixture.grunk_ok(&["clean", "--dry-run"]);
assert!(dry.contains("Nothing was deleted"), "{dry}");
assert!(stale.target.exists(), "--dry-run must not delete");
assert_eq!(fixture.rows("registry_crate"), 1);
let real = fixture.grunk_ok(&["clean"]);
assert!(!stale.target.exists());
assert_eq!(fixture.rows("registry_crate"), 0);
// Both passes should have agreed on the byte count.
let dry_bytes = extract_size(&dry, "Would reclaim ");
let real_bytes = extract_size(&real, "Reclaimed ");
assert_eq!(
dry_bytes, real_bytes,
"dry run said {dry_bytes:?} but the clean reclaimed {real_bytes:?}\n\
--- dry ---\n{dry}\n--- real ---\n{real}"
);
}
fn extract_size(output: &str, prefix: &str) -> Option<String> {
let start = output.find(prefix)? + prefix.len();
let rest = &output[start..];
let end = rest.find(" in ")?;
Some(rest[..end].to_string())
}
#[test]
fn min_age_on_the_command_line_overrides_the_config() {
let fixture = Fixture::new();
let fresh = fixture.project("fresh", Duration::from_secs(60));
// The config says 3d, so nothing happens by default.
fixture.grunk_ok(&["clean"]);
assert!(fresh.target.exists());
fixture.grunk_ok(&["clean", "--min-age", "0s"]);
assert!(
!fresh.target.exists(),
"--min-age 0s should take everything"
);
}
#[test]
fn a_per_category_threshold_overrides_the_blanket_one() {
let fixture = Fixture::new();
fixture.write_config("min_age = \"3d\"\n\n[min_age_overrides]\ntarget = \"60d\"\n");
let project = fixture.project("stale", 30 * DAY);
let tarball = fixture.crate_tarball("old-1.0.0", 30 * DAY);
fixture.grunk_ok(&["clean"]);
assert!(
project.target.exists(),
"targets are held for 60d by this config, and this one is 30d old"
);
assert!(!tarball.exists(), "the tarball is still on the blanket 3d");
}
#[test]
fn categories_can_be_cleaned_one_at_a_time() {
let fixture = Fixture::new();
let project = fixture.project("stale", 30 * DAY);
let tarball = fixture.crate_tarball("old-1.0.0", 30 * DAY);
fixture.grunk_ok(&["clean", "--category", "target"]);
assert!(!project.target.exists());
assert!(tarball.exists(), "only targets were asked for");
}
#[test]
fn add_brings_in_a_project_grunk_knows_nothing_about() {
let fixture = Fixture::new();
let outside = fixture._tmp.path().join("elsewhere/outsider");
let project = fixture.project_in(&outside, "outsider", 30 * DAY);
// Nothing is managed until it is opted in, so nothing sees it yet.
fixture.grunk_ok(&["clean"]);
assert!(project.target.exists());
fixture.grunk_ok(&["add", outside.to_str().unwrap()]);
let listed = fixture.grunk_ok(&["list"]);
assert!(listed.contains("outsider"), "{listed}");
assert!(listed.contains("explicit"), "{listed}");
fixture.grunk_ok(&["clean"]);
assert!(!project.target.exists());
}
#[test]
fn remove_undoes_add_and_leaves_the_rest_of_the_config_alone() {
let fixture = Fixture::new();
fixture.write_config("# a comment worth keeping\nmin_age = \"3d\"\n");
let outside = fixture._tmp.path().join("elsewhere/outsider");
let project = fixture.project_in(&outside, "outsider", 30 * DAY);
fixture.grunk_ok(&["add", outside.to_str().unwrap()]);
fixture.grunk_ok(&["remove", outside.to_str().unwrap()]);
let config = fs::read_to_string(&fixture.config).unwrap();
assert!(config.contains("# a comment worth keeping"), "{config}");
assert!(config.contains("min_age = \"3d\""), "{config}");
fixture.grunk_ok(&["clean"]);
assert!(project.target.exists(), "it should no longer be managed");
}
#[test]
fn scan_manages_what_it_finds_and_nothing_manages_it_before_that() {
// The 75% of projects `.crates.toml` cannot see: never installed, so the
// only way to find them is to go looking — and grunk only goes looking when
// told to.
let fixture = Fixture::new();
let never_installed = fixture.project_in(
&fixture.projects.join("never-installed"),
"never-installed",
30 * DAY,
);
fixture.grunk_ok(&["clean"]);
assert!(
never_installed.target.exists(),
"nothing is managed until it is opted in"
);
let scanned = fixture.grunk_ok(&["scan", fixture.projects.to_str().unwrap()]);
assert!(scanned.contains("never-installed"), "{scanned}");
let listed = fixture.grunk_ok(&["list"]);
assert!(listed.contains("never-installed"), "{listed}");
assert!(
listed.contains("explicit"),
"scan should opt it in:\n{listed}"
);
fixture.grunk_ok(&["clean"]);
assert!(!never_installed.target.exists());
}
#[test]
fn scan_finds_a_project_that_has_never_been_built() {
// A `target/`-based scan can only see projects that have been compiled.
// Scanning for manifests instead means an unbuilt project is found and
// opted in now, so it is managed the moment it is first built.
let fixture = Fixture::new();
let root = fixture.projects.join("unbuilt");
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"unbuilt\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n",
)
.unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
assert!(!root.join("target").exists());
let scanned = fixture.grunk_ok(&["scan", fixture.projects.to_str().unwrap()]);
assert!(scanned.contains("unbuilt"), "{scanned}");
let listed = fixture.grunk_ok(&["list"]);
assert!(listed.contains("unbuilt"), "{listed}");
}
#[test]
fn scan_skips_projects_that_are_already_managed() {
let fixture = Fixture::new();
fixture.project("managed", 30 * DAY);
let scanned = fixture.grunk_ok(&["scan", fixture.projects.to_str().unwrap()]);
assert!(
scanned.contains("no projects that are not already managed"),
"{scanned}"
);
}
#[test]
fn scan_dry_run_changes_nothing() {
let fixture = Fixture::new();
let project = fixture.project_in(&fixture.projects.join("proj"), "proj", 30 * DAY);
let scanned = fixture.grunk_ok(&["scan", "--dry-run", fixture.projects.to_str().unwrap()]);
assert!(scanned.contains("proj"), "{scanned}");
assert!(scanned.contains("Nothing was changed"), "{scanned}");
fixture.grunk_ok(&["clean"]);
assert!(
project.target.exists(),
"--dry-run must not opt anything in"
);
}
#[test]
fn scan_reports_what_each_project_is_worth() {
// The size is the reason to manage a project or not, so scan pays for it.
let fixture = Fixture::new();
fixture.project_in(&fixture.projects.join("proj"), "proj", 30 * DAY);
let scanned = fixture.grunk_ok(&["scan", "--dry-run", fixture.projects.to_str().unwrap()]);
assert!(
scanned.contains("KiB") || scanned.contains("B "),
"{scanned}"
);
}
#[test]
fn a_directory_named_target_that_cargo_did_not_make_is_left_alone() {
let fixture = Fixture::new();
let project = fixture.project("real", 30 * DAY);
// A project whose `target` holds precious hand-made things and no tag.
let impostor = fixture.projects.join("impostor");
fs::create_dir_all(impostor.join("target")).unwrap();
fs::write(
impostor.join("Cargo.toml"),
"[package]\nname = \"impostor\"\n",
)
.unwrap();
let precious = impostor.join("target/precious.txt");
fs::write(&precious, "do not delete").unwrap();
fixture.manage(&impostor);
filetime::set_file_mtime(
&precious,
FileTime::from_system_time(SystemTime::now() - 90 * DAY),
)
.unwrap();
fixture.grunk_ok(&["clean"]);
assert!(!project.target.exists());
assert!(
precious.is_file(),
"without cargo's tag, a `target` directory is not cargo's to delete"
);
}
#[test]
fn an_old_target_without_cargos_tag_is_still_cleaned() {
// Cargo only began tagging target directories in 1.42; builds older than
// that carry `.rustc_info.json` but no `CACHEDIR.TAG`. They are just as
// regenerable, and are often the stalest, largest thing on disk — so a
// clean must reach them, not walk past them for want of a tag.
let fixture = Fixture::new();
let root = fixture.projects.join("ancient");
let target = root.join("target");
fs::create_dir_all(root.join("src")).unwrap();
fs::create_dir_all(target.join("debug")).unwrap();
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"ancient\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n",
)
.unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
fs::write(target.join(".rustc_info.json"), "{}").unwrap();
fs::write(target.join("debug").join("ancient"), vec![b'x'; 8192]).unwrap();
assert!(!target.join("CACHEDIR.TAG").exists(), "no tag, as old cargo left it");
let old = FileTime::from_system_time(SystemTime::now() - 90 * DAY);
for entry in walk(&target) {
filetime::set_file_mtime(&entry, old).unwrap();
}
filetime::set_file_mtime(&target, old).unwrap();
fixture.manage(&root);
fixture.grunk_ok(&["clean"]);
assert!(
!target.exists(),
"an old, untagged, long-stale target must be reclaimed"
);
assert!(
root.join("Cargo.toml").is_file(),
"cleaning must never touch the sources"
);
}
#[test]
fn nothing_to_clean_is_reported_rather_than_claimed_as_success() {
let fixture = Fixture::new();
fixture.project("fresh", Duration::from_secs(60));
let stdout = fixture.grunk_ok(&["clean"]);
assert!(stdout.contains("Nothing to clean"), "{stdout}");
assert!(stdout.contains("too new"), "{stdout}");
}
#[test]
fn clean_says_when_it_has_not_measured_what_it_keeps() {
// A clean skips measuring targets it will not touch, which is the whole
// reason a daily run is quick. It has to say so rather than print a total
// that quietly leaves them out.
let fixture = Fixture::new();
fixture.project("fresh", Duration::from_secs(60));
let stdout = fixture.grunk_ok(&["clean"]);
assert!(stdout.contains("Nothing to clean"), "{stdout}");
assert!(stdout.contains("1 items"), "{stdout}");
assert!(
!stdout.contains("GiB") && !stdout.contains("MiB") && !stdout.contains(" B "),
"a clean must not claim a size it never measured:\n{stdout}"
);
assert!(
stdout.contains("status"),
"should point at the exact number"
);
}
#[test]
fn status_still_measures_what_is_being_kept() {
let fixture = Fixture::new();
fixture.project("fresh", Duration::from_secs(60));
let stdout = fixture.grunk_ok(&["status", "--all"]);
assert!(stdout.contains("Held back for being too new"), "{stdout}");
assert!(stdout.contains("fresh"), "{stdout}");
assert!(
!stdout.contains("unmeasured"),
"a survey measures everything:\n{stdout}"
);
}
#[test]
fn clean_and_status_agree_on_what_may_go() {
// The two do different amounts of work to reach the same verdict. If they
// ever parted company, `status` would be describing some other clean.
let fixture = Fixture::new();
let stale = fixture.project("stale", 30 * DAY);
let fresh = fixture.project("fresh", Duration::from_secs(60));
fixture.crate_tarball("old-1.0.0", 30 * DAY);
let status = fixture.grunk_ok(&["status", "--all"]);
assert!(status.contains("stale/target"), "{status}");
fixture.grunk_ok(&["clean"]);
assert!(!stale.target.exists(), "status called it reclaimable");
assert!(fresh.target.exists(), "status called it too new");
}
#[test]
fn a_target_a_package_outgrew_is_still_cleaned() {
// A package built on its own, then absorbed into a workspace, keeps the
// target directory it had at the time. Cargo builds elsewhere now and never
// mentions the old one — so asking cargo, which is how grunk locates build
// output, walks straight past several megabytes of its own leavings.
let fixture = Fixture::new();
let workspace = fixture.projects.join("ws");
let member = workspace.join("member");
// The member, with the target it had back when it stood alone.
let stale = fixture.project_in(&member, "member", 30 * DAY);
// The workspace that has since swallowed it. Nothing has been built since,
// so the workspace's own target does not exist — cargo simply reports where
// it *would* go, which is somewhere other than the directory above.
fs::write(
workspace.join("Cargo.toml"),
"[workspace]\nmembers = [\"member\"]\nresolver = \"2\"\n",
)
.unwrap();
fixture.manage(&member);
fixture.grunk_ok(&["clean"]);
assert!(
!stale.target.exists(),
"the target the package outgrew is still cargo's output, and still reclaimable"
);
assert!(member.join("Cargo.toml").is_file(), "sources are untouched");
}
#[test]
fn the_binary_works_under_both_its_names() {
// Cargo re-inserts the subcommand name, so `cargo grunk list` arrives as
// `cargo-grunk grunk list`. Run directly, there is no such argument.
let fixture = Fixture::new();
fixture.project("proj", 30 * DAY);
let direct = Command::new(env!("CARGO_BIN_EXE_cargo-grunk"))
.arg("list")
.env("CARGO_HOME", &fixture.cargo_home)
.env("GRUNK_CONFIG", &fixture.config)
.env("CARGO", env!("CARGO"))
.output()
.unwrap();
assert!(direct.status.success());
assert_eq!(
String::from_utf8_lossy(&direct.stdout),
fixture.grunk_ok(&["list"]),
"both invocations should behave identically"
);
}