From eaa9a3b52dc3dca0543173f3e30dbc192d8ad14a Mon Sep 17 00:00:00 2001 From: stoorps Date: Sat, 21 Mar 2026 00:43:02 +0000 Subject: [PATCH] feat: expand source provider resolution --- ...-03-20-source-provider-expansion-design.md | 227 ++++++++++++ ...-provider-expansion-implementation-plan.md | 334 ++++++++++++++++++ ...03-20-task-1-ambiguity-handoff-addendum.md | 241 +++++++++++++ .../2026-03-20-task-1-blocker-note.md | 50 +++ crates/aim-cli/src/lib.rs | 41 +++ crates/aim-cli/src/main.rs | 2 +- crates/aim-cli/tests/end_to_end_cli.rs | 97 +++++ crates/aim-core/src/adapters/direct_url.rs | 4 + crates/aim-core/src/adapters/github.rs | 4 + crates/aim-core/src/adapters/gitlab.rs | 97 ++++- crates/aim-core/src/adapters/mod.rs | 8 + crates/aim-core/src/adapters/sourceforge.rs | 101 +++++- crates/aim-core/src/adapters/traits.rs | 36 +- crates/aim-core/src/app/add.rs | 117 ++++++ crates/aim-core/src/app/update.rs | 40 ++- crates/aim-core/src/domain/source.rs | 10 + crates/aim-core/src/source/input.rs | 212 ++++++++++- crates/aim-core/tests/adapter_contract.rs | 247 ++++++++++++- crates/aim-core/tests/install_failures.rs | 56 +++ crates/aim-core/tests/install_integration.rs | 205 +++++++++++ crates/aim-core/tests/query_resolution.rs | 283 +++++++++++++++ crates/aim-core/tests/registry_roundtrip.rs | 104 ++++++ crates/aim-core/tests/update_planning.rs | 100 ++++++ 23 files changed, 2582 insertions(+), 34 deletions(-) create mode 100644 .plans/007-source-provider-expansion/2026-03-20-source-provider-expansion-design.md create mode 100644 .plans/007-source-provider-expansion/2026-03-20-source-provider-expansion-implementation-plan.md create mode 100644 .plans/007-source-provider-expansion/2026-03-20-task-1-ambiguity-handoff-addendum.md create mode 100644 .plans/007-source-provider-expansion/2026-03-20-task-1-blocker-note.md diff --git a/.plans/007-source-provider-expansion/2026-03-20-source-provider-expansion-design.md b/.plans/007-source-provider-expansion/2026-03-20-source-provider-expansion-design.md new file mode 100644 index 0000000..772bf9f --- /dev/null +++ b/.plans/007-source-provider-expansion/2026-03-20-source-provider-expansion-design.md @@ -0,0 +1,227 @@ +# Source And Provider Expansion Design + +## Goal + +Expand install-source coverage beyond the current GitHub-centric path without collapsing providers, exact artifact sources, and update metadata into one abstraction. + +## Problem Statement + +The current codebase has a real mismatch between what the domain and adapter layers suggest is possible and what the end-to-end install pipeline actually treats as first-class: + +- the domain source model already includes GitHub, GitLab, direct URL, and file +- the adapter layer also advertises SourceForge and zsync shapes +- the public source pipeline and most end-to-end behavior are still effectively GitHub-shaped + +That creates two problems: + +- adding new install origins risks becoming a copy of the GitHub path with provider-specific exceptions bolted on later +- `zsync` is at risk of being modeled as a provider even though the current code treats it primarily as update metadata + +## Design Goals + +- make `GitLab` a real repository-backed install source +- make `SourceForge` a real repository-backed install source +- preserve `direct-url` as a first-class exact-resolution source +- keep install origin semantics truthful in the registry +- allow provider-native update re-resolution where it fits naturally +- keep `zsync` as update metadata rather than forcing it into the install-source model + +## Non-Goals + +- generic search UX across all providers +- full behavioral parity across every provider in the first slice +- rewriting the CLI presentation layer +- promoting `zsync` into a first-class install source +- inventing a universal provider abstraction that erases real capability differences + +## Architectural Decision + +Separate three concerns explicitly: + +- `source kind`: how the user identified the install origin +- `resolution strategy`: how the system turns that origin into a concrete installable artifact +- `update channel`: how an installed application later discovers newer payloads + +Under this model: + +- `GitHub`, `GitLab`, and `SourceForge` are repository-backed source kinds +- `direct-url` is an exact artifact source kind +- `file` remains a local artifact source kind +- `zsync` remains an update-channel and metadata mechanism + +This keeps install and update semantics aligned without pretending every source type has provider-like behavior. + +## Source Taxonomy + +The input classification layer should classify user queries into a small, stable taxonomy: + +- repository-backed sources + - GitHub + - GitLab + - SourceForge +- exact artifact sources + - direct URL +- local artifact sources + - file + +Classification should answer only: + +- what kind of source the user provided +- what canonical locator can be derived +- whether release or asset hints are present +- whether the origin is inherently trackable + +Classification should not try to encode provider-specific release discovery beyond those normalized hints. + +## Resolution Model + +After classification, a resolver layer should convert a `SourceRef` into an installable release candidate. + +### Repository-backed sources + +Repository-backed resolution should: + +- accept a canonical repository or project locator +- discover release or download candidates using provider-specific logic +- select a concrete AppImage payload when confidence is sufficient +- return explicit structured failures when the repository exists but no installable AppImage is available + +This applies to: + +- GitHub +- GitLab +- SourceForge + +### Exact artifact sources + +Exact-resolution sources should: + +- treat the user-provided locator as the concrete payload origin +- derive best-effort metadata without pretending release discovery exists +- remain installable even when rich update tracking is unavailable + +This applies to: + +- direct URL +- file + +## Registry Semantics + +Registry persistence should preserve the truth about where the install came from. + +Each installed record should continue to store: + +- original source kind +- original source locator +- canonical locator when one exists +- installed version +- installed file metadata + +The key rule is: + +- install origin remains the origin the user chose +- update mechanisms are additive metadata, not a replacement source identity + +That means a direct URL install remains a direct URL install even if metadata later yields a richer update channel. A GitLab install remains a GitLab install even if update planning later uses provider-specific release rediscovery. + +## Update Strategy + +Update planning should use the install origin as the primary re-entry point. + +For repository-backed sources: + +- prefer provider-native re-resolution using the canonical locator +- attach update channels discovered during metadata inspection as additional evidence + +For exact-resolution sources: + +- keep update support weak by default unless post-install metadata offers something stronger + +For `zsync`: + +- keep it as discovered metadata and update-channel input +- do not rewrite the install source as `zsync` +- do not require `zsync` install-source tests in this phase + +This preserves a clean distinction between install origin and update mechanism. + +## Error Handling + +The design should distinguish unsupported semantics from runtime failure. + +### Unsupported source semantics + +Examples: + +- malformed provider URL shapes +- a project URL form we do not support yet +- a provider source kind sent to the wrong resolver + +These should fail early during classification or resolver selection with provider-aware messages. + +### Resolvable source, but no installable artifact + +Examples: + +- repository exists but has no AppImage asset +- release metadata is present but incomplete +- multiple assets exist but none match install heuristics confidently + +These should be structured resolution failures rather than generic unsupported errors. + +### Transport or integration failure + +Examples: + +- HTTP download failures +- metadata fetch failures +- local staging or desktop integration failures + +These remain operational failures in the existing install and update flow. + +## Testing Strategy + +Testing should expand along capability lines rather than provider-specific copy-paste. + +### Classification tests + +Add coverage for: + +- GitLab source forms +- SourceForge source forms +- direct URL edge cases +- unsupported or malformed provider inputs + +### Resolver contract tests + +Each resolver should satisfy a shared contract: + +- accepts valid source refs for its own kind +- rejects source refs for other kinds +- returns a concrete install candidate or a structured no-artifact result + +### End-to-end flow tests + +Add focused flow coverage for: + +- install from GitLab source +- install from direct URL +- install from SourceForge source +- truthful registry origin persistence +- update planning that uses install origin plus additive metadata + +### Non-goal tests + +Do not force `zsync` into install-source tests for this phase. + +## Rollout + +Recommended rollout order: + +1. normalize the source taxonomy and resolver interfaces +2. wire GitLab and direct URL cleanly through install and registry persistence +3. add SourceForge using the same resolver contract, limited to supported URL and project forms +4. extend update planning only where the source kind supports provider-native re-resolution naturally +5. leave `zsync` unchanged except to ensure it remains additive update metadata + +This keeps the product honest: each added source gets explicit semantics instead of being forced through a renamed GitHub pathway. \ No newline at end of file diff --git a/.plans/007-source-provider-expansion/2026-03-20-source-provider-expansion-implementation-plan.md b/.plans/007-source-provider-expansion/2026-03-20-source-provider-expansion-implementation-plan.md new file mode 100644 index 0000000..ea670c0 --- /dev/null +++ b/.plans/007-source-provider-expansion/2026-03-20-source-provider-expansion-implementation-plan.md @@ -0,0 +1,334 @@ +# Source And Provider Expansion Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make GitLab and SourceForge real repository-backed install sources, preserve direct URL as a first-class exact-resolution source, and keep zsync as update metadata rather than an install provider. + +**Architecture:** Normalize the source taxonomy first, then add a capability-shaped resolver layer that distinguishes repository-backed sources from exact artifact sources. Preserve truthful install origin data in the registry and let update planning attach richer metadata without rewriting source identity. + +**Tech Stack:** Rust, Cargo workspace, `aim-core` source and adapter modules, existing fixture-backed integration tests in `crates/aim-core/tests`, CLI end-to-end tests in `crates/aim-cli/tests`, existing registry and update planning code. + +## Follow-up Status + +Task 1 hit a classification ambiguity blocker after the initial rollout. The follow-up design and execution live in `.plans/007-source-provider-expansion/2026-03-20-task-1-ambiguity-handoff-addendum.md`. + +Current state on this branch: + +- ambiguous GitLab deep paths and one SourceForge nested download path are now preserved as provider-owned candidate kinds during classification +- the first GitLab candidate slice now resolves as a concrete repository-backed install source at the adapter layer +- the SourceForge `files/releases/stable/download` candidate slice now resolves as a concrete latest-download install source at the adapter layer +- the SourceForge `files/releases/v*/download` slice is now preserved as a provider-owned candidate and reports `NoInstallableArtifact` +- unsupported queries remain distinct from provider-owned no-artifact outcomes + +Classifier policy for follow-up work: + +- accept explicit concrete shapes +- accept explicit provider-candidate shapes +- reject everything else + +Future changes should expand the allowlist deliberately rather than adding broad negative-rule coverage for every unsupported provider page family. + +--- + +### Task 1: Lock down source taxonomy with failing classification tests + +**Files:** +- Modify: `crates/aim-core/tests/query_resolution.rs` +- Modify: `crates/aim-core/src/source/input.rs` +- Modify: `crates/aim-core/src/domain/source.rs` + +**Step 1: Write the failing tests** + +Add classification tests that cover: +- GitLab repository and release-like URL forms that should classify as `GitLab` +- supported SourceForge URL forms that should classify as `SourceForge` +- direct URLs that must remain `DirectUrl` +- malformed provider URLs that must fail as unsupported + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test query_resolution` +Expected: FAIL because SourceForge is not yet part of the public source taxonomy and current classification rules are too narrow. + +**Step 3: Write minimal classification changes** + +Update the source domain and classifier so the public source taxonomy includes the approved source kinds and supported input forms without introducing zsync as an install source. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test query_resolution` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/tests/query_resolution.rs crates/aim-core/src/source/input.rs crates/aim-core/src/domain/source.rs +git commit -m "test: cover expanded source taxonomy" +``` + +### Task 2: Add a shared resolver contract for source capabilities + +**Files:** +- Modify: `crates/aim-core/src/adapters/traits.rs` +- Modify: `crates/aim-core/src/adapters/mod.rs` +- Modify: `crates/aim-core/tests/adapter_contract.rs` +- Modify: `crates/aim-core/src/app/query.rs` + +**Step 1: Write the failing tests** + +Add contract tests that assert: +- repository-backed resolvers accept only their own source kinds +- exact-resolution resolvers accept only exact artifact kinds +- resolvers can return structured “no installable artifact” outcomes rather than collapsing to unsupported + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test adapter_contract` +Expected: FAIL because the current adapter trait does not distinguish source capability outcomes cleanly enough. + +**Step 3: Write minimal resolver contract changes** + +Refine the shared adapter or resolver contract to represent: +- unsupported source kind +- supported source with successful artifact resolution +- supported source with no installable artifact found + +Keep the API small and do not add terminal concerns. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test adapter_contract` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters/traits.rs crates/aim-core/src/adapters/mod.rs crates/aim-core/tests/adapter_contract.rs crates/aim-core/src/app/query.rs +git commit -m "feat: add capability-shaped resolver contract" +``` + +### Task 3: Make GitLab a real repository-backed install source + +**Files:** +- Modify: `crates/aim-core/src/adapters/gitlab.rs` +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-core/tests/adapter_contract.rs` +- Modify: `crates/aim-core/tests/install_integration.rs` +- Modify: `crates/aim-cli/tests/end_to_end_cli.rs` + +**Step 1: Write the failing tests** + +Add tests that assert: +- a GitLab source resolves to a concrete install candidate +- install flow persists a truthful GitLab install origin +- CLI integration can install a fixture-backed GitLab source end to end + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test install_integration` +Expected: FAIL because GitLab resolution is currently placeholder-level and not wired into the add flow meaningfully. + +**Step 3: Write minimal implementation** + +Implement GitLab-specific repository-backed resolution using the new resolver contract and thread the result through the add flow without changing direct URL or zsync semantics. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test install_integration` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters/gitlab.rs crates/aim-core/src/app/add.rs crates/aim-core/tests/adapter_contract.rs crates/aim-core/tests/install_integration.rs crates/aim-cli/tests/end_to_end_cli.rs +git commit -m "feat: add gitlab install source resolution" +``` + +### Task 4: Preserve direct URL as an exact-resolution source + +**Files:** +- Modify: `crates/aim-core/src/adapters/direct_url.rs` +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-core/tests/install_integration.rs` +- Modify: `crates/aim-cli/tests/end_to_end_cli.rs` + +**Step 1: Write the failing tests** + +Add tests that assert: +- direct URL installs continue to resolve exactly to the provided artifact +- registry persistence keeps the original direct URL source kind and locator +- no provider-like reclassification occurs after install + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test install_integration` +Expected: FAIL if the new resolver contract or registry changes accidentally regress exact-resolution behavior. + +**Step 3: Write minimal implementation** + +Adjust the direct URL path to use the new resolver interfaces while preserving exact-resolution semantics and best-effort metadata only. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test install_integration` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters/direct_url.rs crates/aim-core/src/app/add.rs crates/aim-core/tests/install_integration.rs crates/aim-cli/tests/end_to_end_cli.rs +git commit -m "feat: preserve direct url exact resolution semantics" +``` + +### Task 5: Add SourceForge as a repository-backed source for supported project forms + +**Files:** +- Modify: `crates/aim-core/src/adapters/sourceforge.rs` +- Modify: `crates/aim-core/src/source/input.rs` +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-core/tests/adapter_contract.rs` +- Modify: `crates/aim-core/tests/install_integration.rs` +- Modify: `crates/aim-cli/tests/end_to_end_cli.rs` + +**Step 1: Write the failing tests** + +Add tests that assert: +- supported SourceForge URL or project forms classify correctly +- SourceForge resolution can produce a concrete install candidate +- SourceForge installs persist truthful origin data + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test adapter_contract` +Expected: FAIL because SourceForge currently returns unsupported from its adapter. + +**Step 3: Write minimal implementation** + +Implement only the supported SourceForge project or download forms needed for exact current-product scope. Return structured no-artifact failures for valid-but-non-installable projects. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test adapter_contract` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters/sourceforge.rs crates/aim-core/src/source/input.rs crates/aim-core/src/app/add.rs crates/aim-core/tests/adapter_contract.rs crates/aim-core/tests/install_integration.rs crates/aim-cli/tests/end_to_end_cli.rs +git commit -m "feat: add sourceforge install source resolution" +``` + +### Task 6: Keep registry origin truthful and update metadata additive + +**Files:** +- Modify: `crates/aim-core/src/registry/model.rs` +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-core/src/app/update.rs` +- Modify: `crates/aim-core/src/update/channels.rs` +- Modify: `crates/aim-core/tests/update_planning.rs` +- Modify: `crates/aim-core/tests/registry_roundtrip.rs` + +**Step 1: Write the failing tests** + +Add tests that assert: +- GitLab and SourceForge installs preserve original source kind and locator after roundtrip persistence +- direct URL installs remain direct URL installs after metadata inspection +- discovered update channels augment stored state without rewriting source identity +- zsync remains update metadata only + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test update_planning` +Expected: FAIL because update planning and registry expectations do not yet fully encode the approved source-versus-update split. + +**Step 3: Write minimal implementation** + +Adjust registry and update planning logic so install origin remains canonical and update channels remain additive metadata. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test update_planning` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/registry/model.rs crates/aim-core/src/app/add.rs crates/aim-core/src/app/update.rs crates/aim-core/src/update/channels.rs crates/aim-core/tests/update_planning.rs crates/aim-core/tests/registry_roundtrip.rs +git commit -m "feat: preserve source identity through update planning" +``` + +### Task 7: Improve provider-aware error reporting without changing CLI shape + +**Files:** +- Modify: `crates/aim-core/src/adapters/traits.rs` +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-cli/src/lib.rs` +- Modify: `crates/aim-core/tests/install_failures.rs` +- Modify: `crates/aim-cli/tests/end_to_end_cli.rs` + +**Step 1: Write the failing tests** + +Add tests that distinguish: +- unsupported source semantics +- supported source with no installable artifact +- transport or integration failure + +**Step 2: Run test to verify it fails** + +Run: `cargo test --package aim-core --test install_failures` +Expected: FAIL because failure reasons are not yet structured enough to preserve those distinctions. + +**Step 3: Write minimal implementation** + +Introduce explicit failure categories and thread them through the add flow so the CLI can render clearer provider-aware messages without changing the progress UI architecture. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --package aim-core --test install_failures` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters/traits.rs crates/aim-core/src/app/add.rs crates/aim-cli/src/lib.rs crates/aim-core/tests/install_failures.rs crates/aim-cli/tests/end_to_end_cli.rs +git commit -m "feat: clarify provider-aware source resolution failures" +``` + +### Task 8: Full verification + +**Files:** +- Modify: `README.md` +- Modify: `crates/aim-core/tests/github_source_discovery.rs` +- Modify: `crates/aim-core/tests/query_resolution.rs` +- Modify: `crates/aim-core/tests/install_integration.rs` +- Modify: `crates/aim-core/tests/update_planning.rs` +- Modify: `crates/aim-cli/tests/end_to_end_cli.rs` + +**Step 1: Tighten any stale expectations** + +Update docs and tests so the product contract matches the approved design: +- GitLab and SourceForge are install sources +- direct URL remains exact-resolution +- zsync remains update metadata + +**Step 2: Run focused workspace verification** + +Run: `cargo test --package aim-core --test query_resolution --test adapter_contract --test install_integration --test update_planning --test install_failures` +Expected: PASS. + +**Step 3: Run CLI verification** + +Run: `cargo test --package aim-cli --test end_to_end_cli` +Expected: PASS. + +**Step 4: Run full workspace verification** + +Run: `cargo fmt --all && cargo test --workspace` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add README.md crates/aim-core/tests/github_source_discovery.rs crates/aim-core/tests/query_resolution.rs crates/aim-core/tests/install_integration.rs crates/aim-core/tests/update_planning.rs crates/aim-core/tests/install_failures.rs crates/aim-cli/tests/end_to_end_cli.rs +git commit -m "docs: align source provider contract and tests" +``` \ No newline at end of file diff --git a/.plans/007-source-provider-expansion/2026-03-20-task-1-ambiguity-handoff-addendum.md b/.plans/007-source-provider-expansion/2026-03-20-task-1-ambiguity-handoff-addendum.md new file mode 100644 index 0000000..1e41e2e --- /dev/null +++ b/.plans/007-source-provider-expansion/2026-03-20-task-1-ambiguity-handoff-addendum.md @@ -0,0 +1,241 @@ +# Task 1 Ambiguity Handoff Addendum + +## Goal + +Resolve the Task 1 blocker by moving ambiguous GitLab and SourceForge URL handling out of pure taxonomy heuristics and into provider-aware resolution. + +## Problem Restatement + +The blocker is not that the classifier is missing a few more path rules. + +The blocker is that some provider-hosted URL shapes do not carry enough information to determine final install semantics from path shape alone. + +Two cases are responsible for the review churn: + +- GitLab deep paths where a segment may be either a subgroup slug or a resource-like segment +- SourceForge `files/.../download` paths where the same suffix can represent either a concrete file download or a folder-style endpoint + +Trying to settle those cases in `resolve_query(...)` forces the code into a false choice: + +- accept ambiguous inputs too early and misclassify them +- reject provider-owned inputs too early and lose useful context + +## Design Decision + +Adopt an ambiguity handoff model. + +That means: + +- the classifier remains authoritative only for cases it can determine with high confidence +- ambiguous provider-hosted inputs are preserved as provider-owned candidates rather than flattened into `Unsupported` +- provider adapters become the layer that decides whether an ambiguous input is: + - a supported repository or project source + - a supported exact download form + - a supported source with no installable artifact + - truly unsupported for that provider + +## Contract Boundary + +### Classification policy + +The classifier should use a strict positive-matching contract. + +Each input shape must land in exactly one of three buckets: + +- accept as a definite supported source +- accept as an explicit provider-owned candidate +- reject as unsupported + +This means the classifier should prefer a small allowlist of accepted shapes over an expanding catalog of bespoke rejection rules. + +Negative rules are still allowed when needed to protect a known false-positive family, but they are defensive exceptions, not the main design strategy. + +### Classification must do + +- identify definite GitHub, GitLab, SourceForge, direct URL, and file inputs +- accept only explicitly enumerated concrete shapes or explicitly enumerated candidate shapes +- preserve canonical locator hints when they are certain +- preserve enough raw path context for later provider-specific disambiguation +- continue classifying concrete artifact URLs as `DirectUrl` when the classifier can say so confidently + +### Classification must not do + +- grow by accumulating one-off rejection rules for every unsupported provider page family +- guess whether a GitLab deep path is a subgroup path or a resource page when the path shape is ambiguous +- guess whether a SourceForge nested `files/.../download` path is a file or folder endpoint when the path shape is ambiguous +- perform provider-specific network discovery + +### Resolver layer must do + +- own final interpretation of ambiguous provider-hosted inputs +- return structured outcomes through the adapter contract +- keep `UnsupportedSource` reserved for sources the adapter genuinely does not own +- use `NoInstallableArtifact` for provider-owned inputs that are valid but not installable under current scope + +## Proposed Source Model Adjustment + +Introduce an explicit handoff shape for ambiguous provider-owned inputs. + +The minimal acceptable form is: + +- preserve the original locator +- preserve provider ownership +- preserve any canonical parts that are certain +- add a signal that provider resolution is still required before install semantics are known + +This can be modeled either as: + +1. a dedicated ambiguity marker on `SourceRef` +2. additional normalized kinds representing provider-owned unresolved candidates + +The preferred direction is additional normalized kinds, because they keep the ambiguity visible in tests and logs without adding a free-form boolean that can drift. + +Illustrative shapes: + +- `NormalizedSourceKind::GitLabCandidate` +- `NormalizedSourceKind::SourceForgeCandidate` + +The exact enum names are secondary. The important part is making unresolved provider ownership explicit. + +## Provider Responsibilities + +### GitLab + +GitLab adapter logic should decide whether a GitLab-owned ambiguous input is: + +- a valid repository locator +- a release-like source with concrete version semantics +- a provider-owned but non-installable resource page +- unsupported because it does not fit the adapter's supported contract + +Initial scope should stay narrow: + +- keep current definite repository and release-like support +- add only one or two ambiguous deep-path cases as a first expansion slice +- do not try to solve every GitLab resource URL family at once + +### SourceForge + +SourceForge adapter logic should decide whether a SourceForge-owned ambiguous input is: + +- a concrete latest-download install source +- a concrete direct artifact URL +- a provider-owned project or folder view with no installable artifact +- unsupported for current source scope + +Initial scope should stay narrow: + +- keep bare project URLs as provider-owned and non-installable +- keep `files/latest/download` as the first concrete repository-backed install source +- add exactly one nested `files/.../download` ambiguity case to the adapter decision path + +## Testing Strategy + +The blocker should be resolved by shifting assertions to the right layer. + +### Classification tests + +Update `query_resolution` coverage so ambiguous cases assert provider ownership and handoff state instead of asserting final install semantics. + +Coverage should be organized around accepted-shape allowlists: + +- accepted concrete shapes +- accepted candidate shapes +- a small number of representative false-positive guards + +Examples: + +- a concrete SourceForge artifact download still classifies as `DirectUrl` +- a definite GitLab repository form still classifies as `GitLab` +- an ambiguous GitLab deep path becomes a GitLab-owned candidate, not `Unsupported` +- an ambiguous SourceForge nested download path becomes a SourceForge-owned candidate, not prematurely direct or unsupported + +### Adapter contract tests + +Add tests that assert adapters make the final decision for ambiguous handoff inputs. + +Examples: + +- GitLab candidate path resolves to supported repository semantics +- GitLab candidate path resolves to `NoInstallableArtifact` +- SourceForge candidate path resolves to `Resolved` +- SourceForge candidate path resolves to `NoInstallableArtifact` + +### Install and failure tests + +Keep install-flow tests focused on supported concrete outcomes. + +Keep failure tests focused on the distinction between: + +- unsupported query +- provider-owned source with no installable artifact +- runtime install or transport failure + +## Incremental Execution Plan + +### Phase 1: Lock the boundary + +- update the design docs to state that classification only decides what it can know with certainty +- record that ambiguous provider-hosted inputs are a resolver concern + +### Phase 2: Add handoff representation + +- extend the source model with explicit provider-candidate semantics +- thread that representation through the query classifier + +### Phase 3: Shift one GitLab ambiguity case + +- add a failing classification test for an ambiguous GitLab deep path +- classify it as a GitLab-owned candidate +- add adapter contract coverage for the GitLab decision + +### Phase 4: Shift one SourceForge ambiguity case + +- add a failing classification test for a nested `files/.../download` ambiguity case +- classify it as a SourceForge-owned candidate +- add adapter contract coverage for the SourceForge decision + +### Phase 5: Tighten error reporting + +- make sure ambiguous provider-owned inputs that do not yield installable artifacts surface as `NoInstallableArtifact` +- avoid regressing them into unsupported-query failures + +## Progress Update + +Current implementation status in this branch: + +- Phase 1 is complete. The classifier-versus-adapter boundary is now documented explicitly in this addendum. +- Phase 2 is complete. `GitLabCandidate` and `SourceForgeCandidate` now exist in the source model and are produced by classification for the narrow ambiguity cases under test. +- Phase 3 is complete for the first GitLab slice. `https://gitlab.com///releases/` remains a classified candidate, but the GitLab adapter now resolves it as repository semantics with a derived canonical locator. +- Phase 4 is complete for two SourceForge slices. `https://sourceforge.net/projects//files/releases/stable/download` remains a classified candidate and now resolves as a provider-owned latest-download source. `https://sourceforge.net/projects//files/releases/v*/download` is now preserved as a provider-owned candidate and surfaces as `NoInstallableArtifact`. +- Phase 5 is partially complete. Provider-owned ambiguous inputs now distinguish unsupported-query failures from no-artifact outcomes, and both GitLab and SourceForge have at least one adapter-owned positive resolution path. + +The current intended classifier contract is: + +- accept explicit supported shapes +- accept explicit candidate shapes +- reject everything else + +That contract is intentionally stricter than heuristic best-effort classification and intentionally narrower than provider resolution. + +What remains intentionally out of scope for this slice: + +- additional GitLab candidate families beyond the first repository-style deep path +- broader SourceForge folder and version-path families beyond the `releases/stable/download` and narrow `releases/v*/download` rules +- any network-backed provider discovery in classification + +## Success Criteria + +This blocker is considered resolved when: + +- `query_resolution` no longer oscillates over ambiguous provider-owned shapes +- ambiguous provider-hosted URLs are no longer forced into final install semantics during classification +- adapters are the only place where ambiguous provider paths are interpreted fully +- failure reporting distinguishes unsupported inputs from provider-owned non-installable inputs + +## Non-Goals + +- solving every ambiguous GitLab deep-path variant in one pass +- solving every SourceForge nested folder or version path in one pass +- introducing network discovery into the pure query classifier +- expanding current supported source scope beyond what the adapter tests can defend clearly \ No newline at end of file diff --git a/.plans/007-source-provider-expansion/2026-03-20-task-1-blocker-note.md b/.plans/007-source-provider-expansion/2026-03-20-task-1-blocker-note.md new file mode 100644 index 0000000..c6b59c8 --- /dev/null +++ b/.plans/007-source-provider-expansion/2026-03-20-task-1-blocker-note.md @@ -0,0 +1,50 @@ +# Task 1 Blocker Note + +## Summary + +Task 1 is blocked on an unresolved contract decision, not on a failing implementation. + +The focused taxonomy test suite currently passes: + +- `cargo test --package aim-core --test query_resolution` + +But code review keeps surfacing the same underlying issue: some GitLab and SourceForge URL shapes are ambiguous when classified from URL structure alone. + +## Current Blocker + +Two classes of URLs cannot be classified with high confidence using only path-shape heuristics: + +1. GitLab deep paths + - Example ambiguity: a path segment may be either a subgroup slug or a resource-like segment. + - This makes URLs such as deeply nested subgroup paths indistinguishable from some non-repository resource paths without provider-aware resolution. + +2. SourceForge nested `files/.../download` paths + - Some are concrete file downloads. + - Some are folder-style or version-folder download endpoints. + - URL shape alone does not reliably distinguish the two in every case. + +## Why This Blocks Task 1 + +Task 1 is supposed to harden source taxonomy. It is not supposed to solve provider semantics end to end. + +At this point, the remaining disagreement is about where ambiguity should be resolved: + +- in classification, using increasingly complex heuristics +- or later, in provider-aware resolution logic + +Trying to solve this entirely in Task 1 has led to oscillation between: + +- permissive rules that accept too many ambiguous URLs +- conservative rules that reject URLs a reviewer considers potentially valid + +## Recommended Resolution + +Freeze Task 1 on the current conservative contract and move ambiguity handling into Task 2. + +That keeps Task 1 scoped to explicit supported forms and lets the resolver contract decide how to treat ambiguous provider URLs with richer semantics. + +## Current Practical State + +- taxonomy tests are green +- GitLab, SourceForge, and direct URL shapes are covered by focused tests +- ambiguous provider URL handling remains the only unresolved review topic for Task 1 \ No newline at end of file diff --git a/crates/aim-cli/src/lib.rs b/crates/aim-cli/src/lib.rs index b7b1f3e..1687783 100644 --- a/crates/aim-cli/src/lib.rs +++ b/crates/aim-cli/src/lib.rs @@ -155,6 +155,47 @@ pub enum DispatchError { UpdateExecution(aim_core::app::update::ExecuteUpdatesError), } +impl std::fmt::Display for DispatchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AddPlan(error) => match error { + aim_core::app::add::BuildAddPlanError::Query( + aim_core::app::query::ResolveQueryError::Unsupported, + ) => write!(f, "unsupported source query"), + aim_core::app::add::BuildAddPlanError::NoInstallableArtifact { source } => write!( + f, + "no installable artifact found for {} {}", + source.kind.as_str(), + source.locator + ), + aim_core::app::add::BuildAddPlanError::Adapter(id, error) => match error { + aim_core::adapters::traits::AdapterError::UnsupportedQuery => { + write!(f, "{id} does not support this query") + } + aim_core::adapters::traits::AdapterError::UnsupportedSource => { + write!(f, "{id} does not support this source") + } + aim_core::adapters::traits::AdapterError::ResolutionFailed(reason) => { + write!(f, "{id} resolution failed: {reason}") + } + }, + aim_core::app::add::BuildAddPlanError::GitHubDiscovery(error) => { + write!(f, "github discovery failed: {error:?}") + } + aim_core::app::add::BuildAddPlanError::NoCandidates => { + write!(f, "no installable candidates found") + } + }, + Self::AddInstall(error) => write!(f, "install failed: {error:?}"), + Self::Prompt(error) => write!(f, "prompt failed: {error:?}"), + Self::RemovePlan(error) => write!(f, "remove failed: {error:?}"), + Self::Registry(error) => write!(f, "registry failed: {error:?}"), + Self::UpdatePlan(error) => write!(f, "update planning failed: {error:?}"), + Self::UpdateExecution(error) => write!(f, "update execution failed: {error:?}"), + } + } +} + impl From for DispatchError { fn from(value: aim_core::app::add::BuildAddPlanError) -> Self { Self::AddPlan(value) diff --git a/crates/aim-cli/src/main.rs b/crates/aim-cli/src/main.rs index c0ee681..7f5a0ca 100644 --- a/crates/aim-cli/src/main.rs +++ b/crates/aim-cli/src/main.rs @@ -23,7 +23,7 @@ fn main() { } } Err(error) => { - eprintln!("{error:?}"); + eprintln!("{error}"); std::process::exit(1); } } diff --git a/crates/aim-cli/tests/end_to_end_cli.rs b/crates/aim-cli/tests/end_to_end_cli.rs index 68d52f2..ba507c1 100644 --- a/crates/aim-cli/tests/end_to_end_cli.rs +++ b/crates/aim-cli/tests/end_to_end_cli.rs @@ -200,6 +200,103 @@ fn cli_add_installs_and_renders_resolved_mode() { .stdout(contains("Completed steps").not()); } +#[test] +fn cli_add_installs_gitlab_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("https://gitlab.com/example/team-app") + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Installed team-app (user)")) + .stdout(contains("Source: gitlab https://gitlab.com/example/team-app")) + .stdout(contains( + "Artifact: https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage", + )); + + let contents = std::fs::read_to_string(®istry_path).unwrap(); + assert!(contents.contains("source_input = \"https://gitlab.com/example/team-app\"")); + assert!(contents.contains("kind = \"GitLab\"")); + assert!(contents.contains("locator = \"https://gitlab.com/example/team-app\"")); + assert!(contents.contains("canonical_locator = \"example/team-app\"")); +} + +#[test] +fn cli_add_preserves_direct_url_origin_for_provider_like_downloads() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let query = "https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download"; + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg(query) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Installed ")) + .stdout(contains(format!("Source: direct-url {query}"))) + .stdout(contains(format!("Artifact: {query}"))); + + let contents = std::fs::read_to_string(®istry_path).unwrap(); + assert!(contents.contains(&format!("source_input = \"{query}\""))); + assert!(contents.contains("kind = \"DirectUrl\"")); + assert!(contents.contains(&format!("locator = \"{query}\""))); + assert!(!contents.contains("kind = \"SourceForge\"")); +} + +#[test] +fn cli_add_installs_sourceforge_latest_download_with_truthful_origin() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let query = "https://sourceforge.net/projects/team-app/files/latest/download"; + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg(query) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Installed team-app (user)")) + .stdout(contains(format!("Source: sourceforge {query}"))) + .stdout(contains(format!("Artifact: {query}"))); + + let contents = std::fs::read_to_string(®istry_path).unwrap(); + assert!(contents.contains(&format!("source_input = \"{query}\""))); + assert!(contents.contains("kind = \"SourceForge\"")); + assert!(contents.contains(&format!("locator = \"{query}\""))); + assert!(contents.contains("canonical_locator = \"team-app\"")); +} + +#[test] +fn cli_reports_unsupported_source_queries_distinctly() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("https://gitlab.com/example") + .env("AIM_REGISTRY_PATH", ®istry_path) + .assert() + .failure() + .stderr(contains("unsupported source query")); +} + +#[test] +fn cli_reports_supported_sources_without_installable_artifacts_distinctly() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("https://sourceforge.net/projects/team-app/") + .env("AIM_REGISTRY_PATH", ®istry_path) + .assert() + .failure() + .stderr(contains("no installable artifact found")) + .stderr(contains("sourceforge")); +} + #[test] fn cli_add_emits_live_progress_to_stderr() { let dir = tempdir().unwrap(); diff --git a/crates/aim-core/src/adapters/direct_url.rs b/crates/aim-core/src/adapters/direct_url.rs index 4985dd5..1ca95a6 100644 --- a/crates/aim-core/src/adapters/direct_url.rs +++ b/crates/aim-core/src/adapters/direct_url.rs @@ -15,6 +15,10 @@ impl SourceAdapter for DirectUrlAdapter { AdapterCapabilities::exact_resolution_only() } + fn exact_source_kind(&self) -> Option { + Some(SourceKind::DirectUrl) + } + fn normalize(&self, query: &str) -> Result { let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?; if source.kind != SourceKind::DirectUrl { diff --git a/crates/aim-core/src/adapters/github.rs b/crates/aim-core/src/adapters/github.rs index 5428a11..0feb4b3 100644 --- a/crates/aim-core/src/adapters/github.rs +++ b/crates/aim-core/src/adapters/github.rs @@ -30,6 +30,10 @@ impl SourceAdapter for GitHubAdapter { } } + fn repository_source_kind(&self) -> Option { + Some(SourceKind::GitHub) + } + fn normalize(&self, query: &str) -> Result { let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?; if source.kind != SourceKind::GitHub { diff --git a/crates/aim-core/src/adapters/gitlab.rs b/crates/aim-core/src/adapters/gitlab.rs index 2f65b79..8fc97e1 100644 --- a/crates/aim-core/src/adapters/gitlab.rs +++ b/crates/aim-core/src/adapters/gitlab.rs @@ -1,11 +1,35 @@ use crate::adapters::traits::{ - AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter, + AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter, }; use crate::app::query::resolve_query; -use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; +use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind, SourceRef}; pub struct GitLabAdapter; +impl GitLabAdapter { + pub fn artifact_name(source: &SourceRef) -> String { + let slug = canonical_locator(source) + .split('/') + .next_back() + .unwrap_or("app"); + format!("{slug}.AppImage") + } + + pub fn artifact_url(source: &SourceRef) -> String { + let repo = canonical_locator(source); + let artifact_name = Self::artifact_name(source); + + match source.requested_tag.as_deref() { + Some(tag) => { + format!("https://gitlab.com/{repo}/-/releases/{tag}/downloads/{artifact_name}") + } + None => format!( + "https://gitlab.com/{repo}/-/releases/permalink/latest/downloads/{artifact_name}" + ), + } + } +} + impl SourceAdapter for GitLabAdapter { fn id(&self) -> &'static str { "gitlab" @@ -18,6 +42,10 @@ impl SourceAdapter for GitLabAdapter { } } + fn repository_source_kind(&self) -> Option { + Some(SourceKind::GitLab) + } + fn normalize(&self, query: &str) -> Result { let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?; if source.kind != SourceKind::GitLab { @@ -32,12 +60,73 @@ impl SourceAdapter for GitLabAdapter { return Err(AdapterError::UnsupportedSource); } + let resolved_source = resolved_source(source)?; + + let version = resolved_source + .requested_tag + .clone() + .unwrap_or_else(|| "latest".to_owned()); + Ok(AdapterResolution { - source: source.clone(), + source: resolved_source, release: ResolvedRelease { - version: "latest".to_owned(), + version, prerelease: false, }, }) } + + fn resolve_supported_source( + &self, + source: &SourceRef, + ) -> Result { + self.resolve(source).map(AdapterResolveOutcome::Resolved) + } +} + +fn canonical_locator(source: &SourceRef) -> &str { + source + .canonical_locator + .as_deref() + .unwrap_or(source.locator.as_str()) +} + +fn resolved_source(source: &SourceRef) -> Result { + if source.normalized_kind != NormalizedSourceKind::GitLabCandidate { + return Ok(source.clone()); + } + + let canonical_locator = gitlab_locator_path(&source.locator).ok_or_else(|| { + AdapterError::ResolutionFailed( + "gitlab candidate source could not be reduced to a repository path".to_owned(), + ) + })?; + + let mut resolved = source.clone(); + resolved.normalized_kind = NormalizedSourceKind::GitLab; + resolved.canonical_locator = Some(canonical_locator); + resolved.tracks_latest = resolved.requested_tag.is_none(); + Ok(resolved) +} + +fn gitlab_locator_path(locator: &str) -> Option { + let trimmed = locator + .trim_start_matches("https://gitlab.com/") + .trim_start_matches("http://gitlab.com/"); + + if trimmed == locator { + return None; + } + + let path = trimmed + .split(['?', '#']) + .next() + .unwrap_or(trimmed) + .trim_matches('/'); + + if path.is_empty() { + None + } else { + Some(path.to_owned()) + } } diff --git a/crates/aim-core/src/adapters/mod.rs b/crates/aim-core/src/adapters/mod.rs index 6e93726..cc8106c 100644 --- a/crates/aim-core/src/adapters/mod.rs +++ b/crates/aim-core/src/adapters/mod.rs @@ -7,6 +7,9 @@ pub mod test_support; pub mod traits; pub mod zsync; +use crate::adapters::traits::SourceAdapter; +use crate::domain::source::SourceRef; + pub fn all_adapter_kinds() -> Vec<&'static str> { vec![ "github", @@ -17,3 +20,8 @@ pub fn all_adapter_kinds() -> Vec<&'static str> { "custom-json", ] } + +pub fn supports_source(adapter: &A, source: &SourceRef) -> bool { + adapter.repository_source_kind() == Some(source.kind) + || adapter.exact_source_kind() == Some(source.kind) +} diff --git a/crates/aim-core/src/adapters/sourceforge.rs b/crates/aim-core/src/adapters/sourceforge.rs index 566b7f9..2e471e3 100644 --- a/crates/aim-core/src/adapters/sourceforge.rs +++ b/crates/aim-core/src/adapters/sourceforge.rs @@ -1,10 +1,21 @@ use crate::adapters::traits::{ - AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter, + AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter, }; -use crate::domain::source::SourceRef; +use crate::app::query::resolve_query; +use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind, SourceRef}; pub struct SourceForgeAdapter; +impl SourceForgeAdapter { + pub fn artifact_url(source: &SourceRef) -> Option { + if is_resolved_download_locator(&source.locator) { + Some(source.locator.clone()) + } else { + None + } + } +} + impl SourceAdapter for SourceForgeAdapter { fn id(&self) -> &'static str { "sourceforge" @@ -17,11 +28,89 @@ impl SourceAdapter for SourceForgeAdapter { } } - fn normalize(&self, _query: &str) -> Result { - Err(AdapterError::UnsupportedQuery) + fn repository_source_kind(&self) -> Option { + Some(SourceKind::SourceForge) } - fn resolve(&self, _source: &SourceRef) -> Result { - Err(AdapterError::UnsupportedSource) + fn normalize(&self, query: &str) -> Result { + let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?; + if source.kind != SourceKind::SourceForge { + return Err(AdapterError::UnsupportedQuery); + } + + Ok(source) + } + + fn resolve(&self, source: &SourceRef) -> Result { + if source.kind != SourceKind::SourceForge { + return Err(AdapterError::UnsupportedSource); + } + if !is_resolved_download_locator(&source.locator) { + return Err(AdapterError::ResolutionFailed( + "sourceforge source has no concrete latest-download artifact".to_owned(), + )); + } + + Ok(AdapterResolution { + source: resolved_source(source), + release: ResolvedRelease { + version: "latest".to_owned(), + prerelease: false, + }, + }) + } + + fn resolve_supported_source( + &self, + source: &SourceRef, + ) -> Result { + if Self::artifact_url(source).is_some() { + return self.resolve(source).map(AdapterResolveOutcome::Resolved); + } + + if matches!( + source.normalized_kind, + NormalizedSourceKind::SourceForge | NormalizedSourceKind::SourceForgeCandidate + ) { + return Ok(AdapterResolveOutcome::NoInstallableArtifact { + source: source.clone(), + }); + } + + Ok(AdapterResolveOutcome::NoInstallableArtifact { + source: source.clone(), + }) } } + +fn resolved_source(source: &SourceRef) -> SourceRef { + let mut resolved = source.clone(); + if is_sourceforge_stable_download_locator(&resolved.locator) { + resolved.normalized_kind = NormalizedSourceKind::SourceForge; + resolved.tracks_latest = true; + } + + resolved +} + +fn is_resolved_download_locator(locator: &str) -> bool { + is_latest_download_locator(locator) || is_sourceforge_stable_download_locator(locator) +} + +fn is_latest_download_locator(locator: &str) -> bool { + let trimmed = locator + .split(['?', '#']) + .next() + .unwrap_or(locator) + .trim_end_matches('/'); + trimmed.ends_with("/files/latest/download") +} + +fn is_sourceforge_stable_download_locator(locator: &str) -> bool { + let trimmed = locator + .split(['?', '#']) + .next() + .unwrap_or(locator) + .trim_end_matches('/'); + trimmed.ends_with("/files/releases/stable/download") +} diff --git a/crates/aim-core/src/adapters/traits.rs b/crates/aim-core/src/adapters/traits.rs index 6f0cc77..0a2c1b6 100644 --- a/crates/aim-core/src/adapters/traits.rs +++ b/crates/aim-core/src/adapters/traits.rs @@ -1,5 +1,4 @@ -use crate::domain::source::ResolvedRelease; -use crate::domain::source::SourceRef; +use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AdapterCapabilities { @@ -22,6 +21,12 @@ pub struct AdapterResolution { pub release: ResolvedRelease, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AdapterResolveOutcome { + Resolved(AdapterResolution), + NoInstallableArtifact { source: SourceRef }, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum AdapterError { UnsupportedQuery, @@ -34,7 +39,34 @@ pub trait SourceAdapter { fn capabilities(&self) -> AdapterCapabilities; + fn repository_source_kind(&self) -> Option { + None + } + + fn exact_source_kind(&self) -> Option { + None + } + fn normalize(&self, query: &str) -> Result; fn resolve(&self, source: &SourceRef) -> Result; + + fn resolve_supported_source( + &self, + source: &SourceRef, + ) -> Result { + self.resolve(source).map(AdapterResolveOutcome::Resolved) + } + + fn supports_source(&self, source: &SourceRef) -> bool { + crate::adapters::supports_source(self, source) + } + + fn resolve_source(&self, source: &SourceRef) -> Result { + if !self.supports_source(source) { + return Err(AdapterError::UnsupportedSource); + } + + self.resolve_supported_source(source) + } } diff --git a/crates/aim-core/src/app/add.rs b/crates/aim-core/src/app/add.rs index 8bd9923..003a138 100644 --- a/crates/aim-core/src/app/add.rs +++ b/crates/aim-core/src/app/add.rs @@ -2,7 +2,11 @@ use std::env; use std::io::Read; use std::path::{Path, PathBuf}; +use crate::adapters::direct_url::DirectUrlAdapter; +use crate::adapters::gitlab::GitLabAdapter; +use crate::adapters::sourceforge::SourceForgeAdapter; use crate::adapters::traits::AdapterResolution; +use crate::adapters::traits::{AdapterResolveOutcome, SourceAdapter}; use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity}; use crate::app::interaction::{InteractionKind, InteractionRequest}; use crate::app::progress::{ @@ -110,6 +114,115 @@ pub fn build_add_plan_with_reporter( strategy, ) } + SourceKind::GitLab => { + reporter.report(&OperationEvent::StageChanged { + stage: OperationStage::DiscoverRelease, + message: "discovering release".to_owned(), + }); + let adapter = GitLabAdapter; + let resolution = match adapter + .resolve_source(&source) + .map_err(|error| BuildAddPlanError::Adapter("gitlab", error))? + { + AdapterResolveOutcome::Resolved(resolution) => resolution, + AdapterResolveOutcome::NoInstallableArtifact { source } => { + return Err(BuildAddPlanError::NoInstallableArtifact { source }); + } + }; + + reporter.report(&OperationEvent::StageChanged { + stage: OperationStage::SelectArtifact, + message: "selecting artifact".to_owned(), + }); + let artifact_url = GitLabAdapter::artifact_url(&resolution.source); + let strategy = UpdateStrategy { + preferred: crate::domain::update::ChannelPreference { + kind: crate::domain::update::UpdateChannelKind::DirectAsset, + locator: artifact_url.clone(), + reason: "provider-release".to_owned(), + }, + alternates: Vec::new(), + }; + let artifact = ArtifactCandidate { + url: artifact_url, + version: resolution.release.version.clone(), + arch: None, + selection_reason: "provider-release".to_owned(), + }; + + (resolution, artifact, strategy) + } + SourceKind::DirectUrl => { + reporter.report(&OperationEvent::StageChanged { + stage: OperationStage::SelectArtifact, + message: "selecting artifact".to_owned(), + }); + let adapter = DirectUrlAdapter; + let resolution = match adapter + .resolve_source(&source) + .map_err(|error| BuildAddPlanError::Adapter("direct-url", error))? + { + AdapterResolveOutcome::Resolved(resolution) => resolution, + AdapterResolveOutcome::NoInstallableArtifact { source } => { + return Err(BuildAddPlanError::NoInstallableArtifact { source }); + } + }; + let artifact = ArtifactCandidate { + url: resolution.source.locator.clone(), + version: resolution.release.version.clone(), + arch: None, + selection_reason: "exact-input".to_owned(), + }; + let strategy = UpdateStrategy { + preferred: crate::domain::update::ChannelPreference { + kind: crate::domain::update::UpdateChannelKind::DirectAsset, + locator: resolution.source.locator.clone(), + reason: "exact-input".to_owned(), + }, + alternates: Vec::new(), + }; + + (resolution, artifact, strategy) + } + SourceKind::SourceForge => { + reporter.report(&OperationEvent::StageChanged { + stage: OperationStage::DiscoverRelease, + message: "discovering release".to_owned(), + }); + let adapter = SourceForgeAdapter; + let resolution = match adapter + .resolve_source(&source) + .map_err(|error| BuildAddPlanError::Adapter("sourceforge", error))? + { + AdapterResolveOutcome::Resolved(resolution) => resolution, + AdapterResolveOutcome::NoInstallableArtifact { source } => { + return Err(BuildAddPlanError::NoInstallableArtifact { source }); + } + }; + + reporter.report(&OperationEvent::StageChanged { + stage: OperationStage::SelectArtifact, + message: "selecting artifact".to_owned(), + }); + let artifact_url = SourceForgeAdapter::artifact_url(&resolution.source) + .ok_or(BuildAddPlanError::NoCandidates)?; + let artifact = ArtifactCandidate { + url: artifact_url.clone(), + version: resolution.release.version.clone(), + arch: None, + selection_reason: "provider-release".to_owned(), + }; + let strategy = UpdateStrategy { + preferred: crate::domain::update::ChannelPreference { + kind: crate::domain::update::UpdateChannelKind::DirectAsset, + locator: artifact_url, + reason: "provider-release".to_owned(), + }, + alternates: Vec::new(), + }; + + (resolution, artifact, strategy) + } _ => { reporter.report(&OperationEvent::StageChanged { stage: OperationStage::SelectArtifact, @@ -367,7 +480,11 @@ pub struct InstalledApp { #[derive(Debug)] pub enum BuildAddPlanError { Query(ResolveQueryError), + Adapter(&'static str, crate::adapters::traits::AdapterError), GitHubDiscovery(GitHubDiscoveryError), + NoInstallableArtifact { + source: crate::domain::source::SourceRef, + }, NoCandidates, } diff --git a/crates/aim-core/src/app/update.rs b/crates/aim-core/src/app/update.rs index 129fde1..f8687b1 100644 --- a/crates/aim-core/src/app/update.rs +++ b/crates/aim-core/src/app/update.rs @@ -5,6 +5,7 @@ use crate::app::progress::{ NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter, }; use crate::domain::app::{AppRecord, InstallScope}; +use crate::domain::source::SourceKind; use crate::domain::update::{ ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult, UpdateExecutionStatus, UpdatePlan, @@ -116,15 +117,7 @@ fn plan_update(app: &AppRecord) -> PlannedUpdate { } } else { ( - ChannelPreference { - kind: UpdateChannelKind::GitHubReleases, - locator: app - .source - .as_ref() - .map(|source| source.locator.clone()) - .unwrap_or_else(|| app.stable_id.clone()), - reason: "install-origin-match".to_owned(), - }, + fallback_channel_preference(app), "install-origin-match".to_owned(), ) }; @@ -137,6 +130,35 @@ fn plan_update(app: &AppRecord) -> PlannedUpdate { } } +fn fallback_channel_preference(app: &AppRecord) -> ChannelPreference { + let Some(source) = app.source.as_ref() else { + return ChannelPreference { + kind: UpdateChannelKind::GitHubReleases, + locator: app.stable_id.clone(), + reason: "install-origin-match".to_owned(), + }; + }; + + let (kind, locator) = match source.kind { + SourceKind::GitHub => ( + UpdateChannelKind::GitHubReleases, + source + .canonical_locator + .clone() + .unwrap_or_else(|| source.locator.clone()), + ), + SourceKind::GitLab | SourceKind::SourceForge | SourceKind::DirectUrl | SourceKind::File => { + (UpdateChannelKind::DirectAsset, source.locator.clone()) + } + }; + + ChannelPreference { + kind, + locator, + reason: "install-origin-match".to_owned(), + } +} + fn execute_update( app: &AppRecord, install_home: &Path, diff --git a/crates/aim-core/src/domain/source.rs b/crates/aim-core/src/domain/source.rs index 980c7ff..93db015 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, + SourceForge, DirectUrl, File, } @@ -11,6 +12,7 @@ impl SourceKind { match self { Self::GitHub => "github", Self::GitLab => "gitlab", + Self::SourceForge => "sourceforge", Self::DirectUrl => "direct-url", Self::File => "file", } @@ -24,6 +26,7 @@ pub enum SourceInputKind { GitHubReleaseUrl, GitHubReleaseAssetUrl, GitLabUrl, + SourceForgeUrl, DirectUrl, File, } @@ -36,6 +39,7 @@ impl SourceInputKind { Self::GitHubReleaseUrl => "github-release-url", Self::GitHubReleaseAssetUrl => "github-release-asset-url", Self::GitLabUrl => "gitlab-url", + Self::SourceForgeUrl => "sourceforge-url", Self::DirectUrl => "direct-url", Self::File => "file", } @@ -48,6 +52,9 @@ pub enum NormalizedSourceKind { GitHubRelease, GitHubReleaseAsset, GitLab, + GitLabCandidate, + SourceForge, + SourceForgeCandidate, DirectUrl, File, } @@ -59,6 +66,9 @@ impl NormalizedSourceKind { Self::GitHubRelease => "github-release", Self::GitHubReleaseAsset => "github-release-asset", Self::GitLab => "gitlab", + Self::GitLabCandidate => "gitlab-candidate", + Self::SourceForge => "sourceforge", + Self::SourceForgeCandidate => "sourceforge-candidate", Self::DirectUrl => "direct-url", Self::File => "file", } diff --git a/crates/aim-core/src/source/input.rs b/crates/aim-core/src/source/input.rs index c89cac6..8c7b81a 100644 --- a/crates/aim-core/src/source/input.rs +++ b/crates/aim-core/src/source/input.rs @@ -45,17 +45,12 @@ pub fn classify_input(query: &str) -> Result Option> { + let trimmed = query + .trim_start_matches("https://gitlab.com/") + .trim_start_matches("http://gitlab.com/"); + if trimmed == query { + return None; + } + + let trimmed = trim_query_and_fragment(trimmed); + + let parts = trimmed + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + if parts.len() < 2 { + return Some(Err(ClassifyInputError::Unsupported)); + } + + let release_marker = parts.iter().position(|segment| *segment == "-"); + let is_repository_url = release_marker.is_none() && is_supported_gitlab_repo_path(&parts); + let is_release_like_url = matches!(release_marker, Some(index) if index >= 2) + && parts.get(release_marker.unwrap() + 1) == Some(&"releases") + && parts.get(release_marker.unwrap() + 2).is_some() + && parts.len() == release_marker.unwrap() + 3; + let is_ambiguous_candidate = + release_marker.is_none() && is_ambiguous_gitlab_candidate_path(&parts); + if !is_repository_url && !is_release_like_url && !is_ambiguous_candidate { + return Some(Err(ClassifyInputError::Unsupported)); + } + + let canonical_parts = if let Some(index) = release_marker { + &parts[..index] + } else { + &parts[..] + }; + let canonical_locator = canonical_parts.join("/"); + let requested_tag = if let Some(index) = release_marker { + parts.get(index + 2).map(|value| (*value).to_owned()) + } else { + None + }; + let tracks_latest = requested_tag.is_none() && !is_ambiguous_candidate; + + Some(Ok(ClassifiedInput { + kind: SourceInputKind::GitLabUrl, + source_kind: SourceKind::GitLab, + normalized_kind: if is_ambiguous_candidate { + NormalizedSourceKind::GitLabCandidate + } else { + NormalizedSourceKind::GitLab + }, + locator: query.to_owned(), + canonical_locator: if is_ambiguous_candidate { + None + } else { + Some(canonical_locator) + }, + requested_tag, + requested_asset_name: None, + tracks_latest, + })) +} + +fn classify_sourceforge_http(query: &str) -> Option> { + let trimmed = query + .trim_start_matches("https://sourceforge.net/projects/") + .trim_start_matches("http://sourceforge.net/projects/"); + if trimmed == query { + return None; + } + + let trimmed = trim_query_and_fragment(trimmed); + + let parts = trimmed + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + let Some(project) = parts.first() else { + return Some(Err(ClassifyInputError::Unsupported)); + }; + + let is_project_url = parts.len() == 1; + let is_latest_download_url = + parts.len() == 4 && parts[1] == "files" && parts[2] == "latest" && parts[3] == "download"; + let is_root_file_download_url = parts.len() == 4 + && parts[1] == "files" + && parts[3] == "download" + && !matches!(parts[2], "latest" | "releases"); + let is_nested_file_download_url = parts.len() > 4 + && parts[1] == "files" + && parts.last() == Some(&"download") + && parts + .get(parts.len().saturating_sub(2)) + .is_some_and(|segment| segment.contains('.')); + let is_ambiguous_candidate = is_ambiguous_sourceforge_candidate_path(&parts); + let is_concrete_download_url = + !is_latest_download_url && (is_root_file_download_url || is_nested_file_download_url); + if is_concrete_download_url { + return Some(Ok(ClassifiedInput { + kind: SourceInputKind::DirectUrl, + source_kind: SourceKind::DirectUrl, + normalized_kind: NormalizedSourceKind::DirectUrl, + locator: query.to_owned(), + canonical_locator: None, + requested_tag: None, + requested_asset_name: None, + tracks_latest: false, + })); + } + if !is_project_url && !is_latest_download_url && !is_ambiguous_candidate { + return Some(Err(ClassifyInputError::Unsupported)); + } + + Some(Ok(ClassifiedInput { + kind: SourceInputKind::SourceForgeUrl, + source_kind: SourceKind::SourceForge, + normalized_kind: if is_ambiguous_candidate { + NormalizedSourceKind::SourceForgeCandidate + } else { + NormalizedSourceKind::SourceForge + }, + locator: query.to_owned(), + canonical_locator: Some((*project).to_owned()), + requested_tag: None, + requested_asset_name: None, + tracks_latest: is_project_url || is_latest_download_url, + })) +} + +fn trim_query_and_fragment(value: &str) -> &str { + value.split(['?', '#']).next().unwrap_or(value) +} + +fn is_supported_gitlab_repo_path(parts: &[&str]) -> bool { + if parts.len() < 2 { + return false; + } + + if parts.len() == 2 { + return true; + } + + if parts.len() == 3 { + return !is_reserved_gitlab_resource_segment(parts[2]); + } + + if parts[2..] + .iter() + .copied() + .any(is_reserved_gitlab_resource_segment) + { + return false; + } + + true +} + +fn is_reserved_gitlab_resource_segment(segment: &str) -> bool { + matches!( + segment, + "issues" + | "merge_requests" + | "releases" + | "tags" + | "blob" + | "tree" + | "commits" + | "packages" + | "archive" + | "raw" + | "pipelines" + | "jobs" + | "wikis" + | "snippets" + ) +} + +fn is_ambiguous_gitlab_candidate_path(parts: &[&str]) -> bool { + parts.len() == 4 && parts[2] == "releases" +} + +fn is_ambiguous_sourceforge_candidate_path(parts: &[&str]) -> bool { + parts.len() == 5 + && parts[1] == "files" + && parts[2] == "releases" + && (parts[3] == "stable" || is_version_like_sourceforge_folder(parts[3])) + && parts[4] == "download" +} + +fn is_version_like_sourceforge_folder(segment: &str) -> bool { + segment.starts_with('v') && segment.chars().any(|character| character.is_ascii_digit()) +} + fn classify_github_http(query: &str) -> Option { let trimmed = query .trim_start_matches("https://github.com/") diff --git a/crates/aim-core/tests/adapter_contract.rs b/crates/aim-core/tests/adapter_contract.rs index 328a803..0b1ccca 100644 --- a/crates/aim-core/tests/adapter_contract.rs +++ b/crates/aim-core/tests/adapter_contract.rs @@ -1,6 +1,57 @@ +use aim_core::adapters::direct_url::DirectUrlAdapter; use aim_core::adapters::github::GitHubAdapter; use aim_core::adapters::gitlab::GitLabAdapter; -use aim_core::adapters::traits::{AdapterCapabilities, SourceAdapter}; +use aim_core::adapters::sourceforge::SourceForgeAdapter; +use aim_core::adapters::traits::{ + AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter, +}; +use aim_core::app::query::resolve_query; +use aim_core::domain::source::{ + NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef, +}; + +struct FileArtifactAdapter; + +impl SourceAdapter for FileArtifactAdapter { + fn id(&self) -> &'static str { + "file" + } + + fn capabilities(&self) -> AdapterCapabilities { + AdapterCapabilities::exact_resolution_only() + } + + fn exact_source_kind(&self) -> Option { + Some(SourceKind::File) + } + + fn normalize(&self, _query: &str) -> Result { + Err(AdapterError::UnsupportedQuery) + } + + fn resolve(&self, source: &SourceRef) -> Result { + Ok(AdapterResolution { + source: source.clone(), + release: ResolvedRelease { + version: "file".to_owned(), + prerelease: false, + }, + }) + } +} + +fn file_source() -> SourceRef { + SourceRef { + kind: SourceKind::File, + locator: "/tmp/team-app.AppImage".to_owned(), + input_kind: SourceInputKind::File, + normalized_kind: NormalizedSourceKind::File, + canonical_locator: None, + requested_tag: None, + requested_asset_name: None, + tracks_latest: false, + } +} #[test] fn adapter_capabilities_can_report_exact_resolution_only() { @@ -8,6 +59,200 @@ fn adapter_capabilities_can_report_exact_resolution_only() { assert!(!capabilities.supports_search); } +#[test] +fn repository_backed_resolvers_accept_only_their_own_source_kind() { + let github_source = resolve_query("sharkdp/bat").unwrap(); + let gitlab_source = resolve_query("https://gitlab.com/example/team/app").unwrap(); + + let github_adapter: &dyn SourceAdapter = &GitHubAdapter; + assert!(github_adapter.supports_source(&github_source)); + assert!(!github_adapter.supports_source(&gitlab_source)); + assert_eq!( + github_adapter.resolve_source(&gitlab_source), + Err(AdapterError::UnsupportedSource) + ); + + let gitlab_adapter: &dyn SourceAdapter = &GitLabAdapter; + assert!(gitlab_adapter.supports_source(&gitlab_source)); + assert!(!gitlab_adapter.supports_source(&github_source)); + assert_eq!( + gitlab_adapter.resolve_source(&github_source), + Err(AdapterError::UnsupportedSource) + ); +} + +#[test] +fn exact_resolution_resolvers_accept_only_exact_artifact_kinds() { + let direct_url_adapter: &dyn SourceAdapter = &DirectUrlAdapter; + let file_adapter: &dyn SourceAdapter = &FileArtifactAdapter; + let direct_url_source = resolve_query("https://example.com/team-app.AppImage").unwrap(); + let github_source = resolve_query("sharkdp/bat").unwrap(); + let file_source = file_source(); + + assert!(direct_url_adapter.supports_source(&direct_url_source)); + assert!(!direct_url_adapter.supports_source(&file_source)); + assert!(!direct_url_adapter.supports_source(&github_source)); + assert_eq!( + direct_url_adapter.resolve_source(&github_source), + Err(AdapterError::UnsupportedSource) + ); + assert_eq!( + direct_url_adapter.resolve_source(&file_source), + Err(AdapterError::UnsupportedSource) + ); + + let direct_resolution = direct_url_adapter + .resolve_source(&direct_url_source) + .unwrap(); + assert!(matches!( + direct_resolution, + AdapterResolveOutcome::Resolved(AdapterResolution { + release: ResolvedRelease { version, .. }, + .. + }) if version == "unresolved" + )); + + assert!(file_adapter.supports_source(&file_source)); + assert!(!file_adapter.supports_source(&direct_url_source)); + assert!(!file_adapter.supports_source(&github_source)); + assert_eq!( + file_adapter.resolve_source(&direct_url_source), + Err(AdapterError::UnsupportedSource) + ); + + let file_resolution = file_adapter.resolve_source(&file_source).unwrap(); + assert!(matches!( + file_resolution, + AdapterResolveOutcome::Resolved(AdapterResolution { + source, + release: ResolvedRelease { version, .. }, + }) if source.kind == SourceKind::File && version == "file" + )); +} + +#[test] +fn resolvers_can_return_no_installable_artifact_without_looking_unsupported() { + let adapter: &dyn SourceAdapter = &SourceForgeAdapter; + let source = resolve_query("https://sourceforge.net/projects/team-app/").unwrap(); + + let resolution = adapter.resolve_source(&source).unwrap(); + + assert_eq!( + resolution, + AdapterResolveOutcome::NoInstallableArtifact { source } + ); +} + +#[test] +fn no_installable_artifact_outcomes_still_reject_unsupported_source_kinds() { + let adapter: &dyn SourceAdapter = &SourceForgeAdapter; + let unsupported_source = resolve_query("sharkdp/bat").unwrap(); + + assert_eq!( + adapter.resolve_source(&unsupported_source), + Err(AdapterError::UnsupportedSource) + ); +} + +#[test] +fn sourceforge_latest_download_sources_resolve_through_trait() { + let adapter: &dyn SourceAdapter = &SourceForgeAdapter; + + let result = adapter + .normalize("https://sourceforge.net/projects/team-app/files/latest/download") + .unwrap(); + + assert_eq!(result.kind, SourceKind::SourceForge); + + let resolution = adapter.resolve_source(&result).unwrap(); + assert!(matches!( + resolution, + AdapterResolveOutcome::Resolved(AdapterResolution { + source, + release: ResolvedRelease { version, .. }, + }) if source.kind == SourceKind::SourceForge + && source.locator == "https://sourceforge.net/projects/team-app/files/latest/download" + && version == "latest" + )); +} + +#[test] +fn gitlab_candidate_sources_can_resolve_to_repository_semantics() { + let adapter: &dyn SourceAdapter = &GitLabAdapter; + + let result = adapter + .normalize("https://gitlab.com/acme/platform/releases/team-app") + .unwrap(); + + assert_eq!(result.kind, SourceKind::GitLab); + assert_eq!( + result.normalized_kind, + NormalizedSourceKind::GitLabCandidate + ); + + let resolution = adapter.resolve_source(&result).unwrap(); + assert!(matches!( + resolution, + AdapterResolveOutcome::Resolved(AdapterResolution { + source, + release: ResolvedRelease { version, .. }, + }) if source.kind == SourceKind::GitLab + && source.locator == "https://gitlab.com/acme/platform/releases/team-app" + && source.canonical_locator.as_deref() == Some("acme/platform/releases/team-app") + && source.normalized_kind == NormalizedSourceKind::GitLab + && source.tracks_latest + && version == "latest" + )); +} + +#[test] +fn sourceforge_candidate_sources_can_resolve_to_latest_download() { + let adapter: &dyn SourceAdapter = &SourceForgeAdapter; + + let result = adapter + .normalize("https://sourceforge.net/projects/team-app/files/releases/stable/download") + .unwrap(); + + assert_eq!(result.kind, SourceKind::SourceForge); + assert_eq!( + result.normalized_kind, + NormalizedSourceKind::SourceForgeCandidate + ); + + let resolution = adapter.resolve_source(&result).unwrap(); + assert!(matches!( + resolution, + AdapterResolveOutcome::Resolved(AdapterResolution { + source, + release: ResolvedRelease { version, .. }, + }) if source.kind == SourceKind::SourceForge + && source.locator + == "https://sourceforge.net/projects/team-app/files/releases/stable/download" + && version == "latest" + )); +} + +#[test] +fn sourceforge_version_folder_candidates_can_return_no_installable_artifact() { + let adapter: &dyn SourceAdapter = &SourceForgeAdapter; + + let result = adapter + .normalize("https://sourceforge.net/projects/team-app/files/releases/v1-0/download") + .unwrap(); + + assert_eq!(result.kind, SourceKind::SourceForge); + assert_eq!( + result.normalized_kind, + NormalizedSourceKind::SourceForgeCandidate + ); + + let resolution = adapter.resolve_source(&result).unwrap(); + assert_eq!( + resolution, + AdapterResolveOutcome::NoInstallableArtifact { source: result } + ); +} + #[test] fn legacy_github_adapter_delegates_to_source_pipeline() { let adapter: &dyn SourceAdapter = &GitHubAdapter; diff --git a/crates/aim-core/tests/install_failures.rs b/crates/aim-core/tests/install_failures.rs index c47f475..1495a7f 100644 --- a/crates/aim-core/tests/install_failures.rs +++ b/crates/aim-core/tests/install_failures.rs @@ -1,5 +1,9 @@ +use aim_core::app::add::{BuildAddPlanError, build_add_plan_with}; +use aim_core::app::query::ResolveQueryError; +use aim_core::domain::source::SourceKind; use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install}; use aim_core::platform::DesktopHelpers; +use aim_core::source::github::FixtureGitHubTransport; use std::fs; use tempfile::tempdir; @@ -34,3 +38,55 @@ fn integration_failure_removes_new_payload_and_generated_files() { assert!(!final_payload_path.exists()); assert!(!desktop_entry_path.exists()); } + +#[test] +fn unsupported_queries_remain_distinct_from_provider_resolution_failures() { + let error = + build_add_plan_with("https://gitlab.com/example", &FixtureGitHubTransport).unwrap_err(); + + assert!(matches!( + error, + BuildAddPlanError::Query(ResolveQueryError::Unsupported) + )); +} + +#[test] +fn supported_sourceforge_project_without_latest_download_reports_no_installable_artifact() { + let error = build_add_plan_with( + "https://sourceforge.net/projects/team-app/", + &FixtureGitHubTransport, + ) + .unwrap_err(); + + match error { + BuildAddPlanError::NoInstallableArtifact { source } => { + assert_eq!(source.kind, SourceKind::SourceForge); + assert_eq!(source.locator, "https://sourceforge.net/projects/team-app/"); + assert_eq!(source.canonical_locator.as_deref(), Some("team-app")); + } + other => panic!("expected no-installable-artifact error, got {other:?}"), + } +} + +#[test] +fn supported_sourceforge_version_folder_candidate_without_installable_artifact_reports_no_installable_artifact() + { + let error = build_add_plan_with( + "https://sourceforge.net/projects/team-app/files/releases/v1-0/download", + &FixtureGitHubTransport, + ) + .unwrap_err(); + + match error { + BuildAddPlanError::NoInstallableArtifact { source } => { + assert_eq!(source.kind, SourceKind::SourceForge); + assert_eq!( + source.locator, + "https://sourceforge.net/projects/team-app/files/releases/v1-0/download" + ); + assert_eq!(source.canonical_locator.as_deref(), Some("team-app")); + assert_eq!(source.normalized_kind.as_str(), "sourceforge-candidate"); + } + other => panic!("expected no-installable-artifact error, got {other:?}"), + } +} diff --git a/crates/aim-core/tests/install_integration.rs b/crates/aim-core/tests/install_integration.rs index 39f6844..3d79d54 100644 --- a/crates/aim-core/tests/install_integration.rs +++ b/crates/aim-core/tests/install_integration.rs @@ -1,6 +1,7 @@ use aim_core::app::add::{build_add_plan_with_reporter, install_app_with_reporter}; use aim_core::app::progress::{OperationEvent, OperationStage}; use aim_core::domain::app::InstallScope; +use aim_core::domain::source::{NormalizedSourceKind, SourceKind}; use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install}; use aim_core::platform::DesktopHelpers; use aim_core::source::github::FixtureGitHubTransport; @@ -226,3 +227,207 @@ fn install_app_reports_operation_stages_in_order() { ] })); } + +#[test] +fn gitlab_source_builds_concrete_install_candidate() { + let mut events: Vec = Vec::new(); + let mut reporter = |event: &OperationEvent| events.push(event.clone()); + + let plan = build_add_plan_with_reporter( + "https://gitlab.com/example/team-app", + &FixtureGitHubTransport, + &mut reporter, + ) + .unwrap(); + + assert_eq!(plan.resolution.source.kind, SourceKind::GitLab); + assert_eq!( + plan.resolution.source.locator, + "https://gitlab.com/example/team-app" + ); + assert_eq!(plan.resolution.release.version, "latest"); + assert_eq!( + plan.selected_artifact.url, + "https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage" + ); + assert_eq!(plan.selected_artifact.version, "latest"); + assert_eq!(plan.selected_artifact.selection_reason, "provider-release"); + assert!(events.contains(&OperationEvent::StageChanged { + stage: OperationStage::DiscoverRelease, + message: "discovering release".to_owned(), + })); +} + +#[test] +fn gitlab_candidate_builds_concrete_install_candidate() { + let mut events: Vec = Vec::new(); + let mut reporter = |event: &OperationEvent| events.push(event.clone()); + + let query = "https://gitlab.com/acme/platform/releases/team-app"; + let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap(); + + assert_eq!(plan.resolution.source.kind, SourceKind::GitLab); + assert_eq!(plan.resolution.source.locator, query); + assert_eq!( + plan.resolution.source.canonical_locator.as_deref(), + Some("acme/platform/releases/team-app") + ); + assert_eq!( + plan.resolution.source.normalized_kind, + NormalizedSourceKind::GitLab + ); + assert_eq!(plan.resolution.release.version, "latest"); + assert_eq!( + plan.selected_artifact.url, + "https://gitlab.com/acme/platform/releases/team-app/-/releases/permalink/latest/downloads/team-app.AppImage" + ); + assert_eq!(plan.selected_artifact.version, "latest"); + assert_eq!(plan.selected_artifact.selection_reason, "provider-release"); + assert!(events.contains(&OperationEvent::StageChanged { + stage: OperationStage::DiscoverRelease, + message: "discovering release".to_owned(), + })); +} + +#[test] +fn gitlab_install_preserves_truthful_gitlab_origin() { + let root = tempdir().unwrap(); + + unsafe { + std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); + } + + let mut reporter = |_event: &OperationEvent| {}; + let query = "https://gitlab.com/example/team-app"; + let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap(); + + let installed = + install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter) + .unwrap(); + + assert_eq!(installed.record.source_input.as_deref(), Some(query)); + assert_eq!( + installed.record.installed_version.as_deref(), + Some("latest") + ); + assert_eq!(installed.source.kind, SourceKind::GitLab); + assert_eq!(installed.source.locator, query); + assert_eq!( + installed.source.canonical_locator.as_deref(), + Some("example/team-app") + ); + assert_eq!( + installed.selected_artifact.url, + "https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage" + ); +} + +#[test] +fn direct_url_source_uses_exact_input_resolution() { + let mut reporter = |_event: &OperationEvent| {}; + let query = "https://example.com/downloads/team-app.AppImage"; + + let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap(); + + assert_eq!(plan.resolution.source.kind, SourceKind::DirectUrl); + assert_eq!(plan.resolution.source.locator, query); + assert_eq!(plan.resolution.release.version, "unresolved"); + assert_eq!(plan.selected_artifact.url, query); + assert_eq!(plan.selected_artifact.version, "unresolved"); + assert_eq!(plan.selected_artifact.selection_reason, "exact-input"); + assert_eq!(plan.update_strategy.preferred.locator, query); + assert_eq!(plan.update_strategy.preferred.reason, "exact-input"); +} + +#[test] +fn direct_url_install_preserves_truthful_direct_url_origin() { + let root = tempdir().unwrap(); + + unsafe { + std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); + } + + let mut reporter = |_event: &OperationEvent| {}; + let query = "https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download"; + let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap(); + + let installed = + install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter) + .unwrap(); + + assert_eq!(installed.record.source_input.as_deref(), Some(query)); + assert_eq!( + installed.record.installed_version.as_deref(), + Some("unresolved") + ); + assert_eq!(installed.source.kind, SourceKind::DirectUrl); + assert_eq!(installed.source.locator, query); + assert_eq!(installed.selected_artifact.url, query); +} + +#[test] +fn sourceforge_candidate_builds_concrete_install_candidate() { + let mut events: Vec = Vec::new(); + let mut reporter = |event: &OperationEvent| events.push(event.clone()); + + let query = "https://sourceforge.net/projects/team-app/files/releases/stable/download"; + let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap(); + + assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge); + assert_eq!(plan.resolution.source.locator, query); + assert_eq!(plan.resolution.release.version, "latest"); + assert_eq!(plan.selected_artifact.url, query); + assert_eq!(plan.selected_artifact.version, "latest"); + assert_eq!(plan.selected_artifact.selection_reason, "provider-release"); + assert_eq!(plan.update_strategy.preferred.locator, query); + assert_eq!(plan.update_strategy.preferred.reason, "provider-release"); + assert!(events.contains(&OperationEvent::StageChanged { + stage: OperationStage::DiscoverRelease, + message: "discovering release".to_owned(), + })); +} + +#[test] +fn sourceforge_latest_download_builds_concrete_install_candidate() { + let mut reporter = |_event: &OperationEvent| {}; + let query = "https://sourceforge.net/projects/team-app/files/latest/download"; + + let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap(); + + assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge); + assert_eq!(plan.resolution.source.locator, query); + assert_eq!(plan.resolution.release.version, "latest"); + assert_eq!(plan.selected_artifact.url, query); + assert_eq!(plan.selected_artifact.version, "latest"); + assert_eq!(plan.selected_artifact.selection_reason, "provider-release"); +} + +#[test] +fn sourceforge_latest_download_install_preserves_truthful_origin() { + let root = tempdir().unwrap(); + + unsafe { + std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); + } + + let mut reporter = |_event: &OperationEvent| {}; + let query = "https://sourceforge.net/projects/team-app/files/latest/download"; + let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap(); + + let installed = + install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter) + .unwrap(); + + assert_eq!(installed.record.source_input.as_deref(), Some(query)); + assert_eq!( + installed.record.installed_version.as_deref(), + Some("latest") + ); + assert_eq!(installed.source.kind, SourceKind::SourceForge); + assert_eq!(installed.source.locator, query); + assert_eq!( + installed.source.canonical_locator.as_deref(), + Some("team-app") + ); + assert_eq!(installed.selected_artifact.url, query); +} diff --git a/crates/aim-core/tests/query_resolution.rs b/crates/aim-core/tests/query_resolution.rs index 05d00f3..cbef5c7 100644 --- a/crates/aim-core/tests/query_resolution.rs +++ b/crates/aim-core/tests/query_resolution.rs @@ -25,3 +25,286 @@ fn classifies_github_release_asset_url() { NormalizedSourceKind::GitHubReleaseAsset ); } + +#[test] +fn classifies_gitlab_repository_url() { + let source = resolve_query("https://gitlab.com/example/team-app").unwrap(); + + assert_eq!(source.kind, SourceKind::GitLab); + assert_eq!(source.input_kind, SourceInputKind::GitLabUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::GitLab); + assert_eq!( + source.canonical_locator.as_deref(), + Some("example/team-app") + ); + assert!(source.tracks_latest); +} + +#[test] +fn classifies_gitlab_release_like_url() { + let source = resolve_query("https://gitlab.com/example/team-app/-/releases/v1.2.3").unwrap(); + + assert_eq!(source.kind, SourceKind::GitLab); + assert_eq!(source.input_kind, SourceInputKind::GitLabUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::GitLab); + assert_eq!( + source.canonical_locator.as_deref(), + Some("example/team-app") + ); + assert_eq!(source.requested_tag.as_deref(), Some("v1.2.3")); + assert!(!source.tracks_latest); +} + +#[test] +fn classifies_gitlab_subgroup_repository_url() { + let source = resolve_query("https://gitlab.com/example/platform/team-app").unwrap(); + + assert_eq!(source.kind, SourceKind::GitLab); + assert_eq!( + source.canonical_locator.as_deref(), + Some("example/platform/team-app") + ); + assert!(source.tracks_latest); +} + +#[test] +fn classifies_gitlab_deep_subgroup_repository_url() { + let source = resolve_query("https://gitlab.com/example/platform/apps/team-app").unwrap(); + + assert_eq!(source.kind, SourceKind::GitLab); + assert_eq!( + source.canonical_locator.as_deref(), + Some("example/platform/apps/team-app") + ); + assert!(source.tracks_latest); +} + +#[test] +fn classifies_gitlab_repository_with_reserved_namespace_segment() { + let source = resolve_query("https://gitlab.com/example/releases/team-app").unwrap(); + + assert_eq!(source.kind, SourceKind::GitLab); + assert_eq!( + source.canonical_locator.as_deref(), + Some("example/releases/team-app") + ); +} + +#[test] +fn classifies_gitlab_two_segment_repository_with_reserved_slug() { + let source = resolve_query("https://gitlab.com/example/issues").unwrap(); + + assert_eq!(source.kind, SourceKind::GitLab); + assert_eq!(source.canonical_locator.as_deref(), Some("example/issues")); + assert!(source.tracks_latest); +} + +#[test] +fn classifies_sourceforge_project_url() { + let source = resolve_query("https://sourceforge.net/projects/team-app/").unwrap(); + + assert_eq!(source.kind, SourceKind::SourceForge); + assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge); +} + +#[test] +fn classifies_sourceforge_files_url() { + let source = + resolve_query("https://sourceforge.net/projects/team-app/files/latest/download").unwrap(); + + assert_eq!(source.kind, SourceKind::SourceForge); + assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge); +} + +#[test] +fn preserves_direct_url_classification() { + let source = resolve_query("https://example.com/downloads/team-app.AppImage").unwrap(); + + assert_eq!(source.kind, SourceKind::DirectUrl); + assert_eq!(source.input_kind, SourceInputKind::DirectUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl); +} + +#[test] +fn preserves_sourceforge_download_url_as_direct_url() { + let source = resolve_query( + "https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download", + ) + .unwrap(); + + assert_eq!(source.kind, SourceKind::DirectUrl); + assert_eq!(source.input_kind, SourceInputKind::DirectUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl); +} + +#[test] +fn preserves_sourceforge_root_download_url_as_direct_url() { + let source = resolve_query( + "https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download", + ) + .unwrap(); + + assert_eq!(source.kind, SourceKind::DirectUrl); + assert_eq!(source.input_kind, SourceInputKind::DirectUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl); +} + +#[test] +fn preserves_sourceforge_extensionless_root_download_url_as_direct_url() { + let source = + resolve_query("https://sourceforge.net/projects/team-app/files/team-app/download").unwrap(); + + assert_eq!(source.kind, SourceKind::DirectUrl); + assert_eq!(source.input_kind, SourceInputKind::DirectUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl); +} + +#[test] +fn preserves_sourceforge_download_url_with_query_as_direct_url() { + let source = resolve_query( + "https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download?use_mirror=pilotfiber", + ) + .unwrap(); + + assert_eq!(source.kind, SourceKind::DirectUrl); + assert_eq!(source.input_kind, SourceInputKind::DirectUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl); +} + +#[test] +fn rejects_malformed_gitlab_url() { + let error = resolve_query("https://gitlab.com/example").unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn rejects_unsupported_gitlab_url_shape() { + let error = resolve_query("https://gitlab.com/example/team-app/-/issues").unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn rejects_unsupported_gitlab_nested_resource_url() { + let error = resolve_query("https://gitlab.com/example/team-app/issues").unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn rejects_unsupported_gitlab_release_permalink_url() { + let error = resolve_query("https://gitlab.com/example/team-app/-/releases/permalink/latest") + .unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn rejects_unsupported_gitlab_issue_detail_url() { + let error = resolve_query("https://gitlab.com/example/team-app/issues/1").unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn rejects_unsupported_gitlab_blob_url() { + let error = + resolve_query("https://gitlab.com/example/team-app/blob/main/README.md").unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn classifies_ambiguous_gitlab_deep_reserved_segment_as_candidate() { + let source = resolve_query("https://gitlab.com/acme/platform/releases/team-app").unwrap(); + + assert_eq!(source.kind, SourceKind::GitLab); + assert_eq!(source.input_kind, SourceInputKind::GitLabUrl); + assert_eq!( + source.normalized_kind, + NormalizedSourceKind::GitLabCandidate + ); + assert_eq!(source.canonical_locator, None); + assert!(!source.tracks_latest); +} + +#[test] +fn rejects_unsupported_gitlab_packages_url() { + let error = resolve_query("https://gitlab.com/example/team-app/packages").unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn rejects_malformed_sourceforge_url() { + let error = resolve_query("https://sourceforge.net/projects/").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(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn rejects_unsupported_sourceforge_files_shape() { + let error = + resolve_query("https://sourceforge.net/projects/team-app/files/releases").unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn rejects_unsupported_sourceforge_folder_download_shape() { + let error = resolve_query("https://sourceforge.net/projects/team-app/files/releases/download") + .unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn classifies_ambiguous_sourceforge_nested_folder_download_as_candidate() { + let source = + resolve_query("https://sourceforge.net/projects/team-app/files/releases/stable/download") + .unwrap(); + + assert_eq!(source.kind, SourceKind::SourceForge); + assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl); + assert_eq!( + source.normalized_kind, + NormalizedSourceKind::SourceForgeCandidate + ); + assert_eq!(source.canonical_locator.as_deref(), Some("team-app")); + assert!(!source.tracks_latest); +} + +#[test] +fn rejects_unsupported_sourceforge_nested_extensionless_download_shape() { + let error = + resolve_query("https://sourceforge.net/projects/team-app/files/releases/team-app/download") + .unwrap_err(); + + assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); +} + +#[test] +fn classifies_ambiguous_sourceforge_version_folder_download_as_candidate() { + let source = + resolve_query("https://sourceforge.net/projects/team-app/files/releases/v1-0/download") + .unwrap(); + + assert_eq!(source.kind, SourceKind::SourceForge); + assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl); + assert_eq!( + source.normalized_kind, + NormalizedSourceKind::SourceForgeCandidate + ); + assert_eq!(source.canonical_locator.as_deref(), Some("team-app")); + assert!(!source.tracks_latest); +} diff --git a/crates/aim-core/tests/registry_roundtrip.rs b/crates/aim-core/tests/registry_roundtrip.rs index ac030e4..1845cfb 100644 --- a/crates/aim-core/tests/registry_roundtrip.rs +++ b/crates/aim-core/tests/registry_roundtrip.rs @@ -101,3 +101,107 @@ fn registry_round_trips_install_metadata() { Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png") ); } + +#[test] +fn registry_round_trips_source_identity_for_new_provider_kinds() { + let dir = tempdir().unwrap(); + let store = RegistryStore::new(dir.path().join("registry.toml")); + let registry = aim_core::registry::model::Registry { + version: 1, + apps: vec![ + aim_core::domain::app::AppRecord { + stable_id: "example-team-app".to_owned(), + display_name: "team-app".to_owned(), + source_input: Some("https://gitlab.com/example/team-app".to_owned()), + source: Some(aim_core::domain::source::SourceRef { + kind: aim_core::domain::source::SourceKind::GitLab, + locator: "https://gitlab.com/example/team-app".to_owned(), + input_kind: aim_core::domain::source::SourceInputKind::GitLabUrl, + normalized_kind: aim_core::domain::source::NormalizedSourceKind::GitLab, + canonical_locator: Some("example/team-app".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, + }, + aim_core::domain::app::AppRecord { + stable_id: "team-app".to_owned(), + display_name: "team-app".to_owned(), + source_input: Some( + "https://sourceforge.net/projects/team-app/files/latest/download".to_owned(), + ), + source: Some(aim_core::domain::source::SourceRef { + kind: aim_core::domain::source::SourceKind::SourceForge, + locator: "https://sourceforge.net/projects/team-app/files/latest/download" + .to_owned(), + input_kind: aim_core::domain::source::SourceInputKind::SourceForgeUrl, + normalized_kind: aim_core::domain::source::NormalizedSourceKind::SourceForge, + canonical_locator: Some("team-app".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, + }, + aim_core::domain::app::AppRecord { + stable_id: "url-example.com-downloads-team-app.appimage".to_owned(), + display_name: "https://example.com/downloads/team-app.AppImage".to_owned(), + source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()), + source: Some(aim_core::domain::source::SourceRef { + kind: aim_core::domain::source::SourceKind::DirectUrl, + locator: "https://example.com/downloads/team-app.AppImage".to_owned(), + input_kind: aim_core::domain::source::SourceInputKind::DirectUrl, + normalized_kind: aim_core::domain::source::NormalizedSourceKind::DirectUrl, + canonical_locator: None, + requested_tag: None, + requested_asset_name: None, + tracks_latest: false, + }), + installed_version: Some("unresolved".to_owned()), + update_strategy: None, + metadata: Vec::new(), + install: None, + }, + ], + }; + + store.save(®istry).unwrap(); + let loaded = store.load().unwrap(); + + assert_eq!( + loaded.apps[0].source.as_ref().unwrap().kind.as_str(), + "gitlab" + ); + assert_eq!( + loaded.apps[0] + .source + .as_ref() + .unwrap() + .canonical_locator + .as_deref(), + Some("example/team-app") + ); + assert_eq!( + loaded.apps[1].source.as_ref().unwrap().kind.as_str(), + "sourceforge" + ); + assert_eq!( + loaded.apps[1].source.as_ref().unwrap().locator, + "https://sourceforge.net/projects/team-app/files/latest/download" + ); + assert_eq!( + loaded.apps[2].source.as_ref().unwrap().kind.as_str(), + "direct-url" + ); + assert_eq!( + loaded.apps[2].source.as_ref().unwrap().locator, + "https://example.com/downloads/team-app.AppImage" + ); +} diff --git a/crates/aim-core/tests/update_planning.rs b/crates/aim-core/tests/update_planning.rs index e588713..54c009c 100644 --- a/crates/aim-core/tests/update_planning.rs +++ b/crates/aim-core/tests/update_planning.rs @@ -1,6 +1,7 @@ use aim_core::app::progress::{OperationEvent, OperationStage}; use aim_core::app::update::{build_update_plan, execute_updates, execute_updates_with_reporter}; use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope}; +use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef}; use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy}; use tempfile::tempdir; @@ -138,3 +139,102 @@ fn update_execution_reports_per_app_lifecycle_events() { ) })); } + +#[test] +fn update_plan_uses_direct_asset_fallback_for_direct_url_origin() { + let apps = [AppRecord { + stable_id: "team-app".to_owned(), + display_name: "team-app".to_owned(), + source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()), + source: Some(SourceRef { + kind: SourceKind::DirectUrl, + locator: "https://example.com/downloads/team-app.AppImage".to_owned(), + input_kind: SourceInputKind::DirectUrl, + normalized_kind: NormalizedSourceKind::DirectUrl, + canonical_locator: None, + requested_tag: None, + requested_asset_name: None, + tracks_latest: false, + }), + installed_version: Some("unresolved".to_owned()), + update_strategy: None, + metadata: Vec::new(), + install: None, + }]; + + let plan = build_update_plan(&apps).unwrap(); + + assert_eq!( + plan.items[0].selected_channel.kind, + UpdateChannelKind::DirectAsset + ); + assert_eq!( + plan.items[0].selected_channel.locator, + "https://example.com/downloads/team-app.AppImage" + ); + assert_eq!(plan.items[0].selection_reason, "install-origin-match"); +} + +#[test] +fn update_execution_rebuilds_gitlab_source_without_rewriting_origin() { + let install_home = tempdir().unwrap(); + + unsafe { + std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); + } + + let previous = AppRecord { + stable_id: "example-team-app".to_owned(), + display_name: "team-app".to_owned(), + source_input: Some("https://gitlab.com/example/team-app".to_owned()), + source: Some(SourceRef { + kind: SourceKind::GitLab, + locator: "https://gitlab.com/example/team-app".to_owned(), + input_kind: SourceInputKind::GitLabUrl, + normalized_kind: NormalizedSourceKind::GitLab, + canonical_locator: Some("example/team-app".to_owned()), + requested_tag: None, + requested_asset_name: None, + tracks_latest: true, + }), + installed_version: Some("latest".to_owned()), + update_strategy: Some(UpdateStrategy { + preferred: ChannelPreference { + kind: UpdateChannelKind::DirectAsset, + locator: "https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage" + .to_owned(), + reason: "provider-release".to_owned(), + }, + alternates: Vec::new(), + }), + metadata: Vec::new(), + install: Some(InstallMetadata { + scope: InstallScope::User, + payload_path: None, + desktop_entry_path: None, + icon_path: None, + }), + }; + + let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap(); + + assert_eq!(result.updated_count(), 1); + assert_eq!(result.failed_count(), 0); + assert_eq!( + result.apps[0].source.as_ref().unwrap().kind, + SourceKind::GitLab + ); + assert_eq!( + result.apps[0].source.as_ref().unwrap().locator, + "https://gitlab.com/example/team-app" + ); + assert_eq!( + result.apps[0] + .source + .as_ref() + .unwrap() + .canonical_locator + .as_deref(), + Some("example/team-app") + ); +}