feat: expand source provider resolution
This commit is contained in:
parent
9d8ec1e4fd
commit
eaa9a3b52d
23 changed files with 2582 additions and 34 deletions
|
|
@ -15,6 +15,10 @@ impl SourceAdapter for DirectUrlAdapter {
|
|||
AdapterCapabilities::exact_resolution_only()
|
||||
}
|
||||
|
||||
fn exact_source_kind(&self) -> Option<SourceKind> {
|
||||
Some(SourceKind::DirectUrl)
|
||||
}
|
||||
|
||||
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
|
||||
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
||||
if source.kind != SourceKind::DirectUrl {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ impl SourceAdapter for GitHubAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
fn repository_source_kind(&self) -> Option<SourceKind> {
|
||||
Some(SourceKind::GitHub)
|
||||
}
|
||||
|
||||
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
|
||||
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
||||
if source.kind != SourceKind::GitHub {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,35 @@
|
|||
use crate::adapters::traits::{
|
||||
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
|
||||
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
|
||||
};
|
||||
use crate::app::query::resolve_query;
|
||||
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
|
||||
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind, SourceRef};
|
||||
|
||||
pub struct GitLabAdapter;
|
||||
|
||||
impl GitLabAdapter {
|
||||
pub fn artifact_name(source: &SourceRef) -> String {
|
||||
let slug = canonical_locator(source)
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or("app");
|
||||
format!("{slug}.AppImage")
|
||||
}
|
||||
|
||||
pub fn artifact_url(source: &SourceRef) -> String {
|
||||
let repo = canonical_locator(source);
|
||||
let artifact_name = Self::artifact_name(source);
|
||||
|
||||
match source.requested_tag.as_deref() {
|
||||
Some(tag) => {
|
||||
format!("https://gitlab.com/{repo}/-/releases/{tag}/downloads/{artifact_name}")
|
||||
}
|
||||
None => format!(
|
||||
"https://gitlab.com/{repo}/-/releases/permalink/latest/downloads/{artifact_name}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SourceAdapter for GitLabAdapter {
|
||||
fn id(&self) -> &'static str {
|
||||
"gitlab"
|
||||
|
|
@ -18,6 +42,10 @@ impl SourceAdapter for GitLabAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
fn repository_source_kind(&self) -> Option<SourceKind> {
|
||||
Some(SourceKind::GitLab)
|
||||
}
|
||||
|
||||
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
|
||||
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
||||
if source.kind != SourceKind::GitLab {
|
||||
|
|
@ -32,12 +60,73 @@ impl SourceAdapter for GitLabAdapter {
|
|||
return Err(AdapterError::UnsupportedSource);
|
||||
}
|
||||
|
||||
let resolved_source = resolved_source(source)?;
|
||||
|
||||
let version = resolved_source
|
||||
.requested_tag
|
||||
.clone()
|
||||
.unwrap_or_else(|| "latest".to_owned());
|
||||
|
||||
Ok(AdapterResolution {
|
||||
source: source.clone(),
|
||||
source: resolved_source,
|
||||
release: ResolvedRelease {
|
||||
version: "latest".to_owned(),
|
||||
version,
|
||||
prerelease: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_supported_source(
|
||||
&self,
|
||||
source: &SourceRef,
|
||||
) -> Result<AdapterResolveOutcome, AdapterError> {
|
||||
self.resolve(source).map(AdapterResolveOutcome::Resolved)
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_locator(source: &SourceRef) -> &str {
|
||||
source
|
||||
.canonical_locator
|
||||
.as_deref()
|
||||
.unwrap_or(source.locator.as_str())
|
||||
}
|
||||
|
||||
fn resolved_source(source: &SourceRef) -> Result<SourceRef, AdapterError> {
|
||||
if source.normalized_kind != NormalizedSourceKind::GitLabCandidate {
|
||||
return Ok(source.clone());
|
||||
}
|
||||
|
||||
let canonical_locator = gitlab_locator_path(&source.locator).ok_or_else(|| {
|
||||
AdapterError::ResolutionFailed(
|
||||
"gitlab candidate source could not be reduced to a repository path".to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut resolved = source.clone();
|
||||
resolved.normalized_kind = NormalizedSourceKind::GitLab;
|
||||
resolved.canonical_locator = Some(canonical_locator);
|
||||
resolved.tracks_latest = resolved.requested_tag.is_none();
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn gitlab_locator_path(locator: &str) -> Option<String> {
|
||||
let trimmed = locator
|
||||
.trim_start_matches("https://gitlab.com/")
|
||||
.trim_start_matches("http://gitlab.com/");
|
||||
|
||||
if trimmed == locator {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = trimmed
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or(trimmed)
|
||||
.trim_matches('/');
|
||||
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(path.to_owned())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ pub mod test_support;
|
|||
pub mod traits;
|
||||
pub mod zsync;
|
||||
|
||||
use crate::adapters::traits::SourceAdapter;
|
||||
use crate::domain::source::SourceRef;
|
||||
|
||||
pub fn all_adapter_kinds() -> Vec<&'static str> {
|
||||
vec![
|
||||
"github",
|
||||
|
|
@ -17,3 +20,8 @@ pub fn all_adapter_kinds() -> Vec<&'static str> {
|
|||
"custom-json",
|
||||
]
|
||||
}
|
||||
|
||||
pub fn supports_source<A: SourceAdapter + ?Sized>(adapter: &A, source: &SourceRef) -> bool {
|
||||
adapter.repository_source_kind() == Some(source.kind)
|
||||
|| adapter.exact_source_kind() == Some(source.kind)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
use crate::adapters::traits::{
|
||||
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
|
||||
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
|
||||
};
|
||||
use crate::domain::source::SourceRef;
|
||||
use crate::app::query::resolve_query;
|
||||
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind, SourceRef};
|
||||
|
||||
pub struct SourceForgeAdapter;
|
||||
|
||||
impl SourceForgeAdapter {
|
||||
pub fn artifact_url(source: &SourceRef) -> Option<String> {
|
||||
if is_resolved_download_locator(&source.locator) {
|
||||
Some(source.locator.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SourceAdapter for SourceForgeAdapter {
|
||||
fn id(&self) -> &'static str {
|
||||
"sourceforge"
|
||||
|
|
@ -17,11 +28,89 @@ impl SourceAdapter for SourceForgeAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
|
||||
Err(AdapterError::UnsupportedQuery)
|
||||
fn repository_source_kind(&self) -> Option<SourceKind> {
|
||||
Some(SourceKind::SourceForge)
|
||||
}
|
||||
|
||||
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
|
||||
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
|
||||
if source.kind != SourceKind::SourceForge {
|
||||
return Err(AdapterError::UnsupportedQuery);
|
||||
}
|
||||
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
|
||||
if source.kind != SourceKind::SourceForge {
|
||||
return Err(AdapterError::UnsupportedSource);
|
||||
}
|
||||
if !is_resolved_download_locator(&source.locator) {
|
||||
return Err(AdapterError::ResolutionFailed(
|
||||
"sourceforge source has no concrete latest-download artifact".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(AdapterResolution {
|
||||
source: resolved_source(source),
|
||||
release: ResolvedRelease {
|
||||
version: "latest".to_owned(),
|
||||
prerelease: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_supported_source(
|
||||
&self,
|
||||
source: &SourceRef,
|
||||
) -> Result<AdapterResolveOutcome, AdapterError> {
|
||||
if Self::artifact_url(source).is_some() {
|
||||
return self.resolve(source).map(AdapterResolveOutcome::Resolved);
|
||||
}
|
||||
|
||||
if matches!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForge | NormalizedSourceKind::SourceForgeCandidate
|
||||
) {
|
||||
return Ok(AdapterResolveOutcome::NoInstallableArtifact {
|
||||
source: source.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(AdapterResolveOutcome::NoInstallableArtifact {
|
||||
source: source.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_source(source: &SourceRef) -> SourceRef {
|
||||
let mut resolved = source.clone();
|
||||
if is_sourceforge_stable_download_locator(&resolved.locator) {
|
||||
resolved.normalized_kind = NormalizedSourceKind::SourceForge;
|
||||
resolved.tracks_latest = true;
|
||||
}
|
||||
|
||||
resolved
|
||||
}
|
||||
|
||||
fn is_resolved_download_locator(locator: &str) -> bool {
|
||||
is_latest_download_locator(locator) || is_sourceforge_stable_download_locator(locator)
|
||||
}
|
||||
|
||||
fn is_latest_download_locator(locator: &str) -> bool {
|
||||
let trimmed = locator
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or(locator)
|
||||
.trim_end_matches('/');
|
||||
trimmed.ends_with("/files/latest/download")
|
||||
}
|
||||
|
||||
fn is_sourceforge_stable_download_locator(locator: &str) -> bool {
|
||||
let trimmed = locator
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or(locator)
|
||||
.trim_end_matches('/');
|
||||
trimmed.ends_with("/files/releases/stable/download")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use crate::domain::source::ResolvedRelease;
|
||||
use crate::domain::source::SourceRef;
|
||||
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct AdapterCapabilities {
|
||||
|
|
@ -22,6 +21,12 @@ pub struct AdapterResolution {
|
|||
pub release: ResolvedRelease,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum AdapterResolveOutcome {
|
||||
Resolved(AdapterResolution),
|
||||
NoInstallableArtifact { source: SourceRef },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum AdapterError {
|
||||
UnsupportedQuery,
|
||||
|
|
@ -34,7 +39,34 @@ pub trait SourceAdapter {
|
|||
|
||||
fn capabilities(&self) -> AdapterCapabilities;
|
||||
|
||||
fn repository_source_kind(&self) -> Option<SourceKind> {
|
||||
None
|
||||
}
|
||||
|
||||
fn exact_source_kind(&self) -> Option<SourceKind> {
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError>;
|
||||
|
||||
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError>;
|
||||
|
||||
fn resolve_supported_source(
|
||||
&self,
|
||||
source: &SourceRef,
|
||||
) -> Result<AdapterResolveOutcome, AdapterError> {
|
||||
self.resolve(source).map(AdapterResolveOutcome::Resolved)
|
||||
}
|
||||
|
||||
fn supports_source(&self, source: &SourceRef) -> bool {
|
||||
crate::adapters::supports_source(self, source)
|
||||
}
|
||||
|
||||
fn resolve_source(&self, source: &SourceRef) -> Result<AdapterResolveOutcome, AdapterError> {
|
||||
if !self.supports_source(source) {
|
||||
return Err(AdapterError::UnsupportedSource);
|
||||
}
|
||||
|
||||
self.resolve_supported_source(source)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ use std::env;
|
|||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::adapters::direct_url::DirectUrlAdapter;
|
||||
use crate::adapters::gitlab::GitLabAdapter;
|
||||
use crate::adapters::sourceforge::SourceForgeAdapter;
|
||||
use crate::adapters::traits::AdapterResolution;
|
||||
use crate::adapters::traits::{AdapterResolveOutcome, SourceAdapter};
|
||||
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
|
||||
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
||||
use crate::app::progress::{
|
||||
|
|
@ -110,6 +114,115 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
strategy,
|
||||
)
|
||||
}
|
||||
SourceKind::GitLab => {
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DiscoverRelease,
|
||||
message: "discovering release".to_owned(),
|
||||
});
|
||||
let adapter = GitLabAdapter;
|
||||
let resolution = match adapter
|
||||
.resolve_source(&source)
|
||||
.map_err(|error| BuildAddPlanError::Adapter("gitlab", error))?
|
||||
{
|
||||
AdapterResolveOutcome::Resolved(resolution) => resolution,
|
||||
AdapterResolveOutcome::NoInstallableArtifact { source } => {
|
||||
return Err(BuildAddPlanError::NoInstallableArtifact { source });
|
||||
}
|
||||
};
|
||||
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SelectArtifact,
|
||||
message: "selecting artifact".to_owned(),
|
||||
});
|
||||
let artifact_url = GitLabAdapter::artifact_url(&resolution.source);
|
||||
let strategy = UpdateStrategy {
|
||||
preferred: crate::domain::update::ChannelPreference {
|
||||
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
|
||||
locator: artifact_url.clone(),
|
||||
reason: "provider-release".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
};
|
||||
let artifact = ArtifactCandidate {
|
||||
url: artifact_url,
|
||||
version: resolution.release.version.clone(),
|
||||
arch: None,
|
||||
selection_reason: "provider-release".to_owned(),
|
||||
};
|
||||
|
||||
(resolution, artifact, strategy)
|
||||
}
|
||||
SourceKind::DirectUrl => {
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SelectArtifact,
|
||||
message: "selecting artifact".to_owned(),
|
||||
});
|
||||
let adapter = DirectUrlAdapter;
|
||||
let resolution = match adapter
|
||||
.resolve_source(&source)
|
||||
.map_err(|error| BuildAddPlanError::Adapter("direct-url", error))?
|
||||
{
|
||||
AdapterResolveOutcome::Resolved(resolution) => resolution,
|
||||
AdapterResolveOutcome::NoInstallableArtifact { source } => {
|
||||
return Err(BuildAddPlanError::NoInstallableArtifact { source });
|
||||
}
|
||||
};
|
||||
let artifact = ArtifactCandidate {
|
||||
url: resolution.source.locator.clone(),
|
||||
version: resolution.release.version.clone(),
|
||||
arch: None,
|
||||
selection_reason: "exact-input".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
preferred: crate::domain::update::ChannelPreference {
|
||||
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
|
||||
locator: resolution.source.locator.clone(),
|
||||
reason: "exact-input".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
};
|
||||
|
||||
(resolution, artifact, strategy)
|
||||
}
|
||||
SourceKind::SourceForge => {
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DiscoverRelease,
|
||||
message: "discovering release".to_owned(),
|
||||
});
|
||||
let adapter = SourceForgeAdapter;
|
||||
let resolution = match adapter
|
||||
.resolve_source(&source)
|
||||
.map_err(|error| BuildAddPlanError::Adapter("sourceforge", error))?
|
||||
{
|
||||
AdapterResolveOutcome::Resolved(resolution) => resolution,
|
||||
AdapterResolveOutcome::NoInstallableArtifact { source } => {
|
||||
return Err(BuildAddPlanError::NoInstallableArtifact { source });
|
||||
}
|
||||
};
|
||||
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SelectArtifact,
|
||||
message: "selecting artifact".to_owned(),
|
||||
});
|
||||
let artifact_url = SourceForgeAdapter::artifact_url(&resolution.source)
|
||||
.ok_or(BuildAddPlanError::NoCandidates)?;
|
||||
let artifact = ArtifactCandidate {
|
||||
url: artifact_url.clone(),
|
||||
version: resolution.release.version.clone(),
|
||||
arch: None,
|
||||
selection_reason: "provider-release".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
preferred: crate::domain::update::ChannelPreference {
|
||||
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
|
||||
locator: artifact_url,
|
||||
reason: "provider-release".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
};
|
||||
|
||||
(resolution, artifact, strategy)
|
||||
}
|
||||
_ => {
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SelectArtifact,
|
||||
|
|
@ -367,7 +480,11 @@ pub struct InstalledApp {
|
|||
#[derive(Debug)]
|
||||
pub enum BuildAddPlanError {
|
||||
Query(ResolveQueryError),
|
||||
Adapter(&'static str, crate::adapters::traits::AdapterError),
|
||||
GitHubDiscovery(GitHubDiscoveryError),
|
||||
NoInstallableArtifact {
|
||||
source: crate::domain::source::SourceRef,
|
||||
},
|
||||
NoCandidates,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use crate::app::progress::{
|
|||
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
|
||||
};
|
||||
use crate::domain::app::{AppRecord, InstallScope};
|
||||
use crate::domain::source::SourceKind;
|
||||
use crate::domain::update::{
|
||||
ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult,
|
||||
UpdateExecutionStatus, UpdatePlan,
|
||||
|
|
@ -116,15 +117,7 @@ fn plan_update(app: &AppRecord) -> PlannedUpdate {
|
|||
}
|
||||
} else {
|
||||
(
|
||||
ChannelPreference {
|
||||
kind: UpdateChannelKind::GitHubReleases,
|
||||
locator: app
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|source| source.locator.clone())
|
||||
.unwrap_or_else(|| app.stable_id.clone()),
|
||||
reason: "install-origin-match".to_owned(),
|
||||
},
|
||||
fallback_channel_preference(app),
|
||||
"install-origin-match".to_owned(),
|
||||
)
|
||||
};
|
||||
|
|
@ -137,6 +130,35 @@ fn plan_update(app: &AppRecord) -> PlannedUpdate {
|
|||
}
|
||||
}
|
||||
|
||||
fn fallback_channel_preference(app: &AppRecord) -> ChannelPreference {
|
||||
let Some(source) = app.source.as_ref() else {
|
||||
return ChannelPreference {
|
||||
kind: UpdateChannelKind::GitHubReleases,
|
||||
locator: app.stable_id.clone(),
|
||||
reason: "install-origin-match".to_owned(),
|
||||
};
|
||||
};
|
||||
|
||||
let (kind, locator) = match source.kind {
|
||||
SourceKind::GitHub => (
|
||||
UpdateChannelKind::GitHubReleases,
|
||||
source
|
||||
.canonical_locator
|
||||
.clone()
|
||||
.unwrap_or_else(|| source.locator.clone()),
|
||||
),
|
||||
SourceKind::GitLab | SourceKind::SourceForge | SourceKind::DirectUrl | SourceKind::File => {
|
||||
(UpdateChannelKind::DirectAsset, source.locator.clone())
|
||||
}
|
||||
};
|
||||
|
||||
ChannelPreference {
|
||||
kind,
|
||||
locator,
|
||||
reason: "install-origin-match".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_update(
|
||||
app: &AppRecord,
|
||||
install_home: &Path,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
pub enum SourceKind {
|
||||
GitHub,
|
||||
GitLab,
|
||||
SourceForge,
|
||||
DirectUrl,
|
||||
File,
|
||||
}
|
||||
|
|
@ -11,6 +12,7 @@ impl SourceKind {
|
|||
match self {
|
||||
Self::GitHub => "github",
|
||||
Self::GitLab => "gitlab",
|
||||
Self::SourceForge => "sourceforge",
|
||||
Self::DirectUrl => "direct-url",
|
||||
Self::File => "file",
|
||||
}
|
||||
|
|
@ -24,6 +26,7 @@ pub enum SourceInputKind {
|
|||
GitHubReleaseUrl,
|
||||
GitHubReleaseAssetUrl,
|
||||
GitLabUrl,
|
||||
SourceForgeUrl,
|
||||
DirectUrl,
|
||||
File,
|
||||
}
|
||||
|
|
@ -36,6 +39,7 @@ impl SourceInputKind {
|
|||
Self::GitHubReleaseUrl => "github-release-url",
|
||||
Self::GitHubReleaseAssetUrl => "github-release-asset-url",
|
||||
Self::GitLabUrl => "gitlab-url",
|
||||
Self::SourceForgeUrl => "sourceforge-url",
|
||||
Self::DirectUrl => "direct-url",
|
||||
Self::File => "file",
|
||||
}
|
||||
|
|
@ -48,6 +52,9 @@ pub enum NormalizedSourceKind {
|
|||
GitHubRelease,
|
||||
GitHubReleaseAsset,
|
||||
GitLab,
|
||||
GitLabCandidate,
|
||||
SourceForge,
|
||||
SourceForgeCandidate,
|
||||
DirectUrl,
|
||||
File,
|
||||
}
|
||||
|
|
@ -59,6 +66,9 @@ impl NormalizedSourceKind {
|
|||
Self::GitHubRelease => "github-release",
|
||||
Self::GitHubReleaseAsset => "github-release-asset",
|
||||
Self::GitLab => "gitlab",
|
||||
Self::GitLabCandidate => "gitlab-candidate",
|
||||
Self::SourceForge => "sourceforge",
|
||||
Self::SourceForgeCandidate => "sourceforge-candidate",
|
||||
Self::DirectUrl => "direct-url",
|
||||
Self::File => "file",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,17 +45,12 @@ pub fn classify_input(query: &str) -> Result<ClassifiedInput, ClassifyInputError
|
|||
return Ok(classified);
|
||||
}
|
||||
|
||||
if query.starts_with("https://gitlab.com/") || query.starts_with("http://gitlab.com/") {
|
||||
return Ok(ClassifiedInput {
|
||||
kind: SourceInputKind::GitLabUrl,
|
||||
source_kind: SourceKind::GitLab,
|
||||
normalized_kind: NormalizedSourceKind::GitLab,
|
||||
locator: query.to_owned(),
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
});
|
||||
if let Some(classified) = classify_gitlab_http(query) {
|
||||
return classified;
|
||||
}
|
||||
|
||||
if let Some(classified) = classify_sourceforge_http(query) {
|
||||
return classified;
|
||||
}
|
||||
|
||||
if query.starts_with("https://") || query.starts_with("http://") {
|
||||
|
|
@ -92,6 +87,201 @@ pub enum ClassifyInputError {
|
|||
Unsupported,
|
||||
}
|
||||
|
||||
fn classify_gitlab_http(query: &str) -> Option<Result<ClassifiedInput, ClassifyInputError>> {
|
||||
let trimmed = query
|
||||
.trim_start_matches("https://gitlab.com/")
|
||||
.trim_start_matches("http://gitlab.com/");
|
||||
if trimmed == query {
|
||||
return None;
|
||||
}
|
||||
|
||||
let trimmed = trim_query_and_fragment(trimmed);
|
||||
|
||||
let parts = trimmed
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if parts.len() < 2 {
|
||||
return Some(Err(ClassifyInputError::Unsupported));
|
||||
}
|
||||
|
||||
let release_marker = parts.iter().position(|segment| *segment == "-");
|
||||
let is_repository_url = release_marker.is_none() && is_supported_gitlab_repo_path(&parts);
|
||||
let is_release_like_url = matches!(release_marker, Some(index) if index >= 2)
|
||||
&& parts.get(release_marker.unwrap() + 1) == Some(&"releases")
|
||||
&& parts.get(release_marker.unwrap() + 2).is_some()
|
||||
&& parts.len() == release_marker.unwrap() + 3;
|
||||
let is_ambiguous_candidate =
|
||||
release_marker.is_none() && is_ambiguous_gitlab_candidate_path(&parts);
|
||||
if !is_repository_url && !is_release_like_url && !is_ambiguous_candidate {
|
||||
return Some(Err(ClassifyInputError::Unsupported));
|
||||
}
|
||||
|
||||
let canonical_parts = if let Some(index) = release_marker {
|
||||
&parts[..index]
|
||||
} else {
|
||||
&parts[..]
|
||||
};
|
||||
let canonical_locator = canonical_parts.join("/");
|
||||
let requested_tag = if let Some(index) = release_marker {
|
||||
parts.get(index + 2).map(|value| (*value).to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let tracks_latest = requested_tag.is_none() && !is_ambiguous_candidate;
|
||||
|
||||
Some(Ok(ClassifiedInput {
|
||||
kind: SourceInputKind::GitLabUrl,
|
||||
source_kind: SourceKind::GitLab,
|
||||
normalized_kind: if is_ambiguous_candidate {
|
||||
NormalizedSourceKind::GitLabCandidate
|
||||
} else {
|
||||
NormalizedSourceKind::GitLab
|
||||
},
|
||||
locator: query.to_owned(),
|
||||
canonical_locator: if is_ambiguous_candidate {
|
||||
None
|
||||
} else {
|
||||
Some(canonical_locator)
|
||||
},
|
||||
requested_tag,
|
||||
requested_asset_name: None,
|
||||
tracks_latest,
|
||||
}))
|
||||
}
|
||||
|
||||
fn classify_sourceforge_http(query: &str) -> Option<Result<ClassifiedInput, ClassifyInputError>> {
|
||||
let trimmed = query
|
||||
.trim_start_matches("https://sourceforge.net/projects/")
|
||||
.trim_start_matches("http://sourceforge.net/projects/");
|
||||
if trimmed == query {
|
||||
return None;
|
||||
}
|
||||
|
||||
let trimmed = trim_query_and_fragment(trimmed);
|
||||
|
||||
let parts = trimmed
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let Some(project) = parts.first() else {
|
||||
return Some(Err(ClassifyInputError::Unsupported));
|
||||
};
|
||||
|
||||
let is_project_url = parts.len() == 1;
|
||||
let is_latest_download_url =
|
||||
parts.len() == 4 && parts[1] == "files" && parts[2] == "latest" && parts[3] == "download";
|
||||
let is_root_file_download_url = parts.len() == 4
|
||||
&& parts[1] == "files"
|
||||
&& parts[3] == "download"
|
||||
&& !matches!(parts[2], "latest" | "releases");
|
||||
let is_nested_file_download_url = parts.len() > 4
|
||||
&& parts[1] == "files"
|
||||
&& parts.last() == Some(&"download")
|
||||
&& parts
|
||||
.get(parts.len().saturating_sub(2))
|
||||
.is_some_and(|segment| segment.contains('.'));
|
||||
let is_ambiguous_candidate = is_ambiguous_sourceforge_candidate_path(&parts);
|
||||
let is_concrete_download_url =
|
||||
!is_latest_download_url && (is_root_file_download_url || is_nested_file_download_url);
|
||||
if is_concrete_download_url {
|
||||
return Some(Ok(ClassifiedInput {
|
||||
kind: SourceInputKind::DirectUrl,
|
||||
source_kind: SourceKind::DirectUrl,
|
||||
normalized_kind: NormalizedSourceKind::DirectUrl,
|
||||
locator: query.to_owned(),
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}));
|
||||
}
|
||||
if !is_project_url && !is_latest_download_url && !is_ambiguous_candidate {
|
||||
return Some(Err(ClassifyInputError::Unsupported));
|
||||
}
|
||||
|
||||
Some(Ok(ClassifiedInput {
|
||||
kind: SourceInputKind::SourceForgeUrl,
|
||||
source_kind: SourceKind::SourceForge,
|
||||
normalized_kind: if is_ambiguous_candidate {
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
} else {
|
||||
NormalizedSourceKind::SourceForge
|
||||
},
|
||||
locator: query.to_owned(),
|
||||
canonical_locator: Some((*project).to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: is_project_url || is_latest_download_url,
|
||||
}))
|
||||
}
|
||||
|
||||
fn trim_query_and_fragment(value: &str) -> &str {
|
||||
value.split(['?', '#']).next().unwrap_or(value)
|
||||
}
|
||||
|
||||
fn is_supported_gitlab_repo_path(parts: &[&str]) -> bool {
|
||||
if parts.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if parts.len() == 2 {
|
||||
return true;
|
||||
}
|
||||
|
||||
if parts.len() == 3 {
|
||||
return !is_reserved_gitlab_resource_segment(parts[2]);
|
||||
}
|
||||
|
||||
if parts[2..]
|
||||
.iter()
|
||||
.copied()
|
||||
.any(is_reserved_gitlab_resource_segment)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn is_reserved_gitlab_resource_segment(segment: &str) -> bool {
|
||||
matches!(
|
||||
segment,
|
||||
"issues"
|
||||
| "merge_requests"
|
||||
| "releases"
|
||||
| "tags"
|
||||
| "blob"
|
||||
| "tree"
|
||||
| "commits"
|
||||
| "packages"
|
||||
| "archive"
|
||||
| "raw"
|
||||
| "pipelines"
|
||||
| "jobs"
|
||||
| "wikis"
|
||||
| "snippets"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_ambiguous_gitlab_candidate_path(parts: &[&str]) -> bool {
|
||||
parts.len() == 4 && parts[2] == "releases"
|
||||
}
|
||||
|
||||
fn is_ambiguous_sourceforge_candidate_path(parts: &[&str]) -> bool {
|
||||
parts.len() == 5
|
||||
&& parts[1] == "files"
|
||||
&& parts[2] == "releases"
|
||||
&& (parts[3] == "stable" || is_version_like_sourceforge_folder(parts[3]))
|
||||
&& parts[4] == "download"
|
||||
}
|
||||
|
||||
fn is_version_like_sourceforge_folder(segment: &str) -> bool {
|
||||
segment.starts_with('v') && segment.chars().any(|character| character.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn classify_github_http(query: &str) -> Option<ClassifiedInput> {
|
||||
let trimmed = query
|
||||
.trim_start_matches("https://github.com/")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,57 @@
|
|||
use aim_core::adapters::direct_url::DirectUrlAdapter;
|
||||
use aim_core::adapters::github::GitHubAdapter;
|
||||
use aim_core::adapters::gitlab::GitLabAdapter;
|
||||
use aim_core::adapters::traits::{AdapterCapabilities, SourceAdapter};
|
||||
use aim_core::adapters::sourceforge::SourceForgeAdapter;
|
||||
use aim_core::adapters::traits::{
|
||||
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
|
||||
};
|
||||
use aim_core::app::query::resolve_query;
|
||||
use aim_core::domain::source::{
|
||||
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
|
||||
};
|
||||
|
||||
struct FileArtifactAdapter;
|
||||
|
||||
impl SourceAdapter for FileArtifactAdapter {
|
||||
fn id(&self) -> &'static str {
|
||||
"file"
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> AdapterCapabilities {
|
||||
AdapterCapabilities::exact_resolution_only()
|
||||
}
|
||||
|
||||
fn exact_source_kind(&self) -> Option<SourceKind> {
|
||||
Some(SourceKind::File)
|
||||
}
|
||||
|
||||
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
|
||||
Err(AdapterError::UnsupportedQuery)
|
||||
}
|
||||
|
||||
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
|
||||
Ok(AdapterResolution {
|
||||
source: source.clone(),
|
||||
release: ResolvedRelease {
|
||||
version: "file".to_owned(),
|
||||
prerelease: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn file_source() -> SourceRef {
|
||||
SourceRef {
|
||||
kind: SourceKind::File,
|
||||
locator: "/tmp/team-app.AppImage".to_owned(),
|
||||
input_kind: SourceInputKind::File,
|
||||
normalized_kind: NormalizedSourceKind::File,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_capabilities_can_report_exact_resolution_only() {
|
||||
|
|
@ -8,6 +59,200 @@ fn adapter_capabilities_can_report_exact_resolution_only() {
|
|||
assert!(!capabilities.supports_search);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_backed_resolvers_accept_only_their_own_source_kind() {
|
||||
let github_source = resolve_query("sharkdp/bat").unwrap();
|
||||
let gitlab_source = resolve_query("https://gitlab.com/example/team/app").unwrap();
|
||||
|
||||
let github_adapter: &dyn SourceAdapter = &GitHubAdapter;
|
||||
assert!(github_adapter.supports_source(&github_source));
|
||||
assert!(!github_adapter.supports_source(&gitlab_source));
|
||||
assert_eq!(
|
||||
github_adapter.resolve_source(&gitlab_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
|
||||
let gitlab_adapter: &dyn SourceAdapter = &GitLabAdapter;
|
||||
assert!(gitlab_adapter.supports_source(&gitlab_source));
|
||||
assert!(!gitlab_adapter.supports_source(&github_source));
|
||||
assert_eq!(
|
||||
gitlab_adapter.resolve_source(&github_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_resolution_resolvers_accept_only_exact_artifact_kinds() {
|
||||
let direct_url_adapter: &dyn SourceAdapter = &DirectUrlAdapter;
|
||||
let file_adapter: &dyn SourceAdapter = &FileArtifactAdapter;
|
||||
let direct_url_source = resolve_query("https://example.com/team-app.AppImage").unwrap();
|
||||
let github_source = resolve_query("sharkdp/bat").unwrap();
|
||||
let file_source = file_source();
|
||||
|
||||
assert!(direct_url_adapter.supports_source(&direct_url_source));
|
||||
assert!(!direct_url_adapter.supports_source(&file_source));
|
||||
assert!(!direct_url_adapter.supports_source(&github_source));
|
||||
assert_eq!(
|
||||
direct_url_adapter.resolve_source(&github_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
assert_eq!(
|
||||
direct_url_adapter.resolve_source(&file_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
|
||||
let direct_resolution = direct_url_adapter
|
||||
.resolve_source(&direct_url_source)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
direct_resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
release: ResolvedRelease { version, .. },
|
||||
..
|
||||
}) if version == "unresolved"
|
||||
));
|
||||
|
||||
assert!(file_adapter.supports_source(&file_source));
|
||||
assert!(!file_adapter.supports_source(&direct_url_source));
|
||||
assert!(!file_adapter.supports_source(&github_source));
|
||||
assert_eq!(
|
||||
file_adapter.resolve_source(&direct_url_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
|
||||
let file_resolution = file_adapter.resolve_source(&file_source).unwrap();
|
||||
assert!(matches!(
|
||||
file_resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::File && version == "file"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolvers_can_return_no_installable_artifact_without_looking_unsupported() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
let source = resolve_query("https://sourceforge.net/projects/team-app/").unwrap();
|
||||
|
||||
let resolution = adapter.resolve_source(&source).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::NoInstallableArtifact { source }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_installable_artifact_outcomes_still_reject_unsupported_source_kinds() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
let unsupported_source = resolve_query("sharkdp/bat").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
adapter.resolve_source(&unsupported_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_latest_download_sources_resolve_through_trait() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://sourceforge.net/projects/team-app/files/latest/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::SourceForge);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::SourceForge
|
||||
&& source.locator == "https://sourceforge.net/projects/team-app/files/latest/download"
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_candidate_sources_can_resolve_to_repository_semantics() {
|
||||
let adapter: &dyn SourceAdapter = &GitLabAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://gitlab.com/acme/platform/releases/team-app")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::GitLab);
|
||||
assert_eq!(
|
||||
result.normalized_kind,
|
||||
NormalizedSourceKind::GitLabCandidate
|
||||
);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::GitLab
|
||||
&& source.locator == "https://gitlab.com/acme/platform/releases/team-app"
|
||||
&& source.canonical_locator.as_deref() == Some("acme/platform/releases/team-app")
|
||||
&& source.normalized_kind == NormalizedSourceKind::GitLab
|
||||
&& source.tracks_latest
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_candidate_sources_can_resolve_to_latest_download() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://sourceforge.net/projects/team-app/files/releases/stable/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
result.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::SourceForge
|
||||
&& source.locator
|
||||
== "https://sourceforge.net/projects/team-app/files/releases/stable/download"
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_version_folder_candidates_can_return_no_installable_artifact() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://sourceforge.net/projects/team-app/files/releases/v1-0/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
result.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert_eq!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::NoInstallableArtifact { source: result }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_github_adapter_delegates_to_source_pipeline() {
|
||||
let adapter: &dyn SourceAdapter = &GitHubAdapter;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
use aim_core::app::add::{BuildAddPlanError, build_add_plan_with};
|
||||
use aim_core::app::query::ResolveQueryError;
|
||||
use aim_core::domain::source::SourceKind;
|
||||
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
|
||||
use aim_core::platform::DesktopHelpers;
|
||||
use aim_core::source::github::FixtureGitHubTransport;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
|
@ -34,3 +38,55 @@ fn integration_failure_removes_new_payload_and_generated_files() {
|
|||
assert!(!final_payload_path.exists());
|
||||
assert!(!desktop_entry_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_queries_remain_distinct_from_provider_resolution_failures() {
|
||||
let error =
|
||||
build_add_plan_with("https://gitlab.com/example", &FixtureGitHubTransport).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
BuildAddPlanError::Query(ResolveQueryError::Unsupported)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_sourceforge_project_without_latest_download_reports_no_installable_artifact() {
|
||||
let error = build_add_plan_with(
|
||||
"https://sourceforge.net/projects/team-app/",
|
||||
&FixtureGitHubTransport,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
match error {
|
||||
BuildAddPlanError::NoInstallableArtifact { source } => {
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.locator, "https://sourceforge.net/projects/team-app/");
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
}
|
||||
other => panic!("expected no-installable-artifact error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_sourceforge_version_folder_candidate_without_installable_artifact_reports_no_installable_artifact()
|
||||
{
|
||||
let error = build_add_plan_with(
|
||||
"https://sourceforge.net/projects/team-app/files/releases/v1-0/download",
|
||||
&FixtureGitHubTransport,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
match error {
|
||||
BuildAddPlanError::NoInstallableArtifact { source } => {
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
source.locator,
|
||||
"https://sourceforge.net/projects/team-app/files/releases/v1-0/download"
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert_eq!(source.normalized_kind.as_str(), "sourceforge-candidate");
|
||||
}
|
||||
other => panic!("expected no-installable-artifact error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use aim_core::app::add::{build_add_plan_with_reporter, install_app_with_reporter};
|
||||
use aim_core::app::progress::{OperationEvent, OperationStage};
|
||||
use aim_core::domain::app::InstallScope;
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceKind};
|
||||
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
|
||||
use aim_core::platform::DesktopHelpers;
|
||||
use aim_core::source::github::FixtureGitHubTransport;
|
||||
|
|
@ -226,3 +227,207 @@ fn install_app_reports_operation_stages_in_order() {
|
|||
]
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_source_builds_concrete_install_candidate() {
|
||||
let mut events: Vec<OperationEvent> = Vec::new();
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let plan = build_add_plan_with_reporter(
|
||||
"https://gitlab.com/example/team-app",
|
||||
&FixtureGitHubTransport,
|
||||
&mut reporter,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::GitLab);
|
||||
assert_eq!(
|
||||
plan.resolution.source.locator,
|
||||
"https://gitlab.com/example/team-app"
|
||||
);
|
||||
assert_eq!(plan.resolution.release.version, "latest");
|
||||
assert_eq!(
|
||||
plan.selected_artifact.url,
|
||||
"https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
|
||||
);
|
||||
assert_eq!(plan.selected_artifact.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DiscoverRelease,
|
||||
message: "discovering release".to_owned(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_candidate_builds_concrete_install_candidate() {
|
||||
let mut events: Vec<OperationEvent> = Vec::new();
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let query = "https://gitlab.com/acme/platform/releases/team-app";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::GitLab);
|
||||
assert_eq!(plan.resolution.source.locator, query);
|
||||
assert_eq!(
|
||||
plan.resolution.source.canonical_locator.as_deref(),
|
||||
Some("acme/platform/releases/team-app")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.resolution.source.normalized_kind,
|
||||
NormalizedSourceKind::GitLab
|
||||
);
|
||||
assert_eq!(plan.resolution.release.version, "latest");
|
||||
assert_eq!(
|
||||
plan.selected_artifact.url,
|
||||
"https://gitlab.com/acme/platform/releases/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
|
||||
);
|
||||
assert_eq!(plan.selected_artifact.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DiscoverRelease,
|
||||
message: "discovering release".to_owned(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_install_preserves_truthful_gitlab_origin() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://gitlab.com/example/team-app";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
let installed =
|
||||
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed.record.source_input.as_deref(), Some(query));
|
||||
assert_eq!(
|
||||
installed.record.installed_version.as_deref(),
|
||||
Some("latest")
|
||||
);
|
||||
assert_eq!(installed.source.kind, SourceKind::GitLab);
|
||||
assert_eq!(installed.source.locator, query);
|
||||
assert_eq!(
|
||||
installed.source.canonical_locator.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
assert_eq!(
|
||||
installed.selected_artifact.url,
|
||||
"https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_url_source_uses_exact_input_resolution() {
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://example.com/downloads/team-app.AppImage";
|
||||
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(plan.resolution.source.locator, query);
|
||||
assert_eq!(plan.resolution.release.version, "unresolved");
|
||||
assert_eq!(plan.selected_artifact.url, query);
|
||||
assert_eq!(plan.selected_artifact.version, "unresolved");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "exact-input");
|
||||
assert_eq!(plan.update_strategy.preferred.locator, query);
|
||||
assert_eq!(plan.update_strategy.preferred.reason, "exact-input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_url_install_preserves_truthful_direct_url_origin() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
let installed =
|
||||
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed.record.source_input.as_deref(), Some(query));
|
||||
assert_eq!(
|
||||
installed.record.installed_version.as_deref(),
|
||||
Some("unresolved")
|
||||
);
|
||||
assert_eq!(installed.source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(installed.source.locator, query);
|
||||
assert_eq!(installed.selected_artifact.url, query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_candidate_builds_concrete_install_candidate() {
|
||||
let mut events: Vec<OperationEvent> = Vec::new();
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let query = "https://sourceforge.net/projects/team-app/files/releases/stable/download";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(plan.resolution.source.locator, query);
|
||||
assert_eq!(plan.resolution.release.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.url, query);
|
||||
assert_eq!(plan.selected_artifact.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
|
||||
assert_eq!(plan.update_strategy.preferred.locator, query);
|
||||
assert_eq!(plan.update_strategy.preferred.reason, "provider-release");
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DiscoverRelease,
|
||||
message: "discovering release".to_owned(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_latest_download_builds_concrete_install_candidate() {
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://sourceforge.net/projects/team-app/files/latest/download";
|
||||
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(plan.resolution.source.locator, query);
|
||||
assert_eq!(plan.resolution.release.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.url, query);
|
||||
assert_eq!(plan.selected_artifact.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_latest_download_install_preserves_truthful_origin() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://sourceforge.net/projects/team-app/files/latest/download";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
let installed =
|
||||
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed.record.source_input.as_deref(), Some(query));
|
||||
assert_eq!(
|
||||
installed.record.installed_version.as_deref(),
|
||||
Some("latest")
|
||||
);
|
||||
assert_eq!(installed.source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(installed.source.locator, query);
|
||||
assert_eq!(
|
||||
installed.source.canonical_locator.as_deref(),
|
||||
Some("team-app")
|
||||
);
|
||||
assert_eq!(installed.selected_artifact.url, query);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,3 +25,286 @@ fn classifies_github_release_asset_url() {
|
|||
NormalizedSourceKind::GitHubReleaseAsset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_repository_url() {
|
||||
let source = resolve_query("https://gitlab.com/example/team-app").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(source.input_kind, SourceInputKind::GitLabUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::GitLab);
|
||||
assert_eq!(
|
||||
source.canonical_locator.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_release_like_url() {
|
||||
let source = resolve_query("https://gitlab.com/example/team-app/-/releases/v1.2.3").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(source.input_kind, SourceInputKind::GitLabUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::GitLab);
|
||||
assert_eq!(
|
||||
source.canonical_locator.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
assert_eq!(source.requested_tag.as_deref(), Some("v1.2.3"));
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_subgroup_repository_url() {
|
||||
let source = resolve_query("https://gitlab.com/example/platform/team-app").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(
|
||||
source.canonical_locator.as_deref(),
|
||||
Some("example/platform/team-app")
|
||||
);
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_deep_subgroup_repository_url() {
|
||||
let source = resolve_query("https://gitlab.com/example/platform/apps/team-app").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(
|
||||
source.canonical_locator.as_deref(),
|
||||
Some("example/platform/apps/team-app")
|
||||
);
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_repository_with_reserved_namespace_segment() {
|
||||
let source = resolve_query("https://gitlab.com/example/releases/team-app").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(
|
||||
source.canonical_locator.as_deref(),
|
||||
Some("example/releases/team-app")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_two_segment_repository_with_reserved_slug() {
|
||||
let source = resolve_query("https://gitlab.com/example/issues").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("example/issues"));
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_sourceforge_project_url() {
|
||||
let source = resolve_query("https://sourceforge.net/projects/team-app/").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_sourceforge_files_url() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/latest/download").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_direct_url_classification() {
|
||||
let source = resolve_query("https://example.com/downloads/team-app.AppImage").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_sourceforge_download_url_as_direct_url() {
|
||||
let source = resolve_query(
|
||||
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_sourceforge_root_download_url_as_direct_url() {
|
||||
let source = resolve_query(
|
||||
"https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_sourceforge_extensionless_root_download_url_as_direct_url() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/team-app/download").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_sourceforge_download_url_with_query_as_direct_url() {
|
||||
let source = resolve_query(
|
||||
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download?use_mirror=pilotfiber",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_gitlab_url() {
|
||||
let error = resolve_query("https://gitlab.com/example").unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_url_shape() {
|
||||
let error = resolve_query("https://gitlab.com/example/team-app/-/issues").unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_nested_resource_url() {
|
||||
let error = resolve_query("https://gitlab.com/example/team-app/issues").unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_release_permalink_url() {
|
||||
let error = resolve_query("https://gitlab.com/example/team-app/-/releases/permalink/latest")
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_issue_detail_url() {
|
||||
let error = resolve_query("https://gitlab.com/example/team-app/issues/1").unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_blob_url() {
|
||||
let error =
|
||||
resolve_query("https://gitlab.com/example/team-app/blob/main/README.md").unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_ambiguous_gitlab_deep_reserved_segment_as_candidate() {
|
||||
let source = resolve_query("https://gitlab.com/acme/platform/releases/team-app").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(source.input_kind, SourceInputKind::GitLabUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::GitLabCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator, None);
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_packages_url() {
|
||||
let error = resolve_query("https://gitlab.com/example/team-app/packages").unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_sourceforge_url() {
|
||||
let error = resolve_query("https://sourceforge.net/projects/").unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_sourceforge_url_shape() {
|
||||
let error = resolve_query("https://sourceforge.net/projects/team-app/rss").unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_sourceforge_files_shape() {
|
||||
let error =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/releases").unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_sourceforge_folder_download_shape() {
|
||||
let error = resolve_query("https://sourceforge.net/projects/team-app/files/releases/download")
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_ambiguous_sourceforge_nested_folder_download_as_candidate() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/releases/stable/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_sourceforge_nested_extensionless_download_shape() {
|
||||
let error =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/releases/team-app/download")
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_ambiguous_sourceforge_version_folder_download_as_candidate() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/releases/v1-0/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,3 +101,107 @@ fn registry_round_trips_install_metadata() {
|
|||
Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_round_trips_source_identity_for_new_provider_kinds() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
let registry = aim_core::registry::model::Registry {
|
||||
version: 1,
|
||||
apps: vec![
|
||||
aim_core::domain::app::AppRecord {
|
||||
stable_id: "example-team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("https://gitlab.com/example/team-app".to_owned()),
|
||||
source: Some(aim_core::domain::source::SourceRef {
|
||||
kind: aim_core::domain::source::SourceKind::GitLab,
|
||||
locator: "https://gitlab.com/example/team-app".to_owned(),
|
||||
input_kind: aim_core::domain::source::SourceInputKind::GitLabUrl,
|
||||
normalized_kind: aim_core::domain::source::NormalizedSourceKind::GitLab,
|
||||
canonical_locator: Some("example/team-app".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
aim_core::domain::app::AppRecord {
|
||||
stable_id: "team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some(
|
||||
"https://sourceforge.net/projects/team-app/files/latest/download".to_owned(),
|
||||
),
|
||||
source: Some(aim_core::domain::source::SourceRef {
|
||||
kind: aim_core::domain::source::SourceKind::SourceForge,
|
||||
locator: "https://sourceforge.net/projects/team-app/files/latest/download"
|
||||
.to_owned(),
|
||||
input_kind: aim_core::domain::source::SourceInputKind::SourceForgeUrl,
|
||||
normalized_kind: aim_core::domain::source::NormalizedSourceKind::SourceForge,
|
||||
canonical_locator: Some("team-app".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
aim_core::domain::app::AppRecord {
|
||||
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
|
||||
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(aim_core::domain::source::SourceRef {
|
||||
kind: aim_core::domain::source::SourceKind::DirectUrl,
|
||||
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: aim_core::domain::source::SourceInputKind::DirectUrl,
|
||||
normalized_kind: aim_core::domain::source::NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
store.save(®istry).unwrap();
|
||||
let loaded = store.load().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
loaded.apps[0].source.as_ref().unwrap().kind.as_str(),
|
||||
"gitlab"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[0]
|
||||
.source
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.canonical_locator
|
||||
.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[1].source.as_ref().unwrap().kind.as_str(),
|
||||
"sourceforge"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[1].source.as_ref().unwrap().locator,
|
||||
"https://sourceforge.net/projects/team-app/files/latest/download"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[2].source.as_ref().unwrap().kind.as_str(),
|
||||
"direct-url"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[2].source.as_ref().unwrap().locator,
|
||||
"https://example.com/downloads/team-app.AppImage"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use aim_core::app::progress::{OperationEvent, OperationStage};
|
||||
use aim_core::app::update::{build_update_plan, execute_updates, execute_updates_with_reporter};
|
||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
|
@ -138,3 +139,102 @@ fn update_execution_reports_per_app_lifecycle_events() {
|
|||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_plan_uses_direct_asset_fallback_for_direct_url_origin() {
|
||||
let apps = [AppRecord {
|
||||
stable_id: "team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::DirectUrl,
|
||||
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: SourceInputKind::DirectUrl,
|
||||
normalized_kind: NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}];
|
||||
|
||||
let plan = build_update_plan(&apps).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
plan.items[0].selected_channel.kind,
|
||||
UpdateChannelKind::DirectAsset
|
||||
);
|
||||
assert_eq!(
|
||||
plan.items[0].selected_channel.locator,
|
||||
"https://example.com/downloads/team-app.AppImage"
|
||||
);
|
||||
assert_eq!(plan.items[0].selection_reason, "install-origin-match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_execution_rebuilds_gitlab_source_without_rewriting_origin() {
|
||||
let install_home = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: "example-team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("https://gitlab.com/example/team-app".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::GitLab,
|
||||
locator: "https://gitlab.com/example/team-app".to_owned(),
|
||||
input_kind: SourceInputKind::GitLabUrl,
|
||||
normalized_kind: NormalizedSourceKind::GitLab,
|
||||
canonical_locator: Some("example/team-app".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: Some(UpdateStrategy {
|
||||
preferred: ChannelPreference {
|
||||
kind: UpdateChannelKind::DirectAsset,
|
||||
locator: "https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
|
||||
.to_owned(),
|
||||
reason: "provider-release".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
}),
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
|
||||
|
||||
assert_eq!(result.updated_count(), 1);
|
||||
assert_eq!(result.failed_count(), 0);
|
||||
assert_eq!(
|
||||
result.apps[0].source.as_ref().unwrap().kind,
|
||||
SourceKind::GitLab
|
||||
);
|
||||
assert_eq!(
|
||||
result.apps[0].source.as_ref().unwrap().locator,
|
||||
"https://gitlab.com/example/team-app"
|
||||
);
|
||||
assert_eq!(
|
||||
result.apps[0]
|
||||
.source
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.canonical_locator
|
||||
.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue