feat: expand source provider resolution
This commit is contained in:
parent
9d8ec1e4fd
commit
eaa9a3b52d
23 changed files with 2582 additions and 34 deletions
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ fn main() {
|
|||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("{error:?}");
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,6 +200,103 @@ fn cli_add_installs_and_renders_resolved_mode() {
|
|||
.stdout(contains("Completed steps").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_installs_gitlab_source_with_truthful_origin() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg("https://gitlab.com/example/team-app")
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed team-app (user)"))
|
||||
.stdout(contains("Source: gitlab https://gitlab.com/example/team-app"))
|
||||
.stdout(contains(
|
||||
"Artifact: https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage",
|
||||
));
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains("source_input = \"https://gitlab.com/example/team-app\""));
|
||||
assert!(contents.contains("kind = \"GitLab\""));
|
||||
assert!(contents.contains("locator = \"https://gitlab.com/example/team-app\""));
|
||||
assert!(contents.contains("canonical_locator = \"example/team-app\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_preserves_direct_url_origin_for_provider_like_downloads() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let query = "https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download";
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg(query)
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed "))
|
||||
.stdout(contains(format!("Source: direct-url {query}")))
|
||||
.stdout(contains(format!("Artifact: {query}")));
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains(&format!("source_input = \"{query}\"")));
|
||||
assert!(contents.contains("kind = \"DirectUrl\""));
|
||||
assert!(contents.contains(&format!("locator = \"{query}\"")));
|
||||
assert!(!contents.contains("kind = \"SourceForge\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_installs_sourceforge_latest_download_with_truthful_origin() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let query = "https://sourceforge.net/projects/team-app/files/latest/download";
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg(query)
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed team-app (user)"))
|
||||
.stdout(contains(format!("Source: sourceforge {query}")))
|
||||
.stdout(contains(format!("Artifact: {query}")));
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains(&format!("source_input = \"{query}\"")));
|
||||
assert!(contents.contains("kind = \"SourceForge\""));
|
||||
assert!(contents.contains(&format!("locator = \"{query}\"")));
|
||||
assert!(contents.contains("canonical_locator = \"team-app\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_reports_unsupported_source_queries_distinctly() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg("https://gitlab.com/example")
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("unsupported source query"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_reports_supported_sources_without_installable_artifacts_distinctly() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg("https://sourceforge.net/projects/team-app/")
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("no installable artifact found"))
|
||||
.stderr(contains("sourceforge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_emits_live_progress_to_stderr() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,3 +101,107 @@ fn registry_round_trips_install_metadata() {
|
|||
Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_round_trips_source_identity_for_new_provider_kinds() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
let registry = aim_core::registry::model::Registry {
|
||||
version: 1,
|
||||
apps: vec![
|
||||
aim_core::domain::app::AppRecord {
|
||||
stable_id: "example-team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("https://gitlab.com/example/team-app".to_owned()),
|
||||
source: Some(aim_core::domain::source::SourceRef {
|
||||
kind: aim_core::domain::source::SourceKind::GitLab,
|
||||
locator: "https://gitlab.com/example/team-app".to_owned(),
|
||||
input_kind: aim_core::domain::source::SourceInputKind::GitLabUrl,
|
||||
normalized_kind: aim_core::domain::source::NormalizedSourceKind::GitLab,
|
||||
canonical_locator: Some("example/team-app".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
aim_core::domain::app::AppRecord {
|
||||
stable_id: "team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some(
|
||||
"https://sourceforge.net/projects/team-app/files/latest/download".to_owned(),
|
||||
),
|
||||
source: Some(aim_core::domain::source::SourceRef {
|
||||
kind: aim_core::domain::source::SourceKind::SourceForge,
|
||||
locator: "https://sourceforge.net/projects/team-app/files/latest/download"
|
||||
.to_owned(),
|
||||
input_kind: aim_core::domain::source::SourceInputKind::SourceForgeUrl,
|
||||
normalized_kind: aim_core::domain::source::NormalizedSourceKind::SourceForge,
|
||||
canonical_locator: Some("team-app".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
aim_core::domain::app::AppRecord {
|
||||
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
|
||||
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(aim_core::domain::source::SourceRef {
|
||||
kind: aim_core::domain::source::SourceKind::DirectUrl,
|
||||
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: aim_core::domain::source::SourceInputKind::DirectUrl,
|
||||
normalized_kind: aim_core::domain::source::NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
store.save(®istry).unwrap();
|
||||
let loaded = store.load().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
loaded.apps[0].source.as_ref().unwrap().kind.as_str(),
|
||||
"gitlab"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[0]
|
||||
.source
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.canonical_locator
|
||||
.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[1].source.as_ref().unwrap().kind.as_str(),
|
||||
"sourceforge"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[1].source.as_ref().unwrap().locator,
|
||||
"https://sourceforge.net/projects/team-app/files/latest/download"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[2].source.as_ref().unwrap().kind.as_str(),
|
||||
"direct-url"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[2].source.as_ref().unwrap().locator,
|
||||
"https://example.com/downloads/team-app.AppImage"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue