From f8ffb953763ceab41bb97a26afae52df7e31d539 Mon Sep 17 00:00:00 2001 From: stoorps Date: Sat, 21 Mar 2026 20:00:23 +0000 Subject: [PATCH] feat: add AppImageHub provider support --- ...1-appimagehub-and-query-fallback-design.md | 183 +++++++ ...-and-query-fallback-implementation-plan.md | 290 +++++++++++ Cargo.lock | 11 + Cargo.toml | 1 + README.md | 9 +- crates/aim-cli/src/lib.rs | 22 +- crates/aim-cli/tests/end_to_end_cli.rs | 86 ++- crates/aim-cli/tests/search_cli.rs | 18 + crates/aim-core/Cargo.toml | 1 + crates/aim-core/src/adapters/appimagehub.rs | 89 ++++ crates/aim-core/src/adapters/custom_json.rs | 24 - crates/aim-core/src/adapters/mod.rs | 4 +- crates/aim-core/src/app/add.rs | 58 ++- crates/aim-core/src/app/search.rs | 132 ++++- crates/aim-core/src/app/update.rs | 8 +- crates/aim-core/src/domain/source.rs | 8 + crates/aim-core/src/source/appimagehub.rs | 491 ++++++++++++++++++ crates/aim-core/src/source/input.rs | 53 ++ crates/aim-core/src/source/mod.rs | 1 + crates/aim-core/tests/adapter_contract.rs | 56 ++ crates/aim-core/tests/adapter_smoke.rs | 3 +- crates/aim-core/tests/appimagehub_search.rs | 108 ++++ crates/aim-core/tests/query_resolution.rs | 30 ++ 23 files changed, 1636 insertions(+), 50 deletions(-) create mode 100644 .plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-design.md create mode 100644 .plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-implementation-plan.md create mode 100644 crates/aim-core/src/adapters/appimagehub.rs delete mode 100644 crates/aim-core/src/adapters/custom_json.rs create mode 100644 crates/aim-core/src/source/appimagehub.rs create mode 100644 crates/aim-core/tests/appimagehub_search.rs diff --git a/.plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-design.md b/.plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-design.md new file mode 100644 index 0000000..8f67e1b --- /dev/null +++ b/.plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-design.md @@ -0,0 +1,183 @@ +# AppImageHub Provider And Query Fallback Design + +## Summary + +This change adds AppImageHub as a first-class source and search provider, removes the unused `custom-json` stub, and changes positional `aim ` so it falls back to search when no direct installable match is available across providers. The direct-input contract stays deterministic: explicit provider URLs and shorthands still install immediately, while plain-name queries become a search-first discovery experience only after strict direct resolution fails. + +## Goals + +- Add AppImageHub as a supported provider for direct resolution and search. +- Accept direct AppImageHub inputs as either item URLs or `appimagehub/` shorthand. +- Preserve strict direct resolution semantics for provider-specific inputs. +- Make positional `aim ` fall back to cross-provider search when direct resolution is unsupported or yields no installable artifact. +- Remove the `custom-json` adapter stub and its registration/tests. +- Keep provider identity stable in the registry by using AppImageHub numeric item IDs as canonical locators. + +## Non-Goals + +- No AppImageHub slug-based direct input format. +- No new interactive chooser or auto-install of fuzzy search winners in this slice. +- No redesign of `aim search` output beyond adding AppImageHub-backed results. +- No machine-readable provider metadata output beyond what current domain models already expose. +- No provider-agnostic alias layer for AppImageHub names. + +## Approaches + +### Option 1: Canonical IDs for direct AppImageHub input, names through search + +This is the approved design. Direct AppImageHub inputs are limited to the observable stable forms `https://www.appimagehub.com/p/` and `appimagehub/`. Plain names are handled by the cross-provider search path, which can return install-ready `appimagehub/` install queries. This keeps direct installs deterministic and avoids inventing a slug model the service does not clearly expose. + +### Option 2: Title-based direct shorthand + +This would accept human-readable AppImageHub names as if they were stable direct locators. It looks cleaner, but it collapses search and direct resolution into one fuzzy layer and makes title normalization, collisions, and title drift part of the persistent provider contract. + +### Option 3: Provider alias registry + +This would map friendly names to canonical AppImageHub IDs inside `aim`. It could improve CLI ergonomics later, but it adds storage, collision handling, update drift, and debugging overhead that are not justified for the initial provider integration. + +## Approved Design + +### Public Command Behavior + +Positional `aim ` becomes a two-phase command: + +1. attempt strict direct resolution using existing provider/source rules +2. if direct resolution yields an installable artifact, continue through the current add/install flow +3. if direct resolution is unsupported or resolves to a provider item with no installable artifact, fall back to cross-provider search + +This preserves deterministic direct installs for URLs and provider shorthands while turning plain-name queries such as `aim firefox` into a discovery flow instead of a dead-end error. + +The fallback rule is intentionally command-level policy, not a change to `resolve_query(...)`. Strict source classification should remain strict. The new behavior belongs in a higher-level orchestration layer that decides whether the positional command is an install or a search. + +### AppImageHub Input Model + +AppImageHub direct inputs are: + +- `https://www.appimagehub.com/p/` +- `http://www.appimagehub.com/p/` +- `appimagehub/` + +The canonical provider identity is the numeric AppImageHub item ID. That ID should be stored as the canonical locator in the resolved source so the registry remains stable even if the user-facing title changes. + +The source model gains: + +- `SourceKind::AppImageHub` +- a `SourceInputKind` for AppImageHub URLs +- a `SourceInputKind` for AppImageHub shorthand +- `NormalizedSourceKind::AppImageHub` + +The visible locator can remain the item page URL for readability, but provider matching and search/install identity should rely on the canonical ID. + +### Provider Architecture + +AppImageHub should be implemented as a real source adapter plus a real search provider. + +The source adapter is responsible for: + +- normalizing URL and shorthand inputs into a `SourceRef` +- resolving an AppImageHub item into the latest installable AppImage artifact +- returning a provider-specific no-installable-artifact outcome when the item exists but does not expose a usable AppImage asset + +The search provider is responsible for: + +- searching AppImageHub content by title or provider-supported query text +- mapping results into the existing `SearchResult` model +- returning install-ready queries as `appimagehub/` + +This keeps AppImageHub aligned with the existing GitHub/Search provider split and avoids tunneling it through generic direct URLs. + +### Positional Query Fallback Flow + +Add a new orchestration layer for positional `aim ` that makes the install-versus-search decision explicit. Conceptually: + +- attempt build-add-plan +- if a direct install plan is produced, continue with the current install path +- if the add-plan path fails with unsupported query or no installable artifact, build search results instead +- if search finds hits, render search results instead of returning the add error +- if search finds nothing, render the normal empty search state instead of `unsupported source query` + +This should be represented as a dedicated dispatch/core decision rather than scattered error remapping in CLI display code. + +### Error Handling + +The important distinction is between provider resolution and user-facing command behavior: + +- strict direct resolution can still return `Unsupported` +- AppImageHub resolution can still return `NoInstallableArtifact` +- positional `aim ` treats those outcomes as search-fallback triggers +- explicit `aim search ` skips direct resolution entirely and always searches + +Provider failures during search should continue to use the existing warning model. If all providers fail and no results are available, search should still surface provider failure warnings as today. + +Malformed direct AppImageHub inputs should remain malformed direct inputs. They should not silently become provider-specific direct matches. If the text is not a valid direct AppImageHub source, it should either be an unsupported direct query or a plain search term depending on the command path. + +### Search Result Semantics + +AppImageHub search hits should populate: + +- `provider_id = "appimagehub"` +- `display_name = ` +- `source_locator = ` +- `install_query = appimagehub/` +- `canonical_locator = ` + +Installed-status annotation should treat AppImageHub IDs the same way GitHub currently treats canonical locators, so installed AppImageHub apps can show as installed or update-available inside search results. + +### Removal Of `custom-json` + +`custom-json` is a stub with no supported query or resolution behavior. It should be removed outright: + +- delete the adapter module +- remove it from adapter registration +- remove it from smoke/contract coverage where it appears as an expected adapter kind + +This slice should not replace it with another placeholder. AppImageHub is the real provider addition. + +## Testing Strategy + +### Query Classification Coverage + +Add direct-resolution tests for: + +- AppImageHub URL classification +- `appimagehub/` classification +- malformed shorthand rejection + +### Adapter Coverage + +Add AppImageHub adapter tests for: + +- successful normalization from URL and shorthand +- successful resolution to an installable AppImage artifact +- no-installable-artifact outcomes for valid provider items without usable assets + +### Search Coverage + +Add search tests for: + +- AppImageHub provider hit mapping into `SearchResult` +- installed-status annotation based on AppImageHub canonical IDs +- cross-provider search continuing when one provider fails + +### Positional Query Coverage + +Add app/CLI tests for: + +- positional query directly installing when a provider resolves installably +- positional query falling back to search on unsupported query +- positional query falling back to search on no-installable-artifact +- positional query rendering empty search output when fallback search returns no matches + +### Registry/Smoke Coverage + +Update adapter smoke and contract coverage to: + +- remove `custom-json` +- include `appimagehub` + +## Delivery Notes + +- Keep `resolve_query(...)` strict; the fallback behavior belongs in a higher-level add/search decision. +- Prefer small extensions to existing domain types over introducing a second search result model. +- AppImageHub provider identity should be numeric-ID based from day one to avoid future migration churn. +- Do not add title-derived aliases in this slice. \ No newline at end of file diff --git a/.plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-implementation-plan.md b/.plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-implementation-plan.md new file mode 100644 index 0000000..eafe95a --- /dev/null +++ b/.plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-implementation-plan.md @@ -0,0 +1,290 @@ +# AppImageHub Provider And Query Fallback Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add AppImageHub as a first-class provider, remove the `custom-json` stub, and make positional `aim ` fall back to cross-provider search when no direct installable match exists. + +**Architecture:** Keep strict source classification in `aim-core::app::query`, add AppImageHub through the existing source-adapter and search-provider seams, and move positional install-versus-search behavior into a higher-level orchestration path in `aim-cli` and shared app logic. Reuse existing search models and add-plan flows rather than creating parallel provider plumbing. + +**Tech Stack:** Rust workspace, Cargo tests, Clap CLI parsing, existing provider adapter/search abstractions, fixture-driven tests. + +--- + +### Task 1: Extend source identity for AppImageHub + +**Files:** +- Modify: `crates/aim-core/src/domain/source.rs` +- Modify: `crates/aim-core/src/app/query.rs` +- Test: `crates/aim-core/tests/query_resolution.rs` + +**Step 1: Write the failing tests** + +Add tests covering: + +- `resolve_query("https://www.appimagehub.com/p/2338455")` +- `resolve_query("appimagehub/2338455")` +- malformed shorthand such as `appimagehub/firefox` + +Expected assertions: + +- `SourceKind::AppImageHub` +- appropriate `SourceInputKind` +- `NormalizedSourceKind::AppImageHub` +- canonical locator `Some("2338455")` + +**Step 2: Run the focused tests to verify failure** + +Run: `cargo test --package aim-core --test query_resolution` + +Expected: FAIL with unknown source kinds or unsupported AppImageHub inputs. + +**Step 3: Implement the minimal source model changes** + +Update enums and `as_str()` mappings in `crates/aim-core/src/domain/source.rs`, then extend query classification so AppImageHub URLs and `appimagehub/` normalize into a stable `SourceRef`. + +**Step 4: Run the focused tests to verify pass** + +Run: `cargo test --package aim-core --test query_resolution` + +Expected: PASS for the new AppImageHub cases and existing provider cases. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/domain/source.rs crates/aim-core/src/app/query.rs crates/aim-core/tests/query_resolution.rs +git commit -m "feat: classify AppImageHub sources" +``` + +### Task 2: Add the AppImageHub source adapter and remove `custom-json` + +**Files:** +- Create: `crates/aim-core/src/adapters/appimagehub.rs` +- Modify: `crates/aim-core/src/adapters/mod.rs` +- Delete: `crates/aim-core/src/adapters/custom_json.rs` +- Test: `crates/aim-core/tests/adapter_smoke.rs` +- Test: `crates/aim-core/tests/adapter_contract.rs` + +**Step 1: Write the failing tests** + +Add adapter expectations for: + +- `all_adapter_kinds()` contains `"appimagehub"` +- `all_adapter_kinds()` no longer contains `"custom-json"` +- AppImageHub adapter supports exact resolution and search + +**Step 2: Run the focused tests to verify failure** + +Run: `cargo test --package aim-core --test adapter_smoke --test adapter_contract` + +Expected: FAIL because AppImageHub is not registered and `custom-json` is still present. + +**Step 3: Implement the adapter registration changes** + +Create `appimagehub.rs` with `SourceAdapter` support for AppImageHub sources, wire it into `mod.rs`, and remove `custom-json` from module registration and adapter-kind reporting. + +**Step 4: Run the focused tests to verify pass** + +Run: `cargo test --package aim-core --test adapter_smoke --test adapter_contract` + +Expected: PASS with AppImageHub present and `custom-json` removed. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters/appimagehub.rs crates/aim-core/src/adapters/mod.rs crates/aim-core/tests/adapter_smoke.rs crates/aim-core/tests/adapter_contract.rs +git rm crates/aim-core/src/adapters/custom_json.rs +git commit -m "feat: add AppImageHub adapter" +``` + +### Task 3: Add AppImageHub transport-backed resolution + +**Files:** +- Modify: `crates/aim-core/src/adapters/appimagehub.rs` +- Create or Modify: `crates/aim-core/src/source/appimagehub.rs` +- Modify: `crates/aim-core/src/app/add.rs` +- Test: `crates/aim-core/tests/install_payload.rs` +- Test: `crates/aim-core/tests/adapter_contract.rs` + +**Step 1: Write the failing tests** + +Add fixture-backed tests for: + +- resolving `appimagehub/` into an installable AppImage artifact URL +- returning `NoInstallableArtifact` when the item exists but exposes no installable AppImage asset + +**Step 2: Run the focused tests to verify failure** + +Run: `cargo test --package aim-core --test adapter_contract --test install_payload` + +Expected: FAIL because AppImageHub resolution is not implemented. + +**Step 3: Implement the transport and resolution path** + +Add the minimal AppImageHub/OCS transport wrapper, teach the adapter to resolve the latest installable artifact, and update add-plan selection to route AppImageHub through the adapter path rather than the generic fallback branch. + +**Step 4: Run the focused tests to verify pass** + +Run: `cargo test --package aim-core --test adapter_contract --test install_payload` + +Expected: PASS with deterministic fixture-backed AppImageHub resolution. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters/appimagehub.rs crates/aim-core/src/source/appimagehub.rs crates/aim-core/src/app/add.rs crates/aim-core/tests/adapter_contract.rs crates/aim-core/tests/install_payload.rs +git commit -m "feat: resolve AppImageHub artifacts" +``` + +### Task 4: Add AppImageHub search provider integration + +**Files:** +- Modify: `crates/aim-core/src/app/search.rs` +- Create or Modify: `crates/aim-core/src/source/appimagehub.rs` +- Test: `crates/aim-core/tests/query_resolution.rs` +- Test: `crates/aim-core/tests/github_source_discovery.rs` +- Create or Modify: `crates/aim-core/tests/appimagehub_search.rs` + +**Step 1: Write the failing tests** + +Add fixture-backed search tests covering: + +- AppImageHub hit mapping into `SearchResult` +- `install_query = appimagehub/` +- canonical locator matching for installed-status annotation +- mixed-provider search returning GitHub and AppImageHub hits together + +**Step 2: Run the focused tests to verify failure** + +Run: `cargo test --package aim-core --test appimagehub_search` + +Expected: FAIL because AppImageHub search is not wired into the provider list. + +**Step 3: Implement the provider integration** + +Add an AppImageHub `SearchProvider`, wire it into `build_search_results(...)`, and update installed-hit annotation logic so AppImageHub-installed apps are recognized by canonical ID. + +**Step 4: Run the focused tests to verify pass** + +Run: `cargo test --package aim-core --test appimagehub_search` + +Expected: PASS with deterministic AppImageHub search coverage. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/search.rs crates/aim-core/src/source/appimagehub.rs crates/aim-core/tests/appimagehub_search.rs +git commit -m "feat: add AppImageHub search provider" +``` + +### Task 5: Add positional query fallback from install to search + +**Files:** +- Modify: `crates/aim-cli/src/lib.rs` +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-core/src/app/search.rs` +- Test: `crates/aim-cli/tests/end_to_end_cli.rs` +- Test: `crates/aim-cli/tests/cli_smoke.rs` + +**Step 1: Write the failing tests** + +Add CLI/app-flow tests covering: + +- `aim firefox` falls back to search results when direct resolution is unsupported +- positional query falls back to search results when a provider item has no installable artifact +- positional query still installs directly for valid direct provider inputs + +**Step 2: Run the focused tests to verify failure** + +Run: `cargo test --package aim-cli --test end_to_end_cli --test cli_smoke` + +Expected: FAIL because positional queries still surface add errors instead of search results. + +**Step 3: Implement the orchestration change** + +Add a small decision path in dispatch or shared app logic that tries the add flow first, then converts `Unsupported` and `NoInstallableArtifact` outcomes into `SearchResults` for positional queries only. + +**Step 4: Run the focused tests to verify pass** + +Run: `cargo test --package aim-cli --test end_to_end_cli --test cli_smoke` + +Expected: PASS with positional-query fallback behavior. + +**Step 5: Commit** + +```bash +git add crates/aim-cli/src/lib.rs crates/aim-core/src/app/add.rs crates/aim-core/src/app/search.rs crates/aim-cli/tests/end_to_end_cli.rs crates/aim-cli/tests/cli_smoke.rs +git commit -m "feat: fall back to search for positional queries" +``` + +### Task 6: Update CLI rendering and messaging for fallback search + +**Files:** +- Modify: `crates/aim-cli/src/ui/render.rs` +- Modify: `crates/aim-cli/src/ui/theme.rs` +- Test: `crates/aim-cli/tests/ui_summary.rs` +- Test: `crates/aim-cli/tests/cli_commands.rs` + +**Step 1: Write the failing tests** + +Add renderer expectations for: + +- fallback search rendering from positional `aim ` +- empty search state instead of `unsupported source query` +- AppImageHub provider labels and install query formatting where they are visible + +**Step 2: Run the focused tests to verify failure** + +Run: `cargo test --package aim-cli --test ui_summary --test cli_commands` + +Expected: FAIL because fallback-search rendering and AppImageHub labels are not represented. + +**Step 3: Implement the minimal rendering changes** + +Reuse existing search rendering as much as possible, only adjusting dispatch/result handling and any provider-label formatting needed for AppImageHub. + +**Step 4: Run the focused tests to verify pass** + +Run: `cargo test --package aim-cli --test ui_summary --test cli_commands` + +Expected: PASS with stable search output for fallback scenarios. + +**Step 5: Commit** + +```bash +git add crates/aim-cli/src/ui/render.rs crates/aim-cli/src/ui/theme.rs crates/aim-cli/tests/ui_summary.rs crates/aim-cli/tests/cli_commands.rs +git commit -m "feat: render AppImageHub and fallback search results" +``` + +### Task 7: Update docs and run full verification + +**Files:** +- Modify: `README.md` +- Modify: `.plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-design.md` if implementation details drift +- Modify: `.plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-implementation-plan.md` if task wording needs correction + +**Step 1: Update user-facing docs** + +Document: + +- AppImageHub direct query forms +- positional-query fallback-to-search behavior +- removal of `custom-json` from the supported-provider story + +**Step 2: Run formatting and full verification** + +Run: + +```bash +cargo fmt --all +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +Expected: all commands succeed. + +**Step 3: Commit** + +```bash +git add README.md .plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-design.md .plans/011-appimagehub-and-query-fallback/2026-03-21-appimagehub-and-query-fallback-implementation-plan.md +git commit -m "docs: document AppImageHub provider and query fallback" +``` \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 1fec377..abd933c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,7 @@ version = "0.1.0" dependencies = [ "base64", "fs2", + "quick-xml", "reqwest", "serde", "serde_yaml", @@ -1151,6 +1152,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" diff --git a/Cargo.toml b/Cargo.toml index 84a331f..005053e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ 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"] } +quick-xml = { version = "0.37.5", features = ["serialize"] } serde = { version = "1.0.219", features = ["derive"] } serde_yaml = "0.9.34" sha2 = "0.10.8" diff --git a/README.md b/README.md index 0d20941..a24b9e6 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,11 @@ aim remove ## Query Forms - `owner/repo` for GitHub shorthand +- `appimagehub/` for AppImageHub shorthand - GitHub repository URLs - GitHub release URLs - direct GitHub release asset URLs +- AppImageHub item URLs such as `https://www.appimagehub.com/p/2338455` - `https://...` direct URLs - GitLab URLs - SourceForge URLs @@ -36,10 +38,9 @@ aim remove `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` +- search is provider-extensible and currently includes GitHub plus AppImageHub +- search results should resolve to install-ready queries such as `owner/repo` and `appimagehub/` - 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 @@ -50,7 +51,7 @@ By default `aim` auto-detects whether to use user or system scope. Override that ## Current Flow Shape -- `aim ` installs unambiguous apps, shows live progress on stderr, prints an `Installation Summary` on stdout, and renders an `Installation Review` when tracking needs confirmation +- `aim ` installs direct provider matches when available, otherwise falls back to search results, shows live progress on stderr, prints an `Installation Summary` on stdout for installs, and renders an `Installation Review` when tracking needs confirmation - bare `aim` prints an `Update Review` without mutating the registry - `aim update` executes the pending updates, streams live status on stderr, then prints an `Update Summary` - `aim list` renders either `Installed Apps` or `No installed apps yet` diff --git a/crates/aim-cli/src/lib.rs b/crates/aim-cli/src/lib.rs index 0912e93..12e8747 100644 --- a/crates/aim-cli/src/lib.rs +++ b/crates/aim-cli/src/lib.rs @@ -109,7 +109,27 @@ pub fn dispatch_with_reporter( if let Some(query) = cli.query { let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root()); let transport = aim_core::source::github::default_transport(); - let mut plan = build_add_plan_with_reporter(&query, transport.as_ref(), reporter)?; + let plan_result = build_add_plan_with_reporter(&query, transport.as_ref(), reporter); + let mut plan = match plan_result { + Ok(plan) => plan, + Err( + aim_core::app::add::BuildAddPlanError::Query( + aim_core::app::query::ResolveQueryError::Unsupported, + ) + | aim_core::app::add::BuildAddPlanError::NoInstallableArtifact { .. }, + ) => { + 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()), + }); + return Ok(DispatchResult::Search(results)); + } + Err(error) => return Err(error.into()), + }; if !plan.interactions.is_empty() { match ui::prompt::resolve_add_plan_interactions(plan.clone())? { Some(resolved) => { diff --git a/crates/aim-cli/tests/end_to_end_cli.rs b/crates/aim-cli/tests/end_to_end_cli.rs index 8246b8e..ecb91ff 100644 --- a/crates/aim-cli/tests/end_to_end_cli.rs +++ b/crates/aim-cli/tests/end_to_end_cli.rs @@ -200,6 +200,75 @@ fn cli_add_installs_and_renders_resolved_mode() { .stdout(contains("Completed steps").not()); } +#[test] +fn positional_query_falls_back_to_search_for_plain_name_queries() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("firefox") + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Search Results")) + .stdout(contains( + "[appimagehub] Firefox by Mozilla - Official AppImage Edition", + )) + .stdout(contains("Install query: appimagehub/2338455")) + .stdout(contains("Installed firefox").not()) + .stdout(contains("unsupported source query").not()); + + assert!(!registry_path.exists()); +} + +#[test] +fn positional_query_falls_back_to_empty_search_when_direct_item_has_no_appimage() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("appimagehub/2337998") + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Search Results")) + .stdout(contains("No remote matches")) + .stdout(contains("No installed matches")) + .stdout(contains("unsupported source query").not()) + .stdout(contains("no installable artifact").not()); + + assert!(!registry_path.exists()); +} + +#[test] +fn cli_add_installs_appimagehub_source_with_truthful_origin() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("appimagehub/2338455") + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains( + "Installed Firefox by Mozilla - Official AppImage Edition (user)", + )) + .stdout(contains( + "Source: appimagehub https://www.appimagehub.com/p/2338455", + )) + .stdout(contains( + "Artifact: https://files06.pling.com/api/files/download/firefox-x86-64.AppImage", + )); + + let contents = std::fs::read_to_string(®istry_path).unwrap(); + assert!(contents.contains("display_name = \"Firefox by Mozilla - Official AppImage Edition\"")); + assert!(contents.contains("kind = \"AppImageHub\"")); + assert!(contents.contains("canonical_locator = \"2338455\"")); +} + #[test] fn cli_add_installs_gitlab_source_with_truthful_origin() { let dir = tempdir().unwrap(); @@ -329,9 +398,13 @@ fn cli_reports_unsupported_source_queries_distinctly() { cmd.arg("https://gitlab.com/example") .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") .assert() - .failure() - .stderr(contains("unsupported source query")); + .success() + .stdout(contains("Search Results")) + .stdout(contains("No remote matches")) + .stdout(contains("No installed matches")) + .stderr(contains("unsupported source query").not()); } #[test] @@ -342,10 +415,13 @@ fn cli_reports_supported_sources_without_installable_artifacts_distinctly() { cmd.arg("https://sourceforge.net/projects/team-app/") .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") .assert() - .failure() - .stderr(contains("no installable artifact found")) - .stderr(contains("sourceforge")); + .success() + .stdout(contains("Search Results")) + .stdout(contains("No remote matches")) + .stdout(contains("No installed matches")) + .stderr(contains("no installable artifact found").not()); } #[test] diff --git a/crates/aim-cli/tests/search_cli.rs b/crates/aim-cli/tests/search_cli.rs index a7a959c..8f0a1d2 100644 --- a/crates/aim-cli/tests/search_cli.rs +++ b/crates/aim-cli/tests/search_cli.rs @@ -151,3 +151,21 @@ fn search_command_keeps_empty_results_in_plain_text_mode() { .stdout(contains("Search Results")) .stdout(contains("No remote matches")); } + +#[test] +fn search_command_renders_appimagehub_results() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.args(["search", "firefox"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Search Results")) + .stdout(contains( + "[appimagehub] Firefox by Mozilla - Official AppImage Edition", + )) + .stdout(contains("Install query: appimagehub/2338455")); +} diff --git a/crates/aim-core/Cargo.toml b/crates/aim-core/Cargo.toml index 28bde64..3d8c857 100644 --- a/crates/aim-core/Cargo.toml +++ b/crates/aim-core/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] base64.workspace = true fs2.workspace = true +quick-xml.workspace = true reqwest.workspace = true serde.workspace = true serde_yaml.workspace = true diff --git a/crates/aim-core/src/adapters/appimagehub.rs b/crates/aim-core/src/adapters/appimagehub.rs new file mode 100644 index 0000000..cbc5a8d --- /dev/null +++ b/crates/aim-core/src/adapters/appimagehub.rs @@ -0,0 +1,89 @@ +use crate::adapters::traits::{ + AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter, +}; +use crate::app::query::resolve_query; +use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; +use crate::source::appimagehub::{ + AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with, +}; + +pub struct AppImageHubAdapter; + +impl AppImageHubAdapter { + pub fn resolve_source_with( + &self, + source: &SourceRef, + transport: &T, + ) -> Result { + if source.kind != SourceKind::AppImageHub { + return Err(AdapterError::UnsupportedSource); + } + + let resolved = resolve_appimagehub_item_with(source, transport) + .map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))?; + + match resolved { + Some(item) => Ok(AdapterResolveOutcome::Resolved(AdapterResolution { + source: item.source, + release: ResolvedRelease { + version: item.version, + prerelease: false, + }, + })), + None => Ok(AdapterResolveOutcome::NoInstallableArtifact { + source: source.clone(), + }), + } + } +} + +impl SourceAdapter for AppImageHubAdapter { + fn id(&self) -> &'static str { + "appimagehub" + } + + fn capabilities(&self) -> AdapterCapabilities { + AdapterCapabilities { + supports_search: true, + supports_exact_resolution: true, + } + } + + fn repository_source_kind(&self) -> Option { + Some(SourceKind::AppImageHub) + } + + fn normalize(&self, query: &str) -> Result { + let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?; + if source.kind != SourceKind::AppImageHub { + return Err(AdapterError::UnsupportedQuery); + } + + Ok(source) + } + + fn resolve(&self, source: &SourceRef) -> Result { + match resolve_appimagehub_item(source) + .map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))? + { + Some(item) => Ok(AdapterResolution { + source: item.source, + release: ResolvedRelease { + version: item.version, + prerelease: false, + }, + }), + None => Err(AdapterError::ResolutionFailed( + "appimagehub item has no installable AppImage artifact".to_owned(), + )), + } + } + + fn resolve_supported_source( + &self, + source: &SourceRef, + ) -> Result { + let transport = crate::source::appimagehub::default_transport(); + self.resolve_source_with(source, transport.as_ref()) + } +} diff --git a/crates/aim-core/src/adapters/custom_json.rs b/crates/aim-core/src/adapters/custom_json.rs deleted file mode 100644 index 0a5cbde..0000000 --- a/crates/aim-core/src/adapters/custom_json.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::adapters::traits::{ - AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter, -}; -use crate::domain::source::SourceRef; - -pub struct CustomJsonAdapter; - -impl SourceAdapter for CustomJsonAdapter { - fn id(&self) -> &'static str { - "custom-json" - } - - fn capabilities(&self) -> AdapterCapabilities { - AdapterCapabilities::exact_resolution_only() - } - - fn normalize(&self, _query: &str) -> Result { - Err(AdapterError::UnsupportedQuery) - } - - fn resolve(&self, _source: &SourceRef) -> Result { - Err(AdapterError::UnsupportedSource) - } -} diff --git a/crates/aim-core/src/adapters/mod.rs b/crates/aim-core/src/adapters/mod.rs index cc8106c..2d25232 100644 --- a/crates/aim-core/src/adapters/mod.rs +++ b/crates/aim-core/src/adapters/mod.rs @@ -1,4 +1,4 @@ -pub mod custom_json; +pub mod appimagehub; pub mod direct_url; pub mod github; pub mod gitlab; @@ -12,12 +12,12 @@ use crate::domain::source::SourceRef; pub fn all_adapter_kinds() -> Vec<&'static str> { vec![ + "appimagehub", "github", "gitlab", "direct-url", "zsync", "sourceforge", - "custom-json", ] } diff --git a/crates/aim-core/src/app/add.rs b/crates/aim-core/src/app/add.rs index 53fb3a3..99250bd 100644 --- a/crates/aim-core/src/app/add.rs +++ b/crates/aim-core/src/app/add.rs @@ -3,6 +3,7 @@ use std::fs::{self, File}; use std::io::Read; use std::path::{Path, PathBuf}; +use crate::adapters::appimagehub::AppImageHubAdapter; use crate::adapters::direct_url::DirectUrlAdapter; use crate::adapters::gitlab::GitLabAdapter; use crate::adapters::sourceforge::SourceForgeAdapter; @@ -24,6 +25,7 @@ use crate::integration::install::{ use crate::integration::policy::{IntegrationMode, resolve_install_policy}; use crate::metadata::parse_document; use crate::platform::probe_live_host; +use crate::source::appimagehub::resolve_appimagehub_item; use crate::source::github::{ GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy, }; @@ -59,6 +61,7 @@ pub fn build_add_plan_with_reporter( let mut interactions = Vec::new(); let mut parsed_metadata = Vec::new(); + let mut display_name_hint = None; let (resolution, selected_artifact, update_strategy) = match source.kind { SourceKind::GitHub => { reporter.report(&OperationEvent::StageChanged { @@ -156,6 +159,57 @@ pub fn build_add_plan_with_reporter( (resolution, artifact, strategy) } + SourceKind::AppImageHub => { + reporter.report(&OperationEvent::StageChanged { + stage: OperationStage::DiscoverRelease, + message: "discovering release".to_owned(), + }); + let adapter = AppImageHubAdapter; + let resolution = match adapter + .resolve_source(&source) + .map_err(|error| BuildAddPlanError::Adapter("appimagehub", error))? + { + AdapterResolveOutcome::Resolved(resolution) => resolution, + AdapterResolveOutcome::NoInstallableArtifact { source } => { + return Err(BuildAddPlanError::NoInstallableArtifact { source }); + } + }; + let resolved_item = resolve_appimagehub_item(&resolution.source) + .map_err(|error| { + BuildAddPlanError::Adapter( + "appimagehub", + crate::adapters::traits::AdapterError::ResolutionFailed(format!( + "{error:?}" + )), + ) + })? + .ok_or(BuildAddPlanError::NoInstallableArtifact { + source: resolution.source.clone(), + })?; + display_name_hint = Some(resolved_item.title.clone()); + + reporter.report(&OperationEvent::StageChanged { + stage: OperationStage::SelectArtifact, + message: "selecting artifact".to_owned(), + }); + let artifact = ArtifactCandidate { + url: resolved_item.download.url.clone(), + version: resolved_item.version.clone(), + arch: resolved_item.download.arch.clone(), + trusted_checksum: None, + selection_reason: "provider-release".to_owned(), + }; + let strategy = UpdateStrategy { + preferred: crate::domain::update::ChannelPreference { + kind: crate::domain::update::UpdateChannelKind::DirectAsset, + locator: resolved_item.download.url.clone(), + reason: "provider-release".to_owned(), + }, + alternates: Vec::new(), + }; + + (resolution, artifact, strategy) + } SourceKind::DirectUrl => { reporter.report(&OperationEvent::StageChanged { stage: OperationStage::SelectArtifact, @@ -266,6 +320,7 @@ pub fn build_add_plan_with_reporter( interactions, update_strategy, metadata: parsed_metadata, + display_name_hint, }) } @@ -299,6 +354,7 @@ pub struct AddPlan { pub interactions: Vec, pub update_strategy: UpdateStrategy, pub metadata: Vec, + pub display_name_hint: Option, } pub fn materialize_app_record( @@ -312,7 +368,7 @@ pub fn materialize_app_record( .as_deref() .unwrap_or(source_input); let identity = resolve_identity( - None, + plan.display_name_hint.as_deref(), None, Some(identity_source), IdentityFallback::AllowRawUrl, diff --git a/crates/aim-core/src/app/search.rs b/crates/aim-core/src/app/search.rs index f08509c..fd396ea 100644 --- a/crates/aim-core/src/app/search.rs +++ b/crates/aim-core/src/app/search.rs @@ -3,6 +3,9 @@ use crate::domain::search::{ InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults, SearchWarning, }; +use crate::source::appimagehub::{ + AppImageHubSearchError, AppImageHubTransport, search_appimagehub_with, +}; use crate::source::github::{ GitHubSearchError, GitHubTransport, TransportRelease, default_transport, search_github_repositories_with, @@ -37,9 +40,15 @@ 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]) + let github_transport = default_transport(); + let appimagehub_transport = crate::source::appimagehub::default_transport(); + let github_provider = GitHubSearchProvider::new(github_transport.as_ref()); + let appimagehub_provider = AppImageHubSearchProvider::new(appimagehub_transport.as_ref()); + build_search_results_with( + query, + installed_apps, + &[&github_provider, &appimagehub_provider], + ) } pub fn build_search_results_with( @@ -85,6 +94,58 @@ impl<'a, T: GitHubTransport + ?Sized> GitHubSearchProvider<'a, T> { } } +pub struct AppImageHubSearchProvider<'a, T: AppImageHubTransport + ?Sized> { + transport: &'a T, +} + +impl<'a, T: AppImageHubTransport + ?Sized> AppImageHubSearchProvider<'a, T> { + pub fn new(transport: &'a T) -> Self { + Self { transport } + } +} + +impl SearchProvider for AppImageHubSearchProvider<'_, T> { + fn search(&self, query: &SearchQuery) -> Result, SearchProviderError> { + let hits = search_appimagehub_with(&query.text, query.remote_limit, self.transport) + .map_err(|error| { + SearchProviderError::new("appimagehub", &render_appimagehub_search_error(&error)) + })?; + + let normalized_query = normalize_lookup(&query.text); + let mut ranked_hits = hits + .into_iter() + .enumerate() + .map(|(index, hit)| { + ( + appimagehub_remote_match_rank( + &normalized_query, + &hit.name, + hit.summary.as_deref(), + ), + index, + hit, + ) + }) + .collect::>(); + + ranked_hits.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1))); + + Ok(ranked_hits + .into_iter() + .map(|(_, _, hit)| SearchResult { + provider_id: "appimagehub".to_owned(), + display_name: hit.name, + description: hit.summary, + source_locator: hit.detail_page, + install_query: format!("appimagehub/{}", hit.id), + canonical_locator: hit.id, + version: Some(hit.version), + install_status: SearchInstallStatus::Available, + }) + .collect()) + } +} + impl SearchProvider for GitHubSearchProvider<'_, T> { fn search(&self, query: &SearchQuery) -> Result, SearchProviderError> { let name_only_query = format!("{} in:name", query.text); @@ -252,17 +313,28 @@ fn app_matches_remote_hit(app: &AppRecord, hit: &SearchResult) -> bool { } 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)); + if let Some(source) = &app.source { + match source.kind { + crate::domain::source::SourceKind::GitHub + | crate::domain::source::SourceKind::AppImageHub => { + if let Some(locator) = source.canonical_locator.as_deref() { + return Some(normalize_lookup(locator)); + } + return Some(normalize_lookup(&source.locator)); + } + _ => {} } - return Some(normalize_lookup(&source.locator)); } app.source_input.as_deref().and_then(|input| { if input.contains('/') && !input.contains("://") { + if let Some((provider, id)) = input.split_once('/') + && provider.eq_ignore_ascii_case("appimagehub") + && !id.is_empty() + { + return Some(normalize_lookup(id)); + } + Some(normalize_lookup(input)) } else { None @@ -320,3 +392,45 @@ fn render_github_search_error(error: &GitHubSearchError) -> String { GitHubSearchError::Transport(inner) => inner.to_string(), } } + +fn appimagehub_remote_match_rank(query: &str, name: &str, summary: Option<&str>) -> u8 { + let name = normalize_lookup(name); + let summary = summary.map(normalize_lookup); + + if name == query { + return 0; + } + + if name.starts_with(query) { + return 1; + } + + if name.contains(query) { + return 2; + } + + if summary + .as_deref() + .map(|summary| summary.starts_with(query)) + .unwrap_or(false) + { + return 3; + } + + if summary + .as_deref() + .map(|summary| summary.contains(query)) + .unwrap_or(false) + { + return 4; + } + + 5 +} + +fn render_appimagehub_search_error(error: &AppImageHubSearchError) -> String { + match error { + AppImageHubSearchError::Parse(inner) => inner.to_string(), + AppImageHubSearchError::Transport(inner) => inner.to_string(), + } +} diff --git a/crates/aim-core/src/app/update.rs b/crates/aim-core/src/app/update.rs index d716b29..f793182 100644 --- a/crates/aim-core/src/app/update.rs +++ b/crates/aim-core/src/app/update.rs @@ -148,9 +148,11 @@ fn fallback_channel_preference(app: &AppRecord) -> ChannelPreference { .clone() .unwrap_or_else(|| source.locator.clone()), ), - SourceKind::GitLab | SourceKind::SourceForge | SourceKind::DirectUrl | SourceKind::File => { - (UpdateChannelKind::DirectAsset, source.locator.clone()) - } + SourceKind::GitLab + | SourceKind::AppImageHub + | SourceKind::SourceForge + | SourceKind::DirectUrl + | SourceKind::File => (UpdateChannelKind::DirectAsset, source.locator.clone()), }; ChannelPreference { diff --git a/crates/aim-core/src/domain/source.rs b/crates/aim-core/src/domain/source.rs index 93db015..889d463 100644 --- a/crates/aim-core/src/domain/source.rs +++ b/crates/aim-core/src/domain/source.rs @@ -2,6 +2,7 @@ pub enum SourceKind { GitHub, GitLab, + AppImageHub, SourceForge, DirectUrl, File, @@ -12,6 +13,7 @@ impl SourceKind { match self { Self::GitHub => "github", Self::GitLab => "gitlab", + Self::AppImageHub => "appimagehub", Self::SourceForge => "sourceforge", Self::DirectUrl => "direct-url", Self::File => "file", @@ -26,6 +28,8 @@ pub enum SourceInputKind { GitHubReleaseUrl, GitHubReleaseAssetUrl, GitLabUrl, + AppImageHubUrl, + AppImageHubShorthand, SourceForgeUrl, DirectUrl, File, @@ -39,6 +43,8 @@ impl SourceInputKind { Self::GitHubReleaseUrl => "github-release-url", Self::GitHubReleaseAssetUrl => "github-release-asset-url", Self::GitLabUrl => "gitlab-url", + Self::AppImageHubUrl => "appimagehub-url", + Self::AppImageHubShorthand => "appimagehub-shorthand", Self::SourceForgeUrl => "sourceforge-url", Self::DirectUrl => "direct-url", Self::File => "file", @@ -53,6 +59,7 @@ pub enum NormalizedSourceKind { GitHubReleaseAsset, GitLab, GitLabCandidate, + AppImageHub, SourceForge, SourceForgeCandidate, DirectUrl, @@ -67,6 +74,7 @@ impl NormalizedSourceKind { Self::GitHubReleaseAsset => "github-release-asset", Self::GitLab => "gitlab", Self::GitLabCandidate => "gitlab-candidate", + Self::AppImageHub => "appimagehub", Self::SourceForge => "sourceforge", Self::SourceForgeCandidate => "sourceforge-candidate", Self::DirectUrl => "direct-url", diff --git a/crates/aim-core/src/source/appimagehub.rs b/crates/aim-core/src/source/appimagehub.rs new file mode 100644 index 0000000..c614698 --- /dev/null +++ b/crates/aim-core/src/source/appimagehub.rs @@ -0,0 +1,491 @@ +use std::env; +use std::time::Duration; + +use crate::domain::source::SourceRef; + +const DEFAULT_APPIMAGEHUB_API_BASE: &str = "https://api.appimagehub.com/ocs/v1/content"; +const FIXTURE_MODE_ENV: &str = "AIM_APPIMAGEHUB_FIXTURE_MODE"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppImageHubDownload { + pub url: String, + pub name: String, + pub package_type: Option, + pub arch: Option, + pub md5sum: Option, + pub version: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppImageHubItem { + pub id: String, + pub name: String, + pub version: String, + pub summary: Option, + pub detail_page: String, + pub tags: Vec, + pub downloads: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppImageHubSearchHit { + pub id: String, + pub name: String, + pub version: String, + pub summary: Option, + pub detail_page: String, + pub tags: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedAppImageHubItem { + pub source: SourceRef, + pub title: String, + pub version: String, + pub download: AppImageHubDownload, +} + +pub trait AppImageHubTransport { + fn fetch_item(&self, id: &str) -> Result; + + fn search_items( + &self, + query: &str, + limit: usize, + ) -> Result, AppImageHubSearchError>; +} + +pub fn default_transport() -> Box { + if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") + || env::var("AIM_GITHUB_FIXTURE_MODE").ok().as_deref() == Some("1") + { + Box::new(FixtureAppImageHubTransport) + } else { + Box::new(ReqwestAppImageHubTransport::new()) + } +} + +pub fn resolve_appimagehub_item( + source: &SourceRef, +) -> Result, AppImageHubError> { + let transport = default_transport(); + resolve_appimagehub_item_with(source, transport.as_ref()) +} + +pub fn resolve_appimagehub_item_with( + source: &SourceRef, + transport: &T, +) -> Result, AppImageHubError> { + let item = transport.fetch_item(source_id(source)?)?; + let Some(download) = item + .downloads + .iter() + .find(|download| is_appimage_download(download)) + else { + return Ok(None); + }; + + Ok(Some(ResolvedAppImageHubItem { + source: source.clone(), + title: item.name.clone(), + version: resolved_version(&item, download), + download: download.clone(), + })) +} + +pub fn search_appimagehub( + query: &str, + limit: usize, +) -> Result, AppImageHubSearchError> { + let transport = default_transport(); + search_appimagehub_with(query, limit, transport.as_ref()) +} + +pub fn search_appimagehub_with( + query: &str, + limit: usize, + transport: &T, +) -> Result, AppImageHubSearchError> { + transport.search_items(query, limit) +} + +pub struct ReqwestAppImageHubTransport { + client: reqwest::blocking::Client, + api_base: String, +} + +impl Default for ReqwestAppImageHubTransport { + fn default() -> Self { + Self::new() + } +} + +impl ReqwestAppImageHubTransport { + pub fn new() -> Self { + Self { + client: reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("reqwest client should build"), + api_base: env::var("AIM_APPIMAGEHUB_API_BASE") + .unwrap_or_else(|_| DEFAULT_APPIMAGEHUB_API_BASE.to_owned()), + } + } +} + +impl AppImageHubTransport for ReqwestAppImageHubTransport { + fn fetch_item(&self, id: &str) -> Result { + let url = format!("{}/data/{id}", self.api_base); + let xml = self + .client + .get(url) + .send() + .map_err(AppImageHubError::Transport)? + .error_for_status() + .map_err(AppImageHubError::Transport)? + .text() + .map_err(AppImageHubError::Transport)?; + + parse_item_xml(&xml) + } + + fn search_items( + &self, + query: &str, + limit: usize, + ) -> Result, AppImageHubSearchError> { + let url = format!("{}/data", self.api_base); + let xml = self + .client + .get(url) + .query(&[("search", query), ("pagesize", &limit.to_string())]) + .send() + .map_err(AppImageHubSearchError::Transport)? + .error_for_status() + .map_err(AppImageHubSearchError::Transport)? + .text() + .map_err(AppImageHubSearchError::Transport)?; + + parse_search_xml(&xml) + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct FixtureAppImageHubTransport; + +impl AppImageHubTransport for FixtureAppImageHubTransport { + fn fetch_item(&self, id: &str) -> Result { + fixture_item(id).ok_or_else(|| AppImageHubError::FixtureItemMissing(id.to_owned())) + } + + fn search_items( + &self, + query: &str, + limit: usize, + ) -> Result, AppImageHubSearchError> { + Ok(fixture_search_results(query, limit)) + } +} + +#[derive(Debug)] +pub enum AppImageHubError { + FixtureItemMissing(String), + Parse(quick_xml::DeError), + Transport(reqwest::Error), + UnsupportedSource(String), +} + +#[derive(Debug)] +pub enum AppImageHubSearchError { + Parse(quick_xml::DeError), + Transport(reqwest::Error), +} + +#[derive(serde::Deserialize)] +struct OcsSingleResponse { + data: OcsSingleData, +} + +#[derive(serde::Deserialize)] +struct OcsSingleData { + content: OcsContent, +} + +#[derive(serde::Deserialize)] +struct OcsSearchResponse { + data: OcsSearchData, +} + +#[derive(serde::Deserialize)] +struct OcsSearchData { + #[serde(default)] + content: Vec, +} + +#[derive(serde::Deserialize)] +struct OcsContent { + id: String, + name: String, + version: Option, + summary: Option, + detailpage: Option, + tags: Option, + downloadlink1: Option, + downloadname1: Option, + download_package_type1: Option, + download_package_arch1: Option, + downloadmd5sum1: Option, + download_version1: Option, + downloadlink2: Option, + downloadname2: Option, + download_package_type2: Option, + download_package_arch2: Option, + downloadmd5sum2: Option, + download_version2: Option, + downloadlink3: Option, + downloadname3: Option, + download_package_type3: Option, + download_package_arch3: Option, + downloadmd5sum3: Option, + download_version3: Option, +} + +fn parse_item_xml(xml: &str) -> Result { + let parsed = + quick_xml::de::from_str::(xml).map_err(AppImageHubError::Parse)?; + Ok(content_to_item(parsed.data.content)) +} + +fn parse_search_xml(xml: &str) -> Result, AppImageHubSearchError> { + if !xml.contains("") { + return Ok(Vec::new()); + } + + let parsed = + quick_xml::de::from_str::(xml).map_err(AppImageHubSearchError::Parse)?; + Ok(parsed + .data + .content + .into_iter() + .map(|content| AppImageHubSearchHit { + id: content.id, + name: content.name, + version: normalize_version_text(content.version.as_deref()), + summary: content.summary, + detail_page: content + .detailpage + .unwrap_or_else(|| "https://www.appimagehub.com".to_owned()), + tags: split_tags(content.tags.as_deref()), + }) + .collect()) +} + +fn content_to_item(content: OcsContent) -> AppImageHubItem { + let detail_page = content + .detailpage + .clone() + .unwrap_or_else(|| "https://www.appimagehub.com".to_owned()); + let summary = content.summary.clone(); + let tags = split_tags(content.tags.as_deref()); + let downloads = collect_downloads(&content); + + AppImageHubItem { + id: content.id, + name: content.name, + version: normalize_version_text(content.version.as_deref()), + summary, + detail_page, + tags, + downloads, + } +} + +fn collect_downloads(content: &OcsContent) -> Vec { + let mut downloads = Vec::new(); + + for download in [ + download_slot( + content.downloadlink1.as_deref(), + content.downloadname1.as_deref(), + content.download_package_type1.as_deref(), + content.download_package_arch1.as_deref(), + content.downloadmd5sum1.as_deref(), + content.download_version1.as_deref(), + ), + download_slot( + content.downloadlink2.as_deref(), + content.downloadname2.as_deref(), + content.download_package_type2.as_deref(), + content.download_package_arch2.as_deref(), + content.downloadmd5sum2.as_deref(), + content.download_version2.as_deref(), + ), + download_slot( + content.downloadlink3.as_deref(), + content.downloadname3.as_deref(), + content.download_package_type3.as_deref(), + content.download_package_arch3.as_deref(), + content.downloadmd5sum3.as_deref(), + content.download_version3.as_deref(), + ), + ] + .into_iter() + .flatten() + { + downloads.push(download); + } + + downloads +} + +fn download_slot( + link: Option<&str>, + name: Option<&str>, + package_type: Option<&str>, + arch: Option<&str>, + md5sum: Option<&str>, + version: Option<&str>, +) -> Option { + let url = link?.trim(); + if url.is_empty() { + return None; + } + + Some(AppImageHubDownload { + url: url.to_owned(), + name: name.unwrap_or("download").trim().to_owned(), + package_type: trim_optional(package_type), + arch: trim_optional(arch), + md5sum: trim_optional(md5sum), + version: trim_optional(version), + }) +} + +fn trim_optional(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn normalize_version_text(value: Option<&str>) -> String { + let value = value.map(str::trim).filter(|value| !value.is_empty()); + match value { + Some("Latest") | Some("latest") | None => "latest".to_owned(), + Some(other) => other.to_owned(), + } +} + +fn split_tags(tags: Option<&str>) -> Vec { + tags.unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn source_id(source: &SourceRef) -> Result<&str, AppImageHubError> { + source + .canonical_locator + .as_deref() + .or_else(|| source.locator.rsplit('/').next()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| AppImageHubError::UnsupportedSource(source.locator.clone())) +} + +fn is_appimage_download(download: &AppImageHubDownload) -> bool { + download + .package_type + .as_deref() + .map(|kind| kind.eq_ignore_ascii_case("appimage")) + .unwrap_or(false) + || download.name.ends_with(".AppImage") +} + +fn resolved_version(item: &AppImageHubItem, download: &AppImageHubDownload) -> String { + download + .version + .as_deref() + .map(|value| normalize_version_text(Some(value))) + .filter(|value| value != "latest") + .unwrap_or_else(|| item.version.clone()) +} + +fn fixture_item(id: &str) -> Option { + match id { + "2338455" => Some(AppImageHubItem { + id: "2338455".to_owned(), + name: "Firefox by Mozilla - Official AppImage Edition".to_owned(), + version: "latest".to_owned(), + summary: Some("Take control of your internet with the Firefox browser".to_owned()), + detail_page: "https://www.appimagehub.com/p/2338455".to_owned(), + tags: vec![ + "appimage".to_owned(), + "x86-64".to_owned(), + "desktop".to_owned(), + "release-stable".to_owned(), + ], + downloads: vec![AppImageHubDownload { + url: "https://files06.pling.com/api/files/download/firefox-x86-64.AppImage" + .to_owned(), + name: "firefox-x86-64.AppImage".to_owned(), + package_type: Some("appimage".to_owned()), + arch: Some("x86-64".to_owned()), + md5sum: Some("1befdc026535be03a6001f33b11ef91d".to_owned()), + version: None, + }], + }), + "2337998" => Some(AppImageHubItem { + id: "2337998".to_owned(), + name: "Example Non-AppImage Package".to_owned(), + version: "latest".to_owned(), + summary: Some("An item that does not expose an AppImage download".to_owned()), + detail_page: "https://www.appimagehub.com/p/2337998".to_owned(), + tags: vec!["desktop".to_owned()], + downloads: vec![AppImageHubDownload { + url: "https://files06.pling.com/api/files/download/example.deb".to_owned(), + name: "example.deb".to_owned(), + package_type: Some("debian-package".to_owned()), + arch: Some("x86-64".to_owned()), + md5sum: None, + version: Some("2.1.1".to_owned()), + }], + }), + _ => None, + } +} + +fn fixture_search_results(query: &str, limit: usize) -> Vec { + let query = query.trim().to_ascii_lowercase(); + let fixtures = [ + AppImageHubSearchHit { + id: "2338455".to_owned(), + name: "Firefox by Mozilla - Official AppImage Edition".to_owned(), + version: "latest".to_owned(), + summary: Some("Take control of your internet with the Firefox browser".to_owned()), + detail_page: "https://www.appimagehub.com/p/2338455".to_owned(), + tags: vec!["browser".to_owned(), "appimage".to_owned()], + }, + AppImageHubSearchHit { + id: "2338484".to_owned(), + name: "Waterfox".to_owned(), + version: "latest".to_owned(), + summary: Some("Open Source, Private Browsing".to_owned()), + detail_page: "https://www.appimagehub.com/p/2338484".to_owned(), + tags: vec!["browser".to_owned(), "appimage".to_owned()], + }, + ]; + + fixtures + .into_iter() + .filter(|item| { + item.name.to_ascii_lowercase().contains(&query) + || item + .tags + .iter() + .any(|tag| tag.to_ascii_lowercase().contains(&query)) + }) + .take(limit) + .collect() +} diff --git a/crates/aim-core/src/source/input.rs b/crates/aim-core/src/source/input.rs index fd19682..5d1d362 100644 --- a/crates/aim-core/src/source/input.rs +++ b/crates/aim-core/src/source/input.rs @@ -49,6 +49,10 @@ pub fn classify_input(query: &str) -> Result Option> { + if let Some(id) = appimagehub_id_from_url(query) { + return Some(Ok(appimagehub_source_ref( + SourceInputKind::AppImageHubUrl, + id, + ))); + } + + let id = query.strip_prefix("appimagehub/")?; + + if !is_ascii_digits(id) { + return Some(Err(ClassifyInputError::Unsupported)); + } + + Some(Ok(appimagehub_source_ref( + SourceInputKind::AppImageHubShorthand, + id, + ))) +} + fn classify_gitlab_http(query: &str) -> Option> { let trimmed = query .trim_start_matches("https://gitlab.com/") @@ -224,10 +248,39 @@ fn classify_sourceforge_http(query: &str) -> Option Option<&str> { + let trimmed = query + .trim_start_matches("https://www.appimagehub.com/p/") + .trim_start_matches("http://www.appimagehub.com/p/"); + if trimmed == query { + return None; + } + + let id = trim_query_and_fragment(trimmed).trim_matches('/'); + if is_ascii_digits(id) { Some(id) } else { None } +} + +fn appimagehub_source_ref(kind: SourceInputKind, id: &str) -> ClassifiedInput { + ClassifiedInput { + kind, + source_kind: SourceKind::AppImageHub, + normalized_kind: NormalizedSourceKind::AppImageHub, + locator: format!("https://www.appimagehub.com/p/{id}"), + canonical_locator: Some(id.to_owned()), + requested_tag: None, + requested_asset_name: None, + tracks_latest: true, + } +} + fn trim_query_and_fragment(value: &str) -> &str { value.split(['?', '#']).next().unwrap_or(value) } +fn is_ascii_digits(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + fn is_supported_gitlab_repo_path(parts: &[&str]) -> bool { if parts.len() < 2 { return false; diff --git a/crates/aim-core/src/source/mod.rs b/crates/aim-core/src/source/mod.rs index 72aea24..0b41ca4 100644 --- a/crates/aim-core/src/source/mod.rs +++ b/crates/aim-core/src/source/mod.rs @@ -1,2 +1,3 @@ +pub mod appimagehub; pub mod github; pub mod input; diff --git a/crates/aim-core/tests/adapter_contract.rs b/crates/aim-core/tests/adapter_contract.rs index 41eb908..f1f3e14 100644 --- a/crates/aim-core/tests/adapter_contract.rs +++ b/crates/aim-core/tests/adapter_contract.rs @@ -1,3 +1,4 @@ +use aim_core::adapters::appimagehub::AppImageHubAdapter; use aim_core::adapters::direct_url::DirectUrlAdapter; use aim_core::adapters::github::GitHubAdapter; use aim_core::adapters::gitlab::GitLabAdapter; @@ -9,6 +10,7 @@ use aim_core::app::query::resolve_query; use aim_core::domain::source::{ NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef, }; +use aim_core::source::appimagehub::FixtureAppImageHubTransport; struct FileArtifactAdapter; @@ -59,6 +61,60 @@ fn adapter_capabilities_can_report_exact_resolution_only() { assert!(!capabilities.supports_search); } +#[test] +fn appimagehub_adapter_reports_search_and_exact_resolution_capabilities() { + let adapter = AppImageHubAdapter; + + assert_eq!(adapter.id(), "appimagehub"); + assert_eq!( + adapter.repository_source_kind(), + Some(SourceKind::AppImageHub) + ); + assert_eq!(adapter.exact_source_kind(), None); + assert_eq!( + adapter.capabilities(), + AdapterCapabilities { + supports_search: true, + supports_exact_resolution: true, + } + ); +} + +#[test] +fn appimagehub_adapter_resolves_installable_items_through_fixture_transport() { + let adapter = AppImageHubAdapter; + let source = resolve_query("appimagehub/2338455").unwrap(); + + let resolution = adapter + .resolve_source_with(&source, &FixtureAppImageHubTransport) + .unwrap(); + + assert!(matches!( + resolution, + AdapterResolveOutcome::Resolved(AdapterResolution { + source, + release: ResolvedRelease { version, .. }, + }) if source.kind == SourceKind::AppImageHub + && source.canonical_locator.as_deref() == Some("2338455") + && version == "latest" + )); +} + +#[test] +fn appimagehub_adapter_reports_no_installable_artifact_for_non_appimage_items() { + let adapter = AppImageHubAdapter; + let source = resolve_query("appimagehub/2337998").unwrap(); + + let resolution = adapter + .resolve_source_with(&source, &FixtureAppImageHubTransport) + .unwrap(); + + assert_eq!( + resolution, + AdapterResolveOutcome::NoInstallableArtifact { source } + ); +} + #[test] fn repository_backed_resolvers_accept_only_their_own_source_kind() { let github_source = resolve_query("sharkdp/bat").unwrap(); diff --git a/crates/aim-core/tests/adapter_smoke.rs b/crates/aim-core/tests/adapter_smoke.rs index 1c7ccff..90dad47 100644 --- a/crates/aim-core/tests/adapter_smoke.rs +++ b/crates/aim-core/tests/adapter_smoke.rs @@ -4,10 +4,11 @@ use aim_core::adapters::all_adapter_kinds; fn all_expected_adapter_kinds_are_registered() { let kinds = all_adapter_kinds(); + assert!(kinds.contains(&"appimagehub")); assert!(kinds.contains(&"github")); assert!(kinds.contains(&"gitlab")); assert!(kinds.contains(&"direct-url")); assert!(kinds.contains(&"zsync")); assert!(kinds.contains(&"sourceforge")); - assert!(kinds.contains(&"custom-json")); + assert!(!kinds.contains(&"custom-json")); } diff --git a/crates/aim-core/tests/appimagehub_search.rs b/crates/aim-core/tests/appimagehub_search.rs new file mode 100644 index 0000000..7168e0d --- /dev/null +++ b/crates/aim-core/tests/appimagehub_search.rs @@ -0,0 +1,108 @@ +use aim_core::app::search::{ + AppImageHubSearchProvider, GitHubSearchProvider, SearchProvider, SearchProviderError, + build_search_results_with, +}; +use aim_core::domain::app::AppRecord; +use aim_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult}; +use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef}; +use aim_core::source::appimagehub::FixtureAppImageHubTransport; +use aim_core::source::github::FixtureGitHubTransport; + +struct StubProvider { + hit: SearchResult, +} + +impl SearchProvider for StubProvider { + fn search(&self, _query: &SearchQuery) -> Result, SearchProviderError> { + Ok(vec![self.hit.clone()]) + } +} + +#[test] +fn appimagehub_search_provider_maps_hits_to_install_ready_results() { + let provider = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport); + + let results = provider.search(&SearchQuery::new("firefox")).unwrap(); + + assert!(results.iter().any(|hit| { + hit.provider_id == "appimagehub" + && hit.display_name == "Firefox by Mozilla - Official AppImage Edition" + && hit.install_query == "appimagehub/2338455" + && hit.canonical_locator == "2338455" + })); +} + +#[test] +fn appimagehub_hits_are_annotated_as_installed_by_canonical_id() { + let provider = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport); + let installed = vec![AppRecord { + stable_id: "firefox".to_owned(), + display_name: "Firefox by Mozilla - Official AppImage Edition".to_owned(), + source_input: Some("appimagehub/2338455".to_owned()), + source: Some(SourceRef { + kind: SourceKind::AppImageHub, + locator: "https://www.appimagehub.com/p/2338455".to_owned(), + input_kind: SourceInputKind::AppImageHubShorthand, + normalized_kind: NormalizedSourceKind::AppImageHub, + canonical_locator: Some("2338455".to_owned()), + requested_tag: None, + requested_asset_name: None, + tracks_latest: true, + }), + installed_version: Some("latest".to_owned()), + update_strategy: None, + metadata: Vec::new(), + install: None, + }]; + + let results = + build_search_results_with(&SearchQuery::new("firefox"), &installed, &[&provider]).unwrap(); + + assert!(results.remote_hits.iter().any(|hit| { + hit.canonical_locator == "2338455" + && matches!( + hit.install_status, + SearchInstallStatus::Installed { + installed_version: Some(ref version) + } if version == "latest" + ) + })); +} + +#[test] +fn search_can_merge_github_and_appimagehub_providers() { + let github = GitHubSearchProvider::new(&FixtureGitHubTransport); + let appimagehub = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport); + let stub = StubProvider { + hit: SearchResult { + provider_id: "github".to_owned(), + display_name: "firefox-tooling/firestarter".to_owned(), + description: Some("Stub GitHub result".to_owned()), + source_locator: "https://github.com/firefox-tooling/firestarter".to_owned(), + install_query: "firefox-tooling/firestarter".to_owned(), + canonical_locator: "firefox-tooling/firestarter".to_owned(), + version: Some("1.0.0".to_owned()), + install_status: SearchInstallStatus::Available, + }, + }; + + let results = build_search_results_with( + &SearchQuery::new("firefox"), + &[], + &[&stub, &github, &appimagehub], + ) + .unwrap(); + + assert!( + results + .remote_hits + .iter() + .any(|hit| hit.provider_id == "github") + ); + assert!( + results + .remote_hits + .iter() + .any(|hit| hit.provider_id == "appimagehub") + ); +} diff --git a/crates/aim-core/tests/query_resolution.rs b/crates/aim-core/tests/query_resolution.rs index d92c476..4a24583 100644 --- a/crates/aim-core/tests/query_resolution.rs +++ b/crates/aim-core/tests/query_resolution.rs @@ -26,6 +26,29 @@ fn classifies_github_release_asset_url() { ); } +#[test] +fn classifies_appimagehub_item_url() { + let source = resolve_query("https://www.appimagehub.com/p/2338455").unwrap(); + + assert_eq!(source.kind, SourceKind::AppImageHub); + assert_eq!(source.input_kind, SourceInputKind::AppImageHubUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::AppImageHub); + assert_eq!(source.canonical_locator.as_deref(), Some("2338455")); + assert!(source.tracks_latest); +} + +#[test] +fn classifies_appimagehub_id_shorthand() { + let source = resolve_query("appimagehub/2338455").unwrap(); + + assert_eq!(source.kind, SourceKind::AppImageHub); + assert_eq!(source.input_kind, SourceInputKind::AppImageHubShorthand); + assert_eq!(source.normalized_kind, NormalizedSourceKind::AppImageHub); + assert_eq!(source.locator, "https://www.appimagehub.com/p/2338455"); + assert_eq!(source.canonical_locator.as_deref(), Some("2338455")); + assert!(source.tracks_latest); +} + #[test] fn classifies_gitlab_repository_url() { let source = resolve_query("https://gitlab.com/example/team-app").unwrap(); @@ -278,6 +301,13 @@ fn rejects_malformed_sourceforge_url() { assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); } +#[test] +fn rejects_malformed_appimagehub_shorthand() { + let error = resolve_query("appimagehub/firefox").unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + #[test] fn rejects_unsupported_sourceforge_url_shape() { let error = resolve_query("https://sourceforge.net/projects/team-app/rss").unwrap_err();