feat: implement uninstall functionality for managed artifacts and persist install metadata
This commit is contained in:
parent
38f900ad50
commit
842c390260
11 changed files with 626 additions and 21 deletions
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Remove Uninstall Metadata Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make `aim remove` uninstall `aim`-managed app artifacts rather than only unregistering apps, while preserving backward compatibility for existing registry entries and keeping uninstall logic in `aim-core`.
|
||||||
|
|
||||||
|
## Agreed Product Shape
|
||||||
|
|
||||||
|
### Remove behavior
|
||||||
|
|
||||||
|
- `aim remove <QUERY>` should delete the tracked app from the registry
|
||||||
|
- It should also remove `aim`-managed installed artifacts for that app
|
||||||
|
- Missing artifact files should not make removal fail
|
||||||
|
- Desktop refresh helper failures should surface as warnings rather than block removal
|
||||||
|
|
||||||
|
### Metadata model
|
||||||
|
|
||||||
|
- Persist install metadata on successful install
|
||||||
|
- Persist exact installed paths for payload, desktop entry, and icon
|
||||||
|
- Persist the effective install scope
|
||||||
|
- Keep install metadata optional so old registry entries still deserialize cleanly
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
- New installs should uninstall using persisted exact paths
|
||||||
|
- Existing registry entries without install metadata should fall back to derived `aim`-managed paths
|
||||||
|
- Uninstall should only target paths that belong to `aim` management, not arbitrary external files
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
Use a hybrid uninstall model.
|
||||||
|
|
||||||
|
This combines exact persisted install metadata for newly installed apps with a conservative derived-path fallback for legacy registry entries. It avoids breaking older registries while making future uninstall behavior precise and resilient to layout changes.
|
||||||
|
|
||||||
|
The model is:
|
||||||
|
|
||||||
|
- successful install persists `InstallMetadata` into `AppRecord`
|
||||||
|
- remove resolves uninstall targets from metadata when present
|
||||||
|
- remove falls back to `aim`-managed path derivation when metadata is absent
|
||||||
|
- uninstall deletes payload, desktop entry, and icon, then refreshes desktop caches best-effort
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### `InstallMetadata`
|
||||||
|
|
||||||
|
Persist this on each installed app record:
|
||||||
|
|
||||||
|
- `scope`
|
||||||
|
- `payload_path`
|
||||||
|
- `desktop_entry_path`
|
||||||
|
- `icon_path`
|
||||||
|
|
||||||
|
Store these as optional path strings under an optional `install` field on `AppRecord`.
|
||||||
|
|
||||||
|
### Backward compatibility
|
||||||
|
|
||||||
|
- `AppRecord.install` is optional
|
||||||
|
- legacy records continue to load unchanged
|
||||||
|
- remove derives fallback targets from `stable_id` and inferred scope when `install` is absent
|
||||||
|
|
||||||
|
## Remove Execution Model
|
||||||
|
|
||||||
|
### 1. Resolve the app
|
||||||
|
|
||||||
|
- Match the registered app by stable id or display name
|
||||||
|
- Preserve existing ambiguity behavior
|
||||||
|
|
||||||
|
### 2. Resolve uninstall targets
|
||||||
|
|
||||||
|
Prefer persisted metadata:
|
||||||
|
|
||||||
|
- exact payload path
|
||||||
|
- exact desktop entry path
|
||||||
|
- exact icon path
|
||||||
|
- persisted scope
|
||||||
|
|
||||||
|
Fallback for legacy records:
|
||||||
|
|
||||||
|
- payload path from `managed_appimage_path(...)`
|
||||||
|
- desktop entry path from `desktop_entry_path(...)`
|
||||||
|
- icon path from `icon_path(...)`
|
||||||
|
- scope inferred conservatively, defaulting to user-managed locations when exact scope is unknown
|
||||||
|
|
||||||
|
### 3. Delete managed artifacts
|
||||||
|
|
||||||
|
Delete only `aim`-managed artifacts:
|
||||||
|
|
||||||
|
- AppImage payload
|
||||||
|
- generated `.desktop` file
|
||||||
|
- generated icon
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- treat missing files as already removed
|
||||||
|
- collect deleted paths for reporting
|
||||||
|
- do not attempt to delete non-`aim` unmanaged locations
|
||||||
|
|
||||||
|
### 4. Refresh desktop integration best-effort
|
||||||
|
|
||||||
|
- probe helpers using the same capability model used by install
|
||||||
|
- run `update-desktop-database` and `gtk-update-icon-cache` when relevant
|
||||||
|
- return warnings instead of failing removal if refresh helpers are missing or fail
|
||||||
|
|
||||||
|
### 5. Persist registry last
|
||||||
|
|
||||||
|
- only save the registry after uninstall execution completes
|
||||||
|
- successful uninstall with warnings still removes the registry entry
|
||||||
|
|
||||||
|
## Safety Model
|
||||||
|
|
||||||
|
### Managed-only deletion
|
||||||
|
|
||||||
|
Uninstall must only delete files that are known `aim` outputs:
|
||||||
|
|
||||||
|
- persisted metadata generated by `aim`
|
||||||
|
- derived managed paths under `aim`-controlled naming conventions
|
||||||
|
|
||||||
|
### Idempotency
|
||||||
|
|
||||||
|
Repeated remove attempts should be safe:
|
||||||
|
|
||||||
|
- missing files are ignored
|
||||||
|
- warnings can still be emitted for helper refresh failures
|
||||||
|
- registry result remains correct even if artifacts were already manually deleted
|
||||||
|
|
||||||
|
### Failure handling
|
||||||
|
|
||||||
|
- file deletion failures should stop removal and keep the app registered
|
||||||
|
- refresh failures should not block removal
|
||||||
|
- no rollback is needed for already-deleted files
|
||||||
|
|
||||||
|
## Verification Strategy
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
|
||||||
|
- install metadata round-trips through the registry model
|
||||||
|
- remove plan resolves exact persisted paths
|
||||||
|
- remove plan falls back to derived managed paths for legacy records
|
||||||
|
|
||||||
|
### Integration tests
|
||||||
|
|
||||||
|
- add then remove deletes payload, desktop entry, and icon
|
||||||
|
- remove tolerates already-missing artifacts
|
||||||
|
- remove emits warnings when refresh helpers are unavailable or fail
|
||||||
|
- legacy registry entries without install metadata still uninstall derived user-managed files
|
||||||
|
|
||||||
|
## Architecture Slice
|
||||||
|
|
||||||
|
### Core changes
|
||||||
|
|
||||||
|
- extend `domain::app::AppRecord` with optional `InstallMetadata`
|
||||||
|
- populate `InstallMetadata` in the add/install path from `InstalledApp`
|
||||||
|
- extend `app::remove` with uninstall planning and execution
|
||||||
|
- reuse `integration::refresh` helper execution after deletion
|
||||||
|
|
||||||
|
### CLI changes
|
||||||
|
|
||||||
|
- keep CLI thin
|
||||||
|
- `aim-cli` should continue delegating uninstall behavior to `aim-core`
|
||||||
|
- render warnings from uninstall results if present
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- deleting arbitrary external files not managed by `aim`
|
||||||
|
- adding a separate garbage collection command in this change
|
||||||
|
- refactoring install path layout beyond the metadata needed for uninstall
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
# Remove Uninstall Metadata Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make `aim remove` uninstall `aim`-managed payload, desktop, and icon artifacts by persisting install metadata for new installs and falling back to derived managed paths for legacy registry entries.
|
||||||
|
|
||||||
|
**Architecture:** Extend `AppRecord` with optional install metadata, populate it from the successful install result, then upgrade `app/remove.rs` from registry filtering to an uninstall executor that resolves managed targets, deletes artifacts, refreshes desktop integration best-effort, and only then persists the updated registry. Keep `aim-cli` thin by rendering the richer remove outcome returned by `aim-core`.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust, Cargo workspace, serde-backed registry persistence, std filesystem APIs, existing integration path and refresh helpers, fixture-backed tests in `crates/aim-core/tests` and `crates/aim-cli/tests`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Persist install metadata in the registry model
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/aim-core/src/domain/app.rs`
|
||||||
|
- Modify: `crates/aim-core/src/app/add.rs`
|
||||||
|
- Test: `crates/aim-core/tests/registry_roundtrip.rs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add a registry round-trip test that stores an app record with install metadata and asserts scope and file paths survive serialization.
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test registry_round_trips_install_metadata --package aim-core --test registry_roundtrip`
|
||||||
|
Expected: FAIL because `AppRecord` does not yet have install metadata.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add optional `InstallMetadata` and populate it from `InstalledApp` during add/install completion.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cargo test registry_round_trips_install_metadata --package aim-core --test registry_roundtrip`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/aim-core/src/domain/app.rs crates/aim-core/src/app/add.rs crates/aim-core/tests/registry_roundtrip.rs
|
||||||
|
git commit -m "feat: persist install metadata for installed apps"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Add failing remove tests for uninstall behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/aim-core/tests/remove_flow.rs`
|
||||||
|
- Modify: `crates/aim-cli/tests/end_to_end_cli.rs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add tests that assert:
|
||||||
|
- remove deletes persisted payload, desktop entry, and icon files
|
||||||
|
- remove falls back to derived managed paths for legacy records without install metadata
|
||||||
|
- CLI remove leaves no managed artifacts behind after add + remove
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cargo test remove_deletes_installed_artifacts_from_metadata --package aim-core --test remove_flow`
|
||||||
|
Run: `cargo test remove_command_uninstalls_managed_files --package aim-cli --test end_to_end_cli`
|
||||||
|
Expected: FAIL because current remove only unregisters apps.
|
||||||
|
|
||||||
|
**Step 3: Keep tests minimal and precise**
|
||||||
|
|
||||||
|
Use fixture tempdirs and concrete file existence assertions. Avoid broad integration scaffolding beyond the exact managed artifact set.
|
||||||
|
|
||||||
|
**Step 4: Re-run to confirm the red state is correct**
|
||||||
|
|
||||||
|
Expected: still FAIL for missing uninstall behavior, not for unrelated setup issues.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/aim-core/tests/remove_flow.rs crates/aim-cli/tests/end_to_end_cli.rs
|
||||||
|
git commit -m "test: cover uninstall behavior in remove flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Implement uninstall planning and execution in aim-core
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/aim-core/src/app/remove.rs`
|
||||||
|
- Modify: `crates/aim-core/src/integration/refresh.rs`
|
||||||
|
- Modify: `crates/aim-core/src/integration/paths.rs`
|
||||||
|
- Test: `crates/aim-core/tests/remove_flow.rs`
|
||||||
|
|
||||||
|
**Step 1: Write the minimal implementation**
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
- uninstall target resolution from persisted metadata
|
||||||
|
- derived fallback targets for legacy records
|
||||||
|
- deletion of managed payload, desktop entry, and icon
|
||||||
|
- deleted-path reporting
|
||||||
|
- best-effort refresh warnings after deletion
|
||||||
|
|
||||||
|
**Step 2: Run the focused core remove tests**
|
||||||
|
|
||||||
|
Run: `cargo test remove_deletes_installed_artifacts_from_metadata --package aim-core --test remove_flow`
|
||||||
|
Run: `cargo test remove_falls_back_to_derived_managed_paths --package aim-core --test remove_flow`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 3: Refine failure handling**
|
||||||
|
|
||||||
|
Ensure:
|
||||||
|
- missing files are ignored
|
||||||
|
- deletion IO failures stop removal
|
||||||
|
- refresh failures become warnings only
|
||||||
|
|
||||||
|
**Step 4: Re-run the remove test file**
|
||||||
|
|
||||||
|
Run: `cargo test --package aim-core --test remove_flow`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/aim-core/src/app/remove.rs crates/aim-core/src/integration/refresh.rs crates/aim-core/src/integration/paths.rs crates/aim-core/tests/remove_flow.rs
|
||||||
|
git commit -m "feat: uninstall managed artifacts during remove"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Surface uninstall results through the CLI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/aim-cli/src/lib.rs`
|
||||||
|
- Modify: `crates/aim-cli/src/ui/render.rs`
|
||||||
|
- Test: `crates/aim-cli/tests/end_to_end_cli.rs`
|
||||||
|
|
||||||
|
**Step 1: Write the minimal CLI integration**
|
||||||
|
|
||||||
|
Return and render uninstall details from `aim-core`, including warnings when refresh helpers are unavailable or fail.
|
||||||
|
|
||||||
|
**Step 2: Run the focused CLI tests**
|
||||||
|
|
||||||
|
Run: `cargo test remove_command_uninstalls_managed_files --package aim-cli --test end_to_end_cli`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 3: Preserve existing UX shape**
|
||||||
|
|
||||||
|
Keep the CLI thin and avoid duplicating uninstall logic in `aim-cli`.
|
||||||
|
|
||||||
|
**Step 4: Re-run the full CLI end-to-end test file**
|
||||||
|
|
||||||
|
Run: `cargo test --package aim-cli --test end_to_end_cli`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/aim-cli/src/lib.rs crates/aim-cli/src/ui/render.rs crates/aim-cli/tests/end_to_end_cli.rs
|
||||||
|
git commit -m "feat: render uninstall results from remove"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Run full workspace verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: none expected
|
||||||
|
- Test: workspace-wide verification
|
||||||
|
|
||||||
|
**Step 1: Run formatting check**
|
||||||
|
|
||||||
|
Run: `cargo fmt --check`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 2: Run lints**
|
||||||
|
|
||||||
|
Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `cargo test --workspace`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 4: If any failure appears, fix only the uninstall-metadata related issue and re-run verification**
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "test: verify uninstall metadata remove flow"
|
||||||
|
```
|
||||||
|
|
@ -8,7 +8,7 @@ use aim_core::app::add::{
|
||||||
AddPlan, InstalledApp, build_add_plan, install_app, resolve_requested_scope,
|
AddPlan, InstalledApp, build_add_plan, install_app, resolve_requested_scope,
|
||||||
};
|
};
|
||||||
use aim_core::app::list::{ListRow, build_list_rows};
|
use aim_core::app::list::{ListRow, build_list_rows};
|
||||||
use aim_core::app::remove::remove_registered_app;
|
use aim_core::app::remove::{RemovalResult, remove_registered_app};
|
||||||
use aim_core::app::update::build_update_plan;
|
use aim_core::app::update::build_update_plan;
|
||||||
use aim_core::domain::app::AppRecord;
|
use aim_core::domain::app::AppRecord;
|
||||||
use aim_core::domain::update::UpdatePlan;
|
use aim_core::domain::update::UpdatePlan;
|
||||||
|
|
@ -36,12 +36,13 @@ pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
||||||
return match command {
|
return match command {
|
||||||
cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))),
|
cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))),
|
||||||
cli::args::Command::Remove { query } => {
|
cli::args::Command::Remove { query } => {
|
||||||
let removal = remove_registered_app(&query, &apps)?;
|
let removal = remove_registered_app(&query, &apps, &install_home)?;
|
||||||
|
let remaining_apps = removal.remaining_apps.clone();
|
||||||
store.save(&Registry {
|
store.save(&Registry {
|
||||||
version: registry.version,
|
version: registry.version,
|
||||||
apps: removal.remaining_apps,
|
apps: remaining_apps,
|
||||||
})?;
|
})?;
|
||||||
Ok(DispatchResult::Removed(removal.removed.display_name))
|
Ok(DispatchResult::Removed(Box::new(removal)))
|
||||||
}
|
}
|
||||||
cli::args::Command::Update => Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?)),
|
cli::args::Command::Update => Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?)),
|
||||||
};
|
};
|
||||||
|
|
@ -91,7 +92,7 @@ pub enum DispatchResult {
|
||||||
Added(Box<InstalledApp>),
|
Added(Box<InstalledApp>),
|
||||||
List(Vec<ListRow>),
|
List(Vec<ListRow>),
|
||||||
PendingAdd(Box<AddPlan>),
|
PendingAdd(Box<AddPlan>),
|
||||||
Removed(String),
|
Removed(Box<RemovalResult>),
|
||||||
UpdatePlan(UpdatePlan),
|
UpdatePlan(UpdatePlan),
|
||||||
Noop,
|
Noop,
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +102,7 @@ pub enum DispatchError {
|
||||||
AddPlan(aim_core::app::add::BuildAddPlanError),
|
AddPlan(aim_core::app::add::BuildAddPlanError),
|
||||||
AddInstall(aim_core::app::add::InstallAppError),
|
AddInstall(aim_core::app::add::InstallAppError),
|
||||||
Prompt(ui::prompt::PromptError),
|
Prompt(ui::prompt::PromptError),
|
||||||
RemovePlan(aim_core::app::remove::ResolveRegisteredAppError),
|
RemovePlan(aim_core::app::remove::RemoveRegisteredAppError),
|
||||||
Registry(aim_core::registry::store::RegistryStoreError),
|
Registry(aim_core::registry::store::RegistryStoreError),
|
||||||
UpdatePlan(aim_core::app::update::BuildUpdatePlanError),
|
UpdatePlan(aim_core::app::update::BuildUpdatePlanError),
|
||||||
}
|
}
|
||||||
|
|
@ -130,8 +131,8 @@ impl From<aim_core::app::update::BuildUpdatePlanError> for DispatchError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<aim_core::app::remove::ResolveRegisteredAppError> for DispatchError {
|
impl From<aim_core::app::remove::RemoveRegisteredAppError> for DispatchError {
|
||||||
fn from(value: aim_core::app::remove::ResolveRegisteredAppError) -> Self {
|
fn from(value: aim_core::app::remove::RemoveRegisteredAppError) -> Self {
|
||||||
Self::RemovePlan(value)
|
Self::RemovePlan(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String {
|
||||||
DispatchResult::Added(added) => render_added_app(added),
|
DispatchResult::Added(added) => render_added_app(added),
|
||||||
DispatchResult::List(rows) => render_list(rows),
|
DispatchResult::List(rows) => render_list(rows),
|
||||||
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
|
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
|
||||||
DispatchResult::Removed(display_name) => format!("removed: {display_name}"),
|
DispatchResult::Removed(removed) => render_removed_app(removed),
|
||||||
DispatchResult::UpdatePlan(plan) => {
|
DispatchResult::UpdatePlan(plan) => {
|
||||||
render_update_summary(plan.items.len(), plan.items.len(), 0)
|
render_update_summary(plan.items.len(), plan.items.len(), 0)
|
||||||
}
|
}
|
||||||
|
|
@ -72,3 +72,19 @@ fn render_list(rows: &[aim_core::app::list::ListRow]) -> String {
|
||||||
}
|
}
|
||||||
output.trim_end().to_owned()
|
output.trim_end().to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String {
|
||||||
|
let warning_lines = removed
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.map(|warning| format!("warning: {warning}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let summary = format!("removed: {}", removed.removed.display_name);
|
||||||
|
|
||||||
|
if warning_lines.is_empty() {
|
||||||
|
summary
|
||||||
|
} else {
|
||||||
|
format!("{summary}\n{warning_lines}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,40 @@ fn remove_command_removes_registered_app_from_registry_file() {
|
||||||
assert!(!contents.contains("stable_id = \"bat\""));
|
assert!(!contents.contains("stable_id = \"bat\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_command_uninstalls_managed_files() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let registry_path = dir.path().join("registry.toml");
|
||||||
|
let install_home = dir.path().join("install-home");
|
||||||
|
let payload_path = install_home.join(".local/lib/aim/appimages/sharkdp-bat.AppImage");
|
||||||
|
let desktop_path = install_home.join(".local/share/applications/aim-sharkdp-bat.desktop");
|
||||||
|
let icon_path = install_home.join(".local/share/icons/hicolor/256x256/apps/sharkdp-bat.png");
|
||||||
|
|
||||||
|
let mut add_cmd = Command::cargo_bin("aim").unwrap();
|
||||||
|
add_cmd
|
||||||
|
.arg("sharkdp/bat")
|
||||||
|
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||||
|
.env(FIXTURE_MODE_ENV, "1")
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
assert!(payload_path.exists());
|
||||||
|
assert!(desktop_path.exists());
|
||||||
|
assert!(icon_path.exists());
|
||||||
|
|
||||||
|
let mut remove_cmd = Command::cargo_bin("aim").unwrap();
|
||||||
|
remove_cmd
|
||||||
|
.args(["remove", "sharkdp-bat"])
|
||||||
|
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("removed: bat"));
|
||||||
|
|
||||||
|
assert!(!payload_path.exists());
|
||||||
|
assert!(!desktop_path.exists());
|
||||||
|
assert!(!icon_path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn query_command_registers_unambiguous_app_in_registry_file() {
|
fn query_command_registers_unambiguous_app_in_registry_file() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_ident
|
||||||
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
||||||
use crate::app::query::{ResolveQueryError, resolve_query};
|
use crate::app::query::{ResolveQueryError, resolve_query};
|
||||||
use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default};
|
use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default};
|
||||||
use crate::domain::app::{AppRecord, InstallScope};
|
use crate::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||||
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind};
|
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind};
|
||||||
use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy};
|
use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy};
|
||||||
use crate::integration::install::{InstallOutcome, InstallRequest, execute_install};
|
use crate::integration::install::{InstallOutcome, InstallRequest, execute_install};
|
||||||
|
|
@ -177,6 +177,7 @@ pub fn materialize_app_record(
|
||||||
installed_version: Some(plan.selected_artifact.version.clone()),
|
installed_version: Some(plan.selected_artifact.version.clone()),
|
||||||
update_strategy: Some(plan.update_strategy.clone()),
|
update_strategy: Some(plan.update_strategy.clone()),
|
||||||
metadata: plan.metadata.clone(),
|
metadata: plan.metadata.clone(),
|
||||||
|
install: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,7 +187,7 @@ pub fn install_app(
|
||||||
install_home: &Path,
|
install_home: &Path,
|
||||||
requested_scope: InstallScope,
|
requested_scope: InstallScope,
|
||||||
) -> Result<InstalledApp, InstallAppError> {
|
) -> Result<InstalledApp, InstallAppError> {
|
||||||
let record =
|
let mut record =
|
||||||
materialize_app_record(source_input, plan).map_err(InstallAppError::Materialize)?;
|
materialize_app_record(source_input, plan).map_err(InstallAppError::Materialize)?;
|
||||||
let (family, capabilities) =
|
let (family, capabilities) =
|
||||||
probe_live_host(install_home, requested_scope).map_err(InstallAppError::HostProbe)?;
|
probe_live_host(install_home, requested_scope).map_err(InstallAppError::HostProbe)?;
|
||||||
|
|
@ -234,6 +235,19 @@ pub fn install_app(
|
||||||
})
|
})
|
||||||
.map_err(InstallAppError::Install)?;
|
.map_err(InstallAppError::Install)?;
|
||||||
|
|
||||||
|
record.install = Some(InstallMetadata {
|
||||||
|
scope: policy.scope,
|
||||||
|
payload_path: Some(install_outcome.final_payload_path.display().to_string()),
|
||||||
|
desktop_entry_path: install_outcome
|
||||||
|
.desktop_entry_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|path| path.display().to_string()),
|
||||||
|
icon_path: install_outcome
|
||||||
|
.icon_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|path| path.display().to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
Ok(InstalledApp {
|
Ok(InstalledApp {
|
||||||
record,
|
record,
|
||||||
selected_artifact: plan.selected_artifact.clone(),
|
selected_artifact: plan.selected_artifact.clone(),
|
||||||
|
|
@ -279,7 +293,7 @@ pub enum InstallAppError {
|
||||||
|
|
||||||
fn download_artifact_bytes(url: &str) -> Result<Vec<u8>, InstallAppError> {
|
fn download_artifact_bytes(url: &str) -> Result<Vec<u8>, InstallAppError> {
|
||||||
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
|
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
|
||||||
return Ok(b"\x7fELFAppImage".to_vec());
|
return Ok(b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82".to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = reqwest::blocking::get(url).map_err(InstallAppError::Download)?;
|
let response = reqwest::blocking::get(url).map_err(InstallAppError::Download)?;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
||||||
use crate::domain::app::AppRecord;
|
use crate::domain::app::{AppRecord, InstallScope};
|
||||||
|
use crate::integration::paths::{desktop_entry_path, icon_path, managed_appimage_path};
|
||||||
|
use crate::integration::refresh::refresh_integration;
|
||||||
|
use crate::platform::probe_live_host;
|
||||||
|
|
||||||
pub fn resolve_registered_app<'a>(
|
pub fn resolve_registered_app<'a>(
|
||||||
query: &str,
|
query: &str,
|
||||||
|
|
@ -41,19 +48,27 @@ pub struct RemovalPlan {
|
||||||
pub artifact_paths: Vec<String>,
|
pub artifact_paths: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_removal_plan(app: &AppRecord) -> RemovalPlan {
|
pub fn build_removal_plan(app: &AppRecord, install_home: &Path) -> RemovalPlan {
|
||||||
|
let artifact_paths = removal_artifact_paths(app, install_home)
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
RemovalPlan {
|
RemovalPlan {
|
||||||
stable_id: app.stable_id.clone(),
|
stable_id: app.stable_id.clone(),
|
||||||
display_name: app.display_name.clone(),
|
display_name: app.display_name.clone(),
|
||||||
artifact_paths: Vec::new(),
|
artifact_paths,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_registered_app(
|
pub fn remove_registered_app(
|
||||||
query: &str,
|
query: &str,
|
||||||
apps: &[AppRecord],
|
apps: &[AppRecord],
|
||||||
) -> Result<RemovalResult, ResolveRegisteredAppError> {
|
install_home: &Path,
|
||||||
let app = resolve_registered_app(query, apps)?;
|
) -> Result<RemovalResult, RemoveRegisteredAppError> {
|
||||||
|
let app = resolve_registered_app(query, apps).map_err(RemoveRegisteredAppError::Resolve)?;
|
||||||
|
let plan = build_removal_plan(app, install_home);
|
||||||
|
let warnings = delete_artifacts(&plan)?;
|
||||||
let remaining_apps = apps
|
let remaining_apps = apps
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|candidate| candidate.stable_id != app.stable_id)
|
.filter(|candidate| candidate.stable_id != app.stable_id)
|
||||||
|
|
@ -61,8 +76,9 @@ pub fn remove_registered_app(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(RemovalResult {
|
Ok(RemovalResult {
|
||||||
removed: build_removal_plan(app),
|
removed: plan,
|
||||||
remaining_apps,
|
remaining_apps,
|
||||||
|
warnings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +86,13 @@ pub fn remove_registered_app(
|
||||||
pub struct RemovalResult {
|
pub struct RemovalResult {
|
||||||
pub removed: RemovalPlan,
|
pub removed: RemovalPlan,
|
||||||
pub remaining_apps: Vec<AppRecord>,
|
pub remaining_apps: Vec<AppRecord>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RemoveRegisteredAppError {
|
||||||
|
Resolve(ResolveRegisteredAppError),
|
||||||
|
Io(io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
|
@ -81,3 +104,48 @@ pub enum ResolveRegisteredAppError {
|
||||||
fn normalize_lookup(value: &str) -> String {
|
fn normalize_lookup(value: &str) -> String {
|
||||||
value.trim().to_ascii_lowercase()
|
value.trim().to_ascii_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn removal_artifact_paths(app: &AppRecord, install_home: &Path) -> Vec<PathBuf> {
|
||||||
|
if let Some(install) = &app.install {
|
||||||
|
return [
|
||||||
|
install.payload_path.as_deref(),
|
||||||
|
install.desktop_entry_path.as_deref(),
|
||||||
|
install.icon_path.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let scope = InstallScope::User;
|
||||||
|
vec![
|
||||||
|
managed_appimage_path(install_home, scope, &app.stable_id),
|
||||||
|
desktop_entry_path(install_home, scope, &app.stable_id),
|
||||||
|
icon_path(install_home, scope, &app.stable_id),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_artifacts(plan: &RemovalPlan) -> Result<Vec<String>, RemoveRegisteredAppError> {
|
||||||
|
let desktop_path = plan.artifact_paths.get(1).map(PathBuf::from);
|
||||||
|
let icon_path = plan.artifact_paths.get(2).map(PathBuf::from);
|
||||||
|
|
||||||
|
for artifact_path in &plan.artifact_paths {
|
||||||
|
match fs::remove_file(artifact_path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(RemoveRegisteredAppError::Io(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
if let Ok((_, capabilities)) = probe_live_host(Path::new("/"), InstallScope::User) {
|
||||||
|
warnings.extend(refresh_integration(
|
||||||
|
&capabilities.helpers,
|
||||||
|
desktop_path.as_deref(),
|
||||||
|
icon_path.as_deref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(warnings)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::domain::source::SourceRef;
|
use crate::domain::source::SourceRef;
|
||||||
use crate::domain::update::{ParsedMetadata, UpdateStrategy};
|
use crate::domain::update::{ParsedMetadata, UpdateStrategy};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
pub enum InstallScope {
|
pub enum InstallScope {
|
||||||
User,
|
User,
|
||||||
System,
|
System,
|
||||||
|
|
@ -21,6 +21,17 @@ pub struct AppIdentity {
|
||||||
pub confidence: IdentityConfidence,
|
pub confidence: IdentityConfidence,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct InstallMetadata {
|
||||||
|
pub scope: InstallScope,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payload_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub desktop_entry_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
pub struct AppRecord {
|
pub struct AppRecord {
|
||||||
pub stable_id: String,
|
pub stable_id: String,
|
||||||
|
|
@ -35,4 +46,6 @@ pub struct AppRecord {
|
||||||
pub update_strategy: Option<UpdateStrategy>,
|
pub update_strategy: Option<UpdateStrategy>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metadata: Vec<ParsedMetadata>,
|
pub metadata: Vec<ParsedMetadata>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub install: Option<InstallMetadata>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ fn registry_round_trips_update_strategy_and_alternates() {
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
metadata: Vec::new(),
|
metadata: Vec::new(),
|
||||||
|
install: None,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -51,3 +52,52 @@ fn registry_round_trips_update_strategy_and_alternates() {
|
||||||
assert_eq!(strategy.preferred.reason, "install-origin-match");
|
assert_eq!(strategy.preferred.reason, "install-origin-match");
|
||||||
assert_eq!(strategy.alternates.len(), 2);
|
assert_eq!(strategy.alternates.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_round_trips_install_metadata() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||||
|
let registry = aim_core::registry::model::Registry {
|
||||||
|
version: 1,
|
||||||
|
apps: vec![aim_core::domain::app::AppRecord {
|
||||||
|
stable_id: "t3code".to_owned(),
|
||||||
|
display_name: "T3 Code".to_owned(),
|
||||||
|
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||||
|
source: None,
|
||||||
|
installed_version: Some("0.0.11".to_owned()),
|
||||||
|
update_strategy: None,
|
||||||
|
metadata: Vec::new(),
|
||||||
|
install: Some(aim_core::domain::app::InstallMetadata {
|
||||||
|
scope: aim_core::domain::app::InstallScope::User,
|
||||||
|
payload_path: Some(
|
||||||
|
"/tmp/install-home/.local/lib/aim/appimages/t3code.AppImage".to_owned(),
|
||||||
|
),
|
||||||
|
desktop_entry_path: Some(
|
||||||
|
"/tmp/install-home/.local/share/applications/aim-t3code.desktop".to_owned(),
|
||||||
|
),
|
||||||
|
icon_path: Some(
|
||||||
|
"/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png"
|
||||||
|
.to_owned(),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
store.save(®istry).unwrap();
|
||||||
|
let loaded = store.load().unwrap();
|
||||||
|
|
||||||
|
let install = loaded.apps[0].install.as_ref().unwrap();
|
||||||
|
assert_eq!(install.scope, aim_core::domain::app::InstallScope::User);
|
||||||
|
assert_eq!(
|
||||||
|
install.payload_path.as_deref(),
|
||||||
|
Some("/tmp/install-home/.local/lib/aim/appimages/t3code.AppImage")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
install.desktop_entry_path.as_deref(),
|
||||||
|
Some("/tmp/install-home/.local/share/applications/aim-t3code.desktop")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
install.icon_path.as_deref(),
|
||||||
|
Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
|
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
|
||||||
use aim_core::app::list::build_list_rows;
|
use aim_core::app::list::build_list_rows;
|
||||||
use aim_core::app::remove::resolve_registered_app;
|
use aim_core::app::remove::{build_removal_plan, resolve_registered_app};
|
||||||
use aim_core::domain::app::AppRecord;
|
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remove_flow_rejects_unknown_app_names() {
|
fn remove_flow_rejects_unknown_app_names() {
|
||||||
|
|
@ -20,6 +21,7 @@ fn list_flow_returns_display_rows_for_registered_apps() {
|
||||||
installed_version: None,
|
installed_version: None,
|
||||||
update_strategy: None,
|
update_strategy: None,
|
||||||
metadata: Vec::new(),
|
metadata: Vec::new(),
|
||||||
|
install: None,
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
|
|
@ -38,6 +40,7 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
|
||||||
installed_version: None,
|
installed_version: None,
|
||||||
update_strategy: None,
|
update_strategy: None,
|
||||||
metadata: Vec::new(),
|
metadata: Vec::new(),
|
||||||
|
install: None,
|
||||||
},
|
},
|
||||||
AppRecord {
|
AppRecord {
|
||||||
stable_id: "bat-nightly".to_owned(),
|
stable_id: "bat-nightly".to_owned(),
|
||||||
|
|
@ -47,6 +50,7 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
|
||||||
installed_version: None,
|
installed_version: None,
|
||||||
update_strategy: None,
|
update_strategy: None,
|
||||||
metadata: Vec::new(),
|
metadata: Vec::new(),
|
||||||
|
install: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -65,3 +69,59 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removal_plan_prefers_persisted_install_metadata_paths() {
|
||||||
|
let app = 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: Some(InstallMetadata {
|
||||||
|
scope: InstallScope::System,
|
||||||
|
payload_path: Some("/opt/aim/appimages/bat.AppImage".to_owned()),
|
||||||
|
desktop_entry_path: Some("/usr/share/applications/aim-bat.desktop".to_owned()),
|
||||||
|
icon_path: Some("/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let plan = build_removal_plan(&app, Path::new("/home/test"));
|
||||||
|
|
||||||
|
assert_eq!(plan.stable_id, "bat");
|
||||||
|
assert_eq!(
|
||||||
|
plan.artifact_paths,
|
||||||
|
vec![
|
||||||
|
"/opt/aim/appimages/bat.AppImage".to_owned(),
|
||||||
|
"/usr/share/applications/aim-bat.desktop".to_owned(),
|
||||||
|
"/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removal_plan_falls_back_to_derived_managed_user_paths() {
|
||||||
|
let app = 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
let plan = build_removal_plan(&app, Path::new("/home/test"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
plan.artifact_paths,
|
||||||
|
vec![
|
||||||
|
"/home/test/.local/lib/aim/appimages/bat.AppImage".to_owned(),
|
||||||
|
"/home/test/.local/share/applications/aim-bat.desktop".to_owned(),
|
||||||
|
"/home/test/.local/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ fn installed_apps_are_carried_into_review_plan() {
|
||||||
installed_version: None,
|
installed_version: None,
|
||||||
update_strategy: None,
|
update_strategy: None,
|
||||||
metadata: Vec::new(),
|
metadata: Vec::new(),
|
||||||
|
install: None,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let plan = build_update_plan(&apps).unwrap();
|
let plan = build_update_plan(&apps).unwrap();
|
||||||
|
|
@ -49,6 +50,7 @@ fn update_plan_uses_alternate_channel_after_preferred_failure() {
|
||||||
}],
|
}],
|
||||||
}),
|
}),
|
||||||
metadata: Vec::new(),
|
metadata: Vec::new(),
|
||||||
|
install: None,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let plan = build_update_plan(&apps).unwrap();
|
let plan = build_update_plan(&apps).unwrap();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue