cargo_home.rs raw

use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use anyhow::{Context, Result, bail};
use fs4::fs_std::FileExt;
use rayon::prelude::*;
use rusqlite::{Connection, OpenFlags};

use crate::fsutil::{Measurement, human_size, measure, remove};
use crate::policy::{Intent, Policy};
use crate::progress::{Reporter, plural};
use crate::reclaim::{CacheRow, Category, EntryKey, Findings, Provider, Reclaimable, Retained};

/// A tracked artifact as the global cache database describes it, resolved to
/// the path it occupies but not yet checked against the disk.
struct TrackedRow {
    path: PathBuf,
    /// The size cargo recorded, if it recorded one.
    size: Option<i64>,
    timestamp: i64,
    row: CacheRow,
}

/// Cargo's own record of when each cached artifact was last used.
const GLOBAL_CACHE_DB: &str = ".global-cache";

/// The lock file cargo holds while mutating its caches.
const PACKAGE_CACHE_LOCK: &str = ".package-cache";

/// How many cached artifacts go by between progress reports.
const PROGRESS_EVERY: usize = 500;

/// A cargo home directory: `$CARGO_HOME`, usually `~/.cargo`.
#[derive(Debug, Clone)]
pub struct CargoHome {
    root: PathBuf,
}

impl CargoHome {
    pub fn open() -> Result<Self> {
        let root = home::cargo_home().context("locating CARGO_HOME")?;
        Ok(Self::at(root))
    }

    pub fn at(root: PathBuf) -> Self {
        Self { root }
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    pub fn db_path(&self) -> PathBuf {
        self.root.join(GLOBAL_CACHE_DB)
    }

    /// Whether cargo has ever tracked cache usage here.
    pub fn tracks_usage(&self) -> bool {
        self.db_path().is_file()
    }

    /// Take the same exclusive lock cargo takes before touching its caches.
    ///
    /// Without this, a concurrent `cargo build` could be downloading a crate
    /// into the very directory being deleted. The lock file and protocol are
    /// cargo's, not ours; we are just another participant.
    fn lock(&self) -> Result<CacheLock> {
        let path = self.root.join(PACKAGE_CACHE_LOCK);
        let file = OpenOptions::new()
            .read(true)
            .write(true)
            .create(true)
            .open(&path)
            .with_context(|| format!("opening {}", path.display()))?;
        FileExt::lock_exclusive(&file).with_context(|| format!("locking {}", path.display()))?;
        Ok(CacheLock { file })
    }
}

/// Cargo's package cache lock, released on drop.
struct CacheLock {
    file: std::fs::File,
}

impl Drop for CacheLock {
    fn drop(&mut self) {
        let _ = FileExt::unlock(&self.file);
    }
}

/// The reclaimable contents of a cargo home, as tracked by its global cache DB.
///
/// Everything here is driven by cargo's `.global-cache` database rather than by
/// file timestamps. That database is the only honest record of when a cached
/// artifact was last *used*: the mtime of an unpacked source directory says
/// when it was extracted, which can be months before the last build that
/// depended on it. Ageing against mtime would mean deleting things in daily use.
///
/// The registry index is deliberately not reclaimable. Cargo refreshes it on
/// demand and it is small; deleting it buys almost nothing.
pub struct GlobalCache {
    home: CargoHome,
}

impl GlobalCache {
    pub fn new(home: CargoHome) -> Self {
        Self { home }
    }

    fn open_db(&self, flags: OpenFlags) -> Result<Connection> {
        Connection::open_with_flags(self.home.db_path(), flags)
            .with_context(|| format!("opening {}", self.home.db_path().display()))
    }

    /// Sort tracked rows into what may go and what stays.
    ///
    /// The ages and sizes both come out of the database, so an entry can be
    /// judged without touching the disk at all. The only reason to go near it
    /// is to check the files are still there — a tracked artifact with no files
    /// is a stale row, and counting it would promise bytes that are not there
    /// to free.
    ///
    /// A survey pays for that check, because a person is reading the totals. A
    /// clean does not: it is a few thousand seeks to confirm records that are
    /// almost always right, and nothing downstream needs them. Eligible rows
    /// get deleted either way — deletion tolerates a file that has already gone
    /// and reports what it actually removed, so the "reclaimed" figure stays
    /// exact regardless. Only the count of what was held back can drift, by
    /// including rows whose files went missing behind cargo's back.
    fn entries(&self, category: Category, rows: Vec<TrackedRow>, policy: &Policy) -> Findings {
        let rows = match policy.intent() {
            // Independent stats, none waiting on another, so spread the waiting
            // across the pool. `into_par_iter` preserves row order, keeping the
            // report stable from run to run.
            Intent::Survey => rows
                .into_par_iter()
                .filter(|tracked| tracked.path.exists())
                .collect(),
            Intent::Clean => rows,
        };

        let mut findings = Findings::default();
        for tracked in rows {
            let last_used = unix_seconds(tracked.timestamp);

            if policy.is_eligible(category, last_used) {
                findings.reclaimable.push(Reclaimable {
                    category,
                    size: self.size_of(&tracked),
                    path: tracked.path,
                    last_used,
                    key: EntryKey::CacheRow(tracked.row),
                });
            } else {
                // Same bargain as a target directory: a clean does not price up
                // what it is keeping. Most rows would cost nothing to size,
                // since cargo recorded it, and a clean could report those for
                // free — but not the ones cargo left blank, which need a walk.
                // Sizing whichever happened to be cheap would make the report
                // depend on what cargo bothered to write down. One rule
                // instead: a clean counts what it keeps, a survey measures it.
                let measured = match policy.intent() {
                    Intent::Survey => Some(Measurement {
                        size: self.size_of(&tracked),
                        newest: last_used,
                    }),
                    Intent::Clean => None,
                };
                findings.retained.push(Retained {
                    category,
                    path: tracked.path,
                    measured,
                });
            }
        }
        findings
    }

    /// What a tracked artifact occupies.
    ///
    /// Cargo records sizes as it populates the cache, which saves walking
    /// several thousand directories just to print a total. Only the rows it
    /// left blank cost a walk — and that walk parallelises internally, so it
    /// happens out here rather than inside a `par_iter`, which would starve it
    /// of the very threads it needs.
    fn size_of(&self, tracked: &TrackedRow) -> u64 {
        match tracked.size {
            Some(size) if size >= 0 => size as u64,
            _ => measure(&tracked.path).map(|m| m.size).unwrap_or(0),
        }
    }

    fn scan_registry_crates(&self, db: &Connection, policy: &Policy) -> Result<Findings> {
        let mut stmt = db.prepare(
            "SELECT i.name, c.registry_id, c.name, c.size, c.timestamp
             FROM registry_crate c
             JOIN registry_index i ON i.id = c.registry_id",
        )?;
        let rows = stmt
            .query_map([], |row| {
                let index: String = row.get(0)?;
                let registry_id: i64 = row.get(1)?;
                let name: String = row.get(2)?;
                Ok(TrackedRow {
                    // The tracked name already carries the `.crate` extension.
                    path: self
                        .home
                        .root
                        .join("registry/cache")
                        .join(index)
                        .join(&name),
                    size: row.get(3)?,
                    timestamp: row.get(4)?,
                    row: CacheRow::RegistryCrate { registry_id, name },
                })
            })?
            .collect::<rusqlite::Result<Vec<_>>>()?;

        Ok(self.entries(Category::RegistryCrate, rows, policy))
    }

