refactor: rename aim to upm and extract appimage module

This commit is contained in:
stoorps 2026-03-21 22:39:11 +00:00
parent af13e98eb3
commit 863c57e473
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
117 changed files with 2622 additions and 887 deletions

View file

@ -2,20 +2,22 @@
## Workspace Shape ## Workspace Shape
`aim` is a Rust workspace with two main crates: `upm` is a Rust workspace with three main crates:
- `crates/aim-core`: source normalization, provider adapters, install/update planning, payload installation, registry persistence, and desktop integration. - `crates/upm-core`: source normalization, add/update orchestration, registry persistence, install policies, desktop integration, and the provider-composition seam.
- `crates/aim-cli`: argument parsing, config loading, terminal UX, prompting, progress reporting, and summary rendering. - `crates/upm`: argument parsing, config loading, terminal UX, prompting, progress reporting, summary rendering, and provider assembly.
- `crates/upm-appimage`: AppImageHub transport, search-provider behavior, and exact add-resolution for AppImage-backed installs.
The split keeps product logic in `aim-core` so additional frontends can reuse the same install and update pipeline. The split keeps frontend-agnostic logic in `upm-core`, while concrete package-source behavior is composed at the CLI boundary. That keeps the headless layer reusable for future frontends without making provider behavior a permanent core dependency.
## Core Flow ## Core Flow
The main execution path is: The main execution path is:
1. Parse CLI input and load runtime config in `aim-cli`. 1. Parse CLI input and load runtime config in `upm`.
2. Resolve the query into a normalized source in `aim-core`. 2. Assemble a `ProviderRegistry` in `crates/upm/src/providers.rs`.
3. Build an add or update plan through provider adapters and artifact selection. 3. Resolve the query into a normalized source in `upm-core`.
4. Build an add or update plan through core orchestration plus any registered external providers.
4. Download the selected AppImage into a staged path. 4. Download the selected AppImage into a staged path.
5. Verify integrity metadata when available. 5. Verify integrity metadata when available.
6. Commit the payload into the managed install location. 6. Commit the payload into the managed install location.
@ -33,7 +35,16 @@ Supported source classes currently include:
- direct URLs - direct URLs
- local file imports - local file imports
Provider-specific resolution lives in `crates/aim-core/src/adapters` and `crates/aim-core/src/source`. Core source normalization and orchestration live in `crates/upm-core`. AppImageHub-specific transport and provider behavior live in `crates/upm-appimage` and are injected through `ProviderRegistry` rather than hardcoded into core entrypoints.
## Runtime Interface
The rename to `upm` is a hard cutover:
- runtime overrides use `UPM_*`
- legacy `AIM_*` runtime overrides are not read
- default config, registry, payload, and desktop-entry paths use `upm` names
- helper audit logging uses `UPM_DEBUG_EXTERNAL_HELPERS=1`
## Security Hardening State ## Security Hardening State
@ -51,9 +62,9 @@ The remaining deferred AppImageHub host-trust concern is tracked in `security-is
## Persistence And Integration ## Persistence And Integration
- Registry writes are atomic and live under the registry store implementation in `aim-core`. - Registry writes are atomic and live under the registry store implementation in `upm-core`.
- Managed payload, desktop entry, and icon paths are resolved from install policy and scope. - Managed payload, desktop entry, and icon paths are resolved from install policy and scope.
- Desktop integration refresh uses external helpers when available and now supports env-gated audit logging through `AIM_DEBUG_EXTERNAL_HELPERS=1`. - Desktop integration refresh uses external helpers when available and now supports env-gated audit logging through `UPM_DEBUG_EXTERNAL_HELPERS=1`.
## Planning And Audit Artifacts ## Planning And Audit Artifacts

208
.architecture/roadmap.md Normal file
View file

@ -0,0 +1,208 @@
# UPM Roadmap
## Direction
The project is evolving from a focused AppImage manager into `upm`, a modular universal package manager. The target system manages multiple package sources through a shared headless core, keeps the CLI thin, and leaves room for future GUI frontends.
The initial rename-and-extraction slice has now landed as a hard cutover: the runtime surface is `upm`, the shared core is `upm-core`, and AppImage support is composed through `upm-appimage` instead of being embedded directly into the core.
The near-term goal is a Linux-first platform with honest cross-platform architecture. Phase 2 will implement Linux package sources while establishing the abstractions needed for later macOS support and possible Windows support.
## Confirmed Product Decisions
- `aim-cli` becomes `upm`.
- `aim-core` stops being the all-in-one backend and is split.
- AppImage support becomes an installable module named `upm-appimage`.
- Shared orchestration, config, state, resolution, ranking, and frontend-facing APIs move into `upm-core`.
- The `upm` crate becomes a thin CLI client over `upm-core`.
- The rename is a hard cutover. Legacy `AIM_*` runtime interfaces are removed rather than preserved.
- The declarative package file starts in a hybrid mode and is intended to become the source of truth over time.
- Every `upm` invocation should detect drift between declared state and observed system state, then auto-sync metadata as needed.
- Phase 2 is Linux implementation first, with macOS-oriented provider abstractions and packaging seams designed now.
- Feature delivery should happen as vertical slices rather than a single large refactor.
- The `upm` branch is the effective trunk for this evolution work and should be treated as the integration base for future UPM feature branches and worktrees.
## Architectural Destination
### Workspace Shape
The intended workspace shape after the initial refactor is:
- `upm`: thin CLI frontend, ratatui config UI, command routing, presentation.
- `upm-core`: headless application layer, provider registry, resolution pipeline, state model, declarative sync engine, ranking, policies, and frontend-agnostic APIs.
- `upm-appimage`: AppImage provider module extracted from the current `aim-core` implementation.
- future provider modules: `upm-pacman`, `upm-aur`, `upm-flatpak`, `upm-cargo`, `upm-npm`, and later macOS or Windows-specific modules.
### Module Model
UPM should stay modular in both code and packaging:
- modules can be enabled or disabled by config
- providers can be ranked by user priority
- distro packaging can offer grouped installs such as `upm-full`
- lighter installs can ship only the core and selected modules
This means provider capabilities, discovery, search, install, remove, inspect, and sync behavior need stable interfaces in `upm-core` rather than provider-specific branching in the CLI.
### State Model
The long-term model is declarative and config-first, but Phase 2 begins with a hybrid approach:
- `upm`-managed actions update the declarative config directly
- `upm update` and normal command entrypoints can inspect live system state
- drift detection reconciles unmanaged or changed packages into the config representation
- over time the config becomes authoritative and reconciliation becomes stricter
## Phase 2 Milestones
### Milestone 0: Rename And Split Foundation
Deliver the naming and ownership transition without changing product scope yet.
Goals:
- rename workspace crates and package outputs from `aim` to `upm`
- create `upm-core` by extracting reusable infrastructure from `aim-core`
- reduce the CLI crate to a frontend over headless APIs
- isolate current AppImage-specific logic into `upm-appimage`
- compose provider behavior in the CLI through `ProviderRegistry` rather than hardcoded AppImage paths in `upm-core`
- preserve current AppImage functionality and tests during the move
Exit criteria:
- `upm` binary replaces `aim`
- workspace builds under new crate names
- AppImage flows still work end-to-end through the new layering
### Milestone 1: AppImage On The New Core
Make the current AppImage implementation the first real module on the modular architecture.
Goals:
- validate the provider module contract using AppImage as the reference implementation
- move search, add, install, update, show, and remove behaviors behind core provider APIs
- prove the CLI can treat AppImage as just one enabled source
Exit criteria:
- AppImage support is no longer special-cased as the whole product
- provider registration and capability discovery exist in `upm-core`
### Milestone 2: Linux Native Sources
Add the first non-AppImage providers as Linux-focused vertical slices.
Initial supported sources:
- pacman
- AUR
- Flatpak
- cargo global installs
- npm global installs
Goals:
- implement provider discovery and capability coverage for each source
- normalize package identity, version, installed state, and update candidates across providers
- support search, inspect, install, remove, and update planning where each provider can do so safely
- capture provider limitations explicitly rather than faking uniformity
Exit criteria:
- multi-source search works across enabled providers
- installs and removals work through a consistent command model
- provider-specific metadata is normalized enough for ranking and sync
### Milestone 3: Provider Priority And Config UX
Expose modularity directly in the terminal interface.
Goals:
- build a ratatui configuration menu
- allow enablement and disablement per provider
- allow explicit search and install priority ordering
- allow configuration of module weight, source preference, and future policy toggles
- make search ranking obey configured priority instead of hard-coded source bias
Exit criteria:
- users can manage provider selection and ranking from the TUI
- search results are explainable in terms of configured preference order
### Milestone 4: Declarative Package State And Drift Sync
Introduce the nix-like experience incrementally.
Goals:
- define the declarative package file format in `config.toml` or a closely related managed file
- track installed packages by provider in a normalized state model
- implement `upm update` as both package refresh and state reconciliation
- scan currently installed packages from supported providers and build or refresh the declared package set
- auto-sync detected drift during any `upm` command invocation
Phase 2 intent:
- begin in hybrid mode
- move steadily toward config-first behavior
- avoid destructive reconciliation until provider semantics are trustworthy
Exit criteria:
- users can bootstrap a declarative package definition from the current machine state
- repeated `upm` runs keep declared and observed state aligned
- state drift is surfaced clearly and reconciled predictably
### Milestone 5: Packaging, Distribution, And Platform Seams
Make the modular architecture real at packaging time, not just in code.
Goals:
- define packaging layout for standalone core, selected modules, and full installs
- support distro-level grouped packages such as `upm-full`
- ensure unsupported modules degrade cleanly on the wrong OS or distro
- add macOS provider and packaging seams to `upm-core` even if Linux remains the only implemented provider set in Phase 2
Exit criteria:
- module packaging strategy is documented and testable
- cross-platform abstractions exist without blocking Linux delivery
## Phase 2 Non-Goals
The following are explicitly not required to complete Phase 2:
- full macOS provider implementation
- Windows provider implementation
- GUI frontend delivery
- forcing strict config-authoritative reconciliation before provider behavior is stable
- shipping every conceivable Linux package manager in the first expansion
## Success Criteria
Phase 2 is successful when the project can credibly be described as a modular package manager rather than an AppImage manager with extra adapters.
That means:
- the product name, workspace shape, and binary identity are all `upm`
- AppImage support is only one module among several
- Linux users can manage packages from the first targeted provider set
- ranking and enablement are user-controlled through config and TUI
- declarative state exists, is importable from the live system, and stays synchronized through normal use
- the architecture is ready for GUI and later platform expansion without another major rewrite
## Immediate Planning Order
Implementation plans should follow this order:
1. rename and crate extraction
2. provider API definition and AppImage migration onto `upm-core`
3. Linux provider onboarding in a stable order, likely `pacman` then `Flatpak`, then `AUR`, then `cargo`, then `npm`
4. ratatui configuration and ranking UX
5. declarative state model, drift detection, and `update` sync behavior
6. packaging layout and `upm-full` distribution strategy
This order keeps the refactor defensible, gives each slice a usable product outcome, and avoids locking future provider work into AppImage-era assumptions.

View file

@ -0,0 +1,410 @@
# UPM Rename And Core Extraction Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rename the product from `aim` to `upm`, remove legacy `aim` runtime interfaces, extract the shared headless backend into `upm-core`, and move AppImage-specific transport and provider logic into a separate `upm-appimage` module without regressing current AppImage workflows.
**Architecture:** Execute this in vertical slices. First rename the workspace, binary, paths, environment interfaces, and tests to `upm` without carrying legacy `aim` compatibility. Next introduce a narrow provider-composition seam in `upm-core` so AppImage-specific add and search logic can move into `upm-appimage` without creating a dependency cycle. Finally rewire the `upm` CLI to assemble built-in providers, update docs, and run full verification.
**Tech Stack:** Rust workspace, Cargo manifests, clap CLI, ratatui frontend crate, core domain/app modules, fixture-backed provider tests, workspace-wide `cargo test` and `cargo clippy`.
---
### Task 1: Rename the workspace, binary, and default runtime paths to `upm`
**Files:**
- Modify: `Cargo.toml`
- Rename: `crates/aim-cli` -> `crates/upm`
- Rename: `crates/aim-core` -> `crates/upm-core`
- Modify: `crates/upm/Cargo.toml`
- Modify: `crates/upm/src/main.rs`
- Modify: `crates/upm/src/lib.rs`
- Modify: `crates/upm/src/cli/args.rs`
- Modify: `crates/upm/src/config.rs`
- Modify: `crates/upm/src/cli/config.rs`
- Modify: `crates/upm-core/Cargo.toml`
- Modify: `crates/upm-core/src/platform/mod.rs`
- Modify: `crates/upm-core/src/integration/paths.rs`
- Modify: `crates/upm-core/src/integration/policy.rs`
- Test: `crates/upm/tests/cli_smoke.rs`
- Test: `crates/upm/tests/cli_commands.rs`
- Test: `crates/upm/tests/config_loading.rs`
- Test: `crates/upm-core/tests/install_paths.rs`
- Test: `crates/upm-core/tests/install_policy.rs`
**Step 1: Write the failing rename expectations**
Update the selected tests to assert:
- the binary name is `upm`
- clap parses `upm` instead of `aim`
- default config path is `~/.config/upm/config.toml`
- default registry path is `~/.local/share/upm/registry.toml`
- default managed payload roots are `.local/lib/upm/appimages` and `/opt/upm/appimages`
- desktop entry filenames use `upm-<stable-id>.desktop`
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package aim-cli --test cli_smoke
cargo test --package aim-cli --test config_loading
cargo test --package aim-core --test install_paths
cargo test --package aim-core --test install_policy
```
Expected: FAIL because the workspace still exposes `aim`, `aim-cli`, `aim-core`, and `aim` default paths.
**Step 3: Perform the crate and manifest rename**
Run:
```bash
git mv crates/aim-cli crates/upm
git mv crates/aim-core crates/upm-core
```
Then update:
- workspace members and default members in `Cargo.toml`
- package names to `upm` and `upm-core`
- binary name to `upm`
- crate imports from `aim_core` to `upm_core`
- crate imports from `aim_cli` to `upm`
- clap command name from `aim` to `upm`
- default config, registry, payload-root, and desktop-entry paths to `upm`
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm --test cli_smoke
cargo test --package upm --test cli_commands
cargo test --package upm --test config_loading
cargo test --package upm-core --test install_paths
cargo test --package upm-core --test install_policy
```
Expected: PASS.
**Step 5: Commit**
```bash
git add Cargo.toml crates/upm crates/upm-core
git commit -m "refactor: rename workspace to upm"
```
### Task 2: Remove remaining `aim`-named runtime interfaces
**Files:**
- Modify: `crates/upm/src/config.rs`
- Modify: `crates/upm/src/cli/config.rs`
- Modify: `crates/upm/src/lib.rs`
- Modify: `crates/upm/src/ui/prompt.rs`
- Modify: `crates/upm-core/src/platform/mod.rs`
- Modify: `crates/upm-core/src/source/github.rs`
- Modify: `crates/upm-core/src/source/appimagehub.rs`
- Modify: `crates/upm-core/src/integration/refresh.rs`
- Test: `crates/upm/tests/config_loading.rs`
**Step 1: Write the failing strict-rename expectations**
Update representative tests to cover:
- config lookup uses `UPM_CONFIG_PATH`
- registry lookup uses `UPM_REGISTRY_PATH`
- old `AIM_*` config and registry overrides are ignored
- tracking preference uses `UPM_TRACKING_PREFERENCE`
- old `AIM_TRACKING_PREFERENCE` is ignored
- provider fixture execution uses the renamed `UPM_*` interfaces through CLI-facing tests
- managed install and summary output use `upm` paths and desktop prefixes
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm --test config_loading
cargo test --package upm --test search_cli
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: FAIL because representative CLI and config flows still depend on old `aim` names.
**Step 3: Remove the remaining `aim` interfaces**
Update the codebase so renamed runtime interfaces are consistently `upm`:
- environment variable names use `UPM_*`
- helper/debug prefixes print `[upm]`
- GitHub user agent identifies as `upm/0.1`
- old `aim` compatibility reads are removed instead of preserved
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm --test config_loading
cargo test --package upm --test search_cli
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm/src/config.rs crates/upm/src/cli/config.rs crates/upm/src/lib.rs crates/upm/src/ui/prompt.rs crates/upm-core/src/platform/mod.rs crates/upm-core/src/source/github.rs crates/upm-core/src/source/appimagehub.rs crates/upm-core/src/integration/refresh.rs crates/upm/tests/config_loading.rs
git commit -m "refactor: remove remaining aim runtime interfaces"
```
### Task 3: Add a provider-composition seam in `upm-core`
**Files:**
- Create: `crates/upm-core/src/app/providers.rs`
- Modify: `crates/upm-core/src/app/mod.rs`
- Modify: `crates/upm-core/src/app/add.rs`
- Modify: `crates/upm-core/src/app/search.rs`
- Modify: `crates/upm-core/src/lib.rs`
- Create: `crates/upm-core/tests/provider_registry.rs`
**Step 1: Write the failing provider-composition tests**
Create `crates/upm-core/tests/provider_registry.rs` with two focused tests:
- `build_search_results_with_registered_providers_uses_external_hits` using a stub external search provider
- `build_add_plan_with_registered_providers_delegates_appimagehub_like_sources` using a stub external add provider that returns a fixed artifact and release
The tests should prove that `upm-core` orchestration can consume provider-supplied search and add behavior without hardcoding AppImage-specific modules.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm-core --test provider_registry
```
Expected: FAIL because the orchestration layer still hardcodes AppImageHub in `app/add.rs` and `app/search.rs`.
**Step 3: Introduce the narrow composition API**
Create `crates/upm-core/src/app/providers.rs` with minimal types:
- `pub trait ExternalAddProvider`
- `pub struct ExternalAddResolution`
- `pub struct ProviderRegistry<'a>`
Requirements:
- `ProviderRegistry` carries `search_providers: Vec<&'a dyn SearchProvider>` and `external_add_providers: Vec<&'a dyn ExternalAddProvider>`
- `build_search_results` can delegate to `build_search_results_with` using providers supplied by the caller
- `build_add_plan_with_reporter_and_policy` gets a sibling entrypoint that accepts a `ProviderRegistry`
- core built-ins remain in `upm-core`; only AppImage-specific exact-resolution and search logic should move behind the new registry seam
Keep the interface intentionally small. Do not attempt plugin loading or dynamic discovery yet.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm-core --test provider_registry
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-core/src/app/providers.rs crates/upm-core/src/app/mod.rs crates/upm-core/src/app/add.rs crates/upm-core/src/app/search.rs crates/upm-core/src/lib.rs crates/upm-core/tests/provider_registry.rs
git commit -m "refactor: add provider composition seam to upm-core"
```
### Task 4: Extract AppImage-specific logic into `upm-appimage`
**Files:**
- Modify: `Cargo.toml`
- Create: `crates/upm-appimage/Cargo.toml`
- Create: `crates/upm-appimage/src/lib.rs`
- Create: `crates/upm-appimage/src/add.rs`
- Create: `crates/upm-appimage/src/search.rs`
- Create: `crates/upm-appimage/src/source/mod.rs`
- Create: `crates/upm-appimage/src/source/appimagehub.rs`
- Modify: `crates/upm-core/src/adapters/mod.rs`
- Modify: `crates/upm-core/src/source/mod.rs`
- Modify: `crates/upm-core/src/app/add.rs`
- Modify: `crates/upm-core/src/app/search.rs`
- Create: `crates/upm-appimage/tests/appimagehub_search.rs`
- Modify: `crates/upm-core/tests/adapter_contract.rs`
- Modify: `crates/upm-core/tests/adapter_smoke.rs`
**Step 1: Write the failing extracted-module test**
Create `crates/upm-appimage/tests/appimagehub_search.rs` by moving the current AppImageHub search expectations out of `upm-core` and updating imports to target the new crate.
Also update the affected `upm-core` tests so they no longer import `AppImageHubAdapter` from `upm-core` directly.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm-appimage --test appimagehub_search
```
Expected: FAIL because the new crate does not exist yet.
**Step 3: Create the new crate and move AppImageHub implementation into it**
Move the AppImageHub-specific code into the new crate:
- transport and fixture logic from `upm-core/src/source/appimagehub.rs`
- AppImage-backed exact-resolution logic into `crates/upm-appimage/src/add.rs` implementing `ExternalAddProvider`
- AppImageHub search provider logic out of `upm-core/src/app/search.rs` into `crates/upm-appimage/src/search.rs`
`upm-appimage` should depend on `upm-core`, not the other way around.
Leave `SourceKind::AppImageHub`, `SourceInputKind::AppImageHub*`, and `NormalizedSourceKind::AppImageHub` in `upm-core` for this milestone. The deeper provider/domain generalization belongs to the next milestone.
**Step 4: Remove direct AppImageHub wiring from `upm-core`**
Update `upm-core` so it no longer declares:
- `pub mod appimagehub;` in `src/adapters/mod.rs`
- `pub mod appimagehub;` in `src/source/mod.rs`
- built-in AppImageHub search-provider construction in `src/app/search.rs`
- direct `AppImageHubAdapter` imports in `src/app/add.rs`
After this step, AppImage behavior should exist only through the provider registry seam from Task 3.
**Step 5: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm-appimage --test appimagehub_search
cargo test --package upm-core --test adapter_contract
cargo test --package upm-core --test adapter_smoke
```
Expected: PASS.
**Step 6: Commit**
```bash
git add Cargo.toml crates/upm-appimage crates/upm-core/src/adapters/mod.rs crates/upm-core/src/source/mod.rs crates/upm-core/src/app/add.rs crates/upm-core/src/app/search.rs crates/upm-core/tests/adapter_contract.rs crates/upm-core/tests/adapter_smoke.rs
git commit -m "refactor: extract appimage support into upm-appimage"
```
### Task 5: Rewire the `upm` CLI to assemble built-in providers from modules
**Files:**
- Modify: `crates/upm/Cargo.toml`
- Create: `crates/upm/src/providers.rs`
- Modify: `crates/upm/src/lib.rs`
- Test: `crates/upm/tests/search_cli.rs`
- Test: `crates/upm/tests/end_to_end_cli.rs`
- Test: `crates/upm/tests/ui_summary.rs`
**Step 1: Write the failing CLI integration expectations**
Update CLI integration tests to prove that:
- `upm search firefox` still includes AppImageHub results
- direct `upm appimagehub/2338455` install flow still succeeds through the CLI
- the final summary output still renders the new `upm`-prefixed paths and desktop-entry names
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm --test search_cli
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: FAIL because `upm` does not yet assemble AppImage providers through the extracted module.
**Step 3: Add CLI-side provider assembly**
Create `crates/upm/src/providers.rs` that:
- builds the `ProviderRegistry` for `upm-core`
- registers the `upm-appimage` search provider
- registers the `upm-appimage` external add provider
Update `crates/upm/src/lib.rs` so dispatch paths call the provider-aware core entrypoints instead of hardcoded core defaults.
Do not move progress rendering or config loading into `upm-core`; the CLI remains the presentation layer.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm --test search_cli
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm/Cargo.toml crates/upm/src/providers.rs crates/upm/src/lib.rs crates/upm/tests/search_cli.rs crates/upm/tests/end_to_end_cli.rs crates/upm/tests/ui_summary.rs
git commit -m "refactor: compose providers from upm modules"
```
### Task 6: Update docs and run full workspace verification
**Files:**
- Modify: `README.md`
- Modify: `.architecture/overview.md`
- Modify: `.architecture/roadmap.md`
**Step 1: Update product and architecture docs**
Document:
- the workspace rename to `upm`
- `upm-core` as the headless application layer
- `upm-appimage` as the first installable provider module
- compatibility behavior for existing `aim` config and registry locations
- the fact that provider composition now happens in the CLI rather than through hardcoded AppImage paths in `upm-core`
**Step 2: Verify the docs mention the new structure**
Run:
```bash
rg -n "upm-core|upm-appimage|legacy aim|ProviderRegistry|upm" README.md .architecture/overview.md .architecture/roadmap.md
```
Expected: matches showing the renamed crates, provider split, and compatibility note.
**Step 3: Run the full verification suite**
Run:
```bash
cargo fmt --all
cargo test --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
```
Expected: PASS.
**Step 4: Commit**
```bash
git add README.md .architecture/overview.md .architecture/roadmap.md
git commit -m "docs: describe upm core and module split"
```

View file

@ -11,3 +11,7 @@ IMPORTANT TO CHECK BEFORE ANY COMMIT!!
Architecture under `.architecture` must be maintained with each change. Architecture under `.architecture` must be maintained with each change.
- Security issues stumbled upon or noticed during execution **already in code** must live in `security-issues.md`. Newly added issues during execution or planning should be raised to the user and/or dealt with, instead of growing the list. - Security issues stumbled upon or noticed during execution **already in code** must live in `security-issues.md`. Newly added issues during execution or planning should be raised to the user and/or dealt with, instead of growing the list.
- An overview of the workspace, should live in `overview.md`. - An overview of the workspace, should live in `overview.md`.
- A roadmap of the planned direction & features in `roadmap.md`.
## UPM work
We are currently using the `upm` branch as our "main" for all `upm` work, meaning feature branches/worktrees will merge back into here.

81
Cargo.lock generated
View file

@ -11,41 +11,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "aim-cli"
version = "0.1.0"
dependencies = [
"aim-core",
"assert_cmd",
"clap",
"console 0.16.3",
"crossterm",
"dialoguer",
"indicatif",
"libc",
"predicates",
"ratatui",
"serde",
"tempfile",
"toml",
]
[[package]]
name = "aim-core"
version = "0.1.0"
dependencies = [
"base64",
"fs2",
"md5",
"quick-xml",
"reqwest",
"serde",
"serde_yaml",
"sha2",
"tempfile",
"toml",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@ -1966,6 +1931,52 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "upm"
version = "0.1.0"
dependencies = [
"assert_cmd",
"clap",
"console 0.16.3",
"crossterm",
"dialoguer",
"indicatif",
"libc",
"predicates",
"ratatui",
"serde",
"tempfile",
"toml",
"upm-appimage",
"upm-core",
]
[[package]]
name = "upm-appimage"
version = "0.1.0"
dependencies = [
"quick-xml",
"reqwest",
"serde",
"upm-core",
]
[[package]]
name = "upm-core"
version = "0.1.0"
dependencies = [
"base64",
"fs2",
"md5",
"quick-xml",
"reqwest",
"serde",
"serde_yaml",
"sha2",
"tempfile",
"toml",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"

View file

@ -1,10 +1,11 @@
[workspace] [workspace]
members = [ members = [
"crates/aim-core", "crates/upm-core",
"crates/aim-cli", "crates/upm-appimage",
"crates/upm",
] ]
default-members = [ default-members = [
"crates/aim-cli", "crates/upm",
] ]
resolver = "2" resolver = "2"

View file

@ -1,24 +1,25 @@
# aim # upm
AppImage Manager Universal Package Manager
`aim` is a Rust Cargo workspace for managing AppImages from multiple source types. `upm` is a Rust Cargo workspace for a modular package manager with a shared headless core and provider crates.
## Workspace ## Workspace
- `crates/aim-core`: business logic, source adapters, registry, install/update planning - `crates/upm-core`: headless application layer for query normalization, resolution, planning, registry persistence, install/update orchestration, and provider-facing APIs
- `crates/aim-cli`: thin terminal frontend for parsing, prompting, and rendering - `crates/upm`: thin terminal frontend for argument parsing, config loading, prompting, progress reporting, and summary rendering
- `crates/upm-appimage`: AppImageHub transport, search, and add-provider integration composed into the CLI through `ProviderRegistry`
The split is intentional so a future GUI client can reuse `aim-core` without moving logic out of the shared library. The split is intentional so future frontends can reuse `upm-core`, while package-source behavior stays modular instead of being hardcoded into the core.
## Commands ## Commands
```text ```text
aim <QUERY> upm <QUERY>
aim upm
aim update upm update
aim list upm list
aim search <QUERY> upm search <QUERY>
aim remove <QUERY> upm remove <QUERY>
``` ```
## Query Forms ## Query Forms
@ -36,22 +37,22 @@ aim remove <QUERY>
## Search ## Search
`aim search <QUERY>` is part of v0.9 finalisation. `upm search <QUERY>` is part of the initial modular provider surface.
- search is provider-extensible and currently includes GitHub plus AppImageHub - search is provider-extensible and currently includes GitHub plus AppImageHub
- search results should resolve to install-ready queries such as `owner/repo` and `appimagehub/<id>` - search results should resolve to install-ready queries such as `owner/repo` and `appimagehub/<id>`
- the search model is provider-extensible for future phases - provider composition happens in `crates/upm/src/providers.rs`, not through AppImageHub-specific wiring inside `upm-core`
## Scope Overrides ## Scope Overrides
By default `aim` auto-detects whether to use user or system scope. Override that with: By default `upm` auto-detects whether to use user or system scope. Override that with:
- `--user` - `--user`
- `--system` - `--system`
## Config ## Config
Runtime config is loaded from `~/.config/aim/config.toml` or `$XDG_CONFIG_HOME/aim/config.toml`. Runtime config is loaded from `~/.config/upm/config.toml` or `$XDG_CONFIG_HOME/upm/config.toml`.
Example: Example:
@ -63,13 +64,20 @@ allow_http = true
- `allow_http` only permits user-supplied `http://` inputs such as direct URL installs or updates from previously installed direct HTTP origins - `allow_http` only permits user-supplied `http://` inputs such as direct URL installs or updates from previously installed direct HTTP origins
- provider-resolved downloads such as AppImageHub artifacts remain HTTPS-only even when `allow_http = true` - provider-resolved downloads such as AppImageHub artifacts remain HTTPS-only even when `allow_http = true`
## Breaking Rename
- `upm` is a hard rename from `aim`
- runtime overrides now use `UPM_*` names such as `UPM_CONFIG_PATH` and `UPM_REGISTRY_PATH`
- old `AIM_*` runtime overrides are intentionally ignored
- default config and registry locations now live under `upm` paths
## Current Flow Shape ## Current Flow Shape
- `aim <QUERY>` installs direct provider matches when available, otherwise falls back to search results, shows live progress on stderr, prints an `Installation Summary` on stdout for installs, and renders an `Installation Review` when tracking needs confirmation - `upm <QUERY>` installs direct provider matches when available, otherwise falls back to search results, shows live progress on stderr, prints an `Installation Summary` on stdout for installs, and renders an `Installation Review` when tracking needs confirmation
- bare `aim` prints an `Update Review` without mutating the registry - bare `upm` prints an `Update Review` without mutating the registry
- `aim update` executes the pending updates, streams live status on stderr, then prints an `Update Summary` - `upm update` executes the pending updates, streams live status on stderr, then prints an `Update Summary`
- `aim list` renders either `Installed Apps` or `No installed apps yet` - `upm list` renders either `Installed Apps` or `No installed apps yet`
- `aim remove <QUERY>` resolves a registered application name, streams removal progress on stderr, then prints a `Removal Summary` - `upm remove <QUERY>` resolves a registered application name, streams removal progress on stderr, then prints a `Removal Summary`
## Terminal UX ## Terminal UX

View file

@ -1,66 +0,0 @@
use aim_cli::config::{CliConfig, ConfigError, SearchConfig, load_from_path};
use tempfile::tempdir;
#[test]
fn missing_config_file_returns_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = load_from_path(&path).unwrap();
assert_eq!(config, CliConfig::default());
assert_eq!(config.search, SearchConfig::default());
assert!(!config.allow_http);
assert!(config.search.bottom_to_top);
assert!(!config.search.skip_confirmation);
assert_eq!(config.theme.accent, "#b388ff");
assert_eq!(config.theme.accent_secondary, "#d5c2ff");
assert_eq!(config.theme.dim, "#7f7396");
}
#[test]
fn search_section_overrides_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"allow_http = true\n\n[search]\nbottom_to_top = false\nskip_confirmation = true\n\n[theme]\naccent = \"#9f6bff\"\naccent_secondary = \"#efe7ff\"\ndim = \"#6b6480\"\n",
)
.unwrap();
let config = load_from_path(&path).unwrap();
assert_eq!(
config,
CliConfig {
allow_http: true,
search: SearchConfig {
bottom_to_top: false,
skip_confirmation: true,
},
theme: aim_cli::config::ThemeConfig {
accent: "#9f6bff".to_owned(),
accent_secondary: "#efe7ff".to_owned(),
dim: "#6b6480".to_owned(),
},
}
);
}
#[test]
fn malformed_toml_returns_path_aware_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[search\nskip_confirmation = true\n").unwrap();
let error = load_from_path(&path).unwrap_err();
match error {
ConfigError::Parse {
path: error_path, ..
} => {
assert_eq!(error_path, path);
}
other => panic!("expected parse error, got {other:?}"),
}
}

View file

@ -1,21 +0,0 @@
use std::path::Path;
use aim_core::domain::app::InstallScope;
use aim_core::integration::paths::{desktop_entry_path, managed_appimage_path};
#[test]
fn user_scope_path_lands_under_home_managed_dir() {
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::User, "bat");
assert_eq!(
path,
Path::new("/home/test/.local/lib/aim/appimages/bat.AppImage")
);
}
#[test]
fn system_scope_desktop_entry_uses_system_prefix() {
let path = desktop_entry_path(Path::new("/home/test"), InstallScope::System, "bat");
assert_eq!(path, Path::new("/usr/share/applications/aim-bat.desktop"));
}

View file

@ -0,0 +1,14 @@
[package]
name = "upm-appimage"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
path = "src/lib.rs"
[dependencies]
quick-xml.workspace = true
reqwest.workspace = true
serde.workspace = true
upm-core = { path = "../upm-core" }

View file

@ -0,0 +1,163 @@
use crate::source::appimagehub::{
AppImageHubError, AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with,
};
use upm_core::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
};
use upm_core::app::providers::{ExternalAddProvider, ExternalAddResolution};
use upm_core::app::query::resolve_query;
use upm_core::domain::source::{ResolvedRelease, SourceKind, SourceRef};
use upm_core::domain::update::{
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
};
pub struct AppImageHubAdapter;
impl AppImageHubAdapter {
pub fn resolve_source_with<T: AppImageHubTransport + ?Sized>(
&self,
source: &SourceRef,
transport: &T,
) -> Result<AdapterResolveOutcome, AdapterError> {
if source.kind != SourceKind::AppImageHub {
return Err(AdapterError::UnsupportedSource);
}
let resolved = resolve_appimagehub_item_with(source, transport)
.map_err(|error| AdapterError::ResolutionFailed(render_appimagehub_error(&error)))?;
match resolved {
Some(item) => Ok(AdapterResolveOutcome::Resolved(AdapterResolution {
source: item.source,
release: ResolvedRelease {
version: item.version,
prerelease: false,
},
})),
None => Ok(AdapterResolveOutcome::NoInstallableArtifact {
source: source.clone(),
}),
}
}
}
impl SourceAdapter for AppImageHubAdapter {
fn id(&self) -> &'static str {
"appimagehub"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
}
fn repository_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::AppImageHub)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::AppImageHub {
return Err(AdapterError::UnsupportedQuery);
}
Ok(source)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
match resolve_appimagehub_item(source)
.map_err(|error| AdapterError::ResolutionFailed(render_appimagehub_error(&error)))?
{
Some(item) => Ok(AdapterResolution {
source: item.source,
release: ResolvedRelease {
version: item.version,
prerelease: false,
},
}),
None => Err(AdapterError::ResolutionFailed(
"appimagehub item has no installable AppImage artifact".to_owned(),
)),
}
}
fn resolve_supported_source(
&self,
source: &SourceRef,
) -> Result<AdapterResolveOutcome, AdapterError> {
let transport = crate::source::appimagehub::default_transport();
self.resolve_source_with(source, transport.as_ref())
}
}
pub struct AppImageHubAddProvider<'a, T: AppImageHubTransport + ?Sized> {
transport: &'a T,
}
impl<'a, T: AppImageHubTransport + ?Sized> AppImageHubAddProvider<'a, T> {
pub fn new(transport: &'a T) -> Self {
Self { transport }
}
}
impl<T: AppImageHubTransport + ?Sized> ExternalAddProvider for AppImageHubAddProvider<'_, T> {
fn id(&self) -> &'static str {
"appimagehub"
}
fn resolve(&self, source: &SourceRef) -> Result<Option<ExternalAddResolution>, AdapterError> {
if source.kind != SourceKind::AppImageHub {
return Ok(None);
}
let adapter = AppImageHubAdapter;
let resolution = match adapter.resolve_source_with(source, self.transport)? {
AdapterResolveOutcome::Resolved(resolution) => resolution,
AdapterResolveOutcome::NoInstallableArtifact { .. } => return Ok(None),
};
let Some(resolved_item) = resolve_appimagehub_item_with(source, self.transport)
.map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))?
else {
return Ok(None);
};
Ok(Some(ExternalAddResolution {
resolution,
selected_artifact: ArtifactCandidate {
url: resolved_item.download.url.clone(),
version: resolved_item.version.clone(),
arch: resolved_item.download.arch.clone(),
trusted_checksum: None,
weak_checksum_md5: resolved_item.download.md5sum.clone(),
selection_reason: "provider-release".to_owned(),
},
update_strategy: UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::DirectAsset,
locator: resolved_item.download.url.clone(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
},
display_name_hint: Some(resolved_item.title),
}))
}
}
fn render_appimagehub_error(error: &AppImageHubError) -> String {
match error {
AppImageHubError::FixtureItemMissing(id) => {
format!("missing appimagehub fixture item {id}")
}
AppImageHubError::InsecureDownloadUrl(url) => {
format!("insecure appimagehub download url: {url}")
}
AppImageHubError::Parse(error) => error.to_string(),
AppImageHubError::Transport(error) => error.to_string(),
AppImageHubError::UnsupportedSource(locator) => {
format!("unsupported appimagehub source: {locator}")
}
}
}

View file

@ -0,0 +1,6 @@
pub mod add;
pub mod search;
pub mod source;
pub use add::{AppImageHubAdapter, AppImageHubAddProvider};
pub use search::AppImageHubSearchProvider;

View file

@ -0,0 +1,103 @@
use crate::source::appimagehub::{
AppImageHubSearchError, AppImageHubTransport, search_appimagehub_with,
};
use upm_core::app::search::{SearchProvider, SearchProviderError};
use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
pub struct AppImageHubSearchProvider<'a, T: AppImageHubTransport + ?Sized> {
transport: &'a T,
}
impl<'a, T: AppImageHubTransport + ?Sized> AppImageHubSearchProvider<'a, T> {
pub fn new(transport: &'a T) -> Self {
Self { transport }
}
}
impl<T: AppImageHubTransport + ?Sized> SearchProvider for AppImageHubSearchProvider<'_, T> {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let hits = search_appimagehub_with(&query.text, query.remote_limit, self.transport)
.map_err(|error| {
SearchProviderError::new("appimagehub", &render_appimagehub_search_error(&error))
})?;
let normalized_query = normalize_lookup(&query.text);
let mut ranked_hits = hits
.into_iter()
.enumerate()
.map(|(index, hit)| {
(
appimagehub_remote_match_rank(
&normalized_query,
&hit.name,
hit.summary.as_deref(),
),
index,
hit,
)
})
.collect::<Vec<_>>();
ranked_hits.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
Ok(ranked_hits
.into_iter()
.map(|(_, _, hit)| SearchResult {
provider_id: "appimagehub".to_owned(),
display_name: hit.name,
description: hit.summary,
source_locator: hit.detail_page,
install_query: format!("appimagehub/{}", hit.id),
canonical_locator: hit.id,
version: Some(hit.version),
install_status: SearchInstallStatus::Available,
})
.collect())
}
}
fn normalize_lookup(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn appimagehub_remote_match_rank(query: &str, name: &str, summary: Option<&str>) -> u8 {
let name = normalize_lookup(name);
let summary = summary.map(normalize_lookup);
if name == query {
return 0;
}
if name.starts_with(query) {
return 1;
}
if name.contains(query) {
return 2;
}
if summary
.as_deref()
.map(|summary| summary.starts_with(query))
.unwrap_or(false)
{
return 3;
}
if summary
.as_deref()
.map(|summary| summary.contains(query))
.unwrap_or(false)
{
return 4;
}
5
}
fn render_appimagehub_search_error(error: &AppImageHubSearchError) -> String {
match error {
AppImageHubSearchError::Parse(inner) => inner.to_string(),
AppImageHubSearchError::Transport(inner) => inner.to_string(),
}
}

View file

@ -0,0 +1,517 @@
use std::env;
use std::time::Duration;
use upm_core::domain::source::SourceRef;
const DEFAULT_APPIMAGEHUB_API_BASE: &str = "https://api.appimagehub.com/ocs/v1/content";
const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_APPIMAGEHUB_FIXTURE_MODE";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubDownload {
pub url: String,
pub name: String,
pub package_type: Option<String>,
pub arch: Option<String>,
pub md5sum: Option<String>,
pub version: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubItem {
pub id: String,
pub name: String,
pub version: String,
pub summary: Option<String>,
pub detail_page: String,
pub tags: Vec<String>,
pub downloads: Vec<AppImageHubDownload>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubSearchHit {
pub id: String,
pub name: String,
pub version: String,
pub summary: Option<String>,
pub detail_page: String,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolvedAppImageHubItem {
pub source: SourceRef,
pub title: String,
pub version: String,
pub download: AppImageHubDownload,
}
pub trait AppImageHubTransport {
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError>;
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError>;
}
pub fn default_transport() -> Box<dyn AppImageHubTransport> {
if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
{
Box::new(FixtureAppImageHubTransport)
} else {
Box::new(ReqwestAppImageHubTransport::new())
}
}
pub fn resolve_appimagehub_item(
source: &SourceRef,
) -> Result<Option<ResolvedAppImageHubItem>, AppImageHubError> {
let transport = default_transport();
resolve_appimagehub_item_with(source, transport.as_ref())
}
pub fn resolve_appimagehub_item_with<T: AppImageHubTransport + ?Sized>(
source: &SourceRef,
transport: &T,
) -> Result<Option<ResolvedAppImageHubItem>, AppImageHubError> {
let item = transport.fetch_item(source_id(source)?)?;
let Some(download) = item
.downloads
.iter()
.find(|download| is_appimage_download(download))
else {
return Ok(None);
};
validate_download_url(&download.url)?;
Ok(Some(ResolvedAppImageHubItem {
source: source.clone(),
title: item.name.clone(),
version: resolved_version(&item, download),
download: download.clone(),
}))
}
pub fn search_appimagehub(
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
let transport = default_transport();
search_appimagehub_with(query, limit, transport.as_ref())
}
pub fn search_appimagehub_with<T: AppImageHubTransport + ?Sized>(
query: &str,
limit: usize,
transport: &T,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
transport.search_items(query, limit)
}
pub struct ReqwestAppImageHubTransport {
client: reqwest::blocking::Client,
api_base: String,
}
impl Default for ReqwestAppImageHubTransport {
fn default() -> Self {
Self::new()
}
}
impl ReqwestAppImageHubTransport {
pub fn new() -> Self {
Self {
client: reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest client should build"),
api_base: env::var("UPM_APPIMAGEHUB_API_BASE")
.unwrap_or_else(|_| DEFAULT_APPIMAGEHUB_API_BASE.to_owned()),
}
}
}
impl AppImageHubTransport for ReqwestAppImageHubTransport {
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError> {
let url = format!("{}/data/{id}", self.api_base);
let xml = self
.client
.get(url)
.send()
.map_err(AppImageHubError::Transport)?
.error_for_status()
.map_err(AppImageHubError::Transport)?
.text()
.map_err(AppImageHubError::Transport)?;
parse_item_xml(&xml)
}
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
let url = format!("{}/data", self.api_base);
let xml = self
.client
.get(url)
.query(&[("search", query), ("pagesize", &limit.to_string())])
.send()
.map_err(AppImageHubSearchError::Transport)?
.error_for_status()
.map_err(AppImageHubSearchError::Transport)?
.text()
.map_err(AppImageHubSearchError::Transport)?;
parse_search_xml(&xml)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FixtureAppImageHubTransport;
impl AppImageHubTransport for FixtureAppImageHubTransport {
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError> {
fixture_item(id).ok_or_else(|| AppImageHubError::FixtureItemMissing(id.to_owned()))
}
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
Ok(fixture_search_results(query, limit))
}
}
#[derive(Debug)]
pub enum AppImageHubError {
FixtureItemMissing(String),
InsecureDownloadUrl(String),
Parse(quick_xml::DeError),
Transport(reqwest::Error),
UnsupportedSource(String),
}
#[derive(Debug)]
pub enum AppImageHubSearchError {
Parse(quick_xml::DeError),
Transport(reqwest::Error),
}
#[derive(serde::Deserialize)]
struct OcsSingleResponse {
data: OcsSingleData,
}
#[derive(serde::Deserialize)]
struct OcsSingleData {
content: OcsContent,
}
#[derive(serde::Deserialize)]
struct OcsSearchResponse {
data: OcsSearchData,
}
#[derive(serde::Deserialize)]
struct OcsSearchData {
#[serde(default)]
content: Vec<OcsContent>,
}
#[derive(serde::Deserialize)]
struct OcsContent {
id: String,
name: String,
version: Option<String>,
summary: Option<String>,
detailpage: Option<String>,
tags: Option<String>,
downloadlink1: Option<String>,
downloadname1: Option<String>,
download_package_type1: Option<String>,
download_package_arch1: Option<String>,
downloadmd5sum1: Option<String>,
download_version1: Option<String>,
downloadlink2: Option<String>,
downloadname2: Option<String>,
download_package_type2: Option<String>,
download_package_arch2: Option<String>,
downloadmd5sum2: Option<String>,
download_version2: Option<String>,
downloadlink3: Option<String>,
downloadname3: Option<String>,
download_package_type3: Option<String>,
download_package_arch3: Option<String>,
downloadmd5sum3: Option<String>,
download_version3: Option<String>,
}
fn parse_item_xml(xml: &str) -> Result<AppImageHubItem, AppImageHubError> {
let parsed =
quick_xml::de::from_str::<OcsSingleResponse>(xml).map_err(AppImageHubError::Parse)?;
Ok(content_to_item(parsed.data.content))
}
fn parse_search_xml(xml: &str) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
if !xml.contains("<id>") {
return Ok(Vec::new());
}
let parsed =
quick_xml::de::from_str::<OcsSearchResponse>(xml).map_err(AppImageHubSearchError::Parse)?;
Ok(parsed
.data
.content
.into_iter()
.map(|content| AppImageHubSearchHit {
id: content.id,
name: content.name,
version: normalize_version_text(content.version.as_deref()),
summary: content.summary,
detail_page: content
.detailpage
.unwrap_or_else(|| "https://www.appimagehub.com".to_owned()),
tags: split_tags(content.tags.as_deref()),
})
.collect())
}
fn content_to_item(content: OcsContent) -> AppImageHubItem {
let detail_page = content
.detailpage
.clone()
.unwrap_or_else(|| "https://www.appimagehub.com".to_owned());
let summary = content.summary.clone();
let tags = split_tags(content.tags.as_deref());
let downloads = collect_downloads(&content);
AppImageHubItem {
id: content.id,
name: content.name,
version: normalize_version_text(content.version.as_deref()),
summary,
detail_page,
tags,
downloads,
}
}
fn validate_download_url(url: &str) -> Result<(), AppImageHubError> {
if !url.starts_with("https://") {
return Err(AppImageHubError::InsecureDownloadUrl(url.to_owned()));
}
Ok(())
}
fn collect_downloads(content: &OcsContent) -> Vec<AppImageHubDownload> {
let mut downloads = Vec::new();
for download in [
download_slot(
content.downloadlink1.as_deref(),
content.downloadname1.as_deref(),
content.download_package_type1.as_deref(),
content.download_package_arch1.as_deref(),
content.downloadmd5sum1.as_deref(),
content.download_version1.as_deref(),
),
download_slot(
content.downloadlink2.as_deref(),
content.downloadname2.as_deref(),
content.download_package_type2.as_deref(),
content.download_package_arch2.as_deref(),
content.downloadmd5sum2.as_deref(),
content.download_version2.as_deref(),
),
download_slot(
content.downloadlink3.as_deref(),
content.downloadname3.as_deref(),
content.download_package_type3.as_deref(),
content.download_package_arch3.as_deref(),
content.downloadmd5sum3.as_deref(),
content.download_version3.as_deref(),
),
]
.into_iter()
.flatten()
{
downloads.push(download);
}
downloads
}
fn download_slot(
link: Option<&str>,
name: Option<&str>,
package_type: Option<&str>,
arch: Option<&str>,
md5sum: Option<&str>,
version: Option<&str>,
) -> Option<AppImageHubDownload> {
let url = link?.trim();
if url.is_empty() {
return None;
}
Some(AppImageHubDownload {
url: url.to_owned(),
name: name.unwrap_or("download").trim().to_owned(),
package_type: trim_optional(package_type),
arch: trim_optional(arch),
md5sum: trim_optional(md5sum),
version: trim_optional(version),
})
}
fn trim_optional(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_version_text(value: Option<&str>) -> String {
let value = value.map(str::trim).filter(|value| !value.is_empty());
match value {
Some("Latest") | Some("latest") | None => "latest".to_owned(),
Some(other) => other.to_owned(),
}
}
fn split_tags(tags: Option<&str>) -> Vec<String> {
tags.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|tag| !tag.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn source_id(source: &SourceRef) -> Result<&str, AppImageHubError> {
source
.canonical_locator
.as_deref()
.or_else(|| source.locator.rsplit('/').next())
.filter(|value| !value.is_empty())
.ok_or_else(|| AppImageHubError::UnsupportedSource(source.locator.clone()))
}
fn is_appimage_download(download: &AppImageHubDownload) -> bool {
download
.package_type
.as_deref()
.map(|kind| kind.eq_ignore_ascii_case("appimage"))
.unwrap_or(false)
|| download.name.ends_with(".AppImage")
}
fn resolved_version(item: &AppImageHubItem, download: &AppImageHubDownload) -> String {
download
.version
.as_deref()
.map(|value| normalize_version_text(Some(value)))
.filter(|value| value != "latest")
.unwrap_or_else(|| item.version.clone())
}
fn fixture_item(id: &str) -> Option<AppImageHubItem> {
let insecure_http = env::var("UPM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP")
.ok()
.as_deref()
== Some("1");
let bad_md5 = env::var("UPM_APPIMAGEHUB_FIXTURE_BAD_MD5").ok().as_deref() == Some("1");
match id {
"2338455" => Some(AppImageHubItem {
id: "2338455".to_owned(),
name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
version: "latest".to_owned(),
summary: Some("Take control of your internet with the Firefox browser".to_owned()),
detail_page: "https://www.appimagehub.com/p/2338455".to_owned(),
tags: vec![
"appimage".to_owned(),
"x86-64".to_owned(),
"desktop".to_owned(),
"release-stable".to_owned(),
],
downloads: vec![AppImageHubDownload {
url: if insecure_http {
"http://files06.pling.com/api/files/download/firefox-x86-64.AppImage".to_owned()
} else {
"https://files06.pling.com/api/files/download/firefox-x86-64.AppImage"
.to_owned()
},
name: "firefox-x86-64.AppImage".to_owned(),
package_type: Some("appimage".to_owned()),
arch: Some("x86-64".to_owned()),
md5sum: Some(if bad_md5 {
"00000000000000000000000000000000".to_owned()
} else {
"2a685cf45213d5a2a243273fa68dafa6".to_owned()
}),
version: None,
}],
}),
"2337998" => Some(AppImageHubItem {
id: "2337998".to_owned(),
name: "Example Non-AppImage Package".to_owned(),
version: "latest".to_owned(),
summary: Some("An item that does not expose an AppImage download".to_owned()),
detail_page: "https://www.appimagehub.com/p/2337998".to_owned(),
tags: vec!["desktop".to_owned()],
downloads: vec![AppImageHubDownload {
url: "https://files06.pling.com/api/files/download/example.deb".to_owned(),
name: "example.deb".to_owned(),
package_type: Some("debian-package".to_owned()),
arch: Some("x86-64".to_owned()),
md5sum: None,
version: Some("2.1.1".to_owned()),
}],
}),
_ => None,
}
}
fn fixture_search_results(query: &str, limit: usize) -> Vec<AppImageHubSearchHit> {
let query = query.trim().to_ascii_lowercase();
let fixtures = [
AppImageHubSearchHit {
id: "2338455".to_owned(),
name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
version: "latest".to_owned(),
summary: Some("Take control of your internet with the Firefox browser".to_owned()),
detail_page: "https://www.appimagehub.com/p/2338455".to_owned(),
tags: vec!["browser".to_owned(), "appimage".to_owned()],
},
AppImageHubSearchHit {
id: "2338484".to_owned(),
name: "Waterfox".to_owned(),
version: "latest".to_owned(),
summary: Some("Open Source, Private Browsing".to_owned()),
detail_page: "https://www.appimagehub.com/p/2338484".to_owned(),
tags: vec!["browser".to_owned(), "appimage".to_owned()],
},
];
fixtures
.into_iter()
.filter(|item| {
item.name.to_ascii_lowercase().contains(&query)
|| item
.tags
.iter()
.any(|tag| tag.to_ascii_lowercase().contains(&query))
})
.take(limit)
.collect()
}

View file

@ -0,0 +1 @@
pub mod appimagehub;

View file

@ -1,12 +1,16 @@
use aim_core::app::search::{ use upm_appimage::add::{AppImageHubAdapter, AppImageHubAddProvider};
AppImageHubSearchProvider, GitHubSearchProvider, SearchProvider, SearchProviderError, use upm_appimage::search::AppImageHubSearchProvider;
build_search_results_with, use upm_appimage::source::appimagehub::FixtureAppImageHubTransport;
use upm_core::adapters::traits::AdapterResolveOutcome;
use upm_core::app::providers::ExternalAddProvider;
use upm_core::app::query::resolve_query;
use upm_core::app::search::{
GitHubSearchProvider, SearchProvider, SearchProviderError, build_search_results_with,
}; };
use aim_core::domain::app::AppRecord; use upm_core::domain::app::AppRecord;
use aim_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult}; use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef}; use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::source::appimagehub::FixtureAppImageHubTransport; use upm_core::source::github::FixtureGitHubTransport;
use aim_core::source::github::FixtureGitHubTransport;
struct StubProvider { struct StubProvider {
hit: SearchResult, hit: SearchResult,
@ -106,3 +110,40 @@ fn search_can_merge_github_and_appimagehub_providers() {
.any(|hit| hit.provider_id == "appimagehub") .any(|hit| hit.provider_id == "appimagehub")
); );
} }
#[test]
fn appimagehub_adapter_resolves_installable_items_through_fixture_transport() {
let adapter = AppImageHubAdapter;
let source = resolve_query("appimagehub/2338455").unwrap();
let resolution = adapter
.resolve_source_with(&source, &FixtureAppImageHubTransport)
.unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(resolution)
if resolution.source.kind == SourceKind::AppImageHub
&& resolution.source.canonical_locator.as_deref() == Some("2338455")
&& resolution.release.version == "latest"
));
}
#[test]
fn appimagehub_add_provider_resolves_external_add_plan() {
let provider = AppImageHubAddProvider::new(&FixtureAppImageHubTransport);
let source = resolve_query("appimagehub/2338455").unwrap();
let resolution = provider.resolve(&source).unwrap().unwrap();
assert_eq!(resolution.resolution.source.kind, SourceKind::AppImageHub);
assert_eq!(resolution.resolution.release.version, "latest");
assert_eq!(
resolution.selected_artifact.url,
"https://files06.pling.com/api/files/download/firefox-x86-64.AppImage"
);
assert_eq!(
resolution.display_name_hint.as_deref(),
Some("Firefox by Mozilla - Official AppImage Edition")
);
}

