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

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