dev-prune 1.7.0

Universal, lockfile-safe workspace pruner and background dependency cleaner
Documentation
// Copyright 2026 VKrishna04
// SPDX-License-Identifier: Apache-2.0

// Bun adapter implementation.

use super::{
    BloatDir, EnforcePolicy, PackageManager, dir_size_with_hardlinks,
    lock_sync_or_verify_with_timeout, run_command_with_timeout,
};
use anyhow::Result;
use std::path::Path;

/// Bun package manager adapter.
pub struct Bun;

impl PackageManager for Bun {
    /// Returns the name of the package manager.
    fn name(&self) -> &'static str {
        "bun"
    }

    /// Detects if the project uses bun by checking for `bun.lockb` or `bun.lock`.
    fn detect(&self, project_dir: &Path) -> bool {
        project_dir.join("bun.lockb").exists() || project_dir.join("bun.lock").exists()
    }

    /// Returns the bloat directories for bun (node_modules).
    ///
    /// bun hardlinks packages out of `~/.bun/install/cache` on Linux and Windows, so
    /// deleting `node_modules` does not free those bytes — the cache keeps them. On
    /// macOS bun uses clonefile instead, which leaves the link count at 1 and is
    /// invisible to this measurement; APFS clones genuinely are freed block-by-block
    /// as the cache copy diverges, so counting them as freed is the honest reading.
    fn bloat_dirs(&self, project_dir: &Path) -> Vec<BloatDir> {
        let node_modules = project_dir.join("node_modules");
        if node_modules.exists() {
            let size = dir_size_with_hardlinks(&node_modules);
            vec![BloatDir {
                name: "node_modules".to_string(),
                path: node_modules,
                size_bytes: size.freed_bytes,
                shared_bytes: size.shared_bytes,
            }]
        } else {
            vec![]
        }
    }

    /// Verifies the lockfile resolves cleanly, without installing anything.
    ///
    /// `--dry-run` is essential, not cosmetic. A plain `bun install --frozen-lockfile`
    /// is a real install: it downloads every dependency and runs their lifecycle
    /// scripts — third-party code executed as a precondition for *deleting* the very
    /// tree it just built. With `--dry-run`, bun still resolves `package.json` against
    /// the lockfile and fails when they disagree, but writes nothing.
    ///
    /// bun is the one manager whose natural check was already read-only, so there is no
    /// writing form to opt into and `allow_rewrite` changes nothing here.
    fn enforce_lockfile(&self, project_dir: &Path, policy: EnforcePolicy) -> Result<()> {
        let lockfile = if project_dir.join("bun.lockb").exists() {
            project_dir.join("bun.lockb")
        } else {
            project_dir.join("bun.lock")
        };
        lock_sync_or_verify_with_timeout(
            &lockfile,
            "bun",
            &[
                "install",
                "--frozen-lockfile",
                "--dry-run",
                "--ignore-scripts",
            ],
            project_dir,
            policy.timeout,
        )
    }

    /// Restores the dependencies using the lockfile.
    fn restore(&self, project_dir: &Path, timeout: std::time::Duration) -> Result<()> {
        run_command_with_timeout(
            "bun",
            &["install", "--frozen-lockfile"],
            project_dir,
            timeout,
        )
    }

    fn lockfiles(&self) -> &'static [&'static str] {
        &["bun.lockb", "bun.lock"]
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn test_name() {
        assert_eq!(Bun.name(), "bun");
    }

    #[test]
    fn test_detect_positive_lockb() {
        let dir = tempdir().unwrap();
        fs::File::create(dir.path().join("bun.lockb")).unwrap();
        assert!(Bun.detect(dir.path()));
    }

    #[test]
    fn test_detect_positive_lock() {
        let dir = tempdir().unwrap();
        fs::File::create(dir.path().join("bun.lock")).unwrap();
        assert!(Bun.detect(dir.path()));
    }

    #[test]
    fn test_detect_negative() {
        let dir = tempdir().unwrap();
        assert!(!Bun.detect(dir.path()));
    }

    #[test]
    fn test_bloat_dirs_present() {
        let dir = tempdir().unwrap();
        fs::create_dir(dir.path().join("node_modules")).unwrap();
        let bloat = Bun.bloat_dirs(dir.path());
        assert_eq!(bloat.len(), 1);
        assert_eq!(bloat[0].path, dir.path().join("node_modules"));
    }

    #[test]
    fn test_bloat_dirs_absent() {
        let dir = tempdir().unwrap();
        let bloat = Bun.bloat_dirs(dir.path());
        assert!(bloat.is_empty());
    }
}