以 .nvmrc 与 packageManager 作为 Node/pnpm 版本事实,新增 Windows-only 的状态诊断、受控 mise 准备、MCP/Tauri/UI 双投影及 onboarding 自检。 Feature-Confirmed: true Rules-Applied: R01,R04,R06,R07,R08,R10,R12
915 lines
30 KiB
Rust
915 lines
30 KiB
Rust
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();
|
||
}
|
||
}
|