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/")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue