diff --git a/.plans/007-source-provider-expansion/2026-03-20-task-1-ambiguity-handoff-addendum.md b/.plans/007-source-provider-expansion/2026-03-20-task-1-ambiguity-handoff-addendum.md index 1e41e2e..af5b9f1 100644 --- a/.plans/007-source-provider-expansion/2026-03-20-task-1-ambiguity-handoff-addendum.md +++ b/.plans/007-source-provider-expansion/2026-03-20-task-1-ambiguity-handoff-addendum.md @@ -207,8 +207,8 @@ Current implementation status in this branch: - Phase 1 is complete. The classifier-versus-adapter boundary is now documented explicitly in this addendum. - Phase 2 is complete. `GitLabCandidate` and `SourceForgeCandidate` now exist in the source model and are produced by classification for the narrow ambiguity cases under test. - Phase 3 is complete for the first GitLab slice. `https://gitlab.com///releases/` remains a classified candidate, but the GitLab adapter now resolves it as repository semantics with a derived canonical locator. -- Phase 4 is complete for two SourceForge slices. `https://sourceforge.net/projects//files/releases/stable/download` remains a classified candidate and now resolves as a provider-owned latest-download source. `https://sourceforge.net/projects//files/releases/v*/download` is now preserved as a provider-owned candidate and surfaces as `NoInstallableArtifact`. -- Phase 5 is partially complete. Provider-owned ambiguous inputs now distinguish unsupported-query failures from no-artifact outcomes, and both GitLab and SourceForge have at least one adapter-owned positive resolution path. +- Phase 4 is complete for the current SourceForge slices. `https://sourceforge.net/projects//files/releases/stable/download` remains a classified candidate and resolves as a provider-owned install source. The broader single-segment family `https://sourceforge.net/projects//files/releases//download` is also preserved as a provider-owned candidate and resolves through installation and update. When the `` segment is clearly an artifact filename, provider resolution canonicalizes the stored SourceForge source to `https://sourceforge.net/projects//files/releases` while preserving the original typed download URL as the first-install artifact. +- Phase 5 is partially complete. Provider-owned ambiguous inputs now distinguish unsupported-query failures from no-artifact outcomes, and both GitLab and SourceForge have adapter-owned positive resolution paths for the accepted provider families. The current intended classifier contract is: @@ -221,7 +221,7 @@ That contract is intentionally stricter than heuristic best-effort classificatio What remains intentionally out of scope for this slice: - additional GitLab candidate families beyond the first repository-style deep path -- broader SourceForge folder and version-path families beyond the `releases/stable/download` and narrow `releases/v*/download` rules +- broader SourceForge folder families beyond the current single-segment `releases//download` support contract and the `files/releases` provider root - any network-backed provider discovery in classification ## Success Criteria diff --git a/crates/aim-cli/tests/end_to_end_cli.rs b/crates/aim-cli/tests/end_to_end_cli.rs index ba507c1..b3b4603 100644 --- a/crates/aim-cli/tests/end_to_end_cli.rs +++ b/crates/aim-cli/tests/end_to_end_cli.rs @@ -270,6 +270,57 @@ fn cli_add_installs_sourceforge_latest_download_with_truthful_origin() { assert!(contents.contains("canonical_locator = \"team-app\"")); } +#[test] +fn cli_add_installs_sourceforge_release_folder_with_truthful_origin() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let query = "https://sourceforge.net/projects/team-app/files/releases/beta/download"; + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg(query) + .env("AIM_REGISTRY_PATH", ®istry_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(®istry_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_add_file_like_sourceforge_release_download_stores_releases_root_and_preserves_artifact() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let query = + "https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download"; + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg(query) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("Installed team-app (user)")) + .stdout(contains( + "Source: sourceforge https://sourceforge.net/projects/team-app/files/releases", + )) + .stdout(contains(format!("Artifact: {query}"))); + + let contents = std::fs::read_to_string(®istry_path).unwrap(); + assert!(contents.contains(&format!("source_input = \"{query}\""))); + assert!(contents.contains("kind = \"SourceForge\"")); + assert!( + contents.contains("locator = \"https://sourceforge.net/projects/team-app/files/releases\"") + ); + assert!(contents.contains("requested_asset_name = \"team-app-1.0.0.AppImage\"")); +} + #[test] fn cli_reports_unsupported_source_queries_distinctly() { let dir = tempdir().unwrap(); diff --git a/crates/aim-core/src/adapters/sourceforge.rs b/crates/aim-core/src/adapters/sourceforge.rs index 2e471e3..e9c8382 100644 --- a/crates/aim-core/src/adapters/sourceforge.rs +++ b/crates/aim-core/src/adapters/sourceforge.rs @@ -8,11 +8,23 @@ pub struct SourceForgeAdapter; impl SourceForgeAdapter { pub fn artifact_url(source: &SourceRef) -> Option { - if is_resolved_download_locator(&source.locator) { - Some(source.locator.clone()) - } else { - None + if let Some(asset_name) = source.requested_asset_name.as_deref() + && is_sourceforge_releases_root_locator(&source.locator) + { + return Some(format!("{}/{asset_name}/download", source.locator)); } + + if is_latest_download_locator(&source.locator) + || is_sourceforge_release_folder_download_locator(&source.locator) + { + return Some(source.locator.clone()); + } + + if is_sourceforge_releases_root_locator(&source.locator) { + return sourceforge_latest_download_url(&source.locator); + } + + None } } @@ -85,7 +97,14 @@ impl SourceAdapter for SourceForgeAdapter { fn resolved_source(source: &SourceRef) -> SourceRef { let mut resolved = source.clone(); - if is_sourceforge_stable_download_locator(&resolved.locator) { + if is_sourceforge_file_like_release_download_locator(&resolved.locator) { + resolved.locator = sourceforge_releases_root_url(&resolved.locator) + .unwrap_or_else(|| resolved.locator.clone()); + resolved.normalized_kind = NormalizedSourceKind::SourceForge; + resolved.tracks_latest = true; + } else if is_sourceforge_release_folder_download_locator(&resolved.locator) + || is_sourceforge_releases_root_locator(&resolved.locator) + { resolved.normalized_kind = NormalizedSourceKind::SourceForge; resolved.tracks_latest = true; } @@ -94,7 +113,9 @@ fn resolved_source(source: &SourceRef) -> SourceRef { } fn is_resolved_download_locator(locator: &str) -> bool { - is_latest_download_locator(locator) || is_sourceforge_stable_download_locator(locator) + is_latest_download_locator(locator) + || is_sourceforge_release_folder_download_locator(locator) + || is_sourceforge_releases_root_locator(locator) } fn is_latest_download_locator(locator: &str) -> bool { @@ -106,11 +127,140 @@ fn is_latest_download_locator(locator: &str) -> bool { trimmed.ends_with("/files/latest/download") } -fn is_sourceforge_stable_download_locator(locator: &str) -> bool { +fn is_sourceforge_release_folder_download_locator(locator: &str) -> bool { let trimmed = locator .split(['?', '#']) .next() .unwrap_or(locator) .trim_end_matches('/'); - trimmed.ends_with("/files/releases/stable/download") + + let parts = trimmed + .trim_start_matches("https://sourceforge.net/projects/") + .trim_start_matches("http://sourceforge.net/projects/") + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + parts.len() == 5 && parts[1] == "files" && parts[2] == "releases" && parts[4] == "download" +} + +fn is_sourceforge_file_like_release_download_locator(locator: &str) -> bool { + let trimmed = locator + .split(['?', '#']) + .next() + .unwrap_or(locator) + .trim_end_matches('/'); + + let parts = trimmed + .trim_start_matches("https://sourceforge.net/projects/") + .trim_start_matches("http://sourceforge.net/projects/") + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + parts.len() == 5 + && parts[1] == "files" + && parts[2] == "releases" + && is_sourceforge_artifact_name(parts[3]) + && parts[4] == "download" +} + +fn is_sourceforge_artifact_name(segment: &str) -> bool { + let lower = segment.to_ascii_lowercase(); + + [ + ".appimage", + ".tar.gz", + ".tar.xz", + ".tar.bz2", + ".zip", + ".deb", + ".rpm", + ".exe", + ".msi", + ".dmg", + ".pkg", + ".apk", + ".tgz", + ".whl", + ".jar", + ".nupkg", + ] + .iter() + .any(|suffix| lower.ends_with(suffix)) +} + +fn is_sourceforge_releases_root_locator(locator: &str) -> bool { + let trimmed = locator + .split(['?', '#']) + .next() + .unwrap_or(locator) + .trim_end_matches('/'); + + let parts = trimmed + .trim_start_matches("https://sourceforge.net/projects/") + .trim_start_matches("http://sourceforge.net/projects/") + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + parts.len() == 3 && parts[1] == "files" && parts[2] == "releases" +} + +fn sourceforge_releases_root_url(locator: &str) -> Option { + let trimmed = locator + .split(['?', '#']) + .next() + .unwrap_or(locator) + .trim_end_matches('/'); + + let prefix = if trimmed.starts_with("https://sourceforge.net/projects/") { + "https://sourceforge.net/projects/" + } else if trimmed.starts_with("http://sourceforge.net/projects/") { + "http://sourceforge.net/projects/" + } else { + return None; + }; + + let path = trimmed + .trim_start_matches("https://sourceforge.net/projects/") + .trim_start_matches("http://sourceforge.net/projects/") + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + if path.is_empty() { + return None; + } + + Some(format!("{}{}/files/releases", prefix, path[0])) +} + +fn sourceforge_latest_download_url(locator: &str) -> Option { + let trimmed = locator + .split(['?', '#']) + .next() + .unwrap_or(locator) + .trim_end_matches('/'); + + let prefix = if trimmed.starts_with("https://sourceforge.net/projects/") { + "https://sourceforge.net/projects/" + } else if trimmed.starts_with("http://sourceforge.net/projects/") { + "http://sourceforge.net/projects/" + } else { + return None; + }; + + let path = trimmed + .trim_start_matches("https://sourceforge.net/projects/") + .trim_start_matches("http://sourceforge.net/projects/") + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + if path.is_empty() { + return None; + } + + Some(format!("{}{}/files/latest/download", prefix, path[0])) } diff --git a/crates/aim-core/src/app/add.rs b/crates/aim-core/src/app/add.rs index 003a138..54fb062 100644 --- a/crates/aim-core/src/app/add.rs +++ b/crates/aim-core/src/app/add.rs @@ -215,7 +215,7 @@ pub fn build_add_plan_with_reporter( let strategy = UpdateStrategy { preferred: crate::domain::update::ChannelPreference { kind: crate::domain::update::UpdateChannelKind::DirectAsset, - locator: artifact_url, + locator: resolution.source.locator.clone(), reason: "provider-release".to_owned(), }, alternates: Vec::new(), diff --git a/crates/aim-core/src/app/update.rs b/crates/aim-core/src/app/update.rs index f8687b1..3b7c8fc 100644 --- a/crates/aim-core/src/app/update.rs +++ b/crates/aim-core/src/app/update.rs @@ -203,6 +203,12 @@ fn execute_update( } fn update_query(app: &AppRecord) -> Option { + if let Some(source) = app.source.as_ref() + && source.kind == SourceKind::SourceForge + { + return Some(source.locator.clone()); + } + app.source_input.clone().or_else(|| { app.source.as_ref().map(|source| { source diff --git a/crates/aim-core/src/source/input.rs b/crates/aim-core/src/source/input.rs index 8c7b81a..fd19682 100644 --- a/crates/aim-core/src/source/input.rs +++ b/crates/aim-core/src/source/input.rs @@ -171,19 +171,21 @@ fn classify_sourceforge_http(query: &str) -> Option 4 + let is_nested_file_download_url = parts.len() > 5 && 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 requested_asset_name = sourceforge_requested_asset_name(&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 { @@ -198,7 +200,11 @@ fn classify_sourceforge_http(query: &str) -> Option Option bool { } 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" + parts.len() == 5 && parts[1] == "files" && parts[2] == "releases" && 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 sourceforge_requested_asset_name(parts: &[&str]) -> Option { + if parts.len() == 5 + && parts[1] == "files" + && parts[2] == "releases" + && parts[4] == "download" + && is_sourceforge_artifact_name(parts[3]) + { + return Some(parts[3].to_owned()); + } + + None +} + +fn is_sourceforge_artifact_name(segment: &str) -> bool { + let lower = segment.to_ascii_lowercase(); + + [ + ".appimage", + ".tar.gz", + ".tar.xz", + ".tar.bz2", + ".zip", + ".deb", + ".rpm", + ".exe", + ".msi", + ".dmg", + ".pkg", + ".apk", + ".tgz", + ".whl", + ".jar", + ".nupkg", + ] + .iter() + .any(|suffix| lower.ends_with(suffix)) } fn classify_github_http(query: &str) -> Option { diff --git a/crates/aim-core/tests/adapter_contract.rs b/crates/aim-core/tests/adapter_contract.rs index 0b1ccca..41eb908 100644 --- a/crates/aim-core/tests/adapter_contract.rs +++ b/crates/aim-core/tests/adapter_contract.rs @@ -233,7 +233,7 @@ fn sourceforge_candidate_sources_can_resolve_to_latest_download() { } #[test] -fn sourceforge_version_folder_candidates_can_return_no_installable_artifact() { +fn sourceforge_version_folder_candidates_can_resolve_to_latest_download() { let adapter: &dyn SourceAdapter = &SourceForgeAdapter; let result = adapter @@ -247,10 +247,112 @@ fn sourceforge_version_folder_candidates_can_return_no_installable_artifact() { ); let resolution = adapter.resolve_source(&result).unwrap(); - assert_eq!( + assert!(matches!( resolution, - AdapterResolveOutcome::NoInstallableArtifact { source: result } + AdapterResolveOutcome::Resolved(AdapterResolution { + source, + release: ResolvedRelease { version, .. }, + }) if source.kind == SourceKind::SourceForge + && source.locator + == "https://sourceforge.net/projects/team-app/files/releases/v1-0/download" + && source.normalized_kind == NormalizedSourceKind::SourceForge + && source.tracks_latest + && version == "latest" + )); +} + +#[test] +fn sourceforge_prerelease_folder_candidates_can_resolve_to_latest_download() { + let adapter: &dyn SourceAdapter = &SourceForgeAdapter; + + let result = adapter + .normalize("https://sourceforge.net/projects/team-app/files/releases/beta/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/beta/download" + && source.normalized_kind == NormalizedSourceKind::SourceForge + && source.tracks_latest + && version == "latest" + )); +} + +#[test] +fn sourceforge_dotted_release_folder_candidates_can_resolve_to_latest_download() { + let adapter: &dyn SourceAdapter = &SourceForgeAdapter; + + let result = adapter + .normalize("https://sourceforge.net/projects/team-app/files/releases/2026.03/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/2026.03/download" + && source.normalized_kind == NormalizedSourceKind::SourceForge + && source.tracks_latest + && version == "latest" + )); +} + +#[test] +fn sourceforge_file_like_release_candidates_resolve_to_releases_root() { + let adapter: &dyn SourceAdapter = &SourceForgeAdapter; + + let result = adapter + .normalize( + "https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download", + ) + .unwrap(); + + assert_eq!(result.kind, SourceKind::SourceForge); + assert_eq!( + result.normalized_kind, + NormalizedSourceKind::SourceForgeCandidate + ); + assert_eq!( + result.requested_asset_name.as_deref(), + Some("team-app-1.0.0.AppImage") + ); + + 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" + && source.normalized_kind == NormalizedSourceKind::SourceForge + && source.tracks_latest + && source.requested_asset_name.as_deref() == Some("team-app-1.0.0.AppImage") + && version == "latest" + )); } #[test] diff --git a/crates/aim-core/tests/install_failures.rs b/crates/aim-core/tests/install_failures.rs index 1495a7f..9a852c4 100644 --- a/crates/aim-core/tests/install_failures.rs +++ b/crates/aim-core/tests/install_failures.rs @@ -67,26 +67,3 @@ fn supported_sourceforge_project_without_latest_download_reports_no_installable_ 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:?}"), - } -} diff --git a/crates/aim-core/tests/install_integration.rs b/crates/aim-core/tests/install_integration.rs index 3d79d54..fb20394 100644 --- a/crates/aim-core/tests/install_integration.rs +++ b/crates/aim-core/tests/install_integration.rs @@ -387,6 +387,28 @@ fn sourceforge_candidate_builds_concrete_install_candidate() { })); } +#[test] +fn sourceforge_release_folder_builds_concrete_install_candidate() { + let mut reporter = |_event: &OperationEvent| {}; + let query = "https://sourceforge.net/projects/team-app/files/releases/beta/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.source.normalized_kind, + NormalizedSourceKind::SourceForge + ); + assert!(plan.resolution.source.tracks_latest); + 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"); +} + #[test] fn sourceforge_latest_download_builds_concrete_install_candidate() { let mut reporter = |_event: &OperationEvent| {}; @@ -431,3 +453,104 @@ fn sourceforge_latest_download_install_preserves_truthful_origin() { ); assert_eq!(installed.selected_artifact.url, query); } + +#[test] +fn sourceforge_release_folder_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/releases/beta/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.source.normalized_kind, + NormalizedSourceKind::SourceForge + ); + assert_eq!(installed.selected_artifact.url, query); +} + +#[test] +fn sourceforge_file_like_release_download_uses_releases_root_for_source_and_original_url_for_artifact() + { + let mut reporter = |_event: &OperationEvent| {}; + let query = + "https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/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, + "https://sourceforge.net/projects/team-app/files/releases" + ); + assert_eq!( + plan.resolution.source.requested_asset_name.as_deref(), + Some("team-app-1.0.0.AppImage") + ); + 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, + "https://sourceforge.net/projects/team-app/files/releases" + ); + assert_eq!(plan.update_strategy.preferred.reason, "provider-release"); +} + +#[test] +fn sourceforge_file_like_release_download_install_preserves_input_but_stores_releases_root() { + 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/releases/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("latest") + ); + assert_eq!(installed.source.kind, SourceKind::SourceForge); + assert_eq!( + installed.source.locator, + "https://sourceforge.net/projects/team-app/files/releases" + ); + assert_eq!( + installed.source.requested_asset_name.as_deref(), + Some("team-app-1.0.0.AppImage") + ); + assert_eq!( + installed.source.canonical_locator.as_deref(), + Some("team-app") + ); + assert_eq!(installed.selected_artifact.url, query); +} diff --git a/crates/aim-core/tests/query_resolution.rs b/crates/aim-core/tests/query_resolution.rs index cbef5c7..d92c476 100644 --- a/crates/aim-core/tests/query_resolution.rs +++ b/crates/aim-core/tests/query_resolution.rs @@ -128,15 +128,39 @@ fn preserves_direct_url_classification() { } #[test] -fn preserves_sourceforge_download_url_as_direct_url() { +fn classifies_single_segment_sourceforge_release_download_as_candidate() { 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); + 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_eq!( + source.requested_asset_name.as_deref(), + Some("team-app-1.0.0.AppImage") + ); + assert!(!source.tracks_latest); +} + +#[test] +fn classifies_sourceforge_releases_root_as_provider_source() { + let source = resolve_query("https://sourceforge.net/projects/team-app/files/releases").unwrap(); + + assert_eq!(source.kind, SourceKind::SourceForge); + assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge); + assert_eq!( + source.locator, + "https://sourceforge.net/projects/team-app/files/releases" + ); + assert_eq!(source.canonical_locator.as_deref(), Some("team-app")); + assert!(source.tracks_latest); } #[test] @@ -162,15 +186,24 @@ fn preserves_sourceforge_extensionless_root_download_url_as_direct_url() { } #[test] -fn preserves_sourceforge_download_url_with_query_as_direct_url() { +fn classifies_single_segment_sourceforge_release_download_with_query_as_candidate() { 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); + 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_eq!( + source.requested_asset_name.as_deref(), + Some("team-app-1.0.0.AppImage") + ); + assert!(!source.tracks_latest); } #[test] @@ -253,11 +286,18 @@ fn rejects_unsupported_sourceforge_url_shape() { } #[test] -fn rejects_unsupported_sourceforge_files_shape() { - let error = - resolve_query("https://sourceforge.net/projects/team-app/files/releases").unwrap_err(); +fn classifies_sourceforge_files_releases_shape_as_provider_source() { + let source = resolve_query("https://sourceforge.net/projects/team-app/files/releases").unwrap(); - assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); + assert_eq!(source.kind, SourceKind::SourceForge); + assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl); + assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge); + assert_eq!( + source.locator, + "https://sourceforge.net/projects/team-app/files/releases" + ); + assert_eq!(source.canonical_locator.as_deref(), Some("team-app")); + assert!(source.tracks_latest); } #[test] @@ -285,12 +325,19 @@ fn classifies_ambiguous_sourceforge_nested_folder_download_as_candidate() { } #[test] -fn rejects_unsupported_sourceforge_nested_extensionless_download_shape() { - let error = +fn classifies_extensionless_sourceforge_release_folder_download_as_candidate() { + let source = resolve_query("https://sourceforge.net/projects/team-app/files/releases/team-app/download") - .unwrap_err(); + .unwrap(); - assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported); + 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] @@ -308,3 +355,35 @@ fn classifies_ambiguous_sourceforge_version_folder_download_as_candidate() { assert_eq!(source.canonical_locator.as_deref(), Some("team-app")); assert!(!source.tracks_latest); } + +#[test] +fn classifies_prerelease_named_sourceforge_release_folder_download_as_candidate() { + let source = + resolve_query("https://sourceforge.net/projects/team-app/files/releases/beta/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 classifies_dotted_sourceforge_release_folder_download_as_candidate() { + let source = + resolve_query("https://sourceforge.net/projects/team-app/files/releases/2026.03/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); +} diff --git a/crates/aim-core/tests/update_planning.rs b/crates/aim-core/tests/update_planning.rs index 54c009c..e69d57c 100644 --- a/crates/aim-core/tests/update_planning.rs +++ b/crates/aim-core/tests/update_planning.rs @@ -238,3 +238,132 @@ fn update_execution_rebuilds_gitlab_source_without_rewriting_origin() { Some("example/team-app") ); } + +#[test] +fn update_execution_rebuilds_sourceforge_release_folder_without_rewriting_origin() { + let install_home = tempdir().unwrap(); + + unsafe { + std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); + } + + let previous = AppRecord { + stable_id: "team-app".to_owned(), + display_name: "team-app".to_owned(), + source_input: Some( + "https://sourceforge.net/projects/team-app/files/releases/beta/download".to_owned(), + ), + source: Some(SourceRef { + kind: SourceKind::SourceForge, + locator: "https://sourceforge.net/projects/team-app/files/releases/beta/download" + .to_owned(), + input_kind: SourceInputKind::SourceForgeUrl, + normalized_kind: 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: Some(UpdateStrategy { + preferred: ChannelPreference { + kind: UpdateChannelKind::DirectAsset, + locator: "https://sourceforge.net/projects/team-app/files/releases/beta/download" + .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::SourceForge + ); + assert_eq!( + result.apps[0].source.as_ref().unwrap().locator, + "https://sourceforge.net/projects/team-app/files/releases/beta/download" + ); + assert_eq!( + result.apps[0] + .source + .as_ref() + .unwrap() + .canonical_locator + .as_deref(), + Some("team-app") + ); +} + +#[test] +fn update_execution_uses_stored_sourceforge_releases_root_for_file_like_inputs() { + let install_home = tempdir().unwrap(); + + unsafe { + std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1"); + } + + let previous = AppRecord { + stable_id: "team-app".to_owned(), + display_name: "team-app".to_owned(), + source_input: Some( + "https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download" + .to_owned(), + ), + source: Some(SourceRef { + kind: SourceKind::SourceForge, + locator: "https://sourceforge.net/projects/team-app/files/releases".to_owned(), + input_kind: SourceInputKind::SourceForgeUrl, + normalized_kind: NormalizedSourceKind::SourceForge, + canonical_locator: Some("team-app".to_owned()), + requested_tag: None, + requested_asset_name: Some("team-app-1.0.0.AppImage".to_owned()), + tracks_latest: true, + }), + installed_version: Some("latest".to_owned()), + update_strategy: Some(UpdateStrategy { + preferred: ChannelPreference { + kind: UpdateChannelKind::DirectAsset, + locator: "https://sourceforge.net/projects/team-app/files/releases".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().locator, + "https://sourceforge.net/projects/team-app/files/releases" + ); + assert_eq!( + result.apps[0] + .source + .as_ref() + .unwrap() + .requested_asset_name + .as_deref(), + None + ); +}