diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index 42b5313..98d4fe3 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -59,7 +59,8 @@ pub const fn general() -> &'static str { --no-color 关闭颜色(v1 输出不含 ANSI,参数保留以兼容脚本) --quiet 静默人类可读输出(错误与提示仍进 stderr) --help, -h 帮助(有位置参数时按该命令给帮助) - --version, -V 版本 + 系统摘要 + --version, -V 版本 + 系统摘要(只在子命令**之前**生效:`ull --version`; + 子命令里的 `--version` 是它自己的旗标,如 `java install --version V`) 退出码: 0 成功 / 1 用户错误 / 2 内部错误 / 3 部分成功(仅 pack import)" } diff --git a/crates/core/src/game/java/api/version.rs b/crates/core/src/game/java/api/version.rs index dad9ecd..6ed0f9d 100644 --- a/crates/core/src/game/java/api/version.rs +++ b/crates/core/src/game/java/api/version.rs @@ -13,13 +13,21 @@ //! (openapi 里 `page_size` 的 `maximum` 就是 20)把前者翻成后者。同一份响应的 release 名 //! 列表同时是「这个大版本有哪些版本」的唯一来源——版本不存在时就靠它列给人看。 //! +//! **必须翻页**(M280 r1):`page_size` 上限 20 是上游硬约束,而 8 / 11 / 17 线各有 26 条 GA +//! (2026-11-12 实测:`17/ga` page=0 回 20 条、page=1 回 6 条)。只读第一页会把 +//! `17.0.4+8` 这种「上游有、第一页没有」的版本报成「没有这个版本」——文案还会把窗口当总数。 +//! 所以这里按 `page` 翻到「本页不满一页」为止,上限 [`MAX_PAGES`] 页;真到上限就把清单标成 +//! [`VersionListScope::Truncated`],错误文案里如实写「只查了前 N 条」。 +//! //! 这里**只有取**:匹配用 [`matches_version`],匹配不上时把可用清单交给 //! [`version_pin_error`] 生成错误。**不换家、不回落 latest**(那是 `install` / `source` 的事)。 use serde::Deserialize; use super::{ApiBinary, ApiVersion, ADOPTIUM_API_BASE}; -use crate::game::java::catalog::{canonical_version, matches_version, version_pin_error}; +use crate::game::java::catalog::{ + canonical_version, matches_version, version_pin_error, VersionListScope, +}; use crate::game::java::error::{JavaError, JavaErrorKind}; use crate::game::java::net; use crate::game::java::release::JdkRelease; @@ -29,11 +37,14 @@ use crate::game::java::vendor::JdkVendor; /// `feature_releases` 的每页条数(Adoptium 允许的上限)。 pub const PAGE_SIZE: usize = 20; -/// 某大版本的 GA 清单地址(新 → 旧)。 +/// 最多翻几页(正常最多 2 页;5 页 = 100 条,纯防御,别让上游的异常响应把内存吃掉)。 +pub const MAX_PAGES: usize = 5; + +/// 某大版本第 `page` 页的 GA 清单地址(新 → 旧,`page` 从 0 起)。 #[must_use] -pub fn releases_url(major: u32, target: AdoptiumTarget) -> String { +pub fn releases_url(major: u32, target: AdoptiumTarget, page: usize) -> String { format!( - "{ADOPTIUM_API_BASE}/assets/feature_releases/{major}/ga?os={}&arch={}&image_type=jdk&page=0&page_size={PAGE_SIZE}&sort_order=DESC&vendor=eclipse", + "{ADOPTIUM_API_BASE}/assets/feature_releases/{major}/ga?os={}&arch={}&image_type=jdk&page={page}&page_size={PAGE_SIZE}&sort_order=DESC&vendor=eclipse", target.os, target.arch ) } @@ -114,6 +125,40 @@ pub fn parse_release( binary.into_release(name, release.version_data) } +/// 翻页把该大版本的 GA release 名全部取回来(按页拼,直到「本页不满一页」或到 [`MAX_PAGES`])。 +/// +/// `fetch_page(page)` 由调用方给:真跑时是一发 HTTP,单测里是夹具闭包——所以「翻几页 / 什么时候 +/// 停 / 到上限了没有」这段判断**完全离线可测**。 +/// +/// 返回值第二项 = 是否**因为到上限**而停(还有没看到的页)。错误文案要用它决定说 +/// 「上游就这些」还是「只查了前 N 条」。 +/// +/// # Errors +/// +/// 任何一页取不到(网络 / 解析失败)时直接把该错误抛出去。 +pub async fn collect_releases(mut fetch_page: F) -> Result<(Vec, bool), JavaError> +where + F: FnMut(usize) -> Fut, + Fut: std::future::Future, JavaError>>, +{ + let mut all: Vec = Vec::new(); + for page in 0..MAX_PAGES { + let names = fetch_page(page).await?; + let short_page = names.len() < PAGE_SIZE; + all.extend(names); + if short_page { + return Ok((all, false)); + } + } + Ok((all, true)) +} + +/// 在枚举出来的 release 名里挑用户要的那一版(**不取最新、不取第一条**)。 +#[must_use] +pub fn pick_release<'a>(releases: &'a [String], wanted: &str) -> Option<&'a String> { + releases.iter().find(|name| matches_version(wanted, name)) +} + /// 查一次「某大版本的指定版本」。 /// /// # Errors @@ -125,22 +170,30 @@ pub async fn fetch_version( target: AdoptiumTarget, wanted: &str, ) -> Result { - let list = net::get_text(client, &releases_url(major, target), "Adoptium GA 清单").await?; - let releases = parse_release_list(&list)?; - let picked = releases - .iter() - .find(|name| matches_version(wanted, name)) - .cloned(); - let Some(name) = picked else { + let (releases, truncated) = collect_releases(|page| { + let url = releases_url(major, target, page); + async move { + let body = net::get_text(client, &url, "Adoptium GA 清单").await?; + parse_release_list(&body) + } + }) + .await?; + let Some(name) = pick_release(&releases, wanted).cloned() else { let versions = releases .iter() .map(|name| canonical_version(name).to_string()) .collect::>(); + let scope = if truncated { + VersionListScope::Truncated + } else { + VersionListScope::Complete + }; return Err(version_pin_error( JdkVendor::Temurin, major, wanted, &versions, + scope, )); }; let body = net::get_text(client, &release_url(&name, target), "Adoptium 指定版本产物").await?; @@ -158,88 +211,4 @@ pub async fn fetch_version( } #[cfg(test)] -mod tests { - use super::{parse_release, parse_release_list, release_url, releases_url}; - use crate::game::java::error::JavaErrorKind; - use crate::game::java::target::AdoptiumTarget; - - const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64"); - - /// 真机 `feature_releases/21/ga` 响应的裁剪(2026-11-12,只留两条与用到的字段)。 - fn list_body() -> String { - r#"[ - {"release_name":"jdk-21.0.13+9","version_data":{"major":21,"semver":"21.0.13+9.0.LTS"},"binaries":[]}, - {"release_name":"jdk-21.0.12+8","version_data":{"major":21,"semver":"21.0.12+8.0.LTS"},"binaries":[]} - ]"# - .to_string() - } - - /// 真机 `assets/release_name/eclipse/jdk-21.0.13+9` 响应的裁剪。 - fn release_body() -> String { - r#"{ - "release_name": "jdk-21.0.13+9", - "version_data": {"major": 21, "semver": "21.0.13+9.0.LTS", "build": 9}, - "binaries": [ - { - "os": "windows", "architecture": "x64", "image_type": "jdk", "jvm_impl": "hotspot", - "package": { - "name": "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip", - "link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip", - "checksum": "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9", - "size": 205421281 - } - } - ] - }"# - .to_string() - } - - #[test] - fn urls_target_the_versioned_endpoints_never_latest() { - let releases = releases_url(21, WINDOWS); - assert!(releases.starts_with("https://api.adoptium.net/v3/assets/feature_releases/21/ga?")); - assert!(releases.contains("page_size=20")); - assert!(releases.contains("sort_order=DESC")); - assert!(!releases.contains("latest")); - let release = release_url("jdk-21.0.13+9", WINDOWS); - assert!(release - .starts_with("https://api.adoptium.net/v3/assets/release_name/eclipse/jdk-21.0.13+9?")); - assert!(release.contains("os=windows") && release.contains("arch=x64")); - assert!(!release.contains("latest")); - } - - #[test] - fn release_list_keeps_the_upstream_order_and_names() { - assert_eq!( - parse_release_list(&list_body()).expect("应解析"), - vec!["jdk-21.0.13+9".to_string(), "jdk-21.0.12+8".to_string()] - ); - let error = parse_release_list("502").expect_err("HTML 应被拒"); - assert_eq!(error.kind(), JavaErrorKind::Metadata); - } - - #[test] - fn release_body_becomes_the_pinned_archive() { - let release = parse_release(&release_body(), "jdk-21.0.13+9", WINDOWS).expect("应解析"); - assert_eq!(release.version, "21.0.13+9", "版本串取自 release 名"); - assert_eq!(release.semver.as_deref(), Some("21.0.13+9.0.LTS")); - assert_eq!(release.major, 21); - assert_eq!( - release.archive.name, - "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip" - ); - assert_eq!(release.archive.size, 205_421_281); - assert!(release.archive.url.contains("temurin21-binaries")); - } - - #[test] - fn other_platform_has_no_pinned_archive() { - let error = parse_release( - &release_body(), - "jdk-21.0.13+9", - AdoptiumTarget::new("windows", "x86-32"), - ) - .expect_err("x86-32 应没有条目"); - assert_eq!(error.kind(), JavaErrorKind::Metadata); - } -} +mod tests; diff --git a/crates/core/src/game/java/api/version/tests.rs b/crates/core/src/game/java/api/version/tests.rs new file mode 100644 index 0000000..e2bea9a --- /dev/null +++ b/crates/core/src/game/java/api/version/tests.rs @@ -0,0 +1,207 @@ +//! `api::version` 的单测:端点形状、两页夹具下的翻页、按版本挑而不是挑最新。 +//! +//! 从 `version.rs` 拆出来只为守 400 行硬线(M280 r1 加了 P2-1/P2-2 的守卫后那边到 417 行); +//! **纯搬移**,既有断言一字未改。 + +use super::{ + collect_releases, parse_release, parse_release_list, pick_release, release_url, releases_url, + MAX_PAGES, PAGE_SIZE, +}; +use crate::game::java::error::{JavaError, JavaErrorKind}; +use crate::game::java::target::AdoptiumTarget; + +const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64"); + +/// 真机 `feature_releases/21/ga` 响应的裁剪(2026-11-12,只留两条与用到的字段)。 +fn list_body() -> String { + r#"[ + {"release_name":"jdk-21.0.13+9","version_data":{"major":21,"semver":"21.0.13+9.0.LTS"},"binaries":[]}, + {"release_name":"jdk-21.0.12+8","version_data":{"major":21,"semver":"21.0.12+8.0.LTS"},"binaries":[]} + ]"# + .to_string() +} + +/// 真机 `assets/release_name/eclipse/jdk-21.0.13+9` 响应的裁剪。 +fn release_body() -> String { + r#"{ + "release_name": "jdk-21.0.13+9", + "version_data": {"major": 21, "semver": "21.0.13+9.0.LTS", "build": 9}, + "binaries": [ + { + "os": "windows", "architecture": "x64", "image_type": "jdk", "jvm_impl": "hotspot", + "package": { + "name": "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip", + "link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip", + "checksum": "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9", + "size": 205421281 + } + } + ] + }"# + .to_string() +} + +#[test] +fn urls_target_the_versioned_endpoints_never_latest() { + let releases = releases_url(21, WINDOWS, 0); + assert!(releases.starts_with("https://api.adoptium.net/v3/assets/feature_releases/21/ga?")); + assert!(releases.contains("page_size=20")); + assert!(releases.contains("page=0")); + // 页码真的进 URL(翻页不是装饰)。 + assert!(releases_url(21, WINDOWS, 1).contains("page=1")); + assert!(releases.contains("sort_order=DESC")); + assert!(!releases.contains("latest")); + let release = release_url("jdk-21.0.13+9", WINDOWS); + assert!(release + .starts_with("https://api.adoptium.net/v3/assets/release_name/eclipse/jdk-21.0.13+9?")); + assert!(release.contains("os=windows") && release.contains("arch=x64")); + assert!(!release.contains("latest")); +} + +#[test] +fn release_list_keeps_the_upstream_order_and_names() { + assert_eq!( + parse_release_list(&list_body()).expect("应解析"), + vec!["jdk-21.0.13+9".to_string(), "jdk-21.0.12+8".to_string()] + ); + let error = parse_release_list("502").expect_err("HTML 应被拒"); + assert_eq!(error.kind(), JavaErrorKind::Metadata); +} + +#[test] +fn release_body_becomes_the_pinned_archive() { + let release = parse_release(&release_body(), "jdk-21.0.13+9", WINDOWS).expect("应解析"); + assert_eq!(release.version, "21.0.13+9", "版本串取自 release 名"); + assert_eq!(release.semver.as_deref(), Some("21.0.13+9.0.LTS")); + assert_eq!(release.major, 21); + assert_eq!( + release.archive.name, + "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip" + ); + assert_eq!(release.archive.size, 205_421_281); + assert!(release.archive.url.contains("temurin21-binaries")); +} + +#[test] +fn other_platform_has_no_pinned_archive() { + let error = parse_release( + &release_body(), + "jdk-21.0.13+9", + AdoptiumTarget::new("windows", "x86-32"), + ) + .expect_err("x86-32 应没有条目"); + assert_eq!(error.kind(), JavaErrorKind::Metadata); +} + +/// 真机 `17/ga` 的两页形状(2026-11-12 实测 26 条:page=0 满 20 条、page=1 只有 6 条)。 +/// 目标版本 `jdk-17.0.4+8` 在**第二页**,第一页 20 条里没有它。 +fn page_zero() -> Vec { + // 17.0.21 … 17.0.6 共 16 条 + 4 条补丁变体,凑满一页 20 条; + // 这一页里**没有** 17.0.4(它只在第二页)。 + let mut names: Vec = (0..16) + .map(|index| format!("jdk-17.0.{}+9", 21 - index)) + .collect(); + for filler in ["17.0.5.1+1", "17.0.5+11", "17.0.6.1+1", "17.0.10.1+1"] { + names.push(format!("jdk-{filler}")); + } + assert_eq!(names.len(), PAGE_SIZE); + assert!( + pick_release(&names, "17.0.4").is_none(), + "第一页不许有 17.0.4" + ); + names +} + +fn page_one() -> Vec { + vec![ + "jdk-17.0.4.1+1".to_string(), + "jdk-17.0.4+8".to_string(), + "jdk-17.0.3+7".to_string(), + "jdk-17+35".to_string(), + "jdk-16.0.2+7".to_string(), + "jdk-16+36".to_string(), + ] +} + +/// 假的上游:按 page 返回夹具(`pages` 用完之后一律空页)。 +fn two_pages(page: usize) -> Result, JavaError> { + match page { + 0 => Ok(page_zero()), + 1 => Ok(page_one()), + _ => Ok(Vec::new()), + } +} + +/// **P2-1 守卫**:`17.0.4` 只在第二页出现,必须翻到第二页才命中。 +/// +/// 变异证明(复审实测过的那个):把 `collect_releases` 的循环改成「只取第一页」 +/// ⇒ 本用例红(`pick_release` 找不到 `jdk-17.0.4+8`)。 +#[tokio::test] +async fn a_version_on_the_second_page_is_still_found() { + let pages: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); + let (releases, truncated) = collect_releases(|page| { + pages.borrow_mut().push(page); + async move { two_pages(page) } + }) + .await + .expect("两页都应取到"); + + assert_eq!(*pages.borrow(), vec![0, 1], "应该正好翻两页"); + assert_eq!(releases.len(), PAGE_SIZE + page_one().len()); + assert!(!truncated, "第二页不满一页 ⇒ 清单是完整的"); + assert_eq!( + pick_release(&releases, "17.0.4").map(String::as_str), + Some("jdk-17.0.4+8"), + "第二页那一版必须能被选中" + ); + assert_eq!( + pick_release(&releases, "17.0.4.1").map(String::as_str), + Some("jdk-17.0.4.1+1") + ); +} + +/// **P2-2 守卫 1**:列表里同时有目标与更新的版本时,必须选**目标**,不许取第一条/最新。 +/// +/// 变异证明(复审的变异 B):把 `pick_release` 换成 `releases.first()` +/// ⇒ 本用例第一条红(会拿到 `jdk-17.0.21+9`)。 +#[test] +fn the_pinned_version_wins_over_the_newest_entry() { + let releases = vec![ + "jdk-17.0.21+9".to_string(), + "jdk-17.0.12+7".to_string(), + "jdk-17.0.4+8".to_string(), + ]; + assert_eq!( + pick_release(&releases, "17.0.4").map(String::as_str), + Some("jdk-17.0.4+8"), + "点名 17.0.4 时不许拿最新的 17.0.21+9" + ); + assert_eq!( + pick_release(&releases, "17.0.12+7").map(String::as_str), + Some("jdk-17.0.12+7") + ); + assert_eq!(pick_release(&releases, "17.0.99"), None); +} + +/// 上限兜底:上游每页都满(异常情况)时,翻到 [`MAX_PAGES`] 页就停,并如实标「还有更多」。 +/// +/// 变异证明:把 `collect_releases` 的 `for page in 0..MAX_PAGES` 改成 `loop` +/// ⇒ 夹具永远满页,本用例挂死/超时(红)。 +#[tokio::test] +async fn a_never_ending_catalog_stops_at_the_page_cap_and_says_so() { + let calls = std::cell::Cell::new(0usize); + let (releases, truncated) = collect_releases(|page| { + calls.set(calls.get() + 1); + async move { + let _ = page; + Ok((0..PAGE_SIZE) + .map(|index| format!("jdk-17.0.{}+1", index + 1)) + .collect::>()) + } + }) + .await + .expect("每页都成功"); + assert_eq!(calls.get(), MAX_PAGES, "翻满上限就停"); + assert_eq!(releases.len(), MAX_PAGES * PAGE_SIZE); + assert!(truncated, "到上限时必须标成截断,文案才能如实说"); +} diff --git a/crates/core/src/game/java/catalog.rs b/crates/core/src/game/java/catalog.rs index e482a60..1c521ba 100644 --- a/crates/core/src/game/java/catalog.rs +++ b/crates/core/src/game/java/catalog.rs @@ -174,9 +174,29 @@ pub fn version_mismatch(wanted: Option<&str>, found: &str) -> Option { (!matches_version(wanted, found)).then(|| format!("返回的版本是 {found},不是请求的 {wanted}")) } +/// 可用版本清单的完整度:决定错误文案里那句「还有没有更多」怎么说。 +/// +/// 上游的版本清单有三种形态:Oracle 目录页一次就能看全、Microsoft 只有一份快照、 +/// Zulu/Termux 压根没有清单([`Complete`]);Temurin 要走分页,翻到上限还没翻完时 +/// 只能如实说「只查了前 N 条」——**不许把窗口当全集**。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VersionListScope { + /// 上游的清单已经全看到了。 + Complete, + /// 只看了上游的前若干条(Temurin 翻页到上限)。 + Truncated, +} + /// 可用版本列表的人读形态(错误里列给人看;超过 `limit` 条就收口成「等 N 个」)。 +/// +/// `scope` 只影响尾巴那句:`Complete` 时「等 N 个」就是上游总数,`Truncated` 时补一句 +/// 「只查了前 N 条」,因为这时 N 是**我们的窗口**而不是上游的总数。 #[must_use] -pub fn available_versions_text(versions: &[String], limit: usize) -> String { +pub fn available_versions_text( + versions: &[String], + limit: usize, + scope: VersionListScope, +) -> String { if versions.is_empty() { return "(上游一个都没列出来)".to_string(); } @@ -186,10 +206,16 @@ pub fn available_versions_text(versions: &[String], limit: usize) -> String { .map(String::as_str) .collect::>() .join(" / "); - if versions.len() > limit { + let listed = if versions.len() > limit { format!("{shown} 等 {} 个", versions.len()) } else { shown + }; + match scope { + VersionListScope::Complete => listed, + VersionListScope::Truncated => { + format!("{listed}(只查了前 {} 条,后面可能还有)", versions.len()) + } } } @@ -204,8 +230,9 @@ pub fn version_pin_error( major: u32, wanted: &str, available: &[String], + scope: VersionListScope, ) -> JavaError { - let listed = available_versions_text(available, 10); + let listed = available_versions_text(available, 10, scope); let try_other = "要这个版本换一家:`--vendor temurin`(Adoptium 全量补丁)或 `--vendor oracle`"; let vendor_id = vendor.id(); match vendor { @@ -248,153 +275,4 @@ pub fn version_pin_error( } #[cfg(test)] -mod tests { - use super::{choices, default_major, find, vendors_for, BASELINE_MAJOR}; - use crate::game::java::vendor::{ids, JdkVendor}; - - use super::{available_versions_text, matches_version, version_pin_error}; - - /// 目录必须覆盖用户点名的三档(8 / 17 / 21),并钉住基准版本。 - /// - /// 变异证明:从 `CHOICES` 里删掉 8 那一项 ⇒ 本用例红(`find(8)` 为 `None`)。 - #[test] - fn the_catalog_covers_eight_seventeen_and_twenty_one() { - for major in [8, 17, 21] { - assert!(find(major).is_some(), "目录里必须有 Java {major}"); - } - assert_eq!(BASELINE_MAJOR, 17, "D13 的基准版本是 17"); - assert!(find(BASELINE_MAJOR).expect("基准版本在目录里").baseline); - assert_eq!( - choices().len(), - 5, - "目录是 8/11/17/21/25 五档,改动要同步本断言与 UI 文案" - ); - assert!( - choices() - .windows(2) - .all(|pair| pair[0].major < pair[1].major), - "目录必须升序(UI 直接按这个顺序画)" - ); - } - - /// 「8 只能由 Temurin / Zulu 承接」:Oracle 从 9 起、MS 已停发 8。 - /// - /// 变异证明:把 `vendors_for` 里的 `supports` 过滤删掉 ⇒ 本用例红(8 会列出 oracle/microsoft)。 - #[test] - fn java_eight_is_only_served_by_the_vendors_that_still_ship_it() { - assert_eq!(ids(&vendors_for(8, &[], true)), vec!["temurin", "zulu"]); - assert_eq!( - ids(&vendors_for(21, &[], true)), - vec!["temurin", "oracle", "microsoft", "zulu"], - "21 四家都在(国内默认序)" - ); - assert!( - !vendors_for(8, &[], true).contains(&JdkVendor::Microsoft), - "微软已停发 8" - ); - } - - /// 用户优先级要真的排在最前面(与 `install` 的链同源)。 - #[test] - fn the_user_priority_leads_the_vendor_list() { - let priority = vec![JdkVendor::Zulu]; - assert_eq!(ids(&vendors_for(17, &priority, true))[0], "zulu"); - assert_eq!(ids(&vendors_for(17, &priority, true)).len(), 4); - } - - /// 「该装哪个」对齐到目录里的一档:向上取整,没有上下文给基准,超出目录给基准。 - #[test] - fn the_default_major_rounds_up_into_the_catalog() { - assert_eq!(default_major(None), 17); - assert_eq!(default_major(Some(8)), 8, "1.12.2 → 8"); - assert_eq!(default_major(Some(16)), 17, "1.17 要 16 → 目录里的 17"); - assert_eq!(default_major(Some(17)), 17); - assert_eq!(default_major(Some(21)), 21, "1.20.5+ → 21"); - assert_eq!(default_major(Some(25)), 25, "26.1 → 25"); - assert_eq!(default_major(Some(99)), 17, "超出目录时退回基准"); - } - - /// 四家的版本串用同一把尺子量(`matches_version` 是唯一判据)。 - /// - /// 变异证明:把 `matches_version` 换成 `starts_with` ⇒ 本用例红 - /// (`21.0.13` 会命中 MS 的 `21.0.13.0.2`,那是另一个版本)。 - #[test] - fn version_matching_is_shared_and_never_prefix_fuzzy() { - // Temurin:公共部分 ↔ 带构建号;semver 与 release 名两种写法都算同一份。 - assert!(matches_version("21.0.13", "21.0.13+9")); - assert!(matches_version("21.0.13+9", "jdk-21.0.13+9")); - assert!(matches_version("21.0.13+9", "21.0.13+9.0.LTS")); - assert!(matches_version(" 21.0.13 ", "21.0.13")); - // Oracle:目录名就是三段,公共部分一致即命中。 - assert!(matches_version("21.0.2", "21.0.2")); - // 少一位、多一段都不是同一个版本(不许前缀近似)。 - assert!(!matches_version("21.0", "21.0.13")); - assert!(!matches_version("21.0.13", "21.0.13.0.2")); - assert!(!matches_version("21.0.13+8", "21.0.13+9")); - // 用户点名了构建号、候选没给 → 不算命中。 - assert!(!matches_version("21.0.13+9", "21.0.13")); - assert!(!matches_version("", "21.0.13")); - } - - /// 可用版本列表:给几条就列几条,多了收口(错误文案不能无限长)。 - #[test] - fn available_versions_text_caps_the_list() { - let versions: Vec = ["21.0.13", "21.0.12", "21.0.11"] - .iter() - .map(|text| (*text).to_string()) - .collect(); - assert_eq!( - available_versions_text(&versions, 10), - "21.0.13 / 21.0.12 / 21.0.11" - ); - assert_eq!( - available_versions_text(&versions, 2), - "21.0.13 / 21.0.12 等 3 个" - ); - assert!(available_versions_text(&[], 5).contains("一个都没列出来")); - } - - /// 四家的「这家不能按你要的版本装」文案必须**互不相同**、各自说清原因,且给下一步。 - /// - /// 变异证明:把某两家共用同一句「不支持指定版本」⇒ 本用例红。 - #[test] - fn pin_refusals_are_distinct_and_each_explains_itself() { - let snapshot = vec!["21.0.13.0.2".to_string()]; - let listed = vec!["21.0.13+9".to_string()]; - let errors = [ - version_pin_error(JdkVendor::Temurin, 21, "21.0.5", &listed), - version_pin_error(JdkVendor::Oracle, 21, "21.0.5", &["21.0.2".to_string()]), - version_pin_error(JdkVendor::Microsoft, 21, "21.0.5", &snapshot), - version_pin_error(JdkVendor::Zulu, 21, "21.0.5", &[]), - version_pin_error(JdkVendor::Termux, 21, "21.0.5", &[]), - ]; - for (index, left) in errors.iter().enumerate() { - for right in errors.iter().skip(index + 1) { - assert_ne!( - left.message(), - right.message(), - "每家的拒绝理由必须各说各的" - ); - } - assert!( - left.message().contains("21.0.5"), - "错误里要带上用户要的版本" - ); - } - assert!( - errors[0].message().contains("21.0.13+9"), - "可用版本要列出来" - ); - assert!(errors[2].message().contains("只内置了 21.0.13.0.2")); - assert!(errors[2] - .hint() - .unwrap_or_default() - .contains("--vendor microsoft")); - assert!(errors[3].message().contains("latest=true")); - assert!(errors[3] - .hint() - .unwrap_or_default() - .contains("--vendor temurin")); - assert!(errors[4].message().contains("系统包")); - } -} +mod tests; diff --git a/crates/core/src/game/java/catalog/tests.rs b/crates/core/src/game/java/catalog/tests.rs new file mode 100644 index 0000000..3745363 --- /dev/null +++ b/crates/core/src/game/java/catalog/tests.rs @@ -0,0 +1,229 @@ +//! `catalog` 的单测:目录、版本判据、拒绝文案、兜底闸。 +//! +//! 从 `catalog.rs` 拆出来只为守 400 行硬线(M280 r1 要在这里加「兜底闸」与「清单被截断」 +//! 的用例,留在主文件会到 470+);**纯搬移**,既有断言一字未改。 + +use super::{ + available_versions_text, choices, default_major, find, matches_version, vendors_for, + version_mismatch, version_pin_error, VersionListScope, BASELINE_MAJOR, +}; +use crate::game::java::vendor::{ids, JdkVendor}; + +/// 目录必须覆盖用户点名的三档(8 / 17 / 21),并钉住基准版本。 +/// +/// 变异证明:从 `CHOICES` 里删掉 8 那一项 ⇒ 本用例红(`find(8)` 为 `None`)。 +#[test] +fn the_catalog_covers_eight_seventeen_and_twenty_one() { + for major in [8, 17, 21] { + assert!(find(major).is_some(), "目录里必须有 Java {major}"); + } + assert_eq!(BASELINE_MAJOR, 17, "D13 的基准版本是 17"); + assert!(find(BASELINE_MAJOR).expect("基准版本在目录里").baseline); + assert_eq!( + choices().len(), + 5, + "目录是 8/11/17/21/25 五档,改动要同步本断言与 UI 文案" + ); + assert!( + choices() + .windows(2) + .all(|pair| pair[0].major < pair[1].major), + "目录必须升序(UI 直接按这个顺序画)" + ); +} + +/// 「8 只能由 Temurin / Zulu 承接」:Oracle 从 9 起、MS 已停发 8。 +/// +/// 变异证明:把 `vendors_for` 里的 `supports` 过滤删掉 ⇒ 本用例红(8 会列出 oracle/microsoft)。 +#[test] +fn java_eight_is_only_served_by_the_vendors_that_still_ship_it() { + assert_eq!(ids(&vendors_for(8, &[], true)), vec!["temurin", "zulu"]); + assert_eq!( + ids(&vendors_for(21, &[], true)), + vec!["temurin", "oracle", "microsoft", "zulu"], + "21 四家都在(国内默认序)" + ); + assert!( + !vendors_for(8, &[], true).contains(&JdkVendor::Microsoft), + "微软已停发 8" + ); +} + +/// 用户优先级要真的排在最前面(与 `install` 的链同源)。 +#[test] +fn the_user_priority_leads_the_vendor_list() { + let priority = vec![JdkVendor::Zulu]; + assert_eq!(ids(&vendors_for(17, &priority, true))[0], "zulu"); + assert_eq!(ids(&vendors_for(17, &priority, true)).len(), 4); +} + +/// 「该装哪个」对齐到目录里的一档:向上取整,没有上下文给基准,超出目录给基准。 +#[test] +fn the_default_major_rounds_up_into_the_catalog() { + assert_eq!(default_major(None), 17); + assert_eq!(default_major(Some(8)), 8, "1.12.2 → 8"); + assert_eq!(default_major(Some(16)), 17, "1.17 要 16 → 目录里的 17"); + assert_eq!(default_major(Some(17)), 17); + assert_eq!(default_major(Some(21)), 21, "1.20.5+ → 21"); + assert_eq!(default_major(Some(25)), 25, "26.1 → 25"); + assert_eq!(default_major(Some(99)), 17, "超出目录时退回基准"); +} + +/// 四家的版本串用同一把尺子量(`matches_version` 是唯一判据)。 +/// +/// 变异证明:把 `matches_version` 换成 `starts_with` ⇒ 本用例红 +/// (`21.0.13` 会命中 MS 的 `21.0.13.0.2`,那是另一个版本)。 +#[test] +fn version_matching_is_shared_and_never_prefix_fuzzy() { + // Temurin:公共部分 ↔ 带构建号;semver 与 release 名两种写法都算同一份。 + assert!(matches_version("21.0.13", "21.0.13+9")); + assert!(matches_version("21.0.13+9", "jdk-21.0.13+9")); + assert!(matches_version("21.0.13+9", "21.0.13+9.0.LTS")); + assert!(matches_version(" 21.0.13 ", "21.0.13")); + // Oracle:目录名就是三段,公共部分一致即命中。 + assert!(matches_version("21.0.2", "21.0.2")); + // 少一位、多一段都不是同一个版本(不许前缀近似)。 + assert!(!matches_version("21.0", "21.0.13")); + assert!(!matches_version("21.0.13", "21.0.13.0.2")); + assert!(!matches_version("21.0.13+8", "21.0.13+9")); + // 用户点名了构建号、候选没给 → 不算命中。 + assert!(!matches_version("21.0.13+9", "21.0.13")); + assert!(!matches_version("", "21.0.13")); +} + +/// 可用版本列表:给几条就列几条,多了收口(错误文案不能无限长)。 +#[test] +fn available_versions_text_caps_the_list() { + let versions: Vec = ["21.0.13", "21.0.12", "21.0.11"] + .iter() + .map(|text| (*text).to_string()) + .collect(); + assert_eq!( + available_versions_text(&versions, 10, VersionListScope::Complete), + "21.0.13 / 21.0.12 / 21.0.11" + ); + assert_eq!( + available_versions_text(&versions, 2, VersionListScope::Complete), + "21.0.13 / 21.0.12 等 3 个" + ); + assert!(available_versions_text(&[], 5, VersionListScope::Complete).contains("一个都没列出来")); +} + +/// 四家的「这家不能按你要的版本装」文案必须**互不相同**、各自说清原因,且给下一步。 +/// +/// 变异证明:把某两家共用同一句「不支持指定版本」⇒ 本用例红。 +#[test] +fn pin_refusals_are_distinct_and_each_explains_itself() { + let snapshot = vec!["21.0.13.0.2".to_string()]; + let listed = vec!["21.0.13+9".to_string()]; + let errors = [ + version_pin_error( + JdkVendor::Temurin, + 21, + "21.0.5", + &listed, + VersionListScope::Complete, + ), + version_pin_error( + JdkVendor::Oracle, + 21, + "21.0.5", + &["21.0.2".to_string()], + VersionListScope::Complete, + ), + version_pin_error( + JdkVendor::Microsoft, + 21, + "21.0.5", + &snapshot, + VersionListScope::Complete, + ), + version_pin_error( + JdkVendor::Zulu, + 21, + "21.0.5", + &[], + VersionListScope::Complete, + ), + version_pin_error( + JdkVendor::Termux, + 21, + "21.0.5", + &[], + VersionListScope::Complete, + ), + ]; + for (index, left) in errors.iter().enumerate() { + for right in errors.iter().skip(index + 1) { + assert_ne!( + left.message(), + right.message(), + "每家的拒绝理由必须各说各的" + ); + } + assert!( + left.message().contains("21.0.5"), + "错误里要带上用户要的版本" + ); + } + assert!( + errors[0].message().contains("21.0.13+9"), + "可用版本要列出来" + ); + assert!(errors[2].message().contains("只内置了 21.0.13.0.2")); + assert!(errors[2] + .hint() + .unwrap_or_default() + .contains("--vendor microsoft")); + assert!(errors[3].message().contains("latest=true")); + assert!(errors[3] + .hint() + .unwrap_or_default() + .contains("--vendor temurin")); + assert!(errors[4].message().contains("系统包")); +} + +/// **兜底闸(P2-2 守卫 2)**:实际拿到的版本与请求不符时必须给出原因,相符/没钉版本时必须放行。 +/// +/// 变异证明:把 `version_mismatch` 改成恒 `None`(复审的变异 A)⇒ 本用例第一条红。 +#[test] +fn the_version_gate_rejects_a_different_version_and_lets_everything_else_through() { + let reason = version_mismatch(Some("21.0.12"), "21.0.13+9") + .expect("要 21.0.12、拿到 21.0.13+9 ⇒ 必须拒绝"); + assert!(reason.contains("21.0.13+9"), "{reason}"); + assert!(reason.contains("不是请求的 21.0.12"), "{reason}"); + + // 同一个版本(公共部分一致、构建号一致)⇒ 放行。 + assert_eq!(version_mismatch(Some("21.0.12"), "21.0.12+8"), None); + assert_eq!(version_mismatch(Some("21.0.12+8"), "jdk-21.0.12+8"), None); + // 没钉版本 ⇒ 恒放行(不许对默认路径误报)。 + assert_eq!(version_mismatch(None, "21.0.13+9"), None); + // 构建号不同 ⇒ 拒绝。 + assert!(version_mismatch(Some("21.0.12+8"), "21.0.12+9").is_some()); +} + +/// **清单被截断时不许把窗口当全集(P2-1 文案)**:`Complete` 说「等 N 个」, +/// `Truncated` 必须补一句「只查了前 N 条」。 +/// +/// 变异证明:把 `Truncated` 分支改成与 `Complete` 相同 ⇒ 本用例第二条红。 +#[test] +fn a_truncated_list_never_pretends_to_be_the_whole_catalog() { + let versions: Vec = (0..20).map(|index| format!("17.0.{}", index + 1)).collect(); + let complete = available_versions_text(&versions, 2, VersionListScope::Complete); + let truncated = available_versions_text(&versions, 2, VersionListScope::Truncated); + assert_eq!(complete, "17.0.1 / 17.0.2 等 20 个"); + assert_ne!(complete, truncated); + assert!( + truncated.contains("只查了前 20 条"), + "截断时必须如实说:{truncated}" + ); + // 完整清单的拒绝文案里不许出现「只查了」。 + let error = version_pin_error( + JdkVendor::Temurin, + 17, + "17.0.99", + &versions, + VersionListScope::Complete, + ); + assert!(!error.message().contains("只查了"), "{}", error.message()); +} diff --git a/crates/core/src/game/java/install.rs b/crates/core/src/game/java/install.rs index 126af2a..1d9d3de 100644 --- a/crates/core/src/game/java/install.rs +++ b/crates/core/src/game/java/install.rs @@ -42,9 +42,13 @@ use crate::download::{DownloadEngine, DownloadRequest, ResourceKind}; use one::install_one; +mod chain; mod one; +mod plan; mod termux_install; +use chain::resolve_one; + /// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。 pub use super::location::CACHE_DIR; @@ -232,6 +236,7 @@ pub async fn install( request.major, wanted, &[], + catalog::VersionListScope::Complete, )); } Some(_) => { @@ -285,20 +290,20 @@ pub async fn install( continue; } }; - let release = match source::fetch(engine, vendor, request.major, target, wanted).await { + // 取版本 + 兜底闸(版本必须真是点名的那一版,任何一家都不许回落 latest): + // 判据在 `chain::resolve_one` 里,`--dry-run` 的 `plan` 走同一个函数。 + let release = match resolve_one(wanted, || { + source::fetch(engine, vendor, request.major, target, wanted) + }) + .await + { Ok(release) => release, - Err(error) => { - chain.failed(vendor, error.message().to_string()); - reasons.push(format!("{}:{}", vendor.id(), error.message())); + Err(reason) => { + chain.failed(vendor, reason.clone()); + reasons.push(format!("{}:{reason}", vendor.id())); continue; } }; - // 兜底闸:装成的那一版必须真是用户点名的那一版(任何一家都不许回落 latest)。 - if let Some(reason) = catalog::version_mismatch(wanted, &release.version) { - chain.failed(vendor, reason.clone()); - reasons.push(format!("{}:{reason}", vendor.id())); - continue; - } match install_one(engine, location, &release).await { Ok(prepared) => { chain.hit(vendor, format!("命中 {}", release.summary())); @@ -336,57 +341,15 @@ pub async fn install( // 钉了版本时给一句能照着做的:哪几家真能按版本装(默认路径的提示一个字都不动)。 Err(if wanted.is_some() { error.with_hint( - "能按版本装的是 temurin / oracle;microsoft 只认它内置的快照,zulu 上游只提供 latest。\ - `ull java vendors` 看各发行版的可用性", + "能按版本装的是 `--vendor temurin` 与 `--vendor oracle`;\ + microsoft 只认它内置的那份快照,zulu 上游只提供 latest", ) } else { error.with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME") }) } -/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。 -/// -/// `--dry-run` 用它:`request.version` 与 `--vendor` 都参与选择(钉了版本就把这一维钉死), -/// 所以「最后会打哪个 URL、装哪个版本串」在下载之前就能看见——这也是不联网时 -/// 唯一能验证版本选择逻辑的输出。 -/// -/// # Errors -/// -/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 该家没有这个版本)。 -pub async fn plan( - engine: &DownloadEngine, - location: &JavaInstallRoot, - request: &InstallRequest, -) -> Result<(JdkRelease, PathBuf, bool), JavaError> { - let target = AdoptiumTarget::host()?; - let domestic = engine.region().is_domestic(); - let wanted = request.version.as_deref(); - let mut reasons: Vec = Vec::new(); - for vendor in chain_order(request, domestic) { - let release = match source::fetch(engine, vendor, request.major, target, wanted).await { - Ok(release) => release, - Err(error) => { - reasons.push(format!("{}:{}", vendor.id(), error.message())); - continue; - } - }; - if let Some(reason) = catalog::version_mismatch(wanted, &release.version) { - reasons.push(format!("{}:{reason}", vendor.id())); - continue; - } - let root = location.jdk_dir(release.major, release.vendor, &release.version); - let ready = extract::is_ready(&root, release.sha256()); - return Ok((release, root, ready)); - } - Err(JavaError::new( - JavaErrorKind::Metadata, - format!( - "没有可用的 Java {}({} )", - request.major, - reasons.join(";") - ), - )) -} +pub use plan::plan; fn majors_text(vendor: JdkVendor) -> String { vendor diff --git a/crates/core/src/game/java/install/chain.rs b/crates/core/src/game/java/install/chain.rs new file mode 100644 index 0000000..1e6ca67 --- /dev/null +++ b/crates/core/src/game/java/install/chain.rs @@ -0,0 +1,220 @@ +//! `install` 回退链的骨架:一次尝试的判据(取到 / 版本不符 / 失败)与「按顺序试、命中即停」。 +//! +//! 单独成模块有两个理由: +//! +//! 1. `install.rs` 贴着 400 行硬线(M280 r1 加了这些守卫后到 440+); +//! 2. 这段是**纯判断**——真正的「取版本」由闭包注入,所以「版本不符 ⇒ 记失败继续走链」 +//! 与「钉死一家时只试它一家」都能**离线**测。复审的变异 A(把 +//! `catalog::version_mismatch` 改成恒 `None`)就是靠这里的用例变红的:没有它, +//! 「绝不把 latest 当用户要的版本装下去」只有代码、没有守卫。 + +use std::future::Future; + +use super::super::catalog; +use super::super::error::JavaError; +use super::super::release::JdkRelease; +use super::super::vendor::JdkVendor; + +/// 一次尝试的结果:`Ok` = 这一版就是用户要的;`Err` = 一句话原因(进降级记录)。 +pub(super) type Attempt = Result; + +/// 一次尝试的**判据**:取不到 → 原因;取到了但版本不是用户点名的那一版 → 拒绝。 +/// +/// `fetch` 由调用方给(真跑是 `source::fetch`,单测是夹具),所以这道闸离线可测。 +/// `wanted = None`(没钉版本)时恒放行——默认路径不受任何影响。 +pub(super) async fn resolve_one(wanted: Option<&str>, fetch: F) -> Attempt +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + match fetch().await { + Err(error) => Err(error.message().to_string()), + Ok(release) => match catalog::version_mismatch(wanted, &release.version) { + Some(reason) => Err(reason), + None => Ok(release), + }, + } +} + +/// 回退链骨架:按顺序试,第一个通过的即命中;失败与版本不符都变成一条原因继续下一家。 +/// +/// 返回 `(命中的发行版与版本, 每家失败的原因)`。`plan`(`--dry-run`)用它;`install` 还要记 +/// `ChainLog` 与「不支持该大版本」的跳过原因,所以自己驱动循环,但**唯一入口仍是 +/// [`resolve_one`]**——两边的判据只有一份。 +pub(super) async fn chain_fetch( + vendors: &[JdkVendor], + wanted: Option<&str>, + mut fetch: F, +) -> (Option<(JdkVendor, JdkRelease)>, Vec<(JdkVendor, String)>) +where + F: FnMut(JdkVendor) -> Fut, + Fut: Future>, +{ + let mut failures: Vec<(JdkVendor, String)> = Vec::new(); + for candidate in vendors { + let vendor = *candidate; + match resolve_one(wanted, || fetch(vendor)).await { + Ok(release) => return (Some((vendor, release)), failures), + Err(reason) => failures.push((vendor, reason)), + } + } + (None, failures) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::download::RegionProfile; + use crate::download::{ + ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable, + }; + use crate::game::java::error::JavaError; + use crate::game::java::install::{self, InstallRequest, JavaInstallRoot}; + use crate::game::java::release::{JdkArchive, JdkRelease}; + use crate::game::java::vendor::JdkVendor; + + use super::{chain_fetch, resolve_one}; + + fn release(version: &str) -> JdkRelease { + JdkRelease { + vendor: JdkVendor::Temurin, + major: 21, + version: version.to_string(), + semver: None, + release_name: format!("jdk-{version}"), + archive: JdkArchive { + name: format!("jdk-{version}.zip"), + url: "https://example.invalid/x.zip".to_string(), + sha256: "a".repeat(64), + size: 1, + }, + } + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ull-javapin-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir + } + + /// **P2-2 守卫(兜底闸)**:拿到的版本不是点名的那一版 ⇒ 拒绝,原因里带双方版本号。 + /// + /// 变异证明(复审的变异 A):把 `catalog::version_mismatch` 改成恒 `None` + /// ⇒ 本用例第一条红(`expect_err` 拿到 `Ok`)。 + #[tokio::test] + async fn a_release_that_is_not_the_requested_version_is_rejected() { + let refused = resolve_one(Some("21.0.12"), || async { Ok(release("21.0.13+9")) }) + .await + .expect_err("要 21.0.12、供应商回 latest 21.0.13+9 ⇒ 必须拒绝"); + assert!(refused.contains("21.0.13+9"), "{refused}"); + assert!(refused.contains("不是请求的 21.0.12"), "{refused}"); + + // 相符 ⇒ 放行(公共部分一致也算相符)。 + assert!( + resolve_one(Some("21.0.12"), || async { Ok(release("21.0.12+8")) }) + .await + .is_ok() + ); + // 没钉版本 ⇒ 恒放行(默认路径不许误报)。 + assert!(resolve_one(None, || async { Ok(release("21.0.13+9")) }) + .await + .is_ok()); + // 真正取不到 ⇒ 原因原样带出来。 + let failed = resolve_one(Some("21.0.12"), || async { + Err::(JavaError::new( + crate::game::java::JavaErrorKind::Network, + "连不上", + )) + }) + .await + .expect_err("取不到就得报错"); + assert_eq!(failed, "连不上"); + } + + /// **P2-2 守卫(继续走链)**:第一家回落的 latest 必须被记成失败,第二家(真给了那一版)命中。 + /// + /// 变异证明:把 `chain_fetch` 的 `Err(reason) => failures.push(..)` 改成直接返回 + /// ⇒ 本用例红(命中变成第一家那个不符的版本)。 + #[tokio::test] + async fn a_mismatch_records_a_failure_and_the_chain_moves_on() { + let vendors = [JdkVendor::Temurin, JdkVendor::Oracle]; + let (hit, failures) = chain_fetch(&vendors, Some("21.0.12"), |vendor| async move { + match vendor { + // 第一家没按版本取(就是复审担心的那种 bug:静默回落 latest)。 + JdkVendor::Temurin => Ok(release("21.0.13+9")), + _ => Ok(release("21.0.12+8")), + } + }) + .await; + + let (vendor, picked) = hit.expect("第二家应命中"); + assert_eq!(vendor, JdkVendor::Oracle, "命中不许是第一家"); + assert_eq!(picked.version, "21.0.12+8"); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].0, JdkVendor::Temurin); + assert!(failures[0].1.contains("不是请求的 21.0.12")); + } + + /// 全都不符 ⇒ 没有命中,每家一条原因(命令据此退出非 0 并列出全部原因)。 + #[tokio::test] + async fn nothing_matching_means_no_hit_and_every_reason_is_kept() { + let vendors = [JdkVendor::Temurin, JdkVendor::Oracle]; + let (hit, failures) = chain_fetch(&vendors, Some("21.0.12"), |_vendor| async { + Ok(release("21.0.13+9")) + }) + .await; + assert!(hit.is_none(), "一家都没给对版本 ⇒ 不许命中"); + assert_eq!(failures.len(), 2); + assert!(failures + .iter() + .all(|(_, reason)| reason.contains("不是请求的"))); + } + + /// **P2-2 守卫(不换家)**:钉死一家、而这家不支持指定版本 ⇒ `install` 只试它一家 + /// (错误里的「试过 N」必须是 1),一个请求都不发。 + /// + /// 变异证明:把 `source::pinned` 里 Zulu 的拒绝改成回落 `fetch_latest` + /// ⇒ 本用例红(错误里不再有 `latest=true`,且「试过」不再是 1 条原因)。 + #[tokio::test] + async fn a_pinned_vendor_that_cannot_pin_is_tried_alone() { + let dir = temp_dir("pinned-alone"); + let cache = Arc::new(ContentCache::open(dir.join("cache"))); + let engine = DownloadEngine::new( + EngineConfig::default(), + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + cache, + ); + let location = JavaInstallRoot::custom(dir.join("java-managed")); + let request = InstallRequest::with_vendor(21, JdkVendor::Zulu) + .with_version(Some("21.0.13".to_string())); + + let error = install::install(&engine, &location, request) + .await + .expect_err("zulu 上游只有 latest ⇒ 必须报错"); + assert!( + error.message().contains("试过 1"), + "只许试点名的那一家:{}", + error.message() + ); + assert!( + error.message().contains("latest=true"), + "{}", + error.message() + ); + assert!( + error + .hint() + .unwrap_or_default() + .contains("--vendor temurin"), + "要给出能做的下一步:{:?}", + error.hint() + ); + // 一个字节都没落盘(连目录都不该建出来)。 + assert!(!dir.join("java-managed").exists()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/core/src/game/java/install/plan.rs b/crates/core/src/game/java/install/plan.rs new file mode 100644 index 0000000..ff60a0d --- /dev/null +++ b/crates/core/src/game/java/install/plan.rs @@ -0,0 +1,59 @@ +//! `install` 的**只读取**规划段:`--dry-run` 用它算出「会装哪一版、打哪个 URL」。 +//! +//! 从 `install.rs` 拆出来只为守 400 行硬线(M280 r1 给回退链加了守卫后那边到 401 行); +//! 纯搬移,语义未变。与 `install/one.rs`(真装的一步)、`install/chain.rs`(判据与骨架) +//! 同一个拆法。 + +use std::path::PathBuf; + +use super::super::error::{JavaError, JavaErrorKind}; +use super::super::extract; +use super::super::location::JavaInstallRoot; +use super::super::release::JdkRelease; +use super::super::source; +use super::super::target::AdoptiumTarget; +use super::{chain::chain_fetch, chain_order, InstallRequest}; +use crate::download::DownloadEngine; + +/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。 +/// +/// `--dry-run` 用它:`request.version` 与 `--vendor` 都参与选择(钉了版本就把这一维钉死), +/// 所以「最后会打哪个 URL、装哪个版本串」在下载之前就能看见——这也是不联网时 +/// 唯一能验证版本选择逻辑的输出。 +/// +/// # Errors +/// +/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 该家没有这个版本)。 +pub async fn plan( + engine: &DownloadEngine, + location: &JavaInstallRoot, + request: &InstallRequest, +) -> Result<(JdkRelease, PathBuf, bool), JavaError> { + let target = AdoptiumTarget::host()?; + let domestic = engine.region().is_domestic(); + let wanted = request.version.as_deref(); + let candidates = chain_order(request, domestic); + let (hit, failures) = chain_fetch(&candidates, wanted, |vendor| { + source::fetch(engine, vendor, request.major, target, wanted) + }) + .await; + match hit { + Some((_, release)) => { + let root = location.jdk_dir(release.major, release.vendor, &release.version); + let ready = extract::is_ready(&root, release.sha256()); + Ok((release, root, ready)) + } + None => Err(JavaError::new( + JavaErrorKind::Metadata, + format!( + "没有可用的 Java {}({} )", + request.major, + failures + .iter() + .map(|(vendor, reason)| format!("{}:{reason}", vendor.id())) + .collect::>() + .join(";") + ), + )), + } +} diff --git a/crates/core/src/game/java/microsoft.rs b/crates/core/src/game/java/microsoft.rs index 893b94f..e551807 100644 --- a/crates/core/src/game/java/microsoft.rs +++ b/crates/core/src/game/java/microsoft.rs @@ -16,7 +16,7 @@ //! 3. **GitHub 侧没有任何资产**(`microsoft/openjdk` 的 release `assets` 全空), //! 所以 gh-proxy 对这条链毫无用处——`domestic_mirrors` 实测为空数组。 -use super::catalog::{matches_version, version_pin_error}; +use super::catalog::{matches_version, version_pin_error, VersionListScope}; use super::error::{JavaError, JavaErrorKind}; use super::net; use super::release::{parse_sha256_text, JdkArchive, JdkRelease}; @@ -187,6 +187,7 @@ pub async fn fetch_version( major, wanted, &[known.to_string()], + VersionListScope::Complete, )); } fetch_latest(client, table, major, target).await diff --git a/crates/core/src/game/java/oracle/listing.rs b/crates/core/src/game/java/oracle/listing.rs index 5f230b5..ab0a8cd 100644 --- a/crates/core/src/game/java/oracle/listing.rs +++ b/crates/core/src/game/java/oracle/listing.rs @@ -14,7 +14,7 @@ //! //! 「存在不信状态码」的理由见 `oracle.rs` 头:华为云对任意不存在的路径都回 200 + 门户 HTML。 -use super::super::catalog::{matches_version, version_pin_error}; +use super::super::catalog::{matches_version, version_pin_error, VersionListScope}; use super::super::error::{JavaError, JavaErrorKind}; use super::super::vendor::JdkVendor; @@ -102,7 +102,15 @@ pub(super) fn select_version( .iter() .find(|version| matches_version(wanted, version)) .cloned() - .ok_or_else(|| version_pin_error(JdkVendor::Oracle, major, wanted, &available)) + .ok_or_else(|| { + version_pin_error( + JdkVendor::Oracle, + major, + wanted, + &available, + VersionListScope::Complete, + ) + }) } /// 「目录页里没有这个大版本」。 diff --git a/crates/core/src/game/java/source.rs b/crates/core/src/game/java/source.rs index b2df6a3..f28dac1 100644 --- a/crates/core/src/game/java/source.rs +++ b/crates/core/src/game/java/source.rs @@ -82,9 +82,13 @@ async fn pinned( } // 上游只给最新一份(Zulu)/ 由系统包管理器定版本(Termux):明确报错, // 绝不换成别的版本假装装成了用户点名的那一版。 - JdkVendor::Zulu | JdkVendor::Termux => { - Err(catalog::version_pin_error(vendor, major, wanted, &[])) - } + JdkVendor::Zulu | JdkVendor::Termux => Err(catalog::version_pin_error( + vendor, + major, + wanted, + &[], + catalog::VersionListScope::Complete, + )), } }