Skip to main content

dev_prune/adapters/
composer.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Composer adapter for PHP projects.
5//
6// Not opt-in, unlike the build-tool adapters: `vendor/` is a *download* restore.
7// `composer install` reads `composer.lock` and puts back the exact versions recorded in
8// it, the same relationship `package-lock.json` has with `node_modules`.
9
10use super::{
11    BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
12};
13use anyhow::Result;
14use std::path::Path;
15
16/// Composer package manager adapter.
17pub struct Composer;
18
19impl PackageManager for Composer {
20    fn name(&self) -> &'static str {
21        "composer"
22    }
23
24    /// The manifest, not the lockfile: a project that has never installed still has a
25    /// `composer.json`, and reporting the manager it uses is useful before there is any
26    /// bloat to report.
27    fn detect(&self, path: &Path) -> bool {
28        path.join("composer.json").exists()
29    }
30
31    /// `vendor/`, but only when it is Composer's own.
32    ///
33    /// Bundler configured with `bundle config set path vendor/bundle` puts its gems
34    /// inside the same directory. Deleting `vendor/` in such a repository would take
35    /// them with it under a proof that says nothing about them, and no
36    /// `composer install` puts them back. Rare enough to decline rather than model:
37    /// Bundler still claims `vendor/bundle` and prunes it under its own lockfile.
38    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
39        let vendor = path.join("vendor");
40        if !vendor.is_dir() || vendor.join("bundle").is_dir() {
41            return Vec::new();
42        }
43        vec![BloatDir {
44            name: "vendor".to_string(),
45            path: vendor.clone(),
46            size_bytes: dir_size(&vendor),
47            shared_bytes: 0,
48        }]
49    }
50
51    /// `composer validate` is the read-only side: it writes nothing and fails when
52    /// `composer.lock` is no longer in sync with `composer.json`, which is exactly the
53    /// question a prune has to answer. `--no-check-publish` drops the "this package
54    /// could not be published" complaints (missing `description`, `license`) and
55    /// `--no-check-all` the constraint-style nags — neither has anything to do with
56    /// whether the lockfile can rebuild `vendor/`.
57    ///
58    /// The write side is `composer update --no-install`, which re-resolves and writes
59    /// the lockfile without touching `vendor/`.
60    fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
61        enforce_two_tier(
62            &path.join("composer.lock"),
63            "composer",
64            &[
65                "validate",
66                "--no-check-publish",
67                "--no-check-all",
68                "--no-interaction",
69            ],
70            &["update", "--no-install", "--no-interaction"],
71            path,
72            policy,
73        )
74    }
75
76    fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
77        run_command_with_timeout("composer", &["install", "--no-interaction"], path, timeout)
78    }
79
80    fn lockfiles(&self) -> &'static [&'static str] {
81        &["composer.lock"]
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::fs;
89    use tempfile::tempdir;
90
91    #[test]
92    fn detects_on_the_manifest() {
93        let dir = tempdir().unwrap();
94        assert!(!Composer.detect(dir.path()));
95        fs::write(dir.path().join("composer.json"), "{}").unwrap();
96        assert!(Composer.detect(dir.path()));
97    }
98
99    #[test]
100    fn claims_vendor_when_present() {
101        let dir = tempdir().unwrap();
102        assert!(Composer.bloat_dirs(dir.path()).is_empty());
103        fs::create_dir(dir.path().join("vendor")).unwrap();
104        let dirs = Composer.bloat_dirs(dir.path());
105        assert_eq!(dirs.len(), 1);
106        assert_eq!(dirs[0].name, "vendor");
107    }
108
109    #[test]
110    fn declines_a_vendor_directory_bundler_is_living_in() {
111        let dir = tempdir().unwrap();
112        fs::create_dir_all(dir.path().join("vendor").join("bundle")).unwrap();
113        assert!(Composer.bloat_dirs(dir.path()).is_empty());
114    }
115}