crates\nfarms-core\src\tools.rs¶
源码路径:
crates\nfarms-core\src\tools.rs· 1475 行
//! 环境工具检测 —— C# `Tools.cs` 的等价移植。
//!
//! 最要紧的一条是 [`capture`] 里的**解码判定**:原版按**字节**读子进程输出,
//! 是合法 UTF-8 就按 UTF-8(node / Go / nfztna 这些现代 CLI),
//! 否则按**系统 ANSI 代码页**(中文 Windows = GBK,`java -version` / `where` 这类)。
//! 不照抄这个判定,中文 CLI 的输出就会变成乱码 —— 这是原版 README 专门记过的坑。
//!
//! 这里**不引 `encoding_rs`**:GBK 解码直接调 Windows 的
//! `MultiByteToWideChar(GetACP(), …)`,和 `time.rs` 调 `GetLocalTime` 是同一套做法
//! —— 零依赖,而且口径与 C# 的 `Encoding.Default` 天然一致(都是「系统 ANSI」)。
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// 一个工具的检测结果。字段名与 C# `ToolInfo` 一一对应。
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ToolInfo {
pub name: String,
pub key: String,
/// 已安装版本
pub installed: String,
/// 最新版本
pub latest: String,
/// 未安装 / 最新 / 可更新 / 未知
///
/// **检测阶段故意留空,与原版一致** —— C# 的 `Tools.cs` 在这一路上也不调
/// `ComputeState`。所以走 [`detect_all`] / [`versions_lines`] 时,这个字段
/// 只可能是空串或「未安装」;「最新 / 可更新 / 未知」由 [`compute_state`] 产出,
/// 而它**目前没有生产调用方**(原版那条「取最新 → 算状态 → 界面显示可更新」
/// 的链路还没接上)。别把这里当成"链路已经接好"。
pub state: String,
/// 补充信息(路径等)
pub detail: String,
}
pub const TOOL_KEYS: [&str; 8] = ["npm", "jdk", "dotnet", "python", "rust", "cmake", "qt", "go"];
pub const TOOL_NAMES: [&str; 8] = ["npm", "JDK", ".NET", "Python", "Rust", "CMake", "Qt", "Go"];
// ---------------------------------------------------------------- 固定的量
/// 「查询类」子进程的等待上限:`netstat` / `tasklist` 这种只是读一个即时状态,
/// 正常一两百毫秒就返回;给更长的上限只会让界面在一个卡住的进程上多等。
const QUERY_TIMEOUT_MS: u64 = 15_000;
/// 「检测类」子进程的等待上限:版本探测、`go env GOPATH` 这类要等工具自己启动
/// (npm 是 node 脚本,冷启动明显慢于原生命令)。
///
/// 这两档超时原先是散在十几处的字面量(15 秒 / 20 秒),收成具名常量是为了
/// 改的时候只改一处、调用点也能一眼看出自己用的是哪一档。**没有**接进
/// `Settings`:那要改 `settings.rs`(不属本文件的改动范围)。
const DETECT_TIMEOUT_MS: u64 = 20_000;
/// 进程(树)退出后留给读线程的收尾时间 —— 照抄 C# `cs-src/Tools.cs:56`
/// 的 `th.Join(1000)`。等不到就放弃这一路输出,不能把调用方卡住。
const READER_GRACE_MS: u64 = 1_000;
/// `CREATE_NO_WINDOW`(0x0800_0000):子进程不弹控制台窗口。
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// 取最新版本时的联网上限。原版 `cs-src/Tools.cs:403` 的默认参数是 12000ms,
/// 这里放宽到 20s —— **有意偏离原版**:registry / endoflife.date 在国内偶尔要
/// 十几秒,按 12 秒掐会把本来能成的查询判成"获取失败"。
const HTTP_TIMEOUT_SECS: u64 = 20;
/// 联网用的 User-Agent。原版 `cs-src/Tools.cs:408` 写的是 `NFarmsTools/1.0`
/// 且不带地址,这里是 `1.2 (+https://tools.nfarms.cn)` —— 收成常量而不是写死在
/// [`http_get`] 里:要改版本号只改一处,也不会漏改第二处调用。
const HTTP_USER_AGENT: &str = "NFarmsTools/1.2 (+https://tools.nfarms.cn)";
// ---------------------------------------------------------------- 解码
/// 字节流是合法 UTF-8 就按 UTF-8,否则按系统 ANSI 代码页。
///
/// 与 C# `Tools.DecodeSmart` 等价(`new UTF8Encoding(false, true).GetString`
/// 失败后退回 `Encoding.Default`)。
pub fn decode_smart(bytes: &[u8]) -> String {
if bytes.is_empty() {
return String::new();
}
if let Ok(s) = std::str::from_utf8(bytes) {
return s.to_string();
}
#[cfg(windows)]
if let Some(s) = ansi_to_string(bytes) {
return s;
}
// 非 Windows 或转换失败:有损解码,至少不丢内容
String::from_utf8_lossy(bytes).into_owned()
}
#[cfg(windows)]
fn ansi_to_string(bytes: &[u8]) -> Option<String> {
use std::os::raw::c_int;
#[link(name = "kernel32")]
unsafe extern "system" {
fn MultiByteToWideChar(
code_page: u32,
flags: u32,
multi_byte: *const u8,
cb_multi_byte: c_int,
wide_char: *mut u16,
cch_wide_char: c_int,
) -> c_int;
fn GetACP() -> u32;
}
// 注意:整数溢出保护 —— 传入的指针和长度都由我们自己给出
unsafe {
let cp = GetACP();
let need = MultiByteToWideChar(
cp,
0,
bytes.as_ptr(),
bytes.len() as c_int,
std::ptr::null_mut(),
0,
);
if need <= 0 {
return None;
}
let mut buf = vec![0u16; need as usize];
let got = MultiByteToWideChar(
cp,
0,
bytes.as_ptr(),
bytes.len() as c_int,
buf.as_mut_ptr(),
need,
);
if got <= 0 {
return None;
}
buf.truncate(got as usize);
Some(String::from_utf16_lossy(&buf))
}
}
// ---------------------------------------------------------------- 执行
/// 执行命令并捕获 stdout+stderr(C# `Tools.Capture` 的等价物)。
///
/// 细节照抄原版:
/// * 走 `cmd.exe /d /c <命令>` —— 这样 `npm` / `java` 这些 `.cmd`/`.bat` 包装器
/// 才能被正确解析(PATH 上的 .cmd 直接 CreateProcess 是起不来的);
/// * stdout / stderr **各自一个线程**读 —— 只读一个管道会死锁;
/// * 超时就杀**整棵进程树**(见 [`kill_process_tree`]),读线程的收尾有时间上限
/// (原版是 `th.Join(1000)`);
/// * 输出里出现「命令不存在」的英文/中文噪音时返回空串 —— 原版靠这个把
/// 「应用商店别名」和「真装了这个工具」区分开。
pub fn capture(command: &str, timeout_ms: u64) -> String {
#[cfg(windows)]
let mut cmd = {
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
let mut c = Command::new(comspec);
c.arg("/d").arg("/c");
use std::os::windows::process::CommandExt;
// **`raw_arg` 而不是 `arg` —— 这条同样很关键。**
//
// `Command::arg` 在 Windows 上按 MSVCRT 规则给参数加引号/转义:命令串里
// 只要含空格和 `"`,内层引号就会被写成 `\"`,而 **cmd.exe 不认 `\"`** ——
// 整条命令直接散架。
//
// 症状特别隐蔽:命令不报错,只是什么都不输出。于是「读了注册表但没读到」
// 和「这项没装」看起来一模一样。`reg query "HKLM\..." /v ProductName`
// 这类带引号的命令全都静默失败过 —— cyinfo / browser / remote 里
// 每一处注册表读取都中过这一枪。
//
// `raw_arg` 原样写入、不做转义,正是 `cmd /c <整条命令>` 需要的。
c.raw_arg(command);
c
};
// 非 Windows(安卓就是这一类)**没有 `cmd.exe`**:早先这里照样去起 `cmd.exe`,
// spawn 失败就 `return String::new()`,于是所有 `capture` 都返回空串 ——
// 桌面版看不出问题,但安卓上 `cyinfo::fill_android` 的 `getprop` / `df -k /data`
// 因此全部取不到数,`.cy` 报告里「系统 / CPU / 内存」整段变成「(未知)」。
// 这里改用 `sh -c <命令>`:那两条都是"一整串命令"(带参数、带重定向),
// 只有交给 shell 才能照原样解析。
#[cfg(not(windows))]
let mut cmd = {
let mut c = Command::new("sh");
c.arg("-c").arg(command);
c
};
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// **CREATE_NO_WINDOW —— 这条也不能省。**
//
// 少了它,每次 `capture` 都会在屏幕上闪一个控制台窗口。平时一次两次
// 看不出问题(whois 一下、查个版本号),但只要哪个页面**每帧**调它,
// 就会变成满屏疯狂闪黑框 —— 这事真发生过:DSH-Starter 页
// 每帧调 `dsh::find()`(内部就是 `where dsh`),用户看到的就是
// 「一直弹控制台闪屏」。
//
// 两道防线都要有:这里按住窗口不弹,调用方那边也不该每帧起进程。
cmd.creation_flags(CREATE_NO_WINDOW);
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(_) => return String::new(),
};
let out_pipe = child.stdout.take();
let err_pipe = child.stderr.take();
// 读线程把结果**发回来**,而不是用 `JoinHandle::join()` 收:`std` 的 `join()`
// 没有超时,只要还有一个**孙进程**攥着管道写端(见下面 `kill_process_tree` 的
// 注释),它就会一直等下去。原版对应的是 `th.Join(1000)` —— 有上限。
// 只有 channel + `recv_timeout` 才表达得出"最多等 1 秒,等不到就放弃这一路输出"。
let (out_tx, out_rx) = mpsc::channel();
let (err_tx, err_rx) = mpsc::channel();
if let Some(mut p) = out_pipe {
std::thread::spawn(move || {
use std::io::Read;
let mut buf = Vec::new();
let _ = p.read_to_end(&mut buf);
// 接收端已经放弃(超时)时 send 会失败 —— 线程自己结束,忽略返回值
let _ = out_tx.send(buf);
});
}
if let Some(mut p) = err_pipe {
std::thread::spawn(move || {
use std::io::Read;
let mut buf = Vec::new();
let _ = p.read_to_end(&mut buf);
let _ = err_tx.send(buf);
});
}
// 等进程:std 没有 wait_timeout,自己轮询(`try_wait` 不阻塞)
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
let mut timed_out = false;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if Instant::now() >= deadline {
timed_out = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
Err(_) => break,
}
}
if timed_out {
// **杀整棵树,不是只杀直接子进程。**
//
// `Child::kill()` 在 Windows 上只对**直接子进程**做 TerminateProcess ——
// 这里的直接子进程是 `cmd.exe`,真正在跑的是它的子孙(`npm.cmd` 起的 node、
// `ping.exe` 之类)。那些子孙**继承着 stdout/stderr 管道的写端**:只要它们
// 还活着,读线程的 `read_to_end` 就等不到 EOF,于是"已经超时了却还是卡着",
// 而且一路看不到错误 —— 原版这里只 `p.Kill()`,同样只杀直接子进程。
//
// `taskkill /T /F /PID` 连子孙一起收;收不掉(taskkill 被禁用等)再退回
// `Child::kill()`,至少把直接子进程弄死,读线程那边还有 [`READER_GRACE_MS`] 兜底。
kill_process_tree(child.id());
let _ = child.kill();
let _ = child.wait();
}
// 收尾两路输出:进程(树)已经退出,正常情况下这一路**立刻**返回;
// 只有"还有子孙攥着管道"时才走到 1 秒上限 —— 那时宁可丢掉这一路的输出,
// 也不能把调用方卡在这里(C# 的 `th.Join(1000)` 就是这个意思)。
let reader_deadline = Instant::now() + Duration::from_millis(READER_GRACE_MS);
let so = recv_bounded(&out_rx, reader_deadline);
let se = recv_bounded(&err_rx, reader_deadline);
// 两路**各自**判噪音,再把干净的拼起来。
//
// 早先(和原版 `cs-src/Tools.cs:58-62` 一样)是先 `format!("{}\n{}", 两路)`
// 再对整串判:于是 stderr 里一句「命令不存在」会把 stdout 里的正常内容
// (比如 `where` 找到的真实路径)一起冲掉 —— 表现是"命令其实成功了,却什么都
// 读不到"。这是一处**有意偏离原版**的地方:原行为丢的是真内容,不是噪音。
let mut parts: Vec<String> = Vec::new();
for raw in [decode_smart(&so), decode_smart(&se)] {
let line = raw.trim();
if !line.is_empty() && !is_noise(line) {
parts.push(line.to_string());
}
}
parts.join("\n")
}
/// 这段输出是不是「命令不存在 / 加载不起来」的噪音?
///
/// 前两项与 C# `cs-src/Tools.cs:60-62` 一一对应;`could not find files` 是按
/// 审计清单补的那一条:英文 Windows 上 `where <不存在的名字>` 会把
/// `INFO: Could not find files for the given pattern(s).` 写进 stderr 并以 1 退出,
/// 补上它以后 `capture("where X")` 在"X 没装"时返回空串,调用方不会把那句提示语
/// 当成路径(见 audit-verified/farms-core-tools.md 与 core/dsh 那条)。
///
/// 中文系统上同一句是「信息: 用提供的模式无法找到文件。」—— **没有**收进表里:
/// 靠枚举本地化文案判"到底有没有"补一个漏一个,要确定的答案应当看文件系统
/// (`dsh.rs` 的 `parse_where_output` 就是这么做的,并且有测试钉着)。
fn is_noise(text: &str) -> bool {
let lower = text.to_lowercase();
lower.contains("could not be loaded")
|| lower.contains("is not recognized")
|| lower.contains("could not find files")
|| text.contains("不是内部或外部命令")
}
/// 从读线程收一路输出,最多等到 `deadline`;超时或线程没了就当这一路没有输出。
fn recv_bounded(rx: &mpsc::Receiver<Vec<u8>>, deadline: Instant) -> Vec<u8> {
let left = deadline.saturating_duration_since(Instant::now());
rx.recv_timeout(left).unwrap_or_default()
}
/// 终止一棵进程树(Windows 走 `taskkill /T /F`;其它平台没有等价物)。
///
/// 必须**直接**起 `taskkill`,不能走 [`capture`] —— [`capture`] 超时后会调本函数,
/// 本函数再调 `capture` 就成了递归。
///
/// 返回值故意丢弃:这是"尽力而为"的一步,失败还有 `Child::kill()` 与读线程的
/// 时间上限兜底,而这里也没有报错的出口(调用方只拿到一份输出)。
#[cfg(windows)]
fn kill_process_tree(pid: u32) {
use std::os::windows::process::CommandExt;
let mut c = Command::new("taskkill");
c.arg("/T").arg("/F").arg("/PID").arg(pid.to_string());
c.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
// 同样不能弹窗:这条路径会在界面链路上跑
c.creation_flags(CREATE_NO_WINDOW);
let _ = c.status();
}
/// 非 Windows 没有 `taskkill`:`Child::kill()` 照样杀直接子进程,子孙只能等它们
/// 自己退出 —— 读线程那边有 [`READER_GRACE_MS`] 兜底,不会再无限等下去。
#[cfg(not(windows))]
fn kill_process_tree(_pid: u32) {}
// ===========================================================================
// 端口占用者:查是谁 + 终止它
//
// **为什么需要**:本程序的「停止服务」只能停**它自己拉起的子进程**。
// 如果 llama-server 是别处启动的(手动跑、上一次没退干净、或另一个实例),
// 程序看到的是「未运行」,但端口实际被占 —— 这时点「启动服务」会起第二个进程、
// 撞端口,而报错只是一个 bind 失败,看不出前面那个还活着。
//
// 所以需要一个**按端口**的终止入口:不关心谁拉起它,只关心谁占着这个端口。
// ===========================================================================
/// 谁在监听这个端口?返回 `(PID, 进程名)`。
///
/// 用 `netstat -ano` 而不是 TCP 连接探测:连接只能回答「有没有人在听」,
/// 而这里要的是「**是谁**」—— 因为下一步要精确地终止它。
pub fn port_holder(port: u16) -> Option<(u32, String)> {
let out = capture("netstat -ano -p TCP", QUERY_TIMEOUT_MS);
let suffix = format!(":{port}");
for line in out.lines() {
let l = line.trim();
// 只看 LISTENING 的 TCP 行 —— ESTABLISHED 是客户端连接,不是服务
if !l.starts_with("TCP") || !l.contains("LISTENING") {
continue;
}
let cols: Vec<&str> = l.split_whitespace().collect();
if cols.len() < 5 {
continue;
}
// 本地地址列**按后缀**匹配:0.0.0.0:8080 / 127.0.0.1:8080 / [::]:8080
// 三种绑定方式都能认出来
if !cols[1].ends_with(&suffix) {
continue;
}
if let Ok(pid) = cols[cols.len() - 1].parse::<u32>() {
return Some((pid, process_name(pid)));
}
}
None
}
/// 取进程名。拿不到就返回空串 —— **不因为查不到名字就放弃终止**。
fn process_name(pid: u32) -> String {
let out = capture(
&format!("tasklist /FI \"PID eq {pid}\" /FO CSV /NH"),
QUERY_TIMEOUT_MS,
);
first_csv_field(out.lines().next().unwrap_or(""))
}
/// 取 `tasklist /FO CSV` 一行里的第一列(映像名)。
///
/// 按第一个 `","` 定界,**不能**按第一个逗号切:进程名本身可以含逗号,CSV 会给
/// 这种字段加引号,`"my,proc.exe","1234",…` 用 `split(',')` 得到的是 `my` ——
/// 界面上就会显示一个不存在的进程名,用户拿去任务管理器里也找不到它。
fn first_csv_field(line: &str) -> String {
let l = line.trim();
let name = match l.strip_prefix('"') {
Some(rest) => rest.split("\",").next().unwrap_or(""),
None => l.split(',').next().unwrap_or(""),
};
name.trim().to_string()
}
/// **终止占用该端口的进程。**
///
/// 成功返回一句可直接显示的话(带进程名与 PID);失败返回原因 ——
/// 「终止失败」和「本来就没进程」是两回事,要分得开。
pub fn kill_by_port(port: u16) -> Result<String, String> {
let Some((pid, name)) = port_holder(port) else {
return Err(format!("端口 {port} 上没有进程在监听"));
};
// 不让自己杀自己:本程序不会去监听这些端口,但真发生了就是灾难
if pid == std::process::id() {
return Err(format!("PID {pid} 是本程序自己,拒绝终止"));
}
let out = capture(&format!("taskkill /F /PID {pid}"), QUERY_TIMEOUT_MS);
let upper = out.to_ascii_uppercase();
let ok = out.contains("成功") || out.contains("已终止") || upper.contains("SUCCESS");
if ok {
let shown = if name.is_empty() { "未知进程" } else { &name };
Ok(format!("已终止 {shown}(PID {pid},端口 {port})"))
} else {
Err(kill_failure_reason(pid, &out))
}
}
/// `taskkill` 失败时的原因文案。
///
/// `capture` 可能返回空串(`taskkill` 不存在、spawn 失败、或者输出整段被判成噪音),
/// 直接拼进 `终止 PID N 失败:` 会留下一句没有下文的提示 —— 用户分不清是权限不足
/// 还是命令压根没跑起来。空串时补一句兜底。
fn kill_failure_reason(pid: u32, out: &str) -> String {
let line = first_line(out);
if line.is_empty() {
format!("终止 PID {pid} 失败:taskkill 没有输出(通常是权限不足)")
} else {
format!("终止 PID {pid} 失败:{line}")
}
}
/// 第一行(C# `Tools.FirstLine`)。
pub fn first_line(s: &str) -> String {
if s.is_empty() {
return String::new();
}
match s.find('\n') {
Some(i) => s[..i].trim().to_string(),
None => s.trim().to_string(),
}
}
// ---------------------------------------------------------------- 版本比较
/// 宽松版本比较:a<b → -1,a>b → 1,相等 → 0(C# `Tools.CompareVersions`)。
pub fn compare_versions(a: &str, b: &str) -> i32 {
let pa = split_version(a);
let pb = split_version(b);
let n = pa.len().max(pb.len());
for i in 0..n {
let x = pa.get(i).map(|s| parse_num(s)).unwrap_or(0);
let y = pb.get(i).map(|s| parse_num(s)).unwrap_or(0);
if x != y {
return if x < y { -1 } else { 1 };
}
}
0
}
fn split_version(v: &str) -> Vec<String> {
// 预发布后缀(`-rc1` / `+build` / 空格)一律截掉,只比数字段
let s = match v.find(['-', '+', ' ']) {
Some(i) => &v[..i],
None => v,
};
s.split('.').map(|x| x.to_string()).collect()
}
fn parse_num(s: &str) -> i32 {
s.trim().parse::<i32>().unwrap_or(0)
}
/// 未安装 / 未知 / 可更新 / 最新(C# `Tools.ComputeState`)。
///
/// **目前没有生产调用方**:检测阶段不调它(所以 [`ToolInfo::state`] 只有
/// 「未安装」一种取值),原版那条「取最新 → 算状态 → 界面显示可更新」的链路
/// (`--dsh-latest`、环境页)还没接上。它和 [`compare_versions`] 眼下只有测试在调
/// —— 接链路时直接用,不必重写。
pub fn compute_state(installed: &str, latest: &str) -> &'static str {
if installed.is_empty() {
return "未安装";
}
if latest.is_empty() {
return "未知";
}
if compare_versions(installed, latest) < 0 {
"可更新"
} else {
"最新"
}
}
// ---------------------------------------------------------------- 检测
/// 用正则从输出里抠版本号。
///
/// 这里不用 `regex` crate:要匹配的只有几个固定形状,手写一遍比多引一个
/// 依赖划算(而且 `regex` 会显著拖慢编译)。
///
/// 必须遍历**所有**出现位置,只认后面紧跟数字的那一次:前缀本身可能先以
/// 普通单词的形式出现在输出开头。最典型的是 Go —— `go version` 的真实输出是
/// `go version go1.22.1 windows/amd64`,`"go"` 在 index 0 就命中,但后面跟的是
/// 空格而不是版本号;只有第二个 `go`(`go1.22.1`)才是要的那次。这与原版
/// `cs-src/Tools.cs:177` 的 `Regex.Match(raw, @"go([\d.]+)")` 一致:正则引擎在
/// index 0 匹配失败后会继续右移,而不是就此放弃。
/// (早先只做 `raw.find(prefix)`,于是装了 Go 的机器上 `detect_go` 一律显示
/// "未安装";见 audit-verified/nfarms-core-tools.md 第 1 条。)
fn find_version(raw: &str, prefix: &str) -> Option<String> {
// prefix 形如 "Python "、"rustc "、"cmake version "、"go"
for (idx, _) in raw.match_indices(prefix) {
let rest = &raw[idx + prefix.len()..];
// 只接受"前缀后面直接就是数字"的位置。保持这个判定不变是为了不改变
// 其余几个调用方的既有行为:以前首个命中后面若不是数字就直接返回
// None(例如 "Python x"),现在只是多给它一次机会往后找。
if !rest.chars().next().is_some_and(|c| c.is_ascii_digit()) {
continue;
}
let digits: String = rest
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
// 首个字符已确认是数字,digits 必然非空;这里只是不写 unreachable 的假设。
return if digits.is_empty() { None } else { Some(digits) };
}
None
}
/// JDK:`version "1.8.0_301"` 这种带引号的形状。
fn find_quoted_version(raw: &str, prefix: &str) -> Option<String> {
let idx = raw.find(prefix)?;
let rest = &raw[idx + prefix.len()..];
let start = rest.find('"')?;
let after = &rest[start + 1..];
let end = after.find('"')?;
Some(after[..end].to_string())
}
// ---------------------------------------------------------------- 8 种工具
pub fn detect_npm() -> ToolInfo {
let mut t = ToolInfo {
name: "npm".into(),
key: "npm".into(),
..Default::default()
};
t.installed = first_line(&capture("npm -v", DETECT_TIMEOUT_MS));
if t.installed.is_empty() {
t.state = "未安装".into();
} else {
t.detail = format!(
"npm prefix: {}",
first_line(&capture("npm prefix -g", DETECT_TIMEOUT_MS))
);
}
t
}
pub fn detect_jdk() -> ToolInfo {
let mut t = ToolInfo {
name: "JDK".into(),
key: "jdk".into(),
..Default::default()
};
let raw = capture("java -version", DETECT_TIMEOUT_MS);
if let Some(v) = find_quoted_version(&raw, "version") {
t.installed = v;
}
let wh = capture("where java", DETECT_TIMEOUT_MS);
if !wh.is_empty() {
t.detail = first_line(&wh);
}
if t.installed.is_empty() && t.detail.is_empty()
&& let Ok(jh) = std::env::var("JAVA_HOME")
&& !jh.is_empty()
{
t.detail = format!("JAVA_HOME={jh}");
}
if t.installed.is_empty() {
t.state = "未安装".into();
}
t
}
pub fn detect_dotnet() -> ToolInfo {
let mut t = ToolInfo {
name: ".NET".into(),
key: "dotnet".into(),
..Default::default()
};
t.installed = first_line(&capture("dotnet --version", DETECT_TIMEOUT_MS));
let sdks = capture("dotnet --list-sdks", DETECT_TIMEOUT_MS);
if !sdks.is_empty() {
t.detail = sdks.replace('\n', " | ");
}
if t.installed.is_empty() {
t.state = "未安装".into();
}
t
}
pub fn detect_python() -> ToolInfo {
let mut t = ToolInfo {
name: "Python".into(),
key: "python".into(),
..Default::default()
};
let mut raw = capture("python --version", DETECT_TIMEOUT_MS);
let mut v = find_version(&raw, "Python ");
if v.is_none() {
raw = capture("py --version", DETECT_TIMEOUT_MS);
v = find_version(&raw, "Python ");
}
if let Some(v) = v {
t.installed = v;
t.detail = first_line(&raw);
}
if t.installed.is_empty() {
t.state = "未安装".into();
}
t
}
pub fn detect_rust() -> ToolInfo {
let mut t = ToolInfo {
name: "Rust".into(),
key: "rust".into(),
..Default::default()
};
let raw = capture("rustc --version", DETECT_TIMEOUT_MS);
if let Some(v) = find_version(&raw, "rustc ") {
t.installed = v;
t.detail = first_line(&raw);
}
if t.installed.is_empty() {
t.state = "未安装".into();
}
t
}
pub fn detect_cmake() -> ToolInfo {
let mut t = ToolInfo {
name: "CMake".into(),
key: "cmake".into(),
..Default::default()
};
let raw = capture("cmake --version", DETECT_TIMEOUT_MS);
if let Some(v) = find_version(&raw, "cmake version ") {
t.installed = v;
t.detail = first_line(&raw);
}
if t.installed.is_empty() {
t.state = "未安装".into();
}
t
}
pub fn detect_qt() -> ToolInfo {
let mut t = ToolInfo {
name: "Qt".into(),
key: "qt".into(),
..Default::default()
};
let raw = capture("qmake -v", DETECT_TIMEOUT_MS);
if let Some(v) = find_version(&raw, "Qt version ") {
t.installed = v;
}
let path = find_qt_qmake();
if !path.is_empty() {
t.detail = path;
}
if t.installed.is_empty() {
t.state = "未安装".into();
}
t
}
pub fn detect_go() -> ToolInfo {
let mut t = ToolInfo {
name: "Go".into(),
key: "go".into(),
..Default::default()
};
let raw = capture("go version", DETECT_TIMEOUT_MS);
if let Some(v) = find_version(&raw, "go") {
t.installed = v;
t.detail = first_line(&raw);
}
if t.installed.is_empty() {
t.state = "未安装".into();
}
t
}
/// 在常见的 Qt 安装根下找 `qmake.exe`。
///
/// 原版把整个盘递归枚举一遍;这里加了深度上限 —— `C:\Qt` 下递归到底部
/// 可能要几十秒,而 qmake 实际就在 `C:\Qt\<版本>\<套件>\bin\` 这三层里。
fn find_qt_qmake() -> String {
for root in qt_search_roots() {
if let Some(p) = walk_for(&root, "qmake.exe", 4) {
return p;
}
}
String::new()
}
/// Qt 安装根的候选表。
///
/// 早先这张表是 4 个写死的路径(`C:\Qt` / `C:\Qt5` / `D:\Qt` / `D:\Qt5`),
/// 于是装在 `E:\Qt` 或官方安装器默认的 `%USERPROFILE%\Qt` 下时,`detail` 一栏
/// 永远是空的(装了 Qt 且 qmake 在 PATH 上时版本仍会显示 —— 丢的只是路径)。
/// 现在:老的四条**保留在最前面**(老机器上挑到的还是同一条),再补用户目录,
/// 最后按盘符枚举。
///
/// 盘符只取**固定盘与可移动盘**:映射的网络盘在断线时 `is_dir()` 会阻塞好几秒,
/// 而这条路径在 `--versions` 的同步链路上 —— 不能让一个死掉的网络盘拖住整条命令。
fn qt_search_roots() -> Vec<String> {
let mut roots = vec![
r"C:\Qt".to_string(),
r"C:\Qt5".to_string(),
r"D:\Qt".to_string(),
r"D:\Qt5".to_string(),
];
if let Ok(home) = std::env::var("USERPROFILE") {
let home = home.trim_end_matches(['\\', '/']);
if !home.is_empty() {
roots.push(format!("{home}\\Qt"));
roots.push(format!("{home}\\Qt5"));
}
}
for letter in local_drive_letters() {
for suffix in ["Qt", "Qt5"] {
roots.push(format!("{letter}:\\{suffix}"));
}
}
// 去重:同一个目录被 walk 两遍纯属浪费(`C:\Qt` 两处都会出现)
let mut out: Vec<String> = Vec::new();
for r in roots {
if !out.contains(&r) {
out.push(r);
}
}
out
}
/// 固定盘 + 可移动盘的盘符;非 Windows 返回空 —— 那些平台上 `C:\Qt` 这类路径
/// 本来也不存在。
#[cfg(windows)]
fn local_drive_letters() -> Vec<char> {
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetLogicalDrives() -> u32;
fn GetDriveTypeW(root_path_name: *const u16) -> u32;
}
const DRIVE_REMOVABLE: u32 = 2;
const DRIVE_FIXED: u32 = 3;
let mask = unsafe { GetLogicalDrives() };
let mut letters = Vec::new();
for i in 0..26u32 {
if mask & (1 << i) == 0 {
continue;
}
let letter = (b'A' + i as u8) as char;
// "X:\" 的 UTF-16(带结尾 NUL)。用 GetDriveTypeW 而不是直接 is_dir():
// 后者在断线的网络盘上会阻塞,这里必须先按驱动器类型筛掉。
let root: [u16; 4] = [letter as u16, b':' as u16, b'\\' as u16, 0];
let kind = unsafe { GetDriveTypeW(root.as_ptr()) };
if kind == DRIVE_FIXED || kind == DRIVE_REMOVABLE {
letters.push(letter);
}
}
letters
}
#[cfg(not(windows))]
fn local_drive_letters() -> Vec<char> {
Vec::new()
}
fn walk_for(dir: &str, needle: &str, depth: usize) -> Option<String> {
if depth == 0 {
return None;
}
let entries = std::fs::read_dir(dir).ok()?;
let mut subdirs = Vec::new();
for e in entries.flatten() {
let path = e.path();
if path.is_file() {
if path.file_name().and_then(|n| n.to_str()) == Some(needle) {
return Some(path.display().to_string());
}
} else if path.is_dir() {
subdirs.push(path);
}
}
for d in subdirs {
if let Some(p) = walk_for(&d.display().to_string(), needle, depth - 1) {
return Some(p);
}
}
None
}
/// 一个工具的检测项:`(key, 检测函数)`。
///
/// 抽成别名是给 `clippy::type_complexity` 消音:`fn() -> ToolInfo` 这种函数指针
/// 在 clippy 的复杂度计分里权重最高(50),再被数组/元组嵌套放大,把整个类型直接
/// 写在 `const` 上会越过阈值 —— 统一跑的 clippy 那一关就会红。
type ToolDetector = (&'static str, fn() -> ToolInfo);
/// 8 个工具的检测入口表 —— 顺序与 [`TOOL_KEYS`] / [`TOOL_NAMES`] 一一对应。
///
/// 以前 `detect_all` 是手写的 8 个调用,key 清单在 [`TOOL_KEYS`]、显示名清单在
/// [`TOOL_NAMES`]:三份手工清单,加/删一个工具时漏改任何一处都**不会有编译错误**,
/// 只会让 `--versions`(走 `detect_all`)与 `--latest`(走 `TOOL_KEYS`)的行数或
/// 顺序悄悄对不上。现在由这张表驱动,`tool_lists_stay_in_step` 再把三者钉死。
const TOOL_DETECTORS: [ToolDetector; 8] = [
("npm", detect_npm),
("jdk", detect_jdk),
("dotnet", detect_dotnet),
("python", detect_python),
("rust", detect_rust),
("cmake", detect_cmake),
("qt", detect_qt),
("go", detect_go),
];
/// 检测全部 8 个工具。
///
/// **串行**执行:内部一共起 11~12 个子进程,耗时从数百毫秒到数秒,而且**阻塞**
/// 当前线程 —— 调用方必须放进后台线程或 `Job`(`cyinfo::write_report` 就是这么做的,
/// `nfarms-gui` 那侧同理)。不做并发:8 条探测同时抢进程创建收益很小,却要额外
/// 处理输出交错与超时叠加,不值。
///
/// `key` / 显示名一律以 [`TOOL_KEYS`] / [`TOOL_NAMES`] 为准:检测函数里各自也写了
/// 一份字面量,以这两份公开清单为准可以保证「报告里的工具名」不会因为改漏一处
/// 而和别人看到的不一致(`cyinfo` 报告用的就是这个 `name`)。
pub fn detect_all() -> Vec<ToolInfo> {
TOOL_KEYS
.iter()
.copied()
.zip(TOOL_NAMES)
.zip(TOOL_DETECTORS)
.map(|((key, name), (detect_key, detect))| {
debug_assert_eq!(key, detect_key, "TOOL_KEYS 与 TOOL_DETECTORS 顺序不一致");
let mut t = detect();
t.key = key.to_string();
t.name = name.to_string();
t
})
.collect()
}
// ---------------------------------------------------------------- 最新版本
/// 取最新版本:npm 走 registry,其余走 endoflife.date(C# `Tools.FetchLatest`)。
pub fn fetch_latest(key: &str) -> String {
if key == "npm" {
// npm 委托给 [`fetch_npm_latest`],**不**在这里再复制一份 URL + JSON 取值:
// 早先两处各写了一遍,`fetch_npm_latest` 因此成了全仓库无人调用的死代码,
// 改格式时还得记得同步两处。委托以后通用入口有了真实调用方,行为不变
// (同样是 `registry.npmjs.org/npm/latest` + 取 `version`)。
return fetch_npm_latest("npm");
}
let product = if key == "jdk" { "java" } else { key };
let body = match http_get(&format!("https://endoflife.date/api/{product}.json")) {
Some(b) => b,
None => return String::new(),
};
serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.as_array().and_then(|a| a.first()).cloned())
.and_then(|first| first.get("latest").map(js_string))
.unwrap_or_default()
}
/// 查任意 npm 包的最新版本(如 `@deepseek-ai/dsh`)。
///
/// 调用方:[`fetch_latest`] 的 npm 分支(`--latest` 在用,`nfarms-app` 侧)。
/// 同时它也是尚未移植的 `--dsh-latest` 的现成入口(`crates/nfarms-app/src/main.rs`
/// 的 `PENDING_SWITCHES` 里那一条;接线在 `main.rs` 侧,不属本文件的改动范围)——
/// 所以它**不是**死代码,别删:删了那一位接线的同事就得重写一遍 URL 与 JSON 取值。
pub fn fetch_npm_latest(package: &str) -> String {
match http_get(&format!("https://registry.npmjs.org/{package}/latest")) {
Some(body) => npm_version_from_json(&body),
None => String::new(),
}
}
/// 从 npm registry 的 `/<包名>/latest` 响应体里抠出 `version` 字段。
///
/// 抽出来是为了**能离线测**:发请求那一步在测试里跑不了(要网、还要等 20 秒超时),
/// 而"字段名对不对、值不是字符串时会不会 panic、坏 JSON 怎么办"这些真正容易错的
/// 地方全在这一段。不抽的话这条逻辑只能靠真联网的测试覆盖,等于没有测试。
fn npm_version_from_json(body: &str) -> String {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("version").map(js_string))
.unwrap_or_default()
}
fn js_string(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
}
}
fn http_get(url: &str) -> Option<String> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(HTTP_TIMEOUT_SECS)))
.user_agent(HTTP_USER_AGENT)
.build()
.into();
let resp = agent.get(url).call().ok()?;
resp.into_body().read_to_string().ok()
}
// ---------------------------------------------------------------- 环境已装应用
/// 某个工具环境里装的应用清单(C# `Tools.EnvApps`)。
pub fn env_apps(key: &str) -> Vec<String> {
match key {
"npm" => parse_npm_tree(&capture("npm ls -g --depth=0", DETECT_TIMEOUT_MS)),
"python" => parse_pip(&capture("pip list --format=freeze", DETECT_TIMEOUT_MS)),
"dotnet" => parse_dotnet_tools(&capture("dotnet tool list -g", DETECT_TIMEOUT_MS)),
"rust" => parse_cargo(&capture("cargo install --list", DETECT_TIMEOUT_MS)),
"go" => go_bin_apps(),
_ => Vec::new(),
}
}
/// `[+`|]-- 包名@版本` → `包名@版本`。
fn parse_npm_tree(text: &str) -> Vec<String> {
let mut list = Vec::new();
for raw in text.split('\n') {
let line = raw.trim_end_matches('\r');
// 找 "[+`|]--" 这个前缀,取后面到行尾的内容
let bytes = line.as_bytes();
let mut found = None;
for i in 0..bytes.len().saturating_sub(3) {
if matches!(bytes[i], b'+' | b'`' | b'|')
&& bytes[i + 1] == b'-'
&& bytes[i + 2] == b'-'
{
found = Some(i + 3);
break;
}
}
if let Some(pos) = found
&& pos < line.len()
{
let item = line[pos..].trim();
if !item.is_empty() {
list.push(item.to_string());
}
}
}
list
}
/// `pip list --format=freeze` → `包名@版本`(C# `Tools.ParsePip`)。
///
/// 原版是"**只保留含 `==` 的行**,再把 `==` 换成 `@`"。早先这里是"保留每个非空行、
/// 原样输出",于是 pip 自己的告警、以及任何不是 `包名==版本` 的杂项都会混进清单
/// —— 调用方拿到的就不再是"包名@版本"这个形状了。
fn parse_pip(text: &str) -> Vec<String> {
text.split('\n')
.map(|l| l.trim())
.filter(|l| l.contains("=="))
.map(|l| l.replace("==", "@"))
.collect()
}
/// `dotnet tool list -g` 的表格 → `包名@版本`(C# `Tools.ParseDotnetTools`)。
///
/// 原版从第 4 行(index 3)开始、按空白切成列、取前两列拼 `包名@版本`:
/// 前三行是"已安装的 .NET 工具:"、表头、分隔线。早先这里从 index 2 起、只取第一列,
/// **版本列被整列丢掉**。
fn parse_dotnet_tools(text: &str) -> Vec<String> {
let mut list = Vec::new();
for (i, raw) in text.split('\n').enumerate() {
if i < 3 {
continue;
}
let l = raw.trim();
if l.is_empty() || l.starts_with('-') {
continue;
}
// 表格列:包 ID 版本 命令
let parts: Vec<&str> = l.split_whitespace().collect();
if parts.len() < 2 || parts[0].eq_ignore_ascii_case("Package") {
continue;
}
list.push(format!("{}@{}", parts[0], parts[1]));
}
list
}
/// `cargo install --list` → `包名@版本`(C# `Tools.ParseCargo`,
/// 正则 `^(\S+)\s+v([^\s:]+)`)。
///
/// 原版那条正则的两个要点都要保住:①`^(\S+)` 要求**行首没有空白** ——
/// `cargo install --list` 里缩进的行是"这个包装了哪些二进制",不是包本身;
/// ②`v` 后面取到空白**或冒号**(顶层行形如 `cargo-edit v0.12.0:`)。
/// 早先这里跳过所有以 `:` 结尾的行、又跳过所有缩进行,于是**永远返回空表**;
/// 现在照原版把顶层行解出来,缩进行靠行首空白挡掉。
fn parse_cargo(text: &str) -> Vec<String> {
let mut list = Vec::new();
for raw in text.split('\n') {
let l = raw.trim_end_matches('\r');
if l.is_empty() || l.starts_with(char::is_whitespace) {
continue;
}
let Some((name, rest)) = l.split_once(char::is_whitespace) else {
continue;
};
let rest = rest.trim_start();
let Some(ver) = rest.strip_prefix('v') else {
continue;
};
let ver: String = ver
.chars()
.take_while(|c| !c.is_whitespace() && *c != ':')
.collect();
if ver.is_empty() {
continue;
}
list.push(format!("{name}@{ver}"));
}
list
}
/// `go env GOPATH` 下的 `bin\*.exe`(C# `Tools.GoBinApps`)。
fn go_bin_apps() -> Vec<String> {
let gopath = first_line(&capture("go env GOPATH", DETECT_TIMEOUT_MS));
if gopath.is_empty() {
return Vec::new();
}
list_exe_stems(&std::path::Path::new(&gopath).join("bin"))
}
/// 列目录里的 `*.exe`,取不带扩展名的文件名。
///
/// 只认 `.exe`:原版是 `Directory.EnumerateFiles(bin, "*.exe")`;早先这里列**所有**
/// 条目,于是 `bin` 下的 README、无扩展名的调试产物都会被算成"装了的工具"。
/// 用 `file_stem` 而不是 `trim_end_matches(".exe")`:后者会把 `a.exe.exe` 一路剥成 `a`。
///
/// 保留 `sort()`(原版不排序)是**有意偏离**:枚举顺序本身是文件系统给的,
/// 排序只影响展示先后,但顺序稳定对测试与人工对账更友好。
fn list_exe_stems(bin: &std::path::Path) -> Vec<String> {
let mut list = Vec::new();
if let Ok(entries) = std::fs::read_dir(bin) {
for e in entries.flatten() {
let p = e.path();
if !p.is_file() {
continue;
}
let is_exe = p
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x.eq_ignore_ascii_case("exe"));
if !is_exe {
continue;
}
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
list.push(stem.to_string());
}
}
}
list.sort();
list
}
// ---------------------------------------------------------------- CLI 输出
/// `--versions` 的输出行(C# `Program.cs` case "versions")。
///
/// `PadRight(8)` + `state=` 后面**故意可能是空的** —— 原版的检测阶段不调用
/// `ComputeState`,`state` 只有「未安装」一种取值。照抄这个行为,
/// 别「顺手修好」,否则和原版 diff 就不一样了。
pub fn versions_lines() -> Vec<String> {
detect_all()
.into_iter()
.map(|t| {
let installed = if t.installed.is_empty() {
"-".to_string()
} else {
t.installed.clone()
};
let detail = if t.detail.is_empty() {
String::new()
} else {
format!(" detail={}", t.detail)
};
format!(
"{:<8} installed={} state={}{}",
t.key, installed, t.state, detail
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// **自己绑一个端口,让 netstat 把它找出来** —— 比"对着写死的端口断言不 panic"
/// 强得多:它验证的是「真的能查到占用者」,且查到的 PID 必须是本进程。
#[test]
fn finds_the_process_that_holds_a_port() {
let l = std::net::TcpListener::bind("127.0.0.1:0").expect("绑临时端口");
let port = l.local_addr().expect("取本地地址").port();
let holder = port_holder(port);
assert!(holder.is_some(), "自己刚绑上的端口应当能查到占用者(端口 {port})");
assert_eq!(holder.unwrap().0, std::process::id(), "占用者应是本测试进程");
drop(l);
}
/// 在"没人监听、但端口已被本进程占住"的端口上跑一段断言。
///
/// 做法:一条 **ESTABLISHED** TCP 连接的本地端口。端口被本进程的 socket 占着,
/// 别的进程抢不走;而 netstat 里它是 ESTABLISHED、不是 LISTENING ——
/// 正是 `port_holder` 语义下的"空闲端口"。
///
/// **为什么不"扫 60000~65000 找一个能 bind 的端口"**:闭包里的监听器在
/// `is_ok()` 求值完就 drop 了,到真正查询之间有窗口期;别的进程(或本机另一个
/// 测试)在这个窗口里抢到端口,轻则断言偶发失败,重则 `kill_by_port` 的测试
/// 真的对着那个无关进程执行 `taskkill /F`。
fn with_port_that_has_no_listener<F: FnOnce(u16)>(f: F) {
let server = std::net::TcpListener::bind("127.0.0.1:0").expect("绑本地监听");
let port = server.local_addr().expect("取监听端口").port();
let client = std::net::TcpStream::connect(("127.0.0.1", port)).expect("连自己");
let (server_side, _) = server.accept().expect("接受连接");
let local = client.local_addr().expect("取本地端口").port();
assert_ne!(local, port, "客户端与监听端不该是同一个端口");
// client / server_side 一直活到 f 结束:端口在此期间不会被别人拿走
f(local);
drop((client, server_side));
}
/// 空闲端口 → `None`,而不是 panic。
#[test]
fn reports_none_for_a_free_port() {
with_port_that_has_no_listener(|p| {
assert!(
port_holder(p).is_none(),
"端口 {p} 上只有一条 ESTABLISHED 连接,不该查出监听者"
);
});
}
/// 终止一个没监听的端口 → **明确报错,而不是假装成功**,而且**不碰任何进程**。
/// 守的是「'终止失败'和'本来就没进程'要分得开」:混在一起的话,
/// 用户点了终止、看到成功,然后发现进程还在。
#[test]
fn killing_a_free_port_reports_an_error_not_a_success() {
with_port_that_has_no_listener(|p| {
let r = kill_by_port(p);
assert!(r.is_err(), "空闲端口上终止应当报错,实际 {r:?}");
assert!(r.unwrap_err().contains("没有进程"), "报错要说明是「没有进程」");
});
}
/// 端口被**本进程自己**占着时:拒绝终止。
/// 这条同时保证"测试本身不会去 taskkill 任何别的进程" —— 走到拒绝分支就返回了。
#[test]
fn killing_our_own_listener_is_refused() {
let l = std::net::TcpListener::bind("127.0.0.1:0").expect("绑临时端口");
let port = l.local_addr().expect("取本地地址").port();
let r = kill_by_port(port);
assert!(r.is_err(), "对本进程自己的监听端口应当拒绝终止,实际 {r:?}");
let msg = r.unwrap_err();
assert!(msg.contains("拒绝终止"), "报错要说明是拒绝,实际 {msg:?}");
drop(l);
}
#[test]
fn decode_smart_prefers_utf8_when_valid() {
assert_eq!(decode_smart("中文 UTF-8".as_bytes()), "中文 UTF-8");
assert_eq!(decode_smart(b"plain ascii"), "plain ascii");
assert_eq!(decode_smart(b""), "");
}
#[test]
fn version_comparison_is_lenient() {
assert_eq!(compare_versions("1.2.3", "1.2.4"), -1);
assert_eq!(compare_versions("1.2.4", "1.2.3"), 1);
assert_eq!(compare_versions("1.2", "1.2.0"), 0);
// 预发布后缀只截数字段
assert_eq!(compare_versions("20.10.0-rc1", "20.10.0"), 0);
// 段数不同时短的一方补 0
assert_eq!(compare_versions("1.0", "1.0.1"), -1);
}
#[test]
fn compute_state_covers_all_four_cases() {
assert_eq!(compute_state("", "1.0"), "未安装");
assert_eq!(compute_state("1.0", ""), "未知");
assert_eq!(compute_state("1.0", "2.0"), "可更新");
assert_eq!(compute_state("2.0", "2.0"), "最新");
}
#[test]
fn find_version_picks_the_number_after_the_prefix() {
assert_eq!(find_version("Python 3.11.4\r\n", "Python "), Some("3.11.4".into()));
assert_eq!(find_version("rustc 1.85.0 (abc 2025)", "rustc "), Some("1.85.0".into()));
assert_eq!(find_version("no version here", "rustc "), None);
}
#[test]
fn find_version_skips_a_prefix_occurrence_not_followed_by_a_digit() {
// 守的是"Go 在装了 Go 的机器上恒显示未安装"这个回归:真实 `go version`
// 输出的第一个 "go" 是裸词,后面跟空格;版本号在第二个 "go" 后面。
// 只要 find_version 退回"只看首次出现",这里就会拿到 None。
let go = "go version go1.22.1 windows/amd64";
assert_eq!(find_version(go, "go"), Some("1.22.1".into()));
assert_eq!(find_version("go version go1.22.1 windows/amd64\r\n", "go"), Some("1.22.1".into()));
// 只有裸词、没有版本号时仍然返回 None(不能因为多找几次就误报已安装)
assert_eq!(find_version("go version", "go"), None);
// 其它调用方的既有语义不变
assert_eq!(find_version("Python 3.11.4\r\n", "Python "), Some("3.11.4".into()));
assert_eq!(find_version("cmake version 3.29.0", "cmake version "), Some("3.29.0".into()));
}
#[test]
fn quoted_version_for_java() {
let raw = "openjdk version \"17.0.2\" 2022-01-18";
assert_eq!(find_quoted_version(raw, "version"), Some("17.0.2".into()));
assert_eq!(find_quoted_version("no quotes", "version"), None);
}
#[test]
fn npm_tree_parsing_matches_csharp_regex() {
let text = "C:\\Users\\x\\AppData\\Roaming\\npm\n\
+-- @deepseek-ai/dsh@1.2.3\n\
`-- pnpm@9.0.0\n";
let got = parse_npm_tree(text);
assert_eq!(got, vec!["@deepseek-ai/dsh@1.2.3", "pnpm@9.0.0"]);
}
/// `parse_pip` 要照原版:只留含 `==` 的行,并把 `==` 换成 `@`。
/// 早先是"原样保留每个非空行" —— 告警与杂项会混进清单,形状也不再是 `包名@版本`。
#[test]
fn pip_parsing_matches_csharp() {
let text = "pip==26.2.1\r\nsetuptools==75.1.0\r\nWARNING: 一句告警\r\n\r\n";
assert_eq!(parse_pip(text), vec!["pip@26.2.1", "setuptools@75.1.0"]);
assert_eq!(parse_pip(""), Vec::<String>::new());
}
/// `parse_dotnet_tools` 要把**版本列**带上(早先只取包名,版本整列丢了),
/// 并且从第 4 行(index 3)才开始 —— 前三行是标题/表头/分隔线。
#[test]
fn dotnet_tool_parsing_keeps_the_version_column() {
let text = "已安装的 .NET 工具:\r\n\
Package Id Version Commands\r\n\
------------------------------------\r\n\
dotnet-ef 8.0.10 dotnet-ef\r\n\
dotnet-sonarscanner 9.0.1 dotnet-sonarscanner\r\n";
assert_eq!(
parse_dotnet_tools(text),
vec!["dotnet-ef@8.0.10", "dotnet-sonarscanner@9.0.1"]
);
}
/// `parse_cargo` 要照原版正则:`^(\S+)\s+v([^\s:]+)`。
/// 顶层行形如 `cargo-edit v0.12.0:`(带冒号、不缩进),缩进的是"包里有哪些二进制"。
/// 早先"跳过以 `:` 结尾的行 + 跳过缩进行"两条一起,导致**永远返回空表**。
#[test]
fn cargo_parsing_matches_csharp_regex() {
// 用 `concat!` 而不是 `\n\` 续行:`\` 续行会把下一行**行首的空白吃掉**,
// 而"缩进行不算包"正是这条测试要验的东西。
let text = concat!(
"cargo-watch v8.1.2:\n",
" cargo-watch v8.1.2\n",
"cargo-edit v0.12.0:\n",
" cargo-add v0.12.0\n",
"not-a-package-line\n",
);
assert_eq!(parse_cargo(text), vec!["cargo-watch@8.1.2", "cargo-edit@0.12.0"]);
}
/// `go_bin_apps` 只认 `*.exe`(原版 `Directory.EnumerateFiles(bin, "*.exe")`),
/// 并且取的是 `file_stem` —— `trim_end_matches(".exe")` 会把 `a.exe.exe` 剥成 `a`。
#[test]
fn go_bin_listing_takes_only_exe_stems() {
let dir = std::env::temp_dir().join(format!("nfarms-tools-gobin-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
std::fs::write(dir.join("hello.exe"), b"x").expect("写文件");
std::fs::write(dir.join("world.exe"), b"x").expect("写文件");
std::fs::write(dir.join("a.exe.exe"), b"x").expect("写文件");
std::fs::write(dir.join("README.md"), b"x").expect("写文件");
std::fs::write(dir.join("no-extension"), b"x").expect("写文件");
let got = list_exe_stems(&dir);
assert_eq!(got, vec!["a.exe", "hello", "world"], "只该列出 .exe 的文件名主干");
let _ = std::fs::remove_dir_all(&dir);
}
/// `tasklist /FO CSV` 的第一列:进程名**可以含逗号**,CSV 会给它加引号。
/// 按第一个逗号切会把 `my,proc.exe` 截成 `my`,界面上就显示一个不存在的名字。
#[test]
fn csv_first_field_survives_a_comma_in_the_name() {
assert_eq!(first_csv_field("\"my,proc.exe\",\"1234\",\"Console\",\"1\",\"1,234 K\""), "my,proc.exe");
assert_eq!(first_csv_field("\"node.exe\",\"4321\",\"Console\",\"1\",\"10 K\""), "node.exe");
// 拿不到进程名时 tasklist 会给一句提示语 —— 原样带出去,不 panic
assert_eq!(first_csv_field(""), "");
assert_eq!(first_csv_field("信息: 没有运行的任务匹配指定标准。"), "信息: 没有运行的任务匹配指定标准。");
}
/// `taskkill` 失败且**没有任何输出**时不能拼出一句没有下文的提示。
#[test]
fn empty_taskkill_output_gets_a_fallback_reason() {
let empty = kill_failure_reason(1234, "");
assert!(empty.contains("没有输出"), "空输出要有兜底文案:{empty:?}");
let denied = kill_failure_reason(1234, "错误: 拒绝访问。\r\n更多内容");
assert!(denied.contains("拒绝访问"), "有输出时要用第一行:{denied:?}");
assert!(!denied.contains("更多内容"), "只取第一行:{denied:?}");
}
/// 噪音判定必须**分路**:stdout 有正常内容、stderr 只有一句"命令不存在"时,
/// 不能把 stdout 一起丢掉(老实现是在拼接后的整串上判的)。
#[test]
fn noise_is_judged_per_stream() {
assert!(is_noise("'x' is not recognized as an internal or external command"));
assert!(is_noise("'x' 不是内部或外部命令,也不是可运行的程序"));
assert!(is_noise("INFO: Could not find files for the given pattern(s)."));
assert!(is_noise("This app can't run; the module could not be loaded"));
assert!(!is_noise("C:\\Windows\\System32\\java.exe"));
assert!(!is_noise("Python 3.12.10"));
assert!(!is_noise(""));
}
/// 端到端那一半:真的走一遍 `cmd /c`,stdout 打标记、stderr 打一句噪音。
/// 修好之前这里返回空串 —— stdout 被 stderr 的噪音连坐丢掉了。
#[cfg(windows)]
#[test]
fn stdout_survives_noise_on_stderr() {
let out = capture("echo NFARMS-STDOUT-KEPT & echo is not recognized 1>&2", 10_000);
assert!(
out.contains("NFARMS-STDOUT-KEPT"),
"stdout 被 stderr 的噪音吃掉了:{out:?}"
);
}
#[test]
fn capture_of_a_trivial_command_works() {
// 这台机器上 cmd.exe 一定有;echo 的输出是确定性的
let out = capture("echo nfarms-capture-probe", 10_000);
assert!(
out.contains("nfarms-capture-probe"),
"capture 没拿到输出:{out:?}"
);
}
#[test]
fn capture_reports_missing_command_as_empty() {
// 原版的噪音过滤:命令不存在时返回空串,而不是把错误原文当成「已安装」
let out = capture("nfarms-no-such-command-xyz", 10_000);
assert_eq!(out, "", "不存在的命令应该被过滤成空串,实际:{out:?}");
}
/// **超时后必须连子孙一起收,并且真的要收得住。**
///
/// `cmd /c ping -n 20 127.0.0.1` 是"直接子进程 cmd.exe + 孙进程 ping.exe",
/// ping 自己攥着 stdout 管道的写端:
/// * 只 `Child::kill()`(原实现)时,ping 还活着 → 管道不关 → 读线程等不到
/// EOF → [`READER_GRACE_MS`] 到期后一路输出都收不到(实测 `capture` 返回空串);
/// * `taskkill /T /F` 收掉整棵树后,管道立刻关闭,输出照常收回。
/// 所以真正抓住这个回归的是下面**输出非空**那条断言。
///
/// 耗时那条是第二道防线:万一将来把 [`READER_GRACE_MS`] 调大或去掉,
/// `capture` 就会一直等 ping 自己跑完 —— 那时这条会炸(旧行为约 19 秒)。
#[cfg(windows)]
#[test]
fn timeout_kills_the_whole_process_tree() {
// 超时给 1.5 秒:ping 到回环地址每秒一行,这段时间足够它先把
// 表头(含 127.0.0.1)写进管道
let started = Instant::now();
let out = capture("ping -n 20 127.0.0.1", 1_500);
let elapsed = started.elapsed();
assert!(
out.contains("127.0.0.1"),
"超时前应当已经读到 ping 的输出 —— 读不到说明读线程没收住管道\
(没连子孙一起杀);ping 自己没跑起来时这条也会失败(防空断言)。实际:{out:?}"
);
assert!(
elapsed < Duration::from_secs(8),
"超时后没有及时返回:耗时 {elapsed:?}(旧行为约 19 秒),输出 {out:?}"
);
}
/// 非 Windows 上 `capture` 走 `sh -c`(安卓上 `getprop` / `df` 靠这条路)。
/// Windows 上编译不进来,由条件编译挡住。
#[cfg(unix)]
#[test]
fn capture_uses_a_shell_on_unix() {
let out = capture("echo nfarms-sh-c", 10_000);
assert!(out.contains("nfarms-sh-c"), "sh -c 没拿到输出:{out:?}");
}
/// Qt 候选根不能只有 4 个写死的路径 —— 官方安装器默认装在 `%USERPROFILE%\Qt`。
#[test]
fn qt_roots_go_beyond_the_four_hardcoded_paths() {
let roots = qt_search_roots();
assert_eq!(roots[0], r"C:\Qt");
assert_eq!(roots[1], r"C:\Qt5");
assert_eq!(roots[2], r"D:\Qt");
assert_eq!(roots[3], r"D:\Qt5");
#[cfg(windows)]
if let Ok(home) = std::env::var("USERPROFILE") {
let home = home.trim_end_matches(['\\', '/']).to_string();
if !home.is_empty() {
assert!(
roots.contains(&format!("{home}\\Qt")),
"候选表里缺 {home}\\Qt:{roots:?}"
);
}
}
// 去重:同一目录被 walk 两遍纯属浪费
let mut sorted = roots.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), roots.len(), "候选根里有重复:{roots:?}");
}
/// npm registry 的响应体 → 版本号。`fetch_latest("npm")` 与
/// [`fetch_npm_latest`] 共用这一段:字段缺了、值不是字符串、JSON 坏掉,
/// 三种情况都不能 panic、也不能返回半截内容。
#[test]
fn npm_version_is_read_from_the_registry_body() {
assert_eq!(
npm_version_from_json(r#"{"name":"npm","version":"11.6.2"}"#),
"11.6.2"
);
// 与 js_string 的既有语义一致:非字符串按 JSON 字面量输出
assert_eq!(npm_version_from_json(r#"{"version":123}"#), "123");
assert_eq!(npm_version_from_json(r#"{"version":null}"#), "");
// 坏 JSON / 缺字段 → 空串(调用方按"取不到"处理)
assert_eq!(npm_version_from_json("{not json"), "");
assert_eq!(npm_version_from_json(r#"{"name":"npm"}"#), "");
}
/// `--versions` 走 `detect_all`、`--latest` 走 `TOOL_KEYS`:三份清单必须同长同序,
/// 不然加/删一个工具时两条命令的行数会悄悄对不上(没有编译错误)。
/// 这里**不跑**检测 —— 只对表本身,所以是毫秒级的。
#[test]
fn tool_lists_stay_in_step() {
assert_eq!(TOOL_DETECTORS.len(), TOOL_KEYS.len(), "检测表与 TOOL_KEYS 长度不一致");
assert_eq!(TOOL_NAMES.len(), TOOL_KEYS.len(), "TOOL_NAMES 与 TOOL_KEYS 长度不一致");
let keys: Vec<&str> = TOOL_DETECTORS.iter().map(|(k, _)| *k).collect();
assert_eq!(keys, TOOL_KEYS.to_vec(), "检测表与 TOOL_KEYS 的顺序/内容必须一致");
}
/// TEMP-ORACLE-PROBE:打印 `--versions` 的原始行,人工与 `.oracle/rust-versions.txt` 对账。
#[test]
#[ignore]
fn temp_probe_versions_lines() {
for l in versions_lines() {
println!("{l}");
}
}
}