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

@ -1,6 +1,12 @@
use assert_cmd::Command;
use predicates::str::contains;
use aim_cli::cli::args::Command as AimCommand;
use aim_cli::{Cli, DispatchError};
use aim_core::domain::show::{ShowResultError, SourceSummary};
use aim_core::domain::source::SourceKind;
use clap::Parser;
#[test]
fn help_lists_expected_commands() {
let mut cmd = Command::cargo_bin("aim").unwrap();
@ -8,7 +14,62 @@ fn help_lists_expected_commands() {
.assert()
.success()
.stdout(contains("search"))
.stdout(contains("show"))
.stdout(contains("remove"))
.stdout(contains("list"))
.stdout(contains("update"));
}
#[test]
fn cli_parses_show_subcommand() {
let cli = Cli::try_parse_from(["aim", "show", "legacy-bat"]).unwrap();
match cli.command {
Some(AimCommand::Show { value }) => assert_eq!(value.as_deref(), Some("legacy-bat")),
other => panic!("expected show command, got {other:?}"),
}
}
#[test]
fn cli_parses_bare_show_subcommand() {
let cli = Cli::try_parse_from(["aim", "show"]).unwrap();
match cli.command {
Some(AimCommand::Show { value }) => assert_eq!(value, None),
other => panic!("expected bare show command, got {other:?}"),
}
}
#[test]
fn show_ambiguity_error_is_readable() {
let error = DispatchError::Show(ShowResultError::AmbiguousInstalledMatch {
query: "bat".to_owned(),
matches: vec![
"Bat (bat)".to_owned(),
"Bat Preview (legacy-bat)".to_owned(),
],
});
let rendered = error.to_string();
assert!(rendered.contains("multiple installed apps match bat"));
assert!(rendered.contains("Bat (bat)"));
assert!(rendered.contains("Bat Preview (legacy-bat)"));
}
#[test]
fn show_no_installable_artifact_error_is_readable() {
let error = DispatchError::Show(ShowResultError::NoInstallableArtifact {
source: SourceSummary {
kind: SourceKind::SourceForge,
locator: "https://sourceforge.net/projects/team-app/".to_owned(),
canonical_locator: Some("team-app".to_owned()),
},
});
let rendered = error.to_string();
assert!(rendered.contains("no installable artifact found"));
assert!(rendered.contains("sourceforge"));
assert!(rendered.contains("https://sourceforge.net/projects/team-app/"));
}

View file

@ -530,3 +530,62 @@ fn update_command_emits_live_progress_to_stderr() {
.stderr(contains("Resolving source: resolving pingdotgg-t3code"))
.stderr(contains("Saving registry"));
}
#[test]
fn update_command_reports_when_previous_installation_is_restored() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let install_home = dir.path().join("install-home");
let store = RegistryStore::new(registry_path.clone());
let stable_id = "url-example.com-downloads-team-app.appimage";
let payload_path = install_home.join(format!(".local/lib/aim/appimages/{stable_id}.AppImage"));
std::fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
std::fs::write(&payload_path, b"previous-payload").unwrap();
std::fs::create_dir_all(install_home.join(".local/share")).unwrap();
std::fs::write(install_home.join(".local/share/applications"), b"blocker").unwrap();
store
.save(&Registry {
version: 1,
apps: vec![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(aim_core::domain::source::SourceRef {
kind: aim_core::domain::source::SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: aim_core::domain::source::SourceInputKind::DirectUrl,
normalized_kind: aim_core::domain::source::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,
}),
}],
})
.unwrap();
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("update")
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.env("DISPLAY", ":99")
.env("XDG_CURRENT_DESKTOP", "test")
.assert()
.success()
.stdout(contains("Failed:"))
.stdout(contains("restored previous installation"));
assert_eq!(std::fs::read(&payload_path).unwrap(), b"previous-payload");
}

View file

