initial skeleton
This commit is contained in:
parent
dc79fa2448
commit
71f89dde9c
60 changed files with 3480 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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?
|
||||||
3
AGENTS.md
Normal file
3
AGENTS.md
Normal file
|
|
@ -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.
|
||||||
780
Cargo.lock
generated
Normal file
780
Cargo.lock
generated
Normal file
|
|
@ -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"
|
||||||
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
|
@ -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"
|
||||||
40
README.md
40
README.md
|
|
@ -1,2 +1,42 @@
|
||||||
# aim
|
# aim
|
||||||
AppImage Manager
|
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 <QUERY>
|
||||||
|
aim
|
||||||
|
aim update
|
||||||
|
aim list
|
||||||
|
aim remove <QUERY>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 <QUERY>` 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 <QUERY>` resolves a registered application name before removal
|
||||||
|
|
|
||||||
21
crates/aim-cli/Cargo.toml
Normal file
21
crates/aim-cli/Cargo.toml
Normal file
|
|
@ -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
|
||||||
31
crates/aim-cli/src/cli/args.rs
Normal file
31
crates/aim-cli/src/cli/args.rs
Normal file
|
|
@ -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<Command>,
|
||||||
|
|
||||||
|
pub query: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
1
crates/aim-cli/src/cli/mod.rs
Normal file
1
crates/aim-cli/src/cli/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod args;
|
||||||
108
crates/aim-cli/src/lib.rs
Normal file
108
crates/aim-cli/src/lib.rs
Normal file
|
|
@ -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 {
|
||||||
|
<Cli as clap::Parser>::parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
||||||
|
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<ListRow>),
|
||||||
|
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<aim_core::app::add::BuildAddPlanError> for DispatchError {
|
||||||
|
fn from(value: aim_core::app::add::BuildAddPlanError) -> Self {
|
||||||
|
Self::AddPlan(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<aim_core::app::update::BuildUpdatePlanError> for DispatchError {
|
||||||
|
fn from(value: aim_core::app::update::BuildUpdatePlanError) -> Self {
|
||||||
|
Self::UpdatePlan(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<aim_core::app::remove::ResolveRegisteredAppError> for DispatchError {
|
||||||
|
fn from(value: aim_core::app::remove::ResolveRegisteredAppError) -> Self {
|
||||||
|
Self::RemovePlan(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<aim_core::registry::store::RegistryStoreError> for DispatchError {
|
||||||
|
fn from(value: aim_core::registry::store::RegistryStoreError) -> Self {
|
||||||
|
Self::Registry(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/aim-cli/src/main.rs
Normal file
15
crates/aim-cli/src/main.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
crates/aim-cli/src/ui/mod.rs
Normal file
2
crates/aim-cli/src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod prompt;
|
||||||
|
pub mod render;
|
||||||
3
crates/aim-cli/src/ui/prompt.rs
Normal file
3
crates/aim-cli/src/ui/prompt.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
use aim_core::app::interaction::InteractionRequest;
|
||||||
|
|
||||||
|
pub fn handle_interaction(_request: &InteractionRequest) {}
|
||||||
39
crates/aim-cli/src/ui/render.rs
Normal file
39
crates/aim-cli/src/ui/render.rs
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
13
crates/aim-cli/tests/cli_commands.rs
Normal file
13
crates/aim-cli/tests/cli_commands.rs
Normal file
|
|
@ -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"));
|
||||||
|
}
|
||||||
7
crates/aim-cli/tests/cli_smoke.rs
Normal file
7
crates/aim-cli/tests/cli_smoke.rs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
54
crates/aim-cli/tests/end_to_end_cli.rs
Normal file
54
crates/aim-cli/tests/end_to_end_cli.rs
Normal file
|
|
@ -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\""));
|
||||||
|
}
|
||||||
7
crates/aim-cli/tests/ui_summary.rs
Normal file
7
crates/aim-cli/tests/ui_summary.rs
Normal file
|
|
@ -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"));
|
||||||
|
}
|
||||||
15
crates/aim-core/Cargo.toml
Normal file
15
crates/aim-core/Cargo.toml
Normal file
|
|
@ -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
|
||||||
13
crates/aim-core/src/adapters/custom_json.rs
Normal file
13
crates/aim-core/src/adapters/custom_json.rs
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
37
crates/aim-core/src/adapters/direct_url.rs
Normal file
37
crates/aim-core/src/adapters/direct_url.rs
Normal file
|
|
@ -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<AdapterResolution, DirectUrlAdapterError> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
50
crates/aim-core/src/adapters/github.rs
Normal file
50
crates/aim-core/src/adapters/github.rs
Normal file
|
|
@ -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<AdapterResolution, GitHubAdapterError> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
40
crates/aim-core/src/adapters/gitlab.rs
Normal file
40
crates/aim-core/src/adapters/gitlab.rs
Normal file
|
|
@ -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<AdapterResolution, GitLabAdapterError> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
19
crates/aim-core/src/adapters/mod.rs
Normal file
19
crates/aim-core/src/adapters/mod.rs
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
|
}
|
||||||
16
crates/aim-core/src/adapters/sourceforge.rs
Normal file
16
crates/aim-core/src/adapters/sourceforge.rs
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
crates/aim-core/src/adapters/test_support.rs
Normal file
27
crates/aim-core/src/adapters/test_support.rs
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/aim-core/src/adapters/traits.rs
Normal file
29
crates/aim-core/src/adapters/traits.rs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
13
crates/aim-core/src/adapters/zsync.rs
Normal file
13
crates/aim-core/src/adapters/zsync.rs
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
36
crates/aim-core/src/app/add.rs
Normal file
36
crates/aim-core/src/app/add.rs
Normal file
|
|
@ -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<AddPlan, BuildAddPlanError> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
77
crates/aim-core/src/app/identity.rs
Normal file
77
crates/aim-core/src/app/identity.rs
Normal file
|
|
@ -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<AppIdentity, ResolveIdentityError> {
|
||||||
|
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::<String>()
|
||||||
|
.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))
|
||||||
|
}
|
||||||
4
crates/aim-core/src/app/interaction.rs
Normal file
4
crates/aim-core/src/app/interaction.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
pub enum InteractionRequest {
|
||||||
|
SelectRegisteredApp { query: String, matches: Vec<String> },
|
||||||
|
}
|
||||||
16
crates/aim-core/src/app/list.rs
Normal file
16
crates/aim-core/src/app/list.rs
Normal file
|
|
@ -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<ListRow> {
|
||||||
|
apps.iter()
|
||||||
|
.map(|app| ListRow {
|
||||||
|
stable_id: app.stable_id.clone(),
|
||||||
|
display_name: app.display_name.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
8
crates/aim-core/src/app/mod.rs
Normal file
8
crates/aim-core/src/app/mod.rs
Normal file
|
|
@ -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;
|
||||||
55
crates/aim-core/src/app/query.rs
Normal file
55
crates/aim-core/src/app/query.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use crate::domain::source::SourceKind;
|
||||||
|
use crate::domain::source::SourceRef;
|
||||||
|
|
||||||
|
pub fn resolve_query(query: &str) -> Result<SourceRef, ResolveQueryError> {
|
||||||
|
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(':')
|
||||||
|
}
|
||||||
80
crates/aim-core/src/app/remove.rs
Normal file
80
crates/aim-core/src/app/remove.rs
Normal file
|
|
@ -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::<Vec<_>>();
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RemovalResult, ResolveRegisteredAppError> {
|
||||||
|
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<AppRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
pub enum ResolveRegisteredAppError {
|
||||||
|
UnknownApp { query: String },
|
||||||
|
Ambiguous { request: InteractionRequest },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_lookup(value: &str) -> String {
|
||||||
|
value.trim().to_ascii_lowercase()
|
||||||
|
}
|
||||||
28
crates/aim-core/src/app/scope.rs
Normal file
28
crates/aim-core/src/app/scope.rs
Normal file
|
|
@ -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<ScopeOverride>,
|
||||||
|
) -> InstallScope {
|
||||||
|
match override_scope {
|
||||||
|
Some(scope) => resolve_install_scope(is_effective_root, scope),
|
||||||
|
None if is_effective_root => InstallScope::System,
|
||||||
|
None => InstallScope::User,
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/aim-core/src/app/update.rs
Normal file
17
crates/aim-core/src/app/update.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
use crate::domain::app::AppRecord;
|
||||||
|
use crate::domain::update::{PlannedUpdate, UpdatePlan};
|
||||||
|
|
||||||
|
pub fn build_update_plan(apps: &[AppRecord]) -> Result<UpdatePlan, BuildUpdatePlanError> {
|
||||||
|
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 {}
|
||||||
25
crates/aim-core/src/domain/app.rs
Normal file
25
crates/aim-core/src/domain/app.rs
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
3
crates/aim-core/src/domain/mod.rs
Normal file
3
crates/aim-core/src/domain/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod app;
|
||||||
|
pub mod source;
|
||||||
|
pub mod update;
|
||||||
29
crates/aim-core/src/domain/source.rs
Normal file
29
crates/aim-core/src/domain/source.rs
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
10
crates/aim-core/src/domain/update.rs
Normal file
10
crates/aim-core/src/domain/update.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
pub struct UpdatePlan {
|
||||||
|
pub items: Vec<PlannedUpdate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
pub struct PlannedUpdate {
|
||||||
|
pub stable_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
14
crates/aim-core/src/integration/install.rs
Normal file
14
crates/aim-core/src/integration/install.rs
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
2
crates/aim-core/src/integration/mod.rs
Normal file
2
crates/aim-core/src/integration/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod install;
|
||||||
|
pub mod paths;
|
||||||
40
crates/aim-core/src/integration/paths.rs
Normal file
40
crates/aim-core/src/integration/paths.rs
Normal file
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/aim-core/src/lib.rs
Normal file
6
crates/aim-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod adapters;
|
||||||
|
pub mod app;
|
||||||
|
pub mod domain;
|
||||||
|
pub mod integration;
|
||||||
|
pub mod platform;
|
||||||
|
pub mod registry;
|
||||||
25
crates/aim-core/src/platform/mod.rs
Normal file
25
crates/aim-core/src/platform/mod.rs
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
2
crates/aim-core/src/registry/mod.rs
Normal file
2
crates/aim-core/src/registry/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod model;
|
||||||
|
pub mod store;
|
||||||
14
crates/aim-core/src/registry/model.rs
Normal file
14
crates/aim-core/src/registry/model.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct Registry {
|
||||||
|
pub version: u32,
|
||||||
|
pub apps: Vec<crate::domain::app::AppRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Registry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
apps: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
crates/aim-core/src/registry/store.rs
Normal file
59
crates/aim-core/src/registry/store.rs
Normal file
|
|
@ -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<Registry, RegistryStoreError> {
|
||||||
|
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<std::io::Error> for RegistryStoreError {
|
||||||
|
fn from(error: std::io::Error) -> Self {
|
||||||
|
Self::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<toml::de::Error> for RegistryStoreError {
|
||||||
|
fn from(error: toml::de::Error) -> Self {
|
||||||
|
Self::Toml(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<toml::ser::Error> for RegistryStoreError {
|
||||||
|
fn from(error: toml::ser::Error) -> Self {
|
||||||
|
Self::SerializeToml(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/aim-core/tests/adapter_contract.rs
Normal file
7
crates/aim-core/tests/adapter_contract.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
12
crates/aim-core/tests/adapter_smoke.rs
Normal file
12
crates/aim-core/tests/adapter_smoke.rs
Normal file
|
|
@ -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"));
|
||||||
|
}
|
||||||
18
crates/aim-core/tests/github_add_flow.rs
Normal file
18
crates/aim-core/tests/github_add_flow.rs
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
31
crates/aim-core/tests/identity_resolution.rs
Normal file
31
crates/aim-core/tests/identity_resolution.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
21
crates/aim-core/tests/install_paths.rs
Normal file
21
crates/aim-core/tests/install_paths.rs
Normal file
|
|
@ -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"));
|
||||||
|
}
|
||||||
8
crates/aim-core/tests/install_scope.rs
Normal file
8
crates/aim-core/tests/install_scope.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
8
crates/aim-core/tests/query_resolution.rs
Normal file
8
crates/aim-core/tests/query_resolution.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
10
crates/aim-core/tests/registry_roundtrip.rs
Normal file
10
crates/aim-core/tests/registry_roundtrip.rs
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
49
crates/aim-core/tests/remove_flow.rs
Normal file
49
crates/aim-core/tests/remove_flow.rs
Normal file
|
|
@ -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()],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
22
crates/aim-core/tests/update_planning.rs
Normal file
22
crates/aim-core/tests/update_planning.rs
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue