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