crates\nfarms-core\src\settings.rs¶
源码路径:
crates\nfarms-core\src\settings.rs· 809 行
//! NFarms Tools 设置。对应 C# `src\Settings.cs`。
//!
//! 默认路径 `<程序根>\settings.json`;老版本在
//! `%APPDATA%\NFarmsTools\settings.json`,首次运行迁移过来。
//!
//! JSON 里字段名是 **PascalCase**(与 C# 版逐字节兼容),
//! Rust 侧结构体用 snake_case + `#[serde(rename_all = "PascalCase")]`。
//!
//! **字段声明顺序 = JSON 键顺序**。serde 按声明顺序序列化,C# 反射 `GetFields()`
//! 也是声明顺序 —— 顺序对齐了,`save` 出来的文件才能和原版逐字节一致。
//!
//! **往这个结构体里加「C# 原版没有的字段」时,必须挂 `skip_serializing_if`**
//! (判据 = 等于默认值),见下面数字雨那四个字段。否则
//! `reserializes_a_csharp_written_file_byte_for_byte` 立刻变红 ——
//! 那份 fixture 是 C# 原版写出来的,不含新键。若某个新字段**必须**进 JSON
//! (默认值判据不适用),那就只剩一条路:拿 C# 原版重写
//! `tests/data/settings.csharp-written.json`。这一步是人工动作,
//! `cargo test` 不会自愈,别改成"断言里删掉这个键"糊过去。
//!
//! **已知代价:不认识的键会在下一次 `save` 时永久消失。** 结构体没有
//! `deny_unknown_fields`,serde 默认忽略未知键(与 C# 的 `JavaScriptSerializer`
//! 一致),而 `save` 是把结构体整份序列化写回 —— 于是「新版写出的键,被旧版
//! 打开一次」就没了。没有加 `#[serde(flatten)] extra` 兜底:那会让结构体改走
//! map 序列化路径,逐字节护栏里的顺序和数字往返都要重新论证一遍,而收益只是
//! 这种跨版本降级场景。真要兜底,得连 `save` 的合并策略一起设计,不能只加一个
//! 字段了事 —— 这条代价已经写进 `unknown_fields_are_ignored` 当规格锁住。
//!
//! **哪些东西不该加进来**:本程序自己的**运行配置**一律放 `store.json`,由
//! `crate::store` 的 `*_port` 存取器管 —— 统一迁移后的本地服务端口就是这一类
//! (llama.cpp 9094 / sd.cpp 9095 / VictoriaMetrics 9091 / Loki 9092 /
//! Grafana 9093,见 `store.rs` 顶部那个「端口」小节)。这个结构体里除了数字雨
//! 那一组(挂了 `skip_serializing_if`),只放 **C# 原版也有**的设置 ——
//! 端口这类本程序自己的字段没有理由去付上面那个"重造 fixture"的代价,
//! 而把它加进来只会让逐字节护栏变红。
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::log::Log;
use crate::paths::Paths;
use crate::strip_bom;
/// 数字雨(`Slack off › 二进制雨`)的默认值。
///
/// 抽成常量是因为这几个数**同时**决定两件事:用户没调过时界面长什么样,
/// 以及「哪些值算没调过、不必写进 settings.json」(判据见下面那组
/// `is_default_rain_*`)。两处各写一份字面量迟早会漂。
///
/// 数值与最早写死它们的地方(`nfarms-gui/src/rain.rs` 的 `Rain::default()`)
/// 保持一致 —— 界面侧接上这个字段之后,两边读到的默认值应当是同一个。
pub const DEFAULT_RAIN_SPEED: f32 = 18.0;
pub const DEFAULT_RAIN_FONT_SIZE: f32 = 16.0;
pub const DEFAULT_RAIN_PALETTE: i32 = 0;
pub const DEFAULT_RAIN_KATAKANA: bool = false;
/// `skip_serializing_if` 判据:值与默认相同就不写进 JSON。
///
/// 为什么是「等于默认」而不是 `Option::is_none`:`Settings` 的字段默认值本来
/// 就是程序的默认值(见 `Default` impl),再加一层 `Option` 只会让读的一侧
/// 到处 `unwrap_or`。代价是「存了一个恰好等于默认的值」与「从没存过」不可区分
/// —— 而这两者本来就该表现成一样。
fn is_default_rain_speed(v: &f32) -> bool {
*v == DEFAULT_RAIN_SPEED
}
fn is_default_rain_font_size(v: &f32) -> bool {
*v == DEFAULT_RAIN_FONT_SIZE
}
fn is_default_rain_palette(v: &i32) -> bool {
*v == DEFAULT_RAIN_PALETTE
}
fn is_default_rain_katakana(v: &bool) -> bool {
*v == DEFAULT_RAIN_KATAKANA
}
/// **没有 `Eq`**:`rain_speed` / `rain_font_size` 是 `f32`,而浮点不满足全等
/// (先例见 `kb.rs` 的 `Doc`)。`PartialEq` 够用 —— 这个类型只用来比较和存取,
/// 不做 map/set 的键。
///
/// **其中若干字段在 Rust 侧还没有读取方**(全仓检索确认):`music_volume`、
/// `dsh_timeout_seconds`、`printer_port`(GUI 用的是它自己那份 `u16`,见
/// `nfarms-gui/src/lib.rs`)、`printer_name` / `receipt_printer` / `label_printer`
/// / `print_server_url` / `print_device_id` / `print_poll_seconds`、六个 `winget_*`、
/// `browser_last_url` / `browser_engine_mode` / `servo_path`、八个 `pet_*`。
/// 它们是「功能还没移植」的占位,不是废字段:原版 C# 侧每个都在用
/// (`cs-src/MainForm.cs` / `Pet.cs` / `Tools.cs`),所以**不能因为"没人读"就删** ——
/// 删了逐字节护栏会红,将来接页面时还要照着 JSON 格式重新对一遍。
/// (末尾那四个数字雨字段同样只有存取、还没有消费方 —— 界面的接线在
/// `nfarms-gui/src/rain.rs` 那边,不属于"未移植"这一类。)
///
/// **字段注释里的范围(`0..100`、`60..150`、`0=自动 1=…`)是给界面用的,
/// `load` 既不校验也不夹取。** 原版同样不校验,夹取发生在 UI 侧
/// (`cs-src/MainForm.cs` 的 `Math.Max(0, Math.Min(100, ...))`)。在这里 clamp
/// 会把用户手写的越界值静默改掉、再 `save` 就永久变了样;要防越界,应该在
/// **读取那一侧**夹取,而不是在读写通道上做。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase", default)]
pub struct Settings {
/// 自定义背景图片路径
pub background_image: String,
/// 天气地点(默认上海,数据源 wttr.in)
pub weather_location: String,
/// 音乐音量 0..100
pub music_volume: i32,
/// DSH Web 端口
pub dsh_port: i32,
/// DSH 启动等待超时
pub dsh_timeout_seconds: i32,
/// nfos-printer 本地监听端口
///
/// 类型是 `String` 而不是 `u16`:原版 JSON 里就是字符串,改了类型就和
/// C# 写出的文件对不上。默认 `19100`,与原版 C#(`cs-src/Settings.cs:21`)
/// 以及 nfos-printer 自己的默认端口(`cs-src/PrintServer.cs:26`)一致
/// —— 这个字段就是端口的真值,界面侧要接的是它。
pub printer_port: String,
/// 打印机名,空 = 系统默认打印机
pub printer_name: String,
/// 小票打印机名(角色)
pub receipt_printer: String,
/// 标签打印机名(角色)
pub label_printer: String,
/// nfarmsd 打印任务服务器(可选)
pub print_server_url: String,
/// 本机设备 ID(领取服务器任务用)
pub print_device_id: String,
/// 服务器轮询间隔
pub print_poll_seconds: i32,
/// nfztna.exe 路径
pub ztna_path: String,
pub ztna_target: String,
pub ztna_username: String,
pub ztna_identity: String,
pub winget_jdk: String,
pub winget_dotnet: String,
pub winget_python: String,
pub winget_cmake: String,
pub winget_go: String,
pub winget_rustup: String,
// ---- 浏览器 ----
pub browser_home: String,
pub browser_last_url: String,
/// 0=自动 1=Chromium 2=兼容(IE) 3=Servo
pub browser_engine_mode: i32,
/// servoshell.exe(实验性,外部窗口)
pub servo_path: String,
// ---- 桌宠(BongoCat 风格)----
/// 随程序启动
pub pet_enabled: bool,
/// 大小百分比 60..150
pub pet_scale: i32,
/// 不透明度 40..100
pub pet_opacity: i32,
/// 始终置顶
pub pet_top_most: bool,
/// 全屏应用时自动躲起来
pub pet_hide_fullscreen: bool,
/// 0=键鼠 1=只键盘 2=只鼠标 3=不反应
pub pet_triggers: i32,
/// 位置(-1 = 默认右下角)
pub pet_x: i32,
pub pet_y: i32,
// ---- 数字雨(Slack off › 二进制雨)----
//
// 这四个是**本结构体里唯一一组 C# 原版没有的字段**(数字雨是 Rust 版独有的页),
// 所以它们逐个挂了 `skip_serializing_if`:值等于默认时根本不进 JSON,
// `reserializes_a_csharp_written_file_byte_for_byte` 才能继续成立。
// 以后再加原版没有的字段,照这个模式办 —— 否则那条护栏会直接红。
/// 下落速度(行/秒),越大越快
#[serde(skip_serializing_if = "is_default_rain_speed")]
pub rain_speed: f32,
/// 字号(pt)
#[serde(skip_serializing_if = "is_default_rain_font_size")]
pub rain_font_size: f32,
/// 配色下标(见 `nfarms-gui` 的 `rain::PALETTES`,越界按第 0 套处理)
#[serde(skip_serializing_if = "is_default_rain_palette")]
pub rain_palette: i32,
/// 字符集是否掺半角片假名
#[serde(skip_serializing_if = "is_default_rain_katakana")]
pub rain_katakana: bool,
}
impl Default for Settings {
/// 与 C# 版的字段初始化器逐项对应。
///
/// serde 的 `#[serde(default)]` 让「JSON 里缺字段」时落到这里 ——
/// 语义和 C# 的 `JavaScriptSerializer`(先构造默认对象、再覆盖出现过的字段)一致。
///
/// 唯一的例外是末尾那四个数字雨字段:C# 版没有它们,默认值也就不是
/// 「原版初始化器」,而是 `rain.rs` 里原本写死的数(见 `DEFAULT_RAIN_*`)。
fn default() -> Self {
Self {
background_image: String::new(),
weather_location: "shanghai".to_string(),
music_volume: 80,
dsh_port: 7909,
dsh_timeout_seconds: 120,
printer_port: "19100".to_string(),
printer_name: String::new(),
receipt_printer: String::new(),
label_printer: String::new(),
print_server_url: String::new(),
print_device_id: String::new(),
print_poll_seconds: 5,
ztna_path: String::new(),
ztna_target: "console.nfarms.cn:9992".to_string(),
ztna_username: String::new(),
ztna_identity: String::new(),
winget_jdk: "Microsoft.OpenJDK.21".to_string(),
winget_dotnet: "Microsoft.DotNet.SDK.8".to_string(),
winget_python: "Python.Python.3.12".to_string(),
winget_cmake: "Kitware.CMake".to_string(),
winget_go: "GoLang.Go".to_string(),
winget_rustup: "Rustlang.Rustup".to_string(),
browser_home: "https://www.bing.com".to_string(),
browser_last_url: String::new(),
browser_engine_mode: 0,
servo_path: String::new(),
pet_enabled: false,
pet_scale: 100,
pet_opacity: 100,
pet_top_most: true,
pet_hide_fullscreen: true,
pet_triggers: 0,
pet_x: -1,
pet_y: -1,
// C# 原版没有这几个(数字雨是 Rust 版独有的页),数值取
// `DEFAULT_RAIN_*`,即 `rain.rs` 里原本写死的那组。
rain_speed: DEFAULT_RAIN_SPEED,
rain_font_size: DEFAULT_RAIN_FONT_SIZE,
rain_palette: DEFAULT_RAIN_PALETTE,
rain_katakana: DEFAULT_RAIN_KATAKANA,
}
}
}
/// 设置文件默认路径 = `<程序根>\settings.json`。
///
/// 走 `paths.data()` 而不是自己拼 `join`: `src\` 目录已经取消
/// (`paths.src()` 恒等于程序根),`data()` 是那层约定的唯一出口 ——
/// 以后再挪数据文件只改 `paths.rs` 一处,这里的注释也不必跟着变。
pub fn default_path(paths: &Paths) -> PathBuf {
paths.data("settings.json")
}
/// 老位置 `%APPDATA%\NFarmsTools\settings.json` 的迁移,只在目标不存在时做一次。
///
/// C# 把这段塞在 `Settings.DefaultPath` 这个属性 getter 里 ——
/// 「读一次路径」就顺带产生文件系统副作用,调用方完全看不出来。
/// 这里拆成独立函数,迁移时机由调用方显式决定。
///
/// 返回迁移后的 (旧路径, 新路径);没做事就返回 `None`。
///
/// **失败必须留痕**:`create_dir_all` 失败和复制失败都会写一行日志再返回 `None`。
/// 原来这两条路径分别是 `let _ =` 与 `.ok()?`,调用方(`main.rs` 用 `let _ =`
/// 接返回值)看不出区别,紧接着的 `load` 读到的却是一份全新默认值 ——
/// 用户以为设置丢了,日志里却一行线索都没有。日志是唯一的现场。
pub fn migrate_legacy(paths: &Paths, log: &Log) -> Option<(PathBuf, PathBuf)> {
let old = PathBuf::from(std::env::var_os("APPDATA")?)
.join("NFarmsTools")
.join("settings.json");
migrate_legacy_from(&old, &default_path(paths), log)
}
/// `migrate_legacy` 的实际动作:**两个路径都由参数给**。
///
/// 拆出来的理由是可测:`migrate_legacy` 的旧路径写死要读 `APPDATA` 环境变量,
/// 而本工作区是 `edition = "2024"`,`std::env::set_var` 是 `unsafe` 的 ——
/// 测试没法安全地造出「有旧文件」的环境,这也是这条函数一直零测试覆盖的原因。
/// 两个路径提出来之后,迁移的三条路径(成功 / 目标已存在不覆盖 / 复制失败留日志)
/// 都能用两个临时目录直接过磁盘。
///
/// 建目录用的是 `target.parent()` 而不是原实现的 `paths.src()`:生产路径下两者
/// 是同一个目录(`src()` 恒等于程序根,设置文件就在根下),但只要调用方指了别处的
/// 目标(如 `--settings D:\x.json`),`paths.src()` 就是错的目录。
fn migrate_legacy_from(old: &Path, target: &Path, log: &Log) -> Option<(PathBuf, PathBuf)> {
if target.exists() {
return None;
}
if !old.exists() {
return None;
}
if let Some(dir) = target.parent() {
if !dir.as_os_str().is_empty() {
if let Err(e) = std::fs::create_dir_all(dir) {
// 目录建不出来,后面的 copy 必然失败 —— 在这里就报,免得
// 日志里只剩一句"复制失败",看不出根因。
log.write(
"store",
&format!("设置迁移失败:建不了目录 {} —— {e}", dir.display()),
);
return None;
}
}
}
match std::fs::copy(old, target) {
Ok(_) => {
log.write(
"store",
&format!(
"设置已从 {} 迁移到 {}",
old.display(),
target.display()
),
);
Some((old.to_path_buf(), target.to_path_buf()))
}
Err(e) => {
log.write(
"store",
&format!(
"设置迁移失败:{} → {} —— {e}",
old.display(),
target.display()
),
);
None
}
}
}
/// 同一目录下的兄弟文件:`settings.json` → `settings.json.broken` / `.bak`。
///
/// 用「追加后缀」而不是 `with_extension` —— 后者会把 `.json` 换成 `.broken`,
/// 一来名字里看不出原扩展名,二来 `settings`(无扩展)这种路径会变成
/// `settings.broken`,和原文件不好对上号。
fn sibling(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(suffix);
path.with_file_name(name)
}
/// 读取设置。任何异常都退回「全默认」,保证程序一定能起来。
///
/// **解析失败时把原文件另存为 `settings.json.broken`**,而不是就那么放在原地:
/// 调用方拿到的是默认值,用户下一次改任意一项就会触发 `save`,把整份默认值写回
/// 同一个路径 —— 原文件(可能 34 项全是用户调过的)会被无声抹掉,连「修好一个
/// 引号就能救回来」的机会都没有。先备份一份,数据就永远还有抢救余地。
///
/// 用 copy + remove 而不是 rename:`rename` 在 Windows 上不覆盖已存在的目标,
/// 第二次坏文件会原地失败、什么也留不下;copy 再删则是「备份成功才动原文件」,
/// 万一备份写不下去(磁盘满/被占用),原文件也还待在原处,不会被我们弄丢。
/// 备份失败不影响返回值 —— 这个函数的契约始终是「保证能起来」,不抛错。
///
/// **两条去向都要写日志**:备份成功要写出副本在哪(用户照着就能去修),
/// 备份也失败更要写(此时原文件原地没动,数据没丢,但用户必须知道
/// 界面上的默认值不是他的文件)。不写日志的话,用户看到的只是「设置全没了」,
/// 而盘上其实躺着 `settings.json.broken` —— 唯一的线索没人告诉他。
///
/// 日志对象在函数里就地构造(`Log::detect(paths)`,与程序用的是同一套目录约定、
/// 同一个 `logs\`),而不是给 `load` 加一个 `&Log` 参数:这个签名被
/// `nfarms-app/src/main.rs` 与 `nfarms-gui/src/lib.rs` 直接调用,为了写一行日志
/// 去动两个别的 crate 的调用点不划算;而 `paths` 这个参数本来就是为
/// 「默认路径判定」留的,现在正好用上,不再是死参数。构造只在解析失败这条
/// 冷路径上发生,正常启动一次都不会碰。
///
/// 与 C# 版的差异:**数据库优先分支在这个包里是有意不做的**,不是"暂时没写"。
/// 原版在「路径等于默认路径」时先读 SQLite(`Store.LoadSettings()`,
/// `cs-src/Settings.cs:83-93`),JSON 只是回退。补这条远不是"在 `load` 前面插个
/// 分支":要 SQLite 后端 + `settings(key,value)` 表 + 字段↔行的映射,而 `store.rs`
/// 现有的模型是"JSON 顶层键值",和原版那套反射进出的 `ToMap`/`FromMap` 不是
/// 一回事。影响面**有限**,所以可以不做:原版每次 `Save` 都先把整份 JSON 写全
/// (`cs-src/Settings.cs:105-113`)再去同步数据库,JSON 始终是最新的那一份,
/// 只读 JSON 不会读到过期设置。将来真要补,入口就是下面这个路径判定 ——
/// 到那时第一个参数才算完全用上。
///
/// 另一处与原版**有意**的差异:原版解析失败连文件都不碰一下(`catch { }`),
/// 这里会留下 `.broken` 副本 —— 数据安全优先于逐字节等价。
pub fn load(paths: &Paths, path: &Path) -> Settings {
let Ok(text) = std::fs::read_to_string(path) else {
// 文件不存在(首次运行)和文件读不动在结果上无法区分,都算「全新开始」。
return Settings::default();
};
if let Ok(settings) = serde_json::from_str(strip_bom(&text)) {
return settings;
}
let broken = sibling(path, ".broken");
let log = Log::detect(paths);
if std::fs::copy(path, &broken).is_ok() {
// 副本已经在,原文件删不掉只是难看,不影响数据安全 —— 不打扰用户。
let _ = std::fs::remove_file(path);
log.write(
"store",
&format!(
"设置解析失败,原文已保留为 {}(修好里面的语法错即可救回来)",
broken.display()
),
);
} else {
log.write(
"store",
&format!(
"设置解析失败,且备份不成功,原文件未改动:{}",
path.display()
),
);
}
Settings::default()
}
impl Settings {
/// 写设置。
///
/// C# 用 `new UTF8Encoding(true)` —— **带 BOM**。为了能和原版逐字节比对,
/// 这里也写 BOM(注意 `lib.rs::strip_bom` 读回来时会再抹掉)。
///
/// 与 C# 的差异:原版把异常全吞掉(存不下也不告诉用户);这里返回
/// `io::Result`,由调用方决定是弹提示还是忽略。界面层用 `let _ =` 即恢复原语义。
///
/// **还有一条与原版有意不同的护栏**:目标已存在但读不成 `Settings` 时,
/// **拒绝写入**并返回错误。理由是 `load` 遇到坏文件会退回全默认,调用方
/// 手里于是是一份默认值 + 用户刚改的那一项;此时无条件写回就等于用默认值
/// 覆盖掉用户可能有几十项的配置(`load` 留的 `.broken` 只是兜底,不该当成
/// 正常路径)。拒绝写之后,文件原样留在盘上,用户改好那个语法错就全回来了,
/// 界面则通过 `save_settings()` 把错误写进日志。
///
/// 覆盖一份**能读通**的旧文件之前先复制成 `settings.json.bak`:
/// 进程崩溃或写到一半断电时,上一个好版本还在。
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
if !dir.as_os_str().is_empty() {
std::fs::create_dir_all(dir)?;
}
}
if let Ok(existing) = std::fs::read_to_string(path) {
if serde_json::from_str::<Settings>(strip_bom(&existing)).is_err() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"{} 不是能解析的设置文件,已拒绝覆盖(原文件保持不动,请先手工修好或删掉它)",
path.display()
),
));
}
// 备份失败不阻断保存:拒绝保存会丢掉用户刚在界面上做的修改,
// 而备份只是保险,不该反过来变成主流程的失败点。
let _ = std::fs::copy(path, sibling(path, ".bak"));
}
let json = serde_json::to_string(self).map_err(std::io::Error::other)?;
let mut bytes = Vec::with_capacity(json.len() + 3);
bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]);
bytes.extend_from_slice(json.as_bytes());
// 先写临时文件再改名:直接 `fs::write` 会先截断原文件,写到一半出错
// (磁盘满/被杀进程)就只剩半份 JSON —— 而半份 JSON 正是上面那个
// 「下次 load 全默认」的坏文件。改名在同一目录内是原子替换。
let tmp = sibling(path, ".tmp");
std::fs::write(&tmp, &bytes)?;
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp); // 别把半成品留在目录里
return Err(e);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("nfarms-settings-test-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn defaults_match_the_csharp_field_initializers() {
let s = Settings::default();
assert_eq!(s.weather_location, "shanghai");
assert_eq!(s.music_volume, 80);
assert_eq!(s.dsh_port, 7909);
assert_eq!(s.dsh_timeout_seconds, 120);
assert_eq!(s.printer_port, "19100");
assert_eq!(s.print_poll_seconds, 5);
assert_eq!(s.ztna_target, "console.nfarms.cn:9992");
assert_eq!(s.winget_jdk, "Microsoft.OpenJDK.21");
assert_eq!(s.winget_dotnet, "Microsoft.DotNet.SDK.8");
assert_eq!(s.winget_python, "Python.Python.3.12");
assert_eq!(s.winget_cmake, "Kitware.CMake");
assert_eq!(s.winget_go, "GoLang.Go");
assert_eq!(s.winget_rustup, "Rustlang.Rustup");
assert_eq!(s.browser_home, "https://www.bing.com");
assert_eq!(s.browser_engine_mode, 0);
assert_eq!(s.pet_scale, 100);
assert_eq!(s.pet_opacity, 100);
assert!(s.pet_top_most);
assert!(s.pet_hide_fullscreen);
assert!(!s.pet_enabled);
assert_eq!((s.pet_x, s.pet_y), (-1, -1));
}
#[test]
fn json_keys_are_pascal_case_like_the_csharp_version() {
let json = serde_json::to_string(&Settings::default()).unwrap();
for key in [
"\"BackgroundImage\"",
"\"WeatherLocation\"",
"\"MusicVolume\"",
"\"DshTimeoutSeconds\"",
"\"PrinterPort\"",
"\"PetHideFullscreen\"",
"\"PetX\"",
"\"PetY\"",
] {
assert!(json.contains(key), "缺少键 {key}: {json}");
}
}
#[test]
fn missing_fields_fall_back_to_defaults() {
// 老版本写的 settings.json 里没有新字段 —— 不能因此整个文件失效。
let s: Settings = serde_json::from_str(r#"{"WeatherLocation":"beijing"}"#).unwrap();
assert_eq!(s.weather_location, "beijing");
assert_eq!(s.dsh_port, 7909);
}
#[test]
fn unknown_fields_are_ignored() {
// C# 的 JavaScriptSerializer 同样忽略未知字段。
let s: Settings =
serde_json::from_str(r#"{"WeatherLocation":"beijing","Nope":1}"#).unwrap();
assert_eq!(s.weather_location, "beijing");
// **已知代价,写成规格**:忽略之后整份重新序列化,未知键就永久消失
// (模块头解释了为什么不做 `#[serde(flatten)] extra` 兜底)。
// 哪天要给未知键兜底,这条会红 —— 那时是有意改行为,连着模块头一起改。
let d = tmp("unknown-keys");
let f = d.join("settings.json");
std::fs::write(&f, br#"{"WeatherLocation":"beijing","Nope":1}"#).unwrap();
let mut s = load(&Paths::from_exe_dir(&d), &f);
s.music_volume = 35; // 用户在界面上随便改一项,触发整份写回
s.save(&f).unwrap();
let back = strip_bom(&std::fs::read_to_string(&f).unwrap()).to_string();
assert!(
!back.contains("Nope"),
"降级打开会吃掉新版本的键,这是已知代价: {back}"
);
assert!(back.contains(r#""MusicVolume":35"#), "改过的那项要写出去: {back}");
let _ = std::fs::remove_dir_all(&d);
}
/// 守的是高严重度那条里**最后没落地**的一半:`load` 遇到坏文件时除了留
/// `.broken` 副本,还要**写一行日志**。副本解决的是"事后能救",日志解决的是
/// "当场知道" —— 少了日志,用户看到的只是设置全没了,而盘上那份唯一的线索
/// 没人告诉他。
#[test]
fn broken_json_is_reported_in_the_log() {
let d = tmp("broken-log");
let f = d.join("settings.json");
std::fs::write(&f, br#"{"WeatherLocation":"#).unwrap();
assert_eq!(load(&Paths::from_exe_dir(&d), &f), Settings::default());
// 副本在,而且日志指出了它在哪(`logs\` 由 `Log::detect` 按目录约定建出来)。
let broken = d.join("settings.json.broken");
assert!(broken.exists(), "坏文件必须先备份成 settings.json.broken");
let text = std::fs::read_to_string(d.join("logs").join("store.log"))
.expect("解析失败必须写日志");
assert!(
text.contains("settings.json.broken"),
"日志要写出副本路径: {text}"
);
// 原文件已经被挪走:留在原地的话,下一次 save 会走进「拒绝覆盖」那条路,
// 用户改了任何一项都存不下去。
assert!(!f.exists(), "坏文件备份成功后应从原路径移走");
let _ = std::fs::remove_dir_all(&d);
}
/// 守的是「`migrate_legacy` 失败一路静默」+「这条函数零测试覆盖」两条。
///
/// 原来 `create_dir_all` 的返回值被丢、复制用 `.ok()?` 吞错,调用方拿到 `None`
/// 只会当成"没什么可迁的",而随后 `load` 给出的是一份全新默认值 —— 设置看起来
/// 凭空消失,日志里却没有任何线索。三条路径都真过磁盘。
#[test]
fn migrate_legacy_copies_once_and_logs_every_failure() {
let d = tmp("migrate");
let log = Log::at(d.join("logs"));
let logs_dir = d.join("logs");
let store_log = || std::fs::read_to_string(logs_dir.join("store.log")).unwrap_or_default();
let old = d.join("appdata").join("NFarmsTools").join("settings.json");
std::fs::create_dir_all(old.parent().unwrap()).unwrap();
let original = br#"{"WeatherLocation":"beijing"}"#;
std::fs::write(&old, original).unwrap();
let target = d.join("root").join("settings.json");
// 1. 目标不存在:复制过去,返回值给出两端,并留一行成功日志。
// 目标目录(root)事先不存在 —— 也顺带验了 create_dir_all。
let moved = migrate_legacy_from(&old, &target, &log).expect("应当迁移");
assert_eq!(moved, (old.clone(), target.clone()));
assert_eq!(std::fs::read(&target).unwrap(), original);
assert!(store_log().contains("迁移到"), "成功也要留痕: {}", store_log());
// 2. 目标已存在:**不覆盖**(那才是本次会话要读的文件),返回 None。
let keep = br#"{"WeatherLocation":"shanghai"}"#;
std::fs::write(&target, keep).unwrap();
assert!(migrate_legacy_from(&old, &target, &log).is_none());
assert_eq!(std::fs::read(&target).unwrap(), keep);
// 3. 复制失败(旧路径是个目录):返回 None,但日志里必须有原因 ——
// 这正是原来最糟的一条:无感返回,后面 load 全默认。
let bad = d.join("appdata").join("NFarmsTools").join("broken-settings");
std::fs::create_dir_all(&bad).unwrap();
let target2 = d.join("root2").join("settings.json");
assert!(migrate_legacy_from(&bad, &target2, &log).is_none());
assert!(store_log().contains("迁移失败"), "失败必须留日志: {}", store_log());
assert!(!target2.exists(), "复制失败不该留下半份目标文件");
let _ = std::fs::remove_dir_all(&d);
}
/// 守的是这条高严重度缺陷:坏 JSON 本来是「静默归零 + 下次 save 用默认值整份覆盖」,
/// 用户原文件既没备份也没提示,不可逆。
///
/// 现在要求三件事同时成立:
/// 1. `load` 解析失败时,原文件先被留成 `settings.json.broken`,内容一字不差,
/// 之后 `save` 写默认值也救得回来(覆盖发生在备份**之后**);
/// 2. `save` 面对一份还躺在目标路径上的坏文件**拒绝覆盖**,并报错
/// (界面会把它写进日志),而不是用默认值悄悄把它抹掉;
/// 3. 用户把文件修好之后,`save` 恢复正常并先留一份 `.bak`。
#[test]
fn broken_json_is_preserved_as_broken_file_and_save_refuses_to_clobber_it() {
let d = tmp("broken");
let f = d.join("settings.json");
// 用户真的调过参数的文件,只是多了一个引号之类的语法错。
let original = br#"{"WeatherLocation":"beijing","MusicVolume":35,"Nope":"}""#;
std::fs::write(&f, original).unwrap();
assert_eq!(load(&Paths::from_exe_dir(&d), &f), Settings::default());
// 1. 原文被完整留下来了 —— 这就是「修好一个引号就能救回来」的那份。
let broken = d.join("settings.json.broken");
assert!(broken.exists(), "坏文件必须先备份成 settings.json.broken");
assert_eq!(std::fs::read(&broken).unwrap(), original);
// 2. 覆盖一份读不通的设置文件必须被拒绝。这里单独造一个坏文件、并且**故意
// 不走 load**:走到这儿的坏文件还躺在目标路径上(比如刚被外部程序改坏),
// 正是原来会出事的那条路径。
let f2 = d.join("settings2.json");
std::fs::write(&f2, original).unwrap();
let s = Settings {
weather_location: "hangzhou".to_string(),
..Default::default()
};
let err = s.save(&f2).expect_err("读不通的设置文件不允许被覆盖");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
// 原文件一个字节都没被动过,而且没有留下半成品临时文件。
assert_eq!(std::fs::read(&f2).unwrap(), original);
assert!(!d.join("settings2.json.tmp").exists(), "临时文件必须清理干净");
// 3. 用户把文件修好之后,save 恢复正常并先留一份 .bak。
std::fs::write(&f2, br#"{"WeatherLocation":"beijing"}"#).unwrap();
s.save(&f2).unwrap();
assert!(d.join("settings2.json.bak").exists(), "覆盖旧文件前要先备份");
// 读回来要先抹 BOM —— `save` 写的是带 BOM 的 UTF-8(与 C# 版一致)。
let round_tripped: Settings =
serde_json::from_str(strip_bom(&std::fs::read_to_string(&f2).unwrap())).unwrap();
assert_eq!(round_tripped, s);
assert!(!d.join("settings2.json.tmp").exists(), "临时文件必须清理干净");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn save_writes_a_utf8_bom_then_round_trips() {
let d = tmp("roundtrip");
let f = d.join("settings.json");
let s = Settings {
weather_location: "埇桥区".to_string(),
music_volume: 70,
..Default::default()
};
s.save(&f).unwrap();
let bytes = std::fs::read(&f).unwrap();
assert_eq!(&bytes[..3], &[0xEF, 0xBB, 0xBF], "C# 写的是带 BOM 的 UTF-8");
// 非 ASCII 按原字符写,不转义成 \uXXXX —— 与 C# 的 JavaScriptSerializer 一致。
assert!(String::from_utf8_lossy(&bytes).contains("埇桥区"));
let back = load(&Paths::from_exe_dir(&d), &f);
assert_eq!(back, s);
let _ = std::fs::remove_dir_all(&d);
}
/// 由 **C# 原版程序真实写出**的 settings.json(带 BOM,JavaScriptSerializer 的排版)。
const CSHARP_WRITTEN: &str = include_str!("../tests/data/settings.csharp-written.json");
#[test]
fn reserializes_a_csharp_written_file_byte_for_byte() {
// 这条断言同时锁住五件事,少任何一条都会在这里炸出来:
// 1. 字段名是 PascalCase
// 2. 字段声明顺序 = JavaScriptSerializer 的反射顺序 = 文件里的键顺序
// 3. 非 ASCII 原样写,不转义成 \uXXXX
// 4. 没有多余空格(紧凑格式)
// 5. **C# 原版没有的字段(数字雨)在默认值下一个字都不写** —— 靠那几个
// 字段上的 `skip_serializing_if`。谁把它去掉,这条就红。
let raw = strip_bom(CSHARP_WRITTEN);
let parsed: Settings = serde_json::from_str(raw).expect("必须能读 C# 写的文件");
assert_eq!(
serde_json::to_string(&parsed).unwrap(),
raw,
"重新序列化后必须与 C# 原版的字节完全一致"
);
}
#[test]
fn the_csharp_fixture_round_trips_through_defaults() {
// 反方向也验一次:默认值写出去再读回来,还是同一份默认值。
let s = Settings::default();
let json = serde_json::to_string(&s).unwrap();
let back: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
/// 守的是「数字雨四个设置项完全不持久化」这条:
/// 数值必须与界面侧原本写死的那组一致(`rain.rs` 的 `Rain::default()`),
/// 否则「没存过设置」和「存的正好是默认值」会显示出两种雨。
#[test]
fn rain_defaults_match_the_widget_that_hardcoded_them() {
let s = Settings::default();
assert_eq!(s.rain_speed, 18.0);
assert_eq!(s.rain_font_size, 16.0);
assert_eq!(s.rain_palette, 0);
assert!(!s.rain_katakana);
// 默认值必须与 `skip_serializing_if` 的判据是同一个数,否则
// 「默认的雨」会被当成用户改过、白白写进 settings.json。
assert_eq!(s.rain_speed, DEFAULT_RAIN_SPEED);
assert_eq!(s.rain_font_size, DEFAULT_RAIN_FONT_SIZE);
assert_eq!(s.rain_palette, DEFAULT_RAIN_PALETTE);
assert_eq!(s.rain_katakana, DEFAULT_RAIN_KATAKANA);
}
/// 真过一遍磁盘:改完这四个值 → 关程序(这里用重新 `load` 模拟)→ 值还在。
#[test]
fn rain_settings_survive_save_and_load() {
let d = tmp("rain");
let f = d.join("settings.json");
let s = Settings {
rain_speed: 42.5,
rain_font_size: 9.0,
rain_palette: 2,
rain_katakana: true,
..Default::default()
};
s.save(&f).unwrap();
assert_eq!(load(&Paths::from_exe_dir(&d), &f), s);
let _ = std::fs::remove_dir_all(&d);
}
/// 加字段却不用重生成 C# fixture 的前提:默认值一个键都不落进 JSON,
/// 改过的值必须落进去。掉任何一半这条都会红(前一半还会连带
/// `reserializes_a_csharp_written_file_byte_for_byte`)。
#[test]
fn rain_fields_stay_out_of_the_json_while_they_are_default() {
let json = serde_json::to_string(&Settings::default()).unwrap();
assert!(!json.contains("Rain"), "默认值不该出现在 JSON 里: {json}");
let s = Settings {
rain_speed: 42.5,
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert!(
json.contains(r#""RainSpeed":42.5"#),
"非默认值必须写出去,否则「改了不存」没修: {json}"
);
assert!(
!json.contains("RainFontSize"),
"只有改过的那一项该出现: {json}"
);
// 老文件(没有这几个键)读进来必须是默认值,不能整个文件失效。
let old: Settings = serde_json::from_str(r#"{"WeatherLocation":"beijing"}"#).unwrap();
assert_eq!(old.weather_location, "beijing");
assert_eq!(old.rain_speed, DEFAULT_RAIN_SPEED);
assert!(!old.rain_katakana);
}
/// 统一迁移后的本地服务端口(llama 9094 / sd 9095 / VictoriaMetrics 9091 /
/// Loki 9092 / Grafana 9093)住在 `store.json`,见 `crate::store` 的「端口」
/// 一节。**它们不能挪到这个结构体里**:C# 原版没有这些键,加进来就得按模块头
/// 说的挂 `skip_serializing_if`、必要时还得重造
/// `tests/data/settings.csharp-written.json` —— 代价是人工动作,而收益只是
/// 把一份**本程序自己的运行配置**塞进与 C# 逐字节对齐的文件里。
///
/// 这条把"没被顺手加进来"钉住(真加进来时,
/// `reserializes_a_csharp_written_file_byte_for_byte` 也会红,但那条的报错
/// 只说"字节不一致",指不出是哪个字段干的)。
#[test]
fn runtime_ports_stay_out_of_the_csharp_aligned_settings_file() {
let json = serde_json::to_string(&Settings::default()).unwrap();
for key in ["LlamaPort", "SdPort", "VmPort", "LokiPort", "GrafanaPort"] {
assert!(
!json.contains(key),
"{key} 属于 store.json(见 store.rs 的端口一节),不该出现在 settings.json: {json}"
);
}
// 反面:这个文件里**该有**的端口只有 C# 原版那两个,别连带把它们挪走。
assert!(json.contains(r#""DshPort""#), "DshPort 是 C# 原版字段: {json}");
assert!(
json.contains(r#""PrinterPort""#),
"PrinterPort 是 C# 原版字段: {json}"
);
}
}