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;
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 pub messages: Vec<String>,
53}
54
55fn identity(p: &LockPackage) -> (String, String) {
57 (
58 p.version().to_owned(),
59 p.dist_reference().unwrap_or("").to_owned(),
60 )
61}
62
63struct Installed {
66 identity: (String, String),
67 path_source: Option<String>,
68 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
97fn 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
125pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
477fn 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
495fn 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 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 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}