feat: add AppImageHub provider support

This commit is contained in:
stoorps 2026-03-21 20:00:23 +00:00
parent 1ad2f8a532
commit f8ffb95376
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
23 changed files with 1636 additions and 50 deletions

View file

@ -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 <query>` 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/<id>` shorthand.
- Preserve strict direct resolution semantics for provider-specific inputs.
- Make positional `aim <query>` 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/<id>` and `appimagehub/<id>`. Plain names are handled by the cross-provider search path, which can return install-ready `appimagehub/<id>` 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 <query>` 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/<id>`
- `http://www.appimagehub.com/p/<id>`
- `appimagehub/<id>`
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/<id>`
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 <query>` 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 <query>` treats those outcomes as search-fallback triggers
- explicit `aim search <query>` 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 = <item title>`
- `source_locator = <item page url>`
- `install_query = appimagehub/<id>`
- `canonical_locator = <id>`
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/<id>` 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.

View file

@ -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 <query>` 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/<id>` 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/<id>` 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/<id>`
- 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 <query>`
- 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"
```

11
Cargo.lock generated
View file

@ -36,6 +36,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64",
"fs2", "fs2",
"quick-xml",
"reqwest", "reqwest",
"serde", "serde",
"serde_yaml", "serde_yaml",
@ -1151,6 +1152,16 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"

View file

@ -25,6 +25,7 @@ indicatif = "0.17.11"
libc = "0.2.171" libc = "0.2.171"
ratatui = "0.29.0" ratatui = "0.29.0"
reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls"] } 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 = { version = "1.0.219", features = ["derive"] }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
sha2 = "0.10.8" sha2 = "0.10.8"

View file

@ -24,9 +24,11 @@ aim remove <QUERY>
## Query Forms ## Query Forms
- `owner/repo` for GitHub shorthand - `owner/repo` for GitHub shorthand
- `appimagehub/<id>` for AppImageHub shorthand
- GitHub repository URLs - GitHub repository URLs
- GitHub release URLs - GitHub release URLs
- direct GitHub release asset URLs - direct GitHub release asset URLs
- AppImageHub item URLs such as `https://www.appimagehub.com/p/2338455`
- `https://...` direct URLs - `https://...` direct URLs
- GitLab URLs - GitLab URLs
- SourceForge URLs - SourceForge URLs
@ -36,10 +38,9 @@ aim remove <QUERY>
`aim search <QUERY>` is part of v0.9 finalisation. `aim search <QUERY>` is part of v0.9 finalisation.
- v0.9 search is GitHub-backed first - search is provider-extensible and currently includes GitHub plus AppImageHub
- search results should resolve to install-ready GitHub shorthand such as `owner/repo` - search results should resolve to install-ready queries such as `owner/repo` and `appimagehub/<id>`
- the search model is provider-extensible for future phases - 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 ## Scope Overrides
@ -50,7 +51,7 @@ By default `aim` auto-detects whether to use user or system scope. Override that
## Current Flow Shape ## Current Flow Shape
- `aim <QUERY>` installs unambiguous apps, shows live progress on stderr, prints an `Installation Summary` on stdout, and renders an `Installation Review` when tracking needs confirmation - `aim <QUERY>` 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 - 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 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` - `aim list` renders either `Installed Apps` or `No installed apps yet`

View file

@ -109,7 +109,27 @@ pub fn dispatch_with_reporter(
if let Some(query) = cli.query { if let Some(query) = cli.query {
let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root()); let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root());
let transport = aim_core::source::github::default_transport(); 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() { if !plan.interactions.is_empty() {
match ui::prompt::resolve_add_plan_interactions(plan.clone())? { match ui::prompt::resolve_add_plan_interactions(plan.clone())? {
Some(resolved) => { Some(resolved) => {

View file

@ -200,6 +200,75 @@ fn cli_add_installs_and_renders_resolved_mode() {
.stdout(contains("Completed steps").not()); .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", &registry_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", &registry_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", &registry_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(&registry_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] #[test]
fn cli_add_installs_gitlab_source_with_truthful_origin() { fn cli_add_installs_gitlab_source_with_truthful_origin() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
@ -329,9 +398,13 @@ fn cli_reports_unsupported_source_queries_distinctly() {
cmd.arg("https://gitlab.com/example") cmd.arg("https://gitlab.com/example")
.env("AIM_REGISTRY_PATH", &registry_path) .env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert() .assert()
.failure() .success()
.stderr(contains("unsupported source query")); .stdout(contains("Search Results"))
.stdout(contains("No remote matches"))
.stdout(contains("No installed matches"))
.stderr(contains("unsupported source query").not());
} }
#[test] #[test]
@ -342,10 +415,13 @@ fn cli_reports_supported_sources_without_installable_artifacts_distinctly() {
cmd.arg("https://sourceforge.net/projects/team-app/") cmd.arg("https://sourceforge.net/projects/team-app/")
.env("AIM_REGISTRY_PATH", &registry_path) .env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert() .assert()
.failure() .success()
.stderr(contains("no installable artifact found")) .stdout(contains("Search Results"))
.stderr(contains("sourceforge")); .stdout(contains("No remote matches"))
.stdout(contains("No installed matches"))
.stderr(contains("no installable artifact found").not());
} }
#[test] #[test]

View file

@ -151,3 +151,21 @@ fn search_command_keeps_empty_results_in_plain_text_mode() {
.stdout(contains("Search Results")) .stdout(contains("Search Results"))
.stdout(contains("No remote matches")); .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", &registry_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"));
}

View file

@ -10,6 +10,7 @@ path = "src/lib.rs"
[dependencies] [dependencies]
base64.workspace = true base64.workspace = true
fs2.workspace = true fs2.workspace = true
quick-xml.workspace = true
reqwest.workspace = true reqwest.workspace = true
serde.workspace = true serde.workspace = true
serde_yaml.workspace = true serde_yaml.workspace = true

View file

@ -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<T: AppImageHubTransport + ?Sized>(
&self,
source: &SourceRef,
transport: &T,
) -> Result<AdapterResolveOutcome, AdapterError> {
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<SourceKind> {
Some(SourceKind::AppImageHub)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
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<AdapterResolution, AdapterError> {
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<AdapterResolveOutcome, AdapterError> {
let transport = crate::source::appimagehub::default_transport();
self.resolve_source_with(source, transport.as_ref())
}
}

View file

@ -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<SourceRef, AdapterError> {
Err(AdapterError::UnsupportedQuery)
}
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
Err(AdapterError::UnsupportedSource)
}
}

View file

@ -1,4 +1,4 @@
pub mod custom_json; pub mod appimagehub;
pub mod direct_url; pub mod direct_url;
pub mod github; pub mod github;
pub mod gitlab; pub mod gitlab;
@ -12,12 +12,12 @@ use crate::domain::source::SourceRef;
pub fn all_adapter_kinds() -> Vec<&'static str> { pub fn all_adapter_kinds() -> Vec<&'static str> {
vec![ vec![
"appimagehub",
"github", "github",
"gitlab", "gitlab",
"direct-url", "direct-url",
"zsync", "zsync",
"sourceforge", "sourceforge",
"custom-json",
] ]
} }

View file

@ -3,6 +3,7 @@ use std::fs::{self, File};
use std::io::Read; use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::adapters::appimagehub::AppImageHubAdapter;
use crate::adapters::direct_url::DirectUrlAdapter; use crate::adapters::direct_url::DirectUrlAdapter;
use crate::adapters::gitlab::GitLabAdapter; use crate::adapters::gitlab::GitLabAdapter;
use crate::adapters::sourceforge::SourceForgeAdapter; use crate::adapters::sourceforge::SourceForgeAdapter;
@ -24,6 +25,7 @@ use crate::integration::install::{
use crate::integration::policy::{IntegrationMode, resolve_install_policy}; use crate::integration::policy::{IntegrationMode, resolve_install_policy};
use crate::metadata::parse_document; use crate::metadata::parse_document;
use crate::platform::probe_live_host; use crate::platform::probe_live_host;
use crate::source::appimagehub::resolve_appimagehub_item;
use crate::source::github::{ use crate::source::github::{
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy, GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy,
}; };
@ -59,6 +61,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
let mut interactions = Vec::new(); let mut interactions = Vec::new();
let mut parsed_metadata = Vec::new(); let mut parsed_metadata = Vec::new();
let mut display_name_hint = None;
let (resolution, selected_artifact, update_strategy) = match source.kind { let (resolution, selected_artifact, update_strategy) = match source.kind {
SourceKind::GitHub => { SourceKind::GitHub => {
reporter.report(&OperationEvent::StageChanged { reporter.report(&OperationEvent::StageChanged {
@ -156,6 +159,57 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
(resolution, artifact, strategy) (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 => { SourceKind::DirectUrl => {
reporter.report(&OperationEvent::StageChanged { reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact, stage: OperationStage::SelectArtifact,
@ -266,6 +320,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
interactions, interactions,
update_strategy, update_strategy,
metadata: parsed_metadata, metadata: parsed_metadata,
display_name_hint,
}) })
} }
@ -299,6 +354,7 @@ pub struct AddPlan {
pub interactions: Vec<InteractionRequest>, pub interactions: Vec<InteractionRequest>,
pub update_strategy: UpdateStrategy, pub update_strategy: UpdateStrategy,
pub metadata: Vec<ParsedMetadata>, pub metadata: Vec<ParsedMetadata>,
pub display_name_hint: Option<String>,
} }
pub fn materialize_app_record( pub fn materialize_app_record(
@ -312,7 +368,7 @@ pub fn materialize_app_record(
.as_deref() .as_deref()
.unwrap_or(source_input); .unwrap_or(source_input);
let identity = resolve_identity( let identity = resolve_identity(
None, plan.display_name_hint.as_deref(),
None, None,
Some(identity_source), Some(identity_source),
IdentityFallback::AllowRawUrl, IdentityFallback::AllowRawUrl,

View file

@ -3,6 +3,9 @@ use crate::domain::search::{
InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults, InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults,
SearchWarning, SearchWarning,
}; };
use crate::source::appimagehub::{
AppImageHubSearchError, AppImageHubTransport, search_appimagehub_with,
};
use crate::source::github::{ use crate::source::github::{
GitHubSearchError, GitHubTransport, TransportRelease, default_transport, GitHubSearchError, GitHubTransport, TransportRelease, default_transport,
search_github_repositories_with, search_github_repositories_with,
@ -37,9 +40,15 @@ pub fn build_search_results(
query: &SearchQuery, query: &SearchQuery,
installed_apps: &[AppRecord], installed_apps: &[AppRecord],
) -> Result<SearchResults, SearchError> { ) -> Result<SearchResults, SearchError> {
let transport = default_transport(); let github_transport = default_transport();
let provider = GitHubSearchProvider::new(transport.as_ref()); let appimagehub_transport = crate::source::appimagehub::default_transport();
build_search_results_with(query, installed_apps, &[&provider]) 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( 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<T: AppImageHubTransport + ?Sized> SearchProvider for AppImageHubSearchProvider<'_, T> {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, 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::<Vec<_>>();
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<T: GitHubTransport + ?Sized> SearchProvider for GitHubSearchProvider<'_, T> { impl<T: GitHubTransport + ?Sized> SearchProvider for GitHubSearchProvider<'_, T> {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> { fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let name_only_query = format!("{} in:name", query.text); 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<String> { fn app_search_locator(app: &AppRecord) -> Option<String> {
if let Some(source) = &app.source if let Some(source) = &app.source {
&& source.kind == crate::domain::source::SourceKind::GitHub match source.kind {
{ crate::domain::source::SourceKind::GitHub
| crate::domain::source::SourceKind::AppImageHub => {
if let Some(locator) = source.canonical_locator.as_deref() { if let Some(locator) = source.canonical_locator.as_deref() {
return Some(normalize_lookup(locator)); 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| { app.source_input.as_deref().and_then(|input| {
if input.contains('/') && !input.contains("://") { 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)) Some(normalize_lookup(input))
} else { } else {
None None
@ -320,3 +392,45 @@ fn render_github_search_error(error: &GitHubSearchError) -> String {
GitHubSearchError::Transport(inner) => inner.to_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(),
}
}

View file

@ -148,9 +148,11 @@ fn fallback_channel_preference(app: &AppRecord) -> ChannelPreference {
.clone() .clone()
.unwrap_or_else(|| source.locator.clone()), .unwrap_or_else(|| source.locator.clone()),
), ),
SourceKind::GitLab | SourceKind::SourceForge | SourceKind::DirectUrl | SourceKind::File => { SourceKind::GitLab
(UpdateChannelKind::DirectAsset, source.locator.clone()) | SourceKind::AppImageHub
} | SourceKind::SourceForge
| SourceKind::DirectUrl
| SourceKind::File => (UpdateChannelKind::DirectAsset, source.locator.clone()),
}; };
ChannelPreference { ChannelPreference {

View file

@ -2,6 +2,7 @@
pub enum SourceKind { pub enum SourceKind {
GitHub, GitHub,
GitLab, GitLab,
AppImageHub,
SourceForge, SourceForge,
DirectUrl, DirectUrl,
File, File,
@ -12,6 +13,7 @@ impl SourceKind {
match self { match self {
Self::GitHub => "github", Self::GitHub => "github",
Self::GitLab => "gitlab", Self::GitLab => "gitlab",
Self::AppImageHub => "appimagehub",
Self::SourceForge => "sourceforge", Self::SourceForge => "sourceforge",
Self::DirectUrl => "direct-url", Self::DirectUrl => "direct-url",
Self::File => "file", Self::File => "file",
@ -26,6 +28,8 @@ pub enum SourceInputKind {
GitHubReleaseUrl, GitHubReleaseUrl,
GitHubReleaseAssetUrl, GitHubReleaseAssetUrl,
GitLabUrl, GitLabUrl,
AppImageHubUrl,
AppImageHubShorthand,
SourceForgeUrl, SourceForgeUrl,
DirectUrl, DirectUrl,
File, File,
@ -39,6 +43,8 @@ impl SourceInputKind {
Self::GitHubReleaseUrl => "github-release-url", Self::GitHubReleaseUrl => "github-release-url",
Self::GitHubReleaseAssetUrl => "github-release-asset-url", Self::GitHubReleaseAssetUrl => "github-release-asset-url",
Self::GitLabUrl => "gitlab-url", Self::GitLabUrl => "gitlab-url",
Self::AppImageHubUrl => "appimagehub-url",
Self::AppImageHubShorthand => "appimagehub-shorthand",
Self::SourceForgeUrl => "sourceforge-url", Self::SourceForgeUrl => "sourceforge-url",
Self::DirectUrl => "direct-url", Self::DirectUrl => "direct-url",
Self::File => "file", Self::File => "file",
@ -53,6 +59,7 @@ pub enum NormalizedSourceKind {
GitHubReleaseAsset, GitHubReleaseAsset,
GitLab, GitLab,
GitLabCandidate, GitLabCandidate,
AppImageHub,
SourceForge, SourceForge,
SourceForgeCandidate, SourceForgeCandidate,
DirectUrl, DirectUrl,
@ -67,6 +74,7 @@ impl NormalizedSourceKind {
Self::GitHubReleaseAsset => "github-release-asset", Self::GitHubReleaseAsset => "github-release-asset",
Self::GitLab => "gitlab", Self::GitLab => "gitlab",
Self::GitLabCandidate => "gitlab-candidate", Self::GitLabCandidate => "gitlab-candidate",
Self::AppImageHub => "appimagehub",
Self::SourceForge => "sourceforge", Self::SourceForge => "sourceforge",
Self::SourceForgeCandidate => "sourceforge-candidate", Self::SourceForgeCandidate => "sourceforge-candidate",
Self::DirectUrl => "direct-url", Self::DirectUrl => "direct-url",

View file

@ -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<String>,
pub arch: Option<String>,
pub md5sum: Option<String>,
pub version: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubItem {
pub id: String,
pub name: String,
pub version: String,
pub summary: Option<String>,
pub detail_page: String,
pub tags: Vec<String>,
pub downloads: Vec<AppImageHubDownload>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubSearchHit {
pub id: String,
pub name: String,
pub version: String,
pub summary: Option<String>,
pub detail_page: String,
pub tags: Vec<String>,
}
#[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<AppImageHubItem, AppImageHubError>;
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError>;
}
pub fn default_transport() -> Box<dyn AppImageHubTransport> {
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<Option<ResolvedAppImageHubItem>, AppImageHubError> {
let transport = default_transport();
resolve_appimagehub_item_with(source, transport.as_ref())
}
pub fn resolve_appimagehub_item_with<T: AppImageHubTransport + ?Sized>(
source: &SourceRef,
transport: &T,
) -> Result<Option<ResolvedAppImageHubItem>, 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<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
let transport = default_transport();
search_appimagehub_with(query, limit, transport.as_ref())
}
pub fn search_appimagehub_with<T: AppImageHubTransport + ?Sized>(
query: &str,
limit: usize,
transport: &T,
) -> Result<Vec<AppImageHubSearchHit>, 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<AppImageHubItem, AppImageHubError> {
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<Vec<AppImageHubSearchHit>, 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<AppImageHubItem, AppImageHubError> {
fixture_item(id).ok_or_else(|| AppImageHubError::FixtureItemMissing(id.to_owned()))
}
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, 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<OcsContent>,
}
#[derive(serde::Deserialize)]
struct OcsContent {
id: String,
name: String,
version: Option<String>,
summary: Option<String>,
detailpage: Option<String>,
tags: Option<String>,
downloadlink1: Option<String>,
downloadname1: Option<String>,
download_package_type1: Option<String>,
download_package_arch1: Option<String>,
downloadmd5sum1: Option<String>,
download_version1: Option<String>,
downloadlink2: Option<String>,
downloadname2: Option<String>,
download_package_type2: Option<String>,
download_package_arch2: Option<String>,
downloadmd5sum2: Option<String>,
download_version2: Option<String>,
downloadlink3: Option<String>,
downloadname3: Option<String>,
download_package_type3: Option<String>,
download_package_arch3: Option<String>,
downloadmd5sum3: Option<String>,
download_version3: Option<String>,
}
fn parse_item_xml(xml: &str) -> Result<AppImageHubItem, AppImageHubError> {
let parsed =
quick_xml::de::from_str::<OcsSingleResponse>(xml).map_err(AppImageHubError::Parse)?;
Ok(content_to_item(parsed.data.content))
}
fn parse_search_xml(xml: &str) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
if !xml.contains("<id>") {
return Ok(Vec::new());
}
let parsed =
quick_xml::de::from_str::<OcsSearchResponse>(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<AppImageHubDownload> {
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<AppImageHubDownload> {
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<String> {
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<String> {
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<AppImageHubItem> {
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<AppImageHubSearchHit> {
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()
}

View file

@ -49,6 +49,10 @@ pub fn classify_input(query: &str) -> Result<ClassifiedInput, ClassifyInputError
return classified; return classified;
} }
if let Some(classified) = classify_appimagehub_input(query) {
return classified;
}
if let Some(classified) = classify_sourceforge_http(query) { if let Some(classified) = classify_sourceforge_http(query) {
return classified; return classified;
} }
@ -87,6 +91,26 @@ pub enum ClassifyInputError {
Unsupported, Unsupported,
} }
fn classify_appimagehub_input(query: &str) -> Option<Result<ClassifiedInput, ClassifyInputError>> {
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<Result<ClassifiedInput, ClassifyInputError>> { fn classify_gitlab_http(query: &str) -> Option<Result<ClassifiedInput, ClassifyInputError>> {
let trimmed = query let trimmed = query
.trim_start_matches("https://gitlab.com/") .trim_start_matches("https://gitlab.com/")
@ -224,10 +248,39 @@ fn classify_sourceforge_http(query: &str) -> Option<Result<ClassifiedInput, Clas
})) }))
} }
fn appimagehub_id_from_url(query: &str) -> 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 { fn trim_query_and_fragment(value: &str) -> &str {
value.split(['?', '#']).next().unwrap_or(value) 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 { fn is_supported_gitlab_repo_path(parts: &[&str]) -> bool {
if parts.len() < 2 { if parts.len() < 2 {
return false; return false;

View file

@ -1,2 +1,3 @@
pub mod appimagehub;
pub mod github; pub mod github;
pub mod input; pub mod input;

View file

@ -1,3 +1,4 @@
use aim_core::adapters::appimagehub::AppImageHubAdapter;
use aim_core::adapters::direct_url::DirectUrlAdapter; use aim_core::adapters::direct_url::DirectUrlAdapter;
use aim_core::adapters::github::GitHubAdapter; use aim_core::adapters::github::GitHubAdapter;
use aim_core::adapters::gitlab::GitLabAdapter; use aim_core::adapters::gitlab::GitLabAdapter;
@ -9,6 +10,7 @@ use aim_core::app::query::resolve_query;
use aim_core::domain::source::{ use aim_core::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef, NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
}; };
use aim_core::source::appimagehub::FixtureAppImageHubTransport;
struct FileArtifactAdapter; struct FileArtifactAdapter;
@ -59,6 +61,60 @@ fn adapter_capabilities_can_report_exact_resolution_only() {
assert!(!capabilities.supports_search); 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] #[test]
fn repository_backed_resolvers_accept_only_their_own_source_kind() { fn repository_backed_resolvers_accept_only_their_own_source_kind() {
let github_source = resolve_query("sharkdp/bat").unwrap(); let github_source = resolve_query("sharkdp/bat").unwrap();

View file

@ -4,10 +4,11 @@ use aim_core::adapters::all_adapter_kinds;
fn all_expected_adapter_kinds_are_registered() { fn all_expected_adapter_kinds_are_registered() {
let kinds = all_adapter_kinds(); let kinds = all_adapter_kinds();
assert!(kinds.contains(&"appimagehub"));
assert!(kinds.contains(&"github")); assert!(kinds.contains(&"github"));
assert!(kinds.contains(&"gitlab")); assert!(kinds.contains(&"gitlab"));
assert!(kinds.contains(&"direct-url")); assert!(kinds.contains(&"direct-url"));
assert!(kinds.contains(&"zsync")); assert!(kinds.contains(&"zsync"));
assert!(kinds.contains(&"sourceforge")); assert!(kinds.contains(&"sourceforge"));
assert!(kinds.contains(&"custom-json")); assert!(!kinds.contains(&"custom-json"));
} }

View file

@ -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<Vec<SearchResult>, 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")
);
}

View file

@ -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] #[test]
fn classifies_gitlab_repository_url() { fn classifies_gitlab_repository_url() {
let source = resolve_query("https://gitlab.com/example/team-app").unwrap(); 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); 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] #[test]
fn rejects_unsupported_sourceforge_url_shape() { fn rejects_unsupported_sourceforge_url_shape() {
let error = resolve_query("https://sourceforge.net/projects/team-app/rss").unwrap_err(); let error = resolve_query("https://sourceforge.net/projects/team-app/rss").unwrap_err();