Skip to main content

vivacity_core/
installer.rs

1//! The install transaction: diff (lock vs installed state), parallel fetch
2//! into the store, store-to-vendor clone, bin proxies, state files, runtime
3//! stub. Idempotent (rerun after an interruption, it converges): the reference
4//! state is `installed.json` + the presence of the directories, and each
5//! package is laid out by cloning into a previously removed vendor/<name>.
6
7use crate::error::{Error, Result};
8use crate::fetch::{Fetcher, Provenance};
9use crate::layout::Layout;
10use crate::lock::{DistKind, Lock, LockPackage};
11use crate::state::RootPackage;
12use crate::store::Store;
13use serde_json::Value;
14use std::collections::BTreeMap;
15use std::path::Path;
16use std::sync::Arc;
17
18pub struct InstallOptions {
19    pub with_dev: bool,
20    pub offline: bool,
21    /// Download/extraction parallelism.
22    pub jobs: usize,
23    /// No `--no-plugins`: Flex, when installed and allowed, lays out
24    /// `symfony-pack` packages as metapackages.
25    pub plugins_enabled: bool,
26}
27
28impl Default for InstallOptions {
29    fn default() -> Self {
30        InstallOptions {
31            with_dev: true,
32            offline: false,
33            jobs: 16,
34            plugins_enabled: true,
35        }
36    }
37}
38
39#[derive(Debug, Default)]
40pub struct InstallReport {
41    pub installed: usize,
42    pub removed: usize,
43    pub unchanged: usize,
44    pub from_cache: usize,
45    pub from_network: usize,
46    pub store_hits: usize,
47    /// Unchanged packages extracted into the store (pre-existing vendor).
48    pub store_warmed: usize,
49    /// The local repository after the transaction: the lock's entries for
50    /// the packages installed or updated, the previous installed.json
51    /// entries for the unchanged ones (Composer keeps the loaded objects and
52    /// dumps them back; the autoloader is generated from them too).
53    pub local_repository: Option<Lock>,
54    /// Lines Composer prints on stderr during the operations and vivacity
55    /// reproduces (`Skipped installation of bin …`).
56    pub messages: Vec<String>,
57}
58
59/// Installed identity of a package: version + dist reference.
60/// What `Transaction::calculateOperations` compares to decide that a present
61/// package and a resolved one are the same, and so that there is no
62/// operation: the version, BOTH references, and the abandoned mark. A
63/// narrower identity leaves a package unplaced and its installed.json entry
64/// carried over, where Composer re-installs it and rewrites the entry from
65/// the lock.
66type Identity = (String, String, String, String);
67
68fn identity(p: &LockPackage) -> Identity {
69    let field = |a: &str, b: &str| {
70        p.raw
71            .get(a)
72            .and_then(|v| v.get(b))
73            .and_then(Value::as_str)
74            .unwrap_or("")
75            .to_owned()
76    };
77    (
78        p.version().to_owned(),
79        p.dist_reference().unwrap_or("").to_owned(),
80        field("source", "reference"),
81        abandoned_mark(p.raw.get("abandoned")),
82    )
83}
84
85/// `abandoned` is `false`, `true` or a replacement name, and `ArrayDumper`
86/// omits it when false: the three states as one comparable string.
87fn abandoned_mark(v: Option<&Value>) -> String {
88    match v {
89        None | Some(Value::Bool(false)) | Some(Value::Null) => String::new(),
90        Some(Value::Bool(true)) => "true".to_owned(),
91        Some(Value::String(s)) => s.clone(),
92        Some(other) => other.to_string(),
93    }
94}
95
96/// What installed.json says of a package: its identity and, for a `path`
97/// package, the source it was laid out from.
98struct Installed {
99    identity: Identity,
100    path_source: Option<String>,
101    /// The entry as written, for a package that stays.
102    raw: serde_json::Map<String, Value>,
103}
104
105fn installed_packages(composer_dir: &Path) -> BTreeMap<String, Installed> {
106    let mut out = BTreeMap::new();
107    let path = composer_dir.join("installed.json");
108    let Some(v) = crate::jsonfile::read(&path) else {
109        return out;
110    };
111    for p in v["packages"].as_array().into_iter().flatten() {
112        let name = p["name"].as_str().unwrap_or_default();
113        let version = p["version"].as_str().unwrap_or_default();
114        let reference = p["dist"]["reference"].as_str().unwrap_or_default();
115        let source_reference = p["source"]["reference"].as_str().unwrap_or_default();
116        let path_source = (p["dist"]["type"].as_str() == Some("path"))
117            .then(|| p["dist"]["url"].as_str().map(str::to_owned))
118            .flatten();
119        out.insert(
120            name.to_owned(),
121            Installed {
122                identity: (
123                    version.to_owned(),
124                    reference.to_owned(),
125                    source_reference.to_owned(),
126                    abandoned_mark(p.get("abandoned")),
127                ),
128                path_source,
129                raw: p.as_object().cloned().unwrap_or_default(),
130            },
131        );
132    }
133    out
134}
135
136/// The local repository the state files and the autoloader are produced
137/// from: the lock, where an unchanged package keeps the entry installed.json
138/// already had (minus what is recomputed).
139fn local_repository(
140    lock: &Lock,
141    previous: &BTreeMap<String, Installed>,
142    unchanged_names: &std::collections::BTreeSet<&str>,
143    flex_ready: bool,
144) -> Lock {
145    let mut local = lock.clone();
146    for p in local
147        .packages
148        .iter_mut()
149        .chain(local.packages_dev.iter_mut())
150    {
151        if unchanged_names.contains(p.name()) {
152            // Untouched by this run: Composer dumps the package object it
153            // loaded from installed.json, so the entry keeps what it had —
154            // `installation-source` included, whether or not it is there.
155            if let Some(prev) = previous.get(p.name()) {
156                let mut raw = prev.raw.clone();
157                for key in ["version_normalized", "install-path"] {
158                    raw.remove(key);
159                }
160                p.raw = raw;
161            }
162            continue;
163        }
164        // Laid out by this run: the DOWNLOAD phase sets the source, and it
165        // runs for the whole batch before any operation executes. A
166        // metapackage is never downloaded; a `symfony-pack` escapes it only
167        // when Flex was already registered by then (`flex_ready`) —
168        // installed in the same run, the pack is downloaded first and laid
169        // out afterwards by Flex's metapackage installer.
170        p.raw.remove("installation-source");
171        if !p.is_virtual(flex_ready) {
172            p.raw.insert(
173                "installation-source".to_owned(),
174                serde_json::Value::String("dist".to_owned()),
175            );
176        }
177    }
178    local
179}
180
181/// The local repository `install` will produce when its transaction is
182/// empty (every wanted package present in installed.json with the same
183/// identity, its directory in place): computable before the install runs,
184/// so the autoloader can be planned while the install waits on the
185/// network. `None` when a wanted package is not that — the caller then
186/// plans after the install, as before.
187pub fn local_repository_if_unchanged(lock: &Lock, layout: &Layout, with_dev: bool) -> Option<Lock> {
188    let previous = installed_packages(&layout.composer_dir());
189    let mut unchanged: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
190    for p in lock.wanted_packages(with_dev) {
191        if previous.get(p.name()).map(|i| &i.identity) != Some(&identity(p)) {
192            return None;
193        }
194        if layout.install_path(p.name()).is_some()
195            && !layout.abs(p.name()).is_some_and(|d| d.is_dir())
196        {
197            return None;
198        }
199        unchanged.insert(p.name());
200    }
201    // Every package is unchanged here, so the flag is not consulted.
202    Some(local_repository(lock, &previous, &unchanged, false))
203}
204
205pub async fn install(
206    _project_dir: &Path,
207    lock: &Lock,
208    root_manifest: &Value,
209    layout: &Layout,
210    store: Arc<Store>,
211    fetcher: Arc<Fetcher>,
212    opts: &InstallOptions,
213) -> Result<InstallReport> {
214    // Absolute root (the layout's): the relative paths of the proxies and of
215    // the state files must not depend on a relative --working-dir.
216    let project_dir = layout.root();
217    let vendor = layout.vendor_dir();
218    let bin_dir = layout.bin_dir();
219    std::fs::create_dir_all(&vendor).map_err(Error::io(&vendor))?;
220
221    let mut report = InstallReport::default();
222    let wanted: Vec<&LockPackage> = lock.wanted_packages(opts.with_dev).collect();
223    let wanted_names: std::collections::BTreeSet<&str> = wanted.iter().map(|p| p.name()).collect();
224    // `Factory::purgePackages`: a package of installed.json whose install
225    // path is gone is not installed at all (a fresh install, not an
226    // update — no `removeBinaries`, no removal of the old path).
227    let mut previous = installed_packages(&layout.composer_dir());
228    previous.retain(|name, installed| {
229        // `purgePackages` asks the installer: `MetapackageInstaller::
230        // isInstalled` answers from the repository alone, so a package
231        // nothing is laid out for (a metapackage, a Flex pack) is never
232        // purged — there is no path to look at.
233        if installed
234            .raw
235            .get("type")
236            .and_then(serde_json::Value::as_str)
237            == Some("metapackage")
238            || layout.install_path(name).is_none()
239        {
240            return true;
241        }
242        layout
243            .abs(name)
244            .or_else(|| layout.removals().find(|(n, _)| n == name).map(|(_, d)| d))
245            .is_some_and(|d| d.exists())
246    });
247
248    // To lay out: changed identity, or missing directory. Unchanged packages
249    // whose store entry is missing (vendor/ laid out by Composer before vivacity)
250    // are extracted into the store without being re-cloned: the classmap
251    // cache applies from the next run on.
252    let mut to_install: Vec<&LockPackage> = Vec::new();
253    let mut to_warm: Vec<&LockPackage> = Vec::new();
254    let mut unchanged_names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
255    for p in &wanted {
256        // Installed nowhere (a metapackage, a Flex pack): the layout has no
257        // path for it.
258        if layout.install_path(p.name()).is_none() {
259            if previous.get(p.name()).map(|i| &i.identity) == Some(&identity(p)) {
260                unchanged_names.insert(p.name());
261            }
262            continue;
263        }
264        // `LibraryInstaller::isInstalled`: a dangling link is not installed
265        // (`is_dir` follows links).
266        let unchanged = previous.get(p.name()).map(|i| &i.identity) == Some(&identity(p))
267            && layout.abs(p.name()).is_some_and(|d| d.is_dir());
268        if unchanged {
269            unchanged_names.insert(p.name());
270            report.unchanged += 1;
271            if matches!(p.dist_kind(), DistKind::Zip | DistKind::Tar)
272                && !store.contains(p.name(), p.version(), p.store_reference().as_deref())
273            {
274                to_warm.push(p);
275            }
276        } else {
277            to_install.push(p);
278        }
279    }
280    // `PathDownloader::download`: a package cannot be laid out inside its
281    // own source; checked before anything is written.
282    for p in &to_install {
283        if p.dist_kind() == DistKind::Path {
284            if let (Some(dest), Some(url)) = (layout.abs(p.name()), p.dist_url()) {
285                crate::path_install::check_not_inside_source(project_dir, &dest, url, p.name())?;
286            }
287        }
288    }
289
290    // Fetch + extraction into the store, with bounded parallelism. Packages to
291    // "warm" only use the local cache (never the network) and their failure
292    // is silent: it is an optimisation, not an obligation.
293    let sem = Arc::new(tokio::sync::Semaphore::new(opts.jobs.max(1)));
294    let mut tasks = tokio::task::JoinSet::new();
295    let warm_names: std::collections::BTreeSet<&str> = to_warm.iter().map(|p| p.name()).collect();
296    for p in to_install.iter().chain(to_warm.iter()) {
297        if p.dist_kind() == DistKind::Path {
298            continue;
299        }
300        if store.contains(p.name(), p.version(), p.store_reference().as_deref()) {
301            report.store_hits += 1;
302            continue;
303        }
304        let warm_only =
305            warm_names.contains(p.name()) && !to_install.iter().any(|q| q.name() == p.name());
306        let (name, version) = (p.name().to_owned(), p.version().to_owned());
307        let dist_ref = p.store_reference();
308        let kind = if p.dist_kind() == DistKind::Tar {
309            crate::store::DistKind::Tar
310        } else {
311            crate::store::DistKind::Zip
312        };
313        let url = p.dist_url_expanded().ok_or_else(|| Error::Http {
314            url: name.clone(),
315            message:
316                "package without a dist url (the scope detector should have routed to the fallback)"
317                    .to_owned(),
318        })?;
319        let shasum = p.dist_shasum().map(str::to_owned);
320        let (store, fetcher, sem) = (store.clone(), fetcher.clone(), sem.clone());
321        let offline = opts.offline || warm_only;
322        tasks.spawn(async move {
323            let _permit = sem.acquire().await.map_err(|_| Error::Http {
324                url: url.clone(),
325                message: "semaphore closed".to_owned(),
326            })?;
327            let dist_type = match kind {
328                crate::store::DistKind::Zip => "zip",
329                crate::store::DistKind::Tar => "tar",
330            };
331            let fetched = fetcher
332                .dist_bytes_of(&name, &url, dist_type, shasum.as_deref(), offline)
333                .await;
334            let (bytes, provenance) = match fetched {
335                Ok(v) => v,
336                // Warming: zip missing from the cache, do not insist.
337                Err(_) if warm_only => return Ok::<Option<Provenance>, Error>(None),
338                Err(e) => return Err(e),
339            };
340            let store_name = name.clone();
341            let version2 = version.clone();
342            let dist_ref2 = dist_ref.clone();
343            tokio::task::spawn_blocking(move || {
344                store.ensure(&store_name, &version2, dist_ref2.as_deref(), &bytes, kind)
345            })
346            .await
347            .map_err(|e| Error::Http {
348                url: name.clone(),
349                message: format!("extraction task interrupted: {e}"),
350            })??;
351            Ok::<Option<Provenance>, Error>(Some(provenance))
352        });
353    }
354    while let Some(joined) = tasks.join_next().await {
355        let provenance = joined.map_err(|e| Error::Http {
356            url: "join".to_owned(),
357            message: e.to_string(),
358        })??;
359        match provenance {
360            Some(Provenance::Cache) => report.from_cache += 1,
361            Some(Provenance::Network) => report.from_network += 1,
362            None => {}
363        }
364    }
365    report.store_warmed = to_warm.len();
366
367    // Removals: present before, no longer wanted, at the path validated by the
368    // layout (old install-path = recomputed path, like LibraryInstaller).
369    for name in previous.keys() {
370        if !wanted_names.contains(name.as_str()) {
371            report.removed += 1;
372        }
373    }
374    for (name, dir) in layout.removals() {
375        // `PathDownloader::remove`: the install path that *is* the source
376        // stays (", source is still present").
377        let own_source = previous
378            .get(name)
379            .and_then(|i| i.path_source.as_deref())
380            .is_some_and(|url| {
381                crate::path_install::is_own_source(project_dir, &dir.to_string_lossy(), url)
382            });
383        if own_source {
384            continue;
385        }
386        if std::fs::symlink_metadata(&dir).is_ok() {
387            crate::path_install::remove_path(&dir)?;
388            prune_empty_parent(project_dir, &dir);
389        }
390    }
391
392    // Layout: remove the old version, then clone from the store.
393    // Packages land in disjoint directories → fan-out where parallel I/O
394    // pays (Linux: sylius vendor/ wiped 3.75 s -> 1.68 s on ext4/WSL2);
395    // sequential where it does not (APFS clonefile is metadata-bound and
396    // contends: 607 ms -> 635 ms on an M4 Max). Each package stays atomic
397    // (remove-before-clone); only the inter-package order changes, which
398    // affects nothing but mtimes. `VIVACITY_PARALLEL_IO=0|1` overrides.
399    let place = |p: &&LockPackage| -> Result<bool> {
400        let (Some(pkg_root), Some(dest)) = (layout.package_root(p.name()), layout.abs(p.name()))
401        else {
402            return Ok(false);
403        };
404        if p.dist_kind() == DistKind::Path {
405            let url = p.dist_url().unwrap_or_default();
406            // `FileDownloader::update` removes the previous layout before
407            // `install` (a link becomes a mirror when the options changed);
408            // a fresh install keeps a path that already resolves to the
409            // source.
410            if previous.contains_key(p.name()) {
411                crate::path_install::remove_path(&pkg_root)?;
412            }
413            crate::path_install::install(project_dir, &dest, url, p.raw.get("transport-options"))?;
414            return Ok(true);
415        }
416        // Always start again from an empty package root (target-dir included).
417        if std::fs::symlink_metadata(&pkg_root).is_ok() {
418            crate::path_install::remove_path(&pkg_root)?;
419        }
420        let src = store.entry_path(p.name(), p.version(), p.store_reference().as_deref());
421        crate::clone::clone_tree(&src, &dest)?;
422        Ok(true)
423    };
424    let placed: Vec<bool> = if crate::platform::parallel_io() {
425        use rayon::prelude::*;
426        to_install.par_iter().map(place).collect::<Result<_>>()?
427    } else {
428        to_install.iter().map(place).collect::<Result<_>>()?
429    };
430    let installed: usize = placed.into_iter().filter(|placed| *placed).count();
431    report.installed += installed;
432
433    // `BinaryInstaller::removeBinaries` runs `initializeBinDir` before
434    // looking at the package's binaries: an update, a removal, or the
435    // reinstall of a package still listed in installed.json creates
436    // vendor/bin even when nothing has a `bin`.
437    let touches_installed = to_install.iter().any(|p| previous.contains_key(p.name()))
438        || previous.keys().any(|n| !wanted_names.contains(n.as_str()));
439    if touches_installed {
440        std::fs::create_dir_all(&bin_dir).map_err(Error::io(&bin_dir))?;
441    }
442
443    // Bin proxies: rebuilt for the packages actually (re)placed
444    // (`BinaryInstaller::installBinaries` on install/update), and — like
445    // `Installer::run`'s `ensureBinariesPresence` over every installed
446    // package — written for an unchanged package only where the proxy is
447    // MISSING (`installBinaries(..., warnOnOverwrite: false)` skips an
448    // existing one): a wiped `vendor/bin` comes back on a no-op install,
449    // and a no-op install otherwise touches nothing. The purge of orphaned
450    // proxies (removed packages) runs on `wanted`. The `.bat` follows the
451    // resolved bin-compat (`full`, or `auto` on Windows/WSL), like
452    // Composer's BinaryInstaller — a plain Linux/macOS install writes no
453    // `.bat`.
454    let bin_compat = crate::binproxy::resolve_bin_compat(root_manifest)?;
455    let placed: std::collections::HashSet<&str> = to_install.iter().map(|p| p.name()).collect();
456    // `LibraryInstaller::update` removes the old version's binaries before
457    // installing the new ones (`removeBinaries` then `installBinaries`); a
458    // fresh install only adds. The bin directory is removed when a removal
459    // leaves it empty — and recreated by the next `initializeBinDir`.
460    let previous_bins = |name: &str| -> Vec<String> {
461        previous
462            .get(name)
463            .and_then(|i| i.raw.get("bin"))
464            .and_then(Value::as_array)
465            .map(|a| {
466                a.iter()
467                    .filter_map(Value::as_str)
468                    .map(str::to_owned)
469                    .collect()
470            })
471            .unwrap_or_default()
472    };
473    for p in &wanted {
474        let bins = p.bins();
475        let Some(dir) = layout.abs(p.name()) else {
476            continue;
477        };
478        if placed.contains(p.name()) && previous.contains_key(p.name()) {
479            let old = previous_bins(p.name());
480            let old: Vec<&str> = old.iter().map(String::as_str).collect();
481            crate::binproxy::remove_binaries(&bin_dir, &old)?;
482        }
483        if bins.is_empty() {
484            continue;
485        }
486        let missing = || {
487            bins.iter().any(|b| {
488                let b = b.trim_start_matches("./");
489                let link_name = b.rsplit_once('/').map(|(_, f)| f).unwrap_or(b);
490                dir.join(b).exists() && !bin_dir.join(link_name).exists()
491            })
492        };
493        if placed.contains(p.name()) {
494            report.messages.extend(crate::binproxy::install_binaries(
495                &vendor,
496                &bin_dir,
497                p.name(),
498                &dir,
499                &bins,
500                bin_compat,
501                true,
502            )?);
503        } else if missing() {
504            crate::binproxy::install_binaries(
505                &vendor,
506                &bin_dir,
507                p.name(),
508                &dir,
509                &bins,
510                bin_compat,
511                false,
512            )?;
513        }
514    }
515    // Removed packages: `removeBinaries` on each (their proxies and `.bat`).
516    for name in previous.keys() {
517        if !wanted_names.contains(name.as_str()) {
518            let old = previous_bins(name);
519            let old: Vec<&str> = old.iter().map(String::as_str).collect();
520            crate::binproxy::remove_binaries(&bin_dir, &old)?;
521        }
522    }
523    prune_stale_bat_proxies(&bin_dir, &wanted, bin_compat)?;
524
525    // State files + runtime stub, from the local repository: an unchanged
526    // package keeps the entry installed.json already had (its own
527    // `version_normalized`/`installation-source`/`install-path` are
528    // recomputed), so a lock that changed a package's metadata without
529    // changing its identity — routine with `path` packages whose reference
530    // is a git HEAD or none — leaves installed.json and the autoloader as
531    // Composer leaves them.
532    let flex_ready = opts.plugins_enabled
533        && previous.contains_key("symfony/flex")
534        && matches!(
535            crate::layout::plugin_allowed(root_manifest, "symfony/flex"),
536            crate::layout::PluginVerdict::Allowed
537        );
538    let local = local_repository(lock, &previous, &unchanged_names, flex_ready);
539    let root = RootPackage::detect(root_manifest, project_dir, opts.with_dev);
540    crate::state::write_state_files(
541        &layout.composer_dir(),
542        &local,
543        &root,
544        root_manifest,
545        opts.with_dev,
546        layout,
547    )?;
548    report.local_repository = Some(local);
549
550    Ok(report)
551}
552
553/// `LibraryInstaller::uninstall`: the parent directory of the removed package
554/// (vendor/<ns>, web/app/plugins...) is removed if empty, never the project
555/// root.
556fn prune_empty_parent(project_dir: &Path, removed: &Path) {
557    let Some(parent) = removed.parent() else {
558        return;
559    };
560    if parent == project_dir {
561        return;
562    }
563    if std::fs::read_dir(parent)
564        .map(|mut d| d.next().is_none())
565        .unwrap_or(false)
566    {
567        let _ = std::fs::remove_dir(parent);
568    }
569}
570
571/// A `<bin>.bat` next to an expected proxy is the Windows proxy of that
572/// bin — kept only when the resolved bin-compat writes `.bat` proxies at
573/// all; otherwise a leftover from a previous full-mode install, purged so
574/// the tree converges on what Composer produces on a bare checkout.
575/// Nothing else is touched: the bin directory may be the project's own.
576fn prune_stale_bat_proxies(
577    bin_dir: &Path,
578    wanted: &[&LockPackage],
579    bin_compat: crate::binproxy::BinCompat,
580) -> Result<()> {
581    if bin_compat == crate::binproxy::BinCompat::Full {
582        return Ok(());
583    }
584    let Ok(entries) = std::fs::read_dir(bin_dir) else {
585        return Ok(());
586    };
587    let mut expected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
588    for p in wanted {
589        for bin in p.bins() {
590            let bin = bin.trim_start_matches("./");
591            let link = bin.rsplit_once('/').map(|(_, f)| f).unwrap_or(bin);
592            expected.insert(link.to_owned());
593        }
594    }
595    for entry in entries.flatten() {
596        let file_name = entry.file_name().to_string_lossy().into_owned();
597        // A declared bin named `<x>.bat` (paratest ships one) is a proxy
598        // in its own right, written like any other on unix: never stale.
599        if !expected.contains(&file_name)
600            && file_name
601                .strip_suffix(".bat")
602                .is_some_and(|stem| expected.contains(stem))
603        {
604            let p = entry.path();
605            std::fs::remove_file(&p).map_err(Error::io(&p))?;
606        }
607    }
608    Ok(())
609}
610
611#[cfg(test)]
612mod bat_prune_tests {
613    use super::*;
614
615    #[test]
616    fn a_declared_bat_bin_is_not_pruned() {
617        let d = tempfile::tempdir().expect("tmp");
618        let bin_dir = d.path().join("bin");
619        std::fs::create_dir_all(&bin_dir).expect("bin");
620        for f in ["paratest", "paratest.bat", "phpunit", "phpunit.bat"] {
621            std::fs::write(bin_dir.join(f), "x").expect("write");
622        }
623        // paratest declares `bin/paratest.bat` (a proxy of its own on unix);
624        // phpunit does not: its `.bat` is a leftover of a full-mode install.
625        let lock = Lock::parse(
626            &serde_json::json!({"packages": [
627                {"name": "brianium/paratest", "version": "7.3.2", "bin": ["bin/paratest", "bin/paratest.bat"]},
628                {"name": "phpunit/phpunit", "version": "11.0.0", "bin": ["phpunit"]}
629            ], "packages-dev": []})
630            .to_string(),
631        )
632        .expect("lock");
633        let wanted: Vec<&LockPackage> = lock.packages.iter().collect();
634        prune_stale_bat_proxies(&bin_dir, &wanted, crate::binproxy::BinCompat::Proxy)
635            .expect("prune");
636        assert!(
637            bin_dir.join("paratest.bat").exists(),
638            "a declared .bat bin was pruned"
639        );
640        assert!(
641            !bin_dir.join("phpunit.bat").exists(),
642            "the stale .bat was kept"
643        );
644    }
645}