跳转至

crates\nfarms-app\build.rs

源码路径:crates\nfarms-app\build.rs · 552 行

//! 把图标和版本信息**嵌进 exe 文件**。
//!
//! 为什么需要这个:`assets/nfarms.ico` 只在**运行期**被读出来设成窗口图标
//! (见 `nfarms-gui/src/logo.rs`)。exe **文件本身**的图标是另一回事 ——
//! 那一块存在 PE 的**资源节**里,必须在链接时塞进去。没有它,资源管理器里
//! 看到的就是 Windows 的通用空白图标(用户报的就是这个)。
//!
//! 不引 `winresource` crate:需要的只是一次 `windres` 调用,
//! 而 w64devkit 里就带着它(和 `dev.ps1` 给 C 编译器用的是同一套工具)。
//!
//! 找不到资源编译器时**只是跳过**、不让构建失败 —— 少一个图标不该拦住
//! 整个构建,但要打一行 `cargo:warning` 说清楚为什么没图标。
//!
//! 这个文件里**没有**会跑的 `#[test]`(原因见 `self_check` 的说明),
//! 回归保护靠 `self_check()`:它每次构建都执行,断言不成立就直接构建失败。

use std::ffi::OsString;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;

// ---- VERSIONINFO 里那几个固定字段 ----
//
// 只有 `description` 能从 cargo 读到(`CARGO_PKG_DESCRIPTION`,见 `rc_text`);
// 其余几项 Cargo.toml 压根没有对应字段,只能写死 —— 但**只写在这一处**:
// 原先同一个产品名在三个字段里各写一遍,改名字必漏一个。
//
// `ProductName` 特意**不**用 `CARGO_PKG_NAME`:那是 crate 名(`nfarms-app`),
// 而属性页/安装包该显示的是产品名;换成 crate 名是可见的倒退,不是修复。
const COMPANY_NAME: &str = "NFarms";
const PRODUCT_NAME: &str = "NFarms HUB";
const INTERNAL_NAME: &str = "nfarms"; // = `[[bin]] name`
const ORIGINAL_FILENAME: &str = "nfarms.exe"; // = `[[bin]] name` + `.exe`
const COPYRIGHT: &str = "NFarms"; // Cargo.toml 没有 authors/license 字段可读

// VERSIONINFO 的语言 / 代码页:zh-CN + 1200(UTF-16LE,即 "Unicode")。
// 这两个值必须和 `StringFileInfo` 的**块名**一致,否则资源管理器的属性页会
// 整块空白 —— 所以块名由它们算出来(`string_file_info_block()`),不手写死。
const LANG_ID: u32 = 0x0804;
const CODEPAGE: u32 = 1200;

fn main() {
    self_check();

    // 只在为 Windows 目标构建时才有意义
    let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
    if target_os != "windows" {
        return;
    }

    let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
    // crates/nfarms-app -> 工作区根
    let root = manifest
        .parent()
        .and_then(|p| p.parent())
        .map(|p| p.to_path_buf())
        .unwrap_or_else(|| manifest.clone());
    let assets = root.join("assets");
    let ico = assets.join("nfarms.ico");

    // 重跑条件**要在任何 return 之前声明**。
    //
    // 一旦发出过 `rerun-if-*`,cargo 就**不再**默认扫描整个包目录,而 `assets/`
    // 在包目录(`crates/nfarms-app`)之外 —— 不声明它,"先编译(没图标) →
    // 再补上图标 → 重新 build" 这段脚本未必会重跑,图标就一直缺席。
    // 目录那条管"文件出现/消失",文件那条管"内容改动"(目录 mtime 对内容改动
    // 不敏感),所以两条都要。
    let ico_present = ico.is_file();
    for d in rerun_directives(&assets, ico_present.then_some(ico.as_path())) {
        println!("{d}");
    }
    if !ico_present {
        println!("cargo:warning=没找到 {},exe 不会带图标", ico.display());
        return;
    }
    // 坏图标先在这里拦下:否则 windres 失败后会落到下面那句"候选都试过了"的
    // 兜底措辞里,归因指错方向(真实原因是图标文件本身)。
    if let Some(err) = ico_error(&ico) {
        println!(
            "cargo:warning={} 不是能用的 .ico({err}),exe 不会带图标",
            ico.display()
        );
        return;
    }

    let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap_or_default());

    // 版本信息:让 exe 的属性页有内容,安装包也能读到
    let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".into());
    // 描述取自 `crates/nfarms-app/Cargo.toml` 的 `description`(不是写死的),
    // 拿不到就退回产品名。
    let description = std::env::var("CARGO_PKG_DESCRIPTION")
        .ok()
        .filter(|s| !s.trim().is_empty())
        .unwrap_or_else(|| PRODUCT_NAME.to_string());
    let rc = out_dir.join("nfarms.rc");
    // 注意:`fs::write` 写出去的是 **UTF-8** 字节,这决定了 `windres` 要怎么调
    // (见下面 `needs_utf8_codepage` / `--codepage=65001`)。
    let text = rc_text(&ico, four_part(&version), &version, &description);
    if std::fs::write(&rc, &text).is_err() {
        println!("cargo:warning=写 .rc 失败,exe 不会带图标");
        return;
    }

    // **只求值一次**:先前这里调了两遍,等于把整条 PATH 扫两遍、
    // 对每个候选做两遍 `is_file()`。判空与循环共用这一份。
    let candidates = windres_candidates(&BuildEnv::from_process());
    if candidates.is_empty() {
        println!("cargo:warning=一个资源编译器候选都没找到,exe 不会带图标");
        return;
    }

    let obj = out_dir.join("nfarms-res.o");
    // **逐个候选试,谁真能跑就用谁。**
    //
    // 这里踩过一个坑:PATH 上第一个 `windres` 是 rustup 的 self-contained
    // 包装器,它内部会去调 `gcc` —— 而本项目**故意不把 gcc 放进 PATH**
    // (会让 rustc 选错链接器)。于是它必然报
    // `'gcc' is not recognized as an internal or external command`。
    //
    // 所以不能「找到就用」,得「跑通才算」。用 `output()` 而不是 `status()`:
    // 失败时要能看到 windres 自己说的话。
    //
    // 每个候选最多试两次,因为 `.rc` 是 UTF-8(见 `needs_utf8_codepage`)。
    let utf8 = needs_utf8_codepage(&text);
    let mut attempts: Vec<&[&str]> = Vec::new();
    if utf8 {
        attempts.push(&["--codepage=65001"]);
    }
    attempts.push(&[]);
    let mut last_err = String::new();
    for windres in &candidates {
        for extra in &attempts {
            match run_windres(windres, extra, &rc, &obj) {
                Ok(()) => {
                    if utf8 && extra.is_empty() {
                        // 退路:老 binutils / 别的 windres 实现可能不认 `--codepage`。
                        // **图标比版本信息的文字重要**:宁可描述串乱码,也别整个
                        // exe 没图标(和本文件开头"少一个图标不该拦住构建"同一个取舍)。
                        println!(
                            "cargo:warning={} 不认 --codepage=65001,版本信息里的非 ASCII 文字可能乱码",
                            windres.display()
                        );
                    }
                    println!("{}", link_arg_directive(&obj));
                    return;
                }
                Err(e) => last_err = e,
            }
        }
    }
    println!(
        "cargo:warning=候选资源编译器都试过了,但没能把 .rc(图标 + 版本信息)编出来,exe 不会带图标。\n\
         最近一次的失败:{last_err}\n\
         失败信息若说读不了 {}(.rc 里只有这一句依赖外部文件),那是图标文件本身的问题;\n\
         否则装 w64devkit,或把可用的 windres 路径写进 WINDRES 环境变量。",
        ico.display()
    );
}

