跳转至

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}");
        }
    }
}