crates\nfarms-app\src\main.rs¶
源码路径:
crates\nfarms-app\src\main.rs· 1274 行
//! NFarms HUB 命令行诊断。
//!
//! 参数、输出格式与 C# 版 `NFarmsTools.exe` **逐字节对齐** ——
//! 移植期这个 crate 就是验收工具:C# 版跑一遍、Rust 版跑一遍,`diff` 两份输出。
//! 等价性靠比对证明,不靠肉眼看截图。
//!
//! `nfarms-core` 只给出「行」,CRLF 由这里统一补:原版 `Console.WriteLine` 在
//! Windows 上写的是 CRLF,而 Rust 的 `println!` 只写 `\n`,不显式处理的话每一行都会 diff 出来。
// 与 C# 版一致:exe 是 Windows(GUI)子系统,双击**不弹黑框**。
// 诊断模式下的输出靠 `setup_console_for_cli()` 自己接控制台。
// `not(test)` 是必须的 —— 否则测试二进制也会变成 GUI 子系统,`cargo test`
// 的输出会整个看不见。
#![cfg_attr(all(not(test), windows), windows_subsystem = "windows")]
use std::path::PathBuf;
use std::process::ExitCode;
use nfarms_core::{Log, Paths, csharp_bool, settings, weather};
/// 按 .NET 的写法输出一行(CRLF 结尾)。
///
/// **写失败不再被无声吞掉**:stdout 被重定向、父进程只读了一部分就关掉
/// (`nfarms --list-pages | head -1`)时,后面的 `write_all` 会返回
/// `BrokenPipe`。旧写法 `let _ = …` 一律丢弃,各分支照样返回
/// `ExitCode::SUCCESS` —— 调用脚本拿到 0,却只收到了半份输出。
/// 现在改走 [`write_line`]:失败会置上错误标志,`main` 末尾据此返回非 0。
macro_rules! wln {
($($arg:tt)*) => {{
let mut out = std::io::stdout().lock();
if let Err(e) = write_line(&mut out, format_args!($($arg)*)) {
note_output_failure(&e);
}
}};
}
/// 往任意 sink 里写一行(CRLF 结尾),失败按 `wln!` 的同一口径登记。
///
/// 这是 [`write_list_pages`] / [`dump_weather`] 唯一的出口:两个函数、以及
/// `main` 里直接用的 `wln!`,错误都汇到 [`note_output_failure`] 那一处,
/// `main` 末尾的 [`exit_after_output_errors`] 才真的是「任何一条输出路径断了
/// 都会反映到退出码上」,而不是只有 `wln!` 那条路。
macro_rules! wln_to {
($out:expr, $($arg:tt)*) => {{
let out: &mut dyn std::io::Write = $out;
if let Err(e) = write_line(out, format_args!($($arg)*)) {
note_output_failure(&e);
}
}};
}
/// `wln!` 的落点:写正文 + CRLF,把首个 io 错误交给调用方。
///
/// 参数取 `&mut dyn Write` 而不是写死 `Stdout`,是为了让测试拿 `Vec<u8>`
/// 或者一个「写什么都报错」的假 writer 走**同一段代码**,而不是去测一个副本。
/// 取 `dyn` 而不是泛型,是为了让 [`wln_to!`] 能把调用方给的表达式**先强转**成
/// 一个已有的 trait object(泛型版要 `W: Sized`,传 `&mut dyn Write` 会编不过)。
fn write_line(out: &mut dyn std::io::Write, args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
out.write_fmt(args)?;
out.write_all(b"\r\n")
}
thread_local! {
/// 有没有过输出写失败。
///
/// 用 thread-local 而不是 `static AtomicBool`:输出全在主线程上,
/// 而单元测试是各跑各的线程 —— 断言「正常写不置位」时不会被别的测试干扰,
/// 也不必给测试加全局锁。
static OUTPUT_FAILED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// 记下首次输出失败。
///
/// 只记首次的文本,后续失败不再改写:真正有用的原因通常就是第一个
/// (`BrokenPipe`),后面的写只会报同一个错。同时也只写**一次** stderr ——
/// 管道断了的时候 stderr 很可能也连着同一个下游,刷屏没有意义。
fn note_output_failure(err: &std::io::Error) {
let first = OUTPUT_FAILED.with(|f| !f.replace(true));
if first {
eprintln!("[x] 输出写入失败: {err}");
}
}
/// 取走错误标志并还原成退出码。
///
/// `main` 的**每条出口**都要经过这里:正常收尾那一个,以及
/// `Mode::WeatherParse` 里「文件读不了」的提前退出。
/// 诊断类命令自己的成败判据里不掺这一项:它说的是「输出这条路通不通」,
/// 和「命令本身有没有结果」是两件事,分开报才好定位。
fn exit_after_output_errors(code: ExitCode) -> ExitCode {
if OUTPUT_FAILED.with(std::cell::Cell::take) {
eprintln!("[x] 输出不完整(上面已写明原因),退出码按失败处理");
return ExitCode::from(EXIT_WRITE_FAILED_CODE);
}
code
}
/// 输出写失败时用的退出码。
///
/// 用 2(与「用法错误 / 环境不支持」同档),不用 1 —— 1 是「命令跑了、结果是否定」,
/// 这里是「结果没能完整交出去」,调用方看到 1 会以为查出来的就是坏消息。
///
/// 存成 `u8` 而不是 `ExitCode`:`ExitCode::from` 不是 const fn,
/// `const … : ExitCode = ExitCode::from(2)` 直接编不过。用的时候再转。
const EXIT_WRITE_FAILED_CODE: u8 = 2;
#[derive(Debug, PartialEq, Eq)]
enum Mode {
/// 无参数 → 启动图形界面(阶段 2 才有的东西)
Gui,
LayoutTest,
WeatherParse(String),
Logs,
/// libs 组件与本地大模型的安装状态(新增命令,不是 C# 版原有的)
LibsStatus,
/// 按功能分组列出全部内置页面(新增命令)。
///
/// 存在的理由是**冒烟测试**:页面对应关系在编译期就定死在
/// `nfarms-gui::pages::PAGES` 那张常量表里(`plugins::desktop()` 只是把它包成
/// 插件注册表),测试脚本没法自己拼一份「程序眼里的清单」,得问程序本身。
/// 输出格式:`# 组名` 打头,后面每行一个页面 id。
ListPages,
/// 拓扑验证器自检(口径 + 可选真实探测)。`--offline` 只校验口径不发请求,
/// `--topo-timeout N`(N≥500)覆盖单边超时。移植自 C# 版 `--topo-test`。
TopoTest,
/// 8 种运行时工具的版本检测(← C# `--versions`)
Versions,
/// 各工具的最新版本(← C# `--latest`,走网络)
Latest,
/// 某个工具环境里装的应用清单(← C# `--npm-apps`)
NpmApps,
/// 只装可观测性的界面产物(**Grafana 看板 + 品牌 logo**),不起任何服务、不开窗口。
///
/// 存在的理由很具体:`observability::provision_dashboards` /
/// `install_branding` 原来**只有界面那条路**会调(`crates\nfarms-gui\src\lib.rs`
/// 的 `obs_start`),后果是 ——
///
/// * 从命令行验证不了这两个已经写好并有测试的函数;
/// * `libs\scripts\fetch-libs.ps1` 重装 `libs\` 会把 logo 整个冲掉
/// (见 `install_branding` 的注释),要补装就得先去点一次界面。
///
/// 这是给脚本用的命令,所以退出码必须说真话:任何一步失败都返回非 0。
ObsInstall,
/// `--page` 给了个不认识的页面名
UnknownPage(String),
/// C# 版有、这个移植切片还没做的开关。显式报出来,别静默掉进界面模式。
Pending(&'static str),
}
#[derive(Debug)]
struct Args {
settings_path: Option<String>,
mode: Mode,
/// 界面开在哪一页(`--page harness`)。给每一页做无人值守冒烟测试用。
page: Option<nfarms_gui::Page>,
/// `--topo-test` 的配套开关:只校验口径定义,不发网络请求。
topo_offline: bool,
/// `--topo-test` 的单边超时(毫秒,C# 版要求 ≥500 才生效)。
topo_timeout: i32,
/// `--open-image <路径>`:启动时直接打开图片阅读器并载入这张图。
/// 文件关联(双击图片用本程序打开)走的就是这条路。
open_image: Option<String>,
/// `--popout`:启动时就把 `--page` 指定的那一页弹成独立窗口。
/// 想一开机就全屏数字雨,建个快捷方式写 `--page matrix-rain --popout` 即可。
popout: bool,
}
/// 还没移植的开关(逐一从 `Program.cs` 的参数循环里抄出来)。
/// 每移植完一个就从这里删掉一个 —— 这个列表本身就是进度表。
const PENDING_SWITCHES: &[&str] = &[
"--weather",
"--music-test",
"--printer-info",
"--smoke",
"--uicheck",
"--perf",
"--dsh-start",
"--dsh-stop",
"--dsh-tail",
"--shots",
"--shot",
"--nocache",
"--nocomposited",
"--composited",
"--ztna-status",
"--dsh-latest",
"--browser-test",
"--libs-selftest",
"--mini-test",
"--pet-test",
"--store-test",
"--libs-clean",
"--store-install",
"--libs-install",
];
/// 解析命令行。
///
/// 返回 `Result` 而不是「带错误字段的 Args」:用法错误必须**没法被忽略** ——
/// 把错误吞进一个字段里、等调用方想起来去读,正是「半截契约」的样子。
/// 错误文本由 `main` 打到 stderr,并以退出码 2 收场(与「不认识这一页」同档)。
fn parse_args(argv: &[String]) -> Result<Args, String> {
let mut args = Args {
settings_path: None,
mode: Mode::Gui,
page: None,
topo_offline: false,
topo_timeout: nfarms_core::topo::TIMEOUT_MS,
open_image: None,
popout: false,
};
let mut i = 0;
while i < argv.len() {
let a = argv[i].as_str();
match a {
"--settings" if i + 1 < argv.len() => {
args.settings_path = Some(argv[i + 1].clone());
i += 2;
}
// 兜底:`--settings` 写在末位(后面没跟值)时必须报错,不能静默跳过。
//
// 上面那条带守卫的臂在「没有下一个参数」时不匹配,于是落进 `_` 分支
// ——而 `--settings` 不在 `PENDING_SWITCHES` 里、也不被任何分支识别,
// 就被当成「不认识的参数直接忽略」了:用户以为设置生效了,其实还在用默认路径。
// 这种「说了话没人听见」的失败最难查,所以这里明确报出去(main 打 stderr + 退出码 2)。
"--settings" => {
return Err("--settings 后面缺少值(设置文件路径)".to_string());
}
"--weather-parse" if i + 1 < argv.len() => {
args.mode = Mode::WeatherParse(argv[i + 1].clone());
i += 2;
}
"--layout-test" => {
args.mode = Mode::LayoutTest;
i += 1;
}
"--logs" => {
args.mode = Mode::Logs;
i += 1;
}
"--libs-status" => {
args.mode = Mode::LibsStatus;
i += 1;
}
"--list-pages" => {
args.mode = Mode::ListPages;
i += 1;
}
// 与 `--topo-test` / `--versions` 同一条规矩:先出现的模式生效
// (`nfarms --logs --obs-install` 跑的还是 `--logs`)。
"--obs-install" => {
if let Mode::Gui = args.mode {
args.mode = Mode::ObsInstall;
}
i += 1;
}
"--topo-test" => {
if let Mode::Gui = args.mode {
args.mode = Mode::TopoTest;
}
i += 1;
}
"--open-image" if i + 1 < argv.len() => {
args.open_image = Some(argv[i + 1].clone());
i += 2;
}
"--popout" => {
args.popout = true;
i += 1;
}
"--versions" => {
if let Mode::Gui = args.mode {
args.mode = Mode::Versions;
}
i += 1;
}
"--latest" => {
if let Mode::Gui = args.mode {
args.mode = Mode::Latest;
}
i += 1;
}
"--npm-apps" => {
if let Mode::Gui = args.mode {
args.mode = Mode::NpmApps;
}
i += 1;
}
"--offline" => {
args.topo_offline = true;
i += 1;
}
"--topo-timeout" if i + 1 < argv.len() => {
// 与 C# 一致:只有 ≥500 的合法整数才覆盖默认超时
if let Ok(tt) = argv[i + 1].parse::<i32>()
&& tt >= 500
{
args.topo_timeout = tt;
}
i += 2;
}
"--page" if i + 1 < argv.len() => {
let name = argv[i + 1].clone();
match nfarms_gui::Page::from_name(&name) {
Some(p) => args.page = Some(p),
// 只有还没选定模式时才让「没有这一页」成为模式。
//
// 没有这个守卫时这一支会把已经选定的模式**改写**成 UnknownPage
// (无条件 `args.mode = Mode::UnknownPage(name)`),而 `--layout-test`
// 之类是无守卫分支。于是 `--layout-test --page 不存在` 里自检根本没跑,
// 用户只看到一句「没有这一页」,还以为自检过了。
None => {
if let Mode::Gui = args.mode {
args.mode = Mode::UnknownPage(name);
}
}
}
i += 2;
}
_ => {
// 与 C# 的参数循环一致:先出现的模式生效,后面的模式开关不覆盖它。
if let Some(sw) = PENDING_SWITCHES.iter().copied().find(|s| *s == a) {
if let Mode::Gui = args.mode {
args.mode = Mode::Pending(sw);
}
}
// 不认识的参数直接忽略(与 C# 一致)。
i += 1;
}
}
}
Ok(args)
}
/// `--open-image` / `--popout` 在**非界面模式**下会被谁都不读。
///
/// 两者的唯一消费点是 `Mode::Gui` 分支(交给 `nfarms_gui::run`)。
/// `--logs --popout` / `--open-image x.png --list-pages` 这类组合里,参数被解析进
/// `Args` 之后没有任何人读 —— 命令照常跑、退出码 0,用户以为那个开关生效了。
///
/// 返回 `<开关名, 用户给的值>`;由调用方打到 stderr。**不在这里打**:
/// `parse_args` 是纯函数,单元测试要能调它而不往 stderr 里喷东西。
///
/// 只警告、不报错,是刻意的:这些开关和诊断命令拼在一条命令行里
/// (老脚本、文件关联注册表项)不该让整个命令失败 —— C# 版也是直接忽略。
/// 但「静默忽略」和「说了被听见」对排查的差别很大,所以至少要有一行话。
fn ignored_cli_only_flags(args: &Args) -> Vec<(&'static str, String)> {
let mut ignored = Vec::new();
if !matches!(args.mode, Mode::Gui) {
if let Some(path) = &args.open_image {
ignored.push(("--open-image", path.clone()));
}
if args.popout {
ignored.push(("--popout", "(--page)".to_string()));
}
}
ignored
}
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
let args = match parse_args(&argv) {
Ok(a) => a,
Err(msg) => {
// stderr 打在 `setup_console_for_cli()` **之前**是有意的:那之前的 stderr
// 走的还是父进程 cmd 的句柄,不需要自己接控制台。反过来说,若这句
// 接在后面,双击/无控制台启动时会变成「进程退出、什么都没说」。
eprintln!("[x] {msg}");
return ExitCode::from(2);
}
};
// 在接控制台之前先把「被忽略的开关」说出来(理由同上:此时 stderr 还没被接管)。
for (flag, value) in ignored_cli_only_flags(&args) {
eprintln!("[i] {flag} {value} 只在界面模式下生效,本次是诊断命令,已忽略");
}
// 与 C# 一致:有诊断命令时才准备控制台(纯 GUI 模式不需要,也不该动它)。
if !matches!(args.mode, Mode::Gui) {
setup_console_for_cli();
}
// 与 C# 版顺序一致:不管跑哪条命令,都先把目录 / 日志 / 设置解析一遍。
// 注意迁移**不在这里**:它由下面显式的 `settings::migrate_legacy` 负责
// (`nfarms-core/src/settings.rs:249`;C# 版把这段藏在 `DefaultPath` 属性 getter 里,
// 这里拆成了看得见的一步)。别把「读设置」当成迁移的触发点。
let paths = Paths::detect();
let log = Log::detect(&paths);
// ---------------------------------------------------------------- panic 钩子
//
// **这是唯一能把「闪退」变成「有据可查」的东西。**
//
// Rust panic 的默认行为:打 stderr + **退出码 101**。从 GUI 双击启动时
// 没有控制台,stderr 无处可去;而 Windows 的 Application 事件日志
// **不记录退出码 101**(它只记访问越界 / fail-fast 这类硬故障)。
//
// 结果就是一个「窗口凭空消失、哪儿都查不到」的现象。排查「图像生成一半
// 就闪退」时遇到的正是这个:sd-cli 的子进程日志完整(跑完并存图成功)、
// Windows 事件日志空白、应用日志停在生成中途 —— 没有任何证据能定位。
//
// 装上之后:panic 会带**位置和原因**写进 logs\panic.log。
// 单写文件而不是走 `Log`:少一个依赖就少一处编译不过的可能,
// 而且 panic 时连日志模块本身是否可用都不该假设。
{
let root = paths.root().to_path_buf();
std::panic::set_hook(Box::new(move |info| {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let msg = format!("[{secs}] !! PANIC: {info}");
let p = root.join("logs").join("panic.log");
if let Some(d) = p.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&p)
{
use std::io::Write;
let _ = writeln!(f, "{msg}");
}
eprintln!("{msg}");
}));
}
let settings_path: PathBuf = args
.settings_path
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| settings::default_path(&paths));
let _ = settings::migrate_legacy(&paths, &log);
// 读一遍设置,但**结果确实没人用**。
//
// 为什么还要读:`load` 不顺带迁移(那是上一行 `migrate_legacy` 的事),
// 它唯一的副作用是**解析失败时**把原文另存为 `settings.json.broken` 并删掉原文件
// (`nfarms-core/src/settings.rs:360-390`,`load` 自己的注释写明这是「有意」的、
// 数据安全优先的差异)。诊断命令跑一次,坏掉的设置文件就会被保住一份 ——
// 这是原版就有的行为,不能因为诊断分支不看设置就省掉这一句。
//
// 为什么写成 `let _ =` 而不是 `let _settings =`:后者看着像「有个变量待用」,
// 会让人以为这份设置该被下面某处分发出去;实际界面层是**自己重新读一份**的
// (`nfarms-gui/src/lib.rs:1528` 的 `nfarms_core::settings::load`)。写成 `_` 就是
// 明说「只为副作用」。真要让诊断输出用上它,应该显式在
// `Mode::LayoutTest` 分支里打实际路径 —— 但那会和原版 diff 不一致(见上面那段
// 关于 `Settings.DefaultPath` 的注释),所以这里不做。
let _ = settings::load(&paths, &settings_path);
let code = match &args.mode {
Mode::LayoutTest => {
wln!("layout report:");
let (lines, issues) = paths.self_test();
for line in &lines {
wln!("{line}");
}
// C# 这里是 `Console.WriteLine(Paths.SelfTest())`,而 SelfTest() 的返回值
// 自带结尾换行 → 输出里会多一个空行。**刻意保留这个怪癖**:移植期求等价,
// 要改也得等 diff 全绿之后再单独提一次,而不是顺手「修好」让比对失去意义。
wln!("");
// 注意:原版这里打的是 `Settings.DefaultPath`(默认路径),
// 即使传了 `--settings` 也不受影响。别「顺手改进」成实际生效的路径。
wln!(" settings: {}", settings::default_path(&paths).display());
wln!(" logs : {}", log.dir().display());
exit_code(issues == 0)
}
Mode::WeatherParse(file) => {
let text = std::fs::read_to_string(file);
if let Err(e) = &text {
// 原版这里会抛未捕获异常直接把进程崩掉;这里给一行明确报错。
wln!("parse {file} ok=False error={e}");
// 这里曾经是 `return ExitCode::FAILURE;` —— 直接 return 会绕开
// `main` 末尾的 `exit_after_output_errors`。改成用块把退出码交给统一收尾,
// 这样「文件读不了」和「连这行报错都没写出去」不会再分家。
return exit_after_output_errors(ExitCode::FAILURE);
}
let text = text.expect("上面已经处理过 Err");
match weather::parse(nfarms_core::strip_bom(&text)) {
Ok(report) => {
wln!("parse {file} ok={}", csharp_bool(true));
// 详情块走同一个 `wln_to!` 出口,所以它写失败也会被
// `exit_after_output_errors` 记到账上。
let mut out = std::io::stdout().lock();
dump_weather(&mut out, &report);
ExitCode::SUCCESS
}
Err(err) => {
wln!("parse {file} ok={} error={err}", csharp_bool(false));
ExitCode::FAILURE
}
}
}
Mode::Logs => {
wln!(
"日志目录: {}{}",
log.dir().display(),
if log.using_temp_dir() {
" (软件目录不可写,退回临时目录)"
} else {
""
}
);
wln!("主日志 : {}", log.app_path().display());
wln!("代理日志: {}", log.printer_path().display());
wln!("---- 主日志尾部 ----");
wln!("{}", Log::tail(&log.app_path(), 20));
ExitCode::SUCCESS
}
Mode::LibsStatus => {
let lines = nfarms_core::libs::report_lines(&paths);
// 三档退出码(0 成功 / 1 失败 / 2 用法或环境不支持)是文档已经写明给
// 脚本用的契约,而这四条检测命令原来**无论成败都返回 0**
// (`--latest` 甚至在打出「(获取失败/离线)」之后仍然返回 0),
// 脚本据此判成败就等于没判。
//
// 判据用「有没有结果」:report_lines 正常时至少含目录行与组件段落,
// 返回空只可能是实现被改坏。
if lines.is_empty() {
eprintln!("[x] --libs-status 没有任何结果");
ExitCode::FAILURE
} else {
for line in lines {
wln!("{line}");
}
ExitCode::SUCCESS
}
}
Mode::ListPages => {
// 输出整段交给 `write_list_pages`:`--list-pages` 是冒烟脚本的输入,
// 格式(组头 + 三列)得能被测试钉住,不能只活在 main 里。
// 它内部走 `wln_to!`,所以这一路的写失败也会记到 `exit_after_output_errors` 账上。
let pages = write_list_pages(&mut std::io::stdout().lock());
// 这张表由 `pages!` 宏在编译期展开,正常永远非空;空 = 表被掏空了。
if pages == 0 {
eprintln!("[x] --list-pages 没有任何页面(内置页面表是空的?)");
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
Mode::TopoTest => {
// C#:`Console.WriteLine(TopoValidator.SelfTest(!topoOffline, topoTimeout))`。
// SelfTest 返回的字符串本身以 \r\n 结尾,WriteLine 再补一个 ——
// wln! 正好也是「内容 + \r\n」,逐字节等价。
wln!(
"{}",
nfarms_core::topo::self_test(!args.topo_offline, args.topo_timeout)
);
ExitCode::SUCCESS
}
Mode::Versions => {
let lines = nfarms_core::tools::versions_lines();
// 同 `--libs-status`:空结果按失败报。
// 注意这条判据目前是**够不到的** —— `versions_lines()` 的条数等于
// `TOOL_KEYS.len()`(8 个写死的 key),所以它永远非空。写在这里是为了
// 「结果为空 = 失败」这条口径在这四条命令上一致:哪天检测清单变成可以
// 是空集(比如加了 `--only` 过滤),这里不用再补一次。
if lines.is_empty() {
eprintln!("[x] --versions 没有任何结果(工具清单是空的?)");
ExitCode::FAILURE
} else {
for line in lines {
wln!("{line}");
}
ExitCode::SUCCESS
}
}
Mode::Latest => {
// 先把所有行取回来再决定退出码:`(获取失败/离线)` 是**逐行**打的,
// 只看某一行为空就判失败会把「离线但只挂了一个源」也判成失败。
// 判据是「一个都没拿到」—— 那才是「命令没有结果」。
let mut got_any = false;
for key in nfarms_core::tools::TOOL_KEYS {
let v = nfarms_core::tools::fetch_latest(key);
let missed = v.is_empty();
got_any |= !missed;
wln!(
"{:<8} latest={}",
key,
if missed { "(获取失败/离线)" } else { &v }
);
}
if got_any {
ExitCode::SUCCESS
} else {
eprintln!("[x] --latest 一个工具的版本都没取到(离线?)");
ExitCode::FAILURE
}
}
Mode::NpmApps => {
let lines = nfarms_core::tools::env_apps("npm");
// 空清单 = 什么也没查到。
//
// 这里只能停在「查不到」这一层,不能断言「npm 没装」:`env_apps`
// (`nfarms-core/src/tools.rs:919`)对「命令不存在」和「命令跑了但输出为空」
// 都返回空 Vec —— 连「npm 是否可用」都判不出来,要分开得先让 core 侧
// 暴露一个探针。所以文本里两个原因都写上,不编一个确定性的说法。
if lines.is_empty() {
eprintln!("[x] --npm-apps 没有查到任何全局包(npm 未安装,或还没有全局包)");
ExitCode::FAILURE
} else {
for line in lines {
wln!("{line}");
}
ExitCode::SUCCESS
}
}
Mode::ObsInstall => {
// 路径表达式与 `crates\nfarms-gui\src\lib.rs` 的 `obs_start` **逐字相同**
// (`self.paths.libs()` / `self.paths.root().join("data")`)。
// 这里自己拼一份的话,两处早晚会分家,现象是「界面装到 A、命令行装到 B」。
let libs = paths.libs();
let data = paths.root().join("data");
let mut ok = true;
// 每个函数返回「我写到哪儿了」:成功一步报一行,脚本按行读得出来。
// `provision_dashboards` 写的是两个文件(provider + 看板本体),
// 但只返回看板本体那条路径 —— provider 的位置是它内部的事,
// 这里不另抄一遍(抄了就会出现两份路径真话)。
match nfarms_core::observability::provision_dashboards(&data) {
Ok(p) => wln!("已写入 {}", p.display()),
Err(e) => {
wln!("失败: {e}");
ok = false;
}
}
// `install_branding` 在 `custom.ini` 不存在时**报错,而不是自己造一份**:
// 造的那份缺 `[server] http_port`,Grafana 会退回 3000 而健康检查打
// 9093 —— 现象是「永远起不来」,真正的原因是一个缺了半段的配置。
//
// 所以缺的时候不能只丢一句 Err 文本,得给出**能照着做**的话。
//
// 注意这个函数是「先落 logo、再追加 `[branding]`」:custom.ini 不在时
// 它照样 Err,但 logo 那一步已经写完了 —— 不用替它跳过整件事。
let ini = data.join("grafana").join("custom.ini");
match nfarms_core::observability::install_branding(&libs, &data) {
Ok(p) => wln!("已写入 {}", p.display()),
Err(e) => {
wln!("失败: {e}");
if !ini.is_file() {
wln!("缺少配置:{} 不存在", ini.display());
wln!("先开一次界面,在可观测性那一页点「启动 Grafana」:那一步会先生成 custom.ini(带 [server] http_port = 9093),之后再跑本命令");
wln!("别手写一份补上 —— 缺 [server] 的那份会让 Grafana 退回 3000,而健康检查打的是 9093,表现是 Grafana 永远起不来");
}
ok = false;
}
}
// Grafana 的真白标:换素材文件。**必须排在 `install_branding` 之后** ——
// 那一步写的是 `custom.ini` 的 `[branding]` 段,而它在 Grafana **OSS** 里
// 是付费功能、根本没人读(配置被收下,登录页 HTML 里连 `nfarms` 都出现
// 0 次,`<title>` 还是 `Grafana`)。真正起作用的是把 Grafana 自己那几个
// 路径写死的素材文件换掉(名字和扩展名都不能变)。
//
// 幂等:内容一样就一个字节都不写,所以第二次跑这里什么都不打印。
match nfarms_gui::install_grafana_branding(&libs) {
Ok(paths) if paths.is_empty() => {
wln!("无需改动:Grafana 的 logo 素材已经是最新的(与 assets\\nfarms.ico 同一张图)");
}
Ok(paths) => {
for p in paths {
wln!("已写入 {}", p.display());
}
}
Err(e) => {
wln!("失败: {e}");
ok = false;
}
}
// 判据是「三步是不是都成功了」。**任何一步失败都返回非 0** ——
// 这正是这个命令存在的意义:脚本里 `if ($LASTEXITCODE ...)` 得判得出来。
exit_code(ok)
}
Mode::Pending(switch) => {
eprintln!("[i] {switch} 还没移植(见 crates/nfarms-app/src/main.rs 的 PENDING_SWITCHES)");
ExitCode::from(2)
}
Mode::Gui => match nfarms_gui::run(
&paths,
&log,
&settings_path,
args.page,
args.open_image.as_deref(),
args.popout,
) {
Ok(()) => ExitCode::SUCCESS,
// 起不来时用非 0 退出码报出来。本程序是 GUI 子系统,双击场景下 stderr
// 是没人看的,所以 run() 内部同时会写一份到日志。
Err(e) => {
eprintln!("[x] 界面启动失败: {e}");
ExitCode::FAILURE
}
},
Mode::UnknownPage(name) => {
eprintln!("[x] 没有这一页: {name}");
// 说法要准确:内置页面**仍然是编译期常量表** `nfarms-gui::pages::PAGES`
// (由 `pages!` 宏展开,表项全是 `page!(…)`),`plugins::desktop()` 只是把它
// 逐项包成 `PagePlugin` 注册表,`Page::all()` / `from_name()` 也直接读这张表。
// 旧注释说「不再是写死的常量数组」是把两件事说反了 —— 会让读代码的人以为
// 加一个页面只要注册插件,结果在 `--page` 上白试半天。
eprintln!(
"[i] 可用:{}",
nfarms_gui::Page::all()
.map(|p| p.id())
.collect::<Vec<_>>()
.join(" / ")
);
ExitCode::from(2)
}
};
// 收尾点。走完 match 的都到这里;唯一不经过这里的分支是下面 WeatherParse 的
// 提前退出,它自己也显式调了 `exit_after_output_errors`。
// **新增提前 `return` 时别忘了这一条** —— 绕过去就等于「输出断了但退出码还是 0」。
exit_after_output_errors(code)
}
fn exit_code(ok: bool) -> ExitCode {
if ok { ExitCode::SUCCESS } else { ExitCode::FAILURE }
}
/// 让诊断模式有控制台可用,并且按 UTF-8 渲染中文。
///
/// 本程序是 **Windows(GUI)子系统** —— 双击不弹黑框,但代价是诊断模式没有
/// stdout。C# 版 `SetupConsoleForCli()` 的做法照抄过来:
///
/// 1. 看 `GetStdHandle(STD_OUTPUT_HANDLE)` 是不是可用句柄;
/// 2. **不可用**(双击场景)→ 先 `AttachConsole` 附到父进程的控制台(cmd 里跑),
/// 失败就 `AllocConsole` 新开一个;
/// 3. **可用**(被重定向到文件/管道,或本来就在控制台里)→ 什么都不做,
/// 否则会把重定向覆盖掉;
/// 4. 最后把控制台输出代码页设成 UTF-8 —— 中文 Windows 默认是 GBK(936),
/// 不设就是满屏乱码。
///
/// 必须**在任何输出之前**调用:Rust 的 stdout 句柄在第一次用到时才取,
/// 先接好控制台才拿得到正确的句柄。
fn setup_console_for_cli() {
#[cfg(windows)]
{
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetStdHandle(n_std_handle: u32) -> *mut core::ffi::c_void;
fn GetFileType(h_file: *mut core::ffi::c_void) -> u32;
fn AttachConsole(dw_process_id: u32) -> i32;
fn AllocConsole() -> i32;
fn SetConsoleOutputCP(w_code_page: u32) -> i32;
}
const STD_OUTPUT_HANDLE: u32 = -11i32 as u32; // (DWORD)-11
const ATTACH_PARENT_PROCESS: u32 = -1i32 as u32;
const CP_UTF8: u32 = 65001;
const FILE_TYPE_UNKNOWN: u32 = 0;
// SAFETY: 全是无指针参数或只读句柄的 Win32 调用;句柄仅用于判断有效性。
unsafe {
let h = GetStdHandle(STD_OUTPUT_HANDLE);
let invalid = h.is_null() || h as isize == -1;
let usable = !invalid && GetFileType(h) != FILE_TYPE_UNKNOWN;
if !usable && AttachConsole(ATTACH_PARENT_PROCESS) == 0 {
AllocConsole();
}
SetConsoleOutputCP(CP_UTF8);
}
}
}
/// `--list-pages` 的整段输出(组头 + 二级目录 + 每页三列),写进任意 sink。
///
/// 返回**页面行数**(不含组头与二级目录头),给调用方判「是不是一行都没有」。
///
/// 为什么带上 sink 参数而不是直接 `wln!`:这段格式是冒烟脚本的输入,
/// 必须能被测试逐字节钉住;而 `wln!` 永远写到进程 stdout,单元测试接不住。
/// 行尾(`\r\n`)由 [`wln_to!`] / [`write_line`] 统一补,和 `wln!` 是同一条出口。
///
/// sink 取 `&mut dyn Write` 而不是泛型:调用方给的多半是
/// `&mut std::io::stdout().lock()` 这类临时值,`dyn` 能让它按引用传进来,
/// 也免得每换一种 sink 就单态化一份。
///
/// 三级结构落地就靠这里:`# 一级目录`、`## 二级目录`、`id\t侧栏名\t标题`
/// —— 冒烟脚本按 `#`/`##` 认层级,所以**不能**把三级拍平成一串页名。
fn write_list_pages(out: &mut dyn std::io::Write) -> usize {
// 顺序与侧栏一致(注册表按 `Group::ALL` + 组内表序给出的树),
// 不直接按 `PAGES` 的表序打 —— 那张表是按「功能树 + 补充」排的。
let registry = nfarms_gui::desktop_registry();
let mut rows = 0;
for node in registry.grouped_tree() {
wln_to!(out, "# {}", node.group.nav());
for item in &node.items {
match item {
nfarms_gui::NavItem::Leaf(spec) => {
let info = spec.info;
wln_to!(out, "{}\t{}\t{}", info.id, info.nav, info.title);
rows += 1;
}
nfarms_gui::NavItem::Subgroup(sub, kids) => {
wln_to!(out, "## {}", sub.nav());
for spec in kids {
let info = spec.info;
wln_to!(out, "{}\t{}\t{}", info.id, info.nav, info.title);
rows += 1;
}
}
}
}
}
// 末尾一行汇总(以 `#` 开头,所以冒烟脚本那份「跳过 # 开头」的解析不受影响)。
// 它给的是**现算**的数:三级结构是否与 `pages.rs` 那张表一致,一行就能看出来。
wln_to!(
out,
"# 合计 {} 个一级目录 / {} 个二级条目 / {} 页",
nfarms_gui::Group::ALL.len(),
nfarms_gui::second_level_count(),
rows
);
rows
}
/// `--weather-parse` 的详情块,写进任意 sink(理由同 [`write_list_pages`],
/// 包括 `&mut dyn Write` 这个参数形状)。
fn dump_weather(out: &mut dyn std::io::Write, report: &weather::WeatherReport) {
let now = &report.now;
let mut city = now.city.clone();
if !now.region.is_empty() {
city.push_str(" / ");
city.push_str(&now.region);
}
if !now.country.is_empty() {
city.push_str(" / ");
city.push_str(&now.country);
}
wln_to!(out, " city : {city}");
wln_to!(out, " temp : {}C (feels {}C)", now.temp, now.feels);
wln_to!(
out,
" desc : {} icon={}",
now.desc,
weather::icon_for(&now.desc)
);
wln_to!(
out,
" humidity : {}% wind {} uv {} vis {}km",
now.humidity,
now.wind,
now.uv,
now.visibility
);
wln_to!(out, " obsTime : {}", now.obs_time);
for (i, day) in report.days.iter().enumerate() {
wln_to!(
out,
" day{} : {} {} {}~{}C {} {}",
i + 1,
day.date,
day.week,
day.min,
day.max,
day.desc,
day.icon
);
}
}
#[cfg(test)]
mod tests {
use std::fmt::Write as FmtWrite;
use std::io::Write as IoWrite;
use super::*;
fn argv(s: &[&str]) -> Vec<String> {
s.iter().map(|x| x.to_string()).collect()
}
/// 解析必须成功的那些用例走这里。
///
/// `parse_args` 现在返回 `Result`:用法错误(目前只有 `--settings` 缺值)是
/// 合法输出之一,所以断言得先说明「这一次该成功」,而不是用 `unwrap()`
/// 把失败原因丢掉。
fn parsed(s: &[&str]) -> Args {
parse_args(&argv(s)).unwrap_or_else(|e| panic!("{s:?} 应当解析成功,实际:{e}"))
}
/// 把输出收进内存的假控制台。
struct FakeConsole {
bytes: Vec<u8>,
/// true = 每次写都失败,用来复现「管道被下游关掉」。
fail: bool,
}
impl FakeConsole {
fn new() -> Self {
Self {
bytes: Vec::new(),
fail: false,
}
}
fn broken_pipe() -> Self {
Self {
bytes: Vec::new(),
fail: true,
}
}
fn text(&self) -> String {
String::from_utf8(self.bytes.clone()).expect("输出必须是合法 UTF-8")
}
}
impl IoWrite for FakeConsole {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.fail {
return Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"假装下游已经关掉",
));
}
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// 按 `wln!` 的约定逐行送进 sink,行为与 `main` 里那段完全一致。
fn send_lines(out: &mut dyn IoWrite, text: &str) {
for line in text.split_inclusive('\n') {
wln_to!(out, "{}", line.trim_end_matches('\n'));
}
}
fn sample_report() -> weather::WeatherReport {
weather::WeatherReport {
now: weather::WeatherNow {
city: "Pootung".to_string(),
region: "Shanghai".to_string(),
country: "China".to_string(),
temp: "23".to_string(),
feels: "26".to_string(),
desc: "Clear".to_string(),
humidity: "79".to_string(),
wind: "5 km/h ENE".to_string(),
obs_time: "07:00 PM".to_string(),
uv: "0".to_string(),
visibility: "10".to_string(),
},
days: vec![weather::WeatherDay {
date: "2026-09-20".to_string(),
week: "周日".to_string(),
min: "23".to_string(),
max: "29".to_string(),
desc: "Cloudy".to_string(),
icon: "⛅".to_string(),
}],
}
}
#[test]
fn parses_the_ported_switches() {
assert_eq!(parsed(&["--layout-test"]).mode, Mode::LayoutTest);
assert_eq!(parsed(&["--logs"]).mode, Mode::Logs);
assert_eq!(
parsed(&["--weather-parse", "a.json"]).mode,
Mode::WeatherParse("a.json".to_string())
);
assert_eq!(
parsed(&["--settings", "x.json", "--layout-test"]).settings_path,
Some("x.json".to_string())
);
}
#[test]
fn unported_switches_are_announced_not_silently_ignored() {
// 老脚本里面可能还带着 --uicheck;不能因此默默弹出个界面。
assert_eq!(parsed(&["--uicheck"]).mode, Mode::Pending("--uicheck"));
}
/// `--obs-install` 必须解析成一个**模式**,不能被当成不认识的参数忽略掉。
///
/// 忽略的后果不是「少装两个文件」而是**弹出一个界面**:`_` 兜底臂只是
/// `i += 1`,`mode` 留在 `Gui`。这条命令是给无人值守的脚本用的,弹出窗口
/// 会让脚本永远等在那儿(而它以为自己在装看板)。
#[test]
fn obs_install_is_a_mode_not_a_silently_ignored_argument() {
assert_eq!(parsed(&["--obs-install"]).mode, Mode::ObsInstall);
// 与其它模式开关同一条规矩:先出现的模式生效。
assert_eq!(parsed(&["--logs", "--obs-install"]).mode, Mode::Logs);
}
#[test]
fn no_arguments_means_gui() {
assert_eq!(parsed(&[]).mode, Mode::Gui);
// 未知参数(与 C# 一致)被忽略,不改变模式
assert_eq!(parsed(&["--what-is-this"]).mode, Mode::Gui);
}
#[test]
fn a_later_mode_switch_does_not_override_an_earlier_one() {
// 与 C# 的参数循环一致:先出现的模式生效。
assert_eq!(parsed(&["--layout-test", "--uicheck"]).mode, Mode::LayoutTest);
}
/// `--settings` 后没跟值时必须是**错误**,不能被当成「不认识的参数」吃掉。
///
/// 旧行为:该臂的守卫不成立 → 落进 `_` 分支 → 既不在 `PENDING_SWITCHES` 里,
/// 也没有任何分支认它 → 静默 `i += 1` 跳过。用户以为设置生效了,其实还在用默认路径。
/// 谁把兜底臂删掉(或把 `Err` 改回 `Ok`),这条立刻红。
#[test]
fn a_trailing_settings_switch_without_a_value_is_a_usage_error() {
let err = parse_args(&argv(&["--settings"]))
.expect_err("--settings 后面没有值,不能静默跳过");
assert!(
err.contains("--settings"),
"错误文本要点出是哪个开关缺值,实际:{err}"
);
// 出现在中间、被当成值吃掉的那种也别漏:`--settings --logs` 里
// `--logs` 是**值**不是开关,解析仍该成功(与 C# 一致:它只看下一个 token)。
assert_eq!(
parsed(&["--settings", "--logs"]).settings_path,
Some("--logs".to_string())
);
}
/// 认不出的页面名**不能覆盖已经选定的模式**。
///
/// 旧行为:`--layout-test --page 不存在` 里 `--page` 的 None 分支无条件写
/// `args.mode`,`LayoutTest` 被改写成 `UnknownPage` → 自检根本没跑,
/// 用户只看到「没有这一页」,还以为自检过了。
#[test]
fn an_unknown_page_does_not_override_a_mode_that_is_already_selected() {
assert_eq!(
parsed(&["--layout-test", "--page", "根本没有这一页"]).mode,
Mode::LayoutTest,
"已经选定的诊断模式不能被一个认不出的 --page 顶掉"
);
// 反过来:没有任何模式时,「没有这一页」本身就是要报的错误模式。
assert_eq!(
parsed(&["--page", "根本没有这一页"]).mode,
Mode::UnknownPage("根本没有这一页".to_string())
);
}
/// `--open-image` / `--popout` 只在界面模式下有人读,其余模式**必须出声**。
///
/// 这两个参数唯一的消费点是 `Mode::Gui` 分支(交给 `nfarms_gui::run`);
/// `--logs --popout` 里它们被解析进 `Args` 后无人读取,旧行为是既不报错也不弹窗、
/// 退出码还是 0 —— 用户以为开关生效了。
#[test]
fn cli_only_switches_are_reported_as_ignored_in_diagnostic_modes() {
let ignored =
ignored_cli_only_flags(&parsed(&["--logs", "--popout", "--open-image", "a.png"]));
assert_eq!(
ignored,
vec![
("--open-image", "a.png".to_string()),
("--popout", "(--page)".to_string()),
],
"诊断模式下这两个开关都要被报出来"
);
// 界面模式下它们是正常参数,不该报「已忽略」——否则每次双击图片都多一行噪声。
assert!(ignored_cli_only_flags(&parsed(&["--popout"])).is_empty());
assert!(ignored_cli_only_flags(&parsed(&["--open-image", "a.png"])).is_empty());
// 只是 `--page`(没有 --popout / --open-image)时也不该有任何话说。
assert!(ignored_cli_only_flags(&parsed(&["--page", "logs"])).is_empty());
}
/// `--list-pages` 的输出格式与分组顺序得钉死:这是冒烟脚本的输入。
///
/// 断言的是**完整字节流**,包括:
/// * 三级层级:`# 一级目录` / `## 二级目录` / `id\t侧栏名\t标题`;
/// * 组头顺序与 `Group::ALL` 一致(每个组名只出现一次);
/// * `wln_to!` 补的是 CRLF。
#[test]
fn list_pages_prints_every_group_in_order_with_crlf_lines() {
let mut console = FakeConsole::new();
let rows = write_list_pages(&mut console);
let mut expected = String::new();
let mut expected_rows = 0;
// 期望值**从注册表那棵树现推**(和 `write_list_pages` 吃的是同一份数据),
// 这样这条测试守的是「有没有漏打/打重」,而不是把格式抄一遍
for node in nfarms_gui::desktop_registry().grouped_tree() {
let _ = writeln!(expected, "# {}", node.group.nav());
for item in &node.items {
match item {
nfarms_gui::NavItem::Leaf(spec) => {
let info = spec.info;
let _ = writeln!(expected, "{}\t{}\t{}", info.id, info.nav, info.title);
expected_rows += 1;
}
nfarms_gui::NavItem::Subgroup(sub, kids) => {
let _ = writeln!(expected, "## {}", sub.nav());
for spec in kids {
let info = spec.info;
let _ =
writeln!(expected, "{}\t{}\t{}", info.id, info.nav, info.title);
expected_rows += 1;
}
}
}
}
}
// 末尾那行汇总也在同一份期望里 —— 它由 `write_list_pages` 追加,
// 内容全是现算的,所以这里照样现推一遍(而不是抄字符串)
let _ = writeln!(
expected,
"# 合计 {} 个一级目录 / {} 个二级条目 / {} 页",
nfarms_gui::Group::ALL.len(),
nfarms_gui::second_level_count(),
nfarms_gui::PAGES.len()
);
assert_eq!(rows, expected_rows, "返回的行数要等于实际打出来的页面行数");
let actual = console.text();
assert_eq!(
actual,
expected.replace('\n', "\r\n"),
"输出必须逐字节等于「三级层级 + 三列 + CRLF」"
);
// 组头只能出现一次,顺序必须与 `Group::ALL` 一致
let headers: Vec<&str> = actual
.lines()
.filter(|l| l.starts_with("# ") && !l.contains("合计"))
.map(|l| l.trim_end_matches('\r'))
.collect();
let expected_headers: Vec<String> = nfarms_gui::Group::ALL
.iter()
.map(|g| format!("# {}", g.nav()))
.collect();
assert_eq!(
headers,
expected_headers,
"组头必须按 Group::ALL 的顺序、且每个只出现一次"
);
// 二级目录头必须缩进在正确的组下面、且组内不重复
assert!(
actual.contains("## Tauri\r\n") && actual.contains("## Music\r\n"),
"二级目录头没有打出来"
);
// 末尾那行汇总必须与现算的数一致(它同时是「三级结构自洽」的一眼证据)
assert!(
actual.contains(&format!(
"# 合计 {} 个一级目录 / {} 个二级条目 / {} 页\r\n",
nfarms_gui::Group::ALL.len(),
nfarms_gui::second_level_count(),
nfarms_gui::PAGES.len()
)),
"汇总行与实际结构对不上"
);
// 只留组头是不够的:每页一行,行数必须等于内置页面总数。
assert_eq!(
rows,
nfarms_gui::PAGES.len(),
"每一页都要有一行(少一行说明有页面被分组过滤掉了)"
);
assert!(!actual.is_empty(), "内置页面表不该是空的");
}
/// `dump_weather` 的字段排布(含「城市 / 地区 / 国家」的拼接与 day 行)。
#[test]
fn dump_weather_layout_is_pinned() {
let mut console = FakeConsole::new();
dump_weather(&mut console, &sample_report());
let text = console.text();
assert!(
text.starts_with(" city : Pootung / Shanghai / China\r\n"),
"城市行要把 市/地区/国家 用 ' / ' 串起来,实际开头:{:?}",
text.lines().next()
);
assert!(
text.contains("\r\n temp : 23C (feels 26C)\r\n"),
"温度行格式变了:{text:?}"
);
assert!(
text.contains("\r\n desc : Clear icon=☀\r\n"),
"图标要按 icon_for 的规则算出来:{text:?}"
);
assert!(
text.contains("\r\n humidity : 79% wind 5 km/h ENE uv 0 vis 10km\r\n"),
"湿度行格式变了:{text:?}"
);
assert!(
text.contains("\r\n obsTime : 07:00 PM\r\n"),
"观测时间行变了:{text:?}"
);
assert!(
text.contains("\r\n day1 : 2026-09-20 周日 23~29C Cloudy ⛅\r\n"),
"day 行格式或下标变了:{text:?}"
);
assert!(text.ends_with("\r\n"), "最后一行也要有 CRLF");
}
/// 输出写失败要能被登记下来 —— `wln!` 再也不能把错误丢进 `let _ =`。
///
/// 这一条守的是「半截输出 + 退出码 0」那种最难查的失败:调用脚本看到成功,
/// 拿到的却只有半份结果。
#[test]
fn a_failed_write_is_recorded_so_main_can_exit_nonzero() {
let mut broken = FakeConsole::broken_pipe();
wln_to!(&mut broken, "这一行写不出去");
assert_eq!(
exit_after_output_errors(ExitCode::SUCCESS),
ExitCode::from(EXIT_WRITE_FAILED_CODE),
"有过写失败时,命令自己的成功退出码必须被改成失败"
);
// 标志是「取走」语义:收尾一次之后就该干净,免得同进程里后续判断跟着失真
// (测试线程也一样——不取走的话下一条测试会读到上一条的状态)。
assert_eq!(
exit_after_output_errors(ExitCode::SUCCESS),
ExitCode::SUCCESS,
"收尾过后标志应当被清掉"
);
}
/// 正常写不能把标志置起来:否则每条诊断命令都会「成功却返回 2」。
#[test]
fn a_successful_write_leaves_the_failure_flag_clear() {
let mut console = FakeConsole::new();
send_lines(&mut console, "第一行\n第二行");
assert_eq!(console.text(), "第一行\r\n第二行\r\n");
assert_eq!(exit_after_output_errors(ExitCode::SUCCESS), ExitCode::SUCCESS);
}
/// 抽出去的整段输出(`--list-pages` / `--weather-parse` 详情)也要走同一条记账出口。
///
/// 这两段现在不再用 `wln!`,而是各自往一个 sink 里写 —— 没有 [`wln_to!`]
/// 这一层的话,它们写失败就会被静默吞掉(旧代码正是 `let _ = writeln!`),
/// 于是「管道断在 --list-pages 中途」又会变成「半份清单 + 退出码 0」。
#[test]
fn the_writer_only_paths_go_through_the_same_failure_accounting() {
let mut broken = FakeConsole::broken_pipe();
let pages = write_list_pages(&mut broken);
// 行数照样算得出来:判「有没有结果」不该被输出通道的故障污染。
assert_eq!(pages, nfarms_gui::PAGES.len());
assert_eq!(
exit_after_output_errors(ExitCode::SUCCESS),
ExitCode::from(EXIT_WRITE_FAILED_CODE),
"--list-pages 的写失败必须让命令以非 0 收场,而不是只写出去半份"
);
let mut broken = FakeConsole::broken_pipe();
dump_weather(&mut broken, &sample_report());
assert_eq!(
exit_after_output_errors(ExitCode::SUCCESS),
ExitCode::from(EXIT_WRITE_FAILED_CODE),
"--weather-parse 的详情块写失败同样要记账"
);
}
}