1use std::{
2 io::{self, Cursor},
3 path::{Path, PathBuf},
4 process::ExitStatus,
5 sync::Arc,
6};
7
8use bon::Builder;
9use bytes::Bytes;
10use futures::StreamExt;
11use itertools::Itertools;
12use miette::Diagnostic;
13use path_slash::PathExt;
14use strum::IntoEnumIterator;
15use thiserror::Error;
16use tokio::io::AsyncWriteExt;
17use tracing::Instrument;
18
19use crate::{
20 build::{resolve_source_dir, RemotePackageSourceSpec, SrcRockSource},
21 config::Config,
22 fs,
23 lockfile::{LocalPackageLockType, ReadOnly, RemotePackageSourceUrl},
24 lua_rockspec::{BuildBackendSpec, RemoteLuaRockspec},
25 operations::{
26 self,
27 resolve::{
28 luarocks_build_backend_name, PackageInstallData, Resolve, ResolveDependenciesError,
29 },
30 DownloadedRockspec, FetchSrcError, PackageInstallSpec, UnpackError,
31 },
32 package::{PackageReq, PackageSpec},
33 project::project_toml::LocalProjectTomlValidationError,
34 remote_package_db::{RemotePackageDB, RemotePackageDBError},
35 reqwest::RequestError,
36 rockspec::Rockspec,
37 tree::EntryType,
38 workspace::{Workspace, WorkspaceError},
39};
40
41#[allow(clippy::large_enum_variant)]
42pub enum VendorTarget {
43 Workspace(Workspace),
45
46 Rockspec(RemoteLuaRockspec),
48}
49
50#[derive(Builder)]
54#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
55pub struct Vendor<'a> {
56 target: VendorTarget,
57
58 vendor_dir: PathBuf,
60
61 no_lock: Option<bool>,
63
64 no_delete: Option<bool>,
67
68 config: &'a Config,
69}
70
71#[derive(Error, Debug, Diagnostic)]
72pub enum VendorError {
73 #[error(transparent)]
74 #[diagnostic(transparent)]
75 Workspace(#[from] WorkspaceError),
76 #[error("project validation failed")]
77 #[diagnostic(forward(0))]
78 LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
79 #[error("error initialising remote package DB")]
80 #[diagnostic(forward(0))]
81 RemotePackageDB(#[from] RemotePackageDBError),
82 #[error("failed to resolve dependencies")]
83 #[diagnostic(forward(0))]
84 ResolveDependencies(#[from] ResolveDependenciesError),
85 #[error(transparent)]
86 #[diagnostic(transparent)]
87 Fs(#[from] fs::FsError),
88 #[error("failed to vendor Lua RockSpec:\n{0}")]
89 LuaRockSpec(String),
90 #[error("failed to unpack src.rock")]
91 #[diagnostic(forward(0))]
92 Unpack(#[from] UnpackError),
93 #[error("failed to fetch rock source")]
94 #[diagnostic(forward(0))]
95 FetchSrc(#[from] FetchSrcError),
96 #[error("failed to download rock source")]
97 #[diagnostic(forward(0))]
98 Request(#[from] RequestError),
99 #[error("failed to run `cargo vendor`")]
100 #[diagnostic(help("ensure cargo is installed"))]
101 CargoVendor { source: io::Error },
102 #[error("cargo vendor failed.\nstatus: {status}\nstdout: {stdout}\nstderr: {stderr}")]
103 #[diagnostic(help("check the output for details."))]
104 CargoVendorFailure {
105 status: ExitStatus,
106 stdout: String,
107 stderr: String,
108 },
109}
110
111impl<State> VendorBuilder<'_, State>
112where
113 State: vendor_builder::State + vendor_builder::IsComplete,
114{
115 pub async fn vendor_dependencies(self) -> Result<(), VendorError> {
116 do_vendor_dependencies(self._build()).await
117 }
118}
119
120const CARGO_VENDOR_SUBDIR: &str = "cargo";
121
122async fn do_vendor_dependencies(args: Vendor<'_>) -> Result<(), VendorError> {
123 let vendor_dir = args.vendor_dir;
124 let no_delete = args.no_delete.unwrap_or(false);
125 let no_lock = args.no_lock.unwrap_or(false);
126 let target = args.target;
127 let config = args.config;
128 let mut all_packages = Vec::new();
129
130 for lock_type in LocalPackageLockType::iter() {
131 let (package_db, install_specs) =
132 mk_resolve_args(lock_type, no_lock, &target, config).await?;
133
134 let (dep_tx, mut dep_rx) = tokio::sync::mpsc::unbounded_channel();
135 Resolve::<'_, ReadOnly>::new()
136 .dependencies_tx(dep_tx.clone())
137 .build_dependencies_tx(dep_tx)
138 .packages(install_specs)
139 .package_db(Arc::new(package_db))
140 .config(config)
141 .get_all_dependencies()
142 .await?;
143
144 while let Some(dep) = dep_rx.recv().await {
145 all_packages.push(dep);
146 }
147 }
148
149 let all_packages = all_packages
152 .into_iter()
153 .unique_by(|pkg| (pkg.spec.name().clone(), pkg.spec.version().clone()))
154 .collect_vec();
155
156 let cargo_deps: Vec<(PackageSpec, Option<PathBuf>, Vec<PathBuf>)> = all_packages
157 .iter()
158 .filter_map(|pkg| {
159 let rockspec = pkg.downloaded_rock.rockspec();
160 match rockspec.build().current_platform().build_backend {
161 Some(BuildBackendSpec::RustMlua(_) | BuildBackendSpec::RustBinary(_)) => Some((
162 pkg.spec.to_package(),
163 rockspec.source().current_platform().unpack_dir.clone(),
164 rockspec.build().current_platform().copy_directories.clone(),
165 )),
166 _ => None,
167 }
168 })
169 .collect();
170
171 if !no_delete && vendor_dir.exists() {
172 fs::tokio::remove_dir_all(&vendor_dir).await?;
173 }
174
175 let vendor_dir = Arc::new(vendor_dir);
176 vendor_sources(vendor_dir.clone(), config.clone(), all_packages).await?;
177 vendor_target_cargo_deps(&vendor_dir, &target, config).await?;
178 for (dep, unpack_dir, copy_dirs) in cargo_deps {
179 vendor_package_cargo_deps(&vendor_dir, &dep, &unpack_dir, ©_dirs, config).await?;
180 }
181 Ok(())
182}
183
184async fn mk_resolve_args(
185 lock_type: LocalPackageLockType,
186 no_lock: bool,
187 target: &VendorTarget,
188 config: &Config,
189) -> Result<(RemotePackageDB, Vec<PackageInstallSpec>), VendorError> {
190 match &target {
191 VendorTarget::Workspace(workspace) => {
192 let lockfile = workspace.try_lockfile()?;
196 let package_db = match lockfile {
197 Some(lockfile) if !no_lock => lockfile.local_pkg_lock(&lock_type).clone().into(),
198 _ => RemotePackageDB::from_config(config).await?,
199 };
200 let mut install_specs = Vec::new();
201 for project in workspace.members() {
202 let toml = project.toml().into_local()?;
203 push_dependencies(&lock_type, &toml, &mut install_specs)?;
204 if lock_type == LocalPackageLockType::Test {
205 for test_spec_dependency in toml
206 .test()
207 .current_platform()
208 .test_dependencies(project)
209 .iter()
210 .cloned()
211 .map(|dep| PackageInstallSpec::new(dep, EntryType::Entrypoint).build())
212 {
213 install_specs.push(test_spec_dependency);
214 }
215 }
216 }
217 Ok((package_db, install_specs))
218 }
219 VendorTarget::Rockspec(remote_lua_rockspec) => {
220 let package_db = RemotePackageDB::from_config(config).await?;
221 let mut install_specs = Vec::new();
222 push_dependencies(&lock_type, remote_lua_rockspec, &mut install_specs)?;
223 Ok((package_db, install_specs))
224 }
225 }
226}
227
228fn push_dependencies<R: Rockspec>(
229 lock_type: &LocalPackageLockType,
230 rockspec: &R,
231 install_specs: &mut Vec<PackageInstallSpec>,
232) -> Result<(), LocalProjectTomlValidationError> {
233 let mut dependencies: Vec<PackageReq> = match lock_type {
234 LocalPackageLockType::Regular => rockspec
235 .dependencies()
236 .current_platform()
237 .iter()
238 .map(|dep| dep.package_req().clone())
239 .collect_vec(),
240 LocalPackageLockType::Test => rockspec
241 .test_dependencies()
242 .current_platform()
243 .iter()
244 .map(|dep| dep.package_req().clone())
245 .collect_vec(),
246 LocalPackageLockType::Build => rockspec
247 .build_dependencies()
248 .current_platform()
249 .iter()
250 .map(|dep| dep.package_req().clone())
251 .collect_vec(),
252 };
253 if *lock_type == LocalPackageLockType::Build {
254 if let Some(backend) = luarocks_build_backend_name(rockspec) {
255 dependencies.insert(0, backend.into());
256 }
257 }
258 install_specs.extend(
259 dependencies
260 .into_iter()
261 .unique()
262 .map(|dep| PackageInstallSpec::new(dep, EntryType::Entrypoint).build())
263 .collect_vec(),
264 );
265 Ok(())
266}
267
268async fn vendor_sources(
269 vendor_dir: Arc<PathBuf>,
270 config: Config,
271 packages: Vec<PackageInstallData>,
272) -> Result<(), VendorError> {
273 futures::stream::iter(packages.into_iter().map(|dep| {
274 let vendor_dir = Arc::clone(&vendor_dir);
275 let config = config.clone();
276 tokio::spawn(
277 async move {
278 match dep.downloaded_rock {
279 crate::operations::RemoteRockDownload::RockspecOnly { rockspec_download } => {
280 vendor_rockspec_sources(&vendor_dir, rockspec_download, None, &config)
281 .await?
282 }
283 crate::operations::RemoteRockDownload::BinaryRock {
284 rockspec_download,
285 packed_rock,
286 } => vendor_binary_rock(&vendor_dir, rockspec_download, packed_rock).await?,
287 crate::operations::RemoteRockDownload::SrcRock {
288 rockspec_download,
289 src_rock,
290 source_url,
291 } => {
292 let src_rock_source = SrcRockSource {
293 bytes: src_rock,
294 source_url,
295 };
296 vendor_rockspec_sources(
297 &vendor_dir,
298 rockspec_download,
299 Some(src_rock_source),
300 &config,
301 )
302 .await?
303 }
304 };
305 Ok::<_, VendorError>(())
306 }
307 .instrument(tracing::trace_span!("vendor_worker")),
308 )
309 }))
310 .buffered(config.max_jobs())
311 .collect::<Vec<_>>()
312 .instrument(tracing::trace_span!("vendor_collector"))
313 .await
314 .into_iter()
315 .flatten()
316 .try_collect()
317}
318
319#[tracing::instrument(
320 name = "Vendoring source",
321 level = "info",
322 skip_all,
323 fields(
324 package = rockspec_download.rockspec.package().to_string(),
325 version = rockspec_download.rockspec.version().to_string(),
326 ),
327)]
328async fn vendor_rockspec_sources(
329 vendor_dir: &Path,
330 rockspec_download: DownloadedRockspec,
331 src_rock_source: Option<SrcRockSource>,
332 config: &Config,
333) -> Result<(), VendorError> {
334 let rockspec = rockspec_download.rockspec;
335 let package = rockspec.package();
336 let version = rockspec.version();
337 let package_version_str = format!("{}@{}", package, version);
338
339 let source_spec = match src_rock_source {
340 Some(src_rock_source) => RemotePackageSourceSpec::SrcRock(src_rock_source),
341 None => RemotePackageSourceSpec::RockSpec(rockspec_download.source_url),
342 };
343
344 let source_path = vendor_dir.join(&package_version_str);
345
346 fs::tokio::create_dir_all(vendor_dir).await?;
347
348 let rockspec_lua_content = rockspec
349 .to_lua_remote_rockspec_string()
350 .map_err(|err| VendorError::LuaRockSpec(err.to_string()))?;
351
352 let rockspec_file_name = format!("{}-{}.rockspec", package, version);
353 let rockspec_path = vendor_dir.join(rockspec_file_name);
354 fs::tokio::write(&rockspec_path, rockspec_lua_content).await?;
355
356 match source_spec {
357 RemotePackageSourceSpec::SrcRock(SrcRockSource {
358 bytes,
359 source_url: _,
360 }) => {
361 fs::tokio::write(&source_path, &bytes).await?;
362 }
363 RemotePackageSourceSpec::RockSpec(source_url) => match source_url {
364 Some(RemotePackageSourceUrl::Url { url }) => {
365 let bytes = crate::reqwest::download_bytes(config, &url).await?;
366 fs::tokio::write(&source_path, &bytes).await?;
367 }
368 _ => {
369 fs::tokio::create_dir_all(&source_path).await?;
370 operations::FetchSrc::new(&source_path, &rockspec, config)
371 .maybe_source_url(source_url)
372 .fetch_internal()
373 .await?;
374 }
375 },
376 }
377
378 Ok(())
379}
380
381#[tracing::instrument(
382 name = "Vendoring pre-built binary",
383 level = "info",
384 skip_all,
385 fields(
386 package = rockspec_download.rockspec.package().to_string(),
387 version = rockspec_download.rockspec.version().to_string(),
388 ),
389)]
390async fn vendor_binary_rock(
391 vendor_dir: &Path,
392 rockspec_download: DownloadedRockspec,
393 packed_rock: Bytes,
394) -> Result<(), VendorError> {
395 let rockspec = rockspec_download.rockspec;
396 let package = rockspec.package();
397 let version = rockspec.version();
398
399 let file_name = format!("{}@{}.rock", package, version);
400
401 fs::tokio::create_dir_all(&vendor_dir).await?;
402
403 let dest_file = vendor_dir.join(&file_name);
404 let mut file = fs::tokio::create(&dest_file).await?;
405 file.write_all(&packed_rock)
406 .await
407 .map_err(|source| fs::FsError::Write {
408 path: dest_file.to_path_buf(),
409 source,
410 })?;
411
412 let rockspec_lua_content = rockspec
413 .to_lua_remote_rockspec_string()
414 .map_err(|err| VendorError::LuaRockSpec(err.to_string()))?;
415
416 let rockspec_file_name = format!("{}-{}.rockspec", package, version);
417 let rockspec_path = vendor_dir.join(rockspec_file_name);
418 fs::tokio::write(&rockspec_path, rockspec_lua_content).await?;
419
420 Ok(())
421}
422
423#[tracing::instrument(name = "Vendoring cargo dependencies", level = "info", skip_all)]
424async fn vendor_target_cargo_deps(
425 vendor_dir: &Path,
426 target: &VendorTarget,
427 config: &Config,
428) -> Result<(), VendorError> {
429 if is_cargo_build_backend(target) {
430 match target {
431 VendorTarget::Workspace(workspace) => {
432 cargo_vendor(vendor_dir, workspace.root().as_path(), config).await
433 }
434 VendorTarget::Rockspec(rockspec) => {
435 let temp_dir = fs::tempfile::tempdir()?;
436 operations::FetchSrc::new(temp_dir.path(), rockspec, config)
437 .fetch_internal()
438 .await?;
439 cargo_vendor(
440 vendor_dir,
441 &resolve_source_dir(
442 temp_dir.path(),
443 rockspec.source().current_platform().unpack_dir.as_deref(),
444 &rockspec.build().current_platform().copy_directories,
445 )?,
446 config,
447 )
448 .await
449 }
450 }
451 } else {
452 Ok(())
453 }
454}
455
456#[tracing::instrument(
457 name = "Vendoring cargo dependencies",
458 level = "info",
459 skip_all,
460 fields(
461 package = package.name().to_string(),
462 version = package.version().to_string(),
463 ),
464)]
465async fn vendor_package_cargo_deps(
466 vendor_dir: &Path,
467 package: &PackageSpec,
468 unpack_dir: &Option<PathBuf>,
469 copy_dirs: &[PathBuf],
470 config: &Config,
471) -> Result<(), VendorError> {
472 let source_dir = vendor_dir.join(format!("{}@{}", package.name(), package.version()));
473 if source_dir.is_dir() {
474 cargo_vendor(
475 vendor_dir,
476 &resolve_source_dir(&source_dir, unpack_dir.as_deref(), copy_dirs)?,
477 config,
478 )
479 .await?;
480 } else if source_dir.is_file() {
481 let temp_dir = fs::tempfile::tempdir()?;
484 extract_source_archive(&source_dir, unpack_dir.as_deref(), temp_dir.path()).await?;
485 cargo_vendor(
486 vendor_dir,
487 &resolve_source_dir(temp_dir.path(), unpack_dir.as_deref(), copy_dirs)?,
488 config,
489 )
490 .await?;
491 }
492
493 Ok(())
494}
495
496fn is_cargo_build_backend(target: &VendorTarget) -> bool {
497 match target {
498 VendorTarget::Workspace(workspace) => workspace.members().iter().any(|project| {
499 project.toml().into_local().is_ok_and(|toml| {
500 matches!(
501 toml.build().current_platform().build_backend.to_owned(),
502 Some(BuildBackendSpec::RustMlua(_) | BuildBackendSpec::RustBinary(_))
503 )
504 })
505 }),
506 VendorTarget::Rockspec(rockspec) => matches!(
507 rockspec.build().current_platform().build_backend.to_owned(),
508 Some(BuildBackendSpec::RustMlua(_) | BuildBackendSpec::RustBinary(_))
509 ),
510 }
511}
512
513async fn extract_source_archive(
514 source_file: &Path,
515 unpack_dir: Option<&Path>,
516 dest_dir: &Path,
517) -> Result<(), VendorError> {
518 let bytes = fs::tokio::read(source_file).await?;
519 let mime_type = infer::get(&bytes).map(|file_type| file_type.mime_type());
520 let file_name = source_file
521 .file_name()
522 .map(|file_name| file_name.to_string_lossy())
523 .unwrap_or(source_file.to_slash_lossy())
524 .to_string();
525 operations::unpack::unpack(
526 mime_type,
527 Cursor::new(bytes),
528 unpack_dir.is_none(),
529 file_name,
530 dest_dir,
531 )
532 .await?;
533 Ok(())
534}
535
536async fn cargo_vendor(
537 vendor_dir: &Path,
538 source_dir: &Path,
539 config: &Config,
540) -> Result<(), VendorError> {
541 let cargo_vendor_dir = fs::sync::absolute(vendor_dir.join(CARGO_VENDOR_SUBDIR))?;
542 fs::tokio::create_dir_all(&cargo_vendor_dir).await?;
543
544 let output = config
545 .wrapped_command("cargo", ["vendor", "--locked", "--versioned-dirs"])
546 .arg(&cargo_vendor_dir)
547 .current_dir(source_dir)
548 .output()
549 .await
550 .map_err(|source| VendorError::CargoVendor { source })?;
551
552 if output.status.success() {
553 Ok(())
554 } else {
555 Err(VendorError::CargoVendorFailure {
556 status: output.status,
557 stdout: String::from_utf8_lossy(&output.stdout).into(),
558 stderr: String::from_utf8_lossy(&output.stderr).into(),
559 })
560 }
561}