crates\nfarms-core\src\store.rs¶
源码路径:
crates\nfarms-core\src\store.rs· 998 行
//! 一个小 JSON 键值存储 —— 书签、上次打开的文件、同步状态都放它里面。
//!
//! 为什么不直接上 SQLite:C# 的 `Store.cs` 是一套「优先 SQLite、找不到就回退 JSON」
//! 的**反射加载**方案(`Assembly.LoadFrom` + 找 `System.Data.SQLite.dll`),
//! 那是 .NET 特有的绕路。Rust 这边真要 SQLite 就引 `rusqlite`(带 C 源码编译),
//! 但那是个几十 MB 的构建代价 —— 而这里要存的东西(书签几十条、几个路径)
//! 用 JSON 完全够,而且**人能直接打开看、能手工修**。
//!
//! 所以:JSON 优先,接口留成 key/value。以后真要 SQLite,换掉这个文件的实现即可,
//! 调用方不用动。
use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
use crate::Paths;
/// 存储文件名(和 settings.json 一样放在**程序根**,即 `paths.src()` 指的目录)。
///
/// 这里以前写的是「放在 `src\` 下」—— 那是 C# 原版的布局,`Paths::src()` 现在
/// 直接返回程序根,`src\` 这一层早就取消了。留着旧说法会让人照着注释去 `src\`
/// 找一个永远不存在的 `store.json`。
pub const FILE: &str = "store.json";
// ---------------------------------------------------------------- 端口
//
// 本机几个服务的端口是**运行配置**(改了要记住、重启后还认),所以住在
// `store.json`,**不进 `settings.json`**:后者是要和 C# 原版逐字节对齐的那份
// 文件,往里加字段会直接弄红 `nfarms-core::settings` 的逐字节护栏
// (见 settings.rs 模块头那段"原版没有的字段必须挂 `skip_serializing_if`")。
//
// **配对项 —— 改一个必须同时改另一个。** `vm_port` / `loki_port` 不只是本程序
// 拿去连 VictoriaMetrics / Loki 用的:这两个端口还要**原样出现在 Grafana 的
// provision 文件里**(数据源 URL),那份由 `nfarms-core::observability` 生成。
// 只改了这里、忘了那份,症状是 Grafana 能起来但**数据源连不上** ——
// provision 里还指着旧端口,而 Grafana 的日志只会说一句 connection refused,
// 从这句话看不出是端口对不上。
//
// 默认值 = 各服务自己的监听端口(统一迁移后的那一套)。它**只决定初值**:
// 读的一侧只在键缺失或值写坏时回落到默认,**绝不覆盖用户已经存过的值** ——
// 用户盘上写着 8080,读回来就得是 8080,迁移不动老用户的配置。
/// llama.cpp 的默认端口(旧的 8080 已统一迁移到 9094)。
pub const DEFAULT_LLAMA_PORT: u16 = 9094;
/// sd.cpp 的默认端口(旧的 1234 已统一迁移到 9095)。
pub const DEFAULT_SD_PORT: u16 = 9095;
/// VictoriaMetrics 的默认端口。
pub const DEFAULT_VM_PORT: u16 = 9091;
/// Loki 的默认端口。
pub const DEFAULT_LOKI_PORT: u16 = 9092;
/// Grafana 的默认端口。
pub const DEFAULT_GRAFANA_PORT: u16 = 9093;
/// `store.json` 里端口用的键名。
///
/// 键名是**磁盘上的契约**(用户会打开这个文件手改),抽成常量给读写两侧共用:
/// 打错一个字母的后果是"用户的设置不见了、悄悄回到默认",而盘上那份值还好端端
/// 躺在一个没人读的键下面。`llama_port` / `sd_port` 这两个键**已经在盘上了**
/// (见 `nfarms-gui` 的 `LocalModel::save_ports`),不能改名。
pub const KEY_LLAMA_PORT: &str = "llama_port";
pub const KEY_SD_PORT: &str = "sd_port";
pub const KEY_VM_PORT: &str = "vm_port";
pub const KEY_LOKI_PORT: &str = "loki_port";
pub const KEY_GRAFANA_PORT: &str = "grafana_port";
/// 一条书签。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Bookmark {
pub title: String,
pub url: String,
/// 加入时间(`yyyy-MM-dd HH:mm:ss`)
pub added: String,
}
impl Bookmark {
pub fn to_json(&self) -> Value {
json!({ "title": self.title, "url": self.url, "added": self.added })
}
pub fn from_json(v: &Value) -> Option<Self> {
let o = v.as_object()?;
Some(Self {
title: o.get("title")?.as_str()?.to_string(),
url: o.get("url")?.as_str()?.to_string(),
added: o
.get("added")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string(),
})
}
}
pub struct Store {
pub path: PathBuf,
data: Map<String, Value>,
/// 载入时如果文件坏了,这里留一句说明 —— 界面上要如实告诉用户,
/// 而不是悄悄把旧数据覆盖掉。
pub load_note: Option<String>,
}
impl Store {
/// 默认位置:`<程序根>\store.json`。
///
/// 注意是程序根,**不是** `src\`:`Paths::src()` 自己就返回程序根(见 `paths.rs`),
/// 所以这里的 `paths.src()` 没有"少拼一层"。改这句注释前先看一眼 `Paths::src()`。
pub fn default_path(paths: &Paths) -> PathBuf {
paths.src().join(FILE)
}
pub fn load(path: &Path) -> Self {
let mut me = Self {
path: path.to_path_buf(),
data: Map::new(),
load_note: None,
};
match std::fs::read_to_string(path) {
Ok(text) => {
// 和 settings.json 一样要吞 BOM:别的编辑器另存可能带上
let text = crate::strip_bom(&text);
match serde_json::from_str::<Value>(text) {
Ok(Value::Object(o)) => {
me.data = o;
// 文件本身能解析,但里面的书签条目可能是坏的 ——
// 那不会被上面两个分支发现,却会让界面上凭空少东西。
me.note_bookmark_damage();
}
Ok(_) => {
me.load_note = Some(format!("{} 的顶层不是对象,已忽略", path.display()))
}
Err(e) => {
me.load_note = Some(format!(
"{} 解析失败({e}),本次以空存储启动 —— 原文件没有被覆盖,可以手工修",
path.display()
))
}
}
}
Err(_) => { /* 文件不存在就是全新的,不算错误 */ }
}
me
}
/// 载入后对 `bookmarks` 做一次体检,把「界面里会凭空少东西」的情况记进 `load_note`。
///
/// 为什么在这里查、而不是在 `bookmarks()` 里查:`bookmarks()` 收 `&self`,
/// 而且界面每帧都可能调它;要在那里改 `load_note` 就得把签名改成 `&mut self`,
/// 为了记一句"载入时看到过坏条目"把只读访问升级成可变访问,得不偿失。
/// 坏条目只可能从**文件**里进来(`set_bookmarks` 写出去的永远是合法条目),
/// 所以载入是唯一该查、也是唯一能查全的时刻。
///
/// 不这么做会怎样:`bookmarks()` 的 `filter_map` 会把格式不对的条目**无声**丢掉
/// (比如某条的 `url` 被手改成了数字),用户看到书签少了却不知道为什么;
/// 更糟的是下一次加/删书签会把过滤后的列表 `set_bookmarks` 回去,坏条目
/// 连在文件里都消失 —— 那时候再想找回就真没了。
fn note_bookmark_damage(&mut self) {
// 先把结论算完再动 `self`:一边借着 `self.data` 一边写 `self.load_note`
// 借用检查器不会放行,硬写就得先 clone 一份数据,不值当。
let mut broken = 0usize;
let mut undated = 0usize;
let mut not_array = false;
match self.data.get("bookmarks") {
None => return,
Some(Value::Array(arr)) => {
for v in arr {
match Bookmark::from_json(v) {
Some(b) => {
if b.added.is_empty() {
undated += 1;
}
}
None => broken += 1,
}
}
}
Some(_) => not_array = true,
}
if not_array {
self.push_note("bookmarks 不是数组,本次一个书签都读不出来(原文件没动)");
return;
}
if broken > 0 {
self.push_note(&format!(
"有 {broken} 条书签格式不对(title / url 缺失或不是字符串),界面里看不到它们"
));
}
// `added` 缺失时 `Bookmark::from_json` 填空串,界面据此隐藏「加入时间」那一列。
// **这个容忍行为本身是对的**:回填一个"本次启动时间"是编数据,比不显示更糟。
// 但得如实告诉用户"这几条查不到什么时候加的",否则他只会以为界面坏了。
if undated > 0 {
self.push_note(&format!(
"有 {undated} 条书签没有加入时间,界面不显示该列"
));
}
}
/// 往 `load_note` 追加一句。多条问题要逐条列出,不能只留最后一条 ——
/// 「坏文件」和「坏书签」同时发生时,少说一句用户就少知道一件事。
fn push_note(&mut self, msg: &str) {
match &mut self.load_note {
Some(note) => {
note.push(';');
note.push_str(msg);
}
None => self.load_note = Some(msg.to_string()),
}
}
pub fn get_str(&self, key: &str) -> Option<String> {
self.data.get(key)?.as_str().map(|s| s.to_string())
}
pub fn set_str(&mut self, key: &str, value: &str) {
self.data
.insert(key.to_string(), Value::String(value.to_string()));
}
pub fn remove(&mut self, key: &str) {
self.data.remove(key);
}
/// 写回磁盘(缩进 2 空格,给人看的)。
///
/// **先写 `store.json.tmp`,写完再 `rename` 顶替原文件。**
///
/// 为什么不能直接 `fs::write` 覆盖:`fs::write` 是先**截断**目标文件再往里写,
/// 截断与写完之间有个窗口。断电、任务管理器结束进程、磁盘满,任意一种落在这个
/// 窗口里,盘上留下的就是半截 JSON —— 而半截 JSON 在下一次启动时走的是
/// 「解析失败 → 空存储」那条路:**不报错、不崩,但内容全没了**,
/// 用户根本不会知道自己丢了东西。
///
/// `rename` 在同一个目录内是原子的(Windows 上 std 走
/// `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`),所以任何时刻磁盘上要么是
/// 完整的旧内容、要么是完整的新内容,不存在"半截"这个状态。
/// 代价是同目录下多一次写,这个文件的体量(几 KB)可以忽略。
pub fn save(&self) -> Result<(), String> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("建目录失败: {e}"))?;
}
// **覆盖之前,先把"读不懂的原文件"留一份。**
//
// 场景:用户手工改坏了 store.json(少个引号、多个逗号)—— `load`
// 解析失败、退回空存储。这时他随手指点一下(加书签 / 拖侧栏 / 改端口)
// 就会走到这里。**不备份的话,他一整份手写的内容被一个空存储顶掉,
// 而且没有任何提示** —— 那是不可恢复的数据丢失。
//
// 判据是「**磁盘上那份**能不能解析」,而不是「本次 load 有没有失败」:
// save 可能是另一个实例发起的,那个实例未必 load 过这份坏文件。
if self.path.is_file() {
let existing = std::fs::read_to_string(&self.path).unwrap_or_default();
let parseable = serde_json::from_str::<Value>(&existing)
.map(|v| v.is_object())
.unwrap_or(false);
if !parseable && !existing.trim().is_empty() {
let bak = std::path::PathBuf::from(format!("{}.bak", self.path.display()));
// **备份失败就不写。** 宁可这次操作丢掉(用户可以重做),
// 也不能把原文件毁了 —— 那是不可逆的。
std::fs::copy(&self.path, &bak).map_err(|e| {
format!(
"原文件读不懂,备份到 {} 失败: {e} —— 已放弃本次写入,原文件没动",
bak.display()
)
})?;
}
}
// `serde_json::Map<String, Value>` 自己就实现了 `Serialize`,而
// `to_string_pretty` 收的也是 `&T` —— 旧写法 `&Value::Object(self.data.clone())`
// 白复制了一整份数据(书签几百条时就是几百次 Value 的深拷贝)。
// 输出字节完全一样:Map 序列化出来就是那个对象。
let text = serde_json::to_string_pretty(&self.data)
.map_err(|e| format!("序列化失败: {e}"))?;
// 临时文件放在**同一个目录**里,不能放 `%TEMP%`:跨盘 / 跨卷的 rename
// 不是原子的,Windows 上会直接失败(ERROR_NOT_SAME_DEVICE)。
let tmp = PathBuf::from(format!("{}.tmp", self.path.display()));
if let Err(e) = std::fs::write(&tmp, &text) {
// 写坏的临时文件要清掉:没人会去清理它,用户看到 `store.json.tmp`
// 只会更困惑 "这是不是我的数据"。
let _ = std::fs::remove_file(&tmp);
return Err(format!("写临时文件 {} 失败: {e}", tmp.display()));
}
std::fs::rename(&tmp, &self.path).map_err(|e| {
// 替换失败时原文件**一个字都没动**(rename 要么成功要么什么也没发生),
// 所以这里可以放心报"原文件没动";半截的临时文件同样不能留下。
let _ = std::fs::remove_file(&tmp);
format!("替换 {} 失败: {e} —— 原文件没动", self.path.display())
})
}
// ---------------------------------------------------------------- 端口
//
// 常量与「配对项」的说明在文件顶部那个「端口」小节(默认值 9094 / 9095 /
// 9091 / 9092 / 9093)。这里只放**读写口子**:每个端口都是一读一写一对,
// 和 `set_bookmarks` / `nav_order` 那几组一样 —— 只有字段没有入口,
// 界面就永远存不进去,那等于没做。
/// 从 `store.json` 读一个端口。**只读,不写回**:键缺失或值写坏时返回
/// `default`,但绝不把默认值塞进 `data` —— 读设置的动作不该有副作用,
/// 更不该在用户什么都没改的时候改写磁盘内容。
fn get_port(&self, key: &str, default: u16) -> u16 {
// 两种写法都认:字符串 `"9094"`(本程序自己写出去的格式,与盘上已有的
// `llama_port` / `sd_port` 一致)和数字 `9094`(用户手改时更自然的写法)。
// 只认一种的后果是另一种被当成"没设过"、静默回落到默认 ——
// 用户改了端口却发现不起作用,而文件里明明写着。
match self.data.get(key) {
Some(Value::String(s)) => s.trim().parse().unwrap_or(default),
Some(v) => v
.as_u64()
.and_then(|n| u16::try_from(n).ok())
.unwrap_or(default),
None => default,
}
}
/// 写一个端口。**只改内存,落盘要再调 `save()`**(与 `set_bookmarks` 等
/// 其它 setter 一致)。
///
/// 存成字符串而不是数字:与盘上已有的 `llama_port` / `sd_port` 写法一致
/// (`LocalModel::save_ports` 写的就是字符串)。同一个文件里同一种东西两种
/// 类型,下次谁按 JSON 类型去判断就会漏掉一半。
fn set_port(&mut self, key: &str, port: u16) {
self.set_str(key, &port.to_string());
}
/// llama.cpp 的端口。没存过时是 `DEFAULT_LLAMA_PORT`(9094);
/// 存过就原样读回 —— 包括老用户盘上的 8080,迁移不替他改。
pub fn llama_port(&self) -> u16 {
self.get_port(KEY_LLAMA_PORT, DEFAULT_LLAMA_PORT)
}
pub fn set_llama_port(&mut self, port: u16) {
self.set_port(KEY_LLAMA_PORT, port);
}
/// sd.cpp 的端口。没存过时是 `DEFAULT_SD_PORT`(9095);存过就原样读回。
pub fn sd_port(&self) -> u16 {
self.get_port(KEY_SD_PORT, DEFAULT_SD_PORT)
}
pub fn set_sd_port(&mut self, port: u16) {
self.set_port(KEY_SD_PORT, port);
}
/// VictoriaMetrics 的端口。没存过时是 `DEFAULT_VM_PORT`(9091)。
///
/// **配对项**:这个数还要出现在 Grafana 的 provision 文件里
/// (见文件顶部那段),改这里必须同时改那份。
pub fn vm_port(&self) -> u16 {
self.get_port(KEY_VM_PORT, DEFAULT_VM_PORT)
}
pub fn set_vm_port(&mut self, port: u16) {
self.set_port(KEY_VM_PORT, port);
}
/// Loki 的端口。没存过时是 `DEFAULT_LOKI_PORT`(9092)。
///
/// **配对项**:与 `vm_port` 同理,必须和 Grafana provision 文件里的数据源
/// URL 保持一致。
pub fn loki_port(&self) -> u16 {
self.get_port(KEY_LOKI_PORT, DEFAULT_LOKI_PORT)
}
pub fn set_loki_port(&mut self, port: u16) {
self.set_port(KEY_LOKI_PORT, port);
}
/// Grafana 自己的端口。没存过时是 `DEFAULT_GRAFANA_PORT`(9093)。
///
/// 这一个不是"配对项的左边"而是**被指向的那一侧**:provision 文件里写的是
/// VM / Loki 的端口,不是 Grafana 自己的。
pub fn grafana_port(&self) -> u16 {
self.get_port(KEY_GRAFANA_PORT, DEFAULT_GRAFANA_PORT)
}
pub fn set_grafana_port(&mut self, port: u16) {
self.set_port(KEY_GRAFANA_PORT, port);
}
// ---------------------------------------------------------- 书签
//
// 书签单独放一个键下面(`bookmarks`),这样整个文件还能放别的东西,
// 而书签这一块能整体替换 —— 比按 id 散落一地好管。
pub fn bookmarks(&self) -> Vec<Bookmark> {
self.data
.get("bookmarks")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(Bookmark::from_json).collect())
.unwrap_or_default()
}
pub fn set_bookmarks(&mut self, list: &[Bookmark]) {
self.data.insert(
"bookmarks".to_string(),
Value::Array(list.iter().map(|b| b.to_json()).collect()),
);
}
/// 书签判重用的比较键 —— 只抹掉「确定是同一份地址」的写法差异。
///
/// 做三件事:去掉首尾空白、去掉结尾的 `/`、把 **scheme 与 host** 转小写。
///
/// 为什么**不**把整串转小写(只转 scheme/host):`/Report` 和 `/report`
/// 在多数 HTTP 服务器上是两个不同的资源,整串小写会把两条不同的书签并成一条,
/// 用户就再也加不进第二条了 —— 那是"去重"变成"丢数据"。
/// scheme 与 host 大小写不敏感是 RFC 3986 明写的,只转这两段不可能误并。
///
/// 不是 URL 的串(用户存本地路径之类)没有 scheme/host 可分,退化成
/// 「去空白 + 去结尾分隔符 + 整串小写」:路径本来就不该靠大小写区分,
/// 这里小写是为了让 `C:\Docs` 和 `c:\docs` 不至各占一条。
fn dedup_key(url: &str) -> String {
let trimmed = url.trim().trim_end_matches(['/', '\\']);
match trimmed.split_once("://") {
Some((scheme, rest)) => {
// host 到第一个 `/` 为止,后面全是路径(路径保持原样大小写)。
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
format!(
"{}://{}{}",
scheme.to_ascii_lowercase(),
authority.to_ascii_lowercase(),
path
)
}
None => trimmed.to_ascii_lowercase(),
}
}
/// 加一条。同一个 URL 只留一条(重复添加通常是点快了)。
///
/// 判重按**归一化后的键**,不是按原文比:从地址栏复制来的 `https://a/`
/// 和手打的 `https://a` 是同一条,按原文比会各存一份,书签列表里就出现
/// 两条一模一样的条目,删一条还剩一条。
///
/// 存进列表的仍然是 `url` **原文** —— 归一化只用于比较,不能拿它去改用户写的东西
/// (`from_json`/界面上显示的就是用户当初粘的那串)。
pub fn add_bookmark(&mut self, title: &str, url: &str, now: &str) -> bool {
let mut list = self.bookmarks();
let key = Self::dedup_key(url);
if list.iter().any(|b| Self::dedup_key(&b.url) == key) {
return false;
}
list.push(Bookmark {
title: if title.trim().is_empty() {
url.to_string()
} else {
title.trim().to_string()
},
url: url.to_string(),
added: now.to_string(),
});
self.set_bookmarks(&list);
true
}
/// 删一条。**只删一条**;先按原文精确匹配,找不到再按归一化键匹配。
///
/// 为什么比较要用和 `add_bookmark` 同一套归一化键:否则会出现「加不进去、
/// 也删不掉」的条目 —— 存的是 `https://a/`,用户拿 `https://a` 来删,
/// 精确比较会说不存在,而 add 那边又认为它已经在列表里了。
///
/// 为什么**先精确、再用键兜底**,而不是直接 `retain` 掉所有同键条目:
/// 这一版的旧文件里可能已经同时存着 `https://a` 和 `https://a/`
/// (旧版 add 就是这么存的),它们归一化后同键。界面上一次"删除"是删**一行**,
/// 一把删掉两行是不可解释的 —— 用户会以为程序吃了他另一条书签。
/// 精确命中时删的就是他点的那一条,不会波及同键的另一条。
pub fn remove_bookmark(&mut self, url: &str) -> bool {
let mut list = self.bookmarks();
let key = Self::dedup_key(url);
let hit = list
.iter()
.position(|b| b.url == url)
.or_else(|| list.iter().position(|b| Self::dedup_key(&b.url) == key));
match hit {
Some(i) => {
list.remove(i);
self.set_bookmarks(&list);
true
}
None => false,
}
}
// ------------------------------------------------------ 侧栏顺序
//
// 存 **页面 id 的数组**,不是下标。
//
// 存下标会在升级后错乱 —— 这个项目本身就加过页(会话里从 30 页加到 34 页),
// 下标一挪全部对不上;id("ffmpeg" / "log-path")天然稳定,页面删了也只是
// 找不到、被忽略(见 `pages::apply_order`)。
//
// 为什么不开一个单独的 order.json:这个文件的定位就是「本程序自己的、
// 不与 C# 原版对齐的键值存储」,bookmarks / wiki_sources 已经在这了,
// 再多一个键比多一个文件好管。
/// 侧栏顺序。空 = 用功能树默认顺序。
pub fn nav_order(&self) -> Vec<String> {
self.data
.get("nav_order")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
pub fn set_nav_order(&mut self, ids: &[String]) {
self.data.insert(
"nav_order".to_string(),
Value::Array(ids.iter().map(|s| Value::String(s.clone())).collect()),
);
}
// ---------------------------------------------------- 知识库索引路径
//
// 「自定义索引路径」= 用户指定**哪些目录**要被收进本地知识库。
//
// 存的是**原样字符串**(不规范化为绝对路径),理由:用户可能想写相对程序根的
// 路径(比如 `docs`、`..\notes`),在这里强行 canonicalize 会把
// 「程序搬个位置」变成「索引全部失效」。真正的解析放到建索引那一步 ——
// 那里才知道程序根在哪。
//
// 比较时忽略结尾分隔符:免得 `docs` 和 `docs\` 各存一份。
//
// **现状(审计已确认):这一组接口全工程没有调用方。** GUI 里没有「自定义索引
// 路径」界面,`kb.rs` 收的是程序根下写死的 `wiki\` 目录。要收尾只有两条路:
// 在界面侧(`core_pages.rs` 的知识库页)补上入口并让 `kb.rs` 读这个列表,
// 或者把这一整段删掉 —— "只写了接口、没接界面"本身就是半截契约。
// 现在还留在这里的原因:接界面动的是 `kb.rs` / `core_pages.rs`,属于功能立项,
// 不是这个文件能独立收尾的改动。改这里之前先确认那条路选了哪一条。
fn norm_source(s: &str) -> String {
s.trim().trim_end_matches(['\\', '/']).to_string()
}
/// 要索引的源目录(顺序 = 用户添加顺序)。
pub fn wiki_sources(&self) -> Vec<String> {
self.data
.get("wiki_sources")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
pub fn set_wiki_sources(&mut self, list: &[String]) {
self.data.insert(
"wiki_sources".to_string(),
Value::Array(list.iter().map(|s| Value::String(s.clone())).collect()),
);
}
/// 加一个索引路径。空串和重复都拒绝(返回 false)。
pub fn add_wiki_source(&mut self, dir: &str) -> bool {
let d = Self::norm_source(dir);
if d.is_empty() {
return false;
}
let mut list = self.wiki_sources();
if list.iter().any(|x| Self::norm_source(x) == d) {
return false;
}
list.push(dir.trim().to_string());
self.set_wiki_sources(&list);
true
}
pub fn remove_wiki_source(&mut self, dir: &str) -> bool {
let target = Self::norm_source(dir);
let mut list = self.wiki_sources();
let before = list.len();
list.retain(|x| Self::norm_source(x) != target);
let changed = list.len() != before;
if changed {
self.set_wiki_sources(&list);
}
changed
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("nfarms-store-{name}"));
let _ = std::fs::remove_dir_all(&d);
let _ = std::fs::create_dir_all(&d);
d.join(FILE)
}
#[test]
fn missing_file_is_not_an_error() {
let s = Store::load(&tmp("missing"));
assert!(s.load_note.is_none(), "文件不存在就是全新的,不该报错");
assert!(s.bookmarks().is_empty());
}
#[test]
fn roundtrip_survives_save_and_load() {
let p = tmp("roundtrip");
let mut s = Store::load(&p);
assert!(s.add_bookmark("NFarms", "https://nfarms.cn/", "2026-10-03 09:00:00"));
s.set_str("last_dir", r"C:\video");
s.save().unwrap();
let s2 = Store::load(&p);
assert_eq!(s2.bookmarks().len(), 1);
assert_eq!(s2.bookmarks()[0].title, "NFarms");
assert_eq!(s2.get_str("last_dir").unwrap(), r"C:\video");
}
#[test]
fn duplicate_url_is_ignored_and_title_falls_back() {
let p = tmp("dup");
let mut s = Store::load(&p);
assert!(s.add_bookmark("", "https://a/", "t"));
assert_eq!(s.bookmarks()[0].title, "https://a/", "标题空就用 URL 顶上");
assert!(!s.add_bookmark("另一个名字", "https://a/", "t"));
assert_eq!(s.bookmarks().len(), 1);
}
#[test]
fn remove_reports_whether_it_changed_anything() {
let p = tmp("remove");
let mut s = Store::load(&p);
s.add_bookmark("a", "https://a/", "t");
assert!(s.remove_bookmark("https://a/"));
assert!(!s.remove_bookmark("https://a/"), "删第二次应该返回 false");
assert!(s.bookmarks().is_empty());
}
#[test]
fn broken_file_is_reported_and_not_overwritten_implicitly() {
let p = tmp("broken");
std::fs::write(&p, "{ this is not json").unwrap();
let s = Store::load(&p);
assert!(s.load_note.is_some(), "坏文件必须被报出来");
// 只 load 不 save:原文件应当原样还在
assert!(std::fs::read_to_string(&p).unwrap().contains("not json"));
}
#[test]
fn broken_file_is_backed_up_before_any_save_overwrites_it() {
let p = tmp("broken-then-save");
// 故意用「看着像 JSON、其实解析不过」的坏内容 —— 手工改坏的 store.json
// 十有八九长这样(少个引号、多个逗号),而不是上一测试里那种一眼假的乱码。
let broken = "{ \"bookmarks\": [ { \"title\": \"没关引号 } ] }";
std::fs::write(&p, broken).unwrap();
// 模拟界面:启动时 load 拿到坏文件,用户随后随便点了个会落盘的操作(加书签 / 拖侧栏 / 改端口)
let mut s = Store::load(&p);
assert!(s.load_note.is_some(), "坏文件必须被报出来");
s.add_bookmark("NFarms", "https://nfarms.cn/", "2026-10-14 21:00:00");
s.save().unwrap();
// 这一条是本次缺陷的核心:坏文件的原内容必须还留在磁盘上,不能被空存储悄悄顶掉
let bak = PathBuf::from(format!("{}.bak", p.display()));
assert!(
bak.is_file(),
"解析失败时先把原文件留一份 .bak;现在 {} 不见了,原内容已被覆盖",
bak.display()
);
assert_eq!(
std::fs::read_to_string(&bak).unwrap(),
broken,
".bak 必须是坏文件的逐字节副本,不能是重新序列化过的内容"
);
// 新存储照常写出去(用户的操作不能白点),而且是能读回来的
let s2 = Store::load(&p);
assert!(s2.load_note.is_none(), "覆盖后应当是一份正常的存储");
assert_eq!(s2.bookmarks().len(), 1);
}
#[test]
fn bom_prefixed_file_loads() {
let p = tmp("bom");
std::fs::write(&p, "\u{feff}{\"a\":\"b\"}").unwrap();
let s = Store::load(&p);
assert!(s.load_note.is_none());
assert_eq!(s.get_str("a").unwrap(), "b");
}
// --------------------------------------------------------- 原子写
/// `save` 必须落成文件、且不留下临时文件。
#[test]
fn save_lands_the_file_and_leaves_no_temp_behind() {
let p = tmp("atomic-litter");
let mut s = Store::load(&p);
s.set_str("k", "v");
s.save().unwrap();
assert!(p.is_file(), "save 之后目标文件必须存在");
assert!(
!PathBuf::from(format!("{}.tmp", p.display())).exists(),
"save 成功之后不能留下 store.json.tmp —— 下次启动没人会去清理它"
);
let on_disk = std::fs::read_to_string(&p).unwrap();
assert!(
serde_json::from_str::<Value>(&on_disk).is_ok(),
"落盘的必须是一份完整 JSON:{on_disk}"
);
}
/// **这条是"非原子写"的复发哨兵。**
///
/// 目标位置放一个**目录**:`rename` 顶不上去(Windows 上 `MoveFileExW` 不允许
/// 用文件替换目录),正好用来考"替换失败"这条路。断言里那句错误文案是刻意的 ——
/// 旧实现直接 `fs::write(&self.path, text)`,失败时只会说"写失败",根本走不到
/// 替换这一步;谁把 `save` 改回直接覆盖,这条立刻变红。
#[test]
fn a_failed_replace_keeps_the_old_target_and_cleans_up_the_temp() {
let dir = std::env::temp_dir().join("nfarms-store-atomic-fail");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join(FILE);
std::fs::create_dir_all(&p).unwrap();
let mut s = Store::load(&p);
s.set_str("k", "v");
let err = s.save().expect_err("目标顶不上去时必须报错,不能装作写成功");
assert!(
err.contains("替换") && err.contains("原文件没动"),
"错误要说清是「替换失败、原文件没动」,而不是笼统的写失败:{err}"
);
assert!(p.is_dir(), "替换失败后原目标必须原样还在");
assert!(
!dir.join(format!("{FILE}.tmp")).exists(),
"失败的临时文件不能留在盘上:{err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// 落盘字节是**磁盘上的契约**(用户会直接打开这个文件手改),换写法不能改字节。
///
/// 这条同时钉住两件事:去掉 `self.data.clone()` 不改变输出(旧写法的
/// `Value::Object(clone)` 序列化出来就是同一个对象);改走 `.tmp + rename`
/// 之后落盘的仍是同一份 2 空格缩进的 pretty JSON、且结尾不带换行。
/// 键序是 `serde_json::Map`(默认 BTreeMap)的字典序 —— 钉住它,
/// 以后谁给 serde_json 打开 `preserve_order`,这里会先红。
#[test]
fn saved_bytes_are_the_same_pretty_json_as_before() {
let p = tmp("format");
let mut s = Store::load(&p);
s.set_str("k", "v");
s.add_bookmark("T", "https://a/", "2026-10-14 22:00:00");
s.save().unwrap();
let expected = "{\n \"bookmarks\": [\n {\n \"added\": \"2026-10-14 22:00:00\",\n \"title\": \"T\",\n \"url\": \"https://a/\"\n }\n ],\n \"k\": \"v\"\n}";
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
expected,
"落盘格式变了:用户手改过的文件会和程序写出来的对不上"
);
}
// ----------------------------------------------------- 书签判重口径
/// 结尾斜杠 / 首尾空白 / scheme 与 host 的大小写都不该各占一条。
#[test]
fn trivially_different_url_spellings_are_one_bookmark() {
let p = tmp("dedup-spelling");
let mut s = Store::load(&p);
assert!(s.add_bookmark("NFarms", "https://nfarms.cn/", "t"));
assert!(
!s.add_bookmark("again", " https://NFarms.CN ", "t"),
"归一化之后就是同一条,不该再加进去"
);
assert_eq!(s.bookmarks().len(), 1);
assert_eq!(
s.bookmarks()[0].url, "https://nfarms.cn/",
"存的必须是用户当初粘的那串,归一化只用于比较"
);
}
/// **路径大小写不参与归一化** —— 这是有意的取舍,不是漏做。
///
/// HTTP 路径大小写敏感,`/Report` 和 `/report` 可能是两个资源;
/// 整串转小写会让第二条永远加不进来("去重"变成"丢数据")。
#[test]
fn paths_that_differ_only_by_case_stay_separate() {
let p = tmp("dedup-case-path");
let mut s = Store::load(&p);
assert!(s.add_bookmark("A", "https://host/Report", "t"));
assert!(
s.add_bookmark("B", "https://host/report", "t"),
"路径大小写不同必须算两条"
);
assert_eq!(s.bookmarks().len(), 2);
}
/// `remove` 必须和 `add` 用同一套身份,否则会出现"加不进去也删不掉"的条目。
#[test]
fn remove_uses_the_same_identity_as_add() {
let p = tmp("remove-normalized");
let mut s = Store::load(&p);
s.add_bookmark("NFarms", "https://nfarms.cn/", "t");
assert!(
s.remove_bookmark("https://NFarms.cn"),
"add 认为这两串是同一条,remove 就必须也认"
);
assert!(s.bookmarks().is_empty());
}
/// 旧版写下的文件里可能同时存着 `https://a` 和 `https://a/`(归一化后同键)。
/// 界面上点一次删除是删**一行**,不能把另一条也一起带走。
#[test]
fn removing_one_of_two_same_key_bookmarks_takes_only_one() {
let p = tmp("remove-one-of-same-key");
std::fs::write(
&p,
r#"{"bookmarks":[{"title":"A","url":"https://a","added":"t"},{"title":"B","url":"https://a/","added":"t"}]}"#,
)
.unwrap();
let mut s = Store::load(&p);
assert_eq!(s.bookmarks().len(), 2);
assert!(s.remove_bookmark("https://a"), "精确命中,应当删掉 A");
let left = s.bookmarks();
assert_eq!(left.len(), 1, "只该删掉点中的那一条");
assert_eq!(left[0].url, "https://a/", "留下的是 B");
}
// ------------------------------------------------- 坏书签条目要交代
/// 格式不对的条目会被 `bookmarks()` 的 `filter_map` 无声吃掉 —— 必须报出来。
#[test]
fn bookmark_entries_the_reader_drops_are_reported_at_load() {
let p = tmp("bad-bookmark-entry");
// 第二条的 `url` 被手改成了数字 —— `Bookmark::from_json` 返回 `None`。
let raw = r#"{"bookmarks":[{"title":"好","url":"https://a/","added":"t"},{"title":"坏","url":42}]}"#;
std::fs::write(&p, raw).unwrap();
let s = Store::load(&p);
let note = s
.load_note
.as_deref()
.expect("被丢掉的条目必须报出来,不能无声消失");
assert!(
note.contains("1 条书签格式不对"),
"提示要说清丢了几条:{note}"
);
assert_eq!(s.bookmarks().len(), 1, "能读的照常读出来");
// load 不动文件:坏条目还在盘上,用户手工修回来就能恢复;
// 下一次 add/remove 才会把过滤后的列表写回去。
assert_eq!(std::fs::read_to_string(&p).unwrap(), raw);
}
/// `bookmarks` 整个不是数组时一个都读不出来,同样要报。
#[test]
fn bookmarks_that_are_not_an_array_are_reported() {
let p = tmp("bookmarks-not-array");
std::fs::write(&p, r#"{"bookmarks":{"a":1}}"#).unwrap();
let s = Store::load(&p);
let note = s.load_note.as_deref().expect("bookmarks 不是数组也必须报");
assert!(note.contains("不是数组"), "{note}");
assert!(s.bookmarks().is_empty());
}
/// `added` 缺失时容忍(条目照常读出来),但不编时间戳,只如实说一句。
#[test]
fn a_bookmark_without_added_is_kept_but_reported() {
let p = tmp("undated-bookmark");
std::fs::write(&p, r#"{"bookmarks":[{"title":"老","url":"https://old/"}]}"#).unwrap();
let s = Store::load(&p);
assert_eq!(s.bookmarks().len(), 1, "缺 added 不该让整条书签消失");
assert_eq!(
s.bookmarks()[0].added, "",
"**不**回填时间戳:把「本次启动时间」当「加入时间」比不显示更误导"
);
let note = s
.load_note
.as_deref()
.expect("缺 added 要如实说,不能让那一列静悄悄地空着");
assert!(note.contains("没有加入时间"), "{note}");
}
// --------------------------------------------------------------- 端口
/// 默认值 = 统一迁移后的端口表。五个数全在这里钉住:它们同时被界面、
/// `observability` 生成的 provision 文件、拼服务参数的函数引用,
/// 散成字面量迟早会漂成"界面显示 9091、provision 里写 9092"。
#[test]
fn default_ports_match_the_unified_migration() {
let p = tmp("ports-default");
let s = Store::load(&p); // 文件不存在 = 全新安装
assert_eq!(s.llama_port(), 9094, "llama.cpp 的旧默认 8080 已迁移");
assert_eq!(s.sd_port(), 9095, "sd.cpp 的旧默认 1234 已迁移");
assert_eq!(s.vm_port(), 9091);
assert_eq!(s.loki_port(), 9092);
assert_eq!(s.grafana_port(), 9093);
// 读出来的默认值必须与导出的常量是同一个数 —— 下游引常量时不会错位。
assert_eq!(s.llama_port(), DEFAULT_LLAMA_PORT);
assert_eq!(s.sd_port(), DEFAULT_SD_PORT);
assert_eq!(s.vm_port(), DEFAULT_VM_PORT);
assert_eq!(s.loki_port(), DEFAULT_LOKI_PORT);
assert_eq!(s.grafana_port(), DEFAULT_GRAFANA_PORT);
}
/// **存过就认存的那个。** 默认值只影响初值 —— 这是端口迁移最容易出的事故:
/// 老用户盘上写着 8080,新版本一启动就用 9094 把它顶掉,而 DSH 的 provider
/// baseURL 还指着 8080,症状是 connection refused。
#[test]
fn stored_values_win_over_the_new_defaults_and_are_left_alone() {
let p = tmp("ports-stored");
let raw = r#"{"llama_port":"8080","sd_port":"1234","vm_port":"8428","loki_port":3100,"grafana_port":"3000"}"#;
std::fs::write(&p, raw).unwrap();
let s = Store::load(&p);
assert_eq!(s.llama_port(), 8080, "老用户存的值必须原样读回,不能被 9094 顶掉");
assert_eq!(s.sd_port(), 1234, "sd 的旧值同理,不能被 9095 顶掉");
assert_eq!(s.vm_port(), 8428, "字符串写法要认");
assert_eq!(s.loki_port(), 3100, "数字写法也要认(用户手改时更自然)");
assert_eq!(s.grafana_port(), 3000);
// 只读不写:**读设置不该有副作用**,load 之后文件一个字节都没动。
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
raw,
"读端口不许把默认值写回磁盘"
);
}
/// 读和写都通:改 → 存 → 重新 load,值还在;没动过的口子保持默认。
/// 缺了任何一半(只加字段没入口 / 存了读不回),这条都会红。
#[test]
fn ports_survive_save_and_load() {
let p = tmp("ports-roundtrip");
let mut s = Store::load(&p);
s.set_llama_port(19191);
s.set_sd_port(19192);
s.set_vm_port(19193);
s.set_loki_port(19194);
s.set_grafana_port(19195);
s.save().unwrap();
let s2 = Store::load(&p);
assert_eq!(s2.llama_port(), 19191);
assert_eq!(s2.sd_port(), 19192);
assert_eq!(s2.vm_port(), 19193);
assert_eq!(s2.loki_port(), 19194);
assert_eq!(s2.grafana_port(), 19195);
// 只改一个:其余四个必须是默认值,不能被顺手写成别的数。
let p2 = tmp("ports-roundtrip-one");
let mut s3 = Store::load(&p2);
s3.set_loki_port(19194);
s3.save().unwrap();
let s4 = Store::load(&p2);
assert_eq!(s4.loki_port(), 19194);
assert_eq!(s4.llama_port(), DEFAULT_LLAMA_PORT);
assert_eq!(s4.sd_port(), DEFAULT_SD_PORT);
assert_eq!(s4.vm_port(), DEFAULT_VM_PORT);
assert_eq!(s4.grafana_port(), DEFAULT_GRAFANA_PORT);
}
/// 写坏的端口不许一路传下去:`llama_server_args` 或数据源 URL 拿到一个越界的
/// 数才炸,现场离这里十万八千里。不合法就回落默认 ——
/// 与 `LocalModel::load_ports` 的校验口径一致(非法值丢弃、用默认)。
#[test]
fn a_broken_port_value_falls_back_to_the_default() {
let p = tmp("ports-broken-value");
std::fs::write(
&p,
r#"{"llama_port":" ","sd_port":"9095x","vm_port":70000,"loki_port":-1,"grafana_port":true}"#,
)
.unwrap();
let s = Store::load(&p);
assert_eq!(s.llama_port(), DEFAULT_LLAMA_PORT, "空串不算端口");
assert_eq!(s.sd_port(), DEFAULT_SD_PORT, "带垃圾后缀不算端口");
assert_eq!(s.vm_port(), DEFAULT_VM_PORT, "超出 u16 不算端口");
assert_eq!(s.loki_port(), DEFAULT_LOKI_PORT, "负数不算端口");
assert_eq!(s.grafana_port(), DEFAULT_GRAFANA_PORT, "布尔值不算端口");
}
/// 端口和其它键互不干扰:存端口不能把书签顶掉(它们共用一个 `data`)。
#[test]
fn writing_ports_keeps_the_other_keys() {
let p = tmp("ports-keep-keys");
let mut s = Store::load(&p);
s.add_bookmark("NFarms", "https://nfarms.cn/", "t");
s.set_vm_port(19193);
s.save().unwrap();
let s2 = Store::load(&p);
assert_eq!(s2.vm_port(), 19193);
assert_eq!(s2.bookmarks().len(), 1, "存端口不该动到书签");
}
}