diff --git a/.audits/2026-03-21T02-02-28Z.md b/.audits/2026-03-21T02-02-28Z.md new file mode 100644 index 0000000..f535edc --- /dev/null +++ b/.audits/2026-03-21T02-02-28Z.md @@ -0,0 +1,498 @@ +# Repository Audit: aim (AppImage Manager) + +**Timestamp:** 2026-03-21T02:02:28Z (UTC) +**Commit:** f260790d915e350879838d027db8d602311d4437 +**Branch:** main +**Repo Path:** /home/stoorps/repos/aim +**Audit Scope:** Full repository — product completeness, provider support, registry operations, docs-vs-code mismatches, missing tests, v1.0 readiness + +--- + +## Executive Summary + +The `aim` AppImage manager has a **solid architectural foundation** with clean domain separation, good test coverage for GitHub flows, and working install/update/remove operations. However, **critical gaps exist** that block production v1.0 readiness: + +- **Download reliability is insufficient** — entire files loaded into memory, no resume/retry, no timeouts, risking OOM on 500MB+ AppImages +- **Registry safety is weak** — no atomic writes, no backups, no corruption recovery, vulnerable to concurrent access +- **Documentation is stale** — README omits SourceForge, zsync, custom-json providers already in code +- **Missing product features** — no search, no version pinning, no dry-run, no info command, no rollback +- **Checksum verification exists but is unused** — metadata contains SHA-512 checksums that are parsed but never validated during downloads +- **CustomJsonAdapter is a non-functional stub** — declared but all methods return errors + +**v1.0 Readiness Verdict:** **Not ready.** Core reliability gaps (download safety, registry atomicity, checksum validation) must be closed before production use. Feature gaps (search, info, dry-run) are acceptable for v1.0 but limit user expectations. + +--- + +## Surface Map + +### Entry Points +- **CLI:** `aim `, `aim`, `aim update`, `aim list`, `aim remove ` +- **Scope Overrides:** `--user`, `--system` +- **Environment Variables:** `AIM_REGISTRY_PATH`, `AIM_GITHUB_TOKEN`, `GITHUB_TOKEN`, `AIM_GITHUB_API_BASE`, `HOME` + +### Public Interfaces +- **Supported Query Forms:** + - GitHub shorthand: `owner/repo` + - GitHub URLs: repository, release, asset + - GitLab URLs: repository, release-like, ambiguous candidates + - SourceForge URLs: project, file download paths + - Direct HTTPS/HTTP URLs + - Local file imports: `file://...` + +### Implemented Providers +- **Repository-backed:** GitHub, GitLab, SourceForge +- **Exact-resolution:** DirectUrl, File +- **Metadata-only:** Zsync (update channels), Electron Builder (checksums) +- **Stub:** CustomJson (all operations return errors) + +### State & Data Layers +- **Registry:** TOML file at `~/.local/share/aim/registry.toml`, stores installed apps with source, version, update strategy +- **Install Paths:** + - User scope: `~/.local/lib/aim/appimages`, `~/.local/share/applications`, `~/.local/share/icons` + - System scope: `/opt/aim/appimages`, `/usr/share/applications`, `/usr/share/icons` +- **Staging:** `.local/share/aim/staging` for download-before-commit + +### Cross-Cutting Concerns +- **Platform Detection:** Distro family (Debian, RedHat, Arch, Immutable, Nix, Alpine, Other), desktop session presence +- **Error Handling:** Distinct unsupported-query vs no-installable-artifact vs transport-failure errors +- **Progress Reporting:** Live spinners, byte progress, staged events (resolveQuery, discoverRelease, downloadArtifact, etc.) + +--- + +## Findings (Prioritised Backlog) + +### GAP-001: Download Reliability — Memory Exhaustion Risk +**Type:** Reliability / Performance +**Severity:** **Critical** +**Impact:** **Production blocker.** Large AppImages (500MB+) are loaded entirely into memory before writing to disk. Risky on low-memory systems, can trigger OOM kills. +**Evidence:** +- [crates/aim-core/src/app/add.rs](crates/aim-core/src/app/add.rs#L506-L545): `download_artifact_bytes_with_reporter` allocates a `Vec`, reads 16KB chunks in a loop, then `extend_from_slice`. Full payload lives in heap before `stage_and_commit_payload` writes it. +- No streaming-to-disk, no memory pressure handling. + +**Repro/Trace:** +1. Attempt `aim ` where payload is 1GB +2. Watch process RSS grow to 1GB+ before any file write + +**Suggested Fix:** +- Stream directly to staged file path using `io::copy` or chunked writes with temp file + atomic rename +- Alternatively, use memory-mapped I/O for very large files +- Add download timeout via `reqwest::ClientBuilder::timeout` + +**Acceptance Criteria:** +- [ ] Download writes directly to disk without intermediate full-buffer accumulation +- [ ] Memory usage stays constant regardless of AppImage size +- [ ] Configurable timeout (default 5 minutes) + +--- + +### GAP-002: Download Reliability — No Resume, Retry, or Timeout +**Type:** Reliability +**Severity:** **High** +**Impact:** Network interruptions, slow connections, or server timeouts cause complete download restart. Poor UX for large files or unstable networks. +**Evidence:** +- [crates/aim-core/src/app/add.rs](crates/aim-core/src/app/add.rs#L506-L545): Single `reqwest::blocking::get(url)` call, no retry loop, no partial-download resume via Range headers +- No timeout configured on `reqwest::blocking::Client` in `ReqwestGitHubTransport::new()` — hangs indefinitely on stalled connections + +**Suggested Fix:** +- Add retry logic (3 attempts with exponential backoff) +- Support HTTP Range requests for resume if server supports it (check `Accept-Ranges` header) +- Configure request timeout (30s connect, 5m total) +- Provide `--no-resume` flag to skip range logic if needed + +**Acceptance Criteria:** +- [ ] Failed downloads retry up to 3 times +- [ ] Partial downloads resume from last byte if server supports Range +- [ ] Timeout after 5 minutes of inactivity or 30s connect timeout + +--- + +### GAP-003: Checksum Verification Not Used +**Type:** Security / Reliability +**Severity:** **High** +**Impact:** **Silent corruption or supply-chain attacks.** Metadata includes SHA-512 checksums (electron-builder) but downloads are never verified. Corrupted or tampered payloads install successfully. +**Evidence:** +- [crates/aim-core/src/metadata/electron_builder.rs](crates/aim-core/src/metadata/electron_builder.rs#L8): Parses `sha512:` field from `latest-linux.yml` +- [crates/aim-core/src/domain/update.rs](crates/aim-core/src/domain/update.rs#L17): `MetadataHints` struct includes `checksum: Option` +- No verification step in [crates/aim-core/src/app/add.rs](crates/aim-core/src/app/add.rs#L381) `download_artifact_bytes_with_reporter` or [crates/aim-core/src/integration/install.rs](crates/aim-core/src/integration/install.rs#L87-L115) `stage_and_commit_payload` + +**Repro/Trace:** +1. Install app with known electron-builder metadata: `aim owner/repo` +2. Inject corrupt bytes into download stream (or MITM proxy) +3. Installation succeeds without checksum mismatch error + +**Suggested Fix:** +- After download completes, compute SHA-512 of payload if `checksum` hint exists +- Fail installation with clear error if mismatch +- Add `--skip-checksum` flag for advanced users bypassing validation + +**Acceptance Criteria:** +- [ ] SHA-512 checksums from metadata are verified post-download +- [ ] Mismatch triggers clear error with expected vs actual hash +- [ ] Installs without metadata checksums proceed with warning + +--- + +### GAP-004: Registry Corruption — No Atomic Writes or Backups +**Type:** Reliability +**Severity:** **High** +**Impact:** Registry corruption if process killed mid-save. No recovery mechanism; complete data loss of installed apps list. +**Evidence:** +- [crates/aim-core/src/registry/store.rs](crates/aim-core/src/registry/store.rs#L26-L31): `RegistryStore::save` directly writes to `self.path` via `fs::write`. Not atomic (no temp file + rename). +- [crates/aim-core/src/registry/store.rs](crates/aim-core/src/registry/store.rs#L14-L21): `load` returns `toml::de::Error` on corrupt file, no fallback to backup + +**Repro/Trace:** +1. Start `aim sharkdp/bat` +2. Kill process (`kill -9`) during registry save stage +3. Attempt `aim list` — registry file is truncated or corrupt, operation fails + +**Suggested Fix:** +- Atomic save: write to `registry.toml.new`, then `fs::rename` to `registry.toml` +- Backup previous registry to `registry.toml.bak` before overwrite +- On load failure, attempt restore from `.bak` +- Add corruption detection (signature header) for faster failure + +**Acceptance Criteria:** +- [ ] Registry saves are atomic (temp file + rename) +- [ ] Previous registry backed up before save +- [ ] Corrupt registry auto-restores from backup with warning +- [ ] Test: kill process mid-save, verify registry intact + +--- + +### GAP-005: Concurrent Access — No Registry Locking +**Type:** Reliability +**Severity:** **Medium** +**Impact:** Running multiple `aim` commands simultaneously can corrupt registry (race on read-modify-write). Data loss or duplicate entries possible. +**Evidence:** +- [crates/aim-cli/src/lib.rs](crates/aim-cli/src/lib.rs#L34-L38): `dispatch_with_reporter` loads registry, mutates in-memory copy, saves back. No file lock. +- No `flock` or lock file mechanism in [crates/aim-core/src/registry/store.rs](crates/aim-core/src/registry/store.rs) +- Grep for `Mutex` found zero results in `crates/` — no concurrency control + +**Repro/Trace:** +1. Terminal A: `aim sharkdp/bat` (slow network download) +2. Terminal B: `aim update` (starts while A is downloading) +3. Both save registry simultaneously, one clobbers the other + +**Suggested Fix:** +- Advisory file lock on `registry.toml.lock` using `fs2` crate +- Acquire lock in `load()`, release in `save()` +- Fail fast with clear error if lock unavailable after 5s + +**Acceptance Criteria:** +- [ ] Only one `aim` process can modify registry at a time +- [ ] Second process waits or fails cleanly with "registry locked" message +- [ ] Lock automatically released on crash (advisory lock semantics) + +--- + +### GAP-006: Documentation — Missing Providers in README +**Type:** Documentation / DX +**Severity:** **Medium** +**Impact:** Users unaware of supported sources. SourceForge, zsync, custom-json implemented but undocumented. +**Evidence:** +- [README.md](README.md#L23-L30): Documents GitHub, GitLab, direct URLs, file imports +- [crates/aim-core/src/adapters/mod.rs](crates/aim-core/src/adapters/mod.rs#L12-L19): `all_adapter_kinds()` includes `sourceforge`, `zsync`, `custom-json` +- [crates/aim-core/src/source/input.rs](crates/aim-core/src/source/input.rs#L53-L56): `classify_sourceforge_http` actively classifies SourceForge URLs +- grep for "SourceForge" in README.md: **no matches** + +**Suggested Fix:** +- Update README "Query Forms" section: + - Add SourceForge project URLs and file download URLs + - Note zsync as discovered metadata only (not install source) + - Document or remove custom-json (currently nonfunctional) +- Add examples: `aim https://sourceforge.net/projects/app/files/v1.0/App.AppImage/download` + +**Acceptance Criteria:** +- [ ] README documents all functional source types +- [ ] SourceForge examples included +- [ ] Zsync role clarified (metadata vs source) +- [ ] Custom-json either documented as experimental or removed from public list + +--- + +### GAP-007: CustomJsonAdapter Is Non-Functional Stub +**Type:** Missing Feature / Code Quality +**Severity:** **Medium** +**Impact:** Adapter listed in `all_adapter_kinds()` but completely unusable. Misleading. +**Evidence:** +- [crates/aim-core/src/adapters/custom_json.rs](crates/aim-core/src/adapters/custom_json.rs#L1-L24): + - `normalize()` returns `Err(AdapterError::UnsupportedQuery)` + - `resolve()` returns `Err(AdapterError::UnsupportedSource)` + - Zero tests for this adapter in `crates/aim-core/tests/` + +**Suggested Fix:** +- **Option A (remove):** Delete adapter, remove from `all_adapter_kinds()`, document as future work +- **Option B (implement):** Define JSON schema for custom update metadata, implement resolution logic, add tests +- **Option C (mark experimental):** Add comment and feature flag, exclude from default builds + +**Acceptance Criteria:** +- [ ] Either adapter works with documented schema, or removed from public API +- [ ] No adapter listed in `all_adapter_kinds()` that returns errors for all operations + +--- + +### GAP-008: Missing CLI Commands — Search, Info, Show +**Type:** Missing Feature +**Severity:** **Medium** +**Impact:** Users must know exact owner/repo or URL beforehand. No discovery workflow. +**Evidence:** +- [crates/aim-cli/src/cli/args.rs](crates/aim-cli/src/cli/args.rs#L1-L29): Only defines `Remove`, `List`, `Update` subcommands +- No `aim search`, `aim info `, or `aim show ` commands +- GitHub releases API supports search, but no CLI exposure + +**Suggested Fix:** +- `aim search ` — search GitHub for AppImages (requires GitHub API search endpoint or external index) +- `aim info ` or `aim show ` — display app details pre-install (version, description, assets) or post-install (installed paths, source, update status) + +**Acceptance Criteria:** +- [ ] `aim search` returns list of candidate apps with descriptions +- [ ] `aim info` shows app metadata without installing +- [ ] `aim show bat` displays installed app details (version, files, source) + +--- + +### GAP-009: Missing CLI Features — Dry-Run, Force, Verbose +**Type:** Missing Feature +**Severity:** **Medium** +**Impact:** Limited user control over operations. No preview mode, no way to debug issues, no forced reinstall. +**Evidence:** +- [crates/aim-cli/src/cli/args.rs](crates/aim-cli/src/cli/args.rs): No `--dry-run`, `--force`, `--verbose`, `--no-desktop` flags +- All operations execute immediately without preview except update plan (`aim` bare shows pending updates but requires `aim update` to execute) + +**Suggested Fix:** +- Add `--dry-run` / `-n`: Show what would be done without mutating state +- Add `--force` / `-f`: Reinstall even if already installed at same version +- Add `--verbose` / `-v`: Show detailed logs (API requests, file operations) +- Add `--no-desktop`: Skip desktop integration, install payload only + +**Acceptance Criteria:** +- [ ] `aim --dry-run ` prints plan without installing +- [ ] `aim --force ` reinstalls existing app +- [ ] `aim --verbose list` shows debug output +- [ ] `aim --no-desktop ` installs without .desktop file + +--- + +### GAP-010: No Version Pinning or Update Blocking +**Type:** Missing Feature +**Severity:** **Medium** +**Impact:** Users cannot pin to specific versions or exclude from updates. All apps track latest by default (unless installed from specific release URL). +**Evidence:** +- [crates/aim-core/src/domain/app.rs](crates/aim-core/src/domain/app.rs): `AppRecord` has `installed_version`, `update_strategy`, but no `pinned` or `update_policy` field +- `aim update` blindly updates all apps without user filtering + +**Suggested Fix:** +- Add `aim pin ` and `aim unpin ` commands +- Store `pinned: bool` or `update_policy: Auto | Manual | Pinned` in `AppRecord` +- `aim update` skips pinned apps, logs "X apps pinned, skipping" +- Add `--ignore ` flag to `aim update` for one-time exclusion + +**Acceptance Criteria:** +- [ ] `aim pin bat` prevents `aim update` from updating bat +- [ ] `aim list` shows pinned status +- [ ] `aim unpin bat` re-enables updates + +--- + +### GAP-011: No Rollback or Update Failure Recovery +**Type:** Reliability +**Severity:** **Medium** +**Impact:** Failed updates leave old binary deleted but registry updated, or vice versa. No way to revert to previous version. +**Evidence:** +- [crates/aim-core/src/app/update.rs](crates/aim-core/src/app/update.rs#L43-L75): `execute_updates_with_reporter` catches errors and records `UpdateExecutionStatus::Failed`, but old payload is already overwritten by [crates/aim-core/src/integration/install.rs](crates/aim-core/src/integration/install.rs#L102-L103) `fs::rename` steps +- No backup of old `.AppImage` file before update +- grep for `rollback` or `backup` found zero matches + +**Repro/Trace:** +1. `aim sharkdp/bat` (install v1.0) +2. Tamper with network to force update download failure +3. `aim update` — old bat.AppImage replaced with corrupt download, registry shows v1.1 but binary is broken + +**Suggested Fix:** +- Before update, rename `app.AppImage` to `app.AppImage.backup` +- On success, delete backup +- On failure, restore from backup and log rollback +- Add `aim rollback ` command to restore previous version + +**Acceptance Criteria:** +- [ ] Failed updates restore previous payload automatically +- [ ] Registry remains consistent with installed payload +- [ ] Manual rollback command available for user-initiated revert + +--- + +### GAP-012: No Proxy Support or Network Configuration +**Type:** Missing Feature +**Severity:** **Low** +**Impact:** Corporate or privacy-conscious users behind proxies cannot use `aim`. +**Evidence:** +- [crates/aim-core/src/source/github.rs](crates/aim-core/src/source/github.rs#L158-L175): `ReqwestGitHubTransport::new()` uses default `reqwest::blocking::Client` builder, no proxy configuration +- grep for `proxy`, `http_proxy`, `https_proxy`: zero matches + +**Suggested Fix:** +- Respect `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` environment variables +- Use `reqwest::ClientBuilder::proxy()` to configure +- Add `--proxy ` CLI flag for override + +**Acceptance Criteria:** +- [ ] `HTTP_PROXY=http://proxy:8080 aim ` uses proxy +- [ ] Corporate proxy users can install apps + +--- + +### GAP-013: No Offline or Cache-First Mode +**Type:** Missing Feature +**Severity:** **Low** +**Impact:** Cannot use `aim` in air-gapped or intermittent-connectivity environments. Every operation requires network. +**Evidence:** +- All provider adapters make live HTTP requests, no local cache + +**Suggested Fix:** +- Cache GitHub release metadata in `~/.cache/aim/metadata/` for 1 hour +- Add `--offline` flag to use cached data only +- Add `--refresh` to force cache invalidation + +**Acceptance Criteria:** +- [ ] `aim --offline list` works without network if metadata cached +- [ ] Metadata cached for 1 hour, revalidated on next request + +--- + +### GAP-014: No Help Text or Usage Examples +**Type:** Documentation / DX +**Severity:** **Low** +**Impact:** Users must read README or guess commands. Poor discoverability. +**Evidence:** +- [crates/aim-cli/src/cli/args.rs](crates/aim-cli/src/cli/args.rs#L1-L29): Clap definitions exist but no detailed `about` text or examples +- `aim --help` output is minimal (auto-generated clap summary) + +**Suggested Fix:** +- Add `#[command(long_about = "...")]` with detailed usage examples +- Add `#[arg(help = "...")]` for each flag +- Include examples in help: `aim sharkdp/bat`, `aim list`, `aim remove bat` + +**Acceptance Criteria:** +- [ ] `aim --help` shows examples and detailed descriptions +- [ ] Each subcommand has helpful usage text + +--- + +### GAP-015: No CI-Friendly Output or Machine-Parseable Formats +**Type:** Missing Feature +**Severity:** **Low** +**Impact:** Cannot integrate `aim` into automation or scripts. Output is human-readable only. +**Evidence:** +- [crates/aim-cli/src/ui/render.rs](crates/aim-cli/src/ui/render.rs): Output is styled text tables, no JSON or CSV option + +**Suggested Fix:** +- Add `--format json|yaml|csv` flag +- Add `--quiet` / `-q` for errors-only output + +**Acceptance Criteria:** +- [ ] `aim list --format json` returns machine-parseable output +- [ ] `aim --quiet ` suppresses progress spinners + +--- + +### GAP-016: Test Coverage — Missing Tests +**Type:** Testing / Quality +**Severity:** **Medium** +**Impact:** Gaps in reliability verification. +**Evidence:** +- **CustomJsonAdapter:** Zero tests in [crates/aim-core/tests/](crates/aim-core/tests/) +- **Concurrency:** No tests simulating concurrent registry access +- **Corruption recovery:** No tests for registry load failures + backup restore +- **Large files:** No tests for memory usage on 500MB+ downloads +- **Network failures:** Limited retry/timeout tests +- grep for `custom_json` in `tests/`: zero matches + +**Suggested Fix:** +- Add `custom_json_adapter_contract_test` if adapter is retained +- Add `concurrent_registry_access_test` using threads +- Add `registry_corruption_recovery_test` with intentionally corrupt TOML +- Add `large_file_download_memory_test` mocking 1GB payload +- Add `network_failure_retry_test` with flaky mock server + +**Acceptance Criteria:** +- [ ] All adapters in `all_adapter_kinds()` have contract tests +- [ ] Concurrency edge cases covered +- [ ] Corruption recovery path validated + +--- + +## Quick Wins + +Fixes that deliver high user value with low implementation cost: + +1. **GAP-006 (Documentation)** — Update README with SourceForge, zsync usage (~30 minutes) +2. **GAP-014 (Help Text)** — Add detailed clap help strings (~1 hour) +3. **GAP-009 (Dry-Run Flag)** — Add `--dry-run` flag that skips final save (~2 hours) +4. **GAP-007 (CustomJson Cleanup)** — Remove non-functional adapter or add experimental marker (~30 minutes) +5. **GAP-004 (Registry Backups)** — Copy registry to `.bak` before save (~1 hour) + +--- + +## Open Questions + +1. **Checksums:** Should aim fail-closed (reject unverified downloads) or warn-only? Some sources lack checksums. +2. **Update Policy:** Default to auto-update all apps, or require explicit `aim update `? +3. **Version Pinning:** Should pinned apps show in `aim list` with special marker, or hidden? +4. **Search Scope:** Limit search to apps with AppImage releases, or show all repos? +5. **CustomJson:** Is this intended for user-defined update feeds? Should it support local JSON files or HTTP endpoints? +6. **Offline Mode:** Cache release metadata only, or cache payloads too? +7. **Platform Support:** Current code is Linux-only (by design). Document macOS/Windows as non-goals? +8. **Concurrency:** Should `aim` support lock wait timeout (fail fast) or infinite retry? + +--- + +## Appendix + +### Notable Search Patterns +- `TODO|FIXME|HACK`: **0 matches** in Rust source (clean codebase) +- `unimplemented!`: 0 matches +- `panic!`: Minimal use, only in test fixtures +- `.unwrap()`: Mostly in tests, production code uses `Result` + +### Files Reviewed +- All `.rs` files in `crates/aim-core/src/` (app, adapters, domain, integration, platform, registry, source, update, metadata) +- All `.rs` files in `crates/aim-cli/src/` (cli, ui, lib, main) +- All test files in `crates/aim-core/tests/` and `crates/aim-cli/tests/` +- [README.md](README.md), [Cargo.toml](Cargo.toml) +- Planning docs in [.plans/007-source-provider-expansion/](.plans/007-source-provider-expansion/) + +### Test Coverage Hotspots +- **Strong:** GitHub flows (discovery, parsing, install, update), GitLab basic flows, SourceForge URL classification +- **Moderate:** DirectUrl, file imports, metadata parsing (zsync, electron-builder), platform detection, remove flows +- **Weak:** CustomJson (zero), concurrency, corruption recovery, network failures, large file handling + +--- + +## v1.0 Readiness Verdict + +**Status:** **Pre-Alpha → Alpha** (not v1.0-ready) + +**Blockers for v1.0:** +1. **GAP-001 (Memory exhaustion)** — Critical reliability issue +2. **GAP-003 (Checksum verification)** — Critical security issue +3. **GAP-004 (Registry atomicity)** — Critical data safety issue +4. **GAP-005 (Concurrent access)** — High-severity correctness issue + +**Nice-to-Have for v1.0:** +- Search, info commands +- Dry-run mode +- Proxy support +- Better help text + +**Recommendation:** +Close **GAP-001, GAP-003, GAP-004, GAP-005** before any production announcement. After those fixes, aim reaches **beta** status (core safety established). User-facing features (search, pin, rollback) can ship in v1.1+. + +**Effort Estimate (Blockers Only):** ~3-5 engineering days for someone familiar with Rust + reqwest + file I/O patterns. + +**Current Strengths:** +- Clean architecture (adapters, domain, app separation) +- Provider expansion path is well-designed +- Good test coverage for happy paths +- Terminal UX is polished (progress bars, styled output) +- Platform detection handles immutable distros, Nix, etc. + +**Overall Quality:** Well-built for internal/hobby use. Needs production hardening for public v1.0. diff --git a/.plans/008-v0-9-finalisation/2026-03-21-v0-9-finalisation-design.md b/.plans/008-v0-9-finalisation/2026-03-21-v0-9-finalisation-design.md new file mode 100644 index 0000000..c586380 --- /dev/null +++ b/.plans/008-v0-9-finalisation/2026-03-21-v0-9-finalisation-design.md @@ -0,0 +1,168 @@ +# v0.9 Finalisation Design + +## Goal + +Ship a credible v0.9 release that improves operational trust and product completeness without widening provider scope prematurely. This slice hardens downloads, enforces integrity checks, makes registry mutation safer, finalises a provider-extensible search contract with GitHub search as the first implementation, and aligns docs with the actual supported provider surface. + +## Release Positioning + +v0.9 is a trust-and-discoverability release. + +It is not the true v1.0 provider-completion release. `custom-json` is explicitly deferred to v1.0. Broader provider discovery remains phase 2 work. + +## In Scope + +1. Stream artifact downloads to disk instead of buffering whole payloads in memory. +2. Add timeout and retry behavior to the download path. +3. Enforce checksum verification when trusted metadata provides a checksum. +4. Make registry mutation atomic and add advisory locking for mutating flows. +5. Finalise `aim search ` with a provider-extensible search abstraction in `aim-core` and GitHub as the first remote provider. +6. Update user-facing docs so supported providers and current search scope are described honestly. +7. Add regression coverage for the above behaviors. + +## Explicitly Out Of Scope + +1. Implementing `custom-json`. +2. Broad multi-provider remote search parity. +3. Adding `info`, `show`, `dry-run`, rollback, or version pinning. +4. Expanding GitLab or SourceForge install resolution beyond the currently defended contract. +5. Reworking the CLI into an interactive picker or install-from-search workflow. + +## Architecture + +### Download Hardening + +The current add flow downloads into memory and only then stages the payload. v0.9 changes that boundary so the network layer streams into a staged file on disk. Payload validation and final commit remain owned by the install integration path, but the source of truth becomes a staged file rather than a `Vec` buffer. + +This keeps memory usage effectively flat for large AppImages and makes retry or timeout policy attach naturally to the download operation. + +### Integrity Enforcement + +Checksum hints already exist in parsed metadata. v0.9 carries those hints through artifact selection and install execution so the staged payload can be verified before the final install commit. If a trusted checksum exists and validation fails, install must fail closed. If no checksum exists, install continues with no false claim of verification. + +For v0.9, the only enforced trusted checksum contract is the existing electron-builder `sha512` field. That checksum must be treated as a base64-encoded SHA-512 digest of the raw payload bytes. Verification compares the base64 digest of the staged payload against the trimmed metadata value. A malformed trusted checksum is an install failure, not a warning. + +### Registry Safety + +Registry writes move to an atomic temp-file-and-rename pattern. Mutating commands also acquire an advisory registry lock so add, update, and remove cannot clobber each other through concurrent read-modify-write cycles. + +The registry layer remains in `aim-core`; `aim-cli` should continue to orchestrate, not own persistence rules. + +To avoid long lock retention, v0.9 does not hold the registry lock for network discovery, downloads, or desktop integration work. Instead, mutating flows acquire the exclusive advisory lock immediately before the registry transaction, reload the latest registry under lock, apply the final mutation by `stable_id`, save atomically, then release the lock. Read-only flows such as `list`, bare review, and `search` do not take the mutation lock. + +### Search Architecture + +Search becomes a first-class app flow in `aim-core`, not a CLI-only helper. The abstraction should be provider-extensible from the beginning, but only GitHub remote search is implemented in v0.9. + +The stable shape is: + +- `SearchQuery` for raw user intent and optional limits +- `SearchProvider` trait for provider-specific search backends +- `SearchResult` / `SearchResults` domain types for normalized output +- `build_search_results(...)` app entry point that aggregates remote provider hits and local installed matches + +GitHub search should provide install-ready queries that feed the existing add flow. Non-GitHub providers can implement the same contract later without changing the CLI surface. + +For v0.9, GitHub search is repository search only. The normalized install-ready query should be the canonical `owner/repo` form so search results feed the existing add flow without introducing a parallel install path. + +### CLI Surface + +`aim search ` is added as a read-only command. + +Output should distinguish: + +- remote provider results +- installed/local matches +- warnings such as partial provider failure or rate limit degradation + +Search does not install anything in v0.9. It is a discovery surface only. + +The CLI contract should also be deterministic: + +- default remote result limit is 10 +- GitHub remote hits preserve provider ranking order, with canonical locator as the stable tie-breaker when fixtures or adapters need explicit ordering +- local installed matches use case-insensitive substring matching against `stable_id` and `display_name` +- local matches render in a separate section and are sorted by exact match first, then prefix match, then substring match, with `stable_id` as the final tie-breaker + +## Data Flow + +### Add / Install + +1. Resolve query into source semantics. +2. Discover release candidates and metadata as today. +3. Select artifact and attach optional checksum hint. +4. Stream artifact bytes into a staged file. +5. Verify checksum if available. +6. Validate payload shape. +7. Commit staged payload into final location. +8. Persist registry through the locked, atomic registry store. + +If download, checksum verification, or payload validation fails, the staged file must be removed before returning the error. + +### Search + +1. Parse `aim search `. +2. Build `SearchQuery`. +3. Run enabled providers, currently GitHub. +4. Normalize remote hits into provider-neutral results. +5. Derive local installed matches from the registry. +6. Render a stable CLI summary. + +Search warnings must preserve partial-failure explainability. For v0.9, a GitHub rate limit or transport failure should become a warning if any local results still exist, and a command failure only if the overall command would otherwise produce no meaningful result. + +## Error Handling + +### Download Failures + +- Connection and HTTP failures should be retried according to policy. +- Exhausted retries should surface as a clear install failure. +- Timeout should be explicit rather than hanging indefinitely. +- Partial staged payloads must be removed on failure. + +### Checksum Failures + +- A checksum mismatch is a hard error. +- A malformed trusted checksum is a hard error. +- Absence of checksum is not an error. +- Search results must never imply verified integrity. + +### Registry Lock Failures + +- A second mutating process should fail cleanly with an explicit lock message or short wait policy. +- Non-mutating flows like list and bare review should remain read-only and avoid unnecessary lock contention. +- The registry mutation transaction must reload the latest registry state while holding the lock before applying the final mutation. + +### Search Failures + +- Provider failure should degrade to warnings when at least one provider succeeds. +- Search should fail only when the overall operation cannot produce a meaningful result. +- Search ordering, limit behavior, and installed-match rules must be stable across runs. + +## Testing Strategy + +1. Unit tests for streaming or staged download helpers, retry policy, and checksum verification. +2. Registry store tests for atomic write behavior and lock semantics. +3. CLI command tests for `aim search --help` and search rendering. +4. Fixture-backed GitHub search tests in `aim-core`. +5. Install integration tests for checksum pass and checksum mismatch behavior. +6. Focused regression tests to ensure current GitLab, SourceForge, and direct URL install semantics do not regress. +7. Cleanup tests to ensure failed downloads or failed checksum validation do not leave staged payloads behind. + +## Acceptance Criteria + +1. Large artifact downloads no longer require full in-memory buffering. +2. Download timeout and retry policy exist and are covered by tests. +3. Trusted checksums are enforced before final install commit. +4. Registry writes are atomic and mutating commands do not race each other silently. +5. `aim search ` works end to end against GitHub fixtures. +6. Search architecture allows additional providers to be added in phase 2 without changing the public CLI contract. +7. README and related plan docs describe current provider and search scope honestly. +8. Failed download or checksum paths do not leave orphaned staged files behind. + +## v1.0 Follow-On + +The true v1.0 track can build on this by: + +1. implementing `custom-json` +2. widening provider discovery beyond GitHub search +3. expanding provider install semantics where justified by defended tests diff --git a/.plans/008-v0-9-finalisation/2026-03-21-v0-9-finalisation-implementation-plan.md b/.plans/008-v0-9-finalisation/2026-03-21-v0-9-finalisation-implementation-plan.md new file mode 100644 index 0000000..4e20e44 --- /dev/null +++ b/.plans/008-v0-9-finalisation/2026-03-21-v0-9-finalisation-implementation-plan.md @@ -0,0 +1,370 @@ +# v0.9 Finalisation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Finalise v0.9 by hardening artifact downloads, enforcing checksum verification, making registry mutation safe, and shipping provider-extensible search with GitHub as the first provider while keeping `custom-json` deferred to v1.0. + +**Architecture:** Push download, integrity, and registry safety down into `aim-core`, keep `aim-cli` as a thin dispatch and rendering layer, and add a provider-neutral search app flow that can grow to additional providers in phase 2 without changing the CLI contract. + +**Tech Stack:** Rust, Cargo workspace, `aim-core` app/source/registry modules, `aim-cli` command parsing and rendering, reqwest blocking client, existing fixture-backed GitHub tests, CLI integration tests. + +--- + +### Task 1: Lock the v0.9 CLI and docs contract with failing tests + +**Files:** +- Modify: `crates/aim-cli/src/cli/args.rs` +- Modify: `crates/aim-cli/tests/cli_commands.rs` +- Modify: `README.md` +- Modify: `.plans/008-v0-9-finalisation/2026-03-21-v0-9-finalisation-design.md` + +**Step 1: Write the failing tests** + +Extend CLI help coverage so `--help` is expected to include `search`. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-cli --test cli_commands` +Expected: FAIL because the CLI does not yet expose `search`. + +**Step 3: Write minimal implementation** + +Add a `Search { query: String }` subcommand to the CLI args and update the README command/query documentation to reflect: + +- `aim search ` exists in v0.9 +- SourceForge support is documented honestly +- search is GitHub-backed first and provider-extensible, not multi-provider complete +- `custom-json` is not presented as a v0.9 feature + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-cli --test cli_commands` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-cli/src/cli/args.rs crates/aim-cli/tests/cli_commands.rs README.md .plans/008-v0-9-finalisation/2026-03-21-v0-9-finalisation-design.md +git commit -m "feat: add v0.9 search command contract" +``` + +### Task 2: Add failing GitHub search tests and a provider-neutral search model + +**Files:** +- Create: `crates/aim-core/src/app/search.rs` +- Modify: `crates/aim-core/src/app/mod.rs` +- Create: `crates/aim-core/src/domain/search.rs` +- Modify: `crates/aim-core/src/domain/mod.rs` +- Modify: `crates/aim-core/src/source/github.rs` +- Create: `crates/aim-core/tests/search_github.rs` + +**Step 1: Write the failing tests** + +Add search tests that assert: + +- GitHub fixtures can return normalized remote search hits +- normalized results include provider id, display name, description, homepage/source locator, and install-ready query +- the app-level search result type can also carry installed/local matches and warnings +- default remote result limit is 10 +- the install-ready query is canonical `owner/repo` +- remote hit ordering is stable under fixtures + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test search_github` +Expected: FAIL because no search domain or app flow exists yet. + +**Step 3: Write minimal implementation** + +Add provider-neutral search domain types and the app-level search entry point in `aim-core`. Extend the GitHub source transport with the smallest search capability needed for fixture-backed repository search. + +Keep the model narrow: + +- one provider trait or equivalent provider entry shape +- one normalized result type +- no premature provider-specific knobs beyond what GitHub needs now + +For v0.9, make the GitHub provider search repositories only. Preserve provider ranking order from fixtures or transport results, and use canonical locator as the deterministic tie-breaker when a secondary sort is needed. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test search_github` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/search.rs crates/aim-core/src/app/mod.rs crates/aim-core/src/domain/search.rs crates/aim-core/src/domain/mod.rs crates/aim-core/src/source/github.rs crates/aim-core/tests/search_github.rs +git commit -m "feat: add provider-neutral search core" +``` + +### Task 3: Wire `aim search` through dispatch and rendering + +**Files:** +- Modify: `crates/aim-cli/src/lib.rs` +- Modify: `crates/aim-cli/src/ui/render.rs` +- Create: `crates/aim-cli/tests/search_cli.rs` + +**Step 1: Write the failing tests** + +Add CLI integration tests that assert: + +- `aim search bat` prints a `Search Results` heading +- remote GitHub hits render with provider label and install-ready query +- installed/local matches render in a separate section when the registry contains matching apps +- installed/local matches use case-insensitive substring matching across `stable_id` and `display_name` +- installed/local matches are sorted deterministically by exact match, prefix match, substring match, then `stable_id` +- search remains read-only and does not mutate the registry + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-cli --test search_cli` +Expected: FAIL because dispatch and render do not know about search yet. + +**Step 3: Write minimal implementation** + +Add search dispatch to `aim-cli`, call into the new `aim-core` search flow, load registry state for local-match context, and render a stable read-only summary. + +Do not add interactive selection or install-from-search behavior. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-cli --test search_cli` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-cli/src/lib.rs crates/aim-cli/src/ui/render.rs crates/aim-cli/tests/search_cli.rs +git commit -m "feat: wire search through cli" +``` + +### Task 4: Add staged-download tests before changing the artifact pipeline + +**Files:** +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-core/src/integration/install.rs` +- Modify: `crates/aim-core/tests/install_integration.rs` +- Create: `crates/aim-core/tests/download_pipeline.rs` + +**Step 1: Write the failing tests** + +Add tests that assert: + +- artifact download can stream into a staged path instead of returning full in-memory bytes +- the staged file reaches full byte count and still emits progress +- the install path can commit from a staged file source +- a failed download attempt does not leave a partial staged payload behind + +Use fixture or local test doubles rather than real network calls. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test download_pipeline` +Expected: FAIL because the current add flow still downloads into `Vec`. + +**Step 3: Write minimal implementation** + +Refactor the add/install boundary so downloads are streamed into a staged file and the install integration path commits from disk. + +Keep existing operation stages and user-facing progress events intact. + +Make cleanup deterministic: if streaming, payload validation, or post-download verification fails, the staged file must be removed before returning the error. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test download_pipeline` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/add.rs crates/aim-core/src/integration/install.rs crates/aim-core/tests/install_integration.rs crates/aim-core/tests/download_pipeline.rs +git commit -m "refactor: stream artifacts into staged payloads" +``` + +### Task 5: Add retry and timeout policy to the download client + +**Files:** +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-core/src/source/github.rs` +- Modify: `crates/aim-core/tests/download_pipeline.rs` +- Modify: `crates/aim-core/tests/github_source_discovery.rs` + +**Step 1: Write the failing tests** + +Add focused tests that assert: + +- the shared HTTP client is constructed with explicit timeout behavior +- download retries transient failures according to policy +- exhausted retries surface a clear failure +- retry exhaustion does not leave a staged payload behind + +Prefer test doubles around client-building or download helpers over brittle timing assertions. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test download_pipeline` +Expected: FAIL because timeout and retry policy are not represented yet. + +**Step 3: Write minimal implementation** + +Introduce a small shared download client or helper configuration that both GitHub discovery and artifact download can use. Add explicit timeout configuration and retry loops for transient failures. + +Do not add resume support in this slice unless it falls out naturally from the refactor; timeout and retry are the required behaviors. + +Make the timeout contract explicit in code and tests. The implementation does not need user-facing configurability in v0.9, but it must use fixed non-infinite defaults. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test download_pipeline` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/add.rs crates/aim-core/src/source/github.rs crates/aim-core/tests/download_pipeline.rs crates/aim-core/tests/github_source_discovery.rs +git commit -m "feat: add timeout and retry policy to downloads" +``` + +### Task 6: Enforce checksum verification on install + +**Files:** +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-core/src/integration/install.rs` +- Modify: `crates/aim-core/src/domain/update.rs` +- Modify: `crates/aim-core/tests/install_integration.rs` +- Create: `crates/aim-core/tests/checksum_verification.rs` + +**Step 1: Write the failing tests** + +Add tests that assert: + +- installs with a valid checksum succeed +- installs with a checksum mismatch fail before final payload commit +- installs without a checksum still succeed +- malformed trusted checksums fail before final payload commit +- checksum failure does not leave a staged payload behind + +Use fixture metadata with deterministic payload bytes. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test checksum_verification` +Expected: FAIL because checksum hints are parsed but never enforced. + +**Step 3: Write minimal implementation** + +Thread checksum hints through artifact selection into install execution and verify the staged payload before the final rename. Surface a typed install failure for mismatch. + +For v0.9, implement only the existing electron-builder checksum contract: compare the base64-encoded SHA-512 digest of the raw staged payload bytes against the trimmed `sha512` metadata value. Treat malformed trusted checksum input as an install failure. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test checksum_verification` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/add.rs crates/aim-core/src/integration/install.rs crates/aim-core/src/domain/update.rs crates/aim-core/tests/install_integration.rs crates/aim-core/tests/checksum_verification.rs +git commit -m "feat: verify artifact checksum before install" +``` + +### Task 7: Make registry mutation atomic and locked + +**Files:** +- Modify: `crates/aim-core/src/registry/store.rs` +- Modify: `crates/aim-cli/src/lib.rs` +- Create: `crates/aim-core/tests/registry_store.rs` +- Modify: `Cargo.toml` +- Modify: `crates/aim-core/Cargo.toml` + +**Step 1: Write the failing tests** + +Add registry store tests that assert: + +- saves write through a temp file and leave the final registry valid +- concurrent mutating access cannot silently race +- lock acquisition failure surfaces a clear error +- the locked mutation path reloads the latest registry before applying the final mutation +- read-only flows do not require the mutation lock + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test registry_store` +Expected: FAIL because save is a direct `fs::write` with no lock semantics. + +**Step 3: Write minimal implementation** + +Implement atomic save and advisory locking in the registry store. Thread any needed lock lifecycle changes through the CLI mutating commands. + +Keep read-only flows simple and avoid unnecessary lock retention. + +The lock scope must be deterministic: + +- do not hold the registry lock during network discovery, downloads, or desktop integration +- acquire the exclusive lock immediately before the final registry transaction +- reload the latest registry while the lock is held +- apply the mutation by `stable_id` +- save atomically, then release the lock + +If a remove target disappears between pre-lock planning and the locked reload, fail cleanly instead of silently removing the wrong record. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test registry_store` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/registry/store.rs crates/aim-cli/src/lib.rs crates/aim-core/tests/registry_store.rs Cargo.toml crates/aim-core/Cargo.toml +git commit -m "feat: add atomic and locked registry mutation" +``` + +### Task 8: Run provider regression coverage and final verification + +**Files:** +- Modify: `README.md` +- Modify: `.plans/008-v0-9-finalisation/2026-03-21-v0-9-finalisation-implementation-plan.md` + +**Step 1: Add any missing regression expectations** + +If provider or CLI regressions appear during execution, add the smallest missing focused tests in the existing suites: + +- `crates/aim-core/tests/query_resolution.rs` +- `crates/aim-core/tests/adapter_contract.rs` +- `crates/aim-core/tests/install_integration.rs` +- `crates/aim-core/tests/update_planning.rs` +- `crates/aim-cli/tests/end_to_end_cli.rs` + +**Step 2: Run focused verification** + +Run: + +```bash +cargo test --package aim-core --test search_github --test download_pipeline --test checksum_verification --test registry_store --test install_integration +cargo test --package aim-cli --test cli_commands --test search_cli --test end_to_end_cli +``` + +Expected: PASS. + +**Step 3: Run full verification** + +Run: + +```bash +cargo fmt --all +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +Expected: PASS. + +**Step 4: Commit** + +```bash +git add README.md .plans/008-v0-9-finalisation/2026-03-21-v0-9-finalisation-implementation-plan.md crates/ +git commit -m "feat: finalize v0.9 reliability and search" +``` diff --git a/.plans/009-search-interactive-tui/2026-03-21-search-interactive-tui-design.md b/.plans/009-search-interactive-tui/2026-03-21-search-interactive-tui-design.md new file mode 100644 index 0000000..a0aa2e1 --- /dev/null +++ b/.plans/009-search-interactive-tui/2026-03-21-search-interactive-tui-design.md @@ -0,0 +1,137 @@ +# Search Interactive TUI Design + +## Summary + +This change upgrades `aim search ` from a plain text summary into an interactive terminal search flow when stdout and stdin are attached to a TTY. The interactive flow will use `ratatui` for a scrollable result browser, numeric multi-select inspired by `paru`, and an explicit confirmation step before install handoff. + +The repository does not currently contain a checked-in config file, but the product already has a user-facing `config.toml` used for themes outside the repository tree. This design extends that existing config contract rather than introducing a second settings path. + +## Goals + +- Make `aim search ` interactive by default on TTY. +- Render one result per row with clear columns for provider, repository, and install query. +- Support bottom-to-top list orientation by default, with config control. +- Support keyboard paging and numeric multi-select in the result browser. +- Show a confirmation step before install handoff unless disabled in config. +- Keep the search domain provider-extensible and avoid coupling core search ranking to terminal UI code. + +## Non-Goals + +- No `--json` output in this slice. +- No non-interactive rich formatting redesign beyond preserving a readable fallback. +- No general settings overhaul beyond the minimum config foundation needed to read existing theme settings and the new search keys together. + +## Recommended Approach + +### Option 1: Extend `dialoguer` + +This keeps dependencies smaller, but it does not fit the requested behavior well. Paging, bottom-to-top layout, dense row rendering, and `paru`-style numeric selection would have to be simulated awkwardly across prompt screens. + +### Option 2: Add a small config layer plus a dedicated `ratatui` search flow + +This is the recommended approach. It creates a minimal, reusable settings boundary in `aim-cli`, keeps the current `aim-core` search contract intact, and gives the terminal UI enough control to implement the requested interaction model without twisting `dialoguer` into a pseudo-TUI. + +### Option 3: Keep search plain text and launch a second prompt-only selection phase + +This is simpler to ship, but it falls short of the requested UX and would likely be replaced immediately. It also duplicates state between the renderer and the selection prompt. + +## Architecture + +### Config + +Add a lightweight config loader in `aim-cli` that reads the existing user `config.toml` location already used for theme settings. The loader should: + +- tolerate a missing file by returning defaults +- ignore unknown keys +- treat malformed config as a CLI error with a clear path-aware message +- expose a typed `CliConfig` model for UI code + +The search section should be: + +```toml +[search] +bottom_to_top = true +skip_confirmation = false +``` + +This keeps search-specific settings namespaced and avoids a flat `skip_search_confirmation` key that will not scale once more search settings exist. + +### Dispatch Flow + +`aim search ` should continue to build `SearchResults` through `aim-core`. `aim-cli` then chooses one of two render paths: + +- TTY path: launch the interactive search browser +- non-TTY path: render the existing plain text summary + +The interactive search browser should return one of three outcomes: + +- cancelled +- confirmed selection set +- selection set that still requires explicit confirmation + +Install execution is not part of this slice. The result of the search browser can remain a terminal-side selection artifact for now, but the code should be shaped so install handoff can be added without reworking the browser state machine. + +### TUI Model + +Add a dedicated module for interactive search state in `aim-cli`. It should own: + +- the ordered result rows +- the highlighted cursor row +- the selected row indices +- the current page and viewport +- the row number buffer for `paru`-style typed numeric selection +- the config-driven orientation flag +- the confirmation mode state + +Each visible row should stay one line tall. The row should include: + +- numeric index +- provider label +- repository or package identity +- install-ready query + +Warnings and installed matches should remain accessible, but the main browser should prioritize remote installable hits. If installed matches are shown in the interactive view, they should render in a distinct section or with a clear marker so they are not confused with remote install targets. + +### Key Handling + +The search browser should support: + +- `j` / `k` and arrow keys for movement +- `Ctrl+d` / `Ctrl+u` or `PageDown` / `PageUp` for paging +- `g` / `G` for jump to top or bottom +- digit entry for numeric selection ranges and comma-separated values +- `Space` to toggle the highlighted row +- `Enter` to continue to confirmation +- `Esc` or `q` to cancel + +Numeric selection should accept the same grammar throughout the session, for example `1`, `1,4,7`, and `3-6`. Invalid tokens should not panic; they should produce a small inline validation message and preserve the current selection. + +### Confirmation + +After the user leaves the browser with at least one selection, `aim-cli` should show a confirmation step by default. That step should summarise the chosen items and require an explicit yes/no confirmation. + +If `[search].skip_confirmation = true`, the browser should return the chosen set immediately after selection finalization. + +### Error Handling + +- Missing or empty search results should not launch the browser; use the existing text renderer. +- Non-TTY stdin or stdout should not attempt `ratatui` initialization. +- Terminal initialization failure should fall back to plain text output rather than aborting the search command. +- Config parse failure should abort with a clear message because silent misconfiguration would be hard to debug. + +## Testing Strategy + +Follow TDD for each slice: + +1. Add config parsing tests for defaults, valid search overrides, and malformed TOML. +2. Add state-machine tests for numeric selection parsing, paging, orientation, and confirmation transitions. +3. Add CLI tests covering TTY-gated fallback behavior and config-driven confirmation skipping. +4. Add a focused rendering test for one-line row formatting to keep the table stable. + +Avoid end-to-end terminal snapshot tests that depend on terminal escape sequences unless state-level coverage proves insufficient. + +## Delivery Notes + +- Keep `aim-core` unchanged unless a search-install handoff type is clearly needed. +- Keep the existing plain text renderer for non-interactive contexts and future `--json` work. +- Preserve current theme behavior and make the new config loader the shared entry point for both theme settings and search settings. \ No newline at end of file diff --git a/.plans/009-search-interactive-tui/2026-03-21-search-interactive-tui-implementation-plan.md b/.plans/009-search-interactive-tui/2026-03-21-search-interactive-tui-implementation-plan.md new file mode 100644 index 0000000..33d6203 --- /dev/null +++ b/.plans/009-search-interactive-tui/2026-03-21-search-interactive-tui-implementation-plan.md @@ -0,0 +1,166 @@ +# Search Interactive TUI Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add config-backed interactive search to `aim search ` with a `ratatui` browser, numeric multi-select, paging, and an optional confirmation skip. + +**Architecture:** Keep `aim-core` responsible for search retrieval and ranking. Add a small config loader plus a `ratatui`-backed state machine in `aim-cli`, and gate the interactive path on TTY availability with a safe plain-text fallback. + +**Tech Stack:** Rust, clap, serde, toml, ratatui, crossterm, assert_cmd + +--- + +### Task 1: Add CLI config loading for search settings + +**Files:** +- Create: `crates/aim-cli/src/config.rs` +- Modify: `crates/aim-cli/src/lib.rs` +- Modify: `crates/aim-cli/src/main.rs` +- Test: `crates/aim-cli/tests/config_loading.rs` + +**Step 1: Write the failing test** + +Add tests covering: + +- missing config returns defaults +- valid `[search]` config overrides defaults +- malformed TOML returns a path-aware error + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-cli --test config_loading` +Expected: FAIL because `config.rs` and the config loader do not exist. + +**Step 3: Write minimal implementation** + +Implement a typed `CliConfig` with nested `SearchConfig` and the minimum loader API needed by `aim-cli`. + +Defaults: + +```rust +SearchConfig { + bottom_to_top: true, + skip_confirmation: false, +} +``` + +The loader must tolerate a missing file and reject malformed TOML with the resolved path in the error. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-cli --test config_loading` +Expected: PASS + +### Task 2: Add a search browser state machine + +**Files:** +- Create: `crates/aim-cli/src/ui/search_browser.rs` +- Modify: `crates/aim-cli/src/ui/mod.rs` +- Test: `crates/aim-cli/tests/search_browser.rs` + +**Step 1: Write the failing test** + +Add state-level tests for: + +- bottom-to-top ordering default +- cursor movement and page movement +- single index selection +- comma-separated and range numeric selection +- invalid numeric input preserving current selection +- confirmation state transitions + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-cli --test search_browser` +Expected: FAIL because the browser state module does not exist. + +**Step 3: Write minimal implementation** + +Build a pure Rust state model that does not require a live terminal to test. Keep terminal drawing and key-event adaptation separate from selection and pagination logic. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-cli --test search_browser` +Expected: PASS + +### Task 3: Wire `ratatui` and TTY-gated interactive search dispatch + +**Files:** +- Modify: `crates/aim-cli/Cargo.toml` +- Modify: `crates/aim-cli/src/lib.rs` +- Modify: `crates/aim-cli/src/ui/render.rs` +- Modify: `crates/aim-cli/src/main.rs` +- Modify: `crates/aim-cli/src/ui/prompt.rs` +- Test: `crates/aim-cli/tests/search_cli.rs` + +**Step 1: Write the failing test** + +Add CLI coverage for: + +- non-TTY search stays plain text +- config skip confirmation changes the post-selection path +- empty result sets do not launch the browser + +Use deterministic seams rather than a full escape-sequence snapshot test. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-cli --test search_cli` +Expected: FAIL because interactive search dispatch is not implemented. + +**Step 3: Write minimal implementation** + +Add `ratatui` and `crossterm`, initialize the browser only when stdin and stdout are terminals, and fall back cleanly to the existing renderer if terminal setup fails or the result set is empty. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-cli --test search_cli` +Expected: PASS + +### Task 4: Add row rendering and confirmation summary coverage + +**Files:** +- Modify: `crates/aim-cli/src/ui/search_browser.rs` +- Test: `crates/aim-cli/tests/ui_summary.rs` + +**Step 1: Write the failing test** + +Add focused tests for: + +- one-line row formatting +- provider and query columns remaining visible +- confirmation summary content for multi-select + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-cli --test ui_summary` +Expected: FAIL because the browser summaries are not rendered yet. + +**Step 3: Write minimal implementation** + +Implement the smallest formatting helpers needed to keep rows stable and the confirmation screen legible. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-cli --test ui_summary` +Expected: PASS + +### Task 5: Final verification + +**Files:** +- Modify as required by prior tasks only + +**Step 1: Run focused CLI tests** + +Run: `cargo test --package aim-cli --test config_loading --test search_browser --test search_cli --test ui_summary` +Expected: PASS + +**Step 2: Run workspace formatting** + +Run: `cargo fmt --all` +Expected: PASS + +**Step 3: Run workspace linting and regression tests** + +Run: `cargo test --workspace && cargo clippy --workspace --all-targets --all-features -- -D warnings` +Expected: PASS \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 59cc3d7..7141336 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,7 @@ ## Plans, designs, and specs locations. IMPORTANT IF USING BRAINSTORMING SKILL!!! -Docuemnts of these types MUST live in `.plans/` within a sensibly named and indexed subfolder. This can be branch name or a feature name like `001-initial-implementation`, `002-adding-gitlab-provider`, etc. \ No newline at end of file +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. + +## Audits +IMPORTANT!! +Audits are to live in `.audits` with a good name slug plus time & date. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 1b40bbc..1fec377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,24 +19,37 @@ dependencies = [ "assert_cmd", "clap", "console 0.16.3", + "crossterm", "dialoguer", "indicatif", "libc", "predicates", + "ratatui", + "serde", "tempfile", + "toml", ] [[package]] name = "aim-core" version = "0.1.0" dependencies = [ + "base64", + "fs2", "reqwest", "serde", "serde_yaml", + "sha2", "tempfile", "toml", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -132,6 +145,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -155,6 +177,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.57" @@ -223,6 +260,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.15.11" @@ -232,7 +283,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.0", "windows-sys 0.59.0", ] @@ -244,10 +295,88 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "unicode-width", + "unicode-width 0.2.0", "windows-sys 0.61.2", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -266,6 +395,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -277,6 +416,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -335,6 +480,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -384,6 +539,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -430,6 +595,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -632,6 +799,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -674,10 +847,32 @@ dependencies = [ "console 0.15.11", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.2.0", "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -700,6 +895,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -728,6 +932,12 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -740,12 +950,30 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -765,6 +993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -802,6 +1031,35 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -998,6 +1256,36 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1087,6 +1375,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1096,7 +1397,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1147,6 +1448,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -1230,6 +1537,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -1242,6 +1560,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1270,12 +1619,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1322,7 +1699,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1512,6 +1889,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1519,10 +1902,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-width" -version = "0.2.2" +name = "unicode-segmentation" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" @@ -1566,6 +1972,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -1730,6 +2142,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 288f947..84a331f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,14 +14,19 @@ license = "MIT" version = "0.1.0" [workspace.dependencies] +base64 = "0.22.1" clap = { version = "4.5.32", features = ["derive"] } assert_cmd = "2.0.16" dialoguer = "0.12.0" console = "0.16.3" +crossterm = "0.28.1" +fs2 = "0.4.3" indicatif = "0.17.11" libc = "0.2.171" +ratatui = "0.29.0" reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0.219", features = ["derive"] } serde_yaml = "0.9.34" +sha2 = "0.10.8" tempfile = "3.19.1" toml = "0.8.20" diff --git a/README.md b/README.md index 6db61ad..0d20941 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ aim aim aim update aim list +aim search aim remove ``` @@ -28,8 +29,18 @@ aim remove - direct GitHub release asset URLs - `https://...` direct URLs - GitLab URLs +- SourceForge URLs - `file://...` local file imports +## Search + +`aim search ` is part of v0.9 finalisation. + +- v0.9 search is GitHub-backed first +- search results should resolve to install-ready GitHub shorthand such as `owner/repo` +- the search model is provider-extensible for future phases +- `custom-json` is deferred and is not part of the v0.9 search or install contract + ## Scope Overrides By default `aim` auto-detects whether to use user or system scope. Override that with: diff --git a/crates/aim-cli/Cargo.toml b/crates/aim-cli/Cargo.toml index f4b23fc..cfc65ec 100644 --- a/crates/aim-cli/Cargo.toml +++ b/crates/aim-cli/Cargo.toml @@ -15,8 +15,12 @@ path = "src/main.rs" clap.workspace = true dialoguer.workspace = true console.workspace = true +crossterm.workspace = true indicatif.workspace = true libc.workspace = true +ratatui.workspace = true +serde.workspace = true +toml.workspace = true aim-core = { path = "../aim-core" } [dev-dependencies] diff --git a/crates/aim-cli/src/cli/args.rs b/crates/aim-cli/src/cli/args.rs index e4bd264..128dbfa 100644 --- a/crates/aim-cli/src/cli/args.rs +++ b/crates/aim-cli/src/cli/args.rs @@ -26,5 +26,6 @@ impl Cli { pub enum Command { Remove { query: String }, List, + Search { query: String }, Update, } diff --git a/crates/aim-cli/src/config.rs b/crates/aim-cli/src/config.rs new file mode 100644 index 0000000..5032080 --- /dev/null +++ b/crates/aim-cli/src/config.rs @@ -0,0 +1,130 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize)] +pub struct CliConfig { + #[serde(default)] + pub search: SearchConfig, + #[serde(default)] + pub theme: ThemeConfig, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)] +pub struct SearchConfig { + #[serde(default = "default_true")] + pub bottom_to_top: bool, + #[serde(default)] + pub skip_confirmation: bool, +} + +impl Default for SearchConfig { + fn default() -> Self { + Self { + bottom_to_top: true, + skip_confirmation: false, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)] +pub struct ThemeConfig { + #[serde(default = "default_accent")] + pub accent: String, + #[serde(default = "default_accent_secondary")] + pub accent_secondary: String, + #[serde(default = "default_dim")] + pub dim: String, +} + +impl Default for ThemeConfig { + fn default() -> Self { + Self { + accent: default_accent(), + accent_secondary: default_accent_secondary(), + dim: default_dim(), + } + } +} + +pub fn load() -> Result { + load_from_path(&default_path()) +} + +pub fn load_from_path(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(contents) => toml::from_str(&contents).map_err(|source| ConfigError::Parse { + path: path.to_path_buf(), + source, + }), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(CliConfig::default()), + Err(source) => Err(ConfigError::Read { + path: path.to_path_buf(), + source, + }), + } +} + +pub fn default_path() -> PathBuf { + if let Some(path) = env::var_os("AIM_CONFIG_PATH") { + return PathBuf::from(path); + } + + if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") { + return PathBuf::from(config_home).join("aim/config.toml"); + } + + let home = env::var_os("HOME").unwrap_or_else(|| ".".into()); + PathBuf::from(home).join(".config/aim/config.toml") +} + +#[derive(Debug)] +pub enum ConfigError { + Read { + path: PathBuf, + source: std::io::Error, + }, + Parse { + path: PathBuf, + source: toml::de::Error, + }, +} + +fn default_true() -> bool { + true +} + +fn default_accent() -> String { + "#b388ff".to_owned() +} + +fn default_accent_secondary() -> String { + "#d5c2ff".to_owned() +} + +fn default_dim() -> String { + "#7f7396".to_owned() +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read { path, source } => { + write!( + formatter, + "failed to read config {}: {source}", + path.display() + ) + } + Self::Parse { path, source } => { + write!( + formatter, + "failed to parse config {}: {source}", + path.display() + ) + } + } + } +} + +impl std::error::Error for ConfigError {} diff --git a/crates/aim-cli/src/lib.rs b/crates/aim-cli/src/lib.rs index 6312054..92092aa 100644 --- a/crates/aim-cli/src/lib.rs +++ b/crates/aim-cli/src/lib.rs @@ -1,6 +1,8 @@ pub mod cli; +pub mod config; pub mod ui; +use std::collections::{HashMap, HashSet}; use std::env; use std::path::{Path, PathBuf}; @@ -8,12 +10,15 @@ use aim_core::app::add::{ AddPlan, InstalledApp, build_add_plan, install_app_with_reporter, resolve_requested_scope, }; use aim_core::app::list::{ListRow, build_list_rows}; -use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage, ProgressReporter}; +use aim_core::app::progress::{ + NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter, +}; use aim_core::app::remove::{RemovalResult, remove_registered_app_with_reporter}; +use aim_core::app::search::build_search_results; use aim_core::app::update::{build_update_plan, execute_updates_with_reporter}; use aim_core::domain::app::AppRecord; +use aim_core::domain::search::{SearchQuery, SearchResults}; use aim_core::domain::update::{UpdateExecutionResult, UpdatePlan}; -use aim_core::registry::model::Registry; use aim_core::registry::store::RegistryStore; pub use cli::args::Cli; @@ -47,30 +52,37 @@ pub fn dispatch_with_reporter( cli::args::Command::Remove { query } => { let removal = remove_registered_app_with_reporter(&query, &apps, &install_home, reporter)?; - let remaining_apps = removal.remaining_apps.clone(); reporter.report(&OperationEvent::StageChanged { stage: OperationStage::SaveRegistry, message: "saving registry".to_owned(), }); - store.save(&Registry { - version: registry.version, - apps: remaining_apps, + store.mutate_exclusive(|latest| { + remove_app_record(&mut latest.apps, &removal.removed.stable_id); })?; reporter.report(&OperationEvent::Finished { summary: format!("removed {}", removal.removed.stable_id), }); Ok(DispatchResult::Removed(Box::new(removal))) } + cli::args::Command::Search { query } => { + reporter.report(&OperationEvent::Started { + kind: OperationKind::Search, + label: query.clone(), + }); + let results = build_search_results(&SearchQuery::new(&query), &apps)?; + reporter.report(&OperationEvent::Finished { + summary: format!("search complete: {} remote hits", results.remote_hits.len()), + }); + Ok(DispatchResult::Search(results)) + } cli::args::Command::Update => { let updates = execute_updates_with_reporter(&apps, &install_home, reporter)?; - let updated_apps = updates.apps.clone(); reporter.report(&OperationEvent::StageChanged { stage: OperationStage::SaveRegistry, message: "saving registry".to_owned(), }); - store.save(&Registry { - version: registry.version, - apps: updated_apps, + store.mutate_exclusive(|latest| { + merge_updated_app_records(&mut latest.apps, &apps, &updates.apps); })?; reporter.report(&OperationEvent::Finished { summary: format!( @@ -98,15 +110,12 @@ pub fn dispatch_with_reporter( let installed = install_app_with_reporter(&query, &plan, &install_home, requested_scope, reporter)?; - let mut updated_apps = registry.apps.clone(); - upsert_app_record(&mut updated_apps, installed.record.clone()); reporter.report(&OperationEvent::StageChanged { stage: OperationStage::SaveRegistry, message: "saving registry".to_owned(), }); - store.save(&Registry { - version: registry.version, - apps: updated_apps, + store.mutate_exclusive(|latest| { + upsert_app_record(&mut latest.apps, installed.record.clone()); })?; reporter.report(&OperationEvent::Finished { summary: format!("installed {}", installed.record.stable_id), @@ -119,7 +128,11 @@ pub fn dispatch_with_reporter( } pub fn render(result: &DispatchResult) -> String { - ui::render::render_dispatch_result(result) + render_with_config(result, &config::CliConfig::default()) +} + +pub fn render_with_config(result: &DispatchResult, config: &config::CliConfig) -> String { + ui::render::render_dispatch_result_with_config(result, config) } fn registry_path() -> PathBuf { @@ -137,6 +150,7 @@ pub enum DispatchResult { List(Vec), PendingAdd(Box), Removed(Box), + Search(SearchResults), UpdatePlan(UpdatePlan), Updated(Box), Noop, @@ -149,6 +163,7 @@ pub enum DispatchError { Prompt(ui::prompt::PromptError), RemovePlan(aim_core::app::remove::RemoveRegisteredAppError), Registry(aim_core::registry::store::RegistryStoreError), + Search(aim_core::app::search::SearchError), UpdatePlan(aim_core::app::update::BuildUpdatePlanError), UpdateExecution(aim_core::app::update::ExecuteUpdatesError), } @@ -195,6 +210,12 @@ impl From for DispatchError { } } +impl From for DispatchError { + fn from(value: aim_core::app::search::SearchError) -> Self { + Self::Search(value) + } +} + fn upsert_app_record(apps: &mut Vec, record: AppRecord) { if let Some(existing) = apps .iter_mut() @@ -207,6 +228,33 @@ fn upsert_app_record(apps: &mut Vec, record: AppRecord) { apps.push(record); } +fn remove_app_record(apps: &mut Vec, stable_id: &str) { + apps.retain(|app| app.stable_id != stable_id); +} + +fn merge_updated_app_records( + latest_apps: &mut [AppRecord], + original_apps: &[AppRecord], + updated_apps: &[AppRecord], +) { + let original_ids = original_apps + .iter() + .map(|app| app.stable_id.as_str()) + .collect::>(); + let updated_by_id = updated_apps + .iter() + .map(|app| (app.stable_id.as_str(), app.clone())) + .collect::>(); + + for app in latest_apps.iter_mut() { + if original_ids.contains(app.stable_id.as_str()) + && let Some(updated) = updated_by_id.get(app.stable_id.as_str()) + { + *app = updated.clone(); + } + } +} + fn install_home(registry_path: &Path) -> PathBuf { if env::var_os("AIM_REGISTRY_PATH").is_some() { return registry_path diff --git a/crates/aim-cli/src/main.rs b/crates/aim-cli/src/main.rs index 414977b..a18824c 100644 --- a/crates/aim-cli/src/main.rs +++ b/crates/aim-cli/src/main.rs @@ -1,9 +1,17 @@ fn main() { + let config = match aim_cli::config::load() { + Ok(config) => config, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + }; + let cli = aim_cli::parse(); let mut reporter = aim_cli::ui::progress::TerminalProgressReporter::stderr(); match aim_cli::dispatch_with_reporter(cli, &mut reporter) { Ok(result) => { - let output = aim_cli::render(&result); + let output = aim_cli::render_with_config(&result, &config); if !output.is_empty() { println!("{output}"); } diff --git a/crates/aim-cli/src/ui/mod.rs b/crates/aim-cli/src/ui/mod.rs index e22c24a..8808345 100644 --- a/crates/aim-cli/src/ui/mod.rs +++ b/crates/aim-cli/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod progress; pub mod prompt; pub mod render; +pub mod search_browser; pub mod theme; diff --git a/crates/aim-cli/src/ui/progress.rs b/crates/aim-cli/src/ui/progress.rs index a5a5264..7fe25f8 100644 --- a/crates/aim-cli/src/ui/progress.rs +++ b/crates/aim-cli/src/ui/progress.rs @@ -23,6 +23,7 @@ pub fn byte_style() -> ProgressStyle { pub fn operation_label(kind: OperationKind) -> &'static str { match kind { OperationKind::Add => "Installing", + OperationKind::Search => "Searching", OperationKind::UpdateBatch => "Updating", OperationKind::UpdateItem => "Updating", OperationKind::Remove => "Removing", diff --git a/crates/aim-cli/src/ui/render.rs b/crates/aim-cli/src/ui/render.rs index d57028b..fc48f12 100644 --- a/crates/aim-cli/src/ui/render.rs +++ b/crates/aim-cli/src/ui/render.rs @@ -1,7 +1,9 @@ use aim_core::app::add::AddPlan; +use aim_core::domain::search::SearchResults; use aim_core::domain::update::UpdateExecutionStatus; use crate::DispatchResult; +use crate::config::CliConfig; pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> String { [ @@ -14,11 +16,16 @@ pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> St } pub fn render_dispatch_result(result: &DispatchResult) -> String { + render_dispatch_result_with_config(result, &CliConfig::default()) +} + +pub fn render_dispatch_result_with_config(result: &DispatchResult, config: &CliConfig) -> String { match result { DispatchResult::Added(added) => render_added_app(added), DispatchResult::List(rows) => render_list(rows), DispatchResult::PendingAdd(plan) => render_pending_add(plan), DispatchResult::Removed(removed) => render_removed_app(removed), + DispatchResult::Search(results) => render_search_results_with_config(results, config), DispatchResult::UpdatePlan(plan) => render_update_plan(plan), DispatchResult::Updated(result) => render_updated_apps(result), DispatchResult::Noop => String::new(), @@ -119,6 +126,67 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String lines.join("\n") } +fn render_search_results(results: &SearchResults) -> String { + let mut lines = vec![crate::ui::theme::heading("Search Results")]; + + lines.push(crate::ui::theme::heading("Remote Results")); + if results.remote_hits.is_empty() { + lines.push(crate::ui::theme::muted("No remote matches")); + } else { + for hit in &results.remote_hits { + lines.push(crate::ui::theme::bullet(&format!( + "[{}] {}", + hit.provider_id, hit.display_name + ))); + lines.push(format!("Install query: {}", hit.install_query)); + lines.push(format!("Source: {}", hit.source_locator)); + if let Some(description) = &hit.description { + lines.push(format!("Description: {description}")); + } + } + } + + lines.push(crate::ui::theme::heading("Installed Matches")); + if results.installed_matches.is_empty() { + lines.push(crate::ui::theme::muted("No installed matches")); + } else { + for app in &results.installed_matches { + lines.push(crate::ui::theme::bullet(&format!( + "{} ({})", + app.display_name, app.stable_id + ))); + } + } + + if !results.warnings.is_empty() { + lines.push(crate::ui::theme::heading("Warnings")); + for warning in &results.warnings { + match warning.provider_id.as_deref() { + Some(provider_id) => { + lines.push(format!("Warning: {provider_id}: {}", warning.message)) + } + None => lines.push(format!("Warning: {}", warning.message)), + } + } + } + + lines.join("\n") +} + +fn render_search_results_with_config(results: &SearchResults, config: &CliConfig) -> String { + if crate::ui::search_browser::can_launch(results) { + match crate::ui::search_browser::run(results, config) { + Ok(Some(selection)) => { + return crate::ui::search_browser::render_confirmation_summary(&selection.rows); + } + Ok(None) => return String::new(), + Err(_) => {} + } + } + + render_search_results(results) +} + fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String { let mut lines = vec![ crate::ui::theme::heading("Update Summary"), diff --git a/crates/aim-cli/src/ui/search_browser.rs b/crates/aim-cli/src/ui/search_browser.rs new file mode 100644 index 0000000..b518637 --- /dev/null +++ b/crates/aim-cli/src/ui/search_browser.rs @@ -0,0 +1,848 @@ +use std::collections::BTreeSet; +use std::io::IsTerminal; +use std::time::Duration; + +use aim_core::domain::search::{SearchInstallStatus, SearchResult, SearchResults}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::Modifier; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, List, ListItem, Paragraph, Wrap}; +use ratatui::{Frame, Terminal}; + +use crate::config::{CliConfig, SearchConfig}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserPhase { + Browsing, + Confirming, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchRow { + pub status: SearchInstallStatus, + pub provider_id: String, + pub display_name: String, + pub description: Option, + pub install_query: String, + pub version: Option, + pub selectable: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchSelection { + pub rows: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SubmitAction { + None, + Confirming, + Confirmed(SearchSelection), +} + +pub struct SearchBrowserState { + rows: Vec, + query_text: String, + selected: BTreeSet, + cursor: usize, + page_size: usize, + phase: BrowserPhase, + numeric_buffer: String, + status_message: Option, +} + +impl SearchBrowserState { + pub fn new(results: Vec, config: SearchConfig, page_size: usize) -> Self { + Self::new_with_query(results, String::new(), config, page_size) + } + + pub fn new_with_query( + results: Vec, + query_text: String, + config: SearchConfig, + page_size: usize, + ) -> Self { + let mut rows = results + .into_iter() + .map(|result| SearchRow { + selectable: !matches!(result.install_status, SearchInstallStatus::Installed { .. }), + status: result.install_status, + provider_id: result.provider_id, + display_name: result.display_name, + description: result.description, + install_query: result.install_query, + version: result.version, + }) + .collect::>(); + + if config.bottom_to_top { + rows.reverse(); + } + + Self { + rows, + query_text, + selected: BTreeSet::new(), + cursor: 0, + page_size: page_size.max(1), + phase: BrowserPhase::Browsing, + numeric_buffer: String::new(), + status_message: None, + } + } + + pub fn ordered_rows(&self) -> &[SearchRow] { + &self.rows + } + + pub fn query_text(&self) -> &str { + &self.query_text + } + + pub fn selected_rows(&self) -> Vec<&SearchRow> { + self.selected + .iter() + .filter_map(|index| self.rows.get(*index)) + .collect() + } + + pub fn selected_rows_owned(&self) -> Vec { + self.selected_rows().into_iter().cloned().collect() + } + + pub fn selection_expression(&self) -> String { + compress_selection_ranges( + &self + .selected + .iter() + .map(|index| index + 1) + .collect::>(), + ) + } + + pub fn selection_prompt_value(&self) -> String { + if self.numeric_buffer.is_empty() { + self.selection_expression() + } else { + self.numeric_buffer.clone() + } + } + + pub fn phase(&self) -> BrowserPhase { + self.phase + } + + pub fn cursor_position(&self) -> usize { + self.cursor + } + + pub fn selection_count(&self) -> usize { + self.selected.len() + } + + pub fn has_selection(&self) -> bool { + !self.selected.is_empty() + } + + pub fn numeric_buffer(&self) -> &str { + &self.numeric_buffer + } + + pub fn status_message(&self) -> Option<&str> { + self.status_message.as_deref() + } + + pub fn page_bounds(&self) -> (usize, usize) { + let start = (self.cursor / self.page_size) * self.page_size; + let end = (start + self.page_size).min(self.rows.len()); + (start, end) + } + + pub fn move_next(&mut self) { + if self.cursor + 1 < self.rows.len() { + self.cursor += 1; + } + } + + pub fn move_previous(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + pub fn move_to_top(&mut self) { + self.cursor = 0; + } + + pub fn move_to_bottom(&mut self) { + if !self.rows.is_empty() { + self.cursor = self.rows.len() - 1; + } + } + + pub fn page_down(&mut self) { + if self.rows.is_empty() { + return; + } + + let next_page = ((self.cursor / self.page_size) + 1) * self.page_size; + self.cursor = next_page.min(self.rows.len().saturating_sub(1)); + } + + pub fn page_up(&mut self) { + self.cursor = self.cursor.saturating_sub(self.cursor % self.page_size); + self.cursor = self.cursor.saturating_sub(self.page_size); + } + + pub fn toggle_current_selection(&mut self) { + if self + .rows + .get(self.cursor) + .is_some_and(|row| !row.selectable) + { + self.set_status_message("installed result is not selectable"); + return; + } + + if !self.selected.insert(self.cursor) { + self.selected.remove(&self.cursor); + } + + self.clear_status_message(); + } + + pub fn enter_confirmation(&mut self) -> bool { + if self.selected.is_empty() { + return false; + } + + self.phase = BrowserPhase::Confirming; + true + } + + pub fn cancel_confirmation(&mut self) { + self.phase = BrowserPhase::Browsing; + } + + pub fn apply_numeric_selection(&mut self, input: &str) -> Result<(), String> { + let parsed = parse_selection(input, self.rows.len())?; + self.selected = parsed + .into_iter() + .filter(|index| self.rows.get(*index).is_some_and(|row| row.selectable)) + .collect(); + Ok(()) + } + + pub fn submit_selection(&mut self, skip_confirmation: bool) -> SubmitAction { + if !self.has_selection() { + self.set_status_message("select at least one result"); + return SubmitAction::None; + } + + if skip_confirmation { + return SubmitAction::Confirmed(SearchSelection { + rows: self.selected_rows_owned(), + }); + } + + self.enter_confirmation(); + SubmitAction::Confirming + } + + pub fn push_numeric_input(&mut self, character: char) { + self.numeric_buffer.push(character); + self.refresh_selection_from_numeric_buffer(); + } + + pub fn pop_numeric_input(&mut self) { + self.numeric_buffer.pop(); + self.refresh_selection_from_numeric_buffer(); + } + + pub fn clear_numeric_input(&mut self) { + self.numeric_buffer.clear(); + } + + pub fn set_status_message(&mut self, message: impl Into) { + self.status_message = Some(message.into()); + } + + pub fn clear_status_message(&mut self) { + self.status_message = None; + } + + fn is_selected(&self, index: usize) -> bool { + self.selected.contains(&index) + } + + fn refresh_selection_from_numeric_buffer(&mut self) { + let trimmed = self.numeric_buffer.trim(); + if trimmed.is_empty() { + return; + } + + if let Ok(parsed) = parse_selection(trimmed, self.rows.len()) { + self.selected = parsed + .into_iter() + .filter(|index| self.rows.get(*index).is_some_and(|row| row.selectable)) + .collect(); + } + } +} + +#[derive(Debug)] +pub enum SearchBrowserError { + Terminal(std::io::Error), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HighlightSegment { + pub text: String, + pub is_match: bool, +} + +pub fn can_launch(results: &SearchResults) -> bool { + !results.remote_hits.is_empty() + && std::io::stdin().is_terminal() + && std::io::stdout().is_terminal() +} + +pub fn run( + results: &SearchResults, + config: &CliConfig, +) -> Result, SearchBrowserError> { + let mut stdout = std::io::stdout(); + enable_raw_mode().map_err(SearchBrowserError::Terminal)?; + execute!(stdout, EnterAlternateScreen).map_err(SearchBrowserError::Terminal)?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).map_err(SearchBrowserError::Terminal)?; + let outcome = run_loop(&mut terminal, results, config); + + let leave_screen = execute!(terminal.backend_mut(), LeaveAlternateScreen); + let show_cursor = terminal.show_cursor(); + let disable_raw = disable_raw_mode(); + + if let Err(error) = leave_screen { + return Err(SearchBrowserError::Terminal(error)); + } + if let Err(error) = show_cursor { + return Err(SearchBrowserError::Terminal(error)); + } + if let Err(error) = disable_raw { + return Err(SearchBrowserError::Terminal(error)); + } + + outcome +} + +pub fn format_search_row( + index: usize, + row: &SearchRow, + selected: bool, + active: bool, + width: usize, +) -> String { + let cursor = if active { ">" } else { " " }; + let marker = if selected { "[*]" } else { "[ ]" }; + let status = match &row.status { + SearchInstallStatus::Available => "", + SearchInstallStatus::Installed { .. } => "[installed] ", + SearchInstallStatus::UpdateAvailable { .. } => "[update] ", + }; + let version = row + .version + .as_deref() + .map(|value| format!(" v{value}")) + .unwrap_or_default(); + let first_line = format!( + "{cursor}{marker} {index:>2}. {status}{}{version}", + row.display_name + ); + let second_line = match row.description.as_deref() { + Some(description) => format!("{} - {description}", row.provider_id), + None => row.provider_id.clone(), + }; + format!( + "{}\n{}", + truncate_line(&first_line, width), + truncate_line(&format!(" {second_line}"), width) + ) +} + +pub fn highlight_segments(text: &str, query: &str) -> Vec { + let normalized_query = query.trim().to_ascii_lowercase(); + if normalized_query.is_empty() { + return vec![HighlightSegment { + text: text.to_owned(), + is_match: false, + }]; + } + + let normalized_text = text.to_ascii_lowercase(); + let mut start = 0; + let mut segments = Vec::new(); + + while let Some(relative_match) = normalized_text[start..].find(&normalized_query) { + let match_start = start + relative_match; + let match_end = match_start + normalized_query.len(); + + if match_start > start { + segments.push(HighlightSegment { + text: text[start..match_start].to_owned(), + is_match: false, + }); + } + + segments.push(HighlightSegment { + text: text[match_start..match_end].to_owned(), + is_match: true, + }); + start = match_end; + } + + if start < text.len() { + segments.push(HighlightSegment { + text: text[start..].to_owned(), + is_match: false, + }); + } + + if segments.is_empty() { + segments.push(HighlightSegment { + text: text.to_owned(), + is_match: false, + }); + } + + segments +} + +pub fn render_confirmation_summary(rows: &[SearchRow]) -> String { + let mut lines = vec![crate::ui::theme::heading("Confirm Search Selection")]; + lines.push(format!("selected results: {}", rows.len())); + for row in rows { + lines.push(format!( + "{} [{}] {}", + crate::ui::theme::bullet(&row.display_name), + row.provider_id, + row.version + .as_deref() + .map(|value| format!("{} (v{value})", row.install_query)) + .unwrap_or_else(|| row.install_query.clone()) + )); + } + lines.join("\n") +} + +fn run_loop( + terminal: &mut Terminal>, + results: &SearchResults, + config: &CliConfig, +) -> Result, SearchBrowserError> { + let mut state = SearchBrowserState::new_with_query( + results.remote_hits.clone(), + results.query_text.clone(), + config.search.clone(), + 10, + ); + + loop { + terminal + .draw(|frame| draw_browser(frame, &state, results, config)) + .map_err(SearchBrowserError::Terminal)?; + + if !event::poll(Duration::from_millis(250)).map_err(SearchBrowserError::Terminal)? { + continue; + } + + let Event::Key(key) = event::read().map_err(SearchBrowserError::Terminal)? else { + continue; + }; + + if key.kind != KeyEventKind::Press { + continue; + } + + if let Some(outcome) = handle_key_event(&mut state, key.code, key.modifiers, &config.search) + { + return Ok(outcome); + } + } +} + +fn draw_browser( + frame: &mut Frame<'_>, + state: &SearchBrowserState, + _results: &SearchResults, + config: &CliConfig, +) { + let palette = crate::ui::theme::search_browser_palette(&config.theme); + + if state.phase() == BrowserPhase::Confirming { + let area = centered_rect(frame.area(), 70, 40); + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(render_confirmation_summary(&state.selected_rows_owned())) + .style(palette.text_style()), + area, + ); + return; + } + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(frame.area()); + + let header = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(1)]) + .split(layout[0]); + let header_top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(10), Constraint::Length(24)]) + .split(header[0]); + let (start, end) = state.page_bounds(); + + frame.render_widget( + Paragraph::new(Line::styled("Search Results", palette.heading_style())), + header_top[0], + ); + frame.render_widget( + Paragraph::new(vec![ + Line::styled( + format!( + "Showing {}-{} of {}", + start + 1, + end, + state.ordered_rows().len() + ), + palette.muted_style(), + ), + Line::styled( + format!("Selected {}", state.selection_count()), + palette.muted_style(), + ), + ]) + .alignment(Alignment::Right), + header_top[1], + ); + frame.render_widget( + Paragraph::new(Line::styled( + "Enter confirm Space toggle j/k move PgUp/PgDn page g/G jump q cancel", + palette.hint_style(), + )) + .wrap(Wrap { trim: true }), + header[1], + ); + + let width = layout[1].width as usize; + let items = state.ordered_rows()[start..end] + .iter() + .enumerate() + .map(|(offset, row)| { + let absolute = start + offset; + ListItem::new(render_search_row_lines( + absolute + 1, + row, + state.is_selected(absolute), + state.cursor_position() == absolute, + width, + palette, + state.query_text(), + )) + }) + .collect::>(); + frame.render_widget(List::new(items), layout[1]); + + let status = state.status_message().unwrap_or(""); + frame.render_widget( + Paragraph::new(vec![ + Line::from(vec![ + Span::styled("Apps to install: ", palette.text_style()), + Span::styled(state.selection_prompt_value(), palette.text_style()), + Span::styled(" eg. 1 2 3, 1-3", palette.hint_style()), + ]), + Line::styled(status, palette.muted_style()), + ]) + .wrap(Wrap { trim: true }), + layout[2], + ); +} + +fn render_search_row_lines( + index: usize, + row: &SearchRow, + selected: bool, + active: bool, + width: usize, + palette: crate::ui::theme::SearchBrowserPalette, + query_text: &str, +) -> Vec> { + let cursor = if active { ">" } else { " " }; + let checkbox = if selected { "[*]" } else { "[ ]" }; + let checkbox_style = if selected { + palette.checkbox_selected_style() + } else { + palette.checkbox_idle_style() + }; + let name_style = if !row.selectable { + palette.disabled_style() + } else if active { + palette.active_name_style() + } else { + palette.text_style() + }; + let index_style = if row.selectable { + palette.text_style() + } else { + palette.disabled_style() + }; + + let mut first_line = vec![ + Span::styled(cursor.to_owned(), palette.cursor_style()), + Span::raw(" "), + Span::styled(checkbox.to_owned(), checkbox_style), + Span::styled(format!(" {index:>2}. "), index_style), + ]; + + match row.status { + SearchInstallStatus::Available => {} + SearchInstallStatus::Installed { .. } => { + first_line.push(Span::styled( + "[installed] ".to_owned(), + name_style.add_modifier(Modifier::BOLD), + )); + } + SearchInstallStatus::UpdateAvailable { .. } => { + first_line.push(Span::styled( + "[update] ".to_owned(), + name_style.add_modifier(Modifier::BOLD), + )); + } + } + + push_highlighted_spans(&mut first_line, &row.display_name, query_text, name_style); + if let Some(version) = &row.version { + first_line.push(Span::raw(" ")); + first_line.push(Span::styled(format!("v{version}"), palette.version_style())); + } + + let detail_text = match row.description.as_deref() { + Some(description) => format!("{} - {description}", row.provider_id), + None => row.provider_id.clone(), + }; + let detail_text = truncate_line(&detail_text, width.saturating_sub(7)); + let provider_len = row.provider_id.len().min(detail_text.len()); + let (provider_text, remainder) = detail_text.split_at(provider_len); + let mut second_line = vec![Span::raw(" ")]; + second_line.push(Span::styled( + provider_text.to_owned(), + palette.dim_style().add_modifier(Modifier::BOLD), + )); + if !remainder.is_empty() { + push_highlighted_spans(&mut second_line, remainder, query_text, palette.dim_style()); + } + + vec![Line::from(first_line), Line::from(second_line)] +} + +fn handle_key_event( + state: &mut SearchBrowserState, + code: KeyCode, + modifiers: KeyModifiers, + config: &SearchConfig, +) -> Option> { + if state.phase() == BrowserPhase::Confirming { + return match code { + KeyCode::Enter | KeyCode::Char('y') => Some(Some(SearchSelection { + rows: state.selected_rows_owned(), + })), + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('n') => { + state.cancel_confirmation(); + state.set_status_message("confirmation cancelled"); + None + } + _ => None, + }; + } + + match code { + KeyCode::Up | KeyCode::Char('k') => { + state.move_previous(); + state.clear_status_message(); + } + KeyCode::Down | KeyCode::Char('j') => { + state.move_next(); + state.clear_status_message(); + } + KeyCode::PageDown => state.page_down(), + KeyCode::PageUp => state.page_up(), + KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => state.page_down(), + KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => state.page_up(), + KeyCode::Char('g') => state.move_to_top(), + KeyCode::Char('G') => state.move_to_bottom(), + KeyCode::Char(' ') => { + if state.numeric_buffer().is_empty() { + state.toggle_current_selection(); + } else if !state.numeric_buffer().ends_with(' ') { + state.push_numeric_input(' '); + } + } + KeyCode::Char(character) + if character.is_ascii_digit() || character == ',' || character == '-' => + { + state.push_numeric_input(character); + } + KeyCode::Backspace => state.pop_numeric_input(), + KeyCode::Enter => match state.submit_selection(config.skip_confirmation) { + SubmitAction::None | SubmitAction::Confirming => {} + SubmitAction::Confirmed(selection) => return Some(Some(selection)), + }, + KeyCode::Esc | KeyCode::Char('q') => return Some(None), + _ => {} + } + + None +} + +fn parse_selection(input: &str, row_count: usize) -> Result, String> { + let mut selected = BTreeSet::new(); + + for token in input + .split(|character: char| character == ',' || character.is_ascii_whitespace()) + .map(str::trim) + .filter(|token| !token.is_empty()) + { + if let Some((start, end)) = token.split_once('-') { + let start = parse_one_based(start, row_count, input)?; + let end = parse_one_based(end, row_count, input)?; + let (from, to) = if start <= end { + (start, end) + } else { + (end, start) + }; + for index in from..=to { + selected.insert(index); + } + } else { + selected.insert(parse_one_based(token, row_count, input)?); + } + } + + Ok(selected) +} + +fn parse_one_based(token: &str, row_count: usize, original: &str) -> Result { + let parsed = token + .parse::() + .map_err(|_| format!("invalid selection '{original}'"))?; + + if parsed == 0 || parsed > row_count { + return Err(format!("invalid selection '{original}'")); + } + + Ok(parsed - 1) +} + +fn push_highlighted_spans( + target: &mut Vec>, + text: &str, + query: &str, + base_style: ratatui::style::Style, +) { + for segment in highlight_segments(text, query) { + let style = if segment.is_match { + base_style.add_modifier(Modifier::BOLD) + } else { + base_style + }; + target.push(Span::styled(segment.text, style)); + } +} + +fn truncate_line(line: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + + let length = line.chars().count(); + if length <= width { + return line.to_owned(); + } + + if width == 1 { + return ".".to_owned(); + } + + if width <= 3 { + return ".".repeat(width); + } + + let mut truncated = line.chars().take(width - 3).collect::(); + truncated.push_str("..."); + truncated +} + +fn compress_selection_ranges(indices: &[usize]) -> String { + if indices.is_empty() { + return String::new(); + } + + let mut ranges = Vec::new(); + let mut start = indices[0]; + let mut end = indices[0]; + + for &index in &indices[1..] { + if index == end + 1 { + end = index; + continue; + } + + ranges.push(format_range(start, end)); + start = index; + end = index; + } + + ranges.push(format_range(start, end)); + ranges.join(",") +} + +fn format_range(start: usize, end: usize) -> String { + if start == end { + start.to_string() + } else { + format!("{start}-{end}") + } +} + +fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height_percent) / 2), + Constraint::Percentage(height_percent), + Constraint::Percentage((100 - height_percent) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ]) + .split(vertical[1])[1] +} diff --git a/crates/aim-cli/src/ui/theme.rs b/crates/aim-cli/src/ui/theme.rs index 78d7d7e..0ce6c69 100644 --- a/crates/aim-cli/src/ui/theme.rs +++ b/crates/aim-cli/src/ui/theme.rs @@ -1,5 +1,8 @@ use console::style; use dialoguer::theme::ColorfulTheme; +use ratatui::style::{Color, Modifier, Style}; + +use crate::config::ThemeConfig; pub fn dialog_theme() -> ColorfulTheme { ColorfulTheme::default() @@ -20,3 +23,87 @@ pub fn muted(message: &str) -> String { pub fn bullet(message: &str) -> String { format!("- {message}") } + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SearchBrowserPalette { + accent: Color, + accent_secondary: Color, + dim: Color, +} + +pub fn search_browser_palette(config: &ThemeConfig) -> SearchBrowserPalette { + SearchBrowserPalette { + accent: parse_color(&config.accent).unwrap_or(Color::Rgb(179, 136, 255)), + accent_secondary: parse_color(&config.accent_secondary) + .unwrap_or(Color::Rgb(213, 194, 255)), + dim: parse_color(&config.dim).unwrap_or(Color::Rgb(127, 115, 150)), + } +} + +impl SearchBrowserPalette { + pub fn heading_style(self) -> Style { + Style::default() + .fg(self.accent) + .add_modifier(Modifier::BOLD) + } + + pub fn hint_style(self) -> Style { + Style::default().fg(self.dim) + } + + pub fn muted_style(self) -> Style { + Style::default().fg(self.dim) + } + + pub fn text_style(self) -> Style { + Style::default().fg(Color::White) + } + + pub fn dim_style(self) -> Style { + Style::default().fg(self.dim) + } + + pub fn checkbox_selected_style(self) -> Style { + Style::default() + .fg(self.accent) + .add_modifier(Modifier::BOLD) + } + + pub fn checkbox_idle_style(self) -> Style { + Style::default().fg(self.dim) + } + + pub fn version_style(self) -> Style { + Style::default().fg(self.accent_secondary) + } + + pub fn tag_style(self) -> Style { + Style::default().add_modifier(Modifier::BOLD) + } + + pub fn cursor_style(self) -> Style { + Style::default() + .fg(self.accent) + .add_modifier(Modifier::BOLD) + } + + pub fn active_name_style(self) -> Style { + self.text_style().add_modifier(Modifier::BOLD) + } + + pub fn disabled_style(self) -> Style { + self.dim_style() + } +} + +fn parse_color(value: &str) -> Option { + let hex = value.trim().strip_prefix('#')?; + if hex.len() != 6 { + return None; + } + + let red = u8::from_str_radix(&hex[0..2], 16).ok()?; + let green = u8::from_str_radix(&hex[2..4], 16).ok()?; + let blue = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color::Rgb(red, green, blue)) +} diff --git a/crates/aim-cli/tests/cli_commands.rs b/crates/aim-cli/tests/cli_commands.rs index 8bc255e..f20dd05 100644 --- a/crates/aim-cli/tests/cli_commands.rs +++ b/crates/aim-cli/tests/cli_commands.rs @@ -7,6 +7,7 @@ fn help_lists_expected_commands() { cmd.arg("--help") .assert() .success() + .stdout(contains("search")) .stdout(contains("remove")) .stdout(contains("list")) .stdout(contains("update")); diff --git a/crates/aim-cli/tests/config_loading.rs b/crates/aim-cli/tests/config_loading.rs new file mode 100644 index 0000000..02b4508 --- /dev/null +++ b/crates/aim-cli/tests/config_loading.rs @@ -0,0 +1,64 @@ +use aim_cli::config::{CliConfig, ConfigError, SearchConfig, load_from_path}; +use tempfile::tempdir; + +#[test] +fn missing_config_file_returns_defaults() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + let config = load_from_path(&path).unwrap(); + + assert_eq!(config, CliConfig::default()); + assert_eq!(config.search, SearchConfig::default()); + assert!(config.search.bottom_to_top); + assert!(!config.search.skip_confirmation); + assert_eq!(config.theme.accent, "#b388ff"); + assert_eq!(config.theme.accent_secondary, "#d5c2ff"); + assert_eq!(config.theme.dim, "#7f7396"); +} + +#[test] +fn search_section_overrides_defaults() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "[search]\nbottom_to_top = false\nskip_confirmation = true\n\n[theme]\naccent = \"#9f6bff\"\naccent_secondary = \"#efe7ff\"\ndim = \"#6b6480\"\n", + ) + .unwrap(); + + let config = load_from_path(&path).unwrap(); + + assert_eq!( + config, + CliConfig { + search: SearchConfig { + bottom_to_top: false, + skip_confirmation: true, + }, + theme: aim_cli::config::ThemeConfig { + accent: "#9f6bff".to_owned(), + accent_secondary: "#efe7ff".to_owned(), + dim: "#6b6480".to_owned(), + }, + } + ); +} + +#[test] +fn malformed_toml_returns_path_aware_error() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[search\nskip_confirmation = true\n").unwrap(); + + let error = load_from_path(&path).unwrap_err(); + + match error { + ConfigError::Parse { + path: error_path, .. + } => { + assert_eq!(error_path, path); + } + other => panic!("expected parse error, got {other:?}"), + } +} diff --git a/crates/aim-cli/tests/search_browser.rs b/crates/aim-cli/tests/search_browser.rs new file mode 100644 index 0000000..b355a3d --- /dev/null +++ b/crates/aim-cli/tests/search_browser.rs @@ -0,0 +1,234 @@ +use aim_cli::config::SearchConfig; +use aim_cli::ui::search_browser::{BrowserPhase, SearchBrowserState, SubmitAction}; +use aim_core::domain::search::{SearchInstallStatus, SearchResult}; + +#[test] +fn browser_defaults_to_bottom_to_top_ordering() { + let state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3); + + assert_eq!( + visible_names(&state), + vec!["charlie/app", "bravo/app", "alpha/app"] + ); +} + +#[test] +fn browser_moves_cursor_and_pages() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 2); + + state.move_next(); + assert_eq!(state.cursor_position(), 1); + + state.page_down(); + assert_eq!(state.cursor_position(), 2); + + state.page_up(); + assert_eq!(state.cursor_position(), 0); +} + +#[test] +fn browser_supports_single_and_multiple_numeric_selection() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3); + + state.apply_numeric_selection("1,3").unwrap(); + + assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]); +} + +#[test] +fn browser_supports_numeric_ranges() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3); + + state.apply_numeric_selection("1-2").unwrap(); + + assert_eq!(selected_names(&state), vec!["charlie/app", "bravo/app"]); +} + +#[test] +fn browser_supports_space_separated_numeric_selection() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3); + + state.apply_numeric_selection("1 3").unwrap(); + + assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]); +} + +#[test] +fn typing_numeric_input_updates_selection_immediately() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3); + + state.push_numeric_input('1'); + assert_eq!(selected_names(&state), vec!["charlie/app"]); + + state.push_numeric_input(' '); + state.push_numeric_input('3'); + + assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]); +} + +#[test] +fn invalid_numeric_input_keeps_last_good_selection_visible() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3); + + state.push_numeric_input('1'); + assert_eq!(selected_names(&state), vec!["charlie/app"]); + + state.push_numeric_input('-'); + + assert_eq!(selected_names(&state), vec!["charlie/app"]); + assert_eq!(state.numeric_buffer(), "1-"); +} + +#[test] +fn highlight_segments_marks_matching_query_fragments() { + let fragments = aim_cli::ui::search_browser::highlight_segments("pingdotgg/t3code", "dotgg"); + + assert_eq!(fragments.len(), 3); + assert_eq!(fragments[1].text, "dotgg"); + assert!(fragments[1].is_match); +} + +#[test] +fn invalid_numeric_selection_preserves_existing_selection() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3); + state.apply_numeric_selection("2").unwrap(); + + let error = state.apply_numeric_selection("2-z").unwrap_err(); + + assert!(error.contains("2-z")); + assert_eq!(selected_names(&state), vec!["bravo/app"]); +} + +#[test] +fn confirmation_requires_selection_before_transition() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3); + + assert!(!state.enter_confirmation()); + assert_eq!(state.phase(), BrowserPhase::Browsing); + + state.toggle_current_selection(); + assert!(state.enter_confirmation()); + assert_eq!(state.phase(), BrowserPhase::Confirming); + + state.cancel_confirmation(); + assert_eq!(state.phase(), BrowserPhase::Browsing); +} + +#[test] +fn submit_selection_can_skip_confirmation_from_config() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3); + state.toggle_current_selection(); + + let action = state.submit_selection(true); + + assert_eq!( + action, + SubmitAction::Confirmed(aim_cli::ui::search_browser::SearchSelection { + rows: vec![aim_cli::ui::search_browser::SearchRow { + status: SearchInstallStatus::Available, + provider_id: "github".to_owned(), + display_name: "charlie/app".to_owned(), + description: None, + install_query: "charlie/app".to_owned(), + version: Some("1.0.0".to_owned()), + selectable: true, + }], + }) + ); +} + +#[test] +fn installed_rows_are_visible_but_not_selectable() { + let mut state = SearchBrowserState::new(installed_first_results(), SearchConfig::default(), 3); + + state.toggle_current_selection(); + + assert!(state.selected_rows().is_empty()); + assert_eq!( + state.status_message(), + Some("installed result is not selectable") + ); +} + +#[test] +fn update_rows_remain_selectable() { + let mut state = SearchBrowserState::new(update_first_results(), SearchConfig::default(), 3); + + state.toggle_current_selection(); + + assert_eq!(selected_names(&state), vec!["charlie/app"]); +} + +#[test] +fn selection_expression_prefills_from_checklist_selection() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 5); + + state.toggle_current_selection(); + state.move_to_bottom(); + state.toggle_current_selection(); + + assert_eq!(state.selection_expression(), "1,3"); +} + +#[test] +fn selection_expression_compacts_adjacent_ranges() { + let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 5); + + state.apply_numeric_selection("1-3").unwrap(); + + assert_eq!(state.selection_expression(), "1-3"); +} + +fn sample_results() -> Vec { + vec![ + sample_result("alpha/app"), + sample_result("bravo/app"), + sample_result("charlie/app"), + ] +} + +fn sample_result(name: &str) -> SearchResult { + SearchResult { + provider_id: "github".to_owned(), + display_name: name.to_owned(), + description: None, + source_locator: name.to_owned(), + install_query: name.to_owned(), + canonical_locator: name.to_owned(), + version: Some("1.0.0".to_owned()), + install_status: SearchInstallStatus::Available, + } +} + +fn installed_first_results() -> Vec { + let mut results = sample_results(); + results[2].install_status = SearchInstallStatus::Installed { + installed_version: Some("1.0.0".to_owned()), + }; + results +} + +fn update_first_results() -> Vec { + let mut results = sample_results(); + results[2].install_status = SearchInstallStatus::UpdateAvailable { + installed_version: Some("0.9.0".to_owned()), + latest_version: Some("1.0.0".to_owned()), + }; + results +} + +fn visible_names(state: &SearchBrowserState) -> Vec<&str> { + state + .ordered_rows() + .iter() + .map(|row| row.display_name.as_str()) + .collect() +} + +fn selected_names(state: &SearchBrowserState) -> Vec<&str> { + state + .selected_rows() + .iter() + .map(|row| row.display_name.as_str()) + .collect() +} diff --git a/crates/aim-cli/tests/search_cli.rs b/crates/aim-cli/tests/search_cli.rs new file mode 100644 index 0000000..a7a959c --- /dev/null +++ b/crates/aim-cli/tests/search_cli.rs @@ -0,0 +1,153 @@ +use assert_cmd::Command; +use predicates::prelude::PredicateBooleanExt; +use predicates::str::contains; +use tempfile::tempdir; + +const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE"; + +#[test] +fn search_command_renders_remote_github_results() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.args(["search", "bat"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Search Results")) + .stdout(contains("Remote Results")) + .stdout(contains("[github] sharkdp/bat")) + .stdout(contains("Install query: sharkdp/bat")); +} + +#[test] +fn search_command_renders_local_matches_in_deterministic_order() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + std::fs::write( + ®istry_path, + concat!( + "version = 1\n", + "[[apps]]\n", + "stable_id = \"bat\"\n", + "display_name = \"Bat\"\n", + "[[apps]]\n", + "stable_id = \"bat-tools\"\n", + "display_name = \"Bat Tools\"\n", + "[[apps]]\n", + "stable_id = \"acrobat-reader\"\n", + "display_name = \"Acrobat Reader\"\n", + "[[apps]]\n", + "stable_id = \"combat-viewer\"\n", + "display_name = \"Combat Viewer\"\n" + ), + ) + .unwrap(); + + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.args(["search", "bat"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Installed Matches")) + .stdout( + contains("- Bat (bat)") + .and(contains("- Bat Tools (bat-tools)")) + .and(contains("- Acrobat Reader (acrobat-reader)")) + .and(contains("- Combat Viewer (combat-viewer)")), + ); +} + +#[test] +fn search_command_is_read_only_for_registry_contents() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let original = "version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n"; + std::fs::write(®istry_path, original).unwrap(); + + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.args(["search", "bat"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success(); + + let persisted = std::fs::read_to_string(®istry_path).unwrap(); + assert_eq!(persisted, original); +} + +#[test] +fn search_command_fails_fast_on_malformed_config() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, "[search\nskip_confirmation = true\n").unwrap(); + + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.args(["search", "bat"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env("AIM_CONFIG_PATH", &config_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .failure() + .stderr(contains(config_path.to_string_lossy().as_ref())); +} + +#[test] +fn search_command_uses_plain_text_output_when_not_on_a_tty() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let config_path = dir.path().join("config.toml"); + std::fs::write( + &config_path, + "[search]\nbottom_to_top = false\nskip_confirmation = true\n", + ) + .unwrap(); + + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.args(["search", "bat"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env("AIM_CONFIG_PATH", &config_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Search Results")) + .stdout(contains("Remote Results")) + .stdout(contains("[github] sharkdp/bat")); +} + +#[test] +fn search_command_reports_loading_status_to_stderr() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.args(["search", "bat"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stderr(contains("Searching bat")); +} + +#[test] +fn search_command_keeps_empty_results_in_plain_text_mode() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.args(["search", "no-such-app-image-query"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Search Results")) + .stdout(contains("No remote matches")); +} diff --git a/crates/aim-cli/tests/ui_summary.rs b/crates/aim-cli/tests/ui_summary.rs index 91585d5..b7b694b 100644 --- a/crates/aim-cli/tests/ui_summary.rs +++ b/crates/aim-cli/tests/ui_summary.rs @@ -1,7 +1,9 @@ use aim_cli::DispatchResult; use aim_cli::ui::prompt::render_interaction; use aim_cli::ui::render::{render_dispatch_result, render_update_summary}; +use aim_cli::ui::search_browser::{SearchRow, format_search_row, render_confirmation_summary}; use aim_core::app::interaction::{InteractionKind, InteractionRequest}; +use aim_core::domain::search::SearchInstallStatus; use aim_core::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan}; #[test] @@ -68,3 +70,78 @@ fn tracking_prompt_uses_explicit_question_copy() { assert!(output.contains("Choose update tracking")); } + +#[test] +fn search_browser_row_uses_status_tag_version_and_description_layout() { + let row = SearchRow { + status: SearchInstallStatus::Installed { + installed_version: Some("0.0.12".to_owned()), + }, + provider_id: "github".to_owned(), + display_name: "pingdotgg/t3code".to_owned(), + description: Some("The T3 desktop app.".to_owned()), + install_query: "pingdotgg/t3code".to_owned(), + version: Some("0.0.12".to_owned()), + selectable: false, + }; + + let output = format_search_row(1, &row, true, true, 120); + + assert!(output.contains('\n')); + assert!(output.contains("[installed]")); + assert!(output.contains("v0.0.12")); + assert!(output.contains("pingdotgg/t3code")); + assert!(output.contains("github - The T3 desktop app.")); +} + +#[test] +fn search_browser_row_without_description_shows_provider_only() { + let row = SearchRow { + status: SearchInstallStatus::Available, + provider_id: "github".to_owned(), + display_name: "pingdotgg/t3code".to_owned(), + description: None, + install_query: "pingdotgg/t3code".to_owned(), + version: Some("0.0.12".to_owned()), + selectable: true, + }; + + let output = format_search_row(1, &row, false, false, 120); + + assert!(output.contains("github")); + assert!(!output.contains(" - ")); + assert!(!output.contains("No description available")); +} + +#[test] +fn search_confirmation_summary_lists_selected_rows() { + let rows = vec![ + SearchRow { + status: SearchInstallStatus::UpdateAvailable { + installed_version: Some("0.0.11".to_owned()), + latest_version: Some("0.0.12".to_owned()), + }, + provider_id: "github".to_owned(), + display_name: "pingdotgg/t3code".to_owned(), + description: Some("The T3 desktop app.".to_owned()), + install_query: "pingdotgg/t3code".to_owned(), + version: Some("0.0.12".to_owned()), + selectable: true, + }, + SearchRow { + status: SearchInstallStatus::Available, + provider_id: "github".to_owned(), + display_name: "sharkdp/bat".to_owned(), + description: Some("A cat(1) clone with wings.".to_owned()), + install_query: "sharkdp/bat".to_owned(), + version: Some("1.0.0".to_owned()), + selectable: true, + }, + ]; + + let output = render_confirmation_summary(&rows); + + assert!(output.contains("Confirm Search Selection")); + assert!(output.contains("pingdotgg/t3code")); + assert!(output.contains("sharkdp/bat")); +} diff --git a/crates/aim-core/Cargo.toml b/crates/aim-core/Cargo.toml index 5fecd56..28bde64 100644 --- a/crates/aim-core/Cargo.toml +++ b/crates/aim-core/Cargo.toml @@ -8,9 +8,12 @@ license.workspace = true path = "src/lib.rs" [dependencies] +base64.workspace = true +fs2.workspace = true reqwest.workspace = true serde.workspace = true serde_yaml.workspace = true +sha2.workspace = true toml.workspace = true [dev-dependencies] diff --git a/crates/aim-core/src/app/add.rs b/crates/aim-core/src/app/add.rs index 6ae5b87..2682792 100644 --- a/crates/aim-core/src/app/add.rs +++ b/crates/aim-core/src/app/add.rs @@ -1,4 +1,5 @@ use std::env; +use std::fs::{self, File}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -13,12 +14,14 @@ use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default}; use crate::domain::app::{AppRecord, InstallMetadata, InstallScope}; use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind}; use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy}; -use crate::integration::install::{InstallOutcome, InstallRequest, execute_install}; +use crate::integration::install::{ + InstallOutcome, InstallRequest, execute_install, staged_appimage_path, +}; use crate::integration::policy::{IntegrationMode, resolve_install_policy}; use crate::metadata::parse_document; use crate::platform::probe_live_host; use crate::source::github::{ - GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, + GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy, }; use crate::update::channels::build_channels; use crate::update::ranking::{rank_channels, select_artifact, to_preference}; @@ -100,6 +103,7 @@ pub fn build_add_plan_with( url: source.locator.clone(), version: "unresolved".to_owned(), arch: None, + trusted_checksum: None, selection_reason: "heuristic-match".to_owned(), }; let strategy = UpdateStrategy { @@ -238,8 +242,13 @@ pub fn install_app_with_reporter( stage: OperationStage::DownloadArtifact, message: "downloading artifact".to_owned(), }); - let artifact_bytes = - download_artifact_bytes_with_reporter(&plan.selected_artifact.url, reporter)?; + let staging_root = install_home.join(".local/share/aim/staging"); + let staged_payload_path = staged_appimage_path(&staging_root, &record.stable_id); + download_artifact_to_staged_path_with_reporter( + &plan.selected_artifact.url, + &staged_payload_path, + reporter, + )?; let payload_exec = payload_path.clone(); let desktop_owned = match policy.integration_mode { IntegrationMode::PayloadOnly | IntegrationMode::Denied => None, @@ -265,9 +274,9 @@ pub fn install_app_with_reporter( message: "staging payload".to_owned(), }); let install_outcome = execute_install(&InstallRequest { - staging_root: &install_home.join(".local/share/aim/staging"), + staged_payload_path: &staged_payload_path, final_payload_path: &payload_path, - artifact_bytes: &artifact_bytes, + trusted_checksum: plan.selected_artifact.trusted_checksum.as_deref(), desktop: desktop_owned.as_ref().map(|(path, contents)| { crate::integration::install::DesktopIntegrationRequest { desktop_entry_path: path.as_path(), @@ -355,43 +364,117 @@ pub enum InstallAppError { Install(crate::integration::install::PayloadInstallError), } -fn download_artifact_bytes_with_reporter( +fn download_artifact_to_staged_path_with_reporter( url: &str, + staged_payload_path: &Path, reporter: &mut impl ProgressReporter, -) -> Result, InstallAppError> { +) -> Result { + let policy = http_client_policy(); + if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") { - let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82".to_vec(); - reporter.report(&OperationEvent::Progress { - current: bytes.len() as u64, - total: Some(bytes.len() as u64), + let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82"; + return download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || { + Ok(( + Box::new(std::io::Cursor::new(bytes.to_vec())) as Box, + Some(bytes.len() as u64), + )) }); - return Ok(bytes); } - let response = reqwest::blocking::get(url).map_err(InstallAppError::Download)?; - let response = response - .error_for_status() + let client = reqwest::blocking::Client::builder() + .timeout(policy.timeout) + .build() .map_err(InstallAppError::Download)?; - let total = response.content_length(); - let mut response = response; - let mut bytes = Vec::new(); + + download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || { + let response = client.get(url).send().map_err(InstallAppError::Download)?; + let response = response + .error_for_status() + .map_err(InstallAppError::Download)?; + let total = response.content_length(); + Ok((Box::new(response) as Box, total)) + }) +} + +pub fn download_to_staged_path_with_retries( + staged_payload_path: &Path, + reporter: &mut impl ProgressReporter, + policy: crate::source::github::HttpClientPolicy, + mut open_stream: impl FnMut() -> Result<(Box, Option), InstallAppError>, +) -> Result { + let mut last_error = None; + let attempts = policy.max_retries.max(1); + + for attempt in 0..attempts { + match open_stream() { + Ok((mut reader, total)) => { + match stream_payload_to_staged_file_with_reporter( + &mut reader, + total, + staged_payload_path, + reporter, + ) { + Ok(written) => return Ok(written), + Err(error) if attempt + 1 < attempts && is_retryable_download_error(&error) => { + last_error = Some(error); + } + Err(error) => return Err(error), + } + } + Err(error) if attempt + 1 < attempts && is_retryable_download_error(&error) => { + last_error = Some(error); + } + Err(error) => return Err(error), + } + } + + Err(last_error.unwrap_or_else(|| { + InstallAppError::DownloadIo(std::io::Error::other("download failed after retries")) + })) +} + +pub fn stream_payload_to_staged_file_with_reporter( + reader: &mut R, + total: Option, + staged_payload_path: &Path, + reporter: &mut impl ProgressReporter, +) -> Result { + if let Some(parent) = staged_payload_path.parent() { + fs::create_dir_all(parent).map_err(InstallAppError::DownloadIo)?; + } + + let mut file = File::create(staged_payload_path).map_err(InstallAppError::DownloadIo)?; let mut buffer = [0_u8; 16 * 1024]; let mut current = 0_u64; loop { - let read = response - .read(&mut buffer) - .map_err(InstallAppError::DownloadIo)?; + let read = match reader.read(&mut buffer) { + Ok(read) => read, + Err(error) => { + let _ = fs::remove_file(staged_payload_path); + return Err(InstallAppError::DownloadIo(error)); + } + }; if read == 0 { break; } - bytes.extend_from_slice(&buffer[..read]); + if let Err(error) = std::io::Write::write_all(&mut file, &buffer[..read]) { + let _ = fs::remove_file(staged_payload_path); + return Err(InstallAppError::DownloadIo(error)); + } current += read as u64; reporter.report(&OperationEvent::Progress { current, total }); } - Ok(bytes) + Ok(current) +} + +fn is_retryable_download_error(error: &InstallAppError) -> bool { + matches!( + error, + InstallAppError::Download(_) | InstallAppError::DownloadIo(_) + ) } fn render_desktop_entry(display_name: &str, exec_path: &Path) -> String { diff --git a/crates/aim-core/src/app/mod.rs b/crates/aim-core/src/app/mod.rs index e3ef15b..d40efa7 100644 --- a/crates/aim-core/src/app/mod.rs +++ b/crates/aim-core/src/app/mod.rs @@ -6,4 +6,5 @@ pub mod progress; pub mod query; pub mod remove; pub mod scope; +pub mod search; pub mod update; diff --git a/crates/aim-core/src/app/progress.rs b/crates/aim-core/src/app/progress.rs index 6e3069f..a9d099c 100644 --- a/crates/aim-core/src/app/progress.rs +++ b/crates/aim-core/src/app/progress.rs @@ -1,6 +1,7 @@ #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OperationKind { Add, + Search, UpdateBatch, UpdateItem, Remove, diff --git a/crates/aim-core/src/app/search.rs b/crates/aim-core/src/app/search.rs new file mode 100644 index 0000000..f08509c --- /dev/null +++ b/crates/aim-core/src/app/search.rs @@ -0,0 +1,322 @@ +use crate::domain::app::AppRecord; +use crate::domain::search::{ + InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults, + SearchWarning, +}; +use crate::source::github::{ + GitHubSearchError, GitHubTransport, TransportRelease, default_transport, + search_github_repositories_with, +}; +use std::collections::HashSet; + +pub trait SearchProvider { + fn search(&self, query: &SearchQuery) -> Result, SearchProviderError>; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchProviderError { + pub provider_id: String, + pub message: String, +} + +impl SearchProviderError { + pub fn new(provider_id: &str, message: &str) -> Self { + Self { + provider_id: provider_id.to_owned(), + message: message.to_owned(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SearchError { + ProviderFailures(Vec), +} + +pub fn build_search_results( + query: &SearchQuery, + installed_apps: &[AppRecord], +) -> Result { + let transport = default_transport(); + let provider = GitHubSearchProvider::new(transport.as_ref()); + build_search_results_with(query, installed_apps, &[&provider]) +} + +pub fn build_search_results_with( + query: &SearchQuery, + installed_apps: &[AppRecord], + providers: &[&dyn SearchProvider], +) -> Result { + let installed_matches = collect_installed_matches(query, installed_apps); + let mut remote_hits = Vec::new(); + let mut warnings = Vec::new(); + + for provider in providers { + match provider.search(query) { + Ok(mut hits) => remote_hits.append(&mut hits), + Err(error) => warnings.push(SearchWarning { + provider_id: Some(error.provider_id), + message: error.message, + }), + } + } + + annotate_remote_hits_with_install_status(&mut remote_hits, installed_apps); + + if remote_hits.is_empty() && installed_matches.is_empty() && !warnings.is_empty() { + return Err(SearchError::ProviderFailures(warnings)); + } + + Ok(SearchResults { + query_text: query.text.clone(), + remote_hits, + installed_matches, + warnings, + }) +} + +pub struct GitHubSearchProvider<'a, T: GitHubTransport + ?Sized> { + transport: &'a T, +} + +impl<'a, T: GitHubTransport + ?Sized> GitHubSearchProvider<'a, T> { + pub fn new(transport: &'a T) -> Self { + Self { transport } + } +} + +impl SearchProvider for GitHubSearchProvider<'_, T> { + fn search(&self, query: &SearchQuery) -> Result, SearchProviderError> { + let name_only_query = format!("{} in:name", query.text); + let mut ranked_hits = + search_github_repositories_with(&name_only_query, query.remote_limit, self.transport) + .map_err(|error| { + SearchProviderError::new("github", &render_github_search_error(&error)) + })?; + + if ranked_hits.len() < query.remote_limit { + let mut seen = ranked_hits + .iter() + .map(|hit| hit.full_name.clone()) + .collect::>(); + let backfill = + search_github_repositories_with(&query.text, query.remote_limit, self.transport) + .map_err(|error| { + SearchProviderError::new("github", &render_github_search_error(&error)) + })?; + + for hit in backfill { + if ranked_hits.len() >= query.remote_limit { + break; + } + + if seen.insert(hit.full_name.clone()) { + ranked_hits.push(hit); + } + } + } + + let normalized_query = normalize_lookup(&query.text); + let mut ranked_hits = ranked_hits + .into_iter() + .enumerate() + .map(|(index, hit)| { + ( + github_remote_match_rank(&normalized_query, &hit), + index, + hit, + ) + }) + .collect::>(); + + ranked_hits.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1))); + + Ok(ranked_hits + .into_iter() + .filter_map(|(_, _, hit)| { + let full_name = hit.full_name; + let release = latest_appimage_release(self.transport, &full_name)?; + Some(SearchResult { + provider_id: "github".to_owned(), + display_name: full_name.clone(), + description: hit.description, + source_locator: hit.html_url, + install_query: full_name.clone(), + canonical_locator: full_name.clone(), + version: Some(release.tag.trim_start_matches('v').to_owned()), + install_status: SearchInstallStatus::Available, + }) + }) + .collect()) + } +} + +fn latest_appimage_release( + transport: &T, + repo: &str, +) -> Option { + transport.fetch_releases(repo).ok().and_then(|releases| { + releases.into_iter().find(|release| { + release + .assets + .iter() + .any(|asset| asset.name.ends_with(".AppImage")) + }) + }) +} + +fn collect_installed_matches( + query: &SearchQuery, + installed_apps: &[AppRecord], +) -> Vec { + let normalized_query = normalize_lookup(&query.text); + let mut matches = installed_apps + .iter() + .filter_map(|app| { + match_rank(&normalized_query, &app.stable_id, &app.display_name).map(|rank| { + ( + rank, + normalize_lookup(&app.stable_id), + InstalledSearchMatch { + stable_id: app.stable_id.clone(), + display_name: app.display_name.clone(), + installed_version: app.installed_version.clone(), + }, + ) + }) + }) + .collect::>(); + + matches.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + + matches + .into_iter() + .map(|(_, _, installed_match)| installed_match) + .collect() +} + +fn match_rank(query: &str, stable_id: &str, display_name: &str) -> Option { + let stable_id = normalize_lookup(stable_id); + let display_name = normalize_lookup(display_name); + + [stable_id, display_name] + .into_iter() + .filter_map(|candidate| { + if candidate == query { + Some(0) + } else if candidate.starts_with(query) { + Some(1) + } else if candidate.contains(query) { + Some(2) + } else { + None + } + }) + .min() +} + +fn normalize_lookup(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn annotate_remote_hits_with_install_status( + remote_hits: &mut [SearchResult], + installed_apps: &[AppRecord], +) { + for hit in remote_hits.iter_mut() { + if let Some(installed) = installed_apps + .iter() + .find(|app| app_matches_remote_hit(app, hit)) + { + if installed.installed_version == hit.version { + hit.install_status = SearchInstallStatus::Installed { + installed_version: installed.installed_version.clone(), + }; + } else { + hit.install_status = SearchInstallStatus::UpdateAvailable { + installed_version: installed.installed_version.clone(), + latest_version: hit.version.clone(), + }; + } + } + } +} + +fn app_matches_remote_hit(app: &AppRecord, hit: &SearchResult) -> bool { + let Some(locator) = app_search_locator(app) else { + return false; + }; + + locator == normalize_lookup(&hit.install_query) + || locator == normalize_lookup(&hit.canonical_locator) +} + +fn app_search_locator(app: &AppRecord) -> Option { + if let Some(source) = &app.source + && source.kind == crate::domain::source::SourceKind::GitHub + { + if let Some(locator) = source.canonical_locator.as_deref() { + return Some(normalize_lookup(locator)); + } + return Some(normalize_lookup(&source.locator)); + } + + app.source_input.as_deref().and_then(|input| { + if input.contains('/') && !input.contains("://") { + Some(normalize_lookup(input)) + } else { + None + } + }) +} + +fn github_remote_match_rank( + query: &str, + repository: &crate::source::github::TransportRepository, +) -> u8 { + let full_name = normalize_lookup(&repository.full_name); + let description = repository.description.as_deref().map(normalize_lookup); + let mut parts = full_name.split('/'); + let owner = parts.next().unwrap_or_default(); + let repo = parts.next().unwrap_or_default(); + + if full_name == query { + return 0; + } + + if owner == query || repo == query { + return 1; + } + + if full_name.starts_with(query) || owner.starts_with(query) || repo.starts_with(query) { + return 2; + } + + if full_name.contains(query) || owner.contains(query) || repo.contains(query) { + return 3; + } + + if description + .as_deref() + .map(|description| description.starts_with(query)) + .unwrap_or(false) + { + return 4; + } + + if description + .as_deref() + .map(|description| description.contains(query)) + .unwrap_or(false) + { + return 5; + } + + 6 +} + +fn render_github_search_error(error: &GitHubSearchError) -> String { + match error { + GitHubSearchError::Transport(inner) => inner.to_string(), + } +} diff --git a/crates/aim-core/src/domain/mod.rs b/crates/aim-core/src/domain/mod.rs index 479ac2f..31b72cd 100644 --- a/crates/aim-core/src/domain/mod.rs +++ b/crates/aim-core/src/domain/mod.rs @@ -1,3 +1,4 @@ pub mod app; +pub mod search; pub mod source; pub mod update; diff --git a/crates/aim-core/src/domain/search.rs b/crates/aim-core/src/domain/search.rs new file mode 100644 index 0000000..9914aab --- /dev/null +++ b/crates/aim-core/src/domain/search.rs @@ -0,0 +1,68 @@ +pub const DEFAULT_REMOTE_LIMIT: usize = 10; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SearchInstallStatus { + Available, + Installed { + installed_version: Option, + }, + UpdateAvailable { + installed_version: Option, + latest_version: Option, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchQuery { + pub text: String, + pub remote_limit: usize, +} + +impl SearchQuery { + pub fn new(text: &str) -> Self { + Self { + text: text.to_owned(), + remote_limit: DEFAULT_REMOTE_LIMIT, + } + } + + pub fn with_remote_limit(text: &str, remote_limit: usize) -> Self { + Self { + text: text.to_owned(), + remote_limit, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchResult { + pub provider_id: String, + pub display_name: String, + pub description: Option, + pub source_locator: String, + pub install_query: String, + pub canonical_locator: String, + pub version: Option, + pub install_status: SearchInstallStatus, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstalledSearchMatch { + pub stable_id: String, + pub display_name: String, + pub installed_version: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchWarning { + pub provider_id: Option, + pub message: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchResults { + pub query_text: String, + pub remote_hits: Vec, + pub installed_matches: Vec, + pub warnings: Vec, +} diff --git a/crates/aim-core/src/domain/update.rs b/crates/aim-core/src/domain/update.rs index 7f3a835..6e79103 100644 --- a/crates/aim-core/src/domain/update.rs +++ b/crates/aim-core/src/domain/update.rs @@ -85,6 +85,7 @@ pub struct ArtifactCandidate { pub url: String, pub version: String, pub arch: Option, + pub trusted_checksum: Option, pub selection_reason: String, } diff --git a/crates/aim-core/src/integration/install.rs b/crates/aim-core/src/integration/install.rs index f2030f9..c95466e 100644 --- a/crates/aim-core/src/integration/install.rs +++ b/crates/aim-core/src/integration/install.rs @@ -1,9 +1,13 @@ use std::fs; use std::io; +use std::io::Read; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::{error::Error, fmt}; +use base64::Engine; +use sha2::{Digest, Sha512}; + use crate::integration::desktop::{extract_icon_from_payload, write_desktop_integration}; use crate::integration::refresh::refresh_integration; use crate::platform::DesktopHelpers; @@ -24,6 +28,8 @@ pub fn replacement_path(target: &Path) -> PathBuf { #[derive(Debug)] pub enum PayloadInstallError { InvalidArtifact, + ChecksumMismatch, + InvalidTrustedChecksum, Io(io::Error), DesktopIntegration(io::Error), } @@ -38,6 +44,8 @@ impl fmt::Display for PayloadInstallError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidArtifact => write!(f, "artifact is not a valid AppImage"), + Self::ChecksumMismatch => write!(f, "artifact checksum did not match trusted metadata"), + Self::InvalidTrustedChecksum => write!(f, "trusted checksum metadata is malformed"), Self::Io(error) => write!(f, "payload installation failed: {error}"), Self::DesktopIntegration(error) => { write!(f, "desktop integration failed: {error}") @@ -63,9 +71,9 @@ pub struct DesktopIntegrationRequest<'a> { #[derive(Clone, Debug, Eq, PartialEq)] pub struct InstallRequest<'a> { - pub staging_root: &'a Path, + pub staged_payload_path: &'a Path, pub final_payload_path: &'a Path, - pub artifact_bytes: &'a [u8], + pub trusted_checksum: Option<&'a str>, pub desktop: Option>, pub helpers: DesktopHelpers, } @@ -79,33 +87,25 @@ pub struct InstallOutcome { } pub fn stage_and_commit_payload( - staging_root: &Path, + staged_payload_path: &Path, final_payload_path: &Path, - artifact_bytes: &[u8], ) -> Result { - if !is_appimage_payload(artifact_bytes) { + if !is_appimage_payload_path(staged_payload_path)? { + let _ = fs::remove_file(staged_payload_path); return Err(PayloadInstallError::InvalidArtifact); } - let app_id = final_payload_path - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or("download"); - let staged_path = staged_appimage_path(staging_root, app_id); let replacement = replacement_path(final_payload_path); - fs::create_dir_all(staging_root)?; - fs::write(&staged_path, artifact_bytes)?; - - let mut permissions = fs::metadata(&staged_path)?.permissions(); + let mut permissions = fs::metadata(staged_payload_path)?.permissions(); permissions.set_mode(0o755); - fs::set_permissions(&staged_path, permissions)?; + fs::set_permissions(staged_payload_path, permissions)?; if let Some(parent) = final_payload_path.parent() { fs::create_dir_all(parent)?; } - fs::rename(&staged_path, &replacement)?; + fs::rename(staged_payload_path, &replacement)?; fs::rename(&replacement, final_payload_path)?; Ok(PayloadInstallOutcome { @@ -113,24 +113,25 @@ pub fn stage_and_commit_payload( }) } -fn is_appimage_payload(bytes: &[u8]) -> bool { - bytes.starts_with(b"\x7fELF") +fn is_appimage_payload_path(path: &Path) -> Result { + let mut file = fs::File::open(path)?; + let mut header = [0_u8; 4]; + let read = file.read(&mut header)?; + Ok(read == header.len() && header == *b"\x7fELF") } pub fn execute_install( request: &InstallRequest<'_>, ) -> Result { - let payload = stage_and_commit_payload( - request.staging_root, - request.final_payload_path, - request.artifact_bytes, - )?; + verify_trusted_checksum(request.staged_payload_path, request.trusted_checksum)?; + let payload = + stage_and_commit_payload(request.staged_payload_path, request.final_payload_path)?; let mut desktop_entry_path = None; let mut icon_path = None; if let Some(desktop) = &request.desktop { let extracted_icon = if desktop.icon_bytes.is_none() && desktop.icon_path.is_some() { - extract_icon_from_payload(request.artifact_bytes) + extract_icon_from_payload_path(&payload.final_payload_path) } else { None }; @@ -161,3 +162,38 @@ pub fn execute_install( warnings, }) } + +fn extract_icon_from_payload_path(path: &Path) -> Option> { + fs::read(path) + .ok() + .and_then(|payload| extract_icon_from_payload(&payload)) +} + +fn verify_trusted_checksum( + staged_payload_path: &Path, + trusted_checksum: Option<&str>, +) -> Result<(), PayloadInstallError> { + let Some(trusted_checksum) = trusted_checksum.map(str::trim) else { + return Ok(()); + }; + + let decoded = base64::engine::general_purpose::STANDARD + .decode(trusted_checksum) + .map_err(|_| { + let _ = fs::remove_file(staged_payload_path); + PayloadInstallError::InvalidTrustedChecksum + })?; + if decoded.len() != 64 { + let _ = fs::remove_file(staged_payload_path); + return Err(PayloadInstallError::InvalidTrustedChecksum); + } + + let payload = fs::read(staged_payload_path)?; + let actual_checksum = base64::engine::general_purpose::STANDARD.encode(Sha512::digest(payload)); + if actual_checksum != trusted_checksum { + let _ = fs::remove_file(staged_payload_path); + return Err(PayloadInstallError::ChecksumMismatch); + } + + Ok(()) +} diff --git a/crates/aim-core/src/registry/store.rs b/crates/aim-core/src/registry/store.rs index 7ff0361..4d05eb3 100644 --- a/crates/aim-core/src/registry/store.rs +++ b/crates/aim-core/src/registry/store.rs @@ -1,6 +1,8 @@ -use std::fs; +use std::fs::{self, File, OpenOptions}; use std::path::PathBuf; +use fs2::FileExt; + use crate::registry::model::Registry; pub struct RegistryStore { @@ -28,14 +30,71 @@ impl RegistryStore { } let contents = toml::to_string(registry)?; - fs::write(&self.path, contents)?; + let temporary_path = self.temporary_path(); + fs::write(&temporary_path, contents)?; + fs::rename(&temporary_path, &self.path).map_err(|error| { + let _ = fs::remove_file(&temporary_path); + RegistryStoreError::Io(error) + })?; Ok(()) } + + pub fn lock_exclusive(&self) -> Result { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } + + let lock_file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(self.lock_path())?; + + match lock_file.try_lock_exclusive() { + Ok(()) => Ok(RegistryLock { file: lock_file }), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + Err(RegistryStoreError::LockUnavailable) + } + Err(error) => Err(RegistryStoreError::Io(error)), + } + } + + pub fn mutate_exclusive(&self, apply: F) -> Result + where + F: FnOnce(&mut Registry), + { + let _lock = self.lock_exclusive()?; + let mut registry = self.load()?; + apply(&mut registry); + self.save(®istry)?; + Ok(registry) + } + + fn lock_path(&self) -> PathBuf { + self.path.with_extension("toml.lock") + } + + fn temporary_path(&self) -> PathBuf { + self.path.with_extension("toml.tmp") + } +} + +#[derive(Debug)] +pub struct RegistryLock { + file: File, +} + +impl Drop for RegistryLock { + fn drop(&mut self) { + let _ = self.file.unlock(); + } } #[derive(Debug)] pub enum RegistryStoreError { Io(std::io::Error), + LockUnavailable, SerializeToml(toml::ser::Error), Toml(toml::de::Error), } diff --git a/crates/aim-core/src/source/github.rs b/crates/aim-core/src/source/github.rs index b7f793b..dec32d4 100644 --- a/crates/aim-core/src/source/github.rs +++ b/crates/aim-core/src/source/github.rs @@ -1,14 +1,36 @@ use std::env; +use std::time::Duration; use crate::domain::source::{ResolvedRelease, SourceRef}; use crate::metadata::MetadataDocument; const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com"; const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE"; +const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 30; +const DEFAULT_HTTP_MAX_RETRIES: usize = 3; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HttpClientPolicy { + pub timeout: Duration, + pub max_retries: usize, +} + +pub fn http_client_policy() -> HttpClientPolicy { + HttpClientPolicy { + timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS), + max_retries: DEFAULT_HTTP_MAX_RETRIES, + } +} pub trait GitHubTransport { fn fetch_releases(&self, repo: &str) -> Result, GitHubDiscoveryError>; + fn search_repositories( + &self, + query: &str, + limit: usize, + ) -> Result, GitHubSearchError>; + fn fetch_document( &self, url: &str, @@ -30,6 +52,13 @@ pub struct TransportRelease { pub assets: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportRepository { + pub full_name: String, + pub description: Option, + pub html_url: String, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitHubAsset { pub name: String, @@ -130,6 +159,22 @@ pub fn discover_github_candidates_with( }) } +pub fn search_github_repositories( + query: &str, + limit: usize, +) -> Result, GitHubSearchError> { + let transport = default_transport(); + search_github_repositories_with(query, limit, transport.as_ref()) +} + +pub fn search_github_repositories_with( + query: &str, + limit: usize, + transport: &T, +) -> Result, GitHubSearchError> { + transport.search_repositories(query, limit) +} + pub fn default_transport() -> Box { if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") { Box::new(FixtureGitHubTransport) @@ -151,6 +196,7 @@ impl Default for ReqwestGitHubTransport { impl ReqwestGitHubTransport { pub fn new() -> Self { + let policy = http_client_policy(); let mut default_headers = reqwest::header::HeaderMap::new(); default_headers.insert( reqwest::header::USER_AGENT, @@ -171,6 +217,7 @@ impl ReqwestGitHubTransport { Self { client: reqwest::blocking::Client::builder() .default_headers(default_headers) + .timeout(policy.timeout) .build() .expect("reqwest client should build"), api_base: env::var("AIM_GITHUB_API_BASE") @@ -210,6 +257,34 @@ impl GitHubTransport for ReqwestGitHubTransport { .collect()) } + fn search_repositories( + &self, + query: &str, + limit: usize, + ) -> Result, GitHubSearchError> { + let url = format!("{}/search/repositories", self.api_base); + let response = self + .client + .get(url) + .query(&[("q", query), ("per_page", &limit.to_string())]) + .send() + .map_err(GitHubSearchError::Transport)? + .error_for_status() + .map_err(GitHubSearchError::Transport)? + .json::() + .map_err(GitHubSearchError::Transport)?; + + Ok(response + .items + .into_iter() + .map(|repository| TransportRepository { + full_name: repository.full_name, + description: repository.description, + html_url: repository.html_url, + }) + .collect()) + } + fn fetch_document( &self, url: &str, @@ -246,6 +321,14 @@ impl GitHubTransport for FixtureGitHubTransport { Ok(fixture_releases(repo)) } + fn search_repositories( + &self, + query: &str, + limit: usize, + ) -> Result, GitHubSearchError> { + Ok(fixture_repository_search(query, limit)) + } + fn fetch_document( &self, url: &str, @@ -269,6 +352,11 @@ pub enum GitHubDiscoveryError { Transport(reqwest::Error), } +#[derive(Debug)] +pub enum GitHubSearchError { + Transport(reqwest::Error), +} + #[derive(serde::Deserialize)] struct ApiRelease { tag_name: String, @@ -283,6 +371,18 @@ struct ApiAsset { content_type: Option, } +#[derive(serde::Deserialize)] +struct ApiRepositorySearchResponse { + items: Vec, +} + +#[derive(serde::Deserialize)] +struct ApiRepository { + full_name: String, + description: Option, + html_url: String, +} + fn is_appimage_asset(name: &str) -> bool { name.ends_with(".AppImage") } @@ -308,6 +408,16 @@ fn fixture_releases(repo: &str) -> Vec { fixture_release(repo, "v0.0.11", "T3-Code-0.0.11-x86_64.AppImage"), ], "sharkdp/bat" => vec![fixture_release(repo, "v1.0.0", "Bat-1.0.0-x86_64.AppImage")], + "fero1xd/uploadstuff-server" => vec![fixture_release_without_appimage( + repo, + "v1.0.0", + "uploadstuff-server-linux-x86_64.tar.gz", + )], + "Socialure/lawn" => vec![fixture_release_without_appimage( + repo, + "v1.0.0", + "lawn-linux-x86_64.tar.gz", + )], _ => { let repo_name = repo.split('/').next_back().unwrap_or("app"); let title = title_case(repo_name); @@ -339,6 +449,25 @@ fn fixture_release(repo: &str, tag: &str, asset_name: &str) -> TransportRelease } } +fn fixture_release_without_appimage(repo: &str, tag: &str, asset_name: &str) -> TransportRelease { + TransportRelease { + tag: tag.to_owned(), + prerelease: false, + assets: vec![ + TransportAsset { + name: asset_name.to_owned(), + url: format!("https://github.com/{repo}/releases/download/{tag}/{asset_name}"), + content_type: Some("application/gzip".to_owned()), + }, + TransportAsset { + name: "latest-linux.yml".to_owned(), + url: format!("https://github.com/{repo}/releases/download/{tag}/latest-linux.yml"), + content_type: Some("application/yaml".to_owned()), + }, + ], + } +} + fn fixture_document(url: &str) -> Option> { let tag = url.split("/releases/download/").nth(1)?.split('/').next()?; let name = url.split('/').next_back()?; @@ -352,13 +481,85 @@ fn fixture_document(url: &str) -> Option> { }; let version = tag.trim_start_matches('v'); Some( - format!("version: {version}\npath: {appimage}\nsha512: fixture-sha\n").into_bytes(), + format!("version: {version}\npath: {appimage}\nsha512: ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw==\n").into_bytes(), ) } _ => None, } } +fn fixture_repository_search(query: &str, limit: usize) -> Vec { + let (normalized_query, name_only) = parse_fixture_repository_query(query); + + fixture_repository_catalog() + .into_iter() + .filter(|repository| { + let full_name_matches = repository + .full_name + .to_ascii_lowercase() + .contains(&normalized_query); + if name_only { + return full_name_matches; + } + + full_name_matches + || repository + .description + .as_deref() + .map(|description| description.to_ascii_lowercase().contains(&normalized_query)) + .unwrap_or(false) + }) + .take(limit) + .collect() +} + +fn parse_fixture_repository_query(query: &str) -> (String, bool) { + let trimmed = query.trim(); + if let Some(value) = trimmed.strip_suffix(" in:name") { + return (value.trim().to_ascii_lowercase(), true); + } + + (trimmed.to_ascii_lowercase(), false) +} + +fn fixture_repository_catalog() -> Vec { + vec![ + TransportRepository { + full_name: "sharkdp/bat".to_owned(), + description: Some("A cat(1) clone with wings.".to_owned()), + html_url: "https://github.com/sharkdp/bat".to_owned(), + }, + TransportRepository { + full_name: "astatine/bat".to_owned(), + description: Some("A small fixture repository for bat-shaped searches.".to_owned()), + html_url: "https://github.com/astatine/bat".to_owned(), + }, + TransportRepository { + full_name: "eth-p/bat-extras".to_owned(), + description: Some("Bash scripts that integrate with bat.".to_owned()), + html_url: "https://github.com/eth-p/bat-extras".to_owned(), + }, + TransportRepository { + full_name: "fero1xd/uploadstuff-server".to_owned(), + description: Some("Custom Server for UploadThing by pingdotgg".to_owned()), + html_url: "https://github.com/fero1xd/uploadstuff-server".to_owned(), + }, + TransportRepository { + full_name: "Socialure/lawn".to_owned(), + description: Some( + "Video review for creative teams — Socialure-branded fork of pingdotgg/lawn" + .to_owned(), + ), + html_url: "https://github.com/Socialure/lawn".to_owned(), + }, + TransportRepository { + full_name: "pingdotgg/t3code".to_owned(), + description: Some("The T3 desktop app.".to_owned()), + html_url: "https://github.com/pingdotgg/t3code".to_owned(), + }, + ] +} + fn title_case(value: &str) -> String { value .split(['-', '_']) diff --git a/crates/aim-core/src/update/ranking.rs b/crates/aim-core/src/update/ranking.rs index dd756e0..864fa2b 100644 --- a/crates/aim-core/src/update/ranking.rs +++ b/crates/aim-core/src/update/ranking.rs @@ -70,6 +70,7 @@ pub fn select_artifact( .clone() .unwrap_or_else(|| "latest".to_owned()), arch: Some("x86_64".to_owned()), + trusted_checksum: hints.and_then(|value| value.checksum.clone()), selection_reason: selection_reason.to_owned(), } } diff --git a/crates/aim-core/tests/checksum_verification.rs b/crates/aim-core/tests/checksum_verification.rs new file mode 100644 index 0000000..6544f5f --- /dev/null +++ b/crates/aim-core/tests/checksum_verification.rs @@ -0,0 +1,95 @@ +use std::fs; + +use aim_core::integration::install::{InstallRequest, PayloadInstallError, execute_install}; +use aim_core::platform::DesktopHelpers; +use tempfile::tempdir; + +const VALID_FIXTURE_SHA512: &str = + "ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw=="; + +#[test] +fn install_succeeds_with_valid_trusted_checksum() { + let root = tempdir().unwrap(); + let staged_path = write_staged_payload( + root.path(), + b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82", + ); + let final_payload_path = root.path().join("payloads/bat.AppImage"); + + let outcome = execute_install(&InstallRequest { + staged_payload_path: &staged_path, + final_payload_path: &final_payload_path, + trusted_checksum: Some(VALID_FIXTURE_SHA512), + desktop: None, + helpers: DesktopHelpers::default(), + }) + .unwrap(); + + assert_eq!(outcome.final_payload_path, final_payload_path); + assert!(outcome.final_payload_path.exists()); +} + +#[test] +fn install_succeeds_without_trusted_checksum() { + let root = tempdir().unwrap(); + let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage"); + let final_payload_path = root.path().join("payloads/bat.AppImage"); + + let outcome = execute_install(&InstallRequest { + staged_payload_path: &staged_path, + final_payload_path: &final_payload_path, + trusted_checksum: None, + desktop: None, + helpers: DesktopHelpers::default(), + }) + .unwrap(); + + assert!(outcome.final_payload_path.exists()); +} + +#[test] +fn install_fails_before_commit_when_trusted_checksum_mismatches() { + let root = tempdir().unwrap(); + let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage"); + let final_payload_path = root.path().join("payloads/bat.AppImage"); + + let error = execute_install(&InstallRequest { + staged_payload_path: &staged_path, + final_payload_path: &final_payload_path, + trusted_checksum: Some(VALID_FIXTURE_SHA512), + desktop: None, + helpers: DesktopHelpers::default(), + }) + .unwrap_err(); + + assert!(matches!(error, PayloadInstallError::ChecksumMismatch)); + assert!(!final_payload_path.exists()); + assert!(!staged_path.exists()); +} + +#[test] +fn malformed_trusted_checksum_fails_before_commit() { + let root = tempdir().unwrap(); + let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage"); + let final_payload_path = root.path().join("payloads/bat.AppImage"); + + let error = execute_install(&InstallRequest { + staged_payload_path: &staged_path, + final_payload_path: &final_payload_path, + trusted_checksum: Some("not-base64"), + desktop: None, + helpers: DesktopHelpers::default(), + }) + .unwrap_err(); + + assert!(matches!(error, PayloadInstallError::InvalidTrustedChecksum)); + assert!(!final_payload_path.exists()); + assert!(!staged_path.exists()); +} + +fn write_staged_payload(root: &std::path::Path, bytes: &[u8]) -> std::path::PathBuf { + let staged_path = root.join("staging/bat.download"); + fs::create_dir_all(staged_path.parent().unwrap()).unwrap(); + fs::write(&staged_path, bytes).unwrap(); + staged_path +} diff --git a/crates/aim-core/tests/download_pipeline.rs b/crates/aim-core/tests/download_pipeline.rs new file mode 100644 index 0000000..b4ca7f7 --- /dev/null +++ b/crates/aim-core/tests/download_pipeline.rs @@ -0,0 +1,180 @@ +use std::fs; +use std::io::{self, Cursor, Read}; +use std::time::Duration; + +use aim_core::app::add::{ + InstallAppError, download_to_staged_path_with_retries, + stream_payload_to_staged_file_with_reporter, +}; +use aim_core::app::progress::{NoopReporter, OperationEvent}; +use aim_core::integration::install::{InstallRequest, execute_install}; +use aim_core::platform::DesktopHelpers; +use aim_core::source::github::HttpClientPolicy; +use tempfile::tempdir; + +#[test] +fn payload_streaming_writes_staged_file_and_reports_progress() { + let root = tempdir().unwrap(); + let staged_path = root.path().join("staging/bat.download"); + let bytes = b"\x7fELFAppImage"; + let mut reader = Cursor::new(bytes.as_slice()); + let mut events = Vec::new(); + let mut reporter = |event: &OperationEvent| events.push(event.clone()); + + let written = stream_payload_to_staged_file_with_reporter( + &mut reader, + Some(bytes.len() as u64), + &staged_path, + &mut reporter, + ) + .unwrap(); + + assert_eq!(written, bytes.len() as u64); + assert_eq!( + fs::metadata(&staged_path).unwrap().len(), + bytes.len() as u64 + ); + assert!(events.iter().any(|event| { + matches!( + event, + OperationEvent::Progress { + current, + total: Some(total) + } if *current == bytes.len() as u64 && *total == bytes.len() as u64 + ) + })); +} + +#[test] +fn install_commits_from_staged_payload_path() { + let root = tempdir().unwrap(); + let staged_path = root.path().join("staging/bat.download"); + let final_payload_path = root.path().join("payloads/bat.AppImage"); + fs::create_dir_all(staged_path.parent().unwrap()).unwrap(); + fs::write(&staged_path, b"\x7fELFAppImage").unwrap(); + + let outcome = execute_install(&InstallRequest { + staged_payload_path: &staged_path, + final_payload_path: &final_payload_path, + trusted_checksum: None, + desktop: None, + helpers: DesktopHelpers::default(), + }) + .unwrap(); + + assert_eq!(outcome.final_payload_path, final_payload_path); + assert!(outcome.final_payload_path.exists()); + assert!(!staged_path.exists()); +} + +#[test] +fn failed_streaming_download_removes_partial_staged_payload() { + let root = tempdir().unwrap(); + let staged_path = root.path().join("staging/bat.download"); + let mut reader = FailingReader::new(b"\x7fELFpartial".to_vec(), 4); + let mut reporter = NoopReporter; + + let result = stream_payload_to_staged_file_with_reporter( + &mut reader, + Some(12), + &staged_path, + &mut reporter, + ); + + assert!(result.is_err()); + assert!(!staged_path.exists()); +} + +#[test] +fn retry_policy_retries_transient_failures_before_success() { + let root = tempdir().unwrap(); + let staged_path = root.path().join("staging/bat.download"); + let bytes = b"\x7fELFAppImage"; + let mut attempts = 0; + + let written = download_to_staged_path_with_retries( + &staged_path, + &mut NoopReporter, + HttpClientPolicy { + timeout: Duration::from_secs(30), + max_retries: 3, + }, + || { + attempts += 1; + if attempts == 1 { + return Err(InstallAppError::DownloadIo(io::Error::other( + "transient failure", + ))); + } + + Ok(( + Box::new(Cursor::new(bytes.to_vec())) as Box, + Some(bytes.len() as u64), + )) + }, + ) + .unwrap(); + + assert_eq!(attempts, 2); + assert_eq!(written, bytes.len() as u64); + assert!(staged_path.exists()); +} + +#[test] +fn retry_exhaustion_returns_error_and_cleans_staged_payload() { + let root = tempdir().unwrap(); + let staged_path = root.path().join("staging/bat.download"); + let mut attempts = 0; + + let result = download_to_staged_path_with_retries( + &staged_path, + &mut NoopReporter, + HttpClientPolicy { + timeout: Duration::from_secs(30), + max_retries: 2, + }, + || { + attempts += 1; + Ok(( + Box::new(FailingReader::new(b"\x7fELFpartial".to_vec(), 4)) as Box, + Some(12), + )) + }, + ); + + assert!(result.is_err()); + assert_eq!(attempts, 2); + assert!(!staged_path.exists()); +} + +struct FailingReader { + bytes: Vec, + chunk_size: usize, + position: usize, +} + +impl FailingReader { + fn new(bytes: Vec, chunk_size: usize) -> Self { + Self { + bytes, + chunk_size, + position: 0, + } + } +} + +impl Read for FailingReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.position >= self.chunk_size { + return Err(io::Error::other("fixture read failure")); + } + + let remaining = self.chunk_size - self.position; + let to_read = remaining + .min(buffer.len()) + .min(self.bytes.len() - self.position); + buffer[..to_read].copy_from_slice(&self.bytes[self.position..self.position + to_read]); + self.position += to_read; + Ok(to_read) + } +} diff --git a/crates/aim-core/tests/github_source_discovery.rs b/crates/aim-core/tests/github_source_discovery.rs index 5f631ba..7bc94ac 100644 --- a/crates/aim-core/tests/github_source_discovery.rs +++ b/crates/aim-core/tests/github_source_discovery.rs @@ -1,5 +1,8 @@ use aim_core::app::query::resolve_query; -use aim_core::source::github::{FixtureGitHubTransport, discover_github_candidates_with}; +use aim_core::source::github::{ + FixtureGitHubTransport, discover_github_candidates_with, http_client_policy, +}; +use std::time::Duration; #[test] fn discovery_reports_appimage_assets_and_latest_linux_yml() { @@ -31,3 +34,11 @@ fn discovery_marks_explicit_older_release_against_latest_fixture_release() { assert_eq!(discovery.releases[0].tag, "v0.0.12"); assert!(discovery.requested_is_older_release); } + +#[test] +fn github_http_policy_uses_explicit_timeout_and_retry_defaults() { + let policy = http_client_policy(); + + assert_eq!(policy.timeout, Duration::from_secs(30)); + assert_eq!(policy.max_retries, 3); +} diff --git a/crates/aim-core/tests/install_failures.rs b/crates/aim-core/tests/install_failures.rs index c47f475..ae68843 100644 --- a/crates/aim-core/tests/install_failures.rs +++ b/crates/aim-core/tests/install_failures.rs @@ -13,13 +13,15 @@ fn integration_failure_removes_new_payload_and_generated_files() { fs::create_dir(&staging_root).unwrap(); fs::create_dir(&payload_root).unwrap(); fs::write(&blocking_path, "blocker").unwrap(); + let staged_path = staging_root.join("bat.download"); + fs::write(&staged_path, b"\x7fELFAppImage").unwrap(); let final_payload_path = payload_root.join("bat.AppImage"); let desktop_entry_path = blocking_path.join("aim-bat.desktop"); let error = execute_install(&InstallRequest { - staging_root: &staging_root, + staged_payload_path: &staged_path, final_payload_path: &final_payload_path, - artifact_bytes: b"\x7fELFAppImage", + trusted_checksum: None, desktop: Some(DesktopIntegrationRequest { desktop_entry_path: &desktop_entry_path, desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", diff --git a/crates/aim-core/tests/install_integration.rs b/crates/aim-core/tests/install_integration.rs index b2933cf..d843648 100644 --- a/crates/aim-core/tests/install_integration.rs +++ b/crates/aim-core/tests/install_integration.rs @@ -8,21 +8,27 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; +fn write_staged_payload(root: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf { + let staged_path = root.join("staging").join(format!("{name}.download")); + fs::create_dir_all(staged_path.parent().unwrap()).unwrap(); + fs::write(&staged_path, bytes).unwrap(); + staged_path +} + #[test] fn install_writes_desktop_entry_and_reports_refresh_warning_only() { let root = tempdir().unwrap(); - let staging_root = root.path().join("staging"); let payload_root = root.path().join("payloads"); let desktop_root = root.path().join("applications"); - fs::create_dir(&staging_root).unwrap(); fs::create_dir(&payload_root).unwrap(); fs::create_dir(&desktop_root).unwrap(); + let staged_path = write_staged_payload(root.path(), "bat", b"\x7fELFAppImage"); let outcome = execute_install(&InstallRequest { - staging_root: &staging_root, + staged_payload_path: &staged_path, final_payload_path: &payload_root.join("bat.AppImage"), - artifact_bytes: b"\x7fELFAppImage", + trusted_checksum: None, desktop: Some(DesktopIntegrationRequest { desktop_entry_path: &desktop_root.join("aim-bat.desktop"), desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", @@ -40,16 +46,19 @@ fn install_writes_desktop_entry_and_reports_refresh_warning_only() { #[test] fn install_executes_refresh_helpers_when_available() { let root = tempdir().unwrap(); - let staging_root = root.path().join("staging"); let payload_root = root.path().join("payloads"); let desktop_root = root.path().join("applications"); let helper_root = root.path().join("helpers"); let log_path = root.path().join("helpers.log"); - fs::create_dir(&staging_root).unwrap(); fs::create_dir(&payload_root).unwrap(); fs::create_dir(&desktop_root).unwrap(); fs::create_dir(&helper_root).unwrap(); + let staged_path = write_staged_payload( + root.path(), + "bat", + b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82", + ); let update_helper = helper_root.join("update-desktop-database"); let icon_helper = helper_root.join("gtk-update-icon-cache"); @@ -70,9 +79,9 @@ fn install_executes_refresh_helpers_when_available() { fs::create_dir_all(&icon_root).unwrap(); let outcome = execute_install(&InstallRequest { - staging_root: &staging_root, + staged_payload_path: &staged_path, final_payload_path: &payload_root.join("bat.AppImage"), - artifact_bytes: b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82", + trusted_checksum: None, desktop: Some(DesktopIntegrationRequest { desktop_entry_path: &desktop_root.join("aim-bat.desktop"), desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", @@ -97,20 +106,23 @@ fn install_executes_refresh_helpers_when_available() { #[test] fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() { let root = tempdir().unwrap(); - let staging_root = root.path().join("staging"); let payload_root = root.path().join("payloads"); let desktop_root = root.path().join("applications"); let icon_root = root.path().join("icons/hicolor/256x256/apps"); - fs::create_dir(&staging_root).unwrap(); fs::create_dir(&payload_root).unwrap(); fs::create_dir(&desktop_root).unwrap(); fs::create_dir_all(&icon_root).unwrap(); + let staged_path = write_staged_payload( + root.path(), + "bat", + b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82", + ); let outcome = execute_install(&InstallRequest { - staging_root: &staging_root, + staged_payload_path: &staged_path, final_payload_path: &payload_root.join("bat.AppImage"), - artifact_bytes: b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82", + trusted_checksum: None, desktop: Some(DesktopIntegrationRequest { desktop_entry_path: &desktop_root.join("aim-bat.desktop"), desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", diff --git a/crates/aim-core/tests/install_payload.rs b/crates/aim-core/tests/install_payload.rs index c86ca87..37f6278 100644 --- a/crates/aim-core/tests/install_payload.rs +++ b/crates/aim-core/tests/install_payload.rs @@ -11,9 +11,10 @@ fn payload_commit_moves_staged_appimage_into_final_location() { fs::create_dir(&staging_root).unwrap(); fs::create_dir(&payload_root).unwrap(); + let staged_path = staging_root.join("bat.download"); + fs::write(&staged_path, b"\x7fELFAppImage").unwrap(); let final_payload_path = payload_root.join("bat.AppImage"); - let outcome = - stage_and_commit_payload(&staging_root, &final_payload_path, b"\x7fELFAppImage").unwrap(); + let outcome = stage_and_commit_payload(&staged_path, &final_payload_path).unwrap(); assert_eq!( outcome diff --git a/crates/aim-core/tests/registry_roundtrip.rs b/crates/aim-core/tests/registry_roundtrip.rs index ac030e4..9a6c135 100644 --- a/crates/aim-core/tests/registry_roundtrip.rs +++ b/crates/aim-core/tests/registry_roundtrip.rs @@ -101,3 +101,84 @@ fn registry_round_trips_install_metadata() { Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png") ); } + +#[test] +fn registry_save_is_atomic_and_cleans_up_temp_file() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let store = RegistryStore::new(registry_path.clone()); + + store + .save(&aim_core::registry::model::Registry { + version: 1, + apps: vec![aim_core::domain::app::AppRecord { + stable_id: "bat".to_owned(), + display_name: "Bat".to_owned(), + source_input: None, + source: None, + installed_version: None, + update_strategy: None, + metadata: Vec::new(), + install: None, + }], + }) + .unwrap(); + + assert!(registry_path.exists()); + assert!(!dir.path().join("registry.toml.tmp").exists()); +} + +#[test] +fn registry_exclusive_lock_rejects_second_mutator() { + let dir = tempdir().unwrap(); + let store = RegistryStore::new(dir.path().join("registry.toml")); + let _guard = store.lock_exclusive().unwrap(); + + let error = store.lock_exclusive().unwrap_err(); + + assert!(matches!( + error, + aim_core::registry::store::RegistryStoreError::LockUnavailable + )); +} + +#[test] +fn registry_mutate_exclusive_reloads_and_writes_latest_state() { + let dir = tempdir().unwrap(); + let store = RegistryStore::new(dir.path().join("registry.toml")); + store + .save(&aim_core::registry::model::Registry { + version: 1, + apps: vec![aim_core::domain::app::AppRecord { + stable_id: "bat".to_owned(), + display_name: "Bat".to_owned(), + source_input: None, + source: None, + installed_version: None, + update_strategy: None, + metadata: Vec::new(), + install: None, + }], + }) + .unwrap(); + + store + .mutate_exclusive(|registry| { + registry.apps.push(aim_core::domain::app::AppRecord { + stable_id: "t3code".to_owned(), + display_name: "T3 Code".to_owned(), + source_input: None, + source: None, + installed_version: None, + update_strategy: None, + metadata: Vec::new(), + install: None, + }); + }) + .unwrap(); + + let loaded = store.load().unwrap(); + assert_eq!(loaded.apps.len(), 2); + assert_eq!(loaded.apps[0].stable_id, "bat"); + assert_eq!(loaded.apps[1].stable_id, "t3code"); +} diff --git a/crates/aim-core/tests/search_github.rs b/crates/aim-core/tests/search_github.rs new file mode 100644 index 0000000..304c6f7 --- /dev/null +++ b/crates/aim-core/tests/search_github.rs @@ -0,0 +1,212 @@ +use aim_core::app::search::{ + GitHubSearchProvider, SearchProvider, SearchProviderError, build_search_results_with, +}; +use aim_core::domain::app::AppRecord; +use aim_core::domain::search::{SearchInstallStatus, SearchQuery}; +use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef}; +use aim_core::source::github::{FixtureGitHubTransport, search_github_repositories_with}; + +#[test] +fn github_fixtures_return_normalized_remote_hits() { + let query = SearchQuery::new("bat"); + let provider = GitHubSearchProvider::new(&FixtureGitHubTransport); + + let results = build_search_results_with(&query, &[], &[&provider]).unwrap(); + + assert_eq!(query.remote_limit, 10); + assert!(results.installed_matches.is_empty()); + assert!(results.warnings.is_empty()); + assert_eq!(results.remote_hits.len(), 3); + + let first = &results.remote_hits[0]; + assert_eq!(first.provider_id, "github"); + assert_eq!(first.display_name, "sharkdp/bat"); + assert_eq!( + first.description.as_deref(), + Some("A cat(1) clone with wings.") + ); + assert_eq!(first.source_locator, "https://github.com/sharkdp/bat"); + assert_eq!(first.install_query, "sharkdp/bat"); + assert_eq!(first.canonical_locator, "sharkdp/bat"); + assert_eq!(first.version.as_deref(), Some("1.0.0")); + assert_eq!(first.install_status, SearchInstallStatus::Available); +} + +#[test] +fn github_search_respects_limit_and_fixture_order() { + let query = SearchQuery::with_remote_limit("bat", 2); + let provider = GitHubSearchProvider::new(&FixtureGitHubTransport); + + let results = build_search_results_with(&query, &[], &[&provider]).unwrap(); + + let locators = results + .remote_hits + .iter() + .map(|hit| hit.canonical_locator.as_str()) + .collect::>(); + + assert_eq!(locators, vec!["sharkdp/bat", "astatine/bat"]); +} + +#[test] +fn github_search_ranks_full_name_matches_above_description_only_matches() { + let query = SearchQuery::new("pingdotgg"); + let provider = GitHubSearchProvider::new(&FixtureGitHubTransport); + + let results = build_search_results_with(&query, &[], &[&provider]).unwrap(); + + let locators = results + .remote_hits + .iter() + .map(|hit| hit.canonical_locator.as_str()) + .collect::>(); + + assert_eq!(locators[0], "pingdotgg/t3code"); + assert_eq!(locators, vec!["pingdotgg/t3code"]); +} + +#[test] +fn github_search_backfills_description_matches_after_name_matches() { + let query = SearchQuery::with_remote_limit("pingdotgg", 3); + let provider = GitHubSearchProvider::new(&FixtureGitHubTransport); + + let results = build_search_results_with(&query, &[], &[&provider]).unwrap(); + + let locators = results + .remote_hits + .iter() + .map(|hit| hit.canonical_locator.as_str()) + .collect::>(); + + assert_eq!(locators, vec!["pingdotgg/t3code"]); +} + +#[test] +fn github_search_only_returns_repositories_with_appimage_release_assets() { + let query = SearchQuery::new("pingdotgg"); + let provider = GitHubSearchProvider::new(&FixtureGitHubTransport); + + let results = build_search_results_with(&query, &[], &[&provider]).unwrap(); + + assert!( + results + .remote_hits + .iter() + .all(|hit| hit.canonical_locator == "pingdotgg/t3code") + ); +} + +#[test] +fn github_name_only_search_excludes_description_only_matches() { + let hits = + search_github_repositories_with("pingdotgg in:name", 10, &FixtureGitHubTransport).unwrap(); + + let locators = hits + .iter() + .map(|hit| hit.full_name.as_str()) + .collect::>(); + + assert_eq!(locators, vec!["pingdotgg/t3code"]); +} + +#[test] +fn app_search_results_can_carry_local_matches_and_warnings() { + let query = SearchQuery::new("bat"); + let installed = vec![AppRecord { + stable_id: "bat".to_owned(), + display_name: "Bat".to_owned(), + source_input: None, + source: None, + installed_version: Some("1.0.0".to_owned()), + update_strategy: None, + metadata: Vec::new(), + install: None, + }]; + let provider = FailingProvider; + + let results = build_search_results_with(&query, &installed, &[&provider]).unwrap(); + + assert!(results.remote_hits.is_empty()); + assert_eq!(results.installed_matches.len(), 1); + assert_eq!(results.installed_matches[0].stable_id, "bat"); + assert_eq!(results.installed_matches[0].display_name, "Bat"); + assert_eq!(results.warnings.len(), 1); + assert_eq!(results.warnings[0].provider_id.as_deref(), Some("github")); +} + +#[test] +fn github_search_marks_matching_current_install_as_installed() { + let query = SearchQuery::new("bat"); + let installed = vec![installed_github_app("sharkdp/bat", "1.0.0")]; + let provider = GitHubSearchProvider::new(&FixtureGitHubTransport); + + let results = build_search_results_with(&query, &installed, &[&provider]).unwrap(); + let bat = results + .remote_hits + .iter() + .find(|hit| hit.install_query == "sharkdp/bat") + .unwrap(); + + assert_eq!( + bat.install_status, + SearchInstallStatus::Installed { + installed_version: Some("1.0.0".to_owned()), + } + ); +} + +#[test] +fn github_search_marks_older_install_as_update_available() { + let query = SearchQuery::new("pingdotgg"); + let installed = vec![installed_github_app("pingdotgg/t3code", "0.0.11")]; + let provider = GitHubSearchProvider::new(&FixtureGitHubTransport); + + let results = build_search_results_with(&query, &installed, &[&provider]).unwrap(); + let t3code = results + .remote_hits + .iter() + .find(|hit| hit.install_query == "pingdotgg/t3code") + .unwrap(); + + assert_eq!(t3code.version.as_deref(), Some("0.0.12")); + assert_eq!( + t3code.install_status, + SearchInstallStatus::UpdateAvailable { + installed_version: Some("0.0.11".to_owned()), + latest_version: Some("0.0.12".to_owned()), + } + ); +} + +fn installed_github_app(locator: &str, installed_version: &str) -> AppRecord { + AppRecord { + stable_id: locator.replace('/', "-"), + display_name: locator.split('/').next_back().unwrap().to_owned(), + source_input: Some(locator.to_owned()), + source: Some(SourceRef { + kind: SourceKind::GitHub, + locator: locator.to_owned(), + input_kind: SourceInputKind::RepoShorthand, + normalized_kind: NormalizedSourceKind::GitHubRepository, + canonical_locator: Some(locator.to_owned()), + requested_tag: None, + requested_asset_name: None, + tracks_latest: true, + }), + installed_version: Some(installed_version.to_owned()), + update_strategy: None, + metadata: Vec::new(), + install: None, + } +} + +struct FailingProvider; + +impl SearchProvider for FailingProvider { + fn search( + &self, + _query: &SearchQuery, + ) -> Result, SearchProviderError> { + Err(SearchProviderError::new("github", "fixture rate limit")) + } +}