use crate::error::{Error, Result};
use crate::fetch::{Fetcher, Provenance};
use crate::layout::Layout;
use crate::lock::{DistKind, Lock, LockPackage};
use crate::state::RootPackage;
use crate::store::Store;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
pub struct InstallOptions {
pub with_dev: bool,
pub offline: bool,
pub jobs: usize,
pub plugins_enabled: bool,
}
impl Default for InstallOptions {
fn default() -> Self {
InstallOptions {
with_dev: true,
offline: false,
jobs: 16,
plugins_enabled: true,
}
}
}
#[derive(Debug, Default)]
pub struct InstallReport {
pub installed: usize,
pub removed: usize,
pub unchanged: usize,
pub from_cache: usize,
pub from_network: usize,
pub store_hits: usize,
pub store_warmed: usize,
pub local_repository: Option<Lock>,
pub messages: Vec<String>,
}
type Identity = (String, String, String, String);
fn identity(p: &LockPackage) -> Identity {
let field = |a: &str, b: &str| {
p.raw
.get(a)
.and_then(|v| v.get(b))
.and_then(Value::as_str)
.unwrap_or("")
.to_owned()
};
(
p.version().to_owned(),
p.dist_reference().unwrap_or("").to_owned(),
field("source", "reference"),
abandoned_mark(p.raw.get("abandoned")),
)
}
fn abandoned_mark(v: Option<&Value>) -> String {
match v {
None | Some(Value::Bool(false)) | Some(Value::Null) => String::new(),
Some(Value::Bool(true)) => "true".to_owned(),
Some(Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
}
}
struct Installed {
identity: Identity,
path_source: Option<String>,
raw: serde_json::Map<String, Value>,
}
fn installed_packages(composer_dir: &Path) -> BTreeMap<String, Installed> {
let mut out = BTreeMap::new();
let path = composer_dir.join("installed.json");
let Some(v) = crate::jsonfile::read(&path) else {
return out;
};
for p in v["packages"].as_array().into_iter().flatten() {
let name = p["name"].as_str().unwrap_or_default();
let version = p["version"].as_str().unwrap_or_default();
let reference = p["dist"]["reference"].as_str().unwrap_or_default();
let source_reference = p["source"]["reference"].as_str().unwrap_or_default();
let path_source = (p["dist"]["type"].as_str() == Some("path"))
.then(|| p["dist"]["url"].as_str().map(str::to_owned))
.flatten();
out.insert(
name.to_owned(),
Installed {
identity: (
version.to_owned(),
reference.to_owned(),
source_reference.to_owned(),
abandoned_mark(p.get("abandoned")),
),
path_source,
raw: p.as_object().cloned().unwrap_or_default(),
},
);
}
out
}
fn local_repository(
lock: &Lock,
previous: &BTreeMap<String, Installed>,
unchanged_names: &std::collections::BTreeSet<&str>,
flex_ready: bool,
) -> Lock {
let mut local = lock.clone();
for p in local
.packages
.iter_mut()
.chain(local.packages_dev.iter_mut())
{
if unchanged_names.contains(p.name()) {
if let Some(prev) = previous.get(p.name()) {
let mut raw = prev.raw.clone();
for key in ["version_normalized", "install-path"] {
raw.remove(key);
}
p.raw = raw;
}
continue;
}
p.raw.remove("installation-source");
if !p.is_virtual(flex_ready) {
p.raw.insert(
"installation-source".to_owned(),
serde_json::Value::String("dist".to_owned()),
);
}
}
local
}
pub fn local_repository_if_unchanged(lock: &Lock, layout: &Layout, with_dev: bool) -> Option<Lock> {
let previous = installed_packages(&layout.composer_dir());
let mut unchanged: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for p in lock.wanted_packages(with_dev) {
if previous.get(p.name()).map(|i| &i.identity) != Some(&identity(p)) {
return None;
}
if layout.install_path(p.name()).is_some()
&& !layout.abs(p.name()).is_some_and(|d| d.is_dir())
{
return None;
}
unchanged.insert(p.name());
}
Some(local_repository(lock, &previous, &unchanged, false))
}
pub async fn install(
_project_dir: &Path,
lock: &Lock,
root_manifest: &Value,
layout: &Layout,
store: Arc<Store>,
fetcher: Arc<Fetcher>,
opts: &InstallOptions,
) -> Result<InstallReport> {
let project_dir = layout.root();
let vendor = layout.vendor_dir();
let bin_dir = layout.bin_dir();
std::fs::create_dir_all(&vendor).map_err(Error::io(&vendor))?;
let mut report = InstallReport::default();
let wanted: Vec<&LockPackage> = lock.wanted_packages(opts.with_dev).collect();
let wanted_names: std::collections::BTreeSet<&str> = wanted.iter().map(|p| p.name()).collect();
let mut previous = installed_packages(&layout.composer_dir());
previous.retain(|name, installed| {
if installed
.raw
.get("type")
.and_then(serde_json::Value::as_str)
== Some("metapackage")
|| layout.install_path(name).is_none()
{
return true;
}
layout
.abs(name)
.or_else(|| layout.removals().find(|(n, _)| n == name).map(|(_, d)| d))
.is_some_and(|d| d.exists())
});
let mut to_install: Vec<&LockPackage> = Vec::new();
let mut to_warm: Vec<&LockPackage> = Vec::new();
let mut unchanged_names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for p in &wanted {
if layout.install_path(p.name()).is_none() {
if previous.get(p.name()).map(|i| &i.identity) == Some(&identity(p)) {
unchanged_names.insert(p.name());
}
continue;
}
let unchanged = previous.get(p.name()).map(|i| &i.identity) == Some(&identity(p))
&& layout.abs(p.name()).is_some_and(|d| d.is_dir());
if unchanged {
unchanged_names.insert(p.name());
report.unchanged += 1;
if matches!(p.dist_kind(), DistKind::Zip | DistKind::Tar)
&& !store.contains(p.name(), p.version(), p.store_reference().as_deref())
{
to_warm.push(p);
}
} else {
to_install.push(p);
}
}
for p in &to_install {
if p.dist_kind() == DistKind::Path {
if let (Some(dest), Some(url)) = (layout.abs(p.name()), p.dist_url()) {
crate::path_install::check_not_inside_source(project_dir, &dest, url, p.name())?;
}
}
}
let sem = Arc::new(tokio::sync::Semaphore::new(opts.jobs.max(1)));
let mut tasks = tokio::task::JoinSet::new();
let warm_names: std::collections::BTreeSet<&str> = to_warm.iter().map(|p| p.name()).collect();
for p in to_install.iter().chain(to_warm.iter()) {
if p.dist_kind() == DistKind::Path {
continue;
}
if store.contains(p.name(), p.version(), p.store_reference().as_deref()) {
report.store_hits += 1;
continue;
}
let warm_only =
warm_names.contains(p.name()) && !to_install.iter().any(|q| q.name() == p.name());
let (name, version) = (p.name().to_owned(), p.version().to_owned());
let dist_ref = p.store_reference();
let kind = if p.dist_kind() == DistKind::Tar {
crate::store::DistKind::Tar
} else {
crate::store::DistKind::Zip
};
let url = p.dist_url_expanded().ok_or_else(|| Error::Http {
url: name.clone(),
message:
"package without a dist url (the scope detector should have routed to the fallback)"
.to_owned(),
})?;
let shasum = p.dist_shasum().map(str::to_owned);
let (store, fetcher, sem) = (store.clone(), fetcher.clone(), sem.clone());
let offline = opts.offline || warm_only;
tasks.spawn(async move {
let _permit = sem.acquire().await.map_err(|_| Error::Http {
url: url.clone(),
message: "semaphore closed".to_owned(),
})?;
let dist_type = match kind {
crate::store::DistKind::Zip => "zip",
crate::store::DistKind::Tar => "tar",
};
let fetched = fetcher
.dist_bytes_of(&name, &url, dist_type, shasum.as_deref(), offline)
.await;
let (bytes, provenance) = match fetched {
Ok(v) => v,
Err(_) if warm_only => return Ok::<Option<Provenance>, Error>(None),
Err(e) => return Err(e),
};
let store_name = name.clone();
let version2 = version.clone();
let dist_ref2 = dist_ref.clone();
tokio::task::spawn_blocking(move || {
store.ensure(&store_name, &version2, dist_ref2.as_deref(), &bytes, kind)
})
.await
.map_err(|e| Error::Http {
url: name.clone(),
message: format!("extraction task interrupted: {e}"),
})??;
Ok::<Option<Provenance>, Error>(Some(provenance))
});
}
while let Some(joined) = tasks.join_next().await {
let provenance = joined.map_err(|e| Error::Http {
url: "join".to_owned(),
message: e.to_string(),
})??;
match provenance {
Some(Provenance::Cache) => report.from_cache += 1,
Some(Provenance::Network) => report.from_network += 1,
None => {}
}
}
report.store_warmed = to_warm.len();
for name in previous.keys() {
if !wanted_names.contains(name.as_str()) {
report.removed += 1;
}
}
for (name, dir) in layout.removals() {
let own_source = previous
.get(name)
.and_then(|i| i.path_source.as_deref())
.is_some_and(|url| {
crate::path_install::is_own_source(project_dir, &dir.to_string_lossy(), url)
});
if own_source {
continue;
}
if std::fs::symlink_metadata(&dir).is_ok() {
crate::path_install::remove_path(&dir)?;
prune_empty_parent(project_dir, &dir);
}
}
let place = |p: &&LockPackage| -> Result<bool> {
let (Some(pkg_root), Some(dest)) = (layout.package_root(p.name()), layout.abs(p.name()))
else {
return Ok(false);
};
if p.dist_kind() == DistKind::Path {
let url = p.dist_url().unwrap_or_default();
if previous.contains_key(p.name()) {
crate::path_install::remove_path(&pkg_root)?;
}
crate::path_install::install(project_dir, &dest, url, p.raw.get("transport-options"))?;
return Ok(true);
}
if std::fs::symlink_metadata(&pkg_root).is_ok() {
crate::path_install::remove_path(&pkg_root)?;
}
let src = store.entry_path(p.name(), p.version(), p.store_reference().as_deref());
crate::clone::clone_tree(&src, &dest)?;
Ok(true)
};
let placed: Vec<bool> = if crate::platform::parallel_io() {
use rayon::prelude::*;
to_install.par_iter().map(place).collect::<Result<_>>()?
} else {
to_install.iter().map(place).collect::<Result<_>>()?
};
let installed: usize = placed.into_iter().filter(|placed| *placed).count();
report.installed += installed;
let touches_installed = to_install.iter().any(|p| previous.contains_key(p.name()))
|| previous.keys().any(|n| !wanted_names.contains(n.as_str()));
if touches_installed {
std::fs::create_dir_all(&bin_dir).map_err(Error::io(&bin_dir))?;
}
let bin_compat = crate::binproxy::resolve_bin_compat(root_manifest)?;
let placed: std::collections::HashSet<&str> = to_install.iter().map(|p| p.name()).collect();
let previous_bins = |name: &str| -> Vec<String> {
previous
.get(name)
.and_then(|i| i.raw.get("bin"))
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
};
for p in &wanted {
let bins = p.bins();
let Some(dir) = layout.abs(p.name()) else {
continue;
};
if placed.contains(p.name()) && previous.contains_key(p.name()) {
let old = previous_bins(p.name());
let old: Vec<&str> = old.iter().map(String::as_str).collect();
crate::binproxy::remove_binaries(&bin_dir, &old)?;
}
if bins.is_empty() {
continue;
}
let missing = || {
bins.iter().any(|b| {
let b = b.trim_start_matches("./");
let link_name = b.rsplit_once('/').map(|(_, f)| f).unwrap_or(b);
dir.join(b).exists() && !bin_dir.join(link_name).exists()
})
};
if placed.contains(p.name()) {
report.messages.extend(crate::binproxy::install_binaries(
&vendor,
&bin_dir,
p.name(),
&dir,
&bins,
bin_compat,
true,
)?);
} else if missing() {
crate::binproxy::install_binaries(
&vendor,
&bin_dir,
p.name(),
&dir,
&bins,
bin_compat,
false,
)?;
}
}
for name in previous.keys() {
if !wanted_names.contains(name.as_str()) {
let old = previous_bins(name);
let old: Vec<&str> = old.iter().map(String::as_str).collect();
crate::binproxy::remove_binaries(&bin_dir, &old)?;
}
}
prune_stale_bat_proxies(&bin_dir, &wanted, bin_compat)?;
let flex_ready = opts.plugins_enabled
&& previous.contains_key("symfony/flex")
&& matches!(
crate::layout::plugin_allowed(root_manifest, "symfony/flex"),
crate::layout::PluginVerdict::Allowed
);
let local = local_repository(lock, &previous, &unchanged_names, flex_ready);
let root = RootPackage::detect(root_manifest, project_dir, opts.with_dev);
crate::state::write_state_files(
&layout.composer_dir(),
&local,
&root,
root_manifest,
opts.with_dev,
layout,
)?;
report.local_repository = Some(local);
Ok(report)
}
fn prune_empty_parent(project_dir: &Path, removed: &Path) {
let Some(parent) = removed.parent() else {
return;
};
if parent == project_dir {
return;
}
if std::fs::read_dir(parent)
.map(|mut d| d.next().is_none())
.unwrap_or(false)
{
let _ = std::fs::remove_dir(parent);
}
}
fn prune_stale_bat_proxies(
bin_dir: &Path,
wanted: &[&LockPackage],
bin_compat: crate::binproxy::BinCompat,
) -> Result<()> {
if bin_compat == crate::binproxy::BinCompat::Full {
return Ok(());
}
let Ok(entries) = std::fs::read_dir(bin_dir) else {
return Ok(());
};
let mut expected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for p in wanted {
for bin in p.bins() {
let bin = bin.trim_start_matches("./");
let link = bin.rsplit_once('/').map(|(_, f)| f).unwrap_or(bin);
expected.insert(link.to_owned());
}
}
for entry in entries.flatten() {
let file_name = entry.file_name().to_string_lossy().into_owned();
if !expected.contains(&file_name)
&& file_name
.strip_suffix(".bat")
.is_some_and(|stem| expected.contains(stem))
{
let p = entry.path();
std::fs::remove_file(&p).map_err(Error::io(&p))?;
}
}
Ok(())
}
#[cfg(test)]
mod bat_prune_tests {
use super::*;
#[test]
fn a_declared_bat_bin_is_not_pruned() {
let d = tempfile::tempdir().expect("tmp");
let bin_dir = d.path().join("bin");
std::fs::create_dir_all(&bin_dir).expect("bin");
for f in ["paratest", "paratest.bat", "phpunit", "phpunit.bat"] {
std::fs::write(bin_dir.join(f), "x").expect("write");
}
let lock = Lock::parse(
&serde_json::json!({"packages": [
{"name": "brianium/paratest", "version": "7.3.2", "bin": ["bin/paratest", "bin/paratest.bat"]},
{"name": "phpunit/phpunit", "version": "11.0.0", "bin": ["phpunit"]}
], "packages-dev": []})
.to_string(),
)
.expect("lock");
let wanted: Vec<&LockPackage> = lock.packages.iter().collect();
prune_stale_bat_proxies(&bin_dir, &wanted, crate::binproxy::BinCompat::Proxy)
.expect("prune");
assert!(
bin_dir.join("paratest.bat").exists(),
"a declared .bat bin was pruned"
);
assert!(
!bin_dir.join("phpunit.bat").exists(),
"the stale .bat was kept"
);
}
}