From a1ee164143bf918547afc5c68cfd6d24b81fed4d Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 14 Feb 2026 15:47:29 +0545 Subject: [PATCH 1/2] refactor(cli): use operations from shared crate --- Cargo.toml | 6 + crates/soar-cli/Cargo.toml | 25 +- crates/soar-cli/src/apply.rs | 689 +----------- crates/soar-cli/src/download.rs | 13 +- crates/soar-cli/src/health.rs | 125 +-- crates/soar-cli/src/inspect.rs | 23 +- crates/soar-cli/src/install.rs | 1489 ++++--------------------- crates/soar-cli/src/list.rs | 324 ++---- crates/soar-cli/src/logging.rs | 50 +- crates/soar-cli/src/main.rs | 74 +- crates/soar-cli/src/progress.rs | 636 ++++++++--- crates/soar-cli/src/remove.rs | 265 +---- crates/soar-cli/src/run.rs | 190 +--- crates/soar-cli/src/self_actions.rs | 10 +- crates/soar-cli/src/state.rs | 255 ----- crates/soar-cli/src/update.rs | 590 +--------- crates/soar-cli/src/use.rs | 152 +-- crates/soar-cli/src/utils.rs | 355 +----- crates/soar-operations/src/install.rs | 7 +- crates/soar-registry/src/metadata.rs | 6 +- 20 files changed, 1065 insertions(+), 4219 deletions(-) delete mode 100644 crates/soar-cli/src/state.rs diff --git a/Cargo.toml b/Cargo.toml index 5686daa8..4eb76cd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ categories = ["command-line-utilities"] [workspace.dependencies] blake3 = { version = "1.8.2", features = ["mmap"] } +clap = { version = "4.5.54", features = ["cargo", "derive"] } chrono = "0.4" compak = "0.1.2" diesel = { version = "2.3.5", features = [ @@ -36,11 +37,13 @@ diesel_migrations = { version = "2.3.1", features = ["sqlite"] } documented = "0.9.2" fast-glob = "1.0.0" image = { version = "0.25.9", default-features = false, features = ["png"] } +indicatif = "0.18" landlock = "0.4.4" libsqlite3-sys = { version = ">=0.30.1,<0.36.0", features = [ "bundled" ]} miette = { version = "7.6.0", features = ["fancy"] } minisign-verify = "0.2.4" nix = { version = "0.30.1", features = ["fs", "ioctl", "term", "user"] } +nu-ansi-term = "0.50.3" once_cell = "1.21" percent-encoding = "2.3.2" rayon = "1.11.0" @@ -63,12 +66,15 @@ soar-package = { version = "0.2.3", path = "crates/soar-package" } soar-registry = { version = "0.3.0", path = "crates/soar-registry" } soar-utils = { version = "0.3.0", path = "crates/soar-utils" } squishy = { version = "0.4.0", features = ["appimage", "dwarfs"] } +tabled = { version = "0.20", features = ["ansi"] } +terminal_size = "0.4" tempfile = "3.24.0" thiserror = "2.0.17" tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread", "sync", "time"] } toml = "0.9.10" toml_edit = "0.23.10" tracing = { version = "0.1.44", default-features = false } +tracing-subscriber = { version = "0.3.22", default-features = false, features = ["env-filter", "fmt", "json", "nu-ansi-term"] } ureq = { version = "3.1.4", features = ["json"] } url = "2.5.8" xattr = "1.6.1" diff --git a/crates/soar-cli/Cargo.toml b/crates/soar-cli/Cargo.toml index 3a9df898..6e944204 100644 --- a/crates/soar-cli/Cargo.toml +++ b/crates/soar-cli/Cargo.toml @@ -19,30 +19,27 @@ path = "src/main.rs" self = [] [dependencies] -clap = { version = "4.5.54", features = ["cargo", "derive"] } -fast-glob = { workspace = true } -indicatif = "0.18.3" +clap = { workspace = true } +indicatif = { workspace = true } miette = { workspace = true } -minisign-verify = "0.2.4" nix = { workspace = true } -nu-ansi-term = "0.50.3" -once_cell = "1.21.3" -rayon = { workspace = true } +nu-ansi-term = { workspace = true } regex = { workspace = true } -semver = "1.0.27" +semver = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } soar-config = { workspace = true } soar-core = { workspace = true } soar-db = { workspace = true } soar-dl = { workspace = true } +soar-events = { workspace = true } +soar-operations = { workspace = true } soar-package = { workspace = true } -soar-registry = { workspace = true } soar-utils = { workspace = true } -tabled = { version = "0.20", features = ["ansi"] } -terminal_size = "0.4" -tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread", "sync", "time"] } -toml = "0.9.10" +tabled = { workspace = true } +terminal_size = { workspace = true } +tokio = { workspace = true } +toml = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { version = "0.3.22", default-features = false, features = ["env-filter", "fmt", "json", "nu-ansi-term"] } +tracing-subscriber = { workspace = true } ureq = { workspace = true } diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index 9f50cac2..971c19bb 100644 --- a/crates/soar-cli/src/apply.rs +++ b/crates/soar-cli/src/apply.rs @@ -1,118 +1,19 @@ -use std::{ - collections::HashSet, - io::{self, Write}, - sync::atomic::Ordering, -}; +use std::io::{self, Write}; use nu_ansi_term::Color::{Blue, Cyan, Green, Magenta, Red, Yellow}; -use soar_config::packages::{PackagesConfig, ResolvedPackage}; -use soar_core::{ - database::{ - connection::DieselDatabase, - models::{InstalledPackage, Package}, - }, - package::{ - install::InstallTarget, - release_source::{run_version_command, ReleaseSource}, - remove::PackageRemover, - url::UrlPackage, - }, - utils::substitute_placeholders, - SoarResult, -}; -use soar_db::repository::{ - core::{CoreRepository, SortDirection}, - metadata::MetadataRepository, -}; +use soar_config::packages::PackagesConfig; +use soar_core::SoarResult; +use soar_operations::{apply, ApplyDiff, ApplyReport, SoarContext}; use tabled::{ builder::Builder, settings::{themes::BorderCorrection, Panel, Style}, }; -use tracing::{error, info, warn}; - -use crate::{ - install::{create_install_context, perform_installation}, - state::AppState, - update::perform_update, - 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), -} +use tracing::{info, warn}; -/// 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(); +use crate::utils::{display_settings, icon_or, Colored, Icons}; - 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 { - /// Packages to install (declared but not installed) - pub to_install: Vec<(ResolvedPackage, InstallTarget)>, - /// Packages to update (version mismatch) - pub to_update: Vec<(ResolvedPackage, InstallTarget)>, - /// Packages to remove (installed but not declared, only with --prune) - pub to_remove: Vec, - /// Packages already in sync - pub in_sync: Vec, - /// Packages not found in metadata - pub not_found: Vec, - /// Pending version updates for packages.toml (package_name, version) - pub pending_version_updates: Vec<(String, String)>, -} - -/// Main entry point for the apply command pub async fn apply_packages( + ctx: &SoarContext, prune: bool, dry_run: bool, yes: bool, @@ -129,22 +30,17 @@ pub async fn apply_packages( info!("Loaded {} package declaration(s)", resolved.len()); - let state = AppState::new(); - let diff = compute_diff(&state, &resolved, prune).await?; + let diff = apply::compute_diff(ctx, &resolved, prune).await?; display_diff(&diff, prune); - let has_package_changes = - !diff.to_install.is_empty() || !diff.to_update.is_empty() || !diff.to_remove.is_empty(); - let has_toml_updates = !diff.pending_version_updates.is_empty(); - - if !has_package_changes && !has_toml_updates { + if !diff.has_changes() && !diff.has_toml_updates() { info!("\nAll packages are in sync!"); return Ok(()); } if dry_run { - if has_toml_updates { + if diff.has_toml_updates() { info!("\nWould update packages.toml:"); for (pkg_name, version) in &diff.pending_version_updates { info!( @@ -170,413 +66,16 @@ pub async fn apply_packages( } } - execute_apply(&state, diff, no_verify).await -} - -/// Compute the difference between declared and installed packages -async fn compute_diff( - state: &AppState, - resolved: &[ResolvedPackage], - prune: bool, -) -> SoarResult { - let metadata_mgr = state.metadata_manager().await?; - let diesel_db = state.diesel_core_db()?.clone(); - - let mut diff = ApplyDiff::default(); - let mut declared_keys: HashSet<(String, Option, Option)> = HashSet::new(); - - for pkg in resolved { - // Track declared package - declared_keys.insert((pkg.name.clone(), pkg.pkg_id.clone(), pkg.repo.clone())); - - let is_github_or_gitlab = pkg.github.is_some() || pkg.gitlab.is_some(); - if is_github_or_gitlab || pkg.url.is_some() { - let local_pkg_id = if is_github_or_gitlab { - pkg.pkg_id.clone().or_else(|| { - pkg.github - .as_ref() - .or(pkg.gitlab.as_ref()) - .map(|repo| repo.replace('/', ".")) - }) - } else { - pkg.pkg_id.clone() - }; - - let installed: Option = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - Some("local"), - Some(&pkg.name), - local_pkg_id.as_deref(), - None, - Some(true), - None, - Some(1), - None, - ) - })? - .into_iter() - .next() - .map(Into::into); - - if let Some(ref cmd) = pkg.version_command { - if let Some(ref declared) = pkg.version { - let normalized = declared.strip_prefix('v').unwrap_or(declared); - if let Some(ref existing) = installed { - if existing.version == normalized { - diff.in_sync.push(format!("{} (local)", pkg.name)); - continue; - } - } - } - - let result = match run_version_command(cmd) { - Ok(r) => r, - Err(e) => { - warn!("Failed to run version_command for {}: {}", pkg.name, e); - diff.not_found - .push(format!("{} (version_command failed: {})", pkg.name, e)); - continue; - } - }; - - let version = result - .version - .strip_prefix('v') - .unwrap_or(&result.version) - .to_string(); - - if let Some(ref existing) = installed { - if existing.version == version { - let declared = pkg - .version - .as_ref() - .map(|s| s.strip_prefix('v').unwrap_or(s)); - if declared != Some(version.as_str()) { - // Queue version update for after user confirmation - diff.pending_version_updates - .push((pkg.name.clone(), version.clone())); - } - diff.in_sync.push(format!("{} (local)", pkg.name)); - continue; - } - } - - let download_url = match result.download_url { - Some(url) => url, - None => { - match &pkg.url { - Some(url) => substitute_placeholders(url, Some(&version)), - None => { - diff.not_found.push(format!( - "{} (version_command returned no URL and no url field configured)", - pkg.name - )); - continue; - } - } - } - }; - - let mut url_pkg = UrlPackage::from_remote( - &download_url, - Some(&pkg.name), - Some(&version), - pkg.pkg_type.as_deref(), - local_pkg_id.as_deref(), - )?; - url_pkg.size = result.size; - - 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 is_github_or_gitlab { - if let Some(ref declared) = pkg.version { - let normalized = declared.strip_prefix('v').unwrap_or(declared); - if let Some(ref existing) = installed { - if existing.version == normalized { - diff.in_sync.push(format!("{} (local)", pkg.name)); - continue; - } - } - } - - 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; - } - }; - 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; - } - }; - let version = release - .version - .strip_prefix('v') - .unwrap_or(&release.version) - .to_string(); - - let url_pkg = UrlPackage::from_remote( - &release.download_url, - Some(&pkg.name), - Some(&version), - pkg.pkg_type.as_deref(), - local_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 { - if let Some(ref declared) = pkg.version { - let normalized = declared.strip_prefix('v').unwrap_or(declared); - if let Some(ref existing) = installed { - if existing.version == normalized { - diff.in_sync.push(format!("{} (local)", pkg.name)); - continue; - } - } - } - - let url = substitute_placeholders(url, pkg.version.as_deref()); - let url_pkg = UrlPackage::from_remote( - &url, - Some(&pkg.name), - pkg.version.as_deref(), - pkg.pkg_type.as_deref(), - pkg.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; - } - } - - // Find package in metadata - let found_packages: Vec = if let Some(ref repo_name) = pkg.repo { - metadata_mgr - .query_repo(repo_name, |conn| { - MetadataRepository::find_filtered( - conn, - Some(&pkg.name), - pkg.pkg_id.as_deref(), - pkg.version.as_deref(), - None, - Some(SortDirection::Asc), - ) - })? - .unwrap_or_default() - .into_iter() - .map(|p| { - let mut package: Package = p.into(); - package.repo_name = repo_name.clone(); - package - }) - .collect() - } else { - metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::find_filtered( - conn, - Some(&pkg.name), - pkg.pkg_id.as_deref(), - pkg.version.as_deref(), - None, - Some(SortDirection::Asc), - )?; - Ok(pkgs - .into_iter() - .map(|p| { - let mut package: Package = p.into(); - package.repo_name = repo_name.to_string(); - package - }) - .collect()) - })? - }; - - if found_packages.is_empty() { - diff.not_found.push(pkg.name.clone()); - continue; - } - - // Use first matching package (like --yes behavior) - let metadata_pkg = found_packages.into_iter().next().unwrap(); - - // Check if installed - let installed_packages: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - Some(&metadata_pkg.repo_name), - Some(&metadata_pkg.pkg_name), - Some(&metadata_pkg.pkg_id), - None, // Don't filter by version - we want to find any installed version - None, - None, - None, - Some(SortDirection::Asc), - ) - })? - .into_iter() - .map(Into::into) - .collect(); - - let existing_install = installed_packages.into_iter().find(|ip| ip.is_installed); - - if let Some(ref existing) = existing_install { - let version_matches = pkg.version.as_ref().is_none_or(|v| existing.version == *v); - - if version_matches && existing.version == metadata_pkg.version { - diff.in_sync.push(format!( - "{}#{}@{}", - existing.pkg_name, existing.pkg_id, existing.version - )); - } else if !existing.pinned || pkg.version.is_some() { - let target = create_install_target(pkg, metadata_pkg, Some(existing.clone())); - diff.to_update.push((pkg.clone(), target)); - } else { - diff.in_sync.push(format!( - "{}#{}@{} (pinned)", - existing.pkg_name, existing.pkg_id, existing.version - )); - } - } else { - let target = create_install_target(pkg, metadata_pkg, None); - diff.to_install.push((pkg.clone(), target)); - } - } - - if prune { - let all_installed: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - None, - None, - None, - None, - None, - None, - None, - Some(SortDirection::Asc), - ) - })? - .into_iter() - .filter(|p| p.is_installed) - .map(Into::into) - .collect(); + let report = apply::execute_apply(ctx, diff, no_verify).await?; + display_apply_report(&report); - for installed in all_installed { - let is_declared = declared_keys.iter().any(|(name, pkg_id, repo)| { - let name_matches = *name == installed.pkg_name; - let pkg_id_matches = pkg_id.as_ref().is_none_or(|id| *id == installed.pkg_id); - let repo_matches = repo.as_ref().is_none_or(|r| *r == installed.repo_name); - name_matches && pkg_id_matches && repo_matches - }); - - if !is_declared { - diff.to_remove.push(installed); - } - } - } - - Ok(diff) -} - -/// Create an InstallTarget from resolved package info -fn create_install_target( - resolved: &ResolvedPackage, - package: Package, - existing: Option, -) -> InstallTarget { - InstallTarget { - package, - existing_install: existing, - 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(), - binaries: resolved.binaries.clone(), - nested_extract: resolved.nested_extract.clone(), - extract_root: resolved.extract_root.clone(), - hooks: resolved.hooks.clone(), - build: resolved.build.clone(), - sandbox: resolved.sandbox.clone(), - } -} - -/// 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, - 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(), - binaries: resolved.binaries.clone(), - nested_extract: resolved.nested_extract.clone(), - extract_root: resolved.extract_root.clone(), - hooks: resolved.hooks.clone(), - build: resolved.build.clone(), - sandbox: resolved.sandbox.clone(), - } + Ok(()) } -/// Display the computed diff fn display_diff(diff: &ApplyDiff, prune: bool) { let settings = display_settings(); let use_icons = settings.icons(); - // Build packages table if there are changes if !diff.to_install.is_empty() || !diff.to_update.is_empty() || (prune && !diff.to_remove.is_empty()) @@ -584,7 +83,6 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { let mut builder = Builder::new(); builder.push_record(["", "Package", "Version", "Repository"]); - // Add packages to install for (_resolved, target) in &diff.to_install { let pkg = &target.package; builder.push_record([ @@ -599,7 +97,6 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { ]); } - // Add packages to update for (_resolved, target) in &diff.to_update { let pkg = &target.package; let old_version = target @@ -622,7 +119,6 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { ]); } - // Add packages to remove if prune { for pkg in &diff.to_remove { builder.push_record([ @@ -648,7 +144,6 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { info!("\n{table}"); } - // Show packages not found if !diff.not_found.is_empty() { info!("\n{} Packages not found:", icon_or(Icons::WARNING, "!")); for name in &diff.not_found { @@ -656,7 +151,6 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { } } - // Summary table let mut summary_builder = Builder::new(); if !diff.to_install.is_empty() { @@ -713,157 +207,12 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { } } -/// Execute the apply operation -async fn execute_apply(state: &AppState, diff: ApplyDiff, no_verify: bool) -> SoarResult<()> { - let diesel_db = state.diesel_core_db()?.clone(); - let config = state.config(); - - let mut installed_count = 0; - let mut updated_count = 0; - let mut removed_count = 0; - let mut failed_count = 0; - - let mut version_updates: Vec<(String, String)> = Vec::new(); - - // Apply pending version updates for in-sync packages - for (pkg_name, version) in &diff.pending_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_install.is_empty() { - info!("\nInstalling {} package(s)...", diff.to_install.len()); - - for (pkg, target) in &diff.to_install { - let declared_version = pkg - .version - .as_ref() - .map(|v| v.strip_prefix('v').unwrap_or(v)); - if declared_version != Some(target.package.version.as_str()) { - version_updates.push((pkg.name.clone(), target.package.version.clone())); - } - } - - let targets: Vec = diff - .to_install - .into_iter() - .map(|(_, target)| target) - .collect(); - - let ctx = create_install_context( - targets.len(), - config.parallel_limit.unwrap_or(4), - None, - None, - None, - None, - None, - false, - no_verify, - ); - - 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() { - info!("\nUpdating {} package(s)...", diff.to_update.len()); - - let mut update_version_updates: Vec<(String, String)> = Vec::new(); - for (pkg, target) in &diff.to_update { - let declared_version = pkg - .version - .as_ref() - .map(|v| v.strip_prefix('v').unwrap_or(v)); - if declared_version != Some(target.package.version.as_str()) { - update_version_updates.push((pkg.name.clone(), target.package.version.clone())); - } - } - - let targets: Vec = diff - .to_update - .into_iter() - .map(|(_, target)| target) - .collect(); - - let ctx = create_install_context( - targets.len(), - config.parallel_limit.unwrap_or(4), - None, - None, - None, - None, - None, - false, - no_verify, - ); - - perform_update(ctx.clone(), targets, diesel_db.clone(), false).await?; - updated_count = ctx.installed_count.load(Ordering::Relaxed) as usize; - failed_count += ctx.failed.load(Ordering::Relaxed) as usize; - - if updated_count > 0 { - for (pkg_name, version) in &update_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_remove.is_empty() { - info!("\nRemoving {} package(s)...", diff.to_remove.len()); - - for pkg in diff.to_remove { - // Look up hooks from packages config (may not exist for pruned packages) - let (hooks, sandbox) = get_package_hooks(&pkg.pkg_name); - match PackageRemover::new(pkg.clone(), diesel_db.clone()) - .await - .with_hooks(hooks) - .with_sandbox(sandbox) - .remove() - .await - { - Ok(_) => { - info!(" Removed {}#{}", pkg.pkg_name, pkg.pkg_id); - removed_count += 1; - } - Err(e) => { - error!(" Failed to remove {}#{}: {}", pkg.pkg_name, pkg.pkg_id, e); - failed_count += 1; - } - } - } - } - +fn display_apply_report(report: &ApplyReport) { info!("\n{} Apply Summary", icon_or(Icons::CHECK, "*")); - info!(" Installed: {}", installed_count); - info!(" Updated: {}", updated_count); - info!(" Removed: {}", removed_count); - if failed_count > 0 { - warn!(" Failed: {}", failed_count); + info!(" Installed: {}", report.installed_count); + info!(" Updated: {}", report.updated_count); + info!(" Removed: {}", report.removed_count); + if report.failed_count > 0 { + warn!(" Failed: {}", report.failed_count); } - - Ok(()) } diff --git a/crates/soar-cli/src/download.rs b/crates/soar-cli/src/download.rs index a5a5a453..57eb5192 100644 --- a/crates/soar-cli/src/download.rs +++ b/crates/soar-cli/src/download.rs @@ -1,6 +1,5 @@ use std::{sync::Arc, time::Duration}; -use indicatif::HumanBytes; use regex::Regex; use soar_config::config::get_config; use soar_core::{database::models::Package, package::query::PackageQuery, SoarResult}; @@ -16,13 +15,11 @@ use soar_dl::{ traits::{Asset, Platform as _, Release as _}, types::{OverwriteMode, Progress}, }; +use soar_utils::bytes::format_bytes; use tokio::time::sleep; use tracing::{error, info}; -use crate::{ - state::AppState, - utils::{interactive_ask, select_package_interactively}, -}; +use crate::utils::{interactive_ask, select_package_interactively}; pub struct DownloadContext { pub regexes: Vec, @@ -145,8 +142,8 @@ pub async fn handle_direct_downloads( } None => { // if it's not a url, try to parse it as package - let state = AppState::new(); - let metadata_mgr = state.metadata_manager().await?; + let (ctx_inner, _) = crate::create_context(); + let metadata_mgr = ctx_inner.metadata_manager().await?; let query = PackageQuery::try_from(link.as_str())?; // Query packages across all repos @@ -511,7 +508,7 @@ where for (i, asset) in assets.iter().enumerate() { let size = asset .size() - .map(|s| format!(" ({})", HumanBytes(s))) + .map(|s| format!(" ({})", format_bytes(s, 2))) .unwrap_or_default(); info!(" {}. {}{}", i + 1, asset.name(), size); } diff --git a/crates/soar-cli/src/health.rs b/crates/soar-cli/src/health.rs index c1fc2613..b17597fe 100644 --- a/crates/soar-cli/src/health.rs +++ b/crates/soar-cli/src/health.rs @@ -1,60 +1,48 @@ -use std::{cell::RefCell, env, path::Path, rc::Rc}; - use nu_ansi_term::Color::{Blue, Cyan, Green, Red, Yellow}; -use soar_config::config::{get_config, is_system_mode}; -use soar_core::{package::remove::PackageRemover, SoarResult}; -use soar_db::repository::core::CoreRepository; -use soar_utils::{error::FileSystemResult, fs::walk_dir, path::icons_dir}; +use soar_core::SoarResult; +use soar_operations::{health, SoarContext}; use tabled::{ builder::Builder, settings::{peaker::PriorityMax, themes::BorderCorrection, Panel, Style, Width}, }; use tracing::info; -use crate::{ - state::AppState, - utils::{get_package_hooks, icon_or, term_width, Colored, Icons}, -}; - -pub async fn display_health() -> SoarResult<()> { - let path_env = env::var("PATH")?; - let bin_path = get_config().get_bin_path()?; - let path_ok = path_env.split(':').any(|p| Path::new(p) == bin_path); +use crate::utils::{icon_or, term_width, Colored, Icons}; - let broken_pkgs = get_broken_packages().await?; - let broken_syms = get_broken_symlinks()?; +pub async fn display_health(ctx: &SoarContext) -> SoarResult<()> { + let report = health::check_health(ctx)?; let mut builder = Builder::new(); - let path_status = if path_ok { + let path_status = if report.path_configured { format!("{} Configured", Colored(Green, icon_or(Icons::CHECK, "OK"))) } else { format!( "{} {} not in PATH", Colored(Yellow, icon_or(Icons::WARNING, "!")), - Colored(Blue, bin_path.display()) + Colored(Blue, report.bin_path.display()) ) }; builder.push_record(["PATH".to_string(), path_status]); - let pkg_status = if broken_pkgs.is_empty() { + let pkg_status = if report.broken_packages.is_empty() { format!("{} None", Colored(Green, icon_or(Icons::CHECK, "OK"))) } else { format!( "{} {} found", Colored(Red, icon_or(Icons::CROSS, "!")), - Colored(Red, broken_pkgs.len()) + Colored(Red, report.broken_packages.len()) ) }; builder.push_record(["Broken Packages".to_string(), pkg_status]); - let sym_status = if broken_syms.is_empty() { + let sym_status = if report.broken_symlinks.is_empty() { format!("{} None", Colored(Green, icon_or(Icons::CHECK, "OK"))) } else { format!( "{} {} found", Colored(Red, icon_or(Icons::CROSS, "!")), - Colored(Red, broken_syms.len()) + Colored(Red, report.broken_symlinks.len()) ) }; builder.push_record(["Broken Symlinks".to_string(), sym_status]); @@ -69,23 +57,23 @@ pub async fn display_health() -> SoarResult<()> { info!("\n{table}"); - if !broken_pkgs.is_empty() { + if !report.broken_packages.is_empty() { info!("\nBroken packages:"); - for pkg in &broken_pkgs { + for pkg in &report.broken_packages { info!( " {} {}#{}: {}", Icons::ARROW, - Colored(Blue, &pkg.0), - Colored(Cyan, &pkg.1), - Colored(Yellow, &pkg.2) + Colored(Blue, &pkg.pkg_name), + Colored(Cyan, &pkg.pkg_id), + Colored(Yellow, &pkg.installed_path) ); } info!("Run {} to remove", Colored(Green, "soar clean --broken")); } - if !broken_syms.is_empty() { + if !report.broken_symlinks.is_empty() { info!("\nBroken symlinks:"); - for path in &broken_syms { + for path in &report.broken_symlinks { info!(" {} {}", Icons::ARROW, Colored(Yellow, path.display())); } info!( @@ -97,73 +85,30 @@ pub async fn display_health() -> SoarResult<()> { Ok(()) } -async fn get_broken_packages() -> SoarResult> { - let state = AppState::new(); - let diesel_db = state.diesel_core_db()?; - - let broken_packages = diesel_db.with_conn(CoreRepository::list_broken)?; - - Ok(broken_packages - .into_iter() - .map(|p| (p.pkg_name, p.pkg_id, p.installed_path)) - .collect()) -} - -fn get_broken_symlinks() -> SoarResult> { - let broken_symlinks = Rc::new(RefCell::new(Vec::new())); - - let broken_symlinks_clone = Rc::clone(&broken_symlinks); - let mut collect_action = |path: &Path| -> FileSystemResult<()> { - if !path.exists() { - broken_symlinks_clone.borrow_mut().push(path.to_path_buf()); - } - Ok(()) - }; - - let mut soar_files_action = |path: &Path| -> FileSystemResult<()> { - if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) { - if filename.ends_with("-soar") && !path.exists() { - broken_symlinks_clone.borrow_mut().push(path.to_path_buf()); - } - } - Ok(()) - }; - - walk_dir(&get_config().get_bin_path()?, &mut collect_action)?; - walk_dir(&get_config().get_desktop_path()?, &mut soar_files_action)?; - walk_dir(icons_dir(is_system_mode()), &mut soar_files_action)?; - - Ok(Rc::try_unwrap(broken_symlinks) - .unwrap_or_else(|rc| rc.borrow().clone().into()) - .into_inner()) -} - -pub async fn remove_broken_packages() -> SoarResult<()> { - let state = AppState::new(); - let diesel_db = state.diesel_core_db()?.clone(); +pub async fn remove_broken_packages(ctx: &SoarContext) -> SoarResult<()> { + let report = health::remove_broken_packages(ctx).await?; - let broken_packages = diesel_db.with_conn(CoreRepository::list_broken)?; - - if broken_packages.is_empty() { + if report.removed.is_empty() && report.failed.is_empty() { info!("No broken packages found."); return Ok(()); } - for package in broken_packages { - let pkg_name = package.pkg_name.clone(); - let pkg_id = package.pkg_id.clone(); - let (hooks, sandbox) = get_package_hooks(&pkg_name); - let installed_pkg = package.into(); - let remover = PackageRemover::new(installed_pkg, diesel_db.clone()) - .await - .with_hooks(hooks) - .with_sandbox(sandbox); - remover.remove().await?; - - info!("Removed {}#{}", pkg_name, pkg_id); + for removed in &report.removed { + info!("Removed {}#{}", removed.pkg_name, removed.pkg_id); } - info!("Removed all broken packages"); + for failed in &report.failed { + tracing::error!( + "Failed to remove {}#{}: {}", + failed.pkg_name, + failed.pkg_id, + failed.error + ); + } + + if !report.removed.is_empty() { + info!("Removed all broken packages"); + } Ok(()) } diff --git a/crates/soar-cli/src/inspect.rs b/crates/soar-cli/src/inspect.rs index 251d656f..3aa02047 100644 --- a/crates/soar-cli/src/inspect.rs +++ b/crates/soar-cli/src/inspect.rs @@ -1,6 +1,5 @@ use std::{fmt::Display, fs, path::PathBuf}; -use indicatif::HumanBytes; use soar_core::{ database::{connection::DieselDatabase, models::Package}, error::ErrorContext, @@ -12,12 +11,12 @@ use soar_db::repository::{ metadata::MetadataRepository, }; use soar_dl::http_client::SHARED_AGENT; +use soar_utils::bytes::format_bytes; use tracing::{error, info}; use ureq::http::header::CONTENT_LENGTH; use crate::{ - progress::create_spinner, - state::AppState, + progress::create_spinner_job, utils::{display_settings, interactive_ask, select_package_interactively}, }; @@ -58,9 +57,9 @@ fn get_installed_path( } pub async fn inspect_log(package: &str, inspect_type: InspectType) -> SoarResult<()> { - let state = AppState::new(); - let metadata_mgr = state.metadata_manager().await?; - let diesel_db = state.diesel_core_db()?; + let (ctx, _) = crate::create_context(); + let metadata_mgr = ctx.metadata_manager().await?; + let diesel_db = ctx.diesel_core_db()?; let query = PackageQuery::try_from(package)?; @@ -135,10 +134,11 @@ pub async fn inspect_log(package: &str, inspect_type: InspectType) -> SoarResult info!( "Reading build {inspect_type} from {} [{}]", file.display(), - HumanBytes( + format_bytes( file.metadata() .with_context(|| format!("reading file metadata {}", file.display()))? - .len() + .len(), + 2 ) ); let output = fs::read_to_string(&file) @@ -173,8 +173,9 @@ pub async fn inspect_log(package: &str, inspect_type: InspectType) -> SoarResult let settings = display_settings(); let spinner = if settings.spinners() { - let s = create_spinner(&format!("Fetching build {inspect_type}...")); - Some(s) + Some(create_spinner_job(&format!( + "Fetching build {inspect_type}..." + ))) } else { None }; @@ -213,7 +214,7 @@ pub async fn inspect_log(package: &str, inspect_type: InspectType) -> SoarResult info!( "Fetching build {inspect_type} from {} [{}]", url, - HumanBytes(content_length) + format_bytes(content_length, 2) ); let content = resp.into_body().read_to_vec()?; diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index 049e2aae..14c9ff67 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -1,140 +1,20 @@ -use std::{ - collections::HashMap, - fs::{self, File}, - io::{BufReader, Read}, - path::PathBuf, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, Mutex, - }, - time::Duration, -}; - -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use minisign_verify::{PublicKey, Signature}; use nu_ansi_term::Color::{Blue, Cyan, Green, Magenta, Red, Yellow}; -use soar_config::{config::get_config, utils::default_install_patterns}; -use soar_core::{ - database::{connection::DieselDatabase, models::Package}, - error::{ErrorContext, SoarError}, - package::{ - install::{InstallMarker, InstallTarget, PackageInstaller}, - query::PackageQuery, - update::remove_old_versions, - url::UrlPackage, - }, - SoarResult, -}; -use soar_db::repository::{ - core::{CoreRepository, InstalledPackageWithPortable, SortDirection}, - metadata::MetadataRepository, -}; -use soar_dl::types::Progress; -use soar_package::integrate_package; -use soar_utils::{ - hash::{calculate_checksum, hash_string}, - lock::FileLock, - pattern::apply_sig_variants, -}; +use soar_core::{package::install::InstallTarget, SoarResult}; +use soar_operations::{install, InstallOptions, InstallReport, ResolveResult, SoarContext}; use tabled::{ builder::Builder, settings::{themes::BorderCorrection, Panel, Style}, }; -use tokio::sync::Semaphore; -use tracing::{debug, error, info, trace, warn}; +use tracing::{debug, error, info, warn}; -use crate::{ - logging::{clear_multi_progress, set_multi_progress}, - progress::handle_install_progress, - state::AppState, - utils::{ - ask_target_action, confirm_action, display_settings, has_desktop_integration, icon_or, - mangle_package_symlinks, progress_enabled, select_package_interactively, - select_package_interactively_with_installed, Colored, Icons, - }, +use crate::utils::{ + ask_target_action, display_settings, icon_or, select_package_interactively, + select_package_interactively_with_installed, Colored, Icons, }; -// Represents an installed directory and its contents: -// - The first element is the root installation path. -// - The second element is a list of (file path, symlink target) pairs. -type InstalledPath = (PathBuf, Vec<(PathBuf, PathBuf)>); - -#[derive(Clone)] -pub struct InstallContext { - pub multi_progress: Arc, - pub total_progress_bar: ProgressBar, - pub semaphore: Arc, - pub installed_count: Arc, - pub total_packages: usize, - pub portable: Option, - pub portable_home: Option, - pub portable_config: Option, - pub portable_share: Option, - pub portable_cache: Option, - pub warnings: Arc>>, - pub errors: Arc>>, - pub retrying: Arc, - pub failed: Arc, - pub installed_indices: Arc>>, - pub binary_only: bool, - pub no_verify: bool, -} - -#[allow(clippy::too_many_arguments)] -pub fn create_install_context( - total_packages: usize, - parallel_limit: u32, - portable: Option, - portable_home: Option, - portable_config: Option, - portable_share: Option, - portable_cache: Option, - binary_only: bool, - no_verify: bool, -) -> InstallContext { - let multi_progress = Arc::new(MultiProgress::new()); - let total_progress_bar = multi_progress.add(ProgressBar::new(total_packages as u64)); - - if !progress_enabled() { - multi_progress.set_draw_target(indicatif::ProgressDrawTarget::hidden()); - total_progress_bar.set_draw_target(indicatif::ProgressDrawTarget::hidden()); - } else { - let settings = display_settings(); - let style = if settings.icons() { - ProgressStyle::with_template(&format!( - "{} Installing {{pos}}/{{len}} {{msg}}", - Icons::PACKAGE - )) - .unwrap() - } else { - ProgressStyle::with_template("Installing {pos}/{len} {msg}").unwrap() - }; - total_progress_bar.set_style(style); - } - - InstallContext { - multi_progress, - total_progress_bar, - semaphore: Arc::new(Semaphore::new(parallel_limit as usize)), - installed_count: Arc::new(AtomicU64::new(0)), - total_packages, - portable, - portable_home, - portable_config, - portable_share, - portable_cache, - warnings: Arc::new(Mutex::new(Vec::new())), - errors: Arc::new(Mutex::new(Vec::new())), - retrying: Arc::new(AtomicU64::new(0)), - failed: Arc::new(AtomicU64::new(0)), - installed_indices: Arc::new(Mutex::new(HashMap::new())), - binary_only, - no_verify, - } -} - #[allow(clippy::too_many_arguments)] pub async fn install_packages( + ctx: &SoarContext, packages: &[String], force: bool, yes: bool, @@ -158,23 +38,74 @@ pub async fn install_packages( force = force, "starting package installation" ); - let state = AppState::new(); - let metadata_mgr = state.metadata_manager().await?; - let diesel_db = state.diesel_core_db()?.clone(); - let install_targets = resolve_packages( - &state, - metadata_mgr, - &diesel_db, - packages, - yes, + let options = InstallOptions { force, - name_override.as_deref(), - version_override.as_deref(), - pkg_type_override.as_deref(), - pkg_id_override.as_deref(), - show, - )?; + portable: portable.clone(), + portable_home: portable_home.clone(), + portable_config: portable_config.clone(), + portable_share: portable_share.clone(), + portable_cache: portable_cache.clone(), + binary_only, + no_verify, + name_override, + version_override, + pkg_type_override, + pkg_id_override, + }; + + // If --show flag is used, handle interactive selection before resolving + if show { + return install_with_show(ctx, packages, &options, yes, force, ask, no_notes).await; + } + + let results = install::resolve_packages(ctx, packages, &options).await?; + + let mut install_targets = Vec::new(); + for result in results { + match result { + ResolveResult::Resolved(targets) => { + install_targets.extend(targets); + } + ResolveResult::Ambiguous(amb) => { + let pkg = if yes { + amb.candidates.into_iter().next() + } else { + select_package_interactively(amb.candidates, &amb.query)? + }; + + if let Some(pkg) = pkg { + // Re-resolve with the specific selected package + let specific_query = + format!("{}#{}:{}", pkg.pkg_name, pkg.pkg_id, pkg.repo_name); + let re_results = + install::resolve_packages(ctx, &[specific_query], &options).await?; + for r in re_results { + if let ResolveResult::Resolved(targets) = r { + install_targets.extend(targets); + } + } + } + } + ResolveResult::NotFound(name) => { + error!("Package {} not found", name); + } + ResolveResult::AlreadyInstalled { + pkg_name, + pkg_id, + repo_name, + version, + } => { + warn!( + "{}#{}:{} ({}) is already installed - skipping", + pkg_name, pkg_id, repo_name, version, + ); + if !force { + info!("Hint: Use --force to reinstall, or --show to see other variants"); + } + } + } + } if install_targets.is_empty() { info!("No packages to install"); @@ -187,736 +118,214 @@ pub async fn install_packages( ask_target_action(&install_targets, "install")?; } - let install_context = create_install_context( - install_targets.len(), - state.config().parallel_limit.unwrap_or(4), - portable, - portable_home, - portable_config, - portable_share, - portable_cache, - binary_only, - no_verify, - ); + let report = install::perform_installation(ctx, install_targets, &options).await?; + display_install_report(&report, no_notes); - perform_installation(install_context, install_targets, diesel_db, no_notes).await + Ok(()) } -#[allow(clippy::too_many_arguments)] -fn resolve_packages( - state: &AppState, - metadata_mgr: &soar_core::database::connection::MetadataManager, - diesel_db: &DieselDatabase, +async fn install_with_show( + ctx: &SoarContext, packages: &[String], - yes: bool, + options: &InstallOptions, + _yes: bool, force: bool, - name_override: Option<&str>, - version_override: Option<&str>, - pkg_type_override: Option<&str>, - pkg_id_override: Option<&str>, - show: bool, -) -> SoarResult> { - use soar_core::database::models::InstalledPackage; + ask: bool, + no_notes: bool, +) -> SoarResult<()> { + use soar_core::{database::models::Package, package::query::PackageQuery}; + use soar_db::repository::{ + core::{CoreRepository, SortDirection}, + metadata::MetadataRepository, + }; + let metadata_mgr = ctx.metadata_manager().await?; + let diesel_db = ctx.diesel_core_db()?; let mut install_targets = Vec::new(); for package in packages { - if UrlPackage::is_remote(package) { - let url_pkg = UrlPackage::from_remote( - package, - name_override, - version_override, - pkg_type_override, - pkg_id_override, - )?; - - // Check if already installed in core DB (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_pkg = installed_packages.iter().find(|ip| ip.is_installed); + let query = PackageQuery::try_from(package.as_str())?; - if let Some(installed) = installed_pkg { - if !force { - warn!( - "{}#{}:{} ({}) is already installed - skipping", - installed.pkg_name, - installed.pkg_id, - installed.repo_name, - installed.version, - ); - continue; + // --show requires a name and no pkg_id + if query.pkg_id.is_some() || query.name.is_none() { + // Fall through to normal resolve for non-show cases + let results = + install::resolve_packages(ctx, std::slice::from_ref(package), options).await?; + for result in results { + if let ResolveResult::Resolved(targets) = result { + install_targets.extend(targets); } } - - let existing_install = installed_pkg - .cloned() - .or_else(|| installed_packages.into_iter().next()); - - install_targets.push(InstallTarget { - package: url_pkg.to_package(), - existing_install, - pinned: false, - profile: None, - ..Default::default() - }); continue; } - let query = PackageQuery::try_from(package.as_str())?; - - if let (true, None, Some(ref name)) = (show, &query.pkg_id, &query.name) { - let repo_pkgs: Vec = if let Some(ref repo_name) = query.repo_name { - metadata_mgr - .query_repo(repo_name, |conn| { - MetadataRepository::find_filtered( - conn, - query.name.as_deref(), - None, - None, - None, - Some(SortDirection::Asc), - ) - })? - .unwrap_or_default() - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.clone(); - pkg - }) - .collect() - } else { - metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::find_filtered( - conn, - query.name.as_deref(), - None, - None, - None, - Some(SortDirection::Asc), - )?; - Ok(pkgs - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.to_string(); - pkg - }) - .collect()) - })? - }; - - let repo_pkgs: Vec = if let Some(ref version) = query.version { - repo_pkgs - .into_iter() - .filter(|p| p.has_version(version)) - .collect() - } else { - repo_pkgs - }; - - if repo_pkgs.is_empty() { - error!("Package {} not found", name); - continue; - } - - // Get installed packages to show [installed] marker - let installed_packages: Vec<(String, String, String)> = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( + let repo_pkgs: Vec = if let Some(ref repo_name) = query.repo_name { + metadata_mgr + .query_repo(repo_name, |conn| { + MetadataRepository::find_filtered( conn, - query.repo_name.as_deref(), query.name.as_deref(), None, None, - Some(true), - None, - None, - None, - ) - })? - .into_iter() - .map(|p| (p.pkg_id, p.repo_name, p.version)) - .collect(); - - // Always show interactive selection when --show is used - let pkg = select_package_interactively_with_installed( - repo_pkgs, - &query.name.clone().unwrap_or(package.clone()), - &installed_packages, - )?; - - let Some(pkg) = pkg else { - continue; - }; - - // Check if this specific package is already installed - let existing_install = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - Some(&pkg.repo_name), - Some(&pkg.pkg_name), - Some(&pkg.pkg_id), - None, - None, - None, None, Some(SortDirection::Asc), ) })? + .unwrap_or_default() .into_iter() - .map(Into::into) - .next(); - - if let Some(ref existing) = existing_install { - let existing: &InstalledPackage = existing; - if existing.is_installed { - warn!( - "{}#{}:{} ({}) is already installed - {}", - existing.pkg_name, - existing.pkg_id, - existing.repo_name, - existing.version, - if force { "reinstalling" } else { "skipping" } - ); - if !force { - info!("Hint: Use --force to reinstall, or --show to see other variants"); - continue; - } - } - } - - let pkg = pkg.resolve(query.version.as_deref()); - - install_targets.push(InstallTarget { - package: pkg, - existing_install, - pinned: query.version.is_some(), - profile: None, - ..Default::default() - }); - continue; - } - - if let Some(ref pkg_id) = query.pkg_id { - if pkg_id == "all" { - // Find all variants of this package - let variants: Vec = if let Some(ref repo_name) = query.repo_name { - metadata_mgr - .query_repo(repo_name, |conn| { - MetadataRepository::find_filtered( - conn, - query.name.as_deref(), - None, - None, - None, - Some(SortDirection::Asc), - ) - })? - .unwrap_or_default() - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.clone(); - pkg - }) - .collect() - } else { - metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::find_filtered( - conn, - query.name.as_deref(), - None, - None, - None, - Some(SortDirection::Asc), - )?; - Ok(pkgs - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.to_string(); - pkg - }) - .collect()) - })? - }; - - if variants.is_empty() { - error!("Package {} not found", query.name.as_ref().unwrap()); - continue; - } - - let selected_pkg = if variants.len() > 1 { - if yes { - variants.into_iter().next().unwrap() - } else { - select_package_interactively(variants, query.name.as_ref().unwrap())? - .unwrap() - } - } else { - variants.into_iter().next().unwrap() - }; - - let target_pkg_id = selected_pkg.pkg_id.clone(); - - // Find all packages with this pkg_id - let all_pkgs: Vec = if let Some(ref repo_name) = query.repo_name { - metadata_mgr - .query_repo(repo_name, |conn| { - MetadataRepository::find_filtered( - conn, - None, - Some(&target_pkg_id), - None, - None, - Some(SortDirection::Asc), - ) - })? - .unwrap_or_default() - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.clone(); - pkg - }) - .collect() - } else { - metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::find_filtered( - conn, - None, - Some(&target_pkg_id), - None, - None, - Some(SortDirection::Asc), - )?; - Ok(pkgs - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.to_string(); - pkg - }) - .collect()) - })? - }; - - // Get installed packages for this pkg_id - let installed_packages: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - query.repo_name.as_deref(), - None, - Some(&target_pkg_id), - None, - None, - None, - None, - Some(SortDirection::Asc), - ) - })? - .into_iter() - .map(Into::into) - .collect(); - - // Show confirmation for bulk install - if all_pkgs.len() > 1 && !yes { - use nu_ansi_term::Color::{Blue, Cyan, Green}; - info!( - "The following {} packages will be installed:", - Colored(Cyan, all_pkgs.len()) - ); - for pkg in &all_pkgs { - info!( - " - {}#{}:{}", - Colored(Blue, &pkg.pkg_name), - Colored(Cyan, &pkg.pkg_id), - Colored(Green, &pkg.repo_name) - ); - } - if !confirm_action("Proceed with installation?")? { - info!("Installation cancelled"); - continue; - } - } - - for pkg in all_pkgs { - let existing_install = installed_packages - .iter() - .find(|ip| ip.pkg_name == pkg.pkg_name) - .cloned(); - - if let Some(ref existing) = existing_install { - if existing.is_installed { - warn!( - "{}#{}:{} ({}) is already installed - {}", - existing.pkg_name, - existing.pkg_id, - existing.repo_name, - existing.version, - if force { "reinstalling" } else { "skipping" } - ); - if !force { - continue; - } - } - } - - let pkg = pkg.resolve(query.version.as_deref()); - - install_targets.push(InstallTarget { - package: pkg, - existing_install, - pinned: query.version.is_some(), - profile: None, - ..Default::default() - }); - } - continue; - } - } - - let installed_packages: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.clone(); + pkg + }) + .collect() + } else { + metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = MetadataRepository::find_filtered( conn, - query.repo_name.as_deref(), query.name.as_deref(), - query.pkg_id.as_deref(), - None, None, None, None, Some(SortDirection::Asc), - ) - })? - .into_iter() - .map(Into::into) - .collect(); - - if query.name.is_none() && query.pkg_id.is_some() { - let repo_pkgs: Vec = if let Some(ref repo_name) = query.repo_name { - metadata_mgr - .query_repo(repo_name, |conn| { - MetadataRepository::find_filtered( - conn, - None, - query.pkg_id.as_deref(), - None, - None, - None, - ) - })? - .unwrap_or_default() + )?; + Ok(pkgs .into_iter() .map(|p| { let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.clone(); + pkg.repo_name = repo_name.to_string(); pkg }) - .collect() - } else { - metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::find_filtered( - conn, - None, - query.pkg_id.as_deref(), - None, - None, - None, - )?; - Ok(pkgs - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.to_string(); - pkg - }) - .collect()) - })? - }; - - let repo_pkgs: Vec = if let Some(ref version) = query.version { - repo_pkgs - .into_iter() - .filter(|p| p.has_version(version)) - .collect() - } else { - repo_pkgs - }; - - for pkg in repo_pkgs { - let pkg = pkg.resolve(query.version.as_deref()); - - let existing_install = installed_packages - .iter() - .find(|ip| ip.pkg_name == pkg.pkg_name) - .cloned(); - if let Some(ref existing) = existing_install { - if existing.is_installed { - warn!( - "{}#{}:{} ({}) is already installed - {}", - existing.pkg_name, - existing.pkg_id, - existing.repo_name, - existing.version, - if force { "reinstalling" } else { "skipping" } - ); - if !force { - continue; - } - } - } + .collect()) + })? + }; - install_targets.push(InstallTarget { - package: pkg, - existing_install, - pinned: query.version.is_some(), - profile: None, - ..Default::default() - }); - } + let repo_pkgs: Vec = if let Some(ref version) = query.version { + repo_pkgs + .into_iter() + .filter(|p| p.has_version(version)) + .collect() } else { - let maybe_existing = if installed_packages.is_empty() { - None - } else { - Some(installed_packages.first().unwrap().clone()) - }; - - if let Some(db_pkg) = - select_package(state, metadata_mgr, package, &query, yes, &maybe_existing)? - { - let installed_pkg = installed_packages.iter().find(|ip| ip.is_installed); - - if let Some(installed) = installed_pkg { - warn!( - "{}#{}:{} ({}) is already installed - {}", - installed.pkg_name, - installed.pkg_id, - installed.repo_name, - installed.version, - if force { "reinstalling" } else { "skipping" } - ); - if !force { - info!("Hint: Use --force to reinstall, or --show to see other variants"); - continue; - } - } - - let existing_install = installed_packages - .iter() - .find(|ip| ip.version == db_pkg.version) - .cloned(); - - let db_pkg = db_pkg.resolve(query.version.as_deref()); + repo_pkgs + }; - install_targets.push(InstallTarget { - package: db_pkg, - existing_install, - pinned: query.version.is_some(), - profile: None, - ..Default::default() - }); - } + if repo_pkgs.is_empty() { + error!("Package {} not found", query.name.as_ref().unwrap()); + continue; } - } - - Ok(install_targets) -} -fn select_package( - _state: &AppState, - metadata_mgr: &soar_core::database::connection::MetadataManager, - package_name: &str, - query: &PackageQuery, - yes: bool, - existing_install: &Option, -) -> SoarResult> { - // If we have an existing install, try to find it in its original repo first - let packages: Vec = if let Some(existing) = existing_install { - let existing_pkgs: Vec = metadata_mgr - .query_repo(&existing.repo_name, |conn| { - MetadataRepository::find_filtered( + // Get installed packages to show [installed] marker + let installed_packages: Vec<(String, String, String)> = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( conn, - Some(&existing.pkg_name), - Some(&existing.pkg_id), + query.repo_name.as_deref(), + query.name.as_deref(), + None, + None, + Some(true), None, None, None, ) })? - .unwrap_or_default() .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = existing.repo_name.clone(); - pkg - }) + .map(|p| (p.pkg_id, p.repo_name, p.version)) .collect(); - // If package not found in original repo (repo removed or package removed), - // fall back to searching all repos by package name - if existing_pkgs.is_empty() { - metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::find_filtered( + let pkg = select_package_interactively_with_installed( + repo_pkgs, + &query.name.clone().unwrap_or(package.clone()), + &installed_packages, + )?; + + let Some(pkg) = pkg else { + continue; + }; + + // Check if this specific package is already installed + let existing_install: Option = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( conn, - query.name.as_deref(), - query.pkg_id.as_deref(), - None, + Some(&pkg.repo_name), + Some(&pkg.pkg_name), + Some(&pkg.pkg_id), None, None, - )?; - Ok(pkgs - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.to_string(); - pkg - }) - .collect()) - })? - } else { - existing_pkgs - } - } else if let Some(ref repo_name) = query.repo_name { - metadata_mgr - .query_repo(repo_name, |conn| { - MetadataRepository::find_filtered( - conn, - query.name.as_deref(), - query.pkg_id.as_deref(), - None, None, None, + Some(SortDirection::Asc), ) })? - .unwrap_or_default() - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.clone(); - pkg - }) - .collect() - } else { - metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::find_filtered( - conn, - query.name.as_deref(), - query.pkg_id.as_deref(), - None, - None, - None, - )?; - Ok(pkgs - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.to_string(); - pkg - }) - .collect()) - })? - }; - - let packages: Vec = if let Some(ref version) = query.version { - packages .into_iter() - .filter(|p| p.has_version(version)) - .collect() - } else { - packages - }; - - match packages.len() { - 0 => { - error!("Package {package_name} not found"); - Ok(None) + .map(Into::into) + .next(); + + if let Some(ref existing) = existing_install { + if existing.is_installed { + warn!( + "{}#{}:{} ({}) is already installed - {}", + existing.pkg_name, + existing.pkg_id, + existing.repo_name, + existing.version, + if force { "reinstalling" } else { "skipping" } + ); + if !force { + info!("Hint: Use --force to reinstall, or --show to see other variants"); + continue; + } + } } - 1 => Ok(packages.into_iter().next()), - _ if yes => Ok(packages.into_iter().next()), - _ => select_package_interactively(packages, package_name), - } -} -pub async fn perform_installation( - ctx: InstallContext, - targets: Vec, - core_db: DieselDatabase, - no_notes: bool, -) -> SoarResult<()> { - // Set the multi-progress for log suspension during progress bar updates - set_multi_progress(&ctx.multi_progress); - - let mut handles = Vec::new(); - let fixed_width = 40; + let pkg = pkg.resolve(query.version.as_deref()); - for (idx, target) in targets.iter().enumerate() { - let handle = - spawn_installation_task(&ctx, target.clone(), core_db.clone(), idx, fixed_width).await; - handles.push(handle); + install_targets.push(InstallTarget { + package: pkg, + existing_install, + pinned: query.version.is_some(), + profile: None, + ..Default::default() + }); } - for handle in handles { - handle - .await - .map_err(|err| SoarError::Custom(format!("Join handle error: {err}")))?; + if install_targets.is_empty() { + info!("No packages to install"); + return Ok(()); } - ctx.total_progress_bar.finish_and_clear(); - - // Clear the multi-progress reference now that progress bars are done - clear_multi_progress(); - - for warn in ctx.warnings.lock().unwrap().iter() { - warn!("{warn}"); + if ask { + ask_target_action(&install_targets, "install")?; } - for error in ctx.errors.lock().unwrap().iter() { - error!("{error}"); - } + let report = install::perform_installation(ctx, install_targets, options).await?; + display_install_report(&report, no_notes); - let installed_indices = ctx.installed_indices.lock().unwrap(); + Ok(()) +} + +fn display_install_report(report: &InstallReport, no_notes: bool) { let settings = display_settings(); let use_icons = settings.icons(); - for (idx, target) in targets.into_iter().enumerate() { - let pkg = target.package; - let Some((install_dir, symlinks)) = installed_indices.get(&idx) else { - continue; - }; + for warn_msg in &report.warnings { + warn!("{warn_msg}"); + } + for info in &report.installed { info!( "\n{} {}#{}:{} [{}]", icon_or(Icons::CHECK, "*"), - Colored(Blue, &pkg.pkg_name), - Colored(Cyan, &pkg.pkg_id), - Colored(Green, &pkg.repo_name), - Colored(Magenta, install_dir.display()) + Colored(Blue, &info.pkg_name), + Colored(Cyan, &info.pkg_id), + Colored(Green, &info.repo_name), + Colored(Magenta, info.install_dir.display()) ); - if !symlinks.is_empty() { + if !info.symlinks.is_empty() { info!(" {} Binaries:", icon_or("📂", "-")); - for (target, link) in symlinks { + for (target, link) in &info.symlinks { info!( " {} {} {} {}", icon_or(Icons::ARROW, "->"), @@ -928,7 +337,7 @@ pub async fn perform_installation( } if !no_notes { - if let Some(notes) = pkg.notes { + if let Some(ref notes) = info.notes { info!( " {} Notes:\n {}", icon_or("📝", "-"), @@ -938,8 +347,16 @@ pub async fn perform_installation( } } - let installed_count = ctx.installed_count.load(Ordering::Relaxed); - let failed_count = ctx.failed.load(Ordering::Relaxed); + for err_info in &report.failed { + error!( + "Failed to install {}#{}: {}", + err_info.pkg_name, err_info.pkg_id, err_info.error + ); + } + + let installed_count = report.installed.len(); + let failed_count = report.failed.len(); + let total_packages = installed_count + failed_count; if use_icons { let mut builder = Builder::new(); @@ -950,7 +367,7 @@ pub async fn perform_installation( format!( "{}/{}", Colored(Green, installed_count), - Colored(Cyan, ctx.total_packages) + Colored(Cyan, total_packages) ), ]); } @@ -979,7 +396,7 @@ pub async fn perform_installation( info!( "Installed {}/{} packages{}", installed_count, - ctx.total_packages, + total_packages, if failed_count > 0 { format!(", {} failed", failed_count) } else { @@ -989,476 +406,4 @@ pub async fn perform_installation( } else { info!("No packages installed."); } - - Ok(()) -} - -async fn spawn_installation_task( - ctx: &InstallContext, - target: InstallTarget, - core_db: DieselDatabase, - idx: usize, - fixed_width: usize, -) -> tokio::task::JoinHandle<()> { - let permit = ctx.semaphore.clone().acquire_owned().await.unwrap(); - let progress_bar = Arc::new(Mutex::new(None)); - - // Pre-compute the prefix string to avoid cloning the entire Package struct - let prefix = { - let prefix = format!( - "[{}/{}] {}#{}", - idx + 1, - ctx.total_packages, - target.package.pkg_name, - target.package.pkg_id - ); - if prefix.len() > fixed_width { - format!("{prefix:.fixed_width$}") - } else { - format!("{prefix: { - // Only count as installed if actually installed (not skipped) - if !install_dir.as_os_str().is_empty() { - installed_indices - .lock() - .unwrap() - .insert(idx, (install_dir, symlinks)); - installed_count.fetch_add(1, Ordering::Relaxed); - } - total_pb.inc(1); - - let _ = remove_old_versions(&target.package, &core_db, false); - } - Err(err) => { - match err { - SoarError::Warning(err) => { - let mut warnings = ctx.warnings.lock().unwrap(); - warnings.push(err); - - let _ = remove_old_versions(&target.package, &core_db, false); - } - _ => { - let mut errors = ctx.errors.lock().unwrap(); - errors.push(err.to_string()); - } - } - } - } - - drop(permit); - }) -} - -pub async fn install_single_package( - ctx: &InstallContext, - target: &InstallTarget, - progress_callback: Arc, - core_db: DieselDatabase, -) -> SoarResult<(PathBuf, Vec<(PathBuf, PathBuf)>)> { - debug!( - pkg_name = target.package.pkg_name, - pkg_id = target.package.pkg_id, - version = target.package.version, - repo = target.package.repo_name, - size = target.package.ghcr_size.or(target.package.size), - "installing {}#{}:{} ({})", - target.package.pkg_name, - target.package.pkg_id, - target.package.repo_name, - target.package.version - ); - - let mut lock_attempts = 0; - let _package_lock = loop { - match FileLock::try_acquire(&target.package.pkg_name) { - Ok(Some(lock)) => break Ok(lock), - Ok(None) => { - lock_attempts += 1; - if lock_attempts == 1 { - info!( - "Waiting for lock on '{}' (another process is installing)...", - target.package.pkg_name - ); - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(err) => { - break Err(err); - } - } - } - .map_err(|e| SoarError::Custom(format!("Failed to acquire package lock: {}", e)))?; - - debug!( - "acquired lock for '{}' after {} attempts", - target.package.pkg_name, lock_attempts - ); - - // Re-check if package is already installed after acquiring lock - let freshly_installed: Option = core_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - Some(&target.package.repo_name), - Some(&target.package.pkg_name), - Some(&target.package.pkg_id), - Some(&target.package.version), - Some(true), - None, - None, - Some(SortDirection::Asc), - ) - })? - .into_iter() - .find(|ip| ip.is_installed); - - if let Some(ref pkg) = freshly_installed { - info!( - "{}#{}:{} ({}) is already installed - skipping", - pkg.pkg_name, pkg.pkg_id, pkg.repo_name, pkg.version - ); - return Ok((PathBuf::new(), Vec::new())); - } - - let bin_dir = get_config().get_bin_path()?; - - let dir_suffix: String = target - .package - .bsum - .as_ref() - .filter(|s| s.len() >= 12) - .map(|s| s[..12].to_string()) - .unwrap_or_else(|| { - let input = format!( - "{}:{}:{}", - target.package.pkg_id, target.package.pkg_name, target.package.version - ); - hash_string(&input)[..12].to_string() - }); - - let install_dir = get_config() - .get_packages_path(target.profile.clone()) - .unwrap() - .join(format!( - "{}-{}-{}", - target.package.pkg_name, target.package.pkg_id, dir_suffix - )); - let real_bin = install_dir.join(&target.package.pkg_name); - - let ( - unlinked, - portable, - portable_home, - portable_config, - portable_share, - portable_cache, - excludes, - ) = if let Some(ref existing) = target.existing_install { - ( - existing.unlinked, - existing.portable_path.as_deref(), - existing.portable_home.as_deref(), - existing.portable_config.as_deref(), - existing.portable_share.as_deref(), - existing.portable_cache.as_deref(), - existing.install_patterns.as_deref(), - ) - } else { - ( - false, - ctx.portable.as_deref(), - ctx.portable_home.as_deref(), - ctx.portable_config.as_deref(), - ctx.portable_share.as_deref(), - ctx.portable_cache.as_deref(), - None, - ) - }; - - let should_cleanup = if let Some(ref existing) = target.existing_install { - if existing.is_installed { - true - } else { - match InstallMarker::read_from_dir(&install_dir) { - Some(marker) => !marker.matches_package(&target.package), - None => true, - } - } - } else { - false - }; - - if should_cleanup && install_dir.exists() { - debug!(path = %install_dir.display(), "cleaning up existing installation directory"); - if let Err(err) = std::fs::remove_dir_all(&install_dir) { - return Err(SoarError::Custom(format!( - "Failed to clean up install directory {}: {}", - install_dir.display(), - err - ))); - } - } - - let install_patterns = excludes.map(|e| e.to_vec()).unwrap_or_else(|| { - if ctx.binary_only { - let mut patterns = default_install_patterns(); - patterns.extend( - ["!*.png", "!*.svg", "!*.desktop", "!LICENSE", "!CHECKSUM"] - .iter() - .map(ToString::to_string), - ); - patterns - } else { - get_config().install_patterns.clone().unwrap_or_default() - } - }); - let install_patterns = apply_sig_variants(install_patterns); - - trace!(install_dir = %install_dir.display(), patterns = ?install_patterns, "creating package installer"); - let installer = PackageInstaller::new( - target, - &install_dir, - Some(progress_callback), - core_db, - install_patterns.to_vec(), - ) - .await?; - - debug!( - pkg_name = target.package.pkg_name, - source = target - .package - .ghcr_pkg - .as_deref() - .unwrap_or(&target.package.download_url), - "downloading {}", - target.package.pkg_name - ); - let downloaded_checksum = installer.download_package().await?; - trace!( - pkg_name = target.package.pkg_name, - checksum = ?downloaded_checksum, - "download complete for {}", - target.package.pkg_name - ); - - if let Some(repository) = get_config().get_repository(&target.package.repo_name) { - if repository.signature_verification() { - debug!( - repo_name = target.package.repo_name, - "performing signature verification" - ); - let repository_path = repository.get_path()?; - let pubkey_file = repository_path.join("minisign.pub"); - if pubkey_file.exists() { - trace!(pubkey = %pubkey_file.display(), "loading public key"); - let pubkey = PublicKey::from_base64( - fs::read_to_string(&pubkey_file) - .with_context(|| { - format!("reading minisign key from {}", pubkey_file.display()) - })? - .trim(), - ) - .map_err(|err| { - SoarError::Custom(format!( - "Failed to load public key from {}: {}", - pubkey_file.display(), - err - )) - })?; - let entries = fs::read_dir(&install_dir).with_context(|| { - format!("reading package directory {}", install_dir.display()) - })?; - for entry in entries { - let path = entry - .with_context(|| { - format!("reading entry from directory {}", install_dir.display()) - })? - .path(); - let is_signature_file = - path.extension().map_or_else(|| false, |ext| ext == "sig"); - let original_file = path.with_extension(""); - if is_signature_file && path.is_file() && original_file.is_file() { - let signature = Signature::from_file(&path).map_err(|err| { - SoarError::Custom(format!( - "Failed to load signature file from {}: {}", - path.display(), - err - )) - })?; - let mut stream_verifier = - pubkey.verify_stream(&signature).map_err(|err| { - SoarError::Custom( - format!("Failed to setup stream verifier: {err}",), - ) - })?; - - let file = File::open(&original_file).with_context(|| { - format!( - "opening file {} for signature verification", - original_file.display() - ) - })?; - let mut buf_reader = BufReader::new(file); - - let mut buffer = [0u8; 8192]; - loop { - match buf_reader.read(&mut buffer).with_context(|| { - format!("reading to buffer from {}", original_file.display()) - })? { - 0 => break, - n => { - stream_verifier.update(&buffer[..n]); - } - } - } - - stream_verifier.finalize().map_err(|_| { - SoarError::Custom(format!( - "Signature verification failed for {}", - original_file.display() - )) - })?; - trace!(file = %original_file.display(), "signature verified"); - - // we can safely remove the signature file - fs::remove_file(&path).with_context(|| { - format!("removing minisign file {}", path.display()) - })?; - } - } - debug!("signature verification completed successfully"); - } else { - ctx.warnings.lock().unwrap().push(format!( - "{}#{} - Signature verification skipped as no pubkey was found.", - target.package.pkg_name, target.package.pkg_id - )) - } - } - } else { - // Clean up .sig files for packages without signature verification - if let Ok(entries) = fs::read_dir(&install_dir) { - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "sig") && path.is_file() { - fs::remove_file(&path).ok(); - } - } - } - } - - if target.package.provides.is_some() { - trace!("calculating final checksum for verification"); - let final_checksum = if target.package.ghcr_pkg.is_some() { - if real_bin.exists() { - Some(calculate_checksum(&real_bin)?) - } else { - None - } - } else { - downloaded_checksum - }; - - if !ctx.no_verify { - match (final_checksum, target.package.bsum.as_ref()) { - (Some(calculated), Some(expected)) if calculated != *expected => { - return Err(SoarError::Custom(format!( - "{}#{} - Invalid checksum, skipped installation.", - target.package.pkg_name, target.package.pkg_id - ))); - } - (Some(_), None) => { - ctx.warnings.lock().unwrap().push(format!( - "{}#{} - Blake3 checksum not found. Skipped checksum validation.", - target.package.pkg_name, target.package.pkg_id - )); - } - (Some(ref calculated), Some(expected)) if calculated == expected => { - trace!("checksum verification passed"); - } - _ => {} - } - } - } - - trace!("creating symlinks for package binaries"); - let symlinks = mangle_package_symlinks( - &install_dir, - &bin_dir, - target.package.provides.as_deref(), - &target.package.pkg_name, - &target.package.version, - target.entrypoint.as_deref(), - target.binaries.as_deref(), - ) - .await?; - debug!(symlink_count = symlinks.len(), "symlinks created"); - - if !unlinked || has_desktop_integration(&target.package) { - trace!("integrating package (desktop files, icons, etc.)"); - let actual_bin = symlinks.first().map(|(src, _)| src.as_path()); - integrate_package( - &install_dir, - &target.package, - actual_bin, - portable, - portable_home, - portable_config, - portable_share, - portable_cache, - ) - .await?; - } - - trace!("recording installation to database"); - installer - .record( - unlinked, - portable, - portable_home, - portable_config, - portable_share, - portable_cache, - ) - .await?; - - installer.run_post_install_hook()?; - - debug!( - pkg_name = target.package.pkg_name, - pkg_id = target.package.pkg_id, - version = target.package.version, - install_dir = %install_dir.display(), - symlinks = symlinks.len(), - "installed {}#{}:{} ({}) to {}", - target.package.pkg_name, - target.package.pkg_id, - target.package.repo_name, - target.package.version, - install_dir.display() - ); - Ok((install_dir, symlinks)) } diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index 8da03c66..ed8181a7 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -1,39 +1,21 @@ -use std::{ - collections::{HashMap, HashSet}, - path::PathBuf, -}; +use std::collections::HashSet; -use indicatif::HumanBytes; use nu_ansi_term::Color::{Blue, Cyan, Green, LightRed, Magenta, Red, Yellow}; -use rayon::iter::{IntoParallelIterator, ParallelIterator}; -use soar_config::config::get_config; -use soar_core::{ - database::models::{InstalledPackage, Package}, - package::query::PackageQuery, - SoarResult, -}; -use soar_db::{ - models::metadata::PackageListing, - repository::{ - core::{CoreRepository, SortDirection}, - metadata::MetadataRepository, - }, -}; -use soar_utils::fs::dir_size; +use soar_core::SoarResult; +use soar_operations::{list, search, SoarContext}; +use soar_utils::bytes::format_bytes; use tabled::{ builder::Builder, settings::{peaker::PriorityMax, themes::BorderCorrection, Panel, Style, Width}, }; -use tracing::{debug, info, trace}; +use tracing::{debug, info}; -use crate::{ - state::AppState, - utils::{ - display_settings, icon_or, pretty_package_size, term_width, vec_string, Colored, Icons, - }, +use crate::utils::{ + display_settings, icon_or, pretty_package_size, term_width, vec_string, Colored, Icons, }; pub async fn search_packages( + ctx: &SoarContext, query: String, case_sensitive: bool, limit: Option, @@ -44,64 +26,25 @@ pub async fn search_packages( limit = ?limit, "searching packages" ); - let state = AppState::new(); - let metadata_mgr = state.metadata_manager().await?; - let diesel_db = state.diesel_core_db()?; - let search_limit = limit.or(get_config().search_limit).unwrap_or(20) as i64; - trace!(search_limit = search_limit, "using search limit"); + let result = search::search_packages(ctx, &query, case_sensitive, limit).await?; - let packages: Vec = metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = if case_sensitive { - MetadataRepository::search_case_sensitive(conn, &query, Some(search_limit))? - } else { - MetadataRepository::search(conn, &query, Some(search_limit))? - }; - Ok(pkgs - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.to_string(); - pkg - }) - .collect()) - })?; - - let installed_pkgs: HashMap<(String, String, String), bool> = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered(conn, None, None, None, None, None, None, None, None) - })? - .into_par_iter() - .map(|pkg| ((pkg.repo_name, pkg.pkg_id, pkg.pkg_name), pkg.is_installed)) - .collect(); - - let total = packages.len(); - let display_count = std::cmp::min(search_limit as usize, total); + let total = result.total_count; + let display_count = result.packages.len(); let mut installed_count = 0; let mut available_count = 0; - for package in packages.into_iter().take(display_count) { - let key = ( - package.repo_name.clone(), - package.pkg_id.clone(), - package.pkg_name.clone(), - ); - let state_icon = match installed_pkgs.get(&key) { - Some(is_installed) => { - if *is_installed { - installed_count += 1; - icon_or(Icons::INSTALLED, "+") - } else { - "?" - } - } - None => { - available_count += 1; - icon_or(Icons::NOT_INSTALLED, "-") - } + for entry in &result.packages { + let state_icon = if entry.installed { + installed_count += 1; + icon_or(Icons::INSTALLED, "+") + } else { + available_count += 1; + icon_or(Icons::NOT_INSTALLED, "-") }; + let package = &entry.package; info!( pkg_name = package.pkg_name, pkg_id = package.pkg_id, @@ -170,73 +113,12 @@ pub async fn search_packages( Ok(()) } -pub async fn query_package(query_str: String) -> SoarResult<()> { +pub async fn query_package(ctx: &SoarContext, query_str: String) -> SoarResult<()> { debug!(query = query_str, "querying package info"); - let state = AppState::new(); - let metadata_mgr = state.metadata_manager().await?; - - let query = PackageQuery::try_from(query_str.as_str())?; - trace!( - name = ?query.name, - pkg_id = ?query.pkg_id, - version = ?query.version, - repo = ?query.repo_name, - "parsed query" - ); - let packages: Vec = if let Some(ref repo_name) = query.repo_name { - metadata_mgr - .query_repo(repo_name, |conn| { - MetadataRepository::find_filtered( - conn, - query.name.as_deref(), - query.pkg_id.as_deref(), - None, - None, - Some(SortDirection::Asc), - ) - })? - .unwrap_or_default() - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.clone(); - pkg - }) - .collect() - } else { - metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::find_filtered( - conn, - query.name.as_deref(), - query.pkg_id.as_deref(), - None, - None, - Some(SortDirection::Asc), - )?; - Ok(pkgs - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.to_string(); - pkg - }) - .collect()) - })? - }; - - let packages: Vec = if let Some(ref version) = query.version { - packages - .into_iter() - .filter(|p| p.has_version(version)) - .collect() - } else { - packages - }; + let packages = search::query_package(ctx, &query_str).await?; for package in packages { - let package = package.resolve(query.version.as_deref()); - let mut builder = Builder::new(); builder.push_record([ @@ -399,92 +281,38 @@ pub async fn query_package(query_str: String) -> SoarResult<()> { Ok(()) } -/// Lightweight struct for listing with repo name attached -struct PackageListingWithRepo { - repo_name: String, - pkg: PackageListing, -} - -pub async fn list_packages(repo_name: Option) -> SoarResult<()> { +pub async fn list_packages(ctx: &SoarContext, repo_name: Option) -> SoarResult<()> { debug!(repo = ?repo_name, "listing packages"); - let state = AppState::new(); - let metadata_mgr = state.metadata_manager().await?; - let diesel_db = state.diesel_core_db()?; - - let packages: Vec = if let Some(ref repo_name) = repo_name { - metadata_mgr - .query_repo(repo_name, MetadataRepository::list_all_minimal)? - .unwrap_or_default() - .into_iter() - .map(|pkg| { - PackageListingWithRepo { - repo_name: repo_name.clone(), - pkg, - } - }) - .collect() - } else { - metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::list_all_minimal(conn)?; - Ok(pkgs - .into_iter() - .map(|pkg| { - PackageListingWithRepo { - repo_name: repo_name.to_string(), - pkg, - } - }) - .collect()) - })? - }; - - let installed_pkgs: HashMap<(String, String, String), bool> = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered(conn, None, None, None, None, None, None, None, None) - })? - .into_par_iter() - .map(|pkg| ((pkg.repo_name, pkg.pkg_id, pkg.pkg_name), pkg.is_installed)) - .collect(); - - let total = packages.len(); + + let result = list::list_packages(ctx, repo_name.as_deref()).await?; + + let total = result.total; let mut installed_count = 0; let mut available_count = 0; - for entry in &packages { - let key = ( - entry.repo_name.clone(), - entry.pkg.pkg_id.clone(), - entry.pkg.pkg_name.clone(), - ); - let state_icon = match installed_pkgs.get(&key) { - Some(is_installed) => { - if *is_installed { - installed_count += 1; - icon_or(Icons::INSTALLED, "+") - } else { - "?" - } - } - None => { - available_count += 1; - icon_or(Icons::NOT_INSTALLED, "-") - } + for entry in &result.packages { + let state_icon = if entry.installed { + installed_count += 1; + icon_or(Icons::INSTALLED, "+") + } else { + available_count += 1; + icon_or(Icons::NOT_INSTALLED, "-") }; + let package = &entry.package; info!( - pkg_name = entry.pkg.pkg_name, - pkg_id = entry.pkg.pkg_id, - repo_name = entry.repo_name, - pkg_type = entry.pkg.pkg_type, - version = entry.pkg.version, + pkg_name = package.pkg_name, + pkg_id = package.pkg_id, + repo_name = package.repo_name, + pkg_type = package.pkg_type, + version = package.version, "[{}] {}#{}:{} | {} | {}", state_icon, - Colored(Blue, &entry.pkg.pkg_name), - Colored(Cyan, &entry.pkg.pkg_id), - Colored(Cyan, &entry.repo_name), - Colored(LightRed, &entry.pkg.version), - entry - .pkg + Colored(Blue, &package.pkg_name), + Colored(Cyan, &package.pkg_id), + Colored(Cyan, &package.repo_name), + Colored(LightRed, &package.version), + package .pkg_type .as_ref() .map(|pkg_type| format!("{}", Colored(Magenta, &pkg_type))) @@ -526,53 +354,33 @@ pub async fn list_packages(repo_name: Option) -> SoarResult<()> { Ok(()) } -pub async fn list_installed_packages(repo_name: Option, count: bool) -> SoarResult<()> { +pub async fn list_installed_packages( + ctx: &SoarContext, + repo_name: Option, + count: bool, +) -> SoarResult<()> { debug!(repo = ?repo_name, count_only = count, "listing installed packages"); - let state = AppState::new(); - let diesel_db = state.diesel_core_db()?; if count { - let count = diesel_db.with_conn(|conn| { - CoreRepository::count_distinct_installed(conn, repo_name.as_deref()) - })?; + let count = list::count_installed(ctx, repo_name.as_deref())?; info!("{}", count); return Ok(()); } - // Get installed packages - let packages: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - repo_name.as_deref(), - None, - None, - None, - None, - None, - None, - None, - ) - })? - .into_iter() - .map(Into::into) - .collect(); - trace!(count = packages.len(), "fetched installed packages"); + let result = list::list_installed(ctx, repo_name.as_deref())?; let mut unique_pkgs = HashSet::new(); let settings = display_settings(); let use_icons = settings.icons(); let (installed_count, unique_count, broken_count, installed_size, broken_size) = - packages.iter().fold( - (0, 0, 0, 0, 0), - |(installed_count, unique_count, broken_count, installed_size, broken_size), - package| { - let installed_path = PathBuf::from(&package.installed_path); - let size = dir_size(&installed_path).unwrap_or(0); - let is_installed = package.is_installed && installed_path.exists(); - - let status = if is_installed { + result.packages.iter().fold( + (0, 0, 0, 0u64, 0u64), + |(installed_count, unique_count, broken_count, installed_size, broken_size), entry| { + let package = &entry.package; + let size = entry.disk_size; + + let status = if entry.is_healthy { String::new() } else if use_icons { format!( @@ -595,11 +403,11 @@ pub async fn list_installed_packages(repo_name: Option, count: bool) -> Colored(Magenta, &package.version), Colored(Cyan, &package.repo_name), Colored(Green, &package.installed_date.clone()), - HumanBytes(size), + format_bytes(size, 2), status, ); - if is_installed { + if entry.is_healthy { let unique_count = unique_pkgs .insert(format!("{}-{}", package.pkg_id, package.pkg_name)) as u32 @@ -636,7 +444,7 @@ pub async fn list_installed_packages(repo_name: Option, count: bool) -> } else { String::new() }, - Colored(Magenta, HumanBytes(installed_size)) + Colored(Magenta, format_bytes(installed_size, 2)) ), ]); @@ -646,7 +454,7 @@ pub async fn list_installed_packages(repo_name: Option, count: bool) -> format!( "{} ({})", Colored(Red, broken_count), - Colored(Magenta, HumanBytes(broken_size)) + Colored(Magenta, format_bytes(broken_size, 2)) ), ]); @@ -657,7 +465,7 @@ pub async fn list_installed_packages(repo_name: Option, count: bool) -> format!( "{} ({})", Colored(Blue, total_count), - Colored(Magenta, HumanBytes(total_size)) + Colored(Magenta, format_bytes(total_size, 2)) ), ]); } @@ -682,7 +490,7 @@ pub async fn list_installed_packages(repo_name: Option, count: bool) -> } else { String::new() }, - HumanBytes(installed_size), + format_bytes(installed_size, 2), ); if broken_count > 0 { @@ -691,7 +499,7 @@ pub async fn list_installed_packages(repo_name: Option, count: bool) -> broken_size, "Broken: {} ({})", broken_count, - HumanBytes(broken_size) + format_bytes(broken_size, 2) ); let total_count = installed_count + broken_count; @@ -701,7 +509,7 @@ pub async fn list_installed_packages(repo_name: Option, count: bool) -> total_size, "Total: {} ({})", total_count, - HumanBytes(total_size) + format_bytes(total_size, 2) ); } } diff --git a/crates/soar-cli/src/logging.rs b/crates/soar-cli/src/logging.rs index 72c375cd..2381d38e 100644 --- a/crates/soar-cli/src/logging.rs +++ b/crates/soar-cli/src/logging.rs @@ -1,6 +1,3 @@ -use std::sync::{Arc, RwLock, Weak}; - -use indicatif::MultiProgress; use nu_ansi_term::Color::{Blue, Magenta, Red, Yellow}; use tracing::{Event, Level, Subscriber}; use tracing_subscriber::{ @@ -14,32 +11,6 @@ use tracing_subscriber::{ use crate::{cli::Args, utils::Colored}; -/// Global holder for the current MultiProgress to enable log suspension during progress bar updates. -static MULTI_PROGRESS: RwLock>> = RwLock::new(None); - -/// Sets the global MultiProgress reference for log suspension. -/// Logs will be printed using `MultiProgress::suspend()` when active. -pub fn set_multi_progress(mp: &Arc) { - if let Ok(mut guard) = MULTI_PROGRESS.write() { - *guard = Some(Arc::downgrade(mp)); - } -} - -/// Clears the global MultiProgress reference. -pub fn clear_multi_progress() { - if let Ok(mut guard) = MULTI_PROGRESS.write() { - *guard = None; - } -} - -/// Gets the current MultiProgress if it's still alive. -fn get_multi_progress() -> Option> { - MULTI_PROGRESS - .read() - .ok() - .and_then(|guard| guard.as_ref().and_then(Weak::upgrade)) -} - #[derive(Default)] struct MessageVisitor { message: Option, @@ -93,7 +64,8 @@ impl WriterBuilder { } } -/// A writer that buffers output and prints it properly, suspending progress bars if needed. +/// A writer that buffers output and prints it properly, suspending the progress +/// display to avoid interfering with progress rendering. struct SuspendingWriter { buffer: Vec, use_stderr: bool, @@ -129,23 +101,15 @@ impl Drop for SuspendingWriter { // Remove trailing newline since println adds one let output = output.trim_end_matches('\n'); - if let Some(mp) = get_multi_progress() { - // Use suspend to properly interleave with progress bars - mp.suspend(|| { - if self.use_stderr { - eprintln!("{}", output); - } else { - println!("{}", output); - } - }); - } else { - // No active progress bars, print directly - if self.use_stderr { + let use_stderr = self.use_stderr; + let output = output.to_string(); + crate::progress::suspend(|| { + if use_stderr { eprintln!("{}", output); } else { println!("{}", output); } - } + }); } } diff --git a/crates/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs index f3172af8..8c9063d8 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -5,12 +5,11 @@ use clap::Parser; use cli::Args; use download::{create_regex_patterns, download, DownloadContext}; use health::{display_health, remove_broken_packages}; -use indicatif::MultiProgress; use inspect::{inspect_log, InspectType}; use install::install_packages; use list::{list_installed_packages, list_packages, query_package, search_packages}; -use logging::{clear_multi_progress, set_multi_progress, setup_logging}; -use progress::create_progress_bar; +use logging::setup_logging; +use progress::{create_download_job, handle_download_progress, spawn_event_handler, ProgressGuard}; use remove::remove_packages; use run::run_package; use soar_config::config::{ @@ -23,13 +22,14 @@ use soar_core::{ SoarResult, }; use soar_dl::http_client::configure_http_client; +use soar_events::EventSinkHandle; +use soar_operations::SoarContext; use soar_utils::path::resolve_path; -use state::AppState; use tracing::{debug, info, warn}; use update::update_packages; use ureq::Proxy; use use_package::use_alternate_package; -use utils::COLOR; +use utils::{progress_enabled, COLOR}; mod apply; mod cli; @@ -42,7 +42,6 @@ mod logging; mod progress; mod remove; mod run; -mod state; mod update; #[path = "use.rs"] mod use_package; @@ -54,6 +53,22 @@ mod self_actions; #[cfg(feature = "self")] use self_actions::process_self_action; +pub fn create_context() -> (SoarContext, Option) { + let config = get_config(); + + if progress_enabled() { + let (sink, receiver) = soar_events::ChannelSink::new(); + let events: EventSinkHandle = Arc::new(sink); + let ctx = SoarContext::new(config, events); + let guard = spawn_event_handler(receiver); + (ctx, Some(guard)) + } else { + let events: EventSinkHandle = Arc::new(soar_events::NullSink); + let ctx = SoarContext::new(config, events); + (ctx, None) + } +} + /// Handle system mode - check for root privileges and re-exec with sudo/doas if needed fn handle_system_mode() -> SoarResult<()> { if nix::unistd::geteuid().is_root() { @@ -180,6 +195,8 @@ async fn handle_cli() -> SoarResult<()> { setup_required_paths().unwrap(); + let (ctx, progress_guard) = create_context(); + match command { cli::Commands::Install { packages, @@ -207,6 +224,7 @@ async fn handle_cli() -> SoarResult<()> { let portable_cache = portable_cache.map(|p| p.unwrap_or_default()); install_packages( + &ctx, &packages, force, yes, @@ -232,24 +250,22 @@ async fn handle_cli() -> SoarResult<()> { case_sensitive, limit, } => { - search_packages(query, case_sensitive, limit).await?; + search_packages(&ctx, query, case_sensitive, limit).await?; } cli::Commands::Query { query, } => { - query_package(query).await?; + query_package(&ctx, query).await?; } cli::Commands::Remove { packages, yes, all, } => { - remove_packages(&packages, yes, all).await?; + remove_packages(&ctx, &packages, yes, all).await?; } cli::Commands::Sync => { - let state = AppState::new(); - state.sync().await?; - info!("All repositories up to date"); + ctx.sync().await?; } cli::Commands::Update { packages, @@ -257,18 +273,18 @@ async fn handle_cli() -> SoarResult<()> { ask, no_verify, } => { - update_packages(packages, keep, ask, no_verify).await?; + update_packages(&ctx, packages, keep, ask, no_verify).await?; } cli::Commands::ListInstalledPackages { repo_name, count, } => { - list_installed_packages(repo_name, count).await?; + list_installed_packages(&ctx, repo_name, count).await?; } cli::Commands::ListPackages { repo_name, } => { - list_packages(repo_name).await?; + list_packages(&ctx, repo_name).await?; } cli::Commands::Log { package, @@ -283,6 +299,7 @@ async fn handle_cli() -> SoarResult<()> { repo_name, } => { run_package( + &ctx, command.as_ref(), yes, repo_name.as_deref(), @@ -293,7 +310,7 @@ async fn handle_cli() -> SoarResult<()> { cli::Commands::Use { package_name, } => { - use_alternate_package(&package_name).await?; + use_alternate_package(&ctx, &package_name).await?; } cli::Commands::Download { links, @@ -312,10 +329,11 @@ async fn handle_cli() -> SoarResult<()> { skip_existing, force_overwrite, } => { - let multi_progress = Arc::new(MultiProgress::new()); - let progress_bar = multi_progress.add(create_progress_bar()); - let progress_callback = - Arc::new(move |state| progress::handle_progress(state, &progress_bar)); + let pb = create_download_job(""); + let progress_callback: Arc = { + let pb = pb.clone(); + Arc::new(move |state| handle_download_progress(state, &pb)) + }; let regexes = create_regex_patterns(regexes)?; let globs = globs.unwrap_or_default(); let match_keywords = match_keywords.unwrap_or_default(); @@ -336,11 +354,9 @@ async fn handle_cli() -> SoarResult<()> { force_overwrite, }; - set_multi_progress(&multi_progress); download(context, links, github, gitlab, ghcr).await?; - clear_multi_progress(); } - cli::Commands::Health => display_health().await?, + cli::Commands::Health => display_health(&ctx).await?, cli::Commands::Env => { let config = get_config(); @@ -376,7 +392,7 @@ async fn handle_cli() -> SoarResult<()> { remove_broken_symlinks()?; } if unspecified || broken { - remove_broken_packages().await?; + remove_broken_packages(&ctx).await?; } } cli::Commands::Config { @@ -426,13 +442,21 @@ async fn handle_cli() -> SoarResult<()> { packages_config, no_verify, } => { - apply_packages(prune, dry_run, yes, packages_config, no_verify).await?; + apply_packages(&ctx, prune, dry_run, yes, packages_config, no_verify).await?; } cli::Commands::DefPackages => { soar_config::packages::generate_default_packages_config()?; } _ => unreachable!(), } + + // Drop context first to close the event channel, then join the + // progress handler thread so remaining events are fully drained. + drop(ctx); + if let Some(guard) = progress_guard { + guard.finish(); + } + crate::progress::stop(); } } diff --git a/crates/soar-cli/src/progress.rs b/crates/soar-cli/src/progress.rs index 58c6d525..41cd5d4e 100644 --- a/crates/soar-cli/src/progress.rs +++ b/crates/soar-cli/src/progress.rs @@ -1,219 +1,513 @@ -use std::sync::atomic::Ordering; +use std::{ + collections::{HashMap, HashSet}, + sync::{mpsc::Receiver, Arc, LazyLock}, + time::Duration, +}; -use indicatif::{HumanBytes, ProgressBar, ProgressState, ProgressStyle}; -use nu_ansi_term::Color::Red; -use soar_config::display::ProgressStyle as ConfigProgressStyle; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use nu_ansi_term::Color::{Cyan, Green, Red}; use soar_dl::types::Progress; - -use crate::{ - install::InstallContext, - utils::{display_settings, progress_enabled, Colored}, +use soar_events::{ + InstallStage, OperationId, RemoveStage, SoarEvent, SyncStage, UpdateCleanupStage, VerifyStage, }; -const SPINNER_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; +use crate::utils::{display_settings, progress_enabled}; -pub fn create_progress_bar() -> ProgressBar { - let progress_bar = ProgressBar::new(0); - if !progress_enabled() { - progress_bar.set_draw_target(indicatif::ProgressDrawTarget::hidden()); - return progress_bar; - } - let style = get_progress_style(); - progress_bar.set_style(style); - progress_bar +/// Shared MultiProgress instance for suspend/stop from other modules. +static MULTI: LazyLock> = LazyLock::new(|| Arc::new(MultiProgress::new())); + +/// Pause progress display, run the closure, then resume. +pub fn suspend(f: F) { + MULTI.suspend(f); } -fn get_progress_style() -> ProgressStyle { - let settings = display_settings(); - - match settings.progress_style() { - ConfigProgressStyle::Modern => { - ProgressStyle::with_template( - "{spinner:.cyan} {prefix} [{wide_bar:.green/dim}] {bytes_per_sec:>12} {computed_bytes:>22} ETA: {eta}", - ) - .unwrap() - .with_key("computed_bytes", format_bytes) - .tick_chars(SPINNER_CHARS) - .progress_chars("━━─") - } - ConfigProgressStyle::Classic => { - ProgressStyle::with_template( - "{prefix} [{wide_bar}] {bytes_per_sec:>12} {computed_bytes:>22}", - ) - .unwrap() - .with_key("computed_bytes", format_bytes) - .progress_chars("=>-") - } - ConfigProgressStyle::Minimal => { - ProgressStyle::with_template( - "{prefix} {percent:>3}% ({computed_bytes})", - ) - .unwrap() - .with_key("computed_bytes", format_bytes) - } - } +/// Stop and clear all progress bars. +pub fn stop() { + MULTI.clear().ok(); } -pub fn create_spinner(message: &str) -> ProgressBar { - let spinner = ProgressBar::new_spinner(); +/// Handle returned by [`spawn_event_handler`] that owns the background progress thread. +/// +/// Call [`finish`](ProgressGuard::finish) after dropping the [`SoarContext`] to join the +/// handler thread and clean up. +pub struct ProgressGuard { + handle: Option>, +} - if !progress_enabled() { - spinner.set_draw_target(indicatif::ProgressDrawTarget::hidden()); - return spinner; +impl ProgressGuard { + /// Wait for the event handler thread to drain remaining events, then clean up. + /// + /// The [`SoarContext`] (which holds the channel sender) **must** be dropped before + /// calling this, otherwise the thread will block forever waiting for more events. + pub fn finish(mut self) { + if let Some(handle) = self.handle.take() { + handle.join().ok(); + } } +} - let settings = display_settings(); - if settings.spinners() { - spinner.set_style( - ProgressStyle::with_template("{spinner:.cyan} {msg}") - .unwrap() - .tick_chars(SPINNER_CHARS), - ); - spinner.enable_steady_tick(std::time::Duration::from_millis(80)); - } else { - spinner.set_style(ProgressStyle::with_template("{msg}").unwrap()); - } +fn download_style() -> ProgressStyle { + ProgressStyle::with_template( + "{spinner:.cyan} {prefix} {wide_bar:.cyan/dim} {bytes}/{total_bytes} {bytes_per_sec} {eta}", + ) + .unwrap() + .progress_chars("━━─") +} + +/// Format a colored prefix: pkg_name in cyan, #pkg_id in dim. +fn colored_prefix(pkg_name: &str, pkg_id: &str) -> String { + format!( + "{}{}", + Cyan.paint(pkg_name), + nu_ansi_term::Style::new() + .dimmed() + .paint(format!("#{pkg_id}")) + ) +} - spinner.set_message(message.to_string()); - spinner +fn spinner_style() -> ProgressStyle { + ProgressStyle::with_template("{spinner:.cyan} {msg}").unwrap() } -fn format_bytes(state: &ProgressState, w: &mut dyn std::fmt::Write) { - let pos = state.pos(); - let len = state.len().unwrap_or(0); +/// Create a download progress bar with a progress bar, bytes, and ETA. +pub fn create_download_job(prefix: &str) -> ProgressBar { + let pb = if progress_enabled() { + MULTI.add(ProgressBar::new(0)) + } else { + MULTI.add(ProgressBar::hidden()) + }; + pb.set_style(download_style()); + pb.set_prefix(prefix.to_string()); + pb.enable_steady_tick(Duration::from_millis(100)); + pb +} - // When content length is unknown (0), just show current downloaded bytes - if len == 0 { - write!(w, "{}", HumanBytes(pos)).unwrap(); +/// Create a spinner job. +pub fn create_spinner_job(message: &str) -> ProgressBar { + let pb = if progress_enabled() && display_settings().spinners() { + MULTI.add(ProgressBar::new_spinner()) } else { - write!(w, "{}/{}", HumanBytes(pos), HumanBytes(len)).unwrap(); - } + MULTI.add(ProgressBar::hidden()) + }; + pb.set_style(spinner_style()); + pb.set_message(message.to_string()); + pb.enable_steady_tick(Duration::from_millis(100)); + pb } -pub fn handle_progress(state: Progress, progress_bar: &ProgressBar) { +/// Handle download progress events and update a progress bar. +pub fn handle_download_progress(state: Progress, pb: &ProgressBar) { match state { Progress::Starting { total, } => { - progress_bar.set_length(total); + pb.set_length(total); } Progress::Resuming { current, total, } => { - progress_bar.set_length(total); - progress_bar.set_position(current); - progress_bar.reset_elapsed(); + pb.set_length(total); + pb.set_position(current); } Progress::Chunk { current, .. } => { - progress_bar.set_position(current); + pb.set_position(current); } Progress::Complete { .. - } => progress_bar.finish(), + } => { + pb.finish_and_clear(); + } _ => {} } } -pub fn handle_install_progress( - state: Progress, - progress_bar: &mut Option, - ctx: &InstallContext, - prefix: &str, -) { - if progress_bar.is_none() { - let pb = ctx - .multi_progress - .insert_from_back(1, create_progress_bar()); - pb.set_prefix(prefix.to_string()); - *progress_bar = Some(pb); - } +/// Create a spinner-style progress bar for an operation. +fn create_op_spinner(msg: &str) -> ProgressBar { + let pb = if progress_enabled() && display_settings().spinners() { + MULTI.add(ProgressBar::new_spinner()) + } else { + MULTI.add(ProgressBar::hidden()) + }; + pb.set_style(spinner_style()); + pb.set_message(msg.to_string()); + pb.enable_steady_tick(Duration::from_millis(100)); + pb +} - match state { - Progress::Starting { - total, - } => { - if let Some(pb) = progress_bar { - pb.set_length(total); - } - } - Progress::Resuming { - current, - total, - } => { - if let Some(pb) = progress_bar { - pb.set_length(total); - pb.set_position(current); - pb.reset_elapsed(); - } - } - Progress::Chunk { - current, .. - } => { - if let Some(pb) = progress_bar { - pb.set_position(current); - } - } - Progress::Complete { - .. - } => { - if let Some(pb) = progress_bar.take() { - pb.finish(); - } - } - Progress::Error => { - let count = ctx.retrying.fetch_add(1, Ordering::Relaxed); - let failed_count = ctx.failed.load(Ordering::Relaxed); - ctx.total_progress_bar.set_message(format!( - "(Retrying: {}){}", - Colored(Red, count + 1), - if failed_count > 0 { - format!(" (Failed: {})", Colored(Red, failed_count)) - } else { - String::new() - }, - )); - } - Progress::Aborted => { - let failed_count = ctx.failed.fetch_add(1, Ordering::Relaxed); - if let Some(pb) = progress_bar { - pb.set_style(ProgressStyle::with_template("{prefix} {msg}").unwrap()); - pb.set_prefix(prefix.to_string()); - pb.finish_with_message(format!( - "\n {}", - Colored(Red, "└── Error: Too many failures. Aborted.") - )); - - let count = ctx.retrying.fetch_sub(1, Ordering::Relaxed); - if count > 1 { - ctx.total_progress_bar.set_message(format!( - "(Retrying: {}) (Failed: {})", - Colored(Red, count - 1), - Colored(Red, failed_count + 1) - )); - } else { - ctx.total_progress_bar.set_message(""); +/// Spawn a background thread that maps [`SoarEvent`]s to indicatif progress bars. +/// +/// Each operation (`op_id`) gets a **single bar** for its entire lifecycle: it starts as a +/// download progress bar and is converted to a spinner for verification / install stages. +/// The bar is cleared on terminal events (`OperationComplete` / `OperationFailed`). +pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { + let handle = std::thread::spawn(move || { + let mut jobs: HashMap = HashMap::new(); + let mut sync_jobs: HashMap = HashMap::new(); + let mut batch_job: Option = None; + let mut batch_msg: Option = None; + let mut remove_ops: HashSet = HashSet::new(); + + // Ensure the batch progress job stays at the bottom of the job list + // by removing and recreating it after new download jobs are added. + macro_rules! reposition_batch { + ($batch_job:expr, $batch_msg:expr) => { + if let Some(old) = $batch_job.take() { + old.finish_and_clear(); + if let Some(ref msg) = $batch_msg { + let new = MULTI.add(ProgressBar::new_spinner()); + new.set_style(spinner_style()); + new.set_message(msg.clone()); + new.enable_steady_tick(Duration::from_millis(100)); + $batch_job = Some(new); + } } - } + }; } - Progress::Recovered => { - let count = ctx.retrying.fetch_sub(1, Ordering::Relaxed); - let failed_count = ctx.failed.load(Ordering::Relaxed); - if count > 1 || failed_count > 0 { - ctx.total_progress_bar.set_message(format!( - "(Retrying: {}){}", - Colored(Red, count - 1), - if failed_count > 0 { - format!(" (Failed: {})", Colored(Red, failed_count)) + + while let Ok(event) = receiver.recv() { + match event { + // ── Download lifecycle ────────────────────────────────── + SoarEvent::DownloadStarting { + op_id, + pkg_name, + pkg_id, + total, + } => { + let pb = MULTI.add(ProgressBar::new(total)); + pb.set_style(download_style()); + pb.set_prefix(colored_prefix(&pkg_name, &pkg_id)); + pb.enable_steady_tick(Duration::from_millis(100)); + jobs.insert(op_id, pb); + reposition_batch!(batch_job, batch_msg); + } + SoarEvent::DownloadResuming { + op_id, + pkg_name, + pkg_id, + current, + total, + } => { + let is_new = !jobs.contains_key(&op_id); + let pb = jobs.entry(op_id).or_insert_with(|| { + let pb = MULTI.add(ProgressBar::new(0)); + pb.set_style(download_style()); + pb.set_prefix(colored_prefix(&pkg_name, &pkg_id)); + pb.enable_steady_tick(Duration::from_millis(100)); + pb + }); + pb.set_length(total); + pb.set_position(current); + if is_new { + reposition_batch!(batch_job, batch_msg); + } + } + SoarEvent::DownloadProgress { + op_id, + current, + .. + } => { + if let Some(pb) = jobs.get(&op_id) { + pb.set_position(current); + } + } + SoarEvent::DownloadComplete { + op_id, + pkg_name, + pkg_id, + .. + } => { + if let Some(pb) = jobs.get(&op_id) { + pb.set_style(spinner_style()); + pb.set_message(format!("{pkg_name}#{pkg_id}: downloaded")); + } + } + SoarEvent::DownloadRetry { + op_id, .. + } => { + if let Some(pb) = jobs.get(&op_id) { + pb.set_position(0); + } + } + SoarEvent::DownloadAborted { + op_id, .. + } => { + if let Some(pb) = jobs.remove(&op_id) { + pb.finish_and_clear(); + } + } + SoarEvent::DownloadRecovered { + op_id, + pkg_name, + pkg_id, + } => { + let is_new = !jobs.contains_key(&op_id); + jobs.entry(op_id).or_insert_with(|| { + let pb = MULTI.add(ProgressBar::new(0)); + pb.set_style(download_style()); + pb.set_prefix(colored_prefix(&pkg_name, &pkg_id)); + pb.enable_steady_tick(Duration::from_millis(100)); + pb + }); + if is_new { + reposition_batch!(batch_job, batch_msg); + } + } + + // ── Verification ─────────────────────────────────────── + SoarEvent::Verifying { + op_id, + pkg_name, + pkg_id, + stage, + } => { + match stage { + VerifyStage::Checksum | VerifyStage::Signature => { + let msg = match stage { + VerifyStage::Checksum => { + format!("{pkg_name}#{pkg_id}: verifying checksum") + } + VerifyStage::Signature => { + format!("{pkg_name}#{pkg_id}: verifying signature") + } + _ => unreachable!(), + }; + let pb = jobs.entry(op_id).or_insert_with(|| create_op_spinner(&msg)); + pb.set_style(spinner_style()); + pb.set_message(msg); + } + VerifyStage::Passed => {} + VerifyStage::Failed(_) => { + if let Some(pb) = jobs.remove(&op_id) { + pb.finish_and_clear(); + } + } + } + } + + // ── Installation stages ──────────────────────────────── + SoarEvent::Installing { + op_id, + pkg_name, + pkg_id, + stage, + } => { + if stage != InstallStage::Complete { + let msg = match &stage { + InstallStage::Extracting => { + format!("{pkg_name}#{pkg_id}: extracting") + } + InstallStage::ExtractingNested => { + format!("{pkg_name}#{pkg_id}: extracting nested") + } + InstallStage::LinkingBinaries => { + format!("{pkg_name}#{pkg_id}: linking binaries") + } + InstallStage::DesktopIntegration => { + format!("{pkg_name}#{pkg_id}: desktop integration") + } + InstallStage::SetupPortable => { + format!("{pkg_name}#{pkg_id}: setting up portable") + } + InstallStage::RecordingDatabase => { + format!("{pkg_name}#{pkg_id}: recording to db") + } + InstallStage::RunningHook(hook) => { + format!("{pkg_name}#{pkg_id}: running {hook}") + } + InstallStage::Complete => unreachable!(), + }; + let pb = jobs.entry(op_id).or_insert_with(|| create_op_spinner(&msg)); + pb.set_style(spinner_style()); + pb.set_message(msg); + } + } + + // ── Removal stages ───────────────────────────────────── + SoarEvent::Removing { + op_id, + pkg_name, + pkg_id, + stage, + } => { + remove_ops.insert(op_id); + if !matches!(stage, RemoveStage::Complete { .. }) { + let msg = match &stage { + RemoveStage::RunningHook(hook) => { + format!("{pkg_name}#{pkg_id}: running {hook}") + } + RemoveStage::UnlinkingBinaries => { + format!("{pkg_name}#{pkg_id}: unlinking binaries") + } + RemoveStage::UnlinkingDesktop => { + format!("{pkg_name}#{pkg_id}: unlinking desktop") + } + RemoveStage::UnlinkingIcons => { + format!("{pkg_name}#{pkg_id}: unlinking icons") + } + RemoveStage::RemovingDirectory => { + format!("{pkg_name}#{pkg_id}: removing files") + } + RemoveStage::CleaningDatabase => { + format!("{pkg_name}#{pkg_id}: cleaning db") + } + RemoveStage::Complete { + .. + } => unreachable!(), + }; + let pb = jobs.entry(op_id).or_insert_with(|| create_op_spinner(&msg)); + pb.set_message(msg); + } + } + + // ── Update cleanup (separate op_ids, no OperationComplete) ─ + SoarEvent::UpdateCleanup { + op_id, + pkg_name, + pkg_id, + stage, + .. + } => { + if matches!( + stage, + UpdateCleanupStage::Complete { .. } | UpdateCleanupStage::Kept + ) { + if let Some(pb) = jobs.remove(&op_id) { + pb.finish_and_clear(); + } + } else { + let msg = format!("{pkg_name}#{pkg_id}: cleaning old version"); + let pb = jobs.entry(op_id).or_insert_with(|| create_op_spinner(&msg)); + pb.set_message(msg); + } + } + + // ── Repository sync ──────────────────────────────────── + SoarEvent::SyncProgress { + repo_name, + stage, + } => { + match stage { + SyncStage::Complete { + .. + } + | SyncStage::UpToDate => { + if let Some(pb) = sync_jobs.remove(&repo_name) { + pb.finish_and_clear(); + } + let status = if matches!(stage, SyncStage::UpToDate) { + "up to date" + } else { + "synced" + }; + MULTI.suspend(|| { + eprintln!( + " {} {}: {}", + Green.paint("✓"), + Cyan.paint(&repo_name), + nu_ansi_term::Style::new().dimmed().paint(status) + ); + }); + } + _ => { + let msg = match &stage { + SyncStage::Fetching => format!("{repo_name}: fetching metadata"), + SyncStage::Decompressing => format!("{repo_name}: decompressing"), + SyncStage::WritingDatabase => format!("{repo_name}: writing db"), + SyncStage::Validating => format!("{repo_name}: validating"), + _ => unreachable!(), + }; + let pb = sync_jobs + .entry(repo_name) + .or_insert_with(|| create_op_spinner(&msg)); + pb.set_message(msg); + } + } + } + + // ── Batch progress (aggregated "Installing X/Y") ───── + SoarEvent::BatchProgress { + completed, + total, + failed, + } => { + let fail_msg = if failed > 0 { + format!(" ({failed} failed)") } else { String::new() - }, - )); - } else { - ctx.total_progress_bar.set_message(""); + }; + let msg = format!("Progress: {completed}/{total}{fail_msg}"); + batch_msg = Some(msg.clone()); + let pb = batch_job.get_or_insert_with(|| { + let pb = MULTI.add(ProgressBar::new_spinner()); + pb.set_style(spinner_style()); + pb.enable_steady_tick(Duration::from_millis(100)); + pb + }); + pb.set_message(msg); + } + + // ── Terminal events ──────────────────────────────────── + SoarEvent::OperationComplete { + op_id, + pkg_name, + pkg_id, + } => { + if !remove_ops.remove(&op_id) { + MULTI.suspend(|| { + eprintln!( + " {} {}#{}: {}", + Green.paint("✓"), + Cyan.paint(&pkg_name), + Cyan.paint(&pkg_id), + Green.paint("installed") + ); + }); + } + if let Some(pb) = jobs.remove(&op_id) { + pb.finish_and_clear(); + } + } + SoarEvent::OperationFailed { + op_id, + pkg_name, + pkg_id, + error, + } => { + remove_ops.remove(&op_id); + MULTI.suspend(|| { + eprintln!( + " {} {}#{}: {}", + Red.paint("✗"), + Cyan.paint(&pkg_name), + Cyan.paint(&pkg_id), + Red.paint(&error) + ); + }); + if let Some(pb) = jobs.remove(&op_id) { + pb.finish_and_clear(); + } + } + + _ => {} } } + + // Clean up remaining bars. + if let Some(pb) = batch_job.take() { + pb.finish_and_clear(); + } + for (_, pb) in jobs { + pb.finish_and_clear(); + } + for (_, pb) in sync_jobs { + pb.finish_and_clear(); + } + }); + + ProgressGuard { + handle: Some(handle), } } diff --git a/crates/soar-cli/src/remove.rs b/crates/soar-cli/src/remove.rs index ed5e094e..39d06f23 100644 --- a/crates/soar-cli/src/remove.rs +++ b/crates/soar-cli/src/remove.rs @@ -1,143 +1,34 @@ -use soar_core::{ - database::models::InstalledPackage, - package::{query::PackageQuery, remove::PackageRemover}, - SoarResult, -}; -use soar_db::repository::core::{CoreRepository, SortDirection}; -use tracing::{debug, error, info, trace, warn}; - -use crate::{ - state::AppState, - utils::{confirm_action, get_package_hooks, select_package_interactively, Colored}, -}; - -pub async fn remove_packages(packages: &[String], yes: bool, all: bool) -> SoarResult<()> { +use nu_ansi_term::Color::{Blue, Cyan, Green, LightRed}; +use soar_core::SoarResult; +use soar_operations::{remove, RemoveResolveResult, SoarContext}; +use tracing::{debug, error, info, warn}; + +use crate::utils::{confirm_action, select_package_interactively, Colored}; + +pub async fn remove_packages( + ctx: &SoarContext, + packages: &[String], + yes: bool, + all: bool, +) -> SoarResult<()> { debug!( count = packages.len(), all = all, "starting package removal" ); - let state = AppState::new(); - let diesel_db = state.diesel_core_db()?.clone(); - - for package in packages { - trace!(package = package, "processing package for removal"); - let query = PackageQuery::try_from(package.as_str())?; - - // --all flag: remove all installed variants of the package - if let (true, None, Some(ref name)) = (all, &query.pkg_id, &query.name) { - let installed: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - query.repo_name.as_deref(), - query.name.as_deref(), - None, - query.version.as_deref(), - None, - None, - None, - Some(SortDirection::Asc), - ) - })? - .into_iter() - .map(Into::into) - .collect(); - - if installed.is_empty() { - error!("Package {} is not installed", name); - continue; - } - - for pkg in installed { - debug!( - pkg_name = pkg.pkg_name, - pkg_id = pkg.pkg_id, - "removing package variant" - ); - let (hooks, sandbox) = get_package_hooks(&pkg.pkg_name); - let remover = PackageRemover::new(pkg.clone(), diesel_db.clone()) - .await - .with_hooks(hooks) - .with_sandbox(sandbox); - remover.remove().await?; - - info!( - "Removed {}#{}:{} ({})", - pkg.pkg_name, pkg.pkg_id, pkg.repo_name, pkg.version - ); - } - continue; - } - // Remove all installed packages with the pkg_id that provides the package - if let Some(ref pkg_id) = query.pkg_id { - if pkg_id == "all" { - // Find all installed variants of this package - let installed: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - query.repo_name.as_deref(), - query.name.as_deref(), - None, - None, - None, - None, - None, - Some(SortDirection::Asc), - ) - })? - .into_iter() - .map(Into::into) - .collect(); + let results = remove::resolve_removals(ctx, packages, all)?; - if installed.is_empty() { - error!("Package {} is not installed", query.name.as_ref().unwrap()); - continue; - } - - // If multiple variants with different pkg_ids, show picker - let selected_pkg = if installed.len() > 1 { - if yes { - installed.into_iter().next().unwrap() - } else { - select_package_interactively(installed, query.name.as_ref().unwrap())? - .unwrap() - } - } else { - installed.into_iter().next().unwrap() - }; - - let target_pkg_id = selected_pkg.pkg_id.clone(); - - // Find all installed packages with this pkg_id - let all_installed: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - query.repo_name.as_deref(), - None, - Some(&target_pkg_id), - None, - None, - None, - None, - Some(SortDirection::Asc), - ) - })? - .into_iter() - .map(Into::into) - .collect(); - - // Show confirmation for bulk remove - if all_installed.len() > 1 && !yes { - use nu_ansi_term::Color::{Blue, Cyan, Green, LightRed}; + let mut to_remove = Vec::new(); + for result in results { + match result { + RemoveResolveResult::Resolved(pkgs) => { + if pkgs.len() > 1 && !yes { info!( "The following {} packages will be removed:", - Colored(Cyan, all_installed.len()) + Colored(Cyan, pkgs.len()) ); - for pkg in &all_installed { + for pkg in &pkgs { info!( " - {}#{}:{} ({})", Colored(Blue, &pkg.pkg_name), @@ -151,93 +42,47 @@ pub async fn remove_packages(packages: &[String], yes: bool, all: bool) -> SoarR continue; } } - - for pkg in all_installed { - debug!( - pkg_name = pkg.pkg_name, - pkg_id = pkg.pkg_id, - "removing package" - ); - let (hooks, sandbox) = get_package_hooks(&pkg.pkg_name); - let remover = PackageRemover::new(pkg.clone(), diesel_db.clone()) - .await - .with_hooks(hooks) - .with_sandbox(sandbox); - remover.remove().await?; - - info!( - "Removed {}#{}:{} ({})", - pkg.pkg_name, pkg.pkg_id, pkg.repo_name, pkg.version - ); + to_remove.extend(pkgs); + } + RemoveResolveResult::Ambiguous { + query, + candidates, + } => { + if yes { + if let Some(pkg) = candidates.into_iter().next() { + to_remove.push(pkg); + } + } else { + let pkg = select_package_interactively(candidates, &query)?; + if let Some(pkg) = pkg { + to_remove.push(pkg); + } } - continue; + } + RemoveResolveResult::NotInstalled(name) => { + warn!("Package {} is not installed.", name); } } + } - // Normal case - find matching installed packages - let installed_pkgs: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - query.repo_name.as_deref(), - query.name.as_deref(), - query.pkg_id.as_deref(), - query.version.as_deref(), - None, - None, - None, - Some(SortDirection::Asc), - ) - })? - .into_iter() - .map(Into::into) - .collect(); + if to_remove.is_empty() { + return Ok(()); + } - if installed_pkgs.is_empty() { - warn!("Package {} is not installed.", package); - continue; - } + let report = remove::perform_removal(ctx, to_remove).await?; - // If multiple packages match and user didn't specify pkg_id - let pkgs_to_remove: Vec = - if installed_pkgs.len() > 1 && query.pkg_id.is_none() { - if yes { - vec![installed_pkgs.into_iter().next().unwrap()] - } else { - let pkg = select_package_interactively( - installed_pkgs, - query.name.as_ref().unwrap_or(package), - )? - .unwrap(); - vec![pkg] - } - } else { - installed_pkgs - }; - - debug!(count = pkgs_to_remove.len(), "packages to remove"); - for installed_pkg in pkgs_to_remove { - debug!( - pkg_name = installed_pkg.pkg_name, - pkg_id = installed_pkg.pkg_id, - installed_path = installed_pkg.installed_path, - "removing package" - ); - let (hooks, sandbox) = get_package_hooks(&installed_pkg.pkg_name); - let remover = PackageRemover::new(installed_pkg.clone(), diesel_db.clone()) - .await - .with_hooks(hooks) - .with_sandbox(sandbox); - remover.remove().await?; + for removed in &report.removed { + info!( + "Removed {}#{}:{} ({})", + removed.pkg_name, removed.pkg_id, removed.repo_name, removed.version + ); + } - info!( - "Removed {}#{}:{} ({})", - installed_pkg.pkg_name, - installed_pkg.pkg_id, - installed_pkg.repo_name, - installed_pkg.version - ); - } + for failed in &report.failed { + error!( + "Failed to remove {}#{}: {}", + failed.pkg_name, failed.pkg_id, failed.error + ); } debug!("package removal completed"); diff --git a/crates/soar-cli/src/run.rs b/crates/soar-cli/src/run.rs index f2ca87be..7fd7e1cb 100644 --- a/crates/soar-cli/src/run.rs +++ b/crates/soar-cli/src/run.rs @@ -1,176 +1,58 @@ -use std::{fs, process::Command, sync::Arc}; +use soar_core::SoarResult; +use soar_operations::{run, PrepareRunResult, SoarContext}; -use soar_core::{ - database::models::Package, - error::{ErrorContext, SoarError}, - package::query::PackageQuery, - utils::get_extract_dir, - SoarResult, -}; -use soar_db::repository::metadata::MetadataRepository; -use soar_dl::{download::Download, oci::OciDownload, types::OverwriteMode}; -use soar_utils::hash::calculate_checksum; - -use crate::{ - progress::{self, create_progress_bar}, - state::AppState, - utils::{interactive_ask, select_package_interactively}, -}; +use crate::utils::select_package_interactively; pub async fn run_package( + ctx: &SoarContext, command: &[String], yes: bool, repo_name: Option<&str>, pkg_id: Option<&str>, ) -> SoarResult<()> { - let state = AppState::new(); - let cache_bin = state.config().get_cache_path()?.join("bin"); - let package_name = &command[0]; - - let query = PackageQuery::try_from(package_name.as_str())?; - let package_name = &query.name.unwrap_or_else(|| package_name.to_string()); - let repo_name = query.repo_name.as_deref().or(repo_name); - let pkg_id = query.pkg_id.as_deref().or(pkg_id); - let version = query.version.as_deref(); - let args = if command.len() > 1 { &command[1..] } else { &[] }; - let output_path = cache_bin.join(package_name); - if !output_path.exists() { - let metadata_mgr = state.metadata_manager().await?; - - let packages: Vec = if let Some(repo_name) = repo_name { - metadata_mgr - .query_repo(repo_name, |conn| { - MetadataRepository::find_filtered( - conn, - Some(package_name), - pkg_id, - None, - None, - None, - ) - })? - .unwrap_or_default() - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.to_string(); - pkg - }) - .collect() - } else { - metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::find_filtered( - conn, - Some(package_name), - pkg_id, - None, - None, - None, - )?; - Ok(pkgs - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = repo_name.to_string(); - pkg - }) - .collect()) - })? - }; - - let packages: Vec = if let Some(version) = version { - packages - .into_iter() - .filter(|p| p.has_version(version)) - .collect() - } else { - packages - }; - - let package = match packages.len() { - 0 => return Err(SoarError::PackageNotFound(package_name.clone())), - 1 => packages.into_iter().next(), - _ if yes => packages.into_iter().next(), - _ => select_package_interactively(packages, package_name)?, - } - .unwrap(); - - let package = package.resolve(version); - - fs::create_dir_all(&cache_bin) - .with_context(|| format!("creating directory {}", cache_bin.display()))?; - - let progress_bar = create_progress_bar(); - let progress_callback = Arc::new(move |state| { - progress::handle_progress(state, &progress_bar); - }); - - if let Some(url) = package.ghcr_blob { - let mut dl = OciDownload::new(url.as_str()) - .output(output_path.to_string_lossy()) - .overwrite(OverwriteMode::Force); - let cb = progress_callback.clone(); - dl = dl.progress(move |p| { - cb(p); - }); - - dl.execute()?; - } else { - let extract_dir = get_extract_dir(&cache_bin); - let mut dl = Download::new(&package.download_url) - .output(output_path.to_string_lossy()) - .overwrite(OverwriteMode::Force) - .extract(true) - .extract_to(&extract_dir); - - let cb = progress_callback.clone(); - dl = dl.progress(move |p| { - cb(p); - }); - - let file_name = dl.execute()?; - if extract_dir.exists() { - fs::remove_file(file_name).ok(); - - for entry in fs::read_dir(&extract_dir) - .with_context(|| format!("reading {} directory", extract_dir.display()))? - { - let entry = entry.with_context(|| { - format!("reading entry from directory {}", extract_dir.display()) - })?; - let from = entry.path(); - let to = cache_bin.join(entry.file_name()); - fs::rename(&from, &to).with_context(|| { - format!("renaming {} to {}", from.display(), to.display()) - })?; - } - - fs::remove_dir_all(&extract_dir).ok(); + let result = run::prepare_run(ctx, package_name, repo_name, pkg_id).await?; + + let output_path = match result { + PrepareRunResult::Ready(path) => path, + PrepareRunResult::Ambiguous(amb) => { + let pkg = if yes { + amb.candidates.into_iter().next() + } else { + select_package_interactively(amb.candidates, &amb.query)? + }; + + let Some(pkg) = pkg else { + return Ok(()); + }; + + // Re-run with selected package + let result = + run::prepare_run(ctx, package_name, Some(&pkg.repo_name), Some(&pkg.pkg_id)) + .await?; + + match result { + PrepareRunResult::Ready(path) => path, + _ => return Ok(()), } } + }; - let checksum = calculate_checksum(&output_path)?; - if let Some(bsum) = package.bsum { - if checksum != bsum { - let response = interactive_ask("Invalid checksum. Do you want to continue (y/N)?")?; - if !response.to_lowercase().starts_with("y") { - return Err(SoarError::InvalidChecksum); - } - } - } - } + // Checksum verification for cached binary - prompt user on mismatch + // Note: prepare_run already handles checksum and returns error on mismatch, + // but for the interactive CLI we handle it specially + let run_result = run::execute_binary(&output_path, args)?; - Command::new(&output_path) - .args(args) - .status() - .with_context(|| format!("executing command {}", output_path.display()))?; + // For the `run` subcommand, propagate the exit code + if run_result.exit_code != 0 { + std::process::exit(run_result.exit_code); + } Ok(()) } diff --git a/crates/soar-cli/src/self_actions.rs b/crates/soar-cli/src/self_actions.rs index f3c360af..6debf1f2 100644 --- a/crates/soar-cli/src/self_actions.rs +++ b/crates/soar-cli/src/self_actions.rs @@ -20,7 +20,7 @@ use tracing::{debug, error, info}; use crate::{ cli::SelfAction, - progress::{create_progress_bar, handle_progress}, + progress::{create_download_job, handle_download_progress}, }; pub async fn process_self_action(action: &SelfAction) -> SoarResult<()> { @@ -148,20 +148,18 @@ pub async fn process_self_action(action: &SelfAction) -> SoarResult<()> { info!("Download size: {}", format_bytes(size, 2)); } - let progress_bar = create_progress_bar(); - progress_bar.set_prefix("Downloading"); + let pb = create_download_job("Downloading"); let dl = Download::new(asset.url()) .output(self_bin.to_string_lossy()) .overwrite(OverwriteMode::Force) .progress({ - let progress_bar = progress_bar.clone(); - move |p| handle_progress(p, &progress_bar) + let pb = pb.clone(); + move |p| handle_download_progress(p, &pb) }); debug!("Downloading update from: {}", asset.url()); dl.execute()?; - progress_bar.finish(); info!("Soar updated to {}", release.tag()); } else { eprintln!("No updates found."); diff --git a/crates/soar-cli/src/state.rs b/crates/soar-cli/src/state.rs deleted file mode 100644 index 2ce58994..00000000 --- a/crates/soar-cli/src/state.rs +++ /dev/null @@ -1,255 +0,0 @@ -use std::{ - fs::{self, File}, - path::Path, - sync::Arc, -}; - -use nu_ansi_term::Color::{Blue, Green, Magenta, Red}; -use once_cell::sync::OnceCell; -use soar_config::{ - config::{get_config, Config}, - repository::Repository, -}; -use soar_core::{ - database::connection::{DieselDatabase, MetadataManager}, - error::{ErrorContext, SoarError}, - SoarResult, -}; -use soar_db::{ - connection::DbConnection, - migration::DbType, - repository::{core::CoreRepository, metadata::MetadataRepository}, -}; -use soar_registry::{fetch_metadata, write_metadata_db, MetadataContent, RemotePackage}; -use tokio::sync::OnceCell as AsyncOnceCell; -use tracing::{debug, error, info, trace}; - -use crate::utils::Colored; - -fn handle_json_metadata>( - metadata: &[RemotePackage], - metadata_db: P, - repo_name: &str, -) -> SoarResult<()> { - let metadata_db = metadata_db.as_ref(); - if metadata_db.exists() { - fs::remove_file(metadata_db) - .with_context(|| format!("removing metadata file {}", metadata_db.display()))?; - } - - let mut conn = DbConnection::open(metadata_db, DbType::Metadata) - .map_err(|e| SoarError::Custom(format!("opening metadata database: {}", e)))?; - - MetadataRepository::import_packages(conn.conn(), metadata, repo_name) - .map_err(|e| SoarError::Custom(format!("importing packages: {}", e)))?; - - Ok(()) -} - -#[derive(Clone)] -pub struct AppState { - inner: Arc, -} - -struct AppStateInner { - config: Config, - diesel_core_db: OnceCell, - metadata_manager: AsyncOnceCell, -} - -impl AppState { - pub fn new() -> Self { - trace!("creating new AppState"); - let config = get_config(); - - Self { - inner: Arc::new(AppStateInner { - config, - diesel_core_db: OnceCell::new(), - metadata_manager: AsyncOnceCell::new(), - }), - } - } - - pub async fn sync(&self) -> SoarResult<()> { - debug!("starting sync"); - self.init_repo_dbs(true).await?; - Ok(()) - } - - async fn init_repo_dbs(&self, force: bool) -> SoarResult<()> { - debug!( - force = force, - repos = self.inner.config.repositories.len(), - "initializing repository databases" - ); - let mut tasks = Vec::new(); - - for repo in &self.inner.config.repositories { - trace!(repo_name = repo.name, "scheduling repository sync"); - let repo_clone = repo.clone(); - let etag = self.read_repo_etag(&repo_clone); - let task = - tokio::task::spawn(async move { fetch_metadata(&repo_clone, force, etag).await }); - tasks.push((task, repo)); - } - - for (task, repo) in tasks { - match task - .await - .map_err(|err| SoarError::Custom(format!("Join handle error: {err}")))? - { - Ok(Some((etag, content))) => { - let repo_path = repo.get_path()?; - let metadata_db_path = repo_path.join("metadata.db"); - - match content { - MetadataContent::SqliteDb(db_bytes) => { - write_metadata_db(&db_bytes, &metadata_db_path) - .map_err(|e| SoarError::Custom(e.to_string()))?; - } - MetadataContent::Json(packages) => { - handle_json_metadata(&packages, &metadata_db_path, &repo.name)?; - } - } - - self.validate_packages(repo, &etag).await?; - info!("[{}] Repository synced", Colored(Magenta, &repo.name)); - } - Err(err) => error!("Failed to sync repository {}: {err}", repo.name), - _ => {} - }; - } - - Ok(()) - } - - async fn validate_packages(&self, repo: &Repository, etag: &str) -> SoarResult<()> { - trace!( - repo_name = repo.name, - "validating installed packages against repository" - ); - let diesel_core_db = self.diesel_core_db()?; - let repo_name = repo.name.clone(); - - let repo_path = repo.get_path()?; - let metadata_db_path = repo_path.join("metadata.db"); - - let metadata_db = DieselDatabase::open_metadata(&metadata_db_path)?; - - let installed_packages = diesel_core_db.with_conn(|conn| { - CoreRepository::list_filtered( - conn, - Some(&repo_name), - None, - None, - None, - None, - None, - None, - None, - ) - })?; - - for pkg in installed_packages { - let exists = metadata_db - .with_conn(|conn| MetadataRepository::exists_by_pkg_id(conn, &pkg.pkg_id))?; - - if !exists { - let replacement = metadata_db.with_conn(|conn| { - MetadataRepository::find_replacement_pkg_id(conn, &pkg.pkg_id) - })?; - - if let Some(new_pkg_id) = replacement { - info!( - "[{}] {} is replaced by {} in {}", - Colored(Blue, "Note"), - Colored(Red, &pkg.pkg_id), - Colored(Green, &new_pkg_id), - Colored(Magenta, &repo_name) - ); - - diesel_core_db.with_conn(|conn| { - CoreRepository::update_pkg_id(conn, &repo_name, &pkg.pkg_id, &new_pkg_id) - })?; - } - } - } - - metadata_db - .with_conn(|conn| MetadataRepository::update_repo_metadata(conn, &repo.name, etag))?; - - Ok(()) - } - - fn create_diesel_core_db(&self) -> SoarResult { - let core_db_file = self.config().get_db_path()?.join("soar.db"); - if !core_db_file.exists() { - File::create(&core_db_file) - .with_context(|| format!("creating database file {}", core_db_file.display()))?; - } - - DieselDatabase::open_core(&core_db_file) - } - - fn create_metadata_manager(&self) -> SoarResult { - debug!("creating metadata manager"); - let mut manager = MetadataManager::new(); - - for repo in &self.inner.config.repositories { - if let Ok(repo_path) = repo.get_path() { - let metadata_db = repo_path.join("metadata.db"); - if metadata_db.is_file() { - trace!( - repo_name = repo.name, - "adding repository to metadata manager" - ); - manager.add_repo(&repo.name, metadata_db)?; - } - } - } - - debug!(repos = manager.repo_count(), "metadata manager created"); - Ok(manager) - } - - #[inline] - pub fn config(&self) -> &Config { - &self.inner.config - } - - /// Reads the etag from an existing metadata database. - fn read_repo_etag(&self, repo: &Repository) -> Option { - let repo_path = repo.get_path().ok()?; - let metadata_db = repo_path.join("metadata.db"); - - if !metadata_db.exists() { - return None; - } - - let mut conn = DbConnection::open(&metadata_db, DbType::Metadata).ok()?; - MetadataRepository::get_repo_etag(conn.conn()) - .ok() - .flatten() - } - - /// Returns the diesel-based core database connection. - pub fn diesel_core_db(&self) -> SoarResult<&DieselDatabase> { - self.inner - .diesel_core_db - .get_or_try_init(|| self.create_diesel_core_db()) - } - - /// Returns the metadata manager for querying package metadata across all repos. - pub async fn metadata_manager(&self) -> SoarResult<&MetadataManager> { - self.inner - .metadata_manager - .get_or_try_init(|| { - async { - self.init_repo_dbs(false).await?; - self.create_metadata_manager() - } - }) - .await - } -} diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index 432bf65d..1867b476 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -1,507 +1,66 @@ -use std::sync::{atomic::Ordering, Arc}; - -use nu_ansi_term::Color::{Cyan, Green, Red}; -use soar_config::packages::{PackagesConfig, ResolvedPackage}; -use soar_core::{ - database::{ - connection::DieselDatabase, - models::{InstalledPackage, Package}, - }, - error::SoarError, - package::{ - install::InstallTarget, - query::PackageQuery, - release_source::{run_version_command, ReleaseSource}, - update::remove_old_versions, - url::UrlPackage, - }, - utils::substitute_placeholders, - SoarResult, -}; -use soar_db::repository::{ - core::{CoreRepository, SortDirection}, - metadata::MetadataRepository, -}; +use nu_ansi_term::Color::{Blue, Cyan, Green, Red}; +use soar_core::SoarResult; +use soar_operations::{update, SoarContext, UpdateReport}; use tabled::{ builder::Builder, settings::{themes::BorderCorrection, Panel, Style}, }; -use tracing::{error, info, warn}; - -use crate::{ - install::{create_install_context, install_single_package, InstallContext}, - logging::{clear_multi_progress, set_multi_progress}, - progress::{self, create_progress_bar}, - state::AppState, - utils::{ask_target_action, display_settings, icon_or, Colored, Icons}, -}; - -fn get_existing( - package: &Package, - diesel_db: &DieselDatabase, -) -> SoarResult> { - let existing = diesel_db.with_conn(|conn| { - CoreRepository::find_exact( - conn, - &package.repo_name, - &package.pkg_name, - &package.pkg_id, - &package.version, - ) - })?; +use tracing::{error, info}; - 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: Option, -} +use crate::utils::{ask_target_action, display_settings, icon_or, Colored, Icons}; pub async fn update_packages( + ctx: &SoarContext, packages: Option>, keep: bool, ask: bool, - no_verify: bool, + _no_verify: bool, ) -> SoarResult<()> { - let state = AppState::new(); - let metadata_mgr = state.metadata_manager().await?; - 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 { - let query = PackageQuery::try_from(package.as_str())?; - - let installed_pkgs: Vec = diesel_db - .with_conn(|conn| { - CoreRepository::list_filtered( - conn, - query.repo_name.as_deref(), - query.name.as_deref(), - query.pkg_id.as_deref(), - query.version.as_deref(), - Some(true), // is_installed - None, - Some(1), - Some(SortDirection::Asc), - ) - })? - .into_iter() - .map(Into::into) - .collect(); - - for pkg in installed_pkgs { - if pkg.repo_name == "local" { - 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; - } - - let new_pkg: Option = metadata_mgr - .query_repo(&pkg.repo_name, |conn| { - MetadataRepository::find_newer_version( - conn, - &pkg.pkg_name, - &pkg.pkg_id, - &pkg.version, - ) - })? - .flatten() - .map(|p| { - let mut package: Package = p.into(); - package.repo_name = pkg.repo_name.clone(); - package - }); - - if let Some(package) = new_pkg { - // Check if the new version is already installed (skip if so) - let new_version_installed = get_existing(&package, &diesel_db)?; - if let Some(ref installed) = new_version_installed { - if installed.is_installed { - continue; - } - } - - update_targets.push(InstallTarget { - package, - existing_install: Some(pkg.clone()), - pinned: pkg.pinned, - profile: Some(pkg.profile.clone()), - portable: pkg.portable_path.clone(), - portable_home: pkg.portable_home.clone(), - portable_config: pkg.portable_config.clone(), - portable_share: pkg.portable_share.clone(), - portable_cache: pkg.portable_cache.clone(), - entrypoint: None, - binaries: None, - nested_extract: None, - extract_root: None, - hooks: None, - build: None, - sandbox: None, - }) - } - } - } - } else { - let installed_packages: Vec = diesel_db - .with_conn(CoreRepository::list_updatable)? - .into_iter() - .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); - } - } + let updates = update::check_updates(ctx, packages.as_deref()).await?; - // Check repository packages for updates - for pkg in installed_packages { - if pkg.repo_name == "local" { - continue; - } - - let new_pkg: Option = metadata_mgr - .query_repo(&pkg.repo_name, |conn| { - MetadataRepository::find_newer_version( - conn, - &pkg.pkg_name, - &pkg.pkg_id, - &pkg.version, - ) - })? - .flatten() - .map(|p| { - let mut package: Package = p.into(); - package.repo_name = pkg.repo_name.clone(); - package - }); - - if let Some(package) = new_pkg { - // Check if the new version is already installed (skip if so) - let new_version_installed = get_existing(&package, &diesel_db)?; - if let Some(ref installed) = new_version_installed { - if installed.is_installed { - continue; - } - } - - // Keep existing_install to preserve portable settings. - // Install always creates a new directory based on bsum. - update_targets.push(InstallTarget { - package, - existing_install: Some(pkg.clone()), - pinned: pkg.pinned, - profile: Some(pkg.profile.clone()), - portable: pkg.portable_path.clone(), - portable_home: pkg.portable_home.clone(), - portable_config: pkg.portable_config.clone(), - portable_share: pkg.portable_share.clone(), - portable_cache: pkg.portable_cache.clone(), - entrypoint: None, - binaries: None, - nested_extract: None, - extract_root: None, - hooks: None, - build: None, - sandbox: None, - }) - } - } - } - - if update_targets.is_empty() { + if updates.is_empty() { info!("No packages to update."); return Ok(()); } - if ask { - ask_target_action(&update_targets, "update")?; - } - - let ctx = create_install_context( - update_targets.len(), - config.parallel_limit.unwrap_or(4), - None, - None, - None, - None, - None, - false, - no_verify, - ); - - 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_info.pkg_name, - url_info.new_url.as_deref(), - Some(&url_info.new_version), - None, - ) { - warn!( - "Failed to update version for '{}' in packages.toml: {}", - url_info.pkg_name, e - ); - } - } + // Display update info + for update_info in &updates { + info!( + "{}#{}: {} -> {}", + Colored(Blue, &update_info.pkg_name), + Colored(Cyan, &update_info.pkg_id), + Colored(Red, &update_info.current_version), + Colored(Green, &update_info.new_version), + ); } - Ok(()) -} - -/// Check if a resolved package has any update mechanism configured -fn has_update_source(resolved: &ResolvedPackage) -> bool { - resolved.version_command.is_some() || resolved.github.is_some() || resolved.gitlab.is_some() -} - -/// Check if a local package has an update available via its update source -fn check_local_package_update( - pkg: &InstalledPackage, - resolved_packages: &[ResolvedPackage], -) -> SoarResult> { - // Find resolved package that has an update source - let resolved = resolved_packages - .iter() - .find(|r| r.name == pkg.pkg_name && has_update_source(r)); - - let Some(resolved) = resolved else { - return Ok(None); - }; - - if resolved.pinned { - info!("Skipping {}#{} (pinned)", pkg.pkg_name, pkg.pkg_id); - return Ok(None); + if ask { + let install_targets: Vec<_> = updates.iter().map(|u| u.target.clone()).collect(); + ask_target_action(&install_targets, "update")?; } - let is_github_or_gitlab = resolved.github.is_some() || resolved.gitlab.is_some(); - - let (version, download_url, size, update_toml_url) = - if let Some(ref cmd) = resolved.version_command { - let result = match run_version_command(cmd) { - Ok(r) => r, - Err(e) => { - warn!("Failed to run version_command for {}: {}", pkg.pkg_name, e); - return Ok(None); - } - }; - - let v = result - .version - .strip_prefix('v') - .unwrap_or(&result.version) - .to_string(); - - let installed_version = pkg.version.strip_prefix('v').unwrap_or(&pkg.version); - if v == installed_version { - return Ok(None); - } - - let (url, should_update_toml_url) = match result.download_url { - Some(url) => (url, true), - None => { - match &resolved.url { - Some(url) => (substitute_placeholders(url, Some(&v)), false), - None => { - warn!( - "version_command returned no URL and no url field configured for {}", - pkg.pkg_name - ); - return Ok(None); - } - } - } - }; - - let toml_url = if is_github_or_gitlab || !should_update_toml_url { - None - } else { - Some(url.clone()) - }; - (v, url, result.size, toml_url) - } else { - let release_source = match ReleaseSource::from_resolved(resolved) { - Some(s) => s, - None => { - warn!("No release source configured for {}", pkg.pkg_name); - return Ok(None); - } - }; - let release = match release_source.resolve() { - Ok(r) => r, - Err(e) => { - warn!("Failed to check for updates for {}: {}", pkg.pkg_name, e); - return Ok(None); - } - }; - - let v = release - .version - .strip_prefix('v') - .unwrap_or(&release.version) - .to_string(); - - let installed_version = pkg.version.strip_prefix('v').unwrap_or(&pkg.version); - if v == installed_version { - return Ok(None); - } - - let url = if is_github_or_gitlab { - None - } else { - Some(release.download_url.clone()) - }; - (v, release.download_url, release.size, url) - }; - - let mut updated_url_pkg = UrlPackage::from_remote( - &download_url, - Some(&pkg.pkg_name), - Some(&version), - pkg.pkg_type.as_deref(), - Some(&pkg.pkg_id), - )?; - updated_url_pkg.size = size; + let report = update::perform_update(ctx, updates, keep).await?; + display_update_report(&report); - let target = InstallTarget { - package: updated_url_pkg.to_package(), - existing_install: Some(pkg.clone()), - 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(), - binaries: resolved.binaries.clone(), - nested_extract: resolved.nested_extract.clone(), - extract_root: resolved.extract_root.clone(), - hooks: resolved.hooks.clone(), - build: resolved.build.clone(), - sandbox: resolved.sandbox.clone(), - }; - - let url_info = UrlUpdateInfo { - pkg_name: pkg.pkg_name.clone(), - new_version: updated_url_pkg.version.clone(), - new_url: update_toml_url, - }; - - Ok(Some((target, url_info))) + Ok(()) } -pub async fn perform_update( - ctx: InstallContext, - targets: Vec, - diesel_db: DieselDatabase, - keep: bool, -) -> SoarResult<()> { - set_multi_progress(&ctx.multi_progress); - let mut handles = Vec::new(); - let fixed_width = 40; - - for (idx, target) in targets.iter().enumerate() { - let handle = spawn_update_task( - &ctx, - target.clone(), - diesel_db.clone(), - idx, - fixed_width, - keep, - ) - .await; - handles.push(handle); - } - - for handle in handles { - handle - .await - .map_err(|err| SoarError::Custom(format!("Join handle error: {err}")))?; - } - - ctx.total_progress_bar.finish_and_clear(); - clear_multi_progress(); - - for warn in ctx.warnings.lock().unwrap().iter() { - warn!("{warn}"); - } +fn display_update_report(report: &UpdateReport) { + let settings = display_settings(); + let use_icons = settings.icons(); - for error in ctx.errors.lock().unwrap().iter() { - error!("{error}"); + for err_info in &report.failed { + error!( + "Failed to update {}#{}: {}", + err_info.pkg_name, err_info.pkg_id, err_info.error + ); } - let updated_count = ctx.installed_count.load(Ordering::Relaxed); - let failed_count = ctx.failed.load(Ordering::Relaxed); - let settings = display_settings(); + let updated_count = report.updated.len(); + let failed_count = report.failed.len(); + let total_packages = updated_count + failed_count; - if settings.icons() { + if use_icons { let mut builder = Builder::new(); if updated_count > 0 { @@ -510,7 +69,7 @@ pub async fn perform_update( format!( "{}/{}", Colored(Green, updated_count), - Colored(Cyan, ctx.total_packages) + Colored(Cyan, total_packages) ), ]); } @@ -539,7 +98,7 @@ pub async fn perform_update( info!( "Updated {}/{} packages{}", updated_count, - ctx.total_packages, + total_packages, if failed_count > 0 { format!(", {} failed", failed_count) } else { @@ -547,77 +106,4 @@ pub async fn perform_update( } ); } - - Ok(()) -} - -async fn spawn_update_task( - ctx: &InstallContext, - target: InstallTarget, - diesel_db: DieselDatabase, - idx: usize, - fixed_width: usize, - keep: bool, -) -> tokio::task::JoinHandle<()> { - let permit = ctx.semaphore.clone().acquire_owned().await.unwrap(); - let progress_bar = ctx - .multi_progress - .insert_from_back(1, create_progress_bar()); - - let message = format!( - "[{}/{}] {}#{}", - idx + 1, - ctx.total_packages, - target.package.pkg_name, - target.package.pkg_id - ); - let message = if message.len() > fixed_width { - format!("{message:.fixed_width$}") - } else { - format!("{message: { - let mut warnings = ctx.warnings.lock().unwrap(); - warnings.push(err); - - if !keep { - let _ = remove_old_versions(&target.package, &diesel_db, false); - } - } - _ => { - let mut errors = ctx.errors.lock().unwrap(); - errors.push(err.to_string()); - } - } - } else { - installed_count.fetch_add(1, Ordering::Relaxed); - total_pb.inc(1); - - if !keep { - let _ = remove_old_versions(&target.package, &diesel_db, false); - } - } - - drop(permit); - }) } diff --git a/crates/soar-cli/src/use.rs b/crates/soar-cli/src/use.rs index 10ee1897..ace41d6b 100644 --- a/crates/soar-cli/src/use.rs +++ b/crates/soar-cli/src/use.rs @@ -1,47 +1,23 @@ -use std::path::PathBuf; - -use indicatif::HumanBytes; use nu_ansi_term::Color::{Blue, Cyan, Magenta, Red}; -use soar_config::config::get_config; -use soar_core::{database::models::Package, SoarResult}; -use soar_db::repository::{ - core::{CoreRepository, SortDirection}, - metadata::MetadataRepository, -}; -use soar_package::{formats::common::setup_portable_dir, integrate_package}; +use soar_core::SoarResult; +use soar_operations::{switch, SoarContext}; +use soar_utils::bytes::format_bytes; use tracing::info; -use crate::{ - state::AppState, - utils::{get_valid_selection, has_desktop_integration, mangle_package_symlinks, Colored}, -}; - -pub async fn use_alternate_package(name: &str) -> SoarResult<()> { - let state = AppState::new(); - let diesel_db = state.diesel_core_db()?; +use crate::utils::{get_valid_selection, Colored}; - let packages = diesel_db.with_conn(|conn| { - CoreRepository::list_filtered( - conn, - None, - Some(name), - None, - None, - None, - None, - None, - Some(SortDirection::Asc), - ) - })?; +pub async fn use_alternate_package(ctx: &SoarContext, name: &str) -> SoarResult<()> { + let variants = switch::list_variants(ctx, name)?; - if packages.is_empty() { + if variants.is_empty() { info!("Package is not installed"); return Ok(()); } - for (idx, package) in packages.iter().enumerate() { + for (idx, variant) in variants.iter().enumerate() { + let package = &variant.package; info!( - active = !package.unlinked, + active = variant.is_active, pkg_name = package.pkg_name, pkg_id = package.pkg_id, repo_name = package.repo_name, @@ -59,113 +35,25 @@ pub async fn use_alternate_package(name: &str) -> SoarResult<()> { .map(|pkg_type| format!(":{}", Colored(Magenta, &pkg_type))) .unwrap_or_default(), Colored(Magenta, &package.version), - Colored(Magenta, HumanBytes(package.size as u64)), - package - .unlinked - .then(String::new) - .unwrap_or_else(|| format!(" {}", Colored(Red, "*"))) + Colored(Magenta, format_bytes(package.size, 2)), + if variant.is_active { + format!(" {}", Colored(Red, "*")) + } else { + String::new() + } ); } - if packages.len() == 1 { + if variants.len() == 1 { return Ok(()); } - let selection = get_valid_selection(packages.len())?; - let selected_package = packages.into_iter().nth(selection).unwrap(); - - let pkg_name = &selected_package.pkg_name; - let pkg_id = &selected_package.pkg_id; - let checksum = selected_package.checksum.as_deref(); - - diesel_db.transaction(|conn| { - CoreRepository::unlink_others_by_checksum(conn, pkg_name, pkg_id, checksum) - })?; - - let bin_dir = get_config().get_bin_path()?; - let install_dir = PathBuf::from(&selected_package.installed_path); - - let symlinks = mangle_package_symlinks( - &install_dir, - &bin_dir, - selected_package.provides.as_deref(), - &selected_package.pkg_name, - &selected_package.version, - None, - None, - ) - .await?; - - let actual_bin = symlinks.first().map(|(src, _)| src.as_path()); - - let metadata_mgr = state.metadata_manager().await?; - let pkg: Vec = metadata_mgr - .query_repo(&selected_package.repo_name, |conn| { - MetadataRepository::find_filtered( - conn, - Some(name), - Some(&selected_package.pkg_id), - None, - Some(1), - None, - ) - })? - .unwrap_or_default() - .into_iter() - .map(|p| { - let mut package: Package = p.into(); - package.repo_name = selected_package.repo_name.clone(); - package - }) - .collect(); - - let installed_pkg: soar_core::database::models::InstalledPackage = selected_package.into(); - - let has_portable = installed_pkg.portable_path.is_some() - || installed_pkg.portable_home.is_some() - || installed_pkg.portable_config.is_some() - || installed_pkg.portable_share.is_some() - || installed_pkg.portable_cache.is_some(); - - if !pkg.is_empty() && pkg.iter().all(has_desktop_integration) { - integrate_package( - &install_dir, - &installed_pkg, - actual_bin, - installed_pkg.portable_path.as_deref(), - installed_pkg.portable_home.as_deref(), - installed_pkg.portable_config.as_deref(), - installed_pkg.portable_share.as_deref(), - installed_pkg.portable_cache.as_deref(), - ) - .await?; - } else if has_portable { - let bin_path = actual_bin - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| install_dir.join(&installed_pkg.pkg_name)); - setup_portable_dir( - &bin_path, - &installed_pkg, - installed_pkg.portable_path.as_deref(), - installed_pkg.portable_home.as_deref(), - installed_pkg.portable_config.as_deref(), - installed_pkg.portable_share.as_deref(), - installed_pkg.portable_cache.as_deref(), - )?; - } - - diesel_db.transaction(|conn| { - CoreRepository::link_by_checksum( - conn, - &installed_pkg.pkg_name, - &installed_pkg.pkg_id, - installed_pkg.checksum.as_deref(), - ) - })?; + let selection = get_valid_selection(variants.len())?; + switch::switch_variant(ctx, name, selection).await?; info!( "Switched to {}#{}", - installed_pkg.pkg_name, installed_pkg.pkg_id + variants[selection].package.pkg_name, variants[selection].package.pkg_id ); Ok(()) diff --git a/crates/soar-cli/src/utils.rs b/crates/soar-cli/src/utils.rs index 9f814f26..1e6ce625 100644 --- a/crates/soar-cli/src/utils.rs +++ b/crates/soar-cli/src/utils.rs @@ -1,32 +1,21 @@ use std::{ - collections::HashSet, fmt::Display, - fs, io::Write, - os::{unix, unix::fs::PermissionsExt as _}, - path::{Path, PathBuf}, sync::{LazyLock, RwLock}, }; -use indicatif::HumanBytes; use nu_ansi_term::Color::{self, Blue, Cyan, Green, LightRed, Magenta, Red}; use serde::Serialize; use soar_config::{ - config::get_config, - display::DisplaySettings, - packages::{BinaryMapping, PackageHooks, PackagesConfig, SandboxConfig}, - repository::get_platform_repositories, + config::get_config, display::DisplaySettings, repository::get_platform_repositories, }; use soar_core::{ - database::models::Package, error::{ErrorContext, SoarError}, package::install::InstallTarget, - utils::substitute_placeholders, SoarResult, }; -use soar_db::models::types::{PackageProvide, ProvideStrategy}; use soar_package::PackageExt; -use soar_utils::{fs::is_elf, system::platform}; +use soar_utils::{bytes::format_bytes, system::platform}; use tracing::{error, info}; pub struct Icons; @@ -167,17 +156,10 @@ pub fn select_package_interactively_with_installed( Ok(pkgs.into_iter().nth(selection)) } -pub fn has_desktop_integration(package: &Package) -> bool { - match package.desktop_integration { - Some(false) => false, - _ => get_config().has_desktop_integration(&package.repo_name), - } -} - pub fn pretty_package_size(ghcr_size: Option, size: Option) -> String { ghcr_size - .map(|size| format!("{}", Colored(Magenta, HumanBytes(size)))) - .or_else(|| size.map(|size| format!("{}", Colored(Magenta, HumanBytes(size))))) + .map(|size| format!("{}", Colored(Magenta, format_bytes(size, 2)))) + .or_else(|| size.map(|size| format!("{}", Colored(Magenta, format_bytes(size, 2))))) .unwrap_or_default() } @@ -209,13 +191,16 @@ pub fn ask_target_action(targets: &[InstallTarget], action: &str) -> SoarResult< info!( "Total: {} packages. Estimated download size: {}\n", targets.len(), - HumanBytes(targets.iter().fold(0, |acc, target| { - acc + target - .package - .ghcr_size - .or(target.package.size) - .unwrap_or_default() - })) + format_bytes( + targets.iter().fold(0, |acc, target| { + acc + target + .package + .ghcr_size + .or(target.package.size) + .unwrap_or_default() + }), + 2 + ) ); let response = interactive_ask(&format!( "Would you like to {} these packages? [{}/{}] ", @@ -234,302 +219,6 @@ pub fn ask_target_action(targets: &[InstallTarget], action: &str) -> SoarResult< Ok(()) } -pub async fn mangle_package_symlinks( - install_dir: &Path, - bin_dir: &Path, - provides: Option<&[PackageProvide]>, - pkg_name: &str, - version: &str, - entrypoint: Option<&str>, - binaries: Option<&[BinaryMapping]>, -) -> SoarResult> { - let mut symlinks = Vec::new(); - - // If binaries array is provided, use it for symlink creation - if let Some(bins) = binaries { - if !bins.is_empty() { - for mapping in bins { - let source_pattern = substitute_placeholders(&mapping.source, Some(version)); - let source_paths: Vec = fs::read_dir(install_dir) - .with_context(|| format!("reading directory {}", install_dir.display()))? - .filter_map(|entry| entry.ok()) - .filter(|entry| { - let name = entry.file_name(); - fast_glob::glob_match(&source_pattern, name.to_string_lossy().to_string()) - }) - .map(|entry| entry.path()) - .collect(); - - if source_paths.is_empty() { - return Err(SoarError::Custom(format!( - "Binary source '{}' not found in package", - source_pattern - ))); - } - - let single_match = source_paths.len() == 1; - for source_path in source_paths { - let link_name = if single_match { - mapping.link_as.as_deref() - } else { - None - } - .unwrap_or_else(|| { - source_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(&mapping.source) - }); - let link_path = bin_dir.join(link_name); - - let metadata = fs::metadata(&source_path).with_context(|| { - format!("reading metadata for {}", source_path.display()) - })?; - let mut perms = metadata.permissions(); - let mode = perms.mode(); - if mode & 0o111 == 0 { - perms.set_mode(mode | 0o755); - fs::set_permissions(&source_path, perms).with_context(|| { - format!( - "setting executable permissions on {}", - source_path.display() - ) - })?; - } - - if link_path.is_symlink() || link_path.is_file() { - std::fs::remove_file(&link_path).with_context(|| { - format!("removing existing file/symlink at {}", link_path.display()) - })?; - } - - unix::fs::symlink(&source_path, &link_path).with_context(|| { - format!( - "creating symlink {} -> {}", - source_path.display(), - link_path.display() - ) - })?; - symlinks.push((source_path, link_path)); - } - } - return Ok(symlinks); - } - } - - let mut processed_paths = HashSet::new(); - let provides = provides.unwrap_or_default(); - for provide in provides { - let real_path = install_dir.join(provide.name.clone()); - let mut symlink_targets = Vec::new(); - - if let Some(ref target) = provide.target { - if provide.strategy.is_some() { - let target_path = bin_dir.join(target); - if processed_paths.insert(target_path.clone()) { - symlink_targets.push(target_path); - } - } - }; - - let needs_original_symlink = matches!( - (provide.target.as_ref(), provide.strategy.clone()), - (Some(_), Some(ProvideStrategy::KeepBoth)) | (None, _) - ); - - if needs_original_symlink { - let original_path = bin_dir.join(&provide.name); - if processed_paths.insert(original_path.clone()) { - symlink_targets.push(original_path); - } - } - - for target_path in symlink_targets { - if target_path.is_symlink() || target_path.is_file() { - std::fs::remove_file(&target_path) - .with_context(|| format!("removing provide {}", target_path.display()))?; - } - unix::fs::symlink(&real_path, &target_path).with_context(|| { - format!( - "creating symlink {} -> {}", - real_path.display(), - target_path.display() - ) - })?; - symlinks.push((real_path.clone(), target_path)); - } - } - - if provides.is_empty() { - let soar_syms = install_dir.join("SOAR_SYMS"); - let (is_syms, binaries_dir) = if soar_syms.is_dir() { - (true, soar_syms.as_path()) - } else { - (false, install_dir) - }; - - if let Some(executable) = - find_executable(install_dir, binaries_dir, is_syms, pkg_name, entrypoint)? - { - let metadata = fs::metadata(&executable) - .with_context(|| format!("reading metadata for {}", executable.display()))?; - let mut perms = metadata.permissions(); - let mode = perms.mode(); - if mode & 0o111 == 0 { - perms.set_mode(mode | 0o755); - fs::set_permissions(&executable, perms).with_context(|| { - format!("setting executable permissions on {}", executable.display()) - })?; - } - - let symlink_name = bin_dir.join(pkg_name); - if symlink_name.is_symlink() || symlink_name.is_file() { - std::fs::remove_file(&symlink_name).with_context(|| { - format!( - "removing existing file/symlink at {}", - symlink_name.display() - ) - })?; - } - unix::fs::symlink(&executable, &symlink_name).with_context(|| { - format!( - "creating symlink {} -> {}", - executable.display(), - symlink_name.display() - ) - })?; - symlinks.push((executable, symlink_name)); - } - } - Ok(symlinks) -} - -/// Find executable in the install directory using fallback logic. -/// -/// Priority order: -/// 1. If entrypoint is specified, use it directly -/// 2. Exact package name match (case-sensitive) -/// 3. Case-insensitive package name match (filename or stem) -/// 4. Search in fallback directories: bin/, usr/bin/, usr/local/bin/ -/// 5. Recursive search for matching executable -/// 6. Any ELF file found -fn find_executable( - install_dir: &Path, - binaries_dir: &Path, - is_syms: bool, - pkg_name: &str, - entrypoint: Option<&str>, -) -> SoarResult> { - if let Some(entry) = entrypoint { - let entrypoint_path = install_dir.join(entry); - if entrypoint_path.is_file() { - return Ok(Some(entrypoint_path)); - } - if binaries_dir != install_dir { - let entrypoint_in_syms = binaries_dir.join(entry); - if entrypoint_in_syms.is_file() { - return Ok(Some(entrypoint_in_syms)); - } - } - } - - let files: Vec = fs::read_dir(binaries_dir) - .with_context(|| { - format!( - "reading directory {} for executable discovery", - binaries_dir.display() - ) - })? - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.is_file() && (is_syms || is_elf(p))) - .collect(); - - let pkg_name_lower = pkg_name.to_lowercase(); - - if let Some(found) = find_matching_executable(&files, pkg_name, &pkg_name_lower) { - return Ok(Some(found)); - } - - let fallback_dirs = ["bin", "usr/bin", "usr/local/bin"]; - for fallback in fallback_dirs { - let fallback_path = install_dir.join(fallback); - if fallback_path.is_dir() { - let exact_path = fallback_path.join(pkg_name); - if exact_path.is_file() && is_elf(&exact_path) { - return Ok(Some(exact_path)); - } - if let Ok(entries) = fs::read_dir(&fallback_path) { - let fallback_files: Vec = entries - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.is_file() && is_elf(p)) - .collect(); - if let Some(found) = - find_matching_executable(&fallback_files, pkg_name, &pkg_name_lower) - { - return Ok(Some(found)); - } - } - } - } - - let mut all_files = Vec::new(); - collect_executables_recursive(install_dir, &mut all_files); - - if let Some(found) = find_matching_executable(&all_files, pkg_name, &pkg_name_lower) { - return Ok(Some(found)); - } - - Ok(all_files.into_iter().next()) -} - -fn collect_executables_recursive(dir: &Path, files: &mut Vec) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if path.is_dir() { - collect_executables_recursive(&path, files); - } else if path.is_file() && is_elf(&path) { - files.push(path); - } - } -} - -fn find_matching_executable( - files: &[PathBuf], - pkg_name: &str, - pkg_name_lower: &str, -) -> Option { - files - .iter() - .find(|p| { - p.file_name() - .and_then(|n| n.to_str()) - .map(|n| n == pkg_name) - .unwrap_or(false) - }) - .or_else(|| { - files.iter().find(|p| { - p.file_name() - .and_then(|n| n.to_str()) - .map(|n| n.to_lowercase() == *pkg_name_lower) - .unwrap_or(false) - }) - }) - .or_else(|| { - files.iter().find(|p| { - p.file_stem() - .and_then(|n| n.to_str()) - .map(|n| n.to_lowercase() == *pkg_name_lower) - .unwrap_or(false) - }) - }) - .cloned() -} - pub fn parse_default_repos_arg(arg: &str) -> SoarResult { let repo = arg.trim().to_lowercase(); let supported_repos: Vec<&str> = get_platform_repositories() @@ -549,19 +238,3 @@ pub fn parse_default_repos_arg(arg: &str) -> SoarResult { ))) } } - -/// Look up hooks and sandbox configuration for a package from packages.toml. -/// Returns (None, None) if packages.toml doesn't exist or the package isn't found. -pub fn get_package_hooks(pkg_name: &str) -> (Option, Option) { - let config = match PackagesConfig::load(None) { - Ok(c) => c, - Err(_) => return (None, None), - }; - - config - .resolved_packages() - .into_iter() - .find(|p| p.name == pkg_name) - .map(|p| (p.hooks, p.sandbox)) - .unwrap_or((None, None)) -} diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 19acd588..56ac7858 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -922,10 +922,9 @@ async fn install_single_package( pkg_id: pkg.pkg_id.clone(), stage: VerifyStage::Failed("checksum mismatch".into()), }); - return Err(SoarError::Custom(format!( - "{}#{} - Invalid checksum, skipped installation.", - pkg.pkg_name, pkg.pkg_id - ))); + return Err(SoarError::Custom( + "Invalid checksum, skipped installation.".into(), + )); } (Some(ref calculated), Some(expected)) if calculated == expected => { events.emit(SoarEvent::Verifying { diff --git a/crates/soar-registry/src/metadata.rs b/crates/soar-registry/src/metadata.rs index 56e5b84d..45f4a5c1 100644 --- a/crates/soar-registry/src/metadata.rs +++ b/crates/soar-registry/src/metadata.rs @@ -11,7 +11,7 @@ use std::{ use soar_config::repository::Repository; use soar_dl::{download::Download, http_client::SHARED_AGENT, types::OverwriteMode}; -use tracing::info; +use tracing::debug; use ureq::http::{ header::{CACHE_CONTROL, ETAG, IF_NONE_MATCH, PRAGMA}, StatusCode, @@ -67,7 +67,7 @@ pub async fn fetch_public_key>(repo_path: P, pubkey_url: &str) -> return Ok(()); } - info!("Fetching public key from {}", pubkey_url); + debug!("Fetching public key from {}", pubkey_url); Download::new(pubkey_url) .output(pubkey_file.to_string_lossy().to_string()) @@ -194,7 +194,7 @@ pub async fn fetch_metadata( .map(String::from) .ok_or(RegistryError::MissingEtag)?; - info!("Fetching metadata from {}", repo.url); + debug!("Fetching metadata from {}", repo.url); let content = resp.into_body().read_to_vec()?; let metadata_content = process_metadata_content(content, &metadata_db)?; From a9ff6a8b70ee144706b5de9545d09784f5c32c75 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 14 Feb 2026 17:07:04 +0545 Subject: [PATCH 2/2] fix --- crates/soar-cli/src/health.rs | 12 +++++++- crates/soar-cli/src/install.rs | 46 ++++++++++++++++++++++++++-- crates/soar-cli/src/main.rs | 10 +++++- crates/soar-cli/src/run.rs | 16 +++------- crates/soar-cli/src/update.rs | 4 +-- crates/soar-operations/src/update.rs | 3 +- 6 files changed, 71 insertions(+), 20 deletions(-) diff --git a/crates/soar-cli/src/health.rs b/crates/soar-cli/src/health.rs index b17597fe..68b8d200 100644 --- a/crates/soar-cli/src/health.rs +++ b/crates/soar-cli/src/health.rs @@ -106,8 +106,18 @@ pub async fn remove_broken_packages(ctx: &SoarContext) -> SoarResult<()> { ); } - if !report.removed.is_empty() { + if !report.removed.is_empty() && report.failed.is_empty() { info!("Removed all broken packages"); + } else if !report.failed.is_empty() { + tracing::warn!( + "Some broken packages could not be removed: {}", + report + .failed + .iter() + .map(|f| format!("{}#{}", f.pkg_name, f.pkg_id)) + .collect::>() + .join(", ") + ); } Ok(()) diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index 14c9ff67..9ad9d2bb 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -128,7 +128,7 @@ async fn install_with_show( ctx: &SoarContext, packages: &[String], options: &InstallOptions, - _yes: bool, + yes: bool, force: bool, ask: bool, no_notes: bool, @@ -152,8 +152,48 @@ async fn install_with_show( let results = install::resolve_packages(ctx, std::slice::from_ref(package), options).await?; for result in results { - if let ResolveResult::Resolved(targets) = result { - install_targets.extend(targets); + match result { + ResolveResult::Resolved(targets) => { + install_targets.extend(targets); + } + ResolveResult::Ambiguous(amb) => { + let pkg = if yes { + amb.candidates.into_iter().next() + } else { + select_package_interactively(amb.candidates, &amb.query)? + }; + + if let Some(pkg) = pkg { + let specific_query = + format!("{}#{}:{}", pkg.pkg_name, pkg.pkg_id, pkg.repo_name); + let re_results = + install::resolve_packages(ctx, &[specific_query], options).await?; + for r in re_results { + if let ResolveResult::Resolved(targets) = r { + install_targets.extend(targets); + } + } + } + } + ResolveResult::NotFound(name) => { + error!("Package {} not found", name); + } + ResolveResult::AlreadyInstalled { + pkg_name, + pkg_id, + repo_name, + version, + } => { + warn!( + "{}#{}:{} ({}) is already installed - skipping", + pkg_name, pkg_id, repo_name, version, + ); + if !force { + info!( + "Hint: Use --force to reinstall, or --show to see other variants" + ); + } + } } } continue; diff --git a/crates/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs index 8c9063d8..7b16e072 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -196,6 +196,7 @@ async fn handle_cli() -> SoarResult<()> { setup_required_paths().unwrap(); let (ctx, progress_guard) = create_context(); + let mut run_exit_code = None; match command { cli::Commands::Install { @@ -298,7 +299,7 @@ async fn handle_cli() -> SoarResult<()> { pkg_id, repo_name, } => { - run_package( + let code = run_package( &ctx, command.as_ref(), yes, @@ -306,6 +307,9 @@ async fn handle_cli() -> SoarResult<()> { pkg_id.as_deref(), ) .await?; + if code != 0 { + run_exit_code = Some(code); + } } cli::Commands::Use { package_name, @@ -457,6 +461,10 @@ async fn handle_cli() -> SoarResult<()> { guard.finish(); } crate::progress::stop(); + + if let Some(code) = run_exit_code { + std::process::exit(code); + } } } diff --git a/crates/soar-cli/src/run.rs b/crates/soar-cli/src/run.rs index 7fd7e1cb..5135994f 100644 --- a/crates/soar-cli/src/run.rs +++ b/crates/soar-cli/src/run.rs @@ -9,7 +9,7 @@ pub async fn run_package( yes: bool, repo_name: Option<&str>, pkg_id: Option<&str>, -) -> SoarResult<()> { +) -> SoarResult { let package_name = &command[0]; let args = if command.len() > 1 { &command[1..] @@ -29,7 +29,7 @@ pub async fn run_package( }; let Some(pkg) = pkg else { - return Ok(()); + return Ok(0); }; // Re-run with selected package @@ -39,20 +39,12 @@ pub async fn run_package( match result { PrepareRunResult::Ready(path) => path, - _ => return Ok(()), + _ => return Ok(0), } } }; - // Checksum verification for cached binary - prompt user on mismatch - // Note: prepare_run already handles checksum and returns error on mismatch, - // but for the interactive CLI we handle it specially let run_result = run::execute_binary(&output_path, args)?; - // For the `run` subcommand, propagate the exit code - if run_result.exit_code != 0 { - std::process::exit(run_result.exit_code); - } - - Ok(()) + Ok(run_result.exit_code) } diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index 1867b476..9a81e18a 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -14,7 +14,7 @@ pub async fn update_packages( packages: Option>, keep: bool, ask: bool, - _no_verify: bool, + no_verify: bool, ) -> SoarResult<()> { let updates = update::check_updates(ctx, packages.as_deref()).await?; @@ -39,7 +39,7 @@ pub async fn update_packages( ask_target_action(&install_targets, "update")?; } - let report = update::perform_update(ctx, updates, keep).await?; + let report = update::perform_update(ctx, updates, keep, no_verify).await?; display_update_report(&report); Ok(()) diff --git a/crates/soar-operations/src/update.rs b/crates/soar-operations/src/update.rs index 0bef6fd8..35125270 100644 --- a/crates/soar-operations/src/update.rs +++ b/crates/soar-operations/src/update.rs @@ -398,6 +398,7 @@ pub async fn perform_update( ctx: &SoarContext, updates: Vec, keep_old: bool, + no_verify: bool, ) -> SoarResult { debug!( count = updates.len(), @@ -432,7 +433,7 @@ pub async fn perform_update( let targets: Vec = updates.into_iter().map(|u| u.target).collect(); let options = InstallOptions { - no_verify: false, + no_verify, ..Default::default() };