From 0f4bc13134cb7eff5963eae2d8954dd361f9a63a Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Thu, 15 Jan 2026 20:10:11 +0545 Subject: [PATCH 1/4] feat(packages): add github/gitlab as first-class package sources --- crates/soar-cli/src/apply.rs | 189 +++++++-- crates/soar-cli/src/update.rs | 49 ++- crates/soar-config/src/packages.rs | 212 +++++++++- crates/soar-core/src/package/mod.rs | 1 + .../soar-core/src/package/release_source.rs | 383 ++++++++++++++++++ crates/soar-core/src/package/remote_update.rs | 54 +-- crates/soar-core/src/package/url.rs | 21 +- 7 files changed, 813 insertions(+), 96 deletions(-) create mode 100644 crates/soar-core/src/package/release_source.rs diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index 86268b9b0..de4a19a32 100644 --- a/crates/soar-cli/src/apply.rs +++ b/crates/soar-cli/src/apply.rs @@ -7,8 +7,16 @@ use std::{ use nu_ansi_term::Color::{Blue, Cyan, Green, Magenta, Red, Yellow}; use soar_config::packages::{PackagesConfig, ResolvedPackage}; use soar_core::{ - database::models::{InstalledPackage, Package}, - package::{install::InstallTarget, remove::PackageRemover, url::UrlPackage}, + database::{ + connection::DieselDatabase, + models::{InstalledPackage, Package}, + }, + package::{ + install::InstallTarget, + release_source::{run_version_command, ReleaseSource}, + remove::PackageRemover, + url::UrlPackage, + }, SoarResult, }; use soar_db::repository::{ @@ -28,6 +36,63 @@ use crate::{ utils::{display_settings, get_package_hooks, icon_or, Colored, Icons}, }; +/// Result of checking a URL package against installed packages +enum UrlPackageStatus { + /// Package needs to be installed + ToInstall(InstallTarget), + /// Package needs to be updated + ToUpdate(InstallTarget), + /// Package is already in sync + InSync(String), +} + +/// Check a URL package against installed packages and determine its status +fn check_url_package_status( + url_pkg: &UrlPackage, + pkg: &ResolvedPackage, + display_label: &str, + diesel_db: &DieselDatabase, +) -> SoarResult { + let installed_packages: Vec = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + Some("local"), + Some(&url_pkg.pkg_name), + Some(&url_pkg.pkg_id), + None, + None, + None, + None, + Some(SortDirection::Asc), + ) + })? + .into_iter() + .map(Into::into) + .collect(); + + let installed = installed_packages + .iter() + .find(|ip| ip.is_installed) + .cloned(); + + if let Some(ref existing) = installed { + if url_pkg.version != existing.version { + let target = create_url_install_target(url_pkg, pkg, installed); + Ok(UrlPackageStatus::ToUpdate(target)) + } else { + Ok(UrlPackageStatus::InSync(format!( + "{} ({})", + pkg.name, display_label + ))) + } + } else { + let existing_install = installed_packages.into_iter().next(); + let target = create_url_install_target(url_pkg, pkg, existing_install); + Ok(UrlPackageStatus::ToInstall(target)) + } +} + /// Result of comparing declared packages vs installed packages #[derive(Default)] pub struct ApplyDiff { @@ -106,6 +171,67 @@ async fn compute_diff( // Track declared package declared_keys.insert((pkg.name.clone(), pkg.pkg_id.clone(), pkg.repo.clone())); + // Handle GitHub/GitLab release sources + if pkg.github.is_some() || pkg.gitlab.is_some() { + let source = match ReleaseSource::from_resolved(pkg) { + Some(s) => s, + None => { + diff.not_found.push(format!( + "{} (missing asset_pattern for github/gitlab source)", + pkg.name + )); + continue; + } + }; + + // If version is specified, fetch that specific tag; otherwise fetch latest + let release = match source.resolve_version(pkg.version.as_deref()) { + Ok(r) => r, + Err(e) => { + warn!("Failed to resolve release for {}: {}", pkg.name, e); + diff.not_found.push(format!("{} ({})", pkg.name, e)); + continue; + } + }; + + // Use version_command if specified, otherwise use release version + let version = if let Some(ref cmd) = pkg.version_command { + match run_version_command(cmd) { + Ok(v) => v, + Err(e) => { + warn!("Failed to run version_command for {}: {}", pkg.name, e); + release.version.clone() + } + } + } else { + release.version.clone() + }; + + let version = version.strip_prefix('v').unwrap_or(&version).to_string(); + + let derived_pkg_id = pkg.pkg_id.clone().or_else(|| { + pkg.github + .as_ref() + .or(pkg.gitlab.as_ref()) + .map(|repo| repo.replace('/', ".")) + }); + + let url_pkg = UrlPackage::from_remote( + &release.download_url, + Some(&pkg.name), + Some(&version), + pkg.pkg_type.as_deref(), + derived_pkg_id.as_deref(), + )?; + + match check_url_package_status(&url_pkg, pkg, "local", &diesel_db)? { + UrlPackageStatus::ToInstall(target) => diff.to_install.push((pkg.clone(), target)), + UrlPackageStatus::ToUpdate(target) => diff.to_update.push((pkg.clone(), target)), + UrlPackageStatus::InSync(label) => diff.in_sync.push(label), + } + continue; + } + if let Some(ref url) = pkg.url { let url_pkg = UrlPackage::from_remote( url, @@ -115,41 +241,10 @@ async fn compute_diff( pkg.pkg_id.as_deref(), )?; - // Check if installed in core DB with repo_name="local" - let installed_packages: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - Some("local"), - Some(&url_pkg.pkg_name), - Some(&url_pkg.pkg_id), - None, - None, - None, - None, - Some(SortDirection::Asc), - ) - })? - .into_iter() - .map(Into::into) - .collect(); - - let installed = installed_packages - .iter() - .find(|ip| ip.is_installed) - .cloned(); - - if let Some(ref existing) = installed { - if url_pkg.version != existing.version { - let target = create_url_install_target(&url_pkg, pkg, installed); - diff.to_update.push((pkg.clone(), target)); - } else { - diff.in_sync.push(format!("{} (local)", pkg.name)); - } - } else { - let existing_install = installed_packages.into_iter().next(); - let target = create_url_install_target(&url_pkg, pkg, existing_install); - diff.to_install.push((pkg.clone(), target)); + match check_url_package_status(&url_pkg, pkg, "local", &diesel_db)? { + UrlPackageStatus::ToInstall(target) => diff.to_install.push((pkg.clone(), target)), + UrlPackageStatus::ToUpdate(target) => diff.to_update.push((pkg.clone(), target)), + UrlPackageStatus::InSync(label) => diff.in_sync.push(label), } continue; } @@ -491,9 +586,17 @@ async fn execute_apply(state: &AppState, diff: ApplyDiff, no_verify: bool) -> So let mut removed_count = 0; let mut failed_count = 0; + let mut version_updates: Vec<(String, String)> = Vec::new(); + if !diff.to_install.is_empty() { info!("\nInstalling {} package(s)...", diff.to_install.len()); + for (pkg, target) in &diff.to_install { + if (pkg.github.is_some() || pkg.gitlab.is_some()) && pkg.version.is_none() { + version_updates.push((pkg.name.clone(), target.package.version.clone())); + } + } + let targets: Vec = diff .to_install .into_iter() @@ -515,6 +618,18 @@ async fn execute_apply(state: &AppState, diff: ApplyDiff, no_verify: bool) -> So perform_installation(ctx.clone(), targets, diesel_db.clone(), true).await?; installed_count = ctx.installed_count.load(Ordering::Relaxed) as usize; failed_count += ctx.failed.load(Ordering::Relaxed) as usize; + + if installed_count > 0 { + for (pkg_name, version) in &version_updates { + if let Err(e) = PackagesConfig::update_package(pkg_name, None, Some(version), None) + { + warn!( + "Failed to update version for '{}' in packages.toml: {}", + pkg_name, e + ); + } + } + } } if !diff.to_update.is_empty() { diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index fcca720dd..a5e1e2adb 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -1,7 +1,7 @@ use std::sync::{atomic::Ordering, Arc}; use nu_ansi_term::Color::{Cyan, Green, Red}; -use soar_config::packages::PackagesConfig; +use soar_config::packages::{PackagesConfig, ResolvedPackage, UpdateSource}; use soar_core::{ database::{ connection::DieselDatabase, @@ -301,10 +301,10 @@ pub async fn update_packages( .unwrap_or(false); if is_installed { - if let Err(e) = PackagesConfig::update_package_url( + if let Err(e) = PackagesConfig::update_package( &url_info.pkg_name, - &url_info.new_url, - &url_info.new_version, + Some(&url_info.new_url), + Some(&url_info.new_version), None, ) { warn!( @@ -318,22 +318,55 @@ pub async fn update_packages( Ok(()) } +/// Derive an UpdateSource from a resolved package. +fn derive_update_source(resolved: &ResolvedPackage) -> Option { + if let Some(ref update) = resolved.update { + return Some(update.clone()); + } + + if let Some(ref repo) = resolved.github { + return Some(UpdateSource::GitHub { + repo: repo.clone(), + asset_pattern: resolved.asset_pattern.clone(), + include_prerelease: resolved.include_prerelease, + tag_pattern: resolved.tag_pattern.clone(), + }); + } + + if let Some(ref repo) = resolved.gitlab { + return Some(UpdateSource::GitLab { + repo: repo.clone(), + asset_pattern: resolved.asset_pattern.clone(), + include_prerelease: resolved.include_prerelease, + tag_pattern: resolved.tag_pattern.clone(), + }); + } + + None +} + /// Check if a local package has an update available via its update source fn check_local_package_update( pkg: &InstalledPackage, - resolved_packages: &[soar_config::packages::ResolvedPackage], + resolved_packages: &[ResolvedPackage], ) -> SoarResult> { + // Find resolved package that has an update source let resolved = resolved_packages .iter() - .find(|r| r.name == pkg.pkg_name && r.update.is_some()); + .find(|r| r.name == pkg.pkg_name && derive_update_source(r).is_some()); let Some(resolved) = resolved else { return Ok(None); }; - let update_source = resolved.update.as_ref().unwrap(); + if resolved.pinned { + info!("Skipping {}#{} (pinned)", pkg.pkg_name, pkg.pkg_id); + return Ok(None); + } + + let update_source = derive_update_source(resolved).unwrap(); - let remote_update = match check_for_update(update_source, &pkg.version) { + let remote_update = match check_for_update(&update_source, &pkg.version) { Ok(update) => update, Err(e) => { warn!("Failed to check for updates for {}: {}", pkg.pkg_name, e); diff --git a/crates/soar-config/src/packages.rs b/crates/soar-config/src/packages.rs index c950c0d25..a7fe53f38 100644 --- a/crates/soar-config/src/packages.rs +++ b/crates/soar-config/src/packages.rs @@ -146,6 +146,30 @@ pub struct PackageOptions { /// Direct URL to download the package from (makes it a "local" package). pub url: Option, + /// GitHub repository in "owner/repo" format for installing from releases. + /// When set, soar fetches the latest release and downloads the matching asset. + pub github: Option, + + /// GitLab repository in "owner/repo" format for installing from releases. + /// When set, soar fetches the latest release and downloads the matching asset. + pub gitlab: Option, + + /// Glob pattern to match release asset filename (e.g., "*linux*.AppImage"). + /// Required when github/gitlab is set to select the correct asset. + pub asset_pattern: Option, + + /// Whether to include pre-release versions when using github/gitlab sources. + #[serde(default)] + pub include_prerelease: Option, + + /// Glob pattern to match release tag names (e.g., "v*-stable", "nightly-*"). + /// If not set, the first matching release is used. + pub tag_pattern: Option, + + /// Custom command to fetch version (outputs version string on stdout). + /// If not set and github/gitlab is used, version is fetched from releases API. + pub version_command: Option, + /// Package type for URL installs (e.g., appimage, flatimage, archive). pub pkg_type: Option, @@ -227,6 +251,8 @@ pub enum UpdateSource { asset_pattern: Option, /// Whether to include pre-release versions. include_prerelease: Option, + /// Glob pattern to match release tag names (e.g., "v*-stable"). + tag_pattern: Option, }, /// GitLab releases. #[serde(rename = "gitlab")] @@ -237,6 +263,8 @@ pub enum UpdateSource { asset_pattern: Option, /// Whether to include pre-release versions. include_prerelease: Option, + /// Glob pattern to match release tag names (e.g., "v*-stable"). + tag_pattern: Option, }, /// Custom URL endpoint that returns JSON with version/download info. #[serde(rename = "url")] @@ -257,13 +285,19 @@ pub enum UpdateSource { } /// Resolved package specification with name included. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct ResolvedPackage { pub name: String, pub pkg_id: Option, pub version: Option, pub repo: Option, pub url: Option, + pub github: Option, + pub gitlab: Option, + pub asset_pattern: Option, + pub include_prerelease: Option, + pub tag_pattern: Option, + pub version_command: Option, pub pkg_type: Option, pub entrypoint: Option, pub binaries: Option>, @@ -297,6 +331,12 @@ impl PackageSpec { version, repo: None, url: None, + github: None, + gitlab: None, + asset_pattern: None, + include_prerelease: None, + tag_pattern: None, + version_command: None, pkg_type: None, entrypoint: None, binaries: None, @@ -316,15 +356,23 @@ impl PackageSpec { PackageSpec::Detailed(opts) => { // Treat "*" as None (latest version) let version = opts.version.as_ref().filter(|v| v.as_str() != "*").cloned(); - // URL packages: only pinned if explicitly set + // URL/GitHub/GitLab packages: only pinned if explicitly set // Other packages: pinned if explicitly set or if a specific version is requested - let pinned = opts.pinned || (version.is_some() && opts.url.is_none()); + let is_remote = + opts.url.is_some() || opts.github.is_some() || opts.gitlab.is_some(); + let pinned = opts.pinned || (version.is_some() && !is_remote); ResolvedPackage { name: name.to_string(), pkg_id: opts.pkg_id.clone(), version, repo: opts.repo.clone(), url: opts.url.clone(), + github: opts.github.clone(), + gitlab: opts.gitlab.clone(), + asset_pattern: opts.asset_pattern.clone(), + include_prerelease: opts.include_prerelease, + tag_pattern: opts.tag_pattern.clone(), + version_command: opts.version_command.clone(), pkg_type: opts.pkg_type.clone(), entrypoint: opts.entrypoint.clone(), binaries: opts.binaries.clone(), @@ -423,16 +471,22 @@ impl PackagesConfig { Ok(doc) } - /// Update the URL and version for a specific package in the packages.toml file. + /// Update package fields in packages.toml. /// /// This preserves comments and formatting in the file. - /// The version is only updated if a version field already exists in the config. - pub fn update_package_url( + /// - If `new_url` is provided, the `url` field is updated and `version` is updated only if it already exists. + /// - If only `new_version` is provided, the `version` field is added/updated. + /// - Simple string specs are skipped (user explicitly set a version). + pub fn update_package( package_name: &str, - new_url: &str, - new_version: &str, + new_url: Option<&str>, + new_version: Option<&str>, config_path: Option<&str>, ) -> Result<()> { + if new_url.is_none() && new_version.is_none() { + return Ok(()); + } + let config_path = match config_path { Some(p) => PathBuf::from(p), None => PACKAGES_CONFIG_PATH.read().unwrap().clone(), @@ -445,6 +499,72 @@ impl PackagesConfig { } let content = fs::read_to_string(&config_path)?; + + if new_url.is_none() && new_version.is_some() { + let version = new_version.unwrap(); + + let doc = content.parse::()?; + let packages = doc + .get("packages") + .and_then(|p| p.as_table()) + .ok_or_else(|| ConfigError::Custom("No [packages] section found".into()))?; + + let package = packages.get(package_name).ok_or_else(|| { + ConfigError::Custom(format!("Package '{}' not found in config", package_name)) + })?; + + match package { + toml_edit::Item::Value(toml_edit::Value::String(_)) => { + return Ok(()); + } + toml_edit::Item::Value(toml_edit::Value::InlineTable(table)) => { + if table.contains_key("version") { + let mut doc = content.parse::()?; + if let Some(pkg) = doc + .get_mut("packages") + .and_then(|p| p.as_table_mut()) + .and_then(|t| t.get_mut(package_name)) + { + if let toml_edit::Item::Value(toml_edit::Value::InlineTable(t)) = pkg { + t.insert("version", version.into()); + } + } + fs::write(&config_path, doc.to_string())?; + } else { + let updated = add_version_to_inline_table(&content, package_name, version)?; + fs::write(&config_path, updated)?; + } + } + toml_edit::Item::Table(_) => { + let mut doc = content.parse::()?; + if let Some(pkg) = doc + .get_mut("packages") + .and_then(|p| p.as_table_mut()) + .and_then(|t| t.get_mut(package_name)) + { + if let toml_edit::Item::Table(t) = pkg { + t.insert("version", toml_edit::value(version)); + } + } + fs::write(&config_path, doc.to_string())?; + } + _ => { + return Err(ConfigError::Custom(format!( + "Unexpected package format for '{}'", + package_name + ))); + } + } + + info!( + "Updated version to {} for '{}' in {}", + version, + package_name, + config_path.display() + ); + return Ok(()); + } + let mut doc = content.parse::()?; let packages = doc @@ -456,27 +576,41 @@ impl PackagesConfig { ConfigError::Custom(format!("Package '{}' not found in config", package_name)) })?; + let url = new_url.unwrap(); match package { toml_edit::Item::Value(toml_edit::Value::InlineTable(table)) => { - table.insert("url", new_url.into()); - if table.contains_key("version") { - table.insert("version", new_version.into()); + table.insert("url", url.into()); + if let Some(version) = new_version { + if table.contains_key("version") { + table.insert("version", version.into()); + } } } toml_edit::Item::Table(table) => { - table.insert("url", toml_edit::value(new_url)); - if table.contains_key("version") { - table.insert("version", toml_edit::value(new_version)); + table.insert("url", toml_edit::value(url)); + if let Some(version) = new_version { + if table.contains_key("version") { + table.insert("version", toml_edit::value(version)); + } } } _ => { - unreachable!("Package is a simple string (version)"); + return Err(ConfigError::Custom(format!( + "Unexpected package format for '{}'", + package_name + ))); } } fs::write(&config_path, doc.to_string())?; + + let updated = match new_version { + Some(v) => format!("URL and version ({})", v), + None => "URL".to_string(), + }; info!( - "Updated URL for '{}' in {}", + "Updated {} for '{}' in {}", + updated, package_name, config_path.display() ); @@ -485,6 +619,52 @@ impl PackagesConfig { } } +/// Add version field to an inline table using string manipulation. +fn add_version_to_inline_table(content: &str, package_name: &str, version: &str) -> Result { + let search = format!("{} = {{", package_name); + let Some(brace_pos) = content.find(&search).map(|p| p + search.len() - 1) else { + let start = content + .find(&format!("{} =", package_name)) + .ok_or_else(|| ConfigError::Custom(format!("Package '{}' not found", package_name)))?; + let brace_pos = content[start..] + .find('{') + .map(|p| start + p) + .ok_or_else(|| { + ConfigError::Custom(format!("No inline table for '{}'", package_name)) + })?; + return Ok(insert_version_at(content, brace_pos, version)); + }; + + Ok(insert_version_at(content, brace_pos, version)) +} + +fn insert_version_at(content: &str, brace_pos: usize, version: &str) -> String { + let after_brace = &content[brace_pos + 1..]; + let is_multiline = after_brace.starts_with('\n') || after_brace.starts_with("\r\n"); + + if is_multiline { + let next_line_start = brace_pos + 1 + after_brace.find('\n').map(|p| p + 1).unwrap_or(0); + let indent: String = content[next_line_start..] + .chars() + .take_while(|c| c.is_whitespace() && *c != '\n') + .collect(); + format!( + "{}\n{}version = \"{}\",{}", + &content[..=brace_pos], + indent, + version, + &content[brace_pos + 1..] + ) + } else { + format!( + "{} version = \"{}\",{}", + &content[..=brace_pos], + version, + after_brace + ) + } +} + /// Generate a default packages configuration file. pub fn generate_default_packages_config() -> Result<()> { let config_path = PACKAGES_CONFIG_PATH.read().unwrap().clone(); diff --git a/crates/soar-core/src/package/mod.rs b/crates/soar-core/src/package/mod.rs index 4f5f3461a..ab4da6443 100644 --- a/crates/soar-core/src/package/mod.rs +++ b/crates/soar-core/src/package/mod.rs @@ -1,6 +1,7 @@ pub mod hooks; pub mod install; pub mod query; +pub mod release_source; pub mod remote_update; pub mod remove; pub mod update; diff --git a/crates/soar-core/src/package/release_source.rs b/crates/soar-core/src/package/release_source.rs new file mode 100644 index 000000000..2f145f798 --- /dev/null +++ b/crates/soar-core/src/package/release_source.rs @@ -0,0 +1,383 @@ +//! Release source resolution for GitHub/GitLab packages. +//! +//! This module provides functionality to resolve package sources from +//! GitHub or GitLab releases, fetching version and download URL automatically. + +use std::process::Command; + +use soar_config::packages::ResolvedPackage; +use soar_dl::{ + github::{Github, GithubAsset, GithubRelease}, + gitlab::{GitLab, GitLabAsset, GitLabRelease}, + traits::{Asset, Platform, Release}, +}; + +use crate::{error::SoarError, SoarResult}; + +/// Source for fetching package releases. +#[derive(Debug, Clone)] +pub enum ReleaseSource { + /// GitHub releases source. + GitHub { + /// Repository in "owner/repo" format. + repo: String, + /// Glob pattern to match asset filename. + asset_pattern: String, + /// Whether to include pre-release versions. + include_prerelease: bool, + /// Optional glob pattern to match tag names. + tag_pattern: Option, + }, + /// GitLab releases source. + GitLab { + /// Repository in "owner/repo" format. + repo: String, + /// Glob pattern to match asset filename. + asset_pattern: String, + /// Whether to include pre-release versions. + include_prerelease: bool, + /// Optional glob pattern to match tag names. + tag_pattern: Option, + }, +} + +/// Result of resolving a release source. +#[derive(Debug, Clone)] +pub struct ResolvedRelease { + /// The version tag from the release. + pub version: String, + /// Download URL for the matched asset. + pub download_url: String, + /// Optional size of the download in bytes. + pub size: Option, +} + +impl ReleaseSource { + /// Create a ReleaseSource from a resolved package configuration. + /// + /// Returns `None` if the package doesn't have github/gitlab source configured. + pub fn from_resolved(pkg: &ResolvedPackage) -> Option { + if let Some(ref repo) = pkg.github { + let asset_pattern = pkg.asset_pattern.clone()?; + return Some(ReleaseSource::GitHub { + repo: repo.clone(), + asset_pattern, + include_prerelease: pkg.include_prerelease.unwrap_or(false), + tag_pattern: pkg.tag_pattern.clone(), + }); + } + + if let Some(ref repo) = pkg.gitlab { + let asset_pattern = pkg.asset_pattern.clone()?; + return Some(ReleaseSource::GitLab { + repo: repo.clone(), + asset_pattern, + include_prerelease: pkg.include_prerelease.unwrap_or(false), + tag_pattern: pkg.tag_pattern.clone(), + }); + } + + None + } + + /// Resolve the release source to get version and download URL. + /// + /// Fetches releases from the configured source, finds the latest + /// (non-prerelease unless configured), matches the asset pattern, + /// and returns the resolved release info. + pub fn resolve(&self) -> SoarResult { + self.resolve_version(None) + } + + /// Resolve the release source with a specific version/tag. + /// + /// If `version` is Some, fetches that specific tag instead of the latest. + /// The version can be with or without 'v' prefix (both "1.0.0" and "v1.0.0" work). + pub fn resolve_version(&self, version: Option<&str>) -> SoarResult { + match self { + ReleaseSource::GitHub { + repo, + asset_pattern, + include_prerelease, + tag_pattern, + } => { + resolve_github( + repo, + asset_pattern, + *include_prerelease, + tag_pattern.as_deref(), + version, + ) + } + ReleaseSource::GitLab { + repo, + asset_pattern, + include_prerelease, + tag_pattern, + } => { + resolve_gitlab( + repo, + asset_pattern, + *include_prerelease, + tag_pattern.as_deref(), + version, + ) + } + } + } +} + +/// Check if a release matches the tag pattern. +fn matches_tag_pattern(tag: &str, pattern: Option<&str>) -> bool { + match pattern { + Some(p) => fast_glob::glob_match(p, tag), + None => true, + } +} + +/// Resolve a GitHub release source. +fn resolve_github( + repo: &str, + asset_pattern: &str, + include_prerelease: bool, + tag_pattern: Option<&str>, + specific_version: Option<&str>, +) -> SoarResult { + let releases: Vec = Github::fetch_releases(repo, None).map_err(|e| { + SoarError::Custom(format!( + "Failed to fetch GitHub releases for {}: {}", + repo, e + )) + })?; + + let release = releases + .iter() + .find(|r| { + // If a specific version is requested, match it exactly (with or without 'v' prefix) + if let Some(ver) = specific_version { + let tag = r.tag(); + let tag_normalized = tag.strip_prefix('v').unwrap_or(tag); + let ver_normalized = ver.strip_prefix('v').unwrap_or(ver); + return tag_normalized == ver_normalized || tag == ver; + } + + let prerelease_ok = include_prerelease || !r.is_prerelease(); + let tag_ok = matches_tag_pattern(r.tag(), tag_pattern); + prerelease_ok && tag_ok + }) + .ok_or_else(|| { + if let Some(ver) = specific_version { + SoarError::Custom(format!( + "No release found for {} with version '{}'", + repo, ver + )) + } else if tag_pattern.is_some() { + SoarError::Custom(format!( + "No releases found for {} matching tag pattern '{}'", + repo, + tag_pattern.unwrap() + )) + } else { + SoarError::Custom(format!("No releases found for {}", repo)) + } + })?; + + let assets: &[GithubAsset] = release.assets(); + let asset = find_matching_asset(assets, asset_pattern)?; + + Ok(ResolvedRelease { + version: release.tag().to_string(), + download_url: asset.url().to_string(), + size: asset.size(), + }) +} + +/// Resolve a GitLab release source. +fn resolve_gitlab( + repo: &str, + asset_pattern: &str, + include_prerelease: bool, + tag_pattern: Option<&str>, + specific_version: Option<&str>, +) -> SoarResult { + let releases: Vec = GitLab::fetch_releases(repo, None).map_err(|e| { + SoarError::Custom(format!( + "Failed to fetch GitLab releases for {}: {}", + repo, e + )) + })?; + + let release = releases + .iter() + .find(|r| { + // If a specific version is requested, match it exactly (with or without 'v' prefix) + if let Some(ver) = specific_version { + let tag = r.tag(); + let tag_normalized = tag.strip_prefix('v').unwrap_or(tag); + let ver_normalized = ver.strip_prefix('v').unwrap_or(ver); + return tag_normalized == ver_normalized || tag == ver; + } + + let prerelease_ok = include_prerelease || !r.is_prerelease(); + let tag_ok = matches_tag_pattern(r.tag(), tag_pattern); + prerelease_ok && tag_ok + }) + .ok_or_else(|| { + if let Some(ver) = specific_version { + SoarError::Custom(format!( + "No release found for {} with version '{}'", + repo, ver + )) + } else if tag_pattern.is_some() { + SoarError::Custom(format!( + "No releases found for {} matching tag pattern '{}'", + repo, + tag_pattern.unwrap() + )) + } else { + SoarError::Custom(format!("No releases found for {}", repo)) + } + })?; + + let assets: &[GitLabAsset] = release.assets(); + let asset = find_matching_asset(assets, asset_pattern)?; + + Ok(ResolvedRelease { + version: release.tag().to_string(), + download_url: asset.url().to_string(), + size: asset.size(), + }) +} + +/// Find an asset matching the given glob pattern. +fn find_matching_asset<'a, A: Asset>(assets: &'a [A], pattern: &str) -> SoarResult<&'a A> { + if assets.is_empty() { + return Err(SoarError::Custom("No assets found in release".into())); + } + + assets + .iter() + .find(|a| fast_glob::glob_match(pattern, a.name())) + .ok_or_else(|| { + let available = assets + .iter() + .map(|a| a.name()) + .collect::>() + .join(", "); + SoarError::Custom(format!( + "No asset matching pattern '{}' found. Available: {}", + pattern, available + )) + }) +} + +/// Execute a version command and return the version string. +/// +/// The command is executed via `sh -c` and should output a version +/// string on stdout. Leading/trailing whitespace is trimmed. +pub fn run_version_command(command: &str) -> SoarResult { + let output = Command::new("sh") + .arg("-c") + .arg(command) + .output() + .map_err(|e| SoarError::Custom(format!("Failed to execute version command: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SoarError::Custom(format!( + "Version command failed: {}", + stderr + ))); + } + + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if version.is_empty() { + return Err(SoarError::Custom( + "Version command returned empty output".into(), + )); + } + + Ok(version) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_release_source_from_resolved_github() { + let pkg = ResolvedPackage { + name: "test".to_string(), + github: Some("user/repo".to_string()), + asset_pattern: Some("*.AppImage".to_string()), + include_prerelease: Some(true), + ..Default::default() + }; + + let source = ReleaseSource::from_resolved(&pkg).unwrap(); + match source { + ReleaseSource::GitHub { + repo, + asset_pattern, + include_prerelease, + tag_pattern, + } => { + assert_eq!(repo, "user/repo"); + assert_eq!(asset_pattern, "*.AppImage"); + assert!(include_prerelease); + assert!(tag_pattern.is_none()); + } + _ => panic!("Expected GitHub source"), + } + } + + #[test] + fn test_release_source_from_resolved_gitlab() { + let pkg = ResolvedPackage { + name: "test".to_string(), + gitlab: Some("group/project".to_string()), + asset_pattern: Some("*.tar.gz".to_string()), + ..Default::default() + }; + + let source = ReleaseSource::from_resolved(&pkg).unwrap(); + match source { + ReleaseSource::GitLab { + repo, + asset_pattern, + include_prerelease, + tag_pattern, + } => { + assert_eq!(repo, "group/project"); + assert_eq!(asset_pattern, "*.tar.gz"); + assert!(!include_prerelease); + assert!(tag_pattern.is_none()); + } + _ => panic!("Expected GitLab source"), + } + } + + #[test] + fn test_release_source_from_resolved_none() { + let pkg = ResolvedPackage { + name: "test".to_string(), + url: Some("https://example.com/file".to_string()), + ..Default::default() + }; + + assert!(ReleaseSource::from_resolved(&pkg).is_none()); + } + + #[test] + fn test_release_source_requires_asset_pattern() { + let pkg = ResolvedPackage { + name: "test".to_string(), + github: Some("user/repo".to_string()), + asset_pattern: None, // Missing! + ..Default::default() + }; + + assert!(ReleaseSource::from_resolved(&pkg).is_none()); + } +} diff --git a/crates/soar-core/src/package/remote_update.rs b/crates/soar-core/src/package/remote_update.rs index 4c26b2ffa..f3ac21d94 100644 --- a/crates/soar-core/src/package/remote_update.rs +++ b/crates/soar-core/src/package/remote_update.rs @@ -43,34 +43,32 @@ pub fn check_for_update( repo, asset_pattern, include_prerelease, - } => { - check_github( - repo, - asset_pattern.as_deref(), - *include_prerelease, - current_version, - ) - } + tag_pattern, + } => check_github( + repo, + asset_pattern.as_deref(), + *include_prerelease, + tag_pattern.as_deref(), + current_version, + ), UpdateSource::GitLab { repo, asset_pattern, include_prerelease, - } => { - check_gitlab( - repo, - asset_pattern.as_deref(), - *include_prerelease, - current_version, - ) - } + tag_pattern, + } => check_gitlab( + repo, + asset_pattern.as_deref(), + *include_prerelease, + tag_pattern.as_deref(), + current_version, + ), UpdateSource::Url { url, version_path, download_path, } => check_url(url, version_path, download_path, current_version), - UpdateSource::Command { - command, - } => check_command(command, current_version), + UpdateSource::Command { command } => check_command(command, current_version), } } @@ -79,6 +77,7 @@ fn check_github( repo: &str, asset_pattern: Option<&str>, include_prerelease: Option, + tag_pattern: Option<&str>, current_version: &str, ) -> SoarResult> { let releases: Vec = Github::fetch_releases(repo, None).map_err(|e| { @@ -90,9 +89,11 @@ fn check_github( let include_prerelease = include_prerelease.unwrap_or(false); - let release = releases - .iter() - .find(|r: &&GithubRelease| include_prerelease || !r.is_prerelease()); + let release = releases.iter().find(|r: &&GithubRelease| { + let prerelease_ok = include_prerelease || !r.is_prerelease(); + let tag_ok = tag_pattern.map_or(true, |p| fast_glob::glob_match(p, r.tag())); + prerelease_ok && tag_ok + }); let Some(release) = release else { return Ok(None); @@ -119,6 +120,7 @@ fn check_gitlab( repo: &str, asset_pattern: Option<&str>, include_prerelease: Option, + tag_pattern: Option<&str>, current_version: &str, ) -> SoarResult> { let releases: Vec = GitLab::fetch_releases(repo, None).map_err(|e| { @@ -130,9 +132,11 @@ fn check_gitlab( let include_prerelease = include_prerelease.unwrap_or(false); - let release = releases - .iter() - .find(|r: &&GitLabRelease| include_prerelease || !r.is_prerelease()); + let release = releases.iter().find(|r: &&GitLabRelease| { + let prerelease_ok = include_prerelease || !r.is_prerelease(); + let tag_ok = tag_pattern.map_or(true, |p| fast_glob::glob_match(p, r.tag())); + prerelease_ok && tag_ok + }); let Some(release) = release else { return Ok(None); diff --git a/crates/soar-core/src/package/url.rs b/crates/soar-core/src/package/url.rs index ae472db3e..8a70cb516 100644 --- a/crates/soar-core/src/package/url.rs +++ b/crates/soar-core/src/package/url.rs @@ -116,13 +116,14 @@ impl UrlPackage { .map(|s| s.to_lowercase()) .unwrap_or_else(|| package.rsplit('/').next().unwrap_or(package).to_lowercase()); + // Normalize version by stripping "v" prefix for consistency let version = version_override - .map(String::from) - .unwrap_or_else(|| tag.clone()); + .map(|v| v.strip_prefix('v').unwrap_or(v).to_string()) + .unwrap_or_else(|| tag.strip_prefix('v').unwrap_or(&tag).to_string()); let pkg_id = pkg_id_override .map(String::from) - .unwrap_or_else(|| format!("ghcr.io.{}", package.replace('/', "."))); + .unwrap_or_else(|| package.replace('/', ".")); let pkg_type = pkg_type_override.map(|s| s.to_lowercase()); @@ -514,8 +515,8 @@ mod tests { let pkg = UrlPackage::from_ghcr(ghcr, None, None, None, None).unwrap(); assert_eq!(pkg.pkg_name, "soar"); - assert_eq!(pkg.version, "v0.8.1"); - assert_eq!(pkg.pkg_id, "ghcr.io.pkgforge.soar"); + assert_eq!(pkg.version, "0.8.1"); // 'v' prefix stripped + assert_eq!(pkg.pkg_id, "pkgforge.soar"); assert!(pkg.is_ghcr); } @@ -526,7 +527,7 @@ mod tests { assert_eq!(pkg.pkg_name, "repo"); assert_eq!(pkg.version, "sha256:deadbeef1234567890"); - assert_eq!(pkg.pkg_id, "ghcr.io.org.repo"); + assert_eq!(pkg.pkg_id, "org.repo"); assert!(pkg.is_ghcr); } @@ -537,7 +538,7 @@ mod tests { assert_eq!(pkg.pkg_name, "package"); assert_eq!(pkg.version, "latest"); - assert_eq!(pkg.pkg_id, "ghcr.io.org.package"); + assert_eq!(pkg.pkg_id, "org.package"); assert!(pkg.is_ghcr); } @@ -548,7 +549,7 @@ mod tests { assert_eq!(pkg.pkg_name, "repo"); assert_eq!(pkg.version, "1.0"); - assert_eq!(pkg.pkg_id, "ghcr.io.org.team.repo"); + assert_eq!(pkg.pkg_id, "org.team.repo"); assert!(pkg.is_ghcr); } @@ -573,8 +574,8 @@ mod tests { assert_eq!(pkg.repo_name, "local"); assert_eq!(pkg.pkg_name, "soar"); - assert_eq!(pkg.version, "v0.8.1"); - assert_eq!(pkg.pkg_id, "ghcr.io.pkgforge.soar"); + assert_eq!(pkg.version, "0.8.1"); // 'v' prefix stripped + assert_eq!(pkg.pkg_id, "pkgforge.soar"); assert_eq!(pkg.download_url, ""); assert_eq!( pkg.ghcr_pkg, From f112839bb043735b70e89648624e6dff00843aa7 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Thu, 15 Jan 2026 20:13:01 +0545 Subject: [PATCH 2/4] fmt --- crates/soar-core/src/package/remote_update.rs | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/crates/soar-core/src/package/remote_update.rs b/crates/soar-core/src/package/remote_update.rs index f3ac21d94..833fc0909 100644 --- a/crates/soar-core/src/package/remote_update.rs +++ b/crates/soar-core/src/package/remote_update.rs @@ -44,31 +44,37 @@ pub fn check_for_update( asset_pattern, include_prerelease, tag_pattern, - } => check_github( - repo, - asset_pattern.as_deref(), - *include_prerelease, - tag_pattern.as_deref(), - current_version, - ), + } => { + check_github( + repo, + asset_pattern.as_deref(), + *include_prerelease, + tag_pattern.as_deref(), + current_version, + ) + } UpdateSource::GitLab { repo, asset_pattern, include_prerelease, tag_pattern, - } => check_gitlab( - repo, - asset_pattern.as_deref(), - *include_prerelease, - tag_pattern.as_deref(), - current_version, - ), + } => { + check_gitlab( + repo, + asset_pattern.as_deref(), + *include_prerelease, + tag_pattern.as_deref(), + current_version, + ) + } UpdateSource::Url { url, version_path, download_path, } => check_url(url, version_path, download_path, current_version), - UpdateSource::Command { command } => check_command(command, current_version), + UpdateSource::Command { + command, + } => check_command(command, current_version), } } From 15a221829894ba032780c21b68fad17297822d3c Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Thu, 15 Jan 2026 20:51:07 +0545 Subject: [PATCH 3/4] lint --- crates/soar-config/src/packages.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/soar-config/src/packages.rs b/crates/soar-config/src/packages.rs index a7fe53f38..819373288 100644 --- a/crates/soar-config/src/packages.rs +++ b/crates/soar-config/src/packages.rs @@ -500,9 +500,7 @@ impl PackagesConfig { let content = fs::read_to_string(&config_path)?; - if new_url.is_none() && new_version.is_some() { - let version = new_version.unwrap(); - + if let (None, Some(version)) = (new_url, new_version) { let doc = content.parse::()?; let packages = doc .get("packages") @@ -520,14 +518,12 @@ impl PackagesConfig { toml_edit::Item::Value(toml_edit::Value::InlineTable(table)) => { if table.contains_key("version") { let mut doc = content.parse::()?; - if let Some(pkg) = doc + if let Some(toml_edit::Item::Value(toml_edit::Value::InlineTable(t))) = doc .get_mut("packages") .and_then(|p| p.as_table_mut()) .and_then(|t| t.get_mut(package_name)) { - if let toml_edit::Item::Value(toml_edit::Value::InlineTable(t)) = pkg { - t.insert("version", version.into()); - } + t.insert("version", version.into()); } fs::write(&config_path, doc.to_string())?; } else { @@ -537,14 +533,12 @@ impl PackagesConfig { } toml_edit::Item::Table(_) => { let mut doc = content.parse::()?; - if let Some(pkg) = doc + if let Some(toml_edit::Item::Table(t)) = doc .get_mut("packages") .and_then(|p| p.as_table_mut()) .and_then(|t| t.get_mut(package_name)) { - if let toml_edit::Item::Table(t) = pkg { - t.insert("version", toml_edit::value(version)); - } + t.insert("version", toml_edit::value(version)); } fs::write(&config_path, doc.to_string())?; } From f38b6946a5b7f1874a6e0f6ac85074a1067a628d Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Thu, 15 Jan 2026 20:54:36 +0545 Subject: [PATCH 4/4] fix --- crates/soar-package/src/formats/common.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/soar-package/src/formats/common.rs b/crates/soar-package/src/formats/common.rs index e7f5a6fde..a7ef8d167 100644 --- a/crates/soar-package/src/formats/common.rs +++ b/crates/soar-package/src/formats/common.rs @@ -330,6 +330,7 @@ pub fn setup_portable_dir, T: PackageExt>( /// # Errors /// /// Returns [`PackageError`] if integration fails. +#[allow(clippy::too_many_arguments)] pub async fn integrate_package, T: PackageExt>( install_dir: P, package: &T,