From 71f89dde9c99366f57ebf0abf9659e8f614c0b92 Mon Sep 17 00:00:00 2001 From: stoorps Date: Thu, 19 Mar 2026 18:46:50 +0000 Subject: [PATCH] initial skeleton --- .gitignore | 1 + .../2026-03-19-appimage-manager-design.md | 469 ++++++++++ ...19-appimage-manager-implementation-plan.md | 870 ++++++++++++++++++ AGENTS.md | 3 + Cargo.lock | 780 ++++++++++++++++ Cargo.toml | 21 + README.md | 40 + crates/aim-cli/Cargo.toml | 21 + crates/aim-cli/src/cli/args.rs | 31 + crates/aim-cli/src/cli/mod.rs | 1 + crates/aim-cli/src/lib.rs | 108 +++ crates/aim-cli/src/main.rs | 15 + crates/aim-cli/src/ui/mod.rs | 2 + crates/aim-cli/src/ui/prompt.rs | 3 + crates/aim-cli/src/ui/render.rs | 39 + crates/aim-cli/tests/cli_commands.rs | 13 + crates/aim-cli/tests/cli_smoke.rs | 7 + crates/aim-cli/tests/end_to_end_cli.rs | 54 ++ crates/aim-cli/tests/ui_summary.rs | 7 + crates/aim-core/Cargo.toml | 15 + crates/aim-core/src/adapters/custom_json.rs | 13 + crates/aim-core/src/adapters/direct_url.rs | 37 + crates/aim-core/src/adapters/github.rs | 50 + crates/aim-core/src/adapters/gitlab.rs | 40 + crates/aim-core/src/adapters/mod.rs | 19 + crates/aim-core/src/adapters/sourceforge.rs | 16 + crates/aim-core/src/adapters/test_support.rs | 27 + crates/aim-core/src/adapters/traits.rs | 29 + crates/aim-core/src/adapters/zsync.rs | 13 + crates/aim-core/src/app/add.rs | 36 + crates/aim-core/src/app/identity.rs | 77 ++ crates/aim-core/src/app/interaction.rs | 4 + crates/aim-core/src/app/list.rs | 16 + crates/aim-core/src/app/mod.rs | 8 + crates/aim-core/src/app/query.rs | 55 ++ crates/aim-core/src/app/remove.rs | 80 ++ crates/aim-core/src/app/scope.rs | 28 + crates/aim-core/src/app/update.rs | 17 + crates/aim-core/src/domain/app.rs | 25 + crates/aim-core/src/domain/mod.rs | 3 + crates/aim-core/src/domain/source.rs | 29 + crates/aim-core/src/domain/update.rs | 10 + crates/aim-core/src/integration/install.rs | 14 + crates/aim-core/src/integration/mod.rs | 2 + crates/aim-core/src/integration/paths.rs | 40 + crates/aim-core/src/lib.rs | 6 + crates/aim-core/src/platform/mod.rs | 25 + crates/aim-core/src/registry/mod.rs | 2 + crates/aim-core/src/registry/model.rs | 14 + crates/aim-core/src/registry/store.rs | 59 ++ crates/aim-core/tests/adapter_contract.rs | 7 + crates/aim-core/tests/adapter_smoke.rs | 12 + crates/aim-core/tests/github_add_flow.rs | 18 + crates/aim-core/tests/identity_resolution.rs | 31 + crates/aim-core/tests/install_paths.rs | 21 + crates/aim-core/tests/install_scope.rs | 8 + crates/aim-core/tests/query_resolution.rs | 8 + crates/aim-core/tests/registry_roundtrip.rs | 10 + crates/aim-core/tests/remove_flow.rs | 49 + crates/aim-core/tests/update_planning.rs | 22 + 60 files changed, 3480 insertions(+) create mode 100644 .gitignore create mode 100644 .plans/000-appimage-manager/2026-03-19-appimage-manager-design.md create mode 100644 .plans/000-appimage-manager/2026-03-19-appimage-manager-implementation-plan.md create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/aim-cli/Cargo.toml create mode 100644 crates/aim-cli/src/cli/args.rs create mode 100644 crates/aim-cli/src/cli/mod.rs create mode 100644 crates/aim-cli/src/lib.rs create mode 100644 crates/aim-cli/src/main.rs create mode 100644 crates/aim-cli/src/ui/mod.rs create mode 100644 crates/aim-cli/src/ui/prompt.rs create mode 100644 crates/aim-cli/src/ui/render.rs create mode 100644 crates/aim-cli/tests/cli_commands.rs create mode 100644 crates/aim-cli/tests/cli_smoke.rs create mode 100644 crates/aim-cli/tests/end_to_end_cli.rs create mode 100644 crates/aim-cli/tests/ui_summary.rs create mode 100644 crates/aim-core/Cargo.toml create mode 100644 crates/aim-core/src/adapters/custom_json.rs create mode 100644 crates/aim-core/src/adapters/direct_url.rs create mode 100644 crates/aim-core/src/adapters/github.rs create mode 100644 crates/aim-core/src/adapters/gitlab.rs create mode 100644 crates/aim-core/src/adapters/mod.rs create mode 100644 crates/aim-core/src/adapters/sourceforge.rs create mode 100644 crates/aim-core/src/adapters/test_support.rs create mode 100644 crates/aim-core/src/adapters/traits.rs create mode 100644 crates/aim-core/src/adapters/zsync.rs create mode 100644 crates/aim-core/src/app/add.rs create mode 100644 crates/aim-core/src/app/identity.rs create mode 100644 crates/aim-core/src/app/interaction.rs create mode 100644 crates/aim-core/src/app/list.rs create mode 100644 crates/aim-core/src/app/mod.rs create mode 100644 crates/aim-core/src/app/query.rs create mode 100644 crates/aim-core/src/app/remove.rs create mode 100644 crates/aim-core/src/app/scope.rs create mode 100644 crates/aim-core/src/app/update.rs create mode 100644 crates/aim-core/src/domain/app.rs create mode 100644 crates/aim-core/src/domain/mod.rs create mode 100644 crates/aim-core/src/domain/source.rs create mode 100644 crates/aim-core/src/domain/update.rs create mode 100644 crates/aim-core/src/integration/install.rs create mode 100644 crates/aim-core/src/integration/mod.rs create mode 100644 crates/aim-core/src/integration/paths.rs create mode 100644 crates/aim-core/src/lib.rs create mode 100644 crates/aim-core/src/platform/mod.rs create mode 100644 crates/aim-core/src/registry/mod.rs create mode 100644 crates/aim-core/src/registry/model.rs create mode 100644 crates/aim-core/src/registry/store.rs create mode 100644 crates/aim-core/tests/adapter_contract.rs create mode 100644 crates/aim-core/tests/adapter_smoke.rs create mode 100644 crates/aim-core/tests/github_add_flow.rs create mode 100644 crates/aim-core/tests/identity_resolution.rs create mode 100644 crates/aim-core/tests/install_paths.rs create mode 100644 crates/aim-core/tests/install_scope.rs create mode 100644 crates/aim-core/tests/query_resolution.rs create mode 100644 crates/aim-core/tests/registry_roundtrip.rs create mode 100644 crates/aim-core/tests/remove_flow.rs create mode 100644 crates/aim-core/tests/update_planning.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/.plans/000-appimage-manager/2026-03-19-appimage-manager-design.md b/.plans/000-appimage-manager/2026-03-19-appimage-manager-design.md new file mode 100644 index 0000000..63809c0 --- /dev/null +++ b/.plans/000-appimage-manager/2026-03-19-appimage-manager-design.md @@ -0,0 +1,469 @@ +# AppImage Manager Design + +## Goal + +Build AppImage Manager as a Rust workspace where `aim-core` contains the business logic and reusable APIs, and `aim-cli` is a thin terminal client. The first shipped application is the CLI, but the architecture must leave a clean path for a later GUI client to consume the same core install, update, registry, and adapter logic. + +## Agreed Product Shape + +### Command surface + +- `aim {QUERY}`: search and add from a query source +- `aim`: review-first update flow; aliases `aim update` +- `aim update`: explicit update flow +- `aim remove {QUERY}`: remove by registered app name +- `aim list`: list installed AppImages + +### Supported source types + +1. GitHub Releases +2. Direct URL / generic website downloads +3. GitLab Releases +4. zsync / embedded AppImage update info +5. SourceForge +6. Custom JSON feed adapters + +### Install behavior + +- Default installation mode is auto-detected by effective privileges +- `--system` and `--user` override the auto-detected scope +- The tool supports both user and system installations +- The tool performs full desktop-style integration for installed apps + +### Identity and update behavior + +- The system should infer app identity and version when possible +- If confidence is low, the client should prompt interactively for confirmation or edits +- If identity still cannot be stabilized, the registry should fall back to the raw URL as the last-resort key +- Running `aim` with no query should discover updates, present a review list, and then apply only selected updates +- Architecture handling should remain generic: `aim-core` manages whatever AppImage artifact is resolved, while validating obvious mismatches at install time + +## Recommended Architecture + +Use typed source adapters behind a common update engine, packaged in `aim-core` and consumed by thin frontend clients. + +This architecture fits the source diversity without forcing a plugin runtime into v1. Each upstream source gets an explicit Rust adapter that implements a shared contract for identity resolution, release discovery, artifact selection, and update metadata extraction. The shared update engine operates on normalized internal types rather than source-specific details. + +This approach was selected over: + +- a registry-centric-first design, which risks smearing source-specific logic across storage and service layers +- a plugin-first design, which adds packaging, security, and testing complexity too early + +## Workspace Architecture + +The project should be a Cargo workspace with frontend clients over a shared core crate. + +### Workspace crates + +- `crates/aim-core`: all business logic and reusable APIs +- `crates/aim-cli`: thin terminal frontend for the initial shipped application +- `crates/aim-gui`: deferred future GUI client, planned but not implemented in v1 + +The critical rule is that `aim-cli` must not become the home for install, update, registry, or source logic. If behavior should be reusable by a future GUI, it belongs in `aim-core`. + +## Architecture Layers + +The system should be organized into four layers, with the bottom three living in `aim-core`. + +### 1. Client layer + +- Implemented first in `aim-cli` +- Parses commands, flags, and defaults +- Owns presentation only: prompts, colors, spinners, progress bars, and terminal summaries +- Uses: + - `clap` for CLI parsing + - `dialoguer` for interactive prompts and multi-select review flows + - `console` for styled output and readable summaries + - `indicatif` for progress bars and spinners + +This layer should translate user intent into calls into `aim-core` and render responses. It should not contain source-specific business logic, registry mutation logic, or install/update decision logic. + +### 2. Application/service layer + +- Lives in `aim-core` +- Coordinates workflows like add, remove, list, and update +- Applies product rules such as scope selection, update review behavior, and low-confidence identity confirmation +- Suggested services: + - `AddService` + - `UpdateService` + - `RegistryService` + - `IntegrationService` + +### 3. Domain model layer + +- Lives in `aim-core` +- Holds the canonical source-agnostic types used across the system + +Suggested domain types: + +- `AppRecord` +- `InstallScope` +- `SourceRef` +- `SourceKind` +- `ResolvedRelease` +- `InstalledArtifact` +- `UpdatePlan` +- `DesktopIntegration` +- `InteractionRequest` +- `InteractionResponse` + +### 4. Infrastructure layer + +- Lives in `aim-core` +- Source adapters +- Filesystem and install location management +- Registry persistence +- Desktop integration helpers +- Download and HTTP client behavior +- Optional subprocess wrappers for system integration tasks + +## Suggested Module Layout + +Suggested workspace layout: + +- `Cargo.toml` +- `crates/aim-core/Cargo.toml` +- `crates/aim-core/src/lib.rs` +- `crates/aim-core/src/app/` +- `crates/aim-core/src/domain/` +- `crates/aim-core/src/adapters/` +- `crates/aim-core/src/integration/` +- `crates/aim-core/src/registry/` +- `crates/aim-core/src/platform/` +- `crates/aim-cli/Cargo.toml` +- `crates/aim-cli/src/lib.rs` +- `crates/aim-cli/src/main.rs` +- `crates/aim-cli/src/cli/` +- `crates/aim-cli/src/ui/` + +Future-facing placeholder: + +- `crates/aim-gui/` + +This keeps terminal UX separate from the install/update engine and ensures the later GUI can reuse the same core APIs. + +## Core Components + +### Query resolver + +Lives in `aim-core` and turns user input into a normalized `SourceRef`. + +Accepted input forms: + +- URL +- `user_or_org/project` +- file URI +- bare `aim` with no query + +Behavior: + +- Resolve GitHub URLs and `owner/repo` forms to GitHub when unambiguous +- Resolve GitLab URLs and explicit `gitlab:` references to GitLab +- Resolve direct URLs and generic web pages to the direct URL / web adapter +- Resolve `file://` inputs into local import flow + +The query resolver should not perform install logic. + +### Source adapter layer + +Lives in `aim-core`, with one typed adapter per source: + +- GitHub Releases adapter +- GitLab Releases adapter +- Direct URL / generic web adapter +- zsync / embedded update info adapter +- SourceForge adapter +- Custom JSON feed adapter + +Each adapter should expose a shared capability shape: + +- identify app +- enumerate candidate releases +- choose preferred artifact +- expose update metadata +- download or resolve the artifact for download + +Not every source needs to support true search. Some only support exact resolution. The contract should represent those differences honestly. + +### Registry + +Lives in `aim-core` and stores normalized installed app records across user and system scopes. + +It should track: + +- canonical app identity +- display name +- install scope +- source type +- source locator and source-specific update hints +- installed version +- installed artifact path +- artifact fingerprint or hash +- release metadata +- integration artifact paths +- timestamps + +The registry is the bridge between one-time install and repeatable updates, so it must be migration-friendly. + +### Installer and integrator + +Live in `aim-core`. + +Installer responsibilities: + +- staging downloads +- validating artifacts +- moving binaries into managed locations +- setting permissions +- replacing installed artifacts atomically where possible + +Integrator responsibilities: + +- `.desktop` entry generation +- icon extraction or acquisition +- symlink creation +- MIME and related registration where feasible +- correct handling of user vs system targets + +Installer and integration concerns should remain separate so updates can replace binaries without always rebuilding every integration artifact. + +### Update planner and executor + +Live in `aim-core`. + +Planner responsibilities: + +- load registry entries +- ask adapters for update candidates +- compare installed state to available state +- build a reviewable `UpdatePlan` + +Executor responsibilities: + +- apply selected updates +- download and validate updated artifacts +- replace existing artifacts safely +- refresh integration artifacts only when needed +- update registry state +- surface typed results and events for clients + +### Client interaction boundary + +Terminal-specific UI belongs in `aim-cli`, not `aim-core`. + +`aim-core` should expose operation APIs and typed interaction or progress models that clients can render however they want. `aim-cli` should wrap all usage of `dialoguer`, `console`, and `indicatif`. + +This keeps business logic testable without terminal coupling and makes a GUI frontend viable later. + +### Custom JSON feed support + +Custom JSON feeds should be declarative in v1, not arbitrary executable plugins. + +The adapter should support field mapping and release selection rules against a constrained schema family, rather than loading arbitrary code. This delivers flexibility without turning the CLI into a plugin host. + +## End-to-End Data Flow + +### `aim {QUERY}` add flow + +1. `aim-cli` parses CLI input and scope override flags +2. `aim-cli` calls `aim-core` with a normalized request +3. `aim-core` resolves the query into a `SourceRef` +4. `aim-core` selects the appropriate adapter +5. `aim-core` identifies the app and candidate releases +6. `aim-core` returns an interaction state if confidence is low +7. `aim-cli` prompts, then sends the decision back to `aim-core` +8. `aim-core` falls back to raw URL identity if needed +9. `aim-core` downloads to staging +10. `aim-core` validates the artifact as an AppImage and inspects update metadata +11. `aim-core` installs into the correct managed location +12. `aim-core` generates integration artifacts and persists a normalized registry entry + +### `aim` and `aim update` flow + +1. `aim-cli` invokes update discovery in `aim-core` +2. `aim-core` loads relevant registry entries +3. `aim-core` asks each adapter for update candidates +4. `aim-core` builds an `UpdatePlan` +5. `aim-cli` renders the review list and collects selection +6. `aim-core` applies selected updates +7. `aim-core` refreshes registry state and integration artifacts as needed +8. `aim-cli` prints a final success/failure summary + +### `aim list` flow + +- `aim-cli` requests installed app state from `aim-core` and renders the result grouped by scope, source, and version + +### `aim remove {QUERY}` flow + +1. `aim-cli` forwards the query to `aim-core` +2. `aim-core` resolves the query against registered app names +3. `aim-core` emits an interaction request if ambiguity must be resolved +4. `aim-cli` prompts if needed and returns the selection +5. `aim-core` removes artifacts and integration files in the correct order +6. `aim-core` removes registry state while preserving uncertain shared resources conservatively + +## Registry Data Shape + +Each registry record should contain enough source-specific state to make updates reliable without re-deriving identity from filenames. + +Recommended fields: + +- stable app id +- display name +- install scope +- source kind +- source locator +- installed version +- installed file path +- file hash or fingerprint +- release metadata +- updater metadata +- integration artifact paths +- created and updated timestamps + +Examples of source-specific metadata: + +- GitHub/GitLab: owner, repo, release/tag, asset selection hints +- Direct URL: original URL, resolved URL, etag, last-modified when available +- zsync: zsync URL or embedded update info extracted from the AppImage +- SourceForge: project and file path hints +- Custom JSON feed: feed URL plus mapping profile + +## Error Handling Model + +Error handling should be structured internally and concise externally. + +`aim-core` should own structured error types and machine-readable outcomes. `aim-cli` should map those into concise terminal messages. A future GUI should be able to present the same failures without reparsing CLI text. + +Suggested error categories: + +- query resolution error +- source adapter error +- network/download error +- artifact validation error +- install permission or scope error +- desktop integration error +- registry persistence error +- update planning error + +Behavioral expectations: + +- prompt on low-confidence identity rather than silently guessing +- fail clearly on insufficient privileges for system install unless explicit elevation behavior is designed later +- continue update processing across apps when one app fails +- fail-fast within a single app transaction unless a step is intentionally non-fatal +- either roll back on integration failure or explicitly record the app as installed-but-needing-repair + +For v1, prefer: + +- best-effort continuation across apps during update runs +- fail-fast inside a single app update or install transaction +- atomic replacement where possible +- future room for an `aim repair` command, even if not implemented in v1 + +## Testing Strategy + +Testing should map directly to the architecture layers. + +### `aim-core` unit tests + +- query parsing and source resolution +- identity normalization and fallback logic +- version comparison and update selection logic +- install scope resolution +- registry serialization and migrations +- adapter-specific parsing helpers + +### Shared adapter contract tests + +Every adapter should pass a common behavior suite where applicable: + +- can identify app +- can resolve latest candidate +- reports unsupported capabilities honestly +- produces normalized release metadata + +This is the primary protection against drift across heterogeneous source implementations. + +### `aim-core` integration tests + +- add flow per source type using fixtures or mocked HTTP +- update planning across mixed registry entries +- remove flow cleaning registry and integration artifacts +- user vs system path resolution +- registry migration compatibility + +### Filesystem tests + +Use temp directories to simulate: + +- user install roots +- system install roots +- desktop entry locations +- icon and symlink generation + +### `aim-cli` client behavior tests + +- snapshot or golden tests for key terminal flows +- update review list interaction +- low-confidence identity prompt +- success and failure summaries + +Most behavioral coverage should target `aim-core`, with only thin client verification in `aim-cli`. + +Avoid relying on live network tests in the main suite. Keep those as optional smoke coverage. + +### Main risks the test plan must cover + +1. Incorrect identity causing duplicate or non-updatable entries +2. Source-specific regressions hidden behind a shared API surface +3. Incomplete rollback leaving broken installs +4. Scope confusion causing files to land in the wrong locations +5. Business logic leaking into `aim-cli` and diverging from future GUI needs + +## Recommended Persisted Formats And Key Decisions + +### Persisted formats + +- Use a structured registry file or registry store that is easy to migrate and inspect +- Keep source-specific update metadata embedded in each app record rather than scattered across auxiliary files +- Store integration artifact paths explicitly so removal and repair remain deterministic + +### Key design decisions + +- Use a Cargo workspace with `aim-core` and `aim-cli` +- Put all business logic in `aim-core` +- Keep `aim-cli` as a thin terminal adapter over `aim-core` +- Design `aim-core` to be reusable by a future `aim-gui` +- Use typed Rust adapters behind a common update engine +- Normalize identity early and once +- Separate update planning from update execution +- Treat custom JSON feeds as declarative adapters, not executable plugins +- Auto-detect scope by effective privileges, with `--system` and `--user` overrides +- Make bare `aim` a review-first update path + +## Explicit v1 Boundaries + +Included in v1: + +- Cargo workspace with `aim-core` and `aim-cli` +- multi-source AppImage add flow +- user and system scope support +- update planning and selected update execution +- desktop-style integration +- typed adapters for the agreed source list +- declarative custom JSON feed support + +Deferred from v1: + +- `aim-gui` implementation +- general plugin runtime +- arbitrary executable custom adapters +- broad distro-specific deep integration beyond the agreed desktop registration model +- live network-dependent test suite as the main verification strategy +- repair and doctor commands, though the design should leave room for them + +## Open Implementation Notes + +- Because the current workspace is not a git repository, the design document can be saved but not committed yet +- The next step should be an implementation plan that breaks this design into small TDD-oriented tasks \ No newline at end of file diff --git a/.plans/000-appimage-manager/2026-03-19-appimage-manager-implementation-plan.md b/.plans/000-appimage-manager/2026-03-19-appimage-manager-implementation-plan.md new file mode 100644 index 0000000..7e67427 --- /dev/null +++ b/.plans/000-appimage-manager/2026-03-19-appimage-manager-implementation-plan.md @@ -0,0 +1,870 @@ +# AppImage Manager Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a Rust CLI named `aim` that installs, lists, removes, and review-updates AppImages from multiple source types with full desktop-style integration for user and system scopes. + +**Architecture:** Use a single Rust binary with a thin CLI layer over application services, typed source adapters, a normalized registry, and separate installer/integration/update subsystems. Build the project incrementally with test-first steps so the registry model, source resolution, and update planning remain stable as additional adapters land. + +**Tech Stack:** Rust, Cargo, clap, dialoguer, console, indicatif, serde, toml or sqlite-backed persistence, reqwest, tokio, tempfile, assert_cmd, predicates, insta or similar snapshot tooling. + +--- + +### Task 1: Scaffold the Cargo project and dependency baseline + +**Files:** +- Create: `Cargo.toml` +- Create: `src/main.rs` +- Create: `src/lib.rs` +- Create: `tests/cli_smoke.rs` +- Create: `.gitignore` + +**Step 1: Write the failing test** + +```rust +use assert_cmd::Command; + +#[test] +fn cli_shows_help() { + let mut cmd = Command::cargo_bin("aim").unwrap(); + cmd.arg("--help").assert().success(); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test cli_shows_help --test cli_smoke` +Expected: FAIL because the crate and binary do not exist yet + +**Step 3: Write minimal implementation** + +Create a minimal Cargo package with the `aim` binary, library entry point, and an empty `main` using `clap` derive to print help successfully. + +**Step 4: Run test to verify it passes** + +Run: `cargo test cli_shows_help --test cli_smoke` +Expected: PASS + +**Step 5: Commit** + +```bash +git add Cargo.toml src/main.rs src/lib.rs tests/cli_smoke.rs .gitignore +git commit -m "chore: scaffold aim cargo project" +``` + +### Task 2: Add the command surface and top-level CLI parsing + +**Files:** +- Modify: `src/main.rs` +- Create: `src/cli/mod.rs` +- Create: `src/cli/args.rs` +- Test: `tests/cli_commands.rs` + +**Step 1: Write the failing test** + +```rust +use assert_cmd::Command; +use predicates::str::contains; + +#[test] +fn help_lists_expected_commands() { + let mut cmd = Command::cargo_bin("aim").unwrap(); + cmd.arg("--help") + .assert() + .success() + .stdout(contains("remove")) + .stdout(contains("list")) + .stdout(contains("update")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test help_lists_expected_commands --test cli_commands` +Expected: FAIL because subcommands and positional query parsing are not implemented + +**Step 3: Write minimal implementation** + +Implement: +- positional optional query for bare `aim {QUERY}` +- `remove {QUERY}` +- `list` +- `update` +- shared `--system` and `--user` scope override flags where appropriate + +**Step 4: Run test to verify it passes** + +Run: `cargo test help_lists_expected_commands --test cli_commands` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/main.rs src/cli/mod.rs src/cli/args.rs tests/cli_commands.rs +git commit -m "feat: add top-level cli command parsing" +``` + +### Task 3: Define the core domain types and install scope resolution + +**Files:** +- Create: `src/domain/mod.rs` +- Create: `src/domain/app.rs` +- Create: `src/domain/source.rs` +- Create: `src/domain/update.rs` +- Create: `src/app/mod.rs` +- Create: `src/app/scope.rs` +- Test: `tests/install_scope.rs` + +**Step 1: Write the failing test** + +```rust +use aim::app::scope::{resolve_install_scope, ScopeOverride}; +use aim::domain::app::InstallScope; + +#[test] +fn explicit_scope_override_beats_effective_user() { + let scope = resolve_install_scope(false, ScopeOverride::System); + assert_eq!(scope, InstallScope::System); +} +``` + +**Step 2: Run test to verify it fails** + +# AppImage Manager Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a Rust workspace where `aim-core` implements AppImage management logic and `aim-cli` provides a thin terminal frontend for install, list, remove, and review-update flows. + +**Architecture:** Use a Cargo workspace with `aim-core` holding domain models, services, adapters, registry, installer, and update logic, while `aim-cli` only parses arguments, renders terminal UX, and delegates to `aim-core`. Keep client-facing boundaries explicit so a later GUI crate can reuse `aim-core` without moving logic back out of the library. + +**Tech Stack:** Rust, Cargo, clap, dialoguer, console, indicatif, serde, toml or sqlite-backed persistence, reqwest, tokio, tempfile, assert_cmd, predicates, insta or similar snapshot tooling. + +--- + +### Task 1: Scaffold the Cargo workspace baseline + +**Files:** +- Create: `Cargo.toml` +- Create: `crates/aim-core/Cargo.toml` +- Create: `crates/aim-core/src/lib.rs` +- Create: `crates/aim-cli/Cargo.toml` +- Create: `crates/aim-cli/src/lib.rs` +- Create: `crates/aim-cli/src/main.rs` +- Create: `tests/cli_smoke.rs` +- Create: `.gitignore` + +**Step 1: Write the failing test** + +```rust +use assert_cmd::Command; + +#[test] +fn cli_shows_help() { + let mut cmd = Command::cargo_bin("aim").unwrap(); + cmd.arg("--help").assert().success(); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test cli_shows_help --test cli_smoke` +Expected: FAIL because the workspace and binary do not exist yet + +**Step 3: Write minimal implementation** + +Create a minimal Cargo workspace with `aim-core` and `aim-cli`, wiring the `aim` binary through `aim-cli` and exposing a library entry point from `aim-core`. + +**Step 4: Run test to verify it passes** + +Run: `cargo test cli_shows_help --test cli_smoke` +Expected: PASS + +**Step 5: Commit** + +```bash +git add Cargo.toml crates/aim-core/Cargo.toml crates/aim-core/src/lib.rs crates/aim-cli/Cargo.toml crates/aim-cli/src/lib.rs crates/aim-cli/src/main.rs tests/cli_smoke.rs .gitignore +git commit -m "chore: scaffold aim workspace" +``` + +### Task 2: Add the thin CLI command surface + +**Files:** +- Modify: `crates/aim-cli/src/main.rs` +- Create: `crates/aim-cli/src/cli/mod.rs` +- Create: `crates/aim-cli/src/cli/args.rs` +- Test: `tests/cli_commands.rs` + +**Step 1: Write the failing test** + +```rust +use assert_cmd::Command; +use predicates::str::contains; + +#[test] +fn help_lists_expected_commands() { + let mut cmd = Command::cargo_bin("aim").unwrap(); + cmd.arg("--help") + .assert() + .success() + .stdout(contains("remove")) + .stdout(contains("list")) + .stdout(contains("update")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test help_lists_expected_commands --test cli_commands` +Expected: FAIL because subcommands and positional query parsing are not implemented + +**Step 3: Write minimal implementation** + +Implement only: +- positional optional query for bare `aim {QUERY}` +- `remove {QUERY}` +- `list` +- `update` +- shared `--system` and `--user` scope override flags where appropriate + +Do not add business logic here beyond command parsing and delegation stubs. + +**Step 4: Run test to verify it passes** + +Run: `cargo test help_lists_expected_commands --test cli_commands` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-cli/src/main.rs crates/aim-cli/src/cli/mod.rs crates/aim-cli/src/cli/args.rs tests/cli_commands.rs +git commit -m "feat: add thin cli command parsing" +``` + +### Task 3: Define the core domain types and install scope resolution + +**Files:** +- Create: `crates/aim-core/src/domain/mod.rs` +- Create: `crates/aim-core/src/domain/app.rs` +- Create: `crates/aim-core/src/domain/source.rs` +- Create: `crates/aim-core/src/domain/update.rs` +- Create: `crates/aim-core/src/app/mod.rs` +- Create: `crates/aim-core/src/app/scope.rs` +- Test: `tests/install_scope.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::app::scope::{resolve_install_scope, ScopeOverride}; +use aim_core::domain::app::InstallScope; + +#[test] +fn explicit_scope_override_beats_effective_user() { + let scope = resolve_install_scope(false, ScopeOverride::System); + assert_eq!(scope, InstallScope::System); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test explicit_scope_override_beats_effective_user --test install_scope` +Expected: FAIL because core domain types and scope logic do not exist yet + +**Step 3: Write minimal implementation** + +Add domain types for: +- `InstallScope` +- `AppRecord` +- `SourceKind` +- `SourceRef` +- `ResolvedRelease` +- `UpdatePlan` + +Add scope resolution logic that: +- auto-detects by effective privileges +- honors `--system` and `--user` overrides + +**Step 4: Run test to verify it passes** + +Run: `cargo test explicit_scope_override_beats_effective_user --test install_scope` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/domain crates/aim-core/src/app tests/install_scope.rs +git commit -m "feat: add core domain types and scope resolution" +``` + +### Task 4: Implement query parsing and source reference resolution in `aim-core` + +**Files:** +- Create: `crates/aim-core/src/app/query.rs` +- Modify: `crates/aim-core/src/domain/source.rs` +- Test: `tests/query_resolution.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::app::query::resolve_query; +use aim_core::domain::source::SourceKind; + +#[test] +fn owner_repo_defaults_to_github() { + let source = resolve_query("sharkdp/bat").unwrap(); + assert_eq!(source.kind, SourceKind::GitHub); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test owner_repo_defaults_to_github --test query_resolution` +Expected: FAIL because query resolution is not implemented + +**Step 3: Write minimal implementation** + +Support parsing for: +- `owner/repo` as GitHub by default +- GitHub URLs +- GitLab URLs and explicit `gitlab:` prefix +- direct URLs +- `file://` URIs + +Return a normalized `SourceRef` without triggering downloads or installation. + +**Step 4: Run test to verify it passes** + +Run: `cargo test owner_repo_defaults_to_github --test query_resolution` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/query.rs crates/aim-core/src/domain/source.rs tests/query_resolution.rs +git commit -m "feat: resolve user queries into source references" +``` + +### Task 5: Add registry persistence and migration-friendly app records in `aim-core` + +**Files:** +- Create: `crates/aim-core/src/registry/mod.rs` +- Create: `crates/aim-core/src/registry/store.rs` +- Create: `crates/aim-core/src/registry/model.rs` +- Test: `tests/registry_roundtrip.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::registry::store::RegistryStore; +use tempfile::tempdir; + +#[test] +fn registry_round_trips_app_records() { + let dir = tempdir().unwrap(); + let store = RegistryStore::new(dir.path().join("registry.toml")); + let loaded = store.load().unwrap(); + assert!(loaded.apps.is_empty()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test registry_round_trips_app_records --test registry_roundtrip` +Expected: FAIL because no registry store exists + +**Step 3: Write minimal implementation** + +Implement a registry store with: +- serialized root structure +- normalized `AppRecord` persistence +- version field for future migrations +- read and write APIs + +Choose a storage format that is easy to inspect and migrate, such as TOML or SQLite. + +**Step 4: Run test to verify it passes** + +Run: `cargo test registry_round_trips_app_records --test registry_roundtrip` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/registry tests/registry_roundtrip.rs +git commit -m "feat: add persistent core registry store" +``` + +### Task 6: Build the source adapter trait and contract harness in `aim-core` + +**Files:** +- Create: `crates/aim-core/src/adapters/mod.rs` +- Create: `crates/aim-core/src/adapters/traits.rs` +- Create: `crates/aim-core/src/adapters/test_support.rs` +- Test: `tests/adapter_contract.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::adapters::traits::AdapterCapabilities; + +#[test] +fn adapter_capabilities_can_report_exact_resolution_only() { + let capabilities = AdapterCapabilities::exact_resolution_only(); + assert!(!capabilities.supports_search); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test adapter_capabilities_can_report_exact_resolution_only --test adapter_contract` +Expected: FAIL because adapter abstractions do not exist + +**Step 3: Write minimal implementation** + +Define: +- `SourceAdapter` trait +- capability flags +- normalized adapter response types +- reusable test helpers for contract behavior + +Do not implement network-backed adapters yet. Focus on the stable core trait surface. + +**Step 4: Run test to verify it passes** + +Run: `cargo test adapter_capabilities_can_report_exact_resolution_only --test adapter_contract` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters tests/adapter_contract.rs +git commit -m "feat: add source adapter trait and contract surface" +``` + +### Task 7: Define client interaction models in `aim-core` and thin terminal rendering in `aim-cli` + +**Files:** +- Create: `crates/aim-core/src/app/interaction.rs` +- Create: `crates/aim-cli/src/ui/mod.rs` +- Create: `crates/aim-cli/src/ui/render.rs` +- Create: `crates/aim-cli/src/ui/prompt.rs` +- Test: `tests/ui_summary.rs` + +**Step 1: Write the failing test** + +```rust +use aim_cli::ui::render::render_update_summary; + +#[test] +fn update_summary_mentions_selected_count() { + let output = render_update_summary(3, 2, 1); + assert!(output.contains("selected: 2")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test update_summary_mentions_selected_count --test ui_summary` +Expected: FAIL because client rendering helpers do not exist + +**Step 3: Write minimal implementation** + +Create: +- typed interaction and progress models in `aim-core` +- a thin CLI UI facade in `aim-cli` that centralizes styling with `console` +- prompt orchestration using `dialoguer` + +Do not move any business rules into `aim-cli`. + +**Step 4: Run test to verify it passes** + +Run: `cargo test update_summary_mentions_selected_count --test ui_summary` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/interaction.rs crates/aim-cli/src/ui tests/ui_summary.rs +git commit -m "feat: add core interaction models and thin cli ui" +``` + +### Task 8: Implement installer and desktop integration path resolution in `aim-core` + +**Files:** +- Create: `crates/aim-core/src/integration/mod.rs` +- Create: `crates/aim-core/src/integration/paths.rs` +- Create: `crates/aim-core/src/integration/install.rs` +- Create: `crates/aim-core/src/platform/mod.rs` +- Test: `tests/install_paths.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::domain::app::InstallScope; +use aim_core::integration::paths::managed_appimage_path; +use std::path::Path; + +#[test] +fn user_scope_path_lands_under_home_managed_dir() { + let path = managed_appimage_path(Path::new("/home/test"), InstallScope::User, "bat"); + assert!(path.to_string_lossy().contains("bat")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test user_scope_path_lands_under_home_managed_dir --test install_paths` +Expected: FAIL because install path logic does not exist + +**Step 3: Write minimal implementation** + +Implement: +- managed install path resolution for user and system scopes +- integration artifact path calculation +- atomic staging and replacement helpers + +Keep actual desktop registration side effects behind abstractions so they remain testable. + +**Step 4: Run test to verify it passes** + +Run: `cargo test user_scope_path_lands_under_home_managed_dir --test install_paths` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/integration crates/aim-core/src/platform tests/install_paths.rs +git commit -m "feat: add core install and integration path handling" +``` + +### Task 9: Implement identity normalization and raw URL fallback in `aim-core` + +**Files:** +- Create: `crates/aim-core/src/app/identity.rs` +- Modify: `crates/aim-core/src/domain/app.rs` +- Test: `tests/identity_resolution.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::app::identity::{resolve_identity, IdentityFallback}; + +#[test] +fn unresolved_identity_can_fall_back_to_url() { + let identity = resolve_identity(None, None, Some("https://example.com/app.AppImage"), IdentityFallback::AllowRawUrl).unwrap(); + assert!(identity.stable_id.contains("example.com")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test unresolved_identity_can_fall_back_to_url --test identity_resolution` +Expected: FAIL because identity resolution does not exist + +**Step 3: Write minimal implementation** + +Implement identity normalization with: +- confident resolution path +- low-confidence state handling +- raw URL fallback when allowed + +Keep the prompting decision outside this module so the logic remains deterministic and reusable across CLI and GUI clients. + +**Step 4: Run test to verify it passes** + +Run: `cargo test unresolved_identity_can_fall_back_to_url --test identity_resolution` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/identity.rs crates/aim-core/src/domain/app.rs tests/identity_resolution.rs +git commit -m "feat: add core identity normalization and fallback logic" +``` + +### Task 10: Implement update planning in `aim-core` and review-first dispatch in `aim-cli` + +**Files:** +- Create: `crates/aim-core/src/app/update.rs` +- Modify: `crates/aim-cli/src/cli/args.rs` +- Modify: `crates/aim-cli/src/main.rs` +- Test: `tests/update_planning.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::app::update::build_update_plan; + +#[test] +fn empty_registry_produces_empty_plan() { + let plan = build_update_plan(&[]).unwrap(); + assert!(plan.items.is_empty()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test empty_registry_produces_empty_plan --test update_planning` +Expected: FAIL because update planning does not exist + +**Step 3: Write minimal implementation** + +Implement: +- update plan model in `aim-core` +- comparison of installed state against adapter-provided candidate data +- bare `aim` dispatch in `aim-cli` into the `aim-core` update planning path when no positional query is present + +Do not execute downloads yet in this task. Focus on planning and command dispatch. + +**Step 4: Run test to verify it passes** + +Run: `cargo test empty_registry_produces_empty_plan --test update_planning` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/update.rs crates/aim-cli/src/cli/args.rs crates/aim-cli/src/main.rs tests/update_planning.rs +git commit -m "feat: add core update planning and cli dispatch" +``` + +### Task 11: Add the GitHub adapter and one core add flow + +**Files:** +- Create: `crates/aim-core/src/adapters/github.rs` +- Create: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-core/src/adapters/mod.rs` +- Modify: `crates/aim-cli/src/main.rs` +- Test: `tests/github_add_flow.rs` + +**Step 1: Write the failing test** + +```rust +#[test] +fn github_adapter_can_normalize_owner_repo_source() { + let source = aim_core::app::query::resolve_query("sharkdp/bat").unwrap(); + assert_eq!(source.kind.as_str(), "github"); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test github_adapter_can_normalize_owner_repo_source --test github_add_flow` +Expected: FAIL because the add flow and GitHub adapter are not wired into the core services + +**Step 3: Write minimal implementation** + +Implement: +- GitHub adapter skeleton in `aim-core` +- add orchestration flow in `aim-core` from query resolution to normalized release selection +- minimal `aim-cli` wiring to invoke the add flow +- fixture-backed or mocked HTTP path for tests + +**Step 4: Run test to verify it passes** + +Run: `cargo test github_adapter_can_normalize_owner_repo_source --test github_add_flow` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters/github.rs crates/aim-core/src/app/add.rs crates/aim-core/src/adapters/mod.rs crates/aim-cli/src/main.rs tests/github_add_flow.rs +git commit -m "feat: add github source adapter and core add flow" +``` + +### Task 12: Add remaining adapters behind the same core contract + +**Files:** +- Create: `crates/aim-core/src/adapters/gitlab.rs` +- Create: `crates/aim-core/src/adapters/direct_url.rs` +- Create: `crates/aim-core/src/adapters/zsync.rs` +- Create: `crates/aim-core/src/adapters/sourceforge.rs` +- Create: `crates/aim-core/src/adapters/custom_json.rs` +- Modify: `crates/aim-core/src/adapters/mod.rs` +- Test: `tests/adapter_smoke.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::adapters::all_adapter_kinds; + +#[test] +fn all_expected_adapter_kinds_are_registered() { + let kinds = all_adapter_kinds(); + assert!(kinds.contains(&"gitlab")); + assert!(kinds.contains(&"direct-url")); + assert!(kinds.contains(&"zsync")); + assert!(kinds.contains(&"sourceforge")); + assert!(kinds.contains(&"custom-json")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test all_expected_adapter_kinds_are_registered --test adapter_smoke` +Expected: FAIL because the additional adapters do not exist + +**Step 3: Write minimal implementation** + +Add adapter modules and register them behind the shared core trait. Keep each adapter bootstrapped with contract-valid behavior and fixture-friendly parsing paths before adding richer source-specific behaviors. + +**Step 4: Run test to verify it passes** + +Run: `cargo test all_expected_adapter_kinds_are_registered --test adapter_smoke` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters tests/adapter_smoke.rs +git commit -m "feat: add remaining core source adapter skeletons" +``` + +### Task 13: Implement list and remove in `aim-core`, keep `aim-cli` thin + +**Files:** +- Create: `crates/aim-core/src/app/list.rs` +- Create: `crates/aim-core/src/app/remove.rs` +- Modify: `crates/aim-cli/src/main.rs` +- Test: `tests/remove_flow.rs` + +**Step 1: Write the failing test** + +```rust +#[test] +fn remove_flow_rejects_unknown_app_names() { + let result = aim_core::app::remove::resolve_registered_app("bat", &[]); + assert!(result.is_err()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test remove_flow_rejects_unknown_app_names --test remove_flow` +Expected: FAIL because list and remove services do not exist + +**Step 3: Write minimal implementation** + +Implement in `aim-core`: +- list formatting input model +- registered app name matching +- ambiguity handling hooks through interaction requests +- conservative removal sequencing for artifact and integration cleanup + +Add only wiring and rendering in `aim-cli`. + +**Step 4: Run test to verify it passes** + +Run: `cargo test remove_flow_rejects_unknown_app_names --test remove_flow` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/list.rs crates/aim-core/src/app/remove.rs crates/aim-cli/src/main.rs tests/remove_flow.rs +git commit -m "feat: add core list and remove services" +``` + +### Task 14: Wire the binary end to end and document the workspace split + +**Files:** +- Modify: `crates/aim-cli/src/main.rs` +- Modify: `crates/aim-core/src/lib.rs` +- Test: `tests/end_to_end_cli.rs` +- Modify: `README.md` + +**Step 1: Write the failing test** + +```rust +use assert_cmd::Command; +use predicates::str::contains; + +#[test] +fn list_command_runs_without_registry_entries() { + let mut cmd = Command::cargo_bin("aim").unwrap(); + cmd.arg("list").assert().success().stdout(contains("installed")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test list_command_runs_without_registry_entries --test end_to_end_cli` +Expected: FAIL because services are not fully wired into the binary + +**Step 3: Write minimal implementation** + +Wire all top-level commands through `aim-core` service APIs and add minimal README usage documentation for: +- add/query flow +- bare update flow +- list +- remove +- scope overrides + +Also document that the workspace is intentionally split so a future GUI can reuse `aim-core`. + +**Step 4: Run test to verify it passes** + +Run: `cargo test list_command_runs_without_registry_entries --test end_to_end_cli` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-cli/src/main.rs crates/aim-core/src/lib.rs tests/end_to_end_cli.rs README.md +git commit -m "feat: wire aim cli to aim-core end to end" +``` + +### Task 15: Verification sweep and architecture leak check + +**Files:** +- Modify: `README.md` +- Modify: `.plans/appimage-manager/2026-03-19-appimage-manager-design.md` +- Modify: `.plans/appimage-manager/2026-03-19-appimage-manager-implementation-plan.md` + +**Step 1: Write the failing test** + +There is no new product behavior in this task. Instead, identify the highest-risk missing automated check from earlier tasks and add that test first, prioritizing any gap that suggests business logic is drifting into `aim-cli`. + +**Step 2: Run test to verify it fails** + +Run: `cargo test` +Expected: Identify at least one missing assertion or regression gap before making release-readiness claims + +**Step 3: Write minimal implementation** + +Close the smallest meaningful remaining gap. Update docs only where behavior has materially changed from the plan. + +**Step 4: Run test to verify it passes** + +Run: `cargo test` +Expected: PASS + +Run: `cargo fmt --check` +Expected: PASS + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` +Expected: PASS + +**Step 5: Commit** + +```bash +git add README.md .plans/appimage-manager/2026-03-19-appimage-manager-design.md .plans/appimage-manager/2026-03-19-appimage-manager-implementation-plan.md +git commit -m "chore: finalize appimage manager workspace implementation" +``` + +## Notes For Execution + +- This workspace is currently empty and not initialized as a git repository, so commit steps will remain blocked until `git init` or an equivalent repository setup occurs. +- The execution session should create a Cargo workspace, not a single binary crate. +- The first adapter should be GitHub because it exercises the `owner/repo` shorthand and the most likely early-user path. +- Keep custom JSON feed support declarative in v1. +- Do not add a plugin runtime. +- Do not let `aim-cli` accumulate business logic; if a behavior could be reused by a future GUI, it belongs in `aim-core`. + +Plan complete and saved to `.plans/appimage-manager/2026-03-19-appimage-manager-implementation-plan.md`. Two execution options: + +**1. Subagent-Driven (this session)** - I dispatch a fresh subagent per task, review between tasks, and iterate in this session. + +**2. Parallel Session (separate)** - Open a new session with executing-plans and execute the plan with checkpoints. + +Which approach? \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..59cc3d7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +## Plans, designs, and specs locations. +IMPORTANT IF USING BRAINSTORMING SKILL!!! +Docuemnts of these types MUST live in `.plans/` within a sensibly named and indexed subfolder. This can be branch name or a feature name like `001-initial-implementation`, `002-adding-gitlab-provider`, etc. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..dfdacd8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,780 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aim-cli" +version = "0.1.0" +dependencies = [ + "aim-core", + "assert_cmd", + "clap", + "predicates", + "tempfile", +] + +[[package]] +name = "aim-core" +version = "0.1.0" +dependencies = [ + "serde", + "tempfile", + "toml", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert_cmd" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7e60961 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] +members = [ + "crates/aim-core", + "crates/aim-cli", +] +default-members = [ + "crates/aim-cli", +] +resolver = "2" + +[workspace.package] +edition = "2024" +license = "MIT" +version = "0.1.0" + +[workspace.dependencies] +clap = { version = "4.5.32", features = ["derive"] } +assert_cmd = "2.0.16" +serde = { version = "1.0.219", features = ["derive"] } +tempfile = "3.19.1" +toml = "0.8.20" diff --git a/README.md b/README.md index c03c706..6219aff 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,42 @@ # aim AppImage Manager + +`aim` is a Rust Cargo workspace for managing AppImages from multiple source types. + +## Workspace + +- `crates/aim-core`: business logic, source adapters, registry, install/update planning +- `crates/aim-cli`: thin terminal frontend for parsing, prompting, and rendering + +The split is intentional so a future GUI client can reuse `aim-core` without moving logic out of the shared library. + +## Commands + +```text +aim +aim +aim update +aim list +aim remove +``` + +## Query Forms + +- `owner/repo` for GitHub shorthand +- `https://...` direct URLs +- GitLab URLs +- `file://...` local file imports + +## Scope Overrides + +By default `aim` auto-detects whether to use user or system scope. Override that with: + +- `--user` +- `--system` + +## Current Flow Shape + +- `aim ` resolves the query into a normalized source plan +- bare `aim` and `aim update` build a review-first update plan +- `aim list` renders registered applications +- `aim remove ` resolves a registered application name before removal diff --git a/crates/aim-cli/Cargo.toml b/crates/aim-cli/Cargo.toml new file mode 100644 index 0000000..cc9e05f --- /dev/null +++ b/crates/aim-cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "aim-cli" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "aim" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +aim-core = { path = "../aim-core" } + +[dev-dependencies] +assert_cmd.workspace = true +predicates = "3.1.3" +tempfile.workspace = true diff --git a/crates/aim-cli/src/cli/args.rs b/crates/aim-cli/src/cli/args.rs new file mode 100644 index 0000000..f2c37fd --- /dev/null +++ b/crates/aim-cli/src/cli/args.rs @@ -0,0 +1,31 @@ +use clap::Parser; + +#[derive(Debug, Parser)] +#[command(name = "aim")] +#[command(about = "AppImage Manager")] +pub struct Cli { + #[arg(global = true, long = "system", conflicts_with = "user")] + pub system: bool, + + #[arg(global = true, long = "user", conflicts_with = "system")] + pub user: bool, + + #[command(subcommand)] + pub command: Option, + + pub query: Option, +} + +impl Cli { + pub fn is_review_update_flow(&self) -> bool { + matches!(self.command, Some(Command::Update)) + || (self.command.is_none() && self.query.is_none()) + } +} + +#[derive(Debug, clap::Subcommand)] +pub enum Command { + Remove { query: String }, + List, + Update, +} diff --git a/crates/aim-cli/src/cli/mod.rs b/crates/aim-cli/src/cli/mod.rs new file mode 100644 index 0000000..6e10f4a --- /dev/null +++ b/crates/aim-cli/src/cli/mod.rs @@ -0,0 +1 @@ +pub mod args; diff --git a/crates/aim-cli/src/lib.rs b/crates/aim-cli/src/lib.rs new file mode 100644 index 0000000..02596db --- /dev/null +++ b/crates/aim-cli/src/lib.rs @@ -0,0 +1,108 @@ +pub mod cli; +pub mod ui; + +use std::env; +use std::path::PathBuf; + +use aim_core::app::add::build_add_plan; +use aim_core::app::list::{ListRow, build_list_rows}; +use aim_core::app::remove::remove_registered_app; +use aim_core::app::update::build_update_plan; +use aim_core::domain::source::SourceRef; +use aim_core::domain::update::UpdatePlan; +use aim_core::registry::model::Registry; +use aim_core::registry::store::RegistryStore; + +pub use cli::args::Cli; + +pub fn parse() -> Cli { + ::parse() +} + +pub fn dispatch(cli: Cli) -> Result { + let registry_path = registry_path(); + let store = RegistryStore::new(registry_path); + let registry = store.load()?; + let apps = registry.apps.clone(); + + if cli.is_review_update_flow() { + return Ok(DispatchResult::UpdatePlan(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::Remove { query } => { + let removal = remove_registered_app(&query, &apps)?; + store.save(&Registry { + version: registry.version, + apps: removal.remaining_apps, + })?; + Ok(DispatchResult::Removed(removal.removed.display_name)) + } + cli::args::Command::Update => Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?)), + }; + } + + if let Some(query) = cli.query { + return Ok(DispatchResult::AddPlan( + build_add_plan(&query)?.resolution.source, + )); + } + + Ok(DispatchResult::Noop) +} + +pub fn render(result: &DispatchResult) -> String { + ui::render::render_dispatch_result(result) +} + +fn registry_path() -> PathBuf { + if let Some(path) = env::var_os("AIM_REGISTRY_PATH") { + return PathBuf::from(path); + } + + let home = env::var_os("HOME").unwrap_or_else(|| ".".into()); + PathBuf::from(home).join(".local/share/aim/registry.toml") +} + +#[derive(Debug, Eq, PartialEq)] +pub enum DispatchResult { + AddPlan(SourceRef), + List(Vec), + Removed(String), + UpdatePlan(UpdatePlan), + Noop, +} + +#[derive(Debug)] +pub enum DispatchError { + AddPlan(aim_core::app::add::BuildAddPlanError), + RemovePlan(aim_core::app::remove::ResolveRegisteredAppError), + Registry(aim_core::registry::store::RegistryStoreError), + UpdatePlan(aim_core::app::update::BuildUpdatePlanError), +} + +impl From for DispatchError { + fn from(value: aim_core::app::add::BuildAddPlanError) -> Self { + Self::AddPlan(value) + } +} + +impl From for DispatchError { + fn from(value: aim_core::app::update::BuildUpdatePlanError) -> Self { + Self::UpdatePlan(value) + } +} + +impl From for DispatchError { + fn from(value: aim_core::app::remove::ResolveRegisteredAppError) -> Self { + Self::RemovePlan(value) + } +} + +impl From for DispatchError { + fn from(value: aim_core::registry::store::RegistryStoreError) -> Self { + Self::Registry(value) + } +} diff --git a/crates/aim-cli/src/main.rs b/crates/aim-cli/src/main.rs new file mode 100644 index 0000000..b17f3e1 --- /dev/null +++ b/crates/aim-cli/src/main.rs @@ -0,0 +1,15 @@ +fn main() { + let cli = aim_cli::parse(); + match aim_cli::dispatch(cli) { + Ok(result) => { + let output = aim_cli::render(&result); + if !output.is_empty() { + println!("{output}"); + } + } + Err(error) => { + eprintln!("{error:?}"); + std::process::exit(1); + } + } +} diff --git a/crates/aim-cli/src/ui/mod.rs b/crates/aim-cli/src/ui/mod.rs new file mode 100644 index 0000000..aca49d6 --- /dev/null +++ b/crates/aim-cli/src/ui/mod.rs @@ -0,0 +1,2 @@ +pub mod prompt; +pub mod render; diff --git a/crates/aim-cli/src/ui/prompt.rs b/crates/aim-cli/src/ui/prompt.rs new file mode 100644 index 0000000..01e5e3e --- /dev/null +++ b/crates/aim-cli/src/ui/prompt.rs @@ -0,0 +1,3 @@ +use aim_core::app::interaction::InteractionRequest; + +pub fn handle_interaction(_request: &InteractionRequest) {} diff --git a/crates/aim-cli/src/ui/render.rs b/crates/aim-cli/src/ui/render.rs new file mode 100644 index 0000000..33b16bc --- /dev/null +++ b/crates/aim-cli/src/ui/render.rs @@ -0,0 +1,39 @@ +use aim_core::domain::source::SourceRef; + +use crate::DispatchResult; + +pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> String { + format!("updates found: {total}, selected: {selected}, failed: {failed}",) +} + +pub fn render_dispatch_result(result: &DispatchResult) -> String { + match result { + DispatchResult::AddPlan(source) => render_add_plan(source), + DispatchResult::List(rows) => render_list(rows), + DispatchResult::Removed(display_name) => format!("removed: {display_name}"), + DispatchResult::UpdatePlan(plan) => { + render_update_summary(plan.items.len(), plan.items.len(), 0) + } + DispatchResult::Noop => String::new(), + } +} + +fn render_add_plan(source: &SourceRef) -> String { + format!( + "resolved source: {} {}", + source.kind.as_str(), + source.locator + ) +} + +fn render_list(rows: &[aim_core::app::list::ListRow]) -> String { + if rows.is_empty() { + return "installed apps: none".to_owned(); + } + + let mut output = String::from("installed apps:\n"); + for row in rows { + output.push_str(&format!("- {} ({})\n", row.display_name, row.stable_id)); + } + output.trim_end().to_owned() +} diff --git a/crates/aim-cli/tests/cli_commands.rs b/crates/aim-cli/tests/cli_commands.rs new file mode 100644 index 0000000..8bc255e --- /dev/null +++ b/crates/aim-cli/tests/cli_commands.rs @@ -0,0 +1,13 @@ +use assert_cmd::Command; +use predicates::str::contains; + +#[test] +fn help_lists_expected_commands() { + let mut cmd = Command::cargo_bin("aim").unwrap(); + cmd.arg("--help") + .assert() + .success() + .stdout(contains("remove")) + .stdout(contains("list")) + .stdout(contains("update")); +} diff --git a/crates/aim-cli/tests/cli_smoke.rs b/crates/aim-cli/tests/cli_smoke.rs new file mode 100644 index 0000000..39d11be --- /dev/null +++ b/crates/aim-cli/tests/cli_smoke.rs @@ -0,0 +1,7 @@ +use assert_cmd::Command; + +#[test] +fn cli_shows_help() { + let mut cmd = Command::cargo_bin("aim").unwrap(); + cmd.arg("--help").assert().success(); +} diff --git a/crates/aim-cli/tests/end_to_end_cli.rs b/crates/aim-cli/tests/end_to_end_cli.rs new file mode 100644 index 0000000..f6bba54 --- /dev/null +++ b/crates/aim-cli/tests/end_to_end_cli.rs @@ -0,0 +1,54 @@ +use assert_cmd::Command; +use predicates::str::contains; +use tempfile::tempdir; + +#[test] +fn list_command_runs_without_registry_entries() { + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("list") + .assert() + .success() + .stdout(contains("installed")); +} + +#[test] +fn list_command_reads_registered_apps_from_registry_file() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + std::fs::write( + ®istry_path, + "version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n", + ) + .unwrap(); + + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("list") + .env("AIM_REGISTRY_PATH", ®istry_path) + .assert() + .success() + .stdout(contains("Bat (bat)")); +} + +#[test] +fn remove_command_removes_registered_app_from_registry_file() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + std::fs::write( + ®istry_path, + "version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n", + ) + .unwrap(); + + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.args(["remove", "bat"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .assert() + .success() + .stdout(contains("removed: Bat")); + + let contents = std::fs::read_to_string(®istry_path).unwrap(); + assert!(!contents.contains("stable_id = \"bat\"")); +} diff --git a/crates/aim-cli/tests/ui_summary.rs b/crates/aim-cli/tests/ui_summary.rs new file mode 100644 index 0000000..e37b20f --- /dev/null +++ b/crates/aim-cli/tests/ui_summary.rs @@ -0,0 +1,7 @@ +use aim_cli::ui::render::render_update_summary; + +#[test] +fn update_summary_mentions_selected_count() { + let output = render_update_summary(3, 2, 1); + assert!(output.contains("selected: 2")); +} diff --git a/crates/aim-core/Cargo.toml b/crates/aim-core/Cargo.toml new file mode 100644 index 0000000..5469eca --- /dev/null +++ b/crates/aim-core/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "aim-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +serde.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/aim-core/src/adapters/custom_json.rs b/crates/aim-core/src/adapters/custom_json.rs new file mode 100644 index 0000000..7c1eebf --- /dev/null +++ b/crates/aim-core/src/adapters/custom_json.rs @@ -0,0 +1,13 @@ +use crate::adapters::traits::{AdapterCapabilities, SourceAdapter}; + +pub struct CustomJsonAdapter; + +impl SourceAdapter for CustomJsonAdapter { + fn id(&self) -> &'static str { + "custom-json" + } + + fn capabilities(&self) -> AdapterCapabilities { + AdapterCapabilities::exact_resolution_only() + } +} diff --git a/crates/aim-core/src/adapters/direct_url.rs b/crates/aim-core/src/adapters/direct_url.rs new file mode 100644 index 0000000..318aec9 --- /dev/null +++ b/crates/aim-core/src/adapters/direct_url.rs @@ -0,0 +1,37 @@ +use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter}; +use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; + +pub struct DirectUrlAdapter; + +impl DirectUrlAdapter { + pub fn resolve(&self, source: &SourceRef) -> Result { + if source.kind != SourceKind::DirectUrl { + return Err(DirectUrlAdapterError::UnsupportedSource); + } + + Ok(AdapterResolution { + source: SourceRef { + kind: SourceKind::DirectUrl, + locator: source.locator.clone(), + }, + release: ResolvedRelease { + version: "unresolved".to_owned(), + }, + }) + } +} + +impl SourceAdapter for DirectUrlAdapter { + fn id(&self) -> &'static str { + "direct-url" + } + + fn capabilities(&self) -> AdapterCapabilities { + AdapterCapabilities::exact_resolution_only() + } +} + +#[derive(Debug, Eq, PartialEq)] +pub enum DirectUrlAdapterError { + UnsupportedSource, +} diff --git a/crates/aim-core/src/adapters/github.rs b/crates/aim-core/src/adapters/github.rs new file mode 100644 index 0000000..68fb2e7 --- /dev/null +++ b/crates/aim-core/src/adapters/github.rs @@ -0,0 +1,50 @@ +use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter}; +use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; + +pub struct GitHubAdapter; + +impl Default for GitHubAdapter { + fn default() -> Self { + Self::new() + } +} + +impl GitHubAdapter { + pub fn new() -> Self { + Self + } + + pub fn resolve(&self, source: &SourceRef) -> Result { + if source.kind != SourceKind::GitHub { + return Err(GitHubAdapterError::UnsupportedSource); + } + + Ok(AdapterResolution { + source: SourceRef { + kind: SourceKind::GitHub, + locator: source.locator.clone(), + }, + release: ResolvedRelease { + version: "latest".to_owned(), + }, + }) + } +} + +impl SourceAdapter for GitHubAdapter { + fn id(&self) -> &'static str { + "github" + } + + fn capabilities(&self) -> AdapterCapabilities { + AdapterCapabilities { + supports_search: true, + supports_exact_resolution: true, + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub enum GitHubAdapterError { + UnsupportedSource, +} diff --git a/crates/aim-core/src/adapters/gitlab.rs b/crates/aim-core/src/adapters/gitlab.rs new file mode 100644 index 0000000..6f04a71 --- /dev/null +++ b/crates/aim-core/src/adapters/gitlab.rs @@ -0,0 +1,40 @@ +use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter}; +use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; + +pub struct GitLabAdapter; + +impl GitLabAdapter { + pub fn resolve(&self, source: &SourceRef) -> Result { + if source.kind != SourceKind::GitLab { + return Err(GitLabAdapterError::UnsupportedSource); + } + + Ok(AdapterResolution { + source: SourceRef { + kind: SourceKind::GitLab, + locator: source.locator.clone(), + }, + release: ResolvedRelease { + version: "latest".to_owned(), + }, + }) + } +} + +impl SourceAdapter for GitLabAdapter { + fn id(&self) -> &'static str { + "gitlab" + } + + fn capabilities(&self) -> AdapterCapabilities { + AdapterCapabilities { + supports_search: true, + supports_exact_resolution: true, + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub enum GitLabAdapterError { + UnsupportedSource, +} diff --git a/crates/aim-core/src/adapters/mod.rs b/crates/aim-core/src/adapters/mod.rs new file mode 100644 index 0000000..6e93726 --- /dev/null +++ b/crates/aim-core/src/adapters/mod.rs @@ -0,0 +1,19 @@ +pub mod custom_json; +pub mod direct_url; +pub mod github; +pub mod gitlab; +pub mod sourceforge; +pub mod test_support; +pub mod traits; +pub mod zsync; + +pub fn all_adapter_kinds() -> Vec<&'static str> { + vec![ + "github", + "gitlab", + "direct-url", + "zsync", + "sourceforge", + "custom-json", + ] +} diff --git a/crates/aim-core/src/adapters/sourceforge.rs b/crates/aim-core/src/adapters/sourceforge.rs new file mode 100644 index 0000000..63729ba --- /dev/null +++ b/crates/aim-core/src/adapters/sourceforge.rs @@ -0,0 +1,16 @@ +use crate::adapters::traits::{AdapterCapabilities, SourceAdapter}; + +pub struct SourceForgeAdapter; + +impl SourceAdapter for SourceForgeAdapter { + fn id(&self) -> &'static str { + "sourceforge" + } + + fn capabilities(&self) -> AdapterCapabilities { + AdapterCapabilities { + supports_search: true, + supports_exact_resolution: true, + } + } +} diff --git a/crates/aim-core/src/adapters/test_support.rs b/crates/aim-core/src/adapters/test_support.rs new file mode 100644 index 0000000..57424d8 --- /dev/null +++ b/crates/aim-core/src/adapters/test_support.rs @@ -0,0 +1,27 @@ +use crate::adapters::traits::AdapterCapabilities; +use crate::adapters::traits::SourceAdapter; + +#[derive(Debug)] +pub struct MockAdapter { + id: &'static str, + capabilities: AdapterCapabilities, +} + +impl MockAdapter { + pub fn exact_resolution_only() -> Self { + Self { + id: "mock", + capabilities: AdapterCapabilities::exact_resolution_only(), + } + } +} + +impl SourceAdapter for MockAdapter { + fn id(&self) -> &'static str { + self.id + } + + fn capabilities(&self) -> AdapterCapabilities { + self.capabilities + } +} diff --git a/crates/aim-core/src/adapters/traits.rs b/crates/aim-core/src/adapters/traits.rs new file mode 100644 index 0000000..0ba62df --- /dev/null +++ b/crates/aim-core/src/adapters/traits.rs @@ -0,0 +1,29 @@ +use crate::domain::source::ResolvedRelease; +use crate::domain::source::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(Debug, Eq, PartialEq)] +pub struct AdapterResolution { + pub source: SourceRef, + pub release: ResolvedRelease, +} + +pub trait SourceAdapter { + fn id(&self) -> &'static str; + + fn capabilities(&self) -> AdapterCapabilities; +} diff --git a/crates/aim-core/src/adapters/zsync.rs b/crates/aim-core/src/adapters/zsync.rs new file mode 100644 index 0000000..a66fa0c --- /dev/null +++ b/crates/aim-core/src/adapters/zsync.rs @@ -0,0 +1,13 @@ +use crate::adapters::traits::{AdapterCapabilities, SourceAdapter}; + +pub struct ZsyncAdapter; + +impl SourceAdapter for ZsyncAdapter { + fn id(&self) -> &'static str { + "zsync" + } + + fn capabilities(&self) -> AdapterCapabilities { + AdapterCapabilities::exact_resolution_only() + } +} diff --git a/crates/aim-core/src/app/add.rs b/crates/aim-core/src/app/add.rs new file mode 100644 index 0000000..81baeea --- /dev/null +++ b/crates/aim-core/src/app/add.rs @@ -0,0 +1,36 @@ +use crate::adapters::github::{GitHubAdapter, GitHubAdapterError}; +use crate::adapters::traits::AdapterResolution; +use crate::app::query::{ResolveQueryError, resolve_query}; +use crate::domain::source::{SourceKind, SourceRef}; + +pub fn build_add_plan(query: &str) -> Result { + let source = resolve_query(query).map_err(BuildAddPlanError::Query)?; + + let resolution = match source.kind { + SourceKind::GitHub => GitHubAdapter::new() + .resolve(&source) + .map_err(BuildAddPlanError::GitHub)?, + _ => AdapterResolution { + source: SourceRef { + kind: source.kind, + locator: source.locator.clone(), + }, + release: crate::domain::source::ResolvedRelease { + version: "unresolved".to_owned(), + }, + }, + }; + + Ok(AddPlan { resolution }) +} + +#[derive(Debug, Eq, PartialEq)] +pub struct AddPlan { + pub resolution: AdapterResolution, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum BuildAddPlanError { + Query(ResolveQueryError), + GitHub(GitHubAdapterError), +} diff --git a/crates/aim-core/src/app/identity.rs b/crates/aim-core/src/app/identity.rs new file mode 100644 index 0000000..0c4c00b --- /dev/null +++ b/crates/aim-core/src/app/identity.rs @@ -0,0 +1,77 @@ +use crate::domain::app::{AppIdentity, IdentityConfidence}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IdentityFallback { + DisallowRawUrl, + AllowRawUrl, +} + +pub fn resolve_identity( + explicit_name: Option<&str>, + explicit_id: Option<&str>, + source_url: Option<&str>, + fallback: IdentityFallback, +) -> Result { + if let Some(explicit_id) = explicit_id.filter(|value| !value.trim().is_empty()) { + let stable_id = normalize_identifier(explicit_id); + let display_name = explicit_name + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| explicit_id.to_owned()); + + return Ok(AppIdentity { + stable_id, + display_name, + confidence: IdentityConfidence::Confident, + }); + } + + if let Some(explicit_name) = explicit_name.filter(|value| !value.trim().is_empty()) { + return Ok(AppIdentity { + stable_id: normalize_identifier(explicit_name), + display_name: explicit_name.to_owned(), + confidence: IdentityConfidence::NeedsConfirmation, + }); + } + + if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty()) + && fallback == IdentityFallback::AllowRawUrl + { + return Ok(AppIdentity { + stable_id: normalize_url_identifier(source_url), + display_name: source_url.to_owned(), + confidence: IdentityConfidence::RawUrlFallback, + }); + } + + Err(ResolveIdentityError::Unresolved) +} + +#[derive(Debug, Eq, PartialEq)] +pub enum ResolveIdentityError { + Unresolved, +} + +fn normalize_identifier(value: &str) -> String { + value + .trim() + .chars() + .map(|ch| match ch { + 'A'..='Z' => ch.to_ascii_lowercase(), + 'a'..='z' | '0'..='9' | '.' | '-' => ch, + _ => '-', + }) + .collect::() + .trim_matches('-') + .to_owned() +} + +fn normalize_url_identifier(url: &str) -> String { + let trimmed = url + .trim() + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_start_matches("file://"); + + format!("url-{}", normalize_identifier(trimmed)) +} diff --git a/crates/aim-core/src/app/interaction.rs b/crates/aim-core/src/app/interaction.rs new file mode 100644 index 0000000..047f7d8 --- /dev/null +++ b/crates/aim-core/src/app/interaction.rs @@ -0,0 +1,4 @@ +#[derive(Debug, Eq, PartialEq)] +pub enum InteractionRequest { + SelectRegisteredApp { query: String, matches: Vec }, +} diff --git a/crates/aim-core/src/app/list.rs b/crates/aim-core/src/app/list.rs new file mode 100644 index 0000000..8126227 --- /dev/null +++ b/crates/aim-core/src/app/list.rs @@ -0,0 +1,16 @@ +use crate::domain::app::AppRecord; + +#[derive(Debug, Eq, PartialEq)] +pub struct ListRow { + pub stable_id: String, + pub display_name: String, +} + +pub fn build_list_rows(apps: &[AppRecord]) -> Vec { + apps.iter() + .map(|app| ListRow { + stable_id: app.stable_id.clone(), + display_name: app.display_name.clone(), + }) + .collect() +} diff --git a/crates/aim-core/src/app/mod.rs b/crates/aim-core/src/app/mod.rs new file mode 100644 index 0000000..49521a1 --- /dev/null +++ b/crates/aim-core/src/app/mod.rs @@ -0,0 +1,8 @@ +pub mod add; +pub mod identity; +pub mod interaction; +pub mod list; +pub mod query; +pub mod remove; +pub mod scope; +pub mod update; diff --git a/crates/aim-core/src/app/query.rs b/crates/aim-core/src/app/query.rs new file mode 100644 index 0000000..7d389a9 --- /dev/null +++ b/crates/aim-core/src/app/query.rs @@ -0,0 +1,55 @@ +use crate::domain::source::SourceKind; +use crate::domain::source::SourceRef; + +pub fn resolve_query(query: &str) -> Result { + if query.starts_with("file://") { + return Ok(SourceRef { + kind: SourceKind::File, + locator: query.to_owned(), + }); + } + + if query.starts_with("https://gitlab.com/") || query.starts_with("http://gitlab.com/") { + return Ok(SourceRef { + kind: SourceKind::GitLab, + locator: query.to_owned(), + }); + } + + if query.starts_with("https://") || query.starts_with("http://") { + return Ok(SourceRef { + kind: SourceKind::DirectUrl, + locator: query.to_owned(), + }); + } + + if is_github_shorthand(query) { + return Ok(SourceRef { + kind: SourceKind::GitHub, + locator: query.to_owned(), + }); + } + + Err(ResolveQueryError::Unsupported) +} + +#[derive(Debug, Eq, PartialEq)] +pub enum ResolveQueryError { + Unsupported, +} + +fn is_github_shorthand(query: &str) -> bool { + let mut parts = query.split('/'); + let Some(owner) = parts.next() else { + return false; + }; + let Some(repo) = parts.next() else { + return false; + }; + + if parts.next().is_some() { + return false; + } + + !owner.is_empty() && !repo.is_empty() && !owner.contains(':') && !repo.contains(':') +} diff --git a/crates/aim-core/src/app/remove.rs b/crates/aim-core/src/app/remove.rs new file mode 100644 index 0000000..5a54802 --- /dev/null +++ b/crates/aim-core/src/app/remove.rs @@ -0,0 +1,80 @@ +use crate::app::interaction::InteractionRequest; +use crate::domain::app::AppRecord; + +pub fn resolve_registered_app<'a>( + query: &str, + apps: &'a [AppRecord], +) -> Result<&'a AppRecord, ResolveRegisteredAppError> { + let normalized_query = normalize_lookup(query); + let matches = apps + .iter() + .filter(|app| { + normalize_lookup(&app.stable_id) == normalized_query + || normalize_lookup(&app.display_name) == normalized_query + }) + .collect::>(); + + match matches.as_slice() { + [] => Err(ResolveRegisteredAppError::UnknownApp { + query: query.to_owned(), + }), + [app] => Ok(*app), + _ => Err(ResolveRegisteredAppError::Ambiguous { + request: InteractionRequest::SelectRegisteredApp { + query: query.to_owned(), + matches: matches + .iter() + .map(|app| format!("{} ({})", app.display_name, app.stable_id)) + .collect(), + }, + }), + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct RemovalPlan { + pub stable_id: String, + pub display_name: String, + pub artifact_paths: Vec, +} + +pub fn build_removal_plan(app: &AppRecord) -> RemovalPlan { + RemovalPlan { + stable_id: app.stable_id.clone(), + display_name: app.display_name.clone(), + artifact_paths: Vec::new(), + } +} + +pub fn remove_registered_app( + query: &str, + apps: &[AppRecord], +) -> Result { + let app = resolve_registered_app(query, apps)?; + let remaining_apps = apps + .iter() + .filter(|candidate| candidate.stable_id != app.stable_id) + .cloned() + .collect(); + + Ok(RemovalResult { + removed: build_removal_plan(app), + remaining_apps, + }) +} + +#[derive(Debug, Eq, PartialEq)] +pub struct RemovalResult { + pub removed: RemovalPlan, + pub remaining_apps: Vec, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum ResolveRegisteredAppError { + UnknownApp { query: String }, + Ambiguous { request: InteractionRequest }, +} + +fn normalize_lookup(value: &str) -> String { + value.trim().to_ascii_lowercase() +} diff --git a/crates/aim-core/src/app/scope.rs b/crates/aim-core/src/app/scope.rs new file mode 100644 index 0000000..8318c37 --- /dev/null +++ b/crates/aim-core/src/app/scope.rs @@ -0,0 +1,28 @@ +use crate::domain::app::InstallScope; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ScopeOverride { + System, + User, +} + +pub fn resolve_install_scope( + _is_effective_root: bool, + override_scope: ScopeOverride, +) -> InstallScope { + match override_scope { + ScopeOverride::System => InstallScope::System, + ScopeOverride::User => InstallScope::User, + } +} + +pub fn resolve_install_scope_with_default( + is_effective_root: bool, + override_scope: Option, +) -> InstallScope { + match override_scope { + Some(scope) => resolve_install_scope(is_effective_root, scope), + None if is_effective_root => InstallScope::System, + None => InstallScope::User, + } +} diff --git a/crates/aim-core/src/app/update.rs b/crates/aim-core/src/app/update.rs new file mode 100644 index 0000000..181250e --- /dev/null +++ b/crates/aim-core/src/app/update.rs @@ -0,0 +1,17 @@ +use crate::domain::app::AppRecord; +use crate::domain::update::{PlannedUpdate, UpdatePlan}; + +pub fn build_update_plan(apps: &[AppRecord]) -> Result { + Ok(UpdatePlan { + items: apps + .iter() + .map(|app| PlannedUpdate { + stable_id: app.stable_id.clone(), + display_name: app.display_name.clone(), + }) + .collect(), + }) +} + +#[derive(Debug, Eq, PartialEq)] +pub enum BuildUpdatePlanError {} diff --git a/crates/aim-core/src/domain/app.rs b/crates/aim-core/src/domain/app.rs new file mode 100644 index 0000000..0d1741f --- /dev/null +++ b/crates/aim-core/src/domain/app.rs @@ -0,0 +1,25 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InstallScope { + User, + System, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IdentityConfidence { + Confident, + NeedsConfirmation, + RawUrlFallback, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct AppIdentity { + pub stable_id: String, + pub display_name: String, + pub confidence: IdentityConfidence, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct AppRecord { + pub stable_id: String, + pub display_name: String, +} diff --git a/crates/aim-core/src/domain/mod.rs b/crates/aim-core/src/domain/mod.rs new file mode 100644 index 0000000..479ac2f --- /dev/null +++ b/crates/aim-core/src/domain/mod.rs @@ -0,0 +1,3 @@ +pub mod app; +pub mod source; +pub mod update; diff --git a/crates/aim-core/src/domain/source.rs b/crates/aim-core/src/domain/source.rs new file mode 100644 index 0000000..be06c46 --- /dev/null +++ b/crates/aim-core/src/domain/source.rs @@ -0,0 +1,29 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SourceKind { + GitHub, + GitLab, + DirectUrl, + File, +} + +impl SourceKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::GitHub => "github", + Self::GitLab => "gitlab", + Self::DirectUrl => "direct-url", + Self::File => "file", + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct SourceRef { + pub kind: SourceKind, + pub locator: String, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct ResolvedRelease { + pub version: String, +} diff --git a/crates/aim-core/src/domain/update.rs b/crates/aim-core/src/domain/update.rs new file mode 100644 index 0000000..f150914 --- /dev/null +++ b/crates/aim-core/src/domain/update.rs @@ -0,0 +1,10 @@ +#[derive(Debug, Eq, PartialEq)] +pub struct UpdatePlan { + pub items: Vec, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct PlannedUpdate { + pub stable_id: String, + pub display_name: String, +} diff --git a/crates/aim-core/src/integration/install.rs b/crates/aim-core/src/integration/install.rs new file mode 100644 index 0000000..9ae3cde --- /dev/null +++ b/crates/aim-core/src/integration/install.rs @@ -0,0 +1,14 @@ +use std::path::{Path, PathBuf}; + +pub fn staged_appimage_path(staging_root: &Path, app_id: &str) -> PathBuf { + staging_root.join(format!("{app_id}.download")) +} + +pub fn replacement_path(target: &Path) -> PathBuf { + let mut file_name = target + .file_name() + .map(|name| name.to_os_string()) + .unwrap_or_default(); + file_name.push(".new"); + target.with_file_name(file_name) +} diff --git a/crates/aim-core/src/integration/mod.rs b/crates/aim-core/src/integration/mod.rs new file mode 100644 index 0000000..5b917f8 --- /dev/null +++ b/crates/aim-core/src/integration/mod.rs @@ -0,0 +1,2 @@ +pub mod install; +pub mod paths; diff --git a/crates/aim-core/src/integration/paths.rs b/crates/aim-core/src/integration/paths.rs new file mode 100644 index 0000000..8cd7c16 --- /dev/null +++ b/crates/aim-core/src/integration/paths.rs @@ -0,0 +1,40 @@ +use std::path::{Path, PathBuf}; + +use crate::domain::app::InstallScope; +use crate::platform::{ + system_applications_dir, system_icons_dir, system_managed_appimages_dir, user_applications_dir, + user_icons_dir, user_managed_appimages_dir, +}; + +pub fn managed_appimage_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf { + scope_managed_dir(home_dir, scope).join(format!("{app_id}.AppImage")) +} + +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")) +} + +pub fn icon_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf { + scope_icons_dir(home_dir, scope).join(format!("{app_id}.png")) +} + +fn scope_managed_dir(home_dir: &Path, scope: InstallScope) -> PathBuf { + match scope { + InstallScope::User => user_managed_appimages_dir(home_dir), + InstallScope::System => system_managed_appimages_dir(), + } +} + +fn scope_applications_dir(home_dir: &Path, scope: InstallScope) -> PathBuf { + match scope { + InstallScope::User => user_applications_dir(home_dir), + InstallScope::System => system_applications_dir(), + } +} + +fn scope_icons_dir(home_dir: &Path, scope: InstallScope) -> PathBuf { + match scope { + InstallScope::User => user_icons_dir(home_dir), + InstallScope::System => system_icons_dir(), + } +} diff --git a/crates/aim-core/src/lib.rs b/crates/aim-core/src/lib.rs new file mode 100644 index 0000000..db55879 --- /dev/null +++ b/crates/aim-core/src/lib.rs @@ -0,0 +1,6 @@ +pub mod adapters; +pub mod app; +pub mod domain; +pub mod integration; +pub mod platform; +pub mod registry; diff --git a/crates/aim-core/src/platform/mod.rs b/crates/aim-core/src/platform/mod.rs new file mode 100644 index 0000000..fd84e2b --- /dev/null +++ b/crates/aim-core/src/platform/mod.rs @@ -0,0 +1,25 @@ +use std::path::{Path, PathBuf}; + +pub fn user_managed_appimages_dir(home_dir: &Path) -> PathBuf { + home_dir.join(".local/lib/aim/appimages") +} + +pub fn user_applications_dir(home_dir: &Path) -> PathBuf { + home_dir.join(".local/share/applications") +} + +pub fn user_icons_dir(home_dir: &Path) -> PathBuf { + home_dir.join(".local/share/icons/hicolor/256x256/apps") +} + +pub fn system_managed_appimages_dir() -> PathBuf { + PathBuf::from("/opt/aim/appimages") +} + +pub fn system_applications_dir() -> PathBuf { + PathBuf::from("/usr/share/applications") +} + +pub fn system_icons_dir() -> PathBuf { + PathBuf::from("/usr/share/icons/hicolor/256x256/apps") +} diff --git a/crates/aim-core/src/registry/mod.rs b/crates/aim-core/src/registry/mod.rs new file mode 100644 index 0000000..bf69376 --- /dev/null +++ b/crates/aim-core/src/registry/mod.rs @@ -0,0 +1,2 @@ +pub mod model; +pub mod store; diff --git a/crates/aim-core/src/registry/model.rs b/crates/aim-core/src/registry/model.rs new file mode 100644 index 0000000..71fc0e3 --- /dev/null +++ b/crates/aim-core/src/registry/model.rs @@ -0,0 +1,14 @@ +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Registry { + pub version: u32, + pub apps: Vec, +} + +impl Default for Registry { + fn default() -> Self { + Self { + version: 1, + apps: Vec::new(), + } + } +} diff --git a/crates/aim-core/src/registry/store.rs b/crates/aim-core/src/registry/store.rs new file mode 100644 index 0000000..7ff0361 --- /dev/null +++ b/crates/aim-core/src/registry/store.rs @@ -0,0 +1,59 @@ +use std::fs; +use std::path::PathBuf; + +use crate::registry::model::Registry; + +pub struct RegistryStore { + path: PathBuf, +} + +impl RegistryStore { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + pub fn load(&self) -> Result { + if !self.path.exists() { + return Ok(Registry::default()); + } + + let contents = fs::read_to_string(&self.path)?; + let registry = toml::from_str(&contents)?; + Ok(registry) + } + + pub fn save(&self, registry: &Registry) -> Result<(), RegistryStoreError> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } + + let contents = toml::to_string(registry)?; + fs::write(&self.path, contents)?; + Ok(()) + } +} + +#[derive(Debug)] +pub enum RegistryStoreError { + Io(std::io::Error), + SerializeToml(toml::ser::Error), + Toml(toml::de::Error), +} + +impl From for RegistryStoreError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for RegistryStoreError { + fn from(error: toml::de::Error) -> Self { + Self::Toml(error) + } +} + +impl From for RegistryStoreError { + fn from(error: toml::ser::Error) -> Self { + Self::SerializeToml(error) + } +} diff --git a/crates/aim-core/tests/adapter_contract.rs b/crates/aim-core/tests/adapter_contract.rs new file mode 100644 index 0000000..c99fc8e --- /dev/null +++ b/crates/aim-core/tests/adapter_contract.rs @@ -0,0 +1,7 @@ +use aim_core::adapters::traits::AdapterCapabilities; + +#[test] +fn adapter_capabilities_can_report_exact_resolution_only() { + let capabilities = AdapterCapabilities::exact_resolution_only(); + assert!(!capabilities.supports_search); +} diff --git a/crates/aim-core/tests/adapter_smoke.rs b/crates/aim-core/tests/adapter_smoke.rs new file mode 100644 index 0000000..bccbe4d --- /dev/null +++ b/crates/aim-core/tests/adapter_smoke.rs @@ -0,0 +1,12 @@ +use aim_core::adapters::all_adapter_kinds; + +#[test] +fn all_expected_adapter_kinds_are_registered() { + let kinds = all_adapter_kinds(); + + assert!(kinds.contains(&"gitlab")); + assert!(kinds.contains(&"direct-url")); + assert!(kinds.contains(&"zsync")); + assert!(kinds.contains(&"sourceforge")); + assert!(kinds.contains(&"custom-json")); +} diff --git a/crates/aim-core/tests/github_add_flow.rs b/crates/aim-core/tests/github_add_flow.rs new file mode 100644 index 0000000..ab7d87f --- /dev/null +++ b/crates/aim-core/tests/github_add_flow.rs @@ -0,0 +1,18 @@ +use aim_core::app::add::build_add_plan; +use aim_core::app::query::resolve_query; + +#[test] +fn github_adapter_can_normalize_owner_repo_source() { + let source = resolve_query("sharkdp/bat").unwrap(); + + assert_eq!(source.kind.as_str(), "github"); +} + +#[test] +fn add_flow_builds_github_plan_from_owner_repo_query() { + let plan = build_add_plan("sharkdp/bat").unwrap(); + + assert_eq!(plan.resolution.source.kind.as_str(), "github"); + assert_eq!(plan.resolution.source.locator, "sharkdp/bat"); + assert_eq!(plan.resolution.release.version, "latest"); +} diff --git a/crates/aim-core/tests/identity_resolution.rs b/crates/aim-core/tests/identity_resolution.rs new file mode 100644 index 0000000..71d3420 --- /dev/null +++ b/crates/aim-core/tests/identity_resolution.rs @@ -0,0 +1,31 @@ +use aim_core::app::identity::{IdentityFallback, resolve_identity}; +use aim_core::domain::app::IdentityConfidence; + +#[test] +fn unresolved_identity_can_fall_back_to_url() { + let identity = resolve_identity( + None, + None, + Some("https://example.com/app.AppImage"), + IdentityFallback::AllowRawUrl, + ) + .unwrap(); + + assert!(identity.stable_id.contains("example.com")); + assert_eq!(identity.confidence, IdentityConfidence::RawUrlFallback); +} + +#[test] +fn explicit_id_is_treated_as_confident() { + let identity = resolve_identity( + Some("Bat"), + Some("sharkdp/bat"), + Some("https://github.com/sharkdp/bat/releases"), + IdentityFallback::AllowRawUrl, + ) + .unwrap(); + + assert_eq!(identity.stable_id, "sharkdp-bat"); + assert_eq!(identity.display_name, "Bat"); + assert_eq!(identity.confidence, IdentityConfidence::Confident); +} diff --git a/crates/aim-core/tests/install_paths.rs b/crates/aim-core/tests/install_paths.rs new file mode 100644 index 0000000..217e29e --- /dev/null +++ b/crates/aim-core/tests/install_paths.rs @@ -0,0 +1,21 @@ +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")); +} diff --git a/crates/aim-core/tests/install_scope.rs b/crates/aim-core/tests/install_scope.rs new file mode 100644 index 0000000..c0411ce --- /dev/null +++ b/crates/aim-core/tests/install_scope.rs @@ -0,0 +1,8 @@ +use aim_core::app::scope::{ScopeOverride, resolve_install_scope}; +use aim_core::domain::app::InstallScope; + +#[test] +fn explicit_scope_override_beats_effective_user() { + let scope = resolve_install_scope(false, ScopeOverride::System); + assert_eq!(scope, InstallScope::System); +} diff --git a/crates/aim-core/tests/query_resolution.rs b/crates/aim-core/tests/query_resolution.rs new file mode 100644 index 0000000..57a746e --- /dev/null +++ b/crates/aim-core/tests/query_resolution.rs @@ -0,0 +1,8 @@ +use aim_core::app::query::resolve_query; +use aim_core::domain::source::SourceKind; + +#[test] +fn owner_repo_defaults_to_github() { + let source = resolve_query("sharkdp/bat").unwrap(); + assert_eq!(source.kind, SourceKind::GitHub); +} diff --git a/crates/aim-core/tests/registry_roundtrip.rs b/crates/aim-core/tests/registry_roundtrip.rs new file mode 100644 index 0000000..c6e933e --- /dev/null +++ b/crates/aim-core/tests/registry_roundtrip.rs @@ -0,0 +1,10 @@ +use aim_core::registry::store::RegistryStore; +use tempfile::tempdir; + +#[test] +fn registry_round_trips_app_records() { + let dir = tempdir().unwrap(); + let store = RegistryStore::new(dir.path().join("registry.toml")); + let loaded = store.load().unwrap(); + assert!(loaded.apps.is_empty()); +} diff --git a/crates/aim-core/tests/remove_flow.rs b/crates/aim-core/tests/remove_flow.rs new file mode 100644 index 0000000..61a5d9d --- /dev/null +++ b/crates/aim-core/tests/remove_flow.rs @@ -0,0 +1,49 @@ +use aim_core::app::interaction::InteractionRequest; +use aim_core::app::list::build_list_rows; +use aim_core::app::remove::resolve_registered_app; +use aim_core::domain::app::AppRecord; + +#[test] +fn remove_flow_rejects_unknown_app_names() { + let result = resolve_registered_app("bat", &[]); + + assert!(result.is_err()); +} + +#[test] +fn list_flow_returns_display_rows_for_registered_apps() { + let rows = build_list_rows(&[AppRecord { + stable_id: "bat".to_owned(), + display_name: "Bat".to_owned(), + }]); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].stable_id, "bat"); + assert_eq!(rows[0].display_name, "Bat"); +} + +#[test] +fn ambiguous_remove_matches_include_stable_ids_for_client_choice() { + let apps = [ + AppRecord { + stable_id: "bat".to_owned(), + display_name: "Bat".to_owned(), + }, + AppRecord { + stable_id: "bat-nightly".to_owned(), + display_name: "Bat".to_owned(), + }, + ]; + + let error = resolve_registered_app("Bat", &apps).unwrap_err(); + + assert_eq!( + error, + aim_core::app::remove::ResolveRegisteredAppError::Ambiguous { + request: InteractionRequest::SelectRegisteredApp { + query: "Bat".to_owned(), + matches: vec!["Bat (bat)".to_owned(), "Bat (bat-nightly)".to_owned()], + }, + } + ); +} diff --git a/crates/aim-core/tests/update_planning.rs b/crates/aim-core/tests/update_planning.rs new file mode 100644 index 0000000..ba4e089 --- /dev/null +++ b/crates/aim-core/tests/update_planning.rs @@ -0,0 +1,22 @@ +use aim_core::app::update::build_update_plan; +use aim_core::domain::app::AppRecord; + +#[test] +fn empty_registry_produces_empty_plan() { + let plan = build_update_plan(&[]).unwrap(); + + assert!(plan.items.is_empty()); +} + +#[test] +fn installed_apps_are_carried_into_review_plan() { + let apps = [AppRecord { + stable_id: "bat".to_owned(), + display_name: "Bat".to_owned(), + }]; + + let plan = build_update_plan(&apps).unwrap(); + + assert_eq!(plan.items.len(), 1); + assert_eq!(plan.items[0].stable_id, "bat"); +}