feat: expand source provider resolution

This commit is contained in:
stoorps 2026-03-21 00:43:02 +00:00
parent 9d8ec1e4fd
commit eaa9a3b52d
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
23 changed files with 2582 additions and 34 deletions

View file

@ -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.

View file

@ -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"
```

View file

@ -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/<group>/<subgroup>/releases/<repo>` 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/<project>/files/releases/stable/download` remains a classified candidate and now resolves as a provider-owned latest-download source. `https://sourceforge.net/projects/<project>/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

View file

@ -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

View file

@ -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<aim_core::app::add::BuildAddPlanError> for DispatchError {
fn from(value: aim_core::app::add::BuildAddPlanError) -> Self {
Self::AddPlan(value)

View file

@ -23,7 +23,7 @@ fn main() {
}
}
Err(error) => {
eprintln!("{error:?}");
eprintln!("{error}");
std::process::exit(1);
}
}

View file

@ -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", &registry_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(&registry_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", &registry_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(&registry_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", &registry_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(&registry_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", &registry_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", &registry_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();

View file

@ -15,6 +15,10 @@ impl SourceAdapter for DirectUrlAdapter {
AdapterCapabilities::exact_resolution_only()
}
fn exact_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::DirectUrl)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::DirectUrl {

View file

@ -30,6 +30,10 @@ impl SourceAdapter for GitHubAdapter {
}
}
fn repository_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::GitHub)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::GitHub {

View file

@ -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<SourceKind> {
Some(SourceKind::GitLab)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
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<AdapterResolveOutcome, AdapterError> {
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<SourceRef, AdapterError> {
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<String> {
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())
}
}

View file

@ -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<A: SourceAdapter + ?Sized>(adapter: &A, source: &SourceRef) -> bool {
adapter.repository_source_kind() == Some(source.kind)
|| adapter.exact_source_kind() == Some(source.kind)
}

View file

@ -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<String> {
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<SourceRef, AdapterError> {
Err(AdapterError::UnsupportedQuery)
fn repository_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::SourceForge)
}
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
Err(AdapterError::UnsupportedSource)
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
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<AdapterResolution, AdapterError> {
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<AdapterResolveOutcome, AdapterError> {
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")
}

View file

@ -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<SourceKind> {
None
}
fn exact_source_kind(&self) -> Option<SourceKind> {
None
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError>;
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError>;
fn resolve_supported_source(
&self,
source: &SourceRef,
) -> Result<AdapterResolveOutcome, AdapterError> {
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<AdapterResolveOutcome, AdapterError> {
if !self.supports_source(source) {
return Err(AdapterError::UnsupportedSource);
}
self.resolve_supported_source(source)
}
}

View file

@ -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<T: GitHubTransport + ?Sized>(
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,
}

View file

@ -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,

View file

@ -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",
}

View file

@ -45,17 +45,12 @@ pub fn classify_input(query: &str) -> Result<ClassifiedInput, ClassifyInputError
return Ok(classified);
}
if query.starts_with("https://gitlab.com/") || query.starts_with("http://gitlab.com/") {
return Ok(ClassifiedInput {
kind: SourceInputKind::GitLabUrl,
source_kind: SourceKind::GitLab,
normalized_kind: NormalizedSourceKind::GitLab,
locator: query.to_owned(),
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
});
if let Some(classified) = classify_gitlab_http(query) {
return classified;
}
if let Some(classified) = classify_sourceforge_http(query) {
return classified;
}
if query.starts_with("https://") || query.starts_with("http://") {
@ -92,6 +87,201 @@ pub enum ClassifyInputError {
Unsupported,
}
fn classify_gitlab_http(query: &str) -> Option<Result<ClassifiedInput, ClassifyInputError>> {
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::<Vec<_>>();
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<Result<ClassifiedInput, ClassifyInputError>> {
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::<Vec<_>>();
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<ClassifiedInput> {
let trimmed = query
.trim_start_matches("https://github.com/")

View file

@ -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<SourceKind> {
Some(SourceKind::File)
}
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
Err(AdapterError::UnsupportedQuery)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
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;

View file

@ -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:?}"),
}
}

View file

@ -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<OperationEvent> = 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<OperationEvent> = 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<OperationEvent> = 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);
}

View file

@ -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);
}

View file

@ -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(&registry).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"
);
}

View file

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