feat: add AppImageHub provider support
This commit is contained in:
parent
1ad2f8a532
commit
f8ffb95376
23 changed files with 1636 additions and 50 deletions
|
|
@ -10,6 +10,7 @@ path = "src/lib.rs"
|
|||
[dependencies]
|
||||
base64.workspace = true
|
||||
fs2.workspace = true
|
||||
quick-xml.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
|
|
|
|||
89
crates/aim-core/src/adapters/appimagehub.rs
Normal file
89
crates/aim-core/src/adapters/appimagehub.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use crate::adapters::traits::{
|
||||
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
|
||||
};
|
||||
use crate::app::query::resolve_query;
|
||||
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
|
||||
use crate::source::appimagehub::{
|
||||
AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with,
|
||||
};
|
||||
|
||||
pub struct AppImageHubAdapter;
|
||||
|
||||
impl AppImageHubAdapter {
|
||||
pub fn resolve_source_with<T: AppImageHubTransport + ?Sized>(
|
||||
&self,
|
||||
source: &SourceRef,
|
||||
transport: &T,
|
||||
) -> Result<AdapterResolveOutcome, AdapterError> {
|
||||
if source.kind != SourceKind::AppImageHub {
|
||||
return Err(AdapterError::UnsupportedSource);
|
||||
}
|
||||
|
||||
let resolved = resolve_appimagehub_item_with(source, transport)
|
||||
.map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))?;
|
||||
|
||||
match resolved {
|
||||
Some(item) => Ok(AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source: item.source,
|
||||
release: ResolvedRelease {
|
||||
version: item.version,
|
||||
prerelease: false,
|
||||
},
|
||||
})),
|
||||
None => Ok(AdapterResolveOutcome::NoInstallableArtifact {
|
||||
source: source.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SourceAdapter for AppImageHubAdapter {
|
||||
fn id(&self) -> &'static str {
|
||||
"appimagehub"
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> AdapterCapabilities {
|
||||
AdapterCapabilities {
|
||||
supports_search: true,
|
||||
supports_exact_resolution: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_source_kind(&self) -> Option<SourceKind> {
|
||||
Some(SourceKind::AppImageHub)
|
||||
}
|
||||
|
||||
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
|
||||
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
||||
if source.kind != SourceKind::AppImageHub {
|
||||
return Err(AdapterError::UnsupportedQuery);
|
||||
}
|
||||
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
|
||||
match resolve_appimagehub_item(source)
|
||||
.map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))?
|
||||
{
|
||||
Some(item) => Ok(AdapterResolution {
|
||||
source: item.source,
|
||||
release: ResolvedRelease {
|
||||
version: item.version,
|
||||
prerelease: false,
|
||||
},
|
||||
}),
|
||||
None => Err(AdapterError::ResolutionFailed(
|
||||
"appimagehub item has no installable AppImage artifact".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_supported_source(
|
||||
&self,
|
||||
source: &SourceRef,
|
||||
) -> Result<AdapterResolveOutcome, AdapterError> {
|
||||
let transport = crate::source::appimagehub::default_transport();
|
||||
self.resolve_source_with(source, transport.as_ref())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
use crate::adapters::traits::{
|
||||
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
|
||||
};
|
||||
use crate::domain::source::SourceRef;
|
||||
|
||||
pub struct CustomJsonAdapter;
|
||||
|
||||
impl SourceAdapter for CustomJsonAdapter {
|
||||
fn id(&self) -> &'static str {
|
||||
"custom-json"
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> AdapterCapabilities {
|
||||
AdapterCapabilities::exact_resolution_only()
|
||||
}
|
||||
|
||||
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
|
||||
Err(AdapterError::UnsupportedQuery)
|
||||
}
|
||||
|
||||
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
pub mod custom_json;
|
||||
pub mod appimagehub;
|
||||
pub mod direct_url;
|
||||
pub mod github;
|
||||
pub mod gitlab;
|
||||
|
|
@ -12,12 +12,12 @@ use crate::domain::source::SourceRef;
|
|||
|
||||
pub fn all_adapter_kinds() -> Vec<&'static str> {
|
||||
vec![
|
||||
"appimagehub",
|
||||
"github",
|
||||
"gitlab",
|
||||
"direct-url",
|
||||
"zsync",
|
||||
"sourceforge",
|
||||
"custom-json",
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::fs::{self, File};
|
|||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::adapters::appimagehub::AppImageHubAdapter;
|
||||
use crate::adapters::direct_url::DirectUrlAdapter;
|
||||
use crate::adapters::gitlab::GitLabAdapter;
|
||||
use crate::adapters::sourceforge::SourceForgeAdapter;
|
||||
|
|
@ -24,6 +25,7 @@ use crate::integration::install::{
|
|||
use crate::integration::policy::{IntegrationMode, resolve_install_policy};
|
||||
use crate::metadata::parse_document;
|
||||
use crate::platform::probe_live_host;
|
||||
use crate::source::appimagehub::resolve_appimagehub_item;
|
||||
use crate::source::github::{
|
||||
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy,
|
||||
};
|
||||
|
|
@ -59,6 +61,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
|
||||
let mut interactions = Vec::new();
|
||||
let mut parsed_metadata = Vec::new();
|
||||
let mut display_name_hint = None;
|
||||
let (resolution, selected_artifact, update_strategy) = match source.kind {
|
||||
SourceKind::GitHub => {
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
|
|
@ -156,6 +159,57 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
|
||||
(resolution, artifact, strategy)
|
||||
}
|
||||
SourceKind::AppImageHub => {
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DiscoverRelease,
|
||||
message: "discovering release".to_owned(),
|
||||
});
|
||||
let adapter = AppImageHubAdapter;
|
||||
let resolution = match adapter
|
||||
.resolve_source(&source)
|
||||
.map_err(|error| BuildAddPlanError::Adapter("appimagehub", error))?
|
||||
{
|
||||
AdapterResolveOutcome::Resolved(resolution) => resolution,
|
||||
AdapterResolveOutcome::NoInstallableArtifact { source } => {
|
||||
return Err(BuildAddPlanError::NoInstallableArtifact { source });
|
||||
}
|
||||
};
|
||||
let resolved_item = resolve_appimagehub_item(&resolution.source)
|
||||
.map_err(|error| {
|
||||
BuildAddPlanError::Adapter(
|
||||
"appimagehub",
|
||||
crate::adapters::traits::AdapterError::ResolutionFailed(format!(
|
||||
"{error:?}"
|
||||
)),
|
||||
)
|
||||
})?
|
||||
.ok_or(BuildAddPlanError::NoInstallableArtifact {
|
||||
source: resolution.source.clone(),
|
||||
})?;
|
||||
display_name_hint = Some(resolved_item.title.clone());
|
||||
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SelectArtifact,
|
||||
message: "selecting artifact".to_owned(),
|
||||
});
|
||||
let artifact = ArtifactCandidate {
|
||||
url: resolved_item.download.url.clone(),
|
||||
version: resolved_item.version.clone(),
|
||||
arch: resolved_item.download.arch.clone(),
|
||||
trusted_checksum: None,
|
||||
selection_reason: "provider-release".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
preferred: crate::domain::update::ChannelPreference {
|
||||
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
|
||||
locator: resolved_item.download.url.clone(),
|
||||
reason: "provider-release".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
};
|
||||
|
||||
(resolution, artifact, strategy)
|
||||
}
|
||||
SourceKind::DirectUrl => {
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SelectArtifact,
|
||||
|
|
@ -266,6 +320,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
interactions,
|
||||
update_strategy,
|
||||
metadata: parsed_metadata,
|
||||
display_name_hint,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -299,6 +354,7 @@ pub struct AddPlan {
|
|||
pub interactions: Vec<InteractionRequest>,
|
||||
pub update_strategy: UpdateStrategy,
|
||||
pub metadata: Vec<ParsedMetadata>,
|
||||
pub display_name_hint: Option<String>,
|
||||
}
|
||||
|
||||
pub fn materialize_app_record(
|
||||
|
|
@ -312,7 +368,7 @@ pub fn materialize_app_record(
|
|||
.as_deref()
|
||||
.unwrap_or(source_input);
|
||||
let identity = resolve_identity(
|
||||
None,
|
||||
plan.display_name_hint.as_deref(),
|
||||
None,
|
||||
Some(identity_source),
|
||||
IdentityFallback::AllowRawUrl,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ use crate::domain::search::{
|
|||
InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults,
|
||||
SearchWarning,
|
||||
};
|
||||
use crate::source::appimagehub::{
|
||||
AppImageHubSearchError, AppImageHubTransport, search_appimagehub_with,
|
||||
};
|
||||
use crate::source::github::{
|
||||
GitHubSearchError, GitHubTransport, TransportRelease, default_transport,
|
||||
search_github_repositories_with,
|
||||
|
|
@ -37,9 +40,15 @@ pub fn build_search_results(
|
|||
query: &SearchQuery,
|
||||
installed_apps: &[AppRecord],
|
||||
) -> Result<SearchResults, SearchError> {
|
||||
let transport = default_transport();
|
||||
let provider = GitHubSearchProvider::new(transport.as_ref());
|
||||
build_search_results_with(query, installed_apps, &[&provider])
|
||||
let github_transport = default_transport();
|
||||
let appimagehub_transport = crate::source::appimagehub::default_transport();
|
||||
let github_provider = GitHubSearchProvider::new(github_transport.as_ref());
|
||||
let appimagehub_provider = AppImageHubSearchProvider::new(appimagehub_transport.as_ref());
|
||||
build_search_results_with(
|
||||
query,
|
||||
installed_apps,
|
||||
&[&github_provider, &appimagehub_provider],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_search_results_with(
|
||||
|
|
@ -85,6 +94,58 @@ impl<'a, T: GitHubTransport + ?Sized> GitHubSearchProvider<'a, T> {
|
|||
}
|
||||
}
|
||||
|
||||
pub struct AppImageHubSearchProvider<'a, T: AppImageHubTransport + ?Sized> {
|
||||
transport: &'a T,
|
||||
}
|
||||
|
||||
impl<'a, T: AppImageHubTransport + ?Sized> AppImageHubSearchProvider<'a, T> {
|
||||
pub fn new(transport: &'a T) -> Self {
|
||||
Self { transport }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AppImageHubTransport + ?Sized> SearchProvider for AppImageHubSearchProvider<'_, T> {
|
||||
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
|
||||
let hits = search_appimagehub_with(&query.text, query.remote_limit, self.transport)
|
||||
.map_err(|error| {
|
||||
SearchProviderError::new("appimagehub", &render_appimagehub_search_error(&error))
|
||||
})?;
|
||||
|
||||
let normalized_query = normalize_lookup(&query.text);
|
||||
let mut ranked_hits = hits
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, hit)| {
|
||||
(
|
||||
appimagehub_remote_match_rank(
|
||||
&normalized_query,
|
||||
&hit.name,
|
||||
hit.summary.as_deref(),
|
||||
),
|
||||
index,
|
||||
hit,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
ranked_hits.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
|
||||
|
||||
Ok(ranked_hits
|
||||
.into_iter()
|
||||
.map(|(_, _, hit)| SearchResult {
|
||||
provider_id: "appimagehub".to_owned(),
|
||||
display_name: hit.name,
|
||||
description: hit.summary,
|
||||
source_locator: hit.detail_page,
|
||||
install_query: format!("appimagehub/{}", hit.id),
|
||||
canonical_locator: hit.id,
|
||||
version: Some(hit.version),
|
||||
install_status: SearchInstallStatus::Available,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: GitHubTransport + ?Sized> SearchProvider for GitHubSearchProvider<'_, T> {
|
||||
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
|
||||
let name_only_query = format!("{} in:name", query.text);
|
||||
|
|
@ -252,17 +313,28 @@ fn app_matches_remote_hit(app: &AppRecord, hit: &SearchResult) -> bool {
|
|||
}
|
||||
|
||||
fn app_search_locator(app: &AppRecord) -> Option<String> {
|
||||
if let Some(source) = &app.source
|
||||
&& source.kind == crate::domain::source::SourceKind::GitHub
|
||||
{
|
||||
if let Some(locator) = source.canonical_locator.as_deref() {
|
||||
return Some(normalize_lookup(locator));
|
||||
if let Some(source) = &app.source {
|
||||
match source.kind {
|
||||
crate::domain::source::SourceKind::GitHub
|
||||
| crate::domain::source::SourceKind::AppImageHub => {
|
||||
if let Some(locator) = source.canonical_locator.as_deref() {
|
||||
return Some(normalize_lookup(locator));
|
||||
}
|
||||
return Some(normalize_lookup(&source.locator));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Some(normalize_lookup(&source.locator));
|
||||
}
|
||||
|
||||
app.source_input.as_deref().and_then(|input| {
|
||||
if input.contains('/') && !input.contains("://") {
|
||||
if let Some((provider, id)) = input.split_once('/')
|
||||
&& provider.eq_ignore_ascii_case("appimagehub")
|
||||
&& !id.is_empty()
|
||||
{
|
||||
return Some(normalize_lookup(id));
|
||||
}
|
||||
|
||||
Some(normalize_lookup(input))
|
||||
} else {
|
||||
None
|
||||
|
|
@ -320,3 +392,45 @@ fn render_github_search_error(error: &GitHubSearchError) -> String {
|
|||
GitHubSearchError::Transport(inner) => inner.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn appimagehub_remote_match_rank(query: &str, name: &str, summary: Option<&str>) -> u8 {
|
||||
let name = normalize_lookup(name);
|
||||
let summary = summary.map(normalize_lookup);
|
||||
|
||||
if name == query {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if name.starts_with(query) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if name.contains(query) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if summary
|
||||
.as_deref()
|
||||
.map(|summary| summary.starts_with(query))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
if summary
|
||||
.as_deref()
|
||||
.map(|summary| summary.contains(query))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
5
|
||||
}
|
||||
|
||||
fn render_appimagehub_search_error(error: &AppImageHubSearchError) -> String {
|
||||
match error {
|
||||
AppImageHubSearchError::Parse(inner) => inner.to_string(),
|
||||
AppImageHubSearchError::Transport(inner) => inner.to_string(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,9 +148,11 @@ fn fallback_channel_preference(app: &AppRecord) -> ChannelPreference {
|
|||
.clone()
|
||||
.unwrap_or_else(|| source.locator.clone()),
|
||||
),
|
||||
SourceKind::GitLab | SourceKind::SourceForge | SourceKind::DirectUrl | SourceKind::File => {
|
||||
(UpdateChannelKind::DirectAsset, source.locator.clone())
|
||||
}
|
||||
SourceKind::GitLab
|
||||
| SourceKind::AppImageHub
|
||||
| SourceKind::SourceForge
|
||||
| SourceKind::DirectUrl
|
||||
| SourceKind::File => (UpdateChannelKind::DirectAsset, source.locator.clone()),
|
||||
};
|
||||
|
||||
ChannelPreference {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
pub enum SourceKind {
|
||||
GitHub,
|
||||
GitLab,
|
||||
AppImageHub,
|
||||
SourceForge,
|
||||
DirectUrl,
|
||||
File,
|
||||
|
|
@ -12,6 +13,7 @@ impl SourceKind {
|
|||
match self {
|
||||
Self::GitHub => "github",
|
||||
Self::GitLab => "gitlab",
|
||||
Self::AppImageHub => "appimagehub",
|
||||
Self::SourceForge => "sourceforge",
|
||||
Self::DirectUrl => "direct-url",
|
||||
Self::File => "file",
|
||||
|
|
@ -26,6 +28,8 @@ pub enum SourceInputKind {
|
|||
GitHubReleaseUrl,
|
||||
GitHubReleaseAssetUrl,
|
||||
GitLabUrl,
|
||||
AppImageHubUrl,
|
||||
AppImageHubShorthand,
|
||||
SourceForgeUrl,
|
||||
DirectUrl,
|
||||
File,
|
||||
|
|
@ -39,6 +43,8 @@ impl SourceInputKind {
|
|||
Self::GitHubReleaseUrl => "github-release-url",
|
||||
Self::GitHubReleaseAssetUrl => "github-release-asset-url",
|
||||
Self::GitLabUrl => "gitlab-url",
|
||||
Self::AppImageHubUrl => "appimagehub-url",
|
||||
Self::AppImageHubShorthand => "appimagehub-shorthand",
|
||||
Self::SourceForgeUrl => "sourceforge-url",
|
||||
Self::DirectUrl => "direct-url",
|
||||
Self::File => "file",
|
||||
|
|
@ -53,6 +59,7 @@ pub enum NormalizedSourceKind {
|
|||
GitHubReleaseAsset,
|
||||
GitLab,
|
||||
GitLabCandidate,
|
||||
AppImageHub,
|
||||
SourceForge,
|
||||
SourceForgeCandidate,
|
||||
DirectUrl,
|
||||
|
|
@ -67,6 +74,7 @@ impl NormalizedSourceKind {
|
|||
Self::GitHubReleaseAsset => "github-release-asset",
|
||||
Self::GitLab => "gitlab",
|
||||
Self::GitLabCandidate => "gitlab-candidate",
|
||||
Self::AppImageHub => "appimagehub",
|
||||
Self::SourceForge => "sourceforge",
|
||||
Self::SourceForgeCandidate => "sourceforge-candidate",
|
||||
Self::DirectUrl => "direct-url",
|
||||
|
|
|
|||
491
crates/aim-core/src/source/appimagehub.rs
Normal file
491
crates/aim-core/src/source/appimagehub.rs
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
use std::env;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::domain::source::SourceRef;
|
||||
|
||||
const DEFAULT_APPIMAGEHUB_API_BASE: &str = "https://api.appimagehub.com/ocs/v1/content";
|
||||
const FIXTURE_MODE_ENV: &str = "AIM_APPIMAGEHUB_FIXTURE_MODE";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AppImageHubDownload {
|
||||
pub url: String,
|
||||
pub name: String,
|
||||
pub package_type: Option<String>,
|
||||
pub arch: Option<String>,
|
||||
pub md5sum: Option<String>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AppImageHubItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub summary: Option<String>,
|
||||
pub detail_page: String,
|
||||
pub tags: Vec<String>,
|
||||
pub downloads: Vec<AppImageHubDownload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AppImageHubSearchHit {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub summary: Option<String>,
|
||||
pub detail_page: String,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ResolvedAppImageHubItem {
|
||||
pub source: SourceRef,
|
||||
pub title: String,
|
||||
pub version: String,
|
||||
pub download: AppImageHubDownload,
|
||||
}
|
||||
|
||||
pub trait AppImageHubTransport {
|
||||
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError>;
|
||||
|
||||
fn search_items(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError>;
|
||||
}
|
||||
|
||||
pub fn default_transport() -> Box<dyn AppImageHubTransport> {
|
||||
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|
||||
|| env::var("AIM_GITHUB_FIXTURE_MODE").ok().as_deref() == Some("1")
|
||||
{
|
||||
Box::new(FixtureAppImageHubTransport)
|
||||
} else {
|
||||
Box::new(ReqwestAppImageHubTransport::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_appimagehub_item(
|
||||
source: &SourceRef,
|
||||
) -> Result<Option<ResolvedAppImageHubItem>, AppImageHubError> {
|
||||
let transport = default_transport();
|
||||
resolve_appimagehub_item_with(source, transport.as_ref())
|
||||
}
|
||||
|
||||
pub fn resolve_appimagehub_item_with<T: AppImageHubTransport + ?Sized>(
|
||||
source: &SourceRef,
|
||||
transport: &T,
|
||||
) -> Result<Option<ResolvedAppImageHubItem>, AppImageHubError> {
|
||||
let item = transport.fetch_item(source_id(source)?)?;
|
||||
let Some(download) = item
|
||||
.downloads
|
||||
.iter()
|
||||
.find(|download| is_appimage_download(download))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(ResolvedAppImageHubItem {
|
||||
source: source.clone(),
|
||||
title: item.name.clone(),
|
||||
version: resolved_version(&item, download),
|
||||
download: download.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn search_appimagehub(
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
|
||||
let transport = default_transport();
|
||||
search_appimagehub_with(query, limit, transport.as_ref())
|
||||
}
|
||||
|
||||
pub fn search_appimagehub_with<T: AppImageHubTransport + ?Sized>(
|
||||
query: &str,
|
||||
limit: usize,
|
||||
transport: &T,
|
||||
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
|
||||
transport.search_items(query, limit)
|
||||
}
|
||||
|
||||
pub struct ReqwestAppImageHubTransport {
|
||||
client: reqwest::blocking::Client,
|
||||
api_base: String,
|
||||
}
|
||||
|
||||
impl Default for ReqwestAppImageHubTransport {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ReqwestAppImageHubTransport {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("reqwest client should build"),
|
||||
api_base: env::var("AIM_APPIMAGEHUB_API_BASE")
|
||||
.unwrap_or_else(|_| DEFAULT_APPIMAGEHUB_API_BASE.to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppImageHubTransport for ReqwestAppImageHubTransport {
|
||||
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError> {
|
||||
let url = format!("{}/data/{id}", self.api_base);
|
||||
let xml = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(AppImageHubError::Transport)?
|
||||
.error_for_status()
|
||||
.map_err(AppImageHubError::Transport)?
|
||||
.text()
|
||||
.map_err(AppImageHubError::Transport)?;
|
||||
|
||||
parse_item_xml(&xml)
|
||||
}
|
||||
|
||||
fn search_items(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
|
||||
let url = format!("{}/data", self.api_base);
|
||||
let xml = self
|
||||
.client
|
||||
.get(url)
|
||||
.query(&[("search", query), ("pagesize", &limit.to_string())])
|
||||
.send()
|
||||
.map_err(AppImageHubSearchError::Transport)?
|
||||
.error_for_status()
|
||||
.map_err(AppImageHubSearchError::Transport)?
|
||||
.text()
|
||||
.map_err(AppImageHubSearchError::Transport)?;
|
||||
|
||||
parse_search_xml(&xml)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct FixtureAppImageHubTransport;
|
||||
|
||||
impl AppImageHubTransport for FixtureAppImageHubTransport {
|
||||
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError> {
|
||||
fixture_item(id).ok_or_else(|| AppImageHubError::FixtureItemMissing(id.to_owned()))
|
||||
}
|
||||
|
||||
fn search_items(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
|
||||
Ok(fixture_search_results(query, limit))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppImageHubError {
|
||||
FixtureItemMissing(String),
|
||||
Parse(quick_xml::DeError),
|
||||
Transport(reqwest::Error),
|
||||
UnsupportedSource(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppImageHubSearchError {
|
||||
Parse(quick_xml::DeError),
|
||||
Transport(reqwest::Error),
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OcsSingleResponse {
|
||||
data: OcsSingleData,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OcsSingleData {
|
||||
content: OcsContent,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OcsSearchResponse {
|
||||
data: OcsSearchData,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OcsSearchData {
|
||||
#[serde(default)]
|
||||
content: Vec<OcsContent>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OcsContent {
|
||||
id: String,
|
||||
name: String,
|
||||
version: Option<String>,
|
||||
summary: Option<String>,
|
||||
detailpage: Option<String>,
|
||||
tags: Option<String>,
|
||||
downloadlink1: Option<String>,
|
||||
downloadname1: Option<String>,
|
||||
download_package_type1: Option<String>,
|
||||
download_package_arch1: Option<String>,
|
||||
downloadmd5sum1: Option<String>,
|
||||
download_version1: Option<String>,
|
||||
downloadlink2: Option<String>,
|
||||
downloadname2: Option<String>,
|
||||
download_package_type2: Option<String>,
|
||||
download_package_arch2: Option<String>,
|
||||
downloadmd5sum2: Option<String>,
|
||||
download_version2: Option<String>,
|
||||
downloadlink3: Option<String>,
|
||||
downloadname3: Option<String>,
|
||||
download_package_type3: Option<String>,
|
||||
download_package_arch3: Option<String>,
|
||||
downloadmd5sum3: Option<String>,
|
||||
download_version3: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_item_xml(xml: &str) -> Result<AppImageHubItem, AppImageHubError> {
|
||||
let parsed =
|
||||
quick_xml::de::from_str::<OcsSingleResponse>(xml).map_err(AppImageHubError::Parse)?;
|
||||
Ok(content_to_item(parsed.data.content))
|
||||
}
|
||||
|
||||
fn parse_search_xml(xml: &str) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
|
||||
if !xml.contains("<id>") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let parsed =
|
||||
quick_xml::de::from_str::<OcsSearchResponse>(xml).map_err(AppImageHubSearchError::Parse)?;
|
||||
Ok(parsed
|
||||
.data
|
||||
.content
|
||||
.into_iter()
|
||||
.map(|content| AppImageHubSearchHit {
|
||||
id: content.id,
|
||||
name: content.name,
|
||||
version: normalize_version_text(content.version.as_deref()),
|
||||
summary: content.summary,
|
||||
detail_page: content
|
||||
.detailpage
|
||||
.unwrap_or_else(|| "https://www.appimagehub.com".to_owned()),
|
||||
tags: split_tags(content.tags.as_deref()),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn content_to_item(content: OcsContent) -> AppImageHubItem {
|
||||
let detail_page = content
|
||||
.detailpage
|
||||
.clone()
|
||||
.unwrap_or_else(|| "https://www.appimagehub.com".to_owned());
|
||||
let summary = content.summary.clone();
|
||||
let tags = split_tags(content.tags.as_deref());
|
||||
let downloads = collect_downloads(&content);
|
||||
|
||||
AppImageHubItem {
|
||||
id: content.id,
|
||||
name: content.name,
|
||||
version: normalize_version_text(content.version.as_deref()),
|
||||
summary,
|
||||
detail_page,
|
||||
tags,
|
||||
downloads,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_downloads(content: &OcsContent) -> Vec<AppImageHubDownload> {
|
||||
let mut downloads = Vec::new();
|
||||
|
||||
for download in [
|
||||
download_slot(
|
||||
content.downloadlink1.as_deref(),
|
||||
content.downloadname1.as_deref(),
|
||||
content.download_package_type1.as_deref(),
|
||||
content.download_package_arch1.as_deref(),
|
||||
content.downloadmd5sum1.as_deref(),
|
||||
content.download_version1.as_deref(),
|
||||
),
|
||||
download_slot(
|
||||
content.downloadlink2.as_deref(),
|
||||
content.downloadname2.as_deref(),
|
||||
content.download_package_type2.as_deref(),
|
||||
content.download_package_arch2.as_deref(),
|
||||
content.downloadmd5sum2.as_deref(),
|
||||
content.download_version2.as_deref(),
|
||||
),
|
||||
download_slot(
|
||||
content.downloadlink3.as_deref(),
|
||||
content.downloadname3.as_deref(),
|
||||
content.download_package_type3.as_deref(),
|
||||
content.download_package_arch3.as_deref(),
|
||||
content.downloadmd5sum3.as_deref(),
|
||||
content.download_version3.as_deref(),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
downloads.push(download);
|
||||
}
|
||||
|
||||
downloads
|
||||
}
|
||||
|
||||
fn download_slot(
|
||||
link: Option<&str>,
|
||||
name: Option<&str>,
|
||||
package_type: Option<&str>,
|
||||
arch: Option<&str>,
|
||||
md5sum: Option<&str>,
|
||||
version: Option<&str>,
|
||||
) -> Option<AppImageHubDownload> {
|
||||
let url = link?.trim();
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AppImageHubDownload {
|
||||
url: url.to_owned(),
|
||||
name: name.unwrap_or("download").trim().to_owned(),
|
||||
package_type: trim_optional(package_type),
|
||||
arch: trim_optional(arch),
|
||||
md5sum: trim_optional(md5sum),
|
||||
version: trim_optional(version),
|
||||
})
|
||||
}
|
||||
|
||||
fn trim_optional(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn normalize_version_text(value: Option<&str>) -> String {
|
||||
let value = value.map(str::trim).filter(|value| !value.is_empty());
|
||||
match value {
|
||||
Some("Latest") | Some("latest") | None => "latest".to_owned(),
|
||||
Some(other) => other.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_tags(tags: Option<&str>) -> Vec<String> {
|
||||
tags.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_id(source: &SourceRef) -> Result<&str, AppImageHubError> {
|
||||
source
|
||||
.canonical_locator
|
||||
.as_deref()
|
||||
.or_else(|| source.locator.rsplit('/').next())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| AppImageHubError::UnsupportedSource(source.locator.clone()))
|
||||
}
|
||||
|
||||
fn is_appimage_download(download: &AppImageHubDownload) -> bool {
|
||||
download
|
||||
.package_type
|
||||
.as_deref()
|
||||
.map(|kind| kind.eq_ignore_ascii_case("appimage"))
|
||||
.unwrap_or(false)
|
||||
|| download.name.ends_with(".AppImage")
|
||||
}
|
||||
|
||||
fn resolved_version(item: &AppImageHubItem, download: &AppImageHubDownload) -> String {
|
||||
download
|
||||
.version
|
||||
.as_deref()
|
||||
.map(|value| normalize_version_text(Some(value)))
|
||||
.filter(|value| value != "latest")
|
||||
.unwrap_or_else(|| item.version.clone())
|
||||
}
|
||||
|
||||
fn fixture_item(id: &str) -> Option<AppImageHubItem> {
|
||||
match id {
|
||||
"2338455" => Some(AppImageHubItem {
|
||||
id: "2338455".to_owned(),
|
||||
name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
|
||||
version: "latest".to_owned(),
|
||||
summary: Some("Take control of your internet with the Firefox browser".to_owned()),
|
||||
detail_page: "https://www.appimagehub.com/p/2338455".to_owned(),
|
||||
tags: vec![
|
||||
"appimage".to_owned(),
|
||||
"x86-64".to_owned(),
|
||||
"desktop".to_owned(),
|
||||
"release-stable".to_owned(),
|
||||
],
|
||||
downloads: vec![AppImageHubDownload {
|
||||
url: "https://files06.pling.com/api/files/download/firefox-x86-64.AppImage"
|
||||
.to_owned(),
|
||||
name: "firefox-x86-64.AppImage".to_owned(),
|
||||
package_type: Some("appimage".to_owned()),
|
||||
arch: Some("x86-64".to_owned()),
|
||||
md5sum: Some("1befdc026535be03a6001f33b11ef91d".to_owned()),
|
||||
version: None,
|
||||
}],
|
||||
}),
|
||||
"2337998" => Some(AppImageHubItem {
|
||||
id: "2337998".to_owned(),
|
||||
name: "Example Non-AppImage Package".to_owned(),
|
||||
version: "latest".to_owned(),
|
||||
summary: Some("An item that does not expose an AppImage download".to_owned()),
|
||||
detail_page: "https://www.appimagehub.com/p/2337998".to_owned(),
|
||||
tags: vec!["desktop".to_owned()],
|
||||
downloads: vec![AppImageHubDownload {
|
||||
url: "https://files06.pling.com/api/files/download/example.deb".to_owned(),
|
||||
name: "example.deb".to_owned(),
|
||||
package_type: Some("debian-package".to_owned()),
|
||||
arch: Some("x86-64".to_owned()),
|
||||
md5sum: None,
|
||||
version: Some("2.1.1".to_owned()),
|
||||
}],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_search_results(query: &str, limit: usize) -> Vec<AppImageHubSearchHit> {
|
||||
let query = query.trim().to_ascii_lowercase();
|
||||
let fixtures = [
|
||||
AppImageHubSearchHit {
|
||||
id: "2338455".to_owned(),
|
||||
name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
|
||||
version: "latest".to_owned(),
|
||||
summary: Some("Take control of your internet with the Firefox browser".to_owned()),
|
||||
detail_page: "https://www.appimagehub.com/p/2338455".to_owned(),
|
||||
tags: vec!["browser".to_owned(), "appimage".to_owned()],
|
||||
},
|
||||
AppImageHubSearchHit {
|
||||
id: "2338484".to_owned(),
|
||||
name: "Waterfox".to_owned(),
|
||||
version: "latest".to_owned(),
|
||||
summary: Some("Open Source, Private Browsing".to_owned()),
|
||||
detail_page: "https://www.appimagehub.com/p/2338484".to_owned(),
|
||||
tags: vec!["browser".to_owned(), "appimage".to_owned()],
|
||||
},
|
||||
];
|
||||
|
||||
fixtures
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
item.name.to_ascii_lowercase().contains(&query)
|
||||
|| item
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.to_ascii_lowercase().contains(&query))
|
||||
})
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -49,6 +49,10 @@ pub fn classify_input(query: &str) -> Result<ClassifiedInput, ClassifyInputError
|
|||
return classified;
|
||||
}
|
||||
|
||||
if let Some(classified) = classify_appimagehub_input(query) {
|
||||
return classified;
|
||||
}
|
||||
|
||||
if let Some(classified) = classify_sourceforge_http(query) {
|
||||
return classified;
|
||||
}
|
||||
|
|
@ -87,6 +91,26 @@ pub enum ClassifyInputError {
|
|||
Unsupported,
|
||||
}
|
||||
|
||||
fn classify_appimagehub_input(query: &str) -> Option<Result<ClassifiedInput, ClassifyInputError>> {
|
||||
if let Some(id) = appimagehub_id_from_url(query) {
|
||||
return Some(Ok(appimagehub_source_ref(
|
||||
SourceInputKind::AppImageHubUrl,
|
||||
id,
|
||||
)));
|
||||
}
|
||||
|
||||
let id = query.strip_prefix("appimagehub/")?;
|
||||
|
||||
if !is_ascii_digits(id) {
|
||||
return Some(Err(ClassifyInputError::Unsupported));
|
||||
}
|
||||
|
||||
Some(Ok(appimagehub_source_ref(
|
||||
SourceInputKind::AppImageHubShorthand,
|
||||
id,
|
||||
)))
|
||||
}
|
||||
|
||||
fn classify_gitlab_http(query: &str) -> Option<Result<ClassifiedInput, ClassifyInputError>> {
|
||||
let trimmed = query
|
||||
.trim_start_matches("https://gitlab.com/")
|
||||
|
|
@ -224,10 +248,39 @@ fn classify_sourceforge_http(query: &str) -> Option<Result<ClassifiedInput, Clas
|
|||
}))
|
||||
}
|
||||
|
||||
fn appimagehub_id_from_url(query: &str) -> Option<&str> {
|
||||
let trimmed = query
|
||||
.trim_start_matches("https://www.appimagehub.com/p/")
|
||||
.trim_start_matches("http://www.appimagehub.com/p/");
|
||||
if trimmed == query {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = trim_query_and_fragment(trimmed).trim_matches('/');
|
||||
if is_ascii_digits(id) { Some(id) } else { None }
|
||||
}
|
||||
|
||||
fn appimagehub_source_ref(kind: SourceInputKind, id: &str) -> ClassifiedInput {
|
||||
ClassifiedInput {
|
||||
kind,
|
||||
source_kind: SourceKind::AppImageHub,
|
||||
normalized_kind: NormalizedSourceKind::AppImageHub,
|
||||
locator: format!("https://www.appimagehub.com/p/{id}"),
|
||||
canonical_locator: Some(id.to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_query_and_fragment(value: &str) -> &str {
|
||||
value.split(['?', '#']).next().unwrap_or(value)
|
||||
}
|
||||
|
||||
fn is_ascii_digits(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_supported_gitlab_repo_path(parts: &[&str]) -> bool {
|
||||
if parts.len() < 2 {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod appimagehub;
|
||||
pub mod github;
|
||||
pub mod input;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use aim_core::adapters::appimagehub::AppImageHubAdapter;
|
||||
use aim_core::adapters::direct_url::DirectUrlAdapter;
|
||||
use aim_core::adapters::github::GitHubAdapter;
|
||||
use aim_core::adapters::gitlab::GitLabAdapter;
|
||||
|
|
@ -9,6 +10,7 @@ use aim_core::app::query::resolve_query;
|
|||
use aim_core::domain::source::{
|
||||
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
|
||||
};
|
||||
use aim_core::source::appimagehub::FixtureAppImageHubTransport;
|
||||
|
||||
struct FileArtifactAdapter;
|
||||
|
||||
|
|
@ -59,6 +61,60 @@ fn adapter_capabilities_can_report_exact_resolution_only() {
|
|||
assert!(!capabilities.supports_search);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimagehub_adapter_reports_search_and_exact_resolution_capabilities() {
|
||||
let adapter = AppImageHubAdapter;
|
||||
|
||||
assert_eq!(adapter.id(), "appimagehub");
|
||||
assert_eq!(
|
||||
adapter.repository_source_kind(),
|
||||
Some(SourceKind::AppImageHub)
|
||||
);
|
||||
assert_eq!(adapter.exact_source_kind(), None);
|
||||
assert_eq!(
|
||||
adapter.capabilities(),
|
||||
AdapterCapabilities {
|
||||
supports_search: true,
|
||||
supports_exact_resolution: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimagehub_adapter_resolves_installable_items_through_fixture_transport() {
|
||||
let adapter = AppImageHubAdapter;
|
||||
let source = resolve_query("appimagehub/2338455").unwrap();
|
||||
|
||||
let resolution = adapter
|
||||
.resolve_source_with(&source, &FixtureAppImageHubTransport)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::AppImageHub
|
||||
&& source.canonical_locator.as_deref() == Some("2338455")
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimagehub_adapter_reports_no_installable_artifact_for_non_appimage_items() {
|
||||
let adapter = AppImageHubAdapter;
|
||||
let source = resolve_query("appimagehub/2337998").unwrap();
|
||||
|
||||
let resolution = adapter
|
||||
.resolve_source_with(&source, &FixtureAppImageHubTransport)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::NoInstallableArtifact { source }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_backed_resolvers_accept_only_their_own_source_kind() {
|
||||
let github_source = resolve_query("sharkdp/bat").unwrap();
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ use aim_core::adapters::all_adapter_kinds;
|
|||
fn all_expected_adapter_kinds_are_registered() {
|
||||
let kinds = all_adapter_kinds();
|
||||
|
||||
assert!(kinds.contains(&"appimagehub"));
|
||||
assert!(kinds.contains(&"github"));
|
||||
assert!(kinds.contains(&"gitlab"));
|
||||
assert!(kinds.contains(&"direct-url"));
|
||||
assert!(kinds.contains(&"zsync"));
|
||||
assert!(kinds.contains(&"sourceforge"));
|
||||
assert!(kinds.contains(&"custom-json"));
|
||||
assert!(!kinds.contains(&"custom-json"));
|
||||
}
|
||||
|
|
|
|||
108
crates/aim-core/tests/appimagehub_search.rs
Normal file
108
crates/aim-core/tests/appimagehub_search.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
use aim_core::app::search::{
|
||||
AppImageHubSearchProvider, GitHubSearchProvider, SearchProvider, SearchProviderError,
|
||||
build_search_results_with,
|
||||
};
|
||||
use aim_core::domain::app::AppRecord;
|
||||
use aim_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use aim_core::source::appimagehub::FixtureAppImageHubTransport;
|
||||
use aim_core::source::github::FixtureGitHubTransport;
|
||||
|
||||
struct StubProvider {
|
||||
hit: SearchResult,
|
||||
}
|
||||
|
||||
impl SearchProvider for StubProvider {
|
||||
fn search(&self, _query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
|
||||
Ok(vec![self.hit.clone()])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimagehub_search_provider_maps_hits_to_install_ready_results() {
|
||||
let provider = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport);
|
||||
|
||||
let results = provider.search(&SearchQuery::new("firefox")).unwrap();
|
||||
|
||||
assert!(results.iter().any(|hit| {
|
||||
hit.provider_id == "appimagehub"
|
||||
&& hit.display_name == "Firefox by Mozilla - Official AppImage Edition"
|
||||
&& hit.install_query == "appimagehub/2338455"
|
||||
&& hit.canonical_locator == "2338455"
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimagehub_hits_are_annotated_as_installed_by_canonical_id() {
|
||||
let provider = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport);
|
||||
let installed = vec![AppRecord {
|
||||
stable_id: "firefox".to_owned(),
|
||||
display_name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
|
||||
source_input: Some("appimagehub/2338455".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::AppImageHub,
|
||||
locator: "https://www.appimagehub.com/p/2338455".to_owned(),
|
||||
input_kind: SourceInputKind::AppImageHubShorthand,
|
||||
normalized_kind: NormalizedSourceKind::AppImageHub,
|
||||
canonical_locator: Some("2338455".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}];
|
||||
|
||||
let results =
|
||||
build_search_results_with(&SearchQuery::new("firefox"), &installed, &[&provider]).unwrap();
|
||||
|
||||
assert!(results.remote_hits.iter().any(|hit| {
|
||||
hit.canonical_locator == "2338455"
|
||||
&& matches!(
|
||||
hit.install_status,
|
||||
SearchInstallStatus::Installed {
|
||||
installed_version: Some(ref version)
|
||||
} if version == "latest"
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_can_merge_github_and_appimagehub_providers() {
|
||||
let github = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
let appimagehub = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport);
|
||||
let stub = StubProvider {
|
||||
hit: SearchResult {
|
||||
provider_id: "github".to_owned(),
|
||||
display_name: "firefox-tooling/firestarter".to_owned(),
|
||||
description: Some("Stub GitHub result".to_owned()),
|
||||
source_locator: "https://github.com/firefox-tooling/firestarter".to_owned(),
|
||||
install_query: "firefox-tooling/firestarter".to_owned(),
|
||||
canonical_locator: "firefox-tooling/firestarter".to_owned(),
|
||||
version: Some("1.0.0".to_owned()),
|
||||
install_status: SearchInstallStatus::Available,
|
||||
},
|
||||
};
|
||||
|
||||
let results = build_search_results_with(
|
||||
&SearchQuery::new("firefox"),
|
||||
&[],
|
||||
&[&stub, &github, &appimagehub],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.any(|hit| hit.provider_id == "github")
|
||||
);
|
||||
assert!(
|
||||
results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.any(|hit| hit.provider_id == "appimagehub")
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,29 @@ fn classifies_github_release_asset_url() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_appimagehub_item_url() {
|
||||
let source = resolve_query("https://www.appimagehub.com/p/2338455").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::AppImageHub);
|
||||
assert_eq!(source.input_kind, SourceInputKind::AppImageHubUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::AppImageHub);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("2338455"));
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_appimagehub_id_shorthand() {
|
||||
let source = resolve_query("appimagehub/2338455").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::AppImageHub);
|
||||
assert_eq!(source.input_kind, SourceInputKind::AppImageHubShorthand);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::AppImageHub);
|
||||
assert_eq!(source.locator, "https://www.appimagehub.com/p/2338455");
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("2338455"));
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_repository_url() {
|
||||
let source = resolve_query("https://gitlab.com/example/team-app").unwrap();
|
||||
|
|
@ -278,6 +301,13 @@ fn rejects_malformed_sourceforge_url() {
|
|||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_appimagehub_shorthand() {
|
||||
let error = resolve_query("appimagehub/firefox").unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_sourceforge_url_shape() {
|
||||
let error = resolve_query("https://sourceforge.net/projects/team-app/rss").unwrap_err();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue