Skip to main content

dev_prune/adapters/
bundler.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Bundler adapter for Ruby projects.
5//
6// Only the *vendored* install is claimed. Bundler's default is a shared gem home
7// outside the repository (rbenv's, rvm's, or the system one), which is not this tool's
8// business and is shared with every other project on the machine. A repository only
9// has gems inside it when someone ran `bundle config set path vendor/bundle`, and then
10// `bundle install` puts them back from `Gemfile.lock`.
11//
12// `.bundle/` is deliberately not claimed: it holds that path configuration, and
13// deleting it would send the next `bundle install` to the shared gem home instead of
14// back where the project asked for it.
15
16use super::{
17    BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
18};
19use anyhow::Result;
20use std::path::{Path, PathBuf};
21
22/// Bundler package manager adapter.
23pub struct Bundler;
24
25/// Where a vendored bundle lives, relative to the repository root.
26fn vendor_bundle(path: &Path) -> PathBuf {
27    path.join("vendor").join("bundle")
28}
29
30impl PackageManager for Bundler {
31    fn name(&self) -> &'static str {
32        "bundler"
33    }
34
35    fn detect(&self, path: &Path) -> bool {
36        path.join("Gemfile").exists()
37    }
38
39    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
40        let bundle = vendor_bundle(path);
41        if !bundle.is_dir() {
42            return Vec::new();
43        }
44        vec![BloatDir {
45            name: "vendor/bundle".to_string(),
46            path: bundle.clone(),
47            size_bytes: dir_size(&bundle),
48            shared_bytes: 0,
49        }]
50    }
51
52    /// `bundle lock --check` is Bundler's own read-only answer: it resolves the
53    /// `Gemfile` against `Gemfile.lock`, exits non-zero when the lockfile no longer
54    /// satisfies it, and writes nothing either way. Plain `bundle lock` is the write
55    /// side, reached only when there is no lockfile to preserve or the user opted into
56    /// rewrites.
57    fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
58        enforce_two_tier(
59            &path.join("Gemfile.lock"),
60            "bundle",
61            &["lock", "--check"],
62            &["lock"],
63            path,
64            policy,
65        )
66    }
67
68    fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
69        run_command_with_timeout("bundle", &["install"], path, timeout)
70    }
71
72    fn lockfiles(&self) -> &'static [&'static str] {
73        &["Gemfile.lock"]
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use std::fs;
81    use tempfile::tempdir;
82
83    #[test]
84    fn detects_on_the_gemfile() {
85        let dir = tempdir().unwrap();
86        assert!(!Bundler.detect(dir.path()));
87        fs::write(dir.path().join("Gemfile"), "source :rubygems").unwrap();
88        assert!(Bundler.detect(dir.path()));
89    }
90
91    #[test]
92    fn claims_only_a_vendored_bundle() {
93        let dir = tempdir().unwrap();
94        fs::write(dir.path().join("Gemfile"), "").unwrap();
95        assert!(Bundler.bloat_dirs(dir.path()).is_empty());
96        fs::create_dir_all(vendor_bundle(dir.path())).unwrap();
97        let dirs = Bundler.bloat_dirs(dir.path());
98        assert_eq!(dirs.len(), 1);
99        assert_eq!(dirs[0].name, "vendor/bundle");
100    }
101
102    #[test]
103    fn never_claims_the_bundle_config_directory() {
104        let dir = tempdir().unwrap();
105        fs::create_dir(dir.path().join(".bundle")).unwrap();
106        assert!(Bundler.bloat_dirs(dir.path()).is_empty());
107    }
108}