跳转至

crates\nfarms-core\src\cyinfo.rs

源码路径:crates\nfarms-core\src\cyinfo.rs · 1263 行

//! 本机信息报告 —— 启动时自动生成 `<计算机名>-<yyyyMMdd>.cy`。
//!
//! ## `.cy` 是什么格式
//!
//! 仓库里原本**没有**这个扩展名的约定,所以这里定了一个**人能读、脚本也能解**的
//! 最简格式:
//!
//! ```text
//! # NFarms 本机信息报告
//! format=cy/1
//! generated=2026-10-04 19:02:11
//! ...
//! [主机]
//! 计算机名 = MYPC
//! 内存 = 31.5 GB
//! ```
//!
//! 规则只有三条:`#` 开头是注释、`[名字]` 是分节、其余是 `键 = 值`。
//! 这样记事本能直接看,`grep` / `awk` / PowerShell 也都能一行解出来。
//! **如果你们的平台对 `.cy` 有既定 schema,告诉我,改 [`render`] 一个函数就行。**
//!
//! ## 为什么要它
//!
//! 出问题时要问的第一批问题是固定的:什么系统、装了什么、大模型这台机器跑不跑得动。
//! 与其每次现问,不如开机就落一份 —— 文件名带计算机名和日期,天然能按机器归档、
//! 同一天重开也只是覆盖。

use std::fmt::Write as _;
use std::path::{Path, PathBuf};

/// 报告格式版本。以后加字段就升这个号,解析方能据此判断。
pub const FORMAT: &str = "cy/1";

/// 一台机器的画像。
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MachineInfo {
    // ---- 主机 ----
    pub hostname: String,
    pub username: String,
    pub os_name: String,
    pub os_version: String,
    pub os_build: String,
    pub arch: String,
    pub cpu: String,
    pub cores: usize,
    pub ram_bytes: u64,
    pub disk_total: u64,
    pub disk_free: u64,
    pub program_root: String,

    // ---- 环境 ----
    /// `(名字, 版本, 状态)` —— 状态沿用环境页那套(可用/未安装/…)
    pub tools: Vec<(String, String, String)>,

    // ---- 大模型 ----
    pub llama_server: Option<String>,
    pub sd_cli: Option<String>,
    pub gpu: String,
    pub vram_bytes: u64,
    /// 模型文件:`(名字, 大小)`
    pub models: Vec<(String, u64)>,
}

impl MachineInfo {
    /// 大模型这台机器**跑不跑得动**,以及跑得动哪一档。
    ///
    /// 判定口径说明白,免得看到「支持」却不知道为什么:
    /// * `llama-server` 在 → 至少能跑语言模型(纯 CPU 也能跑,只是慢);
    /// * 有独立显存(> 512 MB)→ 能上 GPU 卸载,才有实用速度;
    /// * 模型文件在 → 才真的能开跑,否则只是「有引擎没货」。
    /// * `sd_cli`(文生图)**只看存在性、在报告里单独占一行,不参与这里的判定** ——
    ///   「能不能跑语言模型」和「能不能跑 Stable Diffusion」是两回事,
    ///   混进来会让「支持」这两个字没法解释(有 sd-cli 没 llama-server 的机器
    ///   依然跑不了大模型页)。
    pub fn model_verdict(&self) -> ModelVerdict {
        let has_engine = self.llama_server.is_some();
        let has_models = !self.models.is_empty();
        let has_gpu = self.gpu_usable();
        match (has_engine, has_gpu, has_models) {
            (true, true, true) => ModelVerdict::Ready,
            (true, _, true) => ModelVerdict::CpuOnly,
            (true, _, false) => ModelVerdict::NoModels,
            (false, _, _) => ModelVerdict::NoEngine,
        }
    }

    /// 有没有**用得上的**显卡。
    ///
    /// 不能只看「显存 > 512 MB」:UMA 核显(比如 Intel Arc B370 这种)
    /// 在注册表里**根本没有独立显存值**,读出来就是 0 —— 只看显存会把它
    /// 误判成「没显卡」,而它其实能靠共享内存做 GPU 卸载。
    ///
    /// 所以口径是:**有独立显存** 或 **认得出型号的显卡**(当共享内存看)。
    pub fn gpu_usable(&self) -> bool {
        if self.vram_bytes > 512 * 1024 * 1024 {
            return true;
        }
        let g = self.gpu.trim();
        !g.is_empty() && g != "(未知)"
    }

    /// 显存那一行怎么写。UMA 要说清楚是共享内存,不能只写个 0 B 让人以为读挂了。
    pub fn vram_text(&self) -> String {
        if self.vram_bytes > 0 {
            human(self.vram_bytes)
        } else if self.gpu_usable() {
            "共享内存(UMA,无独立显存)".to_string()
        } else {
            "(未知)".to_string()
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModelVerdict {
    /// 引擎、显存、模型都齐 —— 能跑
    Ready,
    /// 有引擎和模型,但没像样的显存 —— 只能 CPU 跑,慢
    CpuOnly,
    /// 有引擎没模型 —— 得先下模型
    NoModels,
    /// 没有 llama-server —— 大模型页起不来
    NoEngine,
}

impl ModelVerdict {
    pub fn text(self) -> &'static str {
        match self {
            Self::Ready => "支持(GPU 可卸载)",
            Self::CpuOnly => "勉强支持(只能 CPU 跑,会慢)",
            Self::NoModels => "引擎就绪但缺模型文件",
            Self::NoEngine => "不支持(没有 llama-server)",
        }
    }
    pub fn ok(self) -> bool {
        matches!(self, Self::Ready | Self::CpuOnly)
    }
}

/// 采集本机信息。
///
/// **要跑在后台线程里**:8 个工具各是一次子进程探测,连起来 1~2 秒,
/// 放在启动路径上会让窗口晚出来这么久。
pub fn collect(paths: &crate::Paths) -> MachineInfo {
    let mut info = MachineInfo {
        hostname: env_or("COMPUTERNAME", "(未知)"),
        username: env_first(&["USERNAME"]).unwrap_or_else(|| "(未知)".to_string()),
        arch: std::env::consts::ARCH.to_string(),
        cores: std::thread::available_parallelism()
            .map(|n| n.get())
            .unwrap_or(0),
        // **相对路径**:报告是要交给别人看的,不该泄漏本机目录结构。
        // 程序根相对自己就是 `.`。
        program_root: ".".to_string(),
        ..Default::default()
    };

    // OS:注册表比 `ver` 靠谱(ver 在新系统上只报个大概)
    info.os_name = reg_value(
        r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
        "ProductName",
    )
    .unwrap_or_else(|| "Windows(未知版本)".to_string());
    info.os_version = reg_value(
        r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
        "DisplayVersion",
    )
    .or_else(|| {
        reg_value(
            r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
            "ReleaseId",
        )
    })
    .unwrap_or_default();
    info.os_build = reg_value(
        r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
        "CurrentBuildNumber",
    )
    .unwrap_or_default();
    // 新系统上 ProductName 还是写着 "Windows 10"(Win11 没改这个键)—— 用 build 纠正
    if info.os_name.contains("Windows 10")
        && info
            .os_build
            .parse::<u32>()
            .map(|b| b >= 22000)
            .unwrap_or(false)
    {
        info.os_name = info.os_name.replace("Windows 10", "Windows 11");
    }

    info.cpu = reg_value(
        r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
        "ProcessorNameString",
    )
    .map(|s| s.trim().to_string())
    .unwrap_or_else(|| "(未知)".to_string());

    // 只取总量:可用内存原来被 `let _ = avail;` 丢掉(结构体里没有字段承载它)。
    // 要么加字段、要么别采 —— 加字段就得升 `FORMAT`(见文件头:加字段要升号),
    // 而全仓没有任何 `.cy` 读者,不值得动格式;所以这里只问总量。
    if let Some(total) = memory_status() {
        info.ram_bytes = total;
    }
    if let Some((total, free)) = disk_space(paths.root()) {
        info.disk_total = total;
        info.disk_free = free;
    }

    // 环境:复用环境页那套探测,口径一致才不会出现「这页说装了那页说没装」
    for t in crate::tools::detect_all() {
        info.tools
            .push((t.name.clone(), t.installed.clone(), t.state.clone()));
    }

    // 大模型
    let llama = paths.lib("llama.cpp").join("llama-server.exe");
    info.llama_server = llama.is_file().then(|| paths.show(&llama));
    let sd = paths.lib("stable-diffusion.cpp").join("sd-cli.exe");
    info.sd_cli = sd.is_file().then(|| paths.show(&sd));
    // 型号与显存**一次探测同时拿**:两个字段对不上比读不到更糟,
    // 理由见 `gpu_probe` 的注释
    let (gpu, vram) = gpu_probe();
    info.gpu = gpu.unwrap_or_else(|| "(未知)".to_string());
    info.vram_bytes = vram.unwrap_or(0);
    for p in crate::libs::servable_status(paths) {
        if p.present() {
            // `ModelPresence::describe()` 给的是「17.3 GB」这种给人看的串,
            // 而报告里要「名字 + 字节数」两个字段分开,所以自己取
            info.models
                .push((p.model.name.to_string(), p.bytes.unwrap_or(0)));
        }
    }

    // 安卓上没有注册表:换成 /proc + getprop。
    //
    // 这一段**必须存在**,否则安卓上跑出来的报告里「系统 / CPU / 内存」全是
    // 「(未知)」—— 而这份报告恰恰是判断「这台设备能不能跑本地模型」的第一手材料。
    #[cfg(target_os = "android")]
    fill_android(&mut info);

    info
}

// ===========================================================================
// 安卓取数
//
// 全部走 `/proc` 和 `getprop`。**取数和解析分开**:解析器是纯函数,
// 于是它们能在**开发机(Windows)上跑单测** —— 不用真机、不用模拟器。
// 这是这一块唯一能在本机验证的部分,所以特意这么切。
// ===========================================================================

/// 从 `getprop` 输出里取一个属性值。
///
/// 输出形如 `[ro.build.version.release]: [15]`。
/// 属性名要连**方括号和冒号**一起比对,否则 `ro.product.model` 会误命中
/// `ro.product.model_ext` 这种前缀相同的键。
pub fn parse_getprop(text: &str, key: &str) -> Option<String> {
    for line in text.lines() {
        let l = line.trim();
        let Some(rest) = l.strip_prefix('[') else {
            continue;
        };
        let Some(close) = rest.find(']') else { continue };
        if &rest[..close] != key {
            continue;
        }
        let after = rest[close + 1..].trim_start_matches(':').trim();
        let v = after
            .strip_prefix('[')
            .and_then(|s| s.strip_suffix(']'))
            .unwrap_or(after)
            .trim();
        if !v.is_empty() {
            return Some(v.to_string());
        }
    }
    None
}

/// 从 `/proc/cpuinfo` 取 CPU 型号。
///
/// ARM 设备的字段名五花八门:x86 用 `model name`、不少 ARM 用 `Hardware`、
/// 部分内核用 `Processor`。按优先级依次试,都拿不到返回 None
/// (宁可空着,也不要瞎编一个型号)。
///
/// **两个坑都踩过,所以都写进代码里了:**
///
/// 1. 小写的 `processor : 0` 是**每个核的下标**,不是型号。所以 `Processor`
///    这一项必须**精确匹配大写**(其余项不区分大小写)——否则会报出 `CPU = 0`。
/// 2. 就算别名对了,值也可能是个纯数字。再加一道闸:纯数字一律不当型号。
pub fn parse_cpuinfo_model(text: &str) -> Option<String> {
    /// `(字段名, 是否精确匹配大小写)`
    const KEYS: [(&str, bool); 4] = [
        ("model name", false),
        ("Hardware", false),
        ("Processor", true), // 小写的是每核下标
        ("cpu model", false),
    ];
    for (key, exact) in KEYS {
        for line in text.lines() {
            let Some((k, v)) = line.split_once(':') else {
                continue;
            };
            let k = k.trim();
            let hit = if exact {
                k == key
            } else {
                k.eq_ignore_ascii_case(key)
            };
            if !hit {
                continue;
            }
            let v = v.trim();
            if v.is_empty() || v.chars().all(|c| c.is_ascii_digit()) {
                continue;
            }
            return Some(v.to_string());
        }
    }
    None
}

/// 从 `/proc/meminfo` 取物理内存总量(字节)。
///
/// 单位是 **kB**(Linux 的老规矩),要乘 1024 —— 忘了乘就会报出
/// 「31.5 MB 内存」这种一眼假的数。
pub fn parse_meminfo_total(text: &str) -> Option<u64> {
    for line in text.lines() {
        let Some((k, v)) = line.split_once(':') else {
            continue;
        };
        if k.trim() != "MemTotal" {
            continue;
        }
        let kb: u64 = v.trim().trim_end_matches("kB").trim().parse().ok()?;
        return Some(kb * 1024);
    }
    None
}

/// 解析 `df -k` 输出(第二行起,第 2 列总量、第 4 列可用,单位 1K 块)。
pub fn parse_df(text: &str) -> Option<(u64, u64)> {
    for line in text.lines().skip(1) {
        let cols: Vec<&str> = line.split_whitespace().collect();
        if cols.len() < 4 {
            continue;
        }
        let total: u64 = cols[1].parse().ok()?;
        let free: u64 = cols[3].parse().ok()?;
        return Some((total * 1024, free * 1024));
    }
    None
}

#[cfg(target_os = "android")]
fn fill_android(info: &mut MachineInfo) {
    info.os_name = "Android".to_string();
    let props = crate::tools::capture("getprop", 20_000);
    info.os_version = parse_getprop(&props, "ro.build.version.release").unwrap_or_default();
    info.os_build = parse_getprop(&props, "ro.build.display.id").unwrap_or_default();
    // 设备型号比「计算机名」对安卓更有意义
    if let Some(m) = parse_getprop(&props, "ro.product.model") {
        info.hostname = m;
    }
    if let Ok(cpu) = std::fs::read_to_string("/proc/cpuinfo")
        && let Some(m) = parse_cpuinfo_model(&cpu)
    {
        info.cpu = m;
    }
    if let Ok(mem) = std::fs::read_to_string("/proc/meminfo")
        && let Some(b) = parse_meminfo_total(&mem)
    {
        info.ram_bytes = b;
    }
    if let Some((total, free)) = parse_df(&crate::tools::capture("df -k /data", 15_000)) {
        info.disk_total = total;
        info.disk_free = free;
    }
    // `collect` 填的是 Windows 的 `USERNAME`,安卓上根本没有这个变量 ——
    // 不补这一步,报告里「用户名」永远是「(未知)」。按 POSIX 惯例试
    // `USER` / `LOGNAME`;都拿不到就保留原值,不瞎编一个用户名。
    if let Some(u) = env_first(&["USER", "LOGNAME"]) {
        info.username = u;
    }
}

/// 渲染成 `.cy` 文本。
pub fn render(info: &MachineInfo, generated: &str) -> String {
    let mut s = String::with_capacity(2048);
    let verdict = info.model_verdict();

    let _ = writeln!(s, "# NFarms 本机信息报告");
    let _ = writeln!(s, "format = {FORMAT}");
    let _ = writeln!(s, "generated = {generated}");
    let _ = writeln!(s, "version = {}", env!("CARGO_PKG_VERSION"));
    // 说清楚路径口径,免得看的人以为「怎么只有相对路径」
    let _ = writeln!(s, "# 路径均为相对程序根(程序根写作 .)");

    let _ = writeln!(s, "\n[主机]");
    let _ = writeln!(s, "计算机名 = {}", info.hostname);
    let _ = writeln!(s, "用户名 = {}", info.username);
    let _ = writeln!(s, "系统 = {} {}", info.os_name, info.os_version);
    let _ = writeln!(s, "系统内部版本 = {}", info.os_build);
    let _ = writeln!(s, "架构 = {}", info.arch);
    let _ = writeln!(s, "CPU = {}", info.cpu);
    let _ = writeln!(s, "逻辑核心 = {}", info.cores);
    let _ = writeln!(s, "内存 = {}", human(info.ram_bytes));
    let _ = writeln!(
        s,
        "程序盘 = 共 {} / 可用 {}",
        human(info.disk_total),
        human(info.disk_free)
    );
    let _ = writeln!(s, "程序目录 = {}", info.program_root);

    let _ = writeln!(s, "\n[环境]");
    if info.tools.is_empty() {
        let _ = writeln!(s, "(没探测到任何工具)");
    }
    for (name, version, state) in &info.tools {
        let ver = if version.is_empty() { "—" } else { version };
        let st = if state.is_empty() { "已安装" } else { state };
        let _ = writeln!(s, "{name} = {ver}  [{st}]");
    }

    let _ = writeln!(s, "\n[大模型]");
    let _ = writeln!(s, "结论 = {}", verdict.text());
    let _ = writeln!(
        s,
        "llama-server = {}",
        info.llama_server.as_deref().unwrap_or("(没有)")
    );
    let _ = writeln!(s, "sd-cli = {}", info.sd_cli.as_deref().unwrap_or("(没有)"));
    let _ = writeln!(s, "显卡 = {}", info.gpu);
    let _ = writeln!(s, "显存 = {}", info.vram_text());
    let _ = writeln!(s, "模型文件 = {} 个", info.models.len());
    for (name, size) in &info.models {
        let _ = writeln!(s, "  - {name} ({})", human(*size));
    }

    let _ = writeln!(s, "\n# 这个文件由 NFarms HUB 启动时自动生成,同一天重开会覆盖");
    s
}

/// 报告文件名:`<计算机名>-<yyyyMMdd>.cy`。
pub fn file_name(hostname: &str, date: &str) -> String {
    format!("{}-{date}.cy", safe_hostname(hostname))
}

/// 同一台机器最多保留多少份报告。
///
/// 一天一份、同一天重开是覆盖,所以 30 份约等于一个月 —— 再多就只剩「翻旧账」
/// 的价值,而 `logs\` 是一直涨的(原来跨天累积、没有上限,一年就是几百个文件)。
const KEEP_REPORTS: usize = 30;

/// 把计算机名洗成能进文件名的形态(洗完为空则 `UNKNOWN`)。
///
/// 计算机名里的字符要洗一遍:`\ / : * ? " < > |` 在 Windows 文件名里非法,
/// 而计算机名是用户能改的(域里还可能带点号)。
fn safe_hostname(hostname: &str) -> String {
    // 只洗**真的非法**的那九个。注意别把空格算进来 —— 空格在 Windows
    // 文件名里是合法的(我第一版顺手写成 `"\ / : * ? " < > |"`,里面带了空格,
    // 结果计算机名里的空格被换成下划线)。
    const ILLEGAL: [char; 9] = ['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
    let safe: String = hostname
        .trim()
        .chars()
        .map(|c| {
            if ILLEGAL.contains(&c) || c.is_control() {
                '_'
            } else {
                c
            }
        })
        .collect();
    if safe.is_empty() {
        "UNKNOWN".to_string()
    } else {
        safe
    }
}

/// 报告文件名里的日期部分:`yyyyMMdd`。
///
/// 原来是从 `local_now()` 的字符串里 `filter(is_ascii_digit).take(8)` 抠出来的:
/// 字符串形状一变(比如以后给 `generated` 带上时区 `+08:00`),时区的数字就会被
/// 当成日期,而且**不会有任何报错** —— 落盘的文件名从此是错的。
/// 改成从年月日直接格式化,补零由格式化保证,不依赖任何字符串形状。
fn date_stamp(year: i32, month: u32, day: u32) -> String {
    format!("{year:04}{month:02}{day:02}")
}

/// 文件名是不是「这台机器在某一天」的报告。
///
/// 只靠 `starts_with("<机器名>-")` 不够:`MY-PC` 与 `MY-PC-OLD` 两台机器的前缀
/// 会互相套住,清理时就会删掉别人机器的报告。所以要求
/// 「前缀 + 恰好 8 位数字 + `.cy`」,与 `file_name` 造出来的形状严格一致。
fn is_report_of(name: &str, prefix: &str) -> bool {
    let Some(rest) = name.strip_prefix(prefix) else {
        return false;
    };
    let Some(stamp) = rest.strip_suffix(".cy") else {
        return false;
    };
    stamp.len() == 8 && stamp.bytes().all(|b| b.is_ascii_digit())
}

/// 清掉同一台机器更旧的报告,返回删掉的份数。
///
/// `current` 是刚写出的那份,**永远不删**:机器时钟被改回去时它可能反而是
/// 「最旧」的文件名,按排序清理会当场把刚写出来的报告删掉 ——
/// 那是最糟的一种失败(日志说「报告已生成」,文件却没了)。
///
/// 删除失败只记账不报错:清理是顺手做的事,不该让它把「报告写成功」变成失败。
fn prune_old_reports(dir: &Path, hostname: &str, current: &str) -> usize {
    let prefix = format!("{}-", safe_hostname(hostname));
    let Ok(entries) = std::fs::read_dir(dir) else {
        return 0;
    };
    let mut mine: Vec<PathBuf> = entries
        .flatten()
        .map(|e| e.path())
        .filter(|p| {
            p.is_file()
                && p.file_name()
                    .and_then(|n| n.to_str())
                    .is_some_and(|n| is_report_of(n, &prefix))
        })
        .collect();
    // 文件名里的日期是定长 `yyyyMMdd`,所以文件名字典序就是时间序
    mine.sort();
    let mut removed = 0;
    for p in mine.iter().take(mine.len().saturating_sub(KEEP_REPORTS.max(1))) {
        if p.file_name().and_then(|n| n.to_str()) == Some(current) {
            continue;
        }
        if std::fs::remove_file(p).is_ok() {
            removed += 1;
        }
    }
    removed
}

/// 采集并落盘。返回写出的路径。
pub fn write_report(paths: &crate::Paths, log: &crate::Log) -> Result<PathBuf, String> {
    // 报告放**日志目录**里 —— 它本质上是「这次启动留下的记录」,
    // 和日志是一类东西;单独开一个 `reports\` 顶层目录不值当。
    let dir = paths.logs();
    std::fs::create_dir_all(&dir).map_err(|e| format!("建 logs 目录失败: {e}"))?;
    let info = collect(paths);
    // 只读一次时钟:文件名的日期和正文里的 `generated` 必须是同一瞬间 ——
    // 分两次读,跨午夜时会出现「文件名是昨天、内容是今天」。
    let (y, mo, d, h, mi, s) = crate::time::local_now_parts();
    let now = format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}");
    let date = date_stamp(y, mo, d);
    let name = file_name(&info.hostname, &date);
    let path = dir.join(&name);
    let text = render(&info, &now);
    std::fs::write(&path, text).map_err(|e| format!("写报告失败: {e}"))?;
    // 顺手清理同一台机器的旧报告(别的机器归档过来的不碰,刚写出的这份也不碰)
    let removed = prune_old_reports(&dir, &info.hostname, &name);
    // 日志里也写相对路径 —— 和报告口径一致,复制粘贴出去也不带本机目录
    log.write(
        "report",
        &format!(
            "本机信息报告已生成: logs\\{name}(大模型:{})",
            info.model_verdict().text()
        ),
    );
    if removed > 0 {
        // 明确记一笔:清理是删文件,不能悄悄做
        log.write(
            "report",
            &format!("已清理 {removed} 份过期报告(同一台机器只留最近 {KEEP_REPORTS} 份)"),
        );
    }
    Ok(path)
}

// ===========================================================================
// 下面这些是拿系统数据的底层手段。全是「一次 syscall 或一次注册表读」,
// 不引任何 crate —— 和 `time.rs` 调 GetLocalTime 是同一套做法。
// ===========================================================================

fn env_or(key: &str, fallback: &str) -> String {
    std::env::var(key).unwrap_or_else(|_| fallback.to_string())
}

/// 按顺序取候选环境变量里**第一个非空**的值。
///
/// 写成「候选列表」是为了 `fill_android`:安卓上没有 `USERNAME`(那是 Windows 的
/// 变量名),得改试 `USER` / `LOGNAME`。写成独立函数是为了能在开发机上跑单测 ——
/// `#[cfg(target_os = "android")]` 的那些行在 Windows 上连编译都到不了。
///
/// 空串按「没取到」处理:环境变量被设成空串和没设,对报告是同一件事。
fn env_first(keys: &[&str]) -> Option<String> {
    keys.iter().find_map(|k| {
        let v = std::env::var(k).ok()?;
        let v = v.trim().to_string();
        (!v.is_empty()).then_some(v)
    })
}

/// 读注册表一个值。失败返回 None(不是错误 —— 键不存在很常见)。
fn reg_value(key: &str, name: &str) -> Option<String> {
    let text = crate::tools::capture(&format!("reg query \"{key}\" /v {name}"), 15_000);
    for line in text.lines() {
        let cols: Vec<&str> = line.split_whitespace().collect();
        if cols.len() >= 3 && cols[0].eq_ignore_ascii_case(name) {
            // 按类型标记定位,不按空白切列(值里可能有空格)
            let kind = cols[1];
            return line
                .find(kind)
                .map(|p| line[p + kind.len()..].trim().to_string());
        }
    }
    None
}

/// 显示适配器设备类 GUID `{4d36e968-e325-11ce-bfc1-08002be10318}` 所在的键。
///
/// 走显示适配器类的注册表,不用 `wmic` —— 新系统上 wmic 默认已经不装了。
const GPU_CLASS_KEY: &str =
    r"HKLM\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}";

/// 从 `reg query <类键>` 的输出里抽出全部 4 位数字槽位(升序、去重)。
///
/// **原来是写死的 `["0000", "0001", "0002"]`**:第三块以后的显卡永远读不到,
/// 而槽位号并不保证从 `0000` 连续 —— 换过显卡、手动删过子键的机器上可能只剩
/// `0001` / `0003`,那台机器就一辈子显示「(未知)」。
/// 改成枚举,有几个槽位由注册表自己说了算。
///
/// 只认「整行是键路径 + 末段恰好四位数字」的行:同一个类键下还有 `Properties` /
/// `Configuration` 这类**不是适配器**的子键,不过滤就会去读它们的 `DriverDesc`。
/// 值行(`    DriverDesc    REG_SZ    …`)不以 `HKEY_` 开头,天然被排除。
fn parse_reg_subkeys(text: &str) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    for line in text.lines() {
        let l = line.trim();
        if !l.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("HKEY_")) {
            continue;
        }
        let Some((_, last)) = l.rsplit_once('\\') else {
            continue;
        };
        if last.len() == 4 && last.bytes().all(|b| b.is_ascii_digit()) && !out.iter().any(|s| s == last)
        {
            out.push(last.to_string());
        }
    }
    out.sort();
    out
}

/// 列出该类键下所有 4 位数字槽位。
fn reg_subkeys(key: &str) -> Vec<String> {
    parse_reg_subkeys(&crate::tools::capture(&format!("reg query \"{key}\""), 15_000))
}

/// 虚拟显示适配器不算显卡。
///
/// 这个类键是**共用的**:远程桌面、串流/远控软件会把它们的虚拟显示器也登记进来
/// (本机实测同一台机器上就有 `RemoteControl Virtual Display`、
/// `GameViewer Virtual Display Adapter` 两个)。它们的型号对「这台机器能不能跑
/// 本地模型」没有任何参考价值,报上去反而把真显卡盖掉。
fn is_virtual_adapter(desc: &str) -> bool {
    desc.contains("Remote") || desc.contains("Basic Display") || desc.contains("Virtual Display")
}

/// 从「槽位 → DriverDesc」里挑出要报的那块适配器:第一个非空、非虚拟的。
///
/// 列表按槽位名升序(枚举时排好),所以「第一个」是稳定的 ——
/// 同一台机器每次跑出来的结果不会跳。
fn pick_adapter(adapters: &[(String, String)]) -> Option<&(String, String)> {
    adapters.iter().find(|(_, d)| {
        let d = d.trim();
        !d.is_empty() && !is_virtual_adapter(d)
    })
}

/// 解析注册表里报的大小。
///
/// `reg query` 对 QWORD 打的是 `0x…` 十六进制,但有些驱动给十进制;
/// 两种都收。老驱动可能只有 DWORD 版 `MemorySize`,所以两个值名都试。
fn parse_size_value(raw: &str) -> Option<u64> {
    let t = raw.trim();
    match t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
        Some(hex) => u64::from_str_radix(hex, 16).ok(),
        None => t.parse::<u64>().ok(),
    }
}

/// 某个适配器槽位上报的显存(字节)。
fn gpu_memory_of(sub: &str) -> Option<u64> {
    ["HardwareInformation.qwMemorySize", "HardwareInformation.MemorySize"]
        .iter()
        .find_map(|name| {
            let raw = reg_value(&format!("{GPU_CLASS_KEY}\\{sub}"), name)?;
            parse_size_value(&raw)
        })
}

/// 显卡型号 + 显存。**两个值一起取,而且优先取自同一块适配器。**
///
/// 原来是两个函数各遍历一遍:`gpu_name()` 取第一个有 `DriverDesc` 的,
/// `gpu_memory()` 取第一个有 `MemorySize` 的。单显卡机器上两者恰好同槽位,
/// 看不出问题;混合显卡机器上「`0000` 核显、`0001` 独显」时就会报出
/// 「Intel UHD / 8 GB」这种谁也解释不了的组合 —— 名字是核显的,
/// 显存是独显的。所以先挑定适配器,显存先在**同一槽位**上找。
///
/// 同槽位找不到(UMA 核显本来就没有独立显存值)才退回扫其余槽位:
/// 独显的显存仍然要算数,否则会把「核显没有独显有」的混合显卡机器
/// 从「支持 GPU 卸载」误判成「只能 CPU 跑」。
fn gpu_probe() -> (Option<String>, Option<u64>) {
    let adapters: Vec<(String, String)> = reg_subkeys(GPU_CLASS_KEY)
        .into_iter()
        .filter_map(|sub| {
            let desc = reg_value(&format!("{GPU_CLASS_KEY}\\{sub}"), "DriverDesc")?;
            Some((sub, desc.trim().to_string()))
        })
        .collect();
    let Some((sub, name)) = pick_adapter(&adapters) else {
        return (None, None);
    };
    let vram = gpu_memory_of(sub).or_else(|| {
        // 退路也只认非虚拟适配器:虚拟显示器本来就没有显存值,扫它们除了多起
        // 几个 `reg query` 什么也拿不到
        adapters
            .iter()
            .filter(|(s, d)| s != sub && !is_virtual_adapter(d))
            .find_map(|(s, _)| gpu_memory_of(s))
    });
    (Some(name.clone()), vram)
}

