From f3fb8e11a85d33c786709e446685af9752cf6bc0 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 22:49:49 +0545 Subject: [PATCH 01/66] build: require compak 0.2 for single-file compression --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5db1aafe..34fdb776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,9 +412,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compak" -version = "0.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b129e47c79a2208973c9ea9af66840f1ffe533fa0aa8ad5d478d58cb4d9a898d" +checksum = "c830a2769ae55bf48f5be48a24718c9533af40e185d2fe203ade59d6802f49fe" dependencies = [ "bzip2", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 5f6310e8..8ce41e30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ blake3 = { version = "1.8.5", features = ["mmap"] } clap = { version = "4.6.1", features = ["cargo", "derive"] } clap_complete = "4.6.5" chrono = "0.4" -compak = "0.1.2" +compak = "0.2.0" diesel = { version = "2.3.10", features = [ "64-column-tables", "returning_clauses_for_sqlite_3_35", From 2798d451fb1bf8b3682cda8934bce59fb5ef5485 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 22:49:49 +0545 Subject: [PATCH 02/66] feat(registry): read the declarative index format --- crates/soar-registry/src/package.rs | 60 +++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index a94176a8..d8a28ab3 100644 --- a/crates/soar-registry/src/package.rs +++ b/crates/soar-registry/src/package.rs @@ -130,7 +130,11 @@ pub struct RemotePackage { #[serde(alias = "_disabled_reason")] pub disabled_reason: Option, - pub pkg_id: String, + /// Optional. It exists to disambiguate identically-named packages within + /// one repository; where names are already unique a repository can omit + /// it, and the name is used instead. + #[serde(default)] + pub pkg_id: Option, pub pkg_name: String, #[serde(default, deserialize_with = "empty_is_none")] @@ -139,15 +143,15 @@ pub struct RemotePackage { #[serde(default, deserialize_with = "empty_is_none")] pub pkg_type: Option, - #[serde(default, deserialize_with = "empty_is_none")] - pub pkg_webpage: Option, pub description: String, pub version: String, pub download_url: String, - #[serde(default, deserialize_with = "optional_number")] + /// Bytes. Named `size` in the port format; `size_raw` is the older + /// spelling, still accepted so existing repositories keep working. + #[serde(default, alias = "size", deserialize_with = "optional_number")] pub size_raw: Option, #[serde(default, deserialize_with = "empty_is_none")] @@ -179,8 +183,6 @@ pub struct RemotePackage { #[serde(alias = "note")] pub notes: Option>, - #[serde(alias = "tag")] - pub tags: Option>, #[serde(default, deserialize_with = "empty_is_none")] pub bsum: Option, @@ -232,6 +234,14 @@ pub struct RemotePackage { pub repology: Option>, pub snapshots: Option>, pub replaces: Option>, + /// Executables inside the artifact, as source path -> installed name. + /// Lets a package whose binary is named differently from the package + /// itself be installed from the index alone. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binaries: Option>, + /// Pinned side files to install alongside the artifact. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extra: Option>, } #[cfg(test)] @@ -249,11 +259,25 @@ mod tests { }"#; let pkg: RemotePackage = serde_json::from_str(json).unwrap(); - assert_eq!(pkg.pkg_id, "test-pkg"); + assert_eq!(pkg.pkg_id.as_deref(), Some("test-pkg")); assert_eq!(pkg.pkg_name, "test"); assert_eq!(pkg.version, "1.0.0"); } + #[test] + fn test_pkg_id_is_optional() { + let json = r#"{ + "pkg_name": "test", + "description": "A test package", + "version": "1.0.0", + "download_url": "https://example.com/test.tar.gz" + }"#; + + let pkg: RemotePackage = serde_json::from_str(json).unwrap(); + assert_eq!(pkg.pkg_id, None); + assert_eq!(pkg.pkg_name, "test"); + } + #[test] fn test_flexible_bool() { let json = r#"{ @@ -269,3 +293,25 @@ mod tests { assert_eq!(pkg.disabled, Some(true)); } } + +/// One executable inside a package artifact, as published in the index. +/// +/// `source` is relative to the extracted artifact and may be a glob, since +/// archives commonly wrap their contents in a versioned directory. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RemoteBinary { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link_as: Option, +} + +/// A pinned side file as published in the index. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RemoteExtra { + pub url: String, + pub to: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blake3: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, +} From 1d3e9ac4db537b77e456e88bf64d9c281e9c7128 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 22:49:49 +0545 Subject: [PATCH 03/66] feat(db): store binaries and side files --- crates/soar-core/src/database/models.rs | 15 ++-- .../down.sql | 1 + .../up.sql | 4 + .../down.sql | 2 + .../up.sql | 4 + .../2026-07-28-000002-0000_add_extra/down.sql | 1 + .../2026-07-28-000002-0000_add_extra/up.sql | 3 + crates/soar-db/src/migration.rs | 4 - crates/soar-db/src/models/metadata.rs | 82 ++++++++++--------- crates/soar-db/src/models/types.rs | 26 ++++++ crates/soar-db/src/repository/metadata.rs | 44 ++++++---- crates/soar-db/src/schema/metadata.rs | 4 +- 12 files changed, 127 insertions(+), 63 deletions(-) create mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/down.sql create mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/up.sql create mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/down.sql create mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/up.sql create mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/down.sql create mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/up.sql diff --git a/crates/soar-core/src/database/models.rs b/crates/soar-core/src/database/models.rs index e31edda4..cf17d4fa 100644 --- a/crates/soar-core/src/database/models.rs +++ b/crates/soar-core/src/database/models.rs @@ -3,7 +3,10 @@ use std::fmt::Display; use serde::{Deserialize, Serialize}; -use soar_db::{models::types::PackageProvide, repository::core::InstalledPackageWithPortable}; +use soar_db::{ + models::types::{PackageBinary, PackageExtra, PackageProvide}, + repository::core::InstalledPackageWithPortable, +}; use soar_package::PackageExt; /// Package maintainer information. @@ -30,7 +33,6 @@ pub struct Package { pub pkg_name: String, pub pkg_family: Option, pub pkg_type: Option, - pub pkg_webpage: Option, pub app_id: Option, pub description: String, pub version: String, @@ -46,7 +48,6 @@ pub struct Package { pub homepages: Option>, pub notes: Option>, pub source_urls: Option>, - pub tags: Option>, pub categories: Option>, pub icon: Option, pub desktop: Option, @@ -65,6 +66,10 @@ pub struct Package { pub deprecated: bool, pub desktop_integration: Option, pub portable: Option, + /// Executables inside the artifact, as source path -> installed name. + pub binaries: Option>, + /// Pinned side files installed alongside the artifact. + pub extra: Option>, } impl PackageExt for Package { @@ -244,7 +249,6 @@ impl From for Package { pkg_name: pkg.pkg_name, pkg_family: pkg.pkg_family, pkg_type: pkg.pkg_type, - pkg_webpage: pkg.pkg_webpage, app_id: pkg.app_id, description: pkg.description.unwrap_or_default(), version: pkg.version, @@ -260,7 +264,6 @@ impl From for Package { homepages: pkg.homepages, notes: pkg.notes, source_urls: pkg.source_urls, - tags: pkg.tags, categories: pkg.categories, icon: pkg.icon, desktop: pkg.desktop, @@ -279,6 +282,8 @@ impl From for Package { deprecated: false, desktop_integration: pkg.desktop_integration, portable: pkg.portable, + binaries: pkg.binaries, + extra: pkg.extra, } } } diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/down.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/down.sql new file mode 100644 index 00000000..3e1846ce --- /dev/null +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/down.sql @@ -0,0 +1 @@ +ALTER TABLE packages DROP COLUMN binaries; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/up.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/up.sql new file mode 100644 index 00000000..7b6d3041 --- /dev/null +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/up.sql @@ -0,0 +1,4 @@ +-- Where each executable lives inside the artifact, and what to call it. +-- Without this a package whose binary is named differently from the package +-- (gdu_linux_amd64 -> gdu) cannot be installed from a repository index. +ALTER TABLE packages ADD COLUMN binaries JSONB; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/down.sql b/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/down.sql new file mode 100644 index 00000000..849b7668 --- /dev/null +++ b/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE packages ADD COLUMN pkg_webpage TEXT; +ALTER TABLE packages ADD COLUMN tags JSONB; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/up.sql b/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/up.sql new file mode 100644 index 00000000..2d0509f8 --- /dev/null +++ b/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/up.sql @@ -0,0 +1,4 @@ +-- pkg_webpage was a repository-specific URL derivable from the package's own +-- fields, and tags were stored and displayed but never searched or queried. +ALTER TABLE packages DROP COLUMN pkg_webpage; +ALTER TABLE packages DROP COLUMN tags; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/down.sql b/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/down.sql new file mode 100644 index 00000000..5be03fee --- /dev/null +++ b/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/down.sql @@ -0,0 +1 @@ +ALTER TABLE packages DROP COLUMN extra; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/up.sql b/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/up.sql new file mode 100644 index 00000000..ce483898 --- /dev/null +++ b/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/up.sql @@ -0,0 +1,3 @@ +-- Pinned side files installed alongside the artifact, typically a licence the +-- artifact itself does not carry. +ALTER TABLE packages ADD COLUMN extra JSONB; diff --git a/crates/soar-db/src/migration.rs b/crates/soar-db/src/migration.rs index 5c2fc837..343ab005 100644 --- a/crates/soar-db/src/migration.rs +++ b/crates/soar-db/src/migration.rs @@ -110,10 +110,6 @@ pub fn migrate_json_to_jsonb( "UPDATE packages SET source_urls = jsonb(source_urls) WHERE {}", json_condition("source_urls") ), - format!( - "UPDATE packages SET tags = jsonb(tags) WHERE {}", - json_condition("tags") - ), format!( "UPDATE packages SET categories = jsonb(categories) WHERE {}", json_condition("categories") diff --git a/crates/soar-db/src/models/metadata.rs b/crates/soar-db/src/models/metadata.rs index dee3beb3..58fac048 100644 --- a/crates/soar-db/src/models/metadata.rs +++ b/crates/soar-db/src/models/metadata.rs @@ -1,7 +1,11 @@ use diesel::{prelude::*, sqlite::Sqlite}; use serde_json::Value; -use crate::{json_vec, models::types::PackageProvide, schema::metadata::*}; +use crate::{ + json_vec, + models::types::{PackageBinary, PackageExtra, PackageProvide}, + schema::metadata::*, +}; #[derive(Debug, Clone, Selectable)] pub struct Package { @@ -10,7 +14,6 @@ pub struct Package { pub pkg_name: String, pub pkg_family: Option, pub pkg_type: Option, - pub pkg_webpage: Option, pub app_id: Option, pub description: Option, pub version: String, @@ -28,7 +31,6 @@ pub struct Package { pub homepages: Option>, pub notes: Option>, pub source_urls: Option>, - pub tags: Option>, pub categories: Option>, pub build_id: Option, pub build_date: Option, @@ -41,6 +43,10 @@ pub struct Package { pub soar_syms: bool, pub desktop_integration: Option, pub portable: Option, + /// Executables inside the artifact, as source path -> installed name. + pub binaries: Option>, + /// Pinned side files installed alongside the artifact. + pub extra: Option>, } impl Queryable for Package { @@ -52,7 +58,6 @@ impl Queryable for Package { Option, Option, Option, - Option, String, Option, String, @@ -69,7 +74,6 @@ impl Queryable for Package { Option, Option, Option, - Option, Option, Option, Option, @@ -81,6 +85,8 @@ impl Queryable for Package { bool, Option, Option, + Option, + Option, ); fn build(row: Self::Row) -> diesel::deserialize::Result { @@ -90,37 +96,37 @@ impl Queryable for Package { pkg_name: row.2, pkg_family: row.3, pkg_type: row.4, - pkg_webpage: row.5, - app_id: row.6, - description: row.7, - version: row.8, - licenses: json_vec!(row.9), - download_url: row.10, - size: row.11, - ghcr_pkg: row.12, - ghcr_size: row.13, - ghcr_blob: row.14, - ghcr_url: row.15, - bsum: row.16, - icon: row.17, - desktop: row.18, - appstream: row.19, - homepages: json_vec!(row.20), - notes: json_vec!(row.21), - source_urls: json_vec!(row.22), - tags: json_vec!(row.23), - categories: json_vec!(row.24), - build_id: row.25, - build_date: row.26, - build_action: row.27, - build_script: row.28, - build_log: row.29, - provides: json_vec!(row.30), - snapshots: json_vec!(row.31), - replaces: json_vec!(row.32), - soar_syms: row.33, - desktop_integration: row.34, - portable: row.35, + app_id: row.5, + description: row.6, + version: row.7, + licenses: json_vec!(row.8), + download_url: row.9, + size: row.10, + ghcr_pkg: row.11, + ghcr_size: row.12, + ghcr_blob: row.13, + ghcr_url: row.14, + bsum: row.15, + icon: row.16, + desktop: row.17, + appstream: row.18, + homepages: json_vec!(row.19), + notes: json_vec!(row.20), + source_urls: json_vec!(row.21), + categories: json_vec!(row.22), + build_id: row.23, + build_date: row.24, + build_action: row.25, + build_script: row.26, + build_log: row.27, + provides: json_vec!(row.28), + snapshots: json_vec!(row.29), + replaces: json_vec!(row.30), + soar_syms: row.31, + desktop_integration: row.32, + portable: row.33, + binaries: json_vec!(row.34), + extra: json_vec!(row.35), }) } } @@ -190,7 +196,6 @@ pub struct NewPackage<'a> { pub pkg_name: &'a str, pub pkg_family: Option<&'a str>, pub pkg_type: Option<&'a str>, - pub pkg_webpage: Option<&'a str>, pub app_id: Option<&'a str>, pub description: Option<&'a str>, pub version: &'a str, @@ -208,7 +213,6 @@ pub struct NewPackage<'a> { pub homepages: Option, pub notes: Option, pub source_urls: Option, - pub tags: Option, pub categories: Option, pub build_id: Option<&'a str>, pub build_date: Option<&'a str>, @@ -221,6 +225,8 @@ pub struct NewPackage<'a> { pub soar_syms: bool, pub desktop_integration: Option, pub portable: Option, + pub binaries: Option, + pub extra: Option, } #[derive(Default, Insertable)] diff --git a/crates/soar-db/src/models/types.rs b/crates/soar-db/src/models/types.rs index e75a17dd..2fac0e26 100644 --- a/crates/soar-db/src/models/types.rs +++ b/crates/soar-db/src/models/types.rs @@ -137,3 +137,29 @@ mod tests { assert!(!PackageProvide::from_string("@../evil").is_safe()); } } + +/// One executable inside a package artifact. +/// +/// `source` is a path relative to the extracted artifact and may be a glob, +/// since archives often wrap their contents in a versioned directory. +/// `link_as` is the name to install it under, defaulting to the file's own. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PackageBinary { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link_as: Option, +} + +/// A side file installed alongside the artifact, pinned by hash. +/// +/// Exists because some artifacts are a bare binary with no room for a licence, +/// and a package manager that redistributes them still owes one. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PackageExtra { + pub url: String, + pub to: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blake3: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, +} diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index cc1854a8..318acfec 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -484,7 +484,7 @@ impl MetadataRepository { conn: &mut SqliteConnection, metadata: &[RemotePackage], repo_name: &str, - ) -> QueryResult<()> { + ) -> QueryResult { debug!( repo_name = repo_name, count = metadata.len(), @@ -502,23 +502,29 @@ impl MetadataRepository { .execute(conn)?; trace!(repo_name = repo_name, "repository record upserted"); + let mut imported = 0usize; for package in metadata { - Self::insert_remote_package(conn, package)?; + if Self::insert_remote_package(conn, package)? { + imported += 1; + } } debug!( repo_name = repo_name, - count = metadata.len(), + offered = metadata.len(), + imported, "package import completed" ); - Ok(()) + Ok(imported) }) } /// Inserts a single remote package. + /// Returns whether the package was accepted; rejected entries are skipped + /// rather than failing the whole import. fn insert_remote_package( conn: &mut SqliteConnection, package: &RemotePackage, - ) -> QueryResult<()> { + ) -> QueryResult { trace!( pkg_id = package.pkg_id, pkg_name = package.pkg_name, @@ -526,15 +532,25 @@ impl MetadataRepository { "inserting remote package" ); + // An absent or empty pkg_id falls back to the name rather than + // rejecting the package: it only ever existed to disambiguate + // identical names, so a repository whose names are unique has nothing + // to say here. + let pkg_id = package + .pkg_id + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(&package.pkg_name); + // pkg_name and pkg_id are joined into the install dir and interpolated // into resource paths, so a name with separators or '..' would escape it. - if !is_safe_component(&package.pkg_name) || !is_safe_component(&package.pkg_id) { + if !is_safe_component(&package.pkg_name) || !is_safe_component(pkg_id) { warn!( - pkg_id = package.pkg_id, + pkg_id, pkg_name = package.pkg_name, "skipping package with unsafe path component in pkg_name/pkg_id" ); - return Ok(()); + return Ok(false); } let provides = package.provides.as_ref().map(|vec| { @@ -556,11 +572,10 @@ impl MetadataRepository { }); let new_package = NewPackage { - pkg_id: &package.pkg_id, + pkg_id, pkg_name: &package.pkg_name, pkg_family: package.pkg_family.as_deref(), pkg_type: package.pkg_type.as_deref(), - pkg_webpage: package.pkg_webpage.as_deref(), app_id: package.app_id.as_deref(), description: Some(&package.description), version: &package.version, @@ -578,7 +593,6 @@ impl MetadataRepository { homepages: Some(json!(package.homepages)), notes: Some(json!(package.notes)), source_urls: Some(json!(package.src_urls)), - tags: Some(json!(&package.tags)), categories: Some(json!(package.categories)), build_id: package.build_id.as_deref(), build_date: package.build_date.as_deref(), @@ -591,6 +605,8 @@ impl MetadataRepository { soar_syms: package.soar_syms.unwrap_or(false), desktop_integration: package.desktop_integration, portable: package.portable, + binaries: package.binaries.as_ref().map(|b| json!(b)), + extra: package.extra.as_ref().map(|e| json!(e)), }; let inserted = diesel::insert_into(packages::table) @@ -600,8 +616,8 @@ impl MetadataRepository { .execute(conn)?; if inserted == 0 { - trace!(pkg_id = package.pkg_id, "package already exists, skipping"); - return Ok(()); + trace!(pkg_id, "package already exists, skipping"); + return Ok(false); } let package_id = Self::last_insert_id(conn)?; @@ -615,7 +631,7 @@ impl MetadataRepository { } } - Ok(()) + Ok(true) } /// Extracts name and contact from maintainer string format "Name (contact)". diff --git a/crates/soar-db/src/schema/metadata.rs b/crates/soar-db/src/schema/metadata.rs index 72e513b8..890f8bbb 100644 --- a/crates/soar-db/src/schema/metadata.rs +++ b/crates/soar-db/src/schema/metadata.rs @@ -21,7 +21,6 @@ diesel::table! { pkg_name -> Text, pkg_family -> Nullable, pkg_type -> Nullable, - pkg_webpage -> Nullable, app_id -> Nullable, description -> Nullable, version -> Text, @@ -39,7 +38,6 @@ diesel::table! { homepages -> Nullable, notes -> Nullable, source_urls -> Nullable, - tags -> Nullable, categories -> Nullable, build_id -> Nullable, build_date -> Nullable, @@ -52,6 +50,8 @@ diesel::table! { soar_syms -> Bool, desktop_integration -> Nullable, portable -> Nullable, + binaries -> Nullable, + extra -> Nullable, } } From 6a0ad6b7963f7179d20bab8aea2e5f5a03339c7e Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 22:49:49 +0545 Subject: [PATCH 04/66] fix(dl): extract by content, not declared type --- crates/soar-dl/src/download.rs | 39 ++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/soar-dl/src/download.rs b/crates/soar-dl/src/download.rs index 4f3e3789..b7f84049 100644 --- a/crates/soar-dl/src/download.rs +++ b/crates/soar-dl/src/download.rs @@ -322,16 +322,37 @@ impl Download { remove_resume(&output_path)?; + // Extraction is driven by what the file actually is, not by what the + // caller guessed it would be. compak detects by magic number, so an + // ELF (an AppImage, say) is never mistaken for an archive even when + // extraction was requested. if self.extract { - let extract_dir = self.extract_to.unwrap_or_else(|| { - output_path - .parent() - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")) - }); - debug!(archive = %output_path.display(), dest = %extract_dir.display(), "extracting archive"); - - compak::extract_archive(&output_path, &extract_dir)?; + match compak::detect_from_file(&output_path) { + Ok(format) => { + let extract_dir = self.extract_to.unwrap_or_else(|| { + output_path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + }); + debug!(archive = %output_path.display(), dest = %extract_dir.display(), + ?format, "extracting archive"); + // A bare .gz or .bz2 of a single file shares its magic + // number with the tar-wrapped form, so detection can be + // right about the compression and wrong about the + // container. Leave the download in place rather than + // failing the install outright. + if let Err(e) = compak::extract_archive(&output_path, &extract_dir) { + warn!(archive = %output_path.display(), error = %e, + "extraction failed, installing the download as-is"); + std::fs::remove_dir_all(&extract_dir).ok(); + } + } + Err(_) => { + trace!(path = %output_path.display(), + "not an archive, installing as-is"); + } + } } debug!(path = %output_path.display(), "download completed successfully"); From 5d96b13da2c45a9f1cebed1c3087a0d8151baacf Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 22:49:49 +0545 Subject: [PATCH 05/66] feat(core): install side files and fix extraction --- crates/soar-core/src/package/install.rs | 149 ++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 7 deletions(-) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 45cf3e38..46b38fa7 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -29,6 +29,7 @@ use soar_utils::{ error::FileSystemResult, fs::{safe_remove, walk_dir}, hash::calculate_checksum, + path::is_safe_component, }; use tracing::{debug, trace, warn}; @@ -45,6 +46,77 @@ use crate::{ /// /// AppImages and plain binaries are ELF and need the executable bit; archives /// are not and are extracted instead. +/// Fetch the pinned side files an artifact does not carry itself. +/// +/// Each is verified against the hash the index published, on the same footing +/// as the artifact: a licence fetched without checking it would be an +/// unverified download in the middle of an otherwise verified install. +async fn install_extras( + package: &Package, + install_dir: &Path, +) -> SoarResult<()> { + let Some(extras) = &package.extra else { + return Ok(()); + }; + for e in extras { + if !is_safe_component(&e.to) { + warn!(to = e.to, "skipping side file with unsafe name"); + continue; + } + let dest = install_dir.join(&e.to); + if dest.exists() { + continue; + } + let mut dl = Download::new(&e.url) + .output(dest.to_string_lossy()) + .overwrite(OverwriteMode::Skip); + if let Some(sum) = e.blake3.as_ref() { + dl = dl.checksum(sum); + } + match dl.execute() { + Ok(_) => { + // A side file is usually a licence, but it can be a binary an + // upstream ships separately, and that has to be runnable to be + // worth linking. + if is_elf(&dest) { + fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755)).ok(); + } + debug!(file = %dest.display(), "installed side file") + } + Err(err) => { + // A missing licence should not abandon a working install, but + // it must not pass unnoticed either. + fs::remove_file(&dest).ok(); + warn!(url = e.url, error = %err, "could not install side file"); + } + } + } + Ok(()) +} + +/// Give every ELF under `dir` the executable bit. +/// +/// Archive members carry whatever permissions the upstream tarball recorded, +/// and some ship binaries as 0644. The downloaded file is chmodded on the way +/// in, but files that only appear after extraction were being left +/// unexecutable. +fn mark_elfs_executable(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_symlink() { + continue; + } + if path.is_dir() { + mark_elfs_executable(&path); + } else if is_elf(&path) { + fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).ok(); + } + } +} + fn is_elf(path: &Path) -> bool { let mut magic = [0u8; 4]; fs::File::open(path) @@ -697,11 +769,11 @@ impl PackageInstaller { } else { let extract_dir = get_extract_dir(&self.install_dir); - let should_extract = self - .package - .pkg_type - .as_deref() - .is_some_and(|t| t == "archive"); + // Offer extraction unconditionally: the downloader detects the + // format by magic number and leaves non-archives alone. Relying + // on pkg_type meant an archive published as "static" installed + // as an unusable compressed file. + let should_extract = true; let file_path = if let Some(local_src) = local_path_from_url(url) { trace!(source = %local_src.display(), "installing from local file"); @@ -737,7 +809,8 @@ impl PackageInstaller { }; let extract_path = PathBuf::from(&extract_dir); - if extract_path.exists() { + let extracted = extract_path.exists(); + if extracted { fs::remove_file(file_path).ok(); for entry in fs::read_dir(&extract_path) @@ -748,6 +821,19 @@ impl PackageInstaller { })?; let from = entry.path(); let to = self.install_dir.join(entry.file_name()); + // Renaming a directory rewrites its `..`, so the directory + // itself needs the write bit; archives shipping 0555 dirs + // would otherwise fail to promote. + if let Ok(meta) = fs::metadata(&from) { + let mode = meta.permissions().mode(); + if meta.is_dir() && mode & 0o200 == 0 { + fs::set_permissions( + &from, + std::fs::Permissions::from_mode(mode | 0o200), + ) + .ok(); + } + } fs::rename(&from, &to).with_context(|| { format!("renaming {} to {}", from.display(), to.display()) })?; @@ -756,8 +842,38 @@ impl PackageInstaller { fs::remove_dir_all(&extract_path).ok(); } + // Archives conventionally wrap everything in one versioned + // directory (foo-1.2.3-x86_64/). Nothing downstream can guess that + // name, so when extraction leaves exactly one directory behind and + // no extract_root was given, treat it as the root. + let auto_root = if self.extract_root.is_none() && extracted { + let mut dirs = Vec::new(); + let mut files = 0usize; + if let Ok(rd) = fs::read_dir(&self.install_dir) { + for entry in rd.flatten() { + let name = entry.file_name(); + if name.to_string_lossy().starts_with('.') { + continue; + } + if entry.path().is_dir() { + dirs.push(name.to_string_lossy().to_string()); + } else { + files += 1; + } + } + } + if files == 0 && dirs.len() == 1 { + debug!(root = %dirs[0], "auto-detected single extract root"); + Some(dirs.remove(0)) + } else { + None + } + } else { + None + }; + // Handle extract_root: move contents from subdirectory to install root - if let Some(ref root_dir) = self.extract_root { + if let Some(ref root_dir) = self.extract_root.clone().or(auto_root) { let root_dir = substitute_placeholders( root_dir, Some(&self.package.version), @@ -772,6 +888,19 @@ impl PackageInstaller { root_path.display(), self.install_dir.display() ); + + // A file inside the root can share the root's own name + // (age/age). Promoting it would target the directory + // currently being drained, and the clobber below would + // delete the rest of the package. Move the root aside + // first so source and destination can never collide. + let staged = self.install_dir.join(".soar_extract_root"); + fs::remove_dir_all(&staged).ok(); + fs::rename(&root_path, &staged).with_context(|| { + format!("staging {} for promotion", root_path.display()) + })?; + let root_path = staged; + // Move all contents from root_path to install_dir for entry in fs::read_dir(&root_path).with_context(|| { format!("reading extract_root directory {}", root_path.display()) @@ -798,6 +927,12 @@ impl PackageInstaller { } } + if extracted { + mark_elfs_executable(&self.install_dir); + } + + install_extras(&self.package, &self.install_dir).await?; + // Handle nested_extract: extract an archive within the package if let Some(ref nested_archive) = self.nested_extract { let nested_archive = substitute_placeholders( From fa89bd457f8cc7ce1b152b1b0ba030e2fa6b2fd7 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 22:49:49 +0545 Subject: [PATCH 06/66] fix(operations): resolve binaries by path before name --- crates/soar-operations/src/install.rs | 17 ++++++- crates/soar-operations/src/utils.rs | 71 +++++++++++++++++++++++---- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index dd342042..7d3bea41 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -10,7 +10,7 @@ use std::{ }; use minisign_verify::{PublicKey, Signature}; -use soar_config::utils::default_install_patterns; +use soar_config::{packages::BinaryMapping, utils::default_install_patterns}; use soar_core::{ database::{ connection::{DieselDatabase, MetadataManager}, @@ -1042,6 +1042,19 @@ async fn install_single_package( stage: InstallStage::LinkingBinaries, }); + // A repository can describe where its executables live; local + // configuration still wins, so an override in packages.toml is never + // silently replaced by whatever the index says. + let index_binaries: Option> = pkg.binaries.as_ref().map(|bins| { + bins.iter() + .map(|b| BinaryMapping { + source: b.source.clone(), + link_as: b.link_as.clone(), + }) + .collect() + }); + let binaries = target.binaries.clone().or(index_binaries); + let symlinks = mangle_package_symlinks( &install_dir, &bin_dir, @@ -1049,7 +1062,7 @@ async fn install_single_package( &pkg.pkg_name, &pkg.version, target.entrypoint.as_deref(), - target.binaries.as_deref(), + binaries.as_deref(), target.arch_map.as_ref(), ) .await?; diff --git a/crates/soar-operations/src/utils.rs b/crates/soar-operations/src/utils.rs index bf7d65fa..8eef3273 100644 --- a/crates/soar-operations/src/utils.rs +++ b/crates/soar-operations/src/utils.rs @@ -89,6 +89,27 @@ fn create_provide_symlinks( /// Creates symlinks from installed package binaries to the bin directory. #[allow(clippy::too_many_arguments)] +/// Every regular file under `dir`, recursively, skipping symlinks and the +/// bookkeeping entries soar writes alongside a package. +fn walk_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = fs::read_dir(dir) else { + return out; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_symlink() { + continue; + } + if path.is_dir() { + out.extend(walk_files(&path)); + } else if !entry.file_name().to_string_lossy().starts_with('.') { + out.push(path); + } + } + out +} + pub async fn mangle_package_symlinks( install_dir: &Path, bin_dir: &Path, @@ -106,15 +127,47 @@ pub async fn mangle_package_symlinks( for mapping in bins { let source_pattern = substitute_placeholders(&mapping.source, Some(version), arch_map); - 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(); + // Try the most specific reading of the pattern first. Falling + // straight back to the file name would pick an arbitrary one + // when an archive ships the same binary for several + // architectures, each under its own directory. + let files = walk_files(install_dir); + let rel_of = |path: &PathBuf| { + path.strip_prefix(install_dir) + .unwrap_or(path) + .to_string_lossy() + .to_string() + }; + let matching = |pat: &str| -> Vec { + files + .iter() + .filter(|p| fast_glob::glob_match(pat, rel_of(p))) + .cloned() + .collect() + }; + + let mut source_paths = matching(&source_pattern); + // An archive with a single top-level directory has it promoted + // away, so the recorded path still carries a component the + // installed tree no longer has. + if source_paths.is_empty() { + if let Some((_, rest)) = source_pattern.split_once('/') { + source_paths = matching(rest); + } + } + // Last resort: the artifact was rearranged and only the name + // survives. Ambiguity here is handled below. + if source_paths.is_empty() { + source_paths = files + .iter() + .filter(|p| { + p.file_name() + .map(|n| fast_glob::glob_match(&source_pattern, n.to_string_lossy().to_string())) + .unwrap_or(false) + }) + .cloned() + .collect(); + } if source_paths.is_empty() { return Err(SoarError::Custom(format!( From 04d44b151aecf57727150055283c876d85d45ae5 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 22:49:49 +0545 Subject: [PATCH 07/66] fix(cli): report packages rejected during import --- crates/soar-cli/src/json2db.rs | 21 ++++++++++++++++++--- crates/soar-cli/src/list.rs | 8 -------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/soar-cli/src/json2db.rs b/crates/soar-cli/src/json2db.rs index f94460ab..c882c4f1 100644 --- a/crates/soar-cli/src/json2db.rs +++ b/crates/soar-cli/src/json2db.rs @@ -8,7 +8,7 @@ use soar_db::{ connection::DbConnection, migration::DbType, repository::metadata::MetadataRepository, }; use soar_registry::RemotePackage; -use tracing::info; +use tracing::{info, warn}; /// Converts JSON metadata file to SQLite database. pub fn json_to_db(input_path: &str, output_path: &str, repo_name: Option<&str>) -> SoarResult<()> { @@ -49,11 +49,26 @@ pub fn json_to_db(input_path: &str, output_path: &str, repo_name: Option<&str>) let mut conn = DbConnection::open(output_path, DbType::Metadata) .map_err(|e| SoarError::Custom(format!("opening database: {}", e)))?; - MetadataRepository::import_packages(conn.conn(), &packages, repo_name) + // Packages with an unsafe pkg_name/pkg_id are skipped during import. + // Reporting success while writing nothing hides that entirely, which is + // how an empty pkg_id silently produced an empty database. + let imported = MetadataRepository::import_packages(conn.conn(), &packages, repo_name) .map_err(|e| SoarError::Custom(format!("importing packages: {}", e)))?; + let skipped = packages.len() - imported; + if imported == 0 { + return Err(SoarError::Custom(format!( + "imported 0 of {} packages; every entry was rejected, most likely \ + an unsafe or empty pkg_name/pkg_id", + packages.len() + ))); + } + if skipped > 0 { + warn!(skipped, "some packages were rejected during import"); + } + info!( - count = packages.len(), + count = imported, output = %output_path.display(), "Successfully converted JSON to SQLite database" ); diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index ed8181a7..45d2b064 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -237,13 +237,6 @@ pub async fn query_package(ctx: &SoarContext, query_str: String) -> SoarResult<( ]); } - if let Some(ref webindex) = package.pkg_webpage { - builder.push_record([ - format!("{} Index", Icons::LINK), - Colored(Blue, webindex).to_string(), - ]); - } - let table = builder .build() .with(Style::rounded()) @@ -273,7 +266,6 @@ pub async fn query_package(ctx: &SoarContext, query_str: String) -> SoarResult<( build_script = package.build_script, ghcr_blob = package.ghcr_blob, ghcr_pkg = package.ghcr_pkg, - pkg_webpage = package.pkg_webpage, "\n{table}" ); } From 6557122dbf910fa75fc7150b3a165aa2002c874b Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 23:18:28 +0545 Subject: [PATCH 08/66] feat: select variants by family, deprecate pkg_id --- crates/soar-cli/src/apply.rs | 12 ++-- crates/soar-cli/src/download.rs | 6 +- crates/soar-cli/src/health.rs | 12 ++-- crates/soar-cli/src/inspect.rs | 2 + crates/soar-cli/src/install.rs | 38 +++++------ crates/soar-cli/src/list.rs | 9 +-- crates/soar-cli/src/progress.rs | 79 ++++++++++------------- crates/soar-cli/src/remove.rs | 11 ++-- crates/soar-cli/src/update.rs | 7 +- crates/soar-cli/src/use.rs | 7 +- crates/soar-cli/src/utils.rs | 8 +-- crates/soar-core/src/package/install.rs | 4 +- crates/soar-core/src/package/local.rs | 2 +- crates/soar-core/src/package/query.rs | 15 ++++- crates/soar-core/src/package/remove.rs | 6 +- crates/soar-core/src/package/url.rs | 4 +- crates/soar-db/src/repository/metadata.rs | 14 ++-- crates/soar-operations/src/apply.rs | 10 +-- crates/soar-operations/src/install.rs | 41 +++++++----- crates/soar-operations/src/run.rs | 13 ++-- crates/soar-operations/src/search.rs | 2 + crates/soar-operations/src/switch.rs | 1 + crates/soar-registry/src/package.rs | 11 +++- 23 files changed, 168 insertions(+), 146 deletions(-) diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index 971c19bb..ada6674e 100644 --- a/crates/soar-cli/src/apply.rs +++ b/crates/soar-cli/src/apply.rs @@ -88,9 +88,9 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { builder.push_record([ format!("{}", Colored(Green, icon_or("+", "+"))), format!( - "{}#{}", + "{}", Colored(Blue, &pkg.pkg_name), - Colored(Cyan, &pkg.pkg_id) + ), format!("{}", Colored(Green, &pkg.version)), format!("{}", Colored(Magenta, &pkg.repo_name)), @@ -106,9 +106,9 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { builder.push_record([ format!("{}", Colored(Yellow, icon_or("~", "~"))), format!( - "{}#{}", + "{}", Colored(Blue, &pkg.pkg_name), - Colored(Cyan, &pkg.pkg_id) + ), format!( "{} -> {}", @@ -124,9 +124,9 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { builder.push_record([ format!("{}", Colored(Red, icon_or("-", "-"))), format!( - "{}#{}", + "{}", Colored(Blue, &pkg.pkg_name), - Colored(Cyan, &pkg.pkg_id) + ), format!("{}", Colored(Yellow, &pkg.version)), format!("{}", Colored(Magenta, &pkg.repo_name)), diff --git a/crates/soar-cli/src/download.rs b/crates/soar-cli/src/download.rs index 57eb5192..f1c305c0 100644 --- a/crates/soar-cli/src/download.rs +++ b/crates/soar-cli/src/download.rs @@ -153,6 +153,7 @@ pub async fn handle_direct_downloads( MetadataRepository::find_filtered( conn, query.name.as_deref(), + None, query.pkg_id.as_deref(), None, None, @@ -173,6 +174,7 @@ pub async fn handle_direct_downloads( conn, query.name.as_deref(), query.pkg_id.as_deref(), + query.family.as_deref(), None, None, None, @@ -211,8 +213,8 @@ pub async fn handle_direct_downloads( let package = package.resolve(query.version.as_deref()); info!( - "Downloading package: {}#{}", - package.pkg_name, package.pkg_id + "Downloading package: {}", + package.pkg_name ); if let Some(ref url) = package.ghcr_blob { let mut dl = OciDownload::new(url.as_str()).overwrite(ctx.get_overwrite_mode()); diff --git a/crates/soar-cli/src/health.rs b/crates/soar-cli/src/health.rs index 68b8d200..9cbf4e2a 100644 --- a/crates/soar-cli/src/health.rs +++ b/crates/soar-cli/src/health.rs @@ -1,4 +1,4 @@ -use nu_ansi_term::Color::{Blue, Cyan, Green, Red, Yellow}; +use nu_ansi_term::Color::{Blue, Green, Red, Yellow}; use soar_core::SoarResult; use soar_operations::{health, SoarContext}; use tabled::{ @@ -61,10 +61,9 @@ pub async fn display_health(ctx: &SoarContext) -> SoarResult<()> { info!("\nBroken packages:"); for pkg in &report.broken_packages { info!( - " {} {}#{}: {}", + " {} {}: {}", Icons::ARROW, Colored(Blue, &pkg.pkg_name), - Colored(Cyan, &pkg.pkg_id), Colored(Yellow, &pkg.installed_path) ); } @@ -94,14 +93,13 @@ pub async fn remove_broken_packages(ctx: &SoarContext) -> SoarResult<()> { } for removed in &report.removed { - info!("Removed {}#{}", removed.pkg_name, removed.pkg_id); + info!("Removed {}", removed.pkg_name); } for failed in &report.failed { tracing::error!( - "Failed to remove {}#{}: {}", + "Failed to remove {}: {}", failed.pkg_name, - failed.pkg_id, failed.error ); } @@ -114,7 +112,7 @@ pub async fn remove_broken_packages(ctx: &SoarContext) -> SoarResult<()> { report .failed .iter() - .map(|f| format!("{}#{}", f.pkg_name, f.pkg_id)) + .map(|f| format!("{}", f.pkg_name)) .collect::>() .join(", ") ); diff --git a/crates/soar-cli/src/inspect.rs b/crates/soar-cli/src/inspect.rs index 8baefb38..15d9d5b5 100644 --- a/crates/soar-cli/src/inspect.rs +++ b/crates/soar-cli/src/inspect.rs @@ -71,6 +71,7 @@ pub async fn inspect_log(package: &str, inspect_type: InspectType) -> SoarResult conn, query.name.as_deref(), query.pkg_id.as_deref(), + query.family.as_deref(), None, None, Some(SortDirection::Asc), @@ -90,6 +91,7 @@ pub async fn inspect_log(package: &str, inspect_type: InspectType) -> SoarResult conn, query.name.as_deref(), query.pkg_id.as_deref(), + query.family.as_deref(), None, None, Some(SortDirection::Asc), diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index daa8e767..e69e02b1 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -77,7 +77,7 @@ pub async fn install_packages( 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); + format!("{}:{}", pkg.pkg_name, pkg.repo_name); let re_results = install::resolve_packages(ctx, &[specific_query], &options).await?; for r in re_results { @@ -97,13 +97,13 @@ pub async fn install_packages( } ResolveResult::AlreadyInstalled { pkg_name, - pkg_id, repo_name, version, + .. } => { warn!( - "{}#{}:{} ({}) is already installed - skipping", - pkg_name, pkg_id, repo_name, version, + "{}:{} ({}) is already installed - skipping", + pkg_name, repo_name, version, ); if !force { info!("Hint: Use --force to reinstall, or --show to see other variants"); @@ -161,13 +161,13 @@ async fn install_with_show( ResolveResult::Resolved(targets) => install_targets.extend(targets), ResolveResult::AlreadyInstalled { pkg_name, - pkg_id, repo_name, version, + .. } => { warn!( - "{}#{}:{} ({}) is already installed - skipping", - pkg_name, pkg_id, repo_name, version, + "{}:{} ({}) is already installed - skipping", + pkg_name, repo_name, version, ); if !force { info!("Hint: Use --force to reinstall"); @@ -201,7 +201,7 @@ async fn install_with_show( if let Some(pkg) = pkg { let specific_query = - format!("{}#{}:{}", pkg.pkg_name, pkg.pkg_id, pkg.repo_name); + format!("{}:{}", pkg.pkg_name, pkg.repo_name); let re_results = install::resolve_packages(ctx, &[specific_query], options).await?; for r in re_results { @@ -221,13 +221,13 @@ async fn install_with_show( } ResolveResult::AlreadyInstalled { pkg_name, - pkg_id, repo_name, version, + .. } => { warn!( - "{}#{}:{} ({}) is already installed - skipping", - pkg_name, pkg_id, repo_name, version, + "{}:{} ({}) is already installed - skipping", + pkg_name, repo_name, version, ); if !force { info!( @@ -249,6 +249,7 @@ async fn install_with_show( None, None, None, + None, Some(SortDirection::Asc), ) })? @@ -268,6 +269,7 @@ async fn install_with_show( None, None, None, + None, Some(SortDirection::Asc), )?; Ok(pkgs @@ -352,9 +354,8 @@ async fn install_with_show( if let Some(ref existing) = existing_install { if existing.is_installed { warn!( - "{}#{}:{} ({}) is already installed - {}", + "{}:{} ({}) is already installed - {}", existing.pkg_name, - existing.pkg_id, existing.repo_name, existing.version, if force { "reinstalling" } else { "skipping" } @@ -402,10 +403,9 @@ fn display_install_report(report: &InstallReport, no_notes: bool) { for info in &report.installed { info!( - "\n{} {}#{}:{} [{}]", + "\n{} {}:{} [{}]", icon_or(Icons::CHECK, "*"), Colored(Blue, &info.pkg_name), - Colored(Cyan, &info.pkg_id), Colored(Green, &info.repo_name), Colored(Magenta, info.install_dir.display()) ); @@ -424,7 +424,9 @@ fn display_install_report(report: &InstallReport, no_notes: bool) { } if !no_notes { - if let Some(ref notes) = info.notes { + // Most packages have nothing to say, and an empty list would + // otherwise print a heading with no content under it. + if let Some(notes) = info.notes.as_ref().filter(|n| !n.is_empty()) { info!( " {} Notes:\n {}", icon_or("📝", "-"), @@ -436,8 +438,8 @@ fn display_install_report(report: &InstallReport, no_notes: bool) { for err_info in &report.failed { error!( - "Failed to install {}#{}: {}", - err_info.pkg_name, err_info.pkg_id, err_info.error + "Failed to install {}: {}", + err_info.pkg_name, err_info.error ); } diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index 45d2b064..a3cb6d90 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -53,10 +53,9 @@ pub async fn search_packages( version = package.version, description = package.description, size = package.ghcr_size.or(package.size), - "[{}] {}#{}:{} | {} | {} - {} ({})", + "[{}] {}:{} | {} | {} - {} ({})", state_icon, Colored(Blue, &package.pkg_name), - Colored(Cyan, &package.pkg_id), Colored(Green, &package.repo_name), Colored(LightRed, &package.version), package @@ -124,9 +123,8 @@ pub async fn query_package(ctx: &SoarContext, query_str: String) -> SoarResult<( builder.push_record([ format!("{} Name", Icons::PACKAGE), format!( - "{}#{}:{}", + "{}:{}", Colored(Blue, &package.pkg_name), - Colored(Cyan, &package.pkg_id), Colored(Green, &package.repo_name) ), ]); @@ -298,10 +296,9 @@ pub async fn list_packages(ctx: &SoarContext, repo_name: Option) -> Soar repo_name = package.repo_name, pkg_type = package.pkg_type, version = package.version, - "[{}] {}#{}:{} | {} | {}", + "[{}] {}:{} | {} | {}", state_icon, Colored(Blue, &package.pkg_name), - Colored(Cyan, &package.pkg_id), Colored(Cyan, &package.repo_name), Colored(LightRed, &package.version), package diff --git a/crates/soar-cli/src/progress.rs b/crates/soar-cli/src/progress.rs index 38c3afe6..6b3d0f17 100644 --- a/crates/soar-cli/src/progress.rs +++ b/crates/soar-cli/src/progress.rs @@ -56,14 +56,8 @@ fn download_style() -> ProgressStyle { } /// 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}")) - ) +fn colored_prefix(pkg_name: &str) -> String { + Cyan.paint(pkg_name).to_string() } fn spinner_style() -> ProgressStyle { @@ -174,12 +168,12 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { 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.set_prefix(colored_prefix(&pkg_name)); pb.enable_steady_tick(Duration::from_millis(100)); jobs.insert(op_id, pb); reposition_batch!(batch_job, batch_msg); @@ -187,15 +181,15 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { 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.set_prefix(colored_prefix(&pkg_name)); pb.enable_steady_tick(Duration::from_millis(100)); pb }); @@ -217,12 +211,11 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { 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")); + pb.set_message(format!("{pkg_name}: downloaded")); } } SoarEvent::DownloadRetry { @@ -242,13 +235,13 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { 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.set_prefix(colored_prefix(&pkg_name)); pb.enable_steady_tick(Duration::from_millis(100)); pb }); @@ -261,17 +254,17 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { 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") + format!("{pkg_name}: verifying checksum") } VerifyStage::Signature => { - format!("{pkg_name}#{pkg_id}: verifying signature") + format!("{pkg_name}: verifying signature") } _ => unreachable!(), }; @@ -292,30 +285,30 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { 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") + format!("{pkg_name}: extracting") } InstallStage::ExtractingNested => { - format!("{pkg_name}#{pkg_id}: extracting nested") + format!("{pkg_name}: extracting nested") } InstallStage::LinkingBinaries => { - format!("{pkg_name}#{pkg_id}: linking binaries") + format!("{pkg_name}: linking binaries") } InstallStage::DesktopIntegration => { - format!("{pkg_name}#{pkg_id}: desktop integration") + format!("{pkg_name}: desktop integration") } InstallStage::SetupPortable => { - format!("{pkg_name}#{pkg_id}: setting up portable") + format!("{pkg_name}: setting up portable") } InstallStage::RecordingDatabase => { - format!("{pkg_name}#{pkg_id}: recording to db") + format!("{pkg_name}: recording to db") } InstallStage::RunningHook(hook) => { - format!("{pkg_name}#{pkg_id}: running {hook}") + format!("{pkg_name}: running {hook}") } InstallStage::Complete => unreachable!(), }; @@ -332,8 +325,8 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { SoarEvent::Building { op_id, pkg_name, - pkg_id, stage, + .. } => { match stage { BuildStage::Sandboxing => { @@ -350,10 +343,9 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { } MULTI.suspend(|| { eprintln!( - " {} {}#{}: {}", + " {} {}: {}", Cyan.paint("⚙"), Cyan.paint(&pkg_name), - Cyan.paint(&pkg_id), nu_ansi_term::Style::new().dimmed().paint(format!( "build ({}/{})", command_index + 1, @@ -372,29 +364,29 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { 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}") + format!("{pkg_name}: running {hook}") } RemoveStage::UnlinkingBinaries => { - format!("{pkg_name}#{pkg_id}: unlinking binaries") + format!("{pkg_name}: unlinking binaries") } RemoveStage::UnlinkingDesktop => { - format!("{pkg_name}#{pkg_id}: unlinking desktop") + format!("{pkg_name}: unlinking desktop") } RemoveStage::UnlinkingIcons => { - format!("{pkg_name}#{pkg_id}: unlinking icons") + format!("{pkg_name}: unlinking icons") } RemoveStage::RemovingDirectory => { - format!("{pkg_name}#{pkg_id}: removing files") + format!("{pkg_name}: removing files") } RemoveStage::CleaningDatabase => { - format!("{pkg_name}#{pkg_id}: cleaning db") + format!("{pkg_name}: cleaning db") } RemoveStage::Complete { .. @@ -409,7 +401,6 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { SoarEvent::UpdateCleanup { op_id, pkg_name, - pkg_id, stage, .. } => { @@ -421,7 +412,7 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { pb.finish_and_clear(); } } else { - let msg = format!("{pkg_name}#{pkg_id}: cleaning old version"); + let msg = format!("{pkg_name}: cleaning old version"); let pb = jobs.entry(op_id).or_insert_with(|| create_op_spinner(&msg)); pb.set_message(msg); } @@ -496,15 +487,14 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { 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") ); }); @@ -516,16 +506,15 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { 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) ); }); diff --git a/crates/soar-cli/src/remove.rs b/crates/soar-cli/src/remove.rs index 39d06f23..f83aa3a8 100644 --- a/crates/soar-cli/src/remove.rs +++ b/crates/soar-cli/src/remove.rs @@ -30,9 +30,8 @@ pub async fn remove_packages( ); for pkg in &pkgs { info!( - " - {}#{}:{} ({})", + " - {}:{} ({})", Colored(Blue, &pkg.pkg_name), - Colored(Cyan, &pkg.pkg_id), Colored(Green, &pkg.repo_name), Colored(LightRed, &pkg.version) ); @@ -73,15 +72,15 @@ pub async fn remove_packages( for removed in &report.removed { info!( - "Removed {}#{}:{} ({})", - removed.pkg_name, removed.pkg_id, removed.repo_name, removed.version + "Removed {}:{} ({})", + removed.pkg_name, removed.repo_name, removed.version ); } for failed in &report.failed { error!( - "Failed to remove {}#{}: {}", - failed.pkg_name, failed.pkg_id, failed.error + "Failed to remove {}: {}", + failed.pkg_name, failed.error ); } diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index 9a81e18a..ad0c64a2 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -26,9 +26,8 @@ pub async fn update_packages( // 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), ); @@ -51,8 +50,8 @@ fn display_update_report(report: &UpdateReport) { for err_info in &report.failed { error!( - "Failed to update {}#{}: {}", - err_info.pkg_name, err_info.pkg_id, err_info.error + "Failed to update {}: {}", + err_info.pkg_name, err_info.error ); } diff --git a/crates/soar-cli/src/use.rs b/crates/soar-cli/src/use.rs index ace41d6b..9b210c25 100644 --- a/crates/soar-cli/src/use.rs +++ b/crates/soar-cli/src/use.rs @@ -24,10 +24,9 @@ pub async fn use_alternate_package(ctx: &SoarContext, name: &str) -> SoarResult< pkg_type = package.pkg_type, version = package.version, size = package.size, - "[{}] {}#{}:{} ({}-{}) ({}){}", + "[{}] {}:{} ({}-{}) ({}){}", idx + 1, Colored(Blue, &package.pkg_name), - Colored(Cyan, &package.pkg_id), Colored(Cyan, &package.repo_name), package .pkg_type @@ -52,8 +51,8 @@ pub async fn use_alternate_package(ctx: &SoarContext, name: &str) -> SoarResult< switch::switch_variant(ctx, name, selection).await?; info!( - "Switched to {}#{}", - variants[selection].package.pkg_name, variants[selection].package.pkg_id + "Switched to {}", + variants[selection].package.pkg_name ); Ok(()) diff --git a/crates/soar-cli/src/utils.rs b/crates/soar-cli/src/utils.rs index 1e6ce625..9716d3c1 100644 --- a/crates/soar-cli/src/utils.rs +++ b/crates/soar-cli/src/utils.rs @@ -4,7 +4,7 @@ use std::{ sync::{LazyLock, RwLock}, }; -use nu_ansi_term::Color::{self, Blue, Cyan, Green, LightRed, Magenta, Red}; +use nu_ansi_term::Color::{self, Blue, Green, LightRed, Magenta, Red}; use serde::Serialize; use soar_config::{ config::get_config, display::DisplaySettings, repository::get_platform_repositories, @@ -142,10 +142,9 @@ pub fn select_package_interactively_with_installed( String::new() }; info!( - "[{}] {}#{}:{} | {}{}", + "[{}] {}:{} | {}{}", idx + 1, Colored(Blue, &pkg.pkg_name()), - Colored(Cyan, &pkg.pkg_id()), Colored(Green, pkg.repo_name()), Colored(LightRed, pkg.version()), installed_marker @@ -180,9 +179,8 @@ pub fn ask_target_action(targets: &[InstallTarget], action: &str) -> SoarResult< ); for target in targets { info!( - "{}#{}:{} ({})", + "{}:{} ({})", Colored(Blue, &target.package.pkg_name), - Colored(Cyan, &target.package.pkg_id), Colored(Green, &target.package.repo_name), Colored(LightRed, &target.package.version) ) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 46b38fa7..98ad85c5 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -1049,8 +1049,8 @@ impl PackageInstaller { let record_id = record_id.ok_or_else(|| { SoarError::Custom(format!( - "Failed to record installation for {}#{}: package not found in database", - pkg_name, pkg_id + "Failed to record installation for {}: package not found in database", + pkg_name )) })?; diff --git a/crates/soar-core/src/package/local.rs b/crates/soar-core/src/package/local.rs index 2e901f1d..11689584 100644 --- a/crates/soar-core/src/package/local.rs +++ b/crates/soar-core/src/package/local.rs @@ -126,8 +126,8 @@ impl LocalPackage { Ok(Self { path, - pkg_name, pkg_id, + pkg_name, version, pkg_type, size, diff --git a/crates/soar-core/src/package/query.rs b/crates/soar-core/src/package/query.rs index 9ae8df93..17d715c2 100644 --- a/crates/soar-core/src/package/query.rs +++ b/crates/soar-core/src/package/query.rs @@ -1,15 +1,20 @@ use std::sync::OnceLock; use regex::Regex; +use tracing::warn; use crate::error::SoarError; /// Parsed package query string. -/// Supports format: `name#pkg_id@version:repo` +/// +/// Supports `family/name@version:repo`, where the family narrows a name that +/// more than one project publishes. #[derive(Debug)] pub struct PackageQuery { pub name: Option, + pub family: Option, pub repo_name: Option, + /// Deprecated. Repositories no longer publish a package id. pub pkg_id: Option, pub version: Option, } @@ -22,8 +27,9 @@ impl TryFrom<&str> for PackageQuery { let re = PACKAGE_RE.get_or_init(|| { Regex::new( r"(?x) + (?:(?P[^\/\#\@:]+)\/)? # optional family before / (?P[^\/\#\@:]+)? # optional package name - (?:\#(?P[^@:]+))? # optional pkg_id after # + (?:\#(?P[^@:]+))? # deprecated pkg_id after # (?:@(?P[^:]+))? # optional version after @ (?::(?P[^:]+))?$ # optional repo after : ", @@ -43,7 +49,11 @@ impl TryFrom<&str> for PackageQuery { ))?; let name = caps.name("name").map(|m| m.as_str().to_string()); + let family = caps.name("family").map(|m| m.as_str().to_string()); let pkg_id = caps.name("pkg_id").map(|m| m.as_str().to_string()); + if pkg_id.is_some() { + warn!("#pkg_id is deprecated and will be removed; use family/name instead"); + } if pkg_id.is_none() && name.is_none() { return Err(SoarError::InvalidPackageQuery( "Either package name or pkg_id is required".into(), @@ -60,6 +70,7 @@ impl TryFrom<&str> for PackageQuery { Ok(PackageQuery { repo_name: caps.name("repo").map(|m| m.as_str().to_string()), + family, pkg_id, name, version: caps.name("version").map(|m| m.as_str().to_string()), diff --git a/crates/soar-core/src/package/remove.rs b/crates/soar-core/src/package/remove.rs index 5dbb21ac..66b0fa38 100644 --- a/crates/soar-core/src/package/remove.rs +++ b/crates/soar-core/src/package/remove.rs @@ -137,9 +137,8 @@ impl PackageRemover { version = self.package.version, repo = self.package.repo_name, installed_path = self.package.installed_path, - "removing {}#{}:{} ({})", + "removing {}:{} ({})", self.package.pkg_name, - self.package.pkg_id, self.package.repo_name, self.package.version ); @@ -241,9 +240,8 @@ impl PackageRemover { } debug!( - "removed {}#{}:{} ({}) - reclaimed {}", + "removed {}:{} ({}) - reclaimed {}", self.package.pkg_name, - self.package.pkg_id, self.package.repo_name, self.package.version, size_str diff --git a/crates/soar-core/src/package/url.rs b/crates/soar-core/src/package/url.rs index 3aa6db96..0f90b283 100644 --- a/crates/soar-core/src/package/url.rs +++ b/crates/soar-core/src/package/url.rs @@ -155,8 +155,8 @@ impl UrlPackage { Ok(Self { url: reference.to_string(), - pkg_name, pkg_id, + pkg_name, version, pkg_type, is_ghcr: true, @@ -235,8 +235,8 @@ impl UrlPackage { Ok(Self { url: url.to_string(), - pkg_name, pkg_id, + pkg_name, version, pkg_type, is_ghcr: false, diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index 318acfec..d9a530bf 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -390,10 +390,12 @@ impl MetadataRepository { } /// Finds packages with flexible filtering using Diesel DSL. + #[allow(clippy::too_many_arguments)] pub fn find_filtered( conn: &mut SqliteConnection, pkg_name: Option<&str>, pkg_id: Option<&str>, + pkg_family: Option<&str>, version: Option<&str>, limit: Option, sort_by_name: Option, @@ -403,6 +405,11 @@ impl MetadataRepository { if let Some(name) = pkg_name { query = query.filter(packages::pkg_name.eq(name)); } + if let Some(family) = pkg_family { + if family != "all" { + query = query.filter(packages::pkg_family.eq(family)); + } + } if let Some(id) = pkg_id { if id != "all" { query = query.filter(packages::pkg_id.eq(id)); @@ -464,8 +471,8 @@ impl MetadataRepository { .optional(); if let Ok(Some(ref p)) = result { debug!( - "newer version available: {}#{} -> {}", - pkg_name, pkg_id, p.version + "newer version available: {} -> {}", + pkg_name, p.version ); } result @@ -546,7 +553,6 @@ impl MetadataRepository { // into resource paths, so a name with separators or '..' would escape it. if !is_safe_component(&package.pkg_name) || !is_safe_component(pkg_id) { warn!( - pkg_id, pkg_name = package.pkg_name, "skipping package with unsafe path component in pkg_name/pkg_id" ); @@ -581,7 +587,7 @@ impl MetadataRepository { version: &package.version, licenses: Some(json!(package.licenses)), download_url: &package.download_url, - size: package.size_raw.map(|s| s as i64), + size: package.size_raw.or(package.size).map(|s| s as i64), ghcr_pkg: package.ghcr_pkg.as_deref(), ghcr_size: package.ghcr_size_raw.map(|s| s as i64), ghcr_blob: package.ghcr_blob.as_deref(), diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index 82b344c3..ed4755f1 100644 --- a/crates/soar-operations/src/apply.rs +++ b/crates/soar-operations/src/apply.rs @@ -70,6 +70,7 @@ pub async fn compute_diff( MetadataRepository::find_filtered( conn, Some(&pkg.name), + None, pkg.pkg_id.as_deref(), pkg.version.as_deref(), None, @@ -90,6 +91,7 @@ pub async fn compute_diff( conn, Some(&pkg.name), pkg.pkg_id.as_deref(), + None, pkg.version.as_deref(), None, Some(SortDirection::Asc), @@ -137,8 +139,8 @@ pub async fn compute_diff( if version_matches && existing.version == metadata_pkg.version { diff.in_sync.push(format!( - "{}#{}@{}", - existing.pkg_name, existing.pkg_id, existing.version + "{}@{}", + existing.pkg_name, existing.version )); } else if !existing.pinned || pkg.version.is_some() { let resolved_pkg = metadata_pkg.resolve(pkg.version.as_deref()); @@ -146,8 +148,8 @@ pub async fn compute_diff( diff.to_update.push((pkg.clone(), target)); } else { diff.in_sync.push(format!( - "{}#{}@{} (pinned)", - existing.pkg_name, existing.pkg_id, existing.version + "{}@{} (pinned)", + existing.pkg_name, existing.version )); } } else { diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 7d3bea41..d9ce1181 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -206,6 +206,7 @@ fn resolve_all_variants( None, None, None, + None, Some(SortDirection::Asc), ) })? @@ -225,6 +226,7 @@ fn resolve_all_variants( None, None, None, + None, Some(SortDirection::Asc), )?; Ok(pkgs @@ -268,6 +270,7 @@ fn resolve_all_variants( Some(&target_pkg_id), None, None, + None, Some(SortDirection::Asc), ) })? @@ -287,6 +290,7 @@ fn resolve_all_variants( Some(&target_pkg_id), None, None, + None, Some(SortDirection::Asc), )?; Ok(pkgs @@ -376,6 +380,7 @@ fn resolve_by_pkg_id( conn, None, query.pkg_id.as_deref(), + query.family.as_deref(), None, None, None, @@ -395,6 +400,7 @@ fn resolve_by_pkg_id( conn, None, query.pkg_id.as_deref(), + query.family.as_deref(), None, None, None, @@ -537,6 +543,7 @@ fn find_packages( MetadataRepository::find_filtered( conn, Some(&existing.pkg_name), + None, Some(&existing.pkg_id), None, None, @@ -564,6 +571,7 @@ fn find_packages( conn, query.name.as_deref(), query.pkg_id.as_deref(), + query.family.as_deref(), None, None, None, @@ -583,6 +591,7 @@ fn find_packages( conn, query.name.as_deref(), query.pkg_id.as_deref(), + query.family.as_deref(), None, None, None, @@ -815,8 +824,8 @@ async fn install_single_package( .unwrap_or(false); if !has_signing { return Err(SoarError::Custom(format!( - "Refusing to install {}#{}: no checksum or signature available to verify integrity (use --no-verify to override)", - pkg.pkg_name, pkg.pkg_id + "Refusing to install {}: no checksum or signature available to verify integrity (use --no-verify to override)", + pkg.pkg_name ))); } } @@ -827,22 +836,22 @@ async fn install_single_package( .filter(|s| s.len() >= 12) .map(|s| s[..12].to_string()) .unwrap_or_else(|| { - let input = format!("{}:{}:{}", pkg.pkg_id, pkg.pkg_name, pkg.version); + let input = format!("{}:{}", pkg.pkg_name, pkg.version); hash_string(&input)[..12].to_string() }); - // pkg_name/pkg_id are joined into install_dir and interpolated into resource - // paths downstream, so they must not be able to escape the packages dir. - if !is_safe_component(&pkg.pkg_name) || !is_safe_component(&pkg.pkg_id) { + // pkg_name is joined into install_dir and interpolated into resource paths + // downstream, so it must not be able to escape the packages dir. + if !is_safe_component(&pkg.pkg_name) { return Err(SoarError::Custom(format!( - "Refusing to install {}#{}: package name or id is not a valid path component", - pkg.pkg_name, pkg.pkg_id + "Refusing to install {}: package name is not a valid path component", + pkg.pkg_name ))); } let install_dir = config .get_packages_path(target.profile.clone())? - .join(format!("{}-{}-{}", pkg.pkg_name, pkg.pkg_id, dir_suffix)); + .join(format!("{}-{}", pkg.pkg_name, dir_suffix)); let main_binary_name = pkg .provides .as_ref() @@ -924,8 +933,8 @@ async fn install_single_package( let progress_callback = create_progress_bridge( events.clone(), op_id, - pkg.pkg_name.clone(), pkg.pkg_id.clone(), + pkg.pkg_name.clone(), ); trace!(install_dir = %install_dir.display(), "creating package installer"); @@ -959,8 +968,8 @@ async fn install_single_package( verified_sig_count = verify_signatures(pubkey, &install_dir)?; } else { warn!( - "{}#{} - Signature verification skipped as no pubkey was found.", - pkg.pkg_name, pkg.pkg_id + "{} - Signature verification skipped as no pubkey was found.", + pkg.pkg_name ); } } @@ -971,8 +980,8 @@ async fn install_single_package( if !no_verify && !skip_integrity_gate && pkg.bsum.is_none() && verified_sig_count == 0 { return Err(SoarError::Custom(format!( - "Refusing to install {}#{}: no checksum and no valid signature found to verify integrity (use --no-verify to override)", - pkg.pkg_name, pkg.pkg_id + "Refusing to install {}: no checksum and no valid signature found to verify integrity (use --no-verify to override)", + pkg.pkg_name ))); } @@ -1026,8 +1035,8 @@ async fn install_single_package( stage: VerifyStage::Failed("checksum unavailable".into()), }); return Err(SoarError::Custom(format!( - "Could not verify {}#{}: expected a checksum but none could be computed", - pkg.pkg_name, pkg.pkg_id + "Could not verify {}: expected a checksum but none could be computed", + pkg.pkg_name ))); } _ => {} diff --git a/crates/soar-operations/src/run.rs b/crates/soar-operations/src/run.rs index 8f6eb78c..42a1a966 100644 --- a/crates/soar-operations/src/run.rs +++ b/crates/soar-operations/src/run.rs @@ -36,6 +36,7 @@ pub async fn prepare_run( let package_name = query.name.as_deref().unwrap_or(package_name); let repo_name = query.repo_name.as_deref().or(repo_name); let pkg_id = query.pkg_id.as_deref().or(pkg_id); + let family = query.family.as_deref(); let version = query.version.as_deref(); let output_path = cache_bin.join(package_name); @@ -49,7 +50,8 @@ pub async fn prepare_run( conn, Some(package_name), pkg_id, - None, + family, + version, None, None, ) @@ -68,7 +70,8 @@ pub async fn prepare_run( conn, Some(package_name), pkg_id, - None, + family, + version, None, None, )?; @@ -109,8 +112,8 @@ pub async fn prepare_run( // artifacts are digest-verified during download, so they are exempt. if !no_verify && package.bsum.is_none() && package.ghcr_blob.is_none() { return Err(SoarError::Custom(format!( - "Refusing to run {}#{}: no checksum to verify integrity (use --no-verify to override)", - package.pkg_name, package.pkg_id + "Refusing to run {}: no checksum to verify integrity (use --no-verify to override)", + package.pkg_name ))); } @@ -140,8 +143,8 @@ pub async fn prepare_run( let progress_callback = create_progress_bridge( ctx.events().clone(), op_id, - package.pkg_name.clone(), package.pkg_id.clone(), + package.pkg_name.clone(), ); download_to_cache( diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index 1d99c777..94c9e54e 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -222,6 +222,7 @@ pub async fn query_package(ctx: &SoarContext, query_str: &str) -> SoarResult SoarResult, + /// Bytes, as the port format publishes them. The older format uses this + /// name for a human-readable string, which parses as no value, so the two + /// can coexist in one index without either being mistaken for the other. + #[serde(default, deserialize_with = "optional_number")] + pub size: Option, + #[serde(default, deserialize_with = "empty_is_none")] pub ghcr_pkg: Option, From 38ef3376fbd5fd6601298e9e67d4e708a31465a7 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 28 Jul 2026 23:49:52 +0545 Subject: [PATCH 09/66] fix(cli): surface log events instead of dropping them --- crates/soar-cli/src/progress.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/soar-cli/src/progress.rs b/crates/soar-cli/src/progress.rs index 6b3d0f17..e01df84e 100644 --- a/crates/soar-cli/src/progress.rs +++ b/crates/soar-cli/src/progress.rs @@ -5,11 +5,11 @@ use std::{ }; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use nu_ansi_term::Color::{Cyan, Green, Red}; +use nu_ansi_term::Color::{Cyan, Green, Red, Yellow}; use soar_dl::types::Progress; use soar_events::{ - BuildStage, InstallStage, OperationId, RemoveStage, SoarEvent, SyncStage, UpdateCleanupStage, - VerifyStage, + BuildStage, InstallStage, LogLevel, OperationId, RemoveStage, SoarEvent, SyncStage, + UpdateCleanupStage, VerifyStage, }; use crate::utils::{display_settings, progress_enabled}; @@ -523,6 +523,22 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { } } + // Emitted for things that fail without aborting the run, a + // repository that could not be synced most of all. Without a + // handler these were dropped and the failure looked like + // nothing happening. + SoarEvent::Log { level, message } => { + // Printed directly rather than through tracing: the + // subscriber writes via this same progress handle, so + // logging from inside suspend() deadlocks. + MULTI.suspend(|| match level { + LogLevel::Error => eprintln!(" {} {}", Red.paint("✗"), Red.paint(&message)), + LogLevel::Warning => eprintln!(" {} {}", Yellow.paint("!"), message), + LogLevel::Info => eprintln!(" {message}"), + LogLevel::Debug => {} + }); + } + _ => {} } } From 90f2981fb71e221a8c4887b6d8d50504eb78c77a Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 07:42:05 +0545 Subject: [PATCH 10/66] feat: track package family for installed packages --- crates/soar-cli/src/use.rs | 13 ++++++-- crates/soar-core/src/database/models.rs | 3 ++ crates/soar-core/src/package/install.rs | 1 + .../down.sql | 1 + .../up.sql | 3 ++ crates/soar-db/src/models/core.rs | 30 +++++++++++-------- crates/soar-db/src/repository/core.rs | 2 ++ crates/soar-db/src/schema/core.rs | 1 + 8 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/down.sql create mode 100644 crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/up.sql diff --git a/crates/soar-cli/src/use.rs b/crates/soar-cli/src/use.rs index 9b210c25..a4d94e29 100644 --- a/crates/soar-cli/src/use.rs +++ b/crates/soar-cli/src/use.rs @@ -19,19 +19,26 @@ pub async fn use_alternate_package(ctx: &SoarContext, name: &str) -> SoarResult< info!( active = variant.is_active, pkg_name = package.pkg_name, - pkg_id = package.pkg_id, + pkg_family = package.pkg_family, repo_name = package.repo_name, pkg_type = package.pkg_type, version = package.version, size = package.size, - "[{}] {}:{} ({}-{}) ({}){}", + "[{}] {}{}:{} ({}-{}) ({}){}", idx + 1, + // Two projects can publish the same name, so the family is what + // tells their variants apart when there is one. + package + .pkg_family + .as_ref() + .map(|f| format!("{}/", Colored(Magenta, f))) + .unwrap_or_default(), Colored(Blue, &package.pkg_name), Colored(Cyan, &package.repo_name), package .pkg_type .as_ref() - .map(|pkg_type| format!(":{}", Colored(Magenta, &pkg_type))) + .map(|pkg_type| format!("{}", Colored(Magenta, &pkg_type))) .unwrap_or_default(), Colored(Magenta, &package.version), Colored(Magenta, format_bytes(package.size, 2)), diff --git a/crates/soar-core/src/database/models.rs b/crates/soar-core/src/database/models.rs index cf17d4fa..fad381ae 100644 --- a/crates/soar-core/src/database/models.rs +++ b/crates/soar-core/src/database/models.rs @@ -139,6 +139,7 @@ pub struct InstalledPackage { pub repo_name: String, pub pkg_id: String, pub pkg_name: String, + pub pkg_family: Option, pub pkg_type: Option, pub version: String, pub size: u64, @@ -185,6 +186,7 @@ impl From for InstalledPackage { repo_name: pkg.repo_name, pkg_id: pkg.pkg_id, pkg_name: pkg.pkg_name, + pkg_family: pkg.pkg_family, pkg_type: pkg.pkg_type, version: pkg.version, size: pkg.size as u64, @@ -215,6 +217,7 @@ impl From for InstalledPackage { repo_name: pkg.repo_name, pkg_id: pkg.pkg_id, pkg_name: pkg.pkg_name, + pkg_family: pkg.pkg_family, pkg_type: pkg.pkg_type, version: pkg.version, size: pkg.size as u64, diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 98ad85c5..f96bbf70 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -327,6 +327,7 @@ impl PackageInstaller { repo_name, pkg_id, pkg_name, + pkg_family: package.pkg_family.as_deref(), pkg_type, version, size, diff --git a/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/down.sql b/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/down.sql new file mode 100644 index 00000000..a9e6e786 --- /dev/null +++ b/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/down.sql @@ -0,0 +1 @@ +ALTER TABLE packages DROP COLUMN pkg_family; diff --git a/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/up.sql b/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/up.sql new file mode 100644 index 00000000..9f693eed --- /dev/null +++ b/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/up.sql @@ -0,0 +1,3 @@ +-- Which project an installed package came from, so variants can be told apart +-- by family rather than by the package id repositories no longer publish. +ALTER TABLE packages ADD COLUMN pkg_family TEXT; diff --git a/crates/soar-db/src/models/core.rs b/crates/soar-db/src/models/core.rs index 430d0688..04a15beb 100644 --- a/crates/soar-db/src/models/core.rs +++ b/crates/soar-db/src/models/core.rs @@ -9,6 +9,7 @@ pub struct Package { pub repo_name: String, pub pkg_id: String, pub pkg_name: String, + pub pkg_family: Option, pub pkg_type: Option, pub version: String, pub size: i64, @@ -31,6 +32,7 @@ impl Queryable for Package { String, String, Option, + Option, String, i64, Option, @@ -51,19 +53,20 @@ impl Queryable for Package { repo_name: row.1, pkg_id: row.2, pkg_name: row.3, - pkg_type: row.4, - version: row.5, - size: row.6, - checksum: row.7, - installed_path: row.8, - installed_date: row.9, - profile: row.10, - pinned: row.11, - is_installed: row.12, - detached: row.13, - unlinked: row.14, - provides: json_vec!(row.15), - install_patterns: json_vec!(row.16), + pkg_family: row.4, + pkg_type: row.5, + version: row.6, + size: row.7, + checksum: row.8, + installed_path: row.9, + installed_date: row.10, + profile: row.11, + pinned: row.12, + is_installed: row.13, + detached: row.14, + unlinked: row.15, + provides: json_vec!(row.16), + install_patterns: json_vec!(row.17), }) } } @@ -86,6 +89,7 @@ pub struct NewPackage<'a> { pub repo_name: &'a str, pub pkg_id: &'a str, pub pkg_name: &'a str, + pub pkg_family: Option<&'a str>, pub pkg_type: Option<&'a str>, pub version: &'a str, pub size: i64, diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index baf6ee82..dee4e007 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -29,6 +29,7 @@ pub struct InstalledPackageWithPortable { pub repo_name: String, pub pkg_id: String, pub pkg_name: String, + pub pkg_family: Option, pub pkg_type: Option, pub version: String, pub size: i64, @@ -56,6 +57,7 @@ impl From<(Package, Option)> for InstalledPackageWithPortable { repo_name: pkg.repo_name, pkg_id: pkg.pkg_id, pkg_name: pkg.pkg_name, + pkg_family: pkg.pkg_family, pkg_type: pkg.pkg_type, version: pkg.version, size: pkg.size, diff --git a/crates/soar-db/src/schema/core.rs b/crates/soar-db/src/schema/core.rs index ae55e180..738b8ced 100644 --- a/crates/soar-db/src/schema/core.rs +++ b/crates/soar-db/src/schema/core.rs @@ -4,6 +4,7 @@ diesel::table! { repo_name -> Text, pkg_id -> Text, pkg_name -> Text, + pkg_family -> Nullable, pkg_type -> Nullable, version -> Text, size -> BigInt, From 2b589ee6c40d4e611e9eb4460507d6c5caba508f Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 08:01:48 +0545 Subject: [PATCH 11/66] feat: allow packages without an id --- crates/soar-cli/src/inspect.rs | 2 +- crates/soar-cli/src/install.rs | 4 +- crates/soar-cli/src/list.rs | 6 +- crates/soar-cli/src/run.rs | 2 +- crates/soar-cli/src/utils.rs | 2 +- crates/soar-core/src/database/models.rs | 14 +-- crates/soar-core/src/package/install.rs | 21 ++-- crates/soar-core/src/package/local.rs | 1 - crates/soar-core/src/package/remove.rs | 2 +- crates/soar-core/src/package/update.rs | 4 +- crates/soar-core/src/package/url.rs | 2 - .../down.sql | 2 + .../up.sql | 32 ++++++ crates/soar-db/src/models/core.rs | 6 +- crates/soar-db/src/repository/core.rs | 106 ++++++++++++------ crates/soar-db/src/repository/metadata.rs | 13 ++- crates/soar-db/src/schema/core.rs | 2 +- crates/soar-events/src/event.rs | 17 --- crates/soar-operations/src/apply.rs | 11 +- crates/soar-operations/src/context.rs | 16 ++- crates/soar-operations/src/health.rs | 7 -- crates/soar-operations/src/install.rs | 27 +---- crates/soar-operations/src/list.rs | 12 +- crates/soar-operations/src/progress.rs | 8 -- crates/soar-operations/src/remove.rs | 10 +- crates/soar-operations/src/run.rs | 1 - crates/soar-operations/src/search.rs | 13 ++- crates/soar-operations/src/switch.rs | 4 +- crates/soar-operations/src/types.rs | 6 - crates/soar-operations/src/update.rs | 23 +--- crates/soar-package/src/formats/common.rs | 11 +- crates/soar-package/src/traits.rs | 4 +- 32 files changed, 209 insertions(+), 182 deletions(-) create mode 100644 crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/down.sql create mode 100644 crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/up.sql diff --git a/crates/soar-cli/src/inspect.rs b/crates/soar-cli/src/inspect.rs index 15d9d5b5..03b75476 100644 --- a/crates/soar-cli/src/inspect.rs +++ b/crates/soar-cli/src/inspect.rs @@ -44,7 +44,7 @@ fn get_installed_path( conn, &package.repo_name, &package.pkg_name, - &package.pkg_id, + package.pkg_id.as_deref(), &package.version, ) })?; diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index e69e02b1..d07d16a9 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -319,7 +319,7 @@ async fn install_with_show( ) })? .into_iter() - .map(|p| (p.pkg_id, p.repo_name, p.version)) + .map(|p| (p.pkg_id.unwrap_or_default(), p.repo_name, p.version)) .collect(); let pkg = select_package_interactively_with_installed( @@ -339,7 +339,7 @@ async fn install_with_show( conn, Some(&pkg.repo_name), Some(&pkg.pkg_name), - Some(&pkg.pkg_id), + pkg.pkg_id.as_deref(), None, None, None, diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index a3cb6d90..e960faea 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -398,7 +398,11 @@ pub async fn list_installed_packages( if entry.is_healthy { let unique_count = unique_pkgs - .insert(format!("{}-{}", package.pkg_id, package.pkg_name)) + .insert(format!( + "{}-{}", + package.pkg_id.as_deref().unwrap_or_default(), + package.pkg_name + )) as u32 + unique_count; ( diff --git a/crates/soar-cli/src/run.rs b/crates/soar-cli/src/run.rs index 93a402e6..daaea3a2 100644 --- a/crates/soar-cli/src/run.rs +++ b/crates/soar-cli/src/run.rs @@ -38,7 +38,7 @@ pub async fn run_package( ctx, package_name, Some(&pkg.repo_name), - Some(&pkg.pkg_id), + pkg.pkg_id.as_deref(), no_verify, ) .await?; diff --git a/crates/soar-cli/src/utils.rs b/crates/soar-cli/src/utils.rs index 9716d3c1..867674d6 100644 --- a/crates/soar-cli/src/utils.rs +++ b/crates/soar-cli/src/utils.rs @@ -134,7 +134,7 @@ pub fn select_package_interactively_with_installed( info!("Showing available packages for {package_name}"); for (idx, pkg) in pkgs.iter().enumerate() { let is_installed = installed.iter().any(|(pkg_id, repo_name, _version)| { - pkg.pkg_id() == pkg_id && pkg.repo_name() == repo_name + pkg.pkg_id().unwrap_or_default() == pkg_id && pkg.repo_name() == repo_name }); let installed_marker = if is_installed { format!(" {}", Colored(Color::Yellow, "[installed]")) diff --git a/crates/soar-core/src/database/models.rs b/crates/soar-core/src/database/models.rs index fad381ae..eafe6b7b 100644 --- a/crates/soar-core/src/database/models.rs +++ b/crates/soar-core/src/database/models.rs @@ -29,7 +29,7 @@ pub struct Package { pub repo_name: String, pub disabled: Option, pub disabled_reason: Option, - pub pkg_id: String, + pub pkg_id: Option, pub pkg_name: String, pub pkg_family: Option, pub pkg_type: Option, @@ -77,8 +77,8 @@ impl PackageExt for Package { &self.pkg_name } - fn pkg_id(&self) -> &str { - &self.pkg_id + fn pkg_id(&self) -> Option<&str> { + self.pkg_id.as_deref() } fn version(&self) -> &str { @@ -137,7 +137,7 @@ impl Package { pub struct InstalledPackage { pub id: u64, pub repo_name: String, - pub pkg_id: String, + pub pkg_id: Option, pub pkg_name: String, pub pkg_family: Option, pub pkg_type: Option, @@ -165,8 +165,8 @@ impl PackageExt for InstalledPackage { &self.pkg_name } - fn pkg_id(&self) -> &str { - &self.pkg_id + fn pkg_id(&self) -> Option<&str> { + self.pkg_id.as_deref() } fn version(&self) -> &str { @@ -248,7 +248,7 @@ impl From for Package { repo_name: String::new(), // Set by caller disabled: None, disabled_reason: None, - pkg_id: pkg.pkg_id, + pkg_id: Some(pkg.pkg_id), pkg_name: pkg.pkg_name, pkg_family: pkg.pkg_family, pkg_type: pkg.pkg_type, diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index f96bbf70..e56a3d58 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -181,7 +181,8 @@ use crate::utils::substitute_placeholders; /// Marker content to verify partial install matches current package #[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct InstallMarker { - pub pkg_id: String, + #[serde(default)] + pub pkg_id: Option, pub version: String, pub bsum: Option, } @@ -272,7 +273,7 @@ impl PackageInstaller { let has_pending = db.with_conn(|conn| { CoreRepository::has_pending_install( conn, - &package.pkg_id, + package.pkg_id.as_deref(), &package.pkg_name, &package.repo_name, &package.version, @@ -304,7 +305,7 @@ impl PackageInstaller { package.version ); let repo_name = &package.repo_name; - let pkg_id = &package.pkg_id; + let pkg_id = package.pkg_id.as_deref(); let pkg_name = &package.pkg_name; let pkg_type = package.pkg_type.as_deref(); let version = &package.version; @@ -325,7 +326,7 @@ impl PackageInstaller { let new_package = NewInstalledPackage { repo_name, - pkg_id, + pkg_id: pkg_id.as_deref(), pkg_name, pkg_family: package.pkg_family.as_deref(), pkg_type, @@ -369,16 +370,15 @@ impl PackageInstaller { use super::hooks::{run_hook, HookEnv}; let env = HookEnv { + pkg_id: self.package.pkg_id.as_deref().unwrap_or_default(), install_dir: &self.install_dir, pkg_name: &self.package.pkg_name, - pkg_id: &self.package.pkg_id, pkg_version: &self.package.version, }; self.events.emit(SoarEvent::Installing { op_id: self.op_id, pkg_name: self.package.pkg_name.clone(), - pkg_id: self.package.pkg_id.clone(), stage: InstallStage::RunningHook(hook_name.to_string()), }); @@ -489,7 +489,6 @@ impl PackageInstaller { self.events.emit(SoarEvent::Building { op_id: self.op_id, pkg_name: self.package.pkg_name.clone(), - pkg_id: self.package.pkg_id.clone(), stage: BuildStage::Sandboxing, }); } @@ -505,7 +504,6 @@ impl PackageInstaller { self.events.emit(SoarEvent::Building { op_id: self.op_id, pkg_name: self.package.pkg_name.clone(), - pkg_id: self.package.pkg_id.clone(), stage: BuildStage::Running { command_index: i, total_commands, @@ -520,7 +518,7 @@ impl PackageInstaller { ), ("BIN_DIR", bin_dir.to_string_lossy().to_string()), ("PKG_NAME", self.package.pkg_name.clone()), - ("PKG_ID", self.package.pkg_id.clone()), + ("PKG_ID", self.package.pkg_id.clone().unwrap_or_default()), ("PKG_VERSION", self.package.version.clone()), ("NPROC", nproc.clone()), ]; @@ -553,7 +551,7 @@ impl PackageInstaller { .env("INSTALL_DIR", &self.install_dir) .env("BIN_DIR", &bin_dir) .env("PKG_NAME", &self.package.pkg_name) - .env("PKG_ID", &self.package.pkg_id) + .env("PKG_ID", self.package.pkg_id.as_deref().unwrap_or_default()) .env("PKG_VERSION", &self.package.version) .env("NPROC", &nproc) .current_dir(&self.install_dir) @@ -572,7 +570,6 @@ impl PackageInstaller { self.events.emit(SoarEvent::Building { op_id: self.op_id, pkg_name: self.package.pkg_name.clone(), - pkg_id: self.package.pkg_id.clone(), stage: BuildStage::CommandComplete { command_index: i, }, @@ -1024,7 +1021,7 @@ impl PackageInstaller { let package = &self.package; let repo_name = &package.repo_name; let pkg_name = &package.pkg_name; - let pkg_id = &package.pkg_id; + let pkg_id = package.pkg_id.as_deref(); let version = &package.version; let size = package.ghcr_size.unwrap_or(package.size.unwrap_or(0)) as i64; let checksum = package.bsum.as_deref(); diff --git a/crates/soar-core/src/package/local.rs b/crates/soar-core/src/package/local.rs index 11689584..5aeb8182 100644 --- a/crates/soar-core/src/package/local.rs +++ b/crates/soar-core/src/package/local.rs @@ -142,7 +142,6 @@ impl LocalPackage { Package { id: 0, repo_name: "local".to_string(), - pkg_id: self.pkg_id.clone(), pkg_name: self.pkg_name.clone(), pkg_type: self.pkg_type.clone(), version: self.version.clone(), diff --git a/crates/soar-core/src/package/remove.rs b/crates/soar-core/src/package/remove.rs index 66b0fa38..dff1d3e9 100644 --- a/crates/soar-core/src/package/remove.rs +++ b/crates/soar-core/src/package/remove.rs @@ -110,9 +110,9 @@ impl PackageRemover { fn run_hook(&self, hook_name: &str, command: &str) -> SoarResult<()> { let install_dir = PathBuf::from(&self.package.installed_path); let env = HookEnv { + pkg_id: self.package.pkg_id.as_deref().unwrap_or_default(), install_dir: &install_dir, pkg_name: &self.package.pkg_name, - pkg_id: &self.package.pkg_id, pkg_version: &self.package.version, }; diff --git a/crates/soar-core/src/package/update.rs b/crates/soar-core/src/package/update.rs index 71672384..a8eefde5 100644 --- a/crates/soar-core/src/package/update.rs +++ b/crates/soar-core/src/package/update.rs @@ -22,7 +22,7 @@ pub fn remove_old_versions(package: &Package, db: &DieselDatabase, force: bool) } = package; let old_packages = db.with_conn(|conn| { - CoreRepository::get_old_package_paths(conn, pkg_id, pkg_name, repo_name, force) + CoreRepository::get_old_package_paths(conn, pkg_id.as_deref(), pkg_name, repo_name, force) })?; for (_id, installed_path) in &old_packages { @@ -34,7 +34,7 @@ pub fn remove_old_versions(package: &Package, db: &DieselDatabase, force: bool) } db.with_conn(|conn| { - CoreRepository::delete_old_packages(conn, pkg_id, pkg_name, repo_name, force) + CoreRepository::delete_old_packages(conn, pkg_id.as_deref(), pkg_name, repo_name, force) })?; Ok(()) diff --git a/crates/soar-core/src/package/url.rs b/crates/soar-core/src/package/url.rs index 0f90b283..17271a30 100644 --- a/crates/soar-core/src/package/url.rs +++ b/crates/soar-core/src/package/url.rs @@ -250,7 +250,6 @@ impl UrlPackage { Package { id: 0, repo_name: "local".to_string(), - pkg_id: self.pkg_id.clone(), pkg_name: self.pkg_name.clone(), pkg_type: self.pkg_type.clone(), version: self.version.clone(), @@ -264,7 +263,6 @@ impl UrlPackage { Package { id: 0, repo_name: "local".to_string(), - pkg_id: self.pkg_id.clone(), pkg_name: self.pkg_name.clone(), pkg_type: self.pkg_type.clone(), version: self.version.clone(), diff --git a/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/down.sql b/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/down.sql new file mode 100644 index 00000000..1018b173 --- /dev/null +++ b/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/down.sql @@ -0,0 +1,2 @@ +-- Rows without an id cannot be represented once the column is required again. +DELETE FROM packages WHERE pkg_id IS NULL; diff --git a/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/up.sql b/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/up.sql new file mode 100644 index 00000000..bff86d4a --- /dev/null +++ b/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/up.sql @@ -0,0 +1,32 @@ +-- Repositories publishing the declarative format do not produce a package id, +-- so installed rows must be able to omit it. SQLite cannot relax NOT NULL in +-- place, so the table is rebuilt. +CREATE TABLE packages_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_name TEXT NOT NULL, + pkg_id TEXT, + pkg_name TEXT NOT NULL, + pkg_family TEXT, + pkg_type TEXT, + version TEXT NOT NULL, + size BIGINT NOT NULL, + checksum TEXT, + installed_path TEXT NOT NULL, + installed_date TEXT NOT NULL, + profile TEXT NOT NULL, + pinned BOOLEAN NOT NULL DEFAULT 0, + is_installed BOOLEAN NOT NULL DEFAULT 0, + detached BOOLEAN NOT NULL DEFAULT 0, + unlinked BOOLEAN NOT NULL DEFAULT 0, + provides BLOB, + install_patterns BLOB +); + +INSERT INTO packages_new +SELECT id, repo_name, pkg_id, pkg_name, pkg_family, pkg_type, version, size, + checksum, installed_path, installed_date, profile, pinned, is_installed, + detached, unlinked, provides, install_patterns +FROM packages; + +DROP TABLE packages; +ALTER TABLE packages_new RENAME TO packages; diff --git a/crates/soar-db/src/models/core.rs b/crates/soar-db/src/models/core.rs index 04a15beb..cd987d9d 100644 --- a/crates/soar-db/src/models/core.rs +++ b/crates/soar-db/src/models/core.rs @@ -7,7 +7,7 @@ use crate::{json_vec, models::types::PackageProvide, schema::core::*}; pub struct Package { pub id: i32, pub repo_name: String, - pub pkg_id: String, + pub pkg_id: Option, pub pkg_name: String, pub pkg_family: Option, pub pkg_type: Option, @@ -29,7 +29,7 @@ impl Queryable for Package { type Row = ( i32, String, - String, + Option, String, Option, Option, @@ -87,7 +87,7 @@ pub struct PortablePackage { #[diesel(table_name = packages)] pub struct NewPackage<'a> { pub repo_name: &'a str, - pub pkg_id: &'a str, + pub pkg_id: Option<&'a str>, pub pkg_name: &'a str, pub pkg_family: Option<&'a str>, pub pkg_type: Option<&'a str>, diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index dee4e007..8a68ba90 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -1,6 +1,10 @@ //! Core database repository for installed packages. -use diesel::{prelude::*, sql_types::Bool, sqlite::Sqlite}; +use diesel::{ + prelude::*, + sql_types::{Bool, Nullable}, + sqlite::Sqlite, +}; use crate::{ models::{ @@ -10,6 +14,20 @@ use crate::{ schema::core::{packages, portable_package}, }; +/// Matches a row's package id, including rows that have none. +/// +/// A repository publishing the declarative format produces no package id, so +/// `pkg_id = ?` would never match those rows and `pkg_id != ?` would never +/// exclude them. Asking for no id has to mean the row has none either. +fn match_pkg_id( + pkg_id: Option<&str>, +) -> Box>> { + match pkg_id { + Some(id) => Box::new(packages::pkg_id.eq(id.to_string())), + None => Box::new(packages::pkg_id.is_null().nullable()), + } +} + /// Sort direction for queries. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortDirection { @@ -27,7 +45,7 @@ pub type NewInstalledPackage<'a> = NewPackage<'a>; pub struct InstalledPackageWithPortable { pub id: i32, pub repo_name: String, - pub pkg_id: String, + pub pkg_id: Option, pub pkg_name: String, pub pkg_family: Option, pub pkg_type: Option, @@ -175,15 +193,22 @@ impl CoreRepository { conn: &mut SqliteConnection, repo_name: &str, pkg_name: &str, - pkg_id: &str, + pkg_id: Option<&str>, version: &str, ) -> QueryResult> { - let result: Option<(Package, Option)> = packages::table + // The boxed predicate is typed for the bare table, so the join builds + // its own two-branch filter. + let mut query = packages::table .left_join(portable_package::table) .filter(packages::repo_name.eq(repo_name)) .filter(packages::pkg_name.eq(pkg_name)) - .filter(packages::pkg_id.eq(pkg_id)) .filter(packages::version.eq(version)) + .into_boxed(); + query = match pkg_id { + Some(id) => query.filter(packages::pkg_id.eq(id.to_string())), + None => query.filter(packages::pkg_id.is_null()), + }; + let result: Option<(Package, Option)> = query .select((Package::as_select(), Option::::as_select())) .first(conn) .optional()?; @@ -302,13 +327,17 @@ impl CoreRepository { pub fn find_alternates( conn: &mut SqliteConnection, pkg_name: &str, - exclude_pkg_id: &str, + exclude_pkg_id: Option<&str>, exclude_version: &str, ) -> QueryResult> { let results: Vec<(Package, Option)> = packages::table .left_join(portable_package::table) .filter(packages::pkg_name.eq(pkg_name)) - .filter(packages::pkg_id.ne(exclude_pkg_id)) + .filter( + packages::pkg_id + .is_null() + .or(packages::pkg_id.ne(exclude_pkg_id)), + ) .filter(packages::version.ne(exclude_version)) .select((Package::as_select(), Option::::as_select())) .load(conn)?; @@ -319,11 +348,11 @@ impl CoreRepository { /// Finds an installed package by pkg_id and repo_name. pub fn find_by_pkg_id_and_repo( conn: &mut SqliteConnection, - pkg_id: &str, + pkg_id: Option<&str>, repo_name: &str, ) -> QueryResult> { packages::table - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::repo_name.eq(repo_name)) .select(Package::as_select()) .first(conn) @@ -333,12 +362,12 @@ impl CoreRepository { /// Finds an installed package by pkg_id, pkg_name, and repo_name. pub fn find_by_pkg_id_name_and_repo( conn: &mut SqliteConnection, - pkg_id: &str, + pkg_id: Option<&str>, pkg_name: &str, repo_name: &str, ) -> QueryResult> { packages::table - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .select(Package::as_select()) @@ -372,7 +401,7 @@ impl CoreRepository { conn: &mut SqliteConnection, repo_name: &str, pkg_name: &str, - pkg_id: &str, + pkg_id: Option<&str>, version: &str, size: i64, provides: Option>, @@ -385,7 +414,7 @@ impl CoreRepository { packages::table .filter(packages::repo_name.eq(repo_name)) .filter(packages::pkg_name.eq(pkg_name)) - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::version.eq(version)) .filter(packages::is_installed.eq(false)), ) @@ -424,7 +453,7 @@ impl CoreRepository { pub fn unlink_others( conn: &mut SqliteConnection, pkg_name: &str, - keep_pkg_id: &str, + keep_pkg_id: Option<&str>, keep_version: &str, ) -> QueryResult { diesel::update( @@ -432,7 +461,8 @@ impl CoreRepository { .filter(packages::pkg_name.eq(pkg_name)) .filter( packages::pkg_id - .ne(keep_pkg_id) + .is_null() + .or(packages::pkg_id.ne(keep_pkg_id)) .or(packages::version.ne(keep_version)), ), ) @@ -444,13 +474,13 @@ impl CoreRepository { pub fn update_pkg_id( conn: &mut SqliteConnection, repo_name: &str, - old_pkg_id: &str, + old_pkg_id: Option<&str>, new_pkg_id: &str, ) -> QueryResult { diesel::update( packages::table .filter(packages::repo_name.eq(repo_name)) - .filter(packages::pkg_id.eq(old_pkg_id)), + .filter(match_pkg_id(old_pkg_id)), ) .set(packages::pkg_id.eq(new_pkg_id)) .execute(conn) @@ -465,13 +495,13 @@ impl CoreRepository { /// Used to check if we can resume a partial install. pub fn has_pending_install( conn: &mut SqliteConnection, - pkg_id: &str, + pkg_id: Option<&str>, pkg_name: &str, repo_name: &str, version: &str, ) -> QueryResult { let count: i64 = packages::table - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .filter(packages::version.eq(version)) @@ -485,12 +515,12 @@ impl CoreRepository { /// Used to clean up orphaned partial installs before starting a new install. pub fn delete_pending_installs( conn: &mut SqliteConnection, - pkg_id: &str, + pkg_id: Option<&str>, pkg_name: &str, repo_name: &str, ) -> QueryResult> { let paths: Vec = packages::table - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .filter(packages::is_installed.eq(false)) @@ -499,7 +529,7 @@ impl CoreRepository { diesel::delete( packages::table - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .filter(packages::is_installed.eq(false)), @@ -580,13 +610,13 @@ impl CoreRepository { /// If `force` is true, includes pinned packages. Otherwise only unpinned packages. pub fn get_old_package_paths( conn: &mut SqliteConnection, - pkg_id: &str, + pkg_id: Option<&str>, pkg_name: &str, repo_name: &str, force: bool, ) -> QueryResult> { let latest: Option<(i32, String)> = packages::table - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .order(packages::id.desc()) @@ -599,7 +629,7 @@ impl CoreRepository { }; let query = packages::table - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .filter(packages::id.ne(latest_id)) @@ -621,13 +651,13 @@ impl CoreRepository { /// If `force` is true, deletes pinned packages too. Otherwise only unpinned packages. pub fn delete_old_packages( conn: &mut SqliteConnection, - pkg_id: &str, + pkg_id: Option<&str>, pkg_name: &str, repo_name: &str, force: bool, ) -> QueryResult { let latest_id: Option = packages::table - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .order(packages::id.desc()) @@ -647,7 +677,7 @@ impl CoreRepository { }; let query = packages::table - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .filter(packages::id.ne(latest_id)) @@ -661,14 +691,18 @@ impl CoreRepository { pub fn unlink_others_by_checksum( conn: &mut SqliteConnection, pkg_name: &str, - keep_pkg_id: &str, + keep_pkg_id: Option<&str>, keep_checksum: Option<&str>, ) -> QueryResult { if let Some(checksum) = keep_checksum { diesel::update( packages::table .filter(packages::pkg_name.eq(pkg_name)) - .filter(packages::pkg_id.ne(keep_pkg_id)) + .filter( + packages::pkg_id + .is_null() + .or(packages::pkg_id.ne(keep_pkg_id)), + ) .filter(packages::checksum.ne(checksum)), ) .set(packages::unlinked.eq(true)) @@ -677,7 +711,11 @@ impl CoreRepository { diesel::update( packages::table .filter(packages::pkg_name.eq(pkg_name)) - .filter(packages::pkg_id.ne(keep_pkg_id)), + .filter( + packages::pkg_id + .is_null() + .or(packages::pkg_id.ne(keep_pkg_id)), + ), ) .set(packages::unlinked.eq(true)) .execute(conn) @@ -689,14 +727,14 @@ impl CoreRepository { pub fn link_by_checksum( conn: &mut SqliteConnection, pkg_name: &str, - pkg_id: &str, + pkg_id: Option<&str>, checksum: Option<&str>, ) -> QueryResult { if let Some(checksum) = checksum { diesel::update( packages::table .filter(packages::pkg_name.eq(pkg_name)) - .filter(packages::pkg_id.eq(pkg_id)) + .filter(match_pkg_id(pkg_id)) .filter(packages::checksum.eq(checksum)), ) .set(packages::unlinked.eq(false)) @@ -705,7 +743,7 @@ impl CoreRepository { diesel::update( packages::table .filter(packages::pkg_name.eq(pkg_name)) - .filter(packages::pkg_id.eq(pkg_id)), + .filter(match_pkg_id(pkg_id)), ) .set(packages::unlinked.eq(false)) .execute(conn) diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index d9a530bf..9c8f9fd2 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -439,7 +439,7 @@ impl MetadataRepository { pub fn find_newer_version( conn: &mut SqliteConnection, pkg_name: &str, - pkg_id: &str, + pkg_id: Option<&str>, current_version: &str, ) -> QueryResult> { trace!( @@ -455,9 +455,14 @@ impl MetadataRepository { String::new() }; - let result = packages::table - .filter(packages::pkg_name.eq(pkg_name)) - .filter(packages::pkg_id.eq(pkg_id)) + // An installed package without an id is matched by name alone: the + // declarative format publishes no id to compare against. + let mut query = packages::table.into_boxed(); + query = query.filter(packages::pkg_name.eq(pkg_name)); + if let Some(id) = pkg_id { + query = query.filter(packages::pkg_id.eq(id.to_string())); + } + let result = query .filter( sql::("(version > ") .bind::(current_version) diff --git a/crates/soar-db/src/schema/core.rs b/crates/soar-db/src/schema/core.rs index 738b8ced..512c755d 100644 --- a/crates/soar-db/src/schema/core.rs +++ b/crates/soar-db/src/schema/core.rs @@ -2,7 +2,7 @@ diesel::table! { packages (id) { id -> Integer, repo_name -> Text, - pkg_id -> Text, + pkg_id -> Nullable, pkg_name -> Text, pkg_family -> Nullable, pkg_type -> Nullable, diff --git a/crates/soar-events/src/event.rs b/crates/soar-events/src/event.rs index 4f068282..6216daed 100644 --- a/crates/soar-events/src/event.rs +++ b/crates/soar-events/src/event.rs @@ -7,14 +7,12 @@ pub enum SoarEvent { DownloadStarting { op_id: OperationId, pkg_name: String, - pkg_id: String, total: u64, }, /// Download is resuming from a previous checkpoint. DownloadResuming { op_id: OperationId, pkg_name: String, - pkg_id: String, current: u64, total: u64, }, @@ -22,7 +20,6 @@ pub enum SoarEvent { DownloadProgress { op_id: OperationId, pkg_name: String, - pkg_id: String, current: u64, total: u64, }, @@ -30,59 +27,50 @@ pub enum SoarEvent { DownloadComplete { op_id: OperationId, pkg_name: String, - pkg_id: String, total: u64, }, /// Download error, retrying. DownloadRetry { op_id: OperationId, pkg_name: String, - pkg_id: String, }, /// Download permanently failed after retries. DownloadAborted { op_id: OperationId, pkg_name: String, - pkg_id: String, }, /// Download recovered from an error. DownloadRecovered { op_id: OperationId, pkg_name: String, - pkg_id: String, }, /// Verification stage. Verifying { op_id: OperationId, pkg_name: String, - pkg_id: String, stage: VerifyStage, }, /// Install/extraction stage. Installing { op_id: OperationId, pkg_name: String, - pkg_id: String, stage: InstallStage, }, /// Package removal stage. Removing { op_id: OperationId, pkg_name: String, - pkg_id: String, stage: RemoveStage, }, /// Update check for a package. UpdateCheck { pkg_name: String, - pkg_id: String, status: UpdateCheckStatus, }, /// Old version cleanup after update. UpdateCleanup { op_id: OperationId, pkg_name: String, - pkg_id: String, old_version: String, stage: UpdateCleanupStage, }, @@ -90,7 +78,6 @@ pub enum SoarEvent { Hook { op_id: OperationId, pkg_name: String, - pkg_id: String, hook_name: String, stage: HookStage, }, @@ -98,27 +85,23 @@ pub enum SoarEvent { Running { op_id: OperationId, pkg_name: String, - pkg_id: String, stage: RunStage, }, /// Build stage (for source packages). Building { op_id: OperationId, pkg_name: String, - pkg_id: String, stage: BuildStage, }, /// Operation completed successfully. OperationComplete { op_id: OperationId, pkg_name: String, - pkg_id: String, }, /// Operation failed. OperationFailed { op_id: OperationId, pkg_name: String, - pkg_id: String, error: String, }, /// Repository sync progress. diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index ed4755f1..ef951773 100644 --- a/crates/soar-operations/src/apply.rs +++ b/crates/soar-operations/src/apply.rs @@ -120,7 +120,7 @@ pub async fn compute_diff( conn, Some(&metadata_pkg.repo_name), Some(&metadata_pkg.pkg_name), - Some(&metadata_pkg.pkg_id), + metadata_pkg.pkg_id.as_deref(), None, None, None, @@ -182,7 +182,9 @@ pub async fn compute_diff( 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 pkg_id_matches = pkg_id + .as_deref() + .is_none_or(|id| Some(id) == installed.pkg_id.as_deref()); let repo_matches = repo.as_ref().is_none_or(|r| *r == installed.repo_name); name_matches && pkg_id_matches && repo_matches }); @@ -322,7 +324,6 @@ pub async fn execute_apply( ctx.events().emit(SoarEvent::Removing { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: RemoveStage::RunningHook("pre_remove".into()), }); @@ -338,7 +339,6 @@ pub async fn execute_apply( ctx.events().emit(SoarEvent::Removing { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: RemoveStage::Complete { size_freed: None, }, @@ -349,7 +349,6 @@ pub async fn execute_apply( ctx.events().emit(SoarEvent::OperationFailed { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), error: e.to_string(), }); failed_count += 1; @@ -575,7 +574,7 @@ fn check_url_package_status( conn, Some("local"), Some(&url_pkg.pkg_name), - Some(&url_pkg.pkg_id), + Some(url_pkg.pkg_id.as_str()), None, None, None, diff --git a/crates/soar-operations/src/context.rs b/crates/soar-operations/src/context.rs index 5af1bc3f..e5773af4 100644 --- a/crates/soar-operations/src/context.rs +++ b/crates/soar-operations/src/context.rs @@ -230,12 +230,18 @@ impl SoarContext { })?; for pkg in installed_packages { - let exists = metadata_db - .with_conn(|conn| MetadataRepository::exists_by_pkg_id(conn, &pkg.pkg_id))?; + // Replacement tracking is keyed by package id, which the + // declarative format does not produce. Those rows have nothing to + // look up here. + let Some(pkg_id) = pkg.pkg_id.as_deref() else { + continue; + }; + let exists = + metadata_db.with_conn(|conn| MetadataRepository::exists_by_pkg_id(conn, pkg_id))?; if !exists { let replacement = metadata_db.with_conn(|conn| { - MetadataRepository::find_replacement_pkg_id(conn, &pkg.pkg_id) + MetadataRepository::find_replacement_pkg_id(conn, pkg_id) })?; if let Some(new_pkg_id) = replacement { @@ -243,12 +249,12 @@ impl SoarContext { level: LogLevel::Info, message: format!( "{} is replaced by {} in {}", - pkg.pkg_id, new_pkg_id, repo_name + pkg_id, new_pkg_id, repo_name ), }); diesel_core_db.with_conn(|conn| { - CoreRepository::update_pkg_id(conn, &repo_name, &pkg.pkg_id, &new_pkg_id) + CoreRepository::update_pkg_id(conn, &repo_name, Some(pkg_id), &new_pkg_id) })?; } } diff --git a/crates/soar-operations/src/health.rs b/crates/soar-operations/src/health.rs index ea8860e3..b05d5a20 100644 --- a/crates/soar-operations/src/health.rs +++ b/crates/soar-operations/src/health.rs @@ -46,14 +46,12 @@ pub async fn remove_broken_packages(ctx: &SoarContext) -> SoarResult SoarResult SoarResult SoarResult> { .map(|p| { BrokenPackage { pkg_name: p.pkg_name, - pkg_id: p.pkg_id, installed_path: p.installed_path, } }) diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index d9ce1181..3a455cc6 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -153,7 +153,7 @@ fn resolve_synthetic_target( conn, Some("local"), Some(&package.pkg_name), - Some(&package.pkg_id), + package.pkg_id.as_deref(), None, None, None, @@ -171,7 +171,6 @@ fn resolve_synthetic_target( if !options.force { return Ok(ResolveResult::AlreadyInstalled { pkg_name: installed.pkg_name.clone(), - pkg_id: installed.pkg_id.clone(), repo_name: installed.repo_name.clone(), version: installed.version.clone(), }); @@ -267,7 +266,7 @@ fn resolve_all_variants( MetadataRepository::find_filtered( conn, None, - Some(&target_pkg_id), + target_pkg_id.as_deref(), None, None, None, @@ -287,7 +286,7 @@ fn resolve_all_variants( let pkgs = MetadataRepository::find_filtered( conn, None, - Some(&target_pkg_id), + target_pkg_id.as_deref(), None, None, None, @@ -310,7 +309,7 @@ fn resolve_all_variants( conn, query.repo_name.as_deref(), None, - Some(&target_pkg_id), + target_pkg_id.as_deref(), None, None, None, @@ -500,7 +499,6 @@ fn resolve_normal( if !options.force { return Ok(ResolveResult::AlreadyInstalled { pkg_name: installed.pkg_name.clone(), - pkg_id: installed.pkg_id.clone(), repo_name: installed.repo_name.clone(), version: installed.version.clone(), }); @@ -544,7 +542,7 @@ fn find_packages( conn, Some(&existing.pkg_name), None, - Some(&existing.pkg_id), + existing.pkg_id.as_deref(), None, None, None, @@ -669,7 +667,6 @@ pub async fn perform_installation( if !install_dir.as_os_str().is_empty() { installed.lock().unwrap().push(InstalledInfo { pkg_name: target.package.pkg_name.clone(), - pkg_id: target.package.pkg_id.clone(), repo_name: target.package.repo_name.clone(), version: target.package.version.clone(), install_dir, @@ -690,12 +687,10 @@ pub async fn perform_installation( ctx.events().emit(SoarEvent::OperationFailed { op_id, pkg_name: target.package.pkg_name.clone(), - pkg_id: target.package.pkg_id.clone(), error: err.to_string(), }); failed.lock().unwrap().push(FailedInfo { pkg_name: target.package.pkg_name.clone(), - pkg_id: target.package.pkg_id.clone(), error: err.to_string(), }); failed_count.fetch_add(1, Ordering::Relaxed); @@ -797,7 +792,7 @@ async fn install_single_package( conn, Some(&pkg.repo_name), Some(&pkg.pkg_name), - Some(&pkg.pkg_id), + pkg.pkg_id.as_deref(), Some(&pkg.version), Some(true), None, @@ -933,7 +928,6 @@ async fn install_single_package( let progress_callback = create_progress_bridge( events.clone(), op_id, - pkg.pkg_id.clone(), pkg.pkg_name.clone(), ); @@ -960,7 +954,6 @@ async fn install_single_package( events.emit(SoarEvent::Verifying { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: VerifyStage::Signature, }); @@ -990,7 +983,6 @@ async fn install_single_package( events.emit(SoarEvent::Verifying { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: VerifyStage::Checksum, }); @@ -1012,7 +1004,6 @@ async fn install_single_package( events.emit(SoarEvent::Verifying { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: VerifyStage::Failed("checksum mismatch".into()), }); return Err(SoarError::Custom( @@ -1023,7 +1014,6 @@ async fn install_single_package( events.emit(SoarEvent::Verifying { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: VerifyStage::Passed, }); } @@ -1031,7 +1021,6 @@ async fn install_single_package( events.emit(SoarEvent::Verifying { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: VerifyStage::Failed("checksum unavailable".into()), }); return Err(SoarError::Custom(format!( @@ -1047,7 +1036,6 @@ async fn install_single_package( events.emit(SoarEvent::Installing { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: InstallStage::LinkingBinaries, }); @@ -1081,7 +1069,6 @@ async fn install_single_package( events.emit(SoarEvent::Installing { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: InstallStage::DesktopIntegration, }); @@ -1104,7 +1091,6 @@ async fn install_single_package( events.emit(SoarEvent::Installing { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: InstallStage::RecordingDatabase, }); @@ -1124,7 +1110,6 @@ async fn install_single_package( events.emit(SoarEvent::OperationComplete { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), }); debug!( diff --git a/crates/soar-operations/src/list.rs b/crates/soar-operations/src/list.rs index cd4d0408..6dee2fcc 100644 --- a/crates/soar-operations/src/list.rs +++ b/crates/soar-operations/src/list.rs @@ -62,7 +62,16 @@ pub async fn list_packages( 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)) + .map(|pkg| { + ( + ( + pkg.repo_name, + pkg.pkg_id.unwrap_or_default(), + pkg.pkg_name, + ), + pkg.is_installed, + ) + }) .collect(); let total = packages.len(); @@ -80,7 +89,6 @@ pub async fn list_packages( // Build a minimal Package for the entry let package = Package { repo_name: entry.repo_name, - pkg_id: entry.pkg.pkg_id, pkg_name: entry.pkg.pkg_name, pkg_type: entry.pkg.pkg_type, version: entry.pkg.version, diff --git a/crates/soar-operations/src/progress.rs b/crates/soar-operations/src/progress.rs index 6e741b85..017055e4 100644 --- a/crates/soar-operations/src/progress.rs +++ b/crates/soar-operations/src/progress.rs @@ -14,7 +14,6 @@ pub fn create_progress_bridge( events: EventSinkHandle, op_id: OperationId, pkg_name: String, - pkg_id: String, ) -> Arc { Arc::new(move |progress| { let event = match progress { @@ -24,7 +23,6 @@ pub fn create_progress_bridge( SoarEvent::DownloadStarting { op_id, pkg_name: pkg_name.clone(), - pkg_id: pkg_id.clone(), total, } } @@ -35,7 +33,6 @@ pub fn create_progress_bridge( SoarEvent::DownloadResuming { op_id, pkg_name: pkg_name.clone(), - pkg_id: pkg_id.clone(), current, total, } @@ -47,7 +44,6 @@ pub fn create_progress_bridge( SoarEvent::DownloadProgress { op_id, pkg_name: pkg_name.clone(), - pkg_id: pkg_id.clone(), current, total, } @@ -58,7 +54,6 @@ pub fn create_progress_bridge( SoarEvent::DownloadComplete { op_id, pkg_name: pkg_name.clone(), - pkg_id: pkg_id.clone(), total, } } @@ -66,21 +61,18 @@ pub fn create_progress_bridge( SoarEvent::DownloadRetry { op_id, pkg_name: pkg_name.clone(), - pkg_id: pkg_id.clone(), } } Progress::Aborted => { SoarEvent::DownloadAborted { op_id, pkg_name: pkg_name.clone(), - pkg_id: pkg_id.clone(), } } Progress::Recovered => { SoarEvent::DownloadRecovered { op_id, pkg_name: pkg_name.clone(), - pkg_id: pkg_id.clone(), } } }; diff --git a/crates/soar-operations/src/remove.rs b/crates/soar-operations/src/remove.rs index 43198752..f4fee4f3 100644 --- a/crates/soar-operations/src/remove.rs +++ b/crates/soar-operations/src/remove.rs @@ -34,7 +34,7 @@ pub fn resolve_removals( let query = PackageQuery::try_from(package.as_str())?; // --all flag: remove all installed variants matching the name - if let (true, None, Some(ref name)) = (all, &query.pkg_id, &query.name) { + if let (true, None, Some(ref name)) = (all, query.pkg_id.as_deref(), &query.name) { let installed: Vec = diesel_db .with_conn(|conn| { CoreRepository::list_filtered( @@ -101,7 +101,7 @@ pub fn resolve_removals( conn, query.repo_name.as_deref(), None, - Some(&target_pkg_id), + target_pkg_id.as_deref(), None, None, None, @@ -170,7 +170,6 @@ pub async fn perform_removal( ctx.events().emit(SoarEvent::Removing { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: RemoveStage::RunningHook("pre_remove".into()), }); @@ -191,7 +190,6 @@ pub async fn perform_removal( ctx.events().emit(SoarEvent::Removing { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), stage: RemoveStage::Complete { size_freed: None, }, @@ -199,12 +197,10 @@ pub async fn perform_removal( ctx.events().emit(SoarEvent::OperationComplete { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), }); removed.push(RemovedInfo { pkg_name: pkg.pkg_name, - pkg_id: pkg.pkg_id, repo_name: pkg.repo_name, version: pkg.version, }); @@ -213,13 +209,11 @@ pub async fn perform_removal( ctx.events().emit(SoarEvent::OperationFailed { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), error: err.to_string(), }); failed.push(FailedInfo { pkg_name: pkg.pkg_name, - pkg_id: pkg.pkg_id, error: err.to_string(), }); } diff --git a/crates/soar-operations/src/run.rs b/crates/soar-operations/src/run.rs index 42a1a966..f364e6af 100644 --- a/crates/soar-operations/src/run.rs +++ b/crates/soar-operations/src/run.rs @@ -143,7 +143,6 @@ pub async fn prepare_run( let progress_callback = create_progress_bridge( ctx.events().clone(), op_id, - package.pkg_id.clone(), package.pkg_name.clone(), ); diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index 94c9e54e..1d3c4a5b 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -60,7 +60,16 @@ pub async fn search_packages( 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)) + .map(|pkg| { + ( + ( + pkg.repo_name, + pkg.pkg_id.unwrap_or_default(), + pkg.pkg_name, + ), + pkg.is_installed, + ) + }) .collect(); let total_count = packages.len(); @@ -71,7 +80,7 @@ pub async fn search_packages( .map(|package| { let key = ( package.repo_name.clone(), - package.pkg_id.clone(), + package.pkg_id.clone().unwrap_or_default(), package.pkg_name.clone(), ); let installed = installed_pkgs.get(&key).copied().unwrap_or(false); diff --git a/crates/soar-operations/src/switch.rs b/crates/soar-operations/src/switch.rs index 5c024b51..22cb2644 100644 --- a/crates/soar-operations/src/switch.rs +++ b/crates/soar-operations/src/switch.rs @@ -82,7 +82,7 @@ pub async fn switch_variant( .ok_or_else(|| SoarError::Custom("Invalid variant index".into()))?; let pkg_name = &selected_package.pkg_name; - let pkg_id = &selected_package.pkg_id; + let pkg_id = selected_package.pkg_id.as_deref(); let checksum = selected_package.checksum.as_deref(); // Atomically unlink other variants and link the selected one so the DB @@ -119,7 +119,7 @@ pub async fn switch_variant( conn, Some(name), None, - Some(&selected_package.pkg_id), + selected_package.pkg_id.as_deref(), None, Some(1), None, diff --git a/crates/soar-operations/src/types.rs b/crates/soar-operations/src/types.rs index 6e6c3840..963ca7f4 100644 --- a/crates/soar-operations/src/types.rs +++ b/crates/soar-operations/src/types.rs @@ -35,7 +35,6 @@ pub enum ResolveResult { /// Already installed (and not --force). AlreadyInstalled { pkg_name: String, - pkg_id: String, repo_name: String, version: String, }, @@ -58,7 +57,6 @@ pub struct InstallReport { #[derive(Debug)] pub struct InstalledInfo { pub pkg_name: String, - pub pkg_id: String, pub repo_name: String, pub version: String, pub install_dir: PathBuf, @@ -70,7 +68,6 @@ pub struct InstalledInfo { #[derive(Debug)] pub struct FailedInfo { pub pkg_name: String, - pub pkg_id: String, pub error: String, } @@ -93,7 +90,6 @@ pub struct RemoveReport { pub struct RemovedInfo { pub pkg_name: String, - pub pkg_id: String, pub repo_name: String, pub version: String, } @@ -102,7 +98,6 @@ pub struct RemovedInfo { pub struct UpdateInfo { pub pkg_name: String, - pub pkg_id: String, pub repo_name: String, pub current_version: String, pub new_version: String, @@ -168,7 +163,6 @@ pub struct HealthReport { pub struct BrokenPackage { pub pkg_name: String, - pub pkg_id: String, pub installed_path: String, } diff --git a/crates/soar-operations/src/update.rs b/crates/soar-operations/src/update.rs index 57b13084..f59055b1 100644 --- a/crates/soar-operations/src/update.rs +++ b/crates/soar-operations/src/update.rs @@ -137,7 +137,7 @@ fn check_repo_update( ) -> SoarResult> { 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) + MetadataRepository::find_newer_version(conn, &pkg.pkg_name, pkg.pkg_id.as_deref(), &pkg.version) })? .flatten() .map(|p| { @@ -150,7 +150,6 @@ fn check_repo_update( let Some(package) = new_pkg else { ctx.events().emit(SoarEvent::UpdateCheck { pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), status: UpdateCheckStatus::UpToDate { version: pkg.version.clone(), }, @@ -168,7 +167,6 @@ fn check_repo_update( ctx.events().emit(SoarEvent::UpdateCheck { pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), status: UpdateCheckStatus::Available { current_version: pkg.version.clone(), new_version: package.version.clone(), @@ -177,7 +175,6 @@ fn check_repo_update( Ok(Some(UpdateInfo { pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), repo_name: pkg.repo_name.clone(), current_version: pkg.version.clone(), new_version: package.version.clone(), @@ -209,7 +206,6 @@ fn check_local_update( let Some(resolved) = resolved else { ctx.events().emit(SoarEvent::UpdateCheck { pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), status: UpdateCheckStatus::Skipped { reason: "no update source configured".into(), }, @@ -220,7 +216,6 @@ fn check_local_update( if resolved.pinned { ctx.events().emit(SoarEvent::UpdateCheck { pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), status: UpdateCheckStatus::Skipped { reason: "pinned".into(), }, @@ -250,7 +245,6 @@ fn check_local_update( if v == installed_version { ctx.events().emit(SoarEvent::UpdateCheck { pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), status: UpdateCheckStatus::UpToDate { version: pkg.version.clone(), }, @@ -311,7 +305,6 @@ fn check_local_update( if v == installed_version { ctx.events().emit(SoarEvent::UpdateCheck { pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), status: UpdateCheckStatus::UpToDate { version: pkg.version.clone(), }, @@ -332,13 +325,12 @@ fn check_local_update( Some(&pkg.pkg_name), Some(&version), pkg.pkg_type.as_deref(), - Some(&pkg.pkg_id), + pkg.pkg_id.as_deref(), )?; updated_url_pkg.size = size; ctx.events().emit(SoarEvent::UpdateCheck { pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), status: UpdateCheckStatus::Available { current_version: pkg.version.clone(), new_version: version.clone(), @@ -367,7 +359,6 @@ fn check_local_update( Ok(Some(UpdateInfo { pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), repo_name: pkg.repo_name.clone(), current_version: pkg.version.clone(), new_version: version, @@ -389,7 +380,7 @@ fn get_existing( conn, &package.repo_name, &package.pkg_name, - &package.pkg_id, + package.pkg_id.as_deref(), &package.version, ) })?; @@ -449,15 +440,15 @@ pub async fn perform_update( // Clean up old versions only for successfully updated packages if !keep_old { let diesel_db = ctx.diesel_core_db()?.clone(); - let succeeded: HashSet<(&str, &str)> = install_report + let succeeded: HashSet<&str> = install_report .installed .iter() - .map(|i| (i.pkg_name.as_str(), i.pkg_id.as_str())) + .map(|i| i.pkg_name.as_str()) .collect(); for target in &targets { let pkg = &target.package; - if !succeeded.contains(&(pkg.pkg_name.as_str(), pkg.pkg_id.as_str())) { + if !succeeded.contains(pkg.pkg_name.as_str()) { continue; } @@ -465,7 +456,6 @@ pub async fn perform_update( ctx.events().emit(SoarEvent::UpdateCleanup { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), old_version: target .existing_install .as_ref() @@ -479,7 +469,6 @@ pub async fn perform_update( ctx.events().emit(SoarEvent::UpdateCleanup { op_id, pkg_name: pkg.pkg_name.clone(), - pkg_id: pkg.pkg_id.clone(), old_version: target .existing_install .as_ref() diff --git a/crates/soar-package/src/formats/common.rs b/crates/soar-package/src/formats/common.rs index e2b49fc9..c45a6110 100644 --- a/crates/soar-package/src/formats/common.rs +++ b/crates/soar-package/src/formats/common.rs @@ -291,11 +291,12 @@ pub fn setup_portable_dir, T: PackageExt>( portable_share: Option<&str>, portable_cache: Option<&str>, ) -> Result<()> { - let portable_dir_base = get_config().get_portable_dirs()?.join(format!( - "{}-{}", - package.pkg_name(), - package.pkg_id() - )); + // Packages that carry an id keep their existing directory name; those + // without one are named by package alone. + let portable_dir_base = get_config().get_portable_dirs()?.join(match package.pkg_id() { + Some(pkg_id) => format!("{}-{}", package.pkg_name(), pkg_id), + None => package.pkg_name().to_string(), + }); let bin_path = bin_path.as_ref(); let pkg_name = package.pkg_name(); diff --git a/crates/soar-package/src/traits.rs b/crates/soar-package/src/traits.rs index d305609d..e0867d52 100644 --- a/crates/soar-package/src/traits.rs +++ b/crates/soar-package/src/traits.rs @@ -8,8 +8,8 @@ pub trait PackageExt { /// Returns the package name (human-readable name). fn pkg_name(&self) -> &str; - /// Returns the unique package identifier. - fn pkg_id(&self) -> &str; + /// Returns the package identifier, when the repository publishes one. + fn pkg_id(&self) -> Option<&str>; /// Returns the package version string. fn version(&self) -> &str; From ef48f0602f24785bf39d8fdd2d32629afb001923 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 08:17:58 +0545 Subject: [PATCH 12/66] feat(db): stop inventing package ids on import --- crates/soar-core/src/database/models.rs | 2 +- .../down.sql | 2 + .../up.sql | 58 +++++++++++++++++++ crates/soar-db/src/models/metadata.rs | 10 ++-- crates/soar-db/src/repository/metadata.rs | 20 +++---- crates/soar-db/src/schema/metadata.rs | 2 +- crates/soar-operations/src/list.rs | 2 +- crates/soar-operations/src/search.rs | 7 ++- 8 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/down.sql create mode 100644 crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/up.sql diff --git a/crates/soar-core/src/database/models.rs b/crates/soar-core/src/database/models.rs index eafe6b7b..66579f6f 100644 --- a/crates/soar-core/src/database/models.rs +++ b/crates/soar-core/src/database/models.rs @@ -248,7 +248,7 @@ impl From for Package { repo_name: String::new(), // Set by caller disabled: None, disabled_reason: None, - pkg_id: Some(pkg.pkg_id), + pkg_id: pkg.pkg_id, pkg_name: pkg.pkg_name, pkg_family: pkg.pkg_family, pkg_type: pkg.pkg_type, diff --git a/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/down.sql b/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/down.sql new file mode 100644 index 00000000..77dbe7c3 --- /dev/null +++ b/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS packages_identity; +DELETE FROM packages WHERE pkg_id IS NULL; diff --git a/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/up.sql b/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/up.sql new file mode 100644 index 00000000..0cf7f080 --- /dev/null +++ b/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/up.sql @@ -0,0 +1,58 @@ +-- A repository publishing the declarative format has no package id to give, +-- so the column stops being required. SQLite cannot relax NOT NULL in place. +-- +-- The uniqueness key keeps the id, but NULLs are collapsed first: SQLite +-- treats every NULL as distinct, so an id-less package would otherwise insert +-- a fresh duplicate on every sync instead of conflicting with itself. +CREATE TABLE packages_new ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + pkg_id TEXT COLLATE NOCASE, + pkg_family TEXT COLLATE NOCASE, + pkg_name TEXT NOT NULL COLLATE NOCASE, + pkg_type TEXT COLLATE NOCASE, + app_id TEXT COLLATE NOCASE, + description TEXT, + version TEXT NOT NULL, + licenses JSONB, + download_url TEXT NOT NULL, + size BIGINT, + ghcr_pkg TEXT, + ghcr_size BIGINT, + ghcr_blob TEXT, + ghcr_url TEXT, + bsum TEXT, + icon TEXT, + desktop TEXT, + appstream TEXT, + homepages JSONB, + notes JSONB, + source_urls JSONB, + categories JSONB, + build_id TEXT, + build_date TEXT, + build_action TEXT, + build_script TEXT, + build_log TEXT, + provides JSONB, + snapshots JSONB, + replaces JSONB, + soar_syms BOOLEAN NOT NULL DEFAULT false, + desktop_integration BOOLEAN, + portable BOOLEAN, + binaries JSONB, + extra JSONB +); + +INSERT INTO packages_new SELECT + id, pkg_id, pkg_family, pkg_name, pkg_type, app_id, description, version, + licenses, download_url, size, ghcr_pkg, ghcr_size, ghcr_blob, ghcr_url, + bsum, icon, desktop, appstream, homepages, notes, source_urls, categories, + build_id, build_date, build_action, build_script, build_log, provides, + snapshots, replaces, soar_syms, desktop_integration, portable, binaries, extra +FROM packages; + +DROP TABLE packages; +ALTER TABLE packages_new RENAME TO packages; + +CREATE UNIQUE INDEX packages_identity + ON packages (COALESCE(pkg_id, ''), pkg_name, version); diff --git a/crates/soar-db/src/models/metadata.rs b/crates/soar-db/src/models/metadata.rs index 58fac048..2ec25235 100644 --- a/crates/soar-db/src/models/metadata.rs +++ b/crates/soar-db/src/models/metadata.rs @@ -10,7 +10,7 @@ use crate::{ #[derive(Debug, Clone, Selectable)] pub struct Package { pub id: i32, - pub pkg_id: String, + pub pkg_id: Option, pub pkg_name: String, pub pkg_family: Option, pub pkg_type: Option, @@ -52,7 +52,7 @@ pub struct Package { impl Queryable for Package { type Row = ( i32, - String, + Option, String, Option, Option, @@ -137,7 +137,7 @@ impl Queryable for Package { #[diesel(table_name = packages)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct PackageListing { - pub pkg_id: String, + pub pkg_id: Option, pub pkg_name: String, pub pkg_type: Option, pub version: String, @@ -151,7 +151,7 @@ pub struct PackageListing { pub struct FuzzyCandidate { pub id: i32, pub pkg_name: String, - pub pkg_id: String, + pub pkg_id: Option, pub description: Option, } @@ -192,7 +192,7 @@ pub struct PackageMaintainer { #[derive(Default, Insertable)] #[diesel(table_name = packages)] pub struct NewPackage<'a> { - pub pkg_id: &'a str, + pub pkg_id: Option<&'a str>, pub pkg_name: &'a str, pub pkg_family: Option<&'a str>, pub pkg_type: Option<&'a str>, diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index 9c8f9fd2..f27e32a4 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -544,19 +544,16 @@ impl MetadataRepository { "inserting remote package" ); - // An absent or empty pkg_id falls back to the name rather than - // rejecting the package: it only ever existed to disambiguate - // identical names, so a repository whose names are unique has nothing - // to say here. - let pkg_id = package - .pkg_id - .as_deref() - .filter(|s| !s.is_empty()) - .unwrap_or(&package.pkg_name); + // Stored as absent rather than invented: a repository whose names are + // already unique has no id to give, and inventing one from the name + // makes a fabricated value indistinguishable from a published one. + let pkg_id = package.pkg_id.as_deref().filter(|s| !s.is_empty()); // pkg_name and pkg_id are joined into the install dir and interpolated // into resource paths, so a name with separators or '..' would escape it. - if !is_safe_component(&package.pkg_name) || !is_safe_component(pkg_id) { + if !is_safe_component(&package.pkg_name) + || pkg_id.is_some_and(|id| !is_safe_component(id)) + { warn!( pkg_name = package.pkg_name, "skipping package with unsafe path component in pkg_name/pkg_id" @@ -622,8 +619,7 @@ impl MetadataRepository { let inserted = diesel::insert_into(packages::table) .values(&new_package) - .on_conflict((packages::pkg_id, packages::pkg_name, packages::version)) - .do_nothing() + .on_conflict_do_nothing() .execute(conn)?; if inserted == 0 { diff --git a/crates/soar-db/src/schema/metadata.rs b/crates/soar-db/src/schema/metadata.rs index 890f8bbb..d4834faa 100644 --- a/crates/soar-db/src/schema/metadata.rs +++ b/crates/soar-db/src/schema/metadata.rs @@ -17,7 +17,7 @@ diesel::table! { diesel::table! { packages (id) { id -> Integer, - pkg_id -> Text, + pkg_id -> Nullable, pkg_name -> Text, pkg_family -> Nullable, pkg_type -> Nullable, diff --git a/crates/soar-operations/src/list.rs b/crates/soar-operations/src/list.rs index 6dee2fcc..f066cc09 100644 --- a/crates/soar-operations/src/list.rs +++ b/crates/soar-operations/src/list.rs @@ -81,7 +81,7 @@ pub async fn list_packages( .map(|entry| { let key = ( entry.repo_name.clone(), - entry.pkg.pkg_id.clone(), + entry.pkg.pkg_id.clone().unwrap_or_default(), entry.pkg.pkg_name.clone(), ); let installed = installed_pkgs.get(&key).copied().unwrap_or(false); diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index 1d3c4a5b..87f64296 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -193,8 +193,11 @@ fn score_candidates(query: &str, candidates: &[(String, FuzzyCandidate)]) -> Vec let name_buf = Utf32String::from(candidate.pkg_name.as_str()); let name_score = pattern.score(name_buf.slice(..), &mut matcher); - let id_buf = Utf32String::from(candidate.pkg_id.as_str()); - let id_score = pattern.score(id_buf.slice(..), &mut matcher); + // A package without an id simply has nothing extra to match on. + let id_score = candidate.pkg_id.as_deref().and_then(|id| { + let id_buf = Utf32String::from(id); + pattern.score(id_buf.slice(..), &mut matcher) + }); let best_score = [name_score, id_score].into_iter().flatten().max(); From 66c3529c98023091d0196efcb1be4bb5a925335c Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 08:28:16 +0545 Subject: [PATCH 13/66] fix(remove): restore write bits before deleting a package --- crates/soar-core/src/package/remove.rs | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/soar-core/src/package/remove.rs b/crates/soar-core/src/package/remove.rs index dff1d3e9..5e9c94c3 100644 --- a/crates/soar-core/src/package/remove.rs +++ b/crates/soar-core/src/package/remove.rs @@ -1,6 +1,7 @@ use std::{ ffi::OsString, fs, + os::unix::fs::PermissionsExt, path::{Path, PathBuf}, }; @@ -78,6 +79,28 @@ pub struct PackageRemover { sandbox: Option, } +/// Give every directory under `path` the owner write bit. +/// +/// Without it `remove_dir_all` cannot unlink the entries inside, so a package +/// that installed cleanly could not be removed. +fn make_tree_writable(path: &Path) { + let Ok(entries) = fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + let child = entry.path(); + if child.is_dir() && !child.is_symlink() { + make_tree_writable(&child); + } + } + if let Ok(meta) = fs::metadata(path) { + let mode = meta.permissions().mode(); + if mode & 0o200 == 0 { + fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o200)).ok(); + } + } +} + impl PackageRemover { pub async fn new(package: InstalledPackage, db: DieselDatabase, config: Config) -> Self { trace!( @@ -213,6 +236,10 @@ impl PackageRemover { self.package.installed_path, size_str ); + // Archives commonly ship directories read-only, and removing a + // directory's entries needs the write bit on that directory. soar owns + // this tree, so it may restore what it needs to delete it. + make_tree_writable(Path::new(&self.package.installed_path)); if let Err(err) = fs::remove_dir_all(&self.package.installed_path) { // if not found, the package is already removed. if err.kind() != std::io::ErrorKind::NotFound { From fa73bdbc02f5d7db10344de8a3ac4722961dc05f Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 08:32:07 +0545 Subject: [PATCH 14/66] fix: match installed packages by name, not by id --- crates/soar-operations/src/list.rs | 22 ++++++---------------- crates/soar-operations/src/search.rs | 22 ++++++---------------- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/crates/soar-operations/src/list.rs b/crates/soar-operations/src/list.rs index f066cc09..8a6a874e 100644 --- a/crates/soar-operations/src/list.rs +++ b/crates/soar-operations/src/list.rs @@ -57,21 +57,15 @@ pub async fn list_packages( })? }; - let installed_pkgs: HashMap<(String, String, String), bool> = diesel_db + let installed_pkgs: HashMap<(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.unwrap_or_default(), - pkg.pkg_name, - ), - pkg.is_installed, - ) - }) + // Keyed by name, not id: a package installed before ids became + // optional still carries one, while its metadata no longer does, and + // keying on both would stop matching the two. + .map(|pkg| ((pkg.repo_name, pkg.pkg_name), pkg.is_installed)) .collect(); let total = packages.len(); @@ -79,11 +73,7 @@ pub async fn list_packages( let entries: Vec = packages .into_iter() .map(|entry| { - let key = ( - entry.repo_name.clone(), - entry.pkg.pkg_id.clone().unwrap_or_default(), - entry.pkg.pkg_name.clone(), - ); + let key = (entry.repo_name.clone(), entry.pkg.pkg_name.clone()); let installed = installed_pkgs.get(&key).copied().unwrap_or(false); // Build a minimal Package for the entry diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index 87f64296..717d2f4b 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -55,21 +55,15 @@ pub async fn search_packages( fuzzy_search(ctx, query, search_limit).await? }; - let installed_pkgs: HashMap<(String, String, String), bool> = diesel_db + let installed_pkgs: HashMap<(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.unwrap_or_default(), - pkg.pkg_name, - ), - pkg.is_installed, - ) - }) + // Keyed by name, not id: a package installed before ids became + // optional still carries one, while its metadata no longer does, and + // keying on both would stop matching the two. + .map(|pkg| ((pkg.repo_name, pkg.pkg_name), pkg.is_installed)) .collect(); let total_count = packages.len(); @@ -78,11 +72,7 @@ pub async fn search_packages( .into_iter() .take(search_limit) .map(|package| { - let key = ( - package.repo_name.clone(), - package.pkg_id.clone().unwrap_or_default(), - package.pkg_name.clone(), - ); + let key = (package.repo_name.clone(), package.pkg_name.clone()); let installed = installed_pkgs.get(&key).copied().unwrap_or(false); SearchEntry { package, From 450b8da78892eeb7eebbe3e7e9f11ab72bd23d69 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 08:58:07 +0545 Subject: [PATCH 15/66] fix(update): order versions by segment, not by string --- crates/soar-db/src/repository/metadata.rs | 44 +++--- crates/soar-operations/src/update.rs | 2 +- crates/soar-utils/src/lib.rs | 1 + crates/soar-utils/src/version.rs | 168 ++++++++++++++++++++++ 4 files changed, 187 insertions(+), 28 deletions(-) create mode 100644 crates/soar-utils/src/version.rs diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index f27e32a4..8e9fb71d 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -7,6 +7,7 @@ use regex::Regex; use serde_json::json; use soar_registry::RemotePackage; use soar_utils::path::is_safe_component; +use soar_utils::version::{compare_versions, is_newer}; use tracing::{debug, trace, warn}; /// Regex for extracting name and contact from maintainer string format "Name (contact)". @@ -439,41 +440,30 @@ impl MetadataRepository { pub fn find_newer_version( conn: &mut SqliteConnection, pkg_name: &str, - pkg_id: Option<&str>, current_version: &str, ) -> QueryResult> { trace!( pkg_name = pkg_name, - pkg_id = pkg_id, current_version = current_version, "checking for newer version" ); - // Handle both regular versions and HEAD- versions - let head_version = if current_version.starts_with("HEAD-") && current_version.len() > 14 { - current_version[14..].to_string() - } else { - String::new() - }; - - // An installed package without an id is matched by name alone: the - // declarative format publishes no id to compare against. - let mut query = packages::table.into_boxed(); - query = query.filter(packages::pkg_name.eq(pkg_name)); - if let Some(id) = pkg_id { - query = query.filter(packages::pkg_id.eq(id.to_string())); - } - let result = query - .filter( - sql::("(version > ") - .bind::(current_version) - .sql(" OR (version LIKE 'HEAD-%' AND substr(version, 15) > ") - .bind::(&head_version) - .sql("))"), - ) - .order(packages::version.desc()) + // Matched by name alone. An id recorded at install time may no longer + // appear in the metadata at all, and requiring it to match would + // report every such package as already up to date. + // + // Ordering cannot be left to SQL: a string comparison puts 10 below 9 + // and the rebuild suffix in 1.14.0-1 below 1.14.0. Candidates are + // loaded and compared segment-wise instead. + let candidates: Vec = packages::table + .into_boxed() + .filter(packages::pkg_name.eq(pkg_name)) .select(Package::as_select()) - .first(conn) - .optional(); + .load(conn)?; + + let result: QueryResult> = Ok(candidates + .into_iter() + .filter(|p| is_newer(&p.version, current_version)) + .max_by(|a, b| compare_versions(&a.version, &b.version))); if let Ok(Some(ref p)) = result { debug!( "newer version available: {} -> {}", diff --git a/crates/soar-operations/src/update.rs b/crates/soar-operations/src/update.rs index f59055b1..2387218a 100644 --- a/crates/soar-operations/src/update.rs +++ b/crates/soar-operations/src/update.rs @@ -137,7 +137,7 @@ fn check_repo_update( ) -> SoarResult> { let new_pkg: Option = metadata_mgr .query_repo(&pkg.repo_name, |conn| { - MetadataRepository::find_newer_version(conn, &pkg.pkg_name, pkg.pkg_id.as_deref(), &pkg.version) + MetadataRepository::find_newer_version(conn, &pkg.pkg_name, &pkg.version) })? .flatten() .map(|p| { diff --git a/crates/soar-utils/src/lib.rs b/crates/soar-utils/src/lib.rs index 640b1e88..d0718b8a 100644 --- a/crates/soar-utils/src/lib.rs +++ b/crates/soar-utils/src/lib.rs @@ -7,3 +7,4 @@ pub mod path; pub mod pattern; pub mod system; pub mod time; +pub mod version; diff --git a/crates/soar-utils/src/version.rs b/crates/soar-utils/src/version.rs new file mode 100644 index 00000000..80918779 --- /dev/null +++ b/crates/soar-utils/src/version.rs @@ -0,0 +1,168 @@ +//! Ordering for the version strings repositories actually publish. +//! +//! Comparison is segment-wise: a version is split into runs of digits and runs +//! of non-digits, and matching runs are compared numerically when both are +//! numeric and lexically otherwise. This avoids the two ways a plain string +//! comparison gets it wrong, `10` sorting below `9` and `1.10` below `1.9`. +//! +//! Strict semver is deliberately not used. Most published versions carry a +//! rebuild revision as `-N`, which semver reads as a prerelease and therefore +//! ranks *below* the plain version, the opposite of what it means here. Others +//! (`1.05`, `7.1-2`, `r1287.fef2b38-1`) are not valid semver at all. +//! +//! A version built from a commit hash has no order to recover: hashes carry no +//! time. Those compare equal unless identical, so a repository that wants +//! upgrades between snapshots has to publish something ordered, such as a date. + +use std::cmp::Ordering; + +/// Compare two version strings. +/// +/// ``` +/// use std::cmp::Ordering; +/// use soar_utils::version::compare_versions; +/// +/// assert_eq!(compare_versions("10.4.2", "9.0.0"), Ordering::Greater); +/// assert_eq!(compare_versions("1.10.0", "1.9.0"), Ordering::Greater); +/// assert_eq!(compare_versions("1.14.0-2", "1.14.0-1"), Ordering::Greater); +/// assert_eq!(compare_versions("2026.05.24", "2026.05.23"), Ordering::Greater); +/// ``` +pub fn compare_versions(a: &str, b: &str) -> Ordering { + let mut left = segments(a); + let mut right = segments(b); + + loop { + match (left.next(), right.next()) { + (None, None) => return Ordering::Equal, + // A trailing segment means opposite things depending on its + // shape: a number is a further release (1.2.1 over 1.2, or the + // rebuild in 1.14.0-1 over 1.14.0), while text is a prerelease + // (1.2rc and 0.5.7-beta both precede their release). + (Some(x), None) => return extra_segment_order(x), + (None, Some(y)) => return extra_segment_order(y).reverse(), + (Some(x), Some(y)) => match compare_segment(x, y) { + Ordering::Equal => continue, + other => return other, + }, + } + } +} + +/// Whether `candidate` supersedes `current`. +pub fn is_newer(candidate: &str, current: &str) -> bool { + compare_versions(candidate, current) == Ordering::Greater +} + +/// How a version compares against one that stopped earlier, judged by the +/// first segment the longer one carries alone. +fn extra_segment_order(segment: &str) -> Ordering { + if segment.starts_with(|c: char| c.is_ascii_digit()) { + Ordering::Greater + } else { + Ordering::Less + } +} + +fn compare_segment(a: &str, b: &str) -> Ordering { + match (a.parse::(), b.parse::()) { + (Ok(x), Ok(y)) => x.cmp(&y), + // A numeric segment outranks a textual one, so 1.2 beats 1.2rc. + (Ok(_), Err(_)) => Ordering::Greater, + (Err(_), Ok(_)) => Ordering::Less, + (Err(_), Err(_)) => a.cmp(b), + } +} + +/// Split a version into runs of digits and runs of everything else, +/// discarding the separators between them. +fn segments(version: &str) -> impl Iterator { + let mut rest = version; + std::iter::from_fn(move || { + while let Some(c) = rest.chars().next() { + if c.is_ascii_alphanumeric() { + break; + } + rest = &rest[c.len_utf8()..]; + } + if rest.is_empty() { + return None; + } + let numeric = rest.starts_with(|c: char| c.is_ascii_digit()); + let end = rest + .find(|c: char| c.is_ascii_digit() != numeric || !c.is_ascii_alphanumeric()) + .unwrap_or(rest.len()); + let (seg, tail) = rest.split_at(end); + rest = tail; + Some(seg) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numeric_segments_beat_string_order() { + assert_eq!(compare_versions("10.4.2", "9.0.0"), Ordering::Greater); + assert_eq!(compare_versions("1.10.0", "1.9.0"), Ordering::Greater); + assert_eq!(compare_versions("1.06", "1.05"), Ordering::Greater); + } + + #[test] + fn rebuild_revision_is_newer_not_older() { + // semver would call these prereleases and rank them below 1.14.0 + assert_eq!(compare_versions("1.14.0-1", "1.14.0"), Ordering::Greater); + assert_eq!(compare_versions("2.0.19-4", "2.0.19-3"), Ordering::Greater); + assert_eq!(compare_versions("0.2.4-4", "0.2.4-3"), Ordering::Greater); + } + + #[test] + fn dates_order_naturally() { + assert_eq!(compare_versions("2026.05.24", "2026.05.23"), Ordering::Greater); + assert_eq!( + compare_versions("2026.05.24.1.dda726e-1", "2026.05.23.1.aaa111b-1"), + Ordering::Greater + ); + } + + #[test] + fn dates_compare_whatever_separates_them() { + // hyphen-separated, the ISO form + assert_eq!(compare_versions("2026-04-12", "2026-04-11"), Ordering::Greater); + assert_eq!(compare_versions("2026-05-01", "2026-04-30"), Ordering::Greater); + assert_eq!(compare_versions("2027-01-01", "2026-12-31"), Ordering::Greater); + // leading zeros are numeric, not text + assert_eq!(compare_versions("2026-04-09", "2026-04-10"), Ordering::Less); + // the separator itself carries no meaning + assert_eq!(compare_versions("2026-04-12", "2026.04.12"), Ordering::Equal); + assert_eq!(compare_versions("2026-04-12-2", "2026-04-12"), Ordering::Greater); + } + + #[test] + fn equal_versions_compare_equal() { + assert_eq!(compare_versions("1.2.3", "1.2.3"), Ordering::Equal); + assert_eq!(compare_versions("89c99d2a9", "89c99d2a9"), Ordering::Equal); + } + + #[test] + fn commit_hashes_have_no_meaningful_order() { + // Not equal, but any answer is arbitrary; the point is that it is + // stable rather than that it is right. + assert_ne!(compare_versions("89c99d2a9", "0f3a21b"), Ordering::Equal); + } + + #[test] + fn longer_version_wins_when_prefix_matches() { + assert_eq!(compare_versions("1.2.1", "1.2"), Ordering::Greater); + assert_eq!(compare_versions("3.13.1", "3.13"), Ordering::Greater); + } + + #[test] + fn text_segments_rank_below_numeric() { + // a text suffix is a prerelease and precedes the plain version + assert_eq!(compare_versions("1.2", "1.2rc"), Ordering::Greater); + assert_eq!(compare_versions("0.5.7", "0.5.7-beta"), Ordering::Greater); + // between two prereleases, order is lexical + assert_eq!(compare_versions("0.5.7-beta", "0.5.7-alpha"), Ordering::Greater); + } +} From 9bd5d5ff1dcc616a5457b5b87cbaf25ff906e61a Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 11:11:12 +0545 Subject: [PATCH 16/66] feat: declare an index format version --- crates/soar-cli/src/json2db.rs | 2 +- crates/soar-registry/src/error.rs | 6 ++++ crates/soar-registry/src/lib.rs | 1 + crates/soar-registry/src/metadata.rs | 48 ++++++++++++++++++++++++++-- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/soar-cli/src/json2db.rs b/crates/soar-cli/src/json2db.rs index c882c4f1..02f0d3f2 100644 --- a/crates/soar-cli/src/json2db.rs +++ b/crates/soar-cli/src/json2db.rs @@ -23,7 +23,7 @@ pub fn json_to_db(input_path: &str, output_path: &str, repo_name: Option<&str>) let json_content = fs::read_to_string(input_path) .with_context(|| format!("reading JSON metadata from {}", input_path))?; - let packages: Vec = serde_json::from_str(&json_content) + let packages: Vec = soar_registry::parse_index(json_content.as_bytes()) .map_err(|e| SoarError::Custom(format!("parsing JSON from {}: {}", input_path, e)))?; info!(count = packages.len(), "Parsed JSON metadata"); diff --git a/crates/soar-registry/src/error.rs b/crates/soar-registry/src/error.rs index 840288f4..b7b17697 100644 --- a/crates/soar-registry/src/error.rs +++ b/crates/soar-registry/src/error.rs @@ -12,6 +12,12 @@ use thiserror::Error; /// processing, or storing package metadata. #[derive(Error, Diagnostic, Debug)] pub enum RegistryError { + #[error( + "repository index is format {found}, but this soar understands up to \ + {supported}; upgrade soar to use this repository" + )] + UnsupportedFormat { found: u32, supported: u32 }, + #[error("Error while {action}: {source}")] #[diagnostic(code(soar_registry::io))] IoError { diff --git a/crates/soar-registry/src/lib.rs b/crates/soar-registry/src/lib.rs index d2d58245..5cd45938 100644 --- a/crates/soar-registry/src/lib.rs +++ b/crates/soar-registry/src/lib.rs @@ -39,6 +39,7 @@ pub mod package; pub use error::{ErrorContext, RegistryError, Result}; pub use metadata::{ + parse_index, SUPPORTED_FORMAT, fetch_metadata, process_metadata_content, write_metadata_db, MetadataContent, SQLITE_MAGIC_BYTES, ZST_MAGIC_BYTES, }; diff --git a/crates/soar-registry/src/metadata.rs b/crates/soar-registry/src/metadata.rs index 19b034f2..e9be8758 100644 --- a/crates/soar-registry/src/metadata.rs +++ b/crates/soar-registry/src/metadata.rs @@ -21,6 +21,8 @@ use ureq::http::{ }; use url::Url; +use serde::Deserialize; + use crate::{ error::{ErrorContext, RegistryError, Result}, package::RemotePackage, @@ -435,7 +437,8 @@ pub fn process_metadata_content( let tmp_file = File::open(&tmp_path) .with_context(|| format!("opening temporary file {tmp_path}"))?; let reader = BufReader::new(tmp_file); - let metadata: Vec = serde_json::from_reader(reader)?; + let metadata = + serde_json::from_reader::<_, MetadataDocument>(reader)?.into_packages()?; fs::remove_file(&tmp_path) .with_context(|| format!("removing temporary file {tmp_path}"))?; Ok(MetadataContent::Json(metadata)) @@ -443,11 +446,52 @@ pub fn process_metadata_content( } else if content[..4] == SQLITE_MAGIC_BYTES { Ok(MetadataContent::SqliteDb(content)) } else { - let metadata: Vec = serde_json::from_slice(&content)?; + let metadata = serde_json::from_slice::(&content)?.into_packages()?; Ok(MetadataContent::Json(metadata)) } } +/// The highest index format this build understands. +pub const SUPPORTED_FORMAT: u32 = 1; + +/// A metadata index, in either shape it may arrive in. +/// +/// The original form is a bare array of packages. The versioned form wraps it +/// so a client can tell an index it cannot read from one that merely lacks a +/// field, and say so instead of failing on whichever field it noticed first. +#[derive(Deserialize)] +#[serde(untagged)] +enum MetadataDocument { + Versioned { + format: u32, + packages: Vec, + }, + Legacy(Vec), +} + +/// Parse an index in either shape, refusing one newer than this build. +pub fn parse_index(bytes: &[u8]) -> Result> { + serde_json::from_slice::(bytes)?.into_packages() +} + +impl MetadataDocument { + /// Unwrap to the packages, refusing an index newer than this build. + fn into_packages(self) -> Result> { + match self { + MetadataDocument::Legacy(packages) => Ok(packages), + MetadataDocument::Versioned { format, packages } => { + if format > SUPPORTED_FORMAT { + return Err(RegistryError::UnsupportedFormat { + found: format, + supported: SUPPORTED_FORMAT, + }); + } + Ok(packages) + } + } + } +} + /// Writes SQLite database content to a file. /// /// This is a convenience function for writing [`MetadataContent::SqliteDb`] From a1af52bcb023e7c6db401663a31db1876e17c7ba Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 17:59:48 +0545 Subject: [PATCH 17/66] docs(install): explain why licences carry no hash --- crates/soar-core/src/package/install.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index e56a3d58..d7046853 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -46,11 +46,12 @@ use crate::{ /// /// AppImages and plain binaries are ELF and need the executable bit; archives /// are not and are extracted instead. -/// Fetch the pinned side files an artifact does not carry itself. +/// Fetch the side files an artifact does not carry itself. /// -/// Each is verified against the hash the index published, on the same footing -/// as the artifact: a licence fetched without checking it would be an -/// unverified download in the middle of an otherwise verified install. +/// One that published a hash is verified against it, on the same footing as +/// the artifact. A licence publishes none, because it is served from a branch +/// and is documentation rather than something that runs: pinning it would turn +/// an upstream copyright-year edit into a failed download. async fn install_extras( package: &Package, install_dir: &Path, From 4715fb3bf75e8574bd5f3d2d744c7fb979dda826 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 20:44:30 +0545 Subject: [PATCH 18/66] fmt --- crates/soar-cli/src/apply.rs | 18 ++------- crates/soar-cli/src/download.rs | 5 +-- crates/soar-cli/src/health.rs | 6 +-- crates/soar-cli/src/install.rs | 6 +-- crates/soar-cli/src/list.rs | 12 +++--- crates/soar-cli/src/progress.rs | 19 +++++++--- crates/soar-cli/src/remove.rs | 5 +-- crates/soar-cli/src/update.rs | 5 +-- crates/soar-cli/src/use.rs | 5 +-- crates/soar-core/src/package/install.rs | 5 +-- crates/soar-core/src/package/remove.rs | 5 +-- crates/soar-db/src/repository/metadata.rs | 14 +++---- crates/soar-operations/src/apply.rs | 6 +-- crates/soar-operations/src/context.rs | 5 +-- crates/soar-operations/src/install.rs | 14 +++---- crates/soar-operations/src/run.rs | 7 +--- crates/soar-operations/src/utils.rs | 7 +++- crates/soar-package/src/formats/common.rs | 10 +++-- crates/soar-registry/src/lib.rs | 5 +-- crates/soar-registry/src/metadata.rs | 8 ++-- crates/soar-registry/src/package.rs | 2 - crates/soar-utils/src/version.rs | 45 +++++++++++++++++------ 22 files changed, 101 insertions(+), 113 deletions(-) diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index ada6674e..2528a649 100644 --- a/crates/soar-cli/src/apply.rs +++ b/crates/soar-cli/src/apply.rs @@ -87,11 +87,7 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { let pkg = &target.package; builder.push_record([ format!("{}", Colored(Green, icon_or("+", "+"))), - format!( - "{}", - Colored(Blue, &pkg.pkg_name), - - ), + format!("{}", Colored(Blue, &pkg.pkg_name),), format!("{}", Colored(Green, &pkg.version)), format!("{}", Colored(Magenta, &pkg.repo_name)), ]); @@ -105,11 +101,7 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { .map_or("?".to_string(), |e| e.version.clone()); builder.push_record([ format!("{}", Colored(Yellow, icon_or("~", "~"))), - format!( - "{}", - Colored(Blue, &pkg.pkg_name), - - ), + format!("{}", Colored(Blue, &pkg.pkg_name),), format!( "{} -> {}", Colored(Red, &old_version), @@ -123,11 +115,7 @@ fn display_diff(diff: &ApplyDiff, prune: bool) { for pkg in &diff.to_remove { builder.push_record([ format!("{}", Colored(Red, icon_or("-", "-"))), - format!( - "{}", - Colored(Blue, &pkg.pkg_name), - - ), + format!("{}", Colored(Blue, &pkg.pkg_name),), format!("{}", Colored(Yellow, &pkg.version)), format!("{}", Colored(Magenta, &pkg.repo_name)), ]); diff --git a/crates/soar-cli/src/download.rs b/crates/soar-cli/src/download.rs index f1c305c0..519d0219 100644 --- a/crates/soar-cli/src/download.rs +++ b/crates/soar-cli/src/download.rs @@ -212,10 +212,7 @@ pub async fn handle_direct_downloads( let package = package.resolve(query.version.as_deref()); - info!( - "Downloading package: {}", - package.pkg_name - ); + info!("Downloading package: {}", package.pkg_name); if let Some(ref url) = package.ghcr_blob { let mut dl = OciDownload::new(url.as_str()).overwrite(ctx.get_overwrite_mode()); diff --git a/crates/soar-cli/src/health.rs b/crates/soar-cli/src/health.rs index 9cbf4e2a..75cbfd59 100644 --- a/crates/soar-cli/src/health.rs +++ b/crates/soar-cli/src/health.rs @@ -97,11 +97,7 @@ pub async fn remove_broken_packages(ctx: &SoarContext) -> SoarResult<()> { } for failed in &report.failed { - tracing::error!( - "Failed to remove {}: {}", - failed.pkg_name, - failed.error - ); + tracing::error!("Failed to remove {}: {}", failed.pkg_name, failed.error); } if !report.removed.is_empty() && report.failed.is_empty() { diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index d07d16a9..bbf5e314 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -76,8 +76,7 @@ pub async fn install_packages( if let Some(pkg) = pkg { // Re-resolve with the specific selected package - let specific_query = - format!("{}:{}", pkg.pkg_name, pkg.repo_name); + let specific_query = format!("{}:{}", pkg.pkg_name, pkg.repo_name); let re_results = install::resolve_packages(ctx, &[specific_query], &options).await?; for r in re_results { @@ -200,8 +199,7 @@ async fn install_with_show( }; if let Some(pkg) = pkg { - let specific_query = - format!("{}:{}", pkg.pkg_name, pkg.repo_name); + let specific_query = format!("{}:{}", pkg.pkg_name, pkg.repo_name); let re_results = install::resolve_packages(ctx, &[specific_query], options).await?; for r in re_results { diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index e960faea..07b75b68 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -397,13 +397,11 @@ pub async fn list_installed_packages( ); if entry.is_healthy { - let unique_count = unique_pkgs - .insert(format!( - "{}-{}", - package.pkg_id.as_deref().unwrap_or_default(), - package.pkg_name - )) - as u32 + let unique_count = unique_pkgs.insert(format!( + "{}-{}", + package.pkg_id.as_deref().unwrap_or_default(), + package.pkg_name + )) as u32 + unique_count; ( installed_count + 1, diff --git a/crates/soar-cli/src/progress.rs b/crates/soar-cli/src/progress.rs index e01df84e..e9c7a3eb 100644 --- a/crates/soar-cli/src/progress.rs +++ b/crates/soar-cli/src/progress.rs @@ -527,15 +527,22 @@ pub fn spawn_event_handler(receiver: Receiver) -> ProgressGuard { // repository that could not be synced most of all. Without a // handler these were dropped and the failure looked like // nothing happening. - SoarEvent::Log { level, message } => { + SoarEvent::Log { + level, + message, + } => { // Printed directly rather than through tracing: the // subscriber writes via this same progress handle, so // logging from inside suspend() deadlocks. - MULTI.suspend(|| match level { - LogLevel::Error => eprintln!(" {} {}", Red.paint("✗"), Red.paint(&message)), - LogLevel::Warning => eprintln!(" {} {}", Yellow.paint("!"), message), - LogLevel::Info => eprintln!(" {message}"), - LogLevel::Debug => {} + MULTI.suspend(|| { + match level { + LogLevel::Error => { + eprintln!(" {} {}", Red.paint("✗"), Red.paint(&message)) + } + LogLevel::Warning => eprintln!(" {} {}", Yellow.paint("!"), message), + LogLevel::Info => eprintln!(" {message}"), + LogLevel::Debug => {} + } }); } diff --git a/crates/soar-cli/src/remove.rs b/crates/soar-cli/src/remove.rs index f83aa3a8..d108191b 100644 --- a/crates/soar-cli/src/remove.rs +++ b/crates/soar-cli/src/remove.rs @@ -78,10 +78,7 @@ pub async fn remove_packages( } for failed in &report.failed { - error!( - "Failed to remove {}: {}", - failed.pkg_name, failed.error - ); + error!("Failed to remove {}: {}", failed.pkg_name, failed.error); } debug!("package removal completed"); diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index ad0c64a2..6384e681 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -49,10 +49,7 @@ fn display_update_report(report: &UpdateReport) { let use_icons = settings.icons(); for err_info in &report.failed { - error!( - "Failed to update {}: {}", - err_info.pkg_name, err_info.error - ); + error!("Failed to update {}: {}", err_info.pkg_name, err_info.error); } let updated_count = report.updated.len(); diff --git a/crates/soar-cli/src/use.rs b/crates/soar-cli/src/use.rs index a4d94e29..8013f18f 100644 --- a/crates/soar-cli/src/use.rs +++ b/crates/soar-cli/src/use.rs @@ -57,10 +57,7 @@ pub async fn use_alternate_package(ctx: &SoarContext, name: &str) -> SoarResult< let selection = get_valid_selection(variants.len())?; switch::switch_variant(ctx, name, selection).await?; - info!( - "Switched to {}", - variants[selection].package.pkg_name - ); + info!("Switched to {}", variants[selection].package.pkg_name); Ok(()) } diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index d7046853..1e26f725 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -52,10 +52,7 @@ use crate::{ /// the artifact. A licence publishes none, because it is served from a branch /// and is documentation rather than something that runs: pinning it would turn /// an upstream copyright-year edit into a failed download. -async fn install_extras( - package: &Package, - install_dir: &Path, -) -> SoarResult<()> { +async fn install_extras(package: &Package, install_dir: &Path) -> SoarResult<()> { let Some(extras) = &package.extra else { return Ok(()); }; diff --git a/crates/soar-core/src/package/remove.rs b/crates/soar-core/src/package/remove.rs index 5e9c94c3..e2e01e7f 100644 --- a/crates/soar-core/src/package/remove.rs +++ b/crates/soar-core/src/package/remove.rs @@ -268,10 +268,7 @@ impl PackageRemover { debug!( "removed {}:{} ({}) - reclaimed {}", - self.package.pkg_name, - self.package.repo_name, - self.package.version, - size_str + self.package.pkg_name, self.package.repo_name, self.package.version, size_str ); Ok(()) } diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index 8e9fb71d..bb00b813 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -6,8 +6,10 @@ use diesel::{dsl::sql, prelude::*, sql_types::Text}; use regex::Regex; use serde_json::json; use soar_registry::RemotePackage; -use soar_utils::path::is_safe_component; -use soar_utils::version::{compare_versions, is_newer}; +use soar_utils::{ + path::is_safe_component, + version::{compare_versions, is_newer}, +}; use tracing::{debug, trace, warn}; /// Regex for extracting name and contact from maintainer string format "Name (contact)". @@ -465,10 +467,7 @@ impl MetadataRepository { .filter(|p| is_newer(&p.version, current_version)) .max_by(|a, b| compare_versions(&a.version, &b.version))); if let Ok(Some(ref p)) = result { - debug!( - "newer version available: {} -> {}", - pkg_name, p.version - ); + debug!("newer version available: {} -> {}", pkg_name, p.version); } result } @@ -541,8 +540,7 @@ impl MetadataRepository { // pkg_name and pkg_id are joined into the install dir and interpolated // into resource paths, so a name with separators or '..' would escape it. - if !is_safe_component(&package.pkg_name) - || pkg_id.is_some_and(|id| !is_safe_component(id)) + if !is_safe_component(&package.pkg_name) || pkg_id.is_some_and(|id| !is_safe_component(id)) { warn!( pkg_name = package.pkg_name, diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index ef951773..85a505e1 100644 --- a/crates/soar-operations/src/apply.rs +++ b/crates/soar-operations/src/apply.rs @@ -138,10 +138,8 @@ pub async fn compute_diff( 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.version - )); + diff.in_sync + .push(format!("{}@{}", existing.pkg_name, existing.version)); } else if !existing.pinned || pkg.version.is_some() { let resolved_pkg = metadata_pkg.resolve(pkg.version.as_deref()); let target = create_install_target(pkg, resolved_pkg, Some(existing.clone())); diff --git a/crates/soar-operations/src/context.rs b/crates/soar-operations/src/context.rs index e5773af4..d04c2b3d 100644 --- a/crates/soar-operations/src/context.rs +++ b/crates/soar-operations/src/context.rs @@ -240,9 +240,8 @@ impl SoarContext { metadata_db.with_conn(|conn| MetadataRepository::exists_by_pkg_id(conn, pkg_id))?; if !exists { - let replacement = metadata_db.with_conn(|conn| { - MetadataRepository::find_replacement_pkg_id(conn, pkg_id) - })?; + let replacement = metadata_db + .with_conn(|conn| MetadataRepository::find_replacement_pkg_id(conn, pkg_id))?; if let Some(new_pkg_id) = replacement { self.inner.events.emit(SoarEvent::Log { diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 3a455cc6..3cc24d45 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -925,11 +925,7 @@ async fn install_single_package( let install_patterns = apply_sig_variants(install_patterns); // Create progress bridge for download events - let progress_callback = create_progress_bridge( - events.clone(), - op_id, - pkg.pkg_name.clone(), - ); + let progress_callback = create_progress_bridge(events.clone(), op_id, pkg.pkg_name.clone()); trace!(install_dir = %install_dir.display(), "creating package installer"); let installer = PackageInstaller::new( @@ -1044,9 +1040,11 @@ async fn install_single_package( // silently replaced by whatever the index says. let index_binaries: Option> = pkg.binaries.as_ref().map(|bins| { bins.iter() - .map(|b| BinaryMapping { - source: b.source.clone(), - link_as: b.link_as.clone(), + .map(|b| { + BinaryMapping { + source: b.source.clone(), + link_as: b.link_as.clone(), + } }) .collect() }); diff --git a/crates/soar-operations/src/run.rs b/crates/soar-operations/src/run.rs index f364e6af..37e22fcb 100644 --- a/crates/soar-operations/src/run.rs +++ b/crates/soar-operations/src/run.rs @@ -140,11 +140,8 @@ pub async fn prepare_run( .with_context(|| format!("creating directory {}", cache_bin.display()))?; let op_id = next_op_id(); - let progress_callback = create_progress_bridge( - ctx.events().clone(), - op_id, - package.pkg_name.clone(), - ); + let progress_callback = + create_progress_bridge(ctx.events().clone(), op_id, package.pkg_name.clone()); download_to_cache( &package, diff --git a/crates/soar-operations/src/utils.rs b/crates/soar-operations/src/utils.rs index 8eef3273..0369a17a 100644 --- a/crates/soar-operations/src/utils.rs +++ b/crates/soar-operations/src/utils.rs @@ -162,7 +162,12 @@ pub async fn mangle_package_symlinks( .iter() .filter(|p| { p.file_name() - .map(|n| fast_glob::glob_match(&source_pattern, n.to_string_lossy().to_string())) + .map(|n| { + fast_glob::glob_match( + &source_pattern, + n.to_string_lossy().to_string(), + ) + }) .unwrap_or(false) }) .cloned() diff --git a/crates/soar-package/src/formats/common.rs b/crates/soar-package/src/formats/common.rs index c45a6110..536ff352 100644 --- a/crates/soar-package/src/formats/common.rs +++ b/crates/soar-package/src/formats/common.rs @@ -293,10 +293,12 @@ pub fn setup_portable_dir, T: PackageExt>( ) -> Result<()> { // Packages that carry an id keep their existing directory name; those // without one are named by package alone. - let portable_dir_base = get_config().get_portable_dirs()?.join(match package.pkg_id() { - Some(pkg_id) => format!("{}-{}", package.pkg_name(), pkg_id), - None => package.pkg_name().to_string(), - }); + let portable_dir_base = get_config() + .get_portable_dirs()? + .join(match package.pkg_id() { + Some(pkg_id) => format!("{}-{}", package.pkg_name(), pkg_id), + None => package.pkg_name().to_string(), + }); let bin_path = bin_path.as_ref(); let pkg_name = package.pkg_name(); diff --git a/crates/soar-registry/src/lib.rs b/crates/soar-registry/src/lib.rs index 5cd45938..d96df41d 100644 --- a/crates/soar-registry/src/lib.rs +++ b/crates/soar-registry/src/lib.rs @@ -39,8 +39,7 @@ pub mod package; pub use error::{ErrorContext, RegistryError, Result}; pub use metadata::{ - parse_index, SUPPORTED_FORMAT, - fetch_metadata, process_metadata_content, write_metadata_db, MetadataContent, - SQLITE_MAGIC_BYTES, ZST_MAGIC_BYTES, + fetch_metadata, parse_index, process_metadata_content, write_metadata_db, MetadataContent, + SQLITE_MAGIC_BYTES, SUPPORTED_FORMAT, ZST_MAGIC_BYTES, }; pub use package::RemotePackage; diff --git a/crates/soar-registry/src/metadata.rs b/crates/soar-registry/src/metadata.rs index e9be8758..97700702 100644 --- a/crates/soar-registry/src/metadata.rs +++ b/crates/soar-registry/src/metadata.rs @@ -11,6 +11,7 @@ use std::{ }; use minisign_verify::{PublicKey, Signature}; +use serde::Deserialize; use soar_config::repository::Repository; use soar_dl::http_client::SHARED_AGENT; use soar_utils::path::resolve_path; @@ -21,8 +22,6 @@ use ureq::http::{ }; use url::Url; -use serde::Deserialize; - use crate::{ error::{ErrorContext, RegistryError, Result}, package::RemotePackage, @@ -479,7 +478,10 @@ impl MetadataDocument { fn into_packages(self) -> Result> { match self { MetadataDocument::Legacy(packages) => Ok(packages), - MetadataDocument::Versioned { format, packages } => { + MetadataDocument::Versioned { + format, + packages, + } => { if format > SUPPORTED_FORMAT { return Err(RegistryError::UnsupportedFormat { found: format, diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index 0ce3b50a..773dc281 100644 --- a/crates/soar-registry/src/package.rs +++ b/crates/soar-registry/src/package.rs @@ -143,7 +143,6 @@ pub struct RemotePackage { #[serde(default, deserialize_with = "empty_is_none")] pub pkg_type: Option, - pub description: String, pub version: String, @@ -188,7 +187,6 @@ pub struct RemotePackage { #[serde(alias = "note")] pub notes: Option>, - #[serde(default, deserialize_with = "empty_is_none")] pub bsum: Option, diff --git a/crates/soar-utils/src/version.rs b/crates/soar-utils/src/version.rs index 80918779..7f55a0d5 100644 --- a/crates/soar-utils/src/version.rs +++ b/crates/soar-utils/src/version.rs @@ -40,10 +40,12 @@ pub fn compare_versions(a: &str, b: &str) -> Ordering { // (1.2rc and 0.5.7-beta both precede their release). (Some(x), None) => return extra_segment_order(x), (None, Some(y)) => return extra_segment_order(y).reverse(), - (Some(x), Some(y)) => match compare_segment(x, y) { - Ordering::Equal => continue, - other => return other, - }, + (Some(x), Some(y)) => { + match compare_segment(x, y) { + Ordering::Equal => continue, + other => return other, + } + } } } } @@ -118,7 +120,10 @@ mod tests { #[test] fn dates_order_naturally() { - assert_eq!(compare_versions("2026.05.24", "2026.05.23"), Ordering::Greater); + assert_eq!( + compare_versions("2026.05.24", "2026.05.23"), + Ordering::Greater + ); assert_eq!( compare_versions("2026.05.24.1.dda726e-1", "2026.05.23.1.aaa111b-1"), Ordering::Greater @@ -128,14 +133,29 @@ mod tests { #[test] fn dates_compare_whatever_separates_them() { // hyphen-separated, the ISO form - assert_eq!(compare_versions("2026-04-12", "2026-04-11"), Ordering::Greater); - assert_eq!(compare_versions("2026-05-01", "2026-04-30"), Ordering::Greater); - assert_eq!(compare_versions("2027-01-01", "2026-12-31"), Ordering::Greater); + assert_eq!( + compare_versions("2026-04-12", "2026-04-11"), + Ordering::Greater + ); + assert_eq!( + compare_versions("2026-05-01", "2026-04-30"), + Ordering::Greater + ); + assert_eq!( + compare_versions("2027-01-01", "2026-12-31"), + Ordering::Greater + ); // leading zeros are numeric, not text assert_eq!(compare_versions("2026-04-09", "2026-04-10"), Ordering::Less); // the separator itself carries no meaning - assert_eq!(compare_versions("2026-04-12", "2026.04.12"), Ordering::Equal); - assert_eq!(compare_versions("2026-04-12-2", "2026-04-12"), Ordering::Greater); + assert_eq!( + compare_versions("2026-04-12", "2026.04.12"), + Ordering::Equal + ); + assert_eq!( + compare_versions("2026-04-12-2", "2026-04-12"), + Ordering::Greater + ); } #[test] @@ -163,6 +183,9 @@ mod tests { assert_eq!(compare_versions("1.2", "1.2rc"), Ordering::Greater); assert_eq!(compare_versions("0.5.7", "0.5.7-beta"), Ordering::Greater); // between two prereleases, order is lexical - assert_eq!(compare_versions("0.5.7-beta", "0.5.7-alpha"), Ordering::Greater); + assert_eq!( + compare_versions("0.5.7-beta", "0.5.7-alpha"), + Ordering::Greater + ); } } From a72ee194408a0af95d4f0163e9ecc0559234aadb Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 20:54:37 +0545 Subject: [PATCH 19/66] refactor(db): collapse the new migrations into one per database --- .../down.sql | 1 - .../up.sql | 3 - .../down.sql | 31 ++++++++++ .../up.sql | 33 +++++++++++ .../down.sql | 2 - .../up.sql | 32 ----------- .../down.sql | 1 - .../up.sql | 4 -- .../down.sql | 56 +++++++++++++++++++ .../up.sql | 13 ++++- .../down.sql | 2 - .../up.sql | 4 -- .../2026-07-28-000002-0000_add_extra/down.sql | 1 - .../2026-07-28-000002-0000_add_extra/up.sql | 3 - .../down.sql | 2 - 15 files changed, 130 insertions(+), 58 deletions(-) delete mode 100644 crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/down.sql delete mode 100644 crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/up.sql create mode 100644 crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/down.sql create mode 100644 crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql delete mode 100644 crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/down.sql delete mode 100644 crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/up.sql delete mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/down.sql delete mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/up.sql create mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql rename crates/soar-db/migrations/metadata/{2026-07-29-000000-0000_pkg_id_optional => 2026-07-28-000000-0000_declarative_format}/up.sql (73%) delete mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/down.sql delete mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/up.sql delete mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/down.sql delete mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/up.sql delete mode 100644 crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/down.sql diff --git a/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/down.sql b/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/down.sql deleted file mode 100644 index a9e6e786..00000000 --- a/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE packages DROP COLUMN pkg_family; diff --git a/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/up.sql b/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/up.sql deleted file mode 100644 index 9f693eed..00000000 --- a/crates/soar-db/migrations/core/2026-07-28-000000-0000_add_pkg_family/up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Which project an installed package came from, so variants can be told apart --- by family rather than by the package id repositories no longer publish. -ALTER TABLE packages ADD COLUMN pkg_family TEXT; diff --git a/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/down.sql b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/down.sql new file mode 100644 index 00000000..5a789094 --- /dev/null +++ b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/down.sql @@ -0,0 +1,31 @@ +-- Rows without an id cannot be represented once the column is required again. +DELETE FROM packages WHERE pkg_id IS NULL; + +CREATE TABLE packages_old ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + repo_name TEXT NOT NULL, + pkg_id TEXT NOT NULL COLLATE NOCASE, + pkg_name TEXT NOT NULL COLLATE NOCASE, + pkg_type TEXT COLLATE NOCASE, + version TEXT NOT NULL, + size BIGINT NOT NULL, + checksum TEXT, + installed_path TEXT NOT NULL, + installed_date TEXT NOT NULL, + profile TEXT NOT NULL, + pinned BOOLEAN NOT NULL DEFAULT false, + is_installed BOOLEAN NOT NULL DEFAULT false, + detached BOOLEAN NOT NULL DEFAULT false, + unlinked BOOLEAN NOT NULL DEFAULT false, + provides JSONB, + install_patterns JSONB +); + +INSERT INTO packages_old +SELECT id, repo_name, pkg_id, pkg_name, pkg_type, version, size, checksum, + installed_path, installed_date, profile, pinned, is_installed, detached, + unlinked, provides, install_patterns +FROM packages; + +DROP TABLE packages; +ALTER TABLE packages_old RENAME TO packages; diff --git a/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql new file mode 100644 index 00000000..8be8cdbd --- /dev/null +++ b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql @@ -0,0 +1,33 @@ +-- Installed rows record the family a package came from, so variants can be +-- told apart without the package id repositories no longer publish, and the +-- id itself stops being required. SQLite cannot relax NOT NULL in place, so +-- the table is rebuilt. +CREATE TABLE packages_new ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + repo_name TEXT NOT NULL, + pkg_id TEXT COLLATE NOCASE, + pkg_name TEXT NOT NULL COLLATE NOCASE, + pkg_family TEXT COLLATE NOCASE, + pkg_type TEXT COLLATE NOCASE, + version TEXT NOT NULL, + size BIGINT NOT NULL, + checksum TEXT, + installed_path TEXT NOT NULL, + installed_date TEXT NOT NULL, + profile TEXT NOT NULL, + pinned BOOLEAN NOT NULL DEFAULT false, + is_installed BOOLEAN NOT NULL DEFAULT false, + detached BOOLEAN NOT NULL DEFAULT false, + unlinked BOOLEAN NOT NULL DEFAULT false, + provides JSONB, + install_patterns JSONB +); + +INSERT INTO packages_new +SELECT id, repo_name, pkg_id, pkg_name, NULL, pkg_type, version, size, checksum, + installed_path, installed_date, profile, pinned, is_installed, detached, + unlinked, provides, install_patterns +FROM packages; + +DROP TABLE packages; +ALTER TABLE packages_new RENAME TO packages; diff --git a/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/down.sql b/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/down.sql deleted file mode 100644 index 1018b173..00000000 --- a/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/down.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Rows without an id cannot be represented once the column is required again. -DELETE FROM packages WHERE pkg_id IS NULL; diff --git a/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/up.sql b/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/up.sql deleted file mode 100644 index bff86d4a..00000000 --- a/crates/soar-db/migrations/core/2026-07-29-000000-0000_pkg_id_optional/up.sql +++ /dev/null @@ -1,32 +0,0 @@ --- Repositories publishing the declarative format do not produce a package id, --- so installed rows must be able to omit it. SQLite cannot relax NOT NULL in --- place, so the table is rebuilt. -CREATE TABLE packages_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - repo_name TEXT NOT NULL, - pkg_id TEXT, - pkg_name TEXT NOT NULL, - pkg_family TEXT, - pkg_type TEXT, - version TEXT NOT NULL, - size BIGINT NOT NULL, - checksum TEXT, - installed_path TEXT NOT NULL, - installed_date TEXT NOT NULL, - profile TEXT NOT NULL, - pinned BOOLEAN NOT NULL DEFAULT 0, - is_installed BOOLEAN NOT NULL DEFAULT 0, - detached BOOLEAN NOT NULL DEFAULT 0, - unlinked BOOLEAN NOT NULL DEFAULT 0, - provides BLOB, - install_patterns BLOB -); - -INSERT INTO packages_new -SELECT id, repo_name, pkg_id, pkg_name, pkg_family, pkg_type, version, size, - checksum, installed_path, installed_date, profile, pinned, is_installed, - detached, unlinked, provides, install_patterns -FROM packages; - -DROP TABLE packages; -ALTER TABLE packages_new RENAME TO packages; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/down.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/down.sql deleted file mode 100644 index 3e1846ce..00000000 --- a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE packages DROP COLUMN binaries; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/up.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/up.sql deleted file mode 100644 index 7b6d3041..00000000 --- a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_add_binaries/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Where each executable lives inside the artifact, and what to call it. --- Without this a package whose binary is named differently from the package --- (gdu_linux_amd64 -> gdu) cannot be installed from a repository index. -ALTER TABLE packages ADD COLUMN binaries JSONB; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql new file mode 100644 index 00000000..36dbff45 --- /dev/null +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql @@ -0,0 +1,56 @@ +-- Rows without an id cannot be represented once the column is required again. +DROP INDEX IF EXISTS packages_identity; +DELETE FROM packages WHERE pkg_id IS NULL; + +CREATE TABLE packages_old ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + pkg_id TEXT NOT NULL COLLATE NOCASE, + pkg_family TEXT COLLATE NOCASE, + pkg_name TEXT NOT NULL COLLATE NOCASE, + pkg_type TEXT COLLATE NOCASE, + pkg_webpage TEXT, + app_id TEXT COLLATE NOCASE, + description TEXT, + version TEXT NOT NULL, + version_upstream TEXT, + licenses JSONB, + download_url TEXT NOT NULL, + size BIGINT, + ghcr_pkg TEXT, + ghcr_size BIGINT, + ghcr_blob TEXT, + ghcr_url TEXT, + bsum TEXT, + icon TEXT, + desktop TEXT, + appstream TEXT, + homepages JSONB, + notes JSONB, + source_urls JSONB, + tags JSONB, + categories JSONB, + build_id TEXT, + build_date TEXT, + build_action TEXT, + build_script TEXT, + build_log TEXT, + provides JSONB, + snapshots JSONB, + replaces JSONB, + soar_syms BOOLEAN NOT NULL DEFAULT false, + desktop_integration BOOLEAN, + portable BOOLEAN, + UNIQUE (pkg_id, pkg_name, version) +); + +INSERT INTO packages_old SELECT + id, pkg_id, pkg_family, pkg_name, pkg_type, NULL, app_id, description, + version, NULL, licenses, download_url, size, ghcr_pkg, ghcr_size, ghcr_blob, + ghcr_url, bsum, icon, desktop, appstream, homepages, notes, source_urls, + NULL, categories, build_id, build_date, build_action, build_script, + build_log, provides, snapshots, replaces, soar_syms, desktop_integration, + portable +FROM packages; + +DROP TABLE packages; +ALTER TABLE packages_old RENAME TO packages; diff --git a/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/up.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql similarity index 73% rename from crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/up.sql rename to crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql index 0cf7f080..d0d72759 100644 --- a/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/up.sql +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql @@ -1,5 +1,12 @@ --- A repository publishing the declarative format has no package id to give, --- so the column stops being required. SQLite cannot relax NOT NULL in place. +-- The declarative format carries what a package contains rather than leaving +-- it to be guessed: `binaries` says where each executable lives inside the +-- artifact and what to call it, `extra` lists side files installed alongside +-- it. A repository publishing that format has no package id to give, so the +-- id stops being required, and SQLite cannot relax NOT NULL in place. +-- +-- Three columns go: `pkg_webpage` was derivable from the package's own fields, +-- `tags` was stored and displayed but never searched, and `version_upstream` +-- was never read at all. -- -- The uniqueness key keeps the id, but NULLs are collapsed first: SQLite -- treats every NULL as distinct, so an id-less package would otherwise insert @@ -48,7 +55,7 @@ INSERT INTO packages_new SELECT licenses, download_url, size, ghcr_pkg, ghcr_size, ghcr_blob, ghcr_url, bsum, icon, desktop, appstream, homepages, notes, source_urls, categories, build_id, build_date, build_action, build_script, build_log, provides, - snapshots, replaces, soar_syms, desktop_integration, portable, binaries, extra + snapshots, replaces, soar_syms, desktop_integration, portable, NULL, NULL FROM packages; DROP TABLE packages; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/down.sql b/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/down.sql deleted file mode 100644 index 849b7668..00000000 --- a/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE packages ADD COLUMN pkg_webpage TEXT; -ALTER TABLE packages ADD COLUMN tags JSONB; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/up.sql b/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/up.sql deleted file mode 100644 index 2d0509f8..00000000 --- a/crates/soar-db/migrations/metadata/2026-07-28-000001-0000_drop_webpage_tags/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- pkg_webpage was a repository-specific URL derivable from the package's own --- fields, and tags were stored and displayed but never searched or queried. -ALTER TABLE packages DROP COLUMN pkg_webpage; -ALTER TABLE packages DROP COLUMN tags; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/down.sql b/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/down.sql deleted file mode 100644 index 5be03fee..00000000 --- a/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE packages DROP COLUMN extra; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/up.sql b/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/up.sql deleted file mode 100644 index ce483898..00000000 --- a/crates/soar-db/migrations/metadata/2026-07-28-000002-0000_add_extra/up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Pinned side files installed alongside the artifact, typically a licence the --- artifact itself does not carry. -ALTER TABLE packages ADD COLUMN extra JSONB; diff --git a/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/down.sql b/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/down.sql deleted file mode 100644 index 77dbe7c3..00000000 --- a/crates/soar-db/migrations/metadata/2026-07-29-000000-0000_pkg_id_optional/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX IF EXISTS packages_identity; -DELETE FROM packages WHERE pkg_id IS NULL; From b048a1701a53e7f971d916eb2363c3a869ee17d6 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 21:20:03 +0545 Subject: [PATCH 20/66] fix(url): keep the derived id when building a package --- crates/soar-core/src/package/url.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/soar-core/src/package/url.rs b/crates/soar-core/src/package/url.rs index 17271a30..99beef6a 100644 --- a/crates/soar-core/src/package/url.rs +++ b/crates/soar-core/src/package/url.rs @@ -250,6 +250,7 @@ impl UrlPackage { Package { id: 0, repo_name: "local".to_string(), + pkg_id: Some(self.pkg_id.clone()), pkg_name: self.pkg_name.clone(), pkg_type: self.pkg_type.clone(), version: self.version.clone(), @@ -263,6 +264,7 @@ impl UrlPackage { Package { id: 0, repo_name: "local".to_string(), + pkg_id: Some(self.pkg_id.clone()), pkg_name: self.pkg_name.clone(), pkg_type: self.pkg_type.clone(), version: self.version.clone(), @@ -466,7 +468,7 @@ mod tests { assert_eq!(pkg.repo_name, "local"); assert_eq!(pkg.pkg_name, "test"); assert_eq!(pkg.version, "1.0"); - assert_eq!(pkg.pkg_id, "github.com.user.testrepo"); + assert_eq!(pkg.pkg_id.as_deref(), Some("github.com.user.testrepo")); assert_eq!(pkg.download_url, url); } @@ -616,7 +618,7 @@ mod tests { assert_eq!(pkg.repo_name, "local"); assert_eq!(pkg.pkg_name, "soar"); assert_eq!(pkg.version, "0.8.1"); // 'v' prefix stripped - assert_eq!(pkg.pkg_id, "pkgforge.soar"); + assert_eq!(pkg.pkg_id.as_deref(), Some("pkgforge.soar")); assert_eq!(pkg.download_url, ""); assert_eq!( pkg.ghcr_pkg, From 61bf8158c057feddd819bdfc3030c2e56413a844 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 21:20:03 +0545 Subject: [PATCH 21/66] fix: repair tests and lints left by the pkg_id removal --- crates/soar-cli/src/health.rs | 2 +- crates/soar-core/src/package/install.rs | 2 +- crates/soar-events/src/lib.rs | 48 ------------------------- crates/soar-operations/src/progress.rs | 2 +- crates/soar-operations/src/utils.rs | 1 + 5 files changed, 4 insertions(+), 51 deletions(-) diff --git a/crates/soar-cli/src/health.rs b/crates/soar-cli/src/health.rs index 75cbfd59..3fc03bb5 100644 --- a/crates/soar-cli/src/health.rs +++ b/crates/soar-cli/src/health.rs @@ -108,7 +108,7 @@ pub async fn remove_broken_packages(ctx: &SoarContext) -> SoarResult<()> { report .failed .iter() - .map(|f| format!("{}", f.pkg_name)) + .map(|f| f.pkg_name.to_string()) .collect::>() .join(", ") ); diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 1e26f725..6995237f 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -324,7 +324,7 @@ impl PackageInstaller { let new_package = NewInstalledPackage { repo_name, - pkg_id: pkg_id.as_deref(), + pkg_id, pkg_name, pkg_family: package.pkg_family.as_deref(), pkg_type, diff --git a/crates/soar-events/src/lib.rs b/crates/soar-events/src/lib.rs index 142c5be3..7c57ee50 100644 --- a/crates/soar-events/src/lib.rs +++ b/crates/soar-events/src/lib.rs @@ -31,20 +31,17 @@ mod tests { sink.emit(SoarEvent::DownloadStarting { op_id: 1, pkg_name: "test-pkg".to_string(), - pkg_id: "test-pkg-id".to_string(), total: 1024, }); sink.emit(SoarEvent::DownloadProgress { op_id: 1, pkg_name: "test-pkg".to_string(), - pkg_id: "test-pkg-id".to_string(), current: 512, total: 1024, }); sink.emit(SoarEvent::DownloadComplete { op_id: 1, pkg_name: "test-pkg".to_string(), - pkg_id: "test-pkg-id".to_string(), total: 1024, }); @@ -126,7 +123,6 @@ mod tests { sink.emit(SoarEvent::OperationComplete { op_id: 42, pkg_name: "pkg".to_string(), - pkg_id: "pkg-id".to_string(), }); assert_eq!(collector.len(), 1); } @@ -147,68 +143,57 @@ mod tests { collector.emit(SoarEvent::DownloadStarting { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), total: 100, }); collector.emit(SoarEvent::DownloadResuming { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), current: 50, total: 100, }); collector.emit(SoarEvent::DownloadProgress { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), current: 75, total: 100, }); collector.emit(SoarEvent::DownloadComplete { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), total: 100, }); collector.emit(SoarEvent::DownloadRetry { op_id: 2, pkg_name: "b".into(), - pkg_id: "b-id".into(), }); collector.emit(SoarEvent::DownloadAborted { op_id: 2, pkg_name: "b".into(), - pkg_id: "b-id".into(), }); collector.emit(SoarEvent::DownloadRecovered { op_id: 3, pkg_name: "c".into(), - pkg_id: "c-id".into(), }); // Verification collector.emit(SoarEvent::Verifying { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: VerifyStage::Checksum, }); collector.emit(SoarEvent::Verifying { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: VerifyStage::Signature, }); collector.emit(SoarEvent::Verifying { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: VerifyStage::Passed, }); collector.emit(SoarEvent::Verifying { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: VerifyStage::Failed("bad checksum".into()), }); @@ -216,49 +201,41 @@ mod tests { collector.emit(SoarEvent::Installing { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: InstallStage::Extracting, }); collector.emit(SoarEvent::Installing { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: InstallStage::ExtractingNested, }); collector.emit(SoarEvent::Installing { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: InstallStage::LinkingBinaries, }); collector.emit(SoarEvent::Installing { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: InstallStage::DesktopIntegration, }); collector.emit(SoarEvent::Installing { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: InstallStage::SetupPortable, }); collector.emit(SoarEvent::Installing { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: InstallStage::RecordingDatabase, }); collector.emit(SoarEvent::Installing { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: InstallStage::RunningHook("post_install".into()), }); collector.emit(SoarEvent::Installing { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), stage: InstallStage::Complete, }); @@ -266,43 +243,36 @@ mod tests { collector.emit(SoarEvent::Removing { op_id: 5, pkg_name: "e".into(), - pkg_id: "e-id".into(), stage: RemoveStage::RunningHook("pre_remove".into()), }); collector.emit(SoarEvent::Removing { op_id: 5, pkg_name: "e".into(), - pkg_id: "e-id".into(), stage: RemoveStage::UnlinkingBinaries, }); collector.emit(SoarEvent::Removing { op_id: 5, pkg_name: "e".into(), - pkg_id: "e-id".into(), stage: RemoveStage::UnlinkingDesktop, }); collector.emit(SoarEvent::Removing { op_id: 5, pkg_name: "e".into(), - pkg_id: "e-id".into(), stage: RemoveStage::UnlinkingIcons, }); collector.emit(SoarEvent::Removing { op_id: 5, pkg_name: "e".into(), - pkg_id: "e-id".into(), stage: RemoveStage::RemovingDirectory, }); collector.emit(SoarEvent::Removing { op_id: 5, pkg_name: "e".into(), - pkg_id: "e-id".into(), stage: RemoveStage::CleaningDatabase, }); collector.emit(SoarEvent::Removing { op_id: 5, pkg_name: "e".into(), - pkg_id: "e-id".into(), stage: RemoveStage::Complete { size_freed: Some(1024 * 1024), }, @@ -311,7 +281,6 @@ mod tests { // Update checks collector.emit(SoarEvent::UpdateCheck { pkg_name: "f".into(), - pkg_id: "f-id".into(), status: UpdateCheckStatus::Available { current_version: "1.0.0".into(), new_version: "2.0.0".into(), @@ -319,14 +288,12 @@ mod tests { }); collector.emit(SoarEvent::UpdateCheck { pkg_name: "g".into(), - pkg_id: "g-id".into(), status: UpdateCheckStatus::UpToDate { version: "1.0.0".into(), }, }); collector.emit(SoarEvent::UpdateCheck { pkg_name: "h".into(), - pkg_id: "h-id".into(), status: UpdateCheckStatus::Skipped { reason: "pinned".into(), }, @@ -336,14 +303,12 @@ mod tests { collector.emit(SoarEvent::UpdateCleanup { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), old_version: "1.0.0".into(), stage: UpdateCleanupStage::Removing, }); collector.emit(SoarEvent::UpdateCleanup { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), old_version: "1.0.0".into(), stage: UpdateCleanupStage::Complete { size_freed: Some(512), @@ -352,7 +317,6 @@ mod tests { collector.emit(SoarEvent::UpdateCleanup { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), old_version: "1.0.0".into(), stage: UpdateCleanupStage::Kept, }); @@ -361,21 +325,18 @@ mod tests { collector.emit(SoarEvent::Hook { op_id: 5, pkg_name: "e".into(), - pkg_id: "e-id".into(), hook_name: "pre_remove".into(), stage: HookStage::Starting, }); collector.emit(SoarEvent::Hook { op_id: 5, pkg_name: "e".into(), - pkg_id: "e-id".into(), hook_name: "pre_remove".into(), stage: HookStage::Complete, }); collector.emit(SoarEvent::Hook { op_id: 6, pkg_name: "i".into(), - pkg_id: "i-id".into(), hook_name: "post_install".into(), stage: HookStage::Failed { exit_code: Some(1), @@ -386,25 +347,21 @@ mod tests { collector.emit(SoarEvent::Running { op_id: 7, pkg_name: "j".into(), - pkg_id: "j-id".into(), stage: RunStage::CacheHit, }); collector.emit(SoarEvent::Running { op_id: 8, pkg_name: "k".into(), - pkg_id: "k-id".into(), stage: RunStage::Downloading, }); collector.emit(SoarEvent::Running { op_id: 7, pkg_name: "j".into(), - pkg_id: "j-id".into(), stage: RunStage::Executing, }); collector.emit(SoarEvent::Running { op_id: 7, pkg_name: "j".into(), - pkg_id: "j-id".into(), stage: RunStage::Complete { exit_code: 0, }, @@ -414,13 +371,11 @@ mod tests { collector.emit(SoarEvent::Building { op_id: 4, pkg_name: "d".into(), - pkg_id: "d-id".into(), stage: BuildStage::Sandboxing, }); collector.emit(SoarEvent::Building { op_id: 4, pkg_name: "d".into(), - pkg_id: "d-id".into(), stage: BuildStage::Running { command_index: 0, total_commands: 3, @@ -429,7 +384,6 @@ mod tests { collector.emit(SoarEvent::Building { op_id: 4, pkg_name: "d".into(), - pkg_id: "d-id".into(), stage: BuildStage::CommandComplete { command_index: 0, }, @@ -439,12 +393,10 @@ mod tests { collector.emit(SoarEvent::OperationComplete { op_id: 1, pkg_name: "a".into(), - pkg_id: "a-id".into(), }); collector.emit(SoarEvent::OperationFailed { op_id: 2, pkg_name: "b".into(), - pkg_id: "b-id".into(), error: "not found".into(), }); diff --git a/crates/soar-operations/src/progress.rs b/crates/soar-operations/src/progress.rs index 017055e4..f762f850 100644 --- a/crates/soar-operations/src/progress.rs +++ b/crates/soar-operations/src/progress.rs @@ -106,7 +106,7 @@ mod tests { let collector = Arc::new(CollectorSink::default()); let events: EventSinkHandle = collector.clone(); - let bridge = create_progress_bridge(events, 1, "pkg".into(), "pkg-id".into()); + let bridge = create_progress_bridge(events, 1, "pkg".into()); bridge(Progress::Starting { total: 1000, diff --git a/crates/soar-operations/src/utils.rs b/crates/soar-operations/src/utils.rs index 0369a17a..aaee7f7a 100644 --- a/crates/soar-operations/src/utils.rs +++ b/crates/soar-operations/src/utils.rs @@ -110,6 +110,7 @@ fn walk_files(dir: &Path) -> Vec { out } +#[allow(clippy::too_many_arguments)] pub async fn mangle_package_symlinks( install_dir: &Path, bin_dir: &Path, From 203b5c0b4b15e7f2dc500cb75b0bd022520aec9d Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 21:20:03 +0545 Subject: [PATCH 22/66] feat(config): deprecate install_patterns --- crates/soar-config/src/config.rs | 15 ++++++++++++++- crates/soar-config/src/packages.rs | 20 ++++++++++++++++++-- crates/soar-operations/src/install.rs | 2 ++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/soar-config/src/config.rs b/crates/soar-config/src/config.rs index 3fe6deca..48686b98 100644 --- a/crates/soar-config/src/config.rs +++ b/crates/soar-config/src/config.rs @@ -79,8 +79,13 @@ pub struct Config { /// NOTE: This is not yet implemented pub cross_repo_updates: Option, - /// Glob patterns for package files that should be included during install. + /// Glob patterns filtering which files an install keeps. + /// /// Default: ["!*.log", "!SBUILD", "!*.json", "!*.version"] + #[deprecated( + since = "0.13.0", + note = "only the OCI download path applies these; the declarative format does not use it" + )] pub install_patterns: Option>, /// Global override for signature verification @@ -189,6 +194,8 @@ impl Config { soar_utils::path::icons_dir(self.system_mode) } + // Still populated while the OCI path exists; see the field's deprecation. + #[allow(deprecated)] pub fn default_config>(selected_repos: &[T]) -> Self { trace!("creating default configuration"); let soar_root = if is_system_mode() { @@ -280,6 +287,8 @@ impl Config { } /// Creates a default configuration for the given system mode. + // Still populated while the OCI path exists; see the field's deprecation. + #[allow(deprecated)] pub fn default_config_for_mode>(selected_repos: &[T], system_mode: bool) -> Self { trace!( "creating default configuration for system_mode={}", @@ -405,6 +414,8 @@ impl Config { Ok(config) } + // Still populated while the OCI path exists; see the field's deprecation. + #[allow(deprecated)] pub fn resolve(&mut self) -> Result<()> { trace!("resolving configuration"); if !self.profile.contains_key(&self.default_profile) { @@ -777,6 +788,8 @@ mod tests { } #[test] + // Still populated while the OCI path exists; see the field's deprecation. + #[allow(deprecated)] fn test_config_resolve_sets_defaults() { let mut config = Config::default_config::<&str>(&[]); config.ghcr_concurrency = None; diff --git a/crates/soar-config/src/packages.rs b/crates/soar-config/src/packages.rs index de0b30a1..79cfdfd6 100644 --- a/crates/soar-config/src/packages.rs +++ b/crates/soar-config/src/packages.rs @@ -46,7 +46,11 @@ pub struct PackageDefaults { /// Whether to install binary only (exclude logs, desktop files, etc). pub binary_only: Option, - /// Default install patterns. + /// Glob patterns filtering which files an install keeps. + #[deprecated( + since = "0.13.0", + note = "only the OCI download path applies these; the declarative format does not use it" + )] pub install_patterns: Option>, /// Default sandbox configuration applied to all packages. @@ -269,7 +273,11 @@ pub struct PackageOptions { /// Portable directory configuration. pub portable: Option, - /// Custom install patterns (overrides default). + /// Glob patterns filtering which files an install keeps, overriding the default. + #[deprecated( + since = "0.13.0", + note = "only the OCI download path applies these; the declarative format does not use it" + )] pub install_patterns: Option>, /// Whether to install binary only. @@ -326,6 +334,10 @@ pub struct ResolvedPackage { pub pinned: bool, pub profile: Option, pub portable: Option, + #[deprecated( + since = "0.13.0", + note = "only the OCI download path applies these; the declarative format does not use it" + )] pub install_patterns: Option>, pub binary_only: bool, pub arch_map: Option>, @@ -333,6 +345,8 @@ pub struct ResolvedPackage { impl PackageSpec { /// Resolve the package specification with defaults applied. + // Still populated while the OCI path exists; see the field's deprecation. + #[allow(deprecated)] pub fn resolve(&self, name: &str, defaults: Option<&PackageDefaults>) -> ResolvedPackage { match self { PackageSpec::Simple(version_str) => { @@ -452,6 +466,8 @@ impl PackagesConfig { } /// Create a default configuration. + // Still populated while the OCI path exists; see the field's deprecation. + #[allow(deprecated)] pub fn default_config() -> Self { Self { defaults: Some(PackageDefaults { diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 3cc24d45..0659a1b2 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -737,6 +737,8 @@ fn source_skips_integrity_gate(pkg: &Package) -> bool { } #[allow(clippy::too_many_arguments)] +// Reads install_patterns while the OCI path exists; see the field's deprecation. +#[allow(deprecated)] async fn install_single_package( ctx: &SoarContext, target: &InstallTarget, From 1ad8b7553c6dad9b81116b318cf3a0c7c8c55a80 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 21:45:15 +0545 Subject: [PATCH 23/66] fix: scope package lookups by identity, not by a missing id --- crates/soar-cli/src/download.rs | 2 +- crates/soar-cli/src/install.rs | 26 ++++-- crates/soar-cli/src/json2db.rs | 53 ++++++++--- crates/soar-cli/src/utils.rs | 10 ++- crates/soar-core/src/database/models.rs | 8 ++ crates/soar-core/src/package/install.rs | 20 ++++- crates/soar-core/src/package/local.rs | 1 + crates/soar-core/src/package/update.rs | 19 +++- crates/soar-db/src/repository/core.rs | 105 ++++++++++++++++------ crates/soar-db/src/repository/metadata.rs | 14 ++- crates/soar-dl/src/download.rs | 9 +- crates/soar-operations/src/apply.rs | 2 +- crates/soar-operations/src/install.rs | 44 ++++++--- crates/soar-operations/src/list.rs | 15 +++- crates/soar-operations/src/remove.rs | 14 ++- crates/soar-operations/src/search.rs | 15 +++- crates/soar-operations/src/switch.rs | 10 ++- crates/soar-operations/src/types.rs | 1 + crates/soar-operations/src/update.rs | 27 +++++- crates/soar-package/src/formats/common.rs | 19 ++-- crates/soar-package/src/traits.rs | 4 + crates/soar-utils/src/version.rs | 31 ++++++- 22 files changed, 352 insertions(+), 97 deletions(-) diff --git a/crates/soar-cli/src/download.rs b/crates/soar-cli/src/download.rs index 519d0219..870e2fe6 100644 --- a/crates/soar-cli/src/download.rs +++ b/crates/soar-cli/src/download.rs @@ -153,8 +153,8 @@ pub async fn handle_direct_downloads( MetadataRepository::find_filtered( conn, query.name.as_deref(), - None, query.pkg_id.as_deref(), + query.family.as_deref(), None, None, None, diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index bbf5e314..f501a672 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -75,8 +75,15 @@ pub async fn install_packages( }; if let Some(pkg) = pkg { - // Re-resolve with the specific selected package - let specific_query = format!("{}:{}", pkg.pkg_name, pkg.repo_name); + // Re-resolve with the specific selected package. The + // family has to come along, or a name ambiguous within one + // repository resolves right back to the same choice. + let specific_query = match &pkg.pkg_family { + Some(family) => { + format!("{}/{}:{}", family, pkg.pkg_name, pkg.repo_name) + } + None => format!("{}:{}", pkg.pkg_name, pkg.repo_name), + }; let re_results = install::resolve_packages(ctx, &[specific_query], &options).await?; for r in re_results { @@ -199,7 +206,12 @@ async fn install_with_show( }; if let Some(pkg) = pkg { - let specific_query = format!("{}:{}", pkg.pkg_name, pkg.repo_name); + let specific_query = match &pkg.pkg_family { + Some(family) => { + format!("{}/{}:{}", family, pkg.pkg_name, pkg.repo_name) + } + None => format!("{}:{}", pkg.pkg_name, pkg.repo_name), + }; let re_results = install::resolve_packages(ctx, &[specific_query], options).await?; for r in re_results { @@ -245,7 +257,7 @@ async fn install_with_show( conn, query.name.as_deref(), None, - None, + query.family.as_deref(), None, None, Some(SortDirection::Asc), @@ -265,7 +277,7 @@ async fn install_with_show( conn, query.name.as_deref(), None, - None, + query.family.as_deref(), None, None, Some(SortDirection::Asc), @@ -302,7 +314,7 @@ async fn install_with_show( } // Get installed packages to show [installed] marker - let installed_packages: Vec<(String, String, String)> = diesel_db + let installed_packages: Vec<(String, Option, String)> = diesel_db .with_conn(|conn| { CoreRepository::list_filtered( conn, @@ -317,7 +329,7 @@ async fn install_with_show( ) })? .into_iter() - .map(|p| (p.pkg_id.unwrap_or_default(), p.repo_name, p.version)) + .map(|p| (p.pkg_name, p.pkg_family, p.repo_name)) .collect(); let pkg = select_package_interactively_with_installed( diff --git a/crates/soar-cli/src/json2db.rs b/crates/soar-cli/src/json2db.rs index 02f0d3f2..3fb4f06c 100644 --- a/crates/soar-cli/src/json2db.rs +++ b/crates/soar-cli/src/json2db.rs @@ -41,32 +41,59 @@ pub fn json_to_db(input_path: &str, output_path: &str, repo_name: Option<&str>) } } - if output_path.exists() { - fs::remove_file(output_path) - .with_context(|| format!("removing existing database {}", output_path.display()))?; + // Built beside the target and swapped in only once it holds something. + // Writing in place would destroy a working database whenever an import + // turned out to be entirely rejected. + let mut tmp_name = output_path.file_name().unwrap_or_default().to_os_string(); + tmp_name.push(".tmp"); + let tmp_path = output_path.with_file_name(tmp_name); + for stale in [ + &tmp_path, + &tmp_path.with_extension("tmp-wal"), + &tmp_path.with_extension("tmp-shm"), + ] { + fs::remove_file(stale).ok(); } - let mut conn = DbConnection::open(output_path, DbType::Metadata) + let mut conn = DbConnection::open(&tmp_path, DbType::Metadata) .map_err(|e| SoarError::Custom(format!("opening database: {}", e)))?; // Packages with an unsafe pkg_name/pkg_id are skipped during import. // Reporting success while writing nothing hides that entirely, which is // how an empty pkg_id silently produced an empty database. - let imported = MetadataRepository::import_packages(conn.conn(), &packages, repo_name) - .map_err(|e| SoarError::Custom(format!("importing packages: {}", e)))?; + let result = MetadataRepository::import_packages(conn.conn(), &packages, repo_name) + .map_err(|e| SoarError::Custom(format!("importing packages: {}", e))); + let imported = match result { + Ok(n) if n > 0 => n, + other => { + drop(conn); + fs::remove_file(&tmp_path).ok(); + return match other { + Err(e) => Err(e), + Ok(_) => { + Err(SoarError::Custom(format!( + "imported 0 of {} packages; every entry was rejected, most likely \ + an unsafe or empty pkg_name/pkg_id", + packages.len() + ))) + } + }; + } + }; let skipped = packages.len() - imported; - if imported == 0 { - return Err(SoarError::Custom(format!( - "imported 0 of {} packages; every entry was rejected, most likely \ - an unsafe or empty pkg_name/pkg_id", - packages.len() - ))); - } if skipped > 0 { warn!(skipped, "some packages were rejected during import"); } + drop(conn); + fs::rename(&tmp_path, output_path).with_context(|| { + format!( + "replacing {} with the imported database", + output_path.display() + ) + })?; + info!( count = imported, output = %output_path.display(), diff --git a/crates/soar-cli/src/utils.rs b/crates/soar-cli/src/utils.rs index 867674d6..13f361ef 100644 --- a/crates/soar-cli/src/utils.rs +++ b/crates/soar-cli/src/utils.rs @@ -129,12 +129,16 @@ pub fn select_package_interactively( pub fn select_package_interactively_with_installed( pkgs: Vec, package_name: &str, - installed: &[(String, String, String)], // (pkg_id, repo_name, version) + installed: &[(String, Option, String)], // (pkg_name, pkg_family, repo_name) ) -> SoarResult> { info!("Showing available packages for {package_name}"); for (idx, pkg) in pkgs.iter().enumerate() { - let is_installed = installed.iter().any(|(pkg_id, repo_name, _version)| { - pkg.pkg_id().unwrap_or_default() == pkg_id && pkg.repo_name() == repo_name + // Matching on the id would treat every id-less package as identical, + // since they all read as the same empty identity. + let is_installed = installed.iter().any(|(pkg_name, pkg_family, repo_name)| { + pkg.pkg_name() == pkg_name + && pkg.pkg_family() == pkg_family.as_deref() + && pkg.repo_name() == repo_name }); let installed_marker = if is_installed { format!(" {}", Colored(Color::Yellow, "[installed]")) diff --git a/crates/soar-core/src/database/models.rs b/crates/soar-core/src/database/models.rs index 66579f6f..7a64b7e9 100644 --- a/crates/soar-core/src/database/models.rs +++ b/crates/soar-core/src/database/models.rs @@ -81,6 +81,10 @@ impl PackageExt for Package { self.pkg_id.as_deref() } + fn pkg_family(&self) -> Option<&str> { + self.pkg_family.as_deref() + } + fn version(&self) -> &str { &self.version } @@ -169,6 +173,10 @@ impl PackageExt for InstalledPackage { self.pkg_id.as_deref() } + fn pkg_family(&self) -> Option<&str> { + self.pkg_family.as_deref() + } + fn version(&self) -> &str { &self.version } diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 6995237f..ce37eae2 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -830,6 +830,15 @@ impl PackageInstaller { .ok(); } } + // A leftover from an interrupted install would make rename + // fail, the same way the other promotion paths treat it. + if to.exists() { + if to.is_dir() { + fs::remove_dir_all(&to).ok(); + } else { + fs::remove_file(&to).ok(); + } + } fs::rename(&from, &to).with_context(|| { format!("renaming {} to {}", from.display(), to.display()) })?; @@ -1095,8 +1104,15 @@ impl PackageInstaller { } if !unlinked { - self.db - .with_conn(|conn| CoreRepository::unlink_others(conn, pkg_name, pkg_id, version))?; + self.db.with_conn(|conn| { + CoreRepository::unlink_others( + conn, + pkg_name, + pkg_id, + self.package.pkg_family.as_deref(), + version, + ) + })?; let alternate_packages: Vec = self.db.with_conn(|conn| { diff --git a/crates/soar-core/src/package/local.rs b/crates/soar-core/src/package/local.rs index 5aeb8182..4819a0f8 100644 --- a/crates/soar-core/src/package/local.rs +++ b/crates/soar-core/src/package/local.rs @@ -142,6 +142,7 @@ impl LocalPackage { Package { id: 0, repo_name: "local".to_string(), + pkg_id: Some(self.pkg_id.clone()), pkg_name: self.pkg_name.clone(), pkg_type: self.pkg_type.clone(), version: self.version.clone(), diff --git a/crates/soar-core/src/package/update.rs b/crates/soar-core/src/package/update.rs index a8eefde5..7b018504 100644 --- a/crates/soar-core/src/package/update.rs +++ b/crates/soar-core/src/package/update.rs @@ -16,13 +16,21 @@ use crate::{ pub fn remove_old_versions(package: &Package, db: &DieselDatabase, force: bool) -> SoarResult<()> { let Package { pkg_id, + pkg_family, pkg_name, repo_name, .. } = package; let old_packages = db.with_conn(|conn| { - CoreRepository::get_old_package_paths(conn, pkg_id.as_deref(), pkg_name, repo_name, force) + CoreRepository::get_old_package_paths( + conn, + pkg_id.as_deref(), + pkg_family.as_deref(), + pkg_name, + repo_name, + force, + ) })?; for (_id, installed_path) in &old_packages { @@ -34,7 +42,14 @@ pub fn remove_old_versions(package: &Package, db: &DieselDatabase, force: bool) } db.with_conn(|conn| { - CoreRepository::delete_old_packages(conn, pkg_id.as_deref(), pkg_name, repo_name, force) + CoreRepository::delete_old_packages( + conn, + pkg_id.as_deref(), + pkg_family.as_deref(), + pkg_name, + repo_name, + force, + ) })?; Ok(()) diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index 8a68ba90..28b44a8e 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -28,6 +28,17 @@ fn match_pkg_id( } } +/// Same as [`match_pkg_id`] for the family, so cleaning up one package's old +/// versions cannot reach another package that merely shares its name. +fn match_pkg_family( + pkg_family: Option<&str>, +) -> Box>> { + match pkg_family { + Some(family) => Box::new(packages::pkg_family.eq(family.to_string())), + None => Box::new(packages::pkg_family.is_null().nullable()), + } +} + /// Sort direction for queries. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortDirection { @@ -454,20 +465,54 @@ impl CoreRepository { conn: &mut SqliteConnection, pkg_name: &str, keep_pkg_id: Option<&str>, + keep_pkg_family: Option<&str>, keep_version: &str, ) -> QueryResult { - diesel::update( - packages::table - .filter(packages::pkg_name.eq(pkg_name)) - .filter( - packages::pkg_id - .is_null() - .or(packages::pkg_id.ne(keep_pkg_id)) - .or(packages::version.ne(keep_version)), - ), - ) - .set(packages::unlinked.eq(true)) - .execute(conn) + // The row to keep is identified in full. A SQL predicate cannot do it: + // `pkg_id IS NULL` matches every id-less row, which now includes the + // package being installed, so it would unlink itself. + let stale = Self::rows_other_than( + conn, + pkg_name, + keep_pkg_id, + keep_pkg_family, + Some(keep_version), + )?; + if stale.is_empty() { + return Ok(0); + } + diesel::update(packages::table.filter(packages::id.eq_any(stale))) + .set(packages::unlinked.eq(true)) + .execute(conn) + } + + /// Ids of installed rows sharing `pkg_name` but not the given identity. + fn rows_other_than( + conn: &mut SqliteConnection, + pkg_name: &str, + keep_pkg_id: Option<&str>, + keep_pkg_family: Option<&str>, + keep_version: Option<&str>, + ) -> QueryResult> { + let rows: Vec<(i32, Option, Option, String)> = packages::table + .filter(packages::pkg_name.eq(pkg_name)) + .select(( + packages::id, + packages::pkg_id, + packages::pkg_family, + packages::version, + )) + .load(conn)?; + Ok(rows + .into_iter() + .filter(|(_, pkg_id, pkg_family, version)| { + let same = pkg_id.as_deref() == keep_pkg_id + && pkg_family.as_deref() == keep_pkg_family + && keep_version.is_none_or(|v| version == v); + !same + }) + .map(|(id, ..)| id) + .collect()) } /// Updates the pkg_id for packages matching repo_name and old pkg_id. @@ -611,12 +656,14 @@ impl CoreRepository { pub fn get_old_package_paths( conn: &mut SqliteConnection, pkg_id: Option<&str>, + pkg_family: Option<&str>, pkg_name: &str, repo_name: &str, force: bool, ) -> QueryResult> { let latest: Option<(i32, String)> = packages::table .filter(match_pkg_id(pkg_id)) + .filter(match_pkg_family(pkg_family)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .order(packages::id.desc()) @@ -630,6 +677,7 @@ impl CoreRepository { let query = packages::table .filter(match_pkg_id(pkg_id)) + .filter(match_pkg_family(pkg_family)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .filter(packages::id.ne(latest_id)) @@ -652,12 +700,14 @@ impl CoreRepository { pub fn delete_old_packages( conn: &mut SqliteConnection, pkg_id: Option<&str>, + pkg_family: Option<&str>, pkg_name: &str, repo_name: &str, force: bool, ) -> QueryResult { let latest_id: Option = packages::table .filter(match_pkg_id(pkg_id)) + .filter(match_pkg_family(pkg_family)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .order(packages::id.desc()) @@ -678,6 +728,7 @@ impl CoreRepository { let query = packages::table .filter(match_pkg_id(pkg_id)) + .filter(match_pkg_family(pkg_family)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::repo_name.eq(repo_name)) .filter(packages::id.ne(latest_id)) @@ -692,33 +743,29 @@ impl CoreRepository { conn: &mut SqliteConnection, pkg_name: &str, keep_pkg_id: Option<&str>, + keep_pkg_family: Option<&str>, keep_checksum: Option<&str>, ) -> QueryResult { if let Some(checksum) = keep_checksum { + let others = Self::rows_other_than(conn, pkg_name, keep_pkg_id, keep_pkg_family, None)?; + if others.is_empty() { + return Ok(0); + } diesel::update( packages::table - .filter(packages::pkg_name.eq(pkg_name)) - .filter( - packages::pkg_id - .is_null() - .or(packages::pkg_id.ne(keep_pkg_id)), - ) + .filter(packages::id.eq_any(others)) .filter(packages::checksum.ne(checksum)), ) .set(packages::unlinked.eq(true)) .execute(conn) } else { - diesel::update( - packages::table - .filter(packages::pkg_name.eq(pkg_name)) - .filter( - packages::pkg_id - .is_null() - .or(packages::pkg_id.ne(keep_pkg_id)), - ), - ) - .set(packages::unlinked.eq(true)) - .execute(conn) + let others = Self::rows_other_than(conn, pkg_name, keep_pkg_id, keep_pkg_family, None)?; + if others.is_empty() { + return Ok(0); + } + diesel::update(packages::table.filter(packages::id.eq_any(others))) + .set(packages::unlinked.eq(true)) + .execute(conn) } } diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index bb00b813..bbb4dc34 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -442,6 +442,7 @@ impl MetadataRepository { pub fn find_newer_version( conn: &mut SqliteConnection, pkg_name: &str, + pkg_family: Option<&str>, current_version: &str, ) -> QueryResult> { trace!( @@ -456,11 +457,16 @@ impl MetadataRepository { // Ordering cannot be left to SQL: a string comparison puts 10 below 9 // and the rebuild suffix in 1.14.0-1 below 1.14.0. Candidates are // loaded and compared segment-wise instead. - let candidates: Vec = packages::table + let mut query = packages::table .into_boxed() - .filter(packages::pkg_name.eq(pkg_name)) - .select(Package::as_select()) - .load(conn)?; + .filter(packages::pkg_name.eq(pkg_name)); + // Narrowed by family only when the install records one. An older + // install has none, and demanding a match would report it as up to + // date forever. + if let Some(family) = pkg_family { + query = query.filter(packages::pkg_family.eq(family.to_string())); + } + let candidates: Vec = query.select(Package::as_select()).load(conn)?; let result: QueryResult> = Ok(candidates .into_iter() diff --git a/crates/soar-dl/src/download.rs b/crates/soar-dl/src/download.rs index b7f84049..c074916c 100644 --- a/crates/soar-dl/src/download.rs +++ b/crates/soar-dl/src/download.rs @@ -335,6 +335,11 @@ impl Download { .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) }); + // Cleanup below must never reach a directory that was + // already there: the default destination is the download's + // own parent, so removing it would take the user's other + // files with it. + let dir_existed = extract_dir.exists(); debug!(archive = %output_path.display(), dest = %extract_dir.display(), ?format, "extracting archive"); // A bare .gz or .bz2 of a single file shares its magic @@ -345,7 +350,9 @@ impl Download { if let Err(e) = compak::extract_archive(&output_path, &extract_dir) { warn!(archive = %output_path.display(), error = %e, "extraction failed, installing the download as-is"); - std::fs::remove_dir_all(&extract_dir).ok(); + if !dir_existed { + std::fs::remove_dir_all(&extract_dir).ok(); + } } } Err(_) => { diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index 85a505e1..673feb57 100644 --- a/crates/soar-operations/src/apply.rs +++ b/crates/soar-operations/src/apply.rs @@ -70,8 +70,8 @@ pub async fn compute_diff( MetadataRepository::find_filtered( conn, Some(&pkg.name), - None, pkg.pkg_id.as_deref(), + None, pkg.version.as_deref(), None, Some(SortDirection::Asc), diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 0659a1b2..c316b3e3 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -258,16 +258,32 @@ fn resolve_all_variants( } let target_pkg_id = variants[0].pkg_id.clone(); + let target_pkg_name = variants[0].pkg_name.clone(); + let target_pkg_family = variants[0].pkg_family.clone(); + + // Without an id every filter below would be None, which reads as "no + // filter" and returns the whole metadata table. Fall back to the selected + // package's own name and family instead. + let (name_filter, id_filter, family_filter) = match target_pkg_id.as_deref() { + Some(id) => (None, Some(id), None), + None => { + ( + Some(target_pkg_name.as_str()), + None, + target_pkg_family.as_deref(), + ) + } + }; - // Find all packages with this pkg_id + // Find all packages with this identity 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, - target_pkg_id.as_deref(), - None, + name_filter, + id_filter, + family_filter, None, None, Some(SortDirection::Asc), @@ -285,9 +301,9 @@ fn resolve_all_variants( metadata_mgr.query_all_flat(|repo_name, conn| { let pkgs = MetadataRepository::find_filtered( conn, - None, - target_pkg_id.as_deref(), - None, + name_filter, + id_filter, + family_filter, None, None, Some(SortDirection::Asc), @@ -308,8 +324,8 @@ fn resolve_all_variants( CoreRepository::list_filtered( conn, query.repo_name.as_deref(), - None, - target_pkg_id.as_deref(), + name_filter, + id_filter, None, None, None, @@ -667,6 +683,7 @@ pub async fn perform_installation( if !install_dir.as_os_str().is_empty() { installed.lock().unwrap().push(InstalledInfo { pkg_name: target.package.pkg_name.clone(), + pkg_family: target.package.pkg_family.clone(), repo_name: target.package.repo_name.clone(), version: target.package.version.clone(), install_dir, @@ -833,7 +850,14 @@ async fn install_single_package( .filter(|s| s.len() >= 12) .map(|s| s[..12].to_string()) .unwrap_or_else(|| { - let input = format!("{}:{}", pkg.pkg_name, pkg.version); + // Name and version alone collide across sources: the same + // release packaged by two repositories would share a directory. + let source = pkg + .pkg_id + .as_deref() + .or(pkg.ghcr_pkg.as_deref()) + .unwrap_or(pkg.download_url.as_str()); + let input = format!("{}:{}:{}", pkg.pkg_name, pkg.version, source); hash_string(&input)[..12].to_string() }); diff --git a/crates/soar-operations/src/list.rs b/crates/soar-operations/src/list.rs index 8a6a874e..665cc79f 100644 --- a/crates/soar-operations/src/list.rs +++ b/crates/soar-operations/src/list.rs @@ -64,9 +64,20 @@ pub async fn list_packages( .into_par_iter() // Keyed by name, not id: a package installed before ids became // optional still carries one, while its metadata no longer does, and - // keying on both would stop matching the two. + // keying on both would stop matching the two. Rows sharing a key are + // merged rather than overwritten, so one uninstalled version cannot + // mask an installed one. .map(|pkg| ((pkg.repo_name, pkg.pkg_name), pkg.is_installed)) - .collect(); + .fold(HashMap::new, |mut acc, (key, installed)| { + *acc.entry(key).or_insert(false) |= installed; + acc + }) + .reduce(HashMap::new, |mut acc, part| { + for (key, installed) in part { + *acc.entry(key).or_insert(false) |= installed; + } + acc + }); let total = packages.len(); diff --git a/crates/soar-operations/src/remove.rs b/crates/soar-operations/src/remove.rs index f4fee4f3..ff4cf1eb 100644 --- a/crates/soar-operations/src/remove.rs +++ b/crates/soar-operations/src/remove.rs @@ -94,14 +94,22 @@ pub fn resolve_removals( }); } else { let target_pkg_id = installed[0].pkg_id.clone(); - // Find all packages with this pkg_id + let target_pkg_name = installed[0].pkg_name.clone(); + // Without an id, filtering on it alone selects every + // installed package in the repository, which would remove + // far more than was asked for. + let (name_filter, id_filter) = match target_pkg_id.as_deref() { + Some(id) => (None, Some(id)), + None => (Some(target_pkg_name.as_str()), None), + }; + // Find all packages with this identity let all_installed: Vec = diesel_db .with_conn(|conn| { CoreRepository::list_filtered( conn, query.repo_name.as_deref(), - None, - target_pkg_id.as_deref(), + name_filter, + id_filter, None, None, None, diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index 717d2f4b..cb97b943 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -62,9 +62,20 @@ pub async fn search_packages( .into_par_iter() // Keyed by name, not id: a package installed before ids became // optional still carries one, while its metadata no longer does, and - // keying on both would stop matching the two. + // keying on both would stop matching the two. Rows sharing a key are + // merged rather than overwritten, so one uninstalled version cannot + // mask an installed one. .map(|pkg| ((pkg.repo_name, pkg.pkg_name), pkg.is_installed)) - .collect(); + .fold(HashMap::new, |mut acc, (key, installed)| { + *acc.entry(key).or_insert(false) |= installed; + acc + }) + .reduce(HashMap::new, |mut acc, part| { + for (key, installed) in part { + *acc.entry(key).or_insert(false) |= installed; + } + acc + }); let total_count = packages.len(); diff --git a/crates/soar-operations/src/switch.rs b/crates/soar-operations/src/switch.rs index 22cb2644..31b4995f 100644 --- a/crates/soar-operations/src/switch.rs +++ b/crates/soar-operations/src/switch.rs @@ -88,7 +88,13 @@ pub async fn switch_variant( // Atomically unlink other variants and link the selected one so the DB // is never left in a state where all variants are unlinked. diesel_db.transaction(|conn| { - CoreRepository::unlink_others_by_checksum(conn, pkg_name, pkg_id, checksum)?; + CoreRepository::unlink_others_by_checksum( + conn, + pkg_name, + pkg_id, + selected_package.pkg_family.as_deref(), + checksum, + )?; CoreRepository::link_by_checksum(conn, pkg_name, pkg_id, checksum) })?; @@ -118,9 +124,9 @@ pub async fn switch_variant( MetadataRepository::find_filtered( conn, Some(name), - None, selected_package.pkg_id.as_deref(), None, + None, Some(1), None, ) diff --git a/crates/soar-operations/src/types.rs b/crates/soar-operations/src/types.rs index 963ca7f4..a60ec2f1 100644 --- a/crates/soar-operations/src/types.rs +++ b/crates/soar-operations/src/types.rs @@ -57,6 +57,7 @@ pub struct InstallReport { #[derive(Debug)] pub struct InstalledInfo { pub pkg_name: String, + pub pkg_family: Option, pub repo_name: String, pub version: String, pub install_dir: PathBuf, diff --git a/crates/soar-operations/src/update.rs b/crates/soar-operations/src/update.rs index 2387218a..f7379a3e 100644 --- a/crates/soar-operations/src/update.rs +++ b/crates/soar-operations/src/update.rs @@ -137,7 +137,12 @@ fn check_repo_update( ) -> SoarResult> { let new_pkg: Option = metadata_mgr .query_repo(&pkg.repo_name, |conn| { - MetadataRepository::find_newer_version(conn, &pkg.pkg_name, &pkg.version) + MetadataRepository::find_newer_version( + conn, + &pkg.pkg_name, + pkg.pkg_family.as_deref(), + &pkg.version, + ) })? .flatten() .map(|p| { @@ -440,15 +445,29 @@ pub async fn perform_update( // Clean up old versions only for successfully updated packages if !keep_old { let diesel_db = ctx.diesel_core_db()?.clone(); - let succeeded: HashSet<&str> = install_report + // Keyed by the whole identity: a name alone would let a package from + // another repository or family inherit this one's success. + let succeeded: HashSet<(&str, Option<&str>, &str, &str)> = install_report .installed .iter() - .map(|i| i.pkg_name.as_str()) + .map(|i| { + ( + i.pkg_name.as_str(), + i.pkg_family.as_deref(), + i.repo_name.as_str(), + i.version.as_str(), + ) + }) .collect(); for target in &targets { let pkg = &target.package; - if !succeeded.contains(pkg.pkg_name.as_str()) { + if !succeeded.contains(&( + pkg.pkg_name.as_str(), + pkg.pkg_family.as_deref(), + pkg.repo_name.as_str(), + pkg.version.as_str(), + )) { continue; } diff --git a/crates/soar-package/src/formats/common.rs b/crates/soar-package/src/formats/common.rs index 536ff352..a4cfd870 100644 --- a/crates/soar-package/src/formats/common.rs +++ b/crates/soar-package/src/formats/common.rs @@ -291,14 +291,17 @@ pub fn setup_portable_dir, T: PackageExt>( portable_share: Option<&str>, portable_cache: Option<&str>, ) -> Result<()> { - // Packages that carry an id keep their existing directory name; those - // without one are named by package alone. - let portable_dir_base = get_config() - .get_portable_dirs()? - .join(match package.pkg_id() { - Some(pkg_id) => format!("{}-{}", package.pkg_name(), pkg_id), - None => package.pkg_name().to_string(), - }); + // Packages that carry an id keep their existing directory name. Without + // one the family has to stand in, or two packages sharing a name would + // share a portable directory. + let portable_dir_base = + get_config() + .get_portable_dirs()? + .join(match (package.pkg_id(), package.pkg_family()) { + (Some(pkg_id), _) => format!("{}-{}", package.pkg_name(), pkg_id), + (None, Some(family)) => format!("{}-{}", package.pkg_name(), family), + (None, None) => package.pkg_name().to_string(), + }); let bin_path = bin_path.as_ref(); let pkg_name = package.pkg_name(); diff --git a/crates/soar-package/src/traits.rs b/crates/soar-package/src/traits.rs index e0867d52..e6d9b2df 100644 --- a/crates/soar-package/src/traits.rs +++ b/crates/soar-package/src/traits.rs @@ -11,6 +11,10 @@ pub trait PackageExt { /// Returns the package identifier, when the repository publishes one. fn pkg_id(&self) -> Option<&str>; + /// Returns the family the package came from, when the repository + /// publishes one. This is what distinguishes two packages sharing a name. + fn pkg_family(&self) -> Option<&str>; + /// Returns the package version string. fn version(&self) -> &str; diff --git a/crates/soar-utils/src/version.rs b/crates/soar-utils/src/version.rs index 7f55a0d5..a021e663 100644 --- a/crates/soar-utils/src/version.rs +++ b/crates/soar-utils/src/version.rs @@ -28,6 +28,12 @@ use std::cmp::Ordering; /// assert_eq!(compare_versions("2026.05.24", "2026.05.23"), Ordering::Greater); /// ``` pub fn compare_versions(a: &str, b: &str) -> Ordering { + // Two commit hashes carry no order at all, and segment rules would invent + // one, letting an arbitrary hash read as an upgrade or a downgrade. + if a != b && is_commit_hash(a) && is_commit_hash(b) { + return Ordering::Equal; + } + let mut left = segments(a); let mut right = segments(b); @@ -55,6 +61,17 @@ pub fn is_newer(candidate: &str, current: &str) -> bool { compare_versions(candidate, current) == Ordering::Greater } +/// Whether a version is nothing but a commit hash. +/// +/// Requires a letter, so a long run of digits stays a version: 20260412 is a +/// date, not a hash. +fn is_commit_hash(version: &str) -> bool { + version.len() >= 7 + && version.len() <= 40 + && version.chars().all(|c| c.is_ascii_hexdigit()) + && version.chars().any(|c| c.is_ascii_alphabetic()) +} + /// How a version compares against one that stopped earlier, judged by the /// first segment the longer one carries alone. fn extra_segment_order(segment: &str) -> Ordering { @@ -166,9 +183,17 @@ mod tests { #[test] fn commit_hashes_have_no_meaningful_order() { - // Not equal, but any answer is arbitrary; the point is that it is - // stable rather than that it is right. - assert_ne!(compare_versions("89c99d2a9", "0f3a21b"), Ordering::Equal); + // Neither supersedes the other, so neither can drive an upgrade. + assert_eq!(compare_versions("89c99d2a9", "0f3a21b"), Ordering::Equal); + assert_eq!(compare_versions("0f3a21b", "89c99d2a9"), Ordering::Equal); + assert!(!is_newer("89c99d2a9", "0f3a21b")); + assert!(!is_newer("0f3a21b", "89c99d2a9")); + } + + #[test] + fn digit_runs_are_versions_not_hashes() { + // a date-like version must keep comparing normally + assert_eq!(compare_versions("20260413", "20260412"), Ordering::Greater); } #[test] From 614cb7051c48a609b794a44019329afcd0cece96 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 21:56:48 +0545 Subject: [PATCH 24/66] fix(db): refuse a downgrade instead of deleting id-less rows --- .../down.sql | 11 +++++++++-- .../down.sql | 12 ++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/down.sql b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/down.sql index 5a789094..37c31377 100644 --- a/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/down.sql +++ b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/down.sql @@ -1,5 +1,12 @@ --- Rows without an id cannot be represented once the column is required again. -DELETE FROM packages WHERE pkg_id IS NULL; +-- A package without an id cannot be represented once the column is required +-- again. Refuse the downgrade rather than deleting the rows: the CHECK fails +-- when any such row exists, and the table name is what the error reports. +CREATE TEMP TABLE cannot_downgrade_packages_without_pkg_id ( + ok INTEGER NOT NULL CHECK (ok = 1) +); +INSERT INTO cannot_downgrade_packages_without_pkg_id (ok) + SELECT CASE WHEN EXISTS (SELECT 1 FROM packages WHERE pkg_id IS NULL) THEN 0 ELSE 1 END; +DROP TABLE cannot_downgrade_packages_without_pkg_id; CREATE TABLE packages_old ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql index 36dbff45..5c46e7a6 100644 --- a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql @@ -1,6 +1,14 @@ --- Rows without an id cannot be represented once the column is required again. +-- A package without an id cannot be represented once the column is required +-- again. Refuse the downgrade rather than deleting the rows: the CHECK fails +-- when any such row exists, and the table name is what the error reports. +CREATE TEMP TABLE cannot_downgrade_packages_without_pkg_id ( + ok INTEGER NOT NULL CHECK (ok = 1) +); +INSERT INTO cannot_downgrade_packages_without_pkg_id (ok) + SELECT CASE WHEN EXISTS (SELECT 1 FROM packages WHERE pkg_id IS NULL) THEN 0 ELSE 1 END; +DROP TABLE cannot_downgrade_packages_without_pkg_id; + DROP INDEX IF EXISTS packages_identity; -DELETE FROM packages WHERE pkg_id IS NULL; CREATE TABLE packages_old ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, From cd0513eb577b65a0e90115f5f9705f5a1a6665fd Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Wed, 29 Jul 2026 22:01:35 +0545 Subject: [PATCH 25/66] fix(db): let the metadata cache clear on downgrade --- .../down.sql | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql index 5c46e7a6..56e64639 100644 --- a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql @@ -1,12 +1,7 @@ -- A package without an id cannot be represented once the column is required --- again. Refuse the downgrade rather than deleting the rows: the CHECK fails --- when any such row exists, and the table name is what the error reports. -CREATE TEMP TABLE cannot_downgrade_packages_without_pkg_id ( - ok INTEGER NOT NULL CHECK (ok = 1) -); -INSERT INTO cannot_downgrade_packages_without_pkg_id (ok) - SELECT CASE WHEN EXISTS (SELECT 1 FROM packages WHERE pkg_id IS NULL) THEN 0 ELSE 1 END; -DROP TABLE cannot_downgrade_packages_without_pkg_id; +-- again. This table is a cache of the published index, so the rows are simply +-- dropped and the next sync puts them back. +DELETE FROM packages WHERE pkg_id IS NULL; DROP INDEX IF EXISTS packages_identity; From 71407887b018b5db2a1a91c018afafa7992f1c19 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 09:22:20 +0545 Subject: [PATCH 26/66] refactor: stop deriving package ids for local sources --- crates/soar-core/src/package/local.rs | 23 +++-- crates/soar-core/src/package/url.rs | 98 +++++++++++-------- .../up.sql | 7 ++ crates/soar-operations/src/apply.rs | 19 ++-- 4 files changed, 84 insertions(+), 63 deletions(-) diff --git a/crates/soar-core/src/package/local.rs b/crates/soar-core/src/package/local.rs index 4819a0f8..936795e9 100644 --- a/crates/soar-core/src/package/local.rs +++ b/crates/soar-core/src/package/local.rs @@ -27,7 +27,10 @@ pub struct LocalPackage { /// Extracted or overridden package name pub pkg_name: String, /// Generated package ID (lowercase, normalized) - pub pkg_id: String, + pub pkg_id: Option, + /// Where the file came from, used to tell apart two local files that + /// produce the same package name. + pub pkg_family: Option, /// Extracted or overridden version pub version: String, /// Detected package type from extension (e.g., "appimage") @@ -116,17 +119,18 @@ impl LocalPackage { .map(|v| v.strip_prefix('v').unwrap_or(v).to_string()) .unwrap_or(extracted_version); - let pkg_id = pkg_id_override.map(String::from).unwrap_or_else(|| { - if let Some(ref ptype) = pkg_type { - format!("local.{pkg_name}-{ptype}") - } else { - format!("local.{pkg_name}") - } + let pkg_id = pkg_id_override.map(String::from); + // Nothing derives an id. The family carries where the file came from, + // which is what the derived id was standing in for. + let pkg_family = Some(match pkg_type { + Some(ref ptype) => format!("local.{pkg_name}-{ptype}"), + None => format!("local.{pkg_name}"), }); Ok(Self { path, pkg_id, + pkg_family, pkg_name, version, pkg_type, @@ -142,7 +146,8 @@ impl LocalPackage { Package { id: 0, repo_name: "local".to_string(), - pkg_id: Some(self.pkg_id.clone()), + pkg_id: self.pkg_id.clone(), + pkg_family: self.pkg_family.clone(), pkg_name: self.pkg_name.clone(), pkg_type: self.pkg_type.clone(), version: self.version.clone(), @@ -194,7 +199,7 @@ mod tests { assert_eq!(pkg.pkg_name, "myapp"); assert_eq!(pkg.version, "2.0.1"); assert_eq!(pkg.pkg_type, Some("appimage".to_string())); - assert_eq!(pkg.pkg_id, "local.myapp-appimage"); + assert_eq!(pkg.pkg_family.as_deref(), Some("local.myapp-appimage")); let package = pkg.to_package(); assert_eq!(package.repo_name, "local"); diff --git a/crates/soar-core/src/package/url.rs b/crates/soar-core/src/package/url.rs index 99beef6a..d7dd5768 100644 --- a/crates/soar-core/src/package/url.rs +++ b/crates/soar-core/src/package/url.rs @@ -14,8 +14,13 @@ pub struct UrlPackage { pub url: String, /// Extracted or overridden package name pub pkg_name: String, - /// Generated package ID (lowercase, normalized) - pub pkg_id: String, + /// Package id, only when the caller supplied one. Nothing derives an id: + /// repositories on the declarative format publish none, so inventing one + /// here would be the only place they came from. + pub pkg_id: Option, + /// Where the package came from, used to tell apart two sources that + /// produce the same name. This is what an id used to stand in for. + pub pkg_family: Option, /// Extracted or overridden version pub version: String, /// Detected package type from extension (e.g., "appimage") @@ -90,18 +95,25 @@ impl UrlPackage { /// Both are joined into the install dir and interpolated into resource /// paths, so a caller-supplied override containing `/` or `..` would escape /// it. The derived defaults are always dot-separated and unaffected. - fn validate_names(pkg_name: &str, pkg_id: &str) -> SoarResult<()> { + fn validate_names( + pkg_name: &str, + pkg_id: Option<&str>, + pkg_family: Option<&str>, + ) -> SoarResult<()> { if !is_safe_component(pkg_name) { return Err(SoarError::Custom(format!( "Invalid package name '{}': must be a single path component", pkg_name ))); } - if !is_safe_component(pkg_id) { - return Err(SoarError::Custom(format!( - "Invalid package id '{}': must be a single path component", - pkg_id - ))); + for (label, value) in [("id", pkg_id), ("family", pkg_family)] { + if let Some(value) = value { + if !is_safe_component(value) { + return Err(SoarError::Custom(format!( + "Invalid package {label} '{value}': must be a single path component" + ))); + } + } } Ok(()) } @@ -145,17 +157,17 @@ impl UrlPackage { .map(|v| v.strip_prefix('v').unwrap_or(v).to_string()) .unwrap_or_else(|| tag.strip_prefix('v').unwrap_or(&tag).to_string()); - let pkg_id = pkg_id_override - .map(String::from) - .unwrap_or_else(|| package.replace('/', ".")); + let pkg_id = pkg_id_override.map(String::from); + let pkg_family = Some(package.replace('/', ".")); let pkg_type = pkg_type_override.map(|s| s.to_lowercase()); - Self::validate_names(&pkg_name, &pkg_id)?; + Self::validate_names(&pkg_name, pkg_id.as_deref(), pkg_family.as_deref())?; Ok(Self { url: reference.to_string(), pkg_id, + pkg_family, pkg_name, version, pkg_type, @@ -219,23 +231,23 @@ impl UrlPackage { .map(|v| v.strip_prefix('v').unwrap_or(v).to_string()) .unwrap_or(extracted_version); - // Generate pkg_id: use override, or extract from URL, or generate from name and type - let pkg_id = pkg_id_override - .map(String::from) - .or_else(|| extract_pkg_id_from_url(url)) - .unwrap_or_else(|| { - if let Some(ref ptype) = pkg_type { - format!("{}-{}", pkg_name, ptype) - } else { - pkg_name.clone() - } - }); + let pkg_id = pkg_id_override.map(String::from); + // The host and project the URL points at, falling back to the name so + // two unrelated downloads sharing a filename stay distinct. + let pkg_family = extract_family_from_url(url).or_else(|| { + if let Some(ref ptype) = pkg_type { + Some(format!("{}-{}", pkg_name, ptype)) + } else { + Some(pkg_name.clone()) + } + }); - Self::validate_names(&pkg_name, &pkg_id)?; + Self::validate_names(&pkg_name, pkg_id.as_deref(), pkg_family.as_deref())?; Ok(Self { url: url.to_string(), pkg_id, + pkg_family, pkg_name, version, pkg_type, @@ -250,7 +262,8 @@ impl UrlPackage { Package { id: 0, repo_name: "local".to_string(), - pkg_id: Some(self.pkg_id.clone()), + pkg_id: self.pkg_id.clone(), + pkg_family: self.pkg_family.clone(), pkg_name: self.pkg_name.clone(), pkg_type: self.pkg_type.clone(), version: self.version.clone(), @@ -264,7 +277,8 @@ impl UrlPackage { Package { id: 0, repo_name: "local".to_string(), - pkg_id: Some(self.pkg_id.clone()), + pkg_id: self.pkg_id.clone(), + pkg_family: self.pkg_family.clone(), pkg_name: self.pkg_name.clone(), pkg_type: self.pkg_type.clone(), version: self.version.clone(), @@ -277,12 +291,12 @@ impl UrlPackage { } } -/// Extract pkg_id from URL based on host and first two path segments. +/// Extract the family from a URL: host plus the first two path segments. /// /// Examples: /// - `https://github.com/user/repo/...` → `github.com.user.repo` /// - `https://example.com/foo/bar/...` → `example.com.foo.bar` -fn extract_pkg_id_from_url(url: &str) -> Option { +fn extract_family_from_url(url: &str) -> Option { let url = url.trim().to_lowercase(); // Remove protocol @@ -424,7 +438,7 @@ mod tests { assert_eq!(pkg.pkg_name, "myapp"); assert_eq!(pkg.version, "2.0.0"); - assert_eq!(pkg.pkg_id, "github.com.user.repo"); + assert_eq!(pkg.pkg_family.as_deref(), Some("github.com.user.repo")); } #[test] @@ -436,25 +450,25 @@ mod tests { assert_eq!(pkg.pkg_name, "myapp"); assert_eq!(pkg.version, "1.0.0"); assert_eq!(pkg.pkg_type, Some("appimage".to_string())); - assert_eq!(pkg.pkg_id, "example.com.downloads.app"); + assert_eq!(pkg.pkg_family.as_deref(), Some("example.com.downloads.app")); } #[test] - fn test_extract_pkg_id_from_url() { + fn test_extract_family_from_url() { assert_eq!( - extract_pkg_id_from_url("https://github.com/pkgforge/soar/releases/file"), + extract_family_from_url("https://github.com/pkgforge/soar/releases/file"), Some("github.com.pkgforge.soar".to_string()) ); assert_eq!( - extract_pkg_id_from_url("https://gitlab.com/user/project/-/releases"), + extract_family_from_url("https://gitlab.com/user/project/-/releases"), Some("gitlab.com.user.project".to_string()) ); assert_eq!( - extract_pkg_id_from_url("https://example.com/foo/bar/baz"), + extract_family_from_url("https://example.com/foo/bar/baz"), Some("example.com.foo.bar".to_string()) ); assert_eq!( - extract_pkg_id_from_url("https://example.com/app"), + extract_family_from_url("https://example.com/app"), Some("example.com.app".to_string()) ); } @@ -468,7 +482,7 @@ mod tests { assert_eq!(pkg.repo_name, "local"); assert_eq!(pkg.pkg_name, "test"); assert_eq!(pkg.version, "1.0"); - assert_eq!(pkg.pkg_id.as_deref(), Some("github.com.user.testrepo")); + assert_eq!(pkg.pkg_family.as_deref(), Some("github.com.user.testrepo")); assert_eq!(pkg.download_url, url); } @@ -540,7 +554,7 @@ mod tests { assert_eq!(pkg.pkg_name, "soar"); assert_eq!(pkg.version, "0.8.1"); // 'v' prefix stripped - assert_eq!(pkg.pkg_id, "pkgforge.soar"); + assert_eq!(pkg.pkg_family.as_deref(), Some("pkgforge.soar")); assert!(pkg.is_ghcr); } @@ -551,7 +565,7 @@ mod tests { assert_eq!(pkg.pkg_name, "repo"); assert_eq!(pkg.version, "sha256:deadbeef1234567890"); - assert_eq!(pkg.pkg_id, "org.repo"); + assert_eq!(pkg.pkg_family.as_deref(), Some("org.repo")); assert!(pkg.is_ghcr); } @@ -562,7 +576,7 @@ mod tests { assert_eq!(pkg.pkg_name, "package"); assert_eq!(pkg.version, "latest"); - assert_eq!(pkg.pkg_id, "org.package"); + assert_eq!(pkg.pkg_family.as_deref(), Some("org.package")); assert!(pkg.is_ghcr); } @@ -573,7 +587,7 @@ mod tests { assert_eq!(pkg.pkg_name, "repo"); assert_eq!(pkg.version, "1.0"); - assert_eq!(pkg.pkg_id, "org.team.repo"); + assert_eq!(pkg.pkg_family.as_deref(), Some("org.team.repo")); assert!(pkg.is_ghcr); } @@ -586,7 +600,7 @@ mod tests { assert_eq!(pkg.pkg_name, "myapp"); assert_eq!(pkg.version, "2.0.0"); - assert_eq!(pkg.pkg_id, "custom-id"); + assert_eq!(pkg.pkg_id.as_deref(), Some("custom-id")); assert!(pkg.is_ghcr); } @@ -618,7 +632,7 @@ mod tests { assert_eq!(pkg.repo_name, "local"); assert_eq!(pkg.pkg_name, "soar"); assert_eq!(pkg.version, "0.8.1"); // 'v' prefix stripped - assert_eq!(pkg.pkg_id.as_deref(), Some("pkgforge.soar")); + assert_eq!(pkg.pkg_family.as_deref(), Some("pkgforge.soar")); assert_eq!(pkg.download_url, ""); assert_eq!( pkg.ghcr_pkg, diff --git a/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql index 8be8cdbd..d2170ab7 100644 --- a/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql +++ b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql @@ -31,3 +31,10 @@ FROM packages; DROP TABLE packages; ALTER TABLE packages_new RENAME TO packages; + +-- Installs from a URL or a local file used to carry a synthesised id, since +-- that was the only field able to say where they came from. That is now the +-- family's job, so the value moves across and nothing derives an id again. +UPDATE packages +SET pkg_family = pkg_id +WHERE repo_name = 'local' AND pkg_family IS NULL AND pkg_id IS NOT NULL; diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index 673feb57..ff2f0488 100644 --- a/crates/soar-operations/src/apply.rs +++ b/crates/soar-operations/src/apply.rs @@ -370,16 +370,9 @@ fn handle_local_package( diesel_db: &DieselDatabase, diff: &mut ApplyDiff, ) -> SoarResult<()> { - 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() - }; + // Only what the user set. The family is derived from the download URL, + // which identifies the source just as well without minting an id. + let local_pkg_id = pkg.pkg_id.clone(); let installed: Option = diesel_db .with_conn(|conn| { @@ -572,7 +565,7 @@ fn check_url_package_status( conn, Some("local"), Some(&url_pkg.pkg_name), - Some(url_pkg.pkg_id.as_str()), + url_pkg.pkg_id.as_deref(), None, None, None, @@ -584,9 +577,11 @@ fn check_url_package_status( .map(Into::into) .collect(); + // A name alone is not an identity: the family says which source this + // install came from, and only a matching one is the same package. let installed = installed_packages .iter() - .find(|ip| ip.is_installed) + .find(|ip| ip.is_installed && ip.pkg_family == url_pkg.pkg_family) .cloned(); if let Some(ref existing) = installed { From 27b2955c0b358f1efb6def0046a9bba93e65fd7f Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 11:19:29 +0545 Subject: [PATCH 27/66] feat: install packages from the published file list --- crates/soar-config/src/config.rs | 25 +++ crates/soar-core/src/database/models.rs | 5 +- crates/soar-core/src/package/install.rs | 164 +++++++++++++++++- crates/soar-core/src/package/remove.rs | 59 ++++++- .../up.sql | 5 +- crates/soar-db/src/models/metadata.rs | 7 +- crates/soar-db/src/models/types.rs | 13 ++ crates/soar-db/src/repository/metadata.rs | 1 + crates/soar-db/src/schema/metadata.rs | 1 + crates/soar-operations/src/install.rs | 8 +- crates/soar-operations/src/switch.rs | 1 + crates/soar-operations/src/utils.rs | 131 +++++++++++++- crates/soar-registry/src/package.rs | 19 ++ 13 files changed, 422 insertions(+), 17 deletions(-) diff --git a/crates/soar-config/src/config.rs b/crates/soar-config/src/config.rs index 48686b98..97ca2039 100644 --- a/crates/soar-config/src/config.rs +++ b/crates/soar-config/src/config.rs @@ -79,6 +79,11 @@ pub struct Config { /// NOTE: This is not yet implemented pub cross_repo_updates: Option, + /// Shells to link package completions for. Defaults to those whose + /// completion directory already exists, so soar does not create one for a + /// shell nobody uses. + pub completions: Option>, + /// Glob patterns filtering which files an install keeps. /// /// Default: ["!*.log", "!SBUILD", "!*.json", "!*.version"] @@ -277,6 +282,7 @@ impl Config { ghcr_concurrency: Some(8), cross_repo_updates: Some(false), install_patterns: Some(default_install_patterns()), + completions: None, signature_verification: None, desktop_integration: None, @@ -370,6 +376,7 @@ impl Config { ghcr_concurrency: Some(8), cross_repo_updates: Some(false), install_patterns: Some(default_install_patterns()), + completions: None, signature_verification: None, desktop_integration: None, @@ -489,6 +496,24 @@ impl Config { self.default_profile()?.get_bin_path() } + /// Shells whose completions should be linked. + pub fn completion_shells(&self) -> Vec { + if let Some(shells) = &self.completions { + return shells.clone(); + } + let data = soar_utils::path::xdg_data_home(); + let config = soar_utils::path::xdg_config_home(); + [ + ("bash", data.join("bash-completion/completions")), + ("zsh", data.join("zsh/site-functions")), + ("fish", config.join("fish/completions")), + ] + .into_iter() + .filter(|(_, dir)| dir.is_dir()) + .map(|(name, _)| name.to_string()) + .collect() + } + pub fn get_desktop_path(&self) -> Result { if let Ok(env_path) = std::env::var("SOAR_DESKTOP") { return Ok(resolve_path(&env_path)?); diff --git a/crates/soar-core/src/database/models.rs b/crates/soar-core/src/database/models.rs index 7a64b7e9..96ada3be 100644 --- a/crates/soar-core/src/database/models.rs +++ b/crates/soar-core/src/database/models.rs @@ -4,7 +4,7 @@ use std::fmt::Display; use serde::{Deserialize, Serialize}; use soar_db::{ - models::types::{PackageBinary, PackageExtra, PackageProvide}, + models::types::{PackageBinary, PackageExtra, PackageFile, PackageProvide}, repository::core::InstalledPackageWithPortable, }; use soar_package::PackageExt; @@ -70,6 +70,8 @@ pub struct Package { pub binaries: Option>, /// Pinned side files installed alongside the artifact. pub extra: Option>, + /// What the package takes out of its artifact. Absent means all of it. + pub files: Option>, } impl PackageExt for Package { @@ -295,6 +297,7 @@ impl From for Package { portable: pkg.portable, binaries: pkg.binaries, extra: pkg.extra, + files: pkg.files, } } } diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index ce37eae2..3498cc26 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -14,8 +14,9 @@ use soar_config::{ config::Config, packages::{BinaryMapping, BuildConfig, PackageHooks, SandboxConfig}, }; -use soar_db::repository::core::{ - CoreRepository, InstalledPackageWithPortable, NewInstalledPackage, +use soar_db::{ + models::types::PackageFile, + repository::core::{CoreRepository, InstalledPackageWithPortable, NewInstalledPackage}, }; use soar_dl::{ download::Download, @@ -92,6 +93,161 @@ async fn install_extras(package: &Package, install_dir: &Path) -> SoarResult<()> Ok(()) } +/// Lay the package out as its recipe describes: each listed file at its own +/// path, aliases beside it, and nothing else kept. +/// +/// Built in a staging directory and swapped in at the end. Resolving a source +/// can fail, and pruning first would leave a package with its binary deleted +/// and nothing to put back. +fn apply_file_layout(files: &[PackageFile], install_dir: &Path, artifact: &Path) -> SoarResult<()> { + let staging = install_dir.join(".soar-layout"); + fs::remove_dir_all(&staging).ok(); + fs::create_dir_all(&staging) + .with_context(|| format!("creating staging directory {}", staging.display()))?; + + let present = walk_dir_files(install_dir, &staging); + let mut placed = 0usize; + for file in files { + if !is_safe_relative(&file.to) { + warn!(to = file.to, "skipping file with an unsafe target"); + continue; + } + let Some(source) = resolve_source(&file.source, install_dir, &present, artifact) else { + warn!( + source = file.source, + to = file.to, + "file not found in the artifact" + ); + continue; + }; + let dest = staging.join(&file.to); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + fs::rename(&source, &dest).with_context(|| format!("placing {}", file.to))?; + placed += 1; + + let name = dest.file_name().unwrap_or_default().to_os_string(); + for alias in &file.alias { + if !is_safe_relative(alias) { + warn!(alias, "skipping alias with an unsafe target"); + continue; + } + let link = staging.join(alias); + if let Some(parent) = link.parent() { + fs::create_dir_all(parent).ok(); + } + // relative, so the package directory stays movable + std::os::unix::fs::symlink(&name, &link).ok(); + } + } + + // Nothing resolved means the recipe and the artifact disagree. Keeping the + // artifact untouched is better than installing an empty package. + if placed == 0 { + warn!("no listed file was found; leaving the artifact as it is"); + fs::remove_dir_all(&staging).ok(); + return Ok(()); + } + + for entry in fs::read_dir(install_dir) + .with_context(|| format!("reading {}", install_dir.display()))? + .flatten() + { + let path = entry.path(); + if path == staging { + continue; + } + if path.is_dir() && !path.is_symlink() { + fs::remove_dir_all(&path).ok(); + } else { + fs::remove_file(&path).ok(); + } + } + for entry in fs::read_dir(&staging) + .with_context(|| format!("reading {}", staging.display()))? + .flatten() + { + let to = install_dir.join(entry.file_name()); + fs::rename(entry.path(), &to) + .with_context(|| format!("moving {} into place", to.display()))?; + } + fs::remove_dir_all(&staging).ok(); + Ok(()) +} + +/// Whether a path stays inside the directory it is joined to. +fn is_safe_relative(path: &str) -> bool { + !path.is_empty() + && !Path::new(path).is_absolute() + && Path::new(path) + .components() + .all(|c| matches!(c, std::path::Component::Normal(_))) +} + +/// Every regular file under `dir`, skipping the staging directory. +fn walk_dir_files(dir: &Path, skip: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = fs::read_dir(dir) else { + return out; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path == skip || path.is_symlink() { + continue; + } + if path.is_dir() { + out.extend(walk_dir_files(&path, skip)); + } else { + out.push(path); + } + } + out +} + +/// Find a listed source among the extracted files. +/// +/// An empty source means the artifact is itself the file, which is the case for +/// a bare binary. Otherwise the recorded path is tried as written, then without +/// its leading component, since an archive with a single top-level directory +/// has it promoted away, and finally by file name alone. +fn resolve_source( + source: &str, + install_dir: &Path, + present: &[PathBuf], + artifact: &Path, +) -> Option { + // An empty source means the download itself, which is how a bare binary or + // an AppImage says "the artifact is the file". + if source.is_empty() { + return artifact.exists().then(|| artifact.to_path_buf()); + } + let rel_of = |p: &PathBuf| { + p.strip_prefix(install_dir) + .unwrap_or(p) + .to_string_lossy() + .to_string() + }; + // As written first, then without the leading component, since an archive + // with a single top-level directory has it promoted away. + let mut patterns = vec![source.to_string()]; + if let Some((_, rest)) = source.split_once('/') { + patterns.push(rest.to_string()); + } + for pattern in patterns { + if let Some(hit) = present.iter().find(|p| rel_of(p) == pattern) { + return Some(hit.clone()); + } + } + // Last resort, the file name alone: an archive that was extracted but not + // promoted still has everything under the extraction directory. + let name = source.rsplit('/').next().unwrap_or(source); + present + .iter() + .find(|p| p.file_name().is_some_and(|n| n == name)) + .cloned() +} + /// Give every ELF under `dir` the executable bit. /// /// Archive members carry whatever permissions the upstream tarball recorded, @@ -932,6 +1088,10 @@ impl PackageInstaller { } } + if let Some(files) = self.package.files.as_deref().filter(|f| !f.is_empty()) { + apply_file_layout(files, &self.install_dir, output_path)?; + } + if extracted { mark_elfs_executable(&self.install_dir); } diff --git a/crates/soar-core/src/package/remove.rs b/crates/soar-core/src/package/remove.rs index e2e01e7f..64d7abcf 100644 --- a/crates/soar-core/src/package/remove.rs +++ b/crates/soar-core/src/package/remove.rs @@ -15,6 +15,46 @@ use tracing::{debug, trace, warn}; use super::hooks::{run_hook, HookEnv}; +/// Remove every symlink under `dir` that points into `installed_path`. +/// +/// Ownership is decided by where the link goes, not by its name: a completion +/// has to be named after its command, so soar cannot mark its own with a +/// suffix the way it does for desktop files. Anything pointing elsewhere +/// belongs to someone else and is left alone. +fn remove_links_into(dir: &Path, installed_path: &Path, removed: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_symlink() { + if let Ok(target) = fs::read_link(&path) { + if target.starts_with(installed_path) { + trace!("removing link: {}", path.display()); + if fs::remove_file(&path).is_ok() { + removed.push(path); + } + } + } + } else if path.is_dir() { + remove_links_into(&path, installed_path, removed); + } + } +} + +/// The directories a package's files are linked into, beyond `bin`. +fn shared_link_dirs(bin_path: &Path) -> Vec { + let prefix = bin_path.parent().unwrap_or(bin_path); + let data = soar_utils::path::xdg_data_home(); + let config = soar_utils::path::xdg_config_home(); + vec![ + prefix.join("share/man"), + data.join("bash-completion/completions"), + data.join("zsh/site-functions"), + config.join("fish/completions"), + ] +} + /// Removes the bin-directory symlinks a package's `provides` created, keeping /// only those that live directly in `bin_path` and resolve into /// `installed_path`. @@ -178,17 +218,20 @@ impl PackageRemover { let bin_path = self.config.get_bin_path()?; let installed_path = PathBuf::from(&self.package.installed_path); - if let Some(provides) = &self.package.provides { + // An empty list is stored rather than null once nothing declares + // provides, and it must not stand in for "this package has links". + if let Some(provides) = self.package.provides.as_deref().filter(|p| !p.is_empty()) { let removed = remove_provide_symlinks(&bin_path, provides, &installed_path)?; removed_symlinks.extend(removed); } else { - let def_bin = bin_path.join(&self.package.pkg_name); - if def_bin.is_symlink() && def_bin.is_file() { - trace!("removing binary symlink: {}", def_bin.display()); - fs::remove_file(&def_bin) - .with_context(|| format!("removing binary {}", def_bin.display()))?; - removed_symlinks.push(def_bin); - } + // Nothing declares the links anymore, so they are found by + // where they point. This also catches aliases, which a name + // built from pkg_name alone would miss. + remove_links_into(&bin_path, &installed_path, &mut removed_symlinks); + } + + for dir in shared_link_dirs(&bin_path) { + remove_links_into(&dir, &installed_path, &mut removed_symlinks); } let mut remove_action = |path: &Path| -> FileSystemResult<()> { diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql index d0d72759..3e1eb742 100644 --- a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql @@ -47,7 +47,8 @@ CREATE TABLE packages_new ( desktop_integration BOOLEAN, portable BOOLEAN, binaries JSONB, - extra JSONB + extra JSONB, + files JSONB ); INSERT INTO packages_new SELECT @@ -55,7 +56,7 @@ INSERT INTO packages_new SELECT licenses, download_url, size, ghcr_pkg, ghcr_size, ghcr_blob, ghcr_url, bsum, icon, desktop, appstream, homepages, notes, source_urls, categories, build_id, build_date, build_action, build_script, build_log, provides, - snapshots, replaces, soar_syms, desktop_integration, portable, NULL, NULL + snapshots, replaces, soar_syms, desktop_integration, portable, NULL, NULL, NULL FROM packages; DROP TABLE packages; diff --git a/crates/soar-db/src/models/metadata.rs b/crates/soar-db/src/models/metadata.rs index 2ec25235..0af625fc 100644 --- a/crates/soar-db/src/models/metadata.rs +++ b/crates/soar-db/src/models/metadata.rs @@ -3,7 +3,7 @@ use serde_json::Value; use crate::{ json_vec, - models::types::{PackageBinary, PackageExtra, PackageProvide}, + models::types::{PackageBinary, PackageExtra, PackageFile, PackageProvide}, schema::metadata::*, }; @@ -47,6 +47,8 @@ pub struct Package { pub binaries: Option>, /// Pinned side files installed alongside the artifact. pub extra: Option>, + /// What the package takes out of its artifact. + pub files: Option>, } impl Queryable for Package { @@ -87,6 +89,7 @@ impl Queryable for Package { Option, Option, Option, + Option, ); fn build(row: Self::Row) -> diesel::deserialize::Result { @@ -127,6 +130,7 @@ impl Queryable for Package { portable: row.33, binaries: json_vec!(row.34), extra: json_vec!(row.35), + files: json_vec!(row.36), }) } } @@ -227,6 +231,7 @@ pub struct NewPackage<'a> { pub portable: Option, pub binaries: Option, pub extra: Option, + pub files: Option, } #[derive(Default, Insertable)] diff --git a/crates/soar-db/src/models/types.rs b/crates/soar-db/src/models/types.rs index 2fac0e26..4b278385 100644 --- a/crates/soar-db/src/models/types.rs +++ b/crates/soar-db/src/models/types.rs @@ -150,6 +150,19 @@ pub struct PackageBinary { pub link_as: Option, } +/// One file the package installs out of its artifact. +/// +/// `to` is a path inside the package directory, so where it lands says what it +/// is. An empty `source` means the artifact is itself the file. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PackageFile { + #[serde(default)] + pub source: String, + pub to: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub alias: Vec, +} + /// A side file installed alongside the artifact, pinned by hash. /// /// Exists because some artifacts are a bare binary with no room for a licence, diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index bbb4dc34..00a402c0 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -609,6 +609,7 @@ impl MetadataRepository { portable: package.portable, binaries: package.binaries.as_ref().map(|b| json!(b)), extra: package.extra.as_ref().map(|e| json!(e)), + files: package.files.as_ref().map(|f| json!(f)), }; let inserted = diesel::insert_into(packages::table) diff --git a/crates/soar-db/src/schema/metadata.rs b/crates/soar-db/src/schema/metadata.rs index d4834faa..49b42669 100644 --- a/crates/soar-db/src/schema/metadata.rs +++ b/crates/soar-db/src/schema/metadata.rs @@ -52,6 +52,7 @@ diesel::table! { portable -> Nullable, binaries -> Nullable, extra -> Nullable, + files -> Nullable, } } diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index c316b3e3..fc8111ae 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -43,7 +43,7 @@ use tracing::{debug, trace, warn}; use crate::{ progress::{create_progress_bridge, next_op_id}, - utils::{has_desktop_integration, mangle_package_symlinks}, + utils::{has_desktop_integration, link_shared_files, mangle_package_symlinks}, FailedInfo, InstallOptions, InstallReport, InstalledInfo, ResolveResult, SoarContext, }; @@ -1085,9 +1085,15 @@ async fn install_single_package( target.entrypoint.as_deref(), binaries.as_deref(), target.arch_map.as_ref(), + pkg.files.as_deref(), ) .await?; + // Man pages and completions only mean anything where the system looks for + // them, so they are linked out of the package the same way binaries are. + let shared = link_shared_files(&install_dir, &bin_dir, &ctx.config().completion_shells())?; + let symlinks = symlinks.into_iter().chain(shared).collect::>(); + // Desktop integration if !unlinked || has_desktop_integration(pkg, ctx.config()) { events.emit(SoarEvent::Installing { diff --git a/crates/soar-operations/src/switch.rs b/crates/soar-operations/src/switch.rs index 31b4995f..3f9b0d6c 100644 --- a/crates/soar-operations/src/switch.rs +++ b/crates/soar-operations/src/switch.rs @@ -112,6 +112,7 @@ pub async fn switch_variant( None, None, None, + None, ) .await?; diff --git a/crates/soar-operations/src/utils.rs b/crates/soar-operations/src/utils.rs index aaee7f7a..ac8e2914 100644 --- a/crates/soar-operations/src/utils.rs +++ b/crates/soar-operations/src/utils.rs @@ -15,9 +15,9 @@ use soar_core::{ utils::substitute_placeholders, SoarResult, }; -use soar_db::models::types::PackageProvide; +use soar_db::models::types::{PackageFile, PackageProvide}; use soar_utils::fs::is_elf; -use tracing::warn; +use tracing::{debug, warn}; /// Check if a package should have desktop integration (desktop files, icons). pub fn has_desktop_integration(package: &Package, config: &Config) -> bool { @@ -87,6 +87,86 @@ fn create_provide_symlinks( Ok(symlinks) } +/// Where a package's shared files are exposed on the system. +/// +/// Man pages go beside the bin directory, because man-db derives its search +/// path from PATH: for every `.../bin` it also looks at `.../share/man`. That +/// makes them findable with no MANPATH set. +pub fn shared_link_targets( + bin_dir: &Path, + shells: &[String], +) -> Vec<(&'static str, PathBuf, bool)> { + let prefix = bin_dir.parent().unwrap_or(bin_dir); + let data = soar_utils::path::xdg_data_home(); + let config = soar_utils::path::xdg_config_home(); + let wants = |name: &str| shells.iter().any(|s| s == name); + vec![ + ("share/man", prefix.join("share/man"), true), + ( + "share/bash-completion/completions", + data.join("bash-completion/completions"), + wants("bash"), + ), + ( + "share/zsh/site-functions", + data.join("zsh/site-functions"), + wants("zsh"), + ), + ( + "share/fish/vendor_completions.d", + config.join("fish/completions"), + wants("fish"), + ), + ] +} + +/// Link a package's man pages and completions where the system looks for them. +/// +/// A destination already holding something soar did not put there is left +/// alone: a distro package or a hand-written completion outranks ours. +pub fn link_shared_files( + install_dir: &Path, + bin_dir: &Path, + shells: &[String], +) -> SoarResult> { + let mut linked = Vec::new(); + for (relative, destination, enabled) in shared_link_targets(bin_dir, shells) { + if !enabled { + continue; + } + let source_root = install_dir.join(relative); + if !source_root.is_dir() { + continue; + } + for source in walk_files(&source_root) { + let Ok(rest) = source.strip_prefix(&source_root) else { + continue; + }; + let link = destination.join(rest); + if let Some(parent) = link.parent() { + if fs::create_dir_all(parent).is_err() { + continue; + } + } + match fs::read_link(&link) { + // ours, from this package or an older version of it + Ok(target) if target.starts_with(install_dir) => { + fs::remove_file(&link).ok(); + } + Ok(_) | Err(_) if link.exists() || link.is_symlink() => { + debug!(path = %link.display(), "leaving a file soar does not own"); + continue; + } + _ => {} + } + if unix::fs::symlink(&source, &link).is_ok() { + linked.push((source, link)); + } + } + } + Ok(linked) +} + /// Creates symlinks from installed package binaries to the bin directory. #[allow(clippy::too_many_arguments)] /// Every regular file under `dir`, recursively, skipping symlinks and the @@ -120,9 +200,56 @@ pub async fn mangle_package_symlinks( entrypoint: Option<&str>, binaries: Option<&[BinaryMapping]>, arch_map: Option<&HashMap>, + files: Option<&[PackageFile]>, ) -> SoarResult> { let mut symlinks = Vec::new(); + // A package laid out by its file list has already said what its commands + // are: everything in `bin/`. Reading the directory rather than the list + // covers a package that is already installed, which no longer carries one. + let listed: Vec = files + .filter(|f| !f.is_empty()) + .map(|files| { + files + .iter() + .flat_map(|f| std::iter::once(&f.to).chain(f.alias.iter())) + .cloned() + .collect() + }) + .unwrap_or_else(|| { + // Read rather than walk: an alias is a symlink, which walking skips. + fs::read_dir(install_dir.join("bin")) + .into_iter() + .flatten() + .flatten() + .map(|e| format!("bin/{}", e.file_name().to_string_lossy())) + .collect() + }); + if !listed.is_empty() { + for path in &listed { + { + let Some(name) = path.strip_prefix("bin/").filter(|n| !n.contains('/')) else { + continue; + }; + let source_path = install_dir.join(path); + if !source_path.exists() { + continue; + } + let link_path = bin_dir.join(name); + set_executable(&source_path)?; + 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 {}", link_path.display()))?; + symlinks.push((source_path, link_path)); + } + } + return Ok(symlinks); + } + if let Some(bins) = binaries { if !bins.is_empty() { for mapping in bins { diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index 773dc281..01ba3e7c 100644 --- a/crates/soar-registry/src/package.rs +++ b/crates/soar-registry/src/package.rs @@ -245,6 +245,10 @@ pub struct RemotePackage { /// Pinned side files to install alongside the artifact. #[serde(default, skip_serializing_if = "Option::is_none")] pub extra: Option>, + /// What the package takes out of its artifact. Absent means the whole + /// artifact is the package, which is how the older format always behaved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub files: Option>, } #[cfg(test)] @@ -308,6 +312,21 @@ pub struct RemoteBinary { pub link_as: Option, } +/// One file the package installs, as published in the index. +/// +/// `to` is a path inside the package directory, so the directory it lands in +/// says what it is: `bin/` is a command, `share/man/` a manual page. An empty +/// `source` means the artifact is itself the file. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RemoteFile { + #[serde(default)] + pub source: String, + pub to: String, + /// Extra paths, relative to the package directory, resolving to this file. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub alias: Vec, +} + /// A pinned side file as published in the index. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct RemoteExtra { From ef2b305ee1c2c3150ee2401e00f3217847fd3e4b Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 11:30:44 +0545 Subject: [PATCH 28/66] feat(health): report whether man can find installed pages --- crates/soar-cli/src/health.rs | 15 +++++++++++++++ crates/soar-cli/src/install.rs | 26 ++++++++++++++++++++++++++ crates/soar-operations/src/health.rs | 26 ++++++++++++++++++++++++++ crates/soar-operations/src/install.rs | 10 +++++----- crates/soar-operations/src/types.rs | 8 ++++++++ 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/crates/soar-cli/src/health.rs b/crates/soar-cli/src/health.rs index 3fc03bb5..a74584f9 100644 --- a/crates/soar-cli/src/health.rs +++ b/crates/soar-cli/src/health.rs @@ -25,6 +25,21 @@ pub async fn display_health(ctx: &SoarContext) -> SoarResult<()> { }; builder.push_record(["PATH".to_string(), path_status]); + // Only shown once something has installed a manual page, so a user with no + // such package is not told to configure something they do not need. + if let Some(man_dir) = &report.man_path { + let man_status = if report.man_path_configured { + format!("{} Configured", Colored(Green, icon_or(Icons::CHECK, "OK"))) + } else { + format!( + "{} {} not searched by man", + Colored(Yellow, icon_or(Icons::WARNING, "!")), + Colored(Blue, man_dir.display()) + ) + }; + builder.push_record(["MANPATH".to_string(), man_status]); + } + let pkg_status = if report.broken_packages.is_empty() { format!("{} None", Colored(Green, icon_or(Icons::CHECK, "OK"))) } else { diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index f501a672..7bfc4172 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -420,6 +420,32 @@ fn display_install_report(report: &InstallReport, no_notes: bool) { Colored(Magenta, info.install_dir.display()) ); + if !info.shared.is_empty() { + // Listing these would bury the binaries: gh alone ships over a + // hundred manual pages. + let mut man = 0; + let mut completions = 0; + for (_, link) in &info.shared { + let path = link.to_string_lossy(); + if path.contains("/man/") { + man += 1; + } else { + completions += 1; + } + } + let mut parts = Vec::new(); + if man > 0 { + parts.push(format!("{man} man page{}", if man == 1 { "" } else { "s" })); + } + if completions > 0 { + parts.push(format!( + "{completions} completion{}", + if completions == 1 { "" } else { "s" } + )); + } + info!(" {} Linked {}", icon_or("📖", "-"), parts.join(", ")); + } + if !info.symlinks.is_empty() { info!(" {} Binaries:", icon_or("📂", "-")); for (target, link) in &info.symlinks { diff --git a/crates/soar-operations/src/health.rs b/crates/soar-operations/src/health.rs index b05d5a20..a57478c3 100644 --- a/crates/soar-operations/src/health.rs +++ b/crates/soar-operations/src/health.rs @@ -22,12 +22,38 @@ pub fn check_health(ctx: &SoarContext) -> SoarResult { .split(':') .any(|p| resolve_path(p).unwrap_or_default() == bin_path); + // Whether `man` can find what soar installed. An explicit MANPATH replaces + // everything man-db would work out for itself, so a package's manual pages + // can be installed correctly and still be invisible. + let man_dir = bin_path.parent().unwrap_or(&bin_path).join("share/man"); + let man_path = man_dir.is_dir().then(|| man_dir.clone()); + let man_path_configured = match &man_path { + None => true, + Some(dir) => { + let listed = |value: String| { + value + .split(':') + .any(|p| !p.is_empty() && resolve_path(p).unwrap_or_default() == *dir) + }; + let from_env = std::env::var("MANPATH").map(&listed).unwrap_or(false); + from_env + || std::process::Command::new("manpath") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|out| listed(out.trim().to_string())) + .unwrap_or(false) + } + }; + let broken_packages = get_broken_packages(ctx)?; let broken_symlinks = get_broken_symlinks(ctx)?; Ok(HealthReport { path_configured, bin_path, + man_path, + man_path_configured, broken_packages, broken_symlinks, }) diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index fc8111ae..affaa83e 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -679,7 +679,7 @@ pub async fn perform_installation( .await; match result { - Ok((install_dir, symlinks)) => { + Ok((install_dir, symlinks, shared)) => { if !install_dir.as_os_str().is_empty() { installed.lock().unwrap().push(InstalledInfo { pkg_name: target.package.pkg_name.clone(), @@ -688,6 +688,7 @@ pub async fn perform_installation( version: target.package.version.clone(), install_dir, symlinks, + shared, notes: target.package.notes.clone(), }); } @@ -767,7 +768,7 @@ async fn install_single_package( portable_config: Option<&str>, portable_share: Option<&str>, portable_cache: Option<&str>, -) -> SoarResult<(PathBuf, Vec<(PathBuf, PathBuf)>)> { +) -> SoarResult<(PathBuf, Vec<(PathBuf, PathBuf)>, Vec<(PathBuf, PathBuf)>)> { let op_id = next_op_id(); let events = ctx.events().clone(); let pkg = &target.package; @@ -823,7 +824,7 @@ async fn install_single_package( .find(|ip| ip.is_installed); if freshly_installed.is_some() { - return Ok((PathBuf::new(), Vec::new())); + return Ok((PathBuf::new(), Vec::new(), Vec::new())); } let config = ctx.config(); @@ -1092,7 +1093,6 @@ async fn install_single_package( // Man pages and completions only mean anything where the system looks for // them, so they are linked out of the package the same way binaries are. let shared = link_shared_files(&install_dir, &bin_dir, &ctx.config().completion_shells())?; - let symlinks = symlinks.into_iter().chain(shared).collect::>(); // Desktop integration if !unlinked || has_desktop_integration(pkg, ctx.config()) { @@ -1148,7 +1148,7 @@ async fn install_single_package( version = pkg.version, "installation complete" ); - Ok((install_dir, symlinks)) + Ok((install_dir, symlinks, shared)) } fn verify_signatures(pubkey_str: &str, install_dir: &Path) -> SoarResult { diff --git a/crates/soar-operations/src/types.rs b/crates/soar-operations/src/types.rs index a60ec2f1..bc774f15 100644 --- a/crates/soar-operations/src/types.rs +++ b/crates/soar-operations/src/types.rs @@ -62,6 +62,9 @@ pub struct InstalledInfo { pub version: String, pub install_dir: PathBuf, pub symlinks: Vec<(PathBuf, PathBuf)>, + /// Man pages and completions linked out of the package. Counted rather + /// than listed: a package like gh ships over a hundred manual pages. + pub shared: Vec<(PathBuf, PathBuf)>, pub notes: Option>, } @@ -158,6 +161,11 @@ pub struct InstalledEntry { pub struct HealthReport { pub path_configured: bool, pub bin_path: PathBuf, + /// Where soar puts manual pages, and whether `man` will actually look + /// there. Set only once a package has installed one, since there is + /// nothing to warn about otherwise. + pub man_path: Option, + pub man_path_configured: bool, pub broken_packages: Vec, pub broken_symlinks: Vec, } From c8ce8df095c7755f065c2d6c2ca0a05b2dc1d3b6 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 12:05:59 +0545 Subject: [PATCH 29/66] fix: resolve multi-version installs and lay out read-only trees --- crates/soar-core/src/package/install.rs | 4 ++ crates/soar-core/src/package/remove.rs | 14 +++++-- crates/soar-operations/src/install.rs | 53 ++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 3498cc26..5c6c6282 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -105,6 +105,10 @@ fn apply_file_layout(files: &[PackageFile], install_dir: &Path, artifact: &Path) fs::create_dir_all(&staging) .with_context(|| format!("creating staging directory {}", staging.display()))?; + // An archive may ship its directories read-only, and moving a file out of + // one needs write permission on the directory itself. + crate::package::remove::make_tree_writable(install_dir); + let present = walk_dir_files(install_dir, &staging); let mut placed = 0usize; for file in files { diff --git a/crates/soar-core/src/package/remove.rs b/crates/soar-core/src/package/remove.rs index 64d7abcf..10086652 100644 --- a/crates/soar-core/src/package/remove.rs +++ b/crates/soar-core/src/package/remove.rs @@ -123,7 +123,7 @@ pub struct PackageRemover { /// /// Without it `remove_dir_all` cannot unlink the entries inside, so a package /// that installed cleanly could not be removed. -fn make_tree_writable(path: &Path) { +pub(crate) fn make_tree_writable(path: &Path) { let Ok(entries) = fs::read_dir(path) else { return; }; @@ -245,7 +245,12 @@ impl PackageRemover { } Ok(()) }; - walk_dir(&self.config.get_desktop_path()?, &mut remove_action)?; + // A missing desktop or icon directory means there is nothing of + // ours in it, not a reason to abandon the removal half-done. + let desktop_path = self.config.get_desktop_path()?; + if desktop_path.is_dir() { + walk_dir(&desktop_path, &mut remove_action)?; + } let mut remove_action = |path: &Path| -> FileSystemResult<()> { if let Ok(real_path) = fs::read_link(path) { @@ -256,7 +261,10 @@ impl PackageRemover { } Ok(()) }; - walk_dir(self.config.get_icons_path(), &mut remove_action)?; + let icons_path = self.config.get_icons_path(); + if icons_path.is_dir() { + walk_dir(icons_path, &mut remove_action)?; + } } // Calculate directory size before removal for logging diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index affaa83e..3655551b 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -37,6 +37,7 @@ use soar_utils::{ lock::FileLock, path::is_safe_component, pattern::apply_sig_variants, + version::compare_versions, }; use tokio::sync::Semaphore; use tracing::{debug, trace, warn}; @@ -537,6 +538,48 @@ fn resolve_normal( }])) } _ => { + // Several versions of one package are not competing variants, and + // asking which to install would be asking the same question the + // caller already answered by naming it. The newest wins; anything + // else is chosen with an explicit @version. + let identity = |p: &Package| { + ( + p.pkg_name.clone(), + p.pkg_id.clone(), + p.pkg_family.clone(), + p.repo_name.clone(), + ) + }; + let first = identity(&packages[0]); + if packages.iter().all(|p| identity(p) == first) { + let newest = packages + .into_iter() + .max_by(|a, b| compare_versions(&a.version, &b.version)) + .unwrap(); + let installed_pkg = installed_packages.iter().find(|ip| ip.is_installed); + if let Some(installed) = installed_pkg { + if !options.force { + return Ok(ResolveResult::AlreadyInstalled { + pkg_name: installed.pkg_name.clone(), + repo_name: installed.repo_name.clone(), + version: installed.version.clone(), + }); + } + } + let existing_install = installed_packages + .iter() + .find(|ip| ip.version == newest.version) + .cloned(); + let newest = newest.resolve(query.version.as_deref()); + return Ok(ResolveResult::Resolved(vec![InstallTarget { + package: newest, + existing_install, + pinned: query.version.is_some(), + profile: None, + ..Default::default() + }])); + } + Ok(ResolveResult::Ambiguous(crate::AmbiguousPackage { query: package_name.to_string(), candidates: packages, @@ -871,9 +914,17 @@ async fn install_single_package( ))); } + // The version is in the name so a directory can be read at a glance. It is + // not the identity: the hash still is, since two builds of one version must + // not collide. + let dir_name = if is_safe_component(&pkg.version) { + format!("{}-{}-{}", pkg.pkg_name, pkg.version, dir_suffix) + } else { + format!("{}-{}", pkg.pkg_name, dir_suffix) + }; let install_dir = config .get_packages_path(target.profile.clone())? - .join(format!("{}-{}", pkg.pkg_name, dir_suffix)); + .join(dir_name); let main_binary_name = pkg .provides .as_ref() From b180d8963bce871399baee6c1d9a8566da748827 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 12:18:04 +0545 Subject: [PATCH 30/66] fix: show one row per package, not one per version --- crates/soar-operations/src/list.rs | 26 +++++++++++++++++++++++++- crates/soar-operations/src/search.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/soar-operations/src/list.rs b/crates/soar-operations/src/list.rs index 665cc79f..f95b55e5 100644 --- a/crates/soar-operations/src/list.rs +++ b/crates/soar-operations/src/list.rs @@ -9,7 +9,7 @@ use soar_db::{ models::metadata::PackageListing, repository::{core::CoreRepository, metadata::MetadataRepository}, }; -use soar_utils::fs::dir_size; +use soar_utils::{fs::dir_size, version::compare_versions}; use tracing::{debug, trace}; use crate::{ @@ -57,6 +57,30 @@ pub async fn list_packages( })? }; + // One row per package, not per version. A repository publishes every + // version it knows, and listing them all buries the packages themselves. + let mut newest: HashMap<(String, String, Option), ListingWithRepo> = HashMap::new(); + for entry in packages { + let key = ( + entry.repo_name.clone(), + entry.pkg.pkg_name.clone(), + entry.pkg.pkg_id.clone(), + ); + match newest.get(&key) { + Some(kept) if compare_versions(&kept.pkg.version, &entry.pkg.version).is_ge() => {} + _ => { + newest.insert(key, entry); + } + } + } + let mut packages: Vec = newest.into_values().collect(); + packages.sort_by(|a, b| { + a.pkg + .pkg_name + .cmp(&b.pkg.pkg_name) + .then(a.repo_name.cmp(&b.repo_name)) + }); + let installed_pkgs: HashMap<(String, String), bool> = diesel_db .with_conn(|conn| { CoreRepository::list_filtered(conn, None, None, None, None, None, None, None, None) diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index cb97b943..8963c5cb 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -14,6 +14,7 @@ use soar_db::{ metadata::MetadataRepository, }, }; +use soar_utils::version::compare_versions; use tracing::{debug, trace}; use crate::{SearchEntry, SearchResult, SoarContext}; @@ -55,6 +56,32 @@ pub async fn search_packages( fuzzy_search(ctx, query, search_limit).await? }; + // One row per package: a result repeated once per published version says + // nothing extra and pushes real matches off the list. + let mut newest: HashMap<(String, String, Option), Package> = HashMap::new(); + let mut order: Vec<(String, String, Option)> = Vec::new(); + for pkg in packages { + let key = ( + pkg.repo_name.clone(), + pkg.pkg_name.clone(), + pkg.pkg_id.clone(), + ); + match newest.get(&key) { + Some(kept) if compare_versions(&kept.version, &pkg.version).is_ge() => {} + _ => { + if !newest.contains_key(&key) { + order.push(key.clone()); + } + newest.insert(key, pkg); + } + } + } + // ranking order is the point of a search, so it is preserved + let packages: Vec = order + .into_iter() + .filter_map(|k| newest.remove(&k)) + .collect(); + let installed_pkgs: HashMap<(String, String), bool> = diesel_db .with_conn(|conn| { CoreRepository::list_filtered(conn, None, None, None, None, None, None, None, None) From 1d244e59fe6d726d303fd05bf4c77abe6f2309fc Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 12:29:34 +0545 Subject: [PATCH 31/66] feat: name the other versions a package publishes --- crates/soar-cli/src/list.rs | 21 +++++++++++++++++++-- crates/soar-operations/src/list.rs | 22 ++++++++++++++++++++++ crates/soar-operations/src/search.rs | 22 ++++++++++++++++++++++ crates/soar-operations/src/types.rs | 5 +++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index 07b75b68..bab4a445 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -53,11 +53,19 @@ pub async fn search_packages( version = package.version, description = package.description, size = package.ghcr_size.or(package.size), - "[{}] {}:{} | {} | {} - {} ({})", + "[{}] {}:{} | {}{} | {} - {} ({})", state_icon, Colored(Blue, &package.pkg_name), Colored(Green, &package.repo_name), Colored(LightRed, &package.version), + if entry.other_versions.is_empty() { + String::new() + } else { + format!( + " {}", + Colored(Yellow, format!("({})", entry.other_versions.join(", "))) + ) + }, package .pkg_type .as_ref() @@ -296,11 +304,20 @@ pub async fn list_packages(ctx: &SoarContext, repo_name: Option) -> Soar repo_name = package.repo_name, pkg_type = package.pkg_type, version = package.version, - "[{}] {}:{} | {} | {}", + "[{}] {}:{} | {}{} | {}", state_icon, Colored(Blue, &package.pkg_name), Colored(Cyan, &package.repo_name), Colored(LightRed, &package.version), + // only the newest is listed; name the others rather than counting them + if entry.other_versions.is_empty() { + String::new() + } else { + format!( + " {}", + Colored(Yellow, format!("({})", entry.other_versions.join(", "))) + ) + }, package .pkg_type .as_ref() diff --git a/crates/soar-operations/src/list.rs b/crates/soar-operations/src/list.rs index f95b55e5..a5610201 100644 --- a/crates/soar-operations/src/list.rs +++ b/crates/soar-operations/src/list.rs @@ -60,12 +60,17 @@ pub async fn list_packages( // One row per package, not per version. A repository publishes every // version it knows, and listing them all buries the packages themselves. let mut newest: HashMap<(String, String, Option), ListingWithRepo> = HashMap::new(); + let mut counts: HashMap<(String, String, Option), Vec> = HashMap::new(); for entry in packages { let key = ( entry.repo_name.clone(), entry.pkg.pkg_name.clone(), entry.pkg.pkg_id.clone(), ); + counts + .entry(key.clone()) + .or_default() + .push(entry.pkg.version.clone()); match newest.get(&key) { Some(kept) if compare_versions(&kept.pkg.version, &entry.pkg.version).is_ge() => {} _ => { @@ -110,6 +115,22 @@ pub async fn list_packages( .map(|entry| { let key = (entry.repo_name.clone(), entry.pkg.pkg_name.clone()); let installed = installed_pkgs.get(&key).copied().unwrap_or(false); + let other_versions = counts + .get(&( + entry.repo_name.clone(), + entry.pkg.pkg_name.clone(), + entry.pkg.pkg_id.clone(), + )) + .map(|all| { + let mut rest: Vec = all + .iter() + .filter(|v| **v != entry.pkg.version) + .cloned() + .collect(); + rest.sort_by(|a, b| compare_versions(b, a)); + rest + }) + .unwrap_or_default(); // Build a minimal Package for the entry let package = Package { @@ -123,6 +144,7 @@ pub async fn list_packages( PackageListEntry { package, installed, + other_versions, } }) .collect(); diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index 8963c5cb..f8d898e9 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -59,6 +59,7 @@ pub async fn search_packages( // One row per package: a result repeated once per published version says // nothing extra and pushes real matches off the list. let mut newest: HashMap<(String, String, Option), Package> = HashMap::new(); + let mut counts: HashMap<(String, String, Option), Vec> = HashMap::new(); let mut order: Vec<(String, String, Option)> = Vec::new(); for pkg in packages { let key = ( @@ -66,6 +67,10 @@ pub async fn search_packages( pkg.pkg_name.clone(), pkg.pkg_id.clone(), ); + counts + .entry(key.clone()) + .or_default() + .push(pkg.version.clone()); match newest.get(&key) { Some(kept) if compare_versions(&kept.version, &pkg.version).is_ge() => {} _ => { @@ -112,9 +117,26 @@ pub async fn search_packages( .map(|package| { let key = (package.repo_name.clone(), package.pkg_name.clone()); let installed = installed_pkgs.get(&key).copied().unwrap_or(false); + let other_versions = counts + .get(&( + package.repo_name.clone(), + package.pkg_name.clone(), + package.pkg_id.clone(), + )) + .map(|all| { + let mut rest: Vec = all + .iter() + .filter(|v| **v != package.version) + .cloned() + .collect(); + rest.sort_by(|a, b| compare_versions(b, a)); + rest + }) + .unwrap_or_default(); SearchEntry { package, installed, + other_versions, } }) .collect(); diff --git a/crates/soar-operations/src/types.rs b/crates/soar-operations/src/types.rs index bc774f15..d732f296 100644 --- a/crates/soar-operations/src/types.rs +++ b/crates/soar-operations/src/types.rs @@ -132,6 +132,8 @@ pub struct SearchResult { pub struct SearchEntry { pub package: Package, pub installed: bool, + /// The other versions the repository publishes, newest first. + pub other_versions: Vec, } pub struct PackageListResult { @@ -142,6 +144,9 @@ pub struct PackageListResult { pub struct PackageListEntry { pub package: Package, pub installed: bool, + /// The other versions the repository publishes, newest first. Only the + /// newest is listed, so these say what is not being shown. + pub other_versions: Vec, } pub struct InstalledListResult { From 09f5e760502a40d5701ea5e4815419e812d6f9c1 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 12:33:15 +0545 Subject: [PATCH 32/66] fix(query): order versions newest first --- crates/soar-operations/src/search.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index f8d898e9..bdf7bd55 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -320,7 +320,7 @@ pub async fn query_package(ctx: &SoarContext, query_str: &str) -> SoarResult = if let Some(ref version) = query.version { + let mut packages: Vec = if let Some(ref version) = query.version { packages .into_iter() .filter(|p| p.has_version(version)) @@ -330,5 +330,15 @@ pub async fn query_package(ctx: &SoarContext, query_str: &str) -> SoarResult Date: Sat, 1 Aug 2026 13:06:22 +0545 Subject: [PATCH 33/66] fix: tell apart packages that share a name across repos --- crates/soar-cli/src/install.rs | 34 +++------------ crates/soar-cli/src/list.rs | 16 ++++++- crates/soar-db/src/models/metadata.rs | 1 + crates/soar-operations/src/install.rs | 55 ++++++++++++++++++++++-- crates/soar-operations/src/list.rs | 60 ++++++++++++++++++++++----- crates/soar-operations/src/search.rs | 59 ++++++++++++++++++++------ 6 files changed, 168 insertions(+), 57 deletions(-) diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index 7bfc4172..161d99d6 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -75,22 +75,10 @@ pub async fn install_packages( }; if let Some(pkg) = pkg { - // Re-resolve with the specific selected package. The - // family has to come along, or a name ambiguous within one - // repository resolves right back to the same choice. - let specific_query = match &pkg.pkg_family { - Some(family) => { - format!("{}/{}:{}", family, pkg.pkg_name, pkg.repo_name) - } - None => format!("{}:{}", pkg.pkg_name, 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); - } - } + // Install the package that was chosen. Re-resolving it by + // name would ask the same ambiguous question again and + // answer it with nothing. + install_targets.push(install::target_for(ctx, pkg, &options)?); } } ResolveResult::NotFound(name) => { @@ -206,19 +194,7 @@ async fn install_with_show( }; if let Some(pkg) = pkg { - let specific_query = match &pkg.pkg_family { - Some(family) => { - format!("{}/{}:{}", family, pkg.pkg_name, pkg.repo_name) - } - None => format!("{}:{}", pkg.pkg_name, 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); - } - } + install_targets.push(install::target_for(ctx, pkg, options)?); } } ResolveResult::NotFound(name) => { diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index bab4a445..bf2ed249 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -53,8 +53,13 @@ pub async fn search_packages( version = package.version, description = package.description, size = package.ghcr_size.or(package.size), - "[{}] {}:{} | {}{} | {} - {} ({})", + "[{}] {}{}:{} | {}{} | {} - {} ({})", state_icon, + package + .pkg_family + .as_ref() + .map(|f| format!("{}/", Colored(Green, f))) + .unwrap_or_default(), Colored(Blue, &package.pkg_name), Colored(Green, &package.repo_name), Colored(LightRed, &package.version), @@ -304,8 +309,15 @@ pub async fn list_packages(ctx: &SoarContext, repo_name: Option) -> Soar repo_name = package.repo_name, pkg_type = package.pkg_type, version = package.version, - "[{}] {}:{} | {}{} | {}", + "[{}] {}{}:{} | {}{} | {}", state_icon, + // shown the way it is typed, so two packages sharing a name are + // told apart and can be asked for + package + .pkg_family + .as_ref() + .map(|f| format!("{}/", Colored(Cyan, f))) + .unwrap_or_default(), Colored(Blue, &package.pkg_name), Colored(Cyan, &package.repo_name), Colored(LightRed, &package.version), diff --git a/crates/soar-db/src/models/metadata.rs b/crates/soar-db/src/models/metadata.rs index 0af625fc..76b94437 100644 --- a/crates/soar-db/src/models/metadata.rs +++ b/crates/soar-db/src/models/metadata.rs @@ -142,6 +142,7 @@ impl Queryable for Package { #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct PackageListing { pub pkg_id: Option, + pub pkg_family: Option, pub pkg_name: String, pub pkg_type: Option, pub version: String, diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 3655551b..5556c533 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -52,6 +52,44 @@ use crate::{ /// /// For each query string, returns a [`ResolveResult`] indicating whether the package /// was resolved, is ambiguous (multiple candidates), not found, or already installed. +/// Build an install target for a package the caller has already chosen. +/// +/// Resolving it again by name would pose the same ambiguous question that the +/// choice just answered. +pub fn target_for( + ctx: &SoarContext, + package: Package, + options: &InstallOptions, +) -> SoarResult { + let diesel_db = ctx.diesel_core_db()?.clone(); + let existing_install: Option = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + Some(&package.repo_name), + Some(&package.pkg_name), + package.pkg_id.as_deref(), + None, + None, + None, + Some(1), + None, + ) + })? + .into_iter() + .next() + .map(Into::into); + let pinned = options.version_override.is_some(); + let package = package.resolve(options.version_override.as_deref()); + Ok(InstallTarget { + package, + existing_install, + pinned, + profile: None, + ..Default::default() + }) +} + pub async fn resolve_packages( ctx: &SoarContext, packages: &[String], @@ -510,7 +548,11 @@ fn resolve_normal( 0 => Ok(ResolveResult::NotFound(package_name.to_string())), 1 => { let pkg = packages.into_iter().next().unwrap(); - let installed_pkg = installed_packages.iter().find(|ip| ip.is_installed); + // Same name from another repository is a different package, so it + // must not be mistaken for this one already being installed. + let installed_pkg = installed_packages + .iter() + .find(|ip| ip.is_installed && ip.repo_name == pkg.repo_name); if let Some(installed) = installed_pkg { if !options.force { @@ -556,7 +598,9 @@ fn resolve_normal( .into_iter() .max_by(|a, b| compare_versions(&a.version, &b.version)) .unwrap(); - let installed_pkg = installed_packages.iter().find(|ip| ip.is_installed); + let installed_pkg = installed_packages + .iter() + .find(|ip| ip.is_installed && ip.repo_name == newest.repo_name); if let Some(installed) = installed_pkg { if !options.force { return Ok(ResolveResult::AlreadyInstalled { @@ -593,8 +637,13 @@ fn find_packages( query: &PackageQuery, existing_install: &Option, ) -> SoarResult> { + // Naming a repository is a choice, so it outranks where an existing install + // happened to come from. // If we have an existing install, try to find it in its original repo first - if let Some(existing) = existing_install { + if let Some(existing) = existing_install + .as_ref() + .filter(|_| query.repo_name.is_none()) + { let existing_pkgs: Vec = metadata_mgr .query_repo(&existing.repo_name, |conn| { MetadataRepository::find_filtered( diff --git a/crates/soar-operations/src/list.rs b/crates/soar-operations/src/list.rs index a5610201..ce42bd9a 100644 --- a/crates/soar-operations/src/list.rs +++ b/crates/soar-operations/src/list.rs @@ -17,6 +17,27 @@ use crate::{ }; /// List all available packages, optionally filtered by repository. +/// Installed packages by repository and name, each with the family it was +/// installed under, so a package sharing a name is not mistaken for it. +/// What identifies a package apart from its version: repository, name, id and +/// family. Two entries sharing this are two versions of one package. +type PackageKey = (String, String, Option, Option); + +type InstalledIndex = HashMap<(String, String), Vec<(Option, bool)>>; + +fn is_installed( + map: &InstalledIndex, + repo_name: &str, + pkg_name: &str, + pkg_family: Option<&str>, +) -> bool { + map.get(&(repo_name.to_string(), pkg_name.to_string())) + .is_some_and(|rows| { + rows.iter() + .any(|(family, installed)| *installed && family.as_deref() == pkg_family) + }) +} + pub async fn list_packages( ctx: &SoarContext, repo_name: Option<&str>, @@ -59,13 +80,16 @@ pub async fn list_packages( // One row per package, not per version. A repository publishes every // version it knows, and listing them all buries the packages themselves. - let mut newest: HashMap<(String, String, Option), ListingWithRepo> = HashMap::new(); - let mut counts: HashMap<(String, String, Option), Vec> = HashMap::new(); + let mut newest: HashMap = HashMap::new(); + let mut counts: HashMap> = HashMap::new(); for entry in packages { + // family included: two packages sharing a name are different + // packages, not two versions of one let key = ( entry.repo_name.clone(), entry.pkg.pkg_name.clone(), entry.pkg.pkg_id.clone(), + entry.pkg.pkg_family.clone(), ); counts .entry(key.clone()) @@ -86,7 +110,7 @@ pub async fn list_packages( .then(a.repo_name.cmp(&b.repo_name)) }); - let installed_pkgs: HashMap<(String, String), bool> = diesel_db + let installed_pkgs: InstalledIndex = diesel_db .with_conn(|conn| { CoreRepository::list_filtered(conn, None, None, None, None, None, None, None, None) })? @@ -96,14 +120,22 @@ pub async fn list_packages( // keying on both would stop matching the two. Rows sharing a key are // merged rather than overwritten, so one uninstalled version cannot // mask an installed one. - .map(|pkg| ((pkg.repo_name, pkg.pkg_name), pkg.is_installed)) - .fold(HashMap::new, |mut acc, (key, installed)| { - *acc.entry(key).or_insert(false) |= installed; + // Keyed by family too, or a package merely sharing a name would + // inherit the marker. The family is recorded at install time, so a + // package without one matches only entries without one. + .map(|pkg| { + ( + (pkg.repo_name, pkg.pkg_name), + (pkg.pkg_family, pkg.is_installed), + ) + }) + .fold(HashMap::new, |mut acc: HashMap<_, Vec<_>>, (key, value)| { + acc.entry(key).or_default().push(value); acc }) - .reduce(HashMap::new, |mut acc, part| { - for (key, installed) in part { - *acc.entry(key).or_insert(false) |= installed; + .reduce(HashMap::new, |mut acc: HashMap<_, Vec<_>>, part| { + for (key, values) in part { + acc.entry(key).or_default().extend(values); } acc }); @@ -113,13 +145,18 @@ pub async fn list_packages( let entries: Vec = packages .into_iter() .map(|entry| { - let key = (entry.repo_name.clone(), entry.pkg.pkg_name.clone()); - let installed = installed_pkgs.get(&key).copied().unwrap_or(false); + let installed = is_installed( + &installed_pkgs, + &entry.repo_name, + &entry.pkg.pkg_name, + entry.pkg.pkg_family.as_deref(), + ); let other_versions = counts .get(&( entry.repo_name.clone(), entry.pkg.pkg_name.clone(), entry.pkg.pkg_id.clone(), + entry.pkg.pkg_family.clone(), )) .map(|all| { let mut rest: Vec = all @@ -136,6 +173,7 @@ pub async fn list_packages( let package = Package { repo_name: entry.repo_name, pkg_name: entry.pkg.pkg_name, + pkg_family: entry.pkg.pkg_family, pkg_type: entry.pkg.pkg_type, version: entry.pkg.version, ..Default::default() diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index bdf7bd55..f68cf802 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -22,6 +22,27 @@ use crate::{SearchEntry, SearchResult, SoarContext}; /// Search for packages across all repositories. /// /// Uses fuzzy matching by default. Falls back to SQL LIKE for case-sensitive searches. +/// Installed packages by repository and name, each with the family it was +/// installed under, so a package sharing a name is not mistaken for it. +/// What identifies a package apart from its version: repository, name, id and +/// family. Two entries sharing this are two versions of one package. +type PackageKey = (String, String, Option, Option); + +type InstalledIndex = HashMap<(String, String), Vec<(Option, bool)>>; + +fn is_installed( + map: &InstalledIndex, + repo_name: &str, + pkg_name: &str, + pkg_family: Option<&str>, +) -> bool { + map.get(&(repo_name.to_string(), pkg_name.to_string())) + .is_some_and(|rows| { + rows.iter() + .any(|(family, installed)| *installed && family.as_deref() == pkg_family) + }) +} + pub async fn search_packages( ctx: &SoarContext, query: &str, @@ -58,14 +79,15 @@ pub async fn search_packages( // One row per package: a result repeated once per published version says // nothing extra and pushes real matches off the list. - let mut newest: HashMap<(String, String, Option), Package> = HashMap::new(); - let mut counts: HashMap<(String, String, Option), Vec> = HashMap::new(); - let mut order: Vec<(String, String, Option)> = Vec::new(); + let mut newest: HashMap = HashMap::new(); + let mut counts: HashMap> = HashMap::new(); + let mut order: Vec = Vec::new(); for pkg in packages { let key = ( pkg.repo_name.clone(), pkg.pkg_name.clone(), pkg.pkg_id.clone(), + pkg.pkg_family.clone(), ); counts .entry(key.clone()) @@ -87,7 +109,7 @@ pub async fn search_packages( .filter_map(|k| newest.remove(&k)) .collect(); - let installed_pkgs: HashMap<(String, String), bool> = diesel_db + let installed_pkgs: InstalledIndex = diesel_db .with_conn(|conn| { CoreRepository::list_filtered(conn, None, None, None, None, None, None, None, None) })? @@ -97,14 +119,22 @@ pub async fn search_packages( // keying on both would stop matching the two. Rows sharing a key are // merged rather than overwritten, so one uninstalled version cannot // mask an installed one. - .map(|pkg| ((pkg.repo_name, pkg.pkg_name), pkg.is_installed)) - .fold(HashMap::new, |mut acc, (key, installed)| { - *acc.entry(key).or_insert(false) |= installed; + // Keyed by family too, or a package merely sharing a name would + // inherit the marker. The family is recorded at install time, so a + // package without one matches only entries without one. + .map(|pkg| { + ( + (pkg.repo_name, pkg.pkg_name), + (pkg.pkg_family, pkg.is_installed), + ) + }) + .fold(HashMap::new, |mut acc: HashMap<_, Vec<_>>, (key, value)| { + acc.entry(key).or_default().push(value); acc }) - .reduce(HashMap::new, |mut acc, part| { - for (key, installed) in part { - *acc.entry(key).or_insert(false) |= installed; + .reduce(HashMap::new, |mut acc: HashMap<_, Vec<_>>, part| { + for (key, values) in part { + acc.entry(key).or_default().extend(values); } acc }); @@ -115,13 +145,18 @@ pub async fn search_packages( .into_iter() .take(search_limit) .map(|package| { - let key = (package.repo_name.clone(), package.pkg_name.clone()); - let installed = installed_pkgs.get(&key).copied().unwrap_or(false); + let installed = is_installed( + &installed_pkgs, + &package.repo_name, + &package.pkg_name, + package.pkg_family.as_deref(), + ); let other_versions = counts .get(&( package.repo_name.clone(), package.pkg_name.clone(), package.pkg_id.clone(), + package.pkg_family.clone(), )) .map(|all| { let mut rest: Vec = all From 12917947f3f09a33fac9384f85b9f09a42c50ca2 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 13:23:37 +0545 Subject: [PATCH 34/66] fix(run): lay out archives and cache per exact package --- crates/soar-core/src/package/install.rs | 6 +- crates/soar-operations/src/run.rs | 85 +++++++++++++++++++++---- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 5c6c6282..0f955dfa 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -99,7 +99,11 @@ async fn install_extras(package: &Package, install_dir: &Path) -> SoarResult<()> /// Built in a staging directory and swapped in at the end. Resolving a source /// can fail, and pruning first would leave a package with its binary deleted /// and nothing to put back. -fn apply_file_layout(files: &[PackageFile], install_dir: &Path, artifact: &Path) -> SoarResult<()> { +pub fn apply_file_layout( + files: &[PackageFile], + install_dir: &Path, + artifact: &Path, +) -> SoarResult<()> { let staging = install_dir.join(".soar-layout"); fs::remove_dir_all(&staging).ok(); fs::create_dir_all(&staging) diff --git a/crates/soar-operations/src/run.rs b/crates/soar-operations/src/run.rs index 37e22fcb..c92e6518 100644 --- a/crates/soar-operations/src/run.rs +++ b/crates/soar-operations/src/run.rs @@ -3,13 +3,16 @@ use std::{fs, path::Path, process::Command, sync::Arc}; use soar_core::{ database::models::Package, error::{ErrorContext, SoarError}, - package::query::PackageQuery, + package::{install::apply_file_layout, 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 soar_utils::{ + hash::{calculate_checksum, hash_string}, + version::compare_versions, +}; use tracing::debug; use crate::{ @@ -39,8 +42,6 @@ pub async fn prepare_run( let family = query.family.as_deref(); let version = query.version.as_deref(); - let output_path = cache_bin.join(package_name); - let metadata_mgr = ctx.metadata_manager().await?; let packages: Vec = if let Some(repo_name) = repo_name { @@ -86,7 +87,7 @@ pub async fn prepare_run( })? }; - let packages: Vec = if let Some(version) = version { + let mut packages: Vec = if let Some(version) = version { packages .into_iter() .filter(|p| p.has_version(version)) @@ -99,15 +100,60 @@ pub async fn prepare_run( 0 => return Err(SoarError::PackageNotFound(package_name.to_string())), 1 => {} _ => { - return Ok(PrepareRunResult::Ambiguous(AmbiguousPackage { - query: package_name.to_string(), - candidates: packages, - })); + // Several versions of one package are not a choice to put to the + // caller: running a command means running the current one, unless + // an explicit @version says otherwise. + let identity = |p: &Package| { + ( + p.pkg_name.clone(), + p.pkg_id.clone(), + p.pkg_family.clone(), + p.repo_name.clone(), + ) + }; + let first = identity(&packages[0]); + if packages.iter().all(|p| identity(p) == first) { + let newest = packages + .into_iter() + .max_by(|a, b| compare_versions(&a.version, &b.version)) + .unwrap(); + packages = vec![newest]; + } else { + return Ok(PrepareRunResult::Ambiguous(AmbiguousPackage { + query: package_name.to_string(), + candidates: packages, + })); + } } } let package = packages.into_iter().next().unwrap().resolve(version); + // Named like an install: content-addressed, so a different version or a + // different repository never reuses another's cached binary. + let suffix = package + .bsum + .as_deref() + .filter(|s| s.len() >= 12) + .map(|s| s[..12].to_string()) + .unwrap_or_else(|| { + let source = package + .pkg_id + .as_deref() + .or(package.ghcr_pkg.as_deref()) + .unwrap_or(package.download_url.as_str()); + hash_string(&format!( + "{}:{}:{}", + package.pkg_name, package.version, source + ))[..12] + .to_string() + }); + let cache_dir = cache_bin.join(format!( + "{}-{}-{}", + package.pkg_name, package.version, suffix + )); + let output_path = cache_dir.join(&package.pkg_name); + // Refuse to execute a package whose integrity cannot be checked. OCI // artifacts are digest-verified during download, so they are exempt. if !no_verify && package.bsum.is_none() && package.ghcr_blob.is_none() { @@ -136,8 +182,8 @@ pub async fn prepare_run( } } - fs::create_dir_all(&cache_bin) - .with_context(|| format!("creating directory {}", cache_bin.display()))?; + fs::create_dir_all(&cache_dir) + .with_context(|| format!("creating directory {}", cache_dir.display()))?; let op_id = next_op_id(); let progress_callback = @@ -146,11 +192,26 @@ pub async fn prepare_run( download_to_cache( &package, &output_path, - &cache_bin, + &cache_dir, no_verify, progress_callback, )?; + // The artifact may be an archive, in which case the thing to execute is + // wherever the package says it is rather than the download itself. + if let Some(files) = package.files.as_deref().filter(|f| !f.is_empty()) { + apply_file_layout(files, &cache_dir, &output_path)?; + if let Some(binary) = files.iter().find_map(|f| { + f.to.strip_prefix("bin/") + .filter(|rest| !rest.contains('/')) + .map(|_| cache_dir.join(&f.to)) + }) { + if binary.exists() { + return Ok(PrepareRunResult::Ready(binary)); + } + } + } + Ok(PrepareRunResult::Ready(output_path)) } From 06bdca2d460546268b35e0eb080cf211841772db Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 13:26:27 +0545 Subject: [PATCH 35/66] fix(run): reuse the cache and run the chosen package --- crates/soar-cli/src/run.rs | 5 +++-- crates/soar-operations/src/run.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/soar-cli/src/run.rs b/crates/soar-cli/src/run.rs index daaea3a2..633d1b0b 100644 --- a/crates/soar-cli/src/run.rs +++ b/crates/soar-cli/src/run.rs @@ -33,10 +33,11 @@ pub async fn run_package( return Ok(0); }; - // Re-run with selected package + // Run what was chosen. Resolving it by name again would pose + // the same ambiguous question the choice just answered. let result = run::prepare_run( ctx, - package_name, + &format!("{}@{}:{}", pkg.pkg_name, pkg.version, pkg.repo_name), Some(&pkg.repo_name), pkg.pkg_id.as_deref(), no_verify, diff --git a/crates/soar-operations/src/run.rs b/crates/soar-operations/src/run.rs index c92e6518..2c20f49d 100644 --- a/crates/soar-operations/src/run.rs +++ b/crates/soar-operations/src/run.rs @@ -163,6 +163,20 @@ pub async fn prepare_run( ))); } + // A laid-out package keeps its binary, not the artifact it came from, so a + // cache hit is that binary. The directory is content-addressed, which is + // what makes finding it there proof enough. + let laid_out = package.files.as_deref().and_then(|files| { + files.iter().find_map(|f| { + f.to.strip_prefix("bin/") + .filter(|rest| !rest.contains('/')) + .map(|_| cache_dir.join(&f.to)) + }) + }); + if let Some(binary) = laid_out.as_ref().filter(|p| p.exists()) { + return Ok(PrepareRunResult::Ready(binary.clone())); + } + // Reuse a cached binary only after re-verifying it against the expected // checksum, so a stale or tampered cache entry is never executed blindly. if output_path.exists() { From 4f96faff06c52561d805e0e89d1c1473ab9fbe48 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 13:29:18 +0545 Subject: [PATCH 36/66] fix: give each install its own directory --- crates/soar-operations/src/install.rs | 32 ++++++++++++++------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 5556c533..b50ab9aa 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -937,22 +937,24 @@ async fn install_single_package( } } - let dir_suffix: String = pkg + // Keyed on the whole identity rather than the artifact's hash. Two + // repositories shipping byte-identical builds are still two installs, and + // sharing a directory means removing one deletes the other's files. + let content = pkg .bsum - .as_ref() - .filter(|s| s.len() >= 12) - .map(|s| s[..12].to_string()) - .unwrap_or_else(|| { - // Name and version alone collide across sources: the same - // release packaged by two repositories would share a directory. - let source = pkg - .pkg_id - .as_deref() - .or(pkg.ghcr_pkg.as_deref()) - .unwrap_or(pkg.download_url.as_str()); - let input = format!("{}:{}:{}", pkg.pkg_name, pkg.version, source); - hash_string(&input)[..12].to_string() - }); + .as_deref() + .or(pkg.pkg_id.as_deref()) + .or(pkg.ghcr_pkg.as_deref()) + .unwrap_or(pkg.download_url.as_str()); + let dir_suffix: String = hash_string(&format!( + "{}:{}:{}:{}:{}", + pkg.repo_name, + pkg.pkg_family.as_deref().unwrap_or_default(), + pkg.pkg_name, + pkg.version, + content + ))[..12] + .to_string(); // pkg_name is joined into install_dir and interpolated into resource paths // downstream, so it must not be able to escape the packages dir. From 9a03e83b6b17e4b3bbc619457c48f8de02e40693 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 13:36:20 +0545 Subject: [PATCH 37/66] fix: treat the repository as part of a package identity --- crates/soar-core/src/package/install.rs | 1 + crates/soar-db/src/repository/core.rs | 48 ++++++++++++++++++------- crates/soar-operations/src/switch.rs | 1 + 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 0f955dfa..f960c5a6 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -1276,6 +1276,7 @@ impl PackageInstaller { CoreRepository::unlink_others( conn, pkg_name, + &self.package.repo_name, pkg_id, self.package.pkg_family.as_deref(), version, diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index 28b44a8e..f922e1c6 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -19,6 +19,10 @@ use crate::{ /// A repository publishing the declarative format produces no package id, so /// `pkg_id = ?` would never match those rows and `pkg_id != ?` would never /// exclude them. Asking for no id has to mean the row has none either. +/// An installed row reduced to what identifies it: id, repository, package id, +/// family and version. +type IdentityRow = (i32, String, Option, Option, String); + fn match_pkg_id( pkg_id: Option<&str>, ) -> Box>> { @@ -464,6 +468,7 @@ impl CoreRepository { pub fn unlink_others( conn: &mut SqliteConnection, pkg_name: &str, + keep_repo_name: &str, keep_pkg_id: Option<&str>, keep_pkg_family: Option<&str>, keep_version: &str, @@ -474,6 +479,7 @@ impl CoreRepository { let stale = Self::rows_other_than( conn, pkg_name, + keep_repo_name, keep_pkg_id, keep_pkg_family, Some(keep_version), @@ -490,14 +496,16 @@ impl CoreRepository { fn rows_other_than( conn: &mut SqliteConnection, pkg_name: &str, + keep_repo_name: &str, keep_pkg_id: Option<&str>, keep_pkg_family: Option<&str>, keep_version: Option<&str>, ) -> QueryResult> { - let rows: Vec<(i32, Option, Option, String)> = packages::table + let rows: Vec = packages::table .filter(packages::pkg_name.eq(pkg_name)) .select(( packages::id, + packages::repo_name, packages::pkg_id, packages::pkg_family, packages::version, @@ -505,8 +513,12 @@ impl CoreRepository { .load(conn)?; Ok(rows .into_iter() - .filter(|(_, pkg_id, pkg_family, version)| { - let same = pkg_id.as_deref() == keep_pkg_id + .filter(|(_, repo_name, pkg_id, pkg_family, version)| { + // The repository is part of the identity: the same package + // from two of them is two installs, and only one can own the + // command. + let same = repo_name == keep_repo_name + && pkg_id.as_deref() == keep_pkg_id && pkg_family.as_deref() == keep_pkg_family && keep_version.is_none_or(|v| version == v); !same @@ -742,24 +754,36 @@ impl CoreRepository { pub fn unlink_others_by_checksum( conn: &mut SqliteConnection, pkg_name: &str, + keep_repo_name: &str, keep_pkg_id: Option<&str>, keep_pkg_family: Option<&str>, keep_checksum: Option<&str>, ) -> QueryResult { if let Some(checksum) = keep_checksum { - let others = Self::rows_other_than(conn, pkg_name, keep_pkg_id, keep_pkg_family, None)?; + let others = Self::rows_other_than( + conn, + pkg_name, + keep_repo_name, + keep_pkg_id, + keep_pkg_family, + None, + )?; if others.is_empty() { return Ok(0); } - diesel::update( - packages::table - .filter(packages::id.eq_any(others)) - .filter(packages::checksum.ne(checksum)), - ) - .set(packages::unlinked.eq(true)) - .execute(conn) + let _ = checksum; + diesel::update(packages::table.filter(packages::id.eq_any(others))) + .set(packages::unlinked.eq(true)) + .execute(conn) } else { - let others = Self::rows_other_than(conn, pkg_name, keep_pkg_id, keep_pkg_family, None)?; + let others = Self::rows_other_than( + conn, + pkg_name, + keep_repo_name, + keep_pkg_id, + keep_pkg_family, + None, + )?; if others.is_empty() { return Ok(0); } diff --git a/crates/soar-operations/src/switch.rs b/crates/soar-operations/src/switch.rs index 3f9b0d6c..03dc5290 100644 --- a/crates/soar-operations/src/switch.rs +++ b/crates/soar-operations/src/switch.rs @@ -91,6 +91,7 @@ pub async fn switch_variant( CoreRepository::unlink_others_by_checksum( conn, pkg_name, + &selected_package.repo_name, pkg_id, selected_package.pkg_family.as_deref(), checksum, From d60c43af8d614050d400905dec4e1246e48cdab9 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 13:40:21 +0545 Subject: [PATCH 38/66] fix(use): link the chosen row, not every matching checksum --- crates/soar-db/src/repository/core.rs | 10 ++++++++++ crates/soar-operations/src/switch.rs | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index f922e1c6..b055fe0c 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -795,6 +795,16 @@ impl CoreRepository { /// Links a package by pkg_name, pkg_id, and checksum. /// Used when switching to an alternate package version. + /// Mark one installed row as the linked one, by its own id. + /// + /// Matching on a checksum cannot do this: two repositories shipping the + /// same build share it, so linking one would link both. + pub fn link_by_row_id(conn: &mut SqliteConnection, id: i32) -> QueryResult { + diesel::update(packages::table.filter(packages::id.eq(id))) + .set(packages::unlinked.eq(false)) + .execute(conn) + } + pub fn link_by_checksum( conn: &mut SqliteConnection, pkg_name: &str, diff --git a/crates/soar-operations/src/switch.rs b/crates/soar-operations/src/switch.rs index 03dc5290..4af38987 100644 --- a/crates/soar-operations/src/switch.rs +++ b/crates/soar-operations/src/switch.rs @@ -96,7 +96,7 @@ pub async fn switch_variant( selected_package.pkg_family.as_deref(), checksum, )?; - CoreRepository::link_by_checksum(conn, pkg_name, pkg_id, checksum) + CoreRepository::link_by_row_id(conn, selected_package.id) })?; let config = ctx.config(); From 6cb5cdb14c9dc0968e9a555cb30ddb4284674d27 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 13:42:54 +0545 Subject: [PATCH 39/66] fix(run): end the progress line before the program starts --- crates/soar-cli/src/run.rs | 23 +++++++++++++++++++++-- crates/soar-operations/src/run.rs | 27 ++++++++++++++++++++++----- crates/soar-operations/src/types.rs | 2 +- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/soar-cli/src/run.rs b/crates/soar-cli/src/run.rs index 633d1b0b..bb4637fa 100644 --- a/crates/soar-cli/src/run.rs +++ b/crates/soar-cli/src/run.rs @@ -20,8 +20,15 @@ pub async fn run_package( let result = run::prepare_run(ctx, package_name, repo_name, pkg_id, no_verify).await?; + let mut downloaded = false; let output_path = match result { - PrepareRunResult::Ready(path) => path, + PrepareRunResult::Ready { + path, + downloaded: d, + } => { + downloaded = d; + path + } PrepareRunResult::Ambiguous(amb) => { let pkg = if yes { amb.candidates.into_iter().next() @@ -45,12 +52,24 @@ pub async fn run_package( .await?; match result { - PrepareRunResult::Ready(path) => path, + PrepareRunResult::Ready { + path, + downloaded: d, + } => { + downloaded = d; + path + } _ => return Ok(0), } } }; + // The progress bar leaves the cursor mid-line, so a program that writes + // straight to stdout would start where the bar stopped. + if downloaded { + eprintln!(); + } + let run_result = run::execute_binary(&output_path, args)?; Ok(run_result.exit_code) diff --git a/crates/soar-operations/src/run.rs b/crates/soar-operations/src/run.rs index 2c20f49d..1919a7eb 100644 --- a/crates/soar-operations/src/run.rs +++ b/crates/soar-operations/src/run.rs @@ -174,7 +174,10 @@ pub async fn prepare_run( }) }); if let Some(binary) = laid_out.as_ref().filter(|p| p.exists()) { - return Ok(PrepareRunResult::Ready(binary.clone())); + return Ok(PrepareRunResult::Ready { + path: binary.clone(), + downloaded: false, + }); } // Reuse a cached binary only after re-verifying it against the expected @@ -184,7 +187,10 @@ pub async fn prepare_run( Some(ref bsum) if !no_verify => { let checksum = calculate_checksum(&output_path)?; if checksum == *bsum { - return Ok(PrepareRunResult::Ready(output_path)); + return Ok(PrepareRunResult::Ready { + path: output_path, + downloaded: false, + }); } debug!( package = %package.pkg_name, @@ -192,7 +198,12 @@ pub async fn prepare_run( ); fs::remove_file(&output_path).ok(); } - _ => return Ok(PrepareRunResult::Ready(output_path)), + _ => { + return Ok(PrepareRunResult::Ready { + path: output_path, + downloaded: false, + }) + } } } @@ -221,12 +232,18 @@ pub async fn prepare_run( .map(|_| cache_dir.join(&f.to)) }) { if binary.exists() { - return Ok(PrepareRunResult::Ready(binary)); + return Ok(PrepareRunResult::Ready { + path: binary, + downloaded: true, + }); } } } - Ok(PrepareRunResult::Ready(output_path)) + Ok(PrepareRunResult::Ready { + path: output_path, + downloaded: true, + }) } /// Execute a binary with the given arguments. diff --git a/crates/soar-operations/src/types.rs b/crates/soar-operations/src/types.rs index d732f296..9599e7fe 100644 --- a/crates/soar-operations/src/types.rs +++ b/crates/soar-operations/src/types.rs @@ -219,7 +219,7 @@ pub struct ApplyReport { // ---- Run ---- pub enum PrepareRunResult { - Ready(PathBuf), + Ready { path: PathBuf, downloaded: bool }, Ambiguous(AmbiguousPackage), } From 04bc368d1a16d8b165801aa1527c1f33b78a9619 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 13:44:33 +0545 Subject: [PATCH 40/66] fix(run): end the progress line before the program starts --- crates/soar-cli/src/run.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/soar-cli/src/run.rs b/crates/soar-cli/src/run.rs index bb4637fa..ec585f43 100644 --- a/crates/soar-cli/src/run.rs +++ b/crates/soar-cli/src/run.rs @@ -20,7 +20,7 @@ pub async fn run_package( let result = run::prepare_run(ctx, package_name, repo_name, pkg_id, no_verify).await?; - let mut downloaded = false; + let downloaded; let output_path = match result { PrepareRunResult::Ready { path, From 0a833ea8f8b4f20fbe976737b742b6a760dc040e Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:29:30 +0545 Subject: [PATCH 41/66] fix: match a package by its id and family, not its name The original-repo lookup passed the installed id into the family parameter, and update matching dropped the id entirely, so either could resolve to a different package of the same name. --- crates/soar-db/src/repository/metadata.rs | 25 ++++++++++--- crates/soar-operations/src/install.rs | 45 ++++++++++++----------- crates/soar-operations/src/update.rs | 1 + 3 files changed, 45 insertions(+), 26 deletions(-) diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index 00a402c0..edf72409 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -34,6 +34,24 @@ struct PkgIdOnly { pkg_id: String, } +/// Narrow candidates to those carrying `pkg_id`, unless none of them do. +/// +/// An id recorded at install time may have disappeared from the metadata, +/// since a repository that moved to the declarative format publishes none. +/// Demanding a match there would report the package as up to date forever, +/// while ignoring the id altogether lets a different package of the same name +/// pass as a newer build of this one. +pub fn narrow_by_pkg_id(candidates: Vec, pkg_id: Option<&str>) -> Vec { + let Some(id) = pkg_id else { + return candidates; + }; + let matches_id = |p: &Package| p.pkg_id.as_deref() == Some(id); + if !candidates.iter().any(matches_id) { + return candidates; + } + candidates.into_iter().filter(matches_id).collect() +} + /// Repository for package metadata operations. pub struct MetadataRepository; @@ -442,6 +460,7 @@ impl MetadataRepository { pub fn find_newer_version( conn: &mut SqliteConnection, pkg_name: &str, + pkg_id: Option<&str>, pkg_family: Option<&str>, current_version: &str, ) -> QueryResult> { @@ -450,10 +469,6 @@ impl MetadataRepository { current_version = current_version, "checking for newer version" ); - // Matched by name alone. An id recorded at install time may no longer - // appear in the metadata at all, and requiring it to match would - // report every such package as already up to date. - // // Ordering cannot be left to SQL: a string comparison puts 10 below 9 // and the rebuild suffix in 1.14.0-1 below 1.14.0. Candidates are // loaded and compared segment-wise instead. @@ -466,7 +481,7 @@ impl MetadataRepository { if let Some(family) = pkg_family { query = query.filter(packages::pkg_family.eq(family.to_string())); } - let candidates: Vec = query.select(Package::as_select()).load(conn)?; + let candidates = narrow_by_pkg_id(query.select(Package::as_select()).load(conn)?, pkg_id); let result: QueryResult> = Ok(candidates .into_iter() diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index b50ab9aa..f30c0c8c 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -28,7 +28,7 @@ use soar_core::{ }; use soar_db::repository::{ core::{CoreRepository, SortDirection}, - metadata::MetadataRepository, + metadata::{narrow_by_pkg_id, MetadataRepository}, }; use soar_events::{InstallStage, SoarEvent, VerifyStage}; use soar_package::integrate_package; @@ -644,26 +644,29 @@ fn find_packages( .as_ref() .filter(|_| query.repo_name.is_none()) { - let existing_pkgs: Vec = metadata_mgr - .query_repo(&existing.repo_name, |conn| { - MetadataRepository::find_filtered( - conn, - Some(&existing.pkg_name), - None, - existing.pkg_id.as_deref(), - None, - None, - None, - ) - })? - .unwrap_or_default() - .into_iter() - .map(|p| { - let mut pkg: Package = p.into(); - pkg.repo_name = existing.repo_name.clone(); - pkg - }) - .collect(); + let existing_pkgs: Vec = narrow_by_pkg_id( + metadata_mgr + .query_repo(&existing.repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + Some(&existing.pkg_name), + None, + existing.pkg_family.as_deref(), + None, + None, + None, + ) + })? + .unwrap_or_default(), + existing.pkg_id.as_deref(), + ) + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = existing.repo_name.clone(); + pkg + }) + .collect(); if !existing_pkgs.is_empty() { return Ok(existing_pkgs); diff --git a/crates/soar-operations/src/update.rs b/crates/soar-operations/src/update.rs index f7379a3e..730a3c59 100644 --- a/crates/soar-operations/src/update.rs +++ b/crates/soar-operations/src/update.rs @@ -140,6 +140,7 @@ fn check_repo_update( MetadataRepository::find_newer_version( conn, &pkg.pkg_name, + pkg.pkg_id.as_deref(), pkg.pkg_family.as_deref(), &pkg.version, ) From b30389b799cae2e5ac66697efd2dd3a7e95ce0d8 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:29:38 +0545 Subject: [PATCH 42/66] fix(db): tell id-less packages apart by their family The uniqueness key omitted the family, so two variants publishing no id collided and the second was dropped on import. --- .../2026-07-28-000000-0000_declarative_format/up.sql | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql index 3e1eb742..c8a2ffb1 100644 --- a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql @@ -8,9 +8,11 @@ -- `tags` was stored and displayed but never searched, and `version_upstream` -- was never read at all. -- --- The uniqueness key keeps the id, but NULLs are collapsed first: SQLite --- treats every NULL as distinct, so an id-less package would otherwise insert --- a fresh duplicate on every sync instead of conflicting with itself. +-- The uniqueness key keeps the id and adds the family, which is what tells +-- identically-named packages apart once no id is published. NULLs are +-- collapsed first: SQLite treats every NULL as distinct, so an id-less +-- package would otherwise insert a fresh duplicate on every sync instead of +-- conflicting with itself. CREATE TABLE packages_new ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, pkg_id TEXT COLLATE NOCASE, @@ -63,4 +65,4 @@ DROP TABLE packages; ALTER TABLE packages_new RENAME TO packages; CREATE UNIQUE INDEX packages_identity - ON packages (COALESCE(pkg_id, ''), pkg_name, version); + ON packages (COALESCE(pkg_id, ''), COALESCE(pkg_family, ''), pkg_name, version); From 8c76eb92f6d10ff252a19036d0d44926db928f74 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:29:38 +0545 Subject: [PATCH 43/66] fix: fail the install when nothing could be extracted A failed extraction was warned about and the package recorded as installed with nothing in it. Local files now detect the format first, so a plain binary is no longer run through the extractor. --- crates/soar-core/src/package/install.rs | 18 +++++++++++++----- crates/soar-dl/src/download.rs | 23 ++++++----------------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index f960c5a6..5e779714 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -150,12 +150,17 @@ pub fn apply_file_layout( } } - // Nothing resolved means the recipe and the artifact disagree. Keeping the - // artifact untouched is better than installing an empty package. + // Nothing resolved means the recipe and the artifact disagree, which most + // often means the download never extracted. Keeping what is there would + // record a package with no commands in it and still report success, so the + // install fails here instead. if placed == 0 { - warn!("no listed file was found; leaving the artifact as it is"); fs::remove_dir_all(&staging).ok(); - return Ok(()); + return Err(SoarError::Custom(format!( + "none of the {} files listed by {} were found in the artifact", + files.len(), + install_dir.display() + ))); } for entry in fs::read_dir(install_dir) @@ -819,7 +824,10 @@ impl PackageInstaller { .with_context(|| format!("setting permissions on {}", dest.display()))?; } - if extract { + // Extraction is offered for every install, so what the file actually is + // decides: a local AppImage or bare binary is not an archive and is + // left alone rather than failing. + if extract && compak::detect_from_file(dest).is_ok() { debug!(archive = %dest.display(), dest = %extract_dir.display(), "extracting local archive"); compak::extract_archive(dest, extract_dir).map_err(|e| { SoarError::Custom(format!( diff --git a/crates/soar-dl/src/download.rs b/crates/soar-dl/src/download.rs index c074916c..594f4452 100644 --- a/crates/soar-dl/src/download.rs +++ b/crates/soar-dl/src/download.rs @@ -335,25 +335,14 @@ impl Download { .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) }); - // Cleanup below must never reach a directory that was - // already there: the default destination is the download's - // own parent, so removing it would take the user's other - // files with it. - let dir_existed = extract_dir.exists(); debug!(archive = %output_path.display(), dest = %extract_dir.display(), ?format, "extracting archive"); - // A bare .gz or .bz2 of a single file shares its magic - // number with the tar-wrapped form, so detection can be - // right about the compression and wrong about the - // container. Leave the download in place rather than - // failing the install outright. - if let Err(e) = compak::extract_archive(&output_path, &extract_dir) { - warn!(archive = %output_path.display(), error = %e, - "extraction failed, installing the download as-is"); - if !dir_existed { - std::fs::remove_dir_all(&extract_dir).ok(); - } - } + // A failure here fails the install. A bare .gz of a single + // file shares its magic number with the tar-wrapped form, + // but detection decompresses far enough to tell the two + // apart, so what is left is a download that really is + // broken, and swallowing that installs an empty package. + compak::extract_archive(&output_path, &extract_dir)?; } Err(_) => { trace!(path = %output_path.display(), From 2bf2be529123400f57215b7a4c68c4ca3b241d70 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:29:38 +0545 Subject: [PATCH 44/66] fix(link): keep shared links across an upgrade An install directory carries its version, so links from the old one read as foreign and were left dangling. The bin/ fallback also no longer overrides a package that declares its own binaries. --- crates/soar-operations/src/utils.rs | 56 +++++++++++++++++------------ 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/crates/soar-operations/src/utils.rs b/crates/soar-operations/src/utils.rs index ac8e2914..e7f7d681 100644 --- a/crates/soar-operations/src/utils.rs +++ b/crates/soar-operations/src/utils.rs @@ -129,6 +129,10 @@ pub fn link_shared_files( bin_dir: &Path, shells: &[String], ) -> SoarResult> { + // A link pointing anywhere inside soar's own package tree is soar's, + // including one left by an older version of this package: an install + // directory carries its version, so the path never matches the new one. + let packages_root = install_dir.parent().unwrap_or(install_dir); let mut linked = Vec::new(); for (relative, destination, enabled) in shared_link_targets(bin_dir, shells) { if !enabled { @@ -150,7 +154,7 @@ pub fn link_shared_files( } match fs::read_link(&link) { // ours, from this package or an older version of it - Ok(target) if target.starts_with(install_dir) => { + Ok(target) if target.starts_with(packages_root) => { fs::remove_file(&link).ok(); } Ok(_) | Err(_) if link.exists() || link.is_symlink() => { @@ -205,27 +209,35 @@ pub async fn mangle_package_symlinks( let mut symlinks = Vec::new(); // A package laid out by its file list has already said what its commands - // are: everything in `bin/`. Reading the directory rather than the list - // covers a package that is already installed, which no longer carries one. - let listed: Vec = files - .filter(|f| !f.is_empty()) - .map(|files| { - files - .iter() - .flat_map(|f| std::iter::once(&f.to).chain(f.alias.iter())) - .cloned() - .collect() - }) - .unwrap_or_else(|| { - // Read rather than walk: an alias is a symlink, which walking skips. - fs::read_dir(install_dir.join("bin")) - .into_iter() - .flatten() - .flatten() - .map(|e| format!("bin/{}", e.file_name().to_string_lossy())) - .collect() - }); - if !listed.is_empty() { + // are: everything in `bin/`. + let listed: Option> = files.filter(|f| !f.is_empty()).map(|files| { + files + .iter() + .flat_map(|f| std::iter::once(&f.to).chain(f.alias.iter())) + .cloned() + .collect() + }); + // A package installed before the file list existed carries none, so its own + // `bin/` stands in, but only where nothing else describes the package. + // Reading the directory otherwise publishes every executable an archive + // happens to ship, which for a toolchain is dozens of them. + let listed = listed.or_else(|| { + if entrypoint.is_some() + || binaries.is_some_and(|b| !b.is_empty()) + || provides.is_some_and(|p| !p.is_empty()) + { + return None; + } + // Read rather than walk: an alias is a symlink, which walking skips. + let entries: Vec = fs::read_dir(install_dir.join("bin")) + .into_iter() + .flatten() + .flatten() + .map(|e| format!("bin/{}", e.file_name().to_string_lossy())) + .collect(); + (!entries.is_empty()).then_some(entries) + }); + if let Some(listed) = listed { for path in &listed { { let Some(name) = path.strip_prefix("bin/").filter(|n| !n.contains('/')) else { From 9a74c76384d9aba2aa7a0b4f4809578dcaf17d36 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:29:46 +0545 Subject: [PATCH 45/66] fix(version): rank a commit hash below an ordinary version Hashes compared equal to each other but segment-wise against a tag, so the ordering was intransitive and could panic sort_by. --- crates/soar-utils/src/version.rs | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/soar-utils/src/version.rs b/crates/soar-utils/src/version.rs index a021e663..a24e9e0a 100644 --- a/crates/soar-utils/src/version.rs +++ b/crates/soar-utils/src/version.rs @@ -11,8 +11,9 @@ //! (`1.05`, `7.1-2`, `r1287.fef2b38-1`) are not valid semver at all. //! //! A version built from a commit hash has no order to recover: hashes carry no -//! time. Those compare equal unless identical, so a repository that wants -//! upgrades between snapshots has to publish something ordered, such as a date. +//! time. Those compare equal to each other and below any ordinary version, so a +//! repository that wants upgrades between snapshots has to publish something +//! ordered, such as a date. use std::cmp::Ordering; @@ -29,9 +30,16 @@ use std::cmp::Ordering; /// ``` pub fn compare_versions(a: &str, b: &str) -> Ordering { // Two commit hashes carry no order at all, and segment rules would invent - // one, letting an arbitrary hash read as an upgrade or a downgrade. - if a != b && is_commit_hash(a) && is_commit_hash(b) { - return Ordering::Equal; + // one, letting an arbitrary hash read as an upgrade or a downgrade. A hash + // ranks below any ordinary version rather than being compared segment-wise + // against one, which would leave the ordering intransitive: two hashes + // equal to each other yet landing on opposite sides of the same tag, which + // is enough to panic `sort_by`. + match (is_commit_hash(a), is_commit_hash(b)) { + (true, true) => return Ordering::Equal, + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + (false, false) => {} } let mut left = segments(a); @@ -190,6 +198,20 @@ mod tests { assert!(!is_newer("0f3a21b", "89c99d2a9")); } + #[test] + fn hashes_rank_below_ordinary_versions() { + // Sorting a mixed list needs a total order. Comparing each hash + // segment-wise against the tag would put one above it and one below, + // while the two stay equal to each other. + assert_eq!(compare_versions("0f3a21b", "5.0"), Ordering::Less); + assert_eq!(compare_versions("89c99d2a9", "5.0"), Ordering::Less); + assert_eq!(compare_versions("5.0", "89c99d2a9"), Ordering::Greater); + + let mut versions = ["89c99d2a9", "5.0", "0f3a21b", "4.9"]; + versions.sort_by(|a, b| compare_versions(b, a)); + assert_eq!(&versions[..2], &["5.0", "4.9"]); + } + #[test] fn digit_runs_are_versions_not_hashes() { // a date-like version must keep comparing normally From 25abb9fe6b64adcd5b2e638a172ed5692e7a46b1 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:29:46 +0545 Subject: [PATCH 46/66] fix(update): restore write bits before removing a package An archive shipping read-only directories made the removal of a superseded version fail, and the failure was discarded, so the tree and its row stayed behind. --- crates/soar-core/src/package/remove.rs | 2 +- crates/soar-core/src/package/update.rs | 3 +++ crates/soar-operations/src/install.rs | 12 ++++++++++-- crates/soar-operations/src/update.rs | 4 +++- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/soar-core/src/package/remove.rs b/crates/soar-core/src/package/remove.rs index 10086652..a9c7681a 100644 --- a/crates/soar-core/src/package/remove.rs +++ b/crates/soar-core/src/package/remove.rs @@ -123,7 +123,7 @@ pub struct PackageRemover { /// /// Without it `remove_dir_all` cannot unlink the entries inside, so a package /// that installed cleanly could not be removed. -pub(crate) fn make_tree_writable(path: &Path) { +pub fn make_tree_writable(path: &Path) { let Ok(entries) = fs::read_dir(path) else { return; }; diff --git a/crates/soar-core/src/package/update.rs b/crates/soar-core/src/package/update.rs index 7b018504..8cd2f8e2 100644 --- a/crates/soar-core/src/package/update.rs +++ b/crates/soar-core/src/package/update.rs @@ -36,6 +36,9 @@ pub fn remove_old_versions(package: &Package, db: &DieselDatabase, force: bool) for (_id, installed_path) in &old_packages { let path = Path::new(installed_path); if path.exists() { + // An archive may ship its directories read-only, and removing an + // entry needs write permission on the directory holding it. + crate::package::remove::make_tree_writable(path); fs::remove_dir_all(path) .with_context(|| format!("removing old package directory {}", path.display()))?; } diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index f30c0c8c..115f2f4b 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -21,6 +21,7 @@ use soar_core::{ install::{InstallMarker, InstallTarget, PackageInstaller}, local::LocalPackage, query::PackageQuery, + remove::make_tree_writable, update::remove_old_versions, url::UrlPackage, }, @@ -787,13 +788,17 @@ pub async fn perform_installation( notes: target.package.notes.clone(), }); } - let _ = remove_old_versions(&target.package, &db, false); + if let Err(err) = remove_old_versions(&target.package, &db, false) { + warn!(error = %err, "could not remove the superseded version"); + } } Err(err) => { match err { SoarError::Warning(msg) => { warnings.lock().unwrap().push(msg); - let _ = remove_old_versions(&target.package, &db, false); + if let Err(err) = remove_old_versions(&target.package, &db, false) { + warn!(error = %err, "could not remove the superseded version"); + } } _ => { let op_id = next_op_id(); @@ -1032,6 +1037,9 @@ async fn install_single_package( if should_cleanup && install_dir.exists() { debug!(path = %install_dir.display(), "cleaning up existing installation directory"); + // An archive may ship its directories read-only, and removing an entry + // needs write permission on the directory holding it. + make_tree_writable(&install_dir); fs::remove_dir_all(&install_dir).map_err(|err| { SoarError::Custom(format!( "Failed to clean up install directory {}: {}", diff --git a/crates/soar-operations/src/update.rs b/crates/soar-operations/src/update.rs index 730a3c59..cbddce1a 100644 --- a/crates/soar-operations/src/update.rs +++ b/crates/soar-operations/src/update.rs @@ -484,7 +484,9 @@ pub async fn perform_update( stage: UpdateCleanupStage::Removing, }); - let _ = remove_old_versions(pkg, &diesel_db, false); + if let Err(err) = remove_old_versions(pkg, &diesel_db, false) { + warn!(error = %err, "could not remove the superseded version"); + } ctx.events().emit(SoarEvent::UpdateCleanup { op_id, From 3891a963071c9ed586de117b57a8e62dc199b844 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:29:46 +0545 Subject: [PATCH 47/66] fix(db): treat a differing id or version as an alternate Requiring both to differ, and comparing against a NULL id, meant the alternate lookup returned the wrong set either way. --- crates/soar-db/src/repository/core.rs | 46 ++++++++++++++++++++------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index b055fe0c..290c1969 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -14,15 +14,15 @@ use crate::{ schema::core::{packages, portable_package}, }; +/// An installed row reduced to what identifies it: id, repository, package id, +/// family and version. +type IdentityRow = (i32, String, Option, Option, String); + /// Matches a row's package id, including rows that have none. /// /// A repository publishing the declarative format produces no package id, so /// `pkg_id = ?` would never match those rows and `pkg_id != ?` would never /// exclude them. Asking for no id has to mean the row has none either. -/// An installed row reduced to what identifies it: id, repository, package id, -/// family and version. -type IdentityRow = (i32, String, Option, Option, String); - fn match_pkg_id( pkg_id: Option<&str>, ) -> Box>> { @@ -338,22 +338,44 @@ impl CoreRepository { Ok(results.into_iter().map(Into::into).collect()) } - /// Finds installed packages by name, excluding specific pkg_id and version. + /// Finds installed packages of the same name that are not this one. + /// + /// The same version under a different id and the same id at a different + /// version are both other packages, so neither condition alone may be + /// required: only the row matching on both is this package itself. pub fn find_alternates( conn: &mut SqliteConnection, pkg_name: &str, exclude_pkg_id: Option<&str>, exclude_version: &str, ) -> QueryResult> { - let results: Vec<(Package, Option)> = packages::table + let query = packages::table .left_join(portable_package::table) .filter(packages::pkg_name.eq(pkg_name)) - .filter( - packages::pkg_id - .is_null() - .or(packages::pkg_id.ne(exclude_pkg_id)), - ) - .filter(packages::version.ne(exclude_version)) + .into_boxed(); + + // An id-less row differs from one that carries an id, and SQL answers + // `pkg_id != ?` with NULL rather than true in both directions, so + // neither case can be left to the comparison. + let others = match exclude_pkg_id { + Some(id) => { + query.filter( + packages::pkg_id + .is_null() + .or(packages::pkg_id.ne(id.to_string())) + .or(packages::version.ne(exclude_version)), + ) + } + None => { + query.filter( + packages::pkg_id + .is_not_null() + .or(packages::version.ne(exclude_version)), + ) + } + }; + + let results: Vec<(Package, Option)> = others .select((Package::as_select(), Option::::as_select())) .load(conn)?; From 04647b7c7d19b01320f653ded22861aa3d33396d Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:41:30 +0545 Subject: [PATCH 48/66] docs: reattach doc comments to the items they describe Scripted edits left five public items carrying the previous item's documentation, and one attribute on the wrong function. --- crates/soar-core/src/package/install.rs | 8 ++++---- crates/soar-operations/src/install.rs | 9 +++++---- crates/soar-operations/src/list.rs | 6 +++--- crates/soar-operations/src/search.rs | 11 ++++++----- crates/soar-operations/src/utils.rs | 3 +-- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 5e779714..9ea592bd 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -43,10 +43,6 @@ use crate::{ SoarResult, }; -/// Returns `true` if the file at `path` starts with the ELF magic bytes. -/// -/// AppImages and plain binaries are ELF and need the executable bit; archives -/// are not and are extracted instead. /// Fetch the side files an artifact does not carry itself. /// /// One that published a hash is verified against it, on the same footing as @@ -284,6 +280,10 @@ fn mark_elfs_executable(dir: &Path) { } } +/// Returns `true` if the file at `path` starts with the ELF magic bytes. +/// +/// AppImages and plain binaries are ELF and need the executable bit; archives +/// are not and are extracted instead. fn is_elf(path: &Path) -> bool { let mut magic = [0u8; 4]; fs::File::open(path) diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 115f2f4b..cd4f5cdd 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -49,10 +49,6 @@ use crate::{ FailedInfo, InstallOptions, InstallReport, InstalledInfo, ResolveResult, SoarContext, }; -/// Resolve package queries into install targets or ambiguity results. -/// -/// For each query string, returns a [`ResolveResult`] indicating whether the package -/// was resolved, is ambiguous (multiple candidates), not found, or already installed. /// Build an install target for a package the caller has already chosen. /// /// Resolving it again by name would pose the same ambiguous question that the @@ -91,6 +87,11 @@ pub fn target_for( }) } +/// Resolve package queries into install targets or ambiguity results. +/// +/// For each query string, returns a [`ResolveResult`] indicating whether the +/// package was resolved, is ambiguous (multiple candidates), not found, or +/// already installed. pub async fn resolve_packages( ctx: &SoarContext, packages: &[String], diff --git a/crates/soar-operations/src/list.rs b/crates/soar-operations/src/list.rs index ce42bd9a..a38822ba 100644 --- a/crates/soar-operations/src/list.rs +++ b/crates/soar-operations/src/list.rs @@ -16,13 +16,12 @@ use crate::{ InstalledEntry, InstalledListResult, PackageListEntry, PackageListResult, SoarContext, }; -/// List all available packages, optionally filtered by repository. -/// Installed packages by repository and name, each with the family it was -/// installed under, so a package sharing a name is not mistaken for it. /// What identifies a package apart from its version: repository, name, id and /// family. Two entries sharing this are two versions of one package. type PackageKey = (String, String, Option, Option); +/// Installed packages by repository and name, each with the family it was +/// installed under, so a package sharing a name is not mistaken for it. type InstalledIndex = HashMap<(String, String), Vec<(Option, bool)>>; fn is_installed( @@ -38,6 +37,7 @@ fn is_installed( }) } +/// List all available packages, optionally filtered by repository. pub async fn list_packages( ctx: &SoarContext, repo_name: Option<&str>, diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index f68cf802..ac760886 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -19,15 +19,12 @@ use tracing::{debug, trace}; use crate::{SearchEntry, SearchResult, SoarContext}; -/// Search for packages across all repositories. -/// -/// Uses fuzzy matching by default. Falls back to SQL LIKE for case-sensitive searches. -/// Installed packages by repository and name, each with the family it was -/// installed under, so a package sharing a name is not mistaken for it. /// What identifies a package apart from its version: repository, name, id and /// family. Two entries sharing this are two versions of one package. type PackageKey = (String, String, Option, Option); +/// Installed packages by repository and name, each with the family it was +/// installed under, so a package sharing a name is not mistaken for it. type InstalledIndex = HashMap<(String, String), Vec<(Option, bool)>>; fn is_installed( @@ -43,6 +40,10 @@ fn is_installed( }) } +/// Search for packages across all repositories. +/// +/// Uses fuzzy matching by default. Falls back to SQL LIKE for case-sensitive +/// searches. pub async fn search_packages( ctx: &SoarContext, query: &str, diff --git a/crates/soar-operations/src/utils.rs b/crates/soar-operations/src/utils.rs index e7f7d681..d8497083 100644 --- a/crates/soar-operations/src/utils.rs +++ b/crates/soar-operations/src/utils.rs @@ -171,8 +171,6 @@ pub fn link_shared_files( Ok(linked) } -/// Creates symlinks from installed package binaries to the bin directory. -#[allow(clippy::too_many_arguments)] /// Every regular file under `dir`, recursively, skipping symlinks and the /// bookkeeping entries soar writes alongside a package. fn walk_files(dir: &Path) -> Vec { @@ -194,6 +192,7 @@ fn walk_files(dir: &Path) -> Vec { out } +/// Creates symlinks from installed package binaries to the bin directory. #[allow(clippy::too_many_arguments)] pub async fn mangle_package_symlinks( install_dir: &Path, From deceeda94903ad7eb1d3335f012bf79945f0c98a Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:41:39 +0545 Subject: [PATCH 49/66] fix(install): make the file layout survive a failed move A file listed twice is now copied rather than moved a second time, a failure part-way puts back what it took, an alias outside its target's directory gets a path that resolves, and the install marker is kept. --- crates/soar-core/src/package/install.rs | 165 +++++++++++++++++++----- 1 file changed, 130 insertions(+), 35 deletions(-) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 9ea592bd..238e47ad 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, env, fs, io::{Read, Write}, os::unix::fs::PermissionsExt, @@ -109,42 +110,21 @@ pub fn apply_file_layout( // one needs write permission on the directory itself. crate::package::remove::make_tree_writable(install_dir); - let present = walk_dir_files(install_dir, &staging); - let mut placed = 0usize; - for file in files { - if !is_safe_relative(&file.to) { - warn!(to = file.to, "skipping file with an unsafe target"); - continue; - } - let Some(source) = resolve_source(&file.source, install_dir, &present, artifact) else { - warn!( - source = file.source, - to = file.to, - "file not found in the artifact" - ); - continue; - }; - let dest = staging.join(&file.to); - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; - } - fs::rename(&source, &dest).with_context(|| format!("placing {}", file.to))?; - placed += 1; - - let name = dest.file_name().unwrap_or_default().to_os_string(); - for alias in &file.alias { - if !is_safe_relative(alias) { - warn!(alias, "skipping alias with an unsafe target"); - continue; - } - let link = staging.join(alias); - if let Some(parent) = link.parent() { - fs::create_dir_all(parent).ok(); + // What each file was moved from, so a failure part-way can put it back. + let mut moved: Vec<(PathBuf, PathBuf)> = Vec::new(); + let placed = match place_files(files, install_dir, artifact, &staging, &mut moved) { + Ok(placed) => placed, + Err(err) => { + // Leave the package as it was found rather than half emptied: the + // caller may retry, and the sources are only in the staging + // directory this call is about to remove. + for (source, dest) in moved { + fs::rename(&dest, &source).ok(); } - // relative, so the package directory stays movable - std::os::unix::fs::symlink(&name, &link).ok(); + fs::remove_dir_all(&staging).ok(); + return Err(err); } - } + }; // Nothing resolved means the recipe and the artifact disagree, which most // often means the download never extracted. Keeping what is there would @@ -164,7 +144,9 @@ pub fn apply_file_layout( .flatten() { let path = entry.path(); - if path == staging { + // The install marker is soar's own bookkeeping, not package content: + // removing it here loses the record an interrupted install resumes from. + if path == staging || entry.file_name() == INSTALL_MARKER_FILE { continue; } if path.is_dir() && !path.is_symlink() { @@ -185,6 +167,91 @@ pub fn apply_file_layout( Ok(()) } +/// Move each listed file into the staging directory and record where it came +/// from. Returns how many were placed. +fn place_files( + files: &[PackageFile], + install_dir: &Path, + artifact: &Path, + staging: &Path, + moved: &mut Vec<(PathBuf, PathBuf)>, +) -> SoarResult { + let present = walk_dir_files(install_dir, staging); + // Where a source ended up, for the second entry that names the same file. + let mut taken: HashMap = HashMap::new(); + let mut placed = 0usize; + for file in files { + if !is_safe_relative(&file.to) { + warn!(to = file.to, "skipping file with an unsafe target"); + continue; + } + let Some(source) = resolve_source(&file.source, install_dir, &present, artifact) else { + warn!( + source = file.source, + to = file.to, + "file not found in the artifact" + ); + continue; + }; + let dest = staging.join(&file.to); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + // A file listed twice under different names was consumed by the first + // move, so the second is a copy of where it landed. + match taken.get(&source) { + Some(already) => { + fs::copy(already, &dest).with_context(|| format!("copying to {}", file.to))?; + } + None => { + fs::rename(&source, &dest).with_context(|| format!("placing {}", file.to))?; + moved.push((source.clone(), dest.clone())); + taken.insert(source, dest.clone()); + } + } + placed += 1; + + for alias in &file.alias { + if !is_safe_relative(alias) { + warn!(alias, "skipping alias with an unsafe target"); + continue; + } + let link = staging.join(alias); + if let Some(parent) = link.parent() { + fs::create_dir_all(parent).ok(); + } + // relative, so the package directory stays movable + if let Some(target) = relative_to(alias, &file.to) { + std::os::unix::fs::symlink(&target, &link).ok(); + } + } + } + Ok(placed) +} + +/// The path `target` has when read from the directory holding `link`. +/// +/// Nearly every alias is a sibling of what it points at, which is just the +/// file's own name, but nothing in the format requires that. +fn relative_to(link: &str, target: &str) -> Option { + let link_dir: Vec<&str> = link.split('/').collect(); + let link_dir = &link_dir[..link_dir.len().saturating_sub(1)]; + let target_parts: Vec<&str> = target.split('/').collect(); + let shared = link_dir + .iter() + .zip(&target_parts) + .take_while(|(a, b)| a == b) + .count(); + let mut out = PathBuf::new(); + for _ in shared..link_dir.len() { + out.push(".."); + } + for part in &target_parts[shared..] { + out.push(part); + } + (!out.as_os_str().is_empty()).then_some(out) +} + /// Whether a path stays inside the directory it is joined to. fn is_safe_relative(path: &str) -> bool { !path.is_empty() @@ -1331,3 +1398,31 @@ impl PackageInstaller { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::relative_to; + + #[test] + fn alias_beside_its_target_is_just_the_name() { + assert_eq!( + relative_to("bin/fdfind", "bin/fd").unwrap(), + Path::new("fd") + ); + } + + #[test] + fn alias_in_another_directory_climbs_out() { + assert_eq!( + relative_to("share/man/man1/fdfind.1", "share/man/man1/fd.1").unwrap(), + Path::new("fd.1") + ); + assert_eq!( + relative_to("bin/fd", "libexec/fd").unwrap(), + Path::new("../libexec/fd") + ); + assert_eq!(relative_to("fd", "bin/fd").unwrap(), Path::new("bin/fd")); + } +} From 78f0e1b68e1523de420fb38d4e0a1ae55b955101 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:41:39 +0545 Subject: [PATCH 50/66] fix(install): compare the family when matching an install Two identity checks in one function disagreed, and the post-lock recheck could not narrow by family at all. --- crates/soar-operations/src/install.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index cd4f5cdd..a8cc4efc 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -614,7 +614,11 @@ fn resolve_normal( } let existing_install = installed_packages .iter() - .find(|ip| ip.version == newest.version) + .find(|ip| { + ip.version == newest.version + && ip.repo_name == newest.repo_name + && ip.pkg_family.as_deref() == newest.pkg_family.as_deref() + }) .cloned(); let newest = newest.resolve(query.version.as_deref()); return Ok(ResolveResult::Resolved(vec![InstallTarget { @@ -922,7 +926,9 @@ async fn install_single_package( ) })? .into_iter() - .find(|ip| ip.is_installed); + // The query cannot narrow by family, so it is compared here: two + // packages sharing a name in one batch are still two packages. + .find(|ip| ip.is_installed && ip.pkg_family.as_deref() == pkg.pkg_family.as_deref()); if freshly_installed.is_some() { return Ok((PathBuf::new(), Vec::new(), Vec::new())); From 61b420a3b93da8a2be976699059bad6171c4fb7b Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:41:39 +0545 Subject: [PATCH 51/66] fix(run): key the cache by repository, verify what it returns The cache key left out the repository its comment claimed to cover, and a laid-out binary that is the artifact itself is now checked against the published checksum before it is executed. --- crates/soar-operations/src/run.rs | 48 +++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/soar-operations/src/run.rs b/crates/soar-operations/src/run.rs index 1919a7eb..726b3c78 100644 --- a/crates/soar-operations/src/run.rs +++ b/crates/soar-operations/src/run.rs @@ -129,8 +129,10 @@ pub async fn prepare_run( let package = packages.into_iter().next().unwrap().resolve(version); - // Named like an install: content-addressed, so a different version or a - // different repository never reuses another's cached binary. + // Named like an install. A package that published a checksum is keyed by + // it, so identical content is shared and different content never is; + // without one the key is the identity the package was resolved from, + // repository included. let suffix = package .bsum .as_deref() @@ -143,8 +145,12 @@ pub async fn prepare_run( .or(package.ghcr_pkg.as_deref()) .unwrap_or(package.download_url.as_str()); hash_string(&format!( - "{}:{}:{}", - package.pkg_name, package.version, source + "{}:{}:{}:{}:{}", + package.repo_name, + package.pkg_family.as_deref().unwrap_or_default(), + package.pkg_name, + package.version, + source ))[..12] .to_string() }); @@ -164,20 +170,38 @@ pub async fn prepare_run( } // A laid-out package keeps its binary, not the artifact it came from, so a - // cache hit is that binary. The directory is content-addressed, which is - // what makes finding it there proof enough. + // cache hit is that binary. Where the binary is the artifact itself the + // published checksum still describes it, and a cache entry is only reused + // once it matches; a binary taken out of an archive has no checksum of its + // own, and the content-addressed directory is what stands in for one. let laid_out = package.files.as_deref().and_then(|files| { files.iter().find_map(|f| { f.to.strip_prefix("bin/") .filter(|rest| !rest.contains('/')) - .map(|_| cache_dir.join(&f.to)) + .map(|_| (cache_dir.join(&f.to), f.source.is_empty())) }) }); - if let Some(binary) = laid_out.as_ref().filter(|p| p.exists()) { - return Ok(PrepareRunResult::Ready { - path: binary.clone(), - downloaded: false, - }); + if let Some((binary, is_artifact)) = laid_out.as_ref().filter(|(p, _)| p.exists()) { + let verified = match package.bsum { + Some(ref bsum) if *is_artifact && !no_verify => { + let matches = calculate_checksum(binary)? == *bsum; + if !matches { + debug!( + package = %package.pkg_name, + "cached binary checksum mismatch; re-downloading" + ); + fs::remove_dir_all(&cache_dir).ok(); + } + matches + } + _ => true, + }; + if verified { + return Ok(PrepareRunResult::Ready { + path: binary.clone(), + downloaded: false, + }); + } } // Reuse a cached binary only after re-verifying it against the expected From 1f67a0ca1b57c7c32cfd6a9600ad6eb88eacd314 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:41:39 +0545 Subject: [PATCH 52/66] fix(registry): name the field a malformed index failed on An untagged enum reports only that no variant matched, so the index shape is now told apart by its opening character. --- crates/soar-registry/src/metadata.rs | 83 +++++++++++++++++----------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/crates/soar-registry/src/metadata.rs b/crates/soar-registry/src/metadata.rs index 97700702..a971415d 100644 --- a/crates/soar-registry/src/metadata.rs +++ b/crates/soar-registry/src/metadata.rs @@ -5,7 +5,7 @@ use std::{ fs::{self, File}, - io::{self, BufReader, BufWriter, Write}, + io::{self, BufRead, BufReader, BufWriter, Write}, path::{Path, PathBuf}, time::UNIX_EPOCH, }; @@ -436,8 +436,7 @@ pub fn process_metadata_content( let tmp_file = File::open(&tmp_path) .with_context(|| format!("opening temporary file {tmp_path}"))?; let reader = BufReader::new(tmp_file); - let metadata = - serde_json::from_reader::<_, MetadataDocument>(reader)?.into_packages()?; + let metadata = parse_index_reader(reader)?; fs::remove_file(&tmp_path) .with_context(|| format!("removing temporary file {tmp_path}"))?; Ok(MetadataContent::Json(metadata)) @@ -445,7 +444,7 @@ pub fn process_metadata_content( } else if content[..4] == SQLITE_MAGIC_BYTES { Ok(MetadataContent::SqliteDb(content)) } else { - let metadata = serde_json::from_slice::(&content)?.into_packages()?; + let metadata = parse_index(&content)?; Ok(MetadataContent::Json(metadata)) } } @@ -453,44 +452,62 @@ pub fn process_metadata_content( /// The highest index format this build understands. pub const SUPPORTED_FORMAT: u32 = 1; -/// A metadata index, in either shape it may arrive in. +/// The versioned shape of a metadata index. /// -/// The original form is a bare array of packages. The versioned form wraps it -/// so a client can tell an index it cannot read from one that merely lacks a -/// field, and say so instead of failing on whichever field it noticed first. +/// The original shape is a bare array of packages; this one wraps it so a +/// client can tell an index it cannot read from one that merely lacks a field. #[derive(Deserialize)] -#[serde(untagged)] -enum MetadataDocument { - Versioned { - format: u32, - packages: Vec, - }, - Legacy(Vec), +struct VersionedIndex { + format: u32, + packages: Vec, +} + +impl VersionedIndex { + /// Unwrap to the packages, refusing an index newer than this build. + fn into_packages(self) -> Result> { + if self.format > SUPPORTED_FORMAT { + return Err(RegistryError::UnsupportedFormat { + found: self.format, + supported: SUPPORTED_FORMAT, + }); + } + Ok(self.packages) + } +} + +/// Whether an index is the versioned shape, judged by its opening character. +/// +/// The two are told apart here rather than by an untagged enum, which reports +/// only that no variant matched and so turns one malformed field anywhere in +/// the index into a message that names nothing. +fn is_versioned(bytes: &[u8]) -> bool { + bytes + .iter() + .find(|b| !b.is_ascii_whitespace()) + .is_some_and(|b| *b == b'{') } /// Parse an index in either shape, refusing one newer than this build. pub fn parse_index(bytes: &[u8]) -> Result> { - serde_json::from_slice::(bytes)?.into_packages() + if is_versioned(bytes) { + serde_json::from_slice::(bytes)?.into_packages() + } else { + Ok(serde_json::from_slice(bytes)?) + } } -impl MetadataDocument { - /// Unwrap to the packages, refusing an index newer than this build. - fn into_packages(self) -> Result> { - match self { - MetadataDocument::Legacy(packages) => Ok(packages), - MetadataDocument::Versioned { - format, - packages, - } => { - if format > SUPPORTED_FORMAT { - return Err(RegistryError::UnsupportedFormat { - found: format, - supported: SUPPORTED_FORMAT, - }); - } - Ok(packages) - } +/// Parse an index that is still on disk, without holding it twice in memory. +fn parse_index_reader(mut reader: impl BufRead) -> Result> { + let versioned = is_versioned(reader.fill_buf().map_err(|e| { + RegistryError::IoError { + action: "reading metadata".to_string(), + source: e, } + })?); + if versioned { + serde_json::from_reader::<_, VersionedIndex>(reader)?.into_packages() + } else { + Ok(serde_json::from_reader(reader)?) } } From 56aebb87da6d06eb737f37a5254e5ed8a94f8dbb Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:46:38 +0545 Subject: [PATCH 53/66] fix(link): walk once, and refuse a second source for one command The tree was re-walked for every binary mapping, and when several files answered to one name each overwrote the last silently. --- crates/soar-operations/src/utils.rs | 46 +++++++++++++++++++---------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/crates/soar-operations/src/utils.rs b/crates/soar-operations/src/utils.rs index d8497083..32c00da0 100644 --- a/crates/soar-operations/src/utils.rs +++ b/crates/soar-operations/src/utils.rs @@ -263,6 +263,25 @@ pub async fn mangle_package_symlinks( if let Some(bins) = binaries { if !bins.is_empty() { + // Walked once: the tree does not change while the mappings are + // resolved against it. + let present = walk_files(install_dir); + let rel_of = |path: &PathBuf| { + path.strip_prefix(install_dir) + .unwrap_or(path) + .to_string_lossy() + .to_string() + }; + let matching = |pat: &str| -> Vec { + present + .iter() + .filter(|p| fast_glob::glob_match(pat, rel_of(p))) + .cloned() + .collect() + }; + // One name cannot stand for two files, so the first mapping to + // claim it keeps it. + let mut claimed: HashSet = HashSet::new(); for mapping in bins { let source_pattern = substitute_placeholders(&mapping.source, Some(version), arch_map); @@ -270,20 +289,6 @@ pub async fn mangle_package_symlinks( // straight back to the file name would pick an arbitrary one // when an archive ships the same binary for several // architectures, each under its own directory. - let files = walk_files(install_dir); - let rel_of = |path: &PathBuf| { - path.strip_prefix(install_dir) - .unwrap_or(path) - .to_string_lossy() - .to_string() - }; - let matching = |pat: &str| -> Vec { - files - .iter() - .filter(|p| fast_glob::glob_match(pat, rel_of(p))) - .cloned() - .collect() - }; let mut source_paths = matching(&source_pattern); // An archive with a single top-level directory has it promoted @@ -295,9 +300,10 @@ pub async fn mangle_package_symlinks( } } // Last resort: the artifact was rearranged and only the name - // survives. Ambiguity here is handled below. + // survives. Several files can answer to it, so `link_as` is + // dropped below and each keeps its own name. if source_paths.is_empty() { - source_paths = files + source_paths = present .iter() .filter(|p| { p.file_name() @@ -334,6 +340,14 @@ pub async fn mangle_package_symlinks( .unwrap_or(&mapping.source) }); let link_path = bin_dir.join(link_name); + if !claimed.insert(link_path.clone()) { + warn!( + link = %link_path.display(), + source = %source_path.display(), + "skipping a second source for the same command" + ); + continue; + } set_executable(&source_path)?; From 84b4c72c91a1eb387326d6861a4e153399fc08cd Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:46:46 +0545 Subject: [PATCH 54/66] refactor: keep one list of the shared link destinations Linking and unlinking each carried their own copy, so a directory added to one would have been missed by the other. --- crates/soar-core/src/package/remove.rs | 17 ++++++------- crates/soar-core/src/utils.rs | 35 ++++++++++++++++++++++++++ crates/soar-operations/src/utils.rs | 35 +------------------------- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/crates/soar-core/src/package/remove.rs b/crates/soar-core/src/package/remove.rs index a9c7681a..2bc60aa8 100644 --- a/crates/soar-core/src/package/remove.rs +++ b/crates/soar-core/src/package/remove.rs @@ -43,16 +43,15 @@ fn remove_links_into(dir: &Path, installed_path: &Path, removed: &mut Vec Vec { - let prefix = bin_path.parent().unwrap_or(bin_path); - let data = soar_utils::path::xdg_data_home(); - let config = soar_utils::path::xdg_config_home(); - vec![ - prefix.join("share/man"), - data.join("bash-completion/completions"), - data.join("zsh/site-functions"), - config.join("fish/completions"), - ] + crate::utils::shared_link_targets(bin_path, &[]) + .into_iter() + .map(|(_, destination, _)| destination) + .collect() } /// Removes the bin-directory symlinks a package's `provides` created, keeping diff --git a/crates/soar-core/src/utils.rs b/crates/soar-core/src/utils.rs index 9ff5d04b..237543d8 100644 --- a/crates/soar-core/src/utils.rs +++ b/crates/soar-core/src/utils.rs @@ -119,3 +119,38 @@ pub fn substitute_placeholders( None => result, } } + +/// Where a package's shared files are exposed on the system. +/// +/// Each entry is the directory inside the package, the destination on the +/// system, and whether the user asked for it. Man pages go beside the bin +/// directory, because man-db derives its search path from PATH: for every +/// `.../bin` it also looks at `.../share/man`. That makes them findable with +/// no MANPATH set. +pub fn shared_link_targets( + bin_dir: &Path, + shells: &[String], +) -> Vec<(&'static str, PathBuf, bool)> { + let prefix = bin_dir.parent().unwrap_or(bin_dir); + let data = soar_utils::path::xdg_data_home(); + let config = soar_utils::path::xdg_config_home(); + let wants = |name: &str| shells.iter().any(|s| s == name); + vec![ + ("share/man", prefix.join("share/man"), true), + ( + "share/bash-completion/completions", + data.join("bash-completion/completions"), + wants("bash"), + ), + ( + "share/zsh/site-functions", + data.join("zsh/site-functions"), + wants("zsh"), + ), + ( + "share/fish/vendor_completions.d", + config.join("fish/completions"), + wants("fish"), + ), + ] +} diff --git a/crates/soar-operations/src/utils.rs b/crates/soar-operations/src/utils.rs index 32c00da0..2fe5bf80 100644 --- a/crates/soar-operations/src/utils.rs +++ b/crates/soar-operations/src/utils.rs @@ -12,7 +12,7 @@ use soar_config::{ use soar_core::{ database::models::Package, error::{ErrorContext, SoarError}, - utils::substitute_placeholders, + utils::{shared_link_targets, substitute_placeholders}, SoarResult, }; use soar_db::models::types::{PackageFile, PackageProvide}; @@ -87,39 +87,6 @@ fn create_provide_symlinks( Ok(symlinks) } -/// Where a package's shared files are exposed on the system. -/// -/// Man pages go beside the bin directory, because man-db derives its search -/// path from PATH: for every `.../bin` it also looks at `.../share/man`. That -/// makes them findable with no MANPATH set. -pub fn shared_link_targets( - bin_dir: &Path, - shells: &[String], -) -> Vec<(&'static str, PathBuf, bool)> { - let prefix = bin_dir.parent().unwrap_or(bin_dir); - let data = soar_utils::path::xdg_data_home(); - let config = soar_utils::path::xdg_config_home(); - let wants = |name: &str| shells.iter().any(|s| s == name); - vec![ - ("share/man", prefix.join("share/man"), true), - ( - "share/bash-completion/completions", - data.join("bash-completion/completions"), - wants("bash"), - ), - ( - "share/zsh/site-functions", - data.join("zsh/site-functions"), - wants("zsh"), - ), - ( - "share/fish/vendor_completions.d", - config.join("fish/completions"), - wants("fish"), - ), - ] -} - /// Link a package's man pages and completions where the system looks for them. /// /// A destination already holding something soar did not put there is left From e686db1e6e1b145bec82f2c5bd3d28f5011a159c Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:46:46 +0545 Subject: [PATCH 55/66] refactor(db): drop the checksum-keyed unlink and link twins Both had stopped using the checksum they were named for, and one was byte-for-byte what unlink_others already did. --- crates/soar-core/src/package/install.rs | 2 +- crates/soar-db/src/repository/core.rs | 79 ++----------------------- crates/soar-operations/src/switch.rs | 5 +- 3 files changed, 8 insertions(+), 78 deletions(-) diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 238e47ad..b90a7b5a 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -1354,7 +1354,7 @@ impl PackageInstaller { &self.package.repo_name, pkg_id, self.package.pkg_family.as_deref(), - version, + Some(version), ) })?; diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index 290c1969..fbbbc0a5 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -493,18 +493,21 @@ impl CoreRepository { keep_repo_name: &str, keep_pkg_id: Option<&str>, keep_pkg_family: Option<&str>, - keep_version: &str, + keep_version: Option<&str>, ) -> QueryResult { // The row to keep is identified in full. A SQL predicate cannot do it: // `pkg_id IS NULL` matches every id-less row, which now includes the // package being installed, so it would unlink itself. + // + // Switching between versions of one package passes no version, since + // there every other version is exactly what has to be unlinked. let stale = Self::rows_other_than( conn, pkg_name, keep_repo_name, keep_pkg_id, keep_pkg_family, - Some(keep_version), + keep_version, )?; if stale.is_empty() { return Ok(0); @@ -771,52 +774,6 @@ impl CoreRepository { diesel::delete(query).execute(conn) } - /// Unlinks all packages with a given name except those matching pkg_id and checksum. - /// Used when switching between alternate package versions. - pub fn unlink_others_by_checksum( - conn: &mut SqliteConnection, - pkg_name: &str, - keep_repo_name: &str, - keep_pkg_id: Option<&str>, - keep_pkg_family: Option<&str>, - keep_checksum: Option<&str>, - ) -> QueryResult { - if let Some(checksum) = keep_checksum { - let others = Self::rows_other_than( - conn, - pkg_name, - keep_repo_name, - keep_pkg_id, - keep_pkg_family, - None, - )?; - if others.is_empty() { - return Ok(0); - } - let _ = checksum; - diesel::update(packages::table.filter(packages::id.eq_any(others))) - .set(packages::unlinked.eq(true)) - .execute(conn) - } else { - let others = Self::rows_other_than( - conn, - pkg_name, - keep_repo_name, - keep_pkg_id, - keep_pkg_family, - None, - )?; - if others.is_empty() { - return Ok(0); - } - diesel::update(packages::table.filter(packages::id.eq_any(others))) - .set(packages::unlinked.eq(true)) - .execute(conn) - } - } - - /// Links a package by pkg_name, pkg_id, and checksum. - /// Used when switching to an alternate package version. /// Mark one installed row as the linked one, by its own id. /// /// Matching on a checksum cannot do this: two repositories shipping the @@ -826,30 +783,4 @@ impl CoreRepository { .set(packages::unlinked.eq(false)) .execute(conn) } - - pub fn link_by_checksum( - conn: &mut SqliteConnection, - pkg_name: &str, - pkg_id: Option<&str>, - checksum: Option<&str>, - ) -> QueryResult { - if let Some(checksum) = checksum { - diesel::update( - packages::table - .filter(packages::pkg_name.eq(pkg_name)) - .filter(match_pkg_id(pkg_id)) - .filter(packages::checksum.eq(checksum)), - ) - .set(packages::unlinked.eq(false)) - .execute(conn) - } else { - diesel::update( - packages::table - .filter(packages::pkg_name.eq(pkg_name)) - .filter(match_pkg_id(pkg_id)), - ) - .set(packages::unlinked.eq(false)) - .execute(conn) - } - } } diff --git a/crates/soar-operations/src/switch.rs b/crates/soar-operations/src/switch.rs index 4af38987..ae5eede5 100644 --- a/crates/soar-operations/src/switch.rs +++ b/crates/soar-operations/src/switch.rs @@ -83,18 +83,17 @@ pub async fn switch_variant( let pkg_name = &selected_package.pkg_name; let pkg_id = selected_package.pkg_id.as_deref(); - let checksum = selected_package.checksum.as_deref(); // Atomically unlink other variants and link the selected one so the DB // is never left in a state where all variants are unlinked. diesel_db.transaction(|conn| { - CoreRepository::unlink_others_by_checksum( + CoreRepository::unlink_others( conn, pkg_name, &selected_package.repo_name, pkg_id, selected_package.pkg_family.as_deref(), - checksum, + None, )?; CoreRepository::link_by_row_id(conn, selected_package.id) })?; From b7e1d2cde154fd0a697ca6a093219334378dcdde Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 14:46:46 +0545 Subject: [PATCH 56/66] refactor: share the newest-version helpers with search list and search carried the same key, index and predicate verbatim. --- crates/soar-operations/src/list.rs | 22 +--------------------- crates/soar-operations/src/search.rs | 26 ++++---------------------- crates/soar-operations/src/utils.rs | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 43 deletions(-) diff --git a/crates/soar-operations/src/list.rs b/crates/soar-operations/src/list.rs index a38822ba..fe46cbeb 100644 --- a/crates/soar-operations/src/list.rs +++ b/crates/soar-operations/src/list.rs @@ -13,30 +13,10 @@ use soar_utils::{fs::dir_size, version::compare_versions}; use tracing::{debug, trace}; use crate::{ + utils::{is_installed, InstalledIndex, PackageKey}, InstalledEntry, InstalledListResult, PackageListEntry, PackageListResult, SoarContext, }; -/// What identifies a package apart from its version: repository, name, id and -/// family. Two entries sharing this are two versions of one package. -type PackageKey = (String, String, Option, Option); - -/// Installed packages by repository and name, each with the family it was -/// installed under, so a package sharing a name is not mistaken for it. -type InstalledIndex = HashMap<(String, String), Vec<(Option, bool)>>; - -fn is_installed( - map: &InstalledIndex, - repo_name: &str, - pkg_name: &str, - pkg_family: Option<&str>, -) -> bool { - map.get(&(repo_name.to_string(), pkg_name.to_string())) - .is_some_and(|rows| { - rows.iter() - .any(|(family, installed)| *installed && family.as_deref() == pkg_family) - }) -} - /// List all available packages, optionally filtered by repository. pub async fn list_packages( ctx: &SoarContext, diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index ac760886..643d1e2b 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -17,28 +17,10 @@ use soar_db::{ use soar_utils::version::compare_versions; use tracing::{debug, trace}; -use crate::{SearchEntry, SearchResult, SoarContext}; - -/// What identifies a package apart from its version: repository, name, id and -/// family. Two entries sharing this are two versions of one package. -type PackageKey = (String, String, Option, Option); - -/// Installed packages by repository and name, each with the family it was -/// installed under, so a package sharing a name is not mistaken for it. -type InstalledIndex = HashMap<(String, String), Vec<(Option, bool)>>; - -fn is_installed( - map: &InstalledIndex, - repo_name: &str, - pkg_name: &str, - pkg_family: Option<&str>, -) -> bool { - map.get(&(repo_name.to_string(), pkg_name.to_string())) - .is_some_and(|rows| { - rows.iter() - .any(|(family, installed)| *installed && family.as_deref() == pkg_family) - }) -} +use crate::{ + utils::{is_installed, InstalledIndex, PackageKey}, + SearchEntry, SearchResult, SoarContext, +}; /// Search for packages across all repositories. /// diff --git a/crates/soar-operations/src/utils.rs b/crates/soar-operations/src/utils.rs index 2fe5bf80..9d131536 100644 --- a/crates/soar-operations/src/utils.rs +++ b/crates/soar-operations/src/utils.rs @@ -506,6 +506,27 @@ fn find_matching_executable( .cloned() } +/// What identifies a package apart from its version: repository, name, id and +/// family. Two entries sharing this are two versions of one package. +pub type PackageKey = (String, String, Option, Option); + +/// Installed packages by repository and name, each with the family it was +/// installed under, so a package sharing a name is not mistaken for it. +pub type InstalledIndex = HashMap<(String, String), Vec<(Option, bool)>>; + +pub fn is_installed( + map: &InstalledIndex, + repo_name: &str, + pkg_name: &str, + pkg_family: Option<&str>, +) -> bool { + map.get(&(repo_name.to_string(), pkg_name.to_string())) + .is_some_and(|rows| { + rows.iter() + .any(|(family, installed)| *installed && family.as_deref() == pkg_family) + }) +} + #[cfg(test)] mod tests { use std::{ From 099a32520af64272adcdb096264ca061efcb5253 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 16:25:12 +0545 Subject: [PATCH 57/66] fix(db): count and match packages that carry no id A NULL id made COUNT(DISTINCT) skip the row entirely, a raw projection could not load one, and an empty id from an index behaved as a real one. --- crates/soar-db/src/repository/core.rs | 20 ++++++++++++++++++-- crates/soar-db/src/repository/metadata.rs | 4 +++- crates/soar-registry/src/package.rs | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index fbbbc0a5..2f90bb1d 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -209,6 +209,7 @@ impl CoreRepository { repo_name: &str, pkg_name: &str, pkg_id: Option<&str>, + pkg_family: Option<&str>, version: &str, ) -> QueryResult> { // The boxed predicate is typed for the bare table, so the join builds @@ -223,6 +224,10 @@ impl CoreRepository { Some(id) => query.filter(packages::pkg_id.eq(id.to_string())), None => query.filter(packages::pkg_id.is_null()), }; + query = match pkg_family { + Some(family) => query.filter(packages::pkg_family.eq(family.to_string())), + None => query.filter(packages::pkg_family.is_null()), + }; let result: Option<(Package, Option)> = query .select((Package::as_select(), Option::::as_select())) .first(conn) @@ -286,8 +291,12 @@ impl CoreRepository { } query + // NULLs are collapsed first: concatenating one yields NULL, and + // COUNT(DISTINCT) skips it, so every package without an id went + // uncounted. .select(sql::( - "COUNT(DISTINCT pkg_id || '\x00' || pkg_name)", + "COUNT(DISTINCT COALESCE(pkg_id, '') || '\x00' \ + || COALESCE(pkg_family, '') || '\x00' || pkg_name)", )) .first(conn) } @@ -347,12 +356,19 @@ impl CoreRepository { conn: &mut SqliteConnection, pkg_name: &str, exclude_pkg_id: Option<&str>, + pkg_family: Option<&str>, exclude_version: &str, ) -> QueryResult> { - let query = packages::table + let mut query = packages::table .left_join(portable_package::table) .filter(packages::pkg_name.eq(pkg_name)) .into_boxed(); + // Same family only, matching how old versions are found: another + // family sharing the name is a different package, not an alternate. + query = match pkg_family { + Some(family) => query.filter(packages::pkg_family.eq(family.to_string())), + None => query.filter(packages::pkg_family.is_null()), + }; // An id-less row differs from one that carries an id, and SQL answers // `pkg_id != ?` with NULL rather than true in both directions, so diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index edf72409..616e7691 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -306,7 +306,9 @@ impl MetadataRepository { conn: &mut SqliteConnection, pkg_id: &str, ) -> QueryResult> { - let query = "SELECT pkg_id FROM packages WHERE EXISTS \ + // Only rows that have an id: the projection cannot hold a NULL, and a + // package without an id has nothing to answer with anyway. + let query = "SELECT pkg_id FROM packages WHERE pkg_id IS NOT NULL AND EXISTS \ (SELECT 1 FROM json_each(replaces) WHERE json_each.value = ?) LIMIT 1"; diesel::sql_query(query) diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index 01ba3e7c..1a41bc95 100644 --- a/crates/soar-registry/src/package.rs +++ b/crates/soar-registry/src/package.rs @@ -133,7 +133,7 @@ pub struct RemotePackage { /// Optional. It exists to disambiguate identically-named packages within /// one repository; where names are already unique a repository can omit /// it, and the name is used instead. - #[serde(default)] + #[serde(default, deserialize_with = "empty_is_none")] pub pkg_id: Option, pub pkg_name: String, From 5430306ac074a51bd9e01284f5931ed8436d072b Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 16:25:12 +0545 Subject: [PATCH 58/66] fix(query): anchor the parser so a third segment is rejected Unanchored, a/b/c matched from the middle and silently dropped a. --- crates/soar-core/src/package/query.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/soar-core/src/package/query.rs b/crates/soar-core/src/package/query.rs index 17d715c2..da4dcfac 100644 --- a/crates/soar-core/src/package/query.rs +++ b/crates/soar-core/src/package/query.rs @@ -27,6 +27,7 @@ impl TryFrom<&str> for PackageQuery { let re = PACKAGE_RE.get_or_init(|| { Regex::new( r"(?x) + ^ # anchored: a/b/c is not a query (?:(?P[^\/\#\@:]+)\/)? # optional family before / (?P[^\/\#\@:]+)? # optional package name (?:\#(?P[^@:]+))? # deprecated pkg_id after # @@ -77,3 +78,27 @@ impl TryFrom<&str> for PackageQuery { }) } } + +#[cfg(test)] +mod tests { + use super::PackageQuery; + + #[test] + fn parses_every_supported_shape() { + let q = PackageQuery::try_from("ripgrep").unwrap(); + assert_eq!(q.name.as_deref(), Some("ripgrep")); + assert_eq!(q.family, None); + + let q = PackageQuery::try_from("bat/bat@0.24.0:bincache").unwrap(); + assert_eq!(q.family.as_deref(), Some("bat")); + assert_eq!(q.name.as_deref(), Some("bat")); + assert_eq!(q.version.as_deref(), Some("0.24.0")); + assert_eq!(q.repo_name.as_deref(), Some("bincache")); + } + + #[test] + fn a_third_segment_is_not_a_query() { + // Unanchored, this matched from the middle and silently dropped `a`. + assert!(PackageQuery::try_from("a/b/c").is_err()); + } +} From 1d2893fa629cf8e78bf5c62d851bc057320859de Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 16:25:12 +0545 Subject: [PATCH 59/66] fix: narrow every installed-package lookup by family find_exact and find_alternates take the family, and the lookups that cannot filter on it compare it themselves, preferring the installed row over whichever came first. --- crates/soar-cli/src/inspect.rs | 1 + crates/soar-cli/src/install.rs | 7 +++++-- crates/soar-core/src/package/install.rs | 8 +++++++- crates/soar-operations/src/apply.rs | 5 ++++- crates/soar-operations/src/install.rs | 13 ++++++++++--- crates/soar-operations/src/switch.rs | 2 +- crates/soar-operations/src/update.rs | 1 + 7 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/soar-cli/src/inspect.rs b/crates/soar-cli/src/inspect.rs index 03b75476..d48d92c1 100644 --- a/crates/soar-cli/src/inspect.rs +++ b/crates/soar-cli/src/inspect.rs @@ -45,6 +45,7 @@ fn get_installed_path( &package.repo_name, &package.pkg_name, package.pkg_id.as_deref(), + package.pkg_family.as_deref(), &package.version, ) })?; diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index 161d99d6..88ca50d4 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -334,8 +334,11 @@ async fn install_with_show( ) })? .into_iter() - .map(Into::into) - .next(); + // The query cannot narrow by family, and an uninstalled row of the + // same name would otherwise stand in for the installed one. + .filter(|ip| ip.pkg_family.as_deref() == pkg.pkg_family.as_deref()) + .find(|ip| ip.is_installed) + .map(Into::into); if let Some(ref existing) = existing_install { if existing.is_installed { diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index b90a7b5a..5b7aaac3 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -1360,7 +1360,13 @@ impl PackageInstaller { let alternate_packages: Vec = self.db.with_conn(|conn| { - CoreRepository::find_alternates(conn, pkg_name, pkg_id, version) + CoreRepository::find_alternates( + conn, + pkg_name, + pkg_id, + self.package.pkg_family.as_deref(), + version, + ) })?; for alt_pkg in alternate_packages { diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index ff2f0488..a4c2199e 100644 --- a/crates/soar-operations/src/apply.rs +++ b/crates/soar-operations/src/apply.rs @@ -132,7 +132,10 @@ pub async fn compute_diff( .map(Into::into) .collect(); - let existing_install = installed_packages.into_iter().find(|ip| ip.is_installed); + // The query cannot narrow by family, so it is compared here. + let existing_install = installed_packages.into_iter().find(|ip| { + ip.is_installed && ip.pkg_family.as_deref() == metadata_pkg.pkg_family.as_deref() + }); if let Some(ref existing) = existing_install { let version_matches = pkg.version.as_ref().is_none_or(|v| existing.version == *v); diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index a8cc4efc..8ae06a04 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -69,12 +69,15 @@ pub fn target_for( None, None, None, - Some(1), + None, None, ) })? .into_iter() - .next() + // The query cannot narrow by family, and an uninstalled row of the + // same name would otherwise stand in for the installed one. + .filter(|ip| ip.pkg_family.as_deref() == package.pkg_family.as_deref()) + .find(|ip| ip.is_installed) .map(Into::into); let pinned = options.version_override.is_some(); let package = package.resolve(options.version_override.as_deref()); @@ -1195,7 +1198,11 @@ async fn install_single_package( }) .collect() }); - let binaries = target.binaries.clone().or(index_binaries); + let binaries = target + .binaries + .clone() + .filter(|bins| !bins.is_empty()) + .or(index_binaries); let symlinks = mangle_package_symlinks( &install_dir, diff --git a/crates/soar-operations/src/switch.rs b/crates/soar-operations/src/switch.rs index ae5eede5..fa74da27 100644 --- a/crates/soar-operations/src/switch.rs +++ b/crates/soar-operations/src/switch.rs @@ -126,7 +126,7 @@ pub async fn switch_variant( conn, Some(name), selected_package.pkg_id.as_deref(), - None, + selected_package.pkg_family.as_deref(), None, Some(1), None, diff --git a/crates/soar-operations/src/update.rs b/crates/soar-operations/src/update.rs index cbddce1a..9c1e474c 100644 --- a/crates/soar-operations/src/update.rs +++ b/crates/soar-operations/src/update.rs @@ -387,6 +387,7 @@ fn get_existing( &package.repo_name, &package.pkg_name, package.pkg_id.as_deref(), + package.pkg_family.as_deref(), &package.version, ) })?; From e1f862d8548aa106ef24065712c8bdf2d15f6593 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 16:25:19 +0545 Subject: [PATCH 60/66] fix(cli): show and re-resolve a package by its family The ambiguity prompt rendered two candidates identically, rerun resolved by name alone, and the unique count folded them together. --- crates/soar-cli/src/list.rs | 4 +++- crates/soar-cli/src/run.rs | 11 ++++++++++- crates/soar-cli/src/utils.rs | 10 +++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index bf2ed249..e6c3aef7 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -427,8 +427,10 @@ pub async fn list_installed_packages( if entry.is_healthy { let unique_count = unique_pkgs.insert(format!( - "{}-{}", + "{}-{}-{}-{}", + package.repo_name, package.pkg_id.as_deref().unwrap_or_default(), + package.pkg_family.as_deref().unwrap_or_default(), package.pkg_name )) as u32 + unique_count; diff --git a/crates/soar-cli/src/run.rs b/crates/soar-cli/src/run.rs index ec585f43..16247dea 100644 --- a/crates/soar-cli/src/run.rs +++ b/crates/soar-cli/src/run.rs @@ -42,9 +42,18 @@ pub async fn run_package( // Run what was chosen. Resolving it by name again would pose // the same ambiguous question the choice just answered. + let query = match pkg.pkg_family { + Some(ref family) => { + format!( + "{}/{}@{}:{}", + family, pkg.pkg_name, pkg.version, pkg.repo_name + ) + } + None => format!("{}@{}:{}", pkg.pkg_name, pkg.version, pkg.repo_name), + }; let result = run::prepare_run( ctx, - &format!("{}@{}:{}", pkg.pkg_name, pkg.version, pkg.repo_name), + &query, Some(&pkg.repo_name), pkg.pkg_id.as_deref(), no_verify, diff --git a/crates/soar-cli/src/utils.rs b/crates/soar-cli/src/utils.rs index 13f361ef..f50ea67b 100644 --- a/crates/soar-cli/src/utils.rs +++ b/crates/soar-cli/src/utils.rs @@ -145,10 +145,18 @@ pub fn select_package_interactively_with_installed( } else { String::new() }; + // The family is what tells two candidates of the same name apart, so + // this is the one listing that cannot leave it out. + let name = match pkg.pkg_family() { + Some(family) if family != pkg.pkg_name() => { + format!("{}/{}", family, pkg.pkg_name()) + } + _ => pkg.pkg_name().to_string(), + }; info!( "[{}] {}:{} | {}{}", idx + 1, - Colored(Blue, &pkg.pkg_name()), + Colored(Blue, &name), Colored(Green, pkg.repo_name()), Colored(LightRed, pkg.version()), installed_marker From 91f7d5517f23189353829f069e2e298178cc467c Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 16:25:19 +0545 Subject: [PATCH 61/66] fix(portable): reject a family that is not a path component It comes from metadata, so one holding .. would place the portable directory outside the portable root. --- crates/soar-package/src/formats/common.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/soar-package/src/formats/common.rs b/crates/soar-package/src/formats/common.rs index a4cfd870..c91a99f4 100644 --- a/crates/soar-package/src/formats/common.rs +++ b/crates/soar-package/src/formats/common.rs @@ -17,7 +17,7 @@ use regex::Regex; use soar_config::config::get_config; use soar_utils::{ fs::{create_symlink, walk_dir}, - path::icons_dir, + path::{icons_dir, is_safe_component}, }; use tracing::{debug, trace}; @@ -293,15 +293,17 @@ pub fn setup_portable_dir, T: PackageExt>( ) -> Result<()> { // Packages that carry an id keep their existing directory name. Without // one the family has to stand in, or two packages sharing a name would - // share a portable directory. - let portable_dir_base = - get_config() - .get_portable_dirs()? - .join(match (package.pkg_id(), package.pkg_family()) { - (Some(pkg_id), _) => format!("{}-{}", package.pkg_name(), pkg_id), - (None, Some(family)) => format!("{}-{}", package.pkg_name(), family), - (None, None) => package.pkg_name().to_string(), - }); + // share a portable directory. Neither is trusted to be a single path + // component: both come from metadata, and one holding `..` would put the + // directory outside the portable root. + let family = package.pkg_family().filter(|f| is_safe_component(f)); + let portable_dir_base = get_config().get_portable_dirs()?.join( + match (package.pkg_id().filter(|id| is_safe_component(id)), family) { + (Some(pkg_id), _) => format!("{}-{}", package.pkg_name(), pkg_id), + (None, Some(family)) => format!("{}-{}", package.pkg_name(), family), + (None, None) => package.pkg_name().to_string(), + }, + ); let bin_path = bin_path.as_ref(); let pkg_name = package.pkg_name(); From 3f44f8775a307dac9c4485dcb4ae28b61800ec28 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 16:25:19 +0545 Subject: [PATCH 62/66] fix(json2db): name the import temporary after the process A fixed name let a second import delete the database the first was still building. Also documents a diagnostic code and corrects a field comment. --- crates/soar-cli/src/json2db.rs | 4 +++- crates/soar-core/src/package/local.rs | 2 +- crates/soar-registry/src/error.rs | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/soar-cli/src/json2db.rs b/crates/soar-cli/src/json2db.rs index 3fb4f06c..ec2d6fec 100644 --- a/crates/soar-cli/src/json2db.rs +++ b/crates/soar-cli/src/json2db.rs @@ -44,8 +44,10 @@ pub fn json_to_db(input_path: &str, output_path: &str, repo_name: Option<&str>) // Built beside the target and swapped in only once it holds something. // Writing in place would destroy a working database whenever an import // turned out to be entirely rejected. + // Named for this process, so a second import running at the same time + // cannot delete the database this one is still building. let mut tmp_name = output_path.file_name().unwrap_or_default().to_os_string(); - tmp_name.push(".tmp"); + tmp_name.push(format!(".{}.tmp", std::process::id())); let tmp_path = output_path.with_file_name(tmp_name); for stale in [ &tmp_path, diff --git a/crates/soar-core/src/package/local.rs b/crates/soar-core/src/package/local.rs index 936795e9..cd1822ba 100644 --- a/crates/soar-core/src/package/local.rs +++ b/crates/soar-core/src/package/local.rs @@ -26,7 +26,7 @@ pub struct LocalPackage { pub path: PathBuf, /// Extracted or overridden package name pub pkg_name: String, - /// Generated package ID (lowercase, normalized) + /// Package id, set only by an explicit override pub pkg_id: Option, /// Where the file came from, used to tell apart two local files that /// produce the same package name. diff --git a/crates/soar-registry/src/error.rs b/crates/soar-registry/src/error.rs index b7b17697..40b74d37 100644 --- a/crates/soar-registry/src/error.rs +++ b/crates/soar-registry/src/error.rs @@ -16,6 +16,7 @@ pub enum RegistryError { "repository index is format {found}, but this soar understands up to \ {supported}; upgrade soar to use this repository" )] + #[diagnostic(code(soar_registry::unsupported_format))] UnsupportedFormat { found: u32, supported: u32 }, #[error("Error while {action}: {source}")] From e8adfdde8b00faa81de63fe213d5863cfd373d47 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 21:17:04 +0545 Subject: [PATCH 63/66] feat(config): declare a package family, deprecate pkg_id packages.toml gains 'family', which is what the declarative format publishes to tell identically-named projects apart. 'pkg_id' still parses and is marked deprecated, so every remaining read is flagged. --- crates/soar-config/src/packages.rs | 38 ++++++++++++++++++++++-- crates/soar-operations/src/apply.rs | 44 +++++++++++++++++++++------- crates/soar-operations/src/update.rs | 12 ++++++-- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/soar-config/src/packages.rs b/crates/soar-config/src/packages.rs index 79cfdfd6..ca4dc0cd 100644 --- a/crates/soar-config/src/packages.rs +++ b/crates/soar-config/src/packages.rs @@ -191,7 +191,17 @@ impl SandboxConfig { /// Full package options for detailed specification. #[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)] pub struct PackageOptions { + /// Package family, which tells apart projects sharing a name. + /// + /// The declarative format publishes this rather than a package id, so it + /// is what disambiguates `bat` the pager from `bat` the batch runner. + pub family: Option, + /// Specific package ID (for disambiguation when multiple packages share the same name). + #[deprecated( + since = "0.13.0", + note = "repositories publishing the declarative format have no package id; use `family`" + )] pub pkg_id: Option, /// Specific version to install. @@ -312,6 +322,11 @@ pub struct PortableConfig { #[derive(Clone, Debug, Default)] pub struct ResolvedPackage { pub name: String, + pub family: Option, + #[deprecated( + since = "0.13.0", + note = "repositories publishing the declarative format have no package id; use `family`" + )] pub pkg_id: Option, pub version: Option, pub repo: Option, @@ -358,6 +373,7 @@ impl PackageSpec { let pinned = version.is_some(); ResolvedPackage { name: name.to_string(), + family: None, pkg_id: None, version, repo: None, @@ -395,6 +411,7 @@ impl PackageSpec { let pinned = opts.pinned || (version.is_some() && !is_remote); ResolvedPackage { name: name.to_string(), + family: opts.family.clone(), pkg_id: opts.pkg_id.clone(), version, repo: opts.repo.clone(), @@ -495,7 +512,7 @@ impl PackagesConfig { # package_name = "*" # Latest version # package_name = "1.2.3" # Specific version (pinned) # package_name = { version = "1.2" } # Same as above -# package_name = { pkg_id = "pkg-bin", repo = "bincache" } +# package_name = { family = "pkg", repo = "bincache" } # package_name = { pinned = true, portable = { home = "~/.pkg" } } "#; @@ -646,17 +663,32 @@ jq = "1.8.1" fn test_detailed_package_spec() { let toml_str = r#" [packages] -neovim = { pkg_id = "neovim-appimage", repo = "soarpkgs", pinned = true } +neovim = { family = "neovim", repo = "soarpkgs", pinned = true } "#; let config: PackagesConfig = toml::from_str(toml_str).unwrap(); let resolved = config.resolved_packages(); assert_eq!(resolved[0].name, "neovim"); - assert_eq!(resolved[0].pkg_id, Some("neovim-appimage".to_string())); + assert_eq!(resolved[0].family, Some("neovim".to_string())); assert_eq!(resolved[0].repo, Some("soarpkgs".to_string())); assert!(resolved[0].pinned); } + // Still read while the field exists, so it stays covered. + #[allow(deprecated)] + #[test] + fn deprecated_pkg_id_is_still_accepted() { + let toml_str = r#" +[packages] +neovim = { pkg_id = "neovim-appimage", repo = "soarpkgs" } +"#; + let config: PackagesConfig = toml::from_str(toml_str).unwrap(); + let resolved = config.resolved_packages(); + + assert_eq!(resolved[0].pkg_id, Some("neovim-appimage".to_string())); + assert_eq!(resolved[0].family, None); + } + #[test] fn test_defaults_applied() { let toml_str = r#" diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index a4c2199e..ec96de01 100644 --- a/crates/soar-operations/src/apply.rs +++ b/crates/soar-operations/src/apply.rs @@ -52,10 +52,10 @@ pub async fn compute_diff( let diesel_db = ctx.diesel_core_db()?.clone(); let mut diff = ApplyDiff::default(); - let mut declared_keys: HashSet<(String, Option, Option)> = HashSet::new(); + let mut declared_keys: DeclaredKeys = HashSet::new(); for pkg in resolved { - declared_keys.insert((pkg.name.clone(), pkg.pkg_id.clone(), pkg.repo.clone())); + declared_keys.insert(declared_key(pkg)); let is_github_or_gitlab = pkg.github.is_some() || pkg.gitlab.is_some(); if is_github_or_gitlab || pkg.url.is_some() { @@ -70,8 +70,8 @@ pub async fn compute_diff( MetadataRepository::find_filtered( conn, Some(&pkg.name), - pkg.pkg_id.as_deref(), - None, + declared_pkg_id(pkg), + pkg.family.as_deref(), pkg.version.as_deref(), None, Some(SortDirection::Asc), @@ -90,8 +90,8 @@ pub async fn compute_diff( let pkgs = MetadataRepository::find_filtered( conn, Some(&pkg.name), - pkg.pkg_id.as_deref(), - None, + declared_pkg_id(pkg), + pkg.family.as_deref(), pkg.version.as_deref(), None, Some(SortDirection::Asc), @@ -181,13 +181,16 @@ pub async fn compute_diff( .collect(); for installed in all_installed { - let is_declared = declared_keys.iter().any(|(name, pkg_id, repo)| { + let is_declared = declared_keys.iter().any(|(name, pkg_id, family, repo)| { let name_matches = *name == installed.pkg_name; let pkg_id_matches = pkg_id .as_deref() .is_none_or(|id| Some(id) == installed.pkg_id.as_deref()); + let family_matches = family + .as_deref() + .is_none_or(|f| Some(f) == installed.pkg_family.as_deref()); let repo_matches = repo.as_ref().is_none_or(|r| *r == installed.repo_name); - name_matches && pkg_id_matches && repo_matches + name_matches && pkg_id_matches && family_matches && repo_matches }); if !is_declared { @@ -367,6 +370,27 @@ pub async fn execute_apply( } /// Handle local (URL/github/gitlab) packages in apply diff. +/// What a declaration identifies: name, package id, family and repository. +type DeclaredKeys = HashSet<(String, Option, Option, Option)>; + +/// The deprecated package id a declaration still carries, if any. +/// +/// Reading it is the whole point of keeping the field, so the deprecation is +/// answered once here rather than at every use. +#[allow(deprecated)] +fn declared_pkg_id(pkg: &ResolvedPackage) -> Option<&str> { + pkg.pkg_id.as_deref() +} + +fn declared_key(pkg: &ResolvedPackage) -> (String, Option, Option, Option) { + ( + pkg.name.clone(), + declared_pkg_id(pkg).map(str::to_string), + pkg.family.clone(), + pkg.repo.clone(), + ) +} + fn handle_local_package( pkg: &ResolvedPackage, is_github_or_gitlab: bool, @@ -375,7 +399,7 @@ fn handle_local_package( ) -> SoarResult<()> { // Only what the user set. The family is derived from the download URL, // which identifies the source just as well without minting an id. - let local_pkg_id = pkg.pkg_id.clone(); + let local_pkg_id = declared_pkg_id(pkg).map(str::to_string); let installed: Option = diesel_db .with_conn(|conn| { @@ -543,7 +567,7 @@ fn handle_local_package( Some(&pkg.name), pkg.version.as_deref(), pkg.pkg_type.as_deref(), - pkg.pkg_id.as_deref(), + declared_pkg_id(pkg), )?; match check_url_package_status(&url_pkg, pkg, "local", diesel_db)? { diff --git a/crates/soar-operations/src/update.rs b/crates/soar-operations/src/update.rs index 9c1e474c..a2984781 100644 --- a/crates/soar-operations/src/update.rs +++ b/crates/soar-operations/src/update.rs @@ -205,9 +205,15 @@ fn check_local_update( resolved_packages: &[ResolvedPackage], ctx: &SoarContext, ) -> SoarResult> { - let resolved = resolved_packages - .iter() - .find(|r| r.name == pkg.pkg_name && has_update_source(r)); + // A declaration that names a family only speaks for that family, so a + // package of the same name from another one is not updated by it. + let resolved = resolved_packages.iter().find(|r| { + r.name == pkg.pkg_name + && r.family + .as_deref() + .is_none_or(|f| Some(f) == pkg.pkg_family.as_deref()) + && has_update_source(r) + }); let Some(resolved) = resolved else { ctx.events().emit(SoarEvent::UpdateCheck { From aeedb6d4606ff4ed6ef9ecf54d1f8a1fee27e356 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 22:48:34 +0545 Subject: [PATCH 64/66] fix(db): rebuild the packages table without losing rows Dropping the old table cascades portable_package empty and leaves deferred violations a rename cannot clear, so foreign keys are turned off, which is only possible outside a transaction. A published metadata database is migrated on fetch; it was queried with whatever schema built it. --- .../metadata.toml | 3 +++ .../up.sql | 14 +++++------ .../down.sql | 4 +++ .../metadata.toml | 3 +++ .../up.sql | 25 ++++++------------- crates/soar-operations/src/context.rs | 18 +++++++++++++ 6 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/metadata.toml create mode 100644 crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/metadata.toml diff --git a/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/metadata.toml b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/metadata.toml new file mode 100644 index 00000000..b6070ebe --- /dev/null +++ b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/metadata.toml @@ -0,0 +1,3 @@ +# Dropping the old table cascades rows out of tables referencing it, and +# foreign keys can only be turned off outside a transaction. +run_in_transaction = false diff --git a/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql index d2170ab7..92ea98c0 100644 --- a/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql +++ b/crates/soar-db/migrations/core/2026-07-28-000000-0000_declarative_format/up.sql @@ -1,7 +1,6 @@ --- Installed rows record the family a package came from, so variants can be --- told apart without the package id repositories no longer publish, and the --- id itself stops being required. SQLite cannot relax NOT NULL in place, so --- the table is rebuilt. +PRAGMA foreign_keys = OFF; + +-- Rebuilt rather than altered: SQLite cannot relax `pkg_id NOT NULL` in place. CREATE TABLE packages_new ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, repo_name TEXT NOT NULL, @@ -32,9 +31,10 @@ FROM packages; DROP TABLE packages; ALTER TABLE packages_new RENAME TO packages; --- Installs from a URL or a local file used to carry a synthesised id, since --- that was the only field able to say where they came from. That is now the --- family's job, so the value moves across and nothing derives an id again. +-- A synthesised id was how a URL or local install recorded its source; that is +-- the family's job now. UPDATE packages SET pkg_family = pkg_id WHERE repo_name = 'local' AND pkg_family IS NULL AND pkg_id IS NOT NULL; + +PRAGMA foreign_keys = ON; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql index 56e64639..a19ead64 100644 --- a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/down.sql @@ -1,3 +1,5 @@ +PRAGMA foreign_keys = OFF; + -- A package without an id cannot be represented once the column is required -- again. This table is a cache of the published index, so the rows are simply -- dropped and the next sync puts them back. @@ -57,3 +59,5 @@ FROM packages; DROP TABLE packages; ALTER TABLE packages_old RENAME TO packages; + +PRAGMA foreign_keys = ON; diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/metadata.toml b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/metadata.toml new file mode 100644 index 00000000..b6070ebe --- /dev/null +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/metadata.toml @@ -0,0 +1,3 @@ +# Dropping the old table cascades rows out of tables referencing it, and +# foreign keys can only be turned off outside a transaction. +run_in_transaction = false diff --git a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql index c8a2ffb1..82b030fe 100644 --- a/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql +++ b/crates/soar-db/migrations/metadata/2026-07-28-000000-0000_declarative_format/up.sql @@ -1,18 +1,6 @@ --- The declarative format carries what a package contains rather than leaving --- it to be guessed: `binaries` says where each executable lives inside the --- artifact and what to call it, `extra` lists side files installed alongside --- it. A repository publishing that format has no package id to give, so the --- id stops being required, and SQLite cannot relax NOT NULL in place. --- --- Three columns go: `pkg_webpage` was derivable from the package's own fields, --- `tags` was stored and displayed but never searched, and `version_upstream` --- was never read at all. --- --- The uniqueness key keeps the id and adds the family, which is what tells --- identically-named packages apart once no id is published. NULLs are --- collapsed first: SQLite treats every NULL as distinct, so an id-less --- package would otherwise insert a fresh duplicate on every sync instead of --- conflicting with itself. +PRAGMA foreign_keys = OFF; + +-- Rebuilt rather than altered: SQLite cannot relax `pkg_id NOT NULL` in place. CREATE TABLE packages_new ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, pkg_id TEXT COLLATE NOCASE, @@ -48,7 +36,6 @@ CREATE TABLE packages_new ( soar_syms BOOLEAN NOT NULL DEFAULT false, desktop_integration BOOLEAN, portable BOOLEAN, - binaries JSONB, extra JSONB, files JSONB ); @@ -58,11 +45,15 @@ INSERT INTO packages_new SELECT licenses, download_url, size, ghcr_pkg, ghcr_size, ghcr_blob, ghcr_url, bsum, icon, desktop, appstream, homepages, notes, source_urls, categories, build_id, build_date, build_action, build_script, build_log, provides, - snapshots, replaces, soar_syms, desktop_integration, portable, NULL, NULL, NULL + snapshots, replaces, soar_syms, desktop_integration, portable, NULL, NULL FROM packages; DROP TABLE packages; ALTER TABLE packages_new RENAME TO packages; +-- NULLs are collapsed first: SQLite treats every NULL as distinct, so an +-- id-less package would insert a duplicate on every sync instead of conflicting. CREATE UNIQUE INDEX packages_identity ON packages (COALESCE(pkg_id, ''), COALESCE(pkg_family, ''), pkg_name, version); + +PRAGMA foreign_keys = ON; diff --git a/crates/soar-operations/src/context.rs b/crates/soar-operations/src/context.rs index d04c2b3d..a0a6cc7f 100644 --- a/crates/soar-operations/src/context.rs +++ b/crates/soar-operations/src/context.rs @@ -46,6 +46,13 @@ fn handle_json_metadata>( Ok(()) } +/// Bring a published metadata database up to the schema soar reads. +fn migrate_metadata(path: &Path) -> SoarResult<()> { + DbConnection::open(path, DbType::Metadata) + .map_err(|e| SoarError::Custom(format!("migrating repository metadata: {}", e)))?; + Ok(()) +} + #[derive(Clone)] pub struct SoarContext { inner: Arc, @@ -162,6 +169,17 @@ impl SoarContext { MetadataContent::SqliteDb(db_bytes) => { write_metadata_db(&db_bytes, &metadata_db_path) .map_err(|e| SoarError::Custom(e.to_string()))?; + // A published database was built by whatever soar + // the repository runs, so it can predate the + // columns these queries name. Opening it through + // the migration runner brings it up to them. The + // rebuild runs outside a transaction, so a failure + // leaves a half-migrated file: it is discarded + // rather than queried. + if let Err(e) = migrate_metadata(&metadata_db_path) { + fs::remove_file(&metadata_db_path).ok(); + return Err(e); + } } MetadataContent::Json(packages) => { handle_json_metadata(&packages, &metadata_db_path, &repo.name)?; From fdb6e0d1297f3c4533862c3f095b2aa5ffa323af Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 22:48:34 +0545 Subject: [PATCH 65/66] refactor(db): drop the metadata binaries column No repository ever published it and 'files' says more. --- crates/soar-core/src/database/models.rs | 4 +--- crates/soar-db/src/models/metadata.rs | 10 +++------- crates/soar-db/src/models/types.rs | 12 ------------ crates/soar-db/src/repository/metadata.rs | 1 - crates/soar-db/src/schema/metadata.rs | 1 - crates/soar-operations/src/install.rs | 23 ++++------------------- crates/soar-registry/src/package.rs | 15 --------------- 7 files changed, 8 insertions(+), 58 deletions(-) diff --git a/crates/soar-core/src/database/models.rs b/crates/soar-core/src/database/models.rs index 96ada3be..24db6eed 100644 --- a/crates/soar-core/src/database/models.rs +++ b/crates/soar-core/src/database/models.rs @@ -4,7 +4,7 @@ use std::fmt::Display; use serde::{Deserialize, Serialize}; use soar_db::{ - models::types::{PackageBinary, PackageExtra, PackageFile, PackageProvide}, + models::types::{PackageExtra, PackageFile, PackageProvide}, repository::core::InstalledPackageWithPortable, }; use soar_package::PackageExt; @@ -67,7 +67,6 @@ pub struct Package { pub desktop_integration: Option, pub portable: Option, /// Executables inside the artifact, as source path -> installed name. - pub binaries: Option>, /// Pinned side files installed alongside the artifact. pub extra: Option>, /// What the package takes out of its artifact. Absent means all of it. @@ -295,7 +294,6 @@ impl From for Package { deprecated: false, desktop_integration: pkg.desktop_integration, portable: pkg.portable, - binaries: pkg.binaries, extra: pkg.extra, files: pkg.files, } diff --git a/crates/soar-db/src/models/metadata.rs b/crates/soar-db/src/models/metadata.rs index 76b94437..dbf3b63b 100644 --- a/crates/soar-db/src/models/metadata.rs +++ b/crates/soar-db/src/models/metadata.rs @@ -3,7 +3,7 @@ use serde_json::Value; use crate::{ json_vec, - models::types::{PackageBinary, PackageExtra, PackageFile, PackageProvide}, + models::types::{PackageExtra, PackageFile, PackageProvide}, schema::metadata::*, }; @@ -44,7 +44,6 @@ pub struct Package { pub desktop_integration: Option, pub portable: Option, /// Executables inside the artifact, as source path -> installed name. - pub binaries: Option>, /// Pinned side files installed alongside the artifact. pub extra: Option>, /// What the package takes out of its artifact. @@ -89,7 +88,6 @@ impl Queryable for Package { Option, Option, Option, - Option, ); fn build(row: Self::Row) -> diesel::deserialize::Result { @@ -128,9 +126,8 @@ impl Queryable for Package { soar_syms: row.31, desktop_integration: row.32, portable: row.33, - binaries: json_vec!(row.34), - extra: json_vec!(row.35), - files: json_vec!(row.36), + extra: json_vec!(row.34), + files: json_vec!(row.35), }) } } @@ -230,7 +227,6 @@ pub struct NewPackage<'a> { pub soar_syms: bool, pub desktop_integration: Option, pub portable: Option, - pub binaries: Option, pub extra: Option, pub files: Option, } diff --git a/crates/soar-db/src/models/types.rs b/crates/soar-db/src/models/types.rs index 4b278385..f556ad74 100644 --- a/crates/soar-db/src/models/types.rs +++ b/crates/soar-db/src/models/types.rs @@ -138,18 +138,6 @@ mod tests { } } -/// One executable inside a package artifact. -/// -/// `source` is a path relative to the extracted artifact and may be a glob, -/// since archives often wrap their contents in a versioned directory. -/// `link_as` is the name to install it under, defaulting to the file's own. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct PackageBinary { - pub source: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub link_as: Option, -} - /// One file the package installs out of its artifact. /// /// `to` is a path inside the package directory, so where it lands says what it diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index 616e7691..9dd19ef2 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -624,7 +624,6 @@ impl MetadataRepository { soar_syms: package.soar_syms.unwrap_or(false), desktop_integration: package.desktop_integration, portable: package.portable, - binaries: package.binaries.as_ref().map(|b| json!(b)), extra: package.extra.as_ref().map(|e| json!(e)), files: package.files.as_ref().map(|f| json!(f)), }; diff --git a/crates/soar-db/src/schema/metadata.rs b/crates/soar-db/src/schema/metadata.rs index 49b42669..462cd3c1 100644 --- a/crates/soar-db/src/schema/metadata.rs +++ b/crates/soar-db/src/schema/metadata.rs @@ -50,7 +50,6 @@ diesel::table! { soar_syms -> Bool, desktop_integration -> Nullable, portable -> Nullable, - binaries -> Nullable, extra -> Nullable, files -> Nullable, } diff --git a/crates/soar-operations/src/install.rs b/crates/soar-operations/src/install.rs index 8ae06a04..6f34a4d1 100644 --- a/crates/soar-operations/src/install.rs +++ b/crates/soar-operations/src/install.rs @@ -10,7 +10,7 @@ use std::{ }; use minisign_verify::{PublicKey, Signature}; -use soar_config::{packages::BinaryMapping, utils::default_install_patterns}; +use soar_config::utils::default_install_patterns; use soar_core::{ database::{ connection::{DieselDatabase, MetadataManager}, @@ -1185,24 +1185,9 @@ async fn install_single_package( stage: InstallStage::LinkingBinaries, }); - // A repository can describe where its executables live; local - // configuration still wins, so an override in packages.toml is never - // silently replaced by whatever the index says. - let index_binaries: Option> = pkg.binaries.as_ref().map(|bins| { - bins.iter() - .map(|b| { - BinaryMapping { - source: b.source.clone(), - link_as: b.link_as.clone(), - } - }) - .collect() - }); - let binaries = target - .binaries - .clone() - .filter(|bins| !bins.is_empty()) - .or(index_binaries); + // Only what packages.toml declares: a repository says where its files go + // through `files`, not through a binary mapping. + let binaries = target.binaries.clone().filter(|bins| !bins.is_empty()); let symlinks = mangle_package_symlinks( &install_dir, diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index 1a41bc95..76ea1364 100644 --- a/crates/soar-registry/src/package.rs +++ b/crates/soar-registry/src/package.rs @@ -238,10 +238,6 @@ pub struct RemotePackage { pub snapshots: Option>, pub replaces: Option>, /// Executables inside the artifact, as source path -> installed name. - /// Lets a package whose binary is named differently from the package - /// itself be installed from the index alone. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub binaries: Option>, /// Pinned side files to install alongside the artifact. #[serde(default, skip_serializing_if = "Option::is_none")] pub extra: Option>, @@ -301,17 +297,6 @@ mod tests { } } -/// One executable inside a package artifact, as published in the index. -/// -/// `source` is relative to the extracted artifact and may be a glob, since -/// archives commonly wrap their contents in a versioned directory. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct RemoteBinary { - pub source: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub link_as: Option, -} - /// One file the package installs, as published in the index. /// /// `to` is a path inside the package directory, so the directory it lands in From a1634ce48782cfad78b4aa75795e34725264f51f Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sat, 1 Aug 2026 22:48:34 +0545 Subject: [PATCH 66/66] fix(health): treat a missing directory as nothing to check Walking one that does not exist failed the whole check. --- crates/soar-operations/src/health.rs | 32 ++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/soar-operations/src/health.rs b/crates/soar-operations/src/health.rs index a57478c3..cc58e613 100644 --- a/crates/soar-operations/src/health.rs +++ b/crates/soar-operations/src/health.rs @@ -156,16 +156,21 @@ fn get_broken_symlinks(ctx: &SoarContext) -> SoarResult> { let config = ctx.config(); let mut broken = Vec::new(); + // A directory that does not exist holds nothing broken. Walking it is an + // error, and reporting that as a failed health check tells the user + // something is wrong when nothing is. let bin_path = config.get_bin_path()?; - walk_dir( - &bin_path, - &mut |path: &std::path::Path| -> FileSystemResult<()> { - if !path.exists() { - broken.push(path.to_path_buf()); - } - Ok(()) - }, - )?; + if bin_path.is_dir() { + walk_dir( + &bin_path, + &mut |path: &std::path::Path| -> FileSystemResult<()> { + if !path.exists() { + broken.push(path.to_path_buf()); + } + Ok(()) + }, + )?; + } let desktop_path = config.get_desktop_path()?; let mut soar_check = |path: &std::path::Path| -> FileSystemResult<()> { @@ -177,8 +182,13 @@ fn get_broken_symlinks(ctx: &SoarContext) -> SoarResult> { Ok(()) }; - walk_dir(&desktop_path, &mut soar_check)?; - walk_dir(config.get_icons_path(), &mut soar_check)?; + if desktop_path.is_dir() { + walk_dir(&desktop_path, &mut soar_check)?; + } + let icons_path = config.get_icons_path(); + if icons_path.is_dir() { + walk_dir(&icons_path, &mut soar_check)?; + } Ok(broken) }