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),
|
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 {
|
impl From<aim_core::app::add::BuildAddPlanError> for DispatchError {
|
||||||
fn from(value: aim_core::app::add::BuildAddPlanError) -> Self {
|
fn from(value: aim_core::app::add::BuildAddPlanError) -> Self {
|
||||||
Self::AddPlan(value)
|
Self::AddPlan(value)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("{error:?}");
|
eprintln!("{error}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,103 @@ fn cli_add_installs_and_renders_resolved_mode() {
|
||||||
.stdout(contains("Completed steps").not());
|
.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]
|
#[test]
|
||||||
fn cli_add_emits_live_progress_to_stderr() {
|
fn cli_add_emits_live_progress_to_stderr() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ impl SourceAdapter for DirectUrlAdapter {
|
||||||
AdapterCapabilities::exact_resolution_only()
|
AdapterCapabilities::exact_resolution_only()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn exact_source_kind(&self) -> Option<SourceKind> {
|
||||||
|
Some(SourceKind::DirectUrl)
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
|
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
|
||||||
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
||||||
if source.kind != SourceKind::DirectUrl {
|
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> {
|
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
|
||||||
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
||||||
if source.kind != SourceKind::GitHub {
|
if source.kind != SourceKind::GitHub {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,35 @@
|
||||||
use crate::adapters::traits::{
|
use crate::adapters::traits::{
|
||||||
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
|
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
|
||||||
};
|
};
|
||||||
use crate::app::query::resolve_query;
|
use crate::app::query::resolve_query;
|
||||||
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
|
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind, SourceRef};
|
||||||
|
|
||||||
pub struct GitLabAdapter;
|
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 {
|
impl SourceAdapter for GitLabAdapter {
|
||||||
fn id(&self) -> &'static str {
|
fn id(&self) -> &'static str {
|
||||||
"gitlab"
|
"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> {
|
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
|
||||||
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
||||||
if source.kind != SourceKind::GitLab {
|
if source.kind != SourceKind::GitLab {
|
||||||
|
|
@ -32,12 +60,73 @@ impl SourceAdapter for GitLabAdapter {
|
||||||
return Err(AdapterError::UnsupportedSource);
|
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 {
|
Ok(AdapterResolution {
|
||||||
source: source.clone(),
|
source: resolved_source,
|
||||||
release: ResolvedRelease {
|
release: ResolvedRelease {
|
||||||
version: "latest".to_owned(),
|
version,
|
||||||
prerelease: false,
|
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 traits;
|
||||||
pub mod zsync;
|
pub mod zsync;
|
||||||
|
|
||||||
|
use crate::adapters::traits::SourceAdapter;
|
||||||
|
use crate::domain::source::SourceRef;
|
||||||
|
|
||||||
pub fn all_adapter_kinds() -> Vec<&'static str> {
|
pub fn all_adapter_kinds() -> Vec<&'static str> {
|
||||||
vec![
|
vec![
|
||||||
"github",
|
"github",
|
||||||
|
|
@ -17,3 +20,8 @@ pub fn all_adapter_kinds() -> Vec<&'static str> {
|
||||||
"custom-json",
|
"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::{
|
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;
|
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 {
|
impl SourceAdapter for SourceForgeAdapter {
|
||||||
fn id(&self) -> &'static str {
|
fn id(&self) -> &'static str {
|
||||||
"sourceforge"
|
"sourceforge"
|
||||||
|
|
@ -17,11 +28,89 @@ impl SourceAdapter for SourceForgeAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
|
fn repository_source_kind(&self) -> Option<SourceKind> {
|
||||||
Err(AdapterError::UnsupportedQuery)
|
Some(SourceKind::SourceForge)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
|
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
|
||||||
Err(AdapterError::UnsupportedSource)
|
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::{ResolvedRelease, SourceKind, SourceRef};
|
||||||
use crate::domain::source::SourceRef;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct AdapterCapabilities {
|
pub struct AdapterCapabilities {
|
||||||
|
|
@ -22,6 +21,12 @@ pub struct AdapterResolution {
|
||||||
pub release: ResolvedRelease,
|
pub release: ResolvedRelease,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum AdapterResolveOutcome {
|
||||||
|
Resolved(AdapterResolution),
|
||||||
|
NoInstallableArtifact { source: SourceRef },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum AdapterError {
|
pub enum AdapterError {
|
||||||
UnsupportedQuery,
|
UnsupportedQuery,
|
||||||
|
|
@ -34,7 +39,34 @@ pub trait SourceAdapter {
|
||||||
|
|
||||||
fn capabilities(&self) -> AdapterCapabilities;
|
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 normalize(&self, query: &str) -> Result<SourceRef, AdapterError>;
|
||||||
|
|
||||||
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, 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::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
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::AdapterResolution;
|
||||||
|
use crate::adapters::traits::{AdapterResolveOutcome, SourceAdapter};
|
||||||
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
|
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
|
||||||
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
||||||
use crate::app::progress::{
|
use crate::app::progress::{
|
||||||
|
|
@ -110,6 +114,115 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
||||||
strategy,
|
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 {
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
stage: OperationStage::SelectArtifact,
|
stage: OperationStage::SelectArtifact,
|
||||||
|
|
@ -367,7 +480,11 @@ pub struct InstalledApp {
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BuildAddPlanError {
|
pub enum BuildAddPlanError {
|
||||||
Query(ResolveQueryError),
|
Query(ResolveQueryError),
|
||||||
|
Adapter(&'static str, crate::adapters::traits::AdapterError),
|
||||||
GitHubDiscovery(GitHubDiscoveryError),
|
GitHubDiscovery(GitHubDiscoveryError),
|
||||||
|
NoInstallableArtifact {
|
||||||
|
source: crate::domain::source::SourceRef,
|
||||||
|
},
|
||||||
NoCandidates,
|
NoCandidates,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use crate::app::progress::{
|
||||||
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
|
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
|
||||||
};
|
};
|
||||||
use crate::domain::app::{AppRecord, InstallScope};
|
use crate::domain::app::{AppRecord, InstallScope};
|
||||||
|
use crate::domain::source::SourceKind;
|
||||||
use crate::domain::update::{
|
use crate::domain::update::{
|
||||||
ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult,
|
ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult,
|
||||||
UpdateExecutionStatus, UpdatePlan,
|
UpdateExecutionStatus, UpdatePlan,
|
||||||
|
|
@ -116,15 +117,7 @@ fn plan_update(app: &AppRecord) -> PlannedUpdate {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
ChannelPreference {
|
fallback_channel_preference(app),
|
||||||
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(),
|
|
||||||
},
|
|
||||||
"install-origin-match".to_owned(),
|
"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(
|
fn execute_update(
|
||||||
app: &AppRecord,
|
app: &AppRecord,
|
||||||
install_home: &Path,
|
install_home: &Path,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
pub enum SourceKind {
|
pub enum SourceKind {
|
||||||
GitHub,
|
GitHub,
|
||||||
GitLab,
|
GitLab,
|
||||||
|
SourceForge,
|
||||||
DirectUrl,
|
DirectUrl,
|
||||||
File,
|
File,
|
||||||
}
|
}
|
||||||
|
|
@ -11,6 +12,7 @@ impl SourceKind {
|
||||||
match self {
|
match self {
|
||||||
Self::GitHub => "github",
|
Self::GitHub => "github",
|
||||||
Self::GitLab => "gitlab",
|
Self::GitLab => "gitlab",
|
||||||
|
Self::SourceForge => "sourceforge",
|
||||||
Self::DirectUrl => "direct-url",
|
Self::DirectUrl => "direct-url",
|
||||||
Self::File => "file",
|
Self::File => "file",
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +26,7 @@ pub enum SourceInputKind {
|
||||||
GitHubReleaseUrl,
|
GitHubReleaseUrl,
|
||||||
GitHubReleaseAssetUrl,
|
GitHubReleaseAssetUrl,
|
||||||
GitLabUrl,
|
GitLabUrl,
|
||||||
|
SourceForgeUrl,
|
||||||
DirectUrl,
|
DirectUrl,
|
||||||
File,
|
File,
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +39,7 @@ impl SourceInputKind {
|
||||||
Self::GitHubReleaseUrl => "github-release-url",
|
Self::GitHubReleaseUrl => "github-release-url",
|
||||||
Self::GitHubReleaseAssetUrl => "github-release-asset-url",
|
Self::GitHubReleaseAssetUrl => "github-release-asset-url",
|
||||||
Self::GitLabUrl => "gitlab-url",
|
Self::GitLabUrl => "gitlab-url",
|
||||||
|
Self::SourceForgeUrl => "sourceforge-url",
|
||||||
Self::DirectUrl => "direct-url",
|
Self::DirectUrl => "direct-url",
|
||||||
Self::File => "file",
|
Self::File => "file",
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +52,9 @@ pub enum NormalizedSourceKind {
|
||||||
GitHubRelease,
|
GitHubRelease,
|
||||||
GitHubReleaseAsset,
|
GitHubReleaseAsset,
|
||||||
GitLab,
|
GitLab,
|
||||||
|
GitLabCandidate,
|
||||||
|
SourceForge,
|
||||||
|
SourceForgeCandidate,
|
||||||
DirectUrl,
|
DirectUrl,
|
||||||
File,
|
File,
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +66,9 @@ impl NormalizedSourceKind {
|
||||||
Self::GitHubRelease => "github-release",
|
Self::GitHubRelease => "github-release",
|
||||||
Self::GitHubReleaseAsset => "github-release-asset",
|
Self::GitHubReleaseAsset => "github-release-asset",
|
||||||
Self::GitLab => "gitlab",
|
Self::GitLab => "gitlab",
|
||||||
|
Self::GitLabCandidate => "gitlab-candidate",
|
||||||
|
Self::SourceForge => "sourceforge",
|
||||||
|
Self::SourceForgeCandidate => "sourceforge-candidate",
|
||||||
Self::DirectUrl => "direct-url",
|
Self::DirectUrl => "direct-url",
|
||||||
Self::File => "file",
|
Self::File => "file",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,17 +45,12 @@ pub fn classify_input(query: &str) -> Result<ClassifiedInput, ClassifyInputError
|
||||||
return Ok(classified);
|
return Ok(classified);
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.starts_with("https://gitlab.com/") || query.starts_with("http://gitlab.com/") {
|
if let Some(classified) = classify_gitlab_http(query) {
|
||||||
return Ok(ClassifiedInput {
|
return classified;
|
||||||
kind: SourceInputKind::GitLabUrl,
|
}
|
||||||
source_kind: SourceKind::GitLab,
|
|
||||||
normalized_kind: NormalizedSourceKind::GitLab,
|
if let Some(classified) = classify_sourceforge_http(query) {
|
||||||
locator: query.to_owned(),
|
return classified;
|
||||||
canonical_locator: None,
|
|
||||||
requested_tag: None,
|
|
||||||
requested_asset_name: None,
|
|
||||||
tracks_latest: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.starts_with("https://") || query.starts_with("http://") {
|
if query.starts_with("https://") || query.starts_with("http://") {
|
||||||
|
|
@ -92,6 +87,201 @@ pub enum ClassifyInputError {
|
||||||
Unsupported,
|
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> {
|
fn classify_github_http(query: &str) -> Option<ClassifiedInput> {
|
||||||
let trimmed = query
|
let trimmed = query
|
||||||
.trim_start_matches("https://github.com/")
|
.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::github::GitHubAdapter;
|
||||||
use aim_core::adapters::gitlab::GitLabAdapter;
|
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]
|
#[test]
|
||||||
fn adapter_capabilities_can_report_exact_resolution_only() {
|
fn adapter_capabilities_can_report_exact_resolution_only() {
|
||||||
|
|
@ -8,6 +59,200 @@ fn adapter_capabilities_can_report_exact_resolution_only() {
|
||||||
assert!(!capabilities.supports_search);
|
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]
|
#[test]
|
||||||
fn legacy_github_adapter_delegates_to_source_pipeline() {
|
fn legacy_github_adapter_delegates_to_source_pipeline() {
|
||||||
let adapter: &dyn SourceAdapter = &GitHubAdapter;
|
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::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
|
||||||
use aim_core::platform::DesktopHelpers;
|
use aim_core::platform::DesktopHelpers;
|
||||||
|
use aim_core::source::github::FixtureGitHubTransport;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
|
@ -34,3 +38,55 @@ fn integration_failure_removes_new_payload_and_generated_files() {
|
||||||
assert!(!final_payload_path.exists());
|
assert!(!final_payload_path.exists());
|
||||||
assert!(!desktop_entry_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::add::{build_add_plan_with_reporter, install_app_with_reporter};
|
||||||
use aim_core::app::progress::{OperationEvent, OperationStage};
|
use aim_core::app::progress::{OperationEvent, OperationStage};
|
||||||
use aim_core::domain::app::InstallScope;
|
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::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
|
||||||
use aim_core::platform::DesktopHelpers;
|
use aim_core::platform::DesktopHelpers;
|
||||||
use aim_core::source::github::FixtureGitHubTransport;
|
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
|
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")
|
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::progress::{OperationEvent, OperationStage};
|
||||||
use aim_core::app::update::{build_update_plan, execute_updates, execute_updates_with_reporter};
|
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::app::{AppRecord, InstallMetadata, InstallScope};
|
||||||
|
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||||
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
|
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
|
||||||
use tempfile::tempdir;
|
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