/// 让 cargo 在**这些输入**变化时重跑本脚本。
///
/// * `assets`(目录):管图标"文件出现/消失"。只声明过 `rerun-if-changed` 之后,
///   cargo 不再默认扫描包目录,而 `assets/` 在包目录之外 —— 不声明它,
///   "补上图标再 build"就不会重跑。
/// * `ico`(文件):管"内容改动"。目录的 mtime 对目录内文件的内容改动不敏感,
///   所以这一条不能省;图标不存在时**故意不写**它,因为不存在的路径会让 cargo
///   每次构建都判定为"变了",白重编译整个包。
/// * `WINDRES` / `CC` / `NFARMS_W64DEVKIT`:本脚本的输入。不声明它们,按脚本自己
///   的提示设了 `WINDRES` 却不会重跑(得手动 `cargo clean -p nfarms-app`),
///   等于"照提示做了却没效果"。
/// * 没有 `build.rs` 自己:cargo 本来就会在脚本重新编译后重跑它,声明是多余的。
fn rerun_directives(assets: &Path, ico: Option<&Path>) -> Vec<String> {
    let mut out = vec![
        format!("cargo:rerun-if-changed={}", assets.display()),
        "cargo:rerun-if-env-changed=WINDRES".to_string(),
        "cargo:rerun-if-env-changed=CC".to_string(),
        "cargo:rerun-if-env-changed=NFARMS_W64DEVKIT".to_string(),
    ];
    if let Some(ico) = ico {
        out.push(format!("cargo:rerun-if-changed={}", ico.display()));
    }
    out
}

/// 链接参数指令。
///
/// 用 `rustc-link-arg-bins` 而不是 `rustc-link-arg`:后者还作用到 tests/examples
/// 等目标,`cargo test -p nfarms-app` 编出的测试 exe 会白链一份图标资源
/// (本包没有 examples,但没必要为此扩大影响面)。资源只对 `[[bin]] nfarms` 有意义。
fn link_arg_directive(obj: &Path) -> String {
    format!("cargo:rustc-link-arg-bins={}", obj.display())
}

/// 资源编译器的候选,**按「多半能用」排序**,且**还没做存在性过滤**
/// (过滤交给 `usable_candidates`,这样候选表的组成可以被自检直接断言)。
///
/// 已知能用的排前面:项目里 C 编译器用的 w64devkit 那份,它不依赖 gcc 在 PATH 上。
/// rustup 的 `self-contained` 那份排在最后 —— 它是个包装器,要 `gcc` 在 PATH 上
/// 才能工作,而本项目的前提就是 PATH 上**没有** gcc。
fn raw_candidates(env: &BuildEnv) -> Vec<PathBuf> {
    let mut out: Vec<PathBuf> = Vec::new();

    // 1) 显式指定
    if let Some(p) = &env.windres {
        out.push(PathBuf::from(p));
    }
    // 2) `NFARMS_W64DEVKIT`:按 `dev.ps1` 的约定,这个变量指的是 **bin 目录**
    //    (`dev.ps1:14-15` 直接拿它拼 `x86_64-w64-mingw32-gcc.exe`)。
    //    读它的理由很具体:dev.ps1 写着「想换位置就设 NFARMS_W64DEVKIT」,
    //    而这里原本只认写死的旧路径 —— 搬过 w64devkit 的人照做后仍是没图标,
    //    提示等于失效。所以它排在写死的默认路径**前面**。
    if let Some(w64) = &env.w64devkit {
        out.push(Path::new(w64).join("windres.exe"));
    }
    // 3) w64devkit 的默认位置。本机实测第一份存在(安装根 + `w64devkit\bin`),
    //    第二份不存在、永远会被过滤掉,留着只是为了容忍另一种安装布局。
    for cand in [
        r"C:\Cy\Install\Services\w64devkit\w64devkit\bin\windres.exe",
        r"C:\Cy\Install\Services\w64devkit\bin\windres.exe",
    ] {
        out.push(PathBuf::from(cand));
    }
    // 4) 和 CC 放一起(dev.ps1 把 CC 指到 w64devkit 的 gcc,windres 与它同目录)
    if let Some(cc) = &env.cc
        && let Some(dir) = Path::new(cc).parent()
    {
        out.push(dir.join("windres.exe"));
    }
    // 5) PATH 上那些**不是** rustup self-contained 包装器的
    if let Some(path) = &env.path {
        for dir in std::env::split_paths(path) {
            if dir.to_string_lossy().contains("self-contained") {
                continue; // 见函数说明:它要 gcc,注定失败
            }
            for ext in [".exe", ""] {
                out.push(dir.join(format!("windres{ext}")));
            }
        }
    }
    // 6) LLVM 的:**必须是 `llvm-windres`**,那是 windres 的兼容前端
    //    (认 `--input` / `--output` / `--output-format`)。
    //    原本这里找的是 `llvm-rc` —— 它解析的是另一套选项,拿 windres 的参数
    //    喂它必然失败,这个候选等于永远白排在表里。
    if let Some(path) = &env.path {
        for dir in std::env::split_paths(path) {
            for ext in [".exe", ""] {
                out.push(dir.join(format!("llvm-windres{ext}")));
            }
        }
    }
    out
}

/// 把候选表落成「真能用的」:丢掉不存在的,并按原顺序去重。
///
/// `exists` 做成参数,是为了让 `self_check` 能塞一个假的存在性判断进来测这段
/// 逻辑 —— 否则它只能靠真实文件系统碰运气。
fn usable_candidates(raw: Vec<PathBuf>, exists: impl Fn(&Path) -> bool) -> Vec<PathBuf> {
    let mut out: Vec<PathBuf> = Vec::new();
    for p in raw {
        if exists(&p) && !out.contains(&p) {
            out.push(p);
        }
    }
    out
}

fn windres_candidates(env: &BuildEnv) -> Vec<PathBuf> {
    usable_candidates(raw_candidates(env), |p| p.is_file())
}

/// 本脚本读的环境变量,收在一处 —— 自检可以塞假值进来测候选表的组成,
/// 不用去动真实环境。
struct BuildEnv {
    windres: Option<String>,
    w64devkit: Option<String>,
    cc: Option<String>,
    path: Option<OsString>,
}

impl BuildEnv {
    fn from_process() -> Self {
        Self {
            windres: std::env::var("WINDRES").ok(),
            w64devkit: std::env::var("NFARMS_W64DEVKIT").ok(),
            cc: std::env::var("CC").ok(),
            path: std::env::var_os("PATH"),
        }
    }
}

/// 调一次 `windres`。**返回**错误而不是自己打警告:调用方要用它决定要不要换退路
/// (比如 `--codepage` 不被接受时,去掉它再试一次)。
fn run_windres(windres: &Path, extra: &[&str], rc: &Path, obj: &Path) -> Result<(), String> {
    match Command::new(windres)
        .arg("--input")
        .arg(rc)
        .arg("--output")
        .arg(obj)
        .arg("--output-format=coff")
        .args(extra)
        .output()
    {
        Ok(o) if o.status.success() => Ok(()),
        Ok(o) => Err(format!(
            "{} -> {}: {}",
            windres.display(),
            o.status,
            String::from_utf8_lossy(&o.stderr).trim()
        )),
        Err(e) => Err(format!("{} -> 起不来: {e}", windres.display())),
    }
}

/// `.rc` 是 UTF-8,要不要告诉 `windres` 输入代码页是 65001。
///
/// 为什么必须问这一句:描述现在来自 `Cargo.toml`(`crates/nfarms-app/Cargo.toml`
/// 里是中文),而 `windres` 默认按**本地代码页**解 `.rc`。实测同一份 `.rc`:
/// 不带 `--codepage=65001` 时 `FileDescription` 落进资源节的是
/// `\x6d93\x8364\x25bc...` 一串鬼画符,带上就是 `\x4e3b\x7a0b\x5e8f`(主程序)。
/// 全是 ASCII 时不加这个参数,是为了让不认它的 windres 实现也能直接编过。
fn needs_utf8_codepage(rc_text: &str) -> bool {
    !rc_text.is_ascii()
}

