From 1ad2f8a5322dd1fb46ff1e66bf6ee3c0dbeaafd1 Mon Sep 17 00:00:00 2001 From: stoorps Date: Sat, 21 Mar 2026 19:14:20 +0000 Subject: [PATCH] Add show inspection and rollback-safe update UX --- ...6-03-21-show-and-update-rollback-design.md | 171 ++++++++++ ...and-update-rollback-implementation-plan.md | 168 +++++++++ crates/aim-cli/src/cli/args.rs | 1 + crates/aim-cli/src/lib.rs | 81 +++++ crates/aim-cli/src/ui/render.rs | 320 ++++++++++++++++++ crates/aim-cli/tests/cli_commands.rs | 61 ++++ crates/aim-cli/tests/end_to_end_cli.rs | 59 ++++ crates/aim-cli/tests/ui_summary.rs | 297 +++++++++++++++- crates/aim-core/src/app/mod.rs | 1 + crates/aim-core/src/app/show.rs | 280 +++++++++++++++ crates/aim-core/src/app/update.rs | 148 +++++++- crates/aim-core/src/domain/mod.rs | 1 + crates/aim-core/src/domain/show.rs | 125 +++++++ crates/aim-core/tests/install_failures.rs | 64 ++++ crates/aim-core/tests/show_resolution.rs | 303 +++++++++++++++++ crates/aim-core/tests/update_planning.rs | 114 +++++++ 16 files changed, 2187 insertions(+), 7 deletions(-) create mode 100644 .plans/010-show-and-update-rollback/2026-03-21-show-and-update-rollback-design.md create mode 100644 .plans/010-show-and-update-rollback/2026-03-21-show-and-update-rollback-implementation-plan.md create mode 100644 crates/aim-core/src/app/show.rs create mode 100644 crates/aim-core/src/domain/show.rs create mode 100644 crates/aim-core/tests/show_resolution.rs diff --git a/.plans/010-show-and-update-rollback/2026-03-21-show-and-update-rollback-design.md b/.plans/010-show-and-update-rollback/2026-03-21-show-and-update-rollback-design.md new file mode 100644 index 0000000..8a51faf --- /dev/null +++ b/.plans/010-show-and-update-rollback/2026-03-21-show-and-update-rollback-design.md @@ -0,0 +1,171 @@ +# Show Command And Update Rollback Design + +## Summary + +This change adds a read-only `aim show ` 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 ` 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 ` 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 ` rendering installed details +- `aim show ` 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. \ No newline at end of file diff --git a/.plans/010-show-and-update-rollback/2026-03-21-show-and-update-rollback-implementation-plan.md b/.plans/010-show-and-update-rollback/2026-03-21-show-and-update-rollback-implementation-plan.md new file mode 100644 index 0000000..ebc9092 --- /dev/null +++ b/.plans/010-show-and-update-rollback/2026-03-21-show-and-update-rollback-implementation-plan.md @@ -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 ` 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 \ No newline at end of file diff --git a/crates/aim-cli/src/cli/args.rs b/crates/aim-cli/src/cli/args.rs index 128dbfa..1bdc5a3 100644 --- a/crates/aim-cli/src/cli/args.rs +++ b/crates/aim-cli/src/cli/args.rs @@ -27,5 +27,6 @@ pub enum Command { Remove { query: String }, List, Search { query: String }, + Show { value: Option }, Update, } diff --git a/crates/aim-cli/src/lib.rs b/crates/aim-cli/src/lib.rs index a5295d8..0912e93 100644 --- a/crates/aim-cli/src/lib.rs +++ b/crates/aim-cli/src/lib.rs @@ -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), Removed(Box), Search(SearchResults), + Show(Box), + ShowAll(Vec), UpdatePlan(UpdatePlan), Updated(Box), 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 for DispatchError { } } +impl From for DispatchError { + fn from(value: aim_core::domain::show::ShowResultError) -> Self { + Self::Show(value) + } +} + fn upsert_app_record(apps: &mut Vec, record: AppRecord) { if let Some(existing) = apps .iter_mut() diff --git a/crates/aim-cli/src/ui/render.rs b/crates/aim-cli/src/ui/render.rs index 407adae..2581bb7 100644 --- a/crates/aim-cli/src/ui/render.rs +++ b/crates/aim-cli/src/ui/render.rs @@ -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::>() + .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::>(); + 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 { + 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 { + 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 { + 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 { + std::env::var("COLUMNS") + .ok() + .and_then(|value| value.parse::().ok()) + .or_else(|| { + crossterm::terminal::size() + .ok() + .map(|(cols, _)| cols as usize) + }) +} + +fn ordered_metadata_versions(metadata: &[MetadataSummary]) -> Vec { + 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 { + 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) -> 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::(); + let suffix = checksum + .chars() + .skip(checksum_len - SUFFIX_CHARS) + .collect::(); + 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 { [ Some( diff --git a/crates/aim-cli/tests/cli_commands.rs b/crates/aim-cli/tests/cli_commands.rs index f20dd05..3842b51 100644 --- a/crates/aim-cli/tests/cli_commands.rs +++ b/crates/aim-cli/tests/cli_commands.rs @@ -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/")); +} diff --git a/crates/aim-cli/tests/end_to_end_cli.rs b/crates/aim-cli/tests/end_to_end_cli.rs index b3b4603..8246b8e 100644 --- a/crates/aim-cli/tests/end_to_end_cli.rs +++ b/crates/aim-cli/tests/end_to_end_cli.rs @@ -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"); +} diff --git a/crates/aim-cli/tests/ui_summary.rs b/crates/aim-cli/tests/ui_summary.rs index fecde05..b228e84 100644 --- a/crates/aim-cli/tests/ui_summary.rs +++ b/crates/aim-cli/tests/ui_summary.rs @@ -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")); +} diff --git a/crates/aim-core/src/app/mod.rs b/crates/aim-core/src/app/mod.rs index d40efa7..2d46ab8 100644 --- a/crates/aim-core/src/app/mod.rs +++ b/crates/aim-core/src/app/mod.rs @@ -7,4 +7,5 @@ pub mod query; pub mod remove; pub mod scope; pub mod search; +pub mod show; pub mod update; diff --git a/crates/aim-core/src/app/show.rs b/crates/aim-core/src/app/show.rs new file mode 100644 index 0000000..90310e2 --- /dev/null +++ b/crates/aim-core/src/app/show.rs @@ -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 { + 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 { + installed_apps.iter().map(project_installed_show).collect() +} + +pub fn build_show_result_with( + query: &str, + installed_apps: &[AppRecord], + transport: &T, +) -> Result { + 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 { + 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) -> ShowResultError { + ShowResultError::AmbiguousInstalledMatch { + query: query.to_owned(), + matches, + } +} + +enum InstalledLookup<'a> { + Found(&'a AppRecord), + Missing, + Ambiguous(Vec), +} + +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::>(); + + 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 { + (version != "unresolved").then_some(version) +} + +fn collect_metadata_warnings(metadata: &[crate::domain::update::ParsedMetadata]) -> Vec { + 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 { + 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 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, + } + } +} diff --git a/crates/aim-core/src/app/update.rs b/crates/aim-core/src/app/update.rs index 3b7c8fc..d716b29 100644 --- a/crates/aim-core/src/app/update.rs +++ b/crates/aim-core/src/app/update.rs @@ -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 { @@ -218,3 +239,118 @@ fn update_query(app: &AppRecord) -> Option { }) }) } + +fn stage_existing_installation( + app: &AppRecord, + install_home: &Path, +) -> Result, 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::>(); + + 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, +} + +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, +} diff --git a/crates/aim-core/src/domain/mod.rs b/crates/aim-core/src/domain/mod.rs index 31b72cd..c77fc96 100644 --- a/crates/aim-core/src/domain/mod.rs +++ b/crates/aim-core/src/domain/mod.rs @@ -1,4 +1,5 @@ pub mod app; pub mod search; +pub mod show; pub mod source; pub mod update; diff --git a/crates/aim-core/src/domain/show.rs b/crates/aim-core/src/domain/show.rs new file mode 100644 index 0000000..d758c00 --- /dev/null +++ b/crates/aim-core/src/domain/show.rs @@ -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, + pub source_input: Option, + pub source: Option, + pub install_scope: Option, + pub tracked_paths: TrackedInstallPaths, + pub update_strategy: Option, + pub metadata: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RemoteShow { + pub source: SourceSummary, + pub artifact: RemoteArtifactSummary, + pub interactions: Vec, + pub warnings: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SourceSummary { + pub kind: SourceKind, + pub locator: String, + pub canonical_locator: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TrackedInstallPaths { + pub payload_path: Option, + pub desktop_entry_path: Option, + pub icon_path: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpdateStrategySummary { + pub preferred: UpdateChannelSummary, + pub alternates: Vec, +} + +#[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, + pub primary_download: Option, + pub checksum: Option, + pub architecture: Option, + pub channel_label: Option, + pub warnings: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RemoteArtifactSummary { + pub url: String, + pub version: Option, + pub arch: Option, + pub trusted_checksum: Option, + 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, + }, + UnsupportedQuery, + NoInstallableArtifact { + source: SourceSummary, + }, + AdapterResolutionFailed { + adapter_id: String, + kind: AdapterFailureKind, + detail: Option, + }, + GitHubDiscoveryFailed { + kind: GitHubDiscoveryFailureKind, + detail: Option, + }, + NoInstallableCandidates, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AdapterFailureKind { + UnsupportedQuery, + UnsupportedSource, + ResolutionFailed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitHubDiscoveryFailureKind { + Unsupported, + FixtureDocumentMissing, + NoReleases, + Transport, +} diff --git a/crates/aim-core/tests/install_failures.rs b/crates/aim-core/tests/install_failures.rs index b59fea5..ac0bfbd 100644 --- a/crates/aim-core/tests/install_failures.rs +++ b/crates/aim-core/tests/install_failures.rs @@ -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"); +} diff --git a/crates/aim-core/tests/show_resolution.rs b/crates/aim-core/tests/show_resolution.rs new file mode 100644 index 0000000..d7bd78f --- /dev/null +++ b/crates/aim-core/tests/show_resolution.rs @@ -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 { .. } + )); +} diff --git a/crates/aim-core/tests/update_planning.rs b/crates/aim-core/tests/update_planning.rs index e69d57c..3339eaa 100644 --- a/crates/aim-core/tests/update_planning.rs +++ b/crates/aim-core/tests/update_planning.rs @@ -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() + ); +}