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] 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    // The lockfile may contain the same package (name@version) multiple times,
150    // with different constraints.
151    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, &copy_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            // Resolve against the project's lockfile if present, otherwise fall
193            // back to the remote package DB (e.g. for a project that has not
194            // yet generated a lockfile).
195            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        // The vendored source is an archive; extract it so `cargo vendor` can
482        // read its `Cargo.toml`. Keep the temp dir alive while cargo runs.
483        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}