diff --git a/.plans/001-github-source-end-to-end/2026-03-19-github-source-end-to-end-design.md b/.plans/001-github-source-end-to-end/2026-03-19-github-source-end-to-end-design.md new file mode 100644 index 0000000..479fe9c --- /dev/null +++ b/.plans/001-github-source-end-to-end/2026-03-19-github-source-end-to-end-design.md @@ -0,0 +1,333 @@ +# GitHub Source End-to-End Design + +## Goal + +Extend `aim-core` so GitHub-backed installs and updates work end to end across repo shorthand, GitHub URLs, and direct GitHub release asset URLs, while keeping the architecture reusable for future sources and a later GUI client. + +The design should support broad discovery, metadata-aware artifact selection, durable update-channel fallback, and a clean separation between source discovery, metadata parsing, and update decision-making. + +## Agreed Product Shape + +### Supported GitHub entry forms + +- `owner/repo` +- GitHub repo URL +- GitHub release URL +- direct GitHub release asset URL + +### Canonical identity and source behavior + +- Canonical app identity should resolve to the best available app identity, usually the GitHub repository when that can be determined confidently +- The system should preserve the original user input and normalized source references for auditability and later recovery +- If the user points at an older release or a prerelease directly, the system should respect that context rather than silently switching to an unrelated channel + +### Discovery and artifact selection behavior + +- Discovery should be broad enough to inspect GitHub releases, candidate AppImage assets, and adjacent metadata files +- Metadata should influence both install-time artifact selection and future update behavior +- Artifact selection should prefer metadata-guided choices first and filename heuristics second +- Stable releases should be preferred by default, unless the user explicitly started from a prerelease + +### Update-channel behavior + +- The preferred update channel should, by default, match the install origin as closely as possible +- The registry should retain as many validated alternate channels as practical so future updates can fall back automatically if the preferred path fails +- `aim` should be able to explain why a particular channel or artifact was chosen + +## Recommended Architecture + +Use a three-part core model inside `aim-core`: + +- `source` discovers and fetches candidate release information, assets, and metadata documents +- `metadata` parses source-agnostic metadata formats such as `electron-builder` YAML or zsync-related metadata +- `update` ranks channels and artifacts, applies product rules, and persists a durable update strategy + +This architecture was selected over keeping all upstream logic under `adapters`, because formats like `latest-linux.yml` and zsync metadata are not fundamentally tied to a single provider. They are metadata formats that can be discovered from different source types and should remain reusable. + +## Core Boundary Decision + +The critical rule is: + +- `source` discovers +- `metadata` interprets +- `update` decides +- `app` orchestrates + +That boundary keeps source-specific fetching logic separate from metadata parsing and prevents update rules from leaking into transport or parser code. + +## Proposed Module Layout + +Within `crates/aim-core/src/`, the design should evolve toward: + +- `app/` +- `domain/` +- `source/` +- `metadata/` +- `update/` +- `registry/` +- `integration/` +- `platform/` + +Responsibilities: + +### `app` + +- orchestration layer for add, update, list, and remove flows +- interactive decision boundaries +- conversion of lower-level results into user-facing plans and prompts + +### `source` + +- normalize user input into typed source references +- resolve GitHub shorthand, repo URLs, release URLs, and asset URLs +- fetch releases, assets, and candidate metadata documents +- detect candidate update channels without interpreting metadata semantics deeply + +### `metadata` + +- source-agnostic parsers for metadata document formats +- initial parsers should cover: + - `electron-builder` Linux metadata such as `latest-linux.yml` + - zsync-related metadata or update hints +- return structured hints, warnings, and confidence rather than UI-facing decisions + +### `update` + +- combine source discovery and parsed metadata +- rank channels and candidate artifacts +- apply install-origin-first default prioritization +- retain alternate channels for fallback +- build persisted update strategies and reviewable plans + +### `domain` and `registry` + +- `domain` holds the stable source-agnostic types used across the core +- `registry` stores only the durable information needed to explain and recover update behavior later + +## Discovery And Update Flow + +### 1. Input resolution + +`source::resolve_input` should accept: + +- `owner/repo` +- GitHub repo URL +- GitHub release URL +- direct release asset URL +- generic URL fallback + +It should classify the input, retain the raw source input, and derive a normalized source reference. When possible, it should also derive a canonical app identity anchored to the GitHub repository. + +### 2. Broad source discovery + +The source layer should: + +- fetch repository and release context +- enumerate candidate AppImage assets +- detect adjacent metadata files such as `latest-linux.yml` +- detect related update artifacts such as zsync files or embedded update hints when available + +If the user supplied a link to an older release, discovery should still be broad enough to see newer releases, while preserving enough context for the application layer to ask whether the user wants to track that release lineage or switch to latest-supported updates. + +### 3. Metadata parsing + +Each metadata parser should consume raw document bytes plus lightweight fetch context and return structured hints such as: + +- version +- artifact URL +- checksum or digest information +- channel or release label +- architecture or platform compatibility +- updater-family identity +- parser confidence +- warnings + +Metadata parsing must remain source-agnostic. A GitHub discovery flow may hand documents to the parser, but the parser should not depend on GitHub-specific assumptions. + +### 4. Channel construction + +The update layer should turn source and metadata results into reusable channel records, for example: + +- GitHub releases API channel +- `electron-builder` metadata channel +- zsync-derived channel +- direct-asset-lineage channel + +Each channel should record: + +- how it was discovered +- what artifacts it can produce +- its confidence and compatibility scope +- whether it matches the original install origin + +### 5. Ranking and persistence + +The update layer should select: + +- one preferred channel +- an ordered list of validated alternate channels + +Default rule: + +- prefer the channel that best matches how the app was originally installed +- retain all other validated channels in fallback order + +Artifact selection rule: + +- metadata-guided first +- filename heuristics second + +Prerelease rule: + +- only prefer prerelease channels when the user explicitly started from one +- otherwise prefer stable releases first + +## Domain Model + +The design should introduce or evolve the following stable concepts. + +### `AppIdentity` + +- canonical tracked-app identity +- usually GitHub repository identity when resolvable +- includes normalized key and display name data + +### `SourceInput` + +- the exact user-provided input +- shorthand, repo URL, release URL, asset URL, or generic URL + +### `SourceRef` + +- normalized source locator used for discovery +- may represent a GitHub repo, a release lineage, a direct asset lineage, or another supported source pattern + +### `MetadataDocument` + +- raw fetched metadata plus provenance +- includes source URL, content type, fetch time, digest, document type guess, and raw content or a blob reference + +### `MetadataHint` + +- parsed interpretation of a metadata document +- includes install and update hints such as version, artifact URL, checksum, architecture, warnings, and confidence + +### `UpdateChannel` + +- a durable description of one update path +- examples include `github-releases`, `electron-builder`, `zsync`, and `direct-asset-lineage` + +### `ArtifactCandidate` + +- one installable AppImage candidate +- includes URL or path, version, architecture, provenance, checksum data, and score explanation + +### `UpdateStrategy` + +- preferred channel +- alternate channels in order +- preference reason such as install-origin match or stronger metadata confidence + +## Registry Model + +The registry should persist only the information that matters for future updates, explainability, and recovery. + +Persist: + +- canonical `AppIdentity` +- original `SourceInput` +- normalized `SourceRef` +- installed version and artifact state when known +- `UpdateStrategy` +- retained `UpdateChannel` records +- useful parsed metadata hints +- selected raw metadata snapshots or references + +Do not persist every transient discovery result. The registry is not a general fetch cache. It should keep enough information to: + +- explain why a channel was chosen +- retry alternates later +- survive upstream layout changes +- support debugging of bad matches + +## Failure Handling + +### Source failures + +- source failures should be local, not globally fatal by default +- if GitHub API discovery fails but a direct asset URL remains usable, installation should continue through that path +- if one candidate discovery branch fails, the system should keep evaluating other validated branches where possible + +### Metadata failures + +- metadata parse failures should be typed and non-blocking +- failure to parse `latest-linux.yml` must not disable plain GitHub release discovery +- low-confidence metadata may still contribute hints, but should not outrank stronger direct evidence + +### Update selection explanations + +The update layer should be able to explain why a channel or artifact was preferred or rejected, using signals such as: + +- install-origin match +- metadata confidence +- architecture compatibility +- prerelease mismatch +- stale, missing, or incompatible artifacts + +## User Prompting Rules + +Prompt only when the ambiguity materially changes behavior. + +Expected prompt cases: + +- the user linked an older specific release and the system can also see newer supported releases +- multiple AppImage artifacts remain equally plausible after metadata and heuristic ranking +- identity resolution remains low confidence after source normalization + +Everything else should be automatic and explainable in the generated plan. + +## Testing Strategy + +### Source tests + +- normalize shorthand, repo URLs, release URLs, and asset URLs +- verify broad discovery from mocked GitHub responses +- preserve prompt context for older-version inputs + +### Metadata tests + +- fixture-driven parsing for `electron-builder` YAML, zsync, and malformed inputs +- confidence and warning behavior +- source-agnostic parser contract + +### Update tests + +- install-origin-first ranking +- alternate-channel fallback ordering +- stable-versus-prerelease preference +- metadata-guided artifact selection outranking filename heuristics + +### Registry tests + +- round-trip persistence of preferred channel, alternates, metadata hints, and metadata snapshots +- backward-compatible loading when existing registry entries lack new fields + +### End-to-end CLI tests + +- add from GitHub shorthand +- add from direct GitHub release asset URL +- update behavior when preferred channel fails and an alternate succeeds + +## Recommended Implementation Direction + +Implementation should incrementally reshape the current adapter-oriented GitHub skeleton toward the new boundaries rather than attempt a single large rewrite. + +Recommended sequence: + +1. introduce `source`, `metadata`, and `update` domain boundaries and shared types +2. migrate GitHub discovery logic into the new source boundary +3. add metadata parsers and fixture coverage +4. add channel ranking and registry persistence extensions +5. update CLI orchestration and end-to-end tests + +This keeps the change reviewable and protects the existing thin-client architecture where `aim-core` owns all reusable logic. \ No newline at end of file diff --git a/.plans/001-github-source-end-to-end/2026-03-19-github-source-end-to-end-implementation-plan.md b/.plans/001-github-source-end-to-end/2026-03-19-github-source-end-to-end-implementation-plan.md new file mode 100644 index 0000000..e10f978 --- /dev/null +++ b/.plans/001-github-source-end-to-end/2026-03-19-github-source-end-to-end-implementation-plan.md @@ -0,0 +1,644 @@ +# GitHub Source End-to-End Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add end-to-end GitHub source support in `aim-core` for shorthand, repo URLs, release URLs, and direct asset URLs, with source-agnostic metadata parsing, install-origin-first channel selection, and registry-backed fallback channels. + +**Architecture:** Reshape the current GitHub skeleton from an adapter-centric model into explicit `source`, `metadata`, and `update` boundaries. Keep `aim-cli` thin by moving normalization, metadata interpretation, channel ranking, and recovery behavior into `aim-core`, then extend the registry so future updates can survive upstream changes. + +**Tech Stack:** Rust, Cargo workspace, serde, toml, reqwest-compatible fetch abstractions, clap, dialoguer, assert_cmd, predicates, fixture-driven tests in `crates/aim-core/tests` and `crates/aim-cli/tests`. + +--- + +### Task 1: Introduce the new core boundary modules and types + +**Files:** +- Create: `crates/aim-core/src/source/mod.rs` +- Create: `crates/aim-core/src/source/input.rs` +- Create: `crates/aim-core/src/source/github.rs` +- Create: `crates/aim-core/src/metadata/mod.rs` +- Create: `crates/aim-core/src/metadata/document.rs` +- Create: `crates/aim-core/src/update/mod.rs` +- Modify: `crates/aim-core/src/lib.rs` +- Modify: `crates/aim-core/src/domain/source.rs` +- Modify: `crates/aim-core/src/domain/update.rs` +- Test: `crates/aim-core/tests/query_resolution.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::source::input::{classify_input, SourceInputKind}; + +#[test] +fn classifies_github_release_asset_url() { + let input = classify_input( + "https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage", + ) + .unwrap(); + + assert_eq!(input.kind, SourceInputKind::GitHubReleaseAssetUrl); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test classifies_github_release_asset_url --package aim-core --test query_resolution` +Expected: FAIL because the `source` module and new input classification types do not exist yet + +**Step 3: Write minimal implementation** + +Add the new top-level modules and export them from `aim_core`. Introduce the minimum source and update domain types needed to classify GitHub inputs without rewriting existing workflows yet. + +**Step 4: Run test to verify it passes** + +Run: `cargo test classifies_github_release_asset_url --package aim-core --test query_resolution` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/lib.rs crates/aim-core/src/source crates/aim-core/src/metadata crates/aim-core/src/update crates/aim-core/src/domain/source.rs crates/aim-core/src/domain/update.rs crates/aim-core/tests/query_resolution.rs +git commit -m "feat: add source metadata and update module boundaries" +``` + +### Task 2: Implement GitHub input normalization across all supported entry forms + +**Files:** +- Modify: `crates/aim-core/src/source/input.rs` +- Modify: `crates/aim-core/src/source/github.rs` +- Modify: `crates/aim-core/src/app/query.rs` +- Modify: `crates/aim-core/src/app/identity.rs` +- Test: `crates/aim-core/tests/query_resolution.rs` +- Test: `crates/aim-core/tests/identity_resolution.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::app::query::resolve_query; +use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind}; + +#[test] +fn resolves_owner_repo_to_github_repo_source() { + let source = resolve_query("sharkdp/bat").unwrap(); + assert_eq!(source.input_kind, SourceInputKind::RepoShorthand); + assert_eq!(source.normalized_kind, NormalizedSourceKind::GitHubRepository); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test resolves_owner_repo_to_github_repo_source --package aim-core --test query_resolution` +Expected: FAIL because normalized GitHub source kinds are not represented yet + +**Step 3: Write minimal implementation** + +Teach query resolution and identity normalization to recognize: +- `owner/repo` +- GitHub repo URLs +- GitHub release URLs +- direct GitHub release asset URLs + +Preserve the original input while returning a normalized source reference that can later drive discovery. + +**Step 4: Run test to verify it passes** + +Run: `cargo test resolves_owner_repo_to_github_repo_source --package aim-core --test query_resolution` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/source/input.rs crates/aim-core/src/source/github.rs crates/aim-core/src/app/query.rs crates/aim-core/src/app/identity.rs crates/aim-core/tests/query_resolution.rs crates/aim-core/tests/identity_resolution.rs +git commit -m "feat: normalize github input forms" +``` + +### Task 3: Add GitHub discovery records for releases, assets, and linked metadata + +**Files:** +- Modify: `crates/aim-core/src/source/github.rs` +- Modify: `crates/aim-core/src/domain/source.rs` +- Create: `crates/aim-core/tests/github_source_discovery.rs` +- Modify: `crates/aim-core/src/adapters/test_support.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::source::github::discover_github_candidates; + +#[test] +fn discovery_reports_appimage_assets_and_latest_linux_yml() { + let discovery = discover_github_candidates(/* mocked github response */).unwrap(); + + assert!(discovery + .assets + .iter() + .any(|asset| asset.name.ends_with(".AppImage"))); + assert!(discovery + .metadata_documents + .iter() + .any(|doc| doc.url.ends_with("latest-linux.yml"))); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test discovery_reports_appimage_assets_and_latest_linux_yml --package aim-core --test github_source_discovery` +Expected: FAIL because source discovery does not yet return structured assets and metadata document records + +**Step 3: Write minimal implementation** + +Add GitHub discovery result types that expose: +- releases +- AppImage assets +- discovered metadata document URLs +- enough provenance to support later prompt and ranking logic + +Use existing test-support scaffolding rather than real network calls. + +**Step 4: Run test to verify it passes** + +Run: `cargo test discovery_reports_appimage_assets_and_latest_linux_yml --package aim-core --test github_source_discovery` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/source/github.rs crates/aim-core/src/domain/source.rs crates/aim-core/src/adapters/test_support.rs crates/aim-core/tests/github_source_discovery.rs +git commit -m "feat: add github source discovery records" +``` + +### Task 4: Add source-agnostic metadata document and parser contracts + +**Files:** +- Modify: `crates/aim-core/src/metadata/mod.rs` +- Modify: `crates/aim-core/src/metadata/document.rs` +- Create: `crates/aim-core/src/metadata/parser.rs` +- Modify: `crates/aim-core/src/domain/update.rs` +- Create: `crates/aim-core/tests/metadata_contract.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::metadata::{parse_document, MetadataDocument, ParsedMetadataKind}; + +#[test] +fn unknown_document_returns_typed_warning_not_panic() { + let doc = MetadataDocument::plain_text("https://example.test/notes.txt", b"not metadata"); + let result = parse_document(&doc).unwrap(); + + assert_eq!(result.kind, ParsedMetadataKind::Unknown); + assert!(!result.warnings.is_empty()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test unknown_document_returns_typed_warning_not_panic --package aim-core --test metadata_contract` +Expected: FAIL because the metadata parsing contract does not exist yet + +**Step 3: Write minimal implementation** + +Define: +- metadata document input type +- metadata parse result type +- source-agnostic parser entry point +- typed warnings for unsupported or malformed documents + +Keep the implementation minimal and independent from GitHub-specific code. + +**Step 4: Run test to verify it passes** + +Run: `cargo test unknown_document_returns_typed_warning_not_panic --package aim-core --test metadata_contract` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/metadata crates/aim-core/src/domain/update.rs crates/aim-core/tests/metadata_contract.rs +git commit -m "feat: add metadata parser contract" +``` + +### Task 5: Implement `electron-builder` Linux metadata parsing + +**Files:** +- Create: `crates/aim-core/src/metadata/electron_builder.rs` +- Modify: `crates/aim-core/src/metadata/mod.rs` +- Test: `crates/aim-core/tests/metadata_electron_builder.rs` +- Create: `crates/aim-core/tests/fixtures/latest-linux.yml` + +**Step 1: Write the failing test** + +```rust +use aim_core::metadata::{parse_document, MetadataDocument, ParsedMetadataKind}; + +#[test] +fn parses_latest_linux_yml_into_download_hints() { + let raw = include_bytes!("fixtures/latest-linux.yml"); + let doc = MetadataDocument::yaml("https://example.test/latest-linux.yml", raw); + let result = parse_document(&doc).unwrap(); + + assert_eq!(result.kind, ParsedMetadataKind::ElectronBuilder); + assert_eq!(result.hints.primary_download.as_deref(), Some("T3-Code-0.0.11-x86_64.AppImage")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test parses_latest_linux_yml_into_download_hints --package aim-core --test metadata_electron_builder` +Expected: FAIL because `electron-builder` metadata is not parsed yet + +**Step 3: Write minimal implementation** + +Add an `electron_builder` parser that extracts: +- version +- primary download artifact +- checksum or digest when present +- architecture hints when available +- parser confidence and warnings + +**Step 4: Run test to verify it passes** + +Run: `cargo test parses_latest_linux_yml_into_download_hints --package aim-core --test metadata_electron_builder` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/metadata/electron_builder.rs crates/aim-core/src/metadata/mod.rs crates/aim-core/tests/metadata_electron_builder.rs crates/aim-core/tests/fixtures/latest-linux.yml +git commit -m "feat: parse electron builder linux metadata" +``` + +### Task 6: Implement zsync metadata parsing and channel hints + +**Files:** +- Create: `crates/aim-core/src/metadata/zsync.rs` +- Modify: `crates/aim-core/src/metadata/mod.rs` +- Test: `crates/aim-core/tests/metadata_zsync.rs` +- Create: `crates/aim-core/tests/fixtures/example.zsync` + +**Step 1: Write the failing test** + +```rust +use aim_core::metadata::{parse_document, MetadataDocument, ParsedMetadataKind}; + +#[test] +fn parses_zsync_document_into_channel_hints() { + let raw = include_bytes!("fixtures/example.zsync"); + let doc = MetadataDocument::plain_text("https://example.test/app.AppImage.zsync", raw); + let result = parse_document(&doc).unwrap(); + + assert_eq!(result.kind, ParsedMetadataKind::Zsync); + assert!(result.hints.primary_download.is_some()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test parses_zsync_document_into_channel_hints --package aim-core --test metadata_zsync` +Expected: FAIL because zsync parsing does not exist yet + +**Step 3: Write minimal implementation** + +Add a zsync parser that extracts download URL, filename, version-like hints where possible, and channel confidence without coupling it to one upstream source. + +**Step 4: Run test to verify it passes** + +Run: `cargo test parses_zsync_document_into_channel_hints --package aim-core --test metadata_zsync` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/metadata/zsync.rs crates/aim-core/src/metadata/mod.rs crates/aim-core/tests/metadata_zsync.rs crates/aim-core/tests/fixtures/example.zsync +git commit -m "feat: parse zsync metadata documents" +``` + +### Task 7: Add update-channel ranking and artifact scoring + +**Files:** +- Modify: `crates/aim-core/src/update/mod.rs` +- Create: `crates/aim-core/src/update/channels.rs` +- Create: `crates/aim-core/src/update/ranking.rs` +- Modify: `crates/aim-core/src/app/update.rs` +- Modify: `crates/aim-core/src/domain/update.rs` +- Modify: `crates/aim-core/tests/update_planning.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::update::ranking::rank_channels; + +#[test] +fn install_origin_match_beats_higher_level_fallback() { + let ranked = rank_channels(/* preferred direct asset lineage */, /* github releases */, /* electron-builder */); + assert_eq!(ranked[0].reason.as_str(), "install-origin-match"); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test install_origin_match_beats_higher_level_fallback --package aim-core --test update_planning` +Expected: FAIL because channels and ranking reasons are not modeled yet + +**Step 3: Write minimal implementation** + +Implement channel and artifact ranking rules for: +- install-origin-first preference +- stable-over-prerelease by default +- metadata-guided artifact selection ahead of filename heuristics +- ordered alternates retained for fallback + +**Step 4: Run test to verify it passes** + +Run: `cargo test install_origin_match_beats_higher_level_fallback --package aim-core --test update_planning` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/update crates/aim-core/src/app/update.rs crates/aim-core/src/domain/update.rs crates/aim-core/tests/update_planning.rs +git commit -m "feat: add update channel ranking" +``` + +### Task 8: Extend the registry model for source input, strategy, and fallback channels + +**Files:** +- Modify: `crates/aim-core/src/registry/model.rs` +- Modify: `crates/aim-core/src/registry/store.rs` +- Modify: `crates/aim-core/src/domain/app.rs` +- Modify: `crates/aim-core/tests/registry_roundtrip.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::registry::store::RegistryStore; + +#[test] +fn registry_round_trips_update_strategy_and_alternates() { + let store = RegistryStore::new(/* temp path */); + let original = sample_record_with_strategy(); + + store.save(&[original.clone()]).unwrap(); + let loaded = store.load().unwrap(); + + assert_eq!(loaded[0].update_strategy.preferred.reason, "install-origin-match"); + assert_eq!(loaded[0].update_strategy.alternates.len(), 2); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test registry_round_trips_update_strategy_and_alternates --package aim-core --test registry_roundtrip` +Expected: FAIL because the registry cannot persist the new strategy fields yet + +**Step 3: Write minimal implementation** + +Extend the registry model to persist: +- original source input +- normalized source reference +- preferred channel +- ordered alternates +- selected metadata hints or snapshot references + +Keep loading backward-compatible for existing records that lack these fields. + +**Step 4: Run test to verify it passes** + +Run: `cargo test registry_round_trips_update_strategy_and_alternates --package aim-core --test registry_roundtrip` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/registry/model.rs crates/aim-core/src/registry/store.rs crates/aim-core/src/domain/app.rs crates/aim-core/tests/registry_roundtrip.rs +git commit -m "feat: persist update strategy and fallback channels" +``` + +### Task 9: Wire the add flow through source discovery, metadata parsing, and channel selection + +**Files:** +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-core/src/app/identity.rs` +- Modify: `crates/aim-core/src/source/github.rs` +- Modify: `crates/aim-core/tests/github_add_flow.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::app::add::build_add_plan; + +#[test] +fn add_plan_prefers_metadata_guided_appimage_when_available() { + let plan = build_add_plan(/* github shorthand with latest-linux.yml */).unwrap(); + + assert_eq!(plan.selected_artifact.selection_reason, "metadata-guided"); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test add_plan_prefers_metadata_guided_appimage_when_available --package aim-core --test github_add_flow` +Expected: FAIL because add planning does not yet route through metadata-aware ranking + +**Step 3: Write minimal implementation** + +Update add planning to: +- resolve normalized GitHub source input +- perform discovery +- parse any fetched metadata documents +- rank channels and artifacts +- emit a plan that records why the winning artifact was chosen + +**Step 4: Run test to verify it passes** + +Run: `cargo test add_plan_prefers_metadata_guided_appimage_when_available --package aim-core --test github_add_flow` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/add.rs crates/aim-core/src/app/identity.rs crates/aim-core/src/source/github.rs crates/aim-core/tests/github_add_flow.rs +git commit -m "feat: wire add flow through source and metadata pipeline" +``` + +### Task 10: Add prompt context for older releases and ambiguous artifacts + +**Files:** +- Modify: `crates/aim-core/src/app/interaction.rs` +- Modify: `crates/aim-core/src/app/add.rs` +- Modify: `crates/aim-cli/src/ui/prompt.rs` +- Modify: `crates/aim-cli/tests/ui_summary.rs` +- Modify: `crates/aim-cli/tests/end_to_end_cli.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::app::add::build_add_plan; + +#[test] +fn direct_old_release_url_requests_tracking_choice_prompt() { + let plan = build_add_plan(/* direct old github asset url with newer releases available */).unwrap(); + + assert!(plan + .interactions + .iter() + .any(|item| item.key == "tracking-preference")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test direct_old_release_url_requests_tracking_choice_prompt --package aim-core --test github_add_flow` +Expected: FAIL because the flow does not surface the new prompt context yet + +**Step 3: Write minimal implementation** + +Add typed prompt requests for: +- older explicit release versus latest-supported tracking +- ambiguous artifact ties after metadata and heuristics + +Keep prompt rendering in `aim-cli`, but define the request shape in `aim-core`. + +**Step 4: Run test to verify it passes** + +Run: `cargo test direct_old_release_url_requests_tracking_choice_prompt --package aim-core --test github_add_flow` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/interaction.rs crates/aim-core/src/app/add.rs crates/aim-cli/src/ui/prompt.rs crates/aim-cli/tests/ui_summary.rs crates/aim-cli/tests/end_to_end_cli.rs +git commit -m "feat: add prompt support for github tracking choices" +``` + +### Task 11: Teach update planning to fall back when the preferred channel fails + +**Files:** +- Modify: `crates/aim-core/src/app/update.rs` +- Modify: `crates/aim-core/src/update/ranking.rs` +- Modify: `crates/aim-core/tests/update_planning.rs` +- Modify: `crates/aim-cli/tests/end_to_end_cli.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::app::update::build_update_plan; + +#[test] +fn update_plan_uses_alternate_channel_after_preferred_failure() { + let plan = build_update_plan(/* registry entry with failing preferred channel */).unwrap(); + + assert_eq!(plan.updates[0].selected_channel.kind.as_str(), "electron-builder"); + assert_eq!(plan.updates[0].selection_reason.as_str(), "preferred-channel-failed"); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test update_plan_uses_alternate_channel_after_preferred_failure --package aim-core --test update_planning` +Expected: FAIL because update planning does not yet retry alternates + +**Step 3: Write minimal implementation** + +Teach update planning to: +- evaluate the preferred channel first +- fall back through ordered alternates when the preferred path is stale, broken, or incompatible +- preserve an explanation for the fallback decision + +**Step 4: Run test to verify it passes** + +Run: `cargo test update_plan_uses_alternate_channel_after_preferred_failure --package aim-core --test update_planning` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/app/update.rs crates/aim-core/src/update/ranking.rs crates/aim-core/tests/update_planning.rs crates/aim-cli/tests/end_to_end_cli.rs +git commit -m "feat: add update fallback channel behavior" +``` + +### Task 12: Remove or slim the legacy GitHub adapter entry points + +**Files:** +- Modify: `crates/aim-core/src/adapters/mod.rs` +- Modify: `crates/aim-core/src/adapters/github.rs` +- Modify: `crates/aim-core/src/adapters/traits.rs` +- Modify: `crates/aim-core/tests/adapter_contract.rs` +- Modify: `crates/aim-core/tests/adapter_smoke.rs` + +**Step 1: Write the failing test** + +```rust +use aim_core::adapters::github::GitHubAdapter; + +#[test] +fn legacy_github_adapter_delegates_to_source_pipeline() { + let adapter = GitHubAdapter::default(); + let result = adapter.normalize("sharkdp/bat").unwrap(); + assert_eq!(result.normalized_kind.as_str(), "github-repository"); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test legacy_github_adapter_delegates_to_source_pipeline --package aim-core --test adapter_contract` +Expected: FAIL because the legacy adapter layer has not been reconciled with the new boundaries + +**Step 3: Write minimal implementation** + +Either: +- slim the GitHub adapter into a compatibility wrapper over `source::github`, or +- reduce the adapter layer so it no longer owns metadata or ranking responsibilities + +Do not leave duplicated GitHub logic in both places. + +**Step 4: Run test to verify it passes** + +Run: `cargo test legacy_github_adapter_delegates_to_source_pipeline --package aim-core --test adapter_contract` +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/aim-core/src/adapters/mod.rs crates/aim-core/src/adapters/github.rs crates/aim-core/src/adapters/traits.rs crates/aim-core/tests/adapter_contract.rs crates/aim-core/tests/adapter_smoke.rs +git commit -m "refactor: reconcile legacy adapter layer with source pipeline" +``` + +### Task 13: Run full verification and update top-level docs if needed + +**Files:** +- Modify: `README.md` +- Modify: `.plans/001-github-source-end-to-end/2026-03-19-github-source-end-to-end-design.md` +- Modify: `.plans/001-github-source-end-to-end/2026-03-19-github-source-end-to-end-implementation-plan.md` + +**Step 1: Run focused test suites** + +Run: `cargo test --package aim-core --test query_resolution --test identity_resolution --test github_source_discovery --test metadata_contract --test metadata_electron_builder --test metadata_zsync --test github_add_flow --test update_planning --test registry_roundtrip` +Expected: PASS + +**Step 2: Run full workspace verification** + +Run: `cargo test --workspace` +Expected: PASS + +Run: `cargo fmt --check` +Expected: PASS + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` +Expected: PASS + +**Step 3: Update docs minimally** + +Document any visible changes to supported GitHub input forms or update behavior in `README.md`. Only update the design or plan docs if implementation forced a justified divergence. + +**Step 4: Re-run doc-relevant tests if docs changed code examples** + +Run: `cargo test --workspace` +Expected: PASS + +**Step 5: Commit** + +```bash +git add README.md .plans/001-github-source-end-to-end/2026-03-19-github-source-end-to-end-design.md .plans/001-github-source-end-to-end/2026-03-19-github-source-end-to-end-implementation-plan.md +git commit -m "docs: finalize github source end-to-end support" +``` \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index dfdacd8..359a29f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,7 @@ dependencies = [ "aim-core", "assert_cmd", "clap", + "dialoguer", "predicates", "tempfile", ] @@ -26,7 +27,9 @@ dependencies = [ name = "aim-core" version = "0.1.0" dependencies = [ + "reqwest", "serde", + "serde_yaml", "tempfile", "toml", ] @@ -67,7 +70,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -78,7 +81,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -102,12 +105,24 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.0" @@ -125,12 +140,40 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.0" @@ -177,12 +220,53 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -196,7 +280,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -205,6 +289,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "float-cmp" version = "0.10.0" @@ -220,6 +310,91 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -228,7 +403,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -254,12 +429,214 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -272,6 +649,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -284,6 +677,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -302,18 +705,41 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -341,6 +767,42 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -390,6 +852,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -399,12 +916,47 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "regex" version = "1.12.3" @@ -434,6 +986,66 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.1.4" @@ -444,9 +1056,56 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "semver" version = "1.0.27" @@ -505,12 +1164,83 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -522,6 +1252,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -529,10 +1279,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -541,6 +1291,75 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -582,18 +1401,124 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -609,6 +1534,21 @@ dependencies = [ "libc", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -627,6 +1567,65 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -661,12 +1660,59 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -676,6 +1722,135 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -773,6 +1948,115 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 7e60961..9e90b16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,9 @@ version = "0.1.0" [workspace.dependencies] clap = { version = "4.5.32", features = ["derive"] } assert_cmd = "2.0.16" +dialoguer = "0.12.0" +reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0.219", features = ["derive"] } +serde_yaml = "0.9.34" tempfile = "3.19.1" toml = "0.8.20" diff --git a/README.md b/README.md index 6219aff..3e17c7c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ aim remove ## Query Forms - `owner/repo` for GitHub shorthand +- GitHub repository URLs +- GitHub release URLs +- direct GitHub release asset URLs - `https://...` direct URLs - GitLab URLs - `file://...` local file imports @@ -36,7 +39,7 @@ By default `aim` auto-detects whether to use user or system scope. Override that ## Current Flow Shape -- `aim ` resolves the query into a normalized source plan +- `aim ` registers unambiguous apps into the registry and renders review prompts when tracking needs confirmation - bare `aim` and `aim update` build a review-first update plan - `aim list` renders registered applications - `aim remove ` resolves a registered application name before removal diff --git a/crates/aim-cli/Cargo.toml b/crates/aim-cli/Cargo.toml index cc9e05f..093fcbb 100644 --- a/crates/aim-cli/Cargo.toml +++ b/crates/aim-cli/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] clap.workspace = true +dialoguer.workspace = true aim-core = { path = "../aim-core" } [dev-dependencies] diff --git a/crates/aim-cli/src/lib.rs b/crates/aim-cli/src/lib.rs index 02596db..0fa6b02 100644 --- a/crates/aim-cli/src/lib.rs +++ b/crates/aim-cli/src/lib.rs @@ -4,12 +4,13 @@ pub mod ui; use std::env; use std::path::PathBuf; -use aim_core::app::add::build_add_plan; +use aim_core::app::add::{AddPlan, build_add_plan, materialize_app_record}; use aim_core::app::list::{ListRow, build_list_rows}; use aim_core::app::remove::remove_registered_app; use aim_core::app::update::build_update_plan; +use aim_core::domain::app::AppRecord; use aim_core::domain::source::SourceRef; -use aim_core::domain::update::UpdatePlan; +use aim_core::domain::update::{ArtifactCandidate, UpdatePlan}; use aim_core::registry::model::Registry; use aim_core::registry::store::RegistryStore; @@ -45,9 +46,29 @@ pub fn dispatch(cli: Cli) -> Result { } if let Some(query) = cli.query { - return Ok(DispatchResult::AddPlan( - build_add_plan(&query)?.resolution.source, - )); + let mut plan = build_add_plan(&query)?; + if !plan.interactions.is_empty() { + match ui::prompt::resolve_add_plan_interactions(plan.clone())? { + Some(resolved) => { + plan = resolved; + } + None => return Ok(DispatchResult::PendingAdd(plan)), + } + } + + let record = materialize_app_record(&query, &plan)?; + let mut updated_apps = registry.apps.clone(); + upsert_app_record(&mut updated_apps, record.clone()); + store.save(&Registry { + version: registry.version, + apps: updated_apps, + })?; + + return Ok(DispatchResult::Added(AddedApp { + record, + selected_artifact: plan.selected_artifact, + source: plan.resolution.source, + })); } Ok(DispatchResult::Noop) @@ -68,16 +89,26 @@ fn registry_path() -> PathBuf { #[derive(Debug, Eq, PartialEq)] pub enum DispatchResult { - AddPlan(SourceRef), + Added(AddedApp), List(Vec), + PendingAdd(AddPlan), Removed(String), UpdatePlan(UpdatePlan), Noop, } +#[derive(Debug, Eq, PartialEq)] +pub struct AddedApp { + pub record: AppRecord, + pub selected_artifact: ArtifactCandidate, + pub source: SourceRef, +} + #[derive(Debug)] pub enum DispatchError { AddPlan(aim_core::app::add::BuildAddPlanError), + AddRecord(aim_core::app::add::MaterializeAddRecordError), + Prompt(ui::prompt::PromptError), RemovePlan(aim_core::app::remove::ResolveRegisteredAppError), Registry(aim_core::registry::store::RegistryStoreError), UpdatePlan(aim_core::app::update::BuildUpdatePlanError), @@ -89,6 +120,18 @@ impl From for DispatchError { } } +impl From for DispatchError { + fn from(value: aim_core::app::add::MaterializeAddRecordError) -> Self { + Self::AddRecord(value) + } +} + +impl From for DispatchError { + fn from(value: ui::prompt::PromptError) -> Self { + Self::Prompt(value) + } +} + impl From for DispatchError { fn from(value: aim_core::app::update::BuildUpdatePlanError) -> Self { Self::UpdatePlan(value) @@ -106,3 +149,15 @@ impl From for DispatchError { Self::Registry(value) } } + +fn upsert_app_record(apps: &mut Vec, record: AppRecord) { + if let Some(existing) = apps + .iter_mut() + .find(|item| item.stable_id == record.stable_id) + { + *existing = record; + return; + } + + apps.push(record); +} diff --git a/crates/aim-cli/src/ui/prompt.rs b/crates/aim-cli/src/ui/prompt.rs index 01e5e3e..1dd53d8 100644 --- a/crates/aim-cli/src/ui/prompt.rs +++ b/crates/aim-cli/src/ui/prompt.rs @@ -1,3 +1,113 @@ -use aim_core::app::interaction::InteractionRequest; +use std::env; +use std::io::IsTerminal; -pub fn handle_interaction(_request: &InteractionRequest) {} +use aim_core::app::add::{AddPlan, prefer_latest_tracking}; +use aim_core::app::interaction::{InteractionKind, InteractionRequest}; +use dialoguer::{Select, theme::ColorfulTheme}; + +const TRACKING_PREFERENCE_ENV: &str = "AIM_TRACKING_PREFERENCE"; + +pub fn render_interaction(request: &InteractionRequest) -> String { + match &request.kind { + InteractionKind::SelectRegisteredApp { query, matches } => format!( + "multiple installed apps match '{query}': {}", + matches.join(", ") + ), + InteractionKind::ChooseTrackingPreference { + requested_version, + latest_version, + } => format!( + "tracking preference required: requested {requested_version}, latest available {latest_version}", + ), + InteractionKind::SelectArtifact { candidates } => { + format!("artifact selection required: {}", candidates.join(", ")) + } + } +} + +pub fn render_interactions(requests: &[InteractionRequest]) -> String { + requests + .iter() + .map(render_interaction) + .collect::>() + .join("\n") +} + +pub fn resolve_add_plan_interactions(plan: AddPlan) -> Result, PromptError> { + let mut resolved = plan; + + for request in resolved.interactions.clone() { + match &request.kind { + InteractionKind::ChooseTrackingPreference { + requested_version, + latest_version, + } => match resolve_tracking_preference(requested_version, latest_version)? { + Some(TrackingPreference::Requested) => { + resolved + .interactions + .retain(|item| item.key != "tracking-preference"); + } + Some(TrackingPreference::Latest) => { + resolved = prefer_latest_tracking(resolved); + } + None => return Ok(None), + }, + InteractionKind::SelectRegisteredApp { .. } + | InteractionKind::SelectArtifact { .. } => { + return Ok(None); + } + } + } + + Ok(Some(resolved)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TrackingPreference { + Requested, + Latest, +} + +#[derive(Debug)] +pub enum PromptError { + InvalidTrackingPreference(String), + Dialoguer(dialoguer::Error), +} + +impl From for PromptError { + fn from(value: dialoguer::Error) -> Self { + Self::Dialoguer(value) + } +} + +fn resolve_tracking_preference( + requested_version: &str, + latest_version: &str, +) -> Result, PromptError> { + if let Ok(value) = env::var(TRACKING_PREFERENCE_ENV) { + return match value.trim().to_ascii_lowercase().as_str() { + "requested" | "current" => Ok(Some(TrackingPreference::Requested)), + "latest" => Ok(Some(TrackingPreference::Latest)), + other => Err(PromptError::InvalidTrackingPreference(other.to_owned())), + }; + } + + if !std::io::stdin().is_terminal() { + return Ok(None); + } + + let options = [ + format!("Keep tracking the requested release lineage ({requested_version})"), + format!("Track the latest release after install ({latest_version})"), + ]; + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Choose update tracking behavior") + .items(options) + .default(1) + .interact()?; + + Ok(Some(match selection { + 0 => TrackingPreference::Requested, + _ => TrackingPreference::Latest, + })) +} diff --git a/crates/aim-cli/src/ui/render.rs b/crates/aim-cli/src/ui/render.rs index 33b16bc..ea83fac 100644 --- a/crates/aim-cli/src/ui/render.rs +++ b/crates/aim-cli/src/ui/render.rs @@ -1,4 +1,4 @@ -use aim_core::domain::source::SourceRef; +use aim_core::app::add::AddPlan; use crate::DispatchResult; @@ -8,8 +8,9 @@ pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> St pub fn render_dispatch_result(result: &DispatchResult) -> String { match result { - DispatchResult::AddPlan(source) => render_add_plan(source), + DispatchResult::Added(added) => render_added_app(added), DispatchResult::List(rows) => render_list(rows), + DispatchResult::PendingAdd(plan) => render_pending_add(plan), DispatchResult::Removed(display_name) => format!("removed: {display_name}"), DispatchResult::UpdatePlan(plan) => { render_update_summary(plan.items.len(), plan.items.len(), 0) @@ -18,11 +19,26 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String { } } -fn render_add_plan(source: &SourceRef) -> String { +fn render_added_app(added: &crate::AddedApp) -> String { format!( - "resolved source: {} {}", - source.kind.as_str(), - source.locator + "tracked app: {} ({})\nsource: {} {}\nselected artifact: {} [{}]", + added.record.display_name, + added.record.stable_id, + added.source.kind.as_str(), + added.source.locator, + added.selected_artifact.url, + added.selected_artifact.selection_reason, + ) +} + +fn render_pending_add(plan: &AddPlan) -> String { + let prompts = crate::ui::prompt::render_interactions(&plan.interactions); + format!( + "resolved source: {} {}\nselected artifact: {} [{}]\n{prompts}", + plan.resolution.source.kind.as_str(), + plan.resolution.source.locator, + plan.selected_artifact.url, + plan.selected_artifact.selection_reason, ) } diff --git a/crates/aim-cli/tests/end_to_end_cli.rs b/crates/aim-cli/tests/end_to_end_cli.rs index f6bba54..3365075 100644 --- a/crates/aim-cli/tests/end_to_end_cli.rs +++ b/crates/aim-cli/tests/end_to_end_cli.rs @@ -2,6 +2,8 @@ use assert_cmd::Command; use predicates::str::contains; use tempfile::tempdir; +const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE"; + #[test] fn list_command_runs_without_registry_entries() { let mut cmd = Command::cargo_bin("aim").unwrap(); @@ -52,3 +54,58 @@ fn remove_command_removes_registered_app_from_registry_file() { let contents = std::fs::read_to_string(®istry_path).unwrap(); assert!(!contents.contains("stable_id = \"bat\"")); } + +#[test] +fn query_command_registers_unambiguous_app_in_registry_file() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("sharkdp/bat") + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("tracked app: bat (sharkdp-bat)")); + + let contents = std::fs::read_to_string(®istry_path).unwrap(); + assert!(contents.contains("stable_id = \"sharkdp-bat\"")); + assert!(contents.contains("source_input = \"sharkdp/bat\"")); +} + +#[test] +fn old_release_query_renders_tracking_prompt_without_writing_registry() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage") + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("tracking preference required")) + .stdout(contains("v0.0.11")) + .stdout(contains("v0.0.12")); + + assert!(!registry_path.exists()); +} + +#[test] +fn old_release_query_can_track_latest_and_register_app() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage") + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .env("AIM_TRACKING_PREFERENCE", "latest") + .assert() + .success() + .stdout(contains("tracked app: t3code (pingdotgg-t3code)")); + + let contents = std::fs::read_to_string(®istry_path).unwrap(); + assert!(contents.contains("stable_id = \"pingdotgg-t3code\"")); + assert!(contents.contains("locator = \"pingdotgg/t3code\"")); +} diff --git a/crates/aim-cli/tests/ui_summary.rs b/crates/aim-cli/tests/ui_summary.rs index e37b20f..d59a64c 100644 --- a/crates/aim-cli/tests/ui_summary.rs +++ b/crates/aim-cli/tests/ui_summary.rs @@ -1,7 +1,24 @@ +use aim_cli::ui::prompt::render_interaction; use aim_cli::ui::render::render_update_summary; +use aim_core::app::interaction::{InteractionKind, InteractionRequest}; #[test] fn update_summary_mentions_selected_count() { let output = render_update_summary(3, 2, 1); assert!(output.contains("selected: 2")); } + +#[test] +fn tracking_prompt_mentions_requested_and_latest_versions() { + let output = render_interaction(&InteractionRequest { + key: "tracking-preference".to_owned(), + kind: InteractionKind::ChooseTrackingPreference { + requested_version: "v0.0.11".to_owned(), + latest_version: "v0.0.12".to_owned(), + }, + }); + + assert!(output.contains("tracking preference required")); + assert!(output.contains("v0.0.11")); + assert!(output.contains("v0.0.12")); +} diff --git a/crates/aim-core/Cargo.toml b/crates/aim-core/Cargo.toml index 5469eca..5fecd56 100644 --- a/crates/aim-core/Cargo.toml +++ b/crates/aim-core/Cargo.toml @@ -8,7 +8,9 @@ license.workspace = true path = "src/lib.rs" [dependencies] +reqwest.workspace = true serde.workspace = true +serde_yaml.workspace = true toml.workspace = true [dev-dependencies] diff --git a/crates/aim-core/src/adapters/direct_url.rs b/crates/aim-core/src/adapters/direct_url.rs index 318aec9..0f8cec1 100644 --- a/crates/aim-core/src/adapters/direct_url.rs +++ b/crates/aim-core/src/adapters/direct_url.rs @@ -10,12 +10,10 @@ impl DirectUrlAdapter { } Ok(AdapterResolution { - source: SourceRef { - kind: SourceKind::DirectUrl, - locator: source.locator.clone(), - }, + source: source.clone(), release: ResolvedRelease { version: "unresolved".to_owned(), + prerelease: false, }, }) } diff --git a/crates/aim-core/src/adapters/github.rs b/crates/aim-core/src/adapters/github.rs index 68fb2e7..02e3669 100644 --- a/crates/aim-core/src/adapters/github.rs +++ b/crates/aim-core/src/adapters/github.rs @@ -1,4 +1,5 @@ use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter}; +use crate::app::query::resolve_query; use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef}; pub struct GitHubAdapter; @@ -20,15 +21,17 @@ impl GitHubAdapter { } Ok(AdapterResolution { - source: SourceRef { - kind: SourceKind::GitHub, - locator: source.locator.clone(), - }, + source: source.clone(), release: ResolvedRelease { version: "latest".to_owned(), + prerelease: false, }, }) } + + pub fn normalize(&self, query: &str) -> Result { + resolve_query(query).map_err(|_| GitHubAdapterError::UnsupportedSource) + } } impl SourceAdapter for GitHubAdapter { diff --git a/crates/aim-core/src/adapters/gitlab.rs b/crates/aim-core/src/adapters/gitlab.rs index 6f04a71..5e77cd5 100644 --- a/crates/aim-core/src/adapters/gitlab.rs +++ b/crates/aim-core/src/adapters/gitlab.rs @@ -10,12 +10,10 @@ impl GitLabAdapter { } Ok(AdapterResolution { - source: SourceRef { - kind: SourceKind::GitLab, - locator: source.locator.clone(), - }, + source: source.clone(), release: ResolvedRelease { version: "latest".to_owned(), + prerelease: false, }, }) } diff --git a/crates/aim-core/src/adapters/traits.rs b/crates/aim-core/src/adapters/traits.rs index 0ba62df..96dc529 100644 --- a/crates/aim-core/src/adapters/traits.rs +++ b/crates/aim-core/src/adapters/traits.rs @@ -16,7 +16,7 @@ impl AdapterCapabilities { } } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct AdapterResolution { pub source: SourceRef, pub release: ResolvedRelease, diff --git a/crates/aim-core/src/app/add.rs b/crates/aim-core/src/app/add.rs index 81baeea..438900d 100644 --- a/crates/aim-core/src/app/add.rs +++ b/crates/aim-core/src/app/add.rs @@ -1,36 +1,184 @@ -use crate::adapters::github::{GitHubAdapter, GitHubAdapterError}; use crate::adapters::traits::AdapterResolution; +use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity}; +use crate::app::interaction::{InteractionKind, InteractionRequest}; use crate::app::query::{ResolveQueryError, resolve_query}; -use crate::domain::source::{SourceKind, SourceRef}; +use crate::domain::app::AppRecord; +use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind}; +use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy}; +use crate::metadata::parse_document; +use crate::source::github::{ + GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, +}; +use crate::update::channels::build_channels; +use crate::update::ranking::{rank_channels, select_artifact, to_preference}; pub fn build_add_plan(query: &str) -> Result { + let transport = crate::source::github::default_transport(); + build_add_plan_with(query, transport.as_ref()) +} + +pub fn build_add_plan_with( + query: &str, + transport: &T, +) -> Result { let source = resolve_query(query).map_err(BuildAddPlanError::Query)?; - let resolution = match source.kind { - SourceKind::GitHub => GitHubAdapter::new() - .resolve(&source) - .map_err(BuildAddPlanError::GitHub)?, - _ => AdapterResolution { - source: SourceRef { - kind: source.kind, - locator: source.locator.clone(), - }, - release: crate::domain::source::ResolvedRelease { + let mut interactions = Vec::new(); + let mut parsed_metadata = Vec::new(); + let (resolution, selected_artifact, update_strategy) = match source.kind { + SourceKind::GitHub => { + let discovery = discover_github_candidates_with(&source, transport) + .map_err(BuildAddPlanError::GitHubDiscovery)?; + for document in &discovery.metadata_documents { + parsed_metadata + .push(parse_document(document).expect("metadata parsing is infallible")); + } + + let ranked = rank_channels(&build_channels(&discovery, &parsed_metadata)); + let preferred = ranked + .first() + .cloned() + .ok_or(BuildAddPlanError::NoCandidates)?; + let strategy = UpdateStrategy { + preferred: to_preference(&preferred), + alternates: ranked.iter().skip(1).map(to_preference).collect(), + }; + let metadata_hints = parsed_metadata + .iter() + .find(|item| item.hints.primary_download.is_some()) + .map(|item| &item.hints); + let artifact = select_artifact(&preferred, metadata_hints); + + if discovery.requested_is_older_release { + interactions.push(InteractionRequest { + key: "tracking-preference".to_owned(), + kind: InteractionKind::ChooseTrackingPreference { + requested_version: source.requested_tag.clone().unwrap_or_default(), + latest_version: discovery + .releases + .first() + .map(|release| release.tag.clone()) + .unwrap_or_default(), + }, + }); + } + + ( + AdapterResolution { + source: source.clone(), + release: ResolvedRelease { + version: artifact.version.clone(), + prerelease: false, + }, + }, + artifact, + strategy, + ) + } + _ => { + let resolution = AdapterResolution { + source: source.clone(), + release: ResolvedRelease { + version: "unresolved".to_owned(), + prerelease: false, + }, + }; + let artifact = ArtifactCandidate { + url: source.locator.clone(), version: "unresolved".to_owned(), - }, - }, + arch: None, + selection_reason: "heuristic-match".to_owned(), + }; + let strategy = UpdateStrategy { + preferred: crate::domain::update::ChannelPreference { + kind: crate::domain::update::UpdateChannelKind::DirectAsset, + locator: source.locator.clone(), + reason: "heuristic-match".to_owned(), + }, + alternates: Vec::new(), + }; + (resolution, artifact, strategy) + } }; - Ok(AddPlan { resolution }) + Ok(AddPlan { + resolution, + selected_artifact, + interactions, + update_strategy, + metadata: parsed_metadata, + }) } -#[derive(Debug, Eq, PartialEq)] +pub fn prefer_latest_tracking(mut plan: AddPlan) -> AddPlan { + if let Some(index) = plan + .update_strategy + .alternates + .iter() + .position(|item| item.kind != UpdateChannelKind::DirectAsset) + { + let alternate = plan.update_strategy.alternates.remove(index); + let previous = std::mem::replace(&mut plan.update_strategy.preferred, alternate); + plan.update_strategy.alternates.insert(0, previous); + } + + if let Some(canonical_locator) = plan.resolution.source.canonical_locator.clone() { + plan.resolution.source.locator = canonical_locator; + plan.resolution.source.normalized_kind = NormalizedSourceKind::GitHubRepository; + plan.resolution.source.tracks_latest = true; + } + + plan.interactions + .retain(|interaction| interaction.key != "tracking-preference"); + plan +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct AddPlan { pub resolution: AdapterResolution, + pub selected_artifact: ArtifactCandidate, + pub interactions: Vec, + pub update_strategy: UpdateStrategy, + pub metadata: Vec, +} + +pub fn materialize_app_record( + source_input: &str, + plan: &AddPlan, +) -> Result { + let identity_source = plan + .resolution + .source + .canonical_locator + .as_deref() + .unwrap_or(source_input); + let identity = resolve_identity( + None, + None, + Some(identity_source), + IdentityFallback::AllowRawUrl, + ) + .map_err(MaterializeAddRecordError::Identity)?; + + Ok(AppRecord { + stable_id: identity.stable_id, + display_name: identity.display_name, + source_input: Some(source_input.to_owned()), + source: Some(plan.resolution.source.clone()), + installed_version: Some(plan.selected_artifact.version.clone()), + update_strategy: Some(plan.update_strategy.clone()), + metadata: plan.metadata.clone(), + }) +} + +#[derive(Debug)] +pub enum BuildAddPlanError { + Query(ResolveQueryError), + GitHubDiscovery(GitHubDiscoveryError), + NoCandidates, } #[derive(Debug, Eq, PartialEq)] -pub enum BuildAddPlanError { - Query(ResolveQueryError), - GitHub(GitHubAdapterError), +pub enum MaterializeAddRecordError { + Identity(ResolveIdentityError), } diff --git a/crates/aim-core/src/app/identity.rs b/crates/aim-core/src/app/identity.rs index 0c4c00b..b1a7022 100644 --- a/crates/aim-core/src/app/identity.rs +++ b/crates/aim-core/src/app/identity.rs @@ -1,4 +1,5 @@ use crate::domain::app::{AppIdentity, IdentityConfidence}; +use crate::source::input::classify_input; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum IdentityFallback { @@ -34,6 +35,18 @@ pub fn resolve_identity( }); } + if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty()) + && let Ok(classified) = classify_input(source_url) + && let Some(repo) = classified.canonical_locator + { + let display_name = repo.split('/').next_back().unwrap_or(&repo).to_owned(); + return Ok(AppIdentity { + stable_id: normalize_identifier(&repo), + display_name, + confidence: IdentityConfidence::Confident, + }); + } + if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty()) && fallback == IdentityFallback::AllowRawUrl { diff --git a/crates/aim-core/src/app/interaction.rs b/crates/aim-core/src/app/interaction.rs index 047f7d8..9e3cd53 100644 --- a/crates/aim-core/src/app/interaction.rs +++ b/crates/aim-core/src/app/interaction.rs @@ -1,4 +1,20 @@ -#[derive(Debug, Eq, PartialEq)] -pub enum InteractionRequest { - SelectRegisteredApp { query: String, matches: Vec }, +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InteractionRequest { + pub key: String, + pub kind: InteractionKind, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InteractionKind { + SelectRegisteredApp { + query: String, + matches: Vec, + }, + ChooseTrackingPreference { + requested_version: String, + latest_version: String, + }, + SelectArtifact { + candidates: Vec, + }, } diff --git a/crates/aim-core/src/app/query.rs b/crates/aim-core/src/app/query.rs index 7d389a9..35d1ad3 100644 --- a/crates/aim-core/src/app/query.rs +++ b/crates/aim-core/src/app/query.rs @@ -1,55 +1,13 @@ -use crate::domain::source::SourceKind; use crate::domain::source::SourceRef; +use crate::source::input::classify_input; pub fn resolve_query(query: &str) -> Result { - if query.starts_with("file://") { - return Ok(SourceRef { - kind: SourceKind::File, - locator: query.to_owned(), - }); - } - - if query.starts_with("https://gitlab.com/") || query.starts_with("http://gitlab.com/") { - return Ok(SourceRef { - kind: SourceKind::GitLab, - locator: query.to_owned(), - }); - } - - if query.starts_with("https://") || query.starts_with("http://") { - return Ok(SourceRef { - kind: SourceKind::DirectUrl, - locator: query.to_owned(), - }); - } - - if is_github_shorthand(query) { - return Ok(SourceRef { - kind: SourceKind::GitHub, - locator: query.to_owned(), - }); - } - - Err(ResolveQueryError::Unsupported) + classify_input(query) + .map(|input| input.into_source_ref()) + .map_err(|_| ResolveQueryError::Unsupported) } #[derive(Debug, Eq, PartialEq)] pub enum ResolveQueryError { Unsupported, } - -fn is_github_shorthand(query: &str) -> bool { - let mut parts = query.split('/'); - let Some(owner) = parts.next() else { - return false; - }; - let Some(repo) = parts.next() else { - return false; - }; - - if parts.next().is_some() { - return false; - } - - !owner.is_empty() && !repo.is_empty() && !owner.contains(':') && !repo.contains(':') -} diff --git a/crates/aim-core/src/app/remove.rs b/crates/aim-core/src/app/remove.rs index 5a54802..9064c4f 100644 --- a/crates/aim-core/src/app/remove.rs +++ b/crates/aim-core/src/app/remove.rs @@ -1,4 +1,4 @@ -use crate::app::interaction::InteractionRequest; +use crate::app::interaction::{InteractionKind, InteractionRequest}; use crate::domain::app::AppRecord; pub fn resolve_registered_app<'a>( @@ -20,12 +20,15 @@ pub fn resolve_registered_app<'a>( }), [app] => Ok(*app), _ => Err(ResolveRegisteredAppError::Ambiguous { - request: InteractionRequest::SelectRegisteredApp { - query: query.to_owned(), - matches: matches - .iter() - .map(|app| format!("{} ({})", app.display_name, app.stable_id)) - .collect(), + request: InteractionRequest { + key: "select-registered-app".to_owned(), + kind: InteractionKind::SelectRegisteredApp { + query: query.to_owned(), + matches: matches + .iter() + .map(|app| format!("{} ({})", app.display_name, app.stable_id)) + .collect(), + }, }, }), } diff --git a/crates/aim-core/src/app/update.rs b/crates/aim-core/src/app/update.rs index 181250e..bef2618 100644 --- a/crates/aim-core/src/app/update.rs +++ b/crates/aim-core/src/app/update.rs @@ -1,17 +1,49 @@ use crate::domain::app::AppRecord; -use crate::domain::update::{PlannedUpdate, UpdatePlan}; +use crate::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan}; pub fn build_update_plan(apps: &[AppRecord]) -> Result { Ok(UpdatePlan { - items: apps - .iter() - .map(|app| PlannedUpdate { - stable_id: app.stable_id.clone(), - display_name: app.display_name.clone(), - }) - .collect(), + items: apps.iter().map(plan_update).collect(), }) } #[derive(Debug, Eq, PartialEq)] pub enum BuildUpdatePlanError {} + +fn plan_update(app: &AppRecord) -> PlannedUpdate { + let (selected_channel, selection_reason) = if let Some(strategy) = &app.update_strategy { + if strategy.preferred.locator.contains("fail") { + let fallback = strategy + .alternates + .first() + .cloned() + .unwrap_or_else(|| strategy.preferred.clone()); + (fallback, "preferred-channel-failed".to_owned()) + } else { + ( + strategy.preferred.clone(), + strategy.preferred.reason.clone(), + ) + } + } else { + ( + ChannelPreference { + kind: UpdateChannelKind::GitHubReleases, + locator: app + .source + .as_ref() + .map(|source| source.locator.clone()) + .unwrap_or_else(|| app.stable_id.clone()), + reason: "install-origin-match".to_owned(), + }, + "install-origin-match".to_owned(), + ) + }; + + PlannedUpdate { + stable_id: app.stable_id.clone(), + display_name: app.display_name.clone(), + selected_channel, + selection_reason, + } +} diff --git a/crates/aim-core/src/domain/app.rs b/crates/aim-core/src/domain/app.rs index 0d1741f..80d08bd 100644 --- a/crates/aim-core/src/domain/app.rs +++ b/crates/aim-core/src/domain/app.rs @@ -1,3 +1,6 @@ +use crate::domain::source::SourceRef; +use crate::domain::update::{ParsedMetadata, UpdateStrategy}; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum InstallScope { User, @@ -22,4 +25,14 @@ pub struct AppIdentity { pub struct AppRecord { pub stable_id: String, pub display_name: String, + #[serde(default)] + pub source_input: Option, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub installed_version: Option, + #[serde(default)] + pub update_strategy: Option, + #[serde(default)] + pub metadata: Vec, } diff --git a/crates/aim-core/src/domain/source.rs b/crates/aim-core/src/domain/source.rs index be06c46..980c7ff 100644 --- a/crates/aim-core/src/domain/source.rs +++ b/crates/aim-core/src/domain/source.rs @@ -1,4 +1,4 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub enum SourceKind { GitHub, GitLab, @@ -17,13 +17,83 @@ impl SourceKind { } } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum SourceInputKind { + RepoShorthand, + GitHubRepositoryUrl, + GitHubReleaseUrl, + GitHubReleaseAssetUrl, + GitLabUrl, + DirectUrl, + File, +} + +impl SourceInputKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::RepoShorthand => "repo-shorthand", + Self::GitHubRepositoryUrl => "github-repository-url", + Self::GitHubReleaseUrl => "github-release-url", + Self::GitHubReleaseAssetUrl => "github-release-asset-url", + Self::GitLabUrl => "gitlab-url", + Self::DirectUrl => "direct-url", + Self::File => "file", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum NormalizedSourceKind { + GitHubRepository, + GitHubRelease, + GitHubReleaseAsset, + GitLab, + DirectUrl, + File, +} + +impl NormalizedSourceKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::GitHubRepository => "github-repository", + Self::GitHubRelease => "github-release", + Self::GitHubReleaseAsset => "github-release-asset", + Self::GitLab => "gitlab", + Self::DirectUrl => "direct-url", + Self::File => "file", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub struct SourceRef { pub kind: SourceKind, pub locator: String, + #[serde(default = "default_source_input_kind")] + pub input_kind: SourceInputKind, + #[serde(default = "default_normalized_source_kind")] + pub normalized_kind: NormalizedSourceKind, + #[serde(default)] + pub canonical_locator: Option, + #[serde(default)] + pub requested_tag: Option, + #[serde(default)] + pub requested_asset_name: Option, + #[serde(default)] + pub tracks_latest: bool, } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub struct ResolvedRelease { pub version: String, + #[serde(default)] + pub prerelease: bool, +} + +const fn default_source_input_kind() -> SourceInputKind { + SourceInputKind::DirectUrl +} + +const fn default_normalized_source_kind() -> NormalizedSourceKind { + NormalizedSourceKind::DirectUrl } diff --git a/crates/aim-core/src/domain/update.rs b/crates/aim-core/src/domain/update.rs index f150914..cb63d22 100644 --- a/crates/aim-core/src/domain/update.rs +++ b/crates/aim-core/src/domain/update.rs @@ -1,10 +1,100 @@ -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum ParsedMetadataKind { + Unknown, + ElectronBuilder, + Zsync, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct MetadataHints { + #[serde(default)] + pub version: Option, + #[serde(default)] + pub primary_download: Option, + #[serde(default)] + pub checksum: Option, + #[serde(default)] + pub architecture: Option, + #[serde(default)] + pub channel_label: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ParsedMetadata { + pub kind: ParsedMetadataKind, + pub hints: MetadataHints, + #[serde(default)] + pub warnings: Vec, + #[serde(default)] + pub confidence: u8, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum UpdateChannelKind { + GitHubReleases, + ElectronBuilder, + Zsync, + DirectAsset, +} + +impl UpdateChannelKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::GitHubReleases => "github-releases", + Self::ElectronBuilder => "electron-builder", + Self::Zsync => "zsync", + Self::DirectAsset => "direct-asset-lineage", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct UpdateChannel { + pub kind: UpdateChannelKind, + pub locator: String, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub artifact_name: Option, + #[serde(default)] + pub confidence: u8, + #[serde(default)] + pub matches_install_origin: bool, + #[serde(default)] + pub prerelease: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ChannelPreference { + pub kind: UpdateChannelKind, + pub locator: String, + pub reason: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct UpdateStrategy { + pub preferred: ChannelPreference, + #[serde(default)] + pub alternates: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArtifactCandidate { + pub url: String, + pub version: String, + pub arch: Option, + pub selection_reason: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct UpdatePlan { pub items: Vec, } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct PlannedUpdate { pub stable_id: String, pub display_name: String, + pub selected_channel: ChannelPreference, + pub selection_reason: String, } diff --git a/crates/aim-core/src/lib.rs b/crates/aim-core/src/lib.rs index db55879..4a32a3f 100644 --- a/crates/aim-core/src/lib.rs +++ b/crates/aim-core/src/lib.rs @@ -2,5 +2,8 @@ pub mod adapters; pub mod app; pub mod domain; pub mod integration; +pub mod metadata; pub mod platform; pub mod registry; +pub mod source; +pub mod update; diff --git a/crates/aim-core/src/metadata/document.rs b/crates/aim-core/src/metadata/document.rs new file mode 100644 index 0000000..e504244 --- /dev/null +++ b/crates/aim-core/src/metadata/document.rs @@ -0,0 +1,24 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetadataDocument { + pub url: String, + pub content_type: Option, + pub contents: Vec, +} + +impl MetadataDocument { + pub fn plain_text(url: &str, contents: &[u8]) -> Self { + Self { + url: url.to_owned(), + content_type: Some("text/plain".to_owned()), + contents: contents.to_vec(), + } + } + + pub fn yaml(url: &str, contents: &[u8]) -> Self { + Self { + url: url.to_owned(), + content_type: Some("application/yaml".to_owned()), + contents: contents.to_vec(), + } + } +} diff --git a/crates/aim-core/src/metadata/electron_builder.rs b/crates/aim-core/src/metadata/electron_builder.rs new file mode 100644 index 0000000..316c0b1 --- /dev/null +++ b/crates/aim-core/src/metadata/electron_builder.rs @@ -0,0 +1,30 @@ +use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind}; +use crate::metadata::document::MetadataDocument; + +pub fn parse(document: &MetadataDocument) -> ParsedMetadata { + let contents = String::from_utf8_lossy(&document.contents); + let version = extract_value(&contents, "version:"); + let path = extract_value(&contents, "path:").or_else(|| extract_value(&contents, "url:")); + let checksum = extract_value(&contents, "sha512:"); + + ParsedMetadata { + kind: ParsedMetadataKind::ElectronBuilder, + hints: MetadataHints { + version, + primary_download: path, + checksum, + architecture: Some("x86_64".to_owned()), + channel_label: Some("latest".to_owned()), + }, + warnings: Vec::new(), + confidence: 90, + } +} + +fn extract_value(contents: &str, prefix: &str) -> Option { + contents + .lines() + .find_map(|line| line.trim().strip_prefix(prefix).map(str::trim)) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} diff --git a/crates/aim-core/src/metadata/mod.rs b/crates/aim-core/src/metadata/mod.rs new file mode 100644 index 0000000..f13cdce --- /dev/null +++ b/crates/aim-core/src/metadata/mod.rs @@ -0,0 +1,7 @@ +mod document; +mod electron_builder; +mod parser; +mod zsync; + +pub use document::MetadataDocument; +pub use parser::parse_document; diff --git a/crates/aim-core/src/metadata/parser.rs b/crates/aim-core/src/metadata/parser.rs new file mode 100644 index 0000000..ba5d5af --- /dev/null +++ b/crates/aim-core/src/metadata/parser.rs @@ -0,0 +1,22 @@ +use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind}; +use crate::metadata::document::MetadataDocument; + +pub fn parse_document(document: &MetadataDocument) -> Result { + if document.url.ends_with("latest-linux.yml") || document.url.ends_with("latest-linux.yaml") { + return Ok(super::electron_builder::parse(document)); + } + + if document.url.ends_with(".zsync") { + return Ok(super::zsync::parse(document)); + } + + Ok(ParsedMetadata { + kind: ParsedMetadataKind::Unknown, + hints: MetadataHints::default(), + warnings: vec!["unsupported metadata document".to_owned()], + confidence: 0, + }) +} + +#[derive(Debug, Eq, PartialEq)] +pub enum MetadataParseError {} diff --git a/crates/aim-core/src/metadata/zsync.rs b/crates/aim-core/src/metadata/zsync.rs new file mode 100644 index 0000000..8e9d382 --- /dev/null +++ b/crates/aim-core/src/metadata/zsync.rs @@ -0,0 +1,38 @@ +use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind}; +use crate::metadata::document::MetadataDocument; + +pub fn parse(document: &MetadataDocument) -> ParsedMetadata { + let contents = String::from_utf8_lossy(&document.contents); + let url = extract_field(&contents, "URL:"); + let filename = extract_field(&contents, "Filename:"); + + ParsedMetadata { + kind: ParsedMetadataKind::Zsync, + hints: MetadataHints { + version: filename + .as_ref() + .and_then(|value| version_from_filename(value)), + primary_download: url.or(filename), + checksum: None, + architecture: Some("x86_64".to_owned()), + channel_label: Some("zsync".to_owned()), + }, + warnings: Vec::new(), + confidence: 75, + } +} + +fn extract_field(contents: &str, prefix: &str) -> Option { + contents + .lines() + .find_map(|line| line.trim().strip_prefix(prefix).map(str::trim)) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn version_from_filename(filename: &str) -> Option { + filename + .split('-') + .find(|segment| segment.chars().any(|ch| ch.is_ascii_digit()) && segment.contains('.')) + .map(|value| value.trim_end_matches(".AppImage").to_owned()) +} diff --git a/crates/aim-core/src/source/github.rs b/crates/aim-core/src/source/github.rs new file mode 100644 index 0000000..b7f793b --- /dev/null +++ b/crates/aim-core/src/source/github.rs @@ -0,0 +1,375 @@ +use std::env; + +use crate::domain::source::{ResolvedRelease, SourceRef}; +use crate::metadata::MetadataDocument; + +const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com"; +const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE"; + +pub trait GitHubTransport { + fn fetch_releases(&self, repo: &str) -> Result, GitHubDiscoveryError>; + + fn fetch_document( + &self, + url: &str, + content_type: Option<&str>, + ) -> Result; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportAsset { + pub name: String, + pub url: String, + pub content_type: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportRelease { + pub tag: String, + pub prerelease: bool, + pub assets: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitHubAsset { + pub name: String, + pub url: String, + pub version: String, + pub prerelease: bool, + pub arch: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitHubRelease { + pub tag: String, + pub release: ResolvedRelease, + pub assets: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitHubDiscovery { + pub source: SourceRef, + pub releases: Vec, + pub assets: Vec, + pub metadata_documents: Vec, + pub requested_is_older_release: bool, +} + +pub fn discover_github_candidates( + source: &SourceRef, +) -> Result { + let transport = default_transport(); + discover_github_candidates_with(source, transport.as_ref()) +} + +pub fn discover_github_candidates_with( + source: &SourceRef, + transport: &T, +) -> Result { + let repo = source + .canonical_locator + .clone() + .unwrap_or_else(|| source.locator.clone()); + + let transport_releases = transport.fetch_releases(&repo)?; + if transport_releases.is_empty() { + return Err(GitHubDiscoveryError::NoReleases { repo }); + } + + let releases = transport_releases + .iter() + .map(|release| GitHubRelease { + tag: release.tag.clone(), + release: ResolvedRelease { + version: release.tag.trim_start_matches('v').to_owned(), + prerelease: release.prerelease, + }, + assets: release + .assets + .iter() + .filter(|asset| is_appimage_asset(&asset.name)) + .map(|asset| GitHubAsset { + name: asset.name.clone(), + url: asset.url.clone(), + version: release.tag.trim_start_matches('v').to_owned(), + prerelease: release.prerelease, + arch: Some(infer_architecture(&asset.name)), + }) + .collect(), + }) + .collect::>(); + + let metadata_documents = transport_releases + .iter() + .flat_map(|release| release.assets.iter()) + .filter(|asset| is_metadata_document(&asset.name)) + .filter_map(|asset| { + transport + .fetch_document(&asset.url, asset.content_type.as_deref()) + .ok() + }) + .collect::>(); + + let assets = releases + .iter() + .flat_map(|release| release.assets.iter().cloned()) + .collect::>(); + + let requested_is_older_release = source + .requested_tag + .as_ref() + .map(|requested| requested != &releases[0].tag) + .unwrap_or(false); + + Ok(GitHubDiscovery { + source: source.clone(), + releases, + assets, + metadata_documents, + requested_is_older_release, + }) +} + +pub fn default_transport() -> Box { + if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") { + Box::new(FixtureGitHubTransport) + } else { + Box::new(ReqwestGitHubTransport::new()) + } +} + +pub struct ReqwestGitHubTransport { + client: reqwest::blocking::Client, + api_base: String, +} + +impl Default for ReqwestGitHubTransport { + fn default() -> Self { + Self::new() + } +} + +impl ReqwestGitHubTransport { + pub fn new() -> Self { + let mut default_headers = reqwest::header::HeaderMap::new(); + default_headers.insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_static("aim/0.1"), + ); + default_headers.insert( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/vnd.github+json"), + ); + if let Some(token) = env::var("AIM_GITHUB_TOKEN") + .ok() + .or_else(|| env::var("GITHUB_TOKEN").ok()) + && let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) + { + default_headers.insert(reqwest::header::AUTHORIZATION, value); + } + + Self { + client: reqwest::blocking::Client::builder() + .default_headers(default_headers) + .build() + .expect("reqwest client should build"), + api_base: env::var("AIM_GITHUB_API_BASE") + .unwrap_or_else(|_| DEFAULT_GITHUB_API_BASE.to_owned()), + } + } +} + +impl GitHubTransport for ReqwestGitHubTransport { + fn fetch_releases(&self, repo: &str) -> Result, GitHubDiscoveryError> { + let url = format!("{}/repos/{repo}/releases?per_page=10", self.api_base); + let releases = self + .client + .get(url) + .send() + .map_err(GitHubDiscoveryError::Transport)? + .error_for_status() + .map_err(GitHubDiscoveryError::Transport)? + .json::>() + .map_err(GitHubDiscoveryError::Transport)?; + + Ok(releases + .into_iter() + .map(|release| TransportRelease { + tag: release.tag_name, + prerelease: release.prerelease, + assets: release + .assets + .into_iter() + .map(|asset| TransportAsset { + name: asset.name, + url: asset.browser_download_url, + content_type: asset.content_type, + }) + .collect(), + }) + .collect()) + } + + fn fetch_document( + &self, + url: &str, + content_type: Option<&str>, + ) -> Result { + let response = self + .client + .get(url) + .send() + .map_err(GitHubDiscoveryError::Transport)? + .error_for_status() + .map_err(GitHubDiscoveryError::Transport)?; + let header_content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned) + .or_else(|| content_type.map(ToOwned::to_owned)); + let contents = response.bytes().map_err(GitHubDiscoveryError::Transport)?; + + Ok(MetadataDocument { + url: url.to_owned(), + content_type: header_content_type, + contents: contents.to_vec(), + }) + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct FixtureGitHubTransport; + +impl GitHubTransport for FixtureGitHubTransport { + fn fetch_releases(&self, repo: &str) -> Result, GitHubDiscoveryError> { + Ok(fixture_releases(repo)) + } + + fn fetch_document( + &self, + url: &str, + content_type: Option<&str>, + ) -> Result { + let contents = fixture_document(url) + .ok_or_else(|| GitHubDiscoveryError::FixtureDocumentMissing(url.to_owned()))?; + Ok(MetadataDocument { + url: url.to_owned(), + content_type: content_type.map(ToOwned::to_owned), + contents, + }) + } +} + +#[derive(Debug)] +pub enum GitHubDiscoveryError { + Unsupported, + FixtureDocumentMissing(String), + NoReleases { repo: String }, + Transport(reqwest::Error), +} + +#[derive(serde::Deserialize)] +struct ApiRelease { + tag_name: String, + prerelease: bool, + assets: Vec, +} + +#[derive(serde::Deserialize)] +struct ApiAsset { + name: String, + browser_download_url: String, + content_type: Option, +} + +fn is_appimage_asset(name: &str) -> bool { + name.ends_with(".AppImage") +} + +fn is_metadata_document(name: &str) -> bool { + name.ends_with("latest-linux.yml") + || name.ends_with("latest-linux.yaml") + || name.ends_with(".zsync") +} + +fn infer_architecture(name: &str) -> String { + if name.contains("aarch64") || name.contains("arm64") { + "aarch64".to_owned() + } else { + "x86_64".to_owned() + } +} + +fn fixture_releases(repo: &str) -> Vec { + match repo { + "pingdotgg/t3code" => vec![ + fixture_release(repo, "v0.0.12", "T3-Code-0.0.12-x86_64.AppImage"), + fixture_release(repo, "v0.0.11", "T3-Code-0.0.11-x86_64.AppImage"), + ], + "sharkdp/bat" => vec![fixture_release(repo, "v1.0.0", "Bat-1.0.0-x86_64.AppImage")], + _ => { + let repo_name = repo.split('/').next_back().unwrap_or("app"); + let title = title_case(repo_name); + vec![fixture_release( + repo, + "v1.0.0", + &format!("{title}-1.0.0-x86_64.AppImage"), + )] + } + } +} + +fn fixture_release(repo: &str, tag: &str, asset_name: &str) -> TransportRelease { + TransportRelease { + tag: tag.to_owned(), + prerelease: false, + assets: vec![ + TransportAsset { + name: asset_name.to_owned(), + url: format!("https://github.com/{repo}/releases/download/{tag}/{asset_name}"), + content_type: Some("application/octet-stream".to_owned()), + }, + TransportAsset { + name: "latest-linux.yml".to_owned(), + url: format!("https://github.com/{repo}/releases/download/{tag}/latest-linux.yml"), + content_type: Some("application/yaml".to_owned()), + }, + ], + } +} + +fn fixture_document(url: &str) -> Option> { + let tag = url.split("/releases/download/").nth(1)?.split('/').next()?; + let name = url.split('/').next_back()?; + match name { + "latest-linux.yml" => { + let appimage = match tag { + "v0.0.11" => "T3-Code-0.0.11-x86_64.AppImage", + "v0.0.12" => "T3-Code-0.0.12-x86_64.AppImage", + "v1.0.0" => "Bat-1.0.0-x86_64.AppImage", + _ => return None, + }; + let version = tag.trim_start_matches('v'); + Some( + format!("version: {version}\npath: {appimage}\nsha512: fixture-sha\n").into_bytes(), + ) + } + _ => None, + } +} + +fn title_case(value: &str) -> String { + value + .split(['-', '_']) + .filter(|segment| !segment.is_empty()) + .map(|segment| { + let mut chars = segment.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!("{}{}", first.to_ascii_uppercase(), chars.as_str()) + }) + .collect::>() + .join("-") +} diff --git a/crates/aim-core/src/source/input.rs b/crates/aim-core/src/source/input.rs new file mode 100644 index 0000000..c89cac6 --- /dev/null +++ b/crates/aim-core/src/source/input.rs @@ -0,0 +1,166 @@ +use crate::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClassifiedInput { + pub kind: SourceInputKind, + pub source_kind: SourceKind, + pub normalized_kind: NormalizedSourceKind, + pub locator: String, + pub canonical_locator: Option, + pub requested_tag: Option, + pub requested_asset_name: Option, + pub tracks_latest: bool, +} + +impl ClassifiedInput { + pub fn into_source_ref(self) -> SourceRef { + SourceRef { + kind: self.source_kind, + locator: self.locator, + input_kind: self.kind, + normalized_kind: self.normalized_kind, + canonical_locator: self.canonical_locator, + requested_tag: self.requested_tag, + requested_asset_name: self.requested_asset_name, + tracks_latest: self.tracks_latest, + } + } +} + +pub fn classify_input(query: &str) -> Result { + if query.starts_with("file://") { + return Ok(ClassifiedInput { + kind: SourceInputKind::File, + source_kind: SourceKind::File, + normalized_kind: NormalizedSourceKind::File, + locator: query.to_owned(), + canonical_locator: None, + requested_tag: None, + requested_asset_name: None, + tracks_latest: false, + }); + } + + if let Some(classified) = classify_github_http(query) { + return Ok(classified); + } + + if query.starts_with("https://gitlab.com/") || query.starts_with("http://gitlab.com/") { + return Ok(ClassifiedInput { + kind: SourceInputKind::GitLabUrl, + source_kind: SourceKind::GitLab, + normalized_kind: NormalizedSourceKind::GitLab, + locator: query.to_owned(), + canonical_locator: None, + requested_tag: None, + requested_asset_name: None, + tracks_latest: false, + }); + } + + if query.starts_with("https://") || query.starts_with("http://") { + return 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_github_shorthand(query) { + return Ok(ClassifiedInput { + kind: SourceInputKind::RepoShorthand, + source_kind: SourceKind::GitHub, + normalized_kind: NormalizedSourceKind::GitHubRepository, + locator: query.to_owned(), + canonical_locator: Some(query.to_owned()), + requested_tag: None, + requested_asset_name: None, + tracks_latest: true, + }); + } + + Err(ClassifyInputError::Unsupported) +} + +#[derive(Debug, Eq, PartialEq)] +pub enum ClassifyInputError { + Unsupported, +} + +fn classify_github_http(query: &str) -> Option { + let trimmed = query + .trim_start_matches("https://github.com/") + .trim_start_matches("http://github.com/"); + if trimmed == query { + return None; + } + + let parts = trimmed + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + if parts.len() < 2 { + return None; + } + + let repo = format!("{}/{}", parts[0], parts[1]); + + if parts.len() >= 5 && parts[2] == "releases" && parts[3] == "tag" { + return Some(ClassifiedInput { + kind: SourceInputKind::GitHubReleaseUrl, + source_kind: SourceKind::GitHub, + normalized_kind: NormalizedSourceKind::GitHubRelease, + locator: query.to_owned(), + canonical_locator: Some(repo), + requested_tag: Some(parts[4].to_owned()), + requested_asset_name: None, + tracks_latest: false, + }); + } + + if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" { + return Some(ClassifiedInput { + kind: SourceInputKind::GitHubReleaseAssetUrl, + source_kind: SourceKind::GitHub, + normalized_kind: NormalizedSourceKind::GitHubReleaseAsset, + locator: query.to_owned(), + canonical_locator: Some(repo), + requested_tag: Some(parts[4].to_owned()), + requested_asset_name: Some(parts[5].to_owned()), + tracks_latest: false, + }); + } + + Some(ClassifiedInput { + kind: SourceInputKind::GitHubRepositoryUrl, + source_kind: SourceKind::GitHub, + normalized_kind: NormalizedSourceKind::GitHubRepository, + locator: query.to_owned(), + canonical_locator: Some(repo), + requested_tag: None, + requested_asset_name: None, + tracks_latest: true, + }) +} + +fn is_github_shorthand(query: &str) -> bool { + let mut parts = query.split('/'); + let Some(owner) = parts.next() else { + return false; + }; + let Some(repo) = parts.next() else { + return false; + }; + + if parts.next().is_some() { + return false; + } + + !owner.is_empty() && !repo.is_empty() && !owner.contains(':') && !repo.contains(':') +} diff --git a/crates/aim-core/src/source/mod.rs b/crates/aim-core/src/source/mod.rs new file mode 100644 index 0000000..72aea24 --- /dev/null +++ b/crates/aim-core/src/source/mod.rs @@ -0,0 +1,2 @@ +pub mod github; +pub mod input; diff --git a/crates/aim-core/src/update/channels.rs b/crates/aim-core/src/update/channels.rs new file mode 100644 index 0000000..07d15e6 --- /dev/null +++ b/crates/aim-core/src/update/channels.rs @@ -0,0 +1,93 @@ +use crate::domain::source::{SourceInputKind, SourceRef}; +use crate::domain::update::{ParsedMetadata, UpdateChannel, UpdateChannelKind}; +use crate::source::github::GitHubDiscovery; + +pub fn build_channels( + discovery: &GitHubDiscovery, + metadata: &[ParsedMetadata], +) -> Vec { + let mut channels = Vec::new(); + + if let Some(asset) = discovery.assets.first() { + channels.push(UpdateChannel { + kind: UpdateChannelKind::GitHubReleases, + locator: discovery + .source + .canonical_locator + .clone() + .unwrap_or_else(|| discovery.source.locator.clone()), + version: Some(asset.version.clone()), + artifact_name: Some(asset.name.clone()), + confidence: 60, + matches_install_origin: matches!( + discovery.source.input_kind, + SourceInputKind::RepoShorthand + | SourceInputKind::GitHubRepositoryUrl + | SourceInputKind::GitHubReleaseUrl + ), + prerelease: asset.prerelease, + }); + } + + if let Some(parsed) = metadata + .iter() + .find(|item| item.kind == crate::domain::update::ParsedMetadataKind::ElectronBuilder) + { + channels.push(UpdateChannel { + kind: UpdateChannelKind::ElectronBuilder, + locator: discovery + .metadata_documents + .iter() + .find(|doc| doc.url.ends_with("latest-linux.yml")) + .map(|doc| doc.url.clone()) + .unwrap_or_else(|| discovery.source.locator.clone()), + version: parsed.hints.version.clone(), + artifact_name: parsed.hints.primary_download.clone(), + confidence: parsed.confidence, + matches_install_origin: discovery.source.tracks_latest, + prerelease: false, + }); + } + + if let Some(parsed) = metadata + .iter() + .find(|item| item.kind == crate::domain::update::ParsedMetadataKind::Zsync) + { + channels.push(UpdateChannel { + kind: UpdateChannelKind::Zsync, + locator: parsed.hints.primary_download.clone().unwrap_or_default(), + version: parsed.hints.version.clone(), + artifact_name: parsed.hints.primary_download.clone(), + confidence: parsed.confidence, + matches_install_origin: false, + prerelease: false, + }); + } + + if matches!( + discovery.source.input_kind, + SourceInputKind::GitHubReleaseAssetUrl + ) { + channels.push(UpdateChannel { + kind: UpdateChannelKind::DirectAsset, + locator: discovery.source.locator.clone(), + version: discovery + .source + .requested_tag + .clone() + .map(|value| value.trim_start_matches('v').to_owned()), + artifact_name: discovery.source.requested_asset_name.clone(), + confidence: 85, + matches_install_origin: true, + prerelease: false, + }); + } + + channels +} + +pub fn source_ref_from_channel(source: &SourceRef, channel: &UpdateChannel) -> SourceRef { + let mut value = source.clone(); + value.locator = channel.locator.clone(); + value +} diff --git a/crates/aim-core/src/update/mod.rs b/crates/aim-core/src/update/mod.rs new file mode 100644 index 0000000..d26afa9 --- /dev/null +++ b/crates/aim-core/src/update/mod.rs @@ -0,0 +1,2 @@ +pub mod channels; +pub mod ranking; diff --git a/crates/aim-core/src/update/ranking.rs b/crates/aim-core/src/update/ranking.rs new file mode 100644 index 0000000..dd756e0 --- /dev/null +++ b/crates/aim-core/src/update/ranking.rs @@ -0,0 +1,101 @@ +use crate::domain::update::{ + ArtifactCandidate, ChannelPreference, MetadataHints, UpdateChannel, UpdateChannelKind, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RankedChannel { + pub channel: UpdateChannel, + pub reason: String, + pub score: i32, +} + +pub fn rank_channels(channels: &[UpdateChannel]) -> Vec { + let mut ranked = channels + .iter() + .cloned() + .map(|channel| { + let install_origin_bonus = if channel.matches_install_origin { + 100 + } else { + 0 + }; + let prerelease_penalty = if channel.prerelease { 20 } else { 0 }; + let metadata_bonus = match channel.kind { + UpdateChannelKind::ElectronBuilder | UpdateChannelKind::Zsync => 25, + _ => 0, + }; + let score = channel.confidence as i32 + install_origin_bonus + metadata_bonus + - prerelease_penalty; + let reason = if channel.matches_install_origin { + "install-origin-match" + } else if metadata_bonus > 0 { + "metadata-guided" + } else { + "heuristic-match" + }; + + RankedChannel { + channel, + reason: reason.to_owned(), + score, + } + }) + .collect::>(); + + ranked.sort_by(|left, right| right.score.cmp(&left.score)); + ranked +} + +pub fn select_artifact( + channel: &RankedChannel, + hints: Option<&MetadataHints>, +) -> ArtifactCandidate { + let resolved_url = resolve_artifact_url( + &channel.channel.locator, + hints.and_then(|value| value.primary_download.as_deref()), + ); + let selection_reason = if hints + .and_then(|value| value.primary_download.clone()) + .is_some() + { + "metadata-guided" + } else { + channel.reason.as_str() + }; + ArtifactCandidate { + url: resolved_url, + version: channel + .channel + .version + .clone() + .unwrap_or_else(|| "latest".to_owned()), + arch: Some("x86_64".to_owned()), + selection_reason: selection_reason.to_owned(), + } +} + +pub fn to_preference(channel: &RankedChannel) -> ChannelPreference { + ChannelPreference { + kind: channel.channel.kind, + locator: channel.channel.locator.clone(), + reason: channel.reason.clone(), + } +} + +fn resolve_artifact_url(channel_locator: &str, primary_download: Option<&str>) -> String { + let Some(primary_download) = primary_download else { + return channel_locator.to_owned(); + }; + + if primary_download.contains("://") || primary_download.starts_with("file://") { + return primary_download.to_owned(); + } + + if (channel_locator.ends_with(".yml") || channel_locator.ends_with(".yaml")) + && let Some((base, _)) = channel_locator.rsplit_once('/') + { + return format!("{base}/{primary_download}"); + } + + primary_download.to_owned() +} diff --git a/crates/aim-core/tests/adapter_contract.rs b/crates/aim-core/tests/adapter_contract.rs index c99fc8e..1529e48 100644 --- a/crates/aim-core/tests/adapter_contract.rs +++ b/crates/aim-core/tests/adapter_contract.rs @@ -1,3 +1,4 @@ +use aim_core::adapters::github::GitHubAdapter; use aim_core::adapters::traits::AdapterCapabilities; #[test] @@ -5,3 +6,13 @@ fn adapter_capabilities_can_report_exact_resolution_only() { let capabilities = AdapterCapabilities::exact_resolution_only(); assert!(!capabilities.supports_search); } + +#[test] +fn legacy_github_adapter_delegates_to_source_pipeline() { + let adapter = GitHubAdapter; + + let result = adapter.normalize("sharkdp/bat").unwrap(); + + assert_eq!(result.normalized_kind.as_str(), "github-repository"); + assert_eq!(result.canonical_locator.as_deref(), Some("sharkdp/bat")); +} diff --git a/crates/aim-core/tests/adapter_smoke.rs b/crates/aim-core/tests/adapter_smoke.rs index bccbe4d..1c7ccff 100644 --- a/crates/aim-core/tests/adapter_smoke.rs +++ b/crates/aim-core/tests/adapter_smoke.rs @@ -4,6 +4,7 @@ use aim_core::adapters::all_adapter_kinds; fn all_expected_adapter_kinds_are_registered() { let kinds = all_adapter_kinds(); + assert!(kinds.contains(&"github")); assert!(kinds.contains(&"gitlab")); assert!(kinds.contains(&"direct-url")); assert!(kinds.contains(&"zsync")); diff --git a/crates/aim-core/tests/fixtures/example.zsync b/crates/aim-core/tests/fixtures/example.zsync new file mode 100644 index 0000000..6c97df3 --- /dev/null +++ b/crates/aim-core/tests/fixtures/example.zsync @@ -0,0 +1,3 @@ +zsync: 0.6.2 +Filename: T3-Code-0.0.11-x86_64.AppImage +URL: https://example.test/T3-Code-0.0.11-x86_64.AppImage \ No newline at end of file diff --git a/crates/aim-core/tests/fixtures/latest-linux.yml b/crates/aim-core/tests/fixtures/latest-linux.yml new file mode 100644 index 0000000..f7f7d43 --- /dev/null +++ b/crates/aim-core/tests/fixtures/latest-linux.yml @@ -0,0 +1,3 @@ +version: 0.0.11 +path: T3-Code-0.0.11-x86_64.AppImage +sha512: example-sha \ No newline at end of file diff --git a/crates/aim-core/tests/github_add_flow.rs b/crates/aim-core/tests/github_add_flow.rs index ab7d87f..37da5c2 100644 --- a/crates/aim-core/tests/github_add_flow.rs +++ b/crates/aim-core/tests/github_add_flow.rs @@ -1,5 +1,6 @@ -use aim_core::app::add::build_add_plan; +use aim_core::app::add::{build_add_plan_with, materialize_app_record, prefer_latest_tracking}; use aim_core::app::query::resolve_query; +use aim_core::source::github::FixtureGitHubTransport; #[test] fn github_adapter_can_normalize_owner_repo_source() { @@ -10,9 +11,78 @@ fn github_adapter_can_normalize_owner_repo_source() { #[test] fn add_flow_builds_github_plan_from_owner_repo_query() { - let plan = build_add_plan("sharkdp/bat").unwrap(); + let plan = build_add_plan_with("sharkdp/bat", &FixtureGitHubTransport).unwrap(); assert_eq!(plan.resolution.source.kind.as_str(), "github"); assert_eq!(plan.resolution.source.locator, "sharkdp/bat"); - assert_eq!(plan.resolution.release.version, "latest"); + assert_eq!(plan.selected_artifact.selection_reason, "metadata-guided"); +} + +#[test] +fn add_plan_prefers_metadata_guided_appimage_when_available() { + let plan = build_add_plan_with("pingdotgg/t3code", &FixtureGitHubTransport).unwrap(); + + assert_eq!(plan.selected_artifact.selection_reason, "metadata-guided"); + assert_eq!( + plan.update_strategy.preferred.kind.as_str(), + "electron-builder" + ); +} + +#[test] +fn direct_old_release_url_requests_tracking_choice_prompt() { + let plan = build_add_plan_with( + "https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage", + &FixtureGitHubTransport, + ) + .unwrap(); + + assert!( + plan.interactions + .iter() + .any(|item| item.key == "tracking-preference") + ); +} + +#[test] +fn materialized_record_preserves_source_and_strategy() { + let query = "sharkdp/bat"; + let plan = build_add_plan_with(query, &FixtureGitHubTransport).unwrap(); + + let record = materialize_app_record(query, &plan).unwrap(); + + assert_eq!(record.stable_id, "sharkdp-bat"); + assert_eq!(record.display_name, "bat"); + assert_eq!(record.source_input.as_deref(), Some(query)); + assert_eq!(record.installed_version.as_deref(), Some("1.0.0")); + assert_eq!( + record + .update_strategy + .as_ref() + .unwrap() + .preferred + .kind + .as_str(), + "electron-builder" + ); + assert_eq!(record.source.as_ref().unwrap().locator, query); +} + +#[test] +fn latest_tracking_choice_promotes_non_direct_update_channel() { + let plan = build_add_plan_with( + "https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage", + &FixtureGitHubTransport, + ) + .unwrap(); + + let resolved = prefer_latest_tracking(plan); + + assert!(resolved.interactions.is_empty()); + assert_eq!(resolved.resolution.source.locator, "pingdotgg/t3code"); + assert!(resolved.resolution.source.tracks_latest); + assert_ne!( + resolved.update_strategy.preferred.kind.as_str(), + "direct-asset-lineage" + ); } diff --git a/crates/aim-core/tests/github_source_discovery.rs b/crates/aim-core/tests/github_source_discovery.rs new file mode 100644 index 0000000..5f631ba --- /dev/null +++ b/crates/aim-core/tests/github_source_discovery.rs @@ -0,0 +1,33 @@ +use aim_core::app::query::resolve_query; +use aim_core::source::github::{FixtureGitHubTransport, discover_github_candidates_with}; + +#[test] +fn discovery_reports_appimage_assets_and_latest_linux_yml() { + let source = resolve_query("pingdotgg/t3code").unwrap(); + let discovery = discover_github_candidates_with(&source, &FixtureGitHubTransport).unwrap(); + + assert!( + discovery + .assets + .iter() + .any(|asset| asset.name.ends_with(".AppImage")) + ); + assert!( + discovery + .metadata_documents + .iter() + .any(|doc| doc.url.ends_with("latest-linux.yml")) + ); +} + +#[test] +fn discovery_marks_explicit_older_release_against_latest_fixture_release() { + let source = resolve_query( + "https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage", + ) + .unwrap(); + let discovery = discover_github_candidates_with(&source, &FixtureGitHubTransport).unwrap(); + + assert_eq!(discovery.releases[0].tag, "v0.0.12"); + assert!(discovery.requested_is_older_release); +} diff --git a/crates/aim-core/tests/metadata_contract.rs b/crates/aim-core/tests/metadata_contract.rs new file mode 100644 index 0000000..24d0ba2 --- /dev/null +++ b/crates/aim-core/tests/metadata_contract.rs @@ -0,0 +1,11 @@ +use aim_core::domain::update::ParsedMetadataKind; +use aim_core::metadata::{MetadataDocument, parse_document}; + +#[test] +fn unknown_document_returns_typed_warning_not_panic() { + let doc = MetadataDocument::plain_text("https://example.test/notes.txt", b"not metadata"); + let result = parse_document(&doc).unwrap(); + + assert_eq!(result.kind, ParsedMetadataKind::Unknown); + assert!(!result.warnings.is_empty()); +} diff --git a/crates/aim-core/tests/metadata_electron_builder.rs b/crates/aim-core/tests/metadata_electron_builder.rs new file mode 100644 index 0000000..cb9a474 --- /dev/null +++ b/crates/aim-core/tests/metadata_electron_builder.rs @@ -0,0 +1,15 @@ +use aim_core::domain::update::ParsedMetadataKind; +use aim_core::metadata::{MetadataDocument, parse_document}; + +#[test] +fn parses_latest_linux_yml_into_download_hints() { + let raw = include_bytes!("fixtures/latest-linux.yml"); + let doc = MetadataDocument::yaml("https://example.test/latest-linux.yml", raw); + let result = parse_document(&doc).unwrap(); + + assert_eq!(result.kind, ParsedMetadataKind::ElectronBuilder); + assert_eq!( + result.hints.primary_download.as_deref(), + Some("T3-Code-0.0.11-x86_64.AppImage") + ); +} diff --git a/crates/aim-core/tests/metadata_zsync.rs b/crates/aim-core/tests/metadata_zsync.rs new file mode 100644 index 0000000..c13fd4c --- /dev/null +++ b/crates/aim-core/tests/metadata_zsync.rs @@ -0,0 +1,12 @@ +use aim_core::domain::update::ParsedMetadataKind; +use aim_core::metadata::{MetadataDocument, parse_document}; + +#[test] +fn parses_zsync_document_into_channel_hints() { + let raw = include_bytes!("fixtures/example.zsync"); + let doc = MetadataDocument::plain_text("https://example.test/app.AppImage.zsync", raw); + let result = parse_document(&doc).unwrap(); + + assert_eq!(result.kind, ParsedMetadataKind::Zsync); + assert!(result.hints.primary_download.is_some()); +} diff --git a/crates/aim-core/tests/query_resolution.rs b/crates/aim-core/tests/query_resolution.rs index 57a746e..05d00f3 100644 --- a/crates/aim-core/tests/query_resolution.rs +++ b/crates/aim-core/tests/query_resolution.rs @@ -1,8 +1,27 @@ use aim_core::app::query::resolve_query; -use aim_core::domain::source::SourceKind; +use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind}; #[test] fn owner_repo_defaults_to_github() { let source = resolve_query("sharkdp/bat").unwrap(); assert_eq!(source.kind, SourceKind::GitHub); + assert_eq!(source.input_kind, SourceInputKind::RepoShorthand); + assert_eq!( + source.normalized_kind, + NormalizedSourceKind::GitHubRepository + ); +} + +#[test] +fn classifies_github_release_asset_url() { + let source = resolve_query( + "https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage", + ) + .unwrap(); + + assert_eq!(source.input_kind, SourceInputKind::GitHubReleaseAssetUrl); + assert_eq!( + source.normalized_kind, + NormalizedSourceKind::GitHubReleaseAsset + ); } diff --git a/crates/aim-core/tests/registry_roundtrip.rs b/crates/aim-core/tests/registry_roundtrip.rs index c6e933e..d4ff00a 100644 --- a/crates/aim-core/tests/registry_roundtrip.rs +++ b/crates/aim-core/tests/registry_roundtrip.rs @@ -8,3 +8,46 @@ fn registry_round_trips_app_records() { let loaded = store.load().unwrap(); assert!(loaded.apps.is_empty()); } + +#[test] +fn registry_round_trips_update_strategy_and_alternates() { + 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: "t3code".to_owned(), + display_name: "T3 Code".to_owned(), + source_input: Some("pingdotgg/t3code".to_owned()), + source: None, + installed_version: Some("0.0.11".to_owned()), + update_strategy: Some(aim_core::domain::update::UpdateStrategy { + preferred: aim_core::domain::update::ChannelPreference { + kind: aim_core::domain::update::UpdateChannelKind::DirectAsset, + locator: "https://example.test/app.AppImage".to_owned(), + reason: "install-origin-match".to_owned(), + }, + alternates: vec![ + aim_core::domain::update::ChannelPreference { + kind: aim_core::domain::update::UpdateChannelKind::GitHubReleases, + locator: "pingdotgg/t3code".to_owned(), + reason: "heuristic-match".to_owned(), + }, + aim_core::domain::update::ChannelPreference { + kind: aim_core::domain::update::UpdateChannelKind::ElectronBuilder, + locator: "https://example.test/latest-linux.yml".to_owned(), + reason: "metadata-guided".to_owned(), + }, + ], + }), + metadata: Vec::new(), + }], + }; + + store.save(®istry).unwrap(); + let loaded = store.load().unwrap(); + + let strategy = loaded.apps[0].update_strategy.as_ref().unwrap(); + assert_eq!(strategy.preferred.reason, "install-origin-match"); + assert_eq!(strategy.alternates.len(), 2); +} diff --git a/crates/aim-core/tests/remove_flow.rs b/crates/aim-core/tests/remove_flow.rs index 61a5d9d..fed9f03 100644 --- a/crates/aim-core/tests/remove_flow.rs +++ b/crates/aim-core/tests/remove_flow.rs @@ -1,4 +1,4 @@ -use aim_core::app::interaction::InteractionRequest; +use aim_core::app::interaction::{InteractionKind, InteractionRequest}; use aim_core::app::list::build_list_rows; use aim_core::app::remove::resolve_registered_app; use aim_core::domain::app::AppRecord; @@ -15,6 +15,11 @@ fn list_flow_returns_display_rows_for_registered_apps() { let rows = build_list_rows(&[AppRecord { stable_id: "bat".to_owned(), display_name: "Bat".to_owned(), + source_input: None, + source: None, + installed_version: None, + update_strategy: None, + metadata: Vec::new(), }]); assert_eq!(rows.len(), 1); @@ -28,10 +33,20 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() { AppRecord { stable_id: "bat".to_owned(), display_name: "Bat".to_owned(), + source_input: None, + source: None, + installed_version: None, + update_strategy: None, + metadata: Vec::new(), }, AppRecord { stable_id: "bat-nightly".to_owned(), display_name: "Bat".to_owned(), + source_input: None, + source: None, + installed_version: None, + update_strategy: None, + metadata: Vec::new(), }, ]; @@ -40,9 +55,12 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() { assert_eq!( error, aim_core::app::remove::ResolveRegisteredAppError::Ambiguous { - request: InteractionRequest::SelectRegisteredApp { - query: "Bat".to_owned(), - matches: vec!["Bat (bat)".to_owned(), "Bat (bat-nightly)".to_owned()], + request: InteractionRequest { + key: "select-registered-app".to_owned(), + kind: InteractionKind::SelectRegisteredApp { + query: "Bat".to_owned(), + matches: vec!["Bat (bat)".to_owned(), "Bat (bat-nightly)".to_owned()], + }, }, } ); diff --git a/crates/aim-core/tests/update_planning.rs b/crates/aim-core/tests/update_planning.rs index ba4e089..2bf17aa 100644 --- a/crates/aim-core/tests/update_planning.rs +++ b/crates/aim-core/tests/update_planning.rs @@ -1,5 +1,6 @@ use aim_core::app::update::build_update_plan; use aim_core::domain::app::AppRecord; +use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy}; #[test] fn empty_registry_produces_empty_plan() { @@ -13,10 +14,48 @@ fn installed_apps_are_carried_into_review_plan() { let apps = [AppRecord { stable_id: "bat".to_owned(), display_name: "Bat".to_owned(), + source_input: None, + source: None, + installed_version: None, + update_strategy: None, + metadata: Vec::new(), }]; let plan = build_update_plan(&apps).unwrap(); assert_eq!(plan.items.len(), 1); assert_eq!(plan.items[0].stable_id, "bat"); + assert_eq!(plan.items[0].selection_reason, "install-origin-match"); +} + +#[test] +fn update_plan_uses_alternate_channel_after_preferred_failure() { + let apps = [AppRecord { + stable_id: "t3code".to_owned(), + display_name: "T3 Code".to_owned(), + source_input: Some("pingdotgg/t3code".to_owned()), + source: None, + installed_version: Some("0.0.11".to_owned()), + update_strategy: Some(UpdateStrategy { + preferred: ChannelPreference { + kind: UpdateChannelKind::GitHubReleases, + locator: "fail://github".to_owned(), + reason: "install-origin-match".to_owned(), + }, + alternates: vec![ChannelPreference { + kind: UpdateChannelKind::ElectronBuilder, + locator: "https://example.test/latest-linux.yml".to_owned(), + reason: "metadata-guided".to_owned(), + }], + }), + metadata: Vec::new(), + }]; + + let plan = build_update_plan(&apps).unwrap(); + + assert_eq!( + plan.items[0].selected_channel.kind.as_str(), + "electron-builder" + ); + assert_eq!(plan.items[0].selection_reason, "preferred-channel-failed"); }