From 8269d22cb885b0c94ff1068a72e159123a92c00f Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Thu, 8 Jan 2026 22:43:10 +0545 Subject: [PATCH 1/4] feat(update): allow updating remote URL packages --- Cargo.lock | 3 + crates/soar-cli/src/apply.rs | 39 +- crates/soar-cli/src/update.rs | 162 ++++++- crates/soar-config/src/error.rs | 4 + crates/soar-config/src/packages.rs | 118 ++++- crates/soar-core/Cargo.toml | 3 + crates/soar-core/src/package/mod.rs | 1 + crates/soar-core/src/package/remote_update.rs | 420 ++++++++++++++++++ crates/soar-core/src/package/url.rs | 11 +- 9 files changed, 733 insertions(+), 28 deletions(-) create mode 100644 crates/soar-core/src/package/remote_update.rs diff --git a/Cargo.lock b/Cargo.lock index b78ce429d..a5448377b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2229,10 +2229,12 @@ version = "0.10.0" dependencies = [ "chrono", "diesel", + "fast-glob", "libsqlite3-sys", "miette", "nix", "regex", + "semver", "serde", "serde_json", "soar-config", @@ -2244,6 +2246,7 @@ dependencies = [ "toml", "tracing", "ureq", + "url", ] [[package]] diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index 7f275d156..52c2ae6d4 100644 --- a/crates/soar-cli/src/apply.rs +++ b/crates/soar-cli/src/apply.rs @@ -135,25 +135,13 @@ async fn compute_diff( .collect(); let is_already_installed = installed_packages.iter().any(|ip| ip.is_installed); - let existing_install = installed_packages.into_iter().next(); - let target = InstallTarget { - package: url_pkg.to_package(), - existing_install: existing_install.clone(), - with_pkg_id: url_pkg.pkg_type.is_some(), - pinned: false, - profile: pkg.profile.clone(), - portable: pkg.portable.as_ref().and_then(|p| p.path.clone()), - portable_home: pkg.portable.as_ref().and_then(|p| p.home.clone()), - portable_config: pkg.portable.as_ref().and_then(|p| p.config.clone()), - portable_share: pkg.portable.as_ref().and_then(|p| p.share.clone()), - portable_cache: pkg.portable.as_ref().and_then(|p| p.cache.clone()), - entrypoint: pkg.entrypoint.clone(), - }; if !is_already_installed { + let target = create_url_install_target(&url_pkg, pkg, existing_install); diff.to_install.push((pkg.clone(), target)); - } else if url_pkg.version != existing_install.unwrap().version { + } else if url_pkg.version != existing_install.as_ref().unwrap().version { + let target = create_url_install_target(&url_pkg, pkg, existing_install); diff.to_update.push((pkg.clone(), target)); } else { diff.in_sync.push(format!("{} (local)", pkg.name)); @@ -313,6 +301,27 @@ fn create_install_target( } } +/// Create an InstallTarget for a URL package +fn create_url_install_target( + url_pkg: &UrlPackage, + resolved: &ResolvedPackage, + existing: Option, +) -> InstallTarget { + InstallTarget { + package: url_pkg.to_package(), + existing_install: existing, + with_pkg_id: url_pkg.pkg_type.is_some(), + pinned: resolved.pinned, + profile: resolved.profile.clone(), + portable: resolved.portable.as_ref().and_then(|p| p.path.clone()), + portable_home: resolved.portable.as_ref().and_then(|p| p.home.clone()), + portable_config: resolved.portable.as_ref().and_then(|p| p.config.clone()), + portable_share: resolved.portable.as_ref().and_then(|p| p.share.clone()), + portable_cache: resolved.portable.as_ref().and_then(|p| p.cache.clone()), + entrypoint: resolved.entrypoint.clone(), + } +} + /// Display the computed diff fn display_diff(diff: &ApplyDiff, prune: bool) { let settings = display_settings(); diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index 349aab241..10d6c4bec 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -1,13 +1,17 @@ use std::sync::{atomic::Ordering, Arc}; use nu_ansi_term::Color::{Cyan, Green, Red}; +use soar_config::packages::PackagesConfig; use soar_core::{ database::{ connection::DieselDatabase, models::{InstalledPackage, Package}, }, error::SoarError, - package::{install::InstallTarget, query::PackageQuery, update::remove_old_versions}, + package::{ + install::InstallTarget, query::PackageQuery, remote_update::check_for_update, + update::remove_old_versions, url::UrlPackage, + }, SoarResult, }; use soar_db::repository::{ @@ -45,6 +49,14 @@ fn get_existing( Ok(existing.map(Into::into)) } +/// Tracks URL packages that need their packages.toml updated after successful update +#[derive(Clone)] +struct UrlUpdateInfo { + pkg_name: String, + new_version: String, + new_url: String, +} + pub async fn update_packages( packages: Option>, keep: bool, @@ -56,7 +68,15 @@ pub async fn update_packages( let diesel_db = state.diesel_core_db()?.clone(); let config = state.config(); + // Load packages.toml to get update sources for local packages + let packages_config = PackagesConfig::load(None).ok(); + let resolved_packages = packages_config + .as_ref() + .map(|c| c.resolved_packages()) + .unwrap_or_default(); + let mut update_targets = Vec::new(); + let mut url_updates: Vec = Vec::new(); if let Some(packages) = packages { for package in packages { @@ -81,12 +101,18 @@ pub async fn update_packages( .collect(); for pkg in installed_pkgs { - // Skip local packages (installed from URLs) - no version tracking if pkg.repo_name == "local" { - info!( - "Skipping {}#{} (local package - no version tracking)", - pkg.pkg_name, pkg.pkg_id - ); + if let Some((target, url_info)) = + check_local_package_update(&pkg, &resolved_packages)? + { + update_targets.push(target); + url_updates.push(url_info); + } else { + info!( + "Skipping {}#{} (no update source configured)", + pkg.pkg_name, pkg.pkg_id + ); + } continue; } @@ -140,8 +166,36 @@ pub async fn update_packages( .map(Into::into) .collect(); + // Get local packages for update checking + let local_packages: Vec = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + Some("local"), + None, + None, + None, + Some(true), + None, + None, + None, + ) + })? + .into_iter() + .map(Into::into) + .collect(); + + // Check local packages for updates + for pkg in local_packages { + if let Some((target, url_info)) = check_local_package_update(&pkg, &resolved_packages)? + { + update_targets.push(target); + url_updates.push(url_info); + } + } + + // Check repository packages for updates for pkg in installed_packages { - // Skip local packages (installed from URLs) - no version tracking if pkg.repo_name == "local" { continue; } @@ -213,11 +267,103 @@ pub async fn update_packages( no_verify, ); - perform_update(ctx, update_targets, diesel_db, keep).await?; + perform_update(ctx, update_targets, diesel_db.clone(), keep).await?; + + // Update URLs in packages.toml for successfully updated URL packages + for url_info in url_updates { + let is_installed = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + Some("local"), + Some(&url_info.pkg_name), + None, + Some(&url_info.new_version), + Some(true), + None, + Some(1), + None, + ) + }) + .map(|pkgs| !pkgs.is_empty()) + .unwrap_or(false); + + if is_installed { + if let Err(e) = PackagesConfig::update_package_url( + &url_info.pkg_name, + &url_info.new_url, + &url_info.new_version, + None, + ) { + warn!( + "Failed to update URL for '{}' in packages.toml: {}", + url_info.pkg_name, e + ); + } + } + } Ok(()) } +/// 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], +) -> SoarResult> { + let resolved = resolved_packages + .iter() + .find(|r| r.name == pkg.pkg_name && r.update.is_some()); + + let Some(resolved) = resolved else { + return Ok(None); + }; + + let update_source = resolved.update.as_ref().unwrap(); + + 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); + return Ok(None); + } + }; + + let Some(update) = remote_update else { + return Ok(None); + }; + + let updated_url_pkg = UrlPackage::from_remote( + &update.download_url, + Some(&pkg.pkg_name), + Some(&update.new_version), + pkg.pkg_type.as_deref(), + Some(&pkg.pkg_id), + )?; + + let target = InstallTarget { + package: updated_url_pkg.to_package(), + existing_install: Some(pkg.clone()), + with_pkg_id: pkg.with_pkg_id, + pinned: resolved.pinned, + profile: resolved.profile.clone(), + portable: resolved.portable.as_ref().and_then(|p| p.path.clone()), + portable_home: resolved.portable.as_ref().and_then(|p| p.home.clone()), + portable_config: resolved.portable.as_ref().and_then(|p| p.config.clone()), + portable_share: resolved.portable.as_ref().and_then(|p| p.share.clone()), + portable_cache: resolved.portable.as_ref().and_then(|p| p.cache.clone()), + entrypoint: resolved.entrypoint.clone(), + }; + + let url_info = UrlUpdateInfo { + pkg_name: pkg.pkg_name.clone(), + new_version: updated_url_pkg.version.clone(), + new_url: update.download_url, + }; + + Ok(Some((target, url_info))) +} + pub async fn perform_update( ctx: InstallContext, targets: Vec, diff --git a/crates/soar-config/src/error.rs b/crates/soar-config/src/error.rs index 6731ca261..faaa435e8 100644 --- a/crates/soar-config/src/error.rs +++ b/crates/soar-config/src/error.rs @@ -108,6 +108,10 @@ pub enum ConfigError { #[error("Failed to annotate first table in array: {0}")] #[diagnostic(code(soar_config::annotate_first_table))] AnnotateFirstTable(String), + + #[error("{0}")] + #[diagnostic(code(soar_config::custom))] + Custom(String), } impl From for ConfigError { diff --git a/crates/soar-config/src/packages.rs b/crates/soar-config/src/packages.rs index b77e71494..19c21bd8a 100644 --- a/crates/soar-config/src/packages.rs +++ b/crates/soar-config/src/packages.rs @@ -103,6 +103,9 @@ pub struct PackageOptions { /// Whether to install binary only. pub binary_only: Option, + + /// Update source configuration for remote packages. + pub update: Option, } /// Portable directory configuration for a package. @@ -124,6 +127,49 @@ pub struct PortableConfig { pub cache: Option, } +/// Update source configuration for remote packages. +/// Specifies how to check for newer versions. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type")] +pub enum UpdateSource { + /// GitHub releases (auto-detected from github.com URLs). + #[serde(rename = "github")] + GitHub { + /// Repository in "owner/repo" format. + repo: String, + /// Glob pattern to match asset filename (e.g., "*nvim*.appimage"). + asset_pattern: Option, + /// Whether to include pre-release versions. + include_prerelease: Option, + }, + /// GitLab releases. + #[serde(rename = "gitlab")] + GitLab { + /// Repository in "owner/repo" format. + repo: String, + /// Glob pattern to match asset filename. + asset_pattern: Option, + /// Whether to include pre-release versions. + include_prerelease: Option, + }, + /// Custom URL endpoint that returns JSON with version/download info. + #[serde(rename = "url")] + Url { + /// URL that returns JSON response. + url: String, + /// JSON path to version field (e.g., "tag_name" or "version"). + version_path: String, + /// JSON path to download URL field. + download_path: String, + }, + /// Shell command that outputs version and download URL. + #[serde(rename = "command")] + Command { + /// Command to execute. Should output "version\\ndownload_url". + command: String, + }, +} + /// Resolved package specification with name included. #[derive(Clone, Debug)] pub struct ResolvedPackage { @@ -139,6 +185,7 @@ pub struct ResolvedPackage { pub portable: Option, pub install_patterns: Option>, pub binary_only: bool, + pub update: Option, } impl PackageSpec { @@ -165,13 +212,15 @@ impl PackageSpec { portable: None, install_patterns: defaults.and_then(|d| d.install_patterns.clone()), binary_only: defaults.and_then(|d| d.binary_only).unwrap_or(false), + update: None, } } PackageSpec::Detailed(opts) => { // Treat "*" as None (latest version) let version = opts.version.as_ref().filter(|v| v.as_str() != "*").cloned(); - // URL packages are always pinned - let pinned = opts.pinned || version.is_some() || opts.url.is_some(); + // URL 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()); ResolvedPackage { name: name.to_string(), pkg_id: opts.pkg_id.clone(), @@ -194,6 +243,7 @@ impl PackageSpec { .binary_only .or_else(|| defaults.and_then(|d| d.binary_only)) .unwrap_or(false), + update: opts.update.clone(), } } } @@ -268,6 +318,70 @@ impl PackagesConfig { Ok(doc) } + + /// Update the URL and version for a specific package in the packages.toml file. + /// + /// 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( + package_name: &str, + new_url: &str, + new_version: &str, + config_path: Option<&str>, + ) -> Result<()> { + let config_path = match config_path { + Some(p) => PathBuf::from(p), + None => PACKAGES_CONFIG_PATH.read().unwrap().clone(), + }; + + if !config_path.exists() { + return Err(ConfigError::PackagesConfigNotFound( + config_path.display().to_string(), + )); + } + + let content = fs::read_to_string(&config_path)?; + let mut doc = content.parse::()?; + + let packages = doc + .get_mut("packages") + .and_then(|p| p.as_table_mut()) + .ok_or_else(|| ConfigError::Custom("No [packages] section found".into()))?; + + let package = packages.get_mut(package_name).ok_or_else(|| { + ConfigError::Custom(format!("Package '{}' not found in config", package_name)) + })?; + + 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()); + } + } + 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)); + } + } + _ => { + // Package is a simple string (version), convert to detailed form + let mut table = toml_edit::InlineTable::new(); + table.insert("url", new_url.into()); + *package = toml_edit::Item::Value(toml_edit::Value::InlineTable(table)); + } + } + + fs::write(&config_path, doc.to_string())?; + info!( + "Updated URL for '{}' in {}", + package_name, + config_path.display() + ); + + Ok(()) + } } /// Generate a default packages configuration file. diff --git a/crates/soar-core/Cargo.toml b/crates/soar-core/Cargo.toml index 41de57c30..1129bf627 100644 --- a/crates/soar-core/Cargo.toml +++ b/crates/soar-core/Cargo.toml @@ -16,10 +16,12 @@ ignored = ["libsqlite3-sys"] [dependencies] chrono = { workspace = true } diesel = { workspace = true } +fast-glob = { workspace = true } libsqlite3-sys = { workspace = true } miette = { workspace = true } nix = { workspace = true } regex = { workspace = true } +semver = "1.0" serde = { workspace = true } serde_json = { workspace = true } soar-config = { workspace = true } @@ -31,3 +33,4 @@ thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } ureq = { workspace = true } +url = { workspace = true } diff --git a/crates/soar-core/src/package/mod.rs b/crates/soar-core/src/package/mod.rs index edeec0948..abdba75a6 100644 --- a/crates/soar-core/src/package/mod.rs +++ b/crates/soar-core/src/package/mod.rs @@ -1,5 +1,6 @@ pub mod install; pub mod query; +pub mod remote_update; pub mod remove; pub mod update; pub mod url; diff --git a/crates/soar-core/src/package/remote_update.rs b/crates/soar-core/src/package/remote_update.rs new file mode 100644 index 000000000..4c26b2ffa --- /dev/null +++ b/crates/soar-core/src/package/remote_update.rs @@ -0,0 +1,420 @@ +//! Remote package update checking. +//! +//! This module provides functionality to check for updates to remote packages +//! (those installed via URL) using various update sources like GitHub/GitLab +//! releases APIs. + +use soar_config::packages::UpdateSource; +use soar_dl::{ + github::{Github, GithubAsset, GithubRelease}, + gitlab::{GitLab, GitLabAsset, GitLabRelease}, + traits::{Asset, Platform, Release}, +}; + +use crate::{error::SoarError, SoarResult}; + +/// Result of checking for a remote package update. +#[derive(Debug, Clone)] +pub struct RemoteUpdate { + /// The new version available. + pub new_version: String, + /// Download URL for the new version. + pub download_url: String, + /// Optional size of the download in bytes. + pub size: Option, +} + +/// Check for updates to a remote package. +/// +/// # Arguments +/// * `update_source` - The update source configuration +/// * `current_version` - The currently installed version +/// +/// # Returns +/// * `Ok(Some(RemoteUpdate))` if a newer version is available +/// * `Ok(None)` if already at the latest version +/// * `Err` if the check fails +pub fn check_for_update( + update_source: &UpdateSource, + current_version: &str, +) -> SoarResult> { + match update_source { + UpdateSource::GitHub { + repo, + asset_pattern, + include_prerelease, + } => { + check_github( + repo, + asset_pattern.as_deref(), + *include_prerelease, + current_version, + ) + } + UpdateSource::GitLab { + repo, + asset_pattern, + include_prerelease, + } => { + check_gitlab( + repo, + asset_pattern.as_deref(), + *include_prerelease, + 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), + } +} + +/// Check for updates via GitHub releases API. +fn check_github( + repo: &str, + asset_pattern: Option<&str>, + include_prerelease: Option, + current_version: &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 include_prerelease = include_prerelease.unwrap_or(false); + + let release = releases + .iter() + .find(|r: &&GithubRelease| include_prerelease || !r.is_prerelease()); + + let Some(release) = release else { + return Ok(None); + }; + + let new_version = release.tag(); + + if !is_newer_version(current_version, new_version) { + return Ok(None); + } + + let assets: &[GithubAsset] = release.assets(); + let asset = find_matching_asset(assets, asset_pattern)?; + + Ok(Some(RemoteUpdate { + new_version: new_version.to_string(), + download_url: asset.url().to_string(), + size: asset.size(), + })) +} + +/// Check for updates via GitLab releases API. +fn check_gitlab( + repo: &str, + asset_pattern: Option<&str>, + include_prerelease: Option, + current_version: &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 include_prerelease = include_prerelease.unwrap_or(false); + + let release = releases + .iter() + .find(|r: &&GitLabRelease| include_prerelease || !r.is_prerelease()); + + let Some(release) = release else { + return Ok(None); + }; + + let new_version = release.tag(); + + if !is_newer_version(current_version, new_version) { + return Ok(None); + } + + let assets: &[GitLabAsset] = release.assets(); + let asset = find_matching_asset(assets, asset_pattern)?; + + Ok(Some(RemoteUpdate { + new_version: new_version.to_string(), + download_url: asset.url().to_string(), + size: asset.size(), + })) +} + +/// Check for updates via custom URL endpoint. +fn check_url( + url: &str, + version_path: &str, + download_path: &str, + current_version: &str, +) -> SoarResult> { + use soar_dl::http::Http; + + let json: serde_json::Value = Http::json(url).map_err(|e| { + SoarError::Custom(format!("Failed to fetch update info from {}: {}", url, e)) + })?; + + let new_version = extract_json_value(&json, version_path).ok_or_else(|| { + SoarError::Custom(format!( + "Could not find version at path '{}' in response", + version_path + )) + })?; + + if !is_newer_version(current_version, &new_version) { + return Ok(None); + } + + let download_url = extract_json_value(&json, download_path).ok_or_else(|| { + SoarError::Custom(format!( + "Could not find download URL at path '{}' in response", + download_path + )) + })?; + + if !is_valid_download_url(&download_url) { + return Err(SoarError::Custom(format!( + "Invalid download URL returned: {}", + download_url + ))); + } + + Ok(Some(RemoteUpdate { + new_version, + download_url, + size: None, + })) +} + +/// Check for updates via shell command. +fn check_command(command: &str, current_version: &str) -> SoarResult> { + use std::process::Command; + + let output = Command::new("sh") + .arg("-c") + .arg(command) + .output() + .map_err(|e| SoarError::Custom(format!("Failed to execute update command: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SoarError::Custom(format!( + "Update command failed: {}", + stderr + ))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + + let new_version = lines + .next() + .ok_or_else(|| SoarError::Custom("Update command returned no output".into()))? + .trim() + .to_string(); + + if !is_newer_version(current_version, &new_version) { + return Ok(None); + } + + let download_url = lines + .next() + .ok_or_else(|| SoarError::Custom("Update command did not return download URL".into()))? + .trim() + .to_string(); + + if !is_valid_download_url(&download_url) { + return Err(SoarError::Custom(format!( + "Invalid download URL returned by command: {}", + download_url + ))); + } + + Ok(Some(RemoteUpdate { + new_version, + download_url, + size: None, + })) +} + +/// Validate that a download URL is properly formed. +/// +/// Checks that the URL: +/// - Starts with http:// or https:// +/// - Is a valid URL structure (has host, etc.) +fn is_valid_download_url(url: &str) -> bool { + let url = url.trim(); + if url.is_empty() { + return false; + } + + let lower = url.to_lowercase(); + if !lower.starts_with("http://") && !lower.starts_with("https://") { + return false; + } + + match url::Url::parse(url) { + Ok(parsed) => parsed.host().is_some(), + Err(_) => false, + } +} + +/// Compare versions to determine if candidate is newer than current. +/// +/// Uses semver comparison if both versions are valid semver, otherwise +/// treats any difference as potentially newer. +fn is_newer_version(current: &str, candidate: &str) -> bool { + let current = current.strip_prefix('v').unwrap_or(current); + let candidate = candidate.strip_prefix('v').unwrap_or(candidate); + + if current == candidate { + return false; + } + + match ( + semver::Version::parse(current), + semver::Version::parse(candidate), + ) { + (Ok(cur), Ok(cand)) => cand > cur, + // If semver parsing fails, treat different versions as newer + _ => true, + } +} + +/// Find an asset matching the given pattern from a list of assets. +fn find_matching_asset<'a, A: Asset>(assets: &'a [A], pattern: Option<&str>) -> SoarResult<&'a A> { + if assets.is_empty() { + return Err(SoarError::Custom("No assets found in release".into())); + } + + match pattern { + Some(pattern) => { + assets + .iter() + .find(|a| fast_glob::glob_match(pattern, a.name())) + .ok_or_else(|| { + SoarError::Custom(format!( + "No asset matching pattern '{}' found. Available: {}", + pattern, + assets + .iter() + .map(|a| a.name()) + .collect::>() + .join(", ") + )) + }) + } + None => { + // No pattern specified, return first asset + Ok(&assets[0]) + } + } +} + +/// Extract a value from JSON using a simple dot-separated path. +fn extract_json_value(json: &serde_json::Value, path: &str) -> Option { + let mut current = json; + + for key in path.split('.') { + // Handle array indexing like "assets[0]" + if let Some((array_key, index_str)) = key.split_once('[') { + let index_str = index_str.trim_end_matches(']'); + let index: usize = index_str.parse().ok()?; + + current = current.get(array_key)?; + current = current.get(index)?; + } else { + current = current.get(key)?; + } + } + + match current { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_newer_version_semver() { + assert!(is_newer_version("1.0.0", "1.0.1")); + assert!(is_newer_version("1.0.0", "1.1.0")); + assert!(is_newer_version("1.0.0", "2.0.0")); + assert!(!is_newer_version("1.0.0", "1.0.0")); + assert!(!is_newer_version("2.0.0", "1.0.0")); + } + + #[test] + fn test_is_newer_version_with_v_prefix() { + assert!(is_newer_version("v1.0.0", "v1.0.1")); + assert!(is_newer_version("1.0.0", "v1.0.1")); + assert!(is_newer_version("v1.0.0", "1.0.1")); + } + + #[test] + fn test_is_newer_version_non_semver() { + // Non-semver versions: treat any difference as potentially newer + assert!(is_newer_version("abc", "def")); + assert!(is_newer_version("HEAD-123", "HEAD-456")); + } + + #[test] + fn test_extract_json_value() { + let json: serde_json::Value = serde_json::json!({ + "tag_name": "v1.0.0", + "assets": [ + {"name": "app.zip", "browser_download_url": "https://example.com/app.zip"} + ] + }); + + assert_eq!( + extract_json_value(&json, "tag_name"), + Some("v1.0.0".to_string()) + ); + assert_eq!( + extract_json_value(&json, "assets[0].name"), + Some("app.zip".to_string()) + ); + assert_eq!( + extract_json_value(&json, "assets[0].browser_download_url"), + Some("https://example.com/app.zip".to_string()) + ); + assert_eq!(extract_json_value(&json, "nonexistent"), None); + } + + #[test] + fn test_is_valid_download_url() { + // Valid URLs + assert!(is_valid_download_url("https://example.com/file.AppImage")); + assert!(is_valid_download_url("http://example.com/file")); + assert!(is_valid_download_url( + "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/user/repo/releases/download/v1.0/app.zip" + )); + + // Invalid URLs + assert!(!is_valid_download_url("")); // empty + assert!(!is_valid_download_url(" ")); // whitespace only + assert!(!is_valid_download_url("https://")); // no host + assert!(!is_valid_download_url("https://?query=1")); // no host + assert!(!is_valid_download_url("not-a-url")); // no protocol + assert!(!is_valid_download_url("ftp://example.com/file")); // wrong protocol + assert!(!is_valid_download_url("file:///path/to/file")); // file protocol + } +} diff --git a/crates/soar-core/src/package/url.rs b/crates/soar-core/src/package/url.rs index 9c6782ee1..ae472db3e 100644 --- a/crates/soar-core/src/package/url.rs +++ b/crates/soar-core/src/package/url.rs @@ -26,8 +26,12 @@ pub struct UrlPackage { impl UrlPackage { /// Check if a string is a valid HTTP(S) URL. pub fn is_url(input: &str) -> bool { - let input = input.trim().to_lowercase(); - input.starts_with("http://") || input.starts_with("https://") + let input = input.trim(); + let lower = input.to_lowercase(); + if !lower.starts_with("http://") && !lower.starts_with("https://") { + return false; + } + url::Url::parse(input).is_ok() } /// Check if a string is a GHCR (GitHub Container Registry) package reference. @@ -182,8 +186,9 @@ impl UrlPackage { .map(|s| s.to_lowercase()) .unwrap_or(extracted_name); + // Normalize version by stripping "v" prefix for consistency let version = version_override - .map(String::from) + .map(|v| v.strip_prefix('v').unwrap_or(v).to_string()) .unwrap_or(extracted_version); // Generate pkg_id: use override, or extract from URL, or generate from name and type From adf0001949da59d07259cad31f6feca01e5a8530 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Fri, 9 Jan 2026 20:05:38 +0545 Subject: [PATCH 2/4] fix --- crates/soar-cli/src/apply.rs | 2 +- crates/soar-config/src/packages.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index 52c2ae6d4..ef0f2af22 100644 --- a/crates/soar-cli/src/apply.rs +++ b/crates/soar-cli/src/apply.rs @@ -310,7 +310,7 @@ fn create_url_install_target( InstallTarget { package: url_pkg.to_package(), existing_install: existing, - with_pkg_id: url_pkg.pkg_type.is_some(), + with_pkg_id: resolved.pkg_id.is_some(), pinned: resolved.pinned, profile: resolved.profile.clone(), portable: resolved.portable.as_ref().and_then(|p| p.path.clone()), diff --git a/crates/soar-config/src/packages.rs b/crates/soar-config/src/packages.rs index 19c21bd8a..76fe5b27c 100644 --- a/crates/soar-config/src/packages.rs +++ b/crates/soar-config/src/packages.rs @@ -366,10 +366,7 @@ impl PackagesConfig { } } _ => { - // Package is a simple string (version), convert to detailed form - let mut table = toml_edit::InlineTable::new(); - table.insert("url", new_url.into()); - *package = toml_edit::Item::Value(toml_edit::Value::InlineTable(table)); + unreachable!("Package is a simple string (version)"); } } From f4b68db1ec06fbeac8083bd5bb620650d45f8000 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Fri, 9 Jan 2026 20:09:56 +0545 Subject: [PATCH 3/4] fix with_pkg_id --- crates/soar-cli/src/apply.rs | 4 ++-- crates/soar-cli/src/install.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index ef0f2af22..515f09a47 100644 --- a/crates/soar-cli/src/apply.rs +++ b/crates/soar-cli/src/apply.rs @@ -289,7 +289,7 @@ fn create_install_target( InstallTarget { package, existing_install: existing, - with_pkg_id: resolved.pkg_id.is_some(), + with_pkg_id: false, pinned: resolved.pinned, profile: resolved.profile.clone(), portable: resolved.portable.as_ref().and_then(|p| p.path.clone()), @@ -310,7 +310,7 @@ fn create_url_install_target( InstallTarget { package: url_pkg.to_package(), existing_install: existing, - with_pkg_id: resolved.pkg_id.is_some(), + with_pkg_id: false, pinned: resolved.pinned, profile: resolved.profile.clone(), portable: resolved.portable.as_ref().and_then(|p| p.path.clone()), diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index 433ba6df8..855f5c6ed 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -269,7 +269,7 @@ fn resolve_packages( install_targets.push(InstallTarget { package: url_pkg.to_package(), existing_install, - with_pkg_id: url_pkg.pkg_type.is_some(), + with_pkg_id: false, pinned: false, profile: None, ..Default::default() @@ -396,7 +396,7 @@ fn resolve_packages( install_targets.push(InstallTarget { package: pkg, existing_install, - with_pkg_id: true, + with_pkg_id: false, pinned: query.version.is_some(), profile: None, ..Default::default() From a1585b9b6799651040742af10e61b537884d2bb9 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Fri, 9 Jan 2026 20:18:21 +0545 Subject: [PATCH 4/4] fix --- crates/soar-cli/src/apply.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index 515f09a47..a234939e2 100644 --- a/crates/soar-cli/src/apply.rs +++ b/crates/soar-cli/src/apply.rs @@ -134,17 +134,22 @@ async fn compute_diff( .map(Into::into) .collect(); - let is_already_installed = installed_packages.iter().any(|ip| ip.is_installed); - let existing_install = installed_packages.into_iter().next(); - - if !is_already_installed { + 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)); - } else if url_pkg.version != existing_install.as_ref().unwrap().version { - let target = create_url_install_target(&url_pkg, pkg, existing_install); - diff.to_update.push((pkg.clone(), target)); - } else { - diff.in_sync.push(format!("{} (local)", pkg.name)); } continue; }