    fn scan_registry_srcs(&self, db: &Connection, policy: &Policy) -> Result<Findings> {
        let mut stmt = db.prepare(
            "SELECT i.name, s.registry_id, s.name, s.size, s.timestamp
             FROM registry_src s
             JOIN registry_index i ON i.id = s.registry_id",
        )?;
        let rows = stmt
            .query_map([], |row| {
                let index: String = row.get(0)?;
                let registry_id: i64 = row.get(1)?;
                let name: String = row.get(2)?;
                Ok(TrackedRow {
                    path: self.home.root.join("registry/src").join(index).join(&name),
                    size: row.get(3)?,
                    timestamp: row.get(4)?,
                    row: CacheRow::RegistrySrc { registry_id, name },
                })
            })?
            .collect::<rusqlite::Result<Vec<_>>>()?;

        Ok(self.entries(Category::RegistrySrc, rows, policy))
    }

    fn scan_git_checkouts(&self, db: &Connection, policy: &Policy) -> Result<Findings> {
        let mut stmt = db.prepare(
            "SELECT d.name, c.git_id, c.name, c.size, c.timestamp
             FROM git_checkout c
             JOIN git_db d ON d.id = c.git_id",
        )?;
        let rows = stmt
            .query_map([], |row| {
                let db_name: String = row.get(0)?;
                let git_id: i64 = row.get(1)?;
                let name: String = row.get(2)?;
                Ok(TrackedRow {
                    path: self
                        .home
                        .root
                        .join("git/checkouts")
                        .join(db_name)
                        .join(&name),
                    size: row.get(3)?,
                    timestamp: row.get(4)?,
                    row: CacheRow::GitCheckout { git_id, name },
                })
            })?
            .collect::<rusqlite::Result<Vec<_>>>()?;

        Ok(self.entries(Category::GitCheckout, rows, policy))
    }

    /// Bare git clones, aged against their checkouts as well as themselves.
    ///
    /// A checkout is derived from its db, and cargo's schema cascades a db's
    /// deletion to its checkouts. So a db is only as idle as its liveliest
    /// checkout: taking `MAX` here is what stops an old clone from being
    /// deleted out from under a working tree that was used this morning.
    fn scan_git_dbs(&self, db: &Connection, policy: &Policy) -> Result<Findings> {
        let mut stmt = db.prepare(
            "SELECT d.id, d.name, MAX(d.timestamp, COALESCE(MAX(c.timestamp), 0))
             FROM git_db d
             LEFT JOIN git_checkout c ON c.git_id = d.id
             GROUP BY d.id, d.name, d.timestamp",
        )?;
        let rows = stmt
            .query_map([], |row| {
                let id: i64 = row.get(0)?;
                let name: String = row.get(1)?;
                Ok(TrackedRow {
                    path: self.home.root.join("git/db").join(&name),
                    // Cargo records no size for a bare clone, so it is measured.
                    size: None,
                    timestamp: row.get(2)?,
                    row: CacheRow::GitDb { id },
                })
            })?
            .collect::<rusqlite::Result<Vec<_>>>()?;

        Ok(self.entries(Category::GitDb, rows, policy))
    }

    /// Every path a cache row owns on disk.
    ///
    /// Deleting a git db strands its checkouts — they are worthless without the
    /// clone they came from, and cargo's schema drops their rows by cascade —
    /// so they go at the same time.
    fn owned_paths(&self, item: &Reclaimable, db: &Connection) -> Result<Vec<PathBuf>> {
        let EntryKey::CacheRow(CacheRow::GitDb { id }) = &item.key else {
            return Ok(vec![item.path.clone()]);
        };

        let mut stmt = db.prepare("SELECT name FROM git_db WHERE id = ?1")?;
        let mut paths = vec![item.path.clone()];
        let names = stmt.query_map([id], |row| row.get::<_, String>(0))?;
        for name in names {
            paths.push(self.home.root.join("git/checkouts").join(name?));
        }
        Ok(paths)
    }
}

impl Provider for GlobalCache {
    fn scan(&self, policy: &Policy, progress: &Reporter) -> Result<Findings> {
        let mut findings = Findings::default();
        if !self.home.tracks_usage() {
            return Ok(findings);
        }

        // Instant for a clean, several thousand seeks for a survey — so it is
        // worth naming either way, and worth naming before rather than after.
        progress.phase("Examining the cargo home:");
        progress.start(self.home.root().display());

        let db = self.open_db(OpenFlags::SQLITE_OPEN_READ_ONLY)?;

        // A category nobody asked for is not queried at all; each one skipped
        // is a few thousand `exists` calls not made.
        if policy.wants(Category::RegistryCrate) {
            findings.absorb(self.scan_registry_crates(&db, policy)?);
        }
        if policy.wants(Category::RegistrySrc) {
            findings.absorb(self.scan_registry_srcs(&db, policy)?);
        }
        if policy.wants(Category::GitCheckout) {
            findings.absorb(self.scan_git_checkouts(&db, policy)?);
        }
        if policy.wants(Category::GitDb) {
            findings.absorb(self.scan_git_dbs(&db, policy)?);
        }

        progress.finish(format_args!(
            "{} to reclaim, {} held back",
            human_size(
                findings
                    .reclaimable
                    .iter()
                    .map(|item| item.size)
                    .sum::<u64>()
            ),
            findings.retained.len()
        ));
        Ok(findings)
    }

