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 Ok(text) = std::fs::read_to_string(&path) else {
76 return out;
77 };
78 let Ok(v) = serde_json::from_str::<Value>(&text) else {
79 return out;
80 };
81 for p in v["packages"].as_array().into_iter().flatten() {
82 let name = p["name"].as_str().unwrap_or_default();
83 let version = p["version"].as_str().unwrap_or_default();
84 let reference = p["dist"]["reference"].as_str().unwrap_or_default();
85 let path_source = (p["dist"]["type"].as_str() == Some("path"))
86 .then(|| p["dist"]["url"].as_str().map(str::to_owned))
87 .flatten();
88 out.insert(
89 name.to_owned(),
90 Installed {
91 identity: (version.to_owned(), reference.to_owned()),
92 path_source,
93 raw: p.as_object().cloned().unwrap_or_default(),
94 },
95 );
96 }
97 out
98}
99
100pub async fn install(
101 _project_dir: &Path,
102 lock: &Lock,
103 root_manifest: &Value,
104 layout: &Layout,
105 store: Arc<Store>,
106 fetcher: Arc<Fetcher>,
107 opts: &InstallOptions,
108) -> Result<InstallReport> {
109 let project_dir = layout.root();
112 let vendor = layout.vendor_dir();
113 let bin_dir = layout.bin_dir();
114 std::fs::create_dir_all(&vendor).map_err(Error::io(&vendor))?;
115
116 let mut report = InstallReport::default();
117 let wanted: Vec<&LockPackage> = lock.wanted_packages(opts.with_dev).collect();
118 let wanted_names: std::collections::BTreeSet<&str> = wanted.iter().map(|p| p.name()).collect();
119 let mut previous = installed_packages(&layout.composer_dir());
123 previous.retain(|name, _| {
124 layout
125 .abs(name)
126 .or_else(|| layout.removals().find(|(n, _)| n == name).map(|(_, d)| d))
127 .is_some_and(|d| d.exists())
128 });
129
130 let mut to_install: Vec<&LockPackage> = Vec::new();
135 let mut to_warm: Vec<&LockPackage> = Vec::new();
136 let mut unchanged_names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
137 for p in &wanted {
138 if layout.install_path(p.name()).is_none() {
141 if previous.get(p.name()).map(|i| &i.identity) == Some(&identity(p)) {
142 unchanged_names.insert(p.name());
143 }
144 continue;
145 }
146 let unchanged = previous.get(p.name()).map(|i| &i.identity) == Some(&identity(p))
149 && layout.abs(p.name()).is_some_and(|d| d.is_dir());
150 if unchanged {
151 unchanged_names.insert(p.name());
152 report.unchanged += 1;
153 if p.dist_kind() == DistKind::Zip
154 && !store.contains(p.name(), p.version(), p.dist_reference())
155 {
156 to_warm.push(p);
157 }
158 } else {
159 to_install.push(p);
160 }
161 }
162 for p in &to_install {
165 if p.dist_kind() == DistKind::Path {
166 if let (Some(dest), Some(url)) = (layout.abs(p.name()), p.dist_url()) {
167 crate::path_install::check_not_inside_source(project_dir, &dest, url, p.name())?;
168 }
169 }
170 }
171
172 let sem = Arc::new(tokio::sync::Semaphore::new(opts.jobs.max(1)));
176 let mut tasks = tokio::task::JoinSet::new();
177 let warm_names: std::collections::BTreeSet<&str> = to_warm.iter().map(|p| p.name()).collect();
178 for p in to_install.iter().chain(to_warm.iter()) {
179 if p.dist_kind() == DistKind::Path {
180 continue;
181 }
182 if store.contains(p.name(), p.version(), p.dist_reference()) {
183 report.store_hits += 1;
184 continue;
185 }
186 let warm_only =
187 warm_names.contains(p.name()) && !to_install.iter().any(|q| q.name() == p.name());
188 let (name, version) = (p.name().to_owned(), p.version().to_owned());
189 let dist_ref = p.dist_reference().map(str::to_owned);
190 let url = p.dist_url_expanded().ok_or_else(|| Error::Http {
191 url: name.clone(),
192 message:
193 "package without a dist url (the scope detector should have routed to the fallback)"
194 .to_owned(),
195 })?;
196 let shasum = p.dist_shasum().map(str::to_owned);
197 let (store, fetcher, sem) = (store.clone(), fetcher.clone(), sem.clone());
198 let offline = opts.offline || warm_only;
199 tasks.spawn(async move {
200 let _permit = sem.acquire().await.map_err(|_| Error::Http {
201 url: url.clone(),
202 message: "semaphore closed".to_owned(),
203 })?;
204 let fetched = fetcher
205 .dist_bytes(&name, &url, shasum.as_deref(), offline)
206 .await;
207 let (bytes, provenance) = match fetched {
208 Ok(v) => v,
209 Err(_) if warm_only => return Ok::<Option<Provenance>, Error>(None),
211 Err(e) => return Err(e),
212 };
213 let store_name = name.clone();
214 let version2 = version.clone();
215 let dist_ref2 = dist_ref.clone();
216 tokio::task::spawn_blocking(move || {
217 store.ensure(&store_name, &version2, dist_ref2.as_deref(), &bytes)
218 })
219 .await
220 .map_err(|e| Error::Http {
221 url: name.clone(),
222 message: format!("extraction task interrupted: {e}"),
223 })??;
224 Ok::<Option<Provenance>, Error>(Some(provenance))
225 });
226 }
227 while let Some(joined) = tasks.join_next().await {
228 let provenance = joined.map_err(|e| Error::Http {
229 url: "join".to_owned(),
230 message: e.to_string(),
231 })??;
232 match provenance {
233 Some(Provenance::Cache) => report.from_cache += 1,
234 Some(Provenance::Network) => report.from_network += 1,
235 None => {}
236 }
237 }
238 report.store_warmed = to_warm.len();
239
240 for name in previous.keys() {
243 if !wanted_names.contains(name.as_str()) {
244 report.removed += 1;
245 }
246 }
247 for (name, dir) in layout.removals() {
248 let own_source = previous
251 .get(name)
252 .and_then(|i| i.path_source.as_deref())
253 .is_some_and(|url| {
254 crate::path_install::is_own_source(project_dir, &dir.to_string_lossy(), url)
255 });
256 if own_source {
257 continue;
258 }
259 if std::fs::symlink_metadata(&dir).is_ok() {
260 crate::path_install::remove_path(&dir)?;
261 prune_empty_parent(project_dir, &dir);
262 }
263 }
264
265 let place = |p: &&LockPackage| -> Result<bool> {
273 let (Some(pkg_root), Some(dest)) = (layout.package_root(p.name()), layout.abs(p.name()))
274 else {
275 return Ok(false);
276 };
277 if p.dist_kind() == DistKind::Path {
278 let url = p.dist_url().unwrap_or_default();
279 if previous.contains_key(p.name()) {
284 crate::path_install::remove_path(&pkg_root)?;
285 }
286 crate::path_install::install(project_dir, &dest, url, p.raw.get("transport-options"))?;
287 return Ok(true);
288 }
289 if std::fs::symlink_metadata(&pkg_root).is_ok() {
291 crate::path_install::remove_path(&pkg_root)?;
292 }
293 let src = store.entry_path(p.name(), p.version(), p.dist_reference());
294 crate::clone::clone_tree(&src, &dest)?;
295 Ok(true)
296 };
297 let placed: Vec<bool> = if crate::platform::parallel_io() {
298 use rayon::prelude::*;
299 to_install.par_iter().map(place).collect::<Result<_>>()?
300 } else {
301 to_install.iter().map(place).collect::<Result<_>>()?
302 };
303 let installed: usize = placed.into_iter().filter(|placed| *placed).count();
304 report.installed += installed;
305
306 let touches_installed = to_install.iter().any(|p| previous.contains_key(p.name()))
311 || previous.keys().any(|n| !wanted_names.contains(n.as_str()));
312 if touches_installed {
313 std::fs::create_dir_all(&bin_dir).map_err(Error::io(&bin_dir))?;
314 }
315
316 let bin_compat = crate::binproxy::resolve_bin_compat(root_manifest)?;
328 let placed: std::collections::HashSet<&str> = to_install.iter().map(|p| p.name()).collect();
329 let previous_bins = |name: &str| -> Vec<String> {
334 previous
335 .get(name)
336 .and_then(|i| i.raw.get("bin"))
337 .and_then(Value::as_array)
338 .map(|a| {
339 a.iter()
340 .filter_map(Value::as_str)
341 .map(str::to_owned)
342 .collect()
343 })
344 .unwrap_or_default()
345 };
346 for p in &wanted {
347 let bins = p.bins();
348 let Some(dir) = layout.abs(p.name()) else {
349 continue;
350 };
351 if placed.contains(p.name()) && previous.contains_key(p.name()) {
352 let old = previous_bins(p.name());
353 let old: Vec<&str> = old.iter().map(String::as_str).collect();
354 crate::binproxy::remove_binaries(&bin_dir, &old)?;
355 }
356 if bins.is_empty() {
357 continue;
358 }
359 let missing = || {
360 bins.iter().any(|b| {
361 let b = b.trim_start_matches("./");
362 let link_name = b.rsplit_once('/').map(|(_, f)| f).unwrap_or(b);
363 dir.join(b).exists() && !bin_dir.join(link_name).exists()
364 })
365 };
366 if placed.contains(p.name()) {
367 report.messages.extend(crate::binproxy::install_binaries(
368 &vendor,
369 &bin_dir,
370 p.name(),
371 &dir,
372 &bins,
373 bin_compat,
374 true,
375 )?);
376 } else if missing() {
377 crate::binproxy::install_binaries(
378 &vendor,
379 &bin_dir,
380 p.name(),
381 &dir,
382 &bins,
383 bin_compat,
384 false,
385 )?;
386 }
387 }
388 for name in previous.keys() {
390 if !wanted_names.contains(name.as_str()) {
391 let old = previous_bins(name);
392 let old: Vec<&str> = old.iter().map(String::as_str).collect();
393 crate::binproxy::remove_binaries(&bin_dir, &old)?;
394 }
395 }
396 prune_stale_bat_proxies(&bin_dir, &wanted, bin_compat)?;
397
398 let mut local = lock.clone();
406 for p in local
407 .packages
408 .iter_mut()
409 .chain(local.packages_dev.iter_mut())
410 {
411 if !unchanged_names.contains(p.name()) {
412 continue;
413 }
414 if let Some(prev) = previous.get(p.name()) {
415 let mut raw = prev.raw.clone();
416 for key in ["version_normalized", "installation-source", "install-path"] {
417 raw.remove(key);
418 }
419 p.raw = raw;
420 }
421 }
422 let root = RootPackage::detect(root_manifest, project_dir, opts.with_dev);
423 crate::state::write_state_files(
424 &layout.composer_dir(),
425 &local,
426 &root,
427 root_manifest,
428 opts.with_dev,
429 layout,
430 )?;
431 report.local_repository = Some(local);
432
433 Ok(report)
434}
435
436fn prune_empty_parent(project_dir: &Path, removed: &Path) {
440 let Some(parent) = removed.parent() else {
441 return;
442 };
443 if parent == project_dir {
444 return;
445 }
446 if std::fs::read_dir(parent)
447 .map(|mut d| d.next().is_none())
448 .unwrap_or(false)
449 {
450 let _ = std::fs::remove_dir(parent);
451 }
452}
453
454fn prune_stale_bat_proxies(
460 bin_dir: &Path,
461 wanted: &[&LockPackage],
462 bin_compat: crate::binproxy::BinCompat,
463) -> Result<()> {
464 if bin_compat == crate::binproxy::BinCompat::Full {
465 return Ok(());
466 }
467 let Ok(entries) = std::fs::read_dir(bin_dir) else {
468 return Ok(());
469 };
470 let mut expected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
471 for p in wanted {
472 for bin in p.bins() {
473 let bin = bin.trim_start_matches("./");
474 let link = bin.rsplit_once('/').map(|(_, f)| f).unwrap_or(bin);
475 expected.insert(link.to_owned());
476 }
477 }
478 for entry in entries.flatten() {
479 let file_name = entry.file_name().to_string_lossy().into_owned();
480 if file_name
481 .strip_suffix(".bat")
482 .is_some_and(|stem| expected.contains(stem))
483 {
484 let p = entry.path();
485 std::fs::remove_file(&p).map_err(Error::io(&p))?;
486 }
487 }
488 Ok(())
489}