Add show inspection and rollback-safe update UX
This commit is contained in:
parent
27a1b806cd
commit
1ad2f8a532
16 changed files with 2187 additions and 7 deletions
|
|
@ -27,5 +27,6 @@ pub enum Command {
|
|||
Remove { query: String },
|
||||
List,
|
||||
Search { query: String },
|
||||
Show { value: Option<String> },
|
||||
Update,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", ®istry_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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue