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", "中文""#));
}