跳转至

crates\nfarms-core\src\envprobe.rs

源码路径:crates\nfarms-core\src\envprobe.rs · 1643 行

//! 开发环境全景探测 —— Environment 页的新数据源。
//!
//! ## 为什么不改 `tools.rs`
//!
//! `tools.rs` 的 `detect_all()` 喂的是 `--versions` / `--npm-apps` 两条命令,
//! 而它们是 **oracle 逐字节比对**的验收项。往里加组件 = 直接打破验收。
//! 所以这一层是**纯增量**的:新的组件、新的维度(环境变量 / 依赖库 / 镜像源)
//! 全在这里,`tools.rs` 一行不动。
//!
//! ## 十大类
//!
//! 「有哪几类、叫什么、按什么顺序排」只有一处权威定义:`CATALOG`。
//! 这里原来手抄过一遍类名清单,加了「模型运行环境」之后就再也没对上 ——
//! 所以改成指路,不再抄第二份。
//! 每类下面若干个**探测项**(可执行文件 + 版本),外加三个横向维度:
//!
//! * **环境变量**:这些工具链的行为**高度依赖**环境变量(`JAVA_HOME` 指错地方
//!   比不装还难查),所以要把「设了没有、值是什么」列出来;
//! * **镜像源**:npm / pip / cargo / nuget 各自查一遍 —— 国内网络下这是
//!   「装不上」的头号原因;
//! * **系统依赖库**:VC++ 运行库、OpenSSL、zlib 这些**装没装看不出来**的东西
//!   (缺了只会在运行时报「找不到 xxx.dll」)。
//!
//! ## 为什么整份报告要跑在后台线程
//!
//! 六十来次子进程探测,串起来 3 秒上下。放 UI 线程里切到这一页就僵住 ——
//! 所以调用方一律用 `Job` 包起来(和 `tools::detect_all` 同样的处理)。

use std::path::{Path, PathBuf};

use crate::Paths;

/// 一个探测项(某个可执行文件 / 某个目录)。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Item {
    /// 显示名,如 `cargo`
    pub label: String,
    /// 版本或摘要;拿不到就是空
    pub version: String,
    /// 落地路径(拿得到就填)
    pub path: Option<PathBuf>,
    /// 补充说明(比如「没装」「找到了但版本读不出来」)
    pub note: String,
}

impl Item {
    pub fn found(&self) -> bool {
        self.path.is_some() || !self.version.is_empty()
    }
    /// 一行摘要:`1.98.1` 或 `C:\...\cargo.exe`,都没有就给 note。
    pub fn summary(&self) -> String {
        if !self.version.is_empty() {
            self.version.clone()
        } else if let Some(p) = &self.path {
            p.display().to_string()
        } else if !self.note.is_empty() {
            self.note.clone()
        } else {
            "(未找到)".to_string()
        }
    }
}

/// 一类组件(有几类、叫什么见 `CATALOG`)。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Group {
    pub key: &'static str,
    pub name: &'static str,
    /// 这类东西**是干什么用的** —— 界面上一句话说明,比一串版本号有用
    pub purpose: &'static str,
    pub items: Vec<Item>,
}

impl Group {
    /// 找到了几项 / 共几项。
    pub fn found_count(&self) -> (usize, usize) {
        (self.items.iter().filter(|i| i.found()).count(), self.items.len())
    }
    /// 整类的状态:`可用` / `部分可用` / `缺失`。
    pub fn state(&self) -> &'static str {
        let (ok, total) = self.found_count();
        if ok == 0 {
            "缺失"
        } else if ok == total {
            "可用"
        } else {
            "部分可用"
        }
    }
}

/// 一个环境变量。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Var {
    pub name: &'static str,
    /// `None` = 进程环境里根本没有这个变量;
    /// `Some("")` = 设了但值是空串(见 `var_value`)
    pub value: Option<String>,
    /// 它影响什么
    pub note: &'static str,
    /// 建议值(没设时给个参考)
    pub hint: &'static str,
}

/// 一个包管理器的镜像源配置。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mirror {
    pub name: &'static str,
    /// 当前生效的值
    pub current: String,
    /// 是不是官方源(国内网络下这个信息很关键)
    pub official: bool,
}

/// 一份完整的探测报告。
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Report {
    pub groups: Vec<Group>,
    pub vars: Vec<Var>,
    pub mirrors: Vec<Mirror>,
    /// 系统层面的开关(UTF-8 代码页、长路径、开发者模式)
    pub system: Vec<Item>,
}

impl Report {
    /// 可用的类数(总共几类见 `CATALOG`)。
    pub fn usable_groups(&self) -> usize {
        self.groups.iter().filter(|g| g.state() != "缺失").count()
    }
    /// 设了的环境变量数 / 总数。
    ///
    /// 「设了」按 `value.is_some()` 算 —— **设成空串也算设了**:那说明有人
    /// (或某个安装脚本)显式清过它,与「从来没设」在子进程里的行为不同。
    pub fn vars_set(&self) -> (usize, usize) {
        (
            self.vars.iter().filter(|v| v.value.is_some()).count(),
            self.vars.len(),
        )
    }
}

// ===========================================================================
// 探测原语
// ===========================================================================

/// 从任意版本输出里抠出一个版本号。
///
/// 各家格式乱七八糟:`gcc (x86_64-w64-mingw32) 15.2.0`、`v20.11.1`、
/// `Python 3.12.10`、`rustc 1.98.1 (hash 2026-01-01)`。共同点是
/// **版本号以数字开头、含点**。所以统一扫一遍 token,取第一个满足的。
///
/// # 为什么「纯数字」不算版本
///
/// 这里以前多一个 `|| 全是数字` 的分支(为了 `chcp` 输出里的代码页 936)。
/// 代价是 `nvidia-smi --query-gpu=name` 吐出来的
/// `NVIDIA GeForce RTX 4060 Laptop GPU` 里的 `4060` 也被认成版本 ——
/// 界面上「NVIDIA 驱动」那一行于是显示成显卡型号,标的与填的不是一回事,
/// 而且**看不出是错的**(数字看起来很合理)。
/// 现在版本号一律要求带点,代码页那种「本来就不是版本号」的东西各自解析
/// (见 `code_page_number`)。
///
/// 注意 `--version` 有的往 **stderr** 写(java、ssh 都这样),调用方要合流。
pub fn extract_version(text: &str) -> Option<String> {
    for tok in text.split_whitespace() {
        // 去掉前缀 v / V、**两边的引号**、以及结尾的逗号/括号。
        //
        // 引号这条是测出来的:`java -version` 输出的是
        // `openjdk version "21.0.1" 2024-10-15` —— 不剥引号就匹配不到,
        // 于是 JDK 那一类会整类显示「找到了但版本读不出来」。
        let t = tok
            .trim_start_matches(['v', 'V', '"', '\''])
            .trim_end_matches([',', ')', ']', '"', '\'']);
        let looks =
            t.starts_with(|c: char| c.is_ascii_digit()) && t.contains('.') && t.len() <= 24;
        if looks {
            return Some(t.to_string());
        }
    }
    None
}

/// 跑一条命令并抠版本。
fn ver(label: &str, cmd: &str) -> Item {
    let out = crate::tools::capture(cmd, 20_000);
    let version = extract_version(&out).unwrap_or_default();
    let note = if out.trim().is_empty() {
        "没装,或者不在 PATH 上".to_string()
    } else if version.is_empty() {
        "找到了,但版本读不出来".to_string()
    } else {
        String::new()
    };
    Item {
        label: label.to_string(),
        version,
        path: None,
        note,
    }
}

/// 只探测可执行文件在不在(`where`),不读版本 —— 用于版本输出很慢的工具。
fn where_is(label: &str, exe: &str) -> Item {
    let out = crate::tools::capture(&format!("where {exe}"), 15_000);
    let path = out
        .lines()
        .map(|l| l.trim())
        .find(|l| !l.is_empty() && Path::new(l).is_file())
        .map(PathBuf::from);
    Item {
        label: label.to_string(),
        version: String::new(),
        note: if path.is_none() {
            "不在 PATH 上".to_string()
        } else {
            String::new()
        },
        path,
    }
}

/// 看一个目录在不在(用于 SDK 布局而不是可执行文件)。
fn dir_item(label: &str, dir: &Path) -> Item {
    let exists = dir.is_dir();
    Item {
        label: label.to_string(),
        version: String::new(),
        path: exists.then(|| dir.to_path_buf()),
        note: if exists { String::new() } else { "目录不存在".to_string() },
    }
}

/// 数一个目录下有几个子目录(build-tools\34.0.0 这种多版本布局)。
fn count_dirs(dir: &Path) -> usize {
    std::fs::read_dir(dir)
        .map(|it| it.flatten().filter(|e| e.path().is_dir()).count())
        .unwrap_or(0)
}

/// 把 `std::env::var` 的结果归一成 `Var::value`。
///
/// **「设成空串」不能并进「没设」。** 这里原来有一句
/// `.filter(|v| !v.trim().is_empty())`,于是 `set PYTHONPATH=` 留下的空值
/// 在界面上显示成「未设 → 建议 …」,用户永远看不出自己(或某个安装脚本)
/// 把变量清掉了 —— 而两者在子进程里的行为**真的不同**:空值会覆盖掉
/// 父进程/系统里那个值。纯空白同样归一成空串:界面上它和空值画不出区别,
/// 语义上也是「设了但是空」。
///
/// 抽成独立函数是为了能测:`var()` 读的是**本进程**的环境,测试里造不出一个
/// 「设成空串」的变量(改本进程环境会污染并行跑的其它测试,而 Windows 上把
/// 变量设成空串等于删掉它)。
fn var_value(raw: Result<String, std::env::VarError>) -> Option<String> {
    match raw {
        Ok(v) if v.trim().is_empty() => Some(String::new()),
        Ok(v) => Some(v),
        Err(_) => None,
    }
}

/// 读环境变量。
fn var(name: &'static str, note: &'static str, hint: &'static str) -> Var {
    let value = var_value(std::env::var(name));
    Var {
        name,
        value,
        note,
        hint,
    }
}

/// 镜像源读不到时的占位文本。
///
/// 为什么不是朴素的 `(读不到)`,也不是顺手把 `official` 置成 `false`:
/// `official` 只有两态,界面(`core_pages.rs` 的镜像源那一栏)只会在
/// 「官方源」(红)和「已换源」(绿)之间挑一个。读不到时两个都不知道 ——
/// 这时显示「官方源」是**保守**的一侧(红色 = 你可能还在慢的官方源上,
/// 值得去查),而显示「已换源」会让用户以为已经换好了、从此不再管。
/// 所以徽章仍按官方源画,但把这句**假设写明在文本里**,
/// 而不是像以前那样让 `(读不到)` 悄悄算成官方源。
const MIRROR_UNREADABLE: &str = "未知(命令读不到,按官方源处理)";

/// 由「命令输出的首个非空行」判定镜像源。
///
/// 抽成纯函数是为了能测:`mirror()` 自己要起子进程,测试里没法稳定造出
/// 「读不到」和「读到官方源」这两种输入(取决于跑测试的机器装了什么)。
fn classify_mirror(first_line: Option<&str>, official_markers: &[&str]) -> (String, bool) {
    match first_line {
        Some(l) => (l.to_string(), official_markers.iter().any(|m| l.contains(m))),
        None => (MIRROR_UNREADABLE.to_string(), true),
    }
}

/// 查一个包管理器的镜像源(取一条命令的输出首行)。
fn mirror(name: &'static str, cmd: &str, official_markers: &[&str]) -> Mirror {
    let out = crate::tools::capture(cmd, 20_000);
    let first = out.lines().map(|l| l.trim()).find(|l| !l.is_empty());
    let (current, official) = classify_mirror(first, official_markers);
    Mirror {
        name,
        current,
        official,
    }
}

// ===========================================================================
// 采集
// ===========================================================================

/// 采一份全景报告。**必须跑在后台线程**(六十来次子进程)。
pub fn collect(paths: &Paths) -> Report {
    let mut r = Report::default();

    // **模型运行环境排第一** —— 对这个程序(本地模型中枢)来说它是最该先看的,
    // 排在 Android 后面等于藏起来了:用户不滚到底就以为"没做"。
    r.groups.push(group_model(paths));
    r.groups.push(group_qt());
    r.groups.push(group_dotnet());
    r.groups.push(group_c_toolchain());
    r.groups.push(group_rust());
    r.groups.push(group_python());
    r.groups.push(group_jdk());
    r.groups.push(group_node());
    r.groups.push(group_git());
    r.groups.push(group_android(paths));

    r.vars = collect_vars();
    r.mirrors = collect_mirrors();
    r.system = collect_system();
    r
}

/// NVIDIA 驱动要问的字段。
///
/// **只能是 `driver_version`,不能是 `name`**:`--query-gpu=name` 返回的是
/// `NVIDIA GeForce RTX 4060 Laptop GPU`,抠版本会抠到型号 `4060` ——
/// 界面上「NVIDIA 驱动」那一行于是显示成显卡型号,而它看起来完全像一个版本号,
/// 没人能发现填错了。`extract_version` 那边现在要求版本号带点(纯数字不再认),
/// 这里再钉一层:命令本身就该问对字段。
const NVIDIA_DRIVER_CMD: &str = "nvidia-smi --query-gpu=driver_version --format=csv,noheader";

/// 模型运行环境 —— **不是模型文件本身**(那是 Model 页管的事),而是
/// 「跑本地大模型需要什么」:两个推理后端、GPU 运行时、显存。
///
/// 单列一类的理由:它和别的类**不是一回事**。其余九类是「装了没有」,
/// 这一类是「装了能不能跑起来」—— 后端缺一个,llama.cpp 就退化成纯 CPU,
/// 而现象是"能启动但慢得离谱",光看"已安装"根本看不出来。
fn group_model(paths: &Paths) -> Group {
    let mut items = Vec::new();

    for (label, dir, exe) in [
        ("llama.cpp(llama-server)", "llama.cpp", "llama-server.exe"),
        ("stable-diffusion.cpp(sd-cli)", "stable-diffusion.cpp", "sd-cli.exe"),
    ] {
        let p = paths.libs().join(dir).join(exe);
        let ok = p.is_file();
        items.push(Item {
            label: label.to_string(),
            version: String::new(),
            path: ok.then(|| p.clone()),
            note: if ok {
                String::new()
            } else {
                r"缺 —— .\libs\scripts\fetch-libs.ps1 -BinariesOnly".to_string()
            },
        });
    }

    let mdir = paths.libs().join("model");
    let (files, bytes) = dir_stat(&mdir);
    items.push(Item {
        label: "模型目录".to_string(),
        version: if bytes > 0 {
            format!("{} 个文件 / {}", files, human_gb(bytes))
        } else {
            String::new()
        },
        path: mdir.is_dir().then(|| mdir.clone()),
        note: if files == 0 {
            r"空 —— .\libs\scripts\fetch-libs.ps1 -ModelsOnly".to_string()
        } else {
            String::new()
        },
    });

    // GPU 运行时:这几个**装了没装决定推理快慢**,不是"有就行"
    let sys32 = std::env::var("SystemRoot")
        .map(|r| PathBuf::from(r).join("System32"))
        .unwrap_or_else(|_| PathBuf::from(r"C:\Windows\System32"));
    items.push(Item {
        label: "Intel SYCL 运行时".to_string(),
        version: String::new(),
        path: ["sycl8.dll", "sycl7.dll", "sycl6.dll", "libsycl.dll"]
            .iter()
            .map(|d| sys32.join(d))
            .find(|p| p.is_file()),
        note: "Arc 核显走 SYCL 最快;缺了就退回 CPU".to_string(),
    });
    let vk = sys32.join("vulkan-1.dll");
    items.push(Item {
        label: "Vulkan 运行时".to_string(),
        version: String::new(),
        path: vk.is_file().then(|| vk.clone()),
        note: "sd.cpp 的 GPU 路径靠它".to_string(),
    });
    items.push(ver("NVIDIA 驱动", NVIDIA_DRIVER_CMD));
    items.push(ver("NVIDIA CUDA", "nvcc --version"));

    // `vram_text` 是 MachineInfo 的**方法**,不是自由函数 —— 得先采集。
    // 这一步比别的探测重(要查注册表/内存),但整份报告本来就在后台线程跑。
    let vram = crate::cyinfo::collect(paths).vram_text();
    items.push(Item {
        label: "显存".to_string(),
        version: vram,
        path: None,
        note: "UMA 机型上显存与内存是同一块,两个模型同时跑会互相挤".to_string(),
    });

    Group {
        key: "model",
        name: "模型运行环境",
        purpose: "本地推理的后端与 GPU 运行时;缺一个就会「能启动但很慢」",
        items,
    }
}

/// 数一个目录里的文件数与总字节(模型都平铺,不算子目录)。
fn dir_stat(dir: &Path) -> (usize, u64) {
    let mut files = 0usize;
    let mut bytes = 0u64;
    if let Ok(rd) = std::fs::read_dir(dir) {
        for e in rd.flatten() {
            if let Ok(md) = e.metadata()
                && md.is_file()
            {
                files += 1;
                bytes += md.len();
            }
        }
    }
    (files, bytes)
}

fn human_gb(bytes: u64) -> String {
    let gb = bytes as f64 / 1024.0 / 1024.0 / 1024.0;
    if gb >= 1.0 {
        format!("{gb:.2} GB")
    } else {
        format!("{:.0} MB", bytes as f64 / 1024.0 / 1024.0)
    }
}

fn group_qt() -> Group {
    let mut items = vec![ver("qmake", "qmake -query QT_VERSION"), where_is("qmake", "qmake")];
    items.push(where_is("Qt Creator", "qtcreator"));
    // 版本输出是空的说明没装;把 where 的结果并进来
    if items[0].version.is_empty()
        && let Some(p) = items[1].path.clone()
    {
        items[0].path = Some(p);
    }
    items.push(dir_item(
        "CMake(构建 Qt 要用)",
        Path::new("C:\\Program Files\\CMake\\bin"),
    ));
    Group {
        key: "qt",
        name: "Qt",
        purpose: "跨平台 GUI 框架:qmake / CMake + Ninja + MinGW 或 MSVC",
        items,
    }
}

fn group_dotnet() -> Group {
    // `--list-sdks` 一次给出全部 SDK,比 `--version` 有用
    let sdks = crate::tools::capture("dotnet --list-sdks", 30_000);
    let sdk_count = sdks.lines().filter(|l| !l.trim().is_empty()).count();
    let latest = sdks
        .lines()
        .filter_map(extract_version)
        .next()
        .unwrap_or_default();
    let mut items = vec![
        Item {
            label: "SDK(全部版本)".to_string(),
            version: latest,
            path: None,
            note: if sdk_count == 0 {
                "没装".to_string()
            } else {
                format!("{sdk_count} 个")
            },
        },
        ver("dotnet 运行时", "dotnet --version"),
    ];
    // workload:MAUI / Android 走的就是它
    let wl = crate::tools::capture("dotnet workload list", 30_000);
    let has_android = wl.to_ascii_lowercase().contains("android");
    let has_maui = wl.to_ascii_lowercase().contains("maui");
    items.push(Item {
        label: "workload: android".to_string(),
        version: String::new(),
        path: None,
        note: if has_android { "已装".into() } else { "未装(`dotnet workload install android`)".into() },
    });
    items.push(Item {
        label: "workload: maui".to_string(),
        version: String::new(),
        path: None,
        note: if has_maui { "已装".into() } else { "未装".into() },
    });
    Group {
        key: "dotnet",
        name: ".NET",
        purpose: ".NET SDK / ASP.NET Core / MAUI;Android 与 MAUI 目标靠 workload",
        items,
    }
}

fn group_c_toolchain() -> Group {
    let mut items = vec![
        ver("gcc", "gcc --version"),
        ver("clang", "clang --version"),
        // label 里点明「需 VS 开发者环境」:无参 `cl` 在普通 cmd 里必然找不到,
        // 与「没装」在界面上长得一模一样,不写清楚就会被当成缺组件。
        ver("cl(MSVC,需 VS 开发者环境)", "cl"),
        ver("make", "make --version"),
        ver("cmake", "cmake --version"),
        ver("ninja", "ninja --version"),
        ver("gdb", "gdb --version"),
        ver("pkg-config", "pkg-config --version"),
        ver("vcpkg", "vcpkg version"),
        ver("conan", "conan --version"),
    ];
    // vcpkg 位置很关键(它的集成要写进 CMake),单独找一下
    if items[8].path.is_none() {
        let vcpkg_root = std::env::var("VCPKG_ROOT").ok();
        if let Some(root) = vcpkg_root {
            items[8].path = Some(PathBuf::from(&root).join("vcpkg.exe"));
            items[8].note = format!("VCPKG_ROOT={root}");
        }
    }
    Group {
        key: "c",
        name: "C/C++ 工具链",
        purpose: "GCC/Clang/MSVC + Make/CMake/Ninja + 调试器 + 包管理(vcpkg/Conan)",
        items,
    }
}

fn group_rust() -> Group {
    let mut items = vec![
        ver("rustc", "rustc --version"),
        ver("cargo", "cargo --version"),
        ver("clippy", "cargo clippy --version"),
        ver("rustfmt", "rustfmt --version"),
        where_is("rustup", "rustup"),
    ];
    // Android 目标装了没有 —— 交叉编译安卓就靠它
    let targets = crate::tools::capture("rustup target list --installed", 30_000);
    let android: Vec<String> = targets
        .lines()
        .map(|l| l.trim().to_string())
        .filter(|l| l.contains("android"))
        .collect();
    items.push(Item {
        label: "Android 目标".to_string(),
        version: String::new(),
        path: None,
        note: if android.is_empty() {
            "没装(`rustup target add aarch64-linux-android`)".to_string()
        } else {
            android.join(", ")
        },
    });
    Group {
        key: "rust",
        name: "Rust",
        purpose: "rustup 管工具链,cargo 构建,clippy/rustfmt 是质量门",
        items,
    }
}

fn group_python() -> Group {
    let items = vec![
        ver("python", "python --version"),
        ver("pip", "pip --version"),
        ver("poetry", "poetry --version"),
        ver("conda", "conda --version"),
    ];
    Group {
        key: "python",
        name: "Python",
        purpose: "解释器 + pip;多版本隔离靠 venv / conda / poetry",
        items,
    }
}

fn group_jdk() -> Group {
    let items = vec![
        ver("java", "java -version"),
        ver("javac", "javac -version"),
        ver("maven", "mvn -v"),
        ver("gradle", "gradle -v"),
    ];
    Group {
        key: "jdk",
        name: "JDK / 构建",
        purpose: "JDK + Maven/Gradle;Android 构建链的底座,`JAVA_HOME` 指错最难查",
        items,
    }
}

fn group_node() -> Group {
    let items = vec![
        ver("node", "node --version"),
        ver("npm", "npm --version"),
        ver("pnpm", "pnpm --version"),
        ver("yarn", "yarn --version"),
        ver("fnm", "fnm --version"),
        ver("tsc", "tsc --version"),
    ];
    Group {
        key: "node",
        name: "Node.js",
        purpose: "Node + 包管理器;多版本切换靠 nvm / fnm",
        items,
    }
}

fn group_git() -> Group {
    let items = vec![
        ver("git", "git --version"),
        ver("git-lfs", "git lfs version"),
        ver("ssh", "ssh -V"),
        ver("gh(GitHub CLI)", "gh --version"),
    ];
    Group {
        key: "git",
        name: "Git 与凭据",
        purpose: "Git + LFS + OpenSSH + gh;SSH 配置走 GIT_SSH_COMMAND",
        items,
    }
}

/// Android:这一类的探测和别的不同 —— **主要看目录布局,不是看 PATH**。
///
/// SDK 装在哪、里面有哪些包,全靠 `ANDROID_HOME` 指路。所以先读环境变量,
/// 再按标准布局逐个数。
fn group_android(paths: &Paths) -> Group {
    let sdk = std::env::var("ANDROID_HOME")
        .or_else(|_| std::env::var("ANDROID_SDK_ROOT"))
        .ok()
        .filter(|v| !v.trim().is_empty())
        .map(PathBuf::from)
        // 环境变量没设时退回 Android Studio 的默认位置
        .or_else(|| {
            let home = std::env::var("LOCALAPPDATA").ok()?;
            let p = PathBuf::from(home).join("Android").join("Sdk");
            p.is_dir().then_some(p)
        });

    let mut items = Vec::new();
    items.push(Item {
        label: "SDK 根目录".to_string(),
        version: String::new(),
        path: sdk.clone(),
        note: if sdk.is_some() {
            String::new()
        } else {
            "没找到(`ANDROID_HOME` 没设,也没有默认的 %LOCALAPPDATA%\\Android\\Sdk)".to_string()
        },
    });

    if let Some(sdk) = &sdk {
        for (label, sub) in [
            ("platform-tools(adb)", "platform-tools"),
            ("build-tools", "build-tools"),
            ("platforms", "platforms"),
            ("cmdline-tools", "cmdline-tools"),
            ("emulator", "emulator"),
            ("system-images", "system-images"),
        ] {
            let d = sdk.join(sub);
            let n = count_dirs(&d);
            items.push(Item {
                label: label.to_string(),
                version: if n > 0 { format!("{n} 个") } else { String::new() },
                path: d.is_dir().then(|| d.clone()),
                note: if d.is_dir() {
                    String::new()
                } else {
                    "目录不存在".to_string()
                },
            });
        }
    }

    // NDK:环境变量优先,其次 SDK 下的 ndk\ 目录
    let ndk = std::env::var("ANDROID_NDK_HOME")
        .ok()
        .filter(|v| !v.trim().is_empty())
        .map(PathBuf::from)
        .or_else(|| {
            let s = sdk.as_ref()?;
            let dir = s.join("ndk");
            let mut v: Vec<PathBuf> = std::fs::read_dir(&dir)
                .ok()?
                .flatten()
                .map(|e| e.path())
                .filter(|p| p.is_dir())
                .collect();
            v.sort();
            v.pop()
        });
    // NDK 的版本号写在 source.properties 里的 Pkg.Revision
    let ndk_ver = ndk
        .as_ref()
        .and_then(|d| std::fs::read_to_string(d.join("source.properties")).ok())
        .and_then(|t| {
            t.lines()
                .find(|l| l.starts_with("Pkg.Revision"))
                .and_then(|l| l.split('=').nth(1))
                .map(|v| v.trim().to_string())
        })
        .unwrap_or_default();
    items.push(Item {
        label: "NDK".to_string(),
        version: ndk_ver,
        path: ndk,
        note: String::new(),
    });

    items.push(ver("adb", "adb version"));
    items.push(ver("sdkmanager", "sdkmanager --version"));
    items.push(where_is("avdmanager", "avdmanager"));

    let _ = paths;
    Group {
        key: "android",
        name: "Android",
        purpose: "SDK(platform-tools/build-tools/platforms)+ NDK + Gradle;交叉编译的底座",
        items,
    }
}

/// 环境变量:每个工具链的「隐形依赖」。
fn collect_vars() -> Vec<Var> {
    vec![
        // Qt
        var("QTDIR", "Qt 安装根;qmake 找不到 Qt 时第一个该看的就是它", r"C:\Qt\6.6.2\mingw_64"),
        var("QT_PLUGIN_PATH", "Qt 插件搜索路径(平台插件缺了就起不来窗口)", r"%QTDIR%\plugins"),
        var("QML2_IMPORT_PATH", "QML 模块搜索路径", r"%QTDIR%\qml"),
        var("CMAKE_PREFIX_PATH", "CMake 找 Qt 的地方(`find_package(Qt6)`)", r"%QTDIR%"),
        // .NET
        var("DOTNET_ROOT", "dotnet 安装根;多版本共存时靠它", r"C:\Program Files\dotnet"),
        var("NUGET_PACKAGES", "NuGet 包缓存位置(默认在用户目录,可挪到大盘)", r"D:\nuget"),
        var("DOTNET_CLI_TELEMETRY_OPTOUT", "关遥测(`1` 即关)", "1"),
        // C 工具链
        var("CC", "C 编译器;Rust 的 cc crate 也读它", "gcc"),
        var("CXX", "C++ 编译器", "g++"),
        var("CFLAGS", "C 编译附加参数", "-O2"),
        var("CXXFLAGS", "C++ 编译附加参数", "-O2"),
        var("LDFLAGS", "链接附加参数", "-L/usr/local/lib"),
        var("INCLUDE", "MSVC 头文件搜索路径", r"%VCToolsInstallDir%\include"),
        var("LIB", "MSVC 库搜索路径", r"%VCToolsInstallDir%\lib\x64"),
        var("LIBPATH", "MSVC 链接器库路径", ""),
        var("CPATH", "GCC/Clang 的头文件搜索路径", "/usr/local/include"),
        var("LIBRARY_PATH", "GCC/Clang 的库搜索路径", "/usr/local/lib"),
        var("PKG_CONFIG_PATH", "pkg-config 的 .pc 搜索路径", "/usr/local/lib/pkgconfig"),
        // Rust
        var("CARGO_HOME", "cargo 的注册表与工具缓存", r"%USERPROFILE%\.cargo"),
        var("RUSTUP_HOME", "rustup 的工具链存放处", r"%USERPROFILE%\.rustup"),
        // Python
        var("PYTHONPATH", "额外的模块搜索路径", ""),
        var("PYTHONHOME", "解释器根;**设错会让 python 完全起不来**", ""),
        var("VIRTUAL_ENV", "当前激活的虚拟环境", ""),
        var("PIP_INDEX_URL", "pip 镜像源;国内不设它基本装不动", "https://pypi.tuna.tsinghua.edu.cn/simple"),
        // JDK
        var("JAVA_HOME", "Android/Gradle/Maven 全靠它;指错比不装还难查", r"C:\Program Files\Java\jdk-21"),
        var("JDK_HOME", "部分工具只认这个名字", r"%JAVA_HOME%"),
        var("MAVEN_OPTS", "Maven 的 JVM 参数", "-Xmx2g"),
        var("GRADLE_OPTS", "Gradle 的 JVM 参数", "-Xmx2g"),
        // Node
        var("NODE_PATH", "Node 的额外模块搜索路径", ""),
        var("NPM_CONFIG_PREFIX", "全局包安装位置(默认在用户目录)", r"%APPDATA%\npm"),
        var("NPM_CONFIG_REGISTRY", "npm 镜像源", "https://registry.npmmirror.com"),
        var("NVM_HOME", "nvm 安装位置", r"C:\nvm"),
        var("NVM_SYMLINK", "nvm 的当前版本软链", r"C:\nodejs"),
        // Git
        var("GIT_SSH", "ssh 可执行文件", r"C:\Windows\System32\OpenSSH\ssh.exe"),
        var("GIT_SSH_COMMAND", "带参数的 ssh 命令(优先级高于 GIT_SSH)", "ssh -i ~/.ssh/id_ed25519"),
        var("GIT_CONFIG_GLOBAL", "全局 gitconfig 位置", r"%USERPROFILE%\.gitconfig"),
        // Android
        var("ANDROID_HOME", "SDK 根;gradle 与 sdkmanager 都读它", r"%LOCALAPPDATA%\Android\Sdk"),
        var("ANDROID_SDK_ROOT", "老名字,部分工具仍读", r"%ANDROID_HOME%"),
        var("ANDROID_NDK_HOME", "NDK 根;交叉编译必须", r"%ANDROID_HOME%\ndk\27.0.12077973"),
        var("GRADLE_HOME", "Gradle 安装根", r"C:\Gradle\gradle-8.7"),
        // 通用
        var("TEMP", "临时目录;编译中间产物堆这里", r"%LOCALAPPDATA%\Temp"),
        var("LANG", "locale;**中文 Windows 默认 GBK**,不设 UTF-8 会出现乱码", "zh_CN.UTF-8"),
        var("LC_ALL", "覆盖所有 locale 类别", "zh_CN.UTF-8"),
        var("HTTP_PROXY", "HTTP 代理", "http://127.0.0.1:7890"),
        var("HTTPS_PROXY", "HTTPS 代理", "http://127.0.0.1:7890"),
        var("NO_PROXY", "不走代理的地址", "localhost,127.0.0.1,.local"),
        var("SSL_CERT_FILE", "额外的根证书文件", ""),
        var("SSL_CERT_DIR", "额外的根证书目录", ""),
    ]
}

/// 镜像源:国内网络下「装不上」的头号原因。
fn collect_mirrors() -> Vec<Mirror> {
    vec![
        mirror("npm", "npm config get registry", &["registry.npmjs.org"]),
        mirror("pip", "pip config get global.index-url", &["pypi.org"]),
        // cargo **不能**用 `cargo search` 探镜像:那条命令要走网络、还会拉
        // crates.io 索引,实测能把整个检测卡在「检测中…」十几秒。改成纯读配置。
        cargo_mirror(),
        mirror("NuGet", "dotnet nuget list source", &["nuget.org"]),
    ]
}

/// 从 `chcp` 的输出里取代码页数字。
///
/// 代码页是**纯数字**,而 `extract_version` 现在只认带点的版本号(理由见那边),
/// 所以这里单独解析:取最后一个冒号后面那段(`Active code page: 936` /
/// `活动代码页: 936` 两种写法都能取到)。
/// 取不到就返回空串 —— 界面上「控制台代码页」那一行的版本格空着,但 `note`
/// 仍然会说是不是 UTF-8,该有的信息不丢;反过来,为了这一格去给
/// `extract_version` 重开「纯数字也算」的口子,就会把显卡型号当驱动版本
/// (已经踩过一次,见 `NVIDIA_DRIVER_CMD`)。
fn code_page_number(out: &str) -> String {
    out.rsplit(':')
        .next()
        .map(str::trim)
        .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
        .unwrap_or_default()
        .to_string()
}

