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::{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}
46
47/// Installed identity of a package: version + dist reference.
48fn identity(p: &LockPackage) -> (String, String) {
49    (
50        p.version().to_owned(),
51        p.dist_reference().unwrap_or("").to_owned(),
52    )
53}
54
55fn installed_identities(vendor: &Path) -> BTreeMap<String, (String, String)> {
56    let mut out = BTreeMap::new();
57    let path = vendor.join("composer/installed.json");
58    let Ok(text) = std::fs::read_to_string(&path) else {
59        return out;
60    };
61    let Ok(v) = serde_json::from_str::<Value>(&text) else {
62        return out;
63    };
64    for p in v["packages"].as_array().into_iter().flatten() {
65        let name = p["name"].as_str().unwrap_or_default();
66        let version = p["version"].as_str().unwrap_or_default();
67        let reference = p["dist"]["reference"].as_str().unwrap_or_default();
68        out.insert(name.to_owned(), (version.to_owned(), reference.to_owned()));
69    }
70    out
71}
72
73pub async fn install(
74    _project_dir: &Path,
75    lock: &Lock,
76    root_manifest: &Value,
77    layout: &Layout,
78    store: Arc<Store>,
79    fetcher: Arc<Fetcher>,
80    opts: &InstallOptions,
81) -> Result<InstallReport> {
82    // Absolute root (the layout's): the relative paths of the proxies and of
83    // the state files must not depend on a relative --working-dir.
84    let project_dir = layout.root();
85    let vendor = project_dir.join("vendor");
86    std::fs::create_dir_all(&vendor).map_err(Error::io(&vendor))?;
87
88    let mut report = InstallReport::default();
89    let wanted: Vec<&LockPackage> = lock.wanted_packages(opts.with_dev).collect();
90    let wanted_names: std::collections::BTreeSet<&str> = wanted.iter().map(|p| p.name()).collect();
91    let previous = installed_identities(&vendor);
92
93    // To lay out: changed identity, or missing directory. Unchanged packages
94    // whose store entry is missing (vendor/ laid out by Composer before vivacity)
95    // are extracted into the store without being re-cloned: the classmap
96    // cache applies from the next run on.
97    let mut to_install: Vec<&LockPackage> = Vec::new();
98    let mut to_warm: Vec<&LockPackage> = Vec::new();
99    for p in &wanted {
100        if p.is_metapackage() {
101            continue;
102        }
103        let unchanged = previous.get(p.name()) == Some(&identity(p))
104            && layout.abs(p.name()).is_some_and(|d| d.is_dir());
105        if unchanged {
106            report.unchanged += 1;
107            if !store.contains(p.name(), p.version(), p.dist_reference()) {
108                to_warm.push(p);
109            }
110        } else {
111            to_install.push(p);
112        }
113    }
114
115    // Fetch + extraction into the store, with bounded parallelism. Packages to
116    // "warm" only use the local cache (never the network) and their failure
117    // is silent: it is an optimisation, not an obligation.
118    let sem = Arc::new(tokio::sync::Semaphore::new(opts.jobs.max(1)));
119    let mut tasks = tokio::task::JoinSet::new();
120    let warm_names: std::collections::BTreeSet<&str> = to_warm.iter().map(|p| p.name()).collect();
121    for p in to_install.iter().chain(to_warm.iter()) {
122        if store.contains(p.name(), p.version(), p.dist_reference()) {
123            report.store_hits += 1;
124            continue;
125        }
126        let warm_only =
127            warm_names.contains(p.name()) && !to_install.iter().any(|q| q.name() == p.name());
128        let (name, version) = (p.name().to_owned(), p.version().to_owned());
129        let dist_ref = p.dist_reference().map(str::to_owned);
130        let url = p
131            .dist_url()
132            .ok_or_else(|| Error::Http {
133                url: name.clone(),
134                message:
135                    "package without a dist url (the scope detector should have routed to the fallback)"
136                        .to_owned(),
137            })?
138            .to_owned();
139        let shasum = p.dist_shasum().map(str::to_owned);
140        let (store, fetcher, sem) = (store.clone(), fetcher.clone(), sem.clone());
141        let offline = opts.offline || warm_only;
142        tasks.spawn(async move {
143            let _permit = sem.acquire().await.map_err(|_| Error::Http {
144                url: url.clone(),
145                message: "semaphore closed".to_owned(),
146            })?;
147            let fetched = fetcher
148                .dist_bytes(&name, &url, shasum.as_deref(), offline)
149                .await;
150            let (bytes, provenance) = match fetched {
151                Ok(v) => v,
152                // Warming: zip missing from the cache, do not insist.
153                Err(_) if warm_only => return Ok::<Option<Provenance>, Error>(None),
154                Err(e) => return Err(e),
155            };
156            let store_name = name.clone();
157            let version2 = version.clone();
158            let dist_ref2 = dist_ref.clone();
159            tokio::task::spawn_blocking(move || {
160                store.ensure(&store_name, &version2, dist_ref2.as_deref(), &bytes)
161            })
162            .await
163            .map_err(|e| Error::Http {
164                url: name.clone(),
165                message: format!("extraction task interrupted: {e}"),
166            })??;
167            Ok::<Option<Provenance>, Error>(Some(provenance))
168        });
169    }
170    while let Some(joined) = tasks.join_next().await {
171        let provenance = joined.map_err(|e| Error::Http {
172            url: "join".to_owned(),
173            message: e.to_string(),
174        })??;
175        match provenance {
176            Some(Provenance::Cache) => report.from_cache += 1,
177            Some(Provenance::Network) => report.from_network += 1,
178            None => {}
179        }
180    }
181    report.store_warmed = to_warm.len();
182
183    // Removals: present before, no longer wanted, at the path validated by the
184    // layout (old install-path = recomputed path, like LibraryInstaller).
185    for name in previous.keys() {
186        if !wanted_names.contains(name.as_str()) {
187            report.removed += 1;
188        }
189    }
190    for (_, dir) in layout.removals() {
191        if dir.exists() {
192            std::fs::remove_dir_all(&dir).map_err(Error::io(&dir))?;
193            prune_empty_parent(project_dir, &dir);
194        }
195    }
196
197    // Layout: remove the old version, then clone from the store.
198    for p in &to_install {
199        let (Some(pkg_root), Some(dest)) = (layout.package_root(p.name()), layout.abs(p.name()))
200        else {
201            continue;
202        };
203        // Always start again from an empty package root (target-dir included).
204        if pkg_root.exists() {
205            std::fs::remove_dir_all(&pkg_root).map_err(Error::io(&pkg_root))?;
206        }
207        let src = store.entry_path(p.name(), p.version(), p.dist_reference());
208        crate::clone::clone_tree(&src, &dest)?;
209        report.installed += 1;
210    }
211
212    // Bin proxies: rebuilt for every wanted package, then purge of the
213    // orphaned proxies (removed packages). The `.bat` follows the resolved
214    // bin-compat (`full`, or `auto` on Windows/WSL), like Composer's
215    // BinaryInstaller — a plain Linux/macOS install writes no `.bat`.
216    let bin_compat = crate::binproxy::resolve_bin_compat(root_manifest)?;
217    for p in &wanted {
218        let bins = p.bins();
219        if let (false, Some(dir)) = (bins.is_empty(), layout.abs(p.name())) {
220            crate::binproxy::install_binaries(&vendor, &dir, &bins, bin_compat)?;
221        }
222    }
223    prune_orphan_bin_proxies(&vendor, &wanted, bin_compat)?;
224
225    // State files + runtime stub.
226    let root = RootPackage::detect(root_manifest, project_dir, opts.with_dev);
227    crate::state::write_state_files(
228        &vendor.join("composer"),
229        lock,
230        &root,
231        root_manifest,
232        opts.with_dev,
233        layout,
234    )?;
235    if wanted.iter().any(|p| p.name() == "symfony/runtime") {
236        crate::runtime_stub::write_stub(&vendor)?;
237    }
238
239    Ok(report)
240}
241
242/// `LibraryInstaller::uninstall`: the parent directory of the removed package
243/// (vendor/<ns>, web/app/plugins...) is removed if empty, never the project
244/// root.
245fn prune_empty_parent(project_dir: &Path, removed: &Path) {
246    let Some(parent) = removed.parent() else {
247        return;
248    };
249    if parent == project_dir {
250        return;
251    }
252    if std::fs::read_dir(parent)
253        .map(|mut d| d.next().is_none())
254        .unwrap_or(false)
255    {
256        let _ = std::fs::remove_dir(parent);
257    }
258}
259
260fn prune_orphan_bin_proxies(
261    vendor: &Path,
262    wanted: &[&LockPackage],
263    bin_compat: crate::binproxy::BinCompat,
264) -> Result<()> {
265    let bin_dir = vendor.join("bin");
266    let Ok(entries) = std::fs::read_dir(&bin_dir) else {
267        return Ok(());
268    };
269    let mut expected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
270    for p in wanted {
271        for bin in p.bins() {
272            let bin = bin.trim_start_matches("./");
273            let link = bin.rsplit_once('/').map(|(_, f)| f).unwrap_or(bin);
274            expected.insert(link.to_owned());
275        }
276    }
277    for entry in entries.flatten() {
278        let file_name = entry.file_name().to_string_lossy().into_owned();
279        // A `.bat` is the Windows proxy of an expected bin — kept only when
280        // the resolved bin-compat writes `.bat` proxies at all (otherwise a
281        // leftover from a previous full-mode install, purged, converging on
282        // what Composer produces on a bare checkout) — or the proxy of a
283        // removed package (purged), or a user-placed file.
284        let keep = expected.contains(&file_name)
285            || (bin_compat == crate::binproxy::BinCompat::Full
286                && file_name
287                    .strip_suffix(".bat")
288                    .is_some_and(|stem| expected.contains(stem)));
289        if !keep {
290            let p = entry.path();
291            std::fs::remove_file(&p).map_err(Error::io(&p))?;
292        }
293    }
294    Ok(())
295}
296
297/// Utility path: the project's vendor/composer.
298pub fn vendor_composer_dir(project_dir: &Path) -> PathBuf {
299    project_dir.join("vendor/composer")
300}