From cfd0860e7b3bfdc2b14360baf7ac02ddbf061a90 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 23 Dec 2025 12:19:01 +0545 Subject: [PATCH 1/2] feat(packages): add declarative installation --- crates/soar-cli/src/apply.rs | 471 ++++++++++++++++++++++++++ crates/soar-cli/src/cli.rs | 28 ++ crates/soar-cli/src/main.rs | 14 + crates/soar-config/src/error.rs | 14 + crates/soar-config/src/lib.rs | 1 + crates/soar-config/src/packages.rs | 375 ++++++++++++++++++++ crates/soar-db/src/repository/core.rs | 1 - 7 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 crates/soar-cli/src/apply.rs create mode 100644 crates/soar-config/src/packages.rs diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs new file mode 100644 index 000000000..494ace0b2 --- /dev/null +++ b/crates/soar-cli/src/apply.rs @@ -0,0 +1,471 @@ +use std::{ + collections::HashSet, + io::{self, Write}, + sync::atomic::Ordering, +}; + +use nu_ansi_term::Color::{Blue, Cyan, Green, Magenta, Red, Yellow}; +use soar_config::packages::{PackagesConfig, ResolvedPackage}; +use soar_core::{ + database::models::{InstalledPackage, Package}, + package::{install::InstallTarget, remove::PackageRemover}, + SoarResult, +}; +use soar_db::repository::{ + core::{CoreRepository, SortDirection}, + metadata::MetadataRepository, +}; +use tabled::{ + builder::Builder, + settings::{themes::BorderCorrection, Panel, Style}, +}; +use tracing::{error, info, warn}; + +use crate::{ + install::{create_install_context, perform_installation}, + state::AppState, + utils::{display_settings, icon_or, Colored, Icons}, +}; + +/// Result of comparing declared packages vs installed packages +#[derive(Default)] +pub struct ApplyDiff { + /// Packages to install (declared but not installed) + pub to_install: Vec<(ResolvedPackage, InstallTarget)>, + /// Packages to update (version mismatch) + pub to_update: Vec<(ResolvedPackage, InstallTarget)>, + /// Packages to remove (installed but not declared, only with --prune) + pub to_remove: Vec, + /// Packages already in sync + pub in_sync: Vec, + /// Packages not found in metadata + pub not_found: Vec, +} + +/// Main entry point for the apply command +pub async fn apply_packages( + prune: bool, + dry_run: bool, + yes: bool, + packages_config: Option, + no_verify: bool, +) -> SoarResult<()> { + let config = PackagesConfig::load(packages_config.as_deref())?; + let resolved = config.resolved_packages(); + + if resolved.is_empty() { + info!("No packages declared in configuration"); + return Ok(()); + } + + info!("Loaded {} package declaration(s)", resolved.len()); + + let state = AppState::new(); + let diff = compute_diff(&state, &resolved, prune).await?; + + display_diff(&diff, prune); + + if diff.to_install.is_empty() && diff.to_update.is_empty() && diff.to_remove.is_empty() { + info!("\nAll packages are in sync!"); + return Ok(()); + } + + if dry_run { + info!("\n{} Dry run - no changes made", icon_or("", "[DRY RUN]")); + return Ok(()); + } + + if !yes { + print!("\nProceed? [y/N] "); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + if !input.trim().eq_ignore_ascii_case("y") { + info!("Aborted"); + return Ok(()); + } + } + + execute_apply(&state, diff, no_verify).await +} + +/// Compute the difference between declared and installed packages +async fn compute_diff( + state: &AppState, + resolved: &[ResolvedPackage], + prune: bool, +) -> SoarResult { + let metadata_mgr = state.metadata_manager().await?; + let diesel_db = state.diesel_core_db()?.clone(); + + let mut diff = ApplyDiff::default(); + let mut declared_keys: HashSet<(String, Option, Option)> = HashSet::new(); + + for pkg in resolved { + // Track declared package + declared_keys.insert((pkg.name.clone(), pkg.pkg_id.clone(), pkg.repo.clone())); + + // Find package in metadata + let found_packages: Vec = if let Some(ref repo_name) = pkg.repo { + metadata_mgr + .query_repo(repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + Some(&pkg.name), + pkg.pkg_id.as_deref(), + pkg.version.as_deref(), + None, + Some(SortDirection::Asc), + ) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut package: Package = p.into(); + package.repo_name = repo_name.clone(); + package + }) + .collect() + } else { + metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = MetadataRepository::find_filtered( + conn, + Some(&pkg.name), + pkg.pkg_id.as_deref(), + pkg.version.as_deref(), + None, + Some(SortDirection::Asc), + )?; + Ok(pkgs + .into_iter() + .map(|p| { + let mut package: Package = p.into(); + package.repo_name = repo_name.to_string(); + package + }) + .collect()) + })? + }; + + if found_packages.is_empty() { + diff.not_found.push(pkg.name.clone()); + continue; + } + + // Use first matching package (like --yes behavior) + let metadata_pkg = found_packages.into_iter().next().unwrap(); + + // Check if installed + let installed_packages: Vec = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + Some(&metadata_pkg.repo_name), + Some(&metadata_pkg.pkg_name), + Some(&metadata_pkg.pkg_id), + None, // Don't filter by version - we want to find any installed version + None, + None, + None, + Some(SortDirection::Asc), + ) + })? + .into_iter() + .map(Into::into) + .collect(); + + let existing_install = installed_packages.into_iter().find(|ip| ip.is_installed); + + if let Some(ref existing) = existing_install { + let version_matches = pkg.version.as_ref().is_none_or(|v| existing.version == *v); + + if version_matches && existing.version == metadata_pkg.version { + diff.in_sync.push(format!( + "{}#{}@{}", + existing.pkg_name, existing.pkg_id, existing.version + )); + } else if !existing.pinned || pkg.version.is_some() { + let target = create_install_target(pkg, metadata_pkg, Some(existing.clone())); + diff.to_update.push((pkg.clone(), target)); + } else { + diff.in_sync.push(format!( + "{}#{}@{} (pinned)", + existing.pkg_name, existing.pkg_id, existing.version + )); + } + } else { + let target = create_install_target(pkg, metadata_pkg, None); + diff.to_install.push((pkg.clone(), target)); + } + } + + if prune { + let all_installed: Vec = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + None, + None, + None, + None, + None, + None, + None, + Some(SortDirection::Asc), + ) + })? + .into_iter() + .filter(|p| p.is_installed) + .map(Into::into) + .collect(); + + 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().map_or(true, |id| *id == installed.pkg_id); + let repo_matches = repo.as_ref().map_or(true, |r| *r == installed.repo_name); + name_matches && pkg_id_matches && repo_matches + }); + + if !is_declared { + diff.to_remove.push(installed); + } + } + } + + Ok(diff) +} + +/// Create an InstallTarget from resolved package info +fn create_install_target( + resolved: &ResolvedPackage, + package: Package, + existing: Option, +) -> InstallTarget { + InstallTarget { + package, + existing_install: existing, + with_pkg_id: resolved.pkg_id.is_some(), + pinned: resolved.pinned, + profile: resolved.profile.clone(), + portable: resolved.portable.as_ref().and_then(|p| p.path.clone()), + portable_home: resolved.portable.as_ref().and_then(|p| p.home.clone()), + portable_config: resolved.portable.as_ref().and_then(|p| p.config.clone()), + portable_share: resolved.portable.as_ref().and_then(|p| p.share.clone()), + portable_cache: resolved.portable.as_ref().and_then(|p| p.cache.clone()), + } +} + +/// Display the computed diff +fn display_diff(diff: &ApplyDiff, prune: bool) { + let settings = display_settings(); + let use_icons = settings.icons(); + + // Build packages table if there are changes + if !diff.to_install.is_empty() || !diff.to_update.is_empty() || (prune && !diff.to_remove.is_empty()) { + let mut builder = Builder::new(); + builder.push_record(["", "Package", "Version", "Repository"]); + + // Add packages to install + for (_resolved, target) in &diff.to_install { + let pkg = &target.package; + builder.push_record([ + 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)), + ]); + } + + // Add packages to update + for (_resolved, target) in &diff.to_update { + let pkg = &target.package; + let old_version = target.existing_install.as_ref().map_or("?".to_string(), |e| e.version.clone()); + builder.push_record([ + format!("{}", Colored(Yellow, icon_or("~", "~"))), + format!("{}#{}", Colored(Blue, &pkg.pkg_name), Colored(Cyan, &pkg.pkg_id)), + format!("{} -> {}", Colored(Red, &old_version), Colored(Green, &pkg.version)), + format!("{}", Colored(Magenta, &pkg.repo_name)), + ]); + } + + // Add packages to remove + if prune { + for pkg in &diff.to_remove { + 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)), + ]); + } + } + + let table = builder + .build() + .with(Panel::header("Package Changes")) + .with(Style::rounded()) + .with(BorderCorrection {}) + .to_string(); + + info!("\n{table}"); + } + + // Show packages not found + if !diff.not_found.is_empty() { + info!("\n{} Packages not found:", icon_or(Icons::WARNING, "!")); + for name in &diff.not_found { + warn!(" {} {}", icon_or("?", "?"), Colored(Yellow, name)); + } + } + + // Summary table + let mut summary_builder = Builder::new(); + + if !diff.to_install.is_empty() { + summary_builder.push_record([ + format!("{} To Install", icon_or("+", "+")), + format!("{}", Colored(Green, diff.to_install.len())), + ]); + } + if !diff.to_update.is_empty() { + summary_builder.push_record([ + format!("{} To Update", icon_or("~", "~")), + format!("{}", Colored(Yellow, diff.to_update.len())), + ]); + } + if prune && !diff.to_remove.is_empty() { + summary_builder.push_record([ + format!("{} To Remove", icon_or("-", "-")), + format!("{}", Colored(Red, diff.to_remove.len())), + ]); + } + if !diff.in_sync.is_empty() { + summary_builder.push_record([ + format!("{} In Sync", icon_or(Icons::CHECK, "*")), + format!("{}", Colored(Cyan, diff.in_sync.len())), + ]); + } + if !diff.not_found.is_empty() { + summary_builder.push_record([ + format!("{} Not Found", icon_or(Icons::WARNING, "?")), + format!("{}", Colored(Yellow, diff.not_found.len())), + ]); + } + + if use_icons { + let summary_table = summary_builder + .build() + .with(Panel::header("Summary")) + .with(Style::rounded()) + .with(BorderCorrection {}) + .to_string(); + + info!("\n{summary_table}"); + } else { + let total_changes = diff.to_install.len() + diff.to_update.len() + diff.to_remove.len(); + if total_changes > 0 || !diff.in_sync.is_empty() { + info!( + "\nSummary: {} to install, {} to update, {} to remove, {} in sync", + diff.to_install.len(), + diff.to_update.len(), + if prune { diff.to_remove.len() } else { 0 }, + diff.in_sync.len() + ); + } + } +} + +/// Execute the apply operation +async fn execute_apply(state: &AppState, diff: ApplyDiff, no_verify: bool) -> SoarResult<()> { + let diesel_db = state.diesel_core_db()?.clone(); + let config = state.config(); + + let mut installed_count = 0; + let mut updated_count = 0; + let mut removed_count = 0; + let mut failed_count = 0; + + if !diff.to_install.is_empty() { + info!("\nInstalling {} package(s)...", diff.to_install.len()); + + let targets: Vec = diff + .to_install + .into_iter() + .map(|(_, target)| target) + .collect(); + + let ctx = create_install_context( + targets.len(), + config.parallel_limit.unwrap_or(4), + None, + None, + None, + None, + None, + false, + no_verify, + ); + + perform_installation(ctx.clone(), targets, diesel_db.clone(), true).await?; + installed_count = ctx.installed_count.load(Ordering::Relaxed) as usize; + failed_count += ctx.failed.load(Ordering::Relaxed) as usize; + } + + if !diff.to_update.is_empty() { + info!("\nUpdating {} package(s)...", diff.to_update.len()); + + let targets: Vec = diff + .to_update + .into_iter() + .map(|(_, target)| target) + .collect(); + + let ctx = create_install_context( + targets.len(), + config.parallel_limit.unwrap_or(4), + None, + None, + None, + None, + None, + false, + no_verify, + ); + + perform_installation(ctx.clone(), targets, diesel_db.clone(), true).await?; + updated_count = ctx.installed_count.load(Ordering::Relaxed) as usize; + failed_count += ctx.failed.load(Ordering::Relaxed) as usize; + } + + if !diff.to_remove.is_empty() { + info!("\nRemoving {} package(s)...", diff.to_remove.len()); + + for pkg in diff.to_remove { + match PackageRemover::new(pkg.clone(), diesel_db.clone()) + .await + .remove() + .await + { + Ok(_) => { + info!(" Removed {}#{}", pkg.pkg_name, pkg.pkg_id); + removed_count += 1; + } + Err(e) => { + error!(" Failed to remove {}#{}: {}", pkg.pkg_name, pkg.pkg_id, e); + failed_count += 1; + } + } + } + } + + info!("\n{} Apply Summary", icon_or(Icons::CHECK, "*")); + info!(" Installed: {}", installed_count); + info!(" Updated: {}", updated_count); + info!(" Removed: {}", removed_count); + if failed_count > 0 { + warn!(" Failed: {}", failed_count); + } + + Ok(()) +} diff --git a/crates/soar-cli/src/cli.rs b/crates/soar-cli/src/cli.rs index 8c8426c35..44ea12e3f 100644 --- a/crates/soar-cli/src/cli.rs +++ b/crates/soar-cli/src/cli.rs @@ -400,6 +400,34 @@ pub enum Commands { /// Manage nests #[clap(subcommand, name = "nest")] Nest(NestCommands), + + /// Apply declarative package configuration + #[clap(name = "apply")] + Apply { + /// Remove packages not declared in packages.toml + #[arg(required = false, long)] + prune: bool, + + /// Show what would be done without making changes + #[arg(required = false, long)] + dry_run: bool, + + /// Skip confirmation prompts + #[arg(required = false, short, long)] + yes: bool, + + /// Path to packages.toml (default: ~/.config/soar/packages.toml) + #[arg(required = false, long = "packages", value_hint = ValueHint::FilePath)] + packages_config: Option, + + /// Skip checksum verification + #[arg(required = false, long)] + no_verify: bool, + }, + + /// Generate default packages configuration + #[clap(name = "defpackages")] + DefPackages, } #[derive(Subcommand)] diff --git a/crates/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs index c8ede8143..f1f0bedcc 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -10,6 +10,7 @@ use list::{list_installed_packages, list_packages, query_package, search_package use logging::setup_logging; use nest::{add_nest, list_nests, remove_nest}; use progress::create_progress_bar; +use apply::apply_packages; use remove::remove_packages; use run::run_package; use soar_config::config::{ @@ -29,6 +30,7 @@ use ureq::Proxy; use use_package::use_alternate_package; use utils::COLOR; +mod apply; mod cli; mod download; mod health; @@ -377,6 +379,18 @@ async fn handle_cli() -> SoarResult<()> { } } } + cli::Commands::Apply { + prune, + dry_run, + yes, + packages_config, + no_verify, + } => { + apply_packages(prune, dry_run, yes, packages_config, no_verify).await?; + } + cli::Commands::DefPackages => { + soar_config::packages::generate_default_packages_config()?; + } _ => unreachable!(), } } diff --git a/crates/soar-config/src/error.rs b/crates/soar-config/src/error.rs index fd5bb2d68..1abe75eb6 100644 --- a/crates/soar-config/src/error.rs +++ b/crates/soar-config/src/error.rs @@ -25,6 +25,20 @@ pub enum ConfigError { )] ConfigAlreadyExists, + #[error("Packages configuration file not found: {0}")] + #[diagnostic( + code(soar_config::packages_not_found), + help("Create a packages.toml file or run `soar defpackages` to generate one") + )] + PackagesConfigNotFound(String), + + #[error("Packages configuration file already exists")] + #[diagnostic( + code(soar_config::packages_already_exists), + help("Remove the existing packages.toml file or use a different location") + )] + PackagesConfigAlreadyExists, + #[error("Invalid profile: {0}")] #[diagnostic( code(soar_config::invalid_profile), diff --git a/crates/soar-config/src/lib.rs b/crates/soar-config/src/lib.rs index 1a79103d5..b9be53c5a 100644 --- a/crates/soar-config/src/lib.rs +++ b/crates/soar-config/src/lib.rs @@ -2,6 +2,7 @@ pub mod annotations; pub mod config; pub mod display; pub mod error; +pub mod packages; pub mod profile; pub mod repository; pub mod utils; diff --git a/crates/soar-config/src/packages.rs b/crates/soar-config/src/packages.rs new file mode 100644 index 000000000..a6a4810de --- /dev/null +++ b/crates/soar-config/src/packages.rs @@ -0,0 +1,375 @@ +use std::{ + collections::HashMap, + fs, + path::PathBuf, + sync::{LazyLock, RwLock}, +}; + +use documented::{Documented, DocumentedFields}; +use serde::{Deserialize, Serialize}; +use soar_utils::path::xdg_config_home; +use toml_edit::DocumentMut; +use tracing::info; + +use crate::{ + annotations::annotate_toml_table, + error::{ConfigError, Result}, +}; + +/// Path to the packages configuration file +pub static PACKAGES_CONFIG_PATH: LazyLock> = LazyLock::new(|| { + RwLock::new(match std::env::var("SOAR_PACKAGES_CONFIG") { + Ok(path_str) => PathBuf::from(path_str), + Err(_) => xdg_config_home().join("soar").join("packages.toml"), + }) +}); + +/// Declarative package configuration. +/// Defines the desired set of packages to be installed. +#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)] +pub struct PackagesConfig { + /// Default settings applied to all packages unless overridden. + pub defaults: Option, + + /// Map of package names to their specifications. + /// Supports both simple string form (version) and detailed table form. + #[serde(default)] + pub packages: HashMap, +} + +/// Default settings for all packages. +#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)] +pub struct PackageDefaults { + /// Default profile to use for installations. + pub profile: Option, + + /// Whether to install binary only (exclude logs, desktop files, etc). + pub binary_only: Option, + + /// Default install patterns. + pub install_patterns: Option>, +} + +/// Flexible package specification. +/// Can be either a simple string (version) or a detailed options table. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum PackageSpec { + /// Simple form: just a version string (e.g., "1.8.1" or "*" for latest) + Simple(String), + /// Detailed form: full options table + Detailed(Box), +} + +impl Default for PackageSpec { + fn default() -> Self { + Self::Simple("*".to_string()) + } +} + +/// Full package options for detailed specification. +#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)] +pub struct PackageOptions { + /// Specific package ID (for disambiguation when multiple packages share the same name). + pub pkg_id: Option, + + /// Specific version to install. + pub version: Option, + + /// Repository to install from. + pub repo: Option, + + /// Whether to pin this package (prevents automatic updates). + #[serde(default)] + pub pinned: bool, + + /// Profile to install to (overrides default). + pub profile: Option, + + /// Portable directory configuration. + pub portable: Option, + + /// Custom install patterns (overrides default). + pub install_patterns: Option>, + + /// Whether to install binary only. + pub binary_only: Option, +} + +/// Portable directory configuration for a package. +#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)] +pub struct PortableConfig { + /// Base portable path (sets portable_home and portable_config). + pub path: Option, + + /// Portable home directory. + pub home: Option, + + /// Portable config directory. + pub config: Option, + + /// Portable share directory. + pub share: Option, + + /// Portable cache directory. + pub cache: Option, +} + +/// Resolved package specification with name included. +#[derive(Clone, Debug)] +pub struct ResolvedPackage { + pub name: String, + pub pkg_id: Option, + pub version: Option, + pub repo: Option, + pub pinned: bool, + pub profile: Option, + pub portable: Option, + pub install_patterns: Option>, + pub binary_only: bool, +} + +impl PackageSpec { + /// Resolve the package specification with defaults applied. + pub fn resolve(&self, name: &str, defaults: Option<&PackageDefaults>) -> ResolvedPackage { + match self { + PackageSpec::Simple(version_str) => { + let version = if version_str == "*" { + None + } else { + Some(version_str.clone()) + }; + let pinned = version.is_some(); + ResolvedPackage { + name: name.to_string(), + pkg_id: None, + version, + repo: None, + pinned, + profile: defaults.and_then(|d| d.profile.clone()), + portable: None, + install_patterns: defaults.and_then(|d| d.install_patterns.clone()), + binary_only: defaults.and_then(|d| d.binary_only).unwrap_or(false), + } + } + PackageSpec::Detailed(opts) => { + // Treat "*" as None (latest version) + let version = opts.version.as_ref().filter(|v| v.as_str() != "*").cloned(); + let pinned = opts.pinned || version.is_some(); + ResolvedPackage { + name: name.to_string(), + pkg_id: opts.pkg_id.clone(), + version, + repo: opts.repo.clone(), + pinned, + profile: opts + .profile + .clone() + .or_else(|| defaults.and_then(|d| d.profile.clone())), + portable: opts.portable.clone(), + install_patterns: opts + .install_patterns + .clone() + .or_else(|| defaults.and_then(|d| d.install_patterns.clone())), + binary_only: opts + .binary_only + .or_else(|| defaults.and_then(|d| d.binary_only)) + .unwrap_or(false), + } + } + } + } +} + +impl PackagesConfig { + /// Load packages configuration from file. + pub fn load(path: Option<&str>) -> Result { + let config_path = match path { + Some(p) => PathBuf::from(p), + None => PACKAGES_CONFIG_PATH.read().unwrap().clone(), + }; + + if !config_path.exists() { + return Err(ConfigError::PackagesConfigNotFound( + config_path.display().to_string(), + )); + } + + let content = fs::read_to_string(&config_path)?; + let config: PackagesConfig = toml::from_str(&content)?; + Ok(config) + } + + /// Get all packages resolved with defaults applied. + pub fn resolved_packages(&self) -> Vec { + self.packages + .iter() + .map(|(name, spec): (&String, &PackageSpec)| spec.resolve(name, self.defaults.as_ref())) + .collect() + } + + /// Create a default configuration. + pub fn default_config() -> Self { + Self { + defaults: Some(PackageDefaults { + profile: Some("default".to_string()), + binary_only: Some(false), + install_patterns: None, + }), + packages: HashMap::new(), + } + } + + /// Convert config to an annotated TOML document with field documentation. + pub fn to_annotated_document(&self) -> Result { + use toml_edit::Item; + + let toml_string = toml::to_string_pretty(self)?; + let mut doc = toml_string.parse::()?; + + let header = r#"# Soar Declarative Package Configuration +# Run `soar apply` to install packages defined here. +# Run `soar apply --prune` to also remove packages not listed. +# +# Package format: +# 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 = { pinned = true, portable = { home = "~/.pkg" } } + +"#; + doc.as_table_mut().decor_mut().set_prefix(header); + + annotate_toml_table::(doc.as_table_mut(), true)?; + + if let Some(Item::Table(defaults_table)) = doc.get_mut("defaults") { + annotate_toml_table::(defaults_table, false)?; + } + + Ok(doc) + } +} + +/// Generate a default packages configuration file. +pub fn generate_default_packages_config() -> Result<()> { + let config_path = PACKAGES_CONFIG_PATH.read().unwrap().clone(); + + if config_path.exists() { + return Err(ConfigError::PackagesConfigAlreadyExists); + } + + let def_config = PackagesConfig::default_config(); + let annotated_doc = def_config.to_annotated_document()?; + + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(&config_path, annotated_doc.to_string())?; + info!( + "Default packages configuration generated at: {}", + config_path.display() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_package_spec() { + let toml_str = r#" +[packages] +curl = "*" +jq = "1.8.1" +"#; + let config: PackagesConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.packages.len(), 2); + + let resolved = config.resolved_packages(); + let curl = resolved.iter().find(|p| p.name == "curl").unwrap(); + let jq = resolved.iter().find(|p| p.name == "jq").unwrap(); + + assert_eq!(curl.version, None); + assert_eq!(jq.version, Some("1.8.1".to_string())); + } + + #[test] + fn test_detailed_package_spec() { + let toml_str = r#" +[packages] +neovim = { pkg_id = "neovim-appimage", repo = "bincache", 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].repo, Some("bincache".to_string())); + assert!(resolved[0].pinned); + } + + #[test] + fn test_defaults_applied() { + let toml_str = r#" +[defaults] +profile = "work" +binary_only = true + +[packages] +curl = "*" +"#; + let config: PackagesConfig = toml::from_str(toml_str).unwrap(); + let resolved = config.resolved_packages(); + + assert_eq!(resolved[0].profile, Some("work".to_string())); + assert!(resolved[0].binary_only); + } + + #[test] + fn test_package_override_defaults() { + let toml_str = r#" +[defaults] +profile = "default" + +[packages] +special = { profile = "isolated" } +"#; + let config: PackagesConfig = toml::from_str(toml_str).unwrap(); + let resolved = config.resolved_packages(); + + assert_eq!(resolved[0].profile, Some("isolated".to_string())); + } + + #[test] + fn test_portable_config() { + let toml_str = r#" +[packages] +firefox = { portable = { home = "~/.firefox-home", config = "~/.firefox-config" } } +"#; + let config: PackagesConfig = toml::from_str(toml_str).unwrap(); + let resolved = config.resolved_packages(); + + let portable = resolved[0].portable.as_ref().unwrap(); + assert_eq!(portable.home, Some("~/.firefox-home".to_string())); + assert_eq!(portable.config, Some("~/.firefox-config".to_string())); + } + + #[test] + fn test_annotated_document() { + let config = PackagesConfig::default_config(); + let doc = config.to_annotated_document(); + + assert!(doc.is_ok()); + let doc_str = doc.unwrap().to_string(); + + // Should contain header + assert!(doc_str.contains("Soar Declarative Package Configuration")); + assert!(doc_str.contains("soar apply")); + + // Should contain field documentation comments + assert!(doc_str.contains("#")); + } +} diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index b459dc67d..e6287bb20 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -385,7 +385,6 @@ impl CoreRepository { .filter(packages::repo_name.eq(repo_name)) .filter(packages::pkg_name.eq(pkg_name)) .filter(packages::pkg_id.eq(pkg_id)) - .filter(packages::pinned.eq(false)) .filter(packages::version.eq(version)), ) .set(( From c8470d07835de7e96186bcac461788773ed5e903 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 23 Dec 2025 12:21:49 +0545 Subject: [PATCH 2/2] chore: release --- CHANGELOG.md | 14 ++++++++++++++ Cargo.lock | 4 ++-- crates/soar-cli/Cargo.toml | 2 +- crates/soar-core/CHANGELOG.md | 10 ++++++++++ crates/soar-core/Cargo.toml | 2 +- 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5535a7389..442da1d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,18 @@ +## [0.8.2](https://github.com/pkgforge/soar/compare/v0.8.1...v0.8.2) - 2025-12-23 + +### ⛰️ Features + +- *(packages)* Add declarative installation - ([cfd0860](https://github.com/pkgforge/soar/commit/cfd0860e7b3bfdc2b14360baf7ac02ddbf061a90)) + +### 🚜 Refactor + +- *(integration)* Integrate soar with modular crates ([#123](https://github.com/pkgforge/soar/pull/123)) - ([2d340e5](https://github.com/pkgforge/soar/commit/2d340e54ac79fd31087370712f4e189b3391bd16)) + +### ⚙️ Miscellaneous Tasks + +- *(docs)* Fix readme - ([90d8abb](https://github.com/pkgforge/soar/commit/90d8abb9206a304be4c3d8cd5d11ae40584242d6)) + ## [0.8.1](https://github.com/pkgforge/soar/compare/v0.8.0...v0.8.1) - 2025-09-19 ### 🐛 Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index fbc64699f..bf4bc4243 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2151,7 +2151,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "soar-cli" -version = "0.8.1" +version = "0.8.2" dependencies = [ "clap", "indicatif", @@ -2197,7 +2197,7 @@ dependencies = [ [[package]] name = "soar-core" -version = "0.8.1" +version = "0.9.0" dependencies = [ "chrono", "diesel", diff --git a/crates/soar-cli/Cargo.toml b/crates/soar-cli/Cargo.toml index 21f72237e..275afe37c 100644 --- a/crates/soar-cli/Cargo.toml +++ b/crates/soar-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "soar-cli" -version = "0.8.1" +version = "0.8.2" description = "A modern package manager for Linux" default-run = "soar" authors.workspace = true diff --git a/crates/soar-core/CHANGELOG.md b/crates/soar-core/CHANGELOG.md index 0ad1cc6ad..d504ac24e 100644 --- a/crates/soar-core/CHANGELOG.md +++ b/crates/soar-core/CHANGELOG.md @@ -1,4 +1,14 @@ +## [0.9.0](https://github.com/pkgforge/soar/compare/soar-core-v0.8.1...soar-core-v0.9.0) - 2025-12-23 + +### 🚜 Refactor + +- *(integration)* Integrate soar with modular crates ([#123](https://github.com/pkgforge/soar/pull/123)) - ([2d340e5](https://github.com/pkgforge/soar/commit/2d340e54ac79fd31087370712f4e189b3391bd16)) + +### ⚙️ Miscellaneous Tasks + +- *(docs)* Fix readme - ([90d8abb](https://github.com/pkgforge/soar/commit/90d8abb9206a304be4c3d8cd5d11ae40584242d6)) + ## [0.8.1](https://github.com/pkgforge/soar/compare/soar-core-v0.8.0...soar-core-v0.8.1) - 2025-09-19 ### 🐛 Bug Fixes diff --git a/crates/soar-core/Cargo.toml b/crates/soar-core/Cargo.toml index bd64de9c1..7760e7cee 100644 --- a/crates/soar-core/Cargo.toml +++ b/crates/soar-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "soar-core" -version = "0.8.1" +version = "0.9.0" description = "Core library for soar package manager" authors.workspace = true license.workspace = true