feat: expand source provider resolution

This commit is contained in:
stoorps 2026-03-21 00:43:02 +00:00
parent 9d8ec1e4fd
commit eaa9a3b52d
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
23 changed files with 2582 additions and 34 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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())
}
}

View file

@ -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)
}

View file

@ -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")
}

View file

@ -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)
}
}

View file

@ -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,
}

View file

@ -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,

View file

@ -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",
}

View 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/")