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