/// 系统层面的开关 —— 装完还要改系统设置,这一栏就是查那些。
fn collect_system() -> Vec<Item> {
    let mut items = Vec::new();

    // 长路径:超过 260 字符的路径在没开这个开关时**创建文件会失败**,
    // 而 node_modules / .cargo 嵌套轻易就超了。
    let long_paths = crate::tools::capture(
        r#"reg query "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled"#,
        15_000,
    );
    let lp_on = long_paths.contains("0x1");
    items.push(Item {
        label: "长路径支持(LongPathsEnabled)".to_string(),
        version: String::new(),
        path: None,
        note: if lp_on {
            "已开启".to_string()
        } else {
            "未开启 —— 深目录下建文件会失败(`fsutil behavior set disable8dot3` 之外还要改这个)".to_string()
        },
    });

    // 当前代码页:65001 = UTF-8
    let cp = crate::tools::capture("chcp", 10_000);
    let utf8 = cp.contains("65001");
    items.push(Item {
        label: "控制台代码页".to_string(),
        version: code_page_number(&cp),
        path: None,
        note: if utf8 {
            "UTF-8".to_string()
        } else {
            "不是 UTF-8 —— 中文输出会乱码(`chcp 65001`)".to_string()
        },
    });

    // VC++ 运行库:缺了只会在运行时报「找不到 xxx.dll」
    let sys32 = std::env::var("SystemRoot")
        .map(|r| PathBuf::from(r).join("System32"))
        .unwrap_or_else(|_| PathBuf::from(r"C:\Windows\System32"));
    for (label, dll) in [
        ("VC++ 运行库(msvcp140)", "msvcp140.dll"),
        ("OpenSSL(libcrypto)", "libcrypto-3-x64.dll"),
        ("zlib(zlib1)", "zlib1.dll"),
        ("SQLite(sqlite3)", "sqlite3.dll"),
        ("libpng", "libpng16.dll"),
    ] {
        let p = sys32.join(dll);
        items.push(Item {
            label: label.to_string(),
            version: String::new(),
            path: p.is_file().then(|| p.clone()),
            note: if p.is_file() {
                String::new()
            } else {
                format!("System32 下没有 {dll}")
            },
        });
    }

    // 只查 System32,**不查 `libs\`**。这里原来写着「本程序自己带的运行库目录
    // 也算上(绿色版不走 System32)」,但一行实现都没有 —— 注释承诺了一件
    // 不存在的事,读代码的人会以为绿色版已经被覆盖到了。
    //
    // 真去扫 `libs\` 也不对:仓库里没有任何脚本往那儿放这几个 DLL
    // (`fetch-libs.ps1` 只拉 llama.cpp / sd.cpp 与模型),扫了只会多出五行
    // 恒定的「缺」。哪天打包脚本开始自带这些 DLL,再把 `Paths` 传进来一起扫。
    items
}

// ===========================================================================
// 安装 / 更新
//
// 页面副标题一直写着「检测 / 安装 / 更新」,但后两个从来没实现过 ——
// 这一层就是补上它们。
//
// ## 三条原则
//
// 1. **不自己实现下载安装**。每个组件都有官方安装器和它自己的坑(MSI 参数、
//    PATH 写入、注册表项),重写一遍只会更差。我们只负责**挑对命令**。
// 2. **包管理器先探测**。同一件事在 winget / choco / scoop 下命令完全不同;
//    没探测到就说「没装包管理器」,而不是硬跑一条注定失败的命令。
// 3. **命令先给用户看**。装软件是重动作(要写 PATH、可能弹 UAC),
//    界面上要能把将执行的完整命令显示出来再让人按下去。
// ===========================================================================

/// 一个包管理器。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PkgMgr {
    /// `winget` / `choco` / `scoop`
    pub key: &'static str,
    pub exe: &'static str,
    /// 探测到的路径;`None` = 没装
    pub path: Option<PathBuf>,
}

impl PkgMgr {
    pub fn available(&self) -> bool {
        self.path.is_some()
    }
    /// 版本(探测时才跑,所以单独一个函数)。
    pub fn version(&self) -> String {
        if !self.available() {
            return String::new();
        }
        let cmd = match self.key {
            "scoop" => "scoop --version",
            _ => &format!("{} --version", self.exe),
        };
        extract_version(&crate::tools::capture(cmd, 20_000)).unwrap_or_default()
    }
}

/// 探测本机有哪些包管理器。
///
/// 顺序即**优先级**:优先用已经装好的那个 —— 没装包管理器就什么都装不了,
/// 这一点要在界面上直说。
pub fn package_managers() -> Vec<PkgMgr> {
    ["winget", "choco", "scoop"]
        .iter()
        .map(|key| {
            // `where` 找得到才算装了。注意 winget 在 Windows 11 上通常在
            // WindowsApps 下,而那个目录的 ACL 有时会挡住 `is_file()`,
            // 所以 `where` 有输出就认。
            let out = crate::tools::capture(&format!("where {key}"), 15_000);
            let path = out
                .lines()
                .map(|l| l.trim())
                .find(|l| !l.is_empty())
                .map(PathBuf::from);
            PkgMgr {
                key,
                exe: key,
                path,
            }
        })
        .collect()
}

/// 一个可执行的动作(安装或更新一项)。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Action {
    /// `安装` / `更新` / `打补丁`
    pub kind: &'static str,
    /// 装什么,如 `Qt Creator`
    pub what: &'static str,
    /// 完整命令(界面要原样显示给用户看)
    pub cmd: String,
    /// 备注:替代方案、坑
    pub note: &'static str,
}

/// 界面上的动作名 → `libs\scripts\pkg-manage.ps1` 里的组件 id。
///
/// 返回空串表示**脚本不管这一项** —— 那些没有包管理器入口的(vcpkg / NDK /
/// cmdline-tools / MAUI workload)只能手动,保持原来的说明就好。
///
/// 为什么用「名字查表」而不是给每行加一列:那张表有三十多行,加一列要动
/// 每一行,而**改动面积越大越容易在某个不显眼的行上出错**。查表是增量的、
/// 改错了也只影响被查到的那一项。
fn script_id_for(what: &str) -> &'static str {
    match what {
        "CMake" => "cmake",
        "Git" => "git",
        "Node.js LTS" => "node",
        "Python" => "python",
        "rustup 工具链" => "rust",
        "Temurin JDK 21" => "jdk",
        "Qt Creator" => "qt",
        ".NET SDK" => "dotnet",
        "Go" => "go",
        _ => "",
    }
}

