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

@ -155,6 +155,47 @@ pub enum DispatchError {
UpdateExecution(aim_core::app::update::ExecuteUpdatesError),
}
impl std::fmt::Display for DispatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AddPlan(error) => match error {
aim_core::app::add::BuildAddPlanError::Query(
aim_core::app::query::ResolveQueryError::Unsupported,
) => write!(f, "unsupported source query"),
aim_core::app::add::BuildAddPlanError::NoInstallableArtifact { source } => write!(
f,
"no installable artifact found for {} {}",
source.kind.as_str(),
source.locator
),
aim_core::app::add::BuildAddPlanError::Adapter(id, error) => match error {
aim_core::adapters::traits::AdapterError::UnsupportedQuery => {
write!(f, "{id} does not support this query")
}
aim_core::adapters::traits::AdapterError::UnsupportedSource => {
write!(f, "{id} does not support this source")
}
aim_core::adapters::traits::AdapterError::ResolutionFailed(reason) => {
write!(f, "{id} resolution failed: {reason}")
}
},
aim_core::app::add::BuildAddPlanError::GitHubDiscovery(error) => {
write!(f, "github discovery failed: {error:?}")
}
aim_core::app::add::BuildAddPlanError::NoCandidates => {
write!(f, "no installable candidates found")
}
},
Self::AddInstall(error) => write!(f, "install failed: {error:?}"),
Self::Prompt(error) => write!(f, "prompt failed: {error:?}"),
Self::RemovePlan(error) => write!(f, "remove failed: {error:?}"),
Self::Registry(error) => write!(f, "registry failed: {error:?}"),
Self::UpdatePlan(error) => write!(f, "update planning failed: {error:?}"),
Self::UpdateExecution(error) => write!(f, "update execution failed: {error:?}"),
}
}
}
impl From<aim_core::app::add::BuildAddPlanError> for DispatchError {
fn from(value: aim_core::app::add::BuildAddPlanError) -> Self {
Self::AddPlan(value)

View file

@ -23,7 +23,7 @@ fn main() {
}
}
Err(error) => {
eprintln!("{error:?}");
eprintln!("{error}");
std::process::exit(1);
}
}

View file

@ -200,6 +200,103 @@ fn cli_add_installs_and_renders_resolved_mode() {
.stdout(contains("Completed steps").not());
}
#[test]
fn cli_add_installs_gitlab_source_with_truthful_origin() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("https://gitlab.com/example/team-app")
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Installed team-app (user)"))
.stdout(contains("Source: gitlab https://gitlab.com/example/team-app"))
.stdout(contains(
"Artifact: https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage",
));
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(contents.contains("source_input = \"https://gitlab.com/example/team-app\""));
assert!(contents.contains("kind = \"GitLab\""));
assert!(contents.contains("locator = \"https://gitlab.com/example/team-app\""));
assert!(contents.contains("canonical_locator = \"example/team-app\""));
}
#[test]
fn cli_add_preserves_direct_url_origin_for_provider_like_downloads() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let query = "https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download";
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg(query)
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Installed "))
.stdout(contains(format!("Source: direct-url {query}")))
.stdout(contains(format!("Artifact: {query}")));
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(contents.contains(&format!("source_input = \"{query}\"")));
assert!(contents.contains("kind = \"DirectUrl\""));
assert!(contents.contains(&format!("locator = \"{query}\"")));
assert!(!contents.contains("kind = \"SourceForge\""));
}
#[test]
fn cli_add_installs_sourceforge_latest_download_with_truthful_origin() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let query = "https://sourceforge.net/projects/team-app/files/latest/download";
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg(query)
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Installed team-app (user)"))
.stdout(contains(format!("Source: sourceforge {query}")))
.stdout(contains(format!("Artifact: {query}")));
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(contents.contains(&format!("source_input = \"{query}\"")));
assert!(contents.contains("kind = \"SourceForge\""));
assert!(contents.contains(&format!("locator = \"{query}\"")));
assert!(contents.contains("canonical_locator = \"team-app\""));
}
#[test]
fn cli_reports_unsupported_source_queries_distinctly() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("https://gitlab.com/example")
.env("AIM_REGISTRY_PATH", &registry_path)
.assert()
.failure()
.stderr(contains("unsupported source query"));
}
#[test]
fn cli_reports_supported_sources_without_installable_artifacts_distinctly() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("https://sourceforge.net/projects/team-app/")
.env("AIM_REGISTRY_PATH", &registry_path)
.assert()
.failure()
.stderr(contains("no installable artifact found"))
.stderr(contains("sourceforge"));
}
#[test]
fn cli_add_emits_live_progress_to_stderr() {
let dir = tempdir().unwrap();

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(&registry).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"
);
}

View file

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