/// `.rc` 会被 C 预处理器过一遍,所以值里的 `\` 和 `"` 会把整条 VALUE 拆坏。
/// 这些值以前是我们写死的,现在 `description` 来自 `Cargo.toml` —— 输入不再
/// 由本文件保证,必须转义。行尾的 `\0` 是 .rc 的字符串结束符,保持原样。
fn rc_escape(v: &str) -> String {
    v.replace('\\', "\\\\").replace('"', "\\\"")
}

/// `StringFileInfo` 的块名 = 语言 + 代码页,各四位十六进制(小写)。
/// 由 `LANG_ID` / `CODEPAGE` 算出来,免得和 `Translation` 那一行各写一份、改一处漏一处。
fn string_file_info_block() -> String {
    format!("{LANG_ID:04x}{CODEPAGE:04x}")
}

fn rc_text(ico: &Path, four: (u32, u32, u32, u32), version: &str, description: &str) -> String {
    let (v1, v2, v3, v4) = four;
    // 路径里的反斜杠在 .rc 里要写成双反斜杠或正斜杠 —— 用正斜杠最省事
    let ico_for_rc = ico.display().to_string().replace('\\', "/");
    let version = rc_escape(version);
    let description = rc_escape(description);
    format!(
        r#"#include <winver.h>
1 ICON "{ico_for_rc}"

VS_VERSION_INFO VERSIONINFO
FILEVERSION {v1},{v2},{v3},{v4}
PRODUCTVERSION {v1},{v2},{v3},{v4}
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS 0x40004L
FILETYPE 0x1L
FILESUBTYPE 0x0L
BEGIN
  BLOCK "StringFileInfo"
  BEGIN
    BLOCK "{block}"
    BEGIN
      VALUE "CompanyName",      "{COMPANY_NAME}\0"
      VALUE "FileDescription",  "{description}\0"
      VALUE "FileVersion",      "{version}\0"
      VALUE "InternalName",     "{INTERNAL_NAME}\0"
      VALUE "OriginalFilename", "{ORIGINAL_FILENAME}\0"
      VALUE "ProductName",      "{PRODUCT_NAME}\0"
      VALUE "ProductVersion",   "{version}\0"
      VALUE "LegalCopyright",   "{COPYRIGHT}\0"
    END
  END
  BLOCK "VarFileInfo"
  BEGIN
    VALUE "Translation", {LANG_ID:#x}, {CODEPAGE}
  END
END
"#,
        block = string_file_info_block(),
    )
}

/// 只查 `.ico` 最外层那 6 个字节:ICONDIR = reserved(必须 0) + type(必须 1)
/// + count(至少 1)。
///
/// 为什么值得查:坏图标会让 `windres` 失败,而失败后落到的是"候选资源编译器
/// 都试过了"这类兜底措辞,归因指错方向;先查一步,警告就能直说是图标的问题。
/// 只查头、不做逐像素校验:再深就得引图像库,为一个图标不值。
fn ico_header_error(head: &[u8]) -> Option<&'static str> {
    if head.len() < 6 {
        return Some("文件太短,连 ICONDIR 头都不完整");
    }
    if head[0] != 0 || head[1] != 0 {
        return Some("ICONDIR.reserved 不是 0");
    }
    if head[2] != 1 || head[3] != 0 {
        return Some("ICONDIR.type 不是 1(1 = 图标)");
    }
    if u16::from_le_bytes([head[4], head[5]]) == 0 {
        return Some("ICONDIR.count 是 0,里面一张图都没有");
    }
    None
}

fn ico_error(path: &Path) -> Option<String> {
    // 只读头 6 个字节:图标本身可以很大,没必要整个读进来
    let mut head = [0u8; 6];
    let n = match std::fs::File::open(path).and_then(|mut f| f.read(&mut head)) {
        Ok(n) => n,
        Err(e) => return Some(format!("读不出来: {e}")),
    };
    ico_header_error(&head[..n]).map(str::to_string)
}

