diff --git a/.architecture/overview.md b/.architecture/overview.md index 478b8ae..bd0a091 100644 --- a/.architecture/overview.md +++ b/.architecture/overview.md @@ -2,27 +2,92 @@ ## Workspace Shape -`upm` is a Rust workspace with three main crates: +`upm` is a Rust workspace with three main crates today and a fourth planned frontend: -- `crates/upm-core`: source normalization, add/update orchestration, registry persistence, install policies, desktop integration, and the provider-composition seam. -- `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. +- `crates/upm-core`: the application layer for `upm`. It owns command orchestration, module contracts, module registry and composition, registry persistence, install policies, desktop integration, and the unified frontend-facing API that both the CLI and a future GUI will call. +- `crates/upm`: the CLI frontend over `upm-core`. It handles argument parsing, config loading, terminal UX, prompting, progress reporting, summary rendering, and config-driven module presentation. +- `crates/upm-appimage`: the AppImage package-manager module. It should own AppImage-specific acquisition backends, artifact selection, and install-resolution behavior. +- `crates/upm-ui` (planned): a GUI frontend over `upm-core`, not a second application layer. -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. +The intended split is strict: + +- `upm-core` is effectively the application +- `upm` is one frontend over that application +- `upm-ui` will be another frontend over that application +- package-manager modules own their own implementation detail and speak to `upm-core` through normalized traits + +That keeps frontend-agnostic logic in `upm-core`, makes a future GUI a first-class consumer instead of a later retrofit, and prevents frontend layers from accumulating package-manager-specific behavior. + +## Application Boundary + +The architectural boundary is: + +- `upm` may know which modules exist for configuration, enablement, disablement, priority, and display +- `upm-ui` should operate under the same rule as the CLI: it talks to `upm-core`, not directly to modules +- `upm` must not talk directly to a package-manager module or implement module-specific logic +- `upm-core` owns the unified application interface used by the CLI now and a GUI later +- `upm-core` owns module registration, composition, enablement checks, and request fan-out +- `upm-core` fans requests out to enabled modules and aggregates normalized results +- each module owns its own internal backends, source quirks, artifact selection, and provider-specific rules + +In practical terms, `upm-core` is where the product behavior lives. The CLI should remain replaceable. + +## Public API Shape + +`upm-core` should expose one high-level application facade to frontend crates. + +- the public boundary should be an application-facing type such as `UpmApp` +- the facade should present operations like search, add, show, update, remove, and config management in product terms +- frontends should not compose lower-level orchestration services themselves + +That public facade should stay thin. The internal implementation in `upm-core` can and should be split into smaller services such as: + +- module registry and module loading +- search orchestration +- add planning and execution +- show resolution +- update planning and execution +- configuration and state services + +This gives both frontends one stable application boundary without turning the facade into a god object. The orchestration depth stays inside `upm-core`, where it belongs. + +## Module Tree + +The intended tree is: + +- `upm-core` + - public application facade + - internal orchestration services + - module registry and composition + - normalized contracts for package-manager modules +- frontend crates + - `upm` for CLI concerns only + - `upm-ui` for GUI concerns only +- module crates + - `upm-appimage` + - AppImageHub backend + - GitHub-backed AppImage acquisition + - GitLab-backed AppImage acquisition + - SourceForge-backed AppImage acquisition + - direct AppImage URL handling + - AppImage-specific artifact and metadata rules + +The important constraint is that the top layer understands package-manager modules, not the inner mechanics of how each module finds or resolves artifacts. ## Core Flow The main execution path is: 1. Parse CLI input and load runtime config in `upm`. -2. Assemble a `ProviderRegistry` in `crates/upm/src/providers.rs`. -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. -5. Verify integrity metadata when available. -6. Commit the payload into the managed install location. -7. Write desktop integration artifacts and refresh helper caches. -8. Persist registry state atomically. +2. Call the unified application facade in `upm-core`. +3. Let `upm-core` route the request into internal orchestration services. +4. Let those services select enabled modules and fan the request out through normalized module traits. +5. Aggregate normalized results into an add, show, update, search, or remove flow. +6. Download the selected AppImage into a staged path when the chosen module requires it. +7. Verify integrity metadata when available. +8. Commit the payload into the managed install location. +9. Write desktop integration artifacts and refresh helper caches. +10. Persist registry state atomically. ## Source And Provider Model @@ -35,7 +100,9 @@ Supported source classes currently include: - direct URLs - local file imports -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. +Core orchestration and normalized module contracts live in `crates/upm-core`. Package-manager-specific behavior belongs in module crates. + +For the AppImage module, that means `crates/upm-appimage` is the package-manager boundary and should grow to own AppImage-specific backing sources internally. `upm-core` should coordinate the module through normalized traits, not absorb AppImageHub, GitHub-backed AppImage discovery, GitLab-backed AppImage discovery, SourceForge-backed AppImage discovery, or direct AppImage URL handling as first-class application concepts. ## Runtime Interface diff --git a/.architecture/roadmap.md b/.architecture/roadmap.md index 4c76526..a4cb431 100644 --- a/.architecture/roadmap.md +++ b/.architecture/roadmap.md @@ -2,9 +2,9 @@ ## 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 project is evolving from a focused AppImage manager into `upm`, a modular universal package manager. The target system manages multiple package-manager modules through a shared headless application core, keeps frontend crates 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 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 beginning its transition into `upm-appimage` instead of remaining 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. @@ -14,7 +14,8 @@ The near-term goal is a Linux-first platform with honest cross-platform architec - `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 `upm` crate becomes a thin CLI frontend over `upm-core`. +- A future `upm-ui` crate will become a GUI frontend over the same `upm-core` application boundary. - 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. @@ -29,9 +30,10 @@ The near-term goal is a Linux-first platform with honest cross-platform architec 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. +- `upm-ui`: future GUI frontend over the same application core. +- `upm-core`: headless application layer, public application facade, internal orchestration services, module registry, state model, declarative sync engine, ranking, policies, and frontend-agnostic APIs. +- `upm-appimage`: AppImage package-manager module extracted from the current `aim-core` implementation. +- future module crates: `upm-pacman`, `upm-aur`, `upm-flatpak`, `upm-cargo`, `upm-npm`, and later macOS or Windows-specific modules. ### Module Model @@ -42,7 +44,9 @@ UPM should stay modular in both code and packaging: - 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. +This means module capabilities, discovery, search, install, remove, inspect, and sync behavior need stable interfaces in `upm-core` rather than package-manager-specific branching in the CLI or GUI. + +The public API shape should be one application facade in `upm-core`, backed by smaller internal services. Frontends should call the facade; they should not compose lower-level services or talk directly to modules. ### State Model @@ -65,7 +69,7 @@ Goals: - 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` +- remove direct AppImage composition from the CLI and move module composition into `upm-core` - preserve current AppImage functionality and tests during the move Exit criteria: @@ -76,18 +80,20 @@ Exit criteria: ### Milestone 1: AppImage On The New Core -Make the current AppImage implementation the first real module on the modular architecture. +Make the current AppImage implementation the first real package-manager 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 +- establish `upm-core` as the sole application boundary for CLI and future GUI frontends +- validate the module contract using AppImage as the reference implementation +- move AppImage-specific acquisition backends behind `upm-appimage` +- prove the CLI can treat AppImage as just one enabled module Exit criteria: - AppImage support is no longer special-cased as the whole product -- provider registration and capability discovery exist in `upm-core` +- CLI command paths run through the `upm-core` application facade +- AppImage acquisition minutia no longer lives as top-level application concepts in `upm-core` ### Milestone 2: Linux Native Sources @@ -175,8 +181,8 @@ Exit criteria: The following are explicitly not required to complete Phase 2: -- full macOS provider implementation -- Windows provider implementation +- full macOS module implementation +- Windows module implementation - GUI frontend delivery - forcing strict config-authoritative reconciliation before provider behavior is stable - shipping every conceivable Linux package manager in the first expansion @@ -199,7 +205,7 @@ That means: Implementation plans should follow this order: 1. rename and crate extraction -2. provider API definition and AppImage migration onto `upm-core` +2. application facade definition and AppImage migration behind a real module boundary 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 diff --git a/.plans/014-appimage-on-new-core/2026-03-21-appimage-on-new-core-implementation-plan.md b/.plans/014-appimage-on-new-core/2026-03-21-appimage-on-new-core-implementation-plan.md index b356d67..5f5b02d 100644 --- a/.plans/014-appimage-on-new-core/2026-03-21-appimage-on-new-core-implementation-plan.md +++ b/.plans/014-appimage-on-new-core/2026-03-21-appimage-on-new-core-implementation-plan.md @@ -2,57 +2,59 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Validate AppImage as the first real provider module by making `upm-core` treat provider composition as the normal path for capability discovery, remote show resolution, and update execution. +**Goal:** Make `upm-appimage` the first real package-manager module by moving AppImage-specific acquisition paths behind a module boundary and exposing a single application facade from `upm-core` to the CLI and future GUI. -**Architecture:** Keep AppImage-specific transport and resolution logic in `upm-appimage`. Extend `upm-core` only with generic provider-registry contracts and plumbing so the CLI can compose providers once and reuse that composition across `search`, `add`, `show`, and `update`. Avoid introducing speculative provider hooks for `remove` or `list`; those flows are already generic and should stay that way until a real provider needs more surface area. +**Architecture:** `upm-core` becomes the application boundary. It should own a public facade, internal orchestration services, and module registration and composition. `upm` stays a thin frontend and must stop composing AppImage behavior directly. `upm-appimage` becomes the AppImage package-manager module and should absorb AppImageHub plus the other AppImage-producing backends that are still modeled as top-level source concepts in the core. **Tech Stack:** Rust workspace, `upm`, `upm-core`, `upm-appimage`, Cargo integration tests, fixture-backed provider tests, CLI end-to-end tests. --- -### Task 1: Add capability discovery to `ProviderRegistry` +### Task 1: Define the public application facade in `upm-core` **Files:** -- Modify: `crates/upm-core/src/app/providers.rs` +- Modify: `crates/upm-core/src/app/` - Modify: `crates/upm-core/src/lib.rs` -- Test: `crates/upm-core/tests/provider_registry.rs` +- Test: `crates/upm-core/tests/` -**Step 1: Write the failing capability-discovery expectations** +**Step 1: Write the failing facade expectations** -Extend `crates/upm-core/tests/provider_registry.rs` to assert: +Add focused tests proving that: -- the registry can report which provider ids are registered -- the registry can report whether a provider supports search and/or external add -- empty registries report no capabilities +- `upm-core` exposes one public application-facing entrypoint for frontend consumers +- that entrypoint can be constructed without the CLI owning module composition +- the public surface delegates to internal services instead of exposing module wiring details -Keep the expectations generic. Do not encode AppImage-only behavior into the registry API. +Keep the assertions about API shape and ownership, not AppImage specifics. -**Step 2: Run the focused test to verify failure** +**Step 2: Run the focused tests to verify failure** Run: ```bash -cargo test --package upm-core --test provider_registry +cargo test --package upm-core ``` -Expected: FAIL because `ProviderRegistry` is still only a passive bag of references. +Expected: FAIL because the current public surface still assumes narrower provider plumbing and does not expose the intended facade cleanly. -**Step 3: Implement minimal capability discovery** +**Step 3: Implement the minimal facade** -Update `crates/upm-core/src/app/providers.rs` so `ProviderRegistry` exposes a small, stable query surface such as: +Introduce or reshape the public API so `upm-core` exposes a single high-level application facade. -- a way to enumerate registered provider ids -- a way to report capabilities for a provider id -- a small capability record rather than operation-specific booleans scattered around callers +The public boundary should: -Do not add plugin loading, global registries, or dynamic configuration yet. +- represent product operations such as search, add, show, update, remove, and config handling +- hide module registry and orchestration details from frontends +- stay thin and delegate work to internal services -**Step 4: Run the focused test to verify pass** +Do not add dynamic plugin loading yet. + +**Step 4: Run the focused tests to verify pass** Run: ```bash -cargo test --package upm-core --test provider_registry +cargo test --package upm-core ``` Expected: PASS. @@ -60,113 +62,115 @@ Expected: PASS. **Step 5: Commit** ```bash -git add crates/upm-core/src/app/providers.rs crates/upm-core/src/lib.rs crates/upm-core/tests/provider_registry.rs -git commit -m "feat: add provider capability discovery" +git add crates/upm-core/src crates/upm-core/tests +git commit -m "feat: add application facade to upm-core" ``` -### Task 2: Route remote `show` through registered providers +### Task 2: Move module composition out of the CLI and into `upm-core` **Files:** +- Modify: `crates/upm-core/src/app/` +- Modify: `crates/upm/src/lib.rs` +- Modify: `crates/upm/src/providers.rs` +- Test: `crates/upm-core/tests/` +- Test: `crates/upm/tests/end_to_end_cli.rs` + +**Step 1: Write the failing ownership expectations** + +Add coverage proving that: + +- the CLI does not assemble AppImage module composition directly +- the application facade can build or receive module composition internally +- CLI command paths still behave the same through the new boundary + +Prefer one core ownership test and one CLI integration test. + +**Step 2: Run the focused tests to verify failure** + +Run: + +```bash +cargo test --package upm --test end_to_end_cli +``` + +Expected: FAIL because the CLI still owns direct provider assembly. + +**Step 3: Move composition into `upm-core`** + +Update the architecture so: + +- `upm-core` owns module registration and composition +- the CLI constructs the application facade rather than AppImage-specific registries +- direct module composition in `crates/upm/src/providers.rs` is removed or reduced to generic application bootstrapping + +Keep CLI UX, rendering, and summary formatting unchanged. + +**Step 4: Run the focused tests to verify pass** + +Run: + +```bash +cargo test --package upm --test end_to_end_cli +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/upm-core/src crates/upm/src/lib.rs crates/upm/src/providers.rs crates/upm/tests/end_to_end_cli.rs +git commit -m "refactor: move module composition into upm-core" +``` + +### Task 3: Turn `upm-appimage` into the AppImage package-manager boundary + +**Files:** +- Modify: `crates/upm-appimage/src/` +- Modify: `crates/upm-core/src/domain/source.rs` +- Modify: `crates/upm-core/src/app/add.rs` - Modify: `crates/upm-core/src/app/show.rs` -- Modify: `crates/upm/src/lib.rs` -- Test: `crates/upm-core/tests/show_resolution.rs` -- Test: `crates/upm/tests/end_to_end_cli.rs` - -**Step 1: Write the failing `show` expectations** - -Add coverage proving that: - -- remote `show appimagehub/` resolves through the registered provider path -- installed-app `show` behavior remains unchanged -- unsupported queries still fail distinctly from provider-backed remote queries - -Prefer one new `upm-core` test for remote resolution and one CLI-facing assertion in `crates/upm/tests/end_to_end_cli.rs`. - -**Step 2: Run the focused tests to verify failure** - -Run: - -```bash -cargo test --package upm-core --test show_resolution -cargo test --package upm --test end_to_end_cli -``` - -Expected: FAIL because the remote show path still calls the add planner without a `ProviderRegistry`. - -**Step 3: Thread provider composition through `show`** - -Update the show pipeline so: - -- `upm-core` exposes provider-aware show entrypoints alongside the current defaults -- remote show resolution uses `build_add_plan_with_registered_providers` rather than the provider-blind path -- the CLI wraps remote show dispatch in `providers::with_provider_registry(...)` - -Keep installed-record rendering and summary formatting unchanged. - -**Step 4: Run the focused tests to verify pass** - -Run: - -```bash -cargo test --package upm-core --test show_resolution -cargo test --package upm --test end_to_end_cli -``` - -Expected: PASS. - -**Step 5: Commit** - -```bash -git add crates/upm-core/src/app/show.rs crates/upm/src/lib.rs crates/upm-core/tests/show_resolution.rs crates/upm/tests/end_to_end_cli.rs -git commit -m "feat: route show through provider registry" -``` - -### Task 3: Route `update` execution through registered providers - -**Files:** - Modify: `crates/upm-core/src/app/update.rs` -- Modify: `crates/upm/src/lib.rs` -- Test: `crates/upm-core/tests/update_planning.rs` -- Test: `crates/upm/tests/end_to_end_cli.rs` +- Test: `crates/upm-appimage/tests/` +- Test: `crates/upm-core/tests/` -**Step 1: Write the failing `update` expectations** +**Step 1: Write the failing module-boundary expectations** Add coverage proving that: -- AppImage-backed records can be refreshed through the update path with registered providers -- existing GitHub and direct-url update behavior remains unchanged -- the update execution path still restores previous payloads on failure +- AppImage-backed acquisition through GitHub, GitLab, SourceForge, direct URLs, and AppImageHub resolves through `upm-appimage` +- `upm-core` no longer treats those AppImage-producing backends as top-level package-manager concepts +- add, show, and update continue to work through normalized module contracts -Prefer focused `upm-core` tests plus one CLI integration assertion for an AppImage-backed update review or execution path. +Prefer module-focused tests plus a small number of core integration tests. **Step 2: Run the focused tests to verify failure** Run: ```bash -cargo test --package upm-core --test update_planning -cargo test --package upm --test end_to_end_cli +cargo test --package upm-appimage +cargo test --package upm-core ``` -Expected: FAIL because update execution still rebuilds add plans without a `ProviderRegistry`. +Expected: FAIL because AppImage acquisition paths are still split between the core and the AppImage module. -**Step 3: Thread provider composition through `update`** +**Step 3: Move AppImage-specific acquisition logic behind the module** -Update the update pipeline so: +Reshape the source and module boundary so: -- provider-aware update entrypoints exist in `upm-core` -- `execute_update` rebuilds plans through the provider-aware add planner -- the CLI wraps `update` execution in `providers::with_provider_registry(...)` +- AppImage-specific GitHub, GitLab, SourceForge, AppImageHub, and direct URL handling lives in `upm-appimage` +- `upm-core` coordinates AppImage work through normalized module contracts +- core source taxonomy is reduced or reframed so package-manager concepts stay above backend minutia -Do not generalize the update-channel model yet. Reuse the current channel semantics and only change how provider-backed plans are rebuilt. +Do not over-generalize for Flatpak or future providers yet. Only extract what the AppImage module demonstrably needs. **Step 4: Run the focused tests to verify pass** Run: ```bash -cargo test --package upm-core --test update_planning -cargo test --package upm --test end_to_end_cli +cargo test --package upm-appimage +cargo test --package upm-core ``` Expected: PASS. @@ -174,57 +178,57 @@ Expected: PASS. **Step 5: Commit** ```bash -git add crates/upm-core/src/app/update.rs crates/upm/src/lib.rs crates/upm-core/tests/update_planning.rs crates/upm/tests/end_to_end_cli.rs -git commit -m "feat: route updates through provider registry" +git add crates/upm-appimage/src crates/upm-core/src crates/upm-appimage/tests crates/upm-core/tests +git commit -m "refactor: make upm-appimage the appimage module boundary" ``` -### Task 4: Lock AppImage in as the reference provider module +### Task 4: Route search, add, show, and update through the application facade **Files:** -- Modify: `crates/upm-appimage/tests/appimagehub_search.rs` +- Modify: `crates/upm-core/src/app/` +- Modify: `crates/upm/src/lib.rs` - Modify: `crates/upm/tests/end_to_end_cli.rs` - Modify: `crates/upm/tests/ui_summary.rs` -- Test: `crates/upm-core/tests/provider_registry.rs` +- Test: `crates/upm-core/tests/` -**Step 1: Write the failing reference-provider expectations** +**Step 1: Write the failing facade-routing expectations** Add end-to-end coverage proving that AppImage support is fully module-driven: -- `search` still surfaces AppImageHub hits through the registry -- `show` for AppImageHub remote queries works through the registry -- `update` can refresh AppImage-backed records through the registry +- `search` flows through the public application facade +- `add` flows through the public application facade +- `show` flows through the public application facade +- `update` flows through the public application facade - user-facing summaries still render truthful `upm` paths and origins -Keep the assertions focused on module composition rather than UI restyling. +Keep the assertions focused on boundary correctness rather than UI restyling. **Step 2: Run the focused tests to verify failure** Run: ```bash -cargo test --package upm-appimage --test appimagehub_search cargo test --package upm --test end_to_end_cli cargo test --package upm --test ui_summary ``` -Expected: FAIL until the new `show` and `update` registry plumbing is complete. +Expected: FAIL until the facade is the normal command path. -**Step 3: Tighten provider-contract validation** +**Step 3: Tighten application-boundary validation** Update the tests so they prove: -- AppImage is composed only through `upm-appimage` -- `ProviderRegistry` is the shared composition point for all AppImage-facing command paths -- AppImage is still not reintroduced as a hardcoded built-in inside `upm-core` +- frontend command handlers call the application facade rather than module-specific helpers +- AppImage is composed only through `upm-core` and `upm-appimage` +- AppImage is not reintroduced as a hardcoded built-in in the CLI -Do not move AppImageHub back into `all_adapter_kinds()`. +Do not reintroduce package-manager-specific branching in the CLI. **Step 4: Run the focused tests to verify pass** Run: ```bash -cargo test --package upm-appimage --test appimagehub_search cargo test --package upm --test end_to_end_cli cargo test --package upm --test ui_summary ``` @@ -234,35 +238,35 @@ Expected: PASS. **Step 5: Commit** ```bash -git add crates/upm-appimage/tests/appimagehub_search.rs crates/upm/tests/end_to_end_cli.rs crates/upm/tests/ui_summary.rs crates/upm-core/tests/provider_registry.rs -git commit -m "test: validate appimage as reference provider module" +git add crates/upm-core/src crates/upm/src/lib.rs crates/upm/tests/end_to_end_cli.rs crates/upm/tests/ui_summary.rs crates/upm-core/tests +git commit -m "refactor: route commands through upm-core facade" ``` -### Task 5: Update architecture docs and run full verification +### Task 5: Lock the architecture into docs and verify the workspace **Files:** - Modify: `.architecture/overview.md` - Modify: `.architecture/roadmap.md` - Modify: `README.md` -**Step 1: Update docs for Milestone 1 completion state** +**Step 1: Update docs for the new module model** Document: -- capability discovery in `ProviderRegistry` -- provider-aware `show` and `update` execution paths -- AppImage as the first validated provider module on the new architecture -- the explicit non-goal that `remove` and `list` remain generic until a provider needs extra hooks +- `upm-core` as the application boundary +- one public application facade over smaller internal services +- CLI and GUI as thin frontends +- `upm-appimage` as the AppImage package-manager boundary with internal acquisition backends -**Step 2: Verify the docs mention the Milestone 1 state** +**Step 2: Verify the docs mention the agreed architecture** Run: ```bash -rg -n "ProviderRegistry|capabilit|upm-appimage|show|update" README.md .architecture/overview.md .architecture/roadmap.md +rg -n "upm-core|facade|upm-ui|upm-appimage|module" README.md .architecture/overview.md .architecture/roadmap.md ``` -Expected: matches describing the expanded provider surface. +Expected: matches describing the application boundary, thin frontends, and module ownership. **Step 3: Run full verification** @@ -280,5 +284,5 @@ Expected: PASS. ```bash git add README.md .architecture/overview.md .architecture/roadmap.md -git commit -m "docs: describe appimage provider milestone" +git commit -m "docs: describe application facade architecture" ``` \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 5130bcc..d589261 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1959,6 +1959,7 @@ dependencies = [ "reqwest", "serde", "upm-core", + "upm-module-api", ] [[package]] @@ -1975,6 +1976,15 @@ dependencies = [ "sha2", "tempfile", "toml", + "upm-appimage", + "upm-module-api", +] + +[[package]] +name = "upm-module-api" +version = "0.1.0" +dependencies = [ + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 847258b..371e126 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/upm-module-api", "crates/upm-core", "crates/upm-appimage", "crates/upm", diff --git a/README.md b/README.md index d8c15c7..913ac43 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # upm Universal Package Manager -`upm` is a Rust Cargo workspace for a modular package manager with a shared headless core and provider crates. +`upm` is a Rust Cargo workspace for a modular package manager with a shared headless application core, thin frontend crates, and package-manager modules. ## Workspace -- `crates/upm-core`: headless application layer for query normalization, resolution, planning, registry persistence, install/update orchestration, and provider-facing APIs +- `crates/upm-core`: headless application layer for query normalization, orchestration, module registration and composition, registry persistence, install/update planning, and the unified frontend-facing API - `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` +- `crates/upm-appimage`: AppImage package-manager module responsible for AppImage-specific acquisition and resolution behavior +- `crates/upm-ui` (planned): GUI frontend over `upm-core` -The split is intentional so future frontends can reuse `upm-core`, while package-source behavior stays modular instead of being hardcoded into the core. +The split is intentional so future frontends can reuse `upm-core`, while package-manager behavior stays modular instead of being hardcoded into the core or the CLI. ## Commands @@ -37,11 +38,11 @@ upm remove ## Search -`upm search ` is part of the initial modular provider surface. +`upm search ` is part of the initial modular module surface. -- search is provider-extensible and currently includes GitHub plus AppImageHub +- search is module-extensible and currently includes the built-in core search path plus AppImage-backed search sources - search results should resolve to install-ready queries such as `owner/repo` and `appimagehub/` -- provider composition happens in `crates/upm/src/providers.rs`, not through AppImageHub-specific wiring inside `upm-core` +- module composition belongs in `upm-core`, not in the CLI frontend ## Scope Overrides diff --git a/crates/upm-appimage/Cargo.toml b/crates/upm-appimage/Cargo.toml index 8761a15..2353d1c 100644 --- a/crates/upm-appimage/Cargo.toml +++ b/crates/upm-appimage/Cargo.toml @@ -11,4 +11,7 @@ path = "src/lib.rs" quick-xml.workspace = true reqwest.workspace = true serde.workspace = true +upm-module-api = { path = "../upm-module-api" } + +[dev-dependencies] upm-core = { path = "../upm-core" } \ No newline at end of file diff --git a/crates/upm-appimage/src/add.rs b/crates/upm-appimage/src/add.rs index a374254..bb3258a 100644 --- a/crates/upm-appimage/src/add.rs +++ b/crates/upm-appimage/src/add.rs @@ -1,13 +1,14 @@ use crate::source::appimagehub::{ AppImageHubError, AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with, }; -use upm_core::adapters::traits::{ +use upm_module_api::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::{ +use upm_module_api::app::providers::{ExternalAddProvider, ExternalAddResolution}; +use upm_module_api::domain::source::{ + NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef, +}; +use upm_module_api::domain::update::{ ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy, }; @@ -58,7 +59,7 @@ impl SourceAdapter for AppImageHubAdapter { } fn normalize(&self, query: &str) -> Result { - let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?; + let source = resolve_appimagehub_query(query)?; if source.kind != SourceKind::AppImageHub { return Err(AdapterError::UnsupportedQuery); } @@ -92,17 +93,17 @@ impl SourceAdapter for AppImageHubAdapter { } } -pub struct AppImageHubAddProvider<'a, T: AppImageHubTransport + ?Sized> { - transport: &'a T, +pub struct AppImageHubAddProvider { + transport: Box, } -impl<'a, T: AppImageHubTransport + ?Sized> AppImageHubAddProvider<'a, T> { - pub fn new(transport: &'a T) -> Self { +impl AppImageHubAddProvider { + pub fn new(transport: Box) -> Self { Self { transport } } } -impl ExternalAddProvider for AppImageHubAddProvider<'_, T> { +impl ExternalAddProvider for AppImageHubAddProvider { fn id(&self) -> &'static str { "appimagehub" } @@ -113,11 +114,11 @@ impl ExternalAddProvider for AppImageHubAddPro } let adapter = AppImageHubAdapter; - let resolution = match adapter.resolve_source_with(source, self.transport)? { + let resolution = match adapter.resolve_source_with(source, self.transport.as_ref())? { AdapterResolveOutcome::Resolved(resolution) => resolution, AdapterResolveOutcome::NoInstallableArtifact { .. } => return Ok(None), }; - let Some(resolved_item) = resolve_appimagehub_item_with(source, self.transport) + let Some(resolved_item) = resolve_appimagehub_item_with(source, self.transport.as_ref()) .map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))? else { return Ok(None); @@ -161,3 +162,35 @@ fn render_appimagehub_error(error: &AppImageHubError) -> String { } } } + +fn resolve_appimagehub_query(query: &str) -> Result { + let trimmed = query.trim(); + let id = if let Some(id) = trimmed.strip_prefix("appimagehub/") { + id + } else if let Some(id) = trimmed.strip_prefix("https://www.appimagehub.com/p/") { + id + } else if let Some(id) = trimmed.strip_prefix("http://www.appimagehub.com/p/") { + id + } else { + return Err(AdapterError::UnsupportedQuery); + }; + + if !id.chars().all(|ch| ch.is_ascii_digit()) { + return Err(AdapterError::UnsupportedQuery); + } + + Ok(SourceRef { + kind: SourceKind::AppImageHub, + locator: format!("https://www.appimagehub.com/p/{id}"), + input_kind: if trimmed.starts_with("appimagehub/") { + SourceInputKind::AppImageHubShorthand + } else { + SourceInputKind::AppImageHubUrl + }, + normalized_kind: NormalizedSourceKind::AppImageHub, + canonical_locator: Some(id.to_owned()), + requested_tag: None, + requested_asset_name: None, + tracks_latest: true, + }) +} diff --git a/crates/upm-appimage/src/search.rs b/crates/upm-appimage/src/search.rs index 8160f71..b5c2361 100644 --- a/crates/upm-appimage/src/search.rs +++ b/crates/upm-appimage/src/search.rs @@ -1,25 +1,29 @@ use crate::source::appimagehub::{ AppImageHubSearchError, AppImageHubTransport, search_appimagehub_with, }; -use upm_core::app::search::{SearchProvider, SearchProviderError}; -use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult}; +use upm_module_api::app::search::{SearchProvider, SearchProviderError}; +use upm_module_api::domain::search::{SearchInstallStatus, SearchQuery, SearchResult}; -pub struct AppImageHubSearchProvider<'a, T: AppImageHubTransport + ?Sized> { - transport: &'a T, +pub struct AppImageHubSearchProvider { + transport: Box, } -impl<'a, T: AppImageHubTransport + ?Sized> AppImageHubSearchProvider<'a, T> { - pub fn new(transport: &'a T) -> Self { +impl AppImageHubSearchProvider { + pub fn new(transport: Box) -> Self { Self { transport } } } -impl SearchProvider for AppImageHubSearchProvider<'_, T> { +impl SearchProvider for AppImageHubSearchProvider { fn search(&self, query: &SearchQuery) -> Result, 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 hits = + search_appimagehub_with(&query.text, query.remote_limit, self.transport.as_ref()) + .map_err(|error| { + SearchProviderError::new( + "appimagehub", + &render_appimagehub_search_error(&error), + ) + })?; let normalized_query = normalize_lookup(&query.text); let mut ranked_hits = hits diff --git a/crates/upm-appimage/src/source/appimagehub.rs b/crates/upm-appimage/src/source/appimagehub.rs index 165a65f..d718478 100644 --- a/crates/upm-appimage/src/source/appimagehub.rs +++ b/crates/upm-appimage/src/source/appimagehub.rs @@ -1,7 +1,7 @@ use std::env; use std::time::Duration; -use upm_core::domain::source::SourceRef; +use upm_module_api::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"; diff --git a/crates/upm-appimage/tests/appimagehub_search.rs b/crates/upm-appimage/tests/appimagehub_search.rs index 3ccebe5..b06726a 100644 --- a/crates/upm-appimage/tests/appimagehub_search.rs +++ b/crates/upm-appimage/tests/appimagehub_search.rs @@ -24,7 +24,7 @@ impl SearchProvider for StubProvider { #[test] fn appimagehub_search_provider_maps_hits_to_install_ready_results() { - let provider = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport); + let provider = AppImageHubSearchProvider::new(Box::new(FixtureAppImageHubTransport)); let results = provider.search(&SearchQuery::new("firefox")).unwrap(); @@ -38,7 +38,7 @@ fn appimagehub_search_provider_maps_hits_to_install_ready_results() { #[test] fn appimagehub_hits_are_annotated_as_installed_by_canonical_id() { - let provider = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport); + let provider = AppImageHubSearchProvider::new(Box::new(FixtureAppImageHubTransport)); let installed = vec![AppRecord { stable_id: "firefox".to_owned(), display_name: "Firefox by Mozilla - Official AppImage Edition".to_owned(), @@ -76,7 +76,7 @@ fn appimagehub_hits_are_annotated_as_installed_by_canonical_id() { #[test] fn search_can_merge_github_and_appimagehub_providers() { let github = GitHubSearchProvider::new(&FixtureGitHubTransport); - let appimagehub = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport); + let appimagehub = AppImageHubSearchProvider::new(Box::new(FixtureAppImageHubTransport)); let stub = StubProvider { hit: SearchResult { provider_id: "github".to_owned(), @@ -131,7 +131,7 @@ fn appimagehub_adapter_resolves_installable_items_through_fixture_transport() { #[test] fn appimagehub_add_provider_resolves_external_add_plan() { - let provider = AppImageHubAddProvider::new(&FixtureAppImageHubTransport); + let provider = AppImageHubAddProvider::new(Box::new(FixtureAppImageHubTransport)); let source = resolve_query("appimagehub/2338455").unwrap(); let resolution = provider.resolve(&source).unwrap().unwrap(); diff --git a/crates/upm-core/Cargo.toml b/crates/upm-core/Cargo.toml index 7f483e6..39c59d7 100644 --- a/crates/upm-core/Cargo.toml +++ b/crates/upm-core/Cargo.toml @@ -17,6 +17,8 @@ serde.workspace = true serde_yaml.workspace = true sha2.workspace = true toml.workspace = true +upm-appimage = { path = "../upm-appimage" } +upm-module-api = { path = "../upm-module-api" } [dev-dependencies] tempfile.workspace = true diff --git a/crates/upm-core/src/adapters/traits.rs b/crates/upm-core/src/adapters/traits.rs index 0a2c1b6..dc77862 100644 --- a/crates/upm-core/src/adapters/traits.rs +++ b/crates/upm-core/src/adapters/traits.rs @@ -1,72 +1 @@ -use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct AdapterCapabilities { - pub supports_search: bool, - pub supports_exact_resolution: bool, -} - -impl AdapterCapabilities { - pub fn exact_resolution_only() -> Self { - Self { - supports_search: false, - supports_exact_resolution: true, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AdapterResolution { - pub source: SourceRef, - pub release: ResolvedRelease, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum AdapterResolveOutcome { - Resolved(AdapterResolution), - NoInstallableArtifact { source: SourceRef }, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum AdapterError { - UnsupportedQuery, - UnsupportedSource, - ResolutionFailed(String), -} - -pub trait SourceAdapter { - fn id(&self) -> &'static str; - - fn capabilities(&self) -> AdapterCapabilities; - - fn repository_source_kind(&self) -> Option { - None - } - - fn exact_source_kind(&self) -> Option { - None - } - - fn normalize(&self, query: &str) -> Result; - - fn resolve(&self, source: &SourceRef) -> Result; - - fn resolve_supported_source( - &self, - source: &SourceRef, - ) -> Result { - self.resolve(source).map(AdapterResolveOutcome::Resolved) - } - - fn supports_source(&self, source: &SourceRef) -> bool { - crate::adapters::supports_source(self, source) - } - - fn resolve_source(&self, source: &SourceRef) -> Result { - if !self.supports_source(source) { - return Err(AdapterError::UnsupportedSource); - } - - self.resolve_supported_source(source) - } -} +pub use upm_module_api::adapters::traits::*; diff --git a/crates/upm-core/src/app/application.rs b/crates/upm-core/src/app/application.rs new file mode 100644 index 0000000..f83e6e2 --- /dev/null +++ b/crates/upm-core/src/app/application.rs @@ -0,0 +1,182 @@ +use std::path::Path; + +use crate::app::add::{ + AddPlan, AddSecurityPolicy, BuildAddPlanError, InstalledApp, + build_add_plan_with_registered_providers, + build_add_plan_with_reporter_and_registered_providers, install_app_with_reporter, +}; +use crate::app::list::{ListRow, build_list_rows}; +use crate::app::progress::ProgressReporter; +use crate::app::providers::ProviderRegistry; +use crate::app::remove::{RemovalResult, RemoveRegisteredAppError, remove_registered_app}; +use crate::app::search::{SearchError, SearchProvider, build_search_results_with}; +use crate::app::show::build_show_result_with; +use crate::app::update::{ + BuildUpdatePlanError, ExecuteUpdatesError, build_update_plan, + execute_updates_with_reporter_and_policy, +}; +use crate::domain::app::{AppRecord, InstallScope}; +use crate::domain::search::{SearchQuery, SearchResults}; +use crate::domain::show::{InstalledShow, ShowResult, ShowResultError}; +use crate::domain::update::{UpdateExecutionResult, UpdatePlan}; +use crate::source::github::{GitHubTransport, default_transport}; + +pub struct UpmApp<'a> { + github_transport: Box, + providers: ProviderRegistry<'a>, +} + +pub struct UpmAppBuilder<'a> { + github_transport: Option>, + providers: ProviderRegistry<'a>, +} + +impl UpmApp<'static> { + pub fn new() -> Self { + Self::builder() + .with_provider_registry(default_provider_registry()) + .build() + } +} + +impl Default for UpmApp<'static> { + fn default() -> Self { + Self::new() + } +} + +impl<'a> UpmApp<'a> { + pub fn builder() -> UpmAppBuilder<'a> { + UpmAppBuilder { + github_transport: None, + providers: ProviderRegistry::default(), + } + } + + pub fn search( + &self, + query: &SearchQuery, + installed_apps: &[AppRecord], + ) -> Result { + let github_provider = + crate::app::search::GitHubSearchProvider::new(self.github_transport.as_ref()); + let mut resolved_providers = vec![&github_provider as &dyn SearchProvider]; + resolved_providers.extend( + self.providers + .search_providers + .iter() + .map(|provider| provider.as_ref() as &dyn SearchProvider), + ); + build_search_results_with(query, installed_apps, &resolved_providers) + } + + pub fn build_add_plan( + &self, + query: &str, + policy: AddSecurityPolicy, + ) -> Result { + build_add_plan_with_registered_providers( + query, + self.github_transport.as_ref(), + &self.providers, + policy, + ) + } + + pub fn build_add_plan_with_reporter( + &self, + query: &str, + reporter: &mut impl ProgressReporter, + policy: AddSecurityPolicy, + ) -> Result { + build_add_plan_with_reporter_and_registered_providers( + query, + self.github_transport.as_ref(), + reporter, + &self.providers, + policy, + ) + } + + pub fn install_app( + &self, + query: &str, + plan: &AddPlan, + install_home: &Path, + requested_scope: InstallScope, + reporter: &mut impl ProgressReporter, + ) -> Result { + install_app_with_reporter(query, plan, install_home, requested_scope, reporter) + } + + pub fn show( + &self, + query: &str, + installed_apps: &[AppRecord], + ) -> Result { + build_show_result_with(query, installed_apps, self.github_transport.as_ref()) + } + + pub fn show_all(&self, installed_apps: &[AppRecord]) -> Vec { + crate::app::show::build_installed_show_results(installed_apps) + } + + pub fn list(&self, apps: &[AppRecord]) -> Vec { + build_list_rows(apps) + } + + pub fn build_update_plan( + &self, + apps: &[AppRecord], + ) -> Result { + build_update_plan(apps) + } + + pub fn execute_updates( + &self, + apps: &[AppRecord], + install_home: &Path, + reporter: &mut impl ProgressReporter, + policy: AddSecurityPolicy, + ) -> Result { + execute_updates_with_reporter_and_policy(apps, install_home, reporter, policy) + } + + pub fn remove_registered_app( + &self, + query: &str, + apps: &[AppRecord], + install_home: &Path, + ) -> Result { + remove_registered_app(query, apps, install_home) + } +} + +impl<'a> UpmAppBuilder<'a> { + pub fn with_github_transport(mut self, github_transport: Box) -> Self { + self.github_transport = Some(github_transport); + self + } + + pub fn with_provider_registry(mut self, providers: ProviderRegistry<'a>) -> Self { + self.providers = providers; + self + } + + pub fn build(self) -> UpmApp<'a> { + UpmApp { + github_transport: self.github_transport.unwrap_or_else(default_transport), + providers: self.providers, + } + } +} + +fn default_provider_registry() -> ProviderRegistry<'static> { + ProviderRegistry::default() + .with_search_provider(upm_appimage::AppImageHubSearchProvider::new( + upm_appimage::source::appimagehub::default_transport(), + )) + .with_external_add_provider(upm_appimage::AppImageHubAddProvider::new( + upm_appimage::source::appimagehub::default_transport(), + )) +} diff --git a/crates/upm-core/src/app/mod.rs b/crates/upm-core/src/app/mod.rs index 0e632e2..c0618d9 100644 --- a/crates/upm-core/src/app/mod.rs +++ b/crates/upm-core/src/app/mod.rs @@ -1,4 +1,5 @@ pub mod add; +pub mod application; pub mod identity; pub mod interaction; pub mod list; @@ -10,3 +11,5 @@ pub mod scope; pub mod search; pub mod show; pub mod update; + +pub use application::{UpmApp, UpmAppBuilder}; diff --git a/crates/upm-core/src/app/providers.rs b/crates/upm-core/src/app/providers.rs index c703e2e..ba3307e 100644 --- a/crates/upm-core/src/app/providers.rs +++ b/crates/upm-core/src/app/providers.rs @@ -1,24 +1 @@ -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, 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, -} - -#[derive(Default)] -pub struct ProviderRegistry<'a> { - pub search_providers: Vec<&'a dyn SearchProvider>, - pub external_add_providers: Vec<&'a dyn ExternalAddProvider>, -} +pub use upm_module_api::app::providers::*; diff --git a/crates/upm-core/src/app/search.rs b/crates/upm-core/src/app/search.rs index 5928a28..d79b203 100644 --- a/crates/upm-core/src/app/search.rs +++ b/crates/upm-core/src/app/search.rs @@ -9,25 +9,7 @@ use crate::source::github::{ search_github_repositories_with, }; use std::collections::HashSet; - -pub trait SearchProvider { - fn search(&self, query: &SearchQuery) -> Result, SearchProviderError>; -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SearchProviderError { - pub provider_id: String, - pub message: String, -} - -impl SearchProviderError { - pub fn new(provider_id: &str, message: &str) -> Self { - Self { - provider_id: provider_id.to_owned(), - message: message.to_owned(), - } - } -} +pub use upm_module_api::app::search::{SearchProvider, SearchProviderError}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum SearchError { @@ -53,7 +35,12 @@ pub fn build_search_results_with_registered_providers( 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()); + resolved_providers.extend( + providers + .search_providers + .iter() + .map(|provider| provider.as_ref() as &dyn SearchProvider), + ); build_search_results_with(query, installed_apps, &resolved_providers) } diff --git a/crates/upm-core/src/domain/search.rs b/crates/upm-core/src/domain/search.rs index 9914aab..437c024 100644 --- a/crates/upm-core/src/domain/search.rs +++ b/crates/upm-core/src/domain/search.rs @@ -1,68 +1 @@ -pub const DEFAULT_REMOTE_LIMIT: usize = 10; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum SearchInstallStatus { - Available, - Installed { - installed_version: Option, - }, - UpdateAvailable { - installed_version: Option, - latest_version: Option, - }, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SearchQuery { - pub text: String, - pub remote_limit: usize, -} - -impl SearchQuery { - pub fn new(text: &str) -> Self { - Self { - text: text.to_owned(), - remote_limit: DEFAULT_REMOTE_LIMIT, - } - } - - pub fn with_remote_limit(text: &str, remote_limit: usize) -> Self { - Self { - text: text.to_owned(), - remote_limit, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SearchResult { - pub provider_id: String, - pub display_name: String, - pub description: Option, - pub source_locator: String, - pub install_query: String, - pub canonical_locator: String, - pub version: Option, - pub install_status: SearchInstallStatus, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct InstalledSearchMatch { - pub stable_id: String, - pub display_name: String, - pub installed_version: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SearchWarning { - pub provider_id: Option, - pub message: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SearchResults { - pub query_text: String, - pub remote_hits: Vec, - pub installed_matches: Vec, - pub warnings: Vec, -} +pub use upm_module_api::domain::search::*; diff --git a/crates/upm-core/src/domain/source.rs b/crates/upm-core/src/domain/source.rs index 889d463..45d43ae 100644 --- a/crates/upm-core/src/domain/source.rs +++ b/crates/upm-core/src/domain/source.rs @@ -1,117 +1 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] -pub enum SourceKind { - GitHub, - GitLab, - AppImageHub, - SourceForge, - DirectUrl, - File, -} - -impl SourceKind { - pub fn as_str(&self) -> &'static str { - match self { - Self::GitHub => "github", - Self::GitLab => "gitlab", - Self::AppImageHub => "appimagehub", - Self::SourceForge => "sourceforge", - Self::DirectUrl => "direct-url", - Self::File => "file", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] -pub enum SourceInputKind { - RepoShorthand, - GitHubRepositoryUrl, - GitHubReleaseUrl, - GitHubReleaseAssetUrl, - GitLabUrl, - AppImageHubUrl, - AppImageHubShorthand, - SourceForgeUrl, - DirectUrl, - File, -} - -impl SourceInputKind { - pub fn as_str(&self) -> &'static str { - match self { - Self::RepoShorthand => "repo-shorthand", - Self::GitHubRepositoryUrl => "github-repository-url", - Self::GitHubReleaseUrl => "github-release-url", - Self::GitHubReleaseAssetUrl => "github-release-asset-url", - Self::GitLabUrl => "gitlab-url", - Self::AppImageHubUrl => "appimagehub-url", - Self::AppImageHubShorthand => "appimagehub-shorthand", - Self::SourceForgeUrl => "sourceforge-url", - Self::DirectUrl => "direct-url", - Self::File => "file", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] -pub enum NormalizedSourceKind { - GitHubRepository, - GitHubRelease, - GitHubReleaseAsset, - GitLab, - GitLabCandidate, - AppImageHub, - SourceForge, - SourceForgeCandidate, - DirectUrl, - File, -} - -impl NormalizedSourceKind { - pub fn as_str(&self) -> &'static str { - match self { - Self::GitHubRepository => "github-repository", - Self::GitHubRelease => "github-release", - Self::GitHubReleaseAsset => "github-release-asset", - Self::GitLab => "gitlab", - Self::GitLabCandidate => "gitlab-candidate", - Self::AppImageHub => "appimagehub", - Self::SourceForge => "sourceforge", - Self::SourceForgeCandidate => "sourceforge-candidate", - Self::DirectUrl => "direct-url", - Self::File => "file", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] -pub struct SourceRef { - pub kind: SourceKind, - pub locator: String, - #[serde(default = "default_source_input_kind")] - pub input_kind: SourceInputKind, - #[serde(default = "default_normalized_source_kind")] - pub normalized_kind: NormalizedSourceKind, - #[serde(default)] - pub canonical_locator: Option, - #[serde(default)] - pub requested_tag: Option, - #[serde(default)] - pub requested_asset_name: Option, - #[serde(default)] - pub tracks_latest: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] -pub struct ResolvedRelease { - pub version: String, - #[serde(default)] - pub prerelease: bool, -} - -const fn default_source_input_kind() -> SourceInputKind { - SourceInputKind::DirectUrl -} - -const fn default_normalized_source_kind() -> NormalizedSourceKind { - NormalizedSourceKind::DirectUrl -} +pub use upm_module_api::domain::source::*; diff --git a/crates/upm-core/src/domain/update.rs b/crates/upm-core/src/domain/update.rs index 9ce0ddc..4c1cf73 100644 --- a/crates/upm-core/src/domain/update.rs +++ b/crates/upm-core/src/domain/update.rs @@ -1,4 +1,7 @@ use crate::domain::app::AppRecord; +pub use upm_module_api::domain::update::{ + ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy, +}; #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub enum ParsedMetadataKind { @@ -31,25 +34,6 @@ pub struct ParsedMetadata { pub confidence: u8, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] -pub enum UpdateChannelKind { - GitHubReleases, - ElectronBuilder, - Zsync, - DirectAsset, -} - -impl UpdateChannelKind { - pub fn as_str(&self) -> &'static str { - match self { - Self::GitHubReleases => "github-releases", - Self::ElectronBuilder => "electron-builder", - Self::Zsync => "zsync", - Self::DirectAsset => "direct-asset-lineage", - } - } -} - #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub struct UpdateChannel { pub kind: UpdateChannelKind, @@ -66,30 +50,6 @@ pub struct UpdateChannel { pub prerelease: bool, } -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] -pub struct ChannelPreference { - pub kind: UpdateChannelKind, - pub locator: String, - pub reason: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] -pub struct UpdateStrategy { - pub preferred: ChannelPreference, - #[serde(default)] - pub alternates: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ArtifactCandidate { - pub url: String, - pub version: String, - pub arch: Option, - pub trusted_checksum: Option, - pub weak_checksum_md5: Option, - pub selection_reason: String, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct UpdatePlan { pub items: Vec, diff --git a/crates/upm-core/src/lib.rs b/crates/upm-core/src/lib.rs index 60b0706..c95d7ff 100644 --- a/crates/upm-core/src/lib.rs +++ b/crates/upm-core/src/lib.rs @@ -9,3 +9,4 @@ pub mod source; pub mod update; pub use app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry}; +pub use app::{UpmApp, UpmAppBuilder}; diff --git a/crates/upm-core/tests/application_facade.rs b/crates/upm-core/tests/application_facade.rs new file mode 100644 index 0000000..4e84664 --- /dev/null +++ b/crates/upm-core/tests/application_facade.rs @@ -0,0 +1,124 @@ +use upm_core::adapters::traits::AdapterResolution; +use upm_core::app::UpmApp; +use upm_core::app::add::AddSecurityPolicy; +use upm_core::app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry}; +use upm_core::app::search::{SearchProvider, SearchProviderError}; +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, SearchProviderError> { + Ok(vec![SearchResult { + provider_id: "external-search".to_owned(), + display_name: "Firefox Nightly".to_owned(), + description: Some("Provided by facade-owned providers".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, upm_core::adapters::traits::AdapterError> { + Ok( + (source.kind == SourceKind::AppImageHub).then(|| ExternalAddResolution { + resolution: 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 upm_app_can_be_constructed_without_cli_owned_module_composition() { + let _app = UpmApp::new(); +} + +#[test] +fn upm_app_search_delegates_through_the_application_facade() { + let app = UpmApp::builder() + .with_github_transport(Box::new(FixtureGitHubTransport)) + .with_provider_registry( + ProviderRegistry::default().with_search_provider(StubSearchProvider), + ) + .build(); + + let results = app.search(&SearchQuery::new("firefox"), &[]).unwrap(); + + assert!(results.remote_hits.iter().any(|hit| { + hit.provider_id == "external-search" && hit.install_query == "external/firefox-nightly" + })); +} + +#[test] +fn upm_app_add_planning_delegates_through_the_application_facade() { + let app = UpmApp::builder() + .with_github_transport(Box::new(FixtureGitHubTransport)) + .with_provider_registry( + ProviderRegistry::default().with_external_add_provider(StubExternalAddProvider), + ) + .build(); + + let plan = app + .build_add_plan("appimagehub/2338455", AddSecurityPolicy::default()) + .unwrap(); + + assert_eq!(plan.resolution.source.kind, SourceKind::AppImageHub); + assert_eq!( + plan.selected_artifact.url, + "https://downloads.example.invalid/firefox.AppImage" + ); +} diff --git a/crates/upm-core/tests/provider_registry.rs b/crates/upm-core/tests/provider_registry.rs index f4f2a78..4d25130 100644 --- a/crates/upm-core/tests/provider_registry.rs +++ b/crates/upm-core/tests/provider_registry.rs @@ -86,11 +86,7 @@ impl ExternalAddProvider for StubExternalAddProvider { #[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 providers = ProviderRegistry::default().with_search_provider(StubSearchProvider); let results = build_search_results_with_registered_providers(&query, &[], &providers).unwrap(); @@ -129,11 +125,7 @@ fn build_add_plan_with_registered_providers_requires_external_provider_for_appim #[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 registry = ProviderRegistry::default().with_external_add_provider(StubExternalAddProvider); let plan = build_add_plan_with_registered_providers( "appimagehub/2338455", diff --git a/crates/upm-module-api/Cargo.toml b/crates/upm-module-api/Cargo.toml new file mode 100644 index 0000000..93b9f31 --- /dev/null +++ b/crates/upm-module-api/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "upm-module-api" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +serde.workspace = true \ No newline at end of file diff --git a/crates/upm-module-api/src/adapters/mod.rs b/crates/upm-module-api/src/adapters/mod.rs new file mode 100644 index 0000000..f6ac8fc --- /dev/null +++ b/crates/upm-module-api/src/adapters/mod.rs @@ -0,0 +1 @@ +pub mod traits; diff --git a/crates/upm-module-api/src/adapters/traits.rs b/crates/upm-module-api/src/adapters/traits.rs new file mode 100644 index 0000000..3f4c3d3 --- /dev/null +++ b/crates/upm-module-api/src/adapters/traits.rs @@ -0,0 +1,73 @@ +use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AdapterCapabilities { + pub supports_search: bool, + pub supports_exact_resolution: bool, +} + +impl AdapterCapabilities { + pub fn exact_resolution_only() -> Self { + Self { + supports_search: false, + supports_exact_resolution: true, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdapterResolution { + pub source: SourceRef, + pub release: ResolvedRelease, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AdapterResolveOutcome { + Resolved(AdapterResolution), + NoInstallableArtifact { source: SourceRef }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AdapterError { + UnsupportedQuery, + UnsupportedSource, + ResolutionFailed(String), +} + +pub trait SourceAdapter { + fn id(&self) -> &'static str; + + fn capabilities(&self) -> AdapterCapabilities; + + fn repository_source_kind(&self) -> Option { + None + } + + fn exact_source_kind(&self) -> Option { + None + } + + fn normalize(&self, query: &str) -> Result; + + fn resolve(&self, source: &SourceRef) -> Result; + + fn resolve_supported_source( + &self, + source: &SourceRef, + ) -> Result { + self.resolve(source).map(AdapterResolveOutcome::Resolved) + } + + fn supports_source(&self, source: &SourceRef) -> bool { + self.repository_source_kind() == Some(source.kind) + || self.exact_source_kind() == Some(source.kind) + } + + fn resolve_source(&self, source: &SourceRef) -> Result { + if !self.supports_source(source) { + return Err(AdapterError::UnsupportedSource); + } + + self.resolve_supported_source(source) + } +} diff --git a/crates/upm-module-api/src/app/mod.rs b/crates/upm-module-api/src/app/mod.rs new file mode 100644 index 0000000..532c582 --- /dev/null +++ b/crates/upm-module-api/src/app/mod.rs @@ -0,0 +1,2 @@ +pub mod providers; +pub mod search; diff --git a/crates/upm-module-api/src/app/providers.rs b/crates/upm-module-api/src/app/providers.rs new file mode 100644 index 0000000..3b24bb4 --- /dev/null +++ b/crates/upm-module-api/src/app/providers.rs @@ -0,0 +1,42 @@ +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, 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, +} + +#[derive(Default)] +pub struct ProviderRegistry<'a> { + pub search_providers: Vec>, + pub external_add_providers: Vec>, +} + +impl<'a> ProviderRegistry<'a> { + pub fn with_search_provider

