Skip to content

Commit

Permalink
Implement a new --extract-all feature
Browse files Browse the repository at this point in the history
When this is passed, `ubi` will extract the entire archive instead of looking for just an
executable.
  • Loading branch information
autarch committed Jan 3, 2025
1 parent 38a0a98 commit b0e274f
Show file tree
Hide file tree
Showing 12 changed files with 590 additions and 117 deletions.
4 changes: 4 additions & 0 deletions Changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## 0.4.0

- The `ubi` CLI tool now takes an optional `--extract-all` argument. If this is passed, it will only
look for archive files and it will extract the entire contents of an archive it finds. There is
also a new corresponding `UbiBuilder::extract_all` method. Requested by @Entze (Lukas Grassauer).
GH #68.
- The `UbiBuilder::install_dir` method now takes `AsRef<Path>` instead of `PathBuf`, which should
make it more convenient to use.
- Previously, `ubi` would create the install directory very early in its process, well before it had
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,17 @@ Options:
-e, --exe <exe> The name of this project's executable. By default this is the
same as the project name, so for houseabsolute/precious we look
for precious or precious.exe. When running on Windows the
".exe" suffix will be added as needed.
".exe" suffix will be added as needed. You cannot pass
--extract-all when this is set
--extract-all Pass this to tell `ubi` to extract all files from the archive.
By default `ubi` will only extract an executable from an
archive file. But if this is true, it will simply unpack the
archive file in the specified directory. If all of the contents
of the archive file share a top-level directory, that directory
will be removed during unpacking. In other words, if an archive
contains `./project/some-file` and `./project/docs.md`, it will
extract them as `some-file` and `docs.md`. You cannot pass
--exe when this is set.
-m, --matching <matching> A string that will be matched against the release filename when
there are multiple matching files for your OS/arch. For
example, there may be multiple releases for an OS/arch that
Expand Down
20 changes: 19 additions & 1 deletion ubi-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ async fn main() {

const MAX_TERM_WIDTH: usize = 100;

#[allow(clippy::too_many_lines)]
fn cmd() -> Command {
Command::new("ubi")
.version(env!("CARGO_PKG_VERSION"))
Expand Down Expand Up @@ -98,8 +99,22 @@ fn cmd() -> Command {
"The name of this project's executable. By default this is the same as the",
" project name, so for houseabsolute/precious we look for precious or",
r#" precious.exe. When running on Windows the ".exe" suffix will be added"#,
" as needed.",
" as needed. You cannot pass --extract-all when this is set.",
)))
.arg(
Arg::new("extract-all")
.long("extract-all")
.action(ArgAction::SetTrue)
.help(concat!(
// Pass this to tell `ubi` to extract all files from the archive. By default
// `ubi` will only extract an executable from an archive file. But if this is
// true, it will simply unpack the archive file. If all of the contents of
// the archive file share a top-level directory, that directory will be removed
// during unpacking. In other words, if an archive contains
// `./project/some-file` and `./project/docs.md`, it will extract them as
// `some-file` and `docs.md`. You cannot pass --exe when this is set.
)),
)
.arg(
Arg::new("matching")
.long("matching")
Expand Down Expand Up @@ -196,6 +211,9 @@ fn make_ubi<'a>(
if let Some(e) = matches.get_one::<String>("exe") {
builder = builder.exe(e);
}
if matches.get_flag("extract-all") {
builder = builder.extract_all();
}
if let Some(ft) = matches.get_one::<String>("forge") {
builder = builder.forge(ForgeType::from_str(ft)?);
}
Expand Down
71 changes: 54 additions & 17 deletions ubi/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::{
forge::{Forge, ForgeType},
github::GitHub,
gitlab::GitLab,
installer::Installer,
installer::{ArchiveInstaller, ExeInstaller, Installer},
picker::AssetPicker,
ubi::Ubi,
};
Expand Down Expand Up @@ -32,6 +32,7 @@ pub struct UbiBuilder<'a> {
install_dir: Option<PathBuf>,
matching: Option<&'a str>,
exe: Option<&'a str>,
extract_all: bool,
github_token: Option<&'a str>,
gitlab_token: Option<&'a str>,
platform: Option<&'a Platform>,
Expand Down Expand Up @@ -100,12 +101,25 @@ impl<'a> UbiBuilder<'a> {
/// Set the name of the executable to look for in archive files. By default this is the same as
/// the project name, so for `houseabsolute/precious` we look for `precious` or
/// `precious.exe`. When running on Windows the ".exe" suffix will be added as needed.
///
/// You cannot call `extract_all` if you set this.
#[must_use]
pub fn exe(mut self, exe: &'a str) -> Self {
self.exe = Some(exe);
self
}

/// Call this to tell `ubi` to extract all files from the archive. By default `ubi` will look
/// for an executable in an archive file. But if this is true, it will simply unpack the archive
/// file in the specified directory.
///
/// You cannot set `exe` when this is true.
#[must_use]
pub fn extract_all(mut self) -> Self {
self.extract_all = true;
self
}

/// Set a GitHub token to use for API requests. If this is not set then this will be taken from
/// the `GITHUB_TOKEN` env var if it is set.
#[must_use]
Expand Down Expand Up @@ -175,6 +189,9 @@ impl<'a> UbiBuilder<'a> {
if self.url.is_some() && (self.project.is_some() || self.tag.is_some()) {
return Err(anyhow!("You cannot set a url with a project or tag"));
}
if self.exe.is_some() && self.extract_all {
return Err(anyhow!("You cannot set exe and extract_all"));
}

let platform = self.determine_platform()?;

Expand All @@ -183,20 +200,37 @@ impl<'a> UbiBuilder<'a> {
let asset_url = self.url.map(Url::parse).transpose()?;
let (project_name, forge_type) =
parse_project_name(self.project, asset_url.as_ref(), self.forge.clone())?;
let exe = exe_name(self.exe, &project_name, &platform);
let installer = self.new_installer(&project_name, &platform)?;
let forge = self.new_forge(project_name, &forge_type)?;
let install_path = install_path(self.install_dir, &exe)?;
let is_musl = self.is_musl.unwrap_or_else(|| platform_is_musl(&platform));

Ok(Ubi::new(
forge,
asset_url,
AssetPicker::new(self.matching, platform, is_musl),
Installer::new(install_path, exe),
AssetPicker::new(self.matching, platform, is_musl, self.extract_all),
installer,
reqwest_client()?,
))
}

fn new_installer(&self, project_name: &str, platform: &Platform) -> Result<Box<dyn Installer>> {
let (install_path, exe) = if self.extract_all {
(install_path(self.install_dir.as_deref(), None)?, None)
} else {
let exe = exe_name(self.exe, project_name, platform);
(
install_path(self.install_dir.as_deref(), Some(&exe))?,
Some(exe),
)
};

Ok(if self.extract_all {
Box::new(ArchiveInstaller::new(install_path))
} else {
Box::new(ExeInstaller::new(install_path, exe.unwrap()))
})
}

fn new_forge(
&self,
project_name: String,
Expand Down Expand Up @@ -283,17 +317,19 @@ fn parse_project_name(
))
}

fn install_path(install_dir: Option<PathBuf>, exe: &str) -> Result<PathBuf> {
let mut path = if let Some(i) = install_dir {
i
fn install_path(install_dir: Option<&Path>, exe: Option<&str>) -> Result<PathBuf> {
let mut install_dir = if let Some(install_dir) = install_dir {
install_dir.to_path_buf()
} else {
let mut i = env::current_dir()?;
i.push("bin");
i
let mut install_dir = env::current_dir()?;
install_dir.push("bin");
install_dir
};
path.push(exe);
debug!("install path = {}", path.to_string_lossy());
Ok(path)
if let Some(exe) = exe {
install_dir.push(exe);
}
debug!("install path = {}", install_dir.to_string_lossy());
Ok(install_dir)
}

fn exe_name(exe: Option<&str>, project_name: &str, platform: &Platform) -> String {
Expand All @@ -303,12 +339,13 @@ fn exe_name(exe: Option<&str>, project_name: &str, platform: &Platform) -> Strin
_ => e.to_string(),
}
} else {
let parts = project_name.split('/').collect::<Vec<&str>>();
let e = parts[parts.len() - 1].to_string();
// We know that this contains a slash because it already went through `parse_project_name`
// successfully.
let e = project_name.split('/').last().unwrap();
if matches!(platform.target_os, OS::Windows) {
format!("{e}.exe")
} else {
e
e.to_string()
}
};
debug!("exe name = {name}");
Expand Down
19 changes: 19 additions & 0 deletions ubi/src/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,23 @@ impl Extension {
}
}

pub(crate) fn is_archive(&self) -> bool {
match self {
Extension::Bz | Extension::Bz2 | Extension::Exe | Extension::Gz | Extension::Xz => {
false
}
Extension::Tar
| Extension::TarBz
| Extension::TarBz2
| Extension::TarGz
| Extension::TarXz
| Extension::Tbz
| Extension::Tgz
| Extension::Txz
| Extension::Zip => true,
}
}

pub(crate) fn from_path<S: AsRef<str>>(path: S) -> Result<Option<Extension>> {
let path = path.as_ref();
let Some(ext_str) = Path::new(path).extension() else {
Expand Down Expand Up @@ -146,6 +163,8 @@ mod test {
#[test_case("i386-linux-ghcup-0.1.30.0-linux_amd64", Ok(None))]
#[test_case("foo.bar", Err(ExtensionError::UnknownExtension { path: "foo.bar".to_string(), ext: "bar".to_string() }.into()))]
fn from_path(path: &str, expect: Result<Option<Extension>>) {
crate::test_case::init_logging();

let ext = Extension::from_path(path);
if expect.is_ok() {
assert!(ext.is_ok());
Expand Down
Loading

0 comments on commit b0e274f

Please sign in to comment.