    fn remove(&mut self, items: &[Reclaimable], progress: &Reporter) -> Result<Vec<Reclaimable>> {
        if items.is_empty() {
            return Ok(Vec::new());
        }

        progress.phase(format_args!(
            "Deleting {} cached {} from the cargo home:",
            items.len(),
            plural(items.len(), "artifact", "artifacts")
        ));

        let _lock = self.home.lock()?;
        let mut db = self.open_db(OpenFlags::SQLITE_OPEN_READ_WRITE)?;
        let tx = db.transaction()?;

        let mut removed = Vec::with_capacity(items.len());
        for (done, item) in items.iter().enumerate() {
            // Thousands of small deletions: each one is far too dull to narrate,
            // and on a cold spinning disk they are collectively slow enough to
            // need something said. A count every so often is the compromise —
            // proof of progress without a line per crate.
            if done > 0 && done.is_multiple_of(PROGRESS_EVERY) {
                progress.line(format_args!("{done}/{} deleted", items.len()));
            }
            let EntryKey::CacheRow(row) = &item.key else {
                bail!(
                    "{} is not tracked by the global cache and cannot be removed from here",
                    item.path.display()
                );
            };

            // Files first, then the row. A row still standing over deleted
            // files just makes cargo refetch something it already would have;
            // the reverse leaves bytes on disk that nothing will ever reclaim,
            // because cargo has forgotten they exist.
            let mut existed = false;
            for path in self.owned_paths(item, &tx)? {
                existed |= remove(&path)?;
            }
            delete_row(&tx, row)?;
            // The row goes either way — it tracks something that is now
            // definitely absent. But bytes are only counted if there were any.
            if existed {
                removed.push(item.clone());
            }
        }

        tx.commit()?;
        progress.line(format_args!("{}/{} deleted", removed.len(), items.len()));
        Ok(removed)
    }
}

fn delete_row(db: &Connection, row: &CacheRow) -> Result<()> {
    match row {
        CacheRow::RegistryCrate { registry_id, name } => db.execute(
            "DELETE FROM registry_crate WHERE registry_id = ?1 AND name = ?2",
            rusqlite::params![registry_id, name],
        ),
        CacheRow::RegistrySrc { registry_id, name } => db.execute(
            "DELETE FROM registry_src WHERE registry_id = ?1 AND name = ?2",
            rusqlite::params![registry_id, name],
        ),
        CacheRow::GitCheckout { git_id, name } => db.execute(
            "DELETE FROM git_checkout WHERE git_id = ?1 AND name = ?2",
            rusqlite::params![git_id, name],
        ),
        // Checkout rows go by cascade, matching the files removed alongside.
        CacheRow::GitDb { id } => {
            db.execute("DELETE FROM git_db WHERE id = ?1", rusqlite::params![id])
        }
    }
    .with_context(|| format!("deleting cache row {row:?}"))?;
    Ok(())
}

/// Cargo stores timestamps as whole seconds since the epoch.
fn unix_seconds(seconds: i64) -> SystemTime {
    if seconds >= 0 {
        SystemTime::UNIX_EPOCH + Duration::from_secs(seconds as u64)
    } else {
        SystemTime::UNIX_EPOCH - Duration::from_secs(seconds.unsigned_abs())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    use std::fs;

    use tempfile::TempDir;

    /// Cargo's schema, shared with the end-to-end tests so both agree on what
    /// they are testing against.
    fn create_schema(db: &Connection) {
        db.execute_batch(include_str!("../fixtures/global-cache-schema.sql"))
            .unwrap();
    }

    const INDEX: &str = "index.crates.io-1949cf8c6b5b557f";
    const GIT_DB: &str = "badgebuilder-e22ab1d71dcafcb2";

    /// The size cargo recorded for the crate tarball, which is deliberately
    /// nothing like the size of the file the fixture actually writes.
    const TRACKED_CRATE_SIZE: u64 = 4096;

    /// The payload written into the unpacked source directory, big enough that
    /// a measured size is unmistakably it and not directory overhead.
    const SRC_PAYLOAD: usize = 10_000;

    struct Fixture {
        _tmp: TempDir,
        home: CargoHome,
    }

    impl Fixture {
        /// A cargo home with one crate, one src, one git db and one checkout.
        fn new() -> Self {
            let tmp = TempDir::new().unwrap();
            let root = tmp.path().to_path_buf();

            let write = |rel: &str, contents: &[u8]| {
                let path = root.join(rel);
                fs::create_dir_all(path.parent().unwrap()).unwrap();
                fs::write(&path, contents).unwrap();
            };
            let dir = |rel: &str, contents: &[u8]| {
                fs::create_dir_all(root.join(rel)).unwrap();
                fs::write(root.join(rel).join("file"), contents).unwrap();
            };

            write(
                &format!("registry/cache/{INDEX}/serde-1.0.0.crate"),
                b"tiny",
            );
            dir(
                &format!("registry/src/{INDEX}/serde-1.0.0"),
                &vec![b'x'; SRC_PAYLOAD],
            );
            dir(&format!("git/db/{GIT_DB}"), b"contents");
            dir(&format!("git/checkouts/{GIT_DB}/bb58e65"), b"contents");

            let db = Connection::open(root.join(GLOBAL_CACHE_DB)).unwrap();
            create_schema(&db);
            db.execute(
                "INSERT INTO registry_index (id, name, timestamp) VALUES (2, ?1, 100)",
                [INDEX],
            )
            .unwrap();
            db.execute(
                "INSERT INTO registry_crate VALUES (2, 'serde-1.0.0.crate', ?1, 1000)",
                [TRACKED_CRATE_SIZE],
            )
            .unwrap();
            db.execute(
                "INSERT INTO registry_src VALUES (2, 'serde-1.0.0', NULL, 2000)",
                [],
            )
            .unwrap();
            db.execute(
                "INSERT INTO git_db (id, name, timestamp) VALUES (5, ?1, 3000)",
                [GIT_DB],
            )
            .unwrap();
            db.execute(
                "INSERT INTO git_checkout VALUES (5, 'bb58e65', NULL, 4000)",
                [],
            )
            .unwrap();
            drop(db);

            Self {
                _tmp: tmp,
                home: CargoHome::at(root),
            }
        }

        fn cache(&self) -> GlobalCache {
            GlobalCache::new(self.home.clone())
        }

        fn root(&self) -> &Path {
            self.home.root()
        }

        fn rows(&self, table: &str) -> i64 {
            let db = Connection::open(self.home.db_path()).unwrap();
            db.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
                .unwrap()
        }
    }

    /// A policy that finds everything eligible, so scans return it all as
    /// reclaimable and these tests can talk about paths and sizes.
    fn take_everything() -> Policy {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("grunk.toml");
        fs::write(&path, "min_age = \"0s\"\n").unwrap();
        Policy::new(
            &crate::config::Config::load_from(&path).unwrap(),
            &crate::policy::Selection::default(),
            crate::policy::Intent::Survey,
        )
    }

    fn find(items: &[Reclaimable], category: Category) -> &Reclaimable {
        items
            .iter()
            .find(|i| i.category == category)
            .unwrap_or_else(|| panic!("no {category} entry in {items:#?}"))
    }

    #[test]
    fn scan_maps_every_row_to_its_path() {
        let fixture = Fixture::new();
        let items = fixture
            .cache()
            .scan(&take_everything(), &Reporter::silent())
            .unwrap()
            .reclaimable;

        assert_eq!(items.len(), 4, "{items:#?}");
        assert_eq!(
            find(&items, Category::RegistryCrate).path,
            fixture
                .root()
                .join(format!("registry/cache/{INDEX}/serde-1.0.0.crate"))
        );
        assert_eq!(
            find(&items, Category::RegistrySrc).path,
            fixture
                .root()
                .join(format!("registry/src/{INDEX}/serde-1.0.0"))
        );
        assert_eq!(
            find(&items, Category::GitCheckout).path,
            fixture
                .root()
                .join(format!("git/checkouts/{GIT_DB}/bb58e65"))
        );
        assert_eq!(
            find(&items, Category::GitDb).path,
            fixture.root().join(format!("git/db/{GIT_DB}"))
        );
    }

    #[test]
    fn scan_prefers_the_tracked_size_and_measures_when_absent() {
        let fixture = Fixture::new();
        let items = fixture
            .cache()
            .scan(&take_everything(), &Reporter::silent())
            .unwrap()
            .reclaimable;

        // Cargo recorded a size, so it is used rather than walking the file —
        // which is why this reads 4096 and not the four bytes actually written.
        assert_eq!(
            find(&items, Category::RegistryCrate).size,
            TRACKED_CRATE_SIZE
        );

        // Cargo recorded no size for the unpacked sources, so they get measured
        // off the disk. The payload dominates, plus a little for the directory
        // itself, which occupies space too.
        let measured = find(&items, Category::RegistrySrc).size;
        assert!(
            (SRC_PAYLOAD as u64..SRC_PAYLOAD as u64 + 4096).contains(&measured),
            "expected roughly the {SRC_PAYLOAD}-byte payload, got {measured}"
        );
    }

    #[test]
    fn scan_uses_cargos_timestamps() {
        let fixture = Fixture::new();
        let items = fixture
            .cache()
            .scan(&take_everything(), &Reporter::silent())
            .unwrap()
            .reclaimable;

        assert_eq!(
            find(&items, Category::RegistryCrate).last_used,
            unix_seconds(1000)
        );
        assert_eq!(
            find(&items, Category::RegistrySrc).last_used,
            unix_seconds(2000)
        );
    }

    #[test]
    fn a_git_db_is_as_recent_as_its_newest_checkout() {
        // The db row says 3000, but its checkout was used at 4000. Ageing the
        // db at 3000 would let a threshold delete a clone whose working tree is
        // in active use.
        let fixture = Fixture::new();
        let items = fixture
            .cache()
            .scan(&take_everything(), &Reporter::silent())
            .unwrap()
            .reclaimable;

        assert_eq!(find(&items, Category::GitDb).last_used, unix_seconds(4000));
    }

    #[test]
    fn scan_skips_rows_whose_files_are_gone() {
        let fixture = Fixture::new();
        fs::remove_file(
            fixture
                .root()
                .join(format!("registry/cache/{INDEX}/serde-1.0.0.crate")),
        )
        .unwrap();

        let items = fixture
            .cache()
            .scan(&take_everything(), &Reporter::silent())
            .unwrap()
            .reclaimable;
        assert!(!items.iter().any(|i| i.category == Category::RegistryCrate));
        assert_eq!(items.len(), 3);
    }

    #[test]
    fn a_home_without_tracking_has_nothing_to_offer() {
        let tmp = TempDir::new().unwrap();
        let home = CargoHome::at(tmp.path().to_path_buf());

        assert!(!home.tracks_usage());
        let findings = GlobalCache::new(home)
            .scan(&take_everything(), &Reporter::silent())
            .unwrap();
        assert!(findings.reclaimable.is_empty());
        assert!(findings.retained.is_empty());
    }

    #[test]
    fn removing_takes_the_files_and_the_row_together() {
        let fixture = Fixture::new();
        let mut cache = fixture.cache();
        let items = cache
            .scan(&take_everything(), &Reporter::silent())
            .unwrap()
            .reclaimable;
        let crate_entry = find(&items, Category::RegistryCrate).clone();

        let removed = cache
            .remove(std::slice::from_ref(&crate_entry), &Reporter::silent())
            .unwrap();

        assert_eq!(removed.len(), 1);
        assert_eq!(removed[0].size, 4096);
        assert!(!crate_entry.path.exists());
        assert_eq!(
            fixture.rows("registry_crate"),
            0,
            "the row must go with the file, or cargo still believes it is there"
        );
        // Nothing else was touched.
        assert_eq!(fixture.rows("registry_src"), 1);
        assert_eq!(fixture.rows("git_db"), 1);
    }

    #[test]
    fn removing_a_git_db_takes_its_checkouts_too() {
        let fixture = Fixture::new();
        let mut cache = fixture.cache();
        let items = cache
            .scan(&take_everything(), &Reporter::silent())
            .unwrap()
            .reclaimable;
        let git_db = find(&items, Category::GitDb).clone();
        let checkout = find(&items, Category::GitCheckout).path.clone();

        cache
            .remove(std::slice::from_ref(&git_db), &Reporter::silent())
            .unwrap();

        assert!(!git_db.path.exists());
        assert!(
            !checkout.exists(),
            "a checkout is worthless without the clone it came from"
        );
        assert!(
            !fixture
                .root()
                .join(format!("git/checkouts/{GIT_DB}"))
                .exists()
        );
        assert_eq!(fixture.rows("git_db"), 0);
        assert_eq!(
            fixture.rows("git_checkout"),
            0,
            "checkout rows should have gone by cascade"
        );
    }

    #[test]
    fn removing_nothing_touches_nothing() {
        let fixture = Fixture::new();
        let removed = fixture.cache().remove(&[], &Reporter::silent()).unwrap();

        assert!(removed.is_empty());
        assert_eq!(fixture.rows("registry_crate"), 1);
    }

    #[test]
    fn removing_everything_empties_the_home_and_the_db() {
        let fixture = Fixture::new();
        let mut cache = fixture.cache();
        let items = cache
            .scan(&take_everything(), &Reporter::silent())
            .unwrap()
            .reclaimable;

        let removed = cache.remove(&items, &Reporter::silent()).unwrap();

        assert_eq!(removed.len(), 4);
        for item in &items {
            assert!(!item.path.exists(), "{} survived", item.path.display());
        }
        assert_eq!(fixture.rows("registry_crate"), 0);
        assert_eq!(fixture.rows("registry_src"), 0);
        assert_eq!(fixture.rows("git_db"), 0);
        assert_eq!(fixture.rows("git_checkout"), 0);
        // The index itself is never reclaimed.
        assert_eq!(fixture.rows("registry_index"), 1);
    }

    #[test]
    fn scanning_again_after_a_full_clean_finds_nothing() {
        let fixture = Fixture::new();
        let mut cache = fixture.cache();
        let items = cache
            .scan(&take_everything(), &Reporter::silent())
            .unwrap()
            .reclaimable;
        cache.remove(&items, &Reporter::silent()).unwrap();

        assert!(
            cache
                .scan(&take_everything(), &Reporter::silent())
                .unwrap()
                .reclaimable
                .is_empty()
        );
    }
}