View file

@ -1,5 +1,5 @@
[package] [package]
name = "aim-core" name = "upm-core"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true

View file

@ -1,4 +1,3 @@
pub mod appimagehub;
pub mod direct_url; pub mod direct_url;
pub mod github; pub mod github;
pub mod gitlab; pub mod gitlab;
@ -11,14 +10,7 @@ use crate::adapters::traits::SourceAdapter;
use crate::domain::source::SourceRef; use crate::domain::source::SourceRef;
pub fn all_adapter_kinds() -> Vec<&'static str> { pub fn all_adapter_kinds() -> Vec<&'static str> {
vec![ vec!["github", "gitlab", "direct-url", "zsync", "sourceforge"]
"appimagehub",
"github",
"gitlab",
"direct-url",
"zsync",
"sourceforge",
]
} }
pub fn supports_source<A: SourceAdapter + ?Sized>(adapter: &A, source: &SourceRef) -> bool { pub fn supports_source<A: SourceAdapter + ?Sized>(adapter: &A, source: &SourceRef) -> bool {

View file

@ -3,7 +3,6 @@ use std::fs::{self, File};
use std::io::Read; use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::adapters::appimagehub::AppImageHubAdapter;
use crate::adapters::direct_url::DirectUrlAdapter; use crate::adapters::direct_url::DirectUrlAdapter;
use crate::adapters::gitlab::GitLabAdapter; use crate::adapters::gitlab::GitLabAdapter;
use crate::adapters::sourceforge::SourceForgeAdapter; use crate::adapters::sourceforge::SourceForgeAdapter;
@ -14,6 +13,7 @@ use crate::app::interaction::{InteractionKind, InteractionRequest};
use crate::app::progress::{ use crate::app::progress::{
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter, NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
}; };
use crate::app::providers::{ExternalAddResolution, ProviderRegistry};
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, InstallMetadata, InstallScope}; use crate::domain::app::{AppRecord, InstallMetadata, InstallScope};
@ -25,14 +25,14 @@ use crate::integration::install::{
use crate::integration::policy::{IntegrationMode, resolve_install_policy}; use crate::integration::policy::{IntegrationMode, resolve_install_policy};
use crate::metadata::parse_document; use crate::metadata::parse_document;
use crate::platform::probe_live_host; use crate::platform::probe_live_host;
use crate::source::appimagehub::resolve_appimagehub_item;
use crate::source::github::{ use crate::source::github::{
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy, GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy,
}; };
use crate::update::channels::build_channels; use crate::update::channels::build_channels;
use crate::update::ranking::{rank_channels, select_artifact, to_preference}; use crate::update::ranking::{rank_channels, select_artifact, to_preference};
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE"; const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_GITHUB_FIXTURE_MODE";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AddSecurityPolicy { pub struct AddSecurityPolicy {
@ -42,11 +42,12 @@ pub struct AddSecurityPolicy {
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> { pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
let transport = crate::source::github::default_transport(); let transport = crate::source::github::default_transport();
let mut reporter = NoopReporter; let mut reporter = NoopReporter;
build_add_plan_with_reporter_and_policy( build_add_plan_with_reporter_and_policy_and_registry(
query, query,
transport.as_ref(), transport.as_ref(),
&mut reporter, &mut reporter,
AddSecurityPolicy::default(), AddSecurityPolicy::default(),
&ProviderRegistry::default(),
) )
} }
@ -55,11 +56,12 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
transport: &T, transport: &T,
) -> Result<AddPlan, BuildAddPlanError> { ) -> Result<AddPlan, BuildAddPlanError> {
let mut reporter = NoopReporter; let mut reporter = NoopReporter;
build_add_plan_with_reporter_and_policy( build_add_plan_with_reporter_and_policy_and_registry(
query, query,
transport, transport,
&mut reporter, &mut reporter,
AddSecurityPolicy::default(), AddSecurityPolicy::default(),
&ProviderRegistry::default(),
) )
} }
@ -68,11 +70,40 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
transport: &T, transport: &T,
reporter: &mut impl ProgressReporter, reporter: &mut impl ProgressReporter,
) -> Result<AddPlan, BuildAddPlanError> { ) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_policy( build_add_plan_with_reporter_and_policy_and_registry(
query, query,
transport, transport,
reporter, reporter,
AddSecurityPolicy::default(), AddSecurityPolicy::default(),
&ProviderRegistry::default(),
)
}
pub fn build_add_plan_with_registered_providers<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
providers: &ProviderRegistry<'_>,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
let mut reporter = NoopReporter;
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport,
&mut reporter,
policy,
providers,
)
}
pub fn build_add_plan_with_reporter_and_registered_providers<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
providers: &ProviderRegistry<'_>,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_policy_and_registry(
query, transport, reporter, policy, providers,
) )
} }
@ -81,6 +112,22 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
transport: &T, transport: &T,
reporter: &mut impl ProgressReporter, reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy, policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport,
reporter,
policy,
&ProviderRegistry::default(),
)
}
fn build_add_plan_with_reporter_and_policy_and_registry<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
providers: &ProviderRegistry<'_>,
) -> Result<AddPlan, BuildAddPlanError> { ) -> Result<AddPlan, BuildAddPlanError> {
reporter.report(&OperationEvent::StageChanged { reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery, stage: OperationStage::ResolveQuery,
@ -91,8 +138,7 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
let mut interactions = Vec::new(); let mut interactions = Vec::new();
let mut parsed_metadata = Vec::new(); let mut parsed_metadata = Vec::new();
let mut display_name_hint = None; let (resolution, selected_artifact, update_strategy, display_name_hint) = match source.kind {
let (resolution, selected_artifact, update_strategy) = match source.kind {
SourceKind::GitHub => { SourceKind::GitHub => {
reporter.report(&OperationEvent::StageChanged { reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease, stage: OperationStage::DiscoverRelease,
@ -148,6 +194,7 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
}, },
artifact, artifact,
strategy, strategy,
None,
) )
} }
SourceKind::GitLab => { SourceKind::GitLab => {
@ -188,59 +235,29 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
selection_reason: "provider-release".to_owned(), selection_reason: "provider-release".to_owned(),
}; };
(resolution, artifact, strategy) (resolution, artifact, strategy, None)
} }
SourceKind::AppImageHub => { SourceKind::AppImageHub => {
reporter.report(&OperationEvent::StageChanged { reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease, stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(), message: "discovering release".to_owned(),
}); });
let adapter = AppImageHubAdapter; if let Some(external_resolution) =
let resolution = match adapter resolve_registered_external_add_provider(&source, providers)?
.resolve_source(&source)
.map_err(|error| BuildAddPlanError::Adapter("appimagehub", error))?
{ {
AdapterResolveOutcome::Resolved(resolution) => resolution,
AdapterResolveOutcome::NoInstallableArtifact { source } => {
return Err(BuildAddPlanError::NoInstallableArtifact { source });
}
};
let resolved_item = resolve_appimagehub_item(&resolution.source)
.map_err(|error| {
BuildAddPlanError::Adapter(
"appimagehub",
crate::adapters::traits::AdapterError::ResolutionFailed(format!(
"{error:?}"
)),
)
})?
.ok_or(BuildAddPlanError::NoInstallableArtifact {
source: resolution.source.clone(),
})?;
display_name_hint = Some(resolved_item.title.clone());
reporter.report(&OperationEvent::StageChanged { reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact, stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(), message: "selecting artifact".to_owned(),
}); });
let artifact = ArtifactCandidate { (
url: resolved_item.download.url.clone(), external_resolution.resolution,
version: resolved_item.version.clone(), external_resolution.selected_artifact,
arch: resolved_item.download.arch.clone(), external_resolution.update_strategy,
trusted_checksum: None, external_resolution.display_name_hint,
weak_checksum_md5: resolved_item.download.md5sum.clone(), )
selection_reason: "provider-release".to_owned(), } else {
}; return Err(BuildAddPlanError::NoInstallableArtifact { source });
let strategy = UpdateStrategy { }
preferred: crate::domain::update::ChannelPreference {
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
locator: resolved_item.download.url.clone(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
};
(resolution, artifact, strategy)
} }
SourceKind::DirectUrl => { SourceKind::DirectUrl => {
reporter.report(&OperationEvent::StageChanged { reporter.report(&OperationEvent::StageChanged {
@ -274,7 +291,7 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
alternates: Vec::new(), alternates: Vec::new(),
}; };
(resolution, artifact, strategy) (resolution, artifact, strategy, None)
} }
SourceKind::SourceForge => { SourceKind::SourceForge => {
reporter.report(&OperationEvent::StageChanged { reporter.report(&OperationEvent::StageChanged {
@ -315,7 +332,7 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
alternates: Vec::new(), alternates: Vec::new(),
}; };
(resolution, artifact, strategy) (resolution, artifact, strategy, None)
} }
_ => { _ => {
reporter.report(&OperationEvent::StageChanged { reporter.report(&OperationEvent::StageChanged {
@ -345,7 +362,7 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
}, },
alternates: Vec::new(), alternates: Vec::new(),
}; };
(resolution, artifact, strategy) (resolution, artifact, strategy, None)
} }
}; };
@ -359,6 +376,21 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
}) })
} }
fn resolve_registered_external_add_provider(
source: &crate::domain::source::SourceRef,
providers: &ProviderRegistry<'_>,
) -> Result<Option<ExternalAddResolution>, BuildAddPlanError> {
for provider in &providers.external_add_providers {
match provider.resolve(source) {
Ok(Some(resolution)) => return Ok(Some(resolution)),
Ok(None) => continue,
Err(error) => return Err(BuildAddPlanError::Adapter(provider.id(), error)),
}
}
Ok(None)
}
pub fn prefer_latest_tracking(mut plan: AddPlan) -> AddPlan { pub fn prefer_latest_tracking(mut plan: AddPlan) -> AddPlan {
if let Some(index) = plan if let Some(index) = plan
.update_strategy .update_strategy
@ -465,7 +497,7 @@ pub fn install_app_with_reporter(
install_home, install_home,
&policy &policy
.desktop_entry_root .desktop_entry_root
.join(format!("aim-{}.desktop", record.stable_id)), .join(format!("upm-{}.desktop", record.stable_id)),
); );
let icon_path = resolve_target_path( let icon_path = resolve_target_path(
install_home, install_home,
@ -475,7 +507,7 @@ pub fn install_app_with_reporter(
stage: OperationStage::DownloadArtifact, stage: OperationStage::DownloadArtifact,
message: "downloading artifact".to_owned(), message: "downloading artifact".to_owned(),
}); });
let staging_root = install_home.join(".local/share/aim/staging"); let staging_root = install_home.join(".local/share/upm/staging");
let staged_payload_path = staged_appimage_path(&staging_root, &record.stable_id); let staged_payload_path = staged_appimage_path(&staging_root, &record.stable_id);
let artifact_size_bytes = download_artifact_to_staged_path_with_reporter( let artifact_size_bytes = download_artifact_to_staged_path_with_reporter(
&plan.selected_artifact.url, &plan.selected_artifact.url,
@ -630,7 +662,9 @@ fn download_artifact_to_staged_path_with_reporter(
) -> Result<u64, InstallAppError> { ) -> Result<u64, InstallAppError> {
let policy = http_client_policy(); let policy = http_client_policy();
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") { if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
{
let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82"; let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82";
return download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || { return download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || {
Ok(( Ok((

View file

@ -3,6 +3,7 @@ pub mod identity;
pub mod interaction; pub mod interaction;
pub mod list; pub mod list;
pub mod progress; pub mod progress;
pub mod providers;
pub mod query; pub mod query;
pub mod remove; pub mod remove;
pub mod scope; pub mod scope;

View file

@ -0,0 +1,24 @@
use crate::adapters::traits::{AdapterError, AdapterResolution};
use crate::app::search::SearchProvider;
use crate::domain::source::SourceRef;
use crate::domain::update::{ArtifactCandidate, UpdateStrategy};
pub trait ExternalAddProvider {
fn id(&self) -> &'static str;
fn resolve(&self, source: &SourceRef) -> Result<Option<ExternalAddResolution>, AdapterError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExternalAddResolution {
pub resolution: AdapterResolution,
pub selected_artifact: ArtifactCandidate,
pub update_strategy: UpdateStrategy,
pub display_name_hint: Option<String>,
}
#[derive(Default)]
pub struct ProviderRegistry<'a> {
pub search_providers: Vec<&'a dyn SearchProvider>,
pub external_add_providers: Vec<&'a dyn ExternalAddProvider>,
}

View file

@ -1,11 +1,9 @@
use crate::app::providers::ProviderRegistry;
use crate::domain::app::AppRecord; use crate::domain::app::AppRecord;
use crate::domain::search::{ use crate::domain::search::{
InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults, InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults,
SearchWarning, SearchWarning,
}; };
use crate::source::appimagehub::{
AppImageHubSearchError, AppImageHubTransport, search_appimagehub_with,
};
use crate::source::github::{ use crate::source::github::{
GitHubSearchError, GitHubTransport, TransportRelease, default_transport, GitHubSearchError, GitHubTransport, TransportRelease, default_transport,
search_github_repositories_with, search_github_repositories_with,
@ -40,17 +38,26 @@ pub fn build_search_results(
query: &SearchQuery, query: &SearchQuery,
installed_apps: &[AppRecord], installed_apps: &[AppRecord],
) -> Result<SearchResults, SearchError> { ) -> Result<SearchResults, SearchError> {
let github_transport = default_transport(); build_search_results_with_registered_providers(
let appimagehub_transport = crate::source::appimagehub::default_transport();
let github_provider = GitHubSearchProvider::new(github_transport.as_ref());
let appimagehub_provider = AppImageHubSearchProvider::new(appimagehub_transport.as_ref());
build_search_results_with(
query, query,
installed_apps, installed_apps,
&[&github_provider, &appimagehub_provider], &ProviderRegistry::default(),
) )
} }
pub fn build_search_results_with_registered_providers(
query: &SearchQuery,
installed_apps: &[AppRecord],
providers: &ProviderRegistry<'_>,
) -> Result<SearchResults, SearchError> {
let github_transport = default_transport();
let github_provider = GitHubSearchProvider::new(github_transport.as_ref());
let mut resolved_providers = vec![&github_provider as &dyn SearchProvider];
resolved_providers.extend(providers.search_providers.iter().copied());
build_search_results_with(query, installed_apps, &resolved_providers)
}
pub fn build_search_results_with( pub fn build_search_results_with(
query: &SearchQuery, query: &SearchQuery,
installed_apps: &[AppRecord], installed_apps: &[AppRecord],
@ -94,58 +101,6 @@ impl<'a, T: GitHubTransport + ?Sized> GitHubSearchProvider<'a, T> {
} }
} }
pub struct AppImageHubSearchProvider<'a, T: AppImageHubTransport + ?Sized> {
transport: &'a T,
}
impl<'a, T: AppImageHubTransport + ?Sized> AppImageHubSearchProvider<'a, T> {
pub fn new(transport: &'a T) -> Self {
Self { transport }
}
}
impl<T: AppImageHubTransport + ?Sized> SearchProvider for AppImageHubSearchProvider<'_, T> {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let hits = search_appimagehub_with(&query.text, query.remote_limit, self.transport)
.map_err(|error| {
SearchProviderError::new("appimagehub", &render_appimagehub_search_error(&error))
})?;
let normalized_query = normalize_lookup(&query.text);
let mut ranked_hits = hits
.into_iter()
.enumerate()
.map(|(index, hit)| {
(
appimagehub_remote_match_rank(
&normalized_query,
&hit.name,
hit.summary.as_deref(),
),
index,
hit,
)
})
.collect::<Vec<_>>();
ranked_hits.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
Ok(ranked_hits
.into_iter()
.map(|(_, _, hit)| SearchResult {
provider_id: "appimagehub".to_owned(),
display_name: hit.name,
description: hit.summary,
source_locator: hit.detail_page,
install_query: format!("appimagehub/{}", hit.id),
canonical_locator: hit.id,
version: Some(hit.version),
install_status: SearchInstallStatus::Available,
})
.collect())
}
}
impl<T: GitHubTransport + ?Sized> SearchProvider for GitHubSearchProvider<'_, T> { impl<T: GitHubTransport + ?Sized> SearchProvider for GitHubSearchProvider<'_, T> {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> { fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let name_only_query = format!("{} in:name", query.text); let name_only_query = format!("{} in:name", query.text);
@ -392,45 +347,3 @@ fn render_github_search_error(error: &GitHubSearchError) -> String {
GitHubSearchError::Transport(inner) => inner.to_string(), GitHubSearchError::Transport(inner) => inner.to_string(),
} }
} }
fn appimagehub_remote_match_rank(query: &str, name: &str, summary: Option<&str>) -> u8 {
let name = normalize_lookup(name);
let summary = summary.map(normalize_lookup);
if name == query {
return 0;
}
if name.starts_with(query) {
return 1;
}
if name.contains(query) {
return 2;
}
if summary
.as_deref()
.map(|summary| summary.starts_with(query))
.unwrap_or(false)
{
return 3;
}
if summary
.as_deref()
.map(|summary| summary.contains(query))
.unwrap_or(false)
{
return 4;
}
5
}
fn render_appimagehub_search_error(error: &AppImageHubSearchError) -> String {
match error {
AppImageHubSearchError::Parse(inner) => inner.to_string(),
AppImageHubSearchError::Transport(inner) => inner.to_string(),
}
}

View file

@ -291,7 +291,7 @@ fn stage_existing_installation(
} }
let stage_dir = install_home let stage_dir = install_home
.join(".local/share/aim/rollback") .join(".local/share/upm/rollback")
.join(&app.stable_id); .join(&app.stable_id);
fs::create_dir_all(&stage_dir) fs::create_dir_all(&stage_dir)
.map_err(|error| format!("failed to create rollback staging directory: {error}"))?; .map_err(|error| format!("failed to create rollback staging directory: {error}"))?;

View file

@ -11,7 +11,7 @@ pub fn managed_appimage_path(home_dir: &Path, scope: InstallScope, app_id: &str)
} }
pub fn desktop_entry_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf { pub fn desktop_entry_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf {
scope_applications_dir(home_dir, scope).join(format!("aim-{app_id}.desktop")) scope_applications_dir(home_dir, scope).join(format!("upm-{app_id}.desktop"))
} }
pub fn icon_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf { pub fn icon_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf {

View file

@ -37,7 +37,7 @@ pub fn resolve_install_policy(
(DistroFamily::Immutable, InstallScope::System) if capabilities.is_immutable => { (DistroFamily::Immutable, InstallScope::System) if capabilities.is_immutable => {
Ok(InstallPolicy { Ok(InstallPolicy {
scope: InstallScope::User, scope: InstallScope::User,
payload_root: PathBuf::from(".local/lib/aim/appimages"), payload_root: PathBuf::from(".local/lib/upm/appimages"),
desktop_entry_root: PathBuf::from(".local/share/applications"), desktop_entry_root: PathBuf::from(".local/share/applications"),
icon_root: PathBuf::from(".local/share/icons/hicolor/256x256/apps"), icon_root: PathBuf::from(".local/share/icons/hicolor/256x256/apps"),
integration_mode: IntegrationMode::Degraded, integration_mode: IntegrationMode::Degraded,
@ -57,7 +57,7 @@ pub fn resolve_install_policy(
}), }),
_ => Ok(InstallPolicy { _ => Ok(InstallPolicy {
scope: InstallScope::User, scope: InstallScope::User,
payload_root: PathBuf::from(".local/lib/aim/appimages"), payload_root: PathBuf::from(".local/lib/upm/appimages"),
desktop_entry_root: PathBuf::from(".local/share/applications"), desktop_entry_root: PathBuf::from(".local/share/applications"),
icon_root: PathBuf::from(".local/share/icons/hicolor/256x256/apps"), icon_root: PathBuf::from(".local/share/icons/hicolor/256x256/apps"),
integration_mode: if capabilities.has_desktop_session { integration_mode: if capabilities.has_desktop_session {

View file

@ -62,7 +62,7 @@ fn icon_theme_root(icon_path: &Path) -> PathBuf {
} }
fn audit_helper(helper: &Path, args: &[&Path]) { fn audit_helper(helper: &Path, args: &[&Path]) {
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") { if env::var("UPM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
return; return;
} }
@ -72,7 +72,7 @@ fn audit_helper(helper: &Path, args: &[&Path]) {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" "); .join(" ");
eprintln!( eprintln!(
"[aim] helper exec: {}{}{}", "[upm] helper exec: {}{}{}",
helper.display(), helper.display(),
if rendered_args.is_empty() { "" } else { " " }, if rendered_args.is_empty() { "" } else { " " },
rendered_args rendered_args
@ -80,23 +80,23 @@ fn audit_helper(helper: &Path, args: &[&Path]) {
} }
fn audit_helper_status(helper: &Path, code: Option<i32>) { fn audit_helper_status(helper: &Path, code: Option<i32>) {
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") { if env::var("UPM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
return; return;
} }
match code { match code {
Some(code) => eprintln!("[aim] helper exit: {} code={code}", helper.display()), Some(code) => eprintln!("[upm] helper exit: {} code={code}", helper.display()),
None => eprintln!( None => eprintln!(
"[aim] helper exit: {} terminated by signal", "[upm] helper exit: {} terminated by signal",
helper.display() helper.display()
), ),
} }
} }
fn audit_helper_failure(helper: &Path, error: &str) { fn audit_helper_failure(helper: &Path, error: &str) {
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") { if env::var("UPM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
return; return;
} }
eprintln!("[aim] helper failure: {} error={error}", helper.display()); eprintln!("[upm] helper failure: {} error={error}", helper.display());
} }

View file

@ -7,3 +7,5 @@ pub mod platform;
pub mod registry; pub mod registry;
pub mod source; pub mod source;
pub mod update; pub mod update;
pub use app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry};

View file

@ -71,7 +71,7 @@ fn is_writable_dir(path: &Path) -> bool {
return false; return false;
} }
let probe_path = path.join(".aim-write-test"); let probe_path = path.join(".upm-write-test");
let result = OpenOptions::new() let result = OpenOptions::new()
.create(true) .create(true)
.write(true) .write(true)

View file

@ -10,11 +10,11 @@ pub use crate::domain::app::InstallScope;
pub use capabilities::{DesktopHelpers, HostCapabilities, WritableRoots}; pub use capabilities::{DesktopHelpers, HostCapabilities, WritableRoots};
pub use distro::{DistroFamily, detect_distro_family}; pub use distro::{DistroFamily, detect_distro_family};
const OS_RELEASE_PATH_ENV: &str = "AIM_OS_RELEASE_PATH"; const OS_RELEASE_PATH_ENV: &str = "UPM_OS_RELEASE_PATH";
const HELPER_PATHS_ENV: &str = "AIM_HELPER_PATHS"; const HELPER_PATHS_ENV: &str = "UPM_HELPER_PATHS";
pub fn user_managed_appimages_dir(home_dir: &Path) -> PathBuf { pub fn user_managed_appimages_dir(home_dir: &Path) -> PathBuf {
home_dir.join(".local/lib/aim/appimages") home_dir.join(".local/lib/upm/appimages")
} }
pub fn user_applications_dir(home_dir: &Path) -> PathBuf { pub fn user_applications_dir(home_dir: &Path) -> PathBuf {
@ -26,7 +26,7 @@ pub fn user_icons_dir(home_dir: &Path) -> PathBuf {
} }
pub fn system_managed_appimages_dir() -> PathBuf { pub fn system_managed_appimages_dir() -> PathBuf {
PathBuf::from("/opt/aim/appimages") PathBuf::from("/opt/upm/appimages")
} }
pub fn system_applications_dir() -> PathBuf { pub fn system_applications_dir() -> PathBuf {

View file

@ -4,7 +4,8 @@ use std::time::Duration;
use crate::domain::source::SourceRef; use crate::domain::source::SourceRef;
const DEFAULT_APPIMAGEHUB_API_BASE: &str = "https://api.appimagehub.com/ocs/v1/content"; const DEFAULT_APPIMAGEHUB_API_BASE: &str = "https://api.appimagehub.com/ocs/v1/content";
const FIXTURE_MODE_ENV: &str = "AIM_APPIMAGEHUB_FIXTURE_MODE"; const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_APPIMAGEHUB_FIXTURE_MODE";
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubDownload { pub struct AppImageHubDownload {
@ -56,9 +57,8 @@ pub trait AppImageHubTransport {
} }
pub fn default_transport() -> Box<dyn AppImageHubTransport> { pub fn default_transport() -> Box<dyn AppImageHubTransport> {
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var("AIM_GITHUB_FIXTURE_MODE").ok().as_deref() == Some("1") || env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
{
Box::new(FixtureAppImageHubTransport) Box::new(FixtureAppImageHubTransport)
} else { } else {
Box::new(ReqwestAppImageHubTransport::new()) Box::new(ReqwestAppImageHubTransport::new())
@ -129,7 +129,7 @@ impl ReqwestAppImageHubTransport {
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.build() .build()
.expect("reqwest client should build"), .expect("reqwest client should build"),
api_base: env::var("AIM_APPIMAGEHUB_API_BASE") api_base: env::var("UPM_APPIMAGEHUB_API_BASE")
.unwrap_or_else(|_| DEFAULT_APPIMAGEHUB_API_BASE.to_owned()), .unwrap_or_else(|_| DEFAULT_APPIMAGEHUB_API_BASE.to_owned()),
} }
} }
@ -424,11 +424,11 @@ fn resolved_version(item: &AppImageHubItem, download: &AppImageHubDownload) -> S
} }
fn fixture_item(id: &str) -> Option<AppImageHubItem> { fn fixture_item(id: &str) -> Option<AppImageHubItem> {
let insecure_http = env::var("AIM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP") let insecure_http = env::var("UPM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP")
.ok() .ok()
.as_deref() .as_deref()
== Some("1"); == Some("1");
let bad_md5 = env::var("AIM_APPIMAGEHUB_FIXTURE_BAD_MD5").ok().as_deref() == Some("1"); let bad_md5 = env::var("UPM_APPIMAGEHUB_FIXTURE_BAD_MD5").ok().as_deref() == Some("1");
match id { match id {
"2338455" => Some(AppImageHubItem { "2338455" => Some(AppImageHubItem {

View file

@ -5,7 +5,8 @@ use crate::domain::source::{ResolvedRelease, SourceRef};
use crate::metadata::MetadataDocument; use crate::metadata::MetadataDocument;
const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com"; const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com";
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE"; const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_GITHUB_FIXTURE_MODE";
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 30; const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 30;
const DEFAULT_HTTP_MAX_RETRIES: usize = 3; const DEFAULT_HTTP_MAX_RETRIES: usize = 3;
@ -176,7 +177,9 @@ pub fn search_github_repositories_with<T: GitHubTransport + ?Sized>(
} }
pub fn default_transport() -> Box<dyn GitHubTransport> { pub fn default_transport() -> Box<dyn GitHubTransport> {
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") { if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
{
Box::new(FixtureGitHubTransport) Box::new(FixtureGitHubTransport)
} else { } else {
Box::new(ReqwestGitHubTransport::new()) Box::new(ReqwestGitHubTransport::new())
@ -200,13 +203,13 @@ impl ReqwestGitHubTransport {
let mut default_headers = reqwest::header::HeaderMap::new(); let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert( default_headers.insert(
reqwest::header::USER_AGENT, reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("aim/0.1"), reqwest::header::HeaderValue::from_static("upm/0.1"),
); );
default_headers.insert( default_headers.insert(
reqwest::header::ACCEPT, reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/vnd.github+json"), reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
); );
if let Some(token) = env::var("AIM_GITHUB_TOKEN") if let Some(token) = env::var("UPM_GITHUB_TOKEN")
.ok() .ok()
.or_else(|| env::var("GITHUB_TOKEN").ok()) .or_else(|| env::var("GITHUB_TOKEN").ok())
&& let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) && let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
@ -220,7 +223,7 @@ impl ReqwestGitHubTransport {
.timeout(policy.timeout) .timeout(policy.timeout)
.build() .build()
.expect("reqwest client should build"), .expect("reqwest client should build"),
api_base: env::var("AIM_GITHUB_API_BASE") api_base: env::var("UPM_GITHUB_API_BASE")
.unwrap_or_else(|_| DEFAULT_GITHUB_API_BASE.to_owned()), .unwrap_or_else(|_| DEFAULT_GITHUB_API_BASE.to_owned()),
} }
} }

View file

@ -1,3 +1,2 @@
pub mod appimagehub;
pub mod github; pub mod github;
pub mod input; pub mod input;

View file

@ -1,16 +1,14 @@
use aim_core::adapters::appimagehub::AppImageHubAdapter; use upm_core::adapters::direct_url::DirectUrlAdapter;
use aim_core::adapters::direct_url::DirectUrlAdapter; use upm_core::adapters::github::GitHubAdapter;
use aim_core::adapters::github::GitHubAdapter; use upm_core::adapters::gitlab::GitLabAdapter;
use aim_core::adapters::gitlab::GitLabAdapter; use upm_core::adapters::sourceforge::SourceForgeAdapter;
use aim_core::adapters::sourceforge::SourceForgeAdapter; use upm_core::adapters::traits::{
use aim_core::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter, AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
}; };
use aim_core::app::query::resolve_query; use upm_core::app::query::resolve_query;
use aim_core::domain::source::{ use upm_core::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef, NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
}; };
use aim_core::source::appimagehub::FixtureAppImageHubTransport;
struct FileArtifactAdapter; struct FileArtifactAdapter;
@ -61,60 +59,6 @@ fn adapter_capabilities_can_report_exact_resolution_only() {
assert!(!capabilities.supports_search); assert!(!capabilities.supports_search);
} }
#[test]
fn appimagehub_adapter_reports_search_and_exact_resolution_capabilities() {
let adapter = AppImageHubAdapter;
assert_eq!(adapter.id(), "appimagehub");
assert_eq!(
adapter.repository_source_kind(),
Some(SourceKind::AppImageHub)
);
assert_eq!(adapter.exact_source_kind(), None);
assert_eq!(
adapter.capabilities(),
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
);
}
#[test]
fn appimagehub_adapter_resolves_installable_items_through_fixture_transport() {
let adapter = AppImageHubAdapter;
let source = resolve_query("appimagehub/2338455").unwrap();
let resolution = adapter
.resolve_source_with(&source, &FixtureAppImageHubTransport)
.unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
source,
release: ResolvedRelease { version, .. },
}) if source.kind == SourceKind::AppImageHub
&& source.canonical_locator.as_deref() == Some("2338455")
&& version == "latest"
));
}
#[test]
fn appimagehub_adapter_reports_no_installable_artifact_for_non_appimage_items() {
let adapter = AppImageHubAdapter;
let source = resolve_query("appimagehub/2337998").unwrap();
let resolution = adapter
.resolve_source_with(&source, &FixtureAppImageHubTransport)
.unwrap();
assert_eq!(
resolution,
AdapterResolveOutcome::NoInstallableArtifact { source }
);
}
#[test] #[test]
fn repository_backed_resolvers_accept_only_their_own_source_kind() { fn repository_backed_resolvers_accept_only_their_own_source_kind() {
let github_source = resolve_query("sharkdp/bat").unwrap(); let github_source = resolve_query("sharkdp/bat").unwrap();

View file

@ -1,14 +1,14 @@
use aim_core::adapters::all_adapter_kinds; use upm_core::adapters::all_adapter_kinds;
#[test] #[test]
fn all_expected_adapter_kinds_are_registered() { fn all_expected_adapter_kinds_are_registered() {
let kinds = all_adapter_kinds(); let kinds = all_adapter_kinds();
assert!(kinds.contains(&"appimagehub"));
assert!(kinds.contains(&"github")); assert!(kinds.contains(&"github"));
assert!(kinds.contains(&"gitlab")); assert!(kinds.contains(&"gitlab"));
assert!(kinds.contains(&"direct-url")); assert!(kinds.contains(&"direct-url"));
assert!(kinds.contains(&"zsync")); assert!(kinds.contains(&"zsync"));
assert!(kinds.contains(&"sourceforge")); assert!(kinds.contains(&"sourceforge"));
assert!(!kinds.contains(&"appimagehub"));
assert!(!kinds.contains(&"custom-json")); assert!(!kinds.contains(&"custom-json"));
} }

View file

@ -1,8 +1,8 @@
use std::fs; use std::fs;
use aim_core::integration::install::{InstallRequest, PayloadInstallError, execute_install};
use aim_core::platform::DesktopHelpers;
use tempfile::tempdir; use tempfile::tempdir;
use upm_core::integration::install::{InstallRequest, PayloadInstallError, execute_install};
use upm_core::platform::DesktopHelpers;
const VALID_FIXTURE_SHA512: &str = const VALID_FIXTURE_SHA512: &str =
"ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw=="; "ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw==";

View file

@ -2,15 +2,15 @@ use std::fs;
use std::io::{self, Cursor, Read}; use std::io::{self, Cursor, Read};
use std::time::Duration; use std::time::Duration;
use aim_core::app::add::{ use tempfile::tempdir;
use upm_core::app::add::{
InstallAppError, download_to_staged_path_with_retries, InstallAppError, download_to_staged_path_with_retries,
stream_payload_to_staged_file_with_reporter, stream_payload_to_staged_file_with_reporter,
}; };
use aim_core::app::progress::{NoopReporter, OperationEvent}; use upm_core::app::progress::{NoopReporter, OperationEvent};
use aim_core::integration::install::{InstallRequest, execute_install}; use upm_core::integration::install::{InstallRequest, execute_install};
use aim_core::platform::DesktopHelpers; use upm_core::platform::DesktopHelpers;
use aim_core::source::github::HttpClientPolicy; use upm_core::source::github::HttpClientPolicy;
use tempfile::tempdir;
#[test] #[test]
fn payload_streaming_writes_staged_file_and_reports_progress() { fn payload_streaming_writes_staged_file_and_reports_progress() {

View file

@ -1,6 +1,6 @@
use aim_core::app::add::{build_add_plan_with, materialize_app_record, prefer_latest_tracking}; use upm_core::app::add::{build_add_plan_with, materialize_app_record, prefer_latest_tracking};
use aim_core::app::query::resolve_query; use upm_core::app::query::resolve_query;
use aim_core::source::github::FixtureGitHubTransport; use upm_core::source::github::FixtureGitHubTransport;
#[test] #[test]
fn github_adapter_can_normalize_owner_repo_source() { fn github_adapter_can_normalize_owner_repo_source() {

View file

@ -1,8 +1,8 @@
use aim_core::app::query::resolve_query; use std::time::Duration;
use aim_core::source::github::{ use upm_core::app::query::resolve_query;
use upm_core::source::github::{
FixtureGitHubTransport, discover_github_candidates_with, http_client_policy, FixtureGitHubTransport, discover_github_candidates_with, http_client_policy,
}; };
use std::time::Duration;
#[test] #[test]
fn discovery_reports_appimage_assets_and_latest_linux_yml() { fn discovery_reports_appimage_assets_and_latest_linux_yml() {

View file

@ -1,5 +1,5 @@
use aim_core::app::identity::{IdentityFallback, resolve_identity}; use upm_core::app::identity::{IdentityFallback, resolve_identity};
use aim_core::domain::app::IdentityConfidence; use upm_core::domain::app::IdentityConfidence;
#[test] #[test]
fn unresolved_identity_can_fall_back_to_url() { fn unresolved_identity_can_fall_back_to_url() {
@ -42,6 +42,6 @@ fn identifiers_containing_dot_dot_are_rejected() {
assert_eq!( assert_eq!(
error, error,
aim_core::app::identity::ResolveIdentityError::InvalidStableId upm_core::app::identity::ResolveIdentityError::InvalidStableId
); );
} }

View file

@ -1,15 +1,15 @@
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::fs;
use std::sync::Mutex; use std::sync::Mutex;
use tempfile::tempdir; use tempfile::tempdir;
use upm_core::app::add::{BuildAddPlanError, build_add_plan_with};
use upm_core::app::query::ResolveQueryError;
use upm_core::app::update::execute_updates;
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::source::SourceKind;
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceRef};
use upm_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
use upm_core::platform::DesktopHelpers;
use upm_core::source::github::FixtureGitHubTransport;
static ENV_LOCK: Mutex<()> = Mutex::new(()); static ENV_LOCK: Mutex<()> = Mutex::new(());
@ -27,7 +27,7 @@ fn integration_failure_removes_new_payload_and_generated_files() {
fs::write(&staged_path, b"\x7fELFAppImage").unwrap(); fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
let final_payload_path = payload_root.join("bat.AppImage"); let final_payload_path = payload_root.join("bat.AppImage");
let desktop_entry_path = blocking_path.join("aim-bat.desktop"); let desktop_entry_path = blocking_path.join("upm-bat.desktop");
let error = execute_install(&InstallRequest { let error = execute_install(&InstallRequest {
staged_payload_path: &staged_path, staged_payload_path: &staged_path,
final_payload_path: &final_payload_path, final_payload_path: &final_payload_path,
@ -85,13 +85,13 @@ fn failed_update_restores_tracked_desktop_and_icon_files() {
let root = tempdir().unwrap(); let root = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("DISPLAY", ":99"); std::env::set_var("DISPLAY", ":99");
std::env::set_var("XDG_CURRENT_DESKTOP", "test"); std::env::set_var("XDG_CURRENT_DESKTOP", "test");
} }
let payload_path = root.path().join("tracked/team-app.AppImage"); let payload_path = root.path().join("tracked/team-app.AppImage");
let desktop_path = root.path().join("tracked/aim-team-app.desktop"); let desktop_path = root.path().join("tracked/upm-team-app.desktop");
let icon_path = root.path().join("tracked/team-app.png"); let icon_path = root.path().join("tracked/team-app.png");
fs::create_dir_all(payload_path.parent().unwrap()).unwrap(); fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
fs::write(&payload_path, b"previous-payload").unwrap(); fs::write(&payload_path, b"previous-payload").unwrap();

View file

@ -1,13 +1,13 @@
use aim_core::app::add::{build_add_plan_with_reporter, install_app_with_reporter};
use aim_core::app::progress::{OperationEvent, OperationStage};
use aim_core::domain::app::InstallScope;
use aim_core::domain::source::{NormalizedSourceKind, SourceKind};
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::fs;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir; use tempfile::tempdir;
use upm_core::app::add::{build_add_plan_with_reporter, install_app_with_reporter};
use upm_core::app::progress::{OperationEvent, OperationStage};
use upm_core::domain::app::InstallScope;
use upm_core::domain::source::{NormalizedSourceKind, SourceKind};
use upm_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
use upm_core::platform::DesktopHelpers;
use upm_core::source::github::FixtureGitHubTransport;
fn write_staged_payload(root: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf { fn write_staged_payload(root: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
let staged_path = root.join("staging").join(format!("{name}.download")); let staged_path = root.join("staging").join(format!("{name}.download"));
@ -32,7 +32,7 @@ fn install_writes_desktop_entry_and_reports_refresh_warning_only() {
trusted_checksum: None, trusted_checksum: None,
weak_checksum_md5: None, weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest { desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"), desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
icon_path: None, icon_path: None,
icon_bytes: None, icon_bytes: None,
@ -86,7 +86,7 @@ fn install_executes_refresh_helpers_when_available() {
trusted_checksum: None, trusted_checksum: None,
weak_checksum_md5: None, weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest { desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"), desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
icon_path: Some(&icon_root.join("bat.png")), icon_path: Some(&icon_root.join("bat.png")),
icon_bytes: None, icon_bytes: None,
@ -128,7 +128,7 @@ fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
trusted_checksum: None, trusted_checksum: None,
weak_checksum_md5: None, weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest { desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"), desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
icon_path: Some(&icon_root.join("bat.png")), icon_path: Some(&icon_root.join("bat.png")),
icon_bytes: None, icon_bytes: None,
@ -152,7 +152,7 @@ fn install_app_reports_operation_stages_in_order() {
let mut events: Vec<OperationEvent> = Vec::new(); let mut events: Vec<OperationEvent> = Vec::new();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let mut reporter = |event: &OperationEvent| events.push(event.clone()); let mut reporter = |event: &OperationEvent| events.push(event.clone());
@ -249,7 +249,7 @@ fn install_app_sanitizes_desktop_entry_display_names() {
let mut reporter = Vec::new(); let mut reporter = Vec::new();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let mut capture = |event: &OperationEvent| reporter.push(event.clone()); let mut capture = |event: &OperationEvent| reporter.push(event.clone());
@ -349,7 +349,7 @@ fn gitlab_install_preserves_truthful_gitlab_origin() {
let root = tempdir().unwrap(); let root = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let mut reporter = |_event: &OperationEvent| {}; let mut reporter = |_event: &OperationEvent| {};
@ -399,7 +399,7 @@ fn direct_url_install_preserves_truthful_direct_url_origin() {
let root = tempdir().unwrap(); let root = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let mut reporter = |_event: &OperationEvent| {}; let mut reporter = |_event: &OperationEvent| {};
@ -484,7 +484,7 @@ fn sourceforge_latest_download_install_preserves_truthful_origin() {
let root = tempdir().unwrap(); let root = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let mut reporter = |_event: &OperationEvent| {}; let mut reporter = |_event: &OperationEvent| {};
@ -514,7 +514,7 @@ fn sourceforge_release_folder_install_preserves_truthful_origin() {
let root = tempdir().unwrap(); let root = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let mut reporter = |_event: &OperationEvent| {}; let mut reporter = |_event: &OperationEvent| {};
@ -577,7 +577,7 @@ fn sourceforge_file_like_release_download_install_preserves_input_but_stores_rel
let root = tempdir().unwrap(); let root = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let mut reporter = |_event: &OperationEvent| {}; let mut reporter = |_event: &OperationEvent| {};

View file

@ -0,0 +1,28 @@
use std::path::Path;
use upm_core::domain::app::InstallScope;
use upm_core::integration::paths::{desktop_entry_path, managed_appimage_path};
#[test]
fn user_scope_path_lands_under_home_managed_dir() {
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::User, "bat");
assert_eq!(
path,
Path::new("/home/test/.local/lib/upm/appimages/bat.AppImage")
);
}
#[test]
fn system_scope_path_lands_under_opt_upm_dir() {
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::System, "bat");
assert_eq!(path, Path::new("/opt/upm/appimages/bat.AppImage"));
}
#[test]
fn system_scope_desktop_entry_uses_upm_prefix() {
let path = desktop_entry_path(Path::new("/home/test"), InstallScope::System, "bat");
assert_eq!(path, Path::new("/usr/share/applications/upm-bat.desktop"));
}

View file

@ -1,7 +1,7 @@
use aim_core::integration::install::stage_and_commit_payload;
use std::fs; use std::fs;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir; use tempfile::tempdir;
use upm_core::integration::install::stage_and_commit_payload;
#[test] #[test]
fn payload_commit_moves_staged_appimage_into_final_location() { fn payload_commit_moves_staged_appimage_into_final_location() {

View file

@ -1,6 +1,6 @@
use aim_core::integration::policy::{IntegrationMode, resolve_install_policy};
use aim_core::platform::{DistroFamily, HostCapabilities, InstallScope};
use std::path::Path; use std::path::Path;
use upm_core::integration::policy::{IntegrationMode, resolve_install_policy};
use upm_core::platform::{DistroFamily, HostCapabilities, InstallScope};
#[test] #[test]
fn immutable_system_request_downgrades_to_user_when_allowed() { fn immutable_system_request_downgrades_to_user_when_allowed() {
@ -36,7 +36,7 @@ fn system_policy_uses_managed_payload_and_native_integration_roots() {
.unwrap(); .unwrap();
assert_eq!(policy.scope, InstallScope::System); assert_eq!(policy.scope, InstallScope::System);
assert_eq!(policy.payload_root, Path::new("/opt/aim/appimages")); assert_eq!(policy.payload_root, Path::new("/opt/upm/appimages"));
assert_eq!( assert_eq!(
policy.desktop_entry_root, policy.desktop_entry_root,
Path::new("/usr/share/applications") Path::new("/usr/share/applications")

View file

@ -1,5 +1,5 @@
use aim_core::app::scope::{ScopeOverride, resolve_install_scope}; use upm_core::app::scope::{ScopeOverride, resolve_install_scope};
use aim_core::domain::app::InstallScope; use upm_core::domain::app::InstallScope;
#[test] #[test]
fn explicit_scope_override_beats_effective_user() { fn explicit_scope_override_beats_effective_user() {

View file

@ -1,5 +1,5 @@
use aim_core::domain::update::ParsedMetadataKind; use upm_core::domain::update::ParsedMetadataKind;
use aim_core::metadata::{MetadataDocument, parse_document}; use upm_core::metadata::{MetadataDocument, parse_document};
#[test] #[test]
fn unknown_document_returns_typed_warning_not_panic() { fn unknown_document_returns_typed_warning_not_panic() {

View file

@ -1,5 +1,5 @@
use aim_core::domain::update::ParsedMetadataKind; use upm_core::domain::update::ParsedMetadataKind;
use aim_core::metadata::{MetadataDocument, parse_document}; use upm_core::metadata::{MetadataDocument, parse_document};
#[test] #[test]
fn parses_latest_linux_yml_into_download_hints() { fn parses_latest_linux_yml_into_download_hints() {

View file

@ -1,5 +1,5 @@
use aim_core::domain::update::ParsedMetadataKind; use upm_core::domain::update::ParsedMetadataKind;
use aim_core::metadata::{MetadataDocument, parse_document}; use upm_core::metadata::{MetadataDocument, parse_document};
#[test] #[test]
fn parses_zsync_document_into_channel_hints() { fn parses_zsync_document_into_channel_hints() {

View file

@ -1,8 +1,8 @@
use aim_core::platform::capabilities::{probe_desktop_helpers, probe_writable_roots};
use aim_core::platform::distro::{DistroFamily, detect_distro_family};
use std::fs; use std::fs;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir; use tempfile::tempdir;
use upm_core::platform::capabilities::{probe_desktop_helpers, probe_writable_roots};
use upm_core::platform::distro::{DistroFamily, detect_distro_family};
#[test] #[test]
fn detects_fedora_family_from_os_release() { fn detects_fedora_family_from_os_release() {

View file

@ -0,0 +1,159 @@
use upm_core::app::add::{AddSecurityPolicy, build_add_plan_with_registered_providers};
use upm_core::app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry};
use upm_core::app::search::{SearchProvider, build_search_results_with_registered_providers};
use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
use upm_core::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
};
use upm_core::domain::update::{
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
};
use upm_core::source::github::FixtureGitHubTransport;
struct StubSearchProvider;
impl SearchProvider for StubSearchProvider {
fn search(
&self,
_query: &SearchQuery,
) -> Result<Vec<SearchResult>, upm_core::app::search::SearchProviderError> {
Ok(vec![SearchResult {
provider_id: "external-search".to_owned(),
display_name: "Firefox Nightly".to_owned(),
description: Some("Provided by external registry".to_owned()),
source_locator: "https://example.invalid/firefox-nightly".to_owned(),
install_query: "external/firefox-nightly".to_owned(),
canonical_locator: "external/firefox-nightly".to_owned(),
version: Some("2026.03.21".to_owned()),
install_status: SearchInstallStatus::Available,
}])
}
}
struct StubExternalAddProvider;
impl ExternalAddProvider for StubExternalAddProvider {
fn id(&self) -> &'static str {
"stub-appimage"
}
fn resolve(
&self,
source: &SourceRef,
) -> Result<Option<ExternalAddResolution>, upm_core::adapters::traits::AdapterError> {
Ok(
(source.kind == SourceKind::AppImageHub).then(|| ExternalAddResolution {
resolution: upm_core::adapters::traits::AdapterResolution {
source: SourceRef {
kind: SourceKind::AppImageHub,
locator: source.locator.clone(),
input_kind: SourceInputKind::AppImageHubShorthand,
normalized_kind: NormalizedSourceKind::AppImageHub,
canonical_locator: Some("2338455".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
},
release: ResolvedRelease {
version: "stable".to_owned(),
prerelease: false,
},
},
selected_artifact: ArtifactCandidate {
url: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
version: "stable".to_owned(),
arch: Some("x86_64".to_owned()),
trusted_checksum: None,
weak_checksum_md5: Some("deadbeef".to_owned()),
selection_reason: "provider-release".to_owned(),
},
update_strategy: UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::DirectAsset,
locator: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
},
display_name_hint: Some(
"Firefox by Mozilla - Official AppImage Edition".to_owned(),
),
}),
)
}
}
#[test]
fn build_search_results_with_registered_providers_uses_external_hits() {
let query = SearchQuery::new("firefox");
let search_provider = StubSearchProvider;
let providers = ProviderRegistry {
search_providers: vec![&search_provider],
external_add_providers: Vec::new(),
};
let results = build_search_results_with_registered_providers(&query, &[], &providers).unwrap();
let external_hit = results
.remote_hits
.iter()
.find(|hit| hit.provider_id == "external-search")
.unwrap();
assert_eq!(external_hit.install_query, "external/firefox-nightly");
assert!(
results
.remote_hits
.iter()
.all(|hit| hit.provider_id != "appimagehub")
);
}
#[test]
fn build_add_plan_with_registered_providers_requires_external_provider_for_appimagehub() {
let registry = ProviderRegistry::default();
let error = build_add_plan_with_registered_providers(
"appimagehub/2338455",
&FixtureGitHubTransport,
&registry,
AddSecurityPolicy::default(),
)
.unwrap_err();
assert!(matches!(
error,
upm_core::app::add::BuildAddPlanError::NoInstallableArtifact { .. }
));
}
#[test]
fn build_add_plan_with_registered_providers_delegates_appimagehub_like_sources() {
let provider = StubExternalAddProvider;
let registry = ProviderRegistry {
search_providers: Vec::new(),
external_add_providers: vec![&provider],
};
let plan = build_add_plan_with_registered_providers(
"appimagehub/2338455",
&FixtureGitHubTransport,
&registry,
AddSecurityPolicy::default(),
)
.unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::AppImageHub);
assert_eq!(
plan.resolution.source.canonical_locator.as_deref(),
Some("2338455")
);
assert_eq!(
plan.selected_artifact.url,
"https://downloads.example.invalid/firefox.AppImage"
);
assert_eq!(
plan.display_name_hint.as_deref(),
Some("Firefox by Mozilla - Official AppImage Edition")
);
}

View file

@ -1,5 +1,5 @@
use aim_core::app::query::resolve_query; use upm_core::app::query::resolve_query;
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind}; use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind};
#[test] #[test]
fn owner_repo_defaults_to_github() { fn owner_repo_defaults_to_github() {
@ -233,21 +233,21 @@ fn classifies_single_segment_sourceforge_release_download_with_query_as_candidat
fn rejects_malformed_gitlab_url() { fn rejects_malformed_gitlab_url() {
let error = resolve_query("https://gitlab.com/example").unwrap_err(); let error = resolve_query("https://gitlab.com/example").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]
fn rejects_unsupported_gitlab_url_shape() { fn rejects_unsupported_gitlab_url_shape() {
let error = resolve_query("https://gitlab.com/example/team-app/-/issues").unwrap_err(); let error = resolve_query("https://gitlab.com/example/team-app/-/issues").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]
fn rejects_unsupported_gitlab_nested_resource_url() { fn rejects_unsupported_gitlab_nested_resource_url() {
let error = resolve_query("https://gitlab.com/example/team-app/issues").unwrap_err(); let error = resolve_query("https://gitlab.com/example/team-app/issues").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]
@ -255,14 +255,14 @@ fn rejects_unsupported_gitlab_release_permalink_url() {
let error = resolve_query("https://gitlab.com/example/team-app/-/releases/permalink/latest") let error = resolve_query("https://gitlab.com/example/team-app/-/releases/permalink/latest")
.unwrap_err(); .unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]
fn rejects_unsupported_gitlab_issue_detail_url() { fn rejects_unsupported_gitlab_issue_detail_url() {
let error = resolve_query("https://gitlab.com/example/team-app/issues/1").unwrap_err(); let error = resolve_query("https://gitlab.com/example/team-app/issues/1").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]
@ -270,7 +270,7 @@ fn rejects_unsupported_gitlab_blob_url() {
let error = let error =
resolve_query("https://gitlab.com/example/team-app/blob/main/README.md").unwrap_err(); resolve_query("https://gitlab.com/example/team-app/blob/main/README.md").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]
@ -291,28 +291,28 @@ fn classifies_ambiguous_gitlab_deep_reserved_segment_as_candidate() {
fn rejects_unsupported_gitlab_packages_url() { fn rejects_unsupported_gitlab_packages_url() {
let error = resolve_query("https://gitlab.com/example/team-app/packages").unwrap_err(); let error = resolve_query("https://gitlab.com/example/team-app/packages").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]
fn rejects_malformed_sourceforge_url() { fn rejects_malformed_sourceforge_url() {
let error = resolve_query("https://sourceforge.net/projects/").unwrap_err(); let error = resolve_query("https://sourceforge.net/projects/").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]
fn rejects_malformed_appimagehub_shorthand() { fn rejects_malformed_appimagehub_shorthand() {
let error = resolve_query("appimagehub/firefox").unwrap_err(); let error = resolve_query("appimagehub/firefox").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]
fn rejects_unsupported_sourceforge_url_shape() { fn rejects_unsupported_sourceforge_url_shape() {
let error = resolve_query("https://sourceforge.net/projects/team-app/rss").unwrap_err(); let error = resolve_query("https://sourceforge.net/projects/team-app/rss").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]
@ -335,7 +335,7 @@ fn rejects_unsupported_sourceforge_folder_download_shape() {
let error = resolve_query("https://sourceforge.net/projects/team-app/files/releases/download") let error = resolve_query("https://sourceforge.net/projects/team-app/files/releases/download")
.unwrap_err(); .unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
} }
#[test] #[test]

View file

@ -1,5 +1,5 @@
use aim_core::registry::store::RegistryStore;
use tempfile::tempdir; use tempfile::tempdir;
use upm_core::registry::store::RegistryStore;
#[test] #[test]
fn registry_round_trips_app_records() { fn registry_round_trips_app_records() {
@ -13,28 +13,28 @@ fn registry_round_trips_app_records() {
fn registry_round_trips_update_strategy_and_alternates() { fn registry_round_trips_update_strategy_and_alternates() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml")); let store = RegistryStore::new(dir.path().join("registry.toml"));
let registry = aim_core::registry::model::Registry { let registry = upm_core::registry::model::Registry {
version: 1, version: 1,
apps: vec![aim_core::domain::app::AppRecord { apps: vec![upm_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(), stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(), display_name: "T3 Code".to_owned(),
source_input: Some("pingdotgg/t3code".to_owned()), source_input: Some("pingdotgg/t3code".to_owned()),
source: None, source: None,
installed_version: Some("0.0.11".to_owned()), installed_version: Some("0.0.11".to_owned()),
update_strategy: Some(aim_core::domain::update::UpdateStrategy { update_strategy: Some(upm_core::domain::update::UpdateStrategy {
preferred: aim_core::domain::update::ChannelPreference { preferred: upm_core::domain::update::ChannelPreference {
kind: aim_core::domain::update::UpdateChannelKind::DirectAsset, kind: upm_core::domain::update::UpdateChannelKind::DirectAsset,
locator: "https://example.test/app.AppImage".to_owned(), locator: "https://example.test/app.AppImage".to_owned(),
reason: "install-origin-match".to_owned(), reason: "install-origin-match".to_owned(),
}, },
alternates: vec![ alternates: vec![
aim_core::domain::update::ChannelPreference { upm_core::domain::update::ChannelPreference {
kind: aim_core::domain::update::UpdateChannelKind::GitHubReleases, kind: upm_core::domain::update::UpdateChannelKind::GitHubReleases,
locator: "pingdotgg/t3code".to_owned(), locator: "pingdotgg/t3code".to_owned(),
reason: "heuristic-match".to_owned(), reason: "heuristic-match".to_owned(),
}, },
aim_core::domain::update::ChannelPreference { upm_core::domain::update::ChannelPreference {
kind: aim_core::domain::update::UpdateChannelKind::ElectronBuilder, kind: upm_core::domain::update::UpdateChannelKind::ElectronBuilder,
locator: "https://example.test/latest-linux.yml".to_owned(), locator: "https://example.test/latest-linux.yml".to_owned(),
reason: "metadata-guided".to_owned(), reason: "metadata-guided".to_owned(),
}, },
@ -57,9 +57,9 @@ fn registry_round_trips_update_strategy_and_alternates() {
fn registry_round_trips_install_metadata() { fn registry_round_trips_install_metadata() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml")); let store = RegistryStore::new(dir.path().join("registry.toml"));
let registry = aim_core::registry::model::Registry { let registry = upm_core::registry::model::Registry {
version: 1, version: 1,
apps: vec![aim_core::domain::app::AppRecord { apps: vec![upm_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(), stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(), display_name: "T3 Code".to_owned(),
source_input: Some("pingdotgg/t3code".to_owned()), source_input: Some("pingdotgg/t3code".to_owned()),
@ -67,13 +67,13 @@ fn registry_round_trips_install_metadata() {
installed_version: Some("0.0.11".to_owned()), installed_version: Some("0.0.11".to_owned()),
update_strategy: None, update_strategy: None,
metadata: Vec::new(), metadata: Vec::new(),
install: Some(aim_core::domain::app::InstallMetadata { install: Some(upm_core::domain::app::InstallMetadata {
scope: aim_core::domain::app::InstallScope::User, scope: upm_core::domain::app::InstallScope::User,
payload_path: Some( payload_path: Some(
"/tmp/install-home/.local/lib/aim/appimages/t3code.AppImage".to_owned(), "/tmp/install-home/.local/lib/upm/appimages/t3code.AppImage".to_owned(),
), ),
desktop_entry_path: Some( desktop_entry_path: Some(
"/tmp/install-home/.local/share/applications/aim-t3code.desktop".to_owned(), "/tmp/install-home/.local/share/applications/upm-t3code.desktop".to_owned(),
), ),
icon_path: Some( icon_path: Some(
"/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png" "/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png"
@ -87,14 +87,14 @@ fn registry_round_trips_install_metadata() {
let loaded = store.load().unwrap(); let loaded = store.load().unwrap();
let install = loaded.apps[0].install.as_ref().unwrap(); let install = loaded.apps[0].install.as_ref().unwrap();
assert_eq!(install.scope, aim_core::domain::app::InstallScope::User); assert_eq!(install.scope, upm_core::domain::app::InstallScope::User);
assert_eq!( assert_eq!(
install.payload_path.as_deref(), install.payload_path.as_deref(),
Some("/tmp/install-home/.local/lib/aim/appimages/t3code.AppImage") Some("/tmp/install-home/.local/lib/upm/appimages/t3code.AppImage")
); );
assert_eq!( assert_eq!(
install.desktop_entry_path.as_deref(), install.desktop_entry_path.as_deref(),
Some("/tmp/install-home/.local/share/applications/aim-t3code.desktop") Some("/tmp/install-home/.local/share/applications/upm-t3code.desktop")
); );
assert_eq!( assert_eq!(
install.icon_path.as_deref(), install.icon_path.as_deref(),
@ -106,18 +106,18 @@ fn registry_round_trips_install_metadata() {
fn registry_round_trips_source_identity_for_new_provider_kinds() { fn registry_round_trips_source_identity_for_new_provider_kinds() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml")); let store = RegistryStore::new(dir.path().join("registry.toml"));
let registry = aim_core::registry::model::Registry { let registry = upm_core::registry::model::Registry {
version: 1, version: 1,
apps: vec![ apps: vec![
aim_core::domain::app::AppRecord { upm_core::domain::app::AppRecord {
stable_id: "example-team-app".to_owned(), stable_id: "example-team-app".to_owned(),
display_name: "team-app".to_owned(), display_name: "team-app".to_owned(),
source_input: Some("https://gitlab.com/example/team-app".to_owned()), source_input: Some("https://gitlab.com/example/team-app".to_owned()),
source: Some(aim_core::domain::source::SourceRef { source: Some(upm_core::domain::source::SourceRef {
kind: aim_core::domain::source::SourceKind::GitLab, kind: upm_core::domain::source::SourceKind::GitLab,
locator: "https://gitlab.com/example/team-app".to_owned(), locator: "https://gitlab.com/example/team-app".to_owned(),
input_kind: aim_core::domain::source::SourceInputKind::GitLabUrl, input_kind: upm_core::domain::source::SourceInputKind::GitLabUrl,
normalized_kind: aim_core::domain::source::NormalizedSourceKind::GitLab, normalized_kind: upm_core::domain::source::NormalizedSourceKind::GitLab,
canonical_locator: Some("example/team-app".to_owned()), canonical_locator: Some("example/team-app".to_owned()),
requested_tag: None, requested_tag: None,
requested_asset_name: None, requested_asset_name: None,
@ -128,18 +128,18 @@ fn registry_round_trips_source_identity_for_new_provider_kinds() {
metadata: Vec::new(), metadata: Vec::new(),
install: None, install: None,
}, },
aim_core::domain::app::AppRecord { upm_core::domain::app::AppRecord {
stable_id: "team-app".to_owned(), stable_id: "team-app".to_owned(),
display_name: "team-app".to_owned(), display_name: "team-app".to_owned(),
source_input: Some( source_input: Some(
"https://sourceforge.net/projects/team-app/files/latest/download".to_owned(), "https://sourceforge.net/projects/team-app/files/latest/download".to_owned(),
), ),
source: Some(aim_core::domain::source::SourceRef { source: Some(upm_core::domain::source::SourceRef {
kind: aim_core::domain::source::SourceKind::SourceForge, kind: upm_core::domain::source::SourceKind::SourceForge,
locator: "https://sourceforge.net/projects/team-app/files/latest/download" locator: "https://sourceforge.net/projects/team-app/files/latest/download"
.to_owned(), .to_owned(),
input_kind: aim_core::domain::source::SourceInputKind::SourceForgeUrl, input_kind: upm_core::domain::source::SourceInputKind::SourceForgeUrl,
normalized_kind: aim_core::domain::source::NormalizedSourceKind::SourceForge, normalized_kind: upm_core::domain::source::NormalizedSourceKind::SourceForge,
canonical_locator: Some("team-app".to_owned()), canonical_locator: Some("team-app".to_owned()),
requested_tag: None, requested_tag: None,
requested_asset_name: None, requested_asset_name: None,
@ -150,15 +150,15 @@ fn registry_round_trips_source_identity_for_new_provider_kinds() {
metadata: Vec::new(), metadata: Vec::new(),
install: None, install: None,
}, },
aim_core::domain::app::AppRecord { upm_core::domain::app::AppRecord {
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(), stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
display_name: "https://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_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(aim_core::domain::source::SourceRef { source: Some(upm_core::domain::source::SourceRef {
kind: aim_core::domain::source::SourceKind::DirectUrl, kind: upm_core::domain::source::SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(), locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: aim_core::domain::source::SourceInputKind::DirectUrl, input_kind: upm_core::domain::source::SourceInputKind::DirectUrl,
normalized_kind: aim_core::domain::source::NormalizedSourceKind::DirectUrl, normalized_kind: upm_core::domain::source::NormalizedSourceKind::DirectUrl,
canonical_locator: None, canonical_locator: None,
requested_tag: None, requested_tag: None,
requested_asset_name: None, requested_asset_name: None,
@ -213,9 +213,9 @@ fn registry_save_is_atomic_and_cleans_up_temp_file() {
let store = RegistryStore::new(registry_path.clone()); let store = RegistryStore::new(registry_path.clone());
store store
.save(&aim_core::registry::model::Registry { .save(&upm_core::registry::model::Registry {
version: 1, version: 1,
apps: vec![aim_core::domain::app::AppRecord { apps: vec![upm_core::domain::app::AppRecord {
stable_id: "bat".to_owned(), stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(), display_name: "Bat".to_owned(),
source_input: None, source_input: None,
@ -242,7 +242,7 @@ fn registry_exclusive_lock_rejects_second_mutator() {
assert!(matches!( assert!(matches!(
error, error,
aim_core::registry::store::RegistryStoreError::LockUnavailable upm_core::registry::store::RegistryStoreError::LockUnavailable
)); ));
} }
@ -251,9 +251,9 @@ fn registry_mutate_exclusive_reloads_and_writes_latest_state() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml")); let store = RegistryStore::new(dir.path().join("registry.toml"));
store store
.save(&aim_core::registry::model::Registry { .save(&upm_core::registry::model::Registry {
version: 1, version: 1,
apps: vec![aim_core::domain::app::AppRecord { apps: vec![upm_core::domain::app::AppRecord {
stable_id: "bat".to_owned(), stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(), display_name: "Bat".to_owned(),
source_input: None, source_input: None,
@ -268,7 +268,7 @@ fn registry_mutate_exclusive_reloads_and_writes_latest_state() {
store store
.mutate_exclusive(|registry| { .mutate_exclusive(|registry| {
registry.apps.push(aim_core::domain::app::AppRecord { registry.apps.push(upm_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(), stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(), display_name: "T3 Code".to_owned(),
source_input: None, source_input: None,

View file

@ -1,13 +1,13 @@
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
use aim_core::app::list::build_list_rows;
use aim_core::app::progress::{OperationEvent, OperationStage};
use aim_core::app::remove::{
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use std::path::Path; use std::path::Path;
use tempfile::tempdir; use tempfile::tempdir;
use upm_core::app::interaction::{InteractionKind, InteractionRequest};
use upm_core::app::list::build_list_rows;
use upm_core::app::progress::{OperationEvent, OperationStage};
use upm_core::app::remove::{
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
};
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
#[test] #[test]
fn remove_flow_rejects_unknown_app_names() { fn remove_flow_rejects_unknown_app_names() {
@ -74,7 +74,7 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
assert_eq!( assert_eq!(
error, error,
aim_core::app::remove::ResolveRegisteredAppError::Ambiguous { upm_core::app::remove::ResolveRegisteredAppError::Ambiguous {
request: InteractionRequest { request: InteractionRequest {
key: "select-registered-app".to_owned(), key: "select-registered-app".to_owned(),
kind: InteractionKind::SelectRegisteredApp { kind: InteractionKind::SelectRegisteredApp {
@ -98,8 +98,8 @@ fn removal_plan_prefers_persisted_install_metadata_paths() {
metadata: Vec::new(), metadata: Vec::new(),
install: Some(InstallMetadata { install: Some(InstallMetadata {
scope: InstallScope::System, scope: InstallScope::System,
payload_path: Some("/opt/aim/appimages/bat.AppImage".to_owned()), payload_path: Some("/opt/upm/appimages/bat.AppImage".to_owned()),
desktop_entry_path: Some("/usr/share/applications/aim-bat.desktop".to_owned()), desktop_entry_path: Some("/usr/share/applications/upm-bat.desktop".to_owned()),
icon_path: Some("/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned()), icon_path: Some("/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned()),
}), }),
}; };
@ -110,8 +110,8 @@ fn removal_plan_prefers_persisted_install_metadata_paths() {
assert_eq!( assert_eq!(
plan.artifact_paths, plan.artifact_paths,
vec![ vec![
"/opt/aim/appimages/bat.AppImage".to_owned(), "/opt/upm/appimages/bat.AppImage".to_owned(),
"/usr/share/applications/aim-bat.desktop".to_owned(), "/usr/share/applications/upm-bat.desktop".to_owned(),
"/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned(), "/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
] ]
); );
@ -135,8 +135,8 @@ fn removal_plan_falls_back_to_derived_managed_user_paths() {
assert_eq!( assert_eq!(
plan.artifact_paths, plan.artifact_paths,
vec![ vec![
"/home/test/.local/lib/aim/appimages/bat.AppImage".to_owned(), "/home/test/.local/lib/upm/appimages/bat.AppImage".to_owned(),
"/home/test/.local/share/applications/aim-bat.desktop".to_owned(), "/home/test/.local/share/applications/upm-bat.desktop".to_owned(),
"/home/test/.local/share/icons/hicolor/256x256/apps/bat.png".to_owned(), "/home/test/.local/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
] ]
); );
@ -158,7 +158,7 @@ fn remove_flow_reports_resolution_and_cleanup_events() {
payload_path: Some( payload_path: Some(
install_home install_home
.path() .path()
.join(".local/lib/aim/appimages/bat.AppImage") .join(".local/lib/upm/appimages/bat.AppImage")
.display() .display()
.to_string(), .to_string(),
), ),

View file

@ -1,10 +1,10 @@
use aim_core::app::search::{ use upm_core::app::search::{
GitHubSearchProvider, SearchProvider, SearchProviderError, build_search_results_with, GitHubSearchProvider, SearchProvider, SearchProviderError, build_search_results_with,
}; };
use aim_core::domain::app::AppRecord; use upm_core::domain::app::AppRecord;
use aim_core::domain::search::{SearchInstallStatus, SearchQuery}; use upm_core::domain::search::{SearchInstallStatus, SearchQuery};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef}; use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::source::github::{FixtureGitHubTransport, search_github_repositories_with}; use upm_core::source::github::{FixtureGitHubTransport, search_github_repositories_with};
#[test] #[test]
fn github_fixtures_return_normalized_remote_hits() { fn github_fixtures_return_normalized_remote_hits() {
@ -206,7 +206,7 @@ impl SearchProvider for FailingProvider {
fn search( fn search(
&self, &self,
_query: &SearchQuery, _query: &SearchQuery,
) -> Result<Vec<aim_core::domain::search::SearchResult>, SearchProviderError> { ) -> Result<Vec<upm_core::domain::search::SearchResult>, SearchProviderError> {
Err(SearchProviderError::new("github", "fixture rate limit")) Err(SearchProviderError::new("github", "fixture rate limit"))
} }
} }

View file

@ -1,12 +1,12 @@
use aim_core::app::show::{build_show_result, build_show_result_with}; use upm_core::app::show::{build_show_result, build_show_result_with};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope}; use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::show::{ShowResult, ShowResultError}; use upm_core::domain::show::{ShowResult, ShowResultError};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef}; use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::domain::update::{ use upm_core::domain::update::{
ChannelPreference, MetadataHints, ParsedMetadata, ParsedMetadataKind, UpdateChannelKind, ChannelPreference, MetadataHints, ParsedMetadata, ParsedMetadataKind, UpdateChannelKind,
UpdateStrategy, UpdateStrategy,
}; };
use aim_core::source::github::FixtureGitHubTransport; use upm_core::source::github::FixtureGitHubTransport;
#[test] #[test]
fn exact_installed_match_returns_installed_details() { fn exact_installed_match_returns_installed_details() {
@ -48,8 +48,8 @@ fn exact_installed_match_returns_installed_details() {
install: Some(InstallMetadata { install: Some(InstallMetadata {
scope: InstallScope::User, scope: InstallScope::User,
payload_path: Some("/tmp/bat.AppImage".to_owned()), payload_path: Some("/tmp/bat.AppImage".to_owned()),
desktop_entry_path: Some("/tmp/aim-bat.desktop".to_owned()), desktop_entry_path: Some("/tmp/upm-bat.desktop".to_owned()),
icon_path: Some("/tmp/aim-bat.png".to_owned()), icon_path: Some("/tmp/upm-bat.png".to_owned()),
}), }),
}]; }];
@ -180,7 +180,7 @@ fn remote_show_projects_tracking_preference_interaction() {
ShowResult::Remote(remote) => { ShowResult::Remote(remote) => {
assert!(remote.interactions.iter().any(|interaction| matches!( assert!(remote.interactions.iter().any(|interaction| matches!(
interaction, interaction,
aim_core::domain::show::RemoteInteractionSummary::ChooseTrackingPreference { .. } upm_core::domain::show::RemoteInteractionSummary::ChooseTrackingPreference { .. }
))); )));
} }
other => panic!("expected remote result, got {other:?}"), other => panic!("expected remote result, got {other:?}"),

View file

@ -1,16 +1,16 @@
use aim_core::app::add::AddSecurityPolicy;
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage};
use aim_core::app::update::{
build_update_plan, execute_updates, execute_updates_with_reporter,
execute_updates_with_reporter_and_policy,
};
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::fs;
use std::sync::Mutex; use std::sync::Mutex;
use tempfile::tempdir; use tempfile::tempdir;
use upm_core::app::add::AddSecurityPolicy;
use upm_core::app::progress::{NoopReporter, OperationEvent, OperationStage};
use upm_core::app::update::{
build_update_plan, execute_updates, execute_updates_with_reporter,
execute_updates_with_reporter_and_policy,
};
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use upm_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
use upm_core::integration::paths::managed_appimage_path;
static ENV_LOCK: Mutex<()> = Mutex::new(()); static ENV_LOCK: Mutex<()> = Mutex::new(());
@ -189,7 +189,7 @@ fn update_execution_rebuilds_gitlab_source_without_rewriting_origin() {
let install_home = tempdir().unwrap(); let install_home = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let previous = AppRecord { let previous = AppRecord {
@ -253,7 +253,7 @@ fn update_execution_rebuilds_sourceforge_release_folder_without_rewriting_origin
let install_home = tempdir().unwrap(); let install_home = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let previous = AppRecord { let previous = AppRecord {
@ -323,7 +323,7 @@ fn direct_http_updates_are_rejected_by_default() {
let install_home = tempdir().unwrap(); let install_home = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let previous = AppRecord { let previous = AppRecord {
@ -357,7 +357,7 @@ fn direct_http_updates_are_rejected_by_default() {
assert_eq!(result.failed_count(), 1); assert_eq!(result.failed_count(), 1);
assert!(matches!( assert!(matches!(
&result.items[0].status, &result.items[0].status,
aim_core::domain::update::UpdateExecutionStatus::Failed { reason } upm_core::domain::update::UpdateExecutionStatus::Failed { reason }
if reason.contains("InsecureHttpSource") if reason.contains("InsecureHttpSource")
)); ));
} }
@ -370,7 +370,7 @@ fn direct_http_updates_can_be_allowed_by_policy() {
let install_home = tempdir().unwrap(); let install_home = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let previous = AppRecord { let previous = AppRecord {
@ -417,7 +417,7 @@ fn update_execution_uses_stored_sourceforge_releases_root_for_file_like_inputs()
let install_home = tempdir().unwrap(); let install_home = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
} }
let previous = AppRecord { let previous = AppRecord {
@ -482,7 +482,7 @@ fn failed_update_restores_previous_payload_contents() {
let install_home = tempdir().unwrap(); let install_home = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("DISPLAY", ":99"); std::env::set_var("DISPLAY", ":99");
std::env::set_var("XDG_CURRENT_DESKTOP", "test"); std::env::set_var("XDG_CURRENT_DESKTOP", "test");
} }
@ -536,7 +536,7 @@ fn successful_update_removes_rollback_staging_directory() {
let install_home = tempdir().unwrap(); let install_home = tempdir().unwrap();
unsafe { unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
std::env::remove_var("DISPLAY"); std::env::remove_var("DISPLAY");
std::env::remove_var("WAYLAND_DISPLAY"); std::env::remove_var("WAYLAND_DISPLAY");
std::env::remove_var("XDG_CURRENT_DESKTOP"); std::env::remove_var("XDG_CURRENT_DESKTOP");
@ -578,7 +578,7 @@ fn successful_update_removes_rollback_staging_directory() {
assert!( assert!(
!install_home !install_home
.path() .path()
.join(".local/share/aim/rollback") .join(".local/share/upm/rollback")
.exists() .exists()
); );
} }

View file

@ -1,5 +1,5 @@
[package] [package]
name = "aim-cli" name = "upm"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@ -8,7 +8,7 @@ license.workspace = true
path = "src/lib.rs" path = "src/lib.rs"
[[bin]] [[bin]]
name = "aim" name = "upm"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
@ -21,7 +21,8 @@ libc.workspace = true
ratatui.workspace = true ratatui.workspace = true
serde.workspace = true serde.workspace = true
toml.workspace = true toml.workspace = true
aim-core = { path = "../aim-core" } upm-appimage = { path = "../upm-appimage" }
upm-core = { path = "../upm-core" }
[dev-dependencies] [dev-dependencies]
assert_cmd.workspace = true assert_cmd.workspace = true

View file

@ -1,8 +1,8 @@
use clap::Parser; use clap::Parser;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command(name = "aim")] #[command(name = "upm")]
#[command(about = "AppImage Manager")] #[command(about = "Universal Package Manager")]
pub struct Cli { pub struct Cli {
#[arg(global = true, long = "system", conflicts_with = "user")] #[arg(global = true, long = "system", conflicts_with = "user")]
pub system: bool, pub system: bool,

View file

@ -52,10 +52,10 @@ struct FileThemeConfig {
impl AppConfig { impl AppConfig {
pub fn load() -> LoadedConfig { pub fn load() -> LoadedConfig {
let system_path = Some(PathBuf::from("/etc/aim/config.toml")); let system_path = Some(PathBuf::from("/etc/upm/config.toml"));
let user_path = env::var_os("HOME") let user_path = env::var_os("HOME")
.map(PathBuf::from) .map(PathBuf::from)
.map(|home| home.join(".config/aim/config.toml")); .map(|home| home.join(".config/upm/config.toml"));
Self::load_from_paths(system_path.as_deref(), user_path.as_deref()) Self::load_from_paths(system_path.as_deref(), user_path.as_deref())
} }

Some files were not shown because too many files have changed in this diff Show more