Add show inspection and rollback-safe update UX
This commit is contained in:
parent
27a1b806cd
commit
1ad2f8a532
16 changed files with 2187 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -27,5 +27,6 @@ pub enum Command {
|
|||
Remove { query: String },
|
||||
List,
|
||||
Search { query: String },
|
||||
Show { value: Option<String> },
|
||||
Update,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ use aim_core::app::progress::{
|
|||
};
|
||||
use aim_core::app::remove::{RemovalResult, remove_registered_app_with_reporter};
|
||||
use aim_core::app::search::build_search_results;
|
||||
use aim_core::app::show::{build_installed_show_results, build_show_result};
|
||||
use aim_core::app::update::{build_update_plan, execute_updates_with_reporter};
|
||||
use aim_core::domain::app::AppRecord;
|
||||
use aim_core::domain::search::{SearchQuery, SearchResults};
|
||||
use aim_core::domain::show::{InstalledShow, ShowResult};
|
||||
use aim_core::domain::update::{UpdateExecutionResult, UpdatePlan};
|
||||
use aim_core::registry::store::RegistryStore;
|
||||
|
||||
|
|
@ -76,6 +78,13 @@ pub fn dispatch_with_reporter(
|
|||
});
|
||||
Ok(DispatchResult::Search(results))
|
||||
}
|
||||
cli::args::Command::Show { value } => match value {
|
||||
Some(value) => {
|
||||
let result = build_show_result(&value, &apps)?;
|
||||
Ok(DispatchResult::Show(Box::new(result)))
|
||||
}
|
||||
None => Ok(DispatchResult::ShowAll(build_installed_show_results(&apps))),
|
||||
},
|
||||
cli::args::Command::Update => {
|
||||
let updates = execute_updates_with_reporter(&apps, &install_home, reporter)?;
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
|
|
@ -153,6 +162,8 @@ pub enum DispatchResult {
|
|||
PendingAdd(Box<AddPlan>),
|
||||
Removed(Box<RemovalResult>),
|
||||
Search(SearchResults),
|
||||
Show(Box<ShowResult>),
|
||||
ShowAll(Vec<InstalledShow>),
|
||||
UpdatePlan(UpdatePlan),
|
||||
Updated(Box<UpdateExecutionResult>),
|
||||
Noop,
|
||||
|
|
@ -166,6 +177,7 @@ pub enum DispatchError {
|
|||
RemovePlan(aim_core::app::remove::RemoveRegisteredAppError),
|
||||
Registry(aim_core::registry::store::RegistryStoreError),
|
||||
Search(aim_core::app::search::SearchError),
|
||||
Show(aim_core::domain::show::ShowResultError),
|
||||
UpdatePlan(aim_core::app::update::BuildUpdatePlanError),
|
||||
UpdateExecution(aim_core::app::update::ExecuteUpdatesError),
|
||||
}
|
||||
|
|
@ -206,6 +218,69 @@ impl std::fmt::Display for DispatchError {
|
|||
Self::RemovePlan(error) => write!(f, "remove failed: {error:?}"),
|
||||
Self::Registry(error) => write!(f, "registry failed: {error:?}"),
|
||||
Self::Search(error) => write!(f, "search failed: {error:?}"),
|
||||
Self::Show(error) => match error {
|
||||
aim_core::domain::show::ShowResultError::AmbiguousInstalledMatch {
|
||||
query,
|
||||
matches,
|
||||
} => write!(
|
||||
f,
|
||||
"multiple installed apps match {query}: {}",
|
||||
matches.join(", ")
|
||||
),
|
||||
aim_core::domain::show::ShowResultError::UnsupportedQuery => {
|
||||
write!(f, "unsupported source query")
|
||||
}
|
||||
aim_core::domain::show::ShowResultError::NoInstallableArtifact { source } => {
|
||||
write!(
|
||||
f,
|
||||
"no installable artifact found for {} {}",
|
||||
source.kind.as_str(),
|
||||
source.locator
|
||||
)
|
||||
}
|
||||
aim_core::domain::show::ShowResultError::AdapterResolutionFailed {
|
||||
adapter_id,
|
||||
kind,
|
||||
detail,
|
||||
} => match kind {
|
||||
aim_core::domain::show::AdapterFailureKind::UnsupportedQuery => {
|
||||
write!(f, "{adapter_id} does not support this query")
|
||||
}
|
||||
aim_core::domain::show::AdapterFailureKind::UnsupportedSource => {
|
||||
write!(f, "{adapter_id} does not support this source")
|
||||
}
|
||||
aim_core::domain::show::AdapterFailureKind::ResolutionFailed => {
|
||||
if let Some(detail) = detail {
|
||||
write!(f, "{adapter_id} resolution failed: {detail}")
|
||||
} else {
|
||||
write!(f, "{adapter_id} resolution failed")
|
||||
}
|
||||
}
|
||||
},
|
||||
aim_core::domain::show::ShowResultError::GitHubDiscoveryFailed {
|
||||
kind,
|
||||
detail,
|
||||
} => match (kind, detail) {
|
||||
(
|
||||
aim_core::domain::show::GitHubDiscoveryFailureKind::FixtureDocumentMissing,
|
||||
Some(detail),
|
||||
) => write!(f, "github discovery failed: missing fixture document {detail}"),
|
||||
(
|
||||
aim_core::domain::show::GitHubDiscoveryFailureKind::NoReleases,
|
||||
Some(detail),
|
||||
) => write!(f, "github discovery failed: no releases for {detail}"),
|
||||
(aim_core::domain::show::GitHubDiscoveryFailureKind::Unsupported, _) => {
|
||||
write!(f, "github discovery failed: unsupported source")
|
||||
}
|
||||
(aim_core::domain::show::GitHubDiscoveryFailureKind::Transport, _) => {
|
||||
write!(f, "github discovery failed: transport error")
|
||||
}
|
||||
_ => write!(f, "github discovery failed"),
|
||||
},
|
||||
aim_core::domain::show::ShowResultError::NoInstallableCandidates => {
|
||||
write!(f, "no installable candidates found")
|
||||
}
|
||||
},
|
||||
Self::UpdatePlan(error) => write!(f, "update planning failed: {error:?}"),
|
||||
Self::UpdateExecution(error) => write!(f, "update execution failed: {error:?}"),
|
||||
}
|
||||
|
|
@ -260,6 +335,12 @@ impl From<aim_core::app::search::SearchError> for DispatchError {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<aim_core::domain::show::ShowResultError> for DispatchError {
|
||||
fn from(value: aim_core::domain::show::ShowResultError) -> Self {
|
||||
Self::Show(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_app_record(apps: &mut Vec<AppRecord>, record: AppRecord) {
|
||||
if let Some(existing) = apps
|
||||
.iter_mut()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
use aim_core::app::add::AddPlan;
|
||||
use aim_core::domain::search::SearchResults;
|
||||
use aim_core::domain::show::{
|
||||
InstalledShow, MetadataSummary, RemoteInteractionSummary, RemoteShow, ShowResult, SourceSummary,
|
||||
};
|
||||
use aim_core::domain::update::UpdateExecutionStatus;
|
||||
use console::measure_text_width;
|
||||
|
||||
use crate::DispatchResult;
|
||||
use crate::config::CliConfig;
|
||||
|
|
@ -26,6 +30,8 @@ pub fn render_dispatch_result_with_config(result: &DispatchResult, config: &CliC
|
|||
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
|
||||
DispatchResult::Removed(removed) => render_removed_app(removed),
|
||||
DispatchResult::Search(results) => render_search_results_with_config(results, config),
|
||||
DispatchResult::Show(result) => render_show_result(result),
|
||||
DispatchResult::ShowAll(installed) => render_installed_show_list(installed),
|
||||
DispatchResult::UpdatePlan(plan) => render_update_plan(plan),
|
||||
DispatchResult::Updated(result) => render_updated_apps(result),
|
||||
DispatchResult::Noop => String::new(),
|
||||
|
|
@ -189,6 +195,320 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String
|
|||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_show_result(result: &ShowResult) -> String {
|
||||
match result {
|
||||
ShowResult::Installed(installed) => render_installed_show(installed),
|
||||
ShowResult::Remote(remote) => render_remote_show(remote),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_installed_show_list(installed: &[InstalledShow]) -> String {
|
||||
if installed.is_empty() {
|
||||
return crate::ui::theme::muted("No installed apps yet");
|
||||
}
|
||||
|
||||
installed
|
||||
.iter()
|
||||
.map(render_installed_show)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
fn render_installed_show(installed: &InstalledShow) -> String {
|
||||
let mut lines = installed_title_lines(installed);
|
||||
|
||||
if let Some(source_line) = installed_source_line(installed) {
|
||||
lines.push(source_line);
|
||||
}
|
||||
|
||||
if let Some(source_input) = installed.source_input.as_deref()
|
||||
&& should_render_requested_input(installed, source_input)
|
||||
{
|
||||
lines.push(format!(
|
||||
"{} {source_input}",
|
||||
crate::ui::theme::label("Requested")
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(current_metadata) = installed.metadata.first() {
|
||||
lines.extend(metadata_detail_lines(current_metadata));
|
||||
}
|
||||
|
||||
let tracked_paths = [
|
||||
installed.tracked_paths.payload_path.as_deref(),
|
||||
installed.tracked_paths.desktop_entry_path.as_deref(),
|
||||
installed.tracked_paths.icon_path.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
if !tracked_paths.is_empty() {
|
||||
lines.push(installed_files_header(installed.install_scope));
|
||||
lines.extend(
|
||||
tracked_paths
|
||||
.into_iter()
|
||||
.map(|path| crate::ui::theme::muted(&format!(" {path}"))),
|
||||
);
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn installed_title_lines(installed: &InstalledShow) -> Vec<String> {
|
||||
let left = crate::ui::theme::heading(&format!(
|
||||
"{} ({})",
|
||||
installed.display_name, installed.stable_id
|
||||
));
|
||||
let right = installed_right_summary(installed);
|
||||
|
||||
match terminal_width().filter(|width| *width > 0) {
|
||||
Some(width) => {
|
||||
let left_width = measure_text_width(&left);
|
||||
let right_width = measure_text_width(&right);
|
||||
if left_width + right_width + 2 <= width {
|
||||
vec![format!(
|
||||
"{left}{}{right}",
|
||||
" ".repeat(width - left_width - right_width)
|
||||
)]
|
||||
} else {
|
||||
vec![left, right]
|
||||
}
|
||||
}
|
||||
None => vec![left, right],
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_right_summary(installed: &InstalledShow) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(version) = installed.installed_version.as_deref() {
|
||||
parts.push(crate::ui::theme::accent(&format!("v{version}")));
|
||||
}
|
||||
|
||||
if let Some(tag) = installed_status_tag(installed) {
|
||||
parts.push(tag);
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn installed_status_tag(installed: &InstalledShow) -> Option<String> {
|
||||
let versions = ordered_metadata_versions(&installed.metadata);
|
||||
let latest_version = versions.first()?.clone();
|
||||
let installed_version = installed.installed_version.as_deref()?;
|
||||
|
||||
if installed_version == latest_version {
|
||||
Some(bold_muted("[up to date]"))
|
||||
} else {
|
||||
Some(crate::ui::theme::accent("[update available]"))
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_source_line(installed: &InstalledShow) -> Option<String> {
|
||||
let source = installed.source.as_ref()?;
|
||||
Some(labeled_detail_line(
|
||||
"Source",
|
||||
&format!(
|
||||
"{} - {}",
|
||||
source.kind.as_str(),
|
||||
display_source_locator(source)
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn display_source_locator(source: &SourceSummary) -> &str {
|
||||
source
|
||||
.canonical_locator
|
||||
.as_deref()
|
||||
.unwrap_or(source.locator.as_str())
|
||||
}
|
||||
|
||||
fn should_render_requested_input(installed: &InstalledShow, source_input: &str) -> bool {
|
||||
let normalized_input = normalize_show_value(source_input);
|
||||
|
||||
if normalized_input == normalize_show_value(&installed.display_name)
|
||||
|| normalized_input == normalize_show_value(&installed.stable_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
installed.source.as_ref().is_none_or(|source| {
|
||||
normalized_input != normalize_show_value(&source.locator)
|
||||
&& source
|
||||
.canonical_locator
|
||||
.as_deref()
|
||||
.map(normalize_show_value)
|
||||
.is_none_or(|canonical| normalized_input != canonical)
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal_width() -> Option<usize> {
|
||||
std::env::var("COLUMNS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.or_else(|| {
|
||||
crossterm::terminal::size()
|
||||
.ok()
|
||||
.map(|(cols, _)| cols as usize)
|
||||
})
|
||||
}
|
||||
|
||||
fn ordered_metadata_versions(metadata: &[MetadataSummary]) -> Vec<String> {
|
||||
let mut versions = Vec::new();
|
||||
|
||||
for version in metadata.iter().filter_map(|item| item.version.as_deref()) {
|
||||
if !versions.iter().any(|existing| existing == version) {
|
||||
versions.push(version.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
versions
|
||||
}
|
||||
|
||||
fn metadata_detail_lines(metadata: &MetadataSummary) -> Vec<String> {
|
||||
let mut lines = vec![labeled_detail_line(
|
||||
"Update Mechanism",
|
||||
metadata_kind_label(metadata.kind),
|
||||
)];
|
||||
|
||||
if let Some(architecture) = metadata.architecture.as_deref() {
|
||||
lines.push(labeled_detail_line("Architecture", architecture));
|
||||
}
|
||||
|
||||
if let Some(checksum) = metadata.checksum.as_deref() {
|
||||
lines.push(labeled_detail_line(
|
||||
"Checksum",
|
||||
&truncate_checksum(checksum),
|
||||
));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn installed_files_header(scope: Option<aim_core::domain::app::InstallScope>) -> String {
|
||||
let label = match scope {
|
||||
Some(aim_core::domain::app::InstallScope::User) => "Installed as User",
|
||||
Some(aim_core::domain::app::InstallScope::System) => "Installed as System",
|
||||
None => "Installed files",
|
||||
};
|
||||
|
||||
bold_muted_label(label)
|
||||
}
|
||||
|
||||
fn labeled_detail_line(label: &str, value: &str) -> String {
|
||||
format!(
|
||||
"{} {}",
|
||||
bold_muted_label(label),
|
||||
crate::ui::theme::muted(value)
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_checksum(checksum: &str) -> String {
|
||||
const PREFIX_CHARS: usize = 14;
|
||||
const SUFFIX_CHARS: usize = 6;
|
||||
const ELLIPSIS_CHARS: usize = 3;
|
||||
|
||||
let checksum_len = checksum.chars().count();
|
||||
|
||||
if checksum_len <= PREFIX_CHARS + SUFFIX_CHARS + ELLIPSIS_CHARS {
|
||||
checksum.to_owned()
|
||||
} else {
|
||||
let prefix = checksum.chars().take(PREFIX_CHARS).collect::<String>();
|
||||
let suffix = checksum
|
||||
.chars()
|
||||
.skip(checksum_len - SUFFIX_CHARS)
|
||||
.collect::<String>();
|
||||
format!("{prefix}...{suffix}",)
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_kind_label(kind: aim_core::domain::update::ParsedMetadataKind) -> &'static str {
|
||||
match kind {
|
||||
aim_core::domain::update::ParsedMetadataKind::Unknown => "unknown",
|
||||
aim_core::domain::update::ParsedMetadataKind::ElectronBuilder => "electron-builder",
|
||||
aim_core::domain::update::ParsedMetadataKind::Zsync => "zsync",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_show_value(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn bold_muted(message: &str) -> String {
|
||||
let mut style = crate::ui::theme::current_theme().muted;
|
||||
style.bold = true;
|
||||
crate::ui::theme::apply_style_spec(message, &style)
|
||||
}
|
||||
|
||||
fn bold_muted_label(label: &str) -> String {
|
||||
bold_muted(&format!("{label}:"))
|
||||
}
|
||||
|
||||
fn render_remote_show(remote: &RemoteShow) -> String {
|
||||
let mut lines = vec![crate::ui::theme::heading("Resolved Source")];
|
||||
lines.push(format!(
|
||||
"{} {} {}",
|
||||
crate::ui::theme::label("Source"),
|
||||
remote.source.kind.as_str(),
|
||||
remote.source.locator,
|
||||
));
|
||||
if let Some(canonical_locator) = remote.source.canonical_locator.as_deref() {
|
||||
lines.push(format!(
|
||||
"{} {canonical_locator}",
|
||||
crate::ui::theme::label("Canonical")
|
||||
));
|
||||
}
|
||||
lines.push(format!(
|
||||
"{} {}",
|
||||
crate::ui::theme::label("Artifact"),
|
||||
remote.artifact.url,
|
||||
));
|
||||
if let Some(version) = remote.artifact.version.as_deref() {
|
||||
lines.push(format!("{} {version}", crate::ui::theme::label("Version")));
|
||||
}
|
||||
if let Some(checksum) = remote.artifact.trusted_checksum.as_deref() {
|
||||
lines.push(format!(
|
||||
"{} {checksum}",
|
||||
crate::ui::theme::label("Checksum")
|
||||
));
|
||||
}
|
||||
lines.push(format!(
|
||||
"{} {}",
|
||||
crate::ui::theme::label("Selection"),
|
||||
remote.artifact.selection_reason,
|
||||
));
|
||||
|
||||
if !remote.interactions.is_empty() {
|
||||
lines.push(crate::ui::theme::label("Interactions"));
|
||||
for interaction in &remote.interactions {
|
||||
let text = match interaction {
|
||||
RemoteInteractionSummary::ChooseTrackingPreference {
|
||||
requested_version,
|
||||
latest_version,
|
||||
} => format!(
|
||||
"choose tracking preference: requested {requested_version}, latest {latest_version}"
|
||||
),
|
||||
RemoteInteractionSummary::SelectArtifact { candidate_count } => {
|
||||
format!("select artifact: {candidate_count} candidates")
|
||||
}
|
||||
};
|
||||
lines.push(crate::ui::theme::bullet(&text));
|
||||
}
|
||||
}
|
||||
|
||||
if !remote.warnings.is_empty() {
|
||||
lines.push(crate::ui::theme::label("Warnings"));
|
||||
lines.extend(
|
||||
remote
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|warning| format!("Warning: {warning}")),
|
||||
);
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn install_file_paths(added: &aim_core::app::add::InstalledApp) -> Vec<String> {
|
||||
[
|
||||
Some(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
use assert_cmd::Command;
|
||||
use predicates::str::contains;
|
||||
|
||||
use aim_cli::cli::args::Command as AimCommand;
|
||||
use aim_cli::{Cli, DispatchError};
|
||||
use aim_core::domain::show::{ShowResultError, SourceSummary};
|
||||
use aim_core::domain::source::SourceKind;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn help_lists_expected_commands() {
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
|
@ -8,7 +14,62 @@ fn help_lists_expected_commands() {
|
|||
.assert()
|
||||
.success()
|
||||
.stdout(contains("search"))
|
||||
.stdout(contains("show"))
|
||||
.stdout(contains("remove"))
|
||||
.stdout(contains("list"))
|
||||
.stdout(contains("update"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_parses_show_subcommand() {
|
||||
let cli = Cli::try_parse_from(["aim", "show", "legacy-bat"]).unwrap();
|
||||
|
||||
match cli.command {
|
||||
Some(AimCommand::Show { value }) => assert_eq!(value.as_deref(), Some("legacy-bat")),
|
||||
other => panic!("expected show command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_parses_bare_show_subcommand() {
|
||||
let cli = Cli::try_parse_from(["aim", "show"]).unwrap();
|
||||
|
||||
match cli.command {
|
||||
Some(AimCommand::Show { value }) => assert_eq!(value, None),
|
||||
other => panic!("expected bare show command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_ambiguity_error_is_readable() {
|
||||
let error = DispatchError::Show(ShowResultError::AmbiguousInstalledMatch {
|
||||
query: "bat".to_owned(),
|
||||
matches: vec![
|
||||
"Bat (bat)".to_owned(),
|
||||
"Bat Preview (legacy-bat)".to_owned(),
|
||||
],
|
||||
});
|
||||
|
||||
let rendered = error.to_string();
|
||||
|
||||
assert!(rendered.contains("multiple installed apps match bat"));
|
||||
assert!(rendered.contains("Bat (bat)"));
|
||||
assert!(rendered.contains("Bat Preview (legacy-bat)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_no_installable_artifact_error_is_readable() {
|
||||
let error = DispatchError::Show(ShowResultError::NoInstallableArtifact {
|
||||
source: SourceSummary {
|
||||
kind: SourceKind::SourceForge,
|
||||
locator: "https://sourceforge.net/projects/team-app/".to_owned(),
|
||||
canonical_locator: Some("team-app".to_owned()),
|
||||
},
|
||||
});
|
||||
|
||||
let rendered = error.to_string();
|
||||
|
||||
assert!(rendered.contains("no installable artifact found"));
|
||||
assert!(rendered.contains("sourceforge"));
|
||||
assert!(rendered.contains("https://sourceforge.net/projects/team-app/"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -530,3 +530,62 @@ fn update_command_emits_live_progress_to_stderr() {
|
|||
.stderr(contains("Resolving source: resolving pingdotgg-t3code"))
|
||||
.stderr(contains("Saving registry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_command_reports_when_previous_installation_is_restored() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let install_home = dir.path().join("install-home");
|
||||
let store = RegistryStore::new(registry_path.clone());
|
||||
let stable_id = "url-example.com-downloads-team-app.appimage";
|
||||
let payload_path = install_home.join(format!(".local/lib/aim/appimages/{stable_id}.AppImage"));
|
||||
|
||||
std::fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&payload_path, b"previous-payload").unwrap();
|
||||
std::fs::create_dir_all(install_home.join(".local/share")).unwrap();
|
||||
std::fs::write(install_home.join(".local/share/applications"), b"blocker").unwrap();
|
||||
|
||||
store
|
||||
.save(&Registry {
|
||||
version: 1,
|
||||
apps: vec![AppRecord {
|
||||
stable_id: stable_id.to_owned(),
|
||||
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(aim_core::domain::source::SourceRef {
|
||||
kind: aim_core::domain::source::SourceKind::DirectUrl,
|
||||
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: aim_core::domain::source::SourceInputKind::DirectUrl,
|
||||
normalized_kind: aim_core::domain::source::NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: Some(payload_path.display().to_string()),
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg("update")
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.env("DISPLAY", ":99")
|
||||
.env("XDG_CURRENT_DESKTOP", "test")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Failed:"))
|
||||
.stdout(contains("restored previous installation"));
|
||||
|
||||
assert_eq!(std::fs::read(&payload_path).unwrap(), b"previous-payload");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,23 @@ use aim_core::app::list::ListRow;
|
|||
use aim_core::app::remove::{RemovalPlan, RemovalResult};
|
||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use aim_core::domain::search::SearchInstallStatus;
|
||||
use aim_core::domain::show::{
|
||||
InstalledShow, MetadataSummary, RemoteArtifactSummary, RemoteShow, ShowResult, SourceSummary,
|
||||
TrackedInstallPaths, UpdateChannelSummary, UpdateStrategySummary,
|
||||
};
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use aim_core::domain::update::ArtifactCandidate;
|
||||
use aim_core::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan};
|
||||
use aim_core::domain::update::{
|
||||
ChannelPreference, ParsedMetadataKind, PlannedUpdate, UpdateChannelKind, UpdatePlan,
|
||||
};
|
||||
use aim_core::integration::install::InstallOutcome;
|
||||
|
||||
fn muted_bold_label(title: &str) -> String {
|
||||
let mut style = aim_cli::ui::theme::current_theme().muted;
|
||||
style.bold = true;
|
||||
aim_cli::ui::theme::apply_style_spec(&format!("{title}:"), &style)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_summary_mentions_selected_count() {
|
||||
let output = render_update_summary(3, 2, 1);
|
||||
|
|
@ -255,3 +267,286 @@ fn search_confirmation_summary_lists_selected_rows() {
|
|||
assert!(output.contains("pingdotgg/t3code"));
|
||||
assert!(output.contains("sharkdp/bat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_show_summary_renders_source_scope_and_paths() {
|
||||
let output = render_dispatch_result(&DispatchResult::Show(Box::new(ShowResult::Installed(
|
||||
InstalledShow {
|
||||
stable_id: "legacy-bat".to_owned(),
|
||||
display_name: "Legacy Bat".to_owned(),
|
||||
installed_version: Some("0.24.0".to_owned()),
|
||||
source_input: Some("sharkdp/bat".to_owned()),
|
||||
source: Some(SourceSummary {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "https://github.com/sharkdp/bat".to_owned(),
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
}),
|
||||
install_scope: Some(InstallScope::User),
|
||||
tracked_paths: TrackedInstallPaths {
|
||||
payload_path: Some("/tmp/bat.AppImage".to_owned()),
|
||||
desktop_entry_path: Some("/tmp/aim-bat.desktop".to_owned()),
|
||||
icon_path: Some("/tmp/aim-bat.png".to_owned()),
|
||||
},
|
||||
update_strategy: Some(UpdateStrategySummary {
|
||||
preferred: UpdateChannelSummary {
|
||||
kind: UpdateChannelKind::GitHubReleases,
|
||||
locator: "sharkdp/bat".to_owned(),
|
||||
reason: "install-origin-match".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
}),
|
||||
metadata: vec![
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.24.0".to_owned()),
|
||||
primary_download: Some("https://example.test/bat.AppImage".to_owned()),
|
||||
checksum: Some("sha256:abcdefghijklmnopqrstuvwxyz0123456789".to_owned()),
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: None,
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.23.0".to_owned()),
|
||||
primary_download: Some("https://example.test/bat-0.23.0.AppImage".to_owned()),
|
||||
checksum: Some("sha256:efgh".to_owned()),
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: None,
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
],
|
||||
},
|
||||
))));
|
||||
|
||||
assert!(output.contains("Legacy Bat (legacy-bat)"));
|
||||
assert!(output.contains("v0.24.0"));
|
||||
assert!(output.contains("[up to date]"));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Source"),
|
||||
aim_cli::ui::theme::muted("github - sharkdp/bat")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Update Mechanism"),
|
||||
aim_cli::ui::theme::muted("electron-builder")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Architecture"),
|
||||
aim_cli::ui::theme::muted("x86_64")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Checksum"),
|
||||
aim_cli::ui::theme::muted("sha256:abcdefg...456789")
|
||||
)));
|
||||
assert!(output.contains(&muted_bold_label("Installed as User")));
|
||||
assert!(output.contains("/tmp/bat.AppImage"));
|
||||
assert!(output.contains("/tmp/aim-bat.desktop"));
|
||||
assert!(!output.contains("[up to date] User"));
|
||||
assert!(!output.contains("past version"));
|
||||
assert!(!output.contains(&aim_cli::ui::theme::label("Metadata")));
|
||||
assert!(!output.contains(&aim_cli::ui::theme::label("Files")));
|
||||
assert!(!output.contains("abcdefghijklmnopqrstuvwxyz0123456789"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_show_summary_reports_when_newer_versions_are_available() {
|
||||
let output = render_dispatch_result(&DispatchResult::Show(Box::new(ShowResult::Installed(
|
||||
InstalledShow {
|
||||
stable_id: "t3code".to_owned(),
|
||||
display_name: "t3code".to_owned(),
|
||||
installed_version: Some("0.0.13".to_owned()),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: Some(SourceSummary {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "pingdotgg/t3code".to_owned(),
|
||||
canonical_locator: Some("pingdotgg/t3code".to_owned()),
|
||||
}),
|
||||
install_scope: Some(InstallScope::User),
|
||||
tracked_paths: TrackedInstallPaths {
|
||||
payload_path: Some("/tmp/t3code.AppImage".to_owned()),
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
},
|
||||
update_strategy: Some(UpdateStrategySummary {
|
||||
preferred: UpdateChannelSummary {
|
||||
kind: UpdateChannelKind::ElectronBuilder,
|
||||
locator: "https://github.com/pingdotgg/t3code/releases/download/v0.0.16/latest-linux.yml"
|
||||
.to_owned(),
|
||||
reason: "install-origin-match".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
}),
|
||||
metadata: vec![
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.0.16".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: None,
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: Some("latest".to_owned()),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.0.15".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: None,
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: Some("latest".to_owned()),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.0.14".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: None,
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: Some("latest".to_owned()),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.0.13".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: None,
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: Some("latest".to_owned()),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
],
|
||||
},
|
||||
))));
|
||||
|
||||
assert!(output.contains("t3code (t3code)"));
|
||||
assert!(output.contains("v0.0.13"));
|
||||
assert!(output.contains("[update available]"));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Source"),
|
||||
aim_cli::ui::theme::muted("github - pingdotgg/t3code")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Update Mechanism"),
|
||||
aim_cli::ui::theme::muted("electron-builder")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Architecture"),
|
||||
aim_cli::ui::theme::muted("x86_64")
|
||||
)));
|
||||
assert!(output.contains(&muted_bold_label("Installed as User")));
|
||||
assert!(!output.contains("[update available] User"));
|
||||
assert!(!output.contains("past versions"));
|
||||
assert!(!output.contains("latest v0.0.16"));
|
||||
assert!(!output.contains(&aim_cli::ui::theme::label("Metadata")));
|
||||
assert!(!output.contains(&aim_cli::ui::theme::label("Files")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_show_list_renders_each_app_using_singular_show_format() {
|
||||
let output = render_dispatch_result(&DispatchResult::ShowAll(vec![
|
||||
InstalledShow {
|
||||
stable_id: "legacy-bat".to_owned(),
|
||||
display_name: "Legacy Bat".to_owned(),
|
||||
installed_version: Some("0.24.0".to_owned()),
|
||||
source_input: Some("sharkdp/bat".to_owned()),
|
||||
source: Some(SourceSummary {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "https://github.com/sharkdp/bat".to_owned(),
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
}),
|
||||
install_scope: Some(InstallScope::User),
|
||||
tracked_paths: TrackedInstallPaths {
|
||||
payload_path: Some("/tmp/bat.AppImage".to_owned()),
|
||||
desktop_entry_path: Some("/tmp/aim-bat.desktop".to_owned()),
|
||||
icon_path: None,
|
||||
},
|
||||
update_strategy: None,
|
||||
metadata: vec![MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.24.0".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: Some("sha256:abcdefghijklmnopqrstuvwxyz0123456789".to_owned()),
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: None,
|
||||
warnings: Vec::new(),
|
||||
}],
|
||||
},
|
||||
InstalledShow {
|
||||
stable_id: "t3code".to_owned(),
|
||||
display_name: "t3code".to_owned(),
|
||||
installed_version: Some("0.0.13".to_owned()),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: Some(SourceSummary {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "pingdotgg/t3code".to_owned(),
|
||||
canonical_locator: Some("pingdotgg/t3code".to_owned()),
|
||||
}),
|
||||
install_scope: Some(InstallScope::User),
|
||||
tracked_paths: TrackedInstallPaths {
|
||||
payload_path: Some("/tmp/t3code.AppImage".to_owned()),
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
},
|
||||
update_strategy: None,
|
||||
metadata: vec![MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.0.16".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: None,
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: None,
|
||||
warnings: Vec::new(),
|
||||
}],
|
||||
},
|
||||
]));
|
||||
|
||||
assert!(output.contains("Legacy Bat (legacy-bat)"));
|
||||
assert!(output.contains("t3code (t3code)"));
|
||||
assert!(output.contains("\n\n"));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Source"),
|
||||
aim_cli::ui::theme::muted("github - sharkdp/bat")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Source"),
|
||||
aim_cli::ui::theme::muted("github - pingdotgg/t3code")
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_show_summary_renders_source_artifact_and_reason() {
|
||||
let output = render_dispatch_result(&DispatchResult::Show(Box::new(ShowResult::Remote(
|
||||
RemoteShow {
|
||||
source: SourceSummary {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "sharkdp/bat".to_owned(),
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
},
|
||||
artifact: RemoteArtifactSummary {
|
||||
url: "https://github.com/sharkdp/bat/releases/download/v1.0.0/Bat-1.0.0-x86_64.AppImage"
|
||||
.to_owned(),
|
||||
version: Some("1.0.0".to_owned()),
|
||||
arch: Some("x86_64".to_owned()),
|
||||
trusted_checksum: Some("sha512:abcd".to_owned()),
|
||||
selection_reason: "metadata-guided".to_owned(),
|
||||
},
|
||||
interactions: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
))));
|
||||
|
||||
assert!(output.contains("Resolved Source"));
|
||||
assert!(output.contains("github"));
|
||||
assert!(output.contains("sharkdp/bat"));
|
||||
assert!(output.contains("Bat-1.0.0-x86_64.AppImage"));
|
||||
assert!(output.contains("1.0.0"));
|
||||
assert!(output.contains("metadata-guided"));
|
||||
assert!(output.contains("sha512:abcd"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ pub mod query;
|
|||
pub mod remove;
|
||||
pub mod scope;
|
||||
pub mod search;
|
||||
pub mod show;
|
||||
pub mod update;
|
||||
|
|
|
|||
280
crates/aim-core/src/app/show.rs
Normal file
280
crates/aim-core/src/app/show.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod app;
|
||||
pub mod search;
|
||||
pub mod show;
|
||||
pub mod source;
|
||||
pub mod update;
|
||||
|
|
|
|||
125
crates/aim-core/src/domain/show.rs
Normal file
125
crates/aim-core/src/domain/show.rs
Normal 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,
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
303
crates/aim-core/tests/show_resolution.rs
Normal file
303
crates/aim-core/tests/show_resolution.rs
Normal 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 { .. }
|
||||
));
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue