dev-manager-tauri/src-tauri/src/commands/toolchain.rs
lanrtop ead35ad12a feat(toolchain): 增加项目级运行时能力包
以 .nvmrc 与 packageManager 作为 Node/pnpm 版本事实,新增 Windows-only 的状态诊断、受控 mise 准备、MCP/Tauri/UI 双投影及 onboarding 自检。

Feature-Confirmed: true

Rules-Applied: R01,R04,R06,R07,R08,R10,R12
2026-09-22 01:08:31 +09:00

915 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String>,
pub source: Option<String>,
pub candidates: Vec<VersionSource>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResolverContract {
pub kind: String,
pub configured: bool,
pub source: Option<String>,
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<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RuntimeProbe {
pub version: Option<String>,
pub executable: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RuntimeToolStatus {
pub tool: String,
pub declared_version: Option<String>,
pub declared_source: Option<String>,
pub actual_version: Option<String>,
pub executable: Option<String>,
pub status: String,
pub recommendation: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResolverStatus {
pub kind: String,
pub configured: bool,
pub executable: Option<String>,
pub actual_version: Option<String>,
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<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PrepareToolchainResult {
pub command: Vec<String>,
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<String>,
pnpm_version: Option<String>,
}
fn read_trimmed(path: &Path) -> Option<String> {
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::<Vec<_>>();
segments.len() >= 2
&& segments
.iter()
.all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
}
fn push_candidate(candidates: &mut Vec<VersionSource>, 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<VersionSource>, 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<VersionSource>,
pnpm_candidates: &mut Vec<VersionSource>,
) -> 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<MiseConfig, String> {
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<VersionSource>) -> ToolDeclaration {
let exact_versions = candidates
.iter()
.filter(|candidate| candidate.exact)
.map(|candidate| candidate.version.clone())
.collect::<BTreeSet<_>>();
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<ToolchainContract, String> {
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<String> {
#[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<String> {
#[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<PathBuf, String> {
let win_path: Option<String> = 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<ToolchainStatus, String> {
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<ToolchainStatus, String> {
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<PrepareToolchainResult, String> {
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<PrepareToolchainResult, String> {
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();
}
}