Skip to main content

lux_cli/dist/
flat_archive.rs

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