Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ repository = "https://github.com/pkgforge/soar"
license = "MIT"
keywords = ["appimage", "binary", "linux", "package-manager", "portable"]
categories = ["command-line-utilities"]
rust-version = "1.88"
rust-version = "1.93.1"

[workspace.dependencies]
blake3 = { version = "1.8.7", features = ["mmap"] }
Expand Down Expand Up @@ -50,6 +50,7 @@ onelf-format = "0.3.3"
once_cell = "1.21"
percent-encoding = "2.3.2"
rayon = "1.12.0"
releasekit = { version = "0.1.0", default-features = false }
regex = { version = "1.13.1", default-features = false, features = [
"std",
"unicode-case",
Expand Down
11 changes: 10 additions & 1 deletion crates/soar-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ pub enum Commands {
#[arg(required = false, short, long, value_hint = ValueHint::AnyPath)]
output: Option<String>,

/// Regex to select the asset. Only works for github downloads
/// Regex to select the asset. Only works for forge downloads
#[arg(required = false, short = 'r', long = "regex")]
regexes: Option<Vec<String>>,

Expand All @@ -435,6 +435,15 @@ pub enum Commands {
#[arg(required = false, long)]
gitlab: Vec<String>,

/// Codeberg project
#[arg(required = false, long)]
codeberg: Vec<String>,

/// Gitea or Forgejo repository URL, such as
/// https://git.example.com/owner/repo
#[arg(required = false, long, alias = "forgejo")]
gitea: Vec<String>,

/// OCI reference
#[arg(required = false, long)]
ghcr: Vec<String>,
Expand Down
187 changes: 83 additions & 104 deletions crates/soar-cli/src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ use soar_dl::{
download::Download,
error::DownloadError,
filter::Filter,
github::Github,
gitlab::GitLab,
forge::Forge,
oci::OciDownload,
platform::PlatformUrl,
traits::{Asset, Platform as _, Release as _},
platform::{parse_gitea_target, PlatformUrl},
releasekit::Asset,
types::{OverwriteMode, Progress},
};
use soar_utils::bytes::format_bytes;
Expand Down Expand Up @@ -63,16 +62,24 @@ pub async fn download(
links: Vec<String>,
github: Vec<String>,
gitlab: Vec<String>,
codeberg: Vec<String>,
gitea: Vec<String>,
ghcr: Vec<String>,
) -> SoarResult<()> {
handle_direct_downloads(&ctx, links, ctx.output.clone()).await?;

if !github.is_empty() {
handle_github_downloads(&ctx, github).await?;
for (forge, projects) in [
(Forge::GitHub, github),
(Forge::GitLab, gitlab),
(Forge::Codeberg, codeberg),
] {
if !projects.is_empty() {
handle_forge_downloads(&ctx, forge, projects).await?;
}
}

if !gitlab.is_empty() {
handle_gitlab_downloads(&ctx, gitlab).await?;
if !gitea.is_empty() {
handle_gitea_downloads(&ctx, gitea).await?;
}

if !ghcr.is_empty() {
Expand Down Expand Up @@ -120,7 +127,9 @@ pub async fn handle_direct_downloads(
tag,
}) => {
info!("Detected GitHub URL, processing as GitHub release");
if let Err(err) = handle_github_release(ctx, &project, tag.as_deref()) {
if let Err(err) =
handle_forge_release(ctx, &Forge::GitHub, &project, tag.as_deref())
{
error!("{}", err);
}
}
Expand All @@ -129,7 +138,33 @@ pub async fn handle_direct_downloads(
tag,
}) => {
info!("Detected GitLab URL, processing as GitLab release");
if let Err(err) = handle_gitlab_release(ctx, &project, tag.as_deref()) {
if let Err(err) =
handle_forge_release(ctx, &Forge::GitLab, &project, tag.as_deref())
{
error!("{}", err);
}
}
Some(PlatformUrl::Codeberg {
project,
tag,
}) => {
info!("Detected Codeberg URL, processing as Codeberg release");
if let Err(err) =
handle_forge_release(ctx, &Forge::Codeberg, &project, tag.as_deref())
{
error!("{}", err);
}
}
Some(PlatformUrl::Gitea {
instance,
project,
tag,
}) => {
info!("Detected Gitea URL, processing as Gitea release");
let forge = Forge::Gitea {
instance,
};
if let Err(err) = handle_forge_release(ctx, &forge, &project, tag.as_deref()) {
error!("{}", err);
}
}
Expand Down Expand Up @@ -311,12 +346,16 @@ pub async fn handle_oci_downloads(
Ok(())
}

fn handle_github_release(
/// Downloads an asset of a release published on `forge`.
///
/// Without a tag the newest release that is not a prerelease is taken.
fn handle_forge_release(
ctx: &DownloadContext,
forge: &Forge,
project: &str,
tag: Option<&str>,
) -> SoarResult<()> {
let releases = Github::fetch_releases(project, tag)?;
let releases = forge.fetch_releases(project, tag)?;

let release = if let Some(tag) = tag {
releases.iter().find(|r| r.tag() == tag)
Expand All @@ -327,76 +366,7 @@ fn handle_github_release(
.or_else(|| releases.first())
};

let release = release.ok_or_else(|| DownloadError::InvalidResponse)?;

info!("Found release: {}", release.tag());
let filter = ctx.create_filter();

let assets: Vec<_> = release
.assets()
.iter()
.filter(|a| filter.matches(a.name()))
.collect();

if assets.is_empty() {
let available = release
.assets()
.iter()
.map(|a| a.name().to_string())
.collect::<Vec<String>>();

Err(DownloadError::NoMatch {
available,
})?
}

let selected_asset = if assets.len() == 1 || ctx.yes {
assets[0]
} else {
&select_asset_interactively(assets)?
};

info!("Downloading asset: {}", selected_asset.name());

let mut dl = Download::new(selected_asset.url())
.overwrite(ctx.get_overwrite_mode())
.extract(ctx.extract);

if let Some(ref out) = ctx.output {
dl = dl.output(out);
}

if let Some(ref extract_dir) = ctx.extract_dir {
dl = dl.extract_to(extract_dir);
}

let cb = ctx.progress_callback.clone();
dl = dl.progress(move |p| {
cb(p);
});

dl.execute()?;

Ok(())
}

fn handle_gitlab_release(
ctx: &DownloadContext,
project: &str,
tag: Option<&str>,
) -> SoarResult<()> {
let releases = GitLab::fetch_releases(project, tag)?;

let release = if let Some(tag) = tag {
releases.iter().find(|r| r.tag() == tag)
} else {
releases
.iter()
.find(|r| !r.is_prerelease())
.or_else(|| releases.first())
};

let release = release.ok_or_else(|| DownloadError::InvalidResponse)?;
let release = release.ok_or(DownloadError::InvalidResponse)?;

info!("Found release: {}", release.tag());
let filter = ctx.create_filter();
Expand Down Expand Up @@ -461,48 +431,57 @@ pub fn create_regex_patterns(regex_patterns: Option<Vec<String>>) -> SoarResult<
}
}

pub async fn handle_github_downloads(
/// Downloads from each `owner/repo` on `forge`, a tag named after `@` where
/// one is given.
pub async fn handle_forge_downloads(
ctx: &DownloadContext,
forge: Forge,
projects: Vec<String>,
) -> SoarResult<()> {
for project in &projects {
info!("Fetching releases from GitHub: {}", project);
info!("Fetching releases from {}: {}", forge, project);

let (project, tag) = match project.trim().split_once('@') {
Some((proj, tag)) if !tag.trim().is_empty() => (proj, Some(tag.trim())),
_ => (project.trim_end_matches('@'), None),
};
let (project, tag) = split_tag(project);

if let Err(err) = handle_github_release(ctx, project, tag) {
if let Err(err) = handle_forge_release(ctx, &forge, project, tag) {
error!("{}", err);
}
}
Ok(())
}

pub async fn handle_gitlab_downloads(
ctx: &DownloadContext,
projects: Vec<String>,
) -> SoarResult<()> {
for project in &projects {
info!("Fetching releases from GitLab: {}", project);

let (project, tag) = match project.trim().split_once('@') {
Some((proj, tag)) if !tag.trim().is_empty() => (proj, Some(tag.trim())),
_ => (project.trim_end_matches('@'), None),
/// Downloads from each Gitea or Forgejo repository URL.
///
/// The instance is not one soar knows by name, so each target carries its own:
/// `https://git.example.com/owner/repo@v1.0`.
pub async fn handle_gitea_downloads(ctx: &DownloadContext, targets: Vec<String>) -> SoarResult<()> {
for target in &targets {
let Some((instance, project, tag)) = parse_gitea_target(target, false) else {
error!("Invalid Gitea repository URL '{}'", target);
continue;
};

if let Err(err) = handle_gitlab_release(ctx, project, tag) {
info!("Fetching releases from {}: {}", instance, project);

let forge = Forge::Gitea {
instance,
};
if let Err(err) = handle_forge_release(ctx, &forge, &project, tag.as_deref()) {
error!("{}", err);
}
}
Ok(())
}

fn select_asset_interactively<A>(assets: Vec<&A>) -> SoarResult<A>
where
A: Asset + Clone,
{
/// A project reference split from the tag written after its `@`.
fn split_tag(project: &str) -> (&str, Option<&str>) {
match project.trim().split_once('@') {
Some((proj, tag)) if !tag.trim().is_empty() => (proj, Some(tag.trim())),
_ => (project.trim().trim_end_matches('@'), None),
}
}

fn select_asset_interactively(assets: Vec<&Asset>) -> SoarResult<Asset> {
info!("\nAvailable assets:");
for (i, asset) in assets.iter().enumerate() {
let size = asset
Expand Down
9 changes: 7 additions & 2 deletions crates/soar-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ fn requires_root(command: &cli::Commands) -> bool {
..
}
| cli::Commands::Config {
edit: Some(_),
edit: Some(_)
}
| cli::Commands::Repo {
action: cli::RepoAction::Add { .. }
Expand Down Expand Up @@ -317,6 +317,9 @@ async fn handle_cli() -> SoarResult<()> {
} => generate_default_config(repositories.as_slice())?,
command => {
config::init()?;
soar_dl::forge::set_instance_token_vars(
get_config().forge_tokens.clone().unwrap_or_default(),
);

if let Some(ref profile) = args.profile {
set_current_profile(profile)?;
Expand Down Expand Up @@ -458,6 +461,8 @@ async fn handle_cli() -> SoarResult<()> {
exclude_keywords,
github,
gitlab,
codeberg,
gitea,
ghcr,
exact_case,
extract,
Expand Down Expand Up @@ -493,7 +498,7 @@ async fn handle_cli() -> SoarResult<()> {
force_overwrite,
};

download(context, links, github, gitlab, ghcr).await?;
download(context, links, github, gitlab, codeberg, gitea, ghcr).await?;
}
cli::Commands::Health => display_health(&ctx).await?,
cli::Commands::Repo {
Expand Down
Loading
Loading