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"
    );
}