Skip to main content

lux_lib/operations/
vendor.rs

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    /// Vendor dependencies of a Lux workspace
44    Workspace(Workspace),
45
46    /// Vendor dependencies of a Lua RockSpec
47    Rockspec(RemoteLuaRockspec),
48}
49
50/// Vendor a project's dependencies into the specified directory at `<vendor_dir>`.
51/// After this command completes the vendor directory specified by `<vendor_dir>`
52/// will contain all remote sources from dependencies specified.
53#[derive(Builder)]
54#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
55pub struct Vendor<'a> {
56    target: VendorTarget,
57
58    /// The directory in which to vendor the dependencies.
59    vendor_dir: PathBuf,
60
61    /// Ignore the project's lockfile.
62    no_lock: Option<bool>,
63
64    /// Don't delete the `<vendor-dir>` when vendoring,{n}
65    /// but rather keep all existing contents of the vendor directory.
66    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] Box<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 From<ResolveDependenciesError> for VendorError {
112    fn from(source: ResolveDependenciesError) -> Self {
113        Self::ResolveDependencies(Box::new(source))
114    }
115}
116
117impl<State> VendorBuilder<'_, State>
118where
119    State: vendor_builder::State + vendor_builder::IsComplete,
120{
121    pub async fn vendor_dependencies(self) -> Result<(), VendorError> {
122        do_vendor_dependencies(self._build()).await
123    }
124}
125
126const CARGO_VENDOR_SUBDIR: &str = "cargo";
127
128async fn do_vendor_dependencies(args: Vendor<'_>) -> Result<(), VendorError> {
129    let vendor_dir = args.vendor_dir;
130    let no_delete = args.no_delete.unwrap_or(false);
131    let no_lock = args.no_lock.unwrap_or(false);
132    let target = args.target;
133    let config = args.config;
134    let mut all_packages = Vec::new();
135
136    for lock_type in LocalPackageLockType::iter() {
137        let (package_db, install_specs) =
138            mk_resolve_args(lock_type, no_lock, &target, config).await?;
139
140        let (dep_tx, mut dep_rx) = tokio::sync::mpsc::unbounded_channel();
141        Resolve::<'_, ReadOnly>::new()
142            .dependencies_tx(dep_tx.clone())
143            .build_dependencies_tx(dep_tx)
144            .packages(install_specs)
145            .package_db(Arc::new(package_db))
146            .config(config)
147            .get_all_dependencies()
148            .await?;
149
150        while let Some(dep) = dep_rx.recv().await {
151            all_packages.push(dep);
152        }
153    }
154
155    // The lockfile may contain the same package (name@version) multiple times,
156    // with different constraints.
157    let all_packages = all_packages
158        .into_iter()
159        .unique_by(|pkg| (pkg.spec.name().clone(), pkg.spec.version().clone()))
160        .collect_vec();
161
162    let cargo_deps: Vec<(PackageSpec, Option<PathBuf>, Vec<PathBuf>)> = all_packages
163        .iter()
164        .filter_map(|pkg| {
165            let rockspec = pkg.downloaded_rock.rockspec();
166            match rockspec.build().current_platform().build_backend {
167                Some(BuildBackendSpec::RustMlua(_) | BuildBackendSpec::RustBinary(_)) => Some((
168                    pkg.spec.to_package(),
169                    rockspec.source().current_platform().unpack_dir.clone(),
170                    rockspec.build().current_platform().copy_directories.clone(),
171                )),
172                _ => None,
173            }
174        })
175        .collect();
176
177    if !no_delete && vendor_dir.exists() {
178        fs::tokio::remove_dir_all(&vendor_dir).await?;
179    }
180
181    let vendor_dir = Arc::new(vendor_dir);
182    vendor_sources(vendor_dir.clone(), config.clone(), all_packages).await?;
183    vendor_target_cargo_deps(&vendor_dir, &target, config).await?;
184    for (dep, unpack_dir, copy_dirs) in cargo_deps {
185        vendor_package_cargo_deps(&vendor_dir, &dep, &unpack_dir, &copy_dirs, config).await?;
186    }
187    Ok(())
188}
189
190async fn mk_resolve_args(
191    lock_type: LocalPackageLockType,
192    no_lock: bool,
193    target: &VendorTarget,
194    config: &Config,
195) -> Result<(RemotePackageDB, Vec<PackageInstallSpec>), VendorError> {
196    match &target {
197        VendorTarget::Workspace(workspace) => {
198            // Resolve against the project's lockfile if present, otherwise fall
199            // back to the remote package DB (e.g. for a project that has not
200            // yet generated a lockfile).
201            let lockfile = workspace.try_lockfile()?;
202            let package_db = match lockfile {
203                Some(lockfile) if !no_lock => lockfile.local_pkg_locks().into(),
204                _ => RemotePackageDB::from_config(config).await?,
205            };
206            let mut install_specs = Vec::new();
207            for project in workspace.members() {
208                let toml = project.toml().into_local()?;
209                push_dependencies(&lock_type, &toml, &mut install_specs)?;
210                if lock_type == LocalPackageLockType::Test {
211                    for test_spec_dependency in toml
212                        .test()
213                        .current_platform()
214                        .test_dependencies(project)
215                        .iter()
216                        .cloned()
217                        .map(|dep| PackageInstallSpec::new(dep, EntryType::Entrypoint).build())
218                    {
219                        install_specs.push(test_spec_dependency);
220                    }
221                }
222            }
223            Ok((package_db, install_specs))
224        }
225        VendorTarget::Rockspec(remote_lua_rockspec) => {
226            let package_db = RemotePackageDB::from_config(config).await?;
227            let mut install_specs = Vec::new();
228            push_dependencies(&lock_type, remote_lua_rockspec, &mut install_specs)?;
229            Ok((package_db, install_specs))
230        }
231    }
232}
233
234fn push_dependencies<R: Rockspec>(
235    lock_type: &LocalPackageLockType,
236    rockspec: &R,
237    install_specs: &mut Vec<PackageInstallSpec>,
238) -> Result<(), LocalProjectTomlValidationError> {
239    let mut dependencies: Vec<PackageReq> = match lock_type {
240        LocalPackageLockType::Regular => rockspec
241            .dependencies()
242            .current_platform()
243            .iter()
244            .map(|dep| dep.package_req().clone())
245            .collect_vec(),
246        LocalPackageLockType::Test => rockspec
247            .test_dependencies()
248            .current_platform()
249            .iter()
250            .map(|dep| dep.package_req().clone())
251            .collect_vec(),
252        LocalPackageLockType::Build => rockspec
253            .build_dependencies()
254            .current_platform()
255            .iter()
256            .map(|dep| dep.package_req().clone())
257            .collect_vec(),
258    };
259    if *lock_type == LocalPackageLockType::Build {
260        if let Some(backend) = luarocks_build_backend_name(rockspec) {
261            dependencies.insert(0, backend.into());
262        }
263    }
264    install_specs.extend(
265        dependencies
266            .into_iter()
267            .unique()
268            .map(|dep| PackageInstallSpec::new(dep, EntryType::Entrypoint).build())
269            .collect_vec(),
270    );
271    Ok(())
272}
273
274async fn vendor_sources(
275    vendor_dir: Arc<PathBuf>,
276    config: Config,
277    packages: Vec<PackageInstallData>,
278) -> Result<(), VendorError> {
279    futures::stream::iter(packages.into_iter().map(|dep| {
280        let vendor_dir = Arc::clone(&vendor_dir);
281        let config = config.clone();
282        tokio::spawn(
283            async move {
284                match dep.downloaded_rock {
285                    crate::operations::RemoteRockDownload::RockspecOnly { rockspec_download } => {
286                        vendor_rockspec_sources(&vendor_dir, rockspec_download, None, &config)
287                            .await?
288                    }
289                    crate::operations::RemoteRockDownload::BinaryRock {
290                        rockspec_download,
291                        packed_rock,
292                    } => vendor_binary_rock(&vendor_dir, rockspec_download, packed_rock).await?,
293                    crate::operations::RemoteRockDownload::SrcRock {
294                        rockspec_download,
295                        src_rock,
296                        source_url,
297                    } => {
298                        let src_rock_source = SrcRockSource {
299                            bytes: src_rock,
300                            source_url,
301                        };
302                        vendor_rockspec_sources(
303                            &vendor_dir,
304                            rockspec_download,
305                            Some(src_rock_source),
306                            &config,
307                        )
308                        .await?
309                    }
310                };
311                Ok::<_, VendorError>(())
312            }
313            .instrument(tracing::trace_span!("vendor_worker")),
314        )
315    }))
316    .buffered(config.max_jobs())
317    .collect::<Vec<_>>()
318    .instrument(tracing::trace_span!("vendor_collector"))
319    .await
320    .into_iter()
321    .flatten()
322    .try_collect()
323}
324
325#[tracing::instrument(
326    name = "Vendoring source",
327    level = "info",
328    skip_all,
329    fields(
330        package = rockspec_download.rockspec.package().to_string(),
331        version = rockspec_download.rockspec.version().to_string(),
332    ),
333)]
334async fn vendor_rockspec_sources(
335    vendor_dir: &Path,
336    rockspec_download: DownloadedRockspec,
337    src_rock_source: Option<SrcRockSource>,
338    config: &Config,
339) -> Result<(), VendorError> {
340    let rockspec = rockspec_download.rockspec;
341    let package = rockspec.package();
342    let version = rockspec.version();
343    let package_version_str = format!("{}@{}", package, version);
344
345    let source_spec = match src_rock_source {
346        Some(src_rock_source) => RemotePackageSourceSpec::SrcRock(src_rock_source),
347        None => RemotePackageSourceSpec::RockSpec(rockspec_download.source_url),
348    };
349
350    let source_path = vendor_dir.join(&package_version_str);
351
352    fs::tokio::create_dir_all(vendor_dir).await?;
353
354    let rockspec_lua_content = rockspec
355        .to_lua_remote_rockspec_string()
356        .map_err(|err| VendorError::LuaRockSpec(err.to_string()))?;
357
358    let rockspec_file_name = format!("{}-{}.rockspec", package, version);
359    let rockspec_path = vendor_dir.join(rockspec_file_name);
360    fs::tokio::write(&rockspec_path, rockspec_lua_content).await?;
361
362    match source_spec {
363        RemotePackageSourceSpec::SrcRock(SrcRockSource {
364            bytes,
365            source_url: _,
366        }) => {
367            fs::tokio::write(&source_path, &bytes).await?;
368        }
369        RemotePackageSourceSpec::RockSpec(source_url) => match source_url {
370            Some(RemotePackageSourceUrl::Url { url }) => {
371                let bytes = crate::reqwest::download_bytes(config, &url).await?;
372                fs::tokio::write(&source_path, &bytes).await?;
373            }
374            _ => {
375                fs::tokio::create_dir_all(&source_path).await?;
376                operations::FetchSrc::new(&source_path, &rockspec, config)
377                    .maybe_source_url(source_url)
378                    .fetch_internal()
379                    .await?;
380            }
381        },
382    }
383
384    Ok(())
385}
386
387#[tracing::instrument(
388    name = "Vendoring pre-built binary",
389    level = "info",
390    skip_all,
391    fields(
392        package = rockspec_download.rockspec.package().to_string(),
393        version = rockspec_download.rockspec.version().to_string(),
394    ),
395)]
396async fn vendor_binary_rock(
397    vendor_dir: &Path,
398    rockspec_download: DownloadedRockspec,
399    packed_rock: Bytes,
400) -> Result<(), VendorError> {
401    let rockspec = rockspec_download.rockspec;
402    let package = rockspec.package();
403    let version = rockspec.version();
404
405    let file_name = format!("{}@{}.rock", package, version);
406
407    fs::tokio::create_dir_all(&vendor_dir).await?;
408
409    let dest_file = vendor_dir.join(&file_name);
410    let mut file = fs::tokio::create(&dest_file).await?;
411    file.write_all(&packed_rock)
412        .await
413        .map_err(|source| fs::FsError::Write {
414            path: dest_file.to_path_buf(),
415            source,
416        })?;
417
418    let rockspec_lua_content = rockspec
419        .to_lua_remote_rockspec_string()
420        .map_err(|err| VendorError::LuaRockSpec(err.to_string()))?;
421
422    let rockspec_file_name = format!("{}-{}.rockspec", package, version);
423    let rockspec_path = vendor_dir.join(rockspec_file_name);
424    fs::tokio::write(&rockspec_path, rockspec_lua_content).await?;
425
426    Ok(())
427}
428
429#[tracing::instrument(name = "Vendoring cargo dependencies", level = "info", skip_all)]
430async fn vendor_target_cargo_deps(
431    vendor_dir: &Path,
432    target: &VendorTarget,
433    config: &Config,
434) -> Result<(), VendorError> {
435    if is_cargo_build_backend(target) {
436        match target {
437            VendorTarget::Workspace(workspace) => {
438                cargo_vendor(vendor_dir, workspace.root().as_path(), config).await
439            }
440            VendorTarget::Rockspec(rockspec) => {
441                let temp_dir = fs::tempfile::tempdir()?;
442                operations::FetchSrc::new(temp_dir.path(), rockspec, config)
443                    .fetch_internal()
444                    .await?;
445                cargo_vendor(
446                    vendor_dir,
447                    &resolve_source_dir(
448                        temp_dir.path(),
449                        rockspec.source().current_platform().unpack_dir.as_deref(),
450                        &rockspec.build().current_platform().copy_directories,
451                    )?,
452                    config,
453                )
454                .await
455            }
456        }
457    } else {
458        Ok(())
459    }
460}
461
462#[tracing::instrument(
463    name = "Vendoring cargo dependencies",
464    level = "info",
465    skip_all,
466    fields(
467        package = package.name().to_string(),
468        version = package.version().to_string(),
469    ),
470)]
471async fn vendor_package_cargo_deps(
472    vendor_dir: &Path,
473    package: &PackageSpec,
474    unpack_dir: &Option<PathBuf>,
475    copy_dirs: &[PathBuf],
476    config: &Config,
477) -> Result<(), VendorError> {
478    let source_dir = vendor_dir.join(format!("{}@{}", package.name(), package.version()));
479    if source_dir.is_dir() {
480        cargo_vendor(
481            vendor_dir,
482            &resolve_source_dir(&source_dir, unpack_dir.as_deref(), copy_dirs)?,
483            config,
484        )
485        .await?;
486    } else if source_dir.is_file() {
487        // The vendored source is an archive; extract it so `cargo vendor` can
488        // read its `Cargo.toml`. Keep the temp dir alive while cargo runs.
489        let temp_dir = fs::tempfile::tempdir()?;
490        extract_source_archive(&source_dir, unpack_dir.as_deref(), temp_dir.path()).await?;
491        cargo_vendor(
492            vendor_dir,
493            &resolve_source_dir(temp_dir.path(), unpack_dir.as_deref(), copy_dirs)?,
494            config,
495        )
496        .await?;
497    }
498
499    Ok(())
500}
501
502fn is_cargo_build_backend(target: &VendorTarget) -> bool {
503    match target {
504        VendorTarget::Workspace(workspace) => workspace.members().iter().any(|project| {
505            project.toml().into_local().is_ok_and(|toml| {
506                matches!(
507                    toml.build().current_platform().build_backend.to_owned(),
508                    Some(BuildBackendSpec::RustMlua(_) | BuildBackendSpec::RustBinary(_))
509                )
510            })
511        }),
512        VendorTarget::Rockspec(rockspec) => matches!(
513            rockspec.build().current_platform().build_backend.to_owned(),
514            Some(BuildBackendSpec::RustMlua(_) | BuildBackendSpec::RustBinary(_))
515        ),
516    }
517}
518
519async fn extract_source_archive(
520    source_file: &Path,
521    unpack_dir: Option<&Path>,
522    dest_dir: &Path,
523) -> Result<(), VendorError> {
524    let bytes = fs::tokio::read(source_file).await?;
525    let mime_type = infer::get(&bytes).map(|file_type| file_type.mime_type());
526    let file_name = source_file
527        .file_name()
528        .map(|file_name| file_name.to_string_lossy())
529        .unwrap_or(source_file.to_slash_lossy())
530        .to_string();
531    operations::unpack::unpack(
532        mime_type,
533        Cursor::new(bytes),
534        unpack_dir.is_none(),
535        file_name,
536        dest_dir,
537    )
538    .await?;
539    Ok(())
540}
541
542async fn cargo_vendor(
543    vendor_dir: &Path,
544    source_dir: &Path,
545    config: &Config,
546) -> Result<(), VendorError> {
547    let cargo_vendor_dir = fs::sync::absolute(vendor_dir.join(CARGO_VENDOR_SUBDIR))?;
548    fs::tokio::create_dir_all(&cargo_vendor_dir).await?;
549
550    let output = config
551        .wrapped_command("cargo", ["vendor", "--locked", "--versioned-dirs"])
552        .arg(&cargo_vendor_dir)
553        .current_dir(source_dir)
554        .output()
555        .await
556        .map_err(|source| VendorError::CargoVendor { source })?;
557
558    if output.status.success() {
559        Ok(())
560    } else {
561        Err(VendorError::CargoVendorFailure {
562            status: output.status,
563            stdout: String::from_utf8_lossy(&output.stdout).into(),
564            stderr: String::from_utf8_lossy(&output.stderr).into(),
565        })
566    }
567}