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 pub plugins_enabled: bool,
26}
27
28impl Default for InstallOptions {
29 fn default() -> Self {
30 InstallOptions {
31 with_dev: true,
32 offline: false,
33 jobs: 16,
34 plugins_enabled: true,
35 }
36 }
37}
38
39#[derive(Debug, Default)]
40pub struct InstallReport {
41 pub installed: usize,
42 pub removed: usize,
43 pub unchanged: usize,
44 pub from_cache: usize,
45 pub from_network: usize,
46 pub store_hits: usize,
47 pub store_warmed: usize,
49 pub local_repository: Option<Lock>,
54 pub messages: Vec<String>,
57}
58
59type Identity = (String, String, String, String);
67
68fn identity(p: &LockPackage) -> Identity {
69 let field = |a: &str, b: &str| {
70 p.raw
71 .get(a)
72 .and_then(|v| v.get(b))
73 .and_then(Value::as_str)
74 .unwrap_or("")
75 .to_owned()
76 };
77 (
78 p.version().to_owned(),
79 p.dist_reference().unwrap_or("").to_owned(),
80 field("source", "reference"),
81 abandoned_mark(p.raw.get("abandoned")),
82 )
83}
84
85fn abandoned_mark(v: Option<&Value>) -> String {
88 match v {
89 None | Some(Value::Bool(false)) | Some(Value::Null) => String::new(),
90 Some(Value::Bool(true)) => "true".to_owned(),
91 Some(Value::String(s)) => s.clone(),
92 Some(other) => other.to_string(),
93 }
94}
95
96struct Installed {
99 identity: Identity,
100 path_source: Option<String>,
101 raw: serde_json::Map<String, Value>,
103}
104
105fn installed_packages(composer_dir: &Path) -> BTreeMap<String, Installed> {
106 let mut out = BTreeMap::new();
107 let path = composer_dir.join("installed.json");
108 let Some(v) = crate::jsonfile::read(&path) else {
109 return out;
110 };
111 for p in v["packages"].as_array().into_iter().flatten() {
112 let name = p["name"].as_str().unwrap_or_default();
113 let version = p["version"].as_str().unwrap_or_default();
114 let reference = p["dist"]["reference"].as_str().unwrap_or_default();
115 let source_reference = p["source"]["reference"].as_str().unwrap_or_default();
116 let path_source = (p["dist"]["type"].as_str() == Some("path"))
117 .then(|| p["dist"]["url"].as_str().map(str::to_owned))
118 .flatten();
119 out.insert(
120 name.to_owned(),
121 Installed {
122 identity: (
123 version.to_owned(),
124 reference.to_owned(),
125 source_reference.to_owned(),
126 abandoned_mark(p.get("abandoned")),
127 ),
128 path_source,
129 raw: p.as_object().cloned().unwrap_or_default(),
130 },
131 );
132 }
133 out
134}
135
136fn local_repository(
140 lock: &Lock,
141 previous: &BTreeMap<String, Installed>,
142 unchanged_names: &std::collections::BTreeSet<&str>,
143 flex_ready: bool,
144) -> Lock {
145 let mut local = lock.clone();
146 for p in local
147 .packages
148 .iter_mut()
149 .chain(local.packages_dev.iter_mut())
150 {
151 if unchanged_names.contains(p.name()) {
152 if let Some(prev) = previous.get(p.name()) {
156 let mut raw = prev.raw.clone();
157 for key in ["version_normalized", "install-path"] {
158 raw.remove(key);
159 }
160 p.raw = raw;
161 }
162 continue;
163 }
164 p.raw.remove("installation-source");
171 if !p.is_virtual(flex_ready) {
172 p.raw.insert(
173 "installation-source".to_owned(),
174 serde_json::Value::String("dist".to_owned()),
175 );
176 }
177 }
178 local
179}
180
181pub fn local_repository_if_unchanged(lock: &Lock, layout: &Layout, with_dev: bool) -> Option<Lock> {
188 let previous = installed_packages(&layout.composer_dir());
189 let mut unchanged: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
190 for p in lock.wanted_packages(with_dev) {
191 if previous.get(p.name()).map(|i| &i.identity) != Some(&identity(p)) {
192 return None;
193 }
194 if layout.install_path(p.name()).is_some()
195 && !layout.abs(p.name()).is_some_and(|d| d.is_dir())
196 {
197 return None;
198 }
199 unchanged.insert(p.name());
200 }
201 Some(local_repository(lock, &previous, &unchanged, false))
203}
204
205pub async fn install(
206 _project_dir: &Path,
207 lock: &Lock,
208 root_manifest: &Value,
209 layout: &Layout,
210 store: Arc<Store>,
211 fetcher: Arc<Fetcher>,
212 opts: &InstallOptions,
213) -> Result<InstallReport> {
214 let project_dir = layout.root();
217 let vendor = layout.vendor_dir();
218 let bin_dir = layout.bin_dir();
219 std::fs::create_dir_all(&vendor).map_err(Error::io(&vendor))?;
220
221 let mut report = InstallReport::default();
222 let wanted: Vec<&LockPackage> = lock.wanted_packages(opts.with_dev).collect();
223 let wanted_names: std::collections::BTreeSet<&str> = wanted.iter().map(|p| p.name()).collect();
224 let mut previous = installed_packages(&layout.composer_dir());
228 previous.retain(|name, installed| {
229 if installed
234 .raw
235 .get("type")
236 .and_then(serde_json::Value::as_str)
237 == Some("metapackage")
238 || layout.install_path(name).is_none()
239 {
240 return true;
241 }
242 layout
243 .abs(name)
244 .or_else(|| layout.removals().find(|(n, _)| n == name).map(|(_, d)| d))
245 .is_some_and(|d| d.exists())
246 });
247
248 let mut to_install: Vec<&LockPackage> = Vec::new();
253 let mut to_warm: Vec<&LockPackage> = Vec::new();
254 let mut unchanged_names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
255 for p in &wanted {
256 if layout.install_path(p.name()).is_none() {
259 if previous.get(p.name()).map(|i| &i.identity) == Some(&identity(p)) {
260 unchanged_names.insert(p.name());
261 }
262 continue;
263 }
264 let unchanged = previous.get(p.name()).map(|i| &i.identity) == Some(&identity(p))
267 && layout.abs(p.name()).is_some_and(|d| d.is_dir());
268 if unchanged {
269 unchanged_names.insert(p.name());
270 report.unchanged += 1;
271 if matches!(p.dist_kind(), DistKind::Zip | DistKind::Tar)
272 && !store.contains(p.name(), p.version(), p.store_reference().as_deref())
273 {
274 to_warm.push(p);
275 }
276 } else {
277 to_install.push(p);
278 }
279 }
280 for p in &to_install {
283 if p.dist_kind() == DistKind::Path {
284 if let (Some(dest), Some(url)) = (layout.abs(p.name()), p.dist_url()) {
285 crate::path_install::check_not_inside_source(project_dir, &dest, url, p.name())?;
286 }
287 }
288 }
289
290 let sem = Arc::new(tokio::sync::Semaphore::new(opts.jobs.max(1)));
294 let mut tasks = tokio::task::JoinSet::new();
295 let warm_names: std::collections::BTreeSet<&str> = to_warm.iter().map(|p| p.name()).collect();
296 for p in to_install.iter().chain(to_warm.iter()) {
297 if p.dist_kind() == DistKind::Path {
298 continue;
299 }
300 if store.contains(p.name(), p.version(), p.store_reference().as_deref()) {
301 report.store_hits += 1;
302 continue;
303 }
304 let warm_only =
305 warm_names.contains(p.name()) && !to_install.iter().any(|q| q.name() == p.name());
306 let (name, version) = (p.name().to_owned(), p.version().to_owned());
307 let dist_ref = p.store_reference();
308 let kind = if p.dist_kind() == DistKind::Tar {
309 crate::store::DistKind::Tar
310 } else {
311 crate::store::DistKind::Zip
312 };
313 let url = p.dist_url_expanded().ok_or_else(|| Error::Http {
314 url: name.clone(),
315 message:
316 "package without a dist url (the scope detector should have routed to the fallback)"
317 .to_owned(),
318 })?;
319 let shasum = p.dist_shasum().map(str::to_owned);
320 let (store, fetcher, sem) = (store.clone(), fetcher.clone(), sem.clone());
321 let offline = opts.offline || warm_only;
322 tasks.spawn(async move {
323 let _permit = sem.acquire().await.map_err(|_| Error::Http {
324 url: url.clone(),
325 message: "semaphore closed".to_owned(),
326 })?;
327 let dist_type = match kind {
328 crate::store::DistKind::Zip => "zip",
329 crate::store::DistKind::Tar => "tar",
330 };
331 let fetched = fetcher
332 .dist_bytes_of(&name, &url, dist_type, shasum.as_deref(), offline)
333 .await;
334 let (bytes, provenance) = match fetched {
335 Ok(v) => v,
336 Err(_) if warm_only => return Ok::<Option<Provenance>, Error>(None),
338 Err(e) => return Err(e),
339 };
340 let store_name = name.clone();
341 let version2 = version.clone();
342 let dist_ref2 = dist_ref.clone();
343 tokio::task::spawn_blocking(move || {
344 store.ensure(&store_name, &version2, dist_ref2.as_deref(), &bytes, kind)
345 })
346 .await
347 .map_err(|e| Error::Http {
348 url: name.clone(),
349 message: format!("extraction task interrupted: {e}"),
350 })??;
351 Ok::<Option<Provenance>, Error>(Some(provenance))
352 });
353 }
354 while let Some(joined) = tasks.join_next().await {
355 let provenance = joined.map_err(|e| Error::Http {
356 url: "join".to_owned(),
357 message: e.to_string(),
358 })??;
359 match provenance {
360 Some(Provenance::Cache) => report.from_cache += 1,
361 Some(Provenance::Network) => report.from_network += 1,
362 None => {}
363 }
364 }
365 report.store_warmed = to_warm.len();
366
367 for name in previous.keys() {
370 if !wanted_names.contains(name.as_str()) {
371 report.removed += 1;
372 }
373 }
374 for (name, dir) in layout.removals() {
375 let own_source = previous
378 .get(name)
379 .and_then(|i| i.path_source.as_deref())
380 .is_some_and(|url| {
381 crate::path_install::is_own_source(project_dir, &dir.to_string_lossy(), url)
382 });
383 if own_source {
384 continue;
385 }
386 if std::fs::symlink_metadata(&dir).is_ok() {
387 crate::path_install::remove_path(&dir)?;
388 prune_empty_parent(project_dir, &dir);
389 }
390 }
391
392 let place = |p: &&LockPackage| -> Result<bool> {
400 let (Some(pkg_root), Some(dest)) = (layout.package_root(p.name()), layout.abs(p.name()))
401 else {
402 return Ok(false);
403 };
404 if p.dist_kind() == DistKind::Path {
405 let url = p.dist_url().unwrap_or_default();
406 if previous.contains_key(p.name()) {
411 crate::path_install::remove_path(&pkg_root)?;
412 }
413 crate::path_install::install(project_dir, &dest, url, p.raw.get("transport-options"))?;
414 return Ok(true);
415 }
416 if std::fs::symlink_metadata(&pkg_root).is_ok() {
418 crate::path_install::remove_path(&pkg_root)?;
419 }
420 let src = store.entry_path(p.name(), p.version(), p.store_reference().as_deref());
421 crate::clone::clone_tree(&src, &dest)?;
422 Ok(true)
423 };
424 let placed: Vec<bool> = if crate::platform::parallel_io() {
425 use rayon::prelude::*;
426 to_install.par_iter().map(place).collect::<Result<_>>()?
427 } else {
428 to_install.iter().map(place).collect::<Result<_>>()?
429 };
430 let installed: usize = placed.into_iter().filter(|placed| *placed).count();
431 report.installed += installed;
432
433 let touches_installed = to_install.iter().any(|p| previous.contains_key(p.name()))
438 || previous.keys().any(|n| !wanted_names.contains(n.as_str()));
439 if touches_installed {
440 std::fs::create_dir_all(&bin_dir).map_err(Error::io(&bin_dir))?;
441 }
442
443 let bin_compat = crate::binproxy::resolve_bin_compat(root_manifest)?;
455 let placed: std::collections::HashSet<&str> = to_install.iter().map(|p| p.name()).collect();
456 let previous_bins = |name: &str| -> Vec<String> {
461 previous
462 .get(name)
463 .and_then(|i| i.raw.get("bin"))
464 .and_then(Value::as_array)
465 .map(|a| {
466 a.iter()
467 .filter_map(Value::as_str)
468 .map(str::to_owned)
469 .collect()
470 })
471 .unwrap_or_default()
472 };
473 for p in &wanted {
474 let bins = p.bins();
475 let Some(dir) = layout.abs(p.name()) else {
476 continue;
477 };
478 if placed.contains(p.name()) && previous.contains_key(p.name()) {
479 let old = previous_bins(p.name());
480 let old: Vec<&str> = old.iter().map(String::as_str).collect();
481 crate::binproxy::remove_binaries(&bin_dir, &old)?;
482 }
483 if bins.is_empty() {
484 continue;
485 }
486 let missing = || {
487 bins.iter().any(|b| {
488 let b = b.trim_start_matches("./");
489 let link_name = b.rsplit_once('/').map(|(_, f)| f).unwrap_or(b);
490 dir.join(b).exists() && !bin_dir.join(link_name).exists()
491 })
492 };
493 if placed.contains(p.name()) {
494 report.messages.extend(crate::binproxy::install_binaries(
495 &vendor,
496 &bin_dir,
497 p.name(),
498 &dir,
499 &bins,
500 bin_compat,
501 true,
502 )?);
503 } else if missing() {
504 crate::binproxy::install_binaries(
505 &vendor,
506 &bin_dir,
507 p.name(),
508 &dir,
509 &bins,
510 bin_compat,
511 false,
512 )?;
513 }
514 }
515 for name in previous.keys() {
517 if !wanted_names.contains(name.as_str()) {
518 let old = previous_bins(name);
519 let old: Vec<&str> = old.iter().map(String::as_str).collect();
520 crate::binproxy::remove_binaries(&bin_dir, &old)?;
521 }
522 }
523 prune_stale_bat_proxies(&bin_dir, &wanted, bin_compat)?;
524
525 let flex_ready = opts.plugins_enabled
533 && previous.contains_key("symfony/flex")
534 && matches!(
535 crate::layout::plugin_allowed(root_manifest, "symfony/flex"),
536 crate::layout::PluginVerdict::Allowed
537 );
538 let local = local_repository(lock, &previous, &unchanged_names, flex_ready);
539 let root = RootPackage::detect(root_manifest, project_dir, opts.with_dev);
540 crate::state::write_state_files(
541 &layout.composer_dir(),
542 &local,
543 &root,
544 root_manifest,
545 opts.with_dev,
546 layout,
547 )?;
548 report.local_repository = Some(local);
549
550 Ok(report)
551}
552
553fn prune_empty_parent(project_dir: &Path, removed: &Path) {
557 let Some(parent) = removed.parent() else {
558 return;
559 };
560 if parent == project_dir {
561 return;
562 }
563 if std::fs::read_dir(parent)
564 .map(|mut d| d.next().is_none())
565 .unwrap_or(false)
566 {
567 let _ = std::fs::remove_dir(parent);
568 }
569}
570
571fn prune_stale_bat_proxies(
577 bin_dir: &Path,
578 wanted: &[&LockPackage],
579 bin_compat: crate::binproxy::BinCompat,
580) -> Result<()> {
581 if bin_compat == crate::binproxy::BinCompat::Full {
582 return Ok(());
583 }
584 let Ok(entries) = std::fs::read_dir(bin_dir) else {
585 return Ok(());
586 };
587 let mut expected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
588 for p in wanted {
589 for bin in p.bins() {
590 let bin = bin.trim_start_matches("./");
591 let link = bin.rsplit_once('/').map(|(_, f)| f).unwrap_or(bin);
592 expected.insert(link.to_owned());
593 }
594 }
595 for entry in entries.flatten() {
596 let file_name = entry.file_name().to_string_lossy().into_owned();
597 if !expected.contains(&file_name)
600 && file_name
601 .strip_suffix(".bat")
602 .is_some_and(|stem| expected.contains(stem))
603 {
604 let p = entry.path();
605 std::fs::remove_file(&p).map_err(Error::io(&p))?;
606 }
607 }
608 Ok(())
609}
610
611#[cfg(test)]
612mod bat_prune_tests {
613 use super::*;
614
615 #[test]
616 fn a_declared_bat_bin_is_not_pruned() {
617 let d = tempfile::tempdir().expect("tmp");
618 let bin_dir = d.path().join("bin");
619 std::fs::create_dir_all(&bin_dir).expect("bin");
620 for f in ["paratest", "paratest.bat", "phpunit", "phpunit.bat"] {
621 std::fs::write(bin_dir.join(f), "x").expect("write");
622 }
623 let lock = Lock::parse(
626 &serde_json::json!({"packages": [
627 {"name": "brianium/paratest", "version": "7.3.2", "bin": ["bin/paratest", "bin/paratest.bat"]},
628 {"name": "phpunit/phpunit", "version": "11.0.0", "bin": ["phpunit"]}
629 ], "packages-dev": []})
630 .to_string(),
631 )
632 .expect("lock");
633 let wanted: Vec<&LockPackage> = lock.packages.iter().collect();
634 prune_stale_bat_proxies(&bin_dir, &wanted, crate::binproxy::BinCompat::Proxy)
635 .expect("prune");
636 assert!(
637 bin_dir.join("paratest.bat").exists(),
638 "a declared .bat bin was pruned"
639 );
640 assert!(
641 !bin_dir.join("phpunit.bat").exists(),
642 "the stale .bat was kept"
643 );
644 }
645}