1use crate::error::{Error, Result};
8use crate::fetch::{Fetcher, Provenance};
9use crate::layout::Layout;
10use crate::lock::{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}
46
47fn identity(p: &LockPackage) -> (String, String) {
49 (
50 p.version().to_owned(),
51 p.dist_reference().unwrap_or("").to_owned(),
52 )
53}
54
55fn installed_identities(vendor: &Path) -> BTreeMap<String, (String, String)> {
56 let mut out = BTreeMap::new();
57 let path = vendor.join("composer/installed.json");
58 let Ok(text) = std::fs::read_to_string(&path) else {
59 return out;
60 };
61 let Ok(v) = serde_json::from_str::<Value>(&text) else {
62 return out;
63 };
64 for p in v["packages"].as_array().into_iter().flatten() {
65 let name = p["name"].as_str().unwrap_or_default();
66 let version = p["version"].as_str().unwrap_or_default();
67 let reference = p["dist"]["reference"].as_str().unwrap_or_default();
68 out.insert(name.to_owned(), (version.to_owned(), reference.to_owned()));
69 }
70 out
71}
72
73pub async fn install(
74 _project_dir: &Path,
75 lock: &Lock,
76 root_manifest: &Value,
77 layout: &Layout,
78 store: Arc<Store>,
79 fetcher: Arc<Fetcher>,
80 opts: &InstallOptions,
81) -> Result<InstallReport> {
82 let project_dir = layout.root();
85 let vendor = project_dir.join("vendor");
86 std::fs::create_dir_all(&vendor).map_err(Error::io(&vendor))?;
87
88 let mut report = InstallReport::default();
89 let wanted: Vec<&LockPackage> = lock.wanted_packages(opts.with_dev).collect();
90 let wanted_names: std::collections::BTreeSet<&str> = wanted.iter().map(|p| p.name()).collect();
91 let previous = installed_identities(&vendor);
92
93 let mut to_install: Vec<&LockPackage> = Vec::new();
98 let mut to_warm: Vec<&LockPackage> = Vec::new();
99 for p in &wanted {
100 if p.is_metapackage() {
101 continue;
102 }
103 let unchanged = previous.get(p.name()) == Some(&identity(p))
104 && layout.abs(p.name()).is_some_and(|d| d.is_dir());
105 if unchanged {
106 report.unchanged += 1;
107 if !store.contains(p.name(), p.version(), p.dist_reference()) {
108 to_warm.push(p);
109 }
110 } else {
111 to_install.push(p);
112 }
113 }
114
115 let sem = Arc::new(tokio::sync::Semaphore::new(opts.jobs.max(1)));
119 let mut tasks = tokio::task::JoinSet::new();
120 let warm_names: std::collections::BTreeSet<&str> = to_warm.iter().map(|p| p.name()).collect();
121 for p in to_install.iter().chain(to_warm.iter()) {
122 if store.contains(p.name(), p.version(), p.dist_reference()) {
123 report.store_hits += 1;
124 continue;
125 }
126 let warm_only =
127 warm_names.contains(p.name()) && !to_install.iter().any(|q| q.name() == p.name());
128 let (name, version) = (p.name().to_owned(), p.version().to_owned());
129 let dist_ref = p.dist_reference().map(str::to_owned);
130 let url = p
131 .dist_url()
132 .ok_or_else(|| Error::Http {
133 url: name.clone(),
134 message:
135 "package without a dist url (the scope detector should have routed to the fallback)"
136 .to_owned(),
137 })?
138 .to_owned();
139 let shasum = p.dist_shasum().map(str::to_owned);
140 let (store, fetcher, sem) = (store.clone(), fetcher.clone(), sem.clone());
141 let offline = opts.offline || warm_only;
142 tasks.spawn(async move {
143 let _permit = sem.acquire().await.map_err(|_| Error::Http {
144 url: url.clone(),
145 message: "semaphore closed".to_owned(),
146 })?;
147 let fetched = fetcher
148 .dist_bytes(&name, &url, shasum.as_deref(), offline)
149 .await;
150 let (bytes, provenance) = match fetched {
151 Ok(v) => v,
152 Err(_) if warm_only => return Ok::<Option<Provenance>, Error>(None),
154 Err(e) => return Err(e),
155 };
156 let store_name = name.clone();
157 let version2 = version.clone();
158 let dist_ref2 = dist_ref.clone();
159 tokio::task::spawn_blocking(move || {
160 store.ensure(&store_name, &version2, dist_ref2.as_deref(), &bytes)
161 })
162 .await
163 .map_err(|e| Error::Http {
164 url: name.clone(),
165 message: format!("extraction task interrupted: {e}"),
166 })??;
167 Ok::<Option<Provenance>, Error>(Some(provenance))
168 });
169 }
170 while let Some(joined) = tasks.join_next().await {
171 let provenance = joined.map_err(|e| Error::Http {
172 url: "join".to_owned(),
173 message: e.to_string(),
174 })??;
175 match provenance {
176 Some(Provenance::Cache) => report.from_cache += 1,
177 Some(Provenance::Network) => report.from_network += 1,
178 None => {}
179 }
180 }
181 report.store_warmed = to_warm.len();
182
183 for name in previous.keys() {
186 if !wanted_names.contains(name.as_str()) {
187 report.removed += 1;
188 }
189 }
190 for (_, dir) in layout.removals() {
191 if dir.exists() {
192 std::fs::remove_dir_all(&dir).map_err(Error::io(&dir))?;
193 prune_empty_parent(project_dir, &dir);
194 }
195 }
196
197 for p in &to_install {
199 let (Some(pkg_root), Some(dest)) = (layout.package_root(p.name()), layout.abs(p.name()))
200 else {
201 continue;
202 };
203 if pkg_root.exists() {
205 std::fs::remove_dir_all(&pkg_root).map_err(Error::io(&pkg_root))?;
206 }
207 let src = store.entry_path(p.name(), p.version(), p.dist_reference());
208 crate::clone::clone_tree(&src, &dest)?;
209 report.installed += 1;
210 }
211
212 let bin_compat = crate::binproxy::resolve_bin_compat(root_manifest)?;
217 for p in &wanted {
218 let bins = p.bins();
219 if let (false, Some(dir)) = (bins.is_empty(), layout.abs(p.name())) {
220 crate::binproxy::install_binaries(&vendor, &dir, &bins, bin_compat)?;
221 }
222 }
223 prune_orphan_bin_proxies(&vendor, &wanted, bin_compat)?;
224
225 let root = RootPackage::detect(root_manifest, project_dir, opts.with_dev);
227 crate::state::write_state_files(
228 &vendor.join("composer"),
229 lock,
230 &root,
231 root_manifest,
232 opts.with_dev,
233 layout,
234 )?;
235 if wanted.iter().any(|p| p.name() == "symfony/runtime") {
236 crate::runtime_stub::write_stub(&vendor)?;
237 }
238
239 Ok(report)
240}
241
242fn prune_empty_parent(project_dir: &Path, removed: &Path) {
246 let Some(parent) = removed.parent() else {
247 return;
248 };
249 if parent == project_dir {
250 return;
251 }
252 if std::fs::read_dir(parent)
253 .map(|mut d| d.next().is_none())
254 .unwrap_or(false)
255 {
256 let _ = std::fs::remove_dir(parent);
257 }
258}
259
260fn prune_orphan_bin_proxies(
261 vendor: &Path,
262 wanted: &[&LockPackage],
263 bin_compat: crate::binproxy::BinCompat,
264) -> Result<()> {
265 let bin_dir = vendor.join("bin");
266 let Ok(entries) = std::fs::read_dir(&bin_dir) else {
267 return Ok(());
268 };
269 let mut expected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
270 for p in wanted {
271 for bin in p.bins() {
272 let bin = bin.trim_start_matches("./");
273 let link = bin.rsplit_once('/').map(|(_, f)| f).unwrap_or(bin);
274 expected.insert(link.to_owned());
275 }
276 }
277 for entry in entries.flatten() {
278 let file_name = entry.file_name().to_string_lossy().into_owned();
279 let keep = expected.contains(&file_name)
285 || (bin_compat == crate::binproxy::BinCompat::Full
286 && file_name
287 .strip_suffix(".bat")
288 .is_some_and(|stem| expected.contains(stem)));
289 if !keep {
290 let p = entry.path();
291 std::fs::remove_file(&p).map_err(Error::io(&p))?;
292 }
293 }
294 Ok(())
295}
296
297pub fn vendor_composer_dir(project_dir: &Path) -> PathBuf {
299 project_dir.join("vendor/composer")
300}