/// `1.2.3` → `(1,2,3,0)`;`1.2.3.4` → `(1,2,3,4)`。VERSIONINFO 一定要四段。
fn four_part(v: &str) -> (u32, u32, u32, u32) {
    let mut it = v.split('.').map(|s| {
        s.chars()
            .take_while(|c| c.is_ascii_digit())
            .collect::<String>()
            .parse::<u32>()
            .unwrap_or(0)
    });
    (
        it.next().unwrap_or(0),
        it.next().unwrap_or(0),
        it.next().unwrap_or(0),
        it.next().unwrap_or(0),
    )
}

/// 每次构建都跑的自检 —— 相当于这个文件的回归测试。
///
/// 为什么不是 `#[test]`:cargo 编译 `build.rs` 用的是普通 bin 模式,**不是**
/// `--test` 模式,写在 `build.rs` 里的 `#[cfg(test)] mod tests` 在 `cargo test`
/// 下一条都不会执行(实测 "running 0 tests")—— 那是「加了测试却没人调用」,
/// 比没有更糟。而 build 脚本要进 `tests/` 单测又必须新增文件,在"只改本文件"
/// 的约束下做不到。于是把**可判定**的部分收在这里:断言不成立 = 构建直接失败,
/// 同样能拦住复发。
///
/// 只放纯逻辑(候选表组成、cargo 指令文本、`.rc` 文本、解析函数),不碰文件系统、
/// 不读环境变量 —— 否则每次构建的结果会随机器漂移。
fn self_check() {
    // ---- four_part:VERSIONINFO 一定要四段 ----
    assert_eq!(four_part("1.2.3"), (1, 2, 3, 0));
    assert_eq!(four_part("1.2.3.4"), (1, 2, 3, 4));
    assert_eq!(four_part("1.2"), (1, 2, 0, 0));
    assert_eq!(four_part("1.2.3-beta"), (1, 2, 3, 0)); // 预发布后缀只取数字首段
    assert_eq!(four_part("1.2.3.4.5"), (1, 2, 3, 4)); // 多余的段丢掉
    assert_eq!(four_part(""), (0, 0, 0, 0));

    // ---- .ico 头校验:坏图标要能被认出来,而不是赖到"找不到编译器"上 ----
    assert_eq!(ico_header_error(&[0, 0, 1, 0, 1, 0]), None);
    assert!(ico_header_error(&[0, 0, 1]).is_some()); // 头不完整
    assert!(ico_header_error(&[0xde, 0xad, 0xbe, 0xef, 1, 0]).is_some()); // reserved != 0
    assert!(ico_header_error(&[0, 0, 2, 0, 1, 0]).is_some()); // type != 1
    assert!(ico_header_error(&[0, 0, 1, 0, 0, 0]).is_some()); // 一张图都没有

    // ---- 候选表:顺序、来源,以及"别再把 llvm-rc 当 windres 用" ----
    let env = BuildEnv {
        windres: Some(r"C:\win\windres.exe".into()),
        w64devkit: Some(r"D:\w64\bin".into()),
        cc: Some(r"C:\cc\gcc.exe".into()),
        path: Some(OsString::from(r"C:\tools;C:\rt\x\self-contained;C:\llvm\bin")),
    };
    let raw = raw_candidates(&env);
    let has = |p: &str| raw.iter().any(|c| c == Path::new(p));
    assert_eq!(
        raw.first().map(PathBuf::as_path),
        Some(Path::new(r"C:\win\windres.exe"))
    );
    // NFARMS_W64DEVKIT 指的是 bin 目录(dev.ps1 的约定),且要排在写死的默认路径之前
    assert!(has(r"D:\w64\bin\windres.exe"));
    let nfarms = raw
        .iter()
        .position(|c| c == Path::new(r"D:\w64\bin\windres.exe"))
        .unwrap();
    let hard = raw
        .iter()
        .position(|c| c.to_string_lossy().contains(r"w64devkit\w64devkit\bin"))
        .unwrap();
    assert!(nfarms < hard);
    // 和 CC 同目录
    assert!(has(r"C:\cc\windres.exe"));
    // PATH:非 self-contained 的收,self-contained 的丢(它要 gcc,注定失败)
    assert!(has(r"C:\tools\windres.exe"));
    assert!(!has(r"C:\rt\x\self-contained\windres.exe"));
    // LLVM:找 llvm-windres,不找 llvm-rc
    assert!(has(r"C:\llvm\bin\llvm-windres.exe"));
    assert!(!raw.iter().any(|c| c.to_string_lossy().contains("llvm-rc")));

    // ---- 可用候选:丢掉不存在的 + 按原顺序去重 ----
    let dup = vec![PathBuf::from("a"), PathBuf::from("b"), PathBuf::from("a")];
    assert_eq!(
        usable_candidates(dup.clone(), |p| p == Path::new("a") || p == Path::new("b")),
        vec![PathBuf::from("a"), PathBuf::from("b")]
    );
    assert!(usable_candidates(dup, |_| false).is_empty());

    // ---- cargo 指令 ----
    let d = rerun_directives(Path::new(r"R\assets"), None);
    assert!(d.iter().any(|s| s == r"cargo:rerun-if-changed=R\assets"));
    // 图标不在时不能声明它:cargo 对不存在的路径每次都判"变了",会白重编整个包
    assert!(!d.iter().any(|s| s.contains("nfarms.ico")));
    // 这三个都是脚本的输入,不声明的话"照提示设了变量"不会重跑
    assert!(d.iter().any(|s| s == "cargo:rerun-if-env-changed=WINDRES"));
    assert!(d.iter().any(|s| s == "cargo:rerun-if-env-changed=CC"));
    assert!(d.iter().any(|s| s == "cargo:rerun-if-env-changed=NFARMS_W64DEVKIT"));
    // build.rs 自己不用声明(cargo 会随脚本重新编译自动重跑)
    assert!(!d.iter().any(|s| s.contains("build.rs")));
    let d = rerun_directives(
        Path::new(r"R\assets"),
        Some(Path::new(r"R\assets\nfarms.ico")),
    );
    assert!(d
        .iter()
        .any(|s| s == r"cargo:rerun-if-changed=R\assets\nfarms.ico"));
    // 资源只给 bins:测试 exe 不该白链一份(裸的 rustc-link-arg 会带上 tests)
    assert_eq!(
        link_arg_directive(Path::new(r"O\nfarms-res.o")),
        r"cargo:rustc-link-arg-bins=O\nfarms-res.o"
    );

    // ---- .rc 文本 ----
    assert_eq!(rc_escape(r#"a"b\c"#), r#"a\"b\\c"#);
    let text = rc_text(
        Path::new(r"R\assets\nfarms.ico"),
        (1, 2, 3, 4),
        "1.2.3",
        PRODUCT_NAME,
    );
    assert!(text.contains(r#"1 ICON "R/assets/nfarms.ico""#));
    assert!(text.contains("FILEVERSION 1,2,3,4"));
    assert!(text.contains("PRODUCTVERSION 1,2,3,4"));
    // 块名必须与 Translation 一致(不一致 → 属性页整块空白)
    assert_eq!(string_file_info_block(), "080404b0");
    assert!(text.contains(r#"BLOCK "080404b0""#));
    assert!(text.contains(r#"VALUE "Translation", 0x804, 1200"#));
    assert!(text.contains(r#"VALUE "FileVersion",      "1.2.3\0""#));
    // description 现在来自 Cargo.toml,值里的引号/反斜杠不再由我们保证,必须转义
    let text = rc_text(
        Path::new("x.ico"),
        (1, 0, 0, 0),
        "1.0.0",
        "say \"hi\"\\ok 中文",
    );
    assert!(text.contains(r#"VALUE "FileDescription",  "say \"hi\"\\ok 中文\0""#));
    // 非 ASCII 必须让 windres 按 UTF-8 读输入(否则属性页里是乱码,见函数说明)
    assert!(!needs_utf8_codepage(r#"VALUE "x", "abc""#));
    assert!(needs_utf8_codepage(r#"VALUE "x", "中文""#));
}