(mut self, provider: P) -> Self + where + P: SearchProvider + 'a, + { + self.search_providers.push(Box::new(provider)); + self + } + + pub fn with_external_add_provider

(mut self, provider: P) -> Self + where + P: ExternalAddProvider + 'a, + { + self.external_add_providers.push(Box::new(provider)); + self + } +} diff --git a/crates/upm-module-api/src/app/search.rs b/crates/upm-module-api/src/app/search.rs new file mode 100644 index 0000000..c1caff4 --- /dev/null +++ b/crates/upm-module-api/src/app/search.rs @@ -0,0 +1,23 @@ +use crate::domain::search::SearchResult; + +pub trait SearchProvider { + fn search( + &self, + query: &crate::domain::search::SearchQuery, + ) -> Result, SearchProviderError>; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchProviderError { + pub provider_id: String, + pub message: String, +} + +impl SearchProviderError { + pub fn new(provider_id: &str, message: &str) -> Self { + Self { + provider_id: provider_id.to_owned(), + message: message.to_owned(), + } + } +} diff --git a/crates/upm-module-api/src/domain/mod.rs b/crates/upm-module-api/src/domain/mod.rs new file mode 100644 index 0000000..e397353 --- /dev/null +++ b/crates/upm-module-api/src/domain/mod.rs @@ -0,0 +1,3 @@ +pub mod search; +pub mod source; +pub mod update; diff --git a/crates/upm-module-api/src/domain/search.rs b/crates/upm-module-api/src/domain/search.rs new file mode 100644 index 0000000..9914aab --- /dev/null +++ b/crates/upm-module-api/src/domain/search.rs @@ -0,0 +1,68 @@ +pub const DEFAULT_REMOTE_LIMIT: usize = 10; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SearchInstallStatus { + Available, + Installed { + installed_version: Option, + }, + UpdateAvailable { + installed_version: Option, + latest_version: Option, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchQuery { + pub text: String, + pub remote_limit: usize, +} + +impl SearchQuery { + pub fn new(text: &str) -> Self { + Self { + text: text.to_owned(), + remote_limit: DEFAULT_REMOTE_LIMIT, + } + } + + pub fn with_remote_limit(text: &str, remote_limit: usize) -> Self { + Self { + text: text.to_owned(), + remote_limit, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchResult { + pub provider_id: String, + pub display_name: String, + pub description: Option, + pub source_locator: String, + pub install_query: String, + pub canonical_locator: String, + pub version: Option, + pub install_status: SearchInstallStatus, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstalledSearchMatch { + pub stable_id: String, + pub display_name: String, + pub installed_version: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchWarning { + pub provider_id: Option, + pub message: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchResults { + pub query_text: String, + pub remote_hits: Vec, + pub installed_matches: Vec, + pub warnings: Vec, +} diff --git a/crates/upm-module-api/src/domain/source.rs b/crates/upm-module-api/src/domain/source.rs new file mode 100644 index 0000000..889d463 --- /dev/null +++ b/crates/upm-module-api/src/domain/source.rs @@ -0,0 +1,117 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum SourceKind { + GitHub, + GitLab, + AppImageHub, + SourceForge, + DirectUrl, + File, +} + +impl SourceKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::GitHub => "github", + Self::GitLab => "gitlab", + Self::AppImageHub => "appimagehub", + Self::SourceForge => "sourceforge", + Self::DirectUrl => "direct-url", + Self::File => "file", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum SourceInputKind { + RepoShorthand, + GitHubRepositoryUrl, + GitHubReleaseUrl, + GitHubReleaseAssetUrl, + GitLabUrl, + AppImageHubUrl, + AppImageHubShorthand, + SourceForgeUrl, + DirectUrl, + File, +} + +impl SourceInputKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::RepoShorthand => "repo-shorthand", + Self::GitHubRepositoryUrl => "github-repository-url", + Self::GitHubReleaseUrl => "github-release-url", + Self::GitHubReleaseAssetUrl => "github-release-asset-url", + Self::GitLabUrl => "gitlab-url", + Self::AppImageHubUrl => "appimagehub-url", + Self::AppImageHubShorthand => "appimagehub-shorthand", + Self::SourceForgeUrl => "sourceforge-url", + Self::DirectUrl => "direct-url", + Self::File => "file", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum NormalizedSourceKind { + GitHubRepository, + GitHubRelease, + GitHubReleaseAsset, + GitLab, + GitLabCandidate, + AppImageHub, + SourceForge, + SourceForgeCandidate, + DirectUrl, + File, +} + +impl NormalizedSourceKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::GitHubRepository => "github-repository", + Self::GitHubRelease => "github-release", + Self::GitHubReleaseAsset => "github-release-asset", + Self::GitLab => "gitlab", + Self::GitLabCandidate => "gitlab-candidate", + Self::AppImageHub => "appimagehub", + Self::SourceForge => "sourceforge", + Self::SourceForgeCandidate => "sourceforge-candidate", + Self::DirectUrl => "direct-url", + Self::File => "file", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SourceRef { + pub kind: SourceKind, + pub locator: String, + #[serde(default = "default_source_input_kind")] + pub input_kind: SourceInputKind, + #[serde(default = "default_normalized_source_kind")] + pub normalized_kind: NormalizedSourceKind, + #[serde(default)] + pub canonical_locator: Option, + #[serde(default)] + pub requested_tag: Option, + #[serde(default)] + pub requested_asset_name: Option, + #[serde(default)] + pub tracks_latest: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ResolvedRelease { + pub version: String, + #[serde(default)] + pub prerelease: bool, +} + +const fn default_source_input_kind() -> SourceInputKind { + SourceInputKind::DirectUrl +} + +const fn default_normalized_source_kind() -> NormalizedSourceKind { + NormalizedSourceKind::DirectUrl +} diff --git a/crates/upm-module-api/src/domain/update.rs b/crates/upm-module-api/src/domain/update.rs new file mode 100644 index 0000000..e7538a7 --- /dev/null +++ b/crates/upm-module-api/src/domain/update.rs @@ -0,0 +1,42 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum UpdateChannelKind { + GitHubReleases, + ElectronBuilder, + Zsync, + DirectAsset, +} + +impl UpdateChannelKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::GitHubReleases => "github-releases", + Self::ElectronBuilder => "electron-builder", + Self::Zsync => "zsync", + Self::DirectAsset => "direct-asset-lineage", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ChannelPreference { + pub kind: UpdateChannelKind, + pub locator: String, + pub reason: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct UpdateStrategy { + pub preferred: ChannelPreference, + #[serde(default)] + pub alternates: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArtifactCandidate { + pub url: String, + pub version: String, + pub arch: Option, + pub trusted_checksum: Option, + pub weak_checksum_md5: Option, + pub selection_reason: String, +} diff --git a/crates/upm-module-api/src/lib.rs b/crates/upm-module-api/src/lib.rs new file mode 100644 index 0000000..80690c1 --- /dev/null +++ b/crates/upm-module-api/src/lib.rs @@ -0,0 +1,3 @@ +pub mod adapters; +pub mod app; +pub mod domain; diff --git a/crates/upm/src/lib.rs b/crates/upm/src/lib.rs index ba99cb5..e6a9cc1 100644 --- a/crates/upm/src/lib.rs +++ b/crates/upm/src/lib.rs @@ -7,19 +7,12 @@ use std::collections::{HashMap, HashSet}; use std::env; use std::path::{Path, PathBuf}; -use upm_core::app::add::{ - AddPlan, AddSecurityPolicy, InstalledApp, - build_add_plan_with_reporter_and_registered_providers, install_app_with_reporter, - resolve_requested_scope, -}; -use upm_core::app::list::{ListRow, build_list_rows}; +use upm_core::app::add::{AddPlan, AddSecurityPolicy, InstalledApp, resolve_requested_scope}; +use upm_core::app::list::ListRow; use upm_core::app::progress::{ NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter, }; use upm_core::app::remove::{RemovalResult, remove_registered_app_with_reporter}; -use upm_core::app::search::build_search_results_with_registered_providers; -use upm_core::app::show::{build_installed_show_results, build_show_result}; -use upm_core::app::update::{build_update_plan, execute_updates_with_reporter_and_policy}; use upm_core::domain::app::AppRecord; use upm_core::domain::search::{SearchQuery, SearchResults}; use upm_core::domain::show::{InstalledShow, ShowResult}; @@ -54,14 +47,15 @@ pub fn dispatch_with_reporter_and_config( let store = RegistryStore::new(registry_path); let registry = store.load()?; let apps = registry.apps.clone(); + let app = providers::application(); if cli.is_review_update_flow() { - return Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?)); + return Ok(DispatchResult::UpdatePlan(app.build_update_plan(&apps)?)); } if let Some(command) = cli.command { return match command { - cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))), + cli::args::Command::List => Ok(DispatchResult::List(app.list(&apps))), cli::args::Command::Remove { query } => { let removal = remove_registered_app_with_reporter(&query, &apps, &install_home, reporter)?; @@ -82,13 +76,7 @@ pub fn dispatch_with_reporter_and_config( kind: OperationKind::Search, label: query.clone(), }); - let results = providers::with_provider_registry(|providers| { - build_search_results_with_registered_providers( - &SearchQuery::new(&query), - &apps, - providers, - ) - })?; + let results = app.search(&SearchQuery::new(&query), &apps)?; reporter.report(&OperationEvent::Finished { summary: format!("search complete: {} remote hits", results.remote_hits.len()), }); @@ -96,13 +84,13 @@ pub fn dispatch_with_reporter_and_config( } cli::args::Command::Show { value } => match value { Some(value) => { - let result = build_show_result(&value, &apps)?; + let result = app.show(&value, &apps)?; Ok(DispatchResult::Show(Box::new(result))) } - None => Ok(DispatchResult::ShowAll(build_installed_show_results(&apps))), + None => Ok(DispatchResult::ShowAll(app.show_all(&apps))), }, cli::args::Command::Update => { - let updates = execute_updates_with_reporter_and_policy( + let updates = app.execute_updates( &apps, &install_home, reporter, @@ -131,18 +119,13 @@ pub fn dispatch_with_reporter_and_config( if let Some(query) = cli.query { let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root()); - let transport = upm_core::source::github::default_transport(); - let plan_result = providers::with_provider_registry(|providers| { - build_add_plan_with_reporter_and_registered_providers( - &query, - transport.as_ref(), - reporter, - providers, - AddSecurityPolicy { - allow_http_user_sources: config.allow_http, - }, - ) - }); + let plan_result = app.build_add_plan_with_reporter( + &query, + reporter, + AddSecurityPolicy { + allow_http_user_sources: config.allow_http, + }, + ); let mut plan = match plan_result { Ok(plan) => plan, Err( @@ -155,13 +138,7 @@ pub fn dispatch_with_reporter_and_config( kind: OperationKind::Search, label: query.clone(), }); - let results = providers::with_provider_registry(|providers| { - build_search_results_with_registered_providers( - &SearchQuery::new(&query), - &apps, - providers, - ) - })?; + let results = app.search(&SearchQuery::new(&query), &apps)?; reporter.report(&OperationEvent::Finished { summary: format!("search complete: {} remote hits", results.remote_hits.len()), }); @@ -178,8 +155,7 @@ pub fn dispatch_with_reporter_and_config( } } - let installed = - install_app_with_reporter(&query, &plan, &install_home, requested_scope, reporter)?; + let installed = app.install_app(&query, &plan, &install_home, requested_scope, reporter)?; reporter.report(&OperationEvent::StageChanged { stage: OperationStage::SaveRegistry, message: "saving registry".to_owned(), diff --git a/crates/upm/src/providers.rs b/crates/upm/src/providers.rs index 4bcc721..f14e5c3 100644 --- a/crates/upm/src/providers.rs +++ b/crates/upm/src/providers.rs @@ -1,16 +1,3 @@ -use upm_appimage::AppImageHubAddProvider; -use upm_appimage::AppImageHubSearchProvider; -use upm_appimage::source::appimagehub; -use upm_core::ProviderRegistry; - -pub fn with_provider_registry(build: impl FnOnce(&ProviderRegistry<'_>) -> T) -> T { - let appimagehub_transport = appimagehub::default_transport(); - let appimagehub_search = AppImageHubSearchProvider::new(appimagehub_transport.as_ref()); - let appimagehub_add = AppImageHubAddProvider::new(appimagehub_transport.as_ref()); - let providers = ProviderRegistry { - search_providers: vec![&appimagehub_search], - external_add_providers: vec![&appimagehub_add], - }; - - build(&providers) +pub fn application() -> upm_core::UpmApp<'static> { + upm_core::UpmApp::new() }