Skip to main content

lux_cli/dist/
flat_archive.rs

1use std::{
2    io,
3    path::{Path, PathBuf},
4};
5
6use clap::Args;
7use eyre::{eyre, Context as _, OptionExt, Result};
8use lux_lib::{
9    build::{Build, BuildBehaviour},
10    config::{Config, ConfigBuilder},
11    lockfile::LocalPackage,
12    lua_installation::LuaInstallation,
13    lua_rockspec::RemoteLuaRockspec,
14    lua_version::LuaVersion,
15    operations::{Install, InstallProject, PackageInstallSpec},
16    package::{PackageName, PackageReq},
17    progress::MultiProgress,
18    tree::{self, FlatDistTree, InstallTree},
19    workspace::Workspace,
20};
21use path_slash::PathExt;
22use tempfile::{tempdir, TempDir};
23use tokio::fs::{self, File};
24use walkdir::WalkDir;
25use zip::{write::SimpleFileOptions, ZipWriter};
26
27use crate::{args::PackageOrRockspec, workspace::exists_matching_workspace_member};
28
29#[derive(Args)]
30pub struct FlatArchive {
31    /// Path to a RockSpec or a package query for a package to distribute.{n}
32    /// Prioritises local projects if in a workspace, then installed rocks.{n}
33    /// If there is no matching workspace member or installed rock,{n}
34    /// a rock will be downloaded and installed to a temporary directory.{n}
35    /// In case of multiple matches, the latest version will be distributed.{n}
36    ///{n}
37    /// Examples:{n}
38    ///     - "pkg"{n}
39    ///     - "pkg@1.0.0"{n}
40    ///     - "pkg>=1.0.0"{n}
41    ///     - "/path/to/foo-1.0.0-1.rockspec"{n}
42    ///{n}
43    /// If not set, lux will attempt to distribute the current project.{n}
44    /// Must be set in multi-project workspaces.
45    #[clap(value_parser)]
46    package_or_rockspec: Option<PackageOrRockspec>,
47
48    /// The destination path. Defaults to '<cwd>/<package>-<version>.zip'.{n}
49    #[arg(short, long, visible_short_alias = 'd')]
50    destination: Option<PathBuf>,
51
52    #[clap(default_value_t=CompressionMethod::default())]
53    #[arg(short, long, value_enum, visible_short_alias = 'c')]
54    compression_method: CompressionMethod,
55
56    /// Output a JSON path.
57    #[arg(long)]
58    porcelain: bool,
59}
60
61#[derive(Clone, clap::ValueEnum, Default)]
62enum CompressionMethod {
63    /// Store the install tree as is
64    #[default]
65    Stored,
66    /// Compress the install tree using Deflate
67    Deflated,
68    /// Compress the install tree using BZIP2
69    Bzip2,
70    /// Compress the install tree using XZ
71    Xz,
72    /// Compress the install tree using `ZStandard`
73    Zstd,
74    /// Compress the install tree using LZMA
75    Lzma,
76}
77
78pub async fn dist_archive(args: FlatArchive, config: Config) -> Result<()> {
79    let staging_dir = tempdir()?;
80    let config = ConfigBuilder::from(config)
81        // Wrapping bin scripts does not make sense for distributed packages.
82        .wrap_bin_scripts(Some(false))
83        .user_tree(Some(staging_dir.path().to_path_buf()))
84        .build()?;
85
86    let (pkg, install_root) = match &args.package_or_rockspec {
87        None => install_project(None, &staging_dir, &config).await,
88        Some(PackageOrRockspec::Package(package_req))
89            if exists_matching_workspace_member(package_req)? =>
90        {
91            install_project(Some(package_req.name()), &staging_dir, &config).await
92        }
93        Some(PackageOrRockspec::Package(package)) => {
94            install_package(package, &staging_dir, &config).await
95        }
96        Some(PackageOrRockspec::RockSpec(rockspec_path)) => {
97            install_rockspec(rockspec_path, &staging_dir, &config).await
98        }
99    }?;
100
101    let destination = args
102        .destination
103        .clone()
104        .map(|dest| {
105            if dest.is_dir() {
106                dest.join(format!("{}-{}.zip", pkg.name(), pkg.version()))
107            } else {
108                dest
109            }
110        })
111        .unwrap_or(PathBuf::from(format!(
112            "{}-{}.zip",
113            pkg.name(),
114            pkg.version()
115        )));
116
117    zip_dir(&install_root, &destination, &args.compression_method).await?;
118
119    if args.porcelain {
120        println!("{}", serde_json::to_string(&destination)?);
121    } else {
122        println!("Wrote archive to {}", destination.display());
123    }
124
125    Ok(())
126}
127
128async fn install_project(
129    package: Option<&PackageName>,
130    staging_dir: &TempDir,
131    config: &Config,
132) -> Result<(LocalPackage, PathBuf)> {
133    let workspace = Workspace::current_or_err()?;
134    let project = match package {
135        Some(package) => workspace.select_member(package)?,
136        None => workspace.single_member()?,
137    };
138    let lua_version = project.lua_version(config)?;
139    let tree = FlatDistTree::new(staging_dir.path().to_path_buf(), lua_version, config)?;
140    Ok((
141        InstallProject::new()
142            .project(project)
143            .config(config)
144            .tree(&tree)
145            .build()
146            .await?,
147        tree.root(),
148    ))
149}
150
151async fn install_package(
152    package: &PackageReq,
153    staging_dir: &TempDir,
154    config: &Config,
155) -> Result<(LocalPackage, PathBuf)> {
156    let lua_version = LuaVersion::from(config)?.clone();
157    let tree = FlatDistTree::new(staging_dir.path().to_path_buf(), lua_version, config)?;
158    let packages = Install::new(config)
159        .package(
160            PackageInstallSpec::new(package.clone(), tree::EntryType::Entrypoint)
161                .build_behaviour(BuildBehaviour::Force)
162                .build(),
163        )
164        .tree(tree.clone())
165        .install()
166        .await?;
167    let package = packages
168        .into_iter()
169        .find(|pkg| pkg.name() == package.name())
170        .ok_or_eyre("package was not installed")?;
171    Ok((package, tree.root()))
172}
173
174async fn install_rockspec(
175    rockspec_path: &Path,
176    staging_dir: &TempDir,
177    config: &Config,
178) -> Result<(LocalPackage, PathBuf)> {
179    let content = tokio::fs::read_to_string(&rockspec_path).await?;
180    let lua_version = LuaVersion::from(config)?.clone();
181    let rockspec = match rockspec_path
182        .extension()
183        .map(|ext| ext.to_string_lossy().to_string())
184        .unwrap_or("".into())
185        .as_str()
186    {
187        "rockspec" => Ok(RemoteLuaRockspec::new(&content)?),
188        _ => Err(eyre!(
189            "expected a path to a .rockspec or a package requirement."
190        )),
191    }?;
192    let progress = MultiProgress::new_arc(config);
193    let bar = progress.map(|p| p.new_bar());
194    let lua = LuaInstallation::new(
195        &lua_version,
196        config,
197        &progress.map(|progress| progress.new_bar()),
198    )
199    .await?;
200    let tree = FlatDistTree::new(staging_dir.path().to_path_buf(), lua_version, config)?;
201    let package = Build::new()
202        .rockspec(&rockspec)
203        .lua(&lua)
204        .tree(&tree)
205        .entry_type(tree::EntryType::Entrypoint)
206        .config(config)
207        .progress(&bar)
208        .build()
209        .await?;
210    Ok((package, tree.root()))
211}
212
213async fn zip_dir(src_dir: &Path, dest_file: &Path, method: &CompressionMethod) -> Result<()> {
214    if dest_file.exists() {
215        return Err(eyre!("File {} already exists!", dest_file.display()));
216    }
217    let temp_archive = PathBuf::from(format!("{}.part", dest_file.display()));
218    let archive = File::create(&temp_archive).await?.into_std().await;
219    let walkdir = WalkDir::new(src_dir);
220    let mut zip = ZipWriter::new(archive);
221
222    let compression_method = match method {
223        CompressionMethod::Stored => zip::CompressionMethod::Stored,
224        CompressionMethod::Deflated => zip::CompressionMethod::Deflated,
225        CompressionMethod::Bzip2 => zip::CompressionMethod::Bzip2,
226        CompressionMethod::Xz => zip::CompressionMethod::Xz,
227        CompressionMethod::Zstd => zip::CompressionMethod::Zstd,
228        CompressionMethod::Lzma => zip::CompressionMethod::Lzma,
229    };
230
231    #[cfg(target_family = "unix")]
232    let options = SimpleFileOptions::default()
233        .compression_method(compression_method)
234        .unix_permissions(0o755);
235
236    #[cfg(target_family = "windows")]
237    let options = SimpleFileOptions::default().compression_method(compression_method);
238
239    for entry_result in walkdir.into_iter() {
240        let entry = entry_result.map_err(|err| {
241            eyre!(
242                "Error while traversing directory {}: {}.",
243                src_dir.display(),
244                err,
245            )
246        })?;
247        let path = entry.path();
248        let relative_path = path.strip_prefix(src_dir)?;
249        let relative_path_str = relative_path.to_slash_lossy().to_string();
250        if path.is_file() {
251            zip.start_file(relative_path_str, options)?;
252            let mut f = File::open(path).await?.into_std().await;
253            io::copy(&mut f, &mut zip)?;
254        } else if !relative_path.as_os_str().is_empty() {
255            zip.add_directory(relative_path_str, options)?;
256        }
257    }
258    zip.finish()?;
259    fs::rename(&temp_archive, &dest_file)
260        .await
261        .context(format!(
262            "Error renaming {} to {}.",
263            temp_archive.display(),
264            dest_file.display()
265        ))?;
266    Ok(())
267}