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

@ -0,0 +1,171 @@
# Show Command And Update Rollback Design
## Summary
This change adds a read-only `aim show <value>` command and hardens `aim update` so a failed reinstall restores the previously installed payload and generated integration files when possible. The public UX stays small: one `show` command for both installed-app inspection and install-query inspection, plus safer update execution without introducing a separate rollback command.
## Goals
- Add a single `aim show <value>` command for inspecting either an installed app or a resolvable install query.
- Resolve `show` inputs by checking installed apps first, then falling back to source/query resolution.
- Keep `show` read-only and reuse existing core resolution logic instead of creating a parallel inspection pipeline.
- Make update execution restore the previous installation files if the replacement install fails after touching the filesystem.
- Preserve the current registry-write ordering so only successful end-state records are written back.
## Non-Goals
- No standalone `aim rollback` command in this slice.
- No pinning or update-policy UX in this slice.
- No machine-readable `show --json` output yet.
- No registry backup or recovery feature beyond per-update install rollback.
- No redesign of existing `add`, `list`, `search`, or `remove` flows outside the minimum shared logic needed for `show` and rollback.
## Approaches
### Option 1: Separate `info` and `show` commands
This keeps installed-app inspection and remote-query inspection conceptually separate, but it forces the user to learn two entry points for what is effectively the same question: "what is this and what would aim do with it?" It also duplicates argument parsing, dispatch, help text, and render logic.
### Option 2: Make `show` a thin CLI-only wrapper around existing add and list code
This is faster to wire initially, but it would push installed lookup rules and query fallback rules into `aim-cli`, where they become harder to test and easier to drift from add/remove behavior. It also makes rollback hardening more likely to stay bolted onto `update.rs` without a clear boundary.
### Option 3: Add a unified core `show` service plus internal update rollback handling
This is the recommended approach. `aim-core` owns inspection and rollback behavior, while `aim-cli` remains responsible for command parsing and text rendering. The result is a small public surface with testable domain behavior and no extra persistent state.
## Approved Design
### Public Command Behavior
Add `aim show <value>` as a new subcommand. The command accepts the same broad input shapes already supported by install resolution: installed stable ids, display-name-like inputs, provider locators such as `owner/repo`, and supported URLs.
Resolution order is:
1. attempt installed-app lookup
2. if there is one clear installed match, show installed details
3. if there are no installed matches, fall back to query/source resolution
4. if installed lookup is ambiguous, fail without remote fallback
The ambiguity rule matters because an ambiguous installed match should not silently switch meaning and inspect a remote source instead. This should behave like the safe side of `remove`: when the input is not specific enough, the command tells the user to disambiguate.
### Installed-App Show Output
Installed inspection should render a concise but complete summary of the current record. The output should include:
- a compact summary line combining display name, stable id, installed version, and install scope
- a split title row with app name and stable id on the left, and installed version, inline update-status tag, and scope on the right when terminal width allows it
- a stacked fallback layout for narrower terminals rather than truncating the title row
- a secondary source line under the title row that shows provider and normalized source locator
- original source input only when it materially differs from the displayed source
- a compact `N past versions` history indicator below the source line, including the latest known version when the install is behind
- a small metadata block above files, with separate themed sibling lines for metadata kind plus architecture and for checksum
- installed payload, desktop entry, and icon paths rendered as the same style of secondary subinfo rather than a heavier bullet list
This is not intended to dump raw registry TOML. It should be a stable human-oriented summary that answers how the app was installed, whether it is behind, what file paths are currently tracked, and what update lineage exists without repeating a long metadata block.
### Remote Query Show Output
If installed lookup finds no match, `show` should resolve the input the same way `add` does, but stop before performing installation. The result should summarize:
- resolved source kind and locator
- selected artifact URL
- resolved version when available
- trusted checksum when available
- artifact selection reason
- interaction requirements if the add flow would require user choice or confirmation
- warnings produced during resolution
The remote path should reuse existing add-plan building logic rather than creating a second source-resolution implementation. This keeps install behavior and inspection behavior aligned.
### Core Architecture
Add a new inspection module in `aim-core`, with a small domain type such as `ShowResult` that covers the two successful result shapes:
- installed app details
- resolved remote add-plan details
The core service should accept the user input plus the current installed app list and return either a `ShowResult` or a typed error describing:
- ambiguous installed match
- unsupported query
- no installable artifact
- provider resolution failure
This keeps the installed-first policy and error classification in one place. `aim-cli` then only needs to parse the new command, dispatch to the core service, and render the returned structure.
### CLI Architecture
`aim-cli` should add a `Show { value: String }` subcommand and a corresponding `DispatchResult::Show(...)` branch. Rendering belongs in the existing text renderer alongside add/list/search/update summaries.
There should not be separate public `show-installed` and `show-remote` result types in the CLI. The renderer can branch on the shared `ShowResult` model and produce headings such as `Installed App` or `Resolved Source`.
### Update Rollback Design
Rollback belongs inside update execution, not in CLI dispatch. `execute_update(...)` already has the install boundary where the old app record, install home, and reinstall attempt are all visible. That is the right point to stage a backup, perform the install, and restore on failure.
Before reinstalling an app with tracked installation paths, update execution should:
1. collect the currently tracked payload, desktop entry, and icon paths that still exist
2. move those files into a rollback staging directory under the install home
3. attempt the replacement install
4. on success, delete the rollback staging directory
5. on failure, restore the old files to their original locations and return the original app record as the retained registry state
The rollback staging directory should be private to update execution, deterministic enough to debug, and cleaned up best-effort after either success or restore.
### Rollback Result Semantics
The registry should continue to be mutated only after update execution finishes, using the returned app list. That means the current high-level safety property remains unchanged:
- successful update returns the new record
- failed update returns the old record
The new behavior is filesystem safety. If reinstall fails after replacing or partially generating files, update execution should attempt to restore the old payload and integration files before reporting failure.
Restore failure should remain visible. The failure reason should include whether the install failed, whether rollback restoration also failed, and which files were involved. This can be represented as a richer failure string in this slice; a new structured rollback-status enum is not required unless the implementation clearly benefits from it.
### Edge Cases
- If an app has no tracked install paths, rollback is a no-op and the update can fail exactly as it does today.
- If backup creation fails before the replacement install starts, the update should abort rather than risk destructive partial replacement.
- If some tracked files are already missing, backup should proceed with the files that still exist and record the rest as warnings.
- If installed lookup for `show` is ambiguous, return an ambiguity error and do not attempt remote resolution.
- Unsupported source input and "no installable artifact" should remain distinct outcomes in the remote `show` path.
- `show` remains read-only even if the resolved add plan contains interactions; it should describe them rather than prompt.
## Testing Strategy
### Show Coverage
Add core tests for:
- exact installed match returning installed details
- no installed match falling back to remote resolution
- ambiguous installed matches returning a safe error
- unsupported input remaining distinct from no-installable-artifact
- remote result carrying artifact URL, checksum, and warnings through the summary model
Add CLI tests for:
- `aim show <installed-id>` rendering installed details
- `aim show <query>` rendering resolved source details
- ambiguity and provider errors surfacing readable messages
### Rollback Coverage
Add update execution tests for:
- successful update removes rollback staging artifacts
- failed update restores the original payload path
- failed update restores desktop entry and icon files when present
- backup creation failure aborts before destructive replacement
- restore failure reports a compound reason and still keeps the original record in registry output
The tests should prefer temporary directories and fixture transports over shelling out or relying on real network calls.
## Delivery Notes
- Do not add persistent rollback metadata to the registry for this slice.
- Prefer new focused modules for `show` rather than making `lib.rs` or `update.rs` absorb more branching.
- Keep the text output stable and human-readable so later `--json` work can be added as a separate renderer decision instead of reworking the domain model.

View file

@ -0,0 +1,168 @@
# Show Command And Update Rollback Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a read-only `aim show <value>` command that inspects installed apps first and falls back to remote source resolution, while making `aim update` restore the previous installation files when reinstall fails.
**Architecture:** Add a small `aim-core` show service that returns either installed details or resolved add-plan details, then wire a single CLI subcommand and text renderer around that result. Harden update execution in `aim-core` by staging tracked install files before reinstall and restoring them on failure without introducing new registry state.
**Tech Stack:** Rust, clap, serde, toml, tempfile, assert_cmd
---
### Task 1: Add the core `show` domain model and resolution service
**Files:**
- Create: `crates/aim-core/src/app/show.rs`
- Create: `crates/aim-core/src/domain/show.rs`
- Modify: `crates/aim-core/src/app/mod.rs`
- Modify: `crates/aim-core/src/domain/mod.rs`
- Test: `crates/aim-core/tests/show_resolution.rs`
**Step 1: Write the failing test**
Add core tests covering:
- one installed match returns installed details
- no installed match falls back to remote resolution
- ambiguous installed matches return a dedicated error
- unsupported query stays distinct from `NoInstallableArtifact`
Include at least one remote-resolution fixture that proves the `show` result carries artifact URL, selection reason, and trusted checksum.
**Step 2: Run test to verify it fails**
Run: `cargo test --package aim-core --test show_resolution`
Expected: FAIL because the show domain and service do not exist.
**Step 3: Write minimal implementation**
Implement a new `show` service in `aim-core` that accepts the user input and installed app list, applies installed-first resolution, and returns a typed `ShowResult`.
Keep the installed branch focused on existing `AppRecord` data. Keep the remote branch focused on summarizing the already-built add plan instead of exposing all of `AddPlan` directly.
**Step 4: Run test to verify it passes**
Run: `cargo test --package aim-core --test show_resolution`
Expected: PASS
### Task 2: Wire the `show` command through `aim-cli`
**Files:**
- Modify: `crates/aim-cli/src/cli/args.rs`
- Modify: `crates/aim-cli/src/lib.rs`
- Modify: `crates/aim-cli/src/ui/render.rs`
- Test: `crates/aim-cli/tests/cli_commands.rs`
- Test: `crates/aim-cli/tests/ui_summary.rs`
**Step 1: Write the failing test**
Add CLI coverage for:
- `aim show legacy-bat` dispatching successfully and rendering installed details
- `aim show owner/repo` rendering resolved source and artifact details
- ambiguous installed lookup rendering a readable failure
Keep the rendering assertions focused on stable summary lines rather than exact spacing across the entire output block.
**Step 2: Run test to verify it fails**
Run: `cargo test --package aim-cli --test cli_commands --test ui_summary`
Expected: FAIL because the CLI has no `show` command or renderer.
**Step 3: Write minimal implementation**
Add `Show { value: String }` to the clap subcommands, route it through dispatch, convert core `ShowResult` into a new `DispatchResult::Show(...)` variant, and render installed and remote summaries in `ui::render`.
Do not add prompting or mutation to this command.
**Step 4: Run test to verify it passes**
Run: `cargo test --package aim-cli --test cli_commands --test ui_summary`
Expected: PASS
### Task 3: Add rollback staging for update execution
**Files:**
- Modify: `crates/aim-core/src/app/update.rs`
- Modify: `crates/aim-core/src/domain/update.rs`
- Test: `crates/aim-core/tests/update_planning.rs`
**Step 1: Write the failing test**
Add update execution tests covering:
- a failed reinstall restores the original payload file contents
- a failed reinstall keeps returning the previous `AppRecord`
- a successful reinstall removes any rollback staging directory
Use a temporary install home and deterministic fixture inputs so the test does not depend on external services.
**Step 2: Run test to verify it fails**
Run: `cargo test --package aim-core --test update_planning`
Expected: FAIL because update execution does not back up or restore tracked files.
**Step 3: Write minimal implementation**
Add a small rollback helper inside `update.rs` that gathers the tracked install paths, moves existing files into a staging directory under the install home, restores them on failure, and deletes the staging directory on success.
Only enrich `domain::update` if you need a better warning or failure surface for tests and summaries.
**Step 4: Run test to verify it passes**
Run: `cargo test --package aim-core --test update_planning`
Expected: PASS
### Task 4: Cover desktop integration rollback and human-facing failure output
**Files:**
- Modify: `crates/aim-core/src/app/update.rs`
- Modify: `crates/aim-cli/src/ui/render.rs`
- Test: `crates/aim-core/tests/install_failures.rs`
- Test: `crates/aim-cli/tests/end_to_end_cli.rs`
**Step 1: Write the failing test**
Add coverage for:
- update rollback restoring desktop entry and icon files when replacement install fails after file moves
- CLI update summary surfacing a rollback-aware failure reason instead of a generic install error
Use temporary directories and existing fixture-style test seams.
**Step 2: Run test to verify it fails**
Run: `cargo test --package aim-core --test install_failures && cargo test --package aim-cli --test end_to_end_cli`
Expected: FAIL because desktop integration rollback is not restored and the failure output is not rollback-aware.
**Step 3: Write minimal implementation**
Extend the rollback helper to include tracked desktop integration paths and surface a clear failure reason when either backup creation or restore fails.
Keep the CLI output change small: reuse the existing update summary renderer and only improve the failure string content.
**Step 4: Run test to verify it passes**
Run: `cargo test --package aim-core --test install_failures && cargo test --package aim-cli --test end_to_end_cli`
Expected: PASS
### Task 5: Final verification
**Files:**
- Modify as required by prior tasks only
**Step 1: Run focused feature tests**
Run: `cargo test --package aim-core --test show_resolution --test update_planning --test install_failures && cargo test --package aim-cli --test cli_commands --test ui_summary --test end_to_end_cli`
Expected: PASS
**Step 2: Run workspace formatting**
Run: `cargo fmt --all`
Expected: PASS
**Step 3: Run workspace regression and lint checks**
Run: `cargo test --workspace && cargo clippy --workspace --all-targets --all-features -- -D warnings`
Expected: PASS

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

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