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