use rusqlite::{params, Connection}; use serde::Serialize; use serde_json::Value; use std::collections::BTreeSet; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct VersionSource { pub source: String, pub version: String, pub exact: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ToolDeclaration { pub tool: String, pub state: String, pub version: Option, pub source: Option, pub candidates: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ResolverContract { pub kind: String, pub configured: bool, pub source: Option, pub node_idiomatic: bool, pub pnpm_idiomatic: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ToolchainContract { pub node: ToolDeclaration, pub pnpm: ToolDeclaration, pub resolver: ResolverContract, pub conflicts: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RuntimeProbe { pub version: Option, pub executable: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RuntimeToolStatus { pub tool: String, pub declared_version: Option, pub declared_source: Option, pub actual_version: Option, pub executable: Option, pub status: String, pub recommendation: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ResolverStatus { pub kind: String, pub configured: bool, pub executable: Option, pub actual_version: Option, pub status: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ToolchainStatus { pub project_id: String, pub project_root: String, pub node: RuntimeToolStatus, pub pnpm: RuntimeToolStatus, pub resolver: ResolverStatus, pub conflicts: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PrepareToolchainResult { pub command: Vec, pub before: ToolchainStatus, pub after: ToolchainStatus, pub verified: bool, pub stdout: String, pub stderr: String, } #[derive(Default)] struct MiseConfig { exists: bool, node_idiomatic: bool, pnpm_idiomatic: bool, node_version: Option, pnpm_version: Option, } fn read_trimmed(path: &Path) -> Option { std::fs::read_to_string(path) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn normalize_version(value: &str) -> String { value .trim() .trim_matches(|character| character == '"' || character == '\'') .trim_start_matches('v') .split('+') .next() .unwrap_or_default() .trim() .to_string() } fn is_exact_version(value: &str) -> bool { let value = normalize_version(value); if value.is_empty() || value.contains(['<', '>', '=', '^', '~', '*', 'x', 'X', '|', ' ']) { return false; } let core = value.split('-').next().unwrap_or_default(); let segments = core.split('.').collect::>(); segments.len() >= 2 && segments .iter() .all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit())) } fn push_candidate(candidates: &mut Vec, source: &str, version: &str) { let normalized = normalize_version(version); if normalized.is_empty() { return; } candidates.push(VersionSource { source: source.to_string(), exact: is_exact_version(&normalized), version: normalized, }); } fn push_compatibility(candidates: &mut Vec, source: &str, version: &str) { let normalized = normalize_version(version); if normalized.is_empty() { return; } candidates.push(VersionSource { source: source.to_string(), version: normalized, exact: false, }); } fn package_value<'a>(package: &'a Value, path: &[&str]) -> Option<&'a str> { let mut value = package; for key in path { value = value.get(*key)?; } value.as_str() } fn parse_package_json( root: &Path, node_candidates: &mut Vec, pnpm_candidates: &mut Vec, ) -> Result<(), String> { let path = root.join("package.json"); if !path.exists() { return Ok(()); } let content = std::fs::read_to_string(&path) .map_err(|error| format!("读取 {} 失败: {error}", path.display()))?; let package: Value = serde_json::from_str(&content) .map_err(|error| format!("解析 {} 失败: {error}", path.display()))?; if let Some(version) = package_value(&package, &["engines", "node"]) { push_compatibility(node_candidates, "package.json#engines.node", version); } if let Some(version) = package_value(&package, &["volta", "node"]) { push_candidate(node_candidates, "package.json#volta.node", version); } if let Some(package_manager) = package_value(&package, &["packageManager"]) { if let Some(version) = package_manager.strip_prefix("pnpm@") { push_candidate(pnpm_candidates, "package.json#packageManager", version); } } if let Some(version) = package_value(&package, &["engines", "pnpm"]) { push_compatibility(pnpm_candidates, "package.json#engines.pnpm", version); } Ok(()) } fn parse_mise(root: &Path) -> Result { let path = root.join("mise.toml"); if !path.exists() { return Ok(MiseConfig::default()); } let content = std::fs::read_to_string(&path) .map_err(|error| format!("读取 {} 失败: {error}", path.display()))?; let mut config = MiseConfig { exists: true, ..MiseConfig::default() }; let mut section = String::new(); for raw_line in content.lines() { let line = raw_line.split('#').next().unwrap_or_default().trim(); if line.is_empty() { continue; } if line.starts_with('[') && line.ends_with(']') { section = line.trim_matches(['[', ']']).trim().to_string(); continue; } let Some((raw_key, raw_value)) = line.split_once('=') else { continue; }; let key = raw_key.trim().trim_matches(['"', '\'']); let value = raw_value.trim(); if section == "tools" { let parsed = normalize_version(value); if is_exact_version(&parsed) { match key { "node" => config.node_version = Some(parsed), "pnpm" => config.pnpm_version = Some(parsed), _ => {} } } } else if section == "settings" && key == "idiomatic_version_file_enable_tools" { config.node_idiomatic = value .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') .any(|token| token == "node"); config.pnpm_idiomatic = value .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') .any(|token| token == "pnpm"); } } Ok(config) } fn declaration(tool: &str, candidates: Vec) -> ToolDeclaration { let exact_versions = candidates .iter() .filter(|candidate| candidate.exact) .map(|candidate| candidate.version.clone()) .collect::>(); let (state, version, source) = match exact_versions.len() { 0 => ("missing".to_string(), None, None), 1 => { let version = exact_versions.into_iter().next(); let source = version.as_ref().and_then(|selected| { candidates .iter() .find(|candidate| candidate.exact && candidate.version == *selected) .map(|candidate| candidate.source.clone()) }); ("declared".to_string(), version, source) } _ => ("conflict".to_string(), None, None), }; ToolDeclaration { tool: tool.to_string(), state, version, source, candidates, } } pub fn parse_toolchain_contract(root: &Path) -> Result { if !root.is_dir() { return Err(format!("项目目录不存在: {}", root.display())); } let mut node_candidates = Vec::new(); let mut pnpm_candidates = Vec::new(); if let Some(version) = read_trimmed(&root.join(".nvmrc")) { push_candidate(&mut node_candidates, ".nvmrc", &version); } if let Some(version) = read_trimmed(&root.join(".node-version")) { push_candidate(&mut node_candidates, ".node-version", &version); } parse_package_json(root, &mut node_candidates, &mut pnpm_candidates)?; let mise = parse_mise(root)?; if let Some(version) = mise.node_version.as_deref() { push_candidate(&mut node_candidates, "mise.toml#tools.node", version); } if let Some(version) = mise.pnpm_version.as_deref() { push_candidate(&mut pnpm_candidates, "mise.toml#tools.pnpm", version); } let node = declaration("node", node_candidates); let pnpm = declaration("pnpm", pnpm_candidates); let mut conflicts = Vec::new(); if node.state == "conflict" { conflicts.push("node".to_string()); } if pnpm.state == "conflict" { conflicts.push("pnpm".to_string()); } Ok(ToolchainContract { node, pnpm, resolver: ResolverContract { kind: "mise".to_string(), configured: mise.exists, source: mise.exists.then(|| "mise.toml".to_string()), node_idiomatic: mise.node_idiomatic, pnpm_idiomatic: mise.pnpm_idiomatic, }, conflicts, }) } fn locate_executable(tool: &str) -> Option { #[cfg(target_os = "windows")] let output = crate::silent_cmd("where.exe").arg(tool).output().ok()?; #[cfg(not(target_os = "windows"))] let output = crate::silent_cmd("which").arg(tool).output().ok()?; output .status .success() .then(|| { String::from_utf8_lossy(&output.stdout) .lines() .map(str::trim) .find(|line| !line.is_empty()) .unwrap_or_default() .to_string() }) .filter(|path| !path.is_empty()) } fn executable_version(path: &str) -> Option { #[cfg(target_os = "windows")] let output = { let escaped = path.replace('"', "\"\""); crate::silent_cmd("cmd") .args(["/D", "/C", &format!("\"{escaped}\" --version")]) .output() .ok()? }; #[cfg(not(target_os = "windows"))] let output = crate::silent_cmd(path).arg("--version").output().ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let value = stdout .lines() .chain(stderr.lines()) .map(str::trim) .find(|line| !line.is_empty())?; let normalized = normalize_version(value); (!normalized.is_empty()).then_some(normalized) } fn probe_runtime(tool: &str) -> RuntimeProbe { let executable = locate_executable(tool); let version = executable.as_deref().and_then(executable_version); RuntimeProbe { version, executable, } } fn probe_mise_tool(root: &Path, mise_path: &str, tool: &str) -> RuntimeProbe { let executable = crate::silent_cmd(mise_path) .current_dir(root) .args(["which", tool]) .output() .ok() .filter(|output| output.status.success()) .and_then(|output| { String::from_utf8_lossy(&output.stdout) .lines() .map(str::trim) .find(|line| !line.is_empty()) .map(str::to_string) }); let version = crate::silent_cmd(mise_path) .current_dir(root) .args(["exec", "--", tool, "--version"]) .output() .ok() .filter(|output| output.status.success()) .and_then(|output| { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); stdout .lines() .chain(stderr.lines()) .map(str::trim) .find(|line| !line.is_empty()) .map(normalize_version) }) .filter(|value| !value.is_empty()); RuntimeProbe { version, executable, } } fn tool_status( declaration: &ToolDeclaration, actual: RuntimeProbe, managed: bool, ) -> RuntimeToolStatus { let (status, recommendation) = match declaration.state.as_str() { "conflict" => ( "conflict", Some(format!("统一 {} 的精确版本声明后重试", declaration.tool)), ), "missing" => ( "missing_declaration", Some(format!("为 {} 添加项目级精确版本声明", declaration.tool)), ), _ if actual.version.is_none() || actual.executable.is_none() => ( "unavailable", Some(format!("安装或准备项目声明的 {}", declaration.tool)), ), _ if actual.version != declaration.version => ( "mismatch", Some(format!("通过 mise 准备声明的 {} 版本", declaration.tool)), ), _ if managed => ("ready_managed", None), _ => ( "compliant_unmanaged", Some("当前版本匹配,但尚未由项目 resolver 管理".to_string()), ), }; RuntimeToolStatus { tool: declaration.tool.clone(), declared_version: declaration.version.clone(), declared_source: declaration.source.clone(), actual_version: actual.version, executable: actual.executable, status: status.to_string(), recommendation, } } pub fn build_toolchain_status( project_id: &str, root: &Path, contract: ToolchainContract, node_probe: RuntimeProbe, pnpm_probe: RuntimeProbe, mise_probe: RuntimeProbe, ) -> ToolchainStatus { let resolver_ready = contract.resolver.configured && mise_probe.version.is_some() && mise_probe.executable.is_some(); let node_managed = resolver_ready && (contract.resolver.node_idiomatic || contract.node.source.as_deref() == Some("mise.toml#tools.node")); let pnpm_managed = resolver_ready && (contract.resolver.pnpm_idiomatic || contract.pnpm.source.as_deref() == Some("mise.toml#tools.pnpm")); let resolver_status = match ( contract.resolver.configured, mise_probe.executable.is_some(), ) { (true, true) => "ready", (true, false) => "missing", (false, true) => "available_unconfigured", (false, false) => "unavailable", }; ToolchainStatus { project_id: project_id.to_string(), project_root: root.display().to_string(), node: tool_status(&contract.node, node_probe, node_managed), pnpm: tool_status(&contract.pnpm, pnpm_probe, pnpm_managed), resolver: ResolverStatus { kind: contract.resolver.kind.clone(), configured: contract.resolver.configured, executable: mise_probe.executable, actual_version: mise_probe.version, status: resolver_status.to_string(), }, conflicts: contract.conflicts, } } fn registered_project_root(conn: &Connection, project_id: &str) -> Result { let win_path: Option = conn .query_row( "SELECT win_path FROM project_workspaces WHERE id = ?1", params![project_id], |row| row.get(0), ) .map_err(|_| format!("Project not found: {project_id}"))?; let configured = win_path .filter(|path| !path.trim().is_empty()) .ok_or_else(|| format!("项目未配置 Windows 工作目录: {project_id}"))?; std::fs::canonicalize(&configured) .map_err(|error| format!("项目目录不可访问 {}: {error}", configured)) } pub fn get_toolchain_status_for( conn: &Connection, project_id: &str, ) -> Result { let root = registered_project_root(conn, project_id)?; let contract = parse_toolchain_contract(&root)?; let mise_probe = probe_runtime("mise"); let mise_path = mise_probe.executable.as_deref(); let node_is_managed = contract.resolver.configured && (contract.resolver.node_idiomatic || contract.node.source.as_deref() == Some("mise.toml#tools.node")); let pnpm_is_managed = contract.resolver.configured && (contract.resolver.pnpm_idiomatic || contract.pnpm.source.as_deref() == Some("mise.toml#tools.pnpm")); let node_probe = match (node_is_managed, mise_path) { (true, Some(mise)) => probe_mise_tool(&root, mise, "node"), _ => probe_runtime("node"), }; let pnpm_probe = match (pnpm_is_managed, mise_path) { (true, Some(mise)) => probe_mise_tool(&root, mise, "pnpm"), _ => probe_runtime("pnpm"), }; Ok(build_toolchain_status( project_id, &root, contract, node_probe, pnpm_probe, mise_probe, )) } #[tauri::command] pub fn get_toolchain_status(project_id: String) -> Result { let conn = crate::db::pool().get().map_err(|error| error.to_string())?; get_toolchain_status_for(&conn, &project_id) } fn validate_mise_config_for_prepare(root: &Path) -> Result<(), String> { let path = root.join("mise.toml"); let content = std::fs::read_to_string(&path) .map_err(|error| format!("读取 {} 失败: {error}", path.display()))?; let mut section = String::new(); for raw_line in content.lines() { let line = raw_line.split('#').next().unwrap_or_default().trim(); if line.is_empty() { continue; } if line.starts_with('[') && line.ends_with(']') { section = line.trim_matches(['[', ']']).trim().to_string(); if section != "tools" && section != "settings" { return Err(format!( "mise.toml 含不受控 section [{section}],请人工执行工具链准备" )); } continue; } let Some((raw_key, raw_value)) = line.split_once('=') else { return Err("mise.toml 含无法识别的配置行,请人工检查".to_string()); }; let key = raw_key.trim().trim_matches(['"', '\'']); let value = raw_value.trim(); let allowed = match section.as_str() { "tools" => { matches!(key, "node" | "pnpm") && !value.contains(['{', '}']) && is_exact_version(value) } "settings" => key == "idiomatic_version_file_enable_tools", "" => key == "min_version", _ => false, }; if !allowed { return Err(format!( "mise.toml 配置 `{key}` 超出炼境安全准备边界,请人工执行 mise install" )); } } Ok(()) } fn prepare_plan(status: &ToolchainStatus, confirmed: bool) -> Result<(PathBuf, String), String> { if !confirmed { return Err("准备工具链需要用户显式确认".to_string()); } if !status.conflicts.is_empty() { return Err(format!( "工具链声明存在冲突: {}", status.conflicts.join(", ") )); } if status.node.declared_version.is_none() || status.pnpm.declared_version.is_none() { return Err("Node/pnpm 必须都有项目级精确版本声明".to_string()); } if !status.resolver.configured { return Err("项目尚未配置 mise.toml".to_string()); } let mise = status .resolver .executable .clone() .ok_or_else(|| "本机未安装 mise;请先完成一次用户级安装".to_string())?; let root = std::fs::canonicalize(&status.project_root) .map_err(|error| format!("项目目录不可访问 {}: {error}", status.project_root))?; validate_mise_config_for_prepare(&root)?; Ok((root, mise)) } pub fn prepare_toolchain_for( conn: &Connection, project_id: &str, confirmed: bool, ) -> Result { let before = get_toolchain_status_for(conn, project_id)?; let (root, mise) = prepare_plan(&before, confirmed)?; let output = crate::silent_cmd(&mise) .current_dir(&root) .arg("install") .output() .map_err(|error| format!("执行 mise install 失败: {error}"))?; let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); if !output.status.success() { return Err(format!( "mise install 未成功(exit={})\n{}", output.status.code().unwrap_or(-1), if stderr.is_empty() { &stdout } else { &stderr } )); } let after = get_toolchain_status_for(conn, project_id)?; let verified = after.node.status == "ready_managed" && after.pnpm.status == "ready_managed"; Ok(PrepareToolchainResult { command: vec![mise, "install".to_string()], before, after, verified, stdout, stderr, }) } #[tauri::command] pub fn prepare_toolchain( project_id: String, confirmed: bool, ) -> Result { let conn = crate::db::pool().get().map_err(|error| error.to_string())?; prepare_toolchain_for(&conn, &project_id, confirmed) } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; static NEXT_ID: AtomicU64 = AtomicU64::new(1); fn fixture() -> std::path::PathBuf { let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); let root = std::env::temp_dir().join(format!("lian-jing-toolchain-{}-{id}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); root } fn write(root: &Path, name: &str, content: &str) { std::fs::write(root.join(name), content).unwrap(); } #[test] fn parses_consistent_node_and_pnpm_contract() { let root = fixture(); write(&root, ".nvmrc", "24.19.0\n"); write( &root, "package.json", r#"{ "packageManager": "pnpm@9.15.0+sha256.deadbeef", "engines": { "node": "24.19.0", "pnpm": "9.15.0" } }"#, ); write( &root, "mise.toml", "[settings]\nidiomatic_version_file_enable_tools = ['node', 'pnpm']\n", ); let contract = parse_toolchain_contract(&root).unwrap(); assert_eq!(contract.node.state, "declared"); assert_eq!(contract.node.version.as_deref(), Some("24.19.0")); assert_eq!(contract.node.source.as_deref(), Some(".nvmrc")); assert_eq!(contract.pnpm.state, "declared"); assert_eq!(contract.pnpm.version.as_deref(), Some("9.15.0")); assert_eq!( contract.pnpm.source.as_deref(), Some("package.json#packageManager") ); assert!(contract.resolver.node_idiomatic); assert!(contract.resolver.pnpm_idiomatic); assert!(contract.conflicts.is_empty()); std::fs::remove_dir_all(root).unwrap(); } #[test] fn reports_conflicting_exact_node_versions() { let root = fixture(); write(&root, ".nvmrc", "24.19.0\n"); write(&root, ".node-version", "22.14.0\n"); let contract = parse_toolchain_contract(&root).unwrap(); assert_eq!(contract.node.state, "conflict"); assert_eq!(contract.node.version, None); assert_eq!(contract.conflicts, vec!["node"]); std::fs::remove_dir_all(root).unwrap(); } #[test] fn compatible_engine_range_does_not_conflict_with_exact_pin() { let root = fixture(); write(&root, ".nvmrc", "24.19.0\n"); write(&root, "package.json", r#"{"engines":{"node":">=22 <25"}}"#); let contract = parse_toolchain_contract(&root).unwrap(); assert_eq!(contract.node.state, "declared"); assert_eq!(contract.node.version.as_deref(), Some("24.19.0")); assert_eq!(contract.node.candidates.len(), 2); assert!(!contract.node.candidates[1].exact); std::fs::remove_dir_all(root).unwrap(); } #[test] fn exact_engine_values_are_compatibility_only() { let root = fixture(); write(&root, ".nvmrc", "24.19.0\n"); write( &root, "package.json", r#"{"engines":{"node":"22.14.0","pnpm":"9.15.0"}}"#, ); let contract = parse_toolchain_contract(&root).unwrap(); assert_eq!(contract.node.state, "declared"); assert_eq!(contract.node.version.as_deref(), Some("24.19.0")); assert!(!contract.node.candidates[1].exact); assert_eq!(contract.pnpm.state, "missing"); assert!(contract.conflicts.is_empty()); std::fs::remove_dir_all(root).unwrap(); } #[test] fn parses_direct_mise_tool_pins() { let root = fixture(); write( &root, "mise.toml", "[tools]\nnode = '24.19.0'\npnpm = \"9.15.0\"\n", ); let contract = parse_toolchain_contract(&root).unwrap(); assert_eq!(contract.node.version.as_deref(), Some("24.19.0")); assert_eq!( contract.node.source.as_deref(), Some("mise.toml#tools.node") ); assert_eq!(contract.pnpm.version.as_deref(), Some("9.15.0")); assert_eq!( contract.pnpm.source.as_deref(), Some("mise.toml#tools.pnpm") ); assert!(contract.resolver.configured); std::fs::remove_dir_all(root).unwrap(); } #[test] fn empty_project_reports_missing_declarations() { let root = fixture(); let contract = parse_toolchain_contract(&root).unwrap(); assert_eq!(contract.node.state, "missing"); assert_eq!(contract.pnpm.state, "missing"); assert!(!contract.resolver.configured); std::fs::remove_dir_all(root).unwrap(); } fn probe(version: Option<&str>, executable: Option<&str>) -> RuntimeProbe { RuntimeProbe { version: version.map(str::to_string), executable: executable.map(str::to_string), } } #[test] fn status_distinguishes_managed_mismatch_and_unmanaged() { let root = fixture(); write(&root, ".nvmrc", "24.19.0\n"); write(&root, "package.json", r#"{"packageManager":"pnpm@9.15.0"}"#); write( &root, "mise.toml", "[settings]\nidiomatic_version_file_enable_tools = ['node', 'pnpm']\n", ); let contract = parse_toolchain_contract(&root).unwrap(); let status = build_toolchain_status( "p1", &root, contract.clone(), probe(Some("24.19.0"), Some("node.exe")), probe(Some("11.19.0"), Some("pnpm.cmd")), probe(Some("2026.9.12"), Some("mise.exe")), ); assert_eq!(status.node.status, "ready_managed"); assert_eq!(status.pnpm.status, "mismatch"); assert_eq!(status.pnpm.executable.as_deref(), Some("pnpm.cmd")); let unmanaged = build_toolchain_status( "p1", &root, ToolchainContract { resolver: ResolverContract { configured: false, node_idiomatic: false, pnpm_idiomatic: false, source: None, kind: "mise".to_string(), }, ..contract }, probe(Some("24.19.0"), Some("node.exe")), probe(Some("9.15.0"), Some("pnpm.cmd")), probe(None, None), ); assert_eq!(unmanaged.node.status, "compliant_unmanaged"); assert_eq!(unmanaged.pnpm.status, "compliant_unmanaged"); std::fs::remove_dir_all(root).unwrap(); } #[test] fn registered_root_rejects_unknown_project() { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch( "CREATE TABLE project_workspaces ( id TEXT PRIMARY KEY, win_path TEXT );", ) .unwrap(); let error = registered_project_root(&conn, "missing").unwrap_err(); assert_eq!(error, "Project not found: missing"); } #[test] fn prepare_plan_requires_confirmation_and_safe_config() { let root = fixture(); write(&root, ".nvmrc", "24.19.0\n"); write(&root, "package.json", r#"{"packageManager":"pnpm@9.15.0"}"#); write( &root, "mise.toml", "[settings]\nidiomatic_version_file_enable_tools = ['node', 'pnpm']\n", ); let contract = parse_toolchain_contract(&root).unwrap(); let status = build_toolchain_status( "p1", &root, contract, probe(Some("24.19.0"), Some("node.exe")), probe(Some("9.15.0"), Some("pnpm.cmd")), probe(Some("2026.9.12"), Some("mise.exe")), ); assert_eq!( prepare_plan(&status, false).unwrap_err(), "准备工具链需要用户显式确认" ); let (planned_root, mise) = prepare_plan(&status, true).unwrap(); assert_eq!(planned_root, std::fs::canonicalize(&root).unwrap()); assert_eq!(mise, "mise.exe"); std::fs::remove_dir_all(root).unwrap(); } #[test] fn prepare_plan_rejects_custom_mise_tasks() { let root = fixture(); write(&root, ".nvmrc", "24.19.0\n"); write(&root, "package.json", r#"{"packageManager":"pnpm@9.15.0"}"#); write( &root, "mise.toml", "[settings]\nidiomatic_version_file_enable_tools = ['node', 'pnpm']\n[tasks.install]\nrun = 'dangerous'\n", ); let contract = parse_toolchain_contract(&root).unwrap(); let status = build_toolchain_status( "p1", &root, contract, probe(Some("24.19.0"), Some("node.exe")), probe(Some("9.15.0"), Some("pnpm.cmd")), probe(Some("2026.9.12"), Some("mise.exe")), ); let error = prepare_plan(&status, true).unwrap_err(); assert!(error.contains("不受控 section [tasks.install]")); std::fs::remove_dir_all(root).unwrap(); } }