@ -8,11 +8,23 @@ use aim_core::app::list::ListRow;
use aim_core::app::remove::{RemovalPlan, RemovalResult};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::search::SearchInstallStatus;
use aim_core::domain::show::{
InstalledShow, MetadataSummary, RemoteArtifactSummary, RemoteShow, ShowResult, SourceSummary,
TrackedInstallPaths, UpdateChannelSummary, UpdateStrategySummary,
};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::domain::update::ArtifactCandidate;
use aim_core::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan};
use aim_core::domain::update::{
ChannelPreference, ParsedMetadataKind, PlannedUpdate, UpdateChannelKind, UpdatePlan,
};
use aim_core::integration::install::InstallOutcome;
fn muted_bold_label(title: &str) -> String {
let mut style = aim_cli::ui::theme::current_theme().muted;
style.bold = true;
aim_cli::ui::theme::apply_style_spec(&format!("{title}:"), &style)
}
#[test]
fn update_summary_mentions_selected_count() {
let output = render_update_summary(3, 2, 1);
@ -255,3 +267,286 @@ fn search_confirmation_summary_lists_selected_rows() {
assert!(output.contains("pingdotgg/t3code"));
assert!(output.contains("sharkdp/bat"));
}
#[test]
fn installed_show_summary_renders_source_scope_and_paths() {
let output = render_dispatch_result(&DispatchResult::Show(Box::new(ShowResult::Installed(
InstalledShow {
stable_id: "legacy-bat".to_owned(),
display_name: "Legacy Bat".to_owned(),
installed_version: Some("0.24.0".to_owned()),
source_input: Some("sharkdp/bat".to_owned()),
source: Some(SourceSummary {
kind: SourceKind::GitHub,
locator: "https://github.com/sharkdp/bat".to_owned(),
canonical_locator: Some("sharkdp/bat".to_owned()),
}),
install_scope: Some(InstallScope::User),
tracked_paths: TrackedInstallPaths {
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()),
},
update_strategy: Some(UpdateStrategySummary {
preferred: UpdateChannelSummary {
kind: UpdateChannelKind::GitHubReleases,
locator: "sharkdp/bat".to_owned(),
reason: "install-origin-match".to_owned(),
},
alternates: Vec::new(),
}),
metadata: vec![
MetadataSummary {
kind: ParsedMetadataKind::ElectronBuilder,
version: Some("0.24.0".to_owned()),
primary_download: Some("https://example.test/bat.AppImage".to_owned()),
checksum: Some("sha256:abcdefghijklmnopqrstuvwxyz0123456789".to_owned()),
architecture: Some("x86_64".to_owned()),
channel_label: None,
warnings: Vec::new(),
},
MetadataSummary {
kind: ParsedMetadataKind::ElectronBuilder,
version: Some("0.23.0".to_owned()),
primary_download: Some("https://example.test/bat-0.23.0.AppImage".to_owned()),
checksum: Some("sha256:efgh".to_owned()),
architecture: Some("x86_64".to_owned()),
channel_label: None,
warnings: Vec::new(),
},
],
},
))));
assert!(output.contains("Legacy Bat (legacy-bat)"));
assert!(output.contains("v0.24.0"));
assert!(output.contains("[up to date]"));
assert!(output.contains(&format!(
"{} {}",
muted_bold_label("Source"),
aim_cli::ui::theme::muted("github - sharkdp/bat")
)));
assert!(output.contains(&format!(
"{} {}",
muted_bold_label("Update Mechanism"),
aim_cli::ui::theme::muted("electron-builder")
)));
assert!(output.contains(&format!(
"{} {}",
muted_bold_label("Architecture"),
aim_cli::ui::theme::muted("x86_64")
)));
assert!(output.contains(&format!(
"{} {}",
muted_bold_label("Checksum"),
aim_cli::ui::theme::muted("sha256:abcdefg...456789")
)));
assert!(output.contains(&muted_bold_label("Installed as User")));
assert!(output.contains("/tmp/bat.AppImage"));
assert!(output.contains("/tmp/aim-bat.desktop"));
assert!(!output.contains("[up to date] User"));
assert!(!output.contains("past version"));
assert!(!output.contains(&aim_cli::ui::theme::label("Metadata")));
assert!(!output.contains(&aim_cli::ui::theme::label("Files")));
assert!(!output.contains("abcdefghijklmnopqrstuvwxyz0123456789"));
}
#[test]
fn installed_show_summary_reports_when_newer_versions_are_available() {
let output = render_dispatch_result(&DispatchResult::Show(Box::new(ShowResult::Installed(
InstalledShow {
stable_id: "t3code".to_owned(),
display_name: "t3code".to_owned(),
installed_version: Some("0.0.13".to_owned()),
source_input: Some("pingdotgg/t3code".to_owned()),
source: Some(SourceSummary {
kind: SourceKind::GitHub,
locator: "pingdotgg/t3code".to_owned(),
canonical_locator: Some("pingdotgg/t3code".to_owned()),
}),
install_scope: Some(InstallScope::User),
tracked_paths: TrackedInstallPaths {
payload_path: Some("/tmp/t3code.AppImage".to_owned()),
desktop_entry_path: None,
icon_path: None,
},
update_strategy: Some(UpdateStrategySummary {
preferred: UpdateChannelSummary {
kind: UpdateChannelKind::ElectronBuilder,
locator: "https://github.com/pingdotgg/t3code/releases/download/v0.0.16/latest-linux.yml"
.to_owned(),
reason: "install-origin-match".to_owned(),
},
alternates: Vec::new(),
}),
metadata: vec![
MetadataSummary {
kind: ParsedMetadataKind::ElectronBuilder,
version: Some("0.0.16".to_owned()),
primary_download: None,
checksum: None,
architecture: Some("x86_64".to_owned()),
channel_label: Some("latest".to_owned()),
warnings: Vec::new(),
},
MetadataSummary {
kind: ParsedMetadataKind::ElectronBuilder,
version: Some("0.0.15".to_owned()),
primary_download: None,
checksum: None,
architecture: Some("x86_64".to_owned()),
channel_label: Some("latest".to_owned()),
warnings: Vec::new(),
},
MetadataSummary {
kind: ParsedMetadataKind::ElectronBuilder,
version: Some("0.0.14".to_owned()),
primary_download: None,
checksum: None,
architecture: Some("x86_64".to_owned()),
channel_label: Some("latest".to_owned()),
warnings: Vec::new(),
},
MetadataSummary {
kind: ParsedMetadataKind::ElectronBuilder,
version: Some("0.0.13".to_owned()),
primary_download: None,
checksum: None,
architecture: Some("x86_64".to_owned()),
channel_label: Some("latest".to_owned()),
warnings: Vec::new(),
},
],
},
))));
assert!(output.contains("t3code (t3code)"));
assert!(output.contains("v0.0.13"));
assert!(output.contains("[update available]"));
assert!(output.contains(&format!(
"{} {}",
muted_bold_label("Source"),
aim_cli::ui::theme::muted("github - pingdotgg/t3code")
)));
assert!(output.contains(&format!(
"{} {}",
muted_bold_label("Update Mechanism"),
aim_cli::ui::theme::muted("electron-builder")
)));
assert!(output.contains(&format!(
"{} {}",
muted_bold_label("Architecture"),
aim_cli::ui::theme::muted("x86_64")
)));
assert!(output.contains(&muted_bold_label("Installed as User")));
assert!(!output.contains("[update available] User"));
assert!(!output.contains("past versions"));
assert!(!output.contains("latest v0.0.16"));
assert!(!output.contains(&aim_cli::ui::theme::label("Metadata")));
assert!(!output.contains(&aim_cli::ui::theme::label("Files")));
}
#[test]
fn installed_show_list_renders_each_app_using_singular_show_format() {
let output = render_dispatch_result(&DispatchResult::ShowAll(vec![
InstalledShow {
stable_id: "legacy-bat".to_owned(),
display_name: "Legacy Bat".to_owned(),
installed_version: Some("0.24.0".to_owned()),
source_input: Some("sharkdp/bat".to_owned()),
source: Some(SourceSummary {
kind: SourceKind::GitHub,
locator: "https://github.com/sharkdp/bat".to_owned(),
canonical_locator: Some("sharkdp/bat".to_owned()),
}),
install_scope: Some(InstallScope::User),
tracked_paths: TrackedInstallPaths {
payload_path: Some("/tmp/bat.AppImage".to_owned()),
desktop_entry_path: Some("/tmp/aim-bat.desktop".to_owned()),
icon_path: None,
},
update_strategy: None,
metadata: vec![MetadataSummary {
kind: ParsedMetadataKind::ElectronBuilder,
version: Some("0.24.0".to_owned()),
primary_download: None,
checksum: Some("sha256:abcdefghijklmnopqrstuvwxyz0123456789".to_owned()),
architecture: Some("x86_64".to_owned()),
channel_label: None,
warnings: Vec::new(),
}],
},
InstalledShow {
stable_id: "t3code".to_owned(),
display_name: "t3code".to_owned(),
installed_version: Some("0.0.13".to_owned()),
source_input: Some("pingdotgg/t3code".to_owned()),
source: Some(SourceSummary {
kind: SourceKind::GitHub,
locator: "pingdotgg/t3code".to_owned(),
canonical_locator: Some("pingdotgg/t3code".to_owned()),
}),
install_scope: Some(InstallScope::User),
tracked_paths: TrackedInstallPaths {
payload_path: Some("/tmp/t3code.AppImage".to_owned()),
desktop_entry_path: None,
icon_path: None,
},
update_strategy: None,
metadata: vec![MetadataSummary {
kind: ParsedMetadataKind::ElectronBuilder,
version: Some("0.0.16".to_owned()),
primary_download: None,
checksum: None,
architecture: Some("x86_64".to_owned()),
channel_label: None,
warnings: Vec::new(),
}],
},
]));
assert!(output.contains("Legacy Bat (legacy-bat)"));
assert!(output.contains("t3code (t3code)"));
assert!(output.contains("\n\n"));
assert!(output.contains(&format!(
"{} {}",
muted_bold_label("Source"),
aim_cli::ui::theme::muted("github - sharkdp/bat")
)));
assert!(output.contains(&format!(
"{} {}",
muted_bold_label("Source"),
aim_cli::ui::theme::muted("github - pingdotgg/t3code")
)));
}
#[test]
fn remote_show_summary_renders_source_artifact_and_reason() {
let output = render_dispatch_result(&DispatchResult::Show(Box::new(ShowResult::Remote(
RemoteShow {
source: SourceSummary {
kind: SourceKind::GitHub,
locator: "sharkdp/bat".to_owned(),
canonical_locator: Some("sharkdp/bat".to_owned()),
},
artifact: RemoteArtifactSummary {
url: "https://github.com/sharkdp/bat/releases/download/v1.0.0/Bat-1.0.0-x86_64.AppImage"
.to_owned(),
version: Some("1.0.0".to_owned()),
arch: Some("x86_64".to_owned()),
trusted_checksum: Some("sha512:abcd".to_owned()),
selection_reason: "metadata-guided".to_owned(),
},
interactions: Vec::new(),
warnings: Vec::new(),
},
))));
assert!(output.contains("Resolved Source"));
assert!(output.contains("github"));
assert!(output.contains("sharkdp/bat"));
assert!(output.contains("Bat-1.0.0-x86_64.AppImage"));
assert!(output.contains("1.0.0"));
assert!(output.contains("metadata-guided"));
assert!(output.contains("sha512:abcd"));
}