#[cfg(windows)]
fn memory_status() -> Option<u64> {
    // 字段**全是 ABI 布局的一部分**:Win32 按 `length` 写整块结构体,少一个字段
    // 后面的就全错位。所以 `avail_phys` 这些报告里用不到的也必须留着占位 ——
    // 删掉不是「清理」,是读出一堆错位的垃圾数。
    #[repr(C)]
    struct MemoryStatusEx {
        length: u32,
        memory_load: u32,
        total_phys: u64,
        avail_phys: u64,
        total_page_file: u64,
        avail_page_file: u64,
        total_virtual: u64,
        avail_virtual: u64,
        avail_extended_virtual: u64,
    }
    #[link(name = "kernel32")]
    unsafe extern "system" {
        fn GlobalMemoryStatusEx(buf: *mut MemoryStatusEx) -> i32;
    }
    let mut m = MemoryStatusEx {
        length: std::mem::size_of::<MemoryStatusEx>() as u32,
        memory_load: 0,
        total_phys: 0,
        avail_phys: 0,
        total_page_file: 0,
        avail_page_file: 0,
        total_virtual: 0,
        avail_virtual: 0,
        avail_extended_virtual: 0,
    };
    // 安全性:传的是本地栈上结构体,length 已按 ABI 要求填好
    let ok = unsafe { GlobalMemoryStatusEx(&mut m) };
    // 只回总量:`avail_phys` 之类是 ABI 布局的一部分(Win32 按 `length` 写整块),
    // 少一个字段后面的字段就全错位 —— 所以留着占位,但报告里没有字段承载它,
    // 不往外传(以前是 `let _ = avail;`,看着像"采了没用",实际是白采一遍)
    (ok != 0).then_some(m.total_phys)
}

/// 非 Windows:没有 `GlobalMemoryStatusEx`,但 Linux(含 Android)有 `/proc/meminfo`,
/// 解析器是现成的,读一下就行。
///
/// macOS / BSD 没有这个文件 → `None`,报告里显示 `0 B`(与改动前一致)——
/// 这里**不去碰 `statvfs`**:它的结构体布局各平台不同,写错了是 UB,
/// 而仓库里没有任何 Linux/macOS 桌面目标,不值得为不存在的用户冒这个险。
#[cfg(not(windows))]
fn memory_status() -> Option<u64> {
    parse_meminfo_total(&std::fs::read_to_string("/proc/meminfo").ok()?)
}

#[cfg(windows)]
fn disk_space(dir: &Path) -> Option<(u64, u64)> {
    #[link(name = "kernel32")]
    unsafe extern "system" {
        fn GetDiskFreeSpaceExW(
            dir: *const u16,
            free_to_caller: *mut u64,
            total: *mut u64,
            total_free: *mut u64,
        ) -> i32;
    }
    let wide: Vec<u16> = dir
        .as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();
    let (mut free_caller, mut total, mut total_free) = (0u64, 0u64, 0u64);
    let ok = unsafe {
        GetDiskFreeSpaceExW(
            wide.as_ptr(),
            &mut free_caller,
            &mut total,
            &mut total_free,
        )
    };
    (ok != 0).then_some((total, total_free))
}

#[cfg(not(windows))]
fn disk_space(_dir: &Path) -> Option<(u64, u64)> {
    None
}

#[cfg(windows)]
use std::os::windows::ffi::OsStrExt as _;

/// 人类可读的大小。
pub fn human(bytes: u64) -> String {
    const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
    let mut v = bytes as f64;
    let mut i = 0;
    while v >= 1024.0 && i < U.len() - 1 {
        v /= 1024.0;
        i += 1;
    }
    if i == 0 {
        format!("{bytes} B")
    } else {
        format!("{v:.1} {}", U[i])
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn file_name_sanitises_illegal_chars() {
        assert_eq!(file_name("MY-PC", "20261004"), "MY-PC-20261004.cy");
        // 计算机名里的非法字符必须被洗掉,否则建文件会失败
        assert_eq!(file_name(r"a\b/c:d", "20261004"), "a_b_c_d-20261004.cy");
        assert_eq!(file_name("  ", "20261004"), "UNKNOWN-20261004.cy");
        assert_eq!(file_name("", "20261004"), "UNKNOWN-20261004.cy");
        // **空格是合法的**,不能洗掉 —— 混进非法字符集里就错了
        assert_eq!(file_name("MY PC", "20261004"), "MY PC-20261004.cy");
    }

    #[test]
    fn verdict_covers_the_four_cases() {
        let mk = |engine: bool, vram: u64, models: usize| MachineInfo {
            llama_server: engine.then(|| "x".to_string()),
            vram_bytes: vram,
            gpu: if vram > 0 {
                "Discrete GPU".to_string()
            } else {
                String::new()
            },
            models: (0..models).map(|i| (format!("m{i}"), 1)).collect(),
            ..Default::default()
        };
        let gb = 1024 * 1024 * 1024;
        assert_eq!(mk(true, 8 * gb, 2).model_verdict(), ModelVerdict::Ready);
        assert_eq!(mk(true, 0, 2).model_verdict(), ModelVerdict::CpuOnly);
        assert_eq!(mk(true, 8 * gb, 0).model_verdict(), ModelVerdict::NoModels);
        assert_eq!(mk(false, 8 * gb, 2).model_verdict(), ModelVerdict::NoEngine);
        // 「支持」的只有前两种
        assert!(mk(true, 8 * gb, 1).model_verdict().ok());
        assert!(!mk(false, 0, 0).model_verdict().ok());
    }

    /// UMA 核显:注册表里没有独立显存(读出来是 0),但**认得出型号** ——
    /// 不能因此判成「没显卡」。这一条是看了真实报告才补的:
    /// Intel Arc B370 那台机器上显存就是 0 B。
    #[test]
    fn uma_gpu_counts_as_usable_even_with_zero_vram() {
        let uma = MachineInfo {
            gpu: "Intel(R) Arc(TM) B370 GPU".to_string(),
            vram_bytes: 0,
            llama_server: Some("llama-server.exe".to_string()),
            models: vec![("qwen3".to_string(), 1)],
            ..Default::default()
        };
        assert!(uma.gpu_usable());
        assert_eq!(uma.model_verdict(), ModelVerdict::Ready);
        assert_eq!(uma.vram_text(), "共享内存(UMA,无独立显存)");

        // 真读不到显卡时,才退回「只能 CPU」
        let blind = MachineInfo {
            gpu: "(未知)".to_string(),
            vram_bytes: 0,
            llama_server: Some("llama-server.exe".to_string()),
            models: vec![("qwen3".to_string(), 1)],
            ..Default::default()
        };
        assert!(!blind.gpu_usable());
        assert_eq!(blind.model_verdict(), ModelVerdict::CpuOnly);
        assert_eq!(blind.vram_text(), "(未知)");
    }

    #[test]
    fn render_has_all_three_sections_and_is_parseable() {
        let info = MachineInfo {
            hostname: "MYPC".into(),
            username: "u".into(),
            os_name: "Windows 11".into(),
            os_version: "24H2".into(),
            os_build: "26100".into(),
            arch: "x86_64".into(),
            cpu: "Test CPU".into(),
            cores: 8,
            ram_bytes: 16 * 1024 * 1024 * 1024,
            disk_total: 500 * 1024 * 1024 * 1024,
            disk_free: 200 * 1024 * 1024 * 1024,
            program_root: r"C:\x".into(),
            tools: vec![("rust".into(), "1.90.0".into(), String::new())],
            llama_server: Some("llama-server.exe".into()),
            sd_cli: None,
            gpu: "Test GPU".into(),
            vram_bytes: 8 * 1024 * 1024 * 1024,
            models: vec![("qwen3".into(), 5 * 1024 * 1024 * 1024)],
        };
        let text = render(&info, "2026-10-04 19:02:11");
        assert!(text.starts_with("# NFarms 本机信息报告"));
        assert!(text.contains("format = cy/1"));
        for sec in ["[主机]", "[环境]", "[大模型]"] {
            assert!(text.contains(sec), "缺分节 {sec}");
        }
        assert!(text.contains("计算机名 = MYPC"));
        assert!(text.contains("结论 = 支持(GPU 可卸载)"));
        assert!(text.contains("rust = 1.90.0"));
        // 大小换算要人看得懂
        assert!(text.contains("16.0 GB"));
        assert!(text.contains("8.0 GB"));
        // 每行都得是 注释 / 分节 / 键 = 值 三者之一
        for line in text.lines().filter(|l| !l.trim().is_empty()) {
            let ok = line.starts_with('#')
                || line.starts_with('[')
                || line.starts_with("  - ")
                || line.contains(" = ")
                || line.starts_with('(');
            assert!(ok, "这行不符合 .cy 格式: {line}");
        }
    }

    #[test]
    fn human_sizes() {
        assert_eq!(human(0), "0 B");
        assert_eq!(human(512), "512 B");
        assert_eq!(human(1024), "1.0 KB");
        assert_eq!(human(1536), "1.5 KB");
        assert_eq!(human(1024 * 1024 * 1024), "1.0 GB");
    }

    // ---- 安卓解析器:纯函数,所以在开发机上就能验 ----

    #[test]
    fn getprop_needs_the_full_bracketed_key() {
        // 真实 getprop 的一小段
        let out = "[ro.build.version.release]: [15]\n\
                   [ro.product.model]: [Pixel 9]\n\
                   [ro.product.model_ext]: [WRONG]\n";
        assert_eq!(parse_getprop(out, "ro.build.version.release").unwrap(), "15");
        assert_eq!(parse_getprop(out, "ro.product.model").unwrap(), "Pixel 9");
        // 前缀相同的键不能误命中 —— 这条是这段代码最容易写错的地方
        assert!(parse_getprop(out, "ro.product").is_none());
        assert!(parse_getprop(out, "ro.nonexistent").is_none());
        // 空值当成没取到,而不是返回空串
        assert!(parse_getprop("[ro.empty]: []\n", "ro.empty").is_none());
    }

    #[test]
    fn cpuinfo_handles_x86_and_arm_field_names() {
        let x86 = "processor\t: 0\nmodel name\t: Intel(R) Core(TM) i7\ncpu MHz\t: 2400\n";
        assert_eq!(
            parse_cpuinfo_model(x86).unwrap(),
            "Intel(R) Core(TM) i7"
        );
        // ARM 常见的是 Hardware
        let arm = "processor\t: 0\nHardware\t: Qualcomm Technologies, Inc SM8650\n";
        assert_eq!(
            parse_cpuinfo_model(arm).unwrap(),
            "Qualcomm Technologies, Inc SM8650"
        );
        // 大写的 Processor 是型号(部分内核这么写)
        let kern = "Processor\t: ARMv8 Processor rev 1 (v8l)\n";
        assert_eq!(
            parse_cpuinfo_model(kern).unwrap(),
            "ARMv8 Processor rev 1 (v8l)"
        );
        // **小写的 processor 是每核下标,不能当型号** —— 这条测试抓出过一个
        // 真 bug:大小写不敏感匹配会让 `CPU = 0`。
        assert!(parse_cpuinfo_model("processor\t: 0\n").is_none());
        assert!(parse_cpuinfo_model("processor\t: 0\nprocessor\t: 1\n").is_none());
        // 一个都没有 → None,不许瞎编
        assert!(parse_cpuinfo_model("").is_none());
        assert!(parse_cpuinfo_model("BogoMIPS\t: 38.40\n").is_none());
    }

    #[test]
    fn meminfo_converts_kb_to_bytes() {
        let mem = "MemTotal:       32768000 kB\nMemFree:         1000000 kB\n";
        // 32768000 kB = 31.25 GiB。**忘了乘 1024 就会变成 31 MB** —— 一眼假
        assert_eq!(parse_meminfo_total(mem).unwrap(), 32_768_000 * 1024);
        assert_eq!(human(parse_meminfo_total(mem).unwrap()), "31.2 GB");
        assert!(parse_meminfo_total("MemFree: 1 kB\n").is_none());
    }

    #[test]
    fn df_parses_total_and_available() {
        // 真实 df -k 的头部 + 一行
        let out = "Filesystem     1K-blocks    Used Available Use% Mounted on\n\
                   /dev/block/dm-5  120000000 40000000  80000000  34% /data\n";
        let (total, free) = parse_df(out).unwrap();
        assert_eq!(total, 120_000_000 * 1024);
        assert_eq!(free, 80_000_000 * 1024);
        assert!(parse_df("just a header\n").is_none());
    }

    #[test]
    fn collecting_this_machine_does_not_panic() {        // 真采一次本机:字段允许为空(比如没有独显),但不许 panic
        let dir = std::env::temp_dir().join("nfarms-cy-test");
        let _ = std::fs::create_dir_all(&dir);
        let info = collect(&crate::Paths::from_exe_dir(&dir));
        assert!(!info.hostname.is_empty());
        assert_eq!(info.arch, std::env::consts::ARCH);
        let text = render(&info, "2026-10-04 19:02:11");
        assert!(text.contains("[大模型]"));
        assert!(text.contains("结论 = "));
    }

    /// **带引号的命令必须能穿过 `tools::capture`。**
    ///
    /// 这条是回归测试:`Command::arg` 会把内层引号转义成 `\"`,而 cmd.exe 不认,
    /// 于是 `reg query "HKLM\..." /v ProductName` 静默返回空 ——
    /// 表现是「系统/CPU/显卡全是未知」,看起来像没读到,其实命令早就散架了。
    /// 修法是 `raw_arg`。
    #[cfg(windows)]
    #[test]
    fn quoted_registry_query_survives_capture() {
        let out = crate::tools::capture(
            r#"reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v ProductName"#,
            20_000,
        );
        assert!(
            out.contains("REG_SZ"),
            "带引号的 reg query 被转义坏了(应当看到 REG_SZ,实际拿到 {out:?})"
        );
        // 而且 `reg_value` 那套解析要真能抠出值
        let v = reg_value(
            r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
            "ProductName",
        );
        assert!(
            v.is_some(),
            "ProductName 应当能被解析出来,实际 None(原始输出 {out:?})"
        );
        assert!(v.unwrap().to_lowercase().contains("windows"));
    }

    // ---- 显卡:槽位必须枚举,不能写死 0000/0001/0002 ----

    /// `reg query <类键>` 的真实形状:先一行键路径 + 若干值行,空行,
    /// 然后**每个子键一行完整路径**。写死三个槽位会漏掉 `0010` 这种。
    #[test]
    fn registry_subkeys_are_enumerated_not_hardcoded() {
        const BASE: &str = r"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}";
        let sub = |n: &str| format!("{BASE}\\{n}");
        let out = [
            BASE.to_string(),
            "    (Default)    REG_SZ    (数值未设置)".to_string(),
            "    Class    REG_SZ    Display".to_string(),
            // 值行里也能出现 `\0000` 这种文本 —— 按"整行是键路径"过滤才不会把它当子键
            r"    ImagePath    REG_EXPAND_SZ    C:\Windows\0000".to_string(),
            String::new(),
            sub("0000"),
            sub("0001"),
            sub("0001"), // 重复行要去重
            sub("0010"),
            sub("Properties"),  // 不是适配器
            sub("Configuration"), // 同上
        ]
        .join("\n");
        assert_eq!(parse_reg_subkeys(&out), vec!["0000", "0001", "0010"]);
        // reg.exe 被挡 / 键不存在 / 空输出:返回空,不许 panic,也不许编出槽位
        assert!(parse_reg_subkeys("").is_empty());
        assert!(parse_reg_subkeys("错误: 系统找不到指定的注册表项。").is_empty());
    }

    #[test]
    fn gpu_adapter_pick_skips_virtual_and_empty() {
        // 本机实测:这个类键下除了真显卡,还登记着串流/远控软件的虚拟显示器。
        // 虚拟的排在前面的机器上,老代码会把它们当显卡报出去。
        let adapters = vec![
            (
                "0000".to_string(),
                "RemoteControl Virtual Display".to_string(),
            ),
            (
                "0001".to_string(),
                "GameViewer Virtual Display Adapter".to_string(),
            ),
            ("0002".to_string(), "   ".to_string()),
            ("0003".to_string(), "NVIDIA GeForce RTX 4060".to_string()),
        ];
        assert_eq!(pick_adapter(&adapters).unwrap().0, "0003");
        // 全是虚拟适配器时宁可「(未知)」,也不许把远程桌面报成显卡
        let only_virtual = vec![
            (
                "0000".to_string(),
                "Microsoft Basic Display Adapter".to_string(),
            ),
            (
                "0001".to_string(),
                "Microsoft Remote Display Adapter".to_string(),
            ),
        ];
        assert!(pick_adapter(&only_virtual).is_none());
        assert!(pick_adapter(&[]).is_none());
    }

    #[test]
    fn gpu_size_values_accept_hex_and_decimal() {
        // QWORD 那一项:`reg query` 打的是 `0x…`
        assert_eq!(
            parse_size_value("0x100000000"),
            Some(4 * 1024 * 1024 * 1024)
        );
        // 老驱动的 DWORD 项可能给十进制(还带空白)
        assert_eq!(
            parse_size_value(" 8589934592 "),
            Some(8 * 1024 * 1024 * 1024)
        );
        // 读不懂的值一律 None —— **不能变成 0**,否则会当成「没有独立显存」
        assert_eq!(parse_size_value(""), None);
        assert_eq!(parse_size_value("(数值未设置)"), None);
        assert_eq!(parse_size_value("0xZZ"), None);
    }

    // ---- 报告落盘:文件名 / 上限清理 ----

    #[test]
    fn date_stamp_is_zero_padded() {
        // 从年月日格式化,而不是从时间串里 filter 数字抠:一位数的月/日不补零,
        // 文件名的字典序就不再等于时间序,清理会把新的当旧的删
        assert_eq!(date_stamp(2026, 10, 4), "20261004");
        assert_eq!(date_stamp(2026, 1, 9), "20260109");
        assert_eq!(date_stamp(2026, 12, 31), "20261231");
    }

    #[test]
    fn report_file_names_are_recognised_exactly() {
        assert!(is_report_of("MY-PC-20261004.cy", "MY-PC-"));
        assert!(is_report_of("UNKNOWN-20261004.cy", "UNKNOWN-"));
        // 前缀被另一台机器名套住 → 不是本机的报告,清理时不许碰
        assert!(!is_report_of("MY-PC-OLD-20261004.cy", "MY-PC-"));
        // 日期部分不合形的一律不算
        assert!(!is_report_of("MY-PC-2026-10-04.cy", "MY-PC-"));
        assert!(!is_report_of("MY-PC-.cy", "MY-PC-"));
        assert!(!is_report_of("MY-PC-2026100a.cy", "MY-PC-"));
        assert!(!is_report_of("MY-PC-20261004.txt", "MY-PC-"));
    }

    /// 上限清理:超过 `KEEP_REPORTS` 份就删最旧的,但
    /// **只删同一台机器的**,而且**绝不删刚写出的那一份** ——
    /// 后者是重点:机器时钟被调回去时,新报告的名字反而"最旧"。
    #[test]
    fn prune_keeps_the_newest_and_spares_the_current_file() {
        let dir = std::env::temp_dir().join(format!("nfarms-cy-prune-{}", std::process::id()));
        // 前置清理:临时目录是复用的,同进程重跑时残留会让断言飘
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        // 40 天各一份;定长 `yyyyMMdd` 保证文件名字典序 = 时间序
        const DAYS: usize = 40;
        for i in 0..DAYS as u32 {
            let (m, d) = (1 + i / 28, 1 + i % 28);
            std::fs::write(dir.join(format!("PC-2026{m:02}{d:02}.cy")), "x").unwrap();
        }
        // 别的机器、前缀相近的另一台机器、非 `.cy`:都不许动
        for other in ["OTHER-20260101.cy", "PC-OLD-20260101.cy", "PC-20260101.txt"] {
            std::fs::write(dir.join(other), "x").unwrap();
        }
        // 时钟被调回最早那天写出的报告
        let current = "PC-20260101.cy";
        std::fs::write(dir.join(current), "x").unwrap();

        let removed = prune_old_reports(&dir, "PC", current);
        // 超出上限的都在最旧那一段,其中一份是 current(豁免)
        assert_eq!(removed, DAYS - KEEP_REPORTS - 1);
        assert!(dir.join(current).exists(), "刚写出的报告被清理删掉了");
        let left = std::fs::read_dir(&dir)
            .unwrap()
            .flatten()
            .filter(|e| is_report_of(&e.file_name().to_string_lossy(), "PC-"))
            .count();
        assert_eq!(left, KEEP_REPORTS + 1, "本机报告 = 上限 + 被豁免的 current");
        for other in ["OTHER-20260101.cy", "PC-OLD-20260101.cy", "PC-20260101.txt"] {
            assert!(
                dir.join(other).exists(),
                "误删了不属于本机报告的文件 {other}"
            );
        }
        let _ = std::fs::remove_dir_all(&dir);
    }

    // ---- 取值口径 ----

    #[test]
    fn env_first_takes_the_first_non_empty_candidate() {
        // 不在测试里设环境变量:测试并行跑,`set_var` 会互相干扰(2024 edition
        // 里它已经是 unsafe)。用一个必然不存在的名字 + 一个必然存在的名字来验。
        assert!(env_first(&["__NFARMS_CYINFO_UNSET_1__", "__NFARMS_CYINFO_UNSET_2__"]).is_none());
        let path = env_first(&["__NFARMS_CYINFO_UNSET_3__", "PATH"]).expect("PATH 应当存在");
        assert!(!path.trim().is_empty());
    }

    /// `sd-cli` 是文生图的引擎:它在不在**不影响**语言模型的结论。
    /// 这条口径写在 `model_verdict` 的文档注释里,这里把它钉住。
    #[test]
    fn sd_cli_does_not_affect_the_language_model_verdict() {
        let base = MachineInfo {
            llama_server: Some("llama-server.exe".to_string()),
            gpu: "Test GPU".to_string(),
            vram_bytes: 8 * 1024 * 1024 * 1024,
            models: vec![("qwen3".to_string(), 1)],
            ..Default::default()
        };
        let with_sd = MachineInfo {
            sd_cli: Some("sd-cli.exe".to_string()),
            ..base.clone()
        };
        assert_eq!(with_sd.model_verdict(), base.model_verdict());
        // 只有 sd-cli、没有 llama-server:照样是「不支持」
        let sd_only = MachineInfo {
            sd_cli: Some("sd-cli.exe".to_string()),
            gpu: "Test GPU".to_string(),
            vram_bytes: 8 * 1024 * 1024 * 1024,
            models: vec![("qwen3".to_string(), 1)],
            ..Default::default()
        };
        assert_eq!(sd_only.model_verdict(), ModelVerdict::NoEngine);
    }

    /// 内存原来只写进 `ram_bytes`、可用量被 `let _ = avail;` 丢掉。
    /// 现在 `memory_status` 只回总量 —— 盯着它别悄悄变成 0,也别忘了进报告。
    #[cfg(windows)]
    #[test]
    fn physical_ram_total_is_actually_collected() {
        let total = memory_status().expect("Windows 上 GlobalMemoryStatusEx 应当成功");
        assert!(total > 64 * 1024 * 1024, "物理内存总量看着不真: {total} 字节");
        let info = MachineInfo {
            ram_bytes: total,
            ..Default::default()
        };
        assert!(render(&info, "2026-10-04 19:02:11").contains(&format!("内存 = {}", human(total))));
    }
}