diff --git a/crates/aim-cli/src/cli/args.rs b/crates/aim-cli/src/cli/args.rs index f2c37fd..e4bd264 100644 --- a/crates/aim-cli/src/cli/args.rs +++ b/crates/aim-cli/src/cli/args.rs @@ -18,8 +18,7 @@ pub struct Cli { impl Cli { pub fn is_review_update_flow(&self) -> bool { - matches!(self.command, Some(Command::Update)) - || (self.command.is_none() && self.query.is_none()) + self.command.is_none() && self.query.is_none() } } diff --git a/crates/aim-cli/src/lib.rs b/crates/aim-cli/src/lib.rs index 1e34ae5..90da746 100644 --- a/crates/aim-cli/src/lib.rs +++ b/crates/aim-cli/src/lib.rs @@ -9,9 +9,9 @@ use aim_core::app::add::{ }; use aim_core::app::list::{ListRow, build_list_rows}; use aim_core::app::remove::{RemovalResult, remove_registered_app}; -use aim_core::app::update::build_update_plan; +use aim_core::app::update::{build_update_plan, execute_updates}; use aim_core::domain::app::AppRecord; -use aim_core::domain::update::UpdatePlan; +use aim_core::domain::update::{UpdateExecutionResult, UpdatePlan}; use aim_core::registry::model::Registry; use aim_core::registry::store::RegistryStore; @@ -44,7 +44,15 @@ pub fn dispatch(cli: Cli) -> Result { })?; Ok(DispatchResult::Removed(Box::new(removal))) } - cli::args::Command::Update => Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?)), + cli::args::Command::Update => { + let updates = execute_updates(&apps, &install_home)?; + let updated_apps = updates.apps.clone(); + store.save(&Registry { + version: registry.version, + apps: updated_apps, + })?; + Ok(DispatchResult::Updated(Box::new(updates))) + } }; } @@ -94,6 +102,7 @@ pub enum DispatchResult { PendingAdd(Box), Removed(Box), UpdatePlan(UpdatePlan), + Updated(Box), Noop, } @@ -105,6 +114,7 @@ pub enum DispatchError { RemovePlan(aim_core::app::remove::RemoveRegisteredAppError), Registry(aim_core::registry::store::RegistryStoreError), UpdatePlan(aim_core::app::update::BuildUpdatePlanError), + UpdateExecution(aim_core::app::update::ExecuteUpdatesError), } impl From for DispatchError { @@ -131,6 +141,12 @@ impl From for DispatchError { } } +impl From for DispatchError { + fn from(value: aim_core::app::update::ExecuteUpdatesError) -> Self { + Self::UpdateExecution(value) + } +} + impl From for DispatchError { fn from(value: aim_core::app::remove::RemoveRegisteredAppError) -> Self { Self::RemovePlan(value) diff --git a/crates/aim-cli/src/ui/render.rs b/crates/aim-cli/src/ui/render.rs index e1d9ccb..2457055 100644 --- a/crates/aim-cli/src/ui/render.rs +++ b/crates/aim-cli/src/ui/render.rs @@ -1,4 +1,5 @@ use aim_core::app::add::AddPlan; +use aim_core::domain::update::UpdateExecutionStatus; use crate::DispatchResult; @@ -15,6 +16,7 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String { DispatchResult::UpdatePlan(plan) => { render_update_summary(plan.items.len(), plan.items.len(), 0) } + DispatchResult::Updated(result) => render_updated_apps(result), DispatchResult::Noop => String::new(), } } @@ -88,3 +90,29 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String format!("{summary}\n{warning_lines}") } } + +fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String { + let mut lines = vec![format!( + "updated apps: {}, failed: {}", + result.updated_count(), + result.failed_count() + )]; + + for item in &result.items { + match &item.status { + UpdateExecutionStatus::Updated => lines.push(format!( + "updated: {} ({}) {} -> {}", + item.display_name, + item.stable_id, + item.from_version.as_deref().unwrap_or("unknown"), + item.to_version.as_deref().unwrap_or("unknown") + )), + UpdateExecutionStatus::Failed { reason } => lines.push(format!( + "failed: {} ({}) {}", + item.display_name, item.stable_id, reason + )), + } + } + + lines.join("\n") +} diff --git a/crates/aim-cli/tests/end_to_end_cli.rs b/crates/aim-cli/tests/end_to_end_cli.rs index 6d33298..eba472b 100644 --- a/crates/aim-cli/tests/end_to_end_cli.rs +++ b/crates/aim-cli/tests/end_to_end_cli.rs @@ -1,4 +1,8 @@ +use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope}; +use aim_core::registry::model::Registry; +use aim_core::registry::store::RegistryStore; use assert_cmd::Command; +use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use tempfile::tempdir; @@ -179,3 +183,49 @@ fn system_request_on_immutable_host_falls_back_to_user_install() { .stdout(contains("installing as user")) .stdout(contains("downgraded to user scope")); } + +#[test] +fn update_command_applies_updates() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let payload_path = dir + .path() + .join("install-home/.local/lib/aim/appimages/pingdotgg-t3code.AppImage"); + let store = RegistryStore::new(registry_path.clone()); + store + .save(&Registry { + version: 1, + apps: vec![AppRecord { + stable_id: "pingdotgg-t3code".to_owned(), + display_name: "t3code".to_owned(), + source_input: Some("pingdotgg/t3code".to_owned()), + source: None, + installed_version: Some("0.0.11".to_owned()), + update_strategy: None, + metadata: Vec::new(), + install: Some(InstallMetadata { + scope: InstallScope::User, + payload_path: None, + desktop_entry_path: None, + icon_path: None, + }), + }], + }) + .unwrap(); + + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("update") + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("updated apps: 1")) + .stdout(contains("updates found:").not()); + + let updated = store.load().unwrap(); + assert_eq!(updated.apps.len(), 1); + assert_eq!(updated.apps[0].stable_id, "pingdotgg-t3code"); + assert_eq!(updated.apps[0].installed_version.as_deref(), Some("0.0.12")); + assert!(payload_path.exists()); +} diff --git a/crates/aim-core/src/adapters/custom_json.rs b/crates/aim-core/src/adapters/custom_json.rs index 7c1eebf..0a5cbde 100644 --- a/crates/aim-core/src/adapters/custom_json.rs +++ b/crates/aim-core/src/adapters/custom_json.rs @@ -1,4 +1,7 @@ -use crate::adapters::traits::{AdapterCapabilities, SourceAdapter}; +use crate::adapters::traits::{ + AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter, +}; +use crate::domain::source::SourceRef; pub struct CustomJsonAdapter; @@ -10,4 +13,12 @@ impl SourceAdapter for CustomJsonAdapter { fn capabilities(&self) -> AdapterCapabilities { AdapterCapabilities::exact_resolution_only() } + + fn normalize(&self, _query: &str) -> Result { + Err(AdapterError::UnsupportedQuery) + } + + fn resolve(&self, _source: &SourceRef) -> Result { + Err(AdapterError::UnsupportedSource) + } } diff --git a/crates/aim-core/src/adapters/direct_url.rs b/crates/aim-core/src/adapters/direct_url.rs index 0f8cec1..4985dd5 100644 --- a/crates/aim-core/src/adapters/direct_url.rs +++ b/crates/aim-core/src/adapters/direct_url.rs @@ -1,12 +1,32 @@ -use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter}; +use crate::adapters::traits::{ + AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter, +}; +use crate::app::query::resolve_query; use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; pub struct DirectUrlAdapter; -impl DirectUrlAdapter { - pub fn resolve(&self, source: &SourceRef) -> Result { +impl SourceAdapter for DirectUrlAdapter { + fn id(&self) -> &'static str { + "direct-url" + } + + fn capabilities(&self) -> AdapterCapabilities { + AdapterCapabilities::exact_resolution_only() + } + + fn normalize(&self, query: &str) -> Result { + let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?; if source.kind != SourceKind::DirectUrl { - return Err(DirectUrlAdapterError::UnsupportedSource); + return Err(AdapterError::UnsupportedQuery); + } + + Ok(source) + } + + fn resolve(&self, source: &SourceRef) -> Result { + if source.kind != SourceKind::DirectUrl { + return Err(AdapterError::UnsupportedSource); } Ok(AdapterResolution { @@ -18,18 +38,3 @@ impl DirectUrlAdapter { }) } } - -impl SourceAdapter for DirectUrlAdapter { - fn id(&self) -> &'static str { - "direct-url" - } - - fn capabilities(&self) -> AdapterCapabilities { - AdapterCapabilities::exact_resolution_only() - } -} - -#[derive(Debug, Eq, PartialEq)] -pub enum DirectUrlAdapterError { - UnsupportedSource, -} diff --git a/crates/aim-core/src/adapters/github.rs b/crates/aim-core/src/adapters/github.rs index 02e3669..5428a11 100644 --- a/crates/aim-core/src/adapters/github.rs +++ b/crates/aim-core/src/adapters/github.rs @@ -1,4 +1,6 @@ -use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter}; +use crate::adapters::traits::{ + AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter, +}; use crate::app::query::resolve_query; use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; @@ -14,24 +16,6 @@ impl GitHubAdapter { pub fn new() -> Self { Self } - - pub fn resolve(&self, source: &SourceRef) -> Result { - if source.kind != SourceKind::GitHub { - return Err(GitHubAdapterError::UnsupportedSource); - } - - Ok(AdapterResolution { - source: source.clone(), - release: ResolvedRelease { - version: "latest".to_owned(), - prerelease: false, - }, - }) - } - - pub fn normalize(&self, query: &str) -> Result { - resolve_query(query).map_err(|_| GitHubAdapterError::UnsupportedSource) - } } impl SourceAdapter for GitHubAdapter { @@ -45,9 +29,27 @@ impl SourceAdapter for GitHubAdapter { supports_exact_resolution: true, } } -} -#[derive(Debug, Eq, PartialEq)] -pub enum GitHubAdapterError { - UnsupportedSource, + fn normalize(&self, query: &str) -> Result { + let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?; + if source.kind != SourceKind::GitHub { + return Err(AdapterError::UnsupportedQuery); + } + + Ok(source) + } + + fn resolve(&self, source: &SourceRef) -> Result { + if source.kind != SourceKind::GitHub { + return Err(AdapterError::UnsupportedSource); + } + + Ok(AdapterResolution { + source: source.clone(), + release: ResolvedRelease { + version: "latest".to_owned(), + prerelease: false, + }, + }) + } } diff --git a/crates/aim-core/src/adapters/gitlab.rs b/crates/aim-core/src/adapters/gitlab.rs index 5e77cd5..2f65b79 100644 --- a/crates/aim-core/src/adapters/gitlab.rs +++ b/crates/aim-core/src/adapters/gitlab.rs @@ -1,24 +1,11 @@ -use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter}; +use crate::adapters::traits::{ + AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter, +}; +use crate::app::query::resolve_query; use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; pub struct GitLabAdapter; -impl GitLabAdapter { - pub fn resolve(&self, source: &SourceRef) -> Result { - if source.kind != SourceKind::GitLab { - return Err(GitLabAdapterError::UnsupportedSource); - } - - Ok(AdapterResolution { - source: source.clone(), - release: ResolvedRelease { - version: "latest".to_owned(), - prerelease: false, - }, - }) - } -} - impl SourceAdapter for GitLabAdapter { fn id(&self) -> &'static str { "gitlab" @@ -30,9 +17,27 @@ impl SourceAdapter for GitLabAdapter { supports_exact_resolution: true, } } -} -#[derive(Debug, Eq, PartialEq)] -pub enum GitLabAdapterError { - UnsupportedSource, + fn normalize(&self, query: &str) -> Result { + let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?; + if source.kind != SourceKind::GitLab { + return Err(AdapterError::UnsupportedQuery); + } + + Ok(source) + } + + fn resolve(&self, source: &SourceRef) -> Result { + if source.kind != SourceKind::GitLab { + return Err(AdapterError::UnsupportedSource); + } + + Ok(AdapterResolution { + source: source.clone(), + release: ResolvedRelease { + version: "latest".to_owned(), + prerelease: false, + }, + }) + } } diff --git a/crates/aim-core/src/adapters/sourceforge.rs b/crates/aim-core/src/adapters/sourceforge.rs index 63729ba..566b7f9 100644 --- a/crates/aim-core/src/adapters/sourceforge.rs +++ b/crates/aim-core/src/adapters/sourceforge.rs @@ -1,4 +1,7 @@ -use crate::adapters::traits::{AdapterCapabilities, SourceAdapter}; +use crate::adapters::traits::{ + AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter, +}; +use crate::domain::source::SourceRef; pub struct SourceForgeAdapter; @@ -13,4 +16,12 @@ impl SourceAdapter for SourceForgeAdapter { supports_exact_resolution: true, } } + + fn normalize(&self, _query: &str) -> Result { + Err(AdapterError::UnsupportedQuery) + } + + fn resolve(&self, _source: &SourceRef) -> Result { + Err(AdapterError::UnsupportedSource) + } } diff --git a/crates/aim-core/src/adapters/test_support.rs b/crates/aim-core/src/adapters/test_support.rs index 57424d8..cfeaf6b 100644 --- a/crates/aim-core/src/adapters/test_support.rs +++ b/crates/aim-core/src/adapters/test_support.rs @@ -1,5 +1,7 @@ -use crate::adapters::traits::AdapterCapabilities; -use crate::adapters::traits::SourceAdapter; +use crate::adapters::traits::{ + AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter, +}; +use crate::domain::source::SourceRef; #[derive(Debug)] pub struct MockAdapter { @@ -24,4 +26,12 @@ impl SourceAdapter for MockAdapter { fn capabilities(&self) -> AdapterCapabilities { self.capabilities } + + fn normalize(&self, _query: &str) -> Result { + Err(AdapterError::UnsupportedQuery) + } + + fn resolve(&self, _source: &SourceRef) -> Result { + Err(AdapterError::UnsupportedSource) + } } diff --git a/crates/aim-core/src/adapters/traits.rs b/crates/aim-core/src/adapters/traits.rs index 96dc529..6f0cc77 100644 --- a/crates/aim-core/src/adapters/traits.rs +++ b/crates/aim-core/src/adapters/traits.rs @@ -22,8 +22,19 @@ pub struct AdapterResolution { pub release: ResolvedRelease, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AdapterError { + UnsupportedQuery, + UnsupportedSource, + ResolutionFailed(String), +} + pub trait SourceAdapter { fn id(&self) -> &'static str; fn capabilities(&self) -> AdapterCapabilities; + + fn normalize(&self, query: &str) -> Result; + + fn resolve(&self, source: &SourceRef) -> Result; } diff --git a/crates/aim-core/src/adapters/zsync.rs b/crates/aim-core/src/adapters/zsync.rs index a66fa0c..36f0a7a 100644 --- a/crates/aim-core/src/adapters/zsync.rs +++ b/crates/aim-core/src/adapters/zsync.rs @@ -1,4 +1,7 @@ -use crate::adapters::traits::{AdapterCapabilities, SourceAdapter}; +use crate::adapters::traits::{ + AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter, +}; +use crate::domain::source::SourceRef; pub struct ZsyncAdapter; @@ -10,4 +13,12 @@ impl SourceAdapter for ZsyncAdapter { fn capabilities(&self) -> AdapterCapabilities { AdapterCapabilities::exact_resolution_only() } + + fn normalize(&self, _query: &str) -> Result { + Err(AdapterError::UnsupportedQuery) + } + + fn resolve(&self, _source: &SourceRef) -> Result { + Err(AdapterError::UnsupportedSource) + } } diff --git a/crates/aim-core/src/app/update.rs b/crates/aim-core/src/app/update.rs index bef2618..83ddbda 100644 --- a/crates/aim-core/src/app/update.rs +++ b/crates/aim-core/src/app/update.rs @@ -1,5 +1,11 @@ -use crate::domain::app::AppRecord; -use crate::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan}; +use std::path::Path; + +use crate::app::add::{build_add_plan, install_app}; +use crate::domain::app::{AppRecord, InstallScope}; +use crate::domain::update::{ + ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult, + UpdateExecutionStatus, UpdatePlan, +}; pub fn build_update_plan(apps: &[AppRecord]) -> Result { Ok(UpdatePlan { @@ -7,9 +13,59 @@ pub fn build_update_plan(apps: &[AppRecord]) -> Result Result { + let mut updated_apps = Vec::with_capacity(apps.len()); + let mut items = Vec::with_capacity(apps.len()); + + for app in apps { + match execute_update(app, install_home) { + Ok(updated) => { + let warnings = updated + .warnings + .iter() + .chain(updated.install_outcome.warnings.iter()) + .cloned() + .collect(); + let record = updated.record; + items.push(ExecutedUpdate { + stable_id: app.stable_id.clone(), + display_name: app.display_name.clone(), + from_version: app.installed_version.clone(), + to_version: record.installed_version.clone(), + warnings, + status: UpdateExecutionStatus::Updated, + }); + updated_apps.push(record); + } + Err(reason) => { + items.push(ExecutedUpdate { + stable_id: app.stable_id.clone(), + display_name: app.display_name.clone(), + from_version: app.installed_version.clone(), + to_version: app.installed_version.clone(), + warnings: Vec::new(), + status: UpdateExecutionStatus::Failed { reason }, + }); + updated_apps.push(app.clone()); + } + } + } + + Ok(UpdateExecutionResult { + apps: updated_apps, + items, + }) +} + #[derive(Debug, Eq, PartialEq)] pub enum BuildUpdatePlanError {} +#[derive(Debug, Eq, PartialEq)] +pub enum ExecuteUpdatesError {} + fn plan_update(app: &AppRecord) -> PlannedUpdate { let (selected_channel, selection_reason) = if let Some(strategy) = &app.update_strategy { if strategy.preferred.locator.contains("fail") { @@ -47,3 +103,31 @@ fn plan_update(app: &AppRecord) -> PlannedUpdate { selection_reason, } } + +fn execute_update( + app: &AppRecord, + install_home: &Path, +) -> Result { + let query = update_query(app).ok_or_else(|| "missing install source".to_owned())?; + let requested_scope = app + .install + .as_ref() + .map(|install| install.scope) + .unwrap_or(InstallScope::User); + let plan = build_add_plan(&query) + .map_err(|error| format!("failed to build update plan: {error:?}"))?; + + install_app(&query, &plan, install_home, requested_scope) + .map_err(|error| format!("failed to install update: {error:?}")) +} + +fn update_query(app: &AppRecord) -> Option { + app.source_input.clone().or_else(|| { + app.source.as_ref().map(|source| { + source + .canonical_locator + .clone() + .unwrap_or_else(|| source.locator.clone()) + }) + }) +} diff --git a/crates/aim-core/src/domain/update.rs b/crates/aim-core/src/domain/update.rs index cb63d22..7f3a835 100644 --- a/crates/aim-core/src/domain/update.rs +++ b/crates/aim-core/src/domain/update.rs @@ -1,3 +1,5 @@ +use crate::domain::app::AppRecord; + #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub enum ParsedMetadataKind { Unknown, @@ -98,3 +100,41 @@ pub struct PlannedUpdate { pub selected_channel: ChannelPreference, pub selection_reason: String, } + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpdateExecutionResult { + pub apps: Vec, + pub items: Vec, +} + +impl UpdateExecutionResult { + pub fn updated_count(&self) -> usize { + self.items + .iter() + .filter(|item| item.status == UpdateExecutionStatus::Updated) + .count() + } + + pub fn failed_count(&self) -> usize { + self.items + .iter() + .filter(|item| matches!(item.status, UpdateExecutionStatus::Failed { .. })) + .count() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExecutedUpdate { + pub stable_id: String, + pub display_name: String, + pub from_version: Option, + pub to_version: Option, + pub warnings: Vec, + pub status: UpdateExecutionStatus, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UpdateExecutionStatus { + Updated, + Failed { reason: String }, +} diff --git a/crates/aim-core/tests/adapter_contract.rs b/crates/aim-core/tests/adapter_contract.rs index 1529e48..328a803 100644 --- a/crates/aim-core/tests/adapter_contract.rs +++ b/crates/aim-core/tests/adapter_contract.rs @@ -1,5 +1,6 @@ use aim_core::adapters::github::GitHubAdapter; -use aim_core::adapters::traits::AdapterCapabilities; +use aim_core::adapters::gitlab::GitLabAdapter; +use aim_core::adapters::traits::{AdapterCapabilities, SourceAdapter}; #[test] fn adapter_capabilities_can_report_exact_resolution_only() { @@ -9,10 +10,27 @@ fn adapter_capabilities_can_report_exact_resolution_only() { #[test] fn legacy_github_adapter_delegates_to_source_pipeline() { - let adapter = GitHubAdapter; + let adapter: &dyn SourceAdapter = &GitHubAdapter; let result = adapter.normalize("sharkdp/bat").unwrap(); assert_eq!(result.normalized_kind.as_str(), "github-repository"); assert_eq!(result.canonical_locator.as_deref(), Some("sharkdp/bat")); + + let resolution = adapter.resolve(&result).unwrap(); + assert_eq!(resolution.release.version, "latest"); +} + +#[test] +fn gitlab_adapter_normalizes_and_resolves_through_trait() { + let adapter: &dyn SourceAdapter = &GitLabAdapter; + + let result = adapter + .normalize("https://gitlab.com/example/team/app") + .unwrap(); + + assert_eq!(result.kind.as_str(), "gitlab"); + + let resolution = adapter.resolve(&result).unwrap(); + assert_eq!(resolution.release.version, "latest"); } diff --git a/crates/aim-core/tests/update_planning.rs b/crates/aim-core/tests/update_planning.rs index 177d1b5..f3c7bae 100644 --- a/crates/aim-core/tests/update_planning.rs +++ b/crates/aim-core/tests/update_planning.rs @@ -1,6 +1,7 @@ -use aim_core::app::update::build_update_plan; -use aim_core::domain::app::AppRecord; +use aim_core::app::update::{build_update_plan, execute_updates}; +use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope}; use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy}; +use tempfile::tempdir; #[test] fn empty_registry_produces_empty_plan() { @@ -61,3 +62,29 @@ fn update_plan_uses_alternate_channel_after_preferred_failure() { ); assert_eq!(plan.items[0].selection_reason, "preferred-channel-failed"); } + +#[test] +fn failed_update_keeps_previous_app_record() { + let install_home = tempdir().unwrap(); + let previous = AppRecord { + stable_id: "legacy-bat".to_owned(), + display_name: "Legacy Bat".to_owned(), + source_input: None, + source: None, + installed_version: Some("0.9.0".to_owned()), + update_strategy: None, + metadata: Vec::new(), + install: Some(InstallMetadata { + scope: InstallScope::User, + payload_path: None, + desktop_entry_path: None, + icon_path: None, + }), + }; + + let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap(); + + assert_eq!(result.apps, vec![previous]); + assert_eq!(result.updated_count(), 0); + assert_eq!(result.failed_count(), 1); +}