From 842c3902605856e99b9252bc209012ddd6c523e7 Mon Sep 17 00:00:00 2001 From: stoorps Date: Thu, 19 Mar 2026 23:07:25 +0000 Subject: [PATCH] feat: implement uninstall functionality for managed artifacts and persist install metadata --- ...-03-19-remove-uninstall-metadata-design.md | 166 ++++++++++++++++ ...-uninstall-metadata-implementation-plan.md | 181 ++++++++++++++++++ crates/aim-cli/src/lib.rs | 17 +- crates/aim-cli/src/ui/render.rs | 18 +- crates/aim-cli/tests/end_to_end_cli.rs | 34 ++++ crates/aim-core/src/app/add.rs | 20 +- crates/aim-core/src/app/remove.rs | 80 +++++++- crates/aim-core/src/domain/app.rs | 15 +- crates/aim-core/tests/registry_roundtrip.rs | 50 +++++ crates/aim-core/tests/remove_flow.rs | 64 ++++++- crates/aim-core/tests/update_planning.rs | 2 + 11 files changed, 626 insertions(+), 21 deletions(-) create mode 100644 .plans/003-remove-uninstall-metadata/2026-03-19-remove-uninstall-metadata-design.md create mode 100644 .plans/003-remove-uninstall-metadata/2026-03-19-remove-uninstall-metadata-implementation-plan.md diff --git a/.plans/003-remove-uninstall-metadata/2026-03-19-remove-uninstall-metadata-design.md b/.plans/003-remove-uninstall-metadata/2026-03-19-remove-uninstall-metadata-design.md new file mode 100644 index 0000000..e1aecb1 --- /dev/null +++ b/.plans/003-remove-uninstall-metadata/2026-03-19-remove-uninstall-metadata-design.md @@ -0,0 +1,166 @@ +# Remove Uninstall Metadata Design + +## Goal + +Make `aim remove` uninstall `aim`-managed app artifacts rather than only unregistering apps, while preserving backward compatibility for existing registry entries and keeping uninstall logic in `aim-core`. + +## Agreed Product Shape + +### Remove behavior + +- `aim remove ` should delete the tracked app from the registry +- It should also remove `aim`-managed installed artifacts for that app +- Missing artifact files should not make removal fail +- Desktop refresh helper failures should surface as warnings rather than block removal + +### Metadata model + +- Persist install metadata on successful install +- Persist exact installed paths for payload, desktop entry, and icon +- Persist the effective install scope +- Keep install metadata optional so old registry entries still deserialize cleanly + +### Compatibility posture + +- New installs should uninstall using persisted exact paths +- Existing registry entries without install metadata should fall back to derived `aim`-managed paths +- Uninstall should only target paths that belong to `aim` management, not arbitrary external files + +## Recommended Approach + +Use a hybrid uninstall model. + +This combines exact persisted install metadata for newly installed apps with a conservative derived-path fallback for legacy registry entries. It avoids breaking older registries while making future uninstall behavior precise and resilient to layout changes. + +The model is: + +- successful install persists `InstallMetadata` into `AppRecord` +- remove resolves uninstall targets from metadata when present +- remove falls back to `aim`-managed path derivation when metadata is absent +- uninstall deletes payload, desktop entry, and icon, then refreshes desktop caches best-effort + +## Data Model + +### `InstallMetadata` + +Persist this on each installed app record: + +- `scope` +- `payload_path` +- `desktop_entry_path` +- `icon_path` + +Store these as optional path strings under an optional `install` field on `AppRecord`. + +### Backward compatibility + +- `AppRecord.install` is optional +- legacy records continue to load unchanged +- remove derives fallback targets from `stable_id` and inferred scope when `install` is absent + +## Remove Execution Model + +### 1. Resolve the app + +- Match the registered app by stable id or display name +- Preserve existing ambiguity behavior + +### 2. Resolve uninstall targets + +Prefer persisted metadata: + +- exact payload path +- exact desktop entry path +- exact icon path +- persisted scope + +Fallback for legacy records: + +- payload path from `managed_appimage_path(...)` +- desktop entry path from `desktop_entry_path(...)` +- icon path from `icon_path(...)` +- scope inferred conservatively, defaulting to user-managed locations when exact scope is unknown + +### 3. Delete managed artifacts + +Delete only `aim`-managed artifacts: + +- AppImage payload +- generated `.desktop` file +- generated icon + +Rules: + +- treat missing files as already removed +- collect deleted paths for reporting +- do not attempt to delete non-`aim` unmanaged locations + +### 4. Refresh desktop integration best-effort + +- probe helpers using the same capability model used by install +- run `update-desktop-database` and `gtk-update-icon-cache` when relevant +- return warnings instead of failing removal if refresh helpers are missing or fail + +### 5. Persist registry last + +- only save the registry after uninstall execution completes +- successful uninstall with warnings still removes the registry entry + +## Safety Model + +### Managed-only deletion + +Uninstall must only delete files that are known `aim` outputs: + +- persisted metadata generated by `aim` +- derived managed paths under `aim`-controlled naming conventions + +### Idempotency + +Repeated remove attempts should be safe: + +- missing files are ignored +- warnings can still be emitted for helper refresh failures +- registry result remains correct even if artifacts were already manually deleted + +### Failure handling + +- file deletion failures should stop removal and keep the app registered +- refresh failures should not block removal +- no rollback is needed for already-deleted files + +## Verification Strategy + +### Unit tests + +- install metadata round-trips through the registry model +- remove plan resolves exact persisted paths +- remove plan falls back to derived managed paths for legacy records + +### Integration tests + +- add then remove deletes payload, desktop entry, and icon +- remove tolerates already-missing artifacts +- remove emits warnings when refresh helpers are unavailable or fail +- legacy registry entries without install metadata still uninstall derived user-managed files + +## Architecture Slice + +### Core changes + +- extend `domain::app::AppRecord` with optional `InstallMetadata` +- populate `InstallMetadata` in the add/install path from `InstalledApp` +- extend `app::remove` with uninstall planning and execution +- reuse `integration::refresh` helper execution after deletion + +### CLI changes + +- keep CLI thin +- `aim-cli` should continue delegating uninstall behavior to `aim-core` +- render warnings from uninstall results if present + +## Non-Goals + +- deleting arbitrary external files not managed by `aim` +- adding a separate garbage collection command in this change +- refactoring install path layout beyond the metadata needed for uninstall diff --git a/.plans/003-remove-uninstall-metadata/2026-03-19-remove-uninstall-metadata-implementation-plan.md b/.plans/003-remove-uninstall-metadata/2026-03-19-remove-uninstall-metadata-implementation-plan.md new file mode 100644 index 0000000..580c7b3 --- /dev/null +++ b/.plans/003-remove-uninstall-metadata/2026-03-19-remove-uninstall-metadata-implementation-plan.md @@ -0,0 +1,181 @@ +# Remove Uninstall Metadata Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make `aim remove` uninstall `aim`-managed payload, desktop, and icon artifacts by persisting install metadata for new installs and falling back to derived managed paths for legacy registry entries. + +**Architecture:** Extend `AppRecord` with optional install metadata, populate it from the successful install result, then upgrade `app/remove.rs` from registry filtering to an uninstall executor that resolves managed targets, deletes artifacts, refreshes desktop integration best-effort, and only then persists the updated registry. Keep `aim-cli` thin by rendering the richer remove outcome returned by `aim-core`. + +**Tech Stack:** Rust, Cargo workspace, serde-backed registry persistence, std filesystem APIs, existing integration path and refresh helpers, fixture-backed tests in `crates/aim-core/tests` and `crates/aim-cli/tests`. + +--- + +### Task 1: Persist install metadata in the registry model + +**Files:** +- Modify: `crates/aim-core/src/domain/app.rs` +- Modify: `crates/aim-core/src/app/add.rs` +- Test: `crates/aim-core/tests/registry_roundtrip.rs` + +**Step 1: Write the failing test** + +Add a registry round-trip test that stores an app record with install metadata and asserts scope and file paths survive serialization. + +**Step 2: Run test to verify it fails** + +Run: `cargo test registry_round_trips_install_metadata --package aim-core --test registry_roundtrip` +Expected: FAIL because `AppRecord` does not yet have install metadata. + +**Step 3: Write minimal implementation** + +Add optional `InstallMetadata` and populate it from `InstalledApp` during add/install completion. + +**Step 4: Run test to verify it passes** + +Run: `cargo test registry_round_trips_install_metadata --package aim-core --test registry_roundtrip` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/domain/app.rs crates/aim-core/src/app/add.rs crates/aim-core/tests/registry_roundtrip.rs +git commit -m "feat: persist install metadata for installed apps" +``` + +### Task 2: Add failing remove tests for uninstall behavior + +**Files:** +- Modify: `crates/aim-core/tests/remove_flow.rs` +- Modify: `crates/aim-cli/tests/end_to_end_cli.rs` + +**Step 1: Write the failing tests** + +Add tests that assert: +- remove deletes persisted payload, desktop entry, and icon files +- remove falls back to derived managed paths for legacy records without install metadata +- CLI remove leaves no managed artifacts behind after add + remove + +**Step 2: Run tests to verify they fail** + +Run: `cargo test remove_deletes_installed_artifacts_from_metadata --package aim-core --test remove_flow` +Run: `cargo test remove_command_uninstalls_managed_files --package aim-cli --test end_to_end_cli` +Expected: FAIL because current remove only unregisters apps. + +**Step 3: Keep tests minimal and precise** + +Use fixture tempdirs and concrete file existence assertions. Avoid broad integration scaffolding beyond the exact managed artifact set. + +**Step 4: Re-run to confirm the red state is correct** + +Expected: still FAIL for missing uninstall behavior, not for unrelated setup issues. + +**Step 5: Commit** + +```bash +git add crates/aim-core/tests/remove_flow.rs crates/aim-cli/tests/end_to_end_cli.rs +git commit -m "test: cover uninstall behavior in remove flow" +``` + +### Task 3: Implement uninstall planning and execution in aim-core + +**Files:** +- Modify: `crates/aim-core/src/app/remove.rs` +- Modify: `crates/aim-core/src/integration/refresh.rs` +- Modify: `crates/aim-core/src/integration/paths.rs` +- Test: `crates/aim-core/tests/remove_flow.rs` + +**Step 1: Write the minimal implementation** + +Implement: +- uninstall target resolution from persisted metadata +- derived fallback targets for legacy records +- deletion of managed payload, desktop entry, and icon +- deleted-path reporting +- best-effort refresh warnings after deletion + +**Step 2: Run the focused core remove tests** + +Run: `cargo test remove_deletes_installed_artifacts_from_metadata --package aim-core --test remove_flow` +Run: `cargo test remove_falls_back_to_derived_managed_paths --package aim-core --test remove_flow` +Expected: PASS. + +**Step 3: Refine failure handling** + +Ensure: +- missing files are ignored +- deletion IO failures stop removal +- refresh failures become warnings only + +**Step 4: Re-run the remove test file** + +Run: `cargo test --package aim-core --test remove_flow` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/remove.rs crates/aim-core/src/integration/refresh.rs crates/aim-core/src/integration/paths.rs crates/aim-core/tests/remove_flow.rs +git commit -m "feat: uninstall managed artifacts during remove" +``` + +### Task 4: Surface uninstall results through the CLI + +**Files:** +- Modify: `crates/aim-cli/src/lib.rs` +- Modify: `crates/aim-cli/src/ui/render.rs` +- Test: `crates/aim-cli/tests/end_to_end_cli.rs` + +**Step 1: Write the minimal CLI integration** + +Return and render uninstall details from `aim-core`, including warnings when refresh helpers are unavailable or fail. + +**Step 2: Run the focused CLI tests** + +Run: `cargo test remove_command_uninstalls_managed_files --package aim-cli --test end_to_end_cli` +Expected: PASS. + +**Step 3: Preserve existing UX shape** + +Keep the CLI thin and avoid duplicating uninstall logic in `aim-cli`. + +**Step 4: Re-run the full CLI end-to-end test file** + +Run: `cargo test --package aim-cli --test end_to_end_cli` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-cli/src/lib.rs crates/aim-cli/src/ui/render.rs crates/aim-cli/tests/end_to_end_cli.rs +git commit -m "feat: render uninstall results from remove" +``` + +### Task 5: Run full workspace verification + +**Files:** +- Modify: none expected +- Test: workspace-wide verification + +**Step 1: Run formatting check** + +Run: `cargo fmt --check` +Expected: PASS. + +**Step 2: Run lints** + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` +Expected: PASS. + +**Step 3: Run tests** + +Run: `cargo test --workspace` +Expected: PASS. + +**Step 4: If any failure appears, fix only the uninstall-metadata related issue and re-run verification** + +**Step 5: Commit** + +```bash +git add -A +git commit -m "test: verify uninstall metadata remove flow" +``` diff --git a/crates/aim-cli/src/lib.rs b/crates/aim-cli/src/lib.rs index fbb0870..1e34ae5 100644 --- a/crates/aim-cli/src/lib.rs +++ b/crates/aim-cli/src/lib.rs @@ -8,7 +8,7 @@ use aim_core::app::add::{ AddPlan, InstalledApp, build_add_plan, install_app, resolve_requested_scope, }; use aim_core::app::list::{ListRow, build_list_rows}; -use aim_core::app::remove::remove_registered_app; +use aim_core::app::remove::{RemovalResult, remove_registered_app}; use aim_core::app::update::build_update_plan; use aim_core::domain::app::AppRecord; use aim_core::domain::update::UpdatePlan; @@ -36,12 +36,13 @@ pub fn dispatch(cli: Cli) -> Result { return match command { cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))), cli::args::Command::Remove { query } => { - let removal = remove_registered_app(&query, &apps)?; + let removal = remove_registered_app(&query, &apps, &install_home)?; + let remaining_apps = removal.remaining_apps.clone(); store.save(&Registry { version: registry.version, - apps: removal.remaining_apps, + apps: remaining_apps, })?; - Ok(DispatchResult::Removed(removal.removed.display_name)) + Ok(DispatchResult::Removed(Box::new(removal))) } cli::args::Command::Update => Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?)), }; @@ -91,7 +92,7 @@ pub enum DispatchResult { Added(Box), List(Vec), PendingAdd(Box), - Removed(String), + Removed(Box), UpdatePlan(UpdatePlan), Noop, } @@ -101,7 +102,7 @@ pub enum DispatchError { AddPlan(aim_core::app::add::BuildAddPlanError), AddInstall(aim_core::app::add::InstallAppError), Prompt(ui::prompt::PromptError), - RemovePlan(aim_core::app::remove::ResolveRegisteredAppError), + RemovePlan(aim_core::app::remove::RemoveRegisteredAppError), Registry(aim_core::registry::store::RegistryStoreError), UpdatePlan(aim_core::app::update::BuildUpdatePlanError), } @@ -130,8 +131,8 @@ impl From for DispatchError { } } -impl From for DispatchError { - fn from(value: aim_core::app::remove::ResolveRegisteredAppError) -> Self { +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 3f0c9dc..e1d9ccb 100644 --- a/crates/aim-cli/src/ui/render.rs +++ b/crates/aim-cli/src/ui/render.rs @@ -11,7 +11,7 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String { DispatchResult::Added(added) => render_added_app(added), DispatchResult::List(rows) => render_list(rows), DispatchResult::PendingAdd(plan) => render_pending_add(plan), - DispatchResult::Removed(display_name) => format!("removed: {display_name}"), + DispatchResult::Removed(removed) => render_removed_app(removed), DispatchResult::UpdatePlan(plan) => { render_update_summary(plan.items.len(), plan.items.len(), 0) } @@ -72,3 +72,19 @@ fn render_list(rows: &[aim_core::app::list::ListRow]) -> String { } output.trim_end().to_owned() } + +fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String { + let warning_lines = removed + .warnings + .iter() + .map(|warning| format!("warning: {warning}")) + .collect::>() + .join("\n"); + let summary = format!("removed: {}", removed.removed.display_name); + + if warning_lines.is_empty() { + summary + } else { + format!("{summary}\n{warning_lines}") + } +} diff --git a/crates/aim-cli/tests/end_to_end_cli.rs b/crates/aim-cli/tests/end_to_end_cli.rs index 48d5f94..6d33298 100644 --- a/crates/aim-cli/tests/end_to_end_cli.rs +++ b/crates/aim-cli/tests/end_to_end_cli.rs @@ -55,6 +55,40 @@ fn remove_command_removes_registered_app_from_registry_file() { assert!(!contents.contains("stable_id = \"bat\"")); } +#[test] +fn remove_command_uninstalls_managed_files() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let install_home = dir.path().join("install-home"); + let payload_path = install_home.join(".local/lib/aim/appimages/sharkdp-bat.AppImage"); + let desktop_path = install_home.join(".local/share/applications/aim-sharkdp-bat.desktop"); + let icon_path = install_home.join(".local/share/icons/hicolor/256x256/apps/sharkdp-bat.png"); + + let mut add_cmd = Command::cargo_bin("aim").unwrap(); + add_cmd + .arg("sharkdp/bat") + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success(); + + assert!(payload_path.exists()); + assert!(desktop_path.exists()); + assert!(icon_path.exists()); + + let mut remove_cmd = Command::cargo_bin("aim").unwrap(); + remove_cmd + .args(["remove", "sharkdp-bat"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .assert() + .success() + .stdout(contains("removed: bat")); + + assert!(!payload_path.exists()); + assert!(!desktop_path.exists()); + assert!(!icon_path.exists()); +} + #[test] fn query_command_registers_unambiguous_app_in_registry_file() { let dir = tempdir().unwrap(); diff --git a/crates/aim-core/src/app/add.rs b/crates/aim-core/src/app/add.rs index 686111b..9a02fbc 100644 --- a/crates/aim-core/src/app/add.rs +++ b/crates/aim-core/src/app/add.rs @@ -6,7 +6,7 @@ use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_ident use crate::app::interaction::{InteractionKind, InteractionRequest}; use crate::app::query::{ResolveQueryError, resolve_query}; use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default}; -use crate::domain::app::{AppRecord, InstallScope}; +use crate::domain::app::{AppRecord, InstallMetadata, InstallScope}; use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind}; use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy}; use crate::integration::install::{InstallOutcome, InstallRequest, execute_install}; @@ -177,6 +177,7 @@ pub fn materialize_app_record( installed_version: Some(plan.selected_artifact.version.clone()), update_strategy: Some(plan.update_strategy.clone()), metadata: plan.metadata.clone(), + install: None, }) } @@ -186,7 +187,7 @@ pub fn install_app( install_home: &Path, requested_scope: InstallScope, ) -> Result { - let record = + let mut record = materialize_app_record(source_input, plan).map_err(InstallAppError::Materialize)?; let (family, capabilities) = probe_live_host(install_home, requested_scope).map_err(InstallAppError::HostProbe)?; @@ -234,6 +235,19 @@ pub fn install_app( }) .map_err(InstallAppError::Install)?; + record.install = Some(InstallMetadata { + scope: policy.scope, + payload_path: Some(install_outcome.final_payload_path.display().to_string()), + desktop_entry_path: install_outcome + .desktop_entry_path + .as_ref() + .map(|path| path.display().to_string()), + icon_path: install_outcome + .icon_path + .as_ref() + .map(|path| path.display().to_string()), + }); + Ok(InstalledApp { record, selected_artifact: plan.selected_artifact.clone(), @@ -279,7 +293,7 @@ pub enum InstallAppError { fn download_artifact_bytes(url: &str) -> Result, InstallAppError> { if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") { - return Ok(b"\x7fELFAppImage".to_vec()); + return Ok(b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82".to_vec()); } let response = reqwest::blocking::get(url).map_err(InstallAppError::Download)?; diff --git a/crates/aim-core/src/app/remove.rs b/crates/aim-core/src/app/remove.rs index 9064c4f..937bbec 100644 --- a/crates/aim-core/src/app/remove.rs +++ b/crates/aim-core/src/app/remove.rs @@ -1,5 +1,12 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + use crate::app::interaction::{InteractionKind, InteractionRequest}; -use crate::domain::app::AppRecord; +use crate::domain::app::{AppRecord, InstallScope}; +use crate::integration::paths::{desktop_entry_path, icon_path, managed_appimage_path}; +use crate::integration::refresh::refresh_integration; +use crate::platform::probe_live_host; pub fn resolve_registered_app<'a>( query: &str, @@ -41,19 +48,27 @@ pub struct RemovalPlan { pub artifact_paths: Vec, } -pub fn build_removal_plan(app: &AppRecord) -> RemovalPlan { +pub fn build_removal_plan(app: &AppRecord, install_home: &Path) -> RemovalPlan { + let artifact_paths = removal_artifact_paths(app, install_home) + .into_iter() + .map(|path| path.display().to_string()) + .collect(); + RemovalPlan { stable_id: app.stable_id.clone(), display_name: app.display_name.clone(), - artifact_paths: Vec::new(), + artifact_paths, } } pub fn remove_registered_app( query: &str, apps: &[AppRecord], -) -> Result { - let app = resolve_registered_app(query, apps)?; + install_home: &Path, +) -> Result { + let app = resolve_registered_app(query, apps).map_err(RemoveRegisteredAppError::Resolve)?; + let plan = build_removal_plan(app, install_home); + let warnings = delete_artifacts(&plan)?; let remaining_apps = apps .iter() .filter(|candidate| candidate.stable_id != app.stable_id) @@ -61,8 +76,9 @@ pub fn remove_registered_app( .collect(); Ok(RemovalResult { - removed: build_removal_plan(app), + removed: plan, remaining_apps, + warnings, }) } @@ -70,6 +86,13 @@ pub fn remove_registered_app( pub struct RemovalResult { pub removed: RemovalPlan, pub remaining_apps: Vec, + pub warnings: Vec, +} + +#[derive(Debug)] +pub enum RemoveRegisteredAppError { + Resolve(ResolveRegisteredAppError), + Io(io::Error), } #[derive(Debug, Eq, PartialEq)] @@ -81,3 +104,48 @@ pub enum ResolveRegisteredAppError { fn normalize_lookup(value: &str) -> String { value.trim().to_ascii_lowercase() } + +fn removal_artifact_paths(app: &AppRecord, install_home: &Path) -> Vec { + if let Some(install) = &app.install { + return [ + install.payload_path.as_deref(), + install.desktop_entry_path.as_deref(), + install.icon_path.as_deref(), + ] + .into_iter() + .flatten() + .map(PathBuf::from) + .collect(); + } + + let scope = InstallScope::User; + vec![ + managed_appimage_path(install_home, scope, &app.stable_id), + desktop_entry_path(install_home, scope, &app.stable_id), + icon_path(install_home, scope, &app.stable_id), + ] +} + +fn delete_artifacts(plan: &RemovalPlan) -> Result, RemoveRegisteredAppError> { + let desktop_path = plan.artifact_paths.get(1).map(PathBuf::from); + let icon_path = plan.artifact_paths.get(2).map(PathBuf::from); + + for artifact_path in &plan.artifact_paths { + match fs::remove_file(artifact_path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(RemoveRegisteredAppError::Io(error)), + } + } + + let mut warnings = Vec::new(); + if let Ok((_, capabilities)) = probe_live_host(Path::new("/"), InstallScope::User) { + warnings.extend(refresh_integration( + &capabilities.helpers, + desktop_path.as_deref(), + icon_path.as_deref(), + )); + } + + Ok(warnings) +} diff --git a/crates/aim-core/src/domain/app.rs b/crates/aim-core/src/domain/app.rs index 80d08bd..b1e1e38 100644 --- a/crates/aim-core/src/domain/app.rs +++ b/crates/aim-core/src/domain/app.rs @@ -1,7 +1,7 @@ use crate::domain::source::SourceRef; use crate::domain::update::{ParsedMetadata, UpdateStrategy}; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub enum InstallScope { User, System, @@ -21,6 +21,17 @@ pub struct AppIdentity { pub confidence: IdentityConfidence, } +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct InstallMetadata { + pub scope: InstallScope, + #[serde(default)] + pub payload_path: Option, + #[serde(default)] + pub desktop_entry_path: Option, + #[serde(default)] + pub icon_path: Option, +} + #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub struct AppRecord { pub stable_id: String, @@ -35,4 +46,6 @@ pub struct AppRecord { pub update_strategy: Option, #[serde(default)] pub metadata: Vec, + #[serde(default)] + pub install: Option, } diff --git a/crates/aim-core/tests/registry_roundtrip.rs b/crates/aim-core/tests/registry_roundtrip.rs index d4ff00a..ac030e4 100644 --- a/crates/aim-core/tests/registry_roundtrip.rs +++ b/crates/aim-core/tests/registry_roundtrip.rs @@ -41,6 +41,7 @@ fn registry_round_trips_update_strategy_and_alternates() { ], }), metadata: Vec::new(), + install: None, }], }; @@ -51,3 +52,52 @@ fn registry_round_trips_update_strategy_and_alternates() { assert_eq!(strategy.preferred.reason, "install-origin-match"); assert_eq!(strategy.alternates.len(), 2); } + +#[test] +fn registry_round_trips_install_metadata() { + let dir = tempdir().unwrap(); + let store = RegistryStore::new(dir.path().join("registry.toml")); + let registry = aim_core::registry::model::Registry { + version: 1, + apps: vec![aim_core::domain::app::AppRecord { + stable_id: "t3code".to_owned(), + display_name: "T3 Code".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(aim_core::domain::app::InstallMetadata { + scope: aim_core::domain::app::InstallScope::User, + payload_path: Some( + "/tmp/install-home/.local/lib/aim/appimages/t3code.AppImage".to_owned(), + ), + desktop_entry_path: Some( + "/tmp/install-home/.local/share/applications/aim-t3code.desktop".to_owned(), + ), + icon_path: Some( + "/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png" + .to_owned(), + ), + }), + }], + }; + + store.save(®istry).unwrap(); + let loaded = store.load().unwrap(); + + let install = loaded.apps[0].install.as_ref().unwrap(); + assert_eq!(install.scope, aim_core::domain::app::InstallScope::User); + assert_eq!( + install.payload_path.as_deref(), + Some("/tmp/install-home/.local/lib/aim/appimages/t3code.AppImage") + ); + assert_eq!( + install.desktop_entry_path.as_deref(), + Some("/tmp/install-home/.local/share/applications/aim-t3code.desktop") + ); + assert_eq!( + install.icon_path.as_deref(), + Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png") + ); +} diff --git a/crates/aim-core/tests/remove_flow.rs b/crates/aim-core/tests/remove_flow.rs index fed9f03..1da3fed 100644 --- a/crates/aim-core/tests/remove_flow.rs +++ b/crates/aim-core/tests/remove_flow.rs @@ -1,7 +1,8 @@ use aim_core::app::interaction::{InteractionKind, InteractionRequest}; use aim_core::app::list::build_list_rows; -use aim_core::app::remove::resolve_registered_app; -use aim_core::domain::app::AppRecord; +use aim_core::app::remove::{build_removal_plan, resolve_registered_app}; +use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope}; +use std::path::Path; #[test] fn remove_flow_rejects_unknown_app_names() { @@ -20,6 +21,7 @@ fn list_flow_returns_display_rows_for_registered_apps() { installed_version: None, update_strategy: None, metadata: Vec::new(), + install: None, }]); assert_eq!(rows.len(), 1); @@ -38,6 +40,7 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() { installed_version: None, update_strategy: None, metadata: Vec::new(), + install: None, }, AppRecord { stable_id: "bat-nightly".to_owned(), @@ -47,6 +50,7 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() { installed_version: None, update_strategy: None, metadata: Vec::new(), + install: None, }, ]; @@ -65,3 +69,59 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() { } ); } + +#[test] +fn removal_plan_prefers_persisted_install_metadata_paths() { + let app = AppRecord { + stable_id: "bat".to_owned(), + display_name: "Bat".to_owned(), + source_input: None, + source: None, + installed_version: None, + update_strategy: None, + metadata: Vec::new(), + install: Some(InstallMetadata { + scope: InstallScope::System, + payload_path: Some("/opt/aim/appimages/bat.AppImage".to_owned()), + desktop_entry_path: Some("/usr/share/applications/aim-bat.desktop".to_owned()), + icon_path: Some("/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned()), + }), + }; + + let plan = build_removal_plan(&app, Path::new("/home/test")); + + assert_eq!(plan.stable_id, "bat"); + assert_eq!( + plan.artifact_paths, + vec![ + "/opt/aim/appimages/bat.AppImage".to_owned(), + "/usr/share/applications/aim-bat.desktop".to_owned(), + "/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned(), + ] + ); +} + +#[test] +fn removal_plan_falls_back_to_derived_managed_user_paths() { + let app = AppRecord { + stable_id: "bat".to_owned(), + display_name: "Bat".to_owned(), + source_input: None, + source: None, + installed_version: None, + update_strategy: None, + metadata: Vec::new(), + install: None, + }; + + let plan = build_removal_plan(&app, Path::new("/home/test")); + + assert_eq!( + plan.artifact_paths, + vec![ + "/home/test/.local/lib/aim/appimages/bat.AppImage".to_owned(), + "/home/test/.local/share/applications/aim-bat.desktop".to_owned(), + "/home/test/.local/share/icons/hicolor/256x256/apps/bat.png".to_owned(), + ] + ); +} diff --git a/crates/aim-core/tests/update_planning.rs b/crates/aim-core/tests/update_planning.rs index 2bf17aa..177d1b5 100644 --- a/crates/aim-core/tests/update_planning.rs +++ b/crates/aim-core/tests/update_planning.rs @@ -19,6 +19,7 @@ fn installed_apps_are_carried_into_review_plan() { installed_version: None, update_strategy: None, metadata: Vec::new(), + install: None, }]; let plan = build_update_plan(&apps).unwrap(); @@ -49,6 +50,7 @@ fn update_plan_uses_alternate_channel_after_preferred_failure() { }], }), metadata: Vec::new(), + install: None, }]; let plan = build_update_plan(&apps).unwrap();