1use 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 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 pub store_warmed: usize,
45 pub local_repository: Option<Lock>,
50}
51
52fn identity(p: &LockPackage) -> (String, String) {
54 (
55 p.version().to_owned(),
56 p.dist_reference().unwrap_or("").to_owned(),
57 )
58}
59
60struct Installed {
63 identity: (String, String),
64 path_source: Option<String>,
65 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
389fn 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 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
444pub fn vendor_composer_dir(project_dir: &Path) -> PathBuf {
446 project_dir.join("vendor/composer")
447}