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, PathBuf};
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}
51
52/// Installed identity of a package: version + dist reference.
53fn identity(p: &LockPackage) -> (String, String) {
54    (
55        p.version().to_owned(),
56        p.dist_reference().unwrap_or("").to_owned(),
57    )
58}
59
60/// What installed.json says of a package: its identity and, for a `path`
61/// package, the source it was laid out from.
62struct Installed {
63    identity: (String, String),
64    path_source: Option<String>,
65    /// The entry as written, for a package that stays.
66    raw: serde_json::Map<String, Value>,
67}
68
69fn installed_packages(vendor: &Path) -> BTreeMap<String, Installed> {
70    let mut out = BTreeMap::new();
71    let path = vendor.join("composer/installed.json");
72    let Ok(text) = std::fs::read_to_string(&path) else {
73        return out;
74    };
75    let Ok(v) = serde_json::from_str::<Value>(&text) 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
97pub async fn install(
98    _project_dir: &Path,
99    lock: &Lock,
100    root_manifest: &Value,
101    layout: &Layout,
102    store: Arc<Store>,
103    fetcher: Arc<Fetcher>,
104    opts: &InstallOptions,
105) -> Result<InstallReport> {
106    // Absolute root (the layout's): the relative paths of the proxies and of
107    // the state files must not depend on a relative --working-dir.
108    let project_dir = layout.root();
109    let vendor = project_dir.join("vendor");
110    std::fs::create_dir_all(&vendor).map_err(Error::io(&vendor))?;
111
112    let mut report = InstallReport::default();
113    let wanted: Vec<&LockPackage> = lock.wanted_packages(opts.with_dev).collect();
114    let wanted_names: std::collections::BTreeSet<&str> = wanted.iter().map(|p| p.name()).collect();
115    // `Factory::purgePackages`: a package of installed.json whose install
116    // path is gone is not installed at all (a fresh install, not an
117    // update — no `removeBinaries`, no removal of the old path).
118    let mut previous = installed_packages(&vendor);
119    previous.retain(|name, _| {
120        layout
121            .abs(name)
122            .or_else(|| layout.removals().find(|(n, _)| n == name).map(|(_, d)| d))
123            .is_some_and(|d| d.exists())
124    });
125
126    // To lay out: changed identity, or missing directory. Unchanged packages
127    // whose store entry is missing (vendor/ laid out by Composer before vivacity)
128    // are extracted into the store without being re-cloned: the classmap
129    // cache applies from the next run on.
130    let mut to_install: Vec<&LockPackage> = Vec::new();
131    let mut to_warm: Vec<&LockPackage> = Vec::new();
132    let mut unchanged_names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
133    for p in &wanted {
134        if p.is_metapackage() {
135            if previous.get(p.name()).map(|i| &i.identity) == Some(&identity(p)) {
136                unchanged_names.insert(p.name());
137            }
138            continue;
139        }
140        // `LibraryInstaller::isInstalled`: a dangling link is not installed
141        // (`is_dir` follows links).
142        let unchanged = previous.get(p.name()).map(|i| &i.identity) == Some(&identity(p))
143            && layout.abs(p.name()).is_some_and(|d| d.is_dir());
144        if unchanged {
145            unchanged_names.insert(p.name());
146            report.unchanged += 1;
147            if p.dist_kind() == DistKind::Zip
148                && !store.contains(p.name(), p.version(), p.dist_reference())
149            {
150                to_warm.push(p);
151            }
152        } else {
153            to_install.push(p);
154        }
155    }
156    // `PathDownloader::download`: a package cannot be laid out inside its
157    // own source; checked before anything is written.
158    for p in &to_install {
159        if p.dist_kind() == DistKind::Path {
160            if let (Some(dest), Some(url)) = (layout.abs(p.name()), p.dist_url()) {
161                crate::path_install::check_not_inside_source(project_dir, &dest, url, p.name())?;
162            }
163        }
164    }
165
166    // Fetch + extraction into the store, with bounded parallelism. Packages to
167    // "warm" only use the local cache (never the network) and their failure
168    // is silent: it is an optimisation, not an obligation.
169    let sem = Arc::new(tokio::sync::Semaphore::new(opts.jobs.max(1)));
170    let mut tasks = tokio::task::JoinSet::new();
171    let warm_names: std::collections::BTreeSet<&str> = to_warm.iter().map(|p| p.name()).collect();
172    for p in to_install.iter().chain(to_warm.iter()) {
173        if p.dist_kind() == DistKind::Path {
174            continue;
175        }
176        if store.contains(p.name(), p.version(), p.dist_reference()) {
177            report.store_hits += 1;
178            continue;
179        }
180        let warm_only =
181            warm_names.contains(p.name()) && !to_install.iter().any(|q| q.name() == p.name());
182        let (name, version) = (p.name().to_owned(), p.version().to_owned());
183        let dist_ref = p.dist_reference().map(str::to_owned);
184        let url = p
185            .dist_url()
186            .ok_or_else(|| Error::Http {
187                url: name.clone(),
188                message:
189                    "package without a dist url (the scope detector should have routed to the fallback)"
190                        .to_owned(),
191            })?
192            .to_owned();
193        let shasum = p.dist_shasum().map(str::to_owned);
194        let (store, fetcher, sem) = (store.clone(), fetcher.clone(), sem.clone());
195        let offline = opts.offline || warm_only;
196        tasks.spawn(async move {
197            let _permit = sem.acquire().await.map_err(|_| Error::Http {
198                url: url.clone(),
199                message: "semaphore closed".to_owned(),
200            })?;
201            let fetched = fetcher
202                .dist_bytes(&name, &url, shasum.as_deref(), offline)
203                .await;
204            let (bytes, provenance) = match fetched {
205                Ok(v) => v,
206                // Warming: zip missing from the cache, do not insist.
207                Err(_) if warm_only => return Ok::<Option<Provenance>, Error>(None),
208                Err(e) => return Err(e),
209            };
210            let store_name = name.clone();
211            let version2 = version.clone();
212            let dist_ref2 = dist_ref.clone();
213            tokio::task::spawn_blocking(move || {
214                store.ensure(&store_name, &version2, dist_ref2.as_deref(), &bytes)
215            })
216            .await
217            .map_err(|e| Error::Http {
218                url: name.clone(),
219                message: format!("extraction task interrupted: {e}"),
220            })??;
221            Ok::<Option<Provenance>, Error>(Some(provenance))
222        });
223    }
224    while let Some(joined) = tasks.join_next().await {
225        let provenance = joined.map_err(|e| Error::Http {
226            url: "join".to_owned(),
227            message: e.to_string(),
228        })??;
229        match provenance {
230            Some(Provenance::Cache) => report.from_cache += 1,
231            Some(Provenance::Network) => report.from_network += 1,
232            None => {}
233        }
234    }
235    report.store_warmed = to_warm.len();
236
237    // Removals: present before, no longer wanted, at the path validated by the
238    // layout (old install-path = recomputed path, like LibraryInstaller).
239    for name in previous.keys() {
240        if !wanted_names.contains(name.as_str()) {
241            report.removed += 1;
242        }
243    }
244    for (name, dir) in layout.removals() {
245        // `PathDownloader::remove`: the install path that *is* the source
246        // stays (", source is still present").
247        let own_source = previous
248            .get(name)
249            .and_then(|i| i.path_source.as_deref())
250            .is_some_and(|url| {
251                crate::path_install::is_own_source(project_dir, &dir.to_string_lossy(), url)
252            });
253        if own_source {
254            continue;
255        }
256        if std::fs::symlink_metadata(&dir).is_ok() {
257            crate::path_install::remove_path(&dir)?;
258            prune_empty_parent(project_dir, &dir);
259        }
260    }
261
262    // Layout: remove the old version, then clone from the store.
263    // Packages land in disjoint directories → fan-out where parallel I/O
264    // pays (Linux: sylius vendor/ wiped 3.75 s -> 1.68 s on ext4/WSL2);
265    // sequential where it does not (APFS clonefile is metadata-bound and
266    // contends: 607 ms -> 635 ms on an M4 Max). Each package stays atomic
267    // (remove-before-clone); only the inter-package order changes, which
268    // affects nothing but mtimes. `VIVACITY_PARALLEL_IO=0|1` overrides.
269    let place = |p: &&LockPackage| -> Result<bool> {
270        let (Some(pkg_root), Some(dest)) = (layout.package_root(p.name()), layout.abs(p.name()))
271        else {
272            return Ok(false);
273        };
274        if p.dist_kind() == DistKind::Path {
275            let url = p.dist_url().unwrap_or_default();
276            // `FileDownloader::update` removes the previous layout before
277            // `install` (a link becomes a mirror when the options changed);
278            // a fresh install keeps a path that already resolves to the
279            // source.
280            if previous.contains_key(p.name()) {
281                crate::path_install::remove_path(&pkg_root)?;
282            }
283            crate::path_install::install(project_dir, &dest, url, p.raw.get("transport-options"))?;
284            return Ok(true);
285        }
286        // Always start again from an empty package root (target-dir included).
287        if std::fs::symlink_metadata(&pkg_root).is_ok() {
288            crate::path_install::remove_path(&pkg_root)?;
289        }
290        let src = store.entry_path(p.name(), p.version(), p.dist_reference());
291        crate::clone::clone_tree(&src, &dest)?;
292        Ok(true)
293    };
294    let placed: Vec<bool> = if crate::platform::parallel_io() {
295        use rayon::prelude::*;
296        to_install.par_iter().map(place).collect::<Result<_>>()?
297    } else {
298        to_install.iter().map(place).collect::<Result<_>>()?
299    };
300    let installed: usize = placed.into_iter().filter(|placed| *placed).count();
301    report.installed += installed;
302
303    // `BinaryInstaller::removeBinaries` runs `initializeBinDir` before
304    // looking at the package's binaries: an update, a removal, or the
305    // reinstall of a package still listed in installed.json creates
306    // vendor/bin even when nothing has a `bin`.
307    let touches_installed = to_install.iter().any(|p| previous.contains_key(p.name()))
308        || previous.keys().any(|n| !wanted_names.contains(n.as_str()));
309    if touches_installed {
310        let bin_dir = vendor.join("bin");
311        std::fs::create_dir_all(&bin_dir).map_err(Error::io(&bin_dir))?;
312    }
313
314    // Bin proxies: rebuilt for the packages actually (re)placed
315    // (`BinaryInstaller::installBinaries` on install/update), and — like
316    // `Installer::run`'s `ensureBinariesPresence` over every installed
317    // package — written for an unchanged package only where the proxy is
318    // MISSING (`installBinaries(..., warnOnOverwrite: false)` skips an
319    // existing one): a wiped `vendor/bin` comes back on a no-op install,
320    // and a no-op install otherwise touches nothing. The purge of orphaned
321    // proxies (removed packages) runs on `wanted`. The `.bat` follows the
322    // resolved bin-compat (`full`, or `auto` on Windows/WSL), like
323    // Composer's BinaryInstaller — a plain Linux/macOS install writes no
324    // `.bat`.
325    let bin_compat = crate::binproxy::resolve_bin_compat(root_manifest)?;
326    let placed: std::collections::HashSet<&str> = to_install.iter().map(|p| p.name()).collect();
327    for p in &wanted {
328        let bins = p.bins();
329        if bins.is_empty() {
330            continue;
331        }
332        let Some(dir) = layout.abs(p.name()) else {
333            continue;
334        };
335        let missing = || {
336            bins.iter().any(|b| {
337                let b = b.trim_start_matches("./");
338                let link_name = b.rsplit_once('/').map(|(_, f)| f).unwrap_or(b);
339                dir.join(b).exists() && !vendor.join("bin").join(link_name).exists()
340            })
341        };
342        if placed.contains(p.name()) || missing() {
343            crate::binproxy::install_binaries(&vendor, &dir, &bins, bin_compat)?;
344        }
345    }
346    prune_orphan_bin_proxies(&vendor, &wanted, bin_compat)?;
347
348    // State files + runtime stub, from the local repository: an unchanged
349    // package keeps the entry installed.json already had (its own
350    // `version_normalized`/`installation-source`/`install-path` are
351    // recomputed), so a lock that changed a package's metadata without
352    // changing its identity — routine with `path` packages whose reference
353    // is a git HEAD or none — leaves installed.json and the autoloader as
354    // Composer leaves them.
355    let mut local = lock.clone();
356    for p in local
357        .packages
358        .iter_mut()
359        .chain(local.packages_dev.iter_mut())
360    {
361        if !unchanged_names.contains(p.name()) {
362            continue;
363        }
364        if let Some(prev) = previous.get(p.name()) {
365            let mut raw = prev.raw.clone();
366            for key in ["version_normalized", "installation-source", "install-path"] {
367                raw.remove(key);
368            }
369            p.raw = raw;
370        }
371    }
372    let root = RootPackage::detect(root_manifest, project_dir, opts.with_dev);
373    crate::state::write_state_files(
374        &vendor.join("composer"),
375        &local,
376        &root,
377        root_manifest,
378        opts.with_dev,
379        layout,
380    )?;
381    report.local_repository = Some(local);
382    if wanted.iter().any(|p| p.name() == "symfony/runtime") {
383        crate::runtime_stub::write_stub(&vendor)?;
384    }
385
386    Ok(report)
387}
388
389/// `LibraryInstaller::uninstall`: the parent directory of the removed package
390/// (vendor/<ns>, web/app/plugins...) is removed if empty, never the project
391/// root.
392fn prune_empty_parent(project_dir: &Path, removed: &Path) {
393    let Some(parent) = removed.parent() else {
394        return;
395    };
396    if parent == project_dir {
397        return;
398    }
399    if std::fs::read_dir(parent)
400        .map(|mut d| d.next().is_none())
401        .unwrap_or(false)
402    {
403        let _ = std::fs::remove_dir(parent);
404    }
405}
406
407fn prune_orphan_bin_proxies(
408    vendor: &Path,
409    wanted: &[&LockPackage],
410    bin_compat: crate::binproxy::BinCompat,
411) -> Result<()> {
412    let bin_dir = vendor.join("bin");
413    let Ok(entries) = std::fs::read_dir(&bin_dir) else {
414        return Ok(());
415    };
416    let mut expected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
417    for p in wanted {
418        for bin in p.bins() {
419            let bin = bin.trim_start_matches("./");
420            let link = bin.rsplit_once('/').map(|(_, f)| f).unwrap_or(bin);
421            expected.insert(link.to_owned());
422        }
423    }
424    for entry in entries.flatten() {
425        let file_name = entry.file_name().to_string_lossy().into_owned();
426        // A `.bat` is the Windows proxy of an expected bin — kept only when
427        // the resolved bin-compat writes `.bat` proxies at all (otherwise a
428        // leftover from a previous full-mode install, purged, converging on
429        // what Composer produces on a bare checkout) — or the proxy of a
430        // removed package (purged), or a user-placed file.
431        let keep = expected.contains(&file_name)
432            || (bin_compat == crate::binproxy::BinCompat::Full
433                && file_name
434                    .strip_suffix(".bat")
435                    .is_some_and(|stem| expected.contains(stem)));
436        if !keep {
437            let p = entry.path();
438            std::fs::remove_file(&p).map_err(Error::io(&p))?;
439        }
440    }
441    Ok(())
442}
443
444/// Utility path: the project's vendor/composer.
445pub fn vendor_composer_dir(project_dir: &Path) -> PathBuf {
446    project_dir.join("vendor/composer")
447}