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

@ -27,5 +27,6 @@ pub enum Command {
Remove { query: String },
List,
Search { query: String },
Show { value: Option<String> },
Update,
}

View file

@ -16,9 +16,11 @@ use aim_core::app::progress::{
};
use aim_core::app::remove::{RemovalResult, remove_registered_app_with_reporter};
use aim_core::app::search::build_search_results;
use aim_core::app::show::{build_installed_show_results, build_show_result};
use aim_core::app::update::{build_update_plan, execute_updates_with_reporter};
use aim_core::domain::app::AppRecord;
use aim_core::domain::search::{SearchQuery, SearchResults};
use aim_core::domain::show::{InstalledShow, ShowResult};
use aim_core::domain::update::{UpdateExecutionResult, UpdatePlan};
use aim_core::registry::store::RegistryStore;
@ -76,6 +78,13 @@ pub fn dispatch_with_reporter(
});
Ok(DispatchResult::Search(results))
}
cli::args::Command::Show { value } => match value {
Some(value) => {
let result = build_show_result(&value, &apps)?;
Ok(DispatchResult::Show(Box::new(result)))
}
None => Ok(DispatchResult::ShowAll(build_installed_show_results(&apps))),
},
cli::args::Command::Update => {
let updates = execute_updates_with_reporter(&apps, &install_home, reporter)?;
reporter.report(&OperationEvent::StageChanged {
@ -153,6 +162,8 @@ pub enum DispatchResult {
PendingAdd(Box<AddPlan>),
Removed(Box<RemovalResult>),
Search(SearchResults),
Show(Box<ShowResult>),
ShowAll(Vec<InstalledShow>),
UpdatePlan(UpdatePlan),
Updated(Box<UpdateExecutionResult>),
Noop,
@ -166,6 +177,7 @@ pub enum DispatchError {
RemovePlan(aim_core::app::remove::RemoveRegisteredAppError),
Registry(aim_core::registry::store::RegistryStoreError),
Search(aim_core::app::search::SearchError),
Show(aim_core::domain::show::ShowResultError),
UpdatePlan(aim_core::app::update::BuildUpdatePlanError),
UpdateExecution(aim_core::app::update::ExecuteUpdatesError),
}
@ -206,6 +218,69 @@ impl std::fmt::Display for DispatchError {
Self::RemovePlan(error) => write!(f, "remove failed: {error:?}"),
Self::Registry(error) => write!(f, "registry failed: {error:?}"),
Self::Search(error) => write!(f, "search failed: {error:?}"),
Self::Show(error) => match error {
aim_core::domain::show::ShowResultError::AmbiguousInstalledMatch {
query,
matches,
} => write!(
f,
"multiple installed apps match {query}: {}",
matches.join(", ")
),
aim_core::domain::show::ShowResultError::UnsupportedQuery => {
write!(f, "unsupported source query")
}
aim_core::domain::show::ShowResultError::NoInstallableArtifact { source } => {
write!(
f,
"no installable artifact found for {} {}",
source.kind.as_str(),
source.locator
)
}
aim_core::domain::show::ShowResultError::AdapterResolutionFailed {
adapter_id,
kind,
detail,
} => match kind {
aim_core::domain::show::AdapterFailureKind::UnsupportedQuery => {
write!(f, "{adapter_id} does not support this query")
}
aim_core::domain::show::AdapterFailureKind::UnsupportedSource => {
write!(f, "{adapter_id} does not support this source")
}
aim_core::domain::show::AdapterFailureKind::ResolutionFailed => {
if let Some(detail) = detail {
write!(f, "{adapter_id} resolution failed: {detail}")
} else {
write!(f, "{adapter_id} resolution failed")
}
}
},
aim_core::domain::show::ShowResultError::GitHubDiscoveryFailed {
kind,
detail,
} => match (kind, detail) {
(
aim_core::domain::show::GitHubDiscoveryFailureKind::FixtureDocumentMissing,
Some(detail),
) => write!(f, "github discovery failed: missing fixture document {detail}"),
(
aim_core::domain::show::GitHubDiscoveryFailureKind::NoReleases,
Some(detail),
) => write!(f, "github discovery failed: no releases for {detail}"),
(aim_core::domain::show::GitHubDiscoveryFailureKind::Unsupported, _) => {
write!(f, "github discovery failed: unsupported source")
}
(aim_core::domain::show::GitHubDiscoveryFailureKind::Transport, _) => {
write!(f, "github discovery failed: transport error")
}
_ => write!(f, "github discovery failed"),
},
aim_core::domain::show::ShowResultError::NoInstallableCandidates => {
write!(f, "no installable candidates found")
}
},
Self::UpdatePlan(error) => write!(f, "update planning failed: {error:?}"),
Self::UpdateExecution(error) => write!(f, "update execution failed: {error:?}"),
}
@ -260,6 +335,12 @@ impl From<aim_core::app::search::SearchError> for DispatchError {
}
}
impl From<aim_core::domain::show::ShowResultError> for DispatchError {
fn from(value: aim_core::domain::show::ShowResultError) -> Self {
Self::Show(value)
}
}
fn upsert_app_record(apps: &mut Vec<AppRecord>, record: AppRecord) {
if let Some(existing) = apps
.iter_mut()

View file

@ -1,6 +1,10 @@
use aim_core::app::add::AddPlan;
use aim_core::domain::search::SearchResults;
use aim_core::domain::show::{
InstalledShow, MetadataSummary, RemoteInteractionSummary, RemoteShow, ShowResult, SourceSummary,
};
use aim_core::domain::update::UpdateExecutionStatus;
use console::measure_text_width;
use crate::DispatchResult;
use crate::config::CliConfig;
@ -26,6 +30,8 @@ pub fn render_dispatch_result_with_config(result: &DispatchResult, config: &CliC
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
DispatchResult::Removed(removed) => render_removed_app(removed),
DispatchResult::Search(results) => render_search_results_with_config(results, config),
DispatchResult::Show(result) => render_show_result(result),
DispatchResult::ShowAll(installed) => render_installed_show_list(installed),
DispatchResult::UpdatePlan(plan) => render_update_plan(plan),
DispatchResult::Updated(result) => render_updated_apps(result),
DispatchResult::Noop => String::new(),
@ -189,6 +195,320 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String
lines.join("\n")
}
fn render_show_result(result: &ShowResult) -> String {
match result {
ShowResult::Installed(installed) => render_installed_show(installed),
ShowResult::Remote(remote) => render_remote_show(remote),
}
}
fn render_installed_show_list(installed: &[InstalledShow]) -> String {
if installed.is_empty() {
return crate::ui::theme::muted("No installed apps yet");
}
installed
.iter()
.map(render_installed_show)
.collect::<Vec<_>>()
.join("\n\n")
}
fn render_installed_show(installed: &InstalledShow) -> String {
let mut lines = installed_title_lines(installed);
if let Some(source_line) = installed_source_line(installed) {
lines.push(source_line);
}
if let Some(source_input) = installed.source_input.as_deref()
&& should_render_requested_input(installed, source_input)
{
lines.push(format!(
"{} {source_input}",
crate::ui::theme::label("Requested")
));
}
if let Some(current_metadata) = installed.metadata.first() {
lines.extend(metadata_detail_lines(current_metadata));
}
let tracked_paths = [
installed.tracked_paths.payload_path.as_deref(),
installed.tracked_paths.desktop_entry_path.as_deref(),
installed.tracked_paths.icon_path.as_deref(),
]
.into_iter()
.flatten()
.collect::<Vec<_>>();
if !tracked_paths.is_empty() {
lines.push(installed_files_header(installed.install_scope));
lines.extend(
tracked_paths
.into_iter()
.map(|path| crate::ui::theme::muted(&format!(" {path}"))),
);
}
lines.join("\n")
}
fn installed_title_lines(installed: &InstalledShow) -> Vec<String> {
let left = crate::ui::theme::heading(&format!(
"{} ({})",
installed.display_name, installed.stable_id
));
let right = installed_right_summary(installed);
match terminal_width().filter(|width| *width > 0) {
Some(width) => {
let left_width = measure_text_width(&left);
let right_width = measure_text_width(&right);
if left_width + right_width + 2 <= width {
vec![format!(
"{left}{}{right}",
" ".repeat(width - left_width - right_width)
)]
} else {
vec![left, right]
}
}
None => vec![left, right],
}
}
fn installed_right_summary(installed: &InstalledShow) -> String {
let mut parts = Vec::new();
if let Some(version) = installed.installed_version.as_deref() {
parts.push(crate::ui::theme::accent(&format!("v{version}")));
}
if let Some(tag) = installed_status_tag(installed) {
parts.push(tag);
}
parts.join(" ")
}
fn installed_status_tag(installed: &InstalledShow) -> Option<String> {
let versions = ordered_metadata_versions(&installed.metadata);
let latest_version = versions.first()?.clone();
let installed_version = installed.installed_version.as_deref()?;
if installed_version == latest_version {
Some(bold_muted("[up to date]"))
} else {
Some(crate::ui::theme::accent("[update available]"))
}
}
fn installed_source_line(installed: &InstalledShow) -> Option<String> {
let source = installed.source.as_ref()?;
Some(labeled_detail_line(
"Source",
&format!(
"{} - {}",
source.kind.as_str(),
display_source_locator(source)
),
))
}
fn display_source_locator(source: &SourceSummary) -> &str {
source
.canonical_locator
.as_deref()
.unwrap_or(source.locator.as_str())
}
fn should_render_requested_input(installed: &InstalledShow, source_input: &str) -> bool {
let normalized_input = normalize_show_value(source_input);
if normalized_input == normalize_show_value(&installed.display_name)
|| normalized_input == normalize_show_value(&installed.stable_id)
{
return false;
}
installed.source.as_ref().is_none_or(|source| {
normalized_input != normalize_show_value(&source.locator)
&& source
.canonical_locator
.as_deref()
.map(normalize_show_value)
.is_none_or(|canonical| normalized_input != canonical)
})
}
fn terminal_width() -> Option<usize> {
std::env::var("COLUMNS")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.or_else(|| {
crossterm::terminal::size()
.ok()
.map(|(cols, _)| cols as usize)
})
}
fn ordered_metadata_versions(metadata: &[MetadataSummary]) -> Vec<String> {
let mut versions = Vec::new();
for version in metadata.iter().filter_map(|item| item.version.as_deref()) {
if !versions.iter().any(|existing| existing == version) {
versions.push(version.to_owned());
}
}
versions
}
fn metadata_detail_lines(metadata: &MetadataSummary) -> Vec<String> {
let mut lines = vec![labeled_detail_line(
"Update Mechanism",
metadata_kind_label(metadata.kind),
)];
if let Some(architecture) = metadata.architecture.as_deref() {
lines.push(labeled_detail_line("Architecture", architecture));
}
if let Some(checksum) = metadata.checksum.as_deref() {
lines.push(labeled_detail_line(
"Checksum",
&truncate_checksum(checksum),
));
}
lines
}
fn installed_files_header(scope: Option<aim_core::domain::app::InstallScope>) -> String {
let label = match scope {
Some(aim_core::domain::app::InstallScope::User) => "Installed as User",
Some(aim_core::domain::app::InstallScope::System) => "Installed as System",
None => "Installed files",
};
bold_muted_label(label)
}
fn labeled_detail_line(label: &str, value: &str) -> String {
format!(
"{} {}",
bold_muted_label(label),
crate::ui::theme::muted(value)
)
}
fn truncate_checksum(checksum: &str) -> String {
const PREFIX_CHARS: usize = 14;
const SUFFIX_CHARS: usize = 6;
const ELLIPSIS_CHARS: usize = 3;
let checksum_len = checksum.chars().count();
if checksum_len <= PREFIX_CHARS + SUFFIX_CHARS + ELLIPSIS_CHARS {
checksum.to_owned()
} else {
let prefix = checksum.chars().take(PREFIX_CHARS).collect::<String>();
let suffix = checksum
.chars()
.skip(checksum_len - SUFFIX_CHARS)
.collect::<String>();
format!("{prefix}...{suffix}",)
}
}
fn metadata_kind_label(kind: aim_core::domain::update::ParsedMetadataKind) -> &'static str {
match kind {
aim_core::domain::update::ParsedMetadataKind::Unknown => "unknown",
aim_core::domain::update::ParsedMetadataKind::ElectronBuilder => "electron-builder",
aim_core::domain::update::ParsedMetadataKind::Zsync => "zsync",
}
}
fn normalize_show_value(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn bold_muted(message: &str) -> String {
let mut style = crate::ui::theme::current_theme().muted;
style.bold = true;
crate::ui::theme::apply_style_spec(message, &style)
}
fn bold_muted_label(label: &str) -> String {
bold_muted(&format!("{label}:"))
}
fn render_remote_show(remote: &RemoteShow) -> String {
let mut lines = vec![crate::ui::theme::heading("Resolved Source")];
lines.push(format!(
"{} {} {}",
crate::ui::theme::label("Source"),
remote.source.kind.as_str(),
remote.source.locator,
));
if let Some(canonical_locator) = remote.source.canonical_locator.as_deref() {
lines.push(format!(
"{} {canonical_locator}",
crate::ui::theme::label("Canonical")
));
}
lines.push(format!(
"{} {}",
crate::ui::theme::label("Artifact"),
remote.artifact.url,
));
if let Some(version) = remote.artifact.version.as_deref() {
lines.push(format!("{} {version}", crate::ui::theme::label("Version")));
}
if let Some(checksum) = remote.artifact.trusted_checksum.as_deref() {
lines.push(format!(
"{} {checksum}",
crate::ui::theme::label("Checksum")
));
}
lines.push(format!(
"{} {}",
crate::ui::theme::label("Selection"),
remote.artifact.selection_reason,
));
if !remote.interactions.is_empty() {
lines.push(crate::ui::theme::label("Interactions"));
for interaction in &remote.interactions {
let text = match interaction {
RemoteInteractionSummary::ChooseTrackingPreference {
requested_version,
latest_version,
} => format!(
"choose tracking preference: requested {requested_version}, latest {latest_version}"
),
RemoteInteractionSummary::SelectArtifact { candidate_count } => {
format!("select artifact: {candidate_count} candidates")
}
};
lines.push(crate::ui::theme::bullet(&text));
}
}
if !remote.warnings.is_empty() {
lines.push(crate::ui::theme::label("Warnings"));
lines.extend(
remote
.warnings
.iter()
.map(|warning| format!("Warning: {warning}")),
);
}
lines.join("\n")
}
fn install_file_paths(added: &aim_core::app::add::InstalledApp) -> Vec<String> {
[
Some(

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"));
}