feat: implement uninstall functionality for managed artifacts and persist install metadata

This commit is contained in:
stoorps 2026-03-19 23:07:25 +00:00
parent 38f900ad50
commit 842c390260
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
11 changed files with 626 additions and 21 deletions

View file

@ -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<DispatchResult, DispatchError> {
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<InstalledApp>),
List(Vec<ListRow>),
PendingAdd(Box<AddPlan>),
Removed(String),
Removed(Box<RemovalResult>),
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<aim_core::app::update::BuildUpdatePlanError> for DispatchError {
}
}
impl From<aim_core::app::remove::ResolveRegisteredAppError> for DispatchError {
fn from(value: aim_core::app::remove::ResolveRegisteredAppError) -> Self {
impl From<aim_core::app::remove::RemoveRegisteredAppError> for DispatchError {
fn from(value: aim_core::app::remove::RemoveRegisteredAppError) -> Self {
Self::RemovePlan(value)
}
}

View file

@ -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::<Vec<_>>()
.join("\n");
let summary = format!("removed: {}", removed.removed.display_name);
if warning_lines.is_empty() {
summary
} else {
format!("{summary}\n{warning_lines}")
}
}

View file

@ -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", &registry_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", &registry_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();

View file

@ -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<InstalledApp, InstallAppError> {
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<Vec<u8>, 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)?;

View file

@ -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<String>,
}
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<RemovalResult, ResolveRegisteredAppError> {
let app = resolve_registered_app(query, apps)?;
install_home: &Path,
) -> Result<RemovalResult, RemoveRegisteredAppError> {
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<AppRecord>,
pub warnings: Vec<String>,
}
#[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<PathBuf> {
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<Vec<String>, 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)
}

View file

@ -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<String>,
#[serde(default)]
pub desktop_entry_path: Option<String>,
#[serde(default)]
pub icon_path: Option<String>,
}
#[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<UpdateStrategy>,
#[serde(default)]
pub metadata: Vec<ParsedMetadata>,
#[serde(default)]
pub install: Option<InstallMetadata>,
}

View file

@ -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(&registry).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")
);
}

View file

@ -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(),
]
);
}

View file

@ -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();