/// 给每一类生成「安装」「更新」或「手动」动作。
///
/// **每一类都要有东西可看** —— 早先这里没有 `model` 分支,而兜底是 `_ => &[]`,
/// 于是「模型运行环境」整类一条动作都产不出来,界面上那一栏就是光秃秃的
/// (同页别的类都有按钮或说明,只有它是空的,看起来像页面坏了)。
///
/// # 管理器由**脚本**挑,不再由界面选
///
/// 脚本(`libs\scripts\pkg-manage.ps1`)自己探测 winget / choco / scoop 并选一个,
/// 所以这里的命令对三项是同一句。用户不需要先知道自己机器上有哪个管理器 ——
/// 而原来界面那个选择器正是在要求他先知道。
///
/// # 为什么 `mgr` 还在
///
/// 脚本**还不认识**的项(Ninja / LLVM / Gradle / Android Studio …)仍走原来的
/// 按管理器拼命令。一次全换会立刻造成回归:那些项会变得装不了。
/// 等脚本的表补齐了,`mgr` 这个参数就可以连根删掉。
pub fn actions_for(paths: &crate::paths::Paths, group_key: &str, mgr: &str) -> Vec<Action> {
    // (装什么, winget id, choco 名, scoop 名, 安装备注, 更新备注)
    let rows: &[(&str, &str, &str, &str, &str, &str)] = match group_key {
        // 模型运行环境:这一类**没有包管理器入口**,它的安装物是本程序自己带的
        // `libs\scripts\fetch-libs.ps1`(不是 `pkg-manage.ps1`),所以只产「手动」。
        //
        // 为什么不做成可点的「安装」按钮:`run_action` 是一次
        // `capture(cmd, 10min)` —— 没有进度、到点就被杀,而拉后端是几十上百 MB 的
        // 下载(模型几个 GB);更要紧的是 Local Model 页已经有一条带进度、能停、
        // 并且**互斥**(同一时间只允许一个下载进程)的路径(`local_model.rs` 的
        // 「下载缺失组件」),这里再开一个入口就等于绕过那道互斥,两个进程同时往
        // `libs\` 里解压同一份目录。所以这里只把命令写清楚,让人去跑那条正规路径。
        "model" => &[
            ("llama.cpp / sd.cpp 后端", "", "", "",
             r"用 `.\libs\scripts\fetch-libs.ps1 -BinariesOnly`;或到 Local Model 页点「下载缺失组件」", ""),
            ("本地模型文件", "", "", "",
             r"用 `.\libs\scripts\fetch-libs.ps1 -ModelsOnly`;或在 Local Model 页挑一个模型下载", ""),
            ("GPU 运行时(SYCL / Vulkan)", "", "", "",
             "随显卡驱动装:Vulkan 跟 GPU 驱动走,SYCL 跟 Intel 显卡驱动 / oneAPI 走;装完回来重测这一类", ""),
        ],
        "qt" => &[
            ("Qt Creator", "Qt.QtCreator", "qtcreator", "qtcreator",
             "只装 Creator 和 Qt 库;具体 Kit(MinGW/MSVC、Qt 版本)还要在 Creator 里配",
             "Kit 版本用 Creator 的 MaintenanceTool 升,winget 只管外壳"),
            ("CMake", "Kitware.CMake", "cmake", "cmake",
             "Qt 用 CMake 构建;Kit 里也可以让它用自带的", ""),
            ("Ninja", "Ninja-build.Ninja", "ninja", "ninja",
             "CMake 的默认生成器,比 make 快得多", ""),
        ],
        "dotnet" => &[
            (".NET SDK", "Microsoft.DotNet.SDK.8", "dotnet-sdk", "dotnet-sdk",
             "装完还要 `dotnet workload install android` 才能编安卓",
             "`dotnet workload update` 比换 SDK 更常用"),
            (".NET MAUI workload", "", "", "",
             "没有包管理器入口,只能 `dotnet workload install maui`", ""),
        ],
        "c" => &[
            ("CMake", "Kitware.CMake", "cmake", "cmake", "", ""),
            ("Ninja", "Ninja-build.Ninja", "ninja", "ninja", "", ""),
            ("LLVM/Clang", "LLVM.LLVM", "llvm", "llvm",
             "Windows 上还要装 VS Build Tools 才有 MSVC 头文件与库", ""),
            ("vcpkg", "", "", "",
             "没有包管理器入口:`git clone https://github.com/microsoft/vcpkg && .\\bootstrap-vcpkg.bat`", ""),
        ],
        "rust" => &[
            ("rustup 工具链", "Rustlang.Rustup", "rustup", "rustup",
             "装完 `rustup default stable`;Android 目标要 `rustup target add aarch64-linux-android`",
             "**用 `rustup update`,不要用包管理器** —— 包管理器会把 rustup 自己换掉"),
        ],
        "python" => &[
            ("Python", "Python.Python.3.12", "python", "python",
             "装完记得勾 Add to PATH;多版本用 venv 隔离",
             "**别用包管理器升 Python** —— 直接装新版本、旧版本留着,用 venv 选"),
        ],
        "jdk" => &[
            ("Temurin JDK 21", "EclipseAdoptium.Temurin.21.JDK", "temurin21", "temurin21",
             "Android Gradle Plugin 8.x 要求 JDK 17+;装完设 `JAVA_HOME`",
             "多 JDK 共存时靠 `JAVA_HOME` 切换,不要指望 PATH 顺序"),
            ("Gradle", "Gradle.Gradle", "gradle", "gradle",
             "Android 工程一般用 wrapper(`gradlew`),全局 Gradle 只在手动构建时需要", ""),
            ("Maven", "Apache.Maven", "maven", "maven", "", ""),
        ],
        "node" => &[
            ("Node.js LTS", "OpenJS.NodeJS.LTS", "nodejs-lts", "nodejs-lts",
             "装完 `npm i -g pnpm`",
             "多版本切换用 fnm/nvm,**不要**用包管理器升级(会打乱全局包)"),
            ("pnpm", "pnpm.pnpm", "pnpm", "pnpm", "", ""),
            ("fnm(版本切换)", "Schniz.fnm", "fnm", "fnm",
             "比 nvm-windows 快,且和 WSL 里的用法一致", ""),
        ],
        "git" => &[
            ("Git", "Git.Git", "git", "git",
             "装完 `git lfs install` 把 LFS 钩子写进全局配置", ""),
            ("GitHub CLI", "GitHub.cli", "gh", "gh", "装完 `gh auth login`", ""),
        ],
        "android" => &[
            ("Android Studio", "Google.AndroidStudio", "androidstudio", "androidstudio",
             "装它会顺带铺好 SDK 与 cmdline-tools —— **首次装最省事的路**",
             "Studio 自身走它内置的更新器"),
            ("Android cmdline-tools", "", "", "",
             "没有包管理器入口:从 developer.android.com 下 commandlinetools zip 解到 `%ANDROID_HOME%\\cmdline-tools\\latest`",
             "装好之后 **SDK/NDK/构建工具都用 `sdkmanager` 升**,不要用系统的包管理器"),
            ("platform-tools(adb)", "", "", "",
             "同上:`sdkmanager \"platform-tools\"`", ""),
            ("NDK", "", "", "",
             "`sdkmanager \"ndk;27.0.12077973\"`", ""),
        ],
        _ => &[],
    };

    let mut out = Vec::new();
    let script = paths.libs().join("scripts").join("pkg-manage.ps1");
    // 路径可能带空格,所以加引号;`-ExecutionPolicy Bypass` 是必须的 ——
    // 默认策略下这个脚本会因为「未签名」而拒绝加载(实测踩过)。
    let call = |action: &str, id: &str| {
        format!(
            "powershell -NoProfile -ExecutionPolicy Bypass -File \"{}\" -Action {action} -Id {id}",
            script.display()
        )
    };
    for (what, winget_id, choco, scoop, install_note, update_note) in rows {
        // **脚本认识的项优先走脚本**:管理器由它自己挑,不再由界面选。
        let sid = script_id_for(what);
        let (install_cmd, update_cmd) = if !sid.is_empty() {
            (Some(call("install", sid)), Some(call("update", sid)))
        } else {
            match mgr {
                "winget" => (
                    (!winget_id.is_empty()).then(|| format!("winget install --id {winget_id} -e --accept-source-agreements")),
                    (!winget_id.is_empty()).then(|| format!("winget upgrade --id {winget_id} -e")),
                ),
                "choco" => (
                    (!choco.is_empty()).then(|| format!("choco install {choco} -y")),
                    (!choco.is_empty()).then(|| format!("choco upgrade {choco} -y")),
                ),
                "scoop" => (
                    (!scoop.is_empty()).then(|| format!("scoop install {scoop}")),
                    (!scoop.is_empty()).then(|| format!("scoop update {scoop}")),
                ),
                _ => (None, None),
            }
        };
        // 有包管理器入口就出「安装/更新」两个;没有入口的(如 vcpkg)只出说明
        if let Some(c) = &install_cmd {
            out.push(Action {
                kind: "安装",
                what,
                cmd: c.clone(),
                note: install_note,
            });
        }
        if let Some(c) = &update_cmd {
            out.push(Action {
                kind: "更新",
                what,
                cmd: c.clone(),
                note: if update_note.is_empty() { install_note } else { update_note },
            });
        }
        if install_cmd.is_none() && !install_note.is_empty() {
            // 没有一键入口的也列出来 —— 让用户知道**为什么**没有按钮
            out.push(Action {
                kind: "手动",
                what,
                cmd: String::new(),
                note: install_note,
            });
        }
    }
    out
}

/// 各类的 `(key, 显示名)`,顺序与 `collect()` 一致。
///
/// **当前没有生产代码读它**:Environment 页没有分类下拉,组名直接取
/// `Group::name`(`core_pages.rs` 是照报告画分组的)。原文写「界面顶部的分类
/// 下拉用它」是**一句不成立的话** —— 那句已删。
///
/// 那为什么还留着:它把「有哪几类、叫什么」钉在一处,配合
/// `catalog_matches_collected_groups` 这条测试,可以挡住「加了新类忘了登记」
/// (报告里有十类,这里也必须十类、顺序一致)。删掉它等于把这层护栏一起删掉。
/// 将来真要做分类下拉,直接用它就行,不用再补一遍。
pub const CATALOG: [(&str, &str); 10] = [
    ("model", "模型运行环境"),
    ("qt", "Qt"),
    ("dotnet", ".NET"),
    ("c", "C/C++ 工具链"),
    ("rust", "Rust"),
    ("python", "Python"),
    ("jdk", "JDK / 构建"),
    ("node", "Node.js"),
    ("git", "Git 与凭据"),
    ("android", "Android"),
];

/// 执行一条安装 / 更新命令,**带超时**并返回完整输出。
///
/// 为什么要有超时:`winget install` 在某些网络下会**挂在交互提示上**几十分钟 ——
/// 界面上就是一个永远转的圈。宁可超时后如实说「可能卡在交互提示」,
/// 也不要让用户对着一个转不停的圈猜。
///
/// 超时给到 10 分钟:装 Qt / Android Studio 这类几百 MB 的东西确实要几分钟。
pub fn run_action(cmd: &str) -> String {
    if cmd.trim().is_empty() {
        return "空命令,没执行".to_string();
    }
    const TIMEOUT_MS: u64 = 10 * 60 * 1000;
    let out = crate::tools::capture(cmd, TIMEOUT_MS);
    if out.trim().is_empty() {
        format!(
            "命令没有任何输出。可能原因:\n  \
             1. 命令不存在(包管理器被卸载/改名)\n  \
             2. 卡在交互提示上被超时杀掉(winget 常见,加 --accept-source-agreements)\n  \
             3. 权限不足(装到 Program Files 需要管理员)\n\
             命令: {cmd}"
        )
    } else {
        out
    }
}

/// cargo 的镜像源 —— **纯读配置,不联网**。
///
/// 为什么不用 `cargo search`:那条命令会走网络并更新 crates.io 索引,
/// 实测让整个环境检测卡在「检测中…」。而我们要的信息(有没有换源)
/// 就写在 `config.toml` 里,读文件是毫秒级。
///
/// 两个位置都看:`$CARGO_HOME\config.toml`,以及默认的 `~\.cargo\config.toml`。
fn cargo_mirror() -> Mirror {
    let mut cands: Vec<PathBuf> = Vec::new();
    if let Ok(h) = std::env::var("CARGO_HOME") {
        cands.push(PathBuf::from(h).join("config.toml"));
    }
    if let Ok(h) = std::env::var("USERPROFILE") {
        cands.push(PathBuf::from(h).join(".cargo").join("config.toml"));
    }

    for p in cands {
        let Ok(text) = std::fs::read_to_string(&p) else {
            continue;
        };
        // 换源的写法是 `replace-with = "xxx"`,被替换的源里给 registry 地址
        if text.contains("replace-with") || text.contains("registry =") {
            let url = text
                .lines()
                .map(|l| l.trim())
                .find(|l| l.starts_with("registry") && l.contains("http"))
                .and_then(|l| l.split('"').nth(1))
                .unwrap_or("(已换源,但没解析出地址)");
            return Mirror {
                name: "cargo",
                current: format!("{url}  [{}]", p.display()),
                official: false,
            };
        }
        // 有配置文件但没换源 = 官方源
        return Mirror {
            name: "cargo",
            current: format!("官方 crates.io([{}] 没写 replace-with)", p.display()),
            official: true,
        };
    }
    Mirror {
        name: "cargo",
        current: "官方 crates.io(没有 config.toml)".to_string(),
        official: true,
    }
}

#[cfg(test)]
mod tests {
    /// **脚本认识的组件要走脚本**,而不是再拼 winget / choco / scoop 命令。
    ///
    /// 这条钉的是这次接线的核心行为:管理器由脚本挑,界面不该再拼命令。
    /// 退化的话(有人把 match mgr 那段挪回来)这里会红。
    #[test]
    fn known_components_go_through_the_unified_script() {
        let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
        let acts = actions_for(&p, "git", "winget");
        let git = acts.iter().find(|a| a.what == "Git").expect("Git 那条应当在");
        assert!(git.cmd.contains("pkg-manage.ps1"), "没走脚本: {}", git.cmd);
        assert!(git.cmd.contains("-Id git"), "组件 id 不对: {}", git.cmd);
        assert!(
            !git.cmd.contains("winget install"),
            "不该再自己拼 winget 命令: {}",
            git.cmd
        );
    }

    /// 而**脚本还不认识的**组件仍走原来的按管理器拼命令 ——
    /// 一次全换会让这些项直接变成装不了(回归)。
    #[test]
    fn components_the_script_does_not_know_still_use_the_manager() {
        let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
        let acts = actions_for(&p, "qt", "winget");
        let ninja = acts.iter().find(|a| a.what == "Ninja").expect("Ninja 那条应当在");
        assert!(
            ninja.cmd.contains("winget install"),
            "脚本不管的项应当保持原样: {}",
            ninja.cmd
        );
    }

    /// 命令行里必须带 `-ExecutionPolicy Bypass`。
    ///
    /// 少了它,默认策略下这个脚本会因为「未签名」拒绝加载 ——
    /// 而报错是 `cannot be loaded ... is not digitally signed`,
    /// 用户完全看不出这是命令行少了个开关。
    #[test]
    fn the_script_call_bypasses_the_execution_policy() {
        let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
        let acts = actions_for(&p, "git", "winget");
        let git = acts.iter().find(|a| a.what == "Git").expect("Git 那条应当在");
        assert!(
            git.cmd.contains("-ExecutionPolicy Bypass"),
            "少了这个开关脚本根本加载不了: {}",
            git.cmd
        );
    }

    use super::*;

    #[test]
    fn version_extraction_handles_the_real_formats() {
        // 各家 --version 的真实样子
        assert_eq!(
            extract_version("gcc (x86_64-w64-mingw32) 15.2.0").unwrap(),
            "15.2.0"
        );
        assert_eq!(extract_version("v20.11.1").unwrap(), "20.11.1");
        assert_eq!(extract_version("Python 3.12.10").unwrap(), "3.12.10");
        assert_eq!(
            extract_version("rustc 1.98.1 (abcdef 2026-01-01)").unwrap(),
            "1.98.1"
        );
        assert_eq!(
            extract_version("cmake version 3.29.2").unwrap(),
            "3.29.2"
        );
        // java 往 stderr 写,格式是 `openjdk version "21.0.1" 2024-10-15`
        assert_eq!(
            extract_version("openjdk version \"21.0.1\" 2024-10-15").unwrap(),
            "21.0.1"
        );
        // 读不出版本时返回 None,不许瞎编
        assert!(extract_version("").is_none());
        assert!(extract_version("command not found").is_none());
    }

    #[test]
    fn group_state_reflects_how_many_items_were_found() {
        let mk = |found: usize, total: usize| Group {
            key: "t",
            name: "T",
            purpose: "",
            items: (0..total)
                .map(|i| Item {
                    label: format!("i{i}"),
                    version: if i < found { "1.0".into() } else { String::new() },
                    path: None,
                    note: String::new(),
                })
                .collect(),
        };
        assert_eq!(mk(0, 3).state(), "缺失");
        assert_eq!(mk(1, 3).state(), "部分可用");
        assert_eq!(mk(3, 3).state(), "可用");
        assert_eq!(mk(0, 3).found_count(), (0, 3));
    }

    /// 纯数字**不是**版本号。
    ///
    /// 守的是 `nvidia-smi --query-gpu=name` 那类输出:`NVIDIA GeForce RTX 4060
    /// Laptop GPU` 里的 `4060` 曾经被当驱动版本填进界面 —— 它看起来完全像个
    /// 版本号,所以这种错**没人能发现**。
    #[test]
    fn bare_numbers_are_not_versions() {
        assert!(extract_version("NVIDIA GeForce RTX 4060 Laptop GPU").is_none());
        assert!(extract_version("936").is_none());
        // 反过来,真正的驱动版本号(带点)必须读得出来
        assert_eq!(extract_version("566.36").unwrap(), "566.36");
        // 命令本身也要问对字段:问 `name` 就会把型号当版本
        assert!(
            NVIDIA_DRIVER_CMD.contains("driver_version"),
            "驱动版本要问 driver_version: {NVIDIA_DRIVER_CMD}"
        );
        assert!(
            !NVIDIA_DRIVER_CMD.contains("=name"),
            "不能问 name(那是显卡型号): {NVIDIA_DRIVER_CMD}"
        );
    }

    /// 代码页是**纯数字**,`extract_version` 不再管它,得自己解析。
    ///
    /// 这条同时钉住两边:代码页还能显示出来,而「纯数字」的口子没有为了它重新
    /// 开到 `extract_version` 里去(那个口子会把显卡型号当版本)。
    #[test]
    fn code_page_is_parsed_without_extract_version() {
        assert_eq!(code_page_number("Active code page: 936"), "936");
        assert_eq!(code_page_number("活动代码页: 65001"), "65001");
        // 读不到就空着,不许瞎编
        assert_eq!(code_page_number(""), "");
        assert_eq!(code_page_number("No code page here"), "");
    }

    /// 「设了但是空」与「没设」是两回事。
    ///
    /// 界面靠 `value.is_some()` 决定画值还是画「未设 → 建议 …」(core_pages.rs),
    /// 合并了就等于把「有人把它清空了」这条信息抹掉。
    #[test]
    fn an_empty_variable_is_not_an_unset_one() {
        assert_eq!(var_value(Ok(String::new())), Some(String::new()));
        assert_eq!(var_value(Ok("   ".to_string())), Some(String::new()));
        assert_eq!(var_value(Ok("x".to_string())), Some("x".to_string()));
        assert_eq!(var_value(Err(std::env::VarError::NotPresent)), None);
    }

    /// 镜像源读不到时必须**说未知**,不能悄悄算成官方源,更不能算成「已换源」。
    #[test]
    fn unreadable_mirror_is_reported_as_unknown() {
        let (cur, official) = classify_mirror(None, &["registry.npmjs.org"]);
        assert!(cur.contains("未知"), "读不到就要把话说清楚,实际: {cur}");
        assert!(
            official,
            "徽章只有两态,这里必须选保守的一侧(理由见 `MIRROR_UNREADABLE`)"
        );

        let (cur, official) =
            classify_mirror(Some("https://registry.npmmirror.com"), &["registry.npmjs.org"]);
        assert_eq!(cur, "https://registry.npmmirror.com");
        assert!(!official, "换了源就该报已换源");

        let (_, official) =
            classify_mirror(Some("https://registry.npmjs.org/"), &["registry.npmjs.org"]);
        assert!(official, "官方源的标记要认得出来");
    }

