diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index fa20128..42b5313 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -40,8 +40,10 @@ pub const fn general() -> &'static str { config get [] [--show-secret] 读配置(生效值;无 key 时输出整份) config set 写配置(走 setting schema 校验) java list 列出可用 Java(含每个 JDK 的实际落点) - java install [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版; - --vendor temurin|oracle|microsoft|zulu|termux 钉死一家) + java install [--vendor X] [--version V] [--dry-run] + 安装托管 JDK(缺省按地区回退链选发行版; + --vendor temurin|oracle|microsoft|zulu|termux 钉死一家; + --version 钉具体版本,--dry-run 只查版本不下载) java vendors 列出各发行版的镜像/支持版本/当前优先级 agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP; [--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读) @@ -251,9 +253,22 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL "\ %prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java 并打印每个 JDK 的实际落点(managed 项带目录) -%prog% java install [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 +%prog% java install [--vendor X] [--version V] [--dry-run] + 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 %prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序 +指定版本(M280,`--version`): + Temurin temurin 能:打 Adoptium 的「指定版本」端点(不是 latest),版本不存在时列出可用版本 + Oracle oracle 能:版本串来自镜像目录页(`versions_for` 枚举),选定就用它 + Microsoft microsoft 只能装它内置快照里那一个版本号(当前 21 是 21.0.13.0.2);写别的版本会报错 + Zulu zulu 不能:上游只提供 latest;给了 --version 会报错,不会换成别的版本 + Termux termux 不能:版本由系统包管理器决定 + 钉不了的那几家,报错里会写清原因并提示改用 `--vendor temurin`;不会静默回落到最新, + 也不会换一家装个别的版本冒充。`--version` 与 `--vendor` 不匹配时就是「这家没有这个版本」。 + 注:全局 `--version`(打印启动器版本)只在子命令**之前**成立:`ull --version` 是版本, + `ull java install 21 --version 21.0.13+9` 里的是 JDK 版本。 +`--dry-run`:只查版本并打印**最终会打哪个版本串 / 哪个 URL**,不下载、不解压、不写盘。 + 落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走): portable(默认) <数据根>/.ULL/data/java-managed/jdk--/ standard Windows %APPDATA%\\Java\\<发行方>\\<版本>\\;macOS ~/Library/Application Support/Java/… diff --git a/crates/cli/src/cli/model.rs b/crates/cli/src/cli/model.rs index 652fd58..18b828a 100644 --- a/crates/cli/src/cli/model.rs +++ b/crates/cli/src/cli/model.rs @@ -55,12 +55,16 @@ pub enum Command { ConfigSet(ConfigSetArgs), /// `java list`。 JavaList, - /// `java install [--vendor X]`。 + /// `java install [--vendor X] [--version V] [--dry-run]`。 JavaInstall { /// 目标 JDK 主版本(8 / 17 / 21)。 major: u32, /// 钉死的发行版(缺省走 D23 的回退链)。 vendor: Option, + /// 钉死的具体版本(`--version`;`None` = 该家最新 GA)。 + version: Option, + /// 只查版本、打印将要下载的版本与 URL,不落盘(`--dry-run`)。 + dry_run: bool, }, /// `java vendors`:列出各发行版的可用性 / 镜像 / 当前优先级。 JavaVendors, diff --git a/crates/cli/src/cli/parse.rs b/crates/cli/src/cli/parse.rs index f210e28..bda3725 100644 --- a/crates/cli/src/cli/parse.rs +++ b/crates/cli/src/cli/parse.rs @@ -3,13 +3,12 @@ use std::collections::BTreeMap; use std::path::PathBuf; -use unlucky_core::game::java::JdkVendor; - use crate::error::CliError; mod accounts; mod agent; mod instances; +mod java; mod update; use super::model::{ @@ -45,7 +44,9 @@ pub fn parse(args: &[String]) -> Result { // 帮助/版本是「伪命令」,但不提前返回:`--help` 出现在命令前面时, // 主题要从后面收完的位置参数里取(`ull --help launch` 与 `ull launch --help` 等价)。 "--help" | "-h" => help_requested = true, - "--version" | "-V" => version_requested = true, + // 全局 `--version` 只在**子命令之前**成立(`ull --version`):`java install + // --version ` 里的同名旗标是 JDK 版本,必须原样交给子命令解析(M280)。 + "--version" | "-V" if rest.is_empty() => version_requested = true, "--data-dir" => { index += 1; globals.data_dir = Some(PathBuf::from(take_value(args, index, "data-dir")?)); @@ -91,7 +92,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result parse_logs(tail), "diag" => parse_diag(tail), "config" => parse_config(tail), - "java" => parse_java(tail), + "java" => java::parse_java(tail), "accounts" => accounts::parse_accounts(tail), "agent" => agent::parse_agent(tail), "update" => update::parse_update(tail), @@ -243,38 +244,6 @@ fn parse_config(tail: &[String]) -> Result { } } -fn parse_java(tail: &[String]) -> Result { - match tail.first().map(String::as_str) { - None | Some("list") => { - ensure_no_extra(tail.get(1..).unwrap_or_default(), "java list")?; - Ok(Command::JavaList) - } - Some("vendors") => { - ensure_no_extra(tail.get(1..).unwrap_or_default(), "java vendors")?; - Ok(Command::JavaVendors) - } - Some("install") => { - let parsed = parse_flags(tail.get(1..).unwrap_or_default(), &["vendor"], &[])?; - let raw = require_single_positional(&parsed, "java install ")?; - let major = raw - .parse::() - .map_err(|_| CliError::usage(format!("JDK 主版本需要整数,收到 `{raw}`")))?; - let vendor = match parsed.value("vendor") { - Some(text) => Some(JdkVendor::parse(&text).ok_or_else(|| { - CliError::usage(format!( - "--vendor 只有 temurin|oracle|microsoft|zulu,收到 `{text}`" - )) - })?), - None => None, - }; - Ok(Command::JavaInstall { major, vendor }) - } - Some(other) => Err(CliError::usage(format!( - "java 的子命令只有 list|install|vendors,收到 `{other}`" - ))), - } -} - /// 单个位置参数(多一个少一个都算用法错误)。 fn require_single_positional(parsed: &ParsedArgs, usage: &str) -> Result { match parsed.positionals.as_slice() { diff --git a/crates/cli/src/cli/parse/java.rs b/crates/cli/src/cli/parse/java.rs new file mode 100644 index 0000000..bf60f4c --- /dev/null +++ b/crates/cli/src/cli/parse/java.rs @@ -0,0 +1,135 @@ +//! `java` 的子命令解析(`list` / `install` / `vendors`)。 +//! +//! 从 `parse.rs` 拆出来只为守 400 行硬线(M280 的 `--version` / `--dry-run` 让那边贴近上限); +//! `list` / `vendors` 是纯搬移,`install` 多了两个旗标: +//! +//! ```text +//! --version 钉死具体版本(缺省 = 该家最新 GA);能不能钉由发行版决定, +//! 钉不了的时候由 core 报错(本层不猜、不改写用户给的值) +//! --dry-run 只查版本并打印将要下载的版本串与 URL,不落盘 +//! ``` + +use unlucky_core::game::java::JdkVendor; + +use super::super::model::Command; +use super::*; + +pub(super) fn parse_java(tail: &[String]) -> Result { + match tail.first().map(String::as_str) { + None | Some("list") => { + ensure_no_extra(tail.get(1..).unwrap_or_default(), "java list")?; + Ok(Command::JavaList) + } + Some("vendors") => { + ensure_no_extra(tail.get(1..).unwrap_or_default(), "java vendors")?; + Ok(Command::JavaVendors) + } + Some("install") => parse_java_install(tail.get(1..).unwrap_or_default()), + Some(other) => Err(CliError::usage(format!( + "java 的子命令只有 list|install|vendors,收到 `{other}`" + ))), + } +} + +/// `java install [--vendor X] [--version V] [--dry-run]`。 +fn parse_java_install(args: &[String]) -> Result { + let parsed = parse_flags(args, &["vendor", "version"], &["dry-run"])?; + let raw = require_single_positional(&parsed, "java install ")?; + let major = raw + .parse::() + .map_err(|_| CliError::usage(format!("JDK 主版本需要整数,收到 `{raw}`")))?; + let vendor = match parsed.value("vendor") { + Some(text) => Some(JdkVendor::parse(&text).ok_or_else(|| { + CliError::usage(format!( + "--vendor 只有 temurin|oracle|microsoft|zulu,收到 `{text}`" + )) + })?), + None => None, + }; + // 版本串**原样**往下传(`21.0.13+9` / `jdk-21.0.13+9` / `21.0.13` 各有语义)。 + // 空值在这里就拦掉:`--version ""` 到 core 会变成一个没有任何版本能匹配的请求。 + let version = match parsed.value("version") { + Some(text) if text.trim().is_empty() => { + return Err( + CliError::usage("--version 需要一个版本串,例如 --version 21.0.13+9") + .with_hint("%prog% help java 看各发行版能不能指定版本"), + ); + } + Some(text) => Some(text.trim().to_string()), + None => None, + }; + Ok(Command::JavaInstall { + major, + vendor, + version, + dry_run: parsed.has("dry-run"), + }) +} + +#[cfg(test)] +mod tests { + use unlucky_core::game::java::JdkVendor; + + use crate::cli::{parse, Command}; + use crate::error::CliError; + + fn parse_args(args: &[&str]) -> Result { + let owned: Vec = args.iter().map(|arg| arg.to_string()).collect(); + parse(&owned).map(|invocation| invocation.command) + } + + fn command(args: &[&str]) -> Command { + parse_args(args).expect("应解析") + } + + /// M280:`--version` 与 `--dry-run` 是 `java install` 的旗标,不是全局 `--version`。 + /// + /// 变异证明:把全局 `--version` 的 `if rest.is_empty()` 守卫去掉 ⇒ 本用例第一条红 + /// (解析成 `Command::Version`,装版本的需求被吞掉)。 + #[test] + fn pinned_version_and_dry_run_are_install_flags() { + assert_eq!( + command(&["java", "install", "21", "--version", "21.0.13+9"]), + Command::JavaInstall { + major: 21, + vendor: None, + version: Some("21.0.13+9".to_string()), + dry_run: false, + } + ); + assert_eq!( + command(&["java", "install", "21", "--dry-run"]), + Command::JavaInstall { + major: 21, + vendor: None, + version: None, + dry_run: true, + } + ); + assert_eq!( + command(&[ + "java", + "install", + "21", + "--vendor", + "oracle", + "--version", + "21.0.2", + "--dry-run", + ]), + Command::JavaInstall { + major: 21, + vendor: Some(JdkVendor::Oracle), + version: Some("21.0.2".to_string()), + dry_run: true, + } + ); + } + + /// 空版本串在解析层就拦住(到 core 会变成「没有任何版本能匹配」的请求)。 + #[test] + fn an_empty_version_is_a_usage_error() { + assert!(parse_args(&["java", "install", "21", "--version", ""]).is_err()); + assert!(parse_args(&["java", "install", "21", "--version"]).is_err()); + } +} diff --git a/crates/cli/src/commands/java.rs b/crates/cli/src/commands/java.rs index 0608313..a9b1167 100644 --- a/crates/cli/src/commands/java.rs +++ b/crates/cli/src/commands/java.rs @@ -16,11 +16,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use serde_json::{json, Value}; -use unlucky_core::download::blocking::block_on; use unlucky_core::download::{DownloadEngine, RegionProfile}; use unlucky_core::game::java::{ - self, location, vendor, InstallRequest, JavaOrigin, JavaRuntime, JdkVendor, LocationMode, - DEFAULT_MAJOR, + self, location, vendor, JavaOrigin, JavaRuntime, JdkVendor, LocationMode, DEFAULT_MAJOR, }; use unlucky_core::launch::request::JavaSelection; use unlucky_core::platform::paths::ProcessEnv; @@ -31,12 +29,10 @@ use crate::context::Ctx; use crate::error::CliError; use crate::output::Report; +pub use super::java_install::install; pub use super::java_launch::{resolve_for_launch, LaunchJava}; pub use super::java_vendors::vendors; -/// 可自动安装的最老大版本(再老的 Temurin 没有 GA 产物)。 -const MIN_INSTALLABLE_MAJOR: u32 = 8; - /// 装配用的 Java 选择(`--java` > `JAVA_HOME` > PATH 上的 `java`)。 /// /// `snapshot::prepare` 调它拿 [`JavaSelection`];真正的来源优先级在 @@ -131,83 +127,6 @@ pub fn list(ctx: &Ctx) -> Result { Ok(Report::new(data, human)) } -/// `ull java install [--vendor X]`:回退链查版本 → 下载 → 解压 → `java -version` 验证。 -/// -/// 幂等:同一个归档装过就直接复用(不联网、不解压)。下载走 `core::download` 引擎, -/// 国内环境按 D7 的地区策略优先院校/国内镜像。 -/// -/// # Errors -/// -/// 大版本不合法、平台无产物、所有发行版都失败(网络/校验/解压/跑不起来)时返回用户错误。 -pub fn install(ctx: &Ctx, major: u32, vendor: Option) -> Result { - if !(MIN_INSTALLABLE_MAJOR..=99).contains(&major) { - return Err(CliError::usage(format!( - "大版本号看起来不对:{major}(可用 8 / 11 / 17 / 21 / 25 这类 GA 大版本)" - ))); - } - let settings = JavaSettings::load(ctx); - let engine = build_engine(ctx); - let install_root = settings.location(ctx)?; - let request = match vendor { - Some(vendor) => { - InstallRequest::with_vendor(major, vendor).with_priority(settings.priority.clone()) - } - None => InstallRequest::new(major).with_priority(settings.priority.clone()), - }; - let installed = - block_on(java::install(&engine, &install_root, request)).map_err(map_java_error)?; - - let data = json!({ - "major": installed.major, - "vendor": installed.vendor.id(), - "release": installed.release.release_name, - "version": installed.release.version, - "semver": installed.release.semver, - "archive": installed.release.archive.name, - "sha256": installed.release.archive.sha256, - "size": installed.release.archive.size, - "root": installed.root.display().to_string(), - "install_mode": installed.mode.as_str(), - "binary": installed.binary().display().to_string(), - "reused": installed.reused(), - "source": installed.downloaded, - "bytes": installed.bytes, - "files": installed.files, - "extracted": installed.extracted, - "chain": installed.chain.to_json(), - "runtime": candidate_json(&installed.runtime), - }); - - let mut human = Vec::new(); - if installed.reused() { - human.push(format!( - "Java {}({})已就绪({}),未走网络", - installed.major, - installed.vendor.id(), - installed.root.display() - )); - } else { - human.push(format!( - "已安装 Java {}({},来源 {},{} 字节 / {} 个文件)", - installed.major, - installed.release.summary(), - installed.downloaded.as_deref().unwrap_or("?"), - installed.bytes, - installed.files - )); - } - human.push(format!("落点:{}", installed.location_line())); - human.push(format!("可执行文件:{}", installed.binary().display())); - if let Some(version) = &installed.runtime.version { - human.push(format!("探测:{version}")); - } - for note in installed.chain.degradations() { - human.push(format!("降级:{note}")); - } - human.push("`%prog% launch` 会自动优先用它".to_string()); - Ok(Report::new(data, human)) -} - /// 设置里与 JDK 有关的两项(`java.installRoot` / `java.vendorPriority`)。 #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct JavaSettings { @@ -255,7 +174,7 @@ impl JavaSettings { } /// 真正要装到哪(解析失败 → 用户错误,带可照着做的提示)。 - fn location(&self, ctx: &Ctx) -> Result { + pub(super) fn location(&self, ctx: &Ctx) -> Result { java::JavaInstallRoot::resolve( ctx.data.mode, &ctx.layout(), @@ -268,7 +187,7 @@ impl JavaSettings { } /// 落点模式 → 人读一行(含「没设覆盖键时默认落在哪」的解释)。 -fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String { +pub(super) fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String { match mode { LocationMode::Custom => format!( "custom(java.installRoot = {})", @@ -282,7 +201,7 @@ fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String { } /// 一个运行时的 `--json` 形状。 -fn candidate_json(runtime: &JavaRuntime) -> Value { +pub(super) fn candidate_json(runtime: &JavaRuntime) -> Value { json!({ "binary": runtime.binary.display().to_string(), "source": runtime.origin.as_str(), @@ -314,7 +233,7 @@ pub(crate) fn build_engine(ctx: &Ctx) -> Arc { } /// JDK 下载/解压错误 → CLI 错误(保留稳定码与提示)。 -fn map_java_error(error: java::JavaError) -> CliError { +pub(super) fn map_java_error(error: java::JavaError) -> CliError { let cli = CliError::user(error.code(), error.message().to_string()); match error.hint() { Some(hint) => cli.with_hint(hint.to_string()), diff --git a/crates/cli/src/commands/java_install.rs b/crates/cli/src/commands/java_install.rs new file mode 100644 index 0000000..21d8a7f --- /dev/null +++ b/crates/cli/src/commands/java_install.rs @@ -0,0 +1,174 @@ +//! `ull java install`:回退链查版本 → 下载 → 解压 → `java -version` 验证(以及 `--dry-run`)。 +//! +//! 从 `commands/java.rs` 拆出来只为守 400 行硬线(M280 的 `--version` / `--dry-run` 让那边到 +//! 421 行);`list` / 设置读取 / 渲染助手仍在 `java.rs`。与 `java_launch.rs` / `java_vendors.rs` +//! 同一个拆法。 +//! +//! 版本这一维(M280):`--version` 不给就是该家**最新 GA**(与加旗标之前逐字节相同); +//! 给了就要求**精确**那一版——能按版本取的只有 Temurin / Oracle,Microsoft 只认它内置的 +//! 那份快照,Zulu 只有 latest。钉不了时 core 报错并列出可用版本,**绝不回落到最新**。 + +use serde_json::json; + +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::DownloadEngine; +use unlucky_core::game::java::{self, InstallRequest, JdkVendor}; + +use super::java::{build_engine, candidate_json, map_java_error, JavaSettings}; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// 可自动安装的最老大版本(再老的 Temurin 没有 GA 产物)。 +const MIN_INSTALLABLE_MAJOR: u32 = 8; + +/// `ull java install [--vendor X] [--version V] [--dry-run]`: +/// 回退链查版本 → 下载 → 解压 → `java -version` 验证。 +/// +/// 幂等:同一个归档装过就直接复用(不联网、不解压)。下载走 `core::download` 引擎, +/// 国内环境按 D7 的地区策略优先院校/国内镜像。 +/// +/// `--dry-run` 只查版本:打印最终会打哪个版本串、哪个 URL,一个字节都不落盘。 +/// +/// # Errors +/// +/// 大版本不合法、平台无产物、该家没有这个版本、所有发行版都失败(网络/校验/解压/跑不起来)时返回用户错误。 +pub fn install( + ctx: &Ctx, + major: u32, + vendor: Option, + version: Option<&str>, + dry_run: bool, +) -> Result { + if !(MIN_INSTALLABLE_MAJOR..=99).contains(&major) { + return Err(CliError::usage(format!( + "大版本号看起来不对:{major}(可用 8 / 11 / 17 / 21 / 25 这类 GA 大版本)" + ))); + } + let settings = JavaSettings::load(ctx); + let engine = build_engine(ctx); + let install_root = settings.location(ctx)?; + let request = match vendor { + Some(vendor) => { + InstallRequest::with_vendor(major, vendor).with_priority(settings.priority.clone()) + } + None => InstallRequest::new(major).with_priority(settings.priority.clone()), + } + .with_version(version.map(str::to_string)); + if dry_run { + return dry_run_report(&engine, &install_root, &request); + } + let installed = + block_on(java::install(&engine, &install_root, request.clone())).map_err(map_java_error)?; + + let data = json!({ + "major": installed.major, + "vendor": installed.vendor.id(), + "release": installed.release.release_name, + "version": installed.release.version, + "requested_version": request.version, + "semver": installed.release.semver, + "archive": installed.release.archive.name, + "sha256": installed.release.archive.sha256, + "size": installed.release.archive.size, + "root": installed.root.display().to_string(), + "install_mode": installed.mode.as_str(), + "binary": installed.binary().display().to_string(), + "reused": installed.reused(), + "source": installed.downloaded, + "bytes": installed.bytes, + "files": installed.files, + "extracted": installed.extracted, + "chain": installed.chain.to_json(), + "runtime": candidate_json(&installed.runtime), + }); + + let mut human = Vec::new(); + if installed.reused() { + human.push(format!( + "Java {}({})已就绪({}),未走网络", + installed.major, + installed.vendor.id(), + installed.root.display() + )); + } else { + human.push(format!( + "已安装 Java {}({},来源 {},{} 字节 / {} 个文件)", + installed.major, + installed.release.summary(), + installed.downloaded.as_deref().unwrap_or("?"), + installed.bytes, + installed.files + )); + } + if let Some(wanted) = &request.version { + human.push(format!( + "指定版本:{wanted} → 命中 {}", + installed.release.version + )); + } + human.push(format!("落点:{}", installed.location_line())); + human.push(format!("可执行文件:{}", installed.binary().display())); + if let Some(version) = &installed.runtime.version { + human.push(format!("探测:{version}")); + } + for note in installed.chain.degradations() { + human.push(format!("降级:{note}")); + } + human.push("`%prog% launch` 会自动优先用它".to_string()); + Ok(Report::new(data, human)) +} + +/// `--dry-run`:查一次版本(与真装走同一条选择路径),打印版本串与 URL,不下载、不解压、不写盘。 +/// +/// 查版本本身仍要联网,但它把「会装哪一版、打哪个 URL」在下载之前摆出来—— +/// 验证版本选择逻辑不必先下 200 MB。 +fn dry_run_report( + engine: &DownloadEngine, + location: &java::JavaInstallRoot, + request: &InstallRequest, +) -> Result { + let (release, root, ready) = + block_on(java::plan(engine, location, request)).map_err(map_java_error)?; + let data = json!({ + "dry_run": true, + "major": release.major, + "vendor": release.vendor.id(), + "version": release.version, + "requested_version": request.version, + "semver": release.semver, + "release": release.release_name, + "archive": release.archive.name, + "url": release.archive.url, + "sha256": release.archive.sha256, + "size": release.archive.size, + "root": root.display().to_string(), + "install_mode": location.mode.as_str(), + "already_installed": ready, + }); + let mut human = vec![format!( + "将安装 Java {}({})", + release.major, + release.summary() + )]; + if let Some(wanted) = &request.version { + human.push(format!("指定版本:{wanted} → 命中 {}", release.version)); + } + human.push(format!("版本:{}", release.version)); + human.push(format!("URL:{}", release.archive.url)); + human.push(format!( + "落点:{}({})", + root.display(), + location.mode.as_str() + )); + human.push(format!( + "已装好:{}", + if ready { + "是(会直接复用,不联网)" + } else { + "否(会下载)" + } + )); + human.push("(--dry-run:没有下载、没有解压、没有写盘)".to_string()); + Ok(Report::new(data, human)) +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 960f5f8..d709723 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod install; pub mod instance_launch; pub mod instances; pub mod java; +pub mod java_install; pub mod java_launch; pub mod java_vendors; pub mod launch; @@ -58,8 +59,13 @@ pub fn run(invocation: &Invocation) -> Result { config::set(ctx, &args.key, &args.value) }), Command::JavaList => with_ctx(&invocation.globals, java::list), - Command::JavaInstall { major, vendor } => with_ctx(&invocation.globals, |ctx| { - java::install(ctx, *major, *vendor) + Command::JavaInstall { + major, + vendor, + version, + dry_run, + } => with_ctx(&invocation.globals, |ctx| { + java::install(ctx, *major, *vendor, version.as_deref(), *dry_run) }), Command::JavaVendors => with_ctx(&invocation.globals, java::vendors), Command::UpdateCheck(args) => { diff --git a/crates/cli/tests/parse_test.rs b/crates/cli/tests/parse_test.rs index aa2e602..ca6ab0e 100644 --- a/crates/cli/tests/parse_test.rs +++ b/crates/cli/tests/parse_test.rs @@ -236,7 +236,9 @@ fn java_subcommands() { invoke(&["java", "install", "21"]).command, Command::JavaInstall { major: 21, - vendor: None + vendor: None, + version: None, + dry_run: false, } ); // D23:`--vendor` 钉死发行版(别名也认),`java vendors` 列出四家。 @@ -245,6 +247,8 @@ fn java_subcommands() { Command::JavaInstall { major: 21, vendor: Some(unlucky_core::game::java::JdkVendor::Oracle), + version: None, + dry_run: false, } ); assert_eq!( @@ -252,6 +256,8 @@ fn java_subcommands() { Command::JavaInstall { major: 17, vendor: Some(unlucky_core::game::java::JdkVendor::Temurin), + version: None, + dry_run: false, } ); assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors); diff --git a/crates/core/src/game/java/api.rs b/crates/core/src/game/java/api.rs index c04b47e..467e93d 100644 --- a/crates/core/src/game/java/api.rs +++ b/crates/core/src/game/java/api.rs @@ -25,6 +25,10 @@ use super::vendor::JdkVendor; /// Adoptium API 根。 pub const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3"; +mod version; + +pub use version::{fetch_version, parse_release, parse_release_list, release_url, releases_url}; + /// 托管 JDK 的默认供应商标识(D13 v1 的目录名 `jdk--vendor` 沿用它)。 pub const JDK_VENDOR: &str = "temurin"; @@ -112,7 +116,7 @@ pub async fn fetch_latest( /// 响应里的 `package` 子对象。 #[derive(Debug, Deserialize)] -struct ApiPackage { +pub(super) struct ApiPackage { name: String, link: String, #[serde(default)] @@ -121,9 +125,9 @@ struct ApiPackage { size: u64, } -/// 响应里的 `binary` 子对象。 +/// 响应里的 `binary` / `binaries[]` 子对象(`assets/latest` 用前者,`assets/release_name` 用后者)。 #[derive(Debug, Deserialize)] -struct ApiBinary { +pub(super) struct ApiBinary { #[serde(default)] os: Option, #[serde(default)] @@ -134,9 +138,9 @@ struct ApiBinary { package: Option, } -/// 响应里的 `version` 子对象。 +/// 响应里的 `version` / `version_data` 子对象(两者字段同形)。 #[derive(Debug, Deserialize)] -struct ApiVersion { +pub(super) struct ApiVersion { #[serde(default)] major: Option, #[serde(default)] @@ -154,29 +158,30 @@ struct ApiAsset { binary: Option, } -impl ApiAsset { +impl ApiBinary { /// 是否就是目标平台的 jdk 归档(`arch` 参数不过滤,必须自己判)。 - fn matches(&self, target: AdoptiumTarget) -> bool { - let Some(binary) = &self.binary else { - return false; - }; - let same_os = binary.os.as_deref().is_some_and(|os| os == target.os); - let same_arch = binary + pub(super) fn matches(&self, target: AdoptiumTarget) -> bool { + let same_os = self.os.as_deref().is_some_and(|os| os == target.os); + let same_arch = self .architecture .as_deref() .is_some_and(|arch| arch == target.arch); - let is_jdk = binary.image_type.as_deref() != Some("jre"); - same_os && same_arch && is_jdk && binary.package.is_some() + let is_jdk = self.image_type.as_deref() != Some("jre"); + same_os && same_arch && is_jdk && self.package.is_some() } /// 转成对外结果(校验 sha256 形态)。 - fn into_release(self) -> Result { - let package = self - .binary - .and_then(|binary| binary.package) - .ok_or_else(|| { - JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 package 子对象") - })?; + /// + /// `assets/latest` 与 `assets/release_name` 的响应把版本包在不同的外层字段里 + /// (`version` / `version_data` + `release_name`),所以这两个由调用方传进来。 + pub(super) fn into_release( + self, + release_name: String, + version: Option, + ) -> Result { + let package = self.package.ok_or_else(|| { + JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 package 子对象") + })?; let sha256 = package .checksum .as_deref() @@ -189,7 +194,6 @@ impl ApiAsset { ) .with_hint("换一个大版本,或手动装 JDK 后设 JAVA_HOME") })?; - let version = self.version; let major = version .as_ref() .and_then(|version| version.major) @@ -200,7 +204,7 @@ impl ApiAsset { format!("Adoptium 条目里读不出大版本号({})", package.name), ) })?; - let native_version = version_from_release_name(&self.release_name) + let native_version = version_from_release_name(&release_name) .or_else(|| version.as_ref().and_then(|version| version.semver.clone())) .unwrap_or_else(|| major.to_string()); Ok(JdkRelease { @@ -208,7 +212,7 @@ impl ApiAsset { major, version: native_version, semver: version.and_then(|version| version.semver), - release_name: self.release_name, + release_name, archive: JdkArchive { name: package.name, url: package.link, @@ -219,6 +223,23 @@ impl ApiAsset { } } +impl ApiAsset { + /// 是否就是目标平台的 jdk 归档(`arch` 参数不过滤,必须自己判)。 + fn matches(&self, target: AdoptiumTarget) -> bool { + self.binary + .as_ref() + .is_some_and(|binary| binary.matches(target)) + } + + /// 转成对外结果。 + fn into_release(self) -> Result { + let binary = self.binary.ok_or_else(|| { + JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 binary 子对象") + })?; + binary.into_release(self.release_name, self.version) + } +} + /// 从产物名兜底读大版本(`OpenJDK21U-jdk_x64_windows_hotspot_…` → `21`)。 fn major_from_name(name: &str) -> Option { let rest = name.split_once("OpenJDK")?.1; diff --git a/crates/core/src/game/java/api/version.rs b/crates/core/src/game/java/api/version.rs new file mode 100644 index 0000000..dad9ecd --- /dev/null +++ b/crates/core/src/game/java/api/version.rs @@ -0,0 +1,245 @@ +//! Adoptium(Eclipse Temurin)的**指定版本**查询:先枚举该大版本的 GA,把用户给的版本串对上 +//! 一条 release,再用 `assets/release_name` 取那一份归档。 +//! +//! 为什么是两跳(2026-11-12 逐个端点 curl 验过,见 mission M280 报告): +//! +//! 1. `/v3/assets/version/{version}` 在 `/q/openapi` 里有这条路由,但**部署上的服务回 404 空体** +//! (`21.0.13+9` 与 `21.0.13%2B9` 都一样),所以不用它; +//! 2. `GET /v3/assets/release_name/{vendor}/{release_name}`(如 `eclipse/jdk-21.0.13+9`)回 200, +//! 体是 `{release_name, version_data{…}, binaries[]{os,architecture,image_type,package{…}}}` —— +//! 这就是「指定版本」那一跳; +//! 3. 但 `release_name` 要完整名(`jdk-21.0.13+9`),用户给的常是公共版本号(`21.0.13`), +//! 所以先打 `GET /v3/assets/feature_releases/{major}/ga?…&sort_order=DESC&page_size=20` +//! (openapi 里 `page_size` 的 `maximum` 就是 20)把前者翻成后者。同一份响应的 release 名 +//! 列表同时是「这个大版本有哪些版本」的唯一来源——版本不存在时就靠它列给人看。 +//! +//! 这里**只有取**:匹配用 [`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::error::{JavaError, JavaErrorKind}; +use crate::game::java::net; +use crate::game::java::release::JdkRelease; +use crate::game::java::target::AdoptiumTarget; +use crate::game::java::vendor::JdkVendor; + +/// `feature_releases` 的每页条数(Adoptium 允许的上限)。 +pub const PAGE_SIZE: usize = 20; + +/// 某大版本的 GA 清单地址(新 → 旧)。 +#[must_use] +pub fn releases_url(major: u32, target: AdoptiumTarget) -> 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", + target.os, target.arch + ) +} + +/// 指定 release 名的产物地址。 +#[must_use] +pub fn release_url(release_name: &str, target: AdoptiumTarget) -> String { + format!( + "{ADOPTIUM_API_BASE}/assets/release_name/eclipse/{release_name}?os={}&arch={}&image_type=jdk&vendor=eclipse", + target.os, target.arch + ) +} + +/// `feature_releases` / `release_name` 响应里的一条(只有这几项用得上)。 +#[derive(Debug, Deserialize)] +struct ApiRelease { + #[serde(default)] + release_name: String, + #[serde(default)] + version_data: Option, + #[serde(default)] + binaries: Vec, +} + +/// GA 清单响应 → release 名列表(`jdk-21.0.13+9`,上游给的顺序即新 → 旧)。 +/// +/// # Errors +/// +/// 响应不是 JSON 时返回 [`JavaErrorKind::Metadata`](调用方据此降级到下一家发行版)。 +pub fn parse_release_list(json: &str) -> Result, JavaError> { + let releases: Vec = serde_json::from_str(json).map_err(|error| { + JavaError::new( + JavaErrorKind::Metadata, + format!("Adoptium GA 清单不是预期 JSON:{error}"), + ) + })?; + Ok(releases + .into_iter() + .map(|release| release.release_name) + .filter(|name| !canonical_version(name).is_empty()) + .collect()) +} + +/// 指定版本的响应 → 一份可下载归档(挑出目标平台的 jdk 那一条)。 +/// +/// # Errors +/// +/// 响应不是 JSON、没有目标平台产物、sha256 不合法时返回错误。 +pub fn parse_release( + json: &str, + expected: &str, + target: AdoptiumTarget, +) -> Result { + let release: ApiRelease = serde_json::from_str(json).map_err(|error| { + JavaError::new( + JavaErrorKind::Metadata, + format!("Adoptium 指定版本响应不是预期 JSON:{error}"), + ) + })?; + let name = if release.release_name.is_empty() { + expected.to_string() + } else { + release.release_name + }; + let binary = release + .binaries + .into_iter() + .find(|binary| binary.matches(target)) + .ok_or_else(|| { + JavaError::new( + JavaErrorKind::Metadata, + format!( + "Adoptium 的 {name} 没有 {}/{} 的 jdk 产物", + target.os, target.arch + ), + ) + })?; + binary.into_release(name, release.version_data) +} + +/// 查一次「某大版本的指定版本」。 +/// +/// # Errors +/// +/// 清单不可达、该大版本没有版本(错误里列出可用版本)、目标平台无产物、sha256 缺失时返回错误。 +pub async fn fetch_version( + client: &reqwest::Client, + major: u32, + 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 versions = releases + .iter() + .map(|name| canonical_version(name).to_string()) + .collect::>(); + return Err(version_pin_error( + JdkVendor::Temurin, + major, + wanted, + &versions, + )); + }; + let body = net::get_text(client, &release_url(&name, target), "Adoptium 指定版本产物").await?; + let release = parse_release(&body, &name, target)?; + if release.major != major { + return Err(JavaError::new( + JavaErrorKind::Metadata, + format!( + "Adoptium 的 {name} 属于 Java {},不是请求的 {major}", + release.major + ), + )); + } + Ok(release) +} + +#[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); + } +} diff --git a/crates/core/src/game/java/catalog.rs b/crates/core/src/game/java/catalog.rs index bd76bc3..e482a60 100644 --- a/crates/core/src/game/java/catalog.rs +++ b/crates/core/src/game/java/catalog.rs @@ -14,7 +14,15 @@ //! //! [`BASELINE_MAJOR`](17)是 D13 定的基准:缺 Java 时先装它。它同时也是目录的一员, //! 但不是唯一一员——`26.1` 起 MC 要 25,`1.12.2` 只能吃 8,所以要能挑。 +//! +//! ## 指定版本(M280) +//! +//! 「用户给的版本串是不是这一份」只有一个判据:[`matches_version`]。四家的版本串语义不同 +//! (Temurin `21.0.13+9`、Oracle `21.0.2`、MS `21.0.13.0.2`、Zulu `21.0.13.0.3`), +//! 但「相等」这件事可以统一:先按 [`public_version`] 去掉构建号段(`+9`)再比。 +//! 各家模块只负责**把候选版本喂进来**,不许自己再写一份前缀/包含判断。 +use super::error::{JavaError, JavaErrorKind}; use super::vendor::{self, JdkVendor}; /// D13 的基准 JDK 大版本(没有上下文时「装哪个」的默认答案)。 @@ -104,11 +112,148 @@ pub fn default_major(required: Option) -> u32 { .unwrap_or(BASELINE_MAJOR) } +/// 版本串的规范化:去首尾空白 + 去掉 `jdk-` 前缀(Adoptium 的 release 名是 `jdk-21.0.13+9`)。 +#[must_use] +pub fn canonical_version(text: &str) -> &str { + let trimmed = text.trim(); + trimmed + .strip_prefix("jdk-") + .or_else(|| trimmed.strip_prefix("JDK-")) + .unwrap_or(trimmed) + .trim() +} + +/// 版本串的「公共部分」:`21.0.13+9` / `21.0.13+9.0.LTS` → `21.0.13`(`+` 之后是构建号)。 +/// +/// 目录 `/ 戳记 / --json` 用的是 vendor 原生整串,只有「是不是同一个版本」这件事看公共部分。 +#[must_use] +pub fn public_version(text: &str) -> &str { + let canonical = canonical_version(text); + let end = canonical.find('+').unwrap_or(canonical.len()); + canonical[..end].trim_end() +} + +/// 构建号的首段数字(`+9` / `+9.0.LTS` / `+9-LTS` 都归成 `9`;没有 `+` 段时 `None`)。 +fn build_head(text: &str) -> Option { + let canonical = canonical_version(text); + let rest = canonical.get(canonical.find('+')? + 1..)?; + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + digits.parse().ok() +} + +/// 用户点名的版本就是这份候选吗(四家共用的**唯一**判据)。 +/// +/// 三种情形都算命中:整串相同(`21.0.13+9`)、只给公共部分(`21.0.13` ↔ `21.0.13+9`)、 +/// 构建号首段相同(`21.0.13+9` ↔ `21.0.13+9.0.LTS`)。 +/// **不做**前缀近似:`21.0` 不算 `21.0.13`,`21.0.13` 也不算 Microsoft 的 `21.0.13.0.2` +/// —— 少一位就是另一个版本,宁可报错并列出该大版本的可用版本。 +#[must_use] +pub fn matches_version(requested: &str, candidate: &str) -> bool { + let wanted = canonical_version(requested); + let found = canonical_version(candidate); + if wanted.is_empty() || found.is_empty() || public_version(wanted) != public_version(found) { + return false; + } + match (build_head(wanted), build_head(found)) { + // 只给公共部分:该公共部分的唯一产物就是它。 + (None, _) => true, + (Some(wanted), Some(found)) => wanted == found, + // 用户点名了构建号、候选里没给:不能当作命中(可能不是同一个构建)。 + (Some(_), None) => false, + } +} + +/// 兜底闸:实际拿到的版本必须真是用户点名的那一版(`None` = 没钉版本,永远通过)。 +/// +/// 这是「不静默回落」的最后一道保险:即使某家的实现哪天真回落到了 latest, +/// 调用方(`install` / `plan`)也会把它记成一次失败并继续走链,而不是把 latest +/// 当成用户要的版本装下去。 +#[must_use] +pub fn version_mismatch(wanted: Option<&str>, found: &str) -> Option { + let wanted = wanted?; + (!matches_version(wanted, found)).then(|| format!("返回的版本是 {found},不是请求的 {wanted}")) +} + +/// 可用版本列表的人读形态(错误里列给人看;超过 `limit` 条就收口成「等 N 个」)。 +#[must_use] +pub fn available_versions_text(versions: &[String], limit: usize) -> String { + if versions.is_empty() { + return "(上游一个都没列出来)".to_string(); + } + let shown = versions + .iter() + .take(limit) + .map(String::as_str) + .collect::>() + .join(" / "); + if versions.len() > limit { + format!("{shown} 等 {} 个", versions.len()) + } else { + shown + } +} + +/// 「这家装不了你要的那个版本」的错误:**四家文案互不相同**,各自说清原因 + 一句「哪家能做」。 +/// +/// `available` 是这家真正能给的版本串(Temurin/Oracle 给该大版本的清单,MS 给快照里那一个, +/// Zulu/Termux 给空数组——上游根本没有版本清单)。这条错误是用户唯一能看见的下一步, +/// 所以「不静默回落、也不假装支持」这句话只说一次,就在这儿说。 +#[must_use] +pub fn version_pin_error( + vendor: JdkVendor, + major: u32, + wanted: &str, + available: &[String], +) -> JavaError { + let listed = available_versions_text(available, 10); + let try_other = "要这个版本换一家:`--vendor temurin`(Adoptium 全量补丁)或 `--vendor oracle`"; + let vendor_id = vendor.id(); + match vendor { + JdkVendor::Temurin | JdkVendor::Oracle => { + let source = if vendor == JdkVendor::Temurin { + "Adoptium" + } else { + "Oracle 镜像目录" + }; + JavaError::new( + JavaErrorKind::Metadata, + format!("{vendor_id} 的 Java {major} 没有版本 {wanted}({source} 里可用:{listed})"), + ) + .with_hint("不给 --version 就装该大版本最新的 GA;指定版本要写上面列出的形态") + } + JdkVendor::Microsoft => JavaError::new( + JavaErrorKind::Metadata, + format!( + "microsoft 的 Java {major} 只内置了 {listed} 这一份快照,没有 {wanted}(这家不能自选版本,要自选请换 temurin 或 oracle)" + ), + ) + .with_hint(format!( + "微软这套版本号写死在快照里:装它就 `ull java install {major} --vendor microsoft`;{try_other}" + )), + JdkVendor::Zulu => JavaError::new( + JavaErrorKind::Metadata, + format!("zulu 只查最新一份(api 的 latest=true),不能指定版本 {wanted}(要自选请换 temurin 或 oracle)"), + ) + .with_hint(format!( + "只装该大版本最新的 Zulu 就去掉 --version;{try_other}" + )), + JdkVendor::Termux => JavaError::new( + JavaErrorKind::Unsupported, + format!( + "termux 的 JDK 由系统包(pkg install openjdk-{major})决定版本,不能指定 {wanted}(普通平台上要自选请换 temurin 或 oracle)" + ), + ) + .with_hint("去掉 --version 让系统包装它给的那份;要指定版本只能在普通平台上装归档"), + } +} + #[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`)。 @@ -168,4 +313,88 @@ mod tests { 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("系统包")); + } } diff --git a/crates/core/src/game/java/install.rs b/crates/core/src/game/java/install.rs index 6377579..126af2a 100644 --- a/crates/core/src/game/java/install.rs +++ b/crates/core/src/game/java/install.rs @@ -3,7 +3,7 @@ //! ```text //! [Termux] termux_install::attempt 系统包(.deb)——与下面这条链不共用任何一步 //! vendor::order(用户优先级, 地区) D23 的尝试顺序 -//! → source::fetch(vendor, major) 查到该家最新 GA(Temurin/Oracle/MS/Zulu) +//! → source::fetch(vendor, major, 版本) 该家最新 GA,或 `--version` 指定的那一版 //! → location::JavaInstallRoot::jdk_dir 按落点模式算目录 //! → [戳记 sha256 相同 && bin/java 在] 复用 幂等的第一层 //! → engine.download(JavaRuntime 请求) 镜像/健康度/退避/续传/边下边算全在引擎 @@ -28,6 +28,7 @@ use std::path::{Path, PathBuf}; +use super::catalog; use super::error::{JavaError, JavaErrorKind}; use super::extract; use super::location::{JavaInstallRoot, LocationMode}; @@ -35,6 +36,7 @@ use super::probe::{self, JavaRuntime}; use super::release::JdkRelease; use super::source::{self, ChainLog}; use super::target::AdoptiumTarget; +use super::termux; use super::vendor::{self, JdkVendor}; use crate::download::{DownloadEngine, DownloadRequest, ResourceKind}; @@ -55,6 +57,11 @@ pub struct InstallRequest { pub vendor: Option, /// 用户优先级(`java.vendorPriority`,空 = 按地区默认序)。 pub priority: Vec, + /// 钉死具体版本(`--version`);`None` = 该家**最新 GA**(M280 之前的唯一行为)。 + /// + /// 能不能钉由各家的上游决定(Temurin/Oracle 能、Microsoft 只认它的内置快照、 + /// Zulu 只有 latest)——不能钉时**报错**,不会回落到最新还声称装成了这一版。 + pub version: Option, } impl InstallRequest { @@ -65,6 +72,7 @@ impl InstallRequest { major, vendor: None, priority: Vec::new(), + version: None, } } @@ -75,6 +83,7 @@ impl InstallRequest { major, vendor: Some(vendor), priority: Vec::new(), + version: None, } } @@ -85,6 +94,13 @@ impl InstallRequest { self } + /// 钉死具体版本(`--version`;`None` 与不调用本方法完全等价)。 + #[must_use] + pub fn with_version(mut self, version: Option) -> Self { + self.version = version; + self + } + /// 实际尝试顺序(`--vendor` 钉死时链里只放它)。 #[must_use] pub fn order(&self, domestic: bool) -> Vec { @@ -200,23 +216,47 @@ pub async fn install( ) -> Result { let domestic = engine.region().is_domestic(); let priority = request.order(domestic); + let wanted = request.version.as_deref(); let mut reasons: Vec = Vec::new(); // 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。 let _ = super::location::cleanup_orphans(&location.root); // Termux 那一条路(系统包)单独成模块:它与归档链不共用任何一步,见 `termux_install`。 // 它把 chain 还回来(没走成时),归档链接着往上记。 - let mut chain = match termux_install::attempt( - request.vendor, - request.major, - location, - ChainLog::new(), - &mut reasons, - ) - .await? - { - termux_install::Outcome::Done(installed) => return Ok(*installed), - termux_install::Outcome::Skipped(chain) => chain, + // 系统包的版本由包管理器决定,所以钉了版本(`--version`)就不走它:`--vendor termux` + // 直接报「不能指定版本」,其余情况记一条跳过原因后交给归档链。 + let mut chain = match wanted { + Some(wanted) if request.vendor == Some(JdkVendor::Termux) => { + return Err(catalog::version_pin_error( + JdkVendor::Termux, + request.major, + wanted, + &[], + )); + } + Some(_) => { + let mut chain = ChainLog::new(); + // 只有真在 Termux 上才需要解释「为什么不走系统包」(别的平台这条路本来就不存在)。 + if termux::available() { + chain.skip( + JdkVendor::Termux, + "系统包不能指定版本(--version),改走归档链", + ); + } + chain + } + None => match termux_install::attempt( + request.vendor, + request.major, + location, + ChainLog::new(), + &mut reasons, + ) + .await? + { + termux_install::Outcome::Done(installed) => return Ok(*installed), + termux_install::Outcome::Skipped(chain) => chain, + }, }; // 归档发行版需要 Adoptium 的平台参数;Termux/Android 上这里会 Unsupported, @@ -245,7 +285,7 @@ pub async fn install( continue; } }; - let release = match source::fetch(engine, vendor, request.major, target).await { + let release = match source::fetch(engine, vendor, request.major, target, wanted).await { Ok(release) => release, Err(error) => { chain.failed(vendor, error.message().to_string()); @@ -253,6 +293,12 @@ pub async fn install( 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())); @@ -278,7 +324,7 @@ pub async fn install( } } - Err(JavaError::new( + let error = JavaError::new( JavaErrorKind::Metadata, format!( "没有装成 Java {}(试过 {}):{}", @@ -286,40 +332,59 @@ pub async fn install( priority.len(), reasons.join(";") ), - ) - .with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME")) + ); + // 钉了版本时给一句能照着做的:哪几家真能按版本装(默认路径的提示一个字都不动)。 + Err(if wanted.is_some() { + error.with_hint( + "能按版本装的是 temurin / oracle;microsoft 只认它内置的快照,zulu 上游只提供 latest。\ + `ull java vendors` 看各发行版的可用性", + ) + } else { + error.with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME") + }) } /// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。 /// +/// `--dry-run` 用它:`request.version` 与 `--vendor` 都参与选择(钉了版本就把这一维钉死), +/// 所以「最后会打哪个 URL、装哪个版本串」在下载之前就能看见——这也是不联网时 +/// 唯一能验证版本选择逻辑的输出。 +/// /// # Errors /// -/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 所有发行版都没有该大版本)。 +/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 该家没有这个版本)。 pub async fn plan( engine: &DownloadEngine, location: &JavaInstallRoot, - major: u32, + 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 vendor::order(&[], domestic) { - if !vendor.supports(major) { - reasons.push(format!("{}:不支持 Java {major}", vendor.id())); + 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; } - match source::fetch(engine, vendor, major, target).await { - Ok(release) => { - 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(error) => reasons.push(format!("{}:{}", vendor.id(), error.message())), - } + 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 {major}({} )", reasons.join(";")), + format!( + "没有可用的 Java {}({} )", + request.major, + reasons.join(";") + ), )) } diff --git a/crates/core/src/game/java/microsoft.rs b/crates/core/src/game/java/microsoft.rs index 957c59c..893b94f 100644 --- a/crates/core/src/game/java/microsoft.rs +++ b/crates/core/src/game/java/microsoft.rs @@ -16,6 +16,7 @@ //! 3. **GitHub 侧没有任何资产**(`microsoft/openjdk` 的 release `assets` 全空), //! 所以 gh-proxy 对这条链毫无用处——`domestic_mirrors` 实测为空数组。 +use super::catalog::{matches_version, version_pin_error}; use super::error::{JavaError, JavaErrorKind}; use super::net; use super::release::{parse_sha256_text, JdkArchive, JdkRelease}; @@ -137,16 +138,7 @@ pub async fn fetch_latest( major: u32, target: AdoptiumTarget, ) -> Result { - let version = snapshot_version(major).ok_or_else(|| { - JavaError::new( - JavaErrorKind::Metadata, - format!( - "Microsoft OpenJDK 没有 {major} 的 GA(只发 {})", - majors_text() - ), - ) - .with_hint("8 用 temurin;其它大版本用 oracle / zulu") - })?; + let version = snapshot_version(major).ok_or_else(|| missing_major(major))?; let filename = archive_name(version, target); let sha_url = expand_template(&table.sha256_template, version, target); let sha_text = net::get_text(client, &sha_url, "Microsoft OpenJDK 校验文件").await?; @@ -171,6 +163,47 @@ pub async fn fetch_latest( }) } +/// 查一次「某大版本的指定版本 MS OpenJDK」。 +/// +/// 微软没有「按版本查」的接口(官方只有人读 HTML 表),[`SNAPSHOT`] 里每个大版本只有**一个** +/// 版本号(`size` 也是按它实测的),所以只认快照里那一个:别的版本号**明确报错** +/// (文案在 `catalog::version_pin_error`:说清这家不能自选版本、可用的是哪一个), +/// 绝不回落到 latest 假装装成了。 +/// +/// # Errors +/// +/// 该大版本微软不发(含 8)、以及请求的版本号不是快照里那一个时返回错误。 +pub async fn fetch_version( + client: &reqwest::Client, + table: &MsOpenJdkTable, + major: u32, + target: AdoptiumTarget, + wanted: &str, +) -> Result { + let known = snapshot_version(major).ok_or_else(|| missing_major(major))?; + if !matches_version(wanted, known) { + return Err(version_pin_error( + JdkVendor::Microsoft, + major, + wanted, + &[known.to_string()], + )); + } + fetch_latest(client, table, major, target).await +} + +/// 「微软不发这个 Java 大版本」(含 8)。 +fn missing_major(major: u32) -> JavaError { + JavaError::new( + JavaErrorKind::Metadata, + format!( + "Microsoft OpenJDK 没有 {major} 的 GA(只发 {})", + majors_text() + ), + ) + .with_hint("8 用 temurin;其它大版本用 oracle / zulu") +} + fn majors_text() -> String { SNAPSHOT .iter() @@ -182,13 +215,41 @@ fn majors_text() -> String { #[cfg(test)] mod tests { use super::{ - archive_name, expand_template, ms_os, parse_version, snapshot_size, snapshot_version, - SNAPSHOT, + archive_name, expand_template, fetch_version, ms_os, parse_version, snapshot_size, + snapshot_version, SNAPSHOT, }; + use crate::download::MirrorTable; + use crate::game::java::error::JavaErrorKind; use crate::game::java::target::AdoptiumTarget; const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64"); + /// 指定版本:除了快照里那一个版本号,别的一律**报错**(不回落 latest), + /// 而且错误里要说清「只有这份快照」与「可用的是哪个」。 + /// + /// 变异证明:把 `fetch_version` 里的版本校验删掉(直接 `fetch_latest`)⇒ 本用例红。 + #[tokio::test] + async fn pinned_version_outside_the_snapshot_is_refused_not_fallen_back() { + let table = MirrorTable::builtin(); + let microsoft = table.microsoft_openjdk().expect("内置表应带 MS 分发"); + let client = reqwest::Client::new(); + // 错误在取校验文件之前就返回,所以这条用例一个请求都不发。 + let error = fetch_version(&client, microsoft, 21, WINDOWS, "21.0.5") + .await + .expect_err("快照里没有 21.0.5"); + assert_eq!(error.kind(), JavaErrorKind::Metadata); + assert!( + error.message().contains("21.0.13.0.2"), + "错误里要说清这家有哪个版本:{}", + error.message() + ); + assert!( + error.message().contains("不能自选版本"), + "要把「这家不能自选版本」说明白:{}", + error.message() + ); + } + #[test] fn snapshot_covers_the_four_majors_microsoft_ships() { assert_eq!(snapshot_version(21), Some("21.0.13.0.2")); diff --git a/crates/core/src/game/java/oracle.rs b/crates/core/src/game/java/oracle.rs index 08530f8..6ebe679 100644 --- a/crates/core/src/game/java/oracle.rs +++ b/crates/core/src/game/java/oracle.rs @@ -32,6 +32,12 @@ use super::target::AdoptiumTarget; use super::vendor::JdkVendor; use crate::download::MirrorTable; +mod listing; + +pub use listing::{find_archive, parse_links, pick_version, version_key, versions_for}; + +use listing::select_version; + /// Oracle 的 os 段(Adoptium 的 `mac` 在 Oracle 里叫 `macos`)。 #[must_use] pub fn oracle_os(target: AdoptiumTarget) -> &'static str { @@ -50,71 +56,10 @@ pub fn oracle_arch(target: AdoptiumTarget) -> &'static str { } } -/// 目录页 → 本级条目名列表(去掉尾部 `/`、跳过 `../` 与绝对链接,去重保序)。 -#[must_use] -pub fn parse_links(html: &str) -> Vec { - let mut out: Vec = Vec::new(); - let mut rest = html; - while let Some(start) = rest.find("href=\"") { - let after = &rest[start + "href=\"".len()..]; - let Some(end) = after.find('"') else { - break; - }; - let value = after[..end].trim(); - rest = &after[end..]; - if value.is_empty() - || value.starts_with("http") - || value.starts_with("//") - || value.starts_with('?') - || value.contains('#') - { - continue; - } - let name = value.trim_end_matches('/'); - let Some(name) = name.rsplit('/').next() else { - continue; - }; - if name.is_empty() || name == "." || name == ".." { - continue; - } - if !out.iter().any(|known| known == name) { - out.push(name.to_string()); - } - } - out -} - -/// 版本串 → 可比较的数字段(`21.0.2` → `[21, 0, 2]`,`27.0.1.0.3` → `[27,0,1,0,3]`)。 -#[must_use] -pub fn version_key(version: &str) -> Option> { - let mut parts = Vec::new(); - for segment in version.split('.') { - parts.push(segment.parse::().ok()?); - } - (!parts.is_empty()).then_some(parts) -} - -/// 目录页里属于 `major` 的版本串,按版本**降序**(最新在前)。 -#[must_use] -pub fn versions_for(html: &str, major: u32) -> Vec { - let mut found: Vec<(Vec, String)> = parse_links(html) - .into_iter() - .filter_map(|name| { - let key = version_key(&name)?; - (key.first() == Some(&u64::from(major))).then_some((key, name)) - }) - .collect(); - found.sort_by(|left, right| right.0.cmp(&left.0)); - found.into_iter().map(|(_, name)| name).collect() -} - -/// 取 `major` 线的最新 GA 版本串。 -#[must_use] -pub fn pick_version(html: &str, major: u32) -> Option { - versions_for(html, major).into_iter().next() -} - /// 期望文件名(Oracle 的命名是**模板**,哈希只在目录层级上,不在文件名里)。 +/// +/// 目录页解析与版本枚举(`parse_links` / `versions_for` / `select_version`)在 +/// [`listing`] 里,这里只留「拿到版本串之后」的那一段。 #[must_use] pub fn archive_name(version: &str, target: AdoptiumTarget) -> String { format!( @@ -137,12 +82,6 @@ pub fn checksum_url(base: &str, version: &str, filename: &str) -> String { format!("{}.sha256", archive_url(base, version, filename)) } -/// 目录页里是否有这个文件(**唯一**的「存在」判据:不信状态码)。 -#[must_use] -pub fn find_archive(html: &str, filename: &str) -> bool { - parse_links(html).iter().any(|name| name == filename) -} - /// 查一次「某大版本的最新 Oracle OpenJDK GA」。 /// /// 按 `mirrors.json` 里 `oracle_openjdk` 的启用顺序逐个镜像试(国内:华为云 → 南大), @@ -157,6 +96,36 @@ pub async fn fetch_latest( table: &MirrorTable, major: u32, target: AdoptiumTarget, +) -> Result { + try_mirrors(client, table, major, target, None).await +} + +/// 查一次「某大版本的指定版本 Oracle OpenJDK」。 +/// +/// 版本串来自镜像目录页([`versions_for`] 本来就能枚举某 major 的全部版本), +/// 选定就用它,**不再取 [`pick_version`] 的最新**;目录页里没有这个版本时 +/// 由 `catalog::version_pin_error` 报错并列出可用版本。 +/// +/// # Errors +/// +/// 所有镜像都没有这个版本(或版本目录页 / `.sha256` 取不到)时返回错误。 +pub async fn fetch_version( + client: &reqwest::Client, + table: &MirrorTable, + major: u32, + target: AdoptiumTarget, + wanted: &str, +) -> Result { + try_mirrors(client, table, major, target, Some(wanted)).await +} + +/// 逐个镜像试(`wanted` 决定是取最新还是取指定版本)。 +async fn try_mirrors( + client: &reqwest::Client, + table: &MirrorTable, + major: u32, + target: AdoptiumTarget, + wanted: Option<&str>, ) -> Result { let mirrors: Vec<_> = table.oracle_mirrors().collect(); if mirrors.is_empty() { @@ -168,17 +137,18 @@ pub async fn fetch_latest( } let mut reasons: Vec = Vec::new(); for mirror in mirrors { - match fetch_from(client, mirror.base(), major, target).await { + match fetch_from(client, mirror.base(), major, target, wanted).await { Ok(release) => return Ok(release), Err(error) => reasons.push(format!("{}:{}", mirror.name, error.message())), } } + let subject = match wanted { + None => format!("Oracle OpenJDK {major}"), + Some(wanted) => format!("oracle 的 Java {major} 版本 {wanted}"), + }; Err(JavaError::new( JavaErrorKind::Network, - format!( - "Oracle OpenJDK {major} 在所有镜像上都取不到:{}", - reasons.join(";") - ), + format!("{subject} 在所有镜像上都取不到:{}", reasons.join(";")), ) .with_hint("Oracle 的 21 线只到 21.0.2(只发首个 GA + ≤2 update);换 17/25 或改用 temurin")) } @@ -189,6 +159,7 @@ async fn fetch_from( base: &str, major: u32, target: AdoptiumTarget, + wanted: Option<&str>, ) -> Result { let index = net::get_text( client, @@ -196,15 +167,7 @@ async fn fetch_from( "Oracle 镜像根目录", ) .await?; - let version = pick_version(&index, major).ok_or_else(|| { - JavaError::new( - JavaErrorKind::Metadata, - format!( - "镜像目录里没有 {major} 线的 Oracle OpenJDK(目录页 {} 项)", - parse_links(&index).len() - ), - ) - })?; + let version = select_version(&index, major, wanted)?; let filename = archive_name(&version, target); let listing = net::get_text( client, @@ -248,23 +211,10 @@ async fn fetch_from( #[cfg(test)] mod tests { - use super::{ - archive_name, archive_url, checksum_url, find_archive, oracle_os, parse_links, - pick_version, version_key, versions_for, - }; + use super::listing::find_archive; + use super::{archive_name, archive_url, checksum_url, oracle_os}; use crate::game::java::target::AdoptiumTarget; - /// 华为云 `/openjdk/` 的真实目录页片段(2026-10-30 抓取,`
` 列表)。
-    const INDEX: &str = r#"
Name         Last modified     Size

-10/          22-Aug-2021 15:18 -
-17.0.2/      22-Aug-2021 15:19 -
-21/          22-Aug-2021 15:19 -
-21.0.1/      22-Aug-2021 15:19 -
-21.0.2/      06-Jan-2024 15:26 -
-27.0.1.0.3/ 06-Jan-2026 15:26 -
-../
-
"#; - /// 华为云 `/openjdk/21.0.2/` 的真实文件列表片段。 const LISTING: &str = r#"
../
 openjdk-21.0.2_linux-x64_bin.tar.gz 194.18 MB
@@ -273,30 +223,6 @@ mod tests {
 
     const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
 
-    #[test]
-    fn directory_links_are_scraped_without_any_hash_segment() {
-        let links = parse_links(INDEX);
-        assert!(links.contains(&"21.0.2".to_string()));
-        assert!(!links.iter().any(|name| name == ".."));
-        // Oracle 直链里的构建哈希(f2283984656d49d69e91c558476027ac)在镜像目录里根本不存在:
-        // 目录层级只有 `{ver}/`,所以实现里没有任何拼哈希的位置。
-        assert!(!links.iter().any(|name| name.len() == 32));
-    }
-
-    #[test]
-    fn newest_version_of_a_line_wins_and_ordering_is_numeric() {
-        assert_eq!(
-            versions_for(INDEX, 21),
-            vec!["21.0.2".to_string(), "21.0.1".to_string(), "21".to_string()]
-        );
-        assert_eq!(pick_version(INDEX, 21).as_deref(), Some("21.0.2"));
-        // 27.0.1.0.3 是四段+,比 27.0.1 新(字符串比较会得出相反结论)。
-        assert_eq!(version_key("27.0.1.0.3"), Some(vec![27, 0, 1, 0, 3]));
-        assert_eq!(version_key("27.0.1"), Some(vec![27, 0, 1]));
-        assert!(version_key("27.0.1") < version_key("27.0.1.0.3"));
-        assert_eq!(pick_version(INDEX, 8), None);
-    }
-
     #[test]
     fn filename_template_matches_the_archive_on_every_platform() {
         assert_eq!(
diff --git a/crates/core/src/game/java/oracle/listing.rs b/crates/core/src/game/java/oracle/listing.rs
new file mode 100644
index 0000000..5f230b5
--- /dev/null
+++ b/crates/core/src/game/java/oracle/listing.rs
@@ -0,0 +1,182 @@
+//! Oracle OpenJDK 的**纯**那一半:目录页解析、版本枚举、版本选择。
+//!
+//! 从 `oracle.rs` 拆出来只为守 400 行硬线(M280 的指定版本选择让那边到 418 行);
+//! 语义未变——这里一个请求都不发,全是「HTML 文本 → 名字列表 / 版本串」的纯函数:
+//!
+//! ```text
+//! parse_links    一级目录页 → 条目名(去重保序,跳过 ../ 与绝对链接)
+//! version_key    版本串 → 可比较的数字段(27.0.1.0.3 > 27.0.1)
+//! versions_for   目录页里属于某 major 的版本串(降序)
+//! pick_version   该线最新(= versions_for 的第一个)
+//! select_version 在目录页里挑版本:`None` = 最新,`Some` = 必须是这个版本(不近似)
+//! find_archive   目录页里是否真有这个文件名(**唯一**的「存在」判据,不信状态码)
+//! ```
+//!
+//! 「存在不信状态码」的理由见 `oracle.rs` 头:华为云对任意不存在的路径都回 200 + 门户 HTML。
+
+use super::super::catalog::{matches_version, version_pin_error};
+use super::super::error::{JavaError, JavaErrorKind};
+use super::super::vendor::JdkVendor;
+
+/// 目录页 → 本级条目名列表(去掉尾部 `/`、跳过 `../` 与绝对链接,去重保序)。
+#[must_use]
+pub fn parse_links(html: &str) -> Vec {
+    let mut out: Vec = Vec::new();
+    let mut rest = html;
+    while let Some(start) = rest.find("href=\"") {
+        let after = &rest[start + "href=\"".len()..];
+        let Some(end) = after.find('"') else {
+            break;
+        };
+        let value = after[..end].trim();
+        rest = &after[end..];
+        if value.is_empty()
+            || value.starts_with("http")
+            || value.starts_with("//")
+            || value.starts_with('?')
+            || value.contains('#')
+        {
+            continue;
+        }
+        let name = value.trim_end_matches('/');
+        let Some(name) = name.rsplit('/').next() else {
+            continue;
+        };
+        if name.is_empty() || name == "." || name == ".." {
+            continue;
+        }
+        if !out.iter().any(|known| known == name) {
+            out.push(name.to_string());
+        }
+    }
+    out
+}
+
+/// 版本串 → 可比较的数字段(`21.0.2` → `[21, 0, 2]`,`27.0.1.0.3` → `[27,0,1,0,3]`)。
+#[must_use]
+pub fn version_key(version: &str) -> Option> {
+    let mut parts = Vec::new();
+    for segment in version.split('.') {
+        parts.push(segment.parse::().ok()?);
+    }
+    (!parts.is_empty()).then_some(parts)
+}
+
+/// 目录页里属于 `major` 的版本串,按版本**降序**(最新在前)。
+#[must_use]
+pub fn versions_for(html: &str, major: u32) -> Vec {
+    let mut found: Vec<(Vec, String)> = parse_links(html)
+        .into_iter()
+        .filter_map(|name| {
+            let key = version_key(&name)?;
+            (key.first() == Some(&u64::from(major))).then_some((key, name))
+        })
+        .collect();
+    found.sort_by(|left, right| right.0.cmp(&left.0));
+    found.into_iter().map(|(_, name)| name).collect()
+}
+
+/// 取 `major` 线的最新 GA 版本串。
+#[must_use]
+pub fn pick_version(html: &str, major: u32) -> Option {
+    versions_for(html, major).into_iter().next()
+}
+
+/// 目录页里是否有这个文件(**唯一**的「存在」判据:不信状态码)。
+#[must_use]
+pub fn find_archive(html: &str, filename: &str) -> bool {
+    parse_links(html).iter().any(|name| name == filename)
+}
+
+/// 在目录页里挑版本:`None` = 该线最新,`Some` = 必须是这个版本(不做前缀近似)。
+pub(super) fn select_version(
+    html: &str,
+    major: u32,
+    wanted: Option<&str>,
+) -> Result {
+    let Some(wanted) = wanted else {
+        return pick_version(html, major).ok_or_else(|| missing_major(html, major));
+    };
+    let available = versions_for(html, major);
+    available
+        .iter()
+        .find(|version| matches_version(wanted, version))
+        .cloned()
+        .ok_or_else(|| version_pin_error(JdkVendor::Oracle, major, wanted, &available))
+}
+
+/// 「目录页里没有这个大版本」。
+pub(super) fn missing_major(html: &str, major: u32) -> JavaError {
+    JavaError::new(
+        JavaErrorKind::Metadata,
+        format!(
+            "镜像目录里没有 {major} 线的 Oracle OpenJDK(目录页 {} 项)",
+            parse_links(html).len()
+        ),
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{parse_links, pick_version, select_version, version_key, versions_for};
+
+    /// 华为云 `/openjdk/` 的真实目录页片段(2026-10-30 抓取,`
` 列表)。
+    const INDEX: &str = r#"
Name         Last modified     Size

+10/          22-Aug-2021 15:18 -
+17.0.2/      22-Aug-2021 15:19 -
+21/          22-Aug-2021 15:19 -
+21.0.1/      22-Aug-2021 15:19 -
+21.0.2/      06-Jan-2024 15:26 -
+27.0.1.0.3/ 06-Jan-2026 15:26 -
+../
+
"#; + + #[test] + fn directory_links_are_scraped_without_any_hash_segment() { + let links = parse_links(INDEX); + assert!(links.contains(&"21.0.2".to_string())); + assert!(!links.iter().any(|name| name == "..")); + // Oracle 直链里的构建哈希(f2283984656d49d69e91c558476027ac)在镜像目录里根本不存在: + // 目录层级只有 `{ver}/`,所以实现里没有任何拼哈希的位置。 + assert!(!links.iter().any(|name| name.len() == 32)); + } + + #[test] + fn newest_version_of_a_line_wins_and_ordering_is_numeric() { + assert_eq!( + versions_for(INDEX, 21), + vec!["21.0.2".to_string(), "21.0.1".to_string(), "21".to_string()] + ); + assert_eq!(pick_version(INDEX, 21).as_deref(), Some("21.0.2")); + // 27.0.1.0.3 是四段+,比 27.0.1 新(字符串比较会得出相反结论)。 + assert_eq!(version_key("27.0.1.0.3"), Some(vec![27, 0, 1, 0, 3])); + assert_eq!(version_key("27.0.1"), Some(vec![27, 0, 1])); + assert!(version_key("27.0.1") < version_key("27.0.1.0.3")); + assert_eq!(pick_version(INDEX, 8), None); + } + + /// 指定版本走同一条枚举路径:命中就用那一版,**不是** `pick_version` 的最新; + /// 版本不存在时错误里列出该 major 真正有的版本(不许只回一句 404)。 + /// + /// 变异证明:把 `select_version` 的 `Some` 分支改回 `pick_version` ⇒ 第一条断言红。 + #[test] + fn pinned_version_is_taken_from_the_listing_not_the_newest() { + assert_eq!( + select_version(INDEX, 21, Some("21.0.1")).expect("目录页里有 21.0.1"), + "21.0.1" + ); + assert_eq!( + select_version(INDEX, 21, Some("21.0.2")).expect("目录页里有 21.0.2"), + "21.0.2" + ); + // 不给版本 = 今天的行为(最新)。 + assert_eq!(select_version(INDEX, 21, None).expect("最新"), "21.0.2"); + let error = select_version(INDEX, 21, Some("21.0.9")).expect_err("目录页里没有 21.0.9"); + assert!(error.message().contains("21.0.9"), "{}", error.message()); + assert!( + error.message().contains("21.0.2") && error.message().contains("21.0.1"), + "错误里要列出可用版本:{}", + error.message() + ); + } +} diff --git a/crates/core/src/game/java/source.rs b/crates/core/src/game/java/source.rs index a463a84..b2df6a3 100644 --- a/crates/core/src/game/java/source.rs +++ b/crates/core/src/game/java/source.rs @@ -11,59 +11,108 @@ //! 换下一家;同一家内部的镜像换源(TUNA→官方、华为云→南大)仍然归 `core::download` 引擎管。 use super::api; -use super::error::JavaError; +use super::catalog; +use super::error::{JavaError, JavaErrorKind}; use super::microsoft; use super::oracle; use super::release::JdkRelease; use super::target::AdoptiumTarget; use super::vendor::JdkVendor; use super::zulu; -use crate::download::DownloadEngine; +use crate::download::{DownloadEngine, MirrorTable}; -/// 查一次「某发行版某大版本的最新 GA」。 +/// 查一次「某发行版某大版本」的归档。 +/// +/// `version = None` 是要该家**最新 GA**(D13 口径,行为与 M280 之前逐字节一致); +/// `Some(v)` 要求精确版本:能按版本取的只有 Temurin / Oracle,Microsoft 只认内置快照里那一个, +/// Zulu(只有 `latest=true`)与 Termux(系统包定版本)直接报错——错误文案由 +/// [`catalog::version_pin_error`] 统一出,**这里不换家、不回落 latest**。 /// /// # Errors /// -/// 平台无产物、清单不可达、该家没有这个大版本、校验缺失时返回错误——调用方据此降级。 +/// 平台无产物、清单不可达、该版本不存在、校验缺失、这家不支持指定版本时返回错误——调用方据此降级。 pub async fn fetch( engine: &DownloadEngine, vendor: JdkVendor, major: u32, target: AdoptiumTarget, + version: Option<&str>, ) -> Result { let client = engine.client(); let table = engine.mirrors(); + match version { + None => latest(client, table, vendor, major, target).await, + Some(wanted) => pinned(client, table, vendor, major, target, wanted).await, + } +} + +/// 该家的最新 GA(`fetch` 的 `None` 那一路,M280 之前只有这一条)。 +async fn latest( + client: &reqwest::Client, + table: &MirrorTable, + vendor: JdkVendor, + major: u32, + target: AdoptiumTarget, +) -> Result { match vendor { JdkVendor::Temurin => api::fetch_latest(client, major, target).await, JdkVendor::Oracle => oracle::fetch_latest(client, table, major, target).await, JdkVendor::Microsoft => { - let table = table.microsoft_openjdk().ok_or_else(|| { - JavaError::new( - super::error::JavaErrorKind::Metadata, - "镜像清单里没有 microsoft_openjdk 表", - ) - .with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin") - })?; - microsoft::fetch_latest(client, table, major, target).await + microsoft::fetch_latest(client, microsoft_table(table)?, major, target).await } - JdkVendor::Zulu => { - let table = table.zulu().ok_or_else(|| { - JavaError::new( - super::error::JavaErrorKind::Metadata, - "镜像清单里没有 zulu 表", - ) - .with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin") - })?; - zulu::fetch_latest(client, table, major, target).await - } - JdkVendor::Termux => Err(JavaError::new( - super::error::JavaErrorKind::Unsupported, - "Termux 的 JDK 由系统包管理器安装,不走归档下载链", - ) - .with_hint("在 Termux 里用 `ull java install `(默认走 termux 源)")), + JdkVendor::Zulu => zulu::fetch_latest(client, zulu_table(table)?, major, target).await, + JdkVendor::Termux => Err(termux_archive_error()), } } +/// 该家的指定版本(`fetch` 的 `Some` 那一路)。 +async fn pinned( + client: &reqwest::Client, + table: &MirrorTable, + vendor: JdkVendor, + major: u32, + target: AdoptiumTarget, + wanted: &str, +) -> Result { + match vendor { + JdkVendor::Temurin => api::fetch_version(client, major, target, wanted).await, + JdkVendor::Oracle => oracle::fetch_version(client, table, major, target, wanted).await, + JdkVendor::Microsoft => { + microsoft::fetch_version(client, microsoft_table(table)?, major, target, wanted).await + } + // 上游只给最新一份(Zulu)/ 由系统包管理器定版本(Termux):明确报错, + // 绝不换成别的版本假装装成了用户点名的那一版。 + JdkVendor::Zulu | JdkVendor::Termux => { + Err(catalog::version_pin_error(vendor, major, wanted, &[])) + } + } +} + +fn microsoft_table(table: &MirrorTable) -> Result<&crate::download::MsOpenJdkTable, JavaError> { + table.microsoft_openjdk().ok_or_else(|| { + JavaError::new( + JavaErrorKind::Metadata, + "镜像清单里没有 microsoft_openjdk 表", + ) + .with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin") + }) +} + +fn zulu_table(table: &MirrorTable) -> Result<&crate::download::ZuluTable, JavaError> { + table.zulu().ok_or_else(|| { + JavaError::new(JavaErrorKind::Metadata, "镜像清单里没有 zulu 表") + .with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin") + }) +} + +fn termux_archive_error() -> JavaError { + JavaError::new( + JavaErrorKind::Unsupported, + "Termux 的 JDK 由系统包管理器安装,不走归档下载链", + ) + .with_hint("在 Termux 里用 `ull java install `(默认走 termux 源)") +} + /// 一跳的结果。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct VendorAttempt {