跳转至

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 的详情块写失败同样要记账"
        );
    }
}