    /// 各类一个都不能少 —— 这是「修改 Environment」的验收点。
    #[test]
    fn all_ten_categories_are_present() {
        let dir = std::env::temp_dir().join("nfarms-envprobe-test");
        let _ = std::fs::create_dir_all(&dir);
        let r = collect(&crate::Paths::from_exe_dir(&dir));
        let keys: Vec<&str> = r.groups.iter().map(|g| g.key).collect();
        assert_eq!(
            keys,
            ["model", "qt", "dotnet", "c", "rust", "python", "jdk", "node", "git", "android"]
        );
        assert_eq!(r.groups.len(), 10);
    }

    /// `CATALOG` 与 `collect()` 必须一一对应(顺序也要一致)。
    ///
    /// `CATALOG` 的文档注释里一直写着「一致性由 `catalog_matches_collected_groups`
    /// 那条测试守着」, 而**这条测试此前并不存在** —— 于是「加了新类忘了登记
    /// CATALOG」这件事没有任何东西拦得住。这里把它补上。
    #[test]
    fn catalog_matches_collected_groups() {
        let dir = std::env::temp_dir().join("nfarms-envprobe-test3");
        let _ = std::fs::create_dir_all(&dir);
        let r = collect(&crate::Paths::from_exe_dir(&dir));
        let from_report: Vec<(&str, &str)> = r.groups.iter().map(|g| (g.key, g.name)).collect();
        assert_eq!(
            CATALOG.to_vec(),
            from_report,
            "CATALOG 与 collect() 的类目/顺序对不上了"
        );
    }

    /// 采集要真能跑完,而且三个横向维度都要有内容。
    #[test]
    fn collect_produces_vars_mirrors_and_system_rows() {
        let dir = std::env::temp_dir().join("nfarms-envprobe-test2");
        let _ = std::fs::create_dir_all(&dir);
        let r = collect(&crate::Paths::from_exe_dir(&dir));
        // 环境变量清单要覆盖各类各自的关键变量
        for must in [
            "JAVA_HOME", "ANDROID_HOME", "CARGO_HOME", "DOTNET_ROOT", "QTDIR", "CC",
            "NPM_CONFIG_REGISTRY", "GIT_SSH_COMMAND", "PIP_INDEX_URL",
        ] {
            assert!(
                r.vars.iter().any(|v| v.name == must),
                "环境变量清单里缺 {must}"
            );
        }
        assert!(!r.mirrors.is_empty());
        // 长路径与代码页必须被检查(这两个最容易「装好了却用不了」)
        assert!(r.system.iter().any(|i| i.label.contains("长路径")));
        assert!(r.system.iter().any(|i| i.label.contains("代码页")));
    }

    #[test]
    fn item_summary_prefers_version_then_path_then_note() {
        let mut i = Item {
            label: "x".into(),
            version: "1.0".into(),
            path: Some(PathBuf::from(r"C:\a\x.exe")),
            note: "n".into(),
        };
        assert_eq!(i.summary(), "1.0");
        i.version.clear();
        assert!(i.summary().ends_with("x.exe"));
        i.path = None;
        assert_eq!(i.summary(), "n");
        i.note.clear();
        assert_eq!(i.summary(), "(未找到)");
    }
    // ---- 安装 / 更新层 ----

    /// **脚本不管、又没有包管理器时,不能给出点得动的按钮。**
    ///
    /// 这条约束本身没变:命令生成了但本机跑不了,界面就会变成
    /// 「看起来能用其实点不动」。
    ///
    /// 变的是**适用范围**:统一脚本自己会探测管理器、还能走直链兜底,所以
    /// 它管的那些组件(如 rustup)即使界面这边没选管理器,也照样有命令 ——
    /// 这也正是"管理器不该由界面选"的根据。这条守的是**脚本不管的那部分**
    /// (android 那组全是 vcpkg/cmdline-tools 这类没有包管理器入口的)。
    #[test]
    fn no_package_manager_means_no_one_click_actions_for_unknown_ones() {
        let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
        let acts = actions_for(&p, "android", "none");
        assert!(
            acts.iter().all(|a| a.cmd.is_empty()),
            "没有包管理器、脚本也不管,却生成了命令: {acts:?}"
        );
        assert!(acts.iter().any(|a| a.kind == "手动"));
    }

    /// **脚本还不认识的**组件仍按管理器拼命令 —— 三种管理器三种形状。
    ///
    /// 用 Ninja 而不是 Git:Git 已经改走统一脚本了(见
    /// `known_components_go_through_the_unified_script`),这条要守的是那条**兜底路径**。
    /// 兜底还在,没被这次接线误伤。
    #[test]
    fn unknown_components_still_get_a_command_per_package_manager() {
        let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
        let w = actions_for(&p, "qt", "winget");
        let install = w.iter().find(|a| a.what == "Ninja").unwrap();
        assert!(
            install.cmd.starts_with("winget install --id Ninja-build.Ninja"),
            "实际: {}",
            install.cmd
        );
        // winget 的静默安装要接受协议,不然会挂在交互提示上
        assert!(install.cmd.contains("--accept-source-agreements"));

        let c = actions_for(&p, "qt", "choco");
        assert_eq!(
            c.iter().find(|a| a.what == "Ninja").unwrap().cmd,
            "choco install ninja -y"
        );

        let sc = actions_for(&p, "qt", "scoop");
        assert_eq!(
            sc.iter().find(|a| a.what == "Ninja").unwrap().cmd,
            "scoop install ninja"
        );
    }

    /// 「模型运行环境」这一类原来**一条动作都产不出来**:`actions_for` 里没有
    /// `model` 分支,兜底 `_ => &[]` 把整类吞掉,界面上那一栏就是光秃秃的
    /// (别的类都有按钮或说明,只有它空着,看起来像页面坏了)。
    ///
    /// 修法有意**只给「手动」**:这一类要装的东西由本程序自带的
    /// `fetch-libs.ps1` 拉,而 Local Model 页有一条带进度、能停、并且互斥的下载
    /// 路径;在这里再放一个 `capture(cmd, 10min)` 的按钮会绕过那道互斥,
    /// 两个进程同时往 `libs\` 里解压同一份目录。
    #[test]
    fn the_model_group_points_at_the_bundled_fetch_script() {
        let p = crate::paths::Paths::from_exe_dir("C:\\nfarms");
        let acts = actions_for(&p, "model", "winget");
        assert!(!acts.is_empty(), "model 这一类不能一条动作都没有");
        assert!(
            acts.iter().all(|a| a.cmd.is_empty()),
            "模型这一类不该给可点的一键命令(理由见 `actions_for` 的 model 分支): {acts:?}"
        );
        assert!(
            acts.iter().any(|a| a.note.contains("fetch-libs.ps1")),
            "至少要告诉用户跑哪个脚本: {acts:?}"
        );
    }

    /// 每一类都要能产出动作(或明确的「手动」说明)。
    /// 少一类就会出现「这一栏没有安装入口」的空洞 —— `model` 就是这么漏掉的,
    /// 所以这里**必须把十类列全**,少一个就等于又给同一类问题留了门。
    #[test]
    fn every_group_has_some_action() {
        for key in [
            "model", "qt", "dotnet", "c", "rust", "python", "jdk", "node", "git", "android",
        ] {
            let acts = actions_for(&crate::paths::Paths::from_exe_dir("C:\\nfarms"), key, "winget");
            assert!(!acts.is_empty(), "{key} 这一类没有任何动作");
            assert!(
                acts.iter().any(|a| a.kind == "安装" || a.kind == "手动"),
                "{key} 这一类既没有一键安装也没有手动说明"
            );
        }
    }

    /// 更新与安装是**两条不同**的命令 —— 只给安装等于没实现「更新」。
    #[test]
    fn update_is_a_distinct_command_from_install() {
        let acts = actions_for(&crate::paths::Paths::from_exe_dir("C:\\nfarms"), "jdk", "winget");
        let inst = acts.iter().find(|a| a.kind == "安装").unwrap();
        let upd = acts.iter().find(|a| a.kind == "更新").unwrap();
        assert_ne!(inst.cmd, upd.cmd);
        assert!(upd.cmd.contains("-Action update"), "实际: {}", upd.cmd);
    }

    #[test]
    fn rust_guidance_warns_against_package_manager_updates() {
        // 有意写进数据里的经验:rustup 被包管理器换掉会很难查
        let acts = actions_for(&crate::paths::Paths::from_exe_dir("C:\\nfarms"), "rust", "winget");
        assert!(
            acts.iter().any(|a| a.note.contains("rustup update")),
            "Rust 那一类应当提示用 rustup update 而不是包管理器"
        );
    }

    #[test]
    fn package_manager_detection_does_not_panic() {
        let mgrs = package_managers();
        assert_eq!(mgrs.len(), 3);
        assert_eq!(mgrs[0].key, "winget");
        for m in &mgrs {
            assert_eq!(m.available(), m.path.is_some());
            if !m.available() {
                assert!(m.version().is_empty(), "没装的包管理器不该报版本");
            }
        }
    }}