Add show inspection and rollback-safe update UX

This commit is contained in:
stoorps 2026-03-21 19:14:20 +00:00
parent 27a1b806cd
commit 1ad2f8a532
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
16 changed files with 2187 additions and 7 deletions

View file

@ -7,4 +7,5 @@ pub mod query;
pub mod remove;
pub mod scope;
pub mod search;
pub mod show;
pub mod update;

View file

@ -0,0 +1,280 @@
use crate::adapters::traits::AdapterError;
use crate::app::add::{BuildAddPlanError, build_add_plan, build_add_plan_with};
use crate::app::interaction::InteractionKind;
use crate::domain::app::AppRecord;
use crate::domain::show::{
AdapterFailureKind, GitHubDiscoveryFailureKind, InstalledShow, MetadataSummary,
RemoteArtifactSummary, RemoteInteractionSummary, RemoteShow, ShowResult, ShowResultError,
SourceSummary, TrackedInstallPaths, UpdateChannelSummary, UpdateStrategySummary,
};
use crate::source::github::GitHubTransport;
pub fn build_show_result(
query: &str,
installed_apps: &[AppRecord],
) -> Result<ShowResult, ShowResultError> {
match resolve_installed_show(query, installed_apps) {
InstalledLookup::Found(app) => Ok(ShowResult::Installed(project_installed_show(app))),
InstalledLookup::Missing => build_remote_show_result(query),
InstalledLookup::Ambiguous(matches) => Err(ambiguous_installed_match(query, matches)),
}
}
pub fn build_installed_show_results(installed_apps: &[AppRecord]) -> Vec<InstalledShow> {
installed_apps.iter().map(project_installed_show).collect()
}
pub fn build_show_result_with<T: GitHubTransport + ?Sized>(
query: &str,
installed_apps: &[AppRecord],
transport: &T,
) -> Result<ShowResult, ShowResultError> {
match resolve_installed_show(query, installed_apps) {
InstalledLookup::Found(app) => Ok(ShowResult::Installed(project_installed_show(app))),
InstalledLookup::Missing => {
let plan = build_add_plan_with(query, transport).map_err(ShowResultError::from)?;
let warnings = collect_metadata_warnings(&plan.metadata);
let interactions = summarize_interactions(&plan.interactions);
Ok(ShowResult::Remote(RemoteShow {
source: project_source_summary(&plan.resolution.source),
artifact: RemoteArtifactSummary {
url: plan.selected_artifact.url,
version: optional_version(plan.selected_artifact.version),
arch: plan.selected_artifact.arch,
trusted_checksum: plan.selected_artifact.trusted_checksum,
selection_reason: plan.selected_artifact.selection_reason,
},
interactions,
warnings,
}))
}
InstalledLookup::Ambiguous(matches) => Err(ambiguous_installed_match(query, matches)),
}
}
fn build_remote_show_result(query: &str) -> Result<ShowResult, ShowResultError> {
let plan = build_add_plan(query).map_err(ShowResultError::from)?;
let warnings = collect_metadata_warnings(&plan.metadata);
let interactions = summarize_interactions(&plan.interactions);
Ok(ShowResult::Remote(RemoteShow {
source: project_source_summary(&plan.resolution.source),
artifact: RemoteArtifactSummary {
url: plan.selected_artifact.url,
version: optional_version(plan.selected_artifact.version),
arch: plan.selected_artifact.arch,
trusted_checksum: plan.selected_artifact.trusted_checksum,
selection_reason: plan.selected_artifact.selection_reason,
},
interactions,
warnings,
}))
}
fn ambiguous_installed_match(query: &str, matches: Vec<String>) -> ShowResultError {
ShowResultError::AmbiguousInstalledMatch {
query: query.to_owned(),
matches,
}
}
enum InstalledLookup<'a> {
Found(&'a AppRecord),
Missing,
Ambiguous(Vec<String>),
}
fn resolve_installed_show<'a>(query: &str, installed_apps: &'a [AppRecord]) -> InstalledLookup<'a> {
let normalized_query = normalize_lookup(query);
let matches = installed_apps
.iter()
.filter(|app| app_matches_installed_query(app, &normalized_query))
.collect::<Vec<_>>();
match matches.as_slice() {
[] => InstalledLookup::Missing,
[app] => InstalledLookup::Found(app),
_ => InstalledLookup::Ambiguous(
matches
.iter()
.map(|app| format!("{} ({})", app.display_name, app.stable_id))
.collect(),
),
}
}
fn app_matches_installed_query(app: &AppRecord, normalized_query: &str) -> bool {
let mut candidates = vec![
normalize_lookup(&app.stable_id),
normalize_lookup(&app.display_name),
];
if let Some(source_input) = app.source_input.as_deref() {
candidates.push(normalize_lookup(source_input));
}
if let Some(source) = app.source.as_ref() {
candidates.push(normalize_lookup(&source.locator));
if let Some(canonical_locator) = source.canonical_locator.as_deref() {
candidates.push(normalize_lookup(canonical_locator));
}
}
candidates
.iter()
.any(|candidate| candidate == normalized_query)
}
fn normalize_lookup(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn optional_version(version: String) -> Option<String> {
(version != "unresolved").then_some(version)
}
fn collect_metadata_warnings(metadata: &[crate::domain::update::ParsedMetadata]) -> Vec<String> {
metadata
.iter()
.flat_map(|item| item.warnings.iter().cloned())
.collect()
}
fn project_installed_show(app: &AppRecord) -> InstalledShow {
InstalledShow {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
installed_version: app.installed_version.clone().and_then(optional_version),
source_input: app.source_input.clone(),
source: app.source.as_ref().map(project_source_summary),
install_scope: app.install.as_ref().map(|install| install.scope),
tracked_paths: TrackedInstallPaths {
payload_path: app
.install
.as_ref()
.and_then(|install| install.payload_path.clone()),
desktop_entry_path: app
.install
.as_ref()
.and_then(|install| install.desktop_entry_path.clone()),
icon_path: app
.install
.as_ref()
.and_then(|install| install.icon_path.clone()),
},
update_strategy: app
.update_strategy
.as_ref()
.map(|strategy| UpdateStrategySummary {
preferred: UpdateChannelSummary {
kind: strategy.preferred.kind,
locator: strategy.preferred.locator.clone(),
reason: strategy.preferred.reason.clone(),
},
alternates: strategy
.alternates
.iter()
.map(|alternate| UpdateChannelSummary {
kind: alternate.kind,
locator: alternate.locator.clone(),
reason: alternate.reason.clone(),
})
.collect(),
}),
metadata: app
.metadata
.iter()
.map(|item| MetadataSummary {
kind: item.kind,
version: item.hints.version.clone(),
primary_download: item.hints.primary_download.clone(),
checksum: item.hints.checksum.clone(),
architecture: item.hints.architecture.clone(),
channel_label: item.hints.channel_label.clone(),
warnings: item.warnings.clone(),
})
.collect(),
}
}
fn project_source_summary(source: &crate::domain::source::SourceRef) -> SourceSummary {
SourceSummary {
kind: source.kind,
locator: source.locator.clone(),
canonical_locator: source.canonical_locator.clone(),
}
}
fn summarize_interactions(
interactions: &[crate::app::interaction::InteractionRequest],
) -> Vec<RemoteInteractionSummary> {
interactions
.iter()
.filter_map(|interaction| match &interaction.kind {
InteractionKind::SelectRegisteredApp { query, matches } => {
let _ = query;
let _ = matches;
None
}
InteractionKind::ChooseTrackingPreference {
requested_version,
latest_version,
} => Some(RemoteInteractionSummary::ChooseTrackingPreference {
requested_version: requested_version.clone(),
latest_version: latest_version.clone(),
}),
InteractionKind::SelectArtifact { candidates } => {
Some(RemoteInteractionSummary::SelectArtifact {
candidate_count: candidates.len(),
})
}
})
.collect()
}
impl From<BuildAddPlanError> for ShowResultError {
fn from(value: BuildAddPlanError) -> Self {
match value {
BuildAddPlanError::Query(_) => Self::UnsupportedQuery,
BuildAddPlanError::NoInstallableArtifact { source } => Self::NoInstallableArtifact {
source: project_source_summary(&source),
},
BuildAddPlanError::Adapter(id, error) => Self::AdapterResolutionFailed {
adapter_id: id.to_owned(),
kind: match &error {
AdapterError::UnsupportedQuery => AdapterFailureKind::UnsupportedQuery,
AdapterError::UnsupportedSource => AdapterFailureKind::UnsupportedSource,
AdapterError::ResolutionFailed(_) => AdapterFailureKind::ResolutionFailed,
},
detail: match error {
AdapterError::ResolutionFailed(reason) => Some(reason),
_ => None,
},
},
BuildAddPlanError::GitHubDiscovery(error) => Self::GitHubDiscoveryFailed {
kind: match &error {
crate::source::github::GitHubDiscoveryError::Unsupported => {
GitHubDiscoveryFailureKind::Unsupported
}
crate::source::github::GitHubDiscoveryError::FixtureDocumentMissing(_) => {
GitHubDiscoveryFailureKind::FixtureDocumentMissing
}
crate::source::github::GitHubDiscoveryError::NoReleases { .. } => {
GitHubDiscoveryFailureKind::NoReleases
}
crate::source::github::GitHubDiscoveryError::Transport(_) => {
GitHubDiscoveryFailureKind::Transport
}
},
detail: match error {
crate::source::github::GitHubDiscoveryError::FixtureDocumentMissing(url) => {
Some(url)
}
crate::source::github::GitHubDiscoveryError::NoReleases { repo } => Some(repo),
_ => None,
},
},
BuildAddPlanError::NoCandidates => Self::NoInstallableCandidates,
}
}
}

View file

@ -1,4 +1,5 @@
use std::path::Path;
use std::fs;
use std::path::{Path, PathBuf};
use crate::app::add::{build_add_plan, install_app_with_reporter};
use crate::app::progress::{
@ -190,16 +191,36 @@ fn execute_update(
reason
})?;
install_app_with_reporter(&query, &plan, install_home, requested_scope, reporter).map_err(
|error| {
let reason = format!("failed to install update: {error:?}");
let rollback = stage_existing_installation(app, install_home).inspect_err(|reason| {
reporter.report(&OperationEvent::Failed {
stage: OperationStage::StagePayload,
reason: reason.clone(),
});
})?;
install_app_with_reporter(&query, &plan, install_home, requested_scope, reporter)
.map_err(|error| {
let install_reason = format!("failed to install update: {error:?}");
let reason = match rollback.as_ref() {
Some(rollback) => match rollback.restore() {
Ok(()) => format!("{install_reason}; restored previous installation"),
Err(restore_reason) => {
format!("{install_reason}; rollback restore failed: {restore_reason}")
}
},
None => install_reason,
};
reporter.report(&OperationEvent::Failed {
stage: OperationStage::Finalize,
reason: reason.clone(),
});
reason
},
)
})
.inspect(|_| {
if let Some(rollback) = rollback.as_ref() {
let _ = rollback.cleanup();
}
})
}
fn update_query(app: &AppRecord) -> Option<String> {
@ -218,3 +239,118 @@ fn update_query(app: &AppRecord) -> Option<String> {
})
})
}
fn stage_existing_installation(
app: &AppRecord,
install_home: &Path,
) -> Result<Option<RollbackState>, String> {
let Some(install) = app.install.as_ref() else {
return Ok(None);
};
let tracked_paths = [
install.payload_path.as_deref(),
install.desktop_entry_path.as_deref(),
install.icon_path.as_deref(),
]
.into_iter()
.flatten()
.map(PathBuf::from)
.filter(|path| path.exists())
.collect::<Vec<_>>();
if tracked_paths.is_empty() {
return Ok(None);
}
let stage_dir = install_home
.join(".local/share/aim/rollback")
.join(&app.stable_id);
fs::create_dir_all(&stage_dir)
.map_err(|error| format!("failed to create rollback staging directory: {error}"))?;
let mut entries = Vec::with_capacity(tracked_paths.len());
for original_path in tracked_paths {
let backup_path = stage_dir.join(
original_path
.file_name()
.map(|name| name.to_os_string())
.unwrap_or_default(),
);
fs::rename(&original_path, &backup_path).map_err(|error| {
format!(
"failed to stage existing install file {}: {error}",
original_path.display()
)
})?;
entries.push(RollbackEntry {
original_path,
backup_path,
});
}
Ok(Some(RollbackState { stage_dir, entries }))
}
struct RollbackState {
stage_dir: PathBuf,
entries: Vec<RollbackEntry>,
}
impl RollbackState {
fn restore(&self) -> Result<(), String> {
for entry in &self.entries {
if let Some(parent) = entry.original_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"failed to recreate rollback parent {}: {error}",
parent.display()
)
})?;
}
fs::rename(&entry.backup_path, &entry.original_path).map_err(|error| {
format!(
"failed to restore {}: {error}",
entry.original_path.display()
)
})?;
}
self.cleanup()
}
fn cleanup(&self) -> Result<(), String> {
if self.stage_dir.exists() {
fs::remove_dir_all(&self.stage_dir).map_err(|error| {
format!(
"failed to remove rollback staging directory {}: {error}",
self.stage_dir.display()
)
})?;
}
if let Some(parent) = self.stage_dir.parent()
&& parent.exists()
&& fs::read_dir(parent)
.map_err(|error| {
format!(
"failed to inspect rollback parent directory {}: {error}",
parent.display()
)
})?
.next()
.is_none()
{
fs::remove_dir(parent).map_err(|error| {
format!(
"failed to remove rollback parent directory {}: {error}",
parent.display()
)
})?;
}
Ok(())
}
}
struct RollbackEntry {
original_path: PathBuf,
backup_path: PathBuf,
}

View file

@ -1,4 +1,5 @@
pub mod app;
pub mod search;
pub mod show;
pub mod source;
pub mod update;

View file

@ -0,0 +1,125 @@
use crate::domain::app::InstallScope;
use crate::domain::source::SourceKind;
use crate::domain::update::{ParsedMetadataKind, UpdateChannelKind};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ShowResult {
Installed(InstalledShow),
Remote(RemoteShow),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstalledShow {
pub stable_id: String,
pub display_name: String,
pub installed_version: Option<String>,
pub source_input: Option<String>,
pub source: Option<SourceSummary>,
pub install_scope: Option<InstallScope>,
pub tracked_paths: TrackedInstallPaths,
pub update_strategy: Option<UpdateStrategySummary>,
pub metadata: Vec<MetadataSummary>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteShow {
pub source: SourceSummary,
pub artifact: RemoteArtifactSummary,
pub interactions: Vec<RemoteInteractionSummary>,
pub warnings: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceSummary {
pub kind: SourceKind,
pub locator: String,
pub canonical_locator: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrackedInstallPaths {
pub payload_path: Option<String>,
pub desktop_entry_path: Option<String>,
pub icon_path: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdateStrategySummary {
pub preferred: UpdateChannelSummary,
pub alternates: Vec<UpdateChannelSummary>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdateChannelSummary {
pub kind: UpdateChannelKind,
pub locator: String,
pub reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetadataSummary {
pub kind: ParsedMetadataKind,
pub version: Option<String>,
pub primary_download: Option<String>,
pub checksum: Option<String>,
pub architecture: Option<String>,
pub channel_label: Option<String>,
pub warnings: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteArtifactSummary {
pub url: String,
pub version: Option<String>,
pub arch: Option<String>,
pub trusted_checksum: Option<String>,
pub selection_reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RemoteInteractionSummary {
ChooseTrackingPreference {
requested_version: String,
latest_version: String,
},
SelectArtifact {
candidate_count: usize,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ShowResultError {
AmbiguousInstalledMatch {
query: String,
matches: Vec<String>,
},
UnsupportedQuery,
NoInstallableArtifact {
source: SourceSummary,
},
AdapterResolutionFailed {
adapter_id: String,
kind: AdapterFailureKind,
detail: Option<String>,
},
GitHubDiscoveryFailed {
kind: GitHubDiscoveryFailureKind,
detail: Option<String>,
},
NoInstallableCandidates,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AdapterFailureKind {
UnsupportedQuery,
UnsupportedSource,
ResolutionFailed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GitHubDiscoveryFailureKind {
Unsupported,
FixtureDocumentMissing,
NoReleases,
Transport,
}

View file

@ -1,12 +1,18 @@
use aim_core::app::add::{BuildAddPlanError, build_add_plan_with};
use aim_core::app::query::ResolveQueryError;
use aim_core::app::update::execute_updates;
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::source::SourceKind;
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceRef};
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
use aim_core::platform::DesktopHelpers;
use aim_core::source::github::FixtureGitHubTransport;
use std::fs;
use std::sync::Mutex;
use tempfile::tempdir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn integration_failure_removes_new_payload_and_generated_files() {
let root = tempdir().unwrap();
@ -69,3 +75,61 @@ fn supported_sourceforge_project_without_latest_download_reports_no_installable_
other => panic!("expected no-installable-artifact error, got {other:?}"),
}
}
#[test]
fn failed_update_restores_tracked_desktop_and_icon_files() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let root = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("DISPLAY", ":99");
std::env::set_var("XDG_CURRENT_DESKTOP", "test");
}
let payload_path = root.path().join("tracked/team-app.AppImage");
let desktop_path = root.path().join("tracked/aim-team-app.desktop");
let icon_path = root.path().join("tracked/team-app.png");
fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
fs::write(&payload_path, b"previous-payload").unwrap();
fs::write(&desktop_path, b"previous-desktop").unwrap();
fs::write(&icon_path, b"previous-icon").unwrap();
let blocking_applications_root = root.path().join(".local/share/applications");
fs::create_dir_all(blocking_applications_root.parent().unwrap()).unwrap();
fs::write(&blocking_applications_root, b"blocker").unwrap();
let previous = AppRecord {
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some(payload_path.display().to_string()),
desktop_entry_path: Some(desktop_path.display().to_string()),
icon_path: Some(icon_path.display().to_string()),
}),
};
let result = execute_updates(std::slice::from_ref(&previous), root.path()).unwrap();
assert_eq!(result.failed_count(), 1);
assert_eq!(fs::read(&payload_path).unwrap(), b"previous-payload");
assert_eq!(fs::read(&desktop_path).unwrap(), b"previous-desktop");
assert_eq!(fs::read(&icon_path).unwrap(), b"previous-icon");
}

View file

@ -0,0 +1,303 @@
use aim_core::app::show::{build_show_result, build_show_result_with};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::show::{ShowResult, ShowResultError};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::domain::update::{
ChannelPreference, MetadataHints, ParsedMetadata, ParsedMetadataKind, UpdateChannelKind,
UpdateStrategy,
};
use aim_core::source::github::FixtureGitHubTransport;
#[test]
fn exact_installed_match_returns_installed_details() {
let apps = vec![AppRecord {
stable_id: "legacy-bat".to_owned(),
display_name: "Legacy Bat".to_owned(),
source_input: Some("sharkdp/bat".to_owned()),
source: Some(SourceRef {
kind: SourceKind::GitHub,
locator: "https://github.com/sharkdp/bat".to_owned(),
input_kind: SourceInputKind::RepoShorthand,
normalized_kind: NormalizedSourceKind::GitHubRepository,
canonical_locator: Some("sharkdp/bat".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some("0.24.0".to_owned()),
update_strategy: Some(UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::GitHubReleases,
locator: "sharkdp/bat".to_owned(),
reason: "install-origin-match".to_owned(),
},
alternates: Vec::new(),
}),
metadata: vec![ParsedMetadata {
kind: ParsedMetadataKind::ElectronBuilder,
hints: MetadataHints {
version: Some("0.24.0".to_owned()),
primary_download: Some("https://example.test/bat.AppImage".to_owned()),
checksum: Some("sha256:abcd".to_owned()),
architecture: Some("x86_64".to_owned()),
channel_label: None,
},
warnings: Vec::new(),
confidence: 90,
}],
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some("/tmp/bat.AppImage".to_owned()),
desktop_entry_path: Some("/tmp/aim-bat.desktop".to_owned()),
icon_path: Some("/tmp/aim-bat.png".to_owned()),
}),
}];
let result = build_show_result("legacy-bat", &apps).unwrap();
match result {
ShowResult::Installed(installed) => {
assert_eq!(installed.stable_id, "legacy-bat");
assert_eq!(installed.display_name, "Legacy Bat");
assert_eq!(installed.installed_version.as_deref(), Some("0.24.0"));
assert_eq!(installed.install_scope, Some(InstallScope::User));
assert_eq!(
installed.source.as_ref().unwrap().locator,
"https://github.com/sharkdp/bat"
);
assert_eq!(
installed.tracked_paths.payload_path.as_deref(),
Some("/tmp/bat.AppImage")
);
assert!(installed.update_strategy.is_some());
assert_eq!(installed.metadata.len(), 1);
}
other => panic!("expected installed result, got {other:?}"),
}
}
#[test]
fn installed_source_lineage_matches_before_remote_fallback() {
let apps = vec![AppRecord {
stable_id: "legacy-bat".to_owned(),
display_name: "Legacy Bat".to_owned(),
source_input: Some("sharkdp/bat".to_owned()),
source: Some(SourceRef {
kind: SourceKind::GitHub,
locator: "https://github.com/sharkdp/bat".to_owned(),
input_kind: SourceInputKind::RepoShorthand,
normalized_kind: NormalizedSourceKind::GitHubRepository,
canonical_locator: Some("sharkdp/bat".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some("0.24.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
}];
let result = build_show_result_with("sharkdp/bat", &apps, &FixtureGitHubTransport).unwrap();
match result {
ShowResult::Installed(installed) => {
assert_eq!(installed.stable_id, "legacy-bat");
assert_eq!(installed.source_input.as_deref(), Some("sharkdp/bat"));
}
other => panic!("expected installed result, got {other:?}"),
}
}
#[test]
fn installed_direct_url_show_omits_unresolved_version() {
let apps = vec![AppRecord {
stable_id: "team-app".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
}];
let result = build_show_result("team-app", &apps).unwrap();
match result {
ShowResult::Installed(installed) => {
assert_eq!(installed.installed_version, None);
assert_eq!(
installed.source.as_ref().unwrap().kind,
SourceKind::DirectUrl
);
}
other => panic!("expected installed result, got {other:?}"),
}
}
#[test]
fn no_installed_match_falls_back_to_remote_resolution() {
let result = build_show_result_with("sharkdp/bat", &[], &FixtureGitHubTransport).unwrap();
match result {
ShowResult::Remote(remote) => {
assert_eq!(remote.source.kind, SourceKind::GitHub);
assert_eq!(
remote.source.canonical_locator.as_deref(),
Some("sharkdp/bat")
);
assert!(remote.artifact.url.ends_with("Bat-1.0.0-x86_64.AppImage"));
assert_eq!(remote.artifact.version.as_deref(), Some("1.0.0"));
assert!(remote.artifact.trusted_checksum.is_some());
assert!(!remote.artifact.selection_reason.is_empty());
assert!(remote.interactions.is_empty());
assert!(remote.warnings.is_empty());
}
other => panic!("expected remote result, got {other:?}"),
}
}
#[test]
fn remote_show_projects_tracking_preference_interaction() {
let result = build_show_result_with(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
&[],
&FixtureGitHubTransport,
)
.unwrap();
match result {
ShowResult::Remote(remote) => {
assert!(remote.interactions.iter().any(|interaction| matches!(
interaction,
aim_core::domain::show::RemoteInteractionSummary::ChooseTrackingPreference { .. }
)));
}
other => panic!("expected remote result, got {other:?}"),
}
}
#[test]
fn direct_url_remote_show_omits_unresolved_version() {
let result = build_show_result_with(
"https://example.com/downloads/team-app.AppImage",
&[],
&FixtureGitHubTransport,
)
.unwrap();
match result {
ShowResult::Remote(remote) => {
assert_eq!(remote.source.kind, SourceKind::DirectUrl);
assert_eq!(remote.artifact.version, None);
assert_eq!(
remote.artifact.url,
"https://example.com/downloads/team-app.AppImage"
);
}
other => panic!("expected remote result, got {other:?}"),
}
}
#[test]
fn ambiguous_installed_matches_return_dedicated_error() {
let apps = vec![
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,
},
AppRecord {
stable_id: "legacy-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 error = build_show_result("bat", &apps).unwrap_err();
match error {
ShowResultError::AmbiguousInstalledMatch { matches, .. } => {
assert_eq!(matches.len(), 2);
assert!(matches.iter().any(|item: &String| item.contains("bat")));
assert!(
matches
.iter()
.any(|item: &String| item.contains("legacy-bat"))
);
}
other => panic!("expected ambiguous installed match, got {other:?}"),
}
}
#[test]
fn ambiguous_installed_match_blocks_valid_remote_fallback() {
let apps = vec![
AppRecord {
stable_id: "bat-alpha".to_owned(),
display_name: "sharkdp/bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
},
AppRecord {
stable_id: "bat-beta".to_owned(),
display_name: "sharkdp/bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
},
];
let error = build_show_result_with("sharkdp/bat", &apps, &FixtureGitHubTransport).unwrap_err();
assert!(matches!(
error,
ShowResultError::AmbiguousInstalledMatch { .. }
));
}
#[test]
fn unsupported_query_stays_distinct_from_no_installable_artifact() {
let unsupported =
build_show_result_with("https://gitlab.com/example", &[], &FixtureGitHubTransport)
.unwrap_err();
let no_artifact = build_show_result_with(
"https://sourceforge.net/projects/team-app/",
&[],
&FixtureGitHubTransport,
)
.unwrap_err();
assert!(matches!(unsupported, ShowResultError::UnsupportedQuery));
assert!(matches!(
no_artifact,
ShowResultError::NoInstallableArtifact { .. }
));
}

View file

@ -3,8 +3,13 @@ use aim_core::app::update::{build_update_plan, execute_updates, execute_updates_
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
use aim_core::integration::paths::managed_appimage_path;
use std::fs;
use std::sync::Mutex;
use tempfile::tempdir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn empty_registry_produces_empty_plan() {
let plan = build_update_plan(&[]).unwrap();
@ -367,3 +372,112 @@ fn update_execution_uses_stored_sourceforge_releases_root_for_file_like_inputs()
None
);
}
#[test]
fn failed_update_restores_previous_payload_contents() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("DISPLAY", ":99");
std::env::set_var("XDG_CURRENT_DESKTOP", "test");
}
let stable_id = "url-example.com-downloads-team-app.appimage";
let payload_path = managed_appimage_path(install_home.path(), InstallScope::User, stable_id);
fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
fs::write(&payload_path, b"previous-payload").unwrap();
let desktop_root = install_home.path().join(".local/share/applications");
fs::create_dir_all(desktop_root.parent().unwrap()).unwrap();
fs::write(&desktop_root, b"blocker").unwrap();
let previous = AppRecord {
stable_id: stable_id.to_owned(),
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some(payload_path.display().to_string()),
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.failed_count(), 1);
assert_eq!(result.apps, vec![previous]);
assert_eq!(fs::read(&payload_path).unwrap(), b"previous-payload");
}
#[test]
fn successful_update_removes_rollback_staging_directory() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::remove_var("DISPLAY");
std::env::remove_var("WAYLAND_DISPLAY");
std::env::remove_var("XDG_CURRENT_DESKTOP");
}
let stable_id = "url-example.com-downloads-team-app.appimage";
let payload_path = managed_appimage_path(install_home.path(), InstallScope::User, stable_id);
fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
fs::write(&payload_path, b"previous-payload").unwrap();
let previous = AppRecord {
stable_id: stable_id.to_owned(),
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some(payload_path.display().to_string()),
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.updated_count(), 1);
assert!(
!install_home
.path()
.join(".local/share/aim/rollback")
.exists()
);
}