crates\nfarms-core\src\paths.rs¶
源码路径:
crates\nfarms-core\src\paths.rs· 654 行
//! 目录约定(全部相对「程序根」)。对应 C# `src\Paths.cs`。
//!
//! ```text
//! libs\ 所有第三方组件:webview2(Chromium) / ffmpeg / bililive-go / servo / sqlite
//! libs\live\ 直播录制输出(bililive-go 的产物,归它自己那层)
//! libs\scripts\ 取第三方组件的那套脚本(fetch-libs.ps1 等)
//! logs\ 所有日志 + 报告(nfarms-hub.log / nfos-printer.log /
//! bililive-go.log / dsh-web.log / *.cy)
//! logs\bak\ 所有备份(*.cs.bak、被清理掉的源码包等)
//! wiki\ 知识库的索引与元数据(见 [`Paths::wiki`])
//! settings.json / store.json 就在程序根 —— `src\` 已取消
//! ```
//!
//! **顶层只有三个目录。** 早先 `bak\` 和 `live\` 是和 `logs\` 平级的,
//! 现在收进去了:备份是「历史留存物」,和日志一起看好找;直播录像是第三方
//! 组件的产物,归 `libs\` 名下才不像是本程序自己的输出。
//!
//! **程序根就是 exe 所在目录**,不再有 `bin\` 上溯 —— 两个 exe 平铺在根目录,
//! 再上溯就会跑到程序根外面去;单独把 exe 拷出来放也照样能用(退化成
//! exe 所在目录)。
//!
//! **与 C# 的唯一设计差异**:C# 的 `Paths` 是静态类,属性读的是进程全局状态,
//! 单测没法造环境。这里做成一个可构造的值 ——
//! `Paths::from_exe_dir(临时目录)` 就能模拟任意部署形态,
//! 后面每一个自检命令都会因此变得可测。
use std::path::{Path, PathBuf};
/// 程序根下的**顶层**约定目录。
///
/// 只剩三个了:`bak\` 并进 `logs\`、`live\` 并进 `libs\` 之后程序根清爽得多。
///
/// ⚠️ 这份清单**就是** `--layout-test` 输出里那三行顶层的名字 ——
/// [`Paths::convention_dirs`] 的名字取自这里,不再另抄一遍。
/// 该用例在 oracle 里被登记为「有意分歧」(`scripts\verify-oracle.ps1` 的
/// `Diverges`:顶层目录由 6 个改为 4 个),输出不一致时只打印分歧、不判红,
/// 但会多一处 diff 噪音。这是一次用户明确要求的**有意分歧**(原版是六个
/// 平级目录),所以 golden 跟着改是对的 —— 但要知道自己在改什么。
pub const CONVENTION_DIRS: [&str; 3] = ["libs", "logs", "wiki"];
/// **嵌套**约定目录的显示名(`logs\bak`、`libs\live`)。
///
/// 单独列出来是因为它们带父目录前缀:报告里一眼能看出挂在谁下面,
/// 而不是和顶层三个混在一起看不出层级。
const NESTED_CONVENTION_DIRS: [&str; 2] = [r"logs\bak", r"libs\live"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Paths {
exe_dir: PathBuf,
root: PathBuf,
}
/// 目录可写性探测([`Paths::writable`])的结果。
///
/// **三态而不是 `bool`**:「写进去了、探针也清掉了」和「写进去了、探针留在
/// 人家目录里」是两件不同的事。旧实现把后者 `let _ =` 吞掉、照样报 `true`,
/// 于是这次失败既不在返回值里也不在日志里 —— 用户只会在自己的目录里
/// 发现一个没人认领的 `.write-probe-xxxxxx`。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Writable {
/// 能写入,探针已清理干净。
Yes,
/// 能写入,**但探针文件删不掉**,已经留在 `dir` 里。路径一并带出来 ——
/// 只说「没删掉」用户也不知道该去找什么。
ProbeLeft(PathBuf),
/// 写不进去:目录建不出来,或探针写不进去。
No,
}
impl Writable {
/// 这个目录**能不能用**。`ProbeLeft` 算能用 —— 残留一个探针文件不影响
/// 写入,把它判成不可用会让报告和自检结果一起说谎(旧行为也是报 `true`)。
pub fn is_writable(&self) -> bool {
!matches!(self, Writable::No)
}
}
impl Paths {
/// 按当前进程 exe 的位置推导(等价于 C# 的 `Paths.Root`)。
pub fn detect() -> Self {
Self::from_exe_dir(process_exe_dir())
}
/// 从「假设 exe 在这个目录」出发推导整套路径。测试入口。
pub fn from_exe_dir(exe_dir: impl AsRef<Path>) -> Self {
let exe_dir = normalize_dir(exe_dir.as_ref());
// **程序根就是 exe 所在目录** —— 不再有 `bin\` 上溯。
//
// 以前 exe 放 `bin\`、根要往上退一层;现在两个 exe 平铺在根目录,
// 再上溯就会跑到程序根**外面**去(设置读不到、日志写到别人家)。
let root = exe_dir.clone();
Self { exe_dir, root }
}
pub fn exe_dir(&self) -> &Path {
&self.exe_dir
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn libs(&self) -> PathBuf {
self.root.join("libs")
}
pub fn logs(&self) -> PathBuf {
self.root.join("logs")
}
/// `src\` 已取消:settings.json / store.json 就在程序根。
pub fn src(&self) -> PathBuf {
self.root.clone()
}
/// 脚本目录 —— `libs\scripts\`。
///
/// 原来在程序根下的 `scripts\`。挪进 `libs\` 的理由:
/// `libs\scripts\fetch-libs.ps1` 是**取第三方组件的那套脚本**
/// (ffmpeg / 模型 / 运行时),它天生属于 `libs\` 这一块的事 ——
/// 摆在根目录反而像是本程序自己的工具脚本。
///
/// 以前这个路径在好几处**各自拼** `root.join("scripts")`,这次收进 `Paths`,
/// 以后再挪只改这一行。
pub fn scripts(&self) -> PathBuf {
self.libs().join("scripts")
}
/// 取组件脚本(`libs\scripts\fetch-libs.ps1`)。
pub fn fetch_libs_script(&self) -> PathBuf {
self.scripts().join("fetch-libs.ps1")
}
/// 本地知识库目录 —— `wiki\`。
///
/// 放知识库的**索引与元数据**,**不放源文档**:源文档在用户自己的目录里,
/// 由「自定义索引路径」指过去。分开的理由很实际 ——
/// 索引是可重建的中间产物,单独放才能随手删掉重建,而不用碰用户的文件。
///
/// **现在没有 `index.json` 这种东西**:`kb::load` 是直接 `read_dir`
/// 扫这个目录取文档的,从来不读索引文件。以前这里挂着一个 `wiki_index()`
/// 访问器,全仓零调用者 —— 留着它只会让人以为「索引文件」这条约定已经
/// 实现了,所以删掉了。哪天真要做索引,再加访问器,别先留个空壳。
pub fn wiki(&self) -> PathBuf {
self.root.join("wiki")
}
/// 备份目录 —— `logs\bak\`。
///
/// 以前是程序根下的 `bak\`,和 `logs\` 平级。合并的理由:两者都是
/// 「历史留存物」,而且备份的往往是**日志分析时要对照的东西**
/// (被清理掉的 ffmpeg 源码包、改过的配置文件),放一起更好找。
/// 副作用是程序根清爽了:顶层只剩 `libs\ logs\ wiki\` 三个。
pub fn bak(&self) -> PathBuf {
self.logs().join("bak")
}
/// 直播录制输出目录 —— `libs\live\`。
///
/// 以前是程序根下的 `live\`。挪进 `libs\` 是因为它**本质上是第三方
/// 组件(bililive-go)的产物**,和 `libs\bililive-go\` 是一回事,
/// 放在程序根下反而像是本程序自己的输出目录。
pub fn live(&self) -> PathBuf {
self.libs().join("live")
}
pub fn shots(&self) -> PathBuf {
self.root.join("shots")
}
/// `libs\` 下的某个组件目录。
pub fn lib(&self, sub: &str) -> PathBuf {
self.libs().join(sub)
}
/// 数据文件(设置 / sqlite 库等)—— 按约定就在**程序根**下
/// (`src\` 已取消,[`src`](Self::src) 返回的就是程序根)。
pub fn data(&self, file: &str) -> PathBuf {
self.src().join(file)
}
/// 约定目录的 (显示名, 路径)。
///
/// 五个条目,但**顶层只有三个**:`logs\bak` 和 `libs\live` 是嵌套的。
/// 之所以保留五个(而不是直接删掉后两个),是因为自检报告里仍然要
/// 检查这两个目录存不存在、可不可写 —— 它们只是换了位置,不是消失了。
/// 显示名带上父目录,**报告里一眼能看出它在谁下面**。
///
/// 名单**只此一份**:顶层三个的显示名来自 [`CONVENTION_DIRS`],
/// 两个嵌套的来自 [`NESTED_CONVENTION_DIRS`]。以前的写法是在函数体里
/// 再抄一遍同样的字面量,于是常量改了输出也不变(`--layout-test` 读的是
/// 函数体那份),两份名单只会各自漂移。
pub fn convention_dirs(&self) -> [(&'static str, PathBuf); CONVENTION_DIRS.len() + 2] {
[
(CONVENTION_DIRS[0], self.libs()),
(CONVENTION_DIRS[1], self.logs()),
(CONVENTION_DIRS[2], self.wiki()),
(NESTED_CONVENTION_DIRS[0], self.bak()),
(NESTED_CONVENTION_DIRS[1], self.live()),
]
}
/// 把路径显示成**相对程序根**的形式(在程序根内时)。
///
/// 为什么:程序是绿色版的 —— 用户看到的应当是 `logs\nfarms-hub.log`、
/// `libs\model\qwen3.gguf`,而不是 `C:\某人的目录\...`。三个好处:
///
/// 1. **短**,界面上的键值行不会被长路径撑爆;
/// 2. **不泄漏本机目录结构** —— 这一点在 `.cy` 报告上尤其要紧,那份文件
/// 是要交给平台/同事看的;
/// 3. **可移植**:同一份报告在别的机器上照样能读懂。
///
/// 规则:
/// * 路径就是程序根 → `.`
/// * 在程序根内 → `logs\nfarms-hub.log`(不带 `.\` 前缀,和命令行的习惯一致)
/// * 在程序根外(比如 `%TEMP%`、`C:\Program Files\...`)→ **原样绝对路径**。
/// 硬凑一个 `..\..\` 只会更难读,而且那种路径本来就该让人看见它在哪。
///
/// **「在不在根下」在 Windows 上大小写不敏感地判**:`strip_prefix` 是按
/// 分量逐字节比较的,`C:\deploy` 遇上 `c:\DEPLOY\logs\x.log` 会直接失配,
/// 于是本可以写成 `logs\x.log` 的路径在报告里甩出一整条绝对路径(实测)。
/// Windows 的文件系统本身不区分大小写,所以失配后补一次 ASCII 大小写
/// 不敏感的分量比较;切出来的那一段保持调用方传入的原样,显示不变形。
/// 非 Windows 不启用这条兜底:那里的 `/a` 与 `/A` 是两个不同目录,
/// 放宽了会把根外的路径误判成根内。
pub fn show(&self, p: &Path) -> String {
let rest = match p.strip_prefix(self.root()) {
Ok(rest) => Some(rest.to_path_buf()),
Err(_) => self.strip_root_ignore_ascii_case(p),
};
match rest {
Some(rest) if rest.as_os_str().is_empty() => ".".to_string(),
Some(rest) => rest.display().to_string(),
None => p.display().to_string(),
}
}
/// `strip_prefix` 失配后的兜底:逐分量做 **ASCII 大小写不敏感**的前缀比较。
///
/// 返回的是从 `p` 上**切下来的那一段**(而不是拿 `root` 重新拼),所以
/// 大小写与调用方给的路径一致 —— 报告要显示磁盘上真实的名字。
/// 非 Windows 恒 `None`,理由见 [`show`](Self::show)。
#[cfg(windows)]
fn strip_root_ignore_ascii_case(&self, p: &Path) -> Option<PathBuf> {
let root: Vec<_> = self.root().components().collect();
let cand: Vec<_> = p.components().collect();
if cand.len() < root.len() {
return None;
}
// 比较的是**路径分量**,所以 `C:\deploy-old` 不会被当成 `C:\deploy`
// 的子路径(分量 `deploy-old` ≠ `deploy`),这一点与 `strip_prefix` 一致。
let same = root.iter().zip(cand.iter()).all(|(a, b)| {
a.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case(&b.as_os_str().to_string_lossy())
});
if !same {
return None;
}
Some(cand[root.len()..].iter().collect())
}
/// 非 Windows:路径的大小写是**有意义**的,不做任何放宽。
#[cfg(not(windows))]
fn strip_root_ignore_ascii_case(&self, _p: &Path) -> Option<PathBuf> {
None
}
/// 和 [`show`](Self::show) 一样,但给 `PathBuf` / `Option<PathBuf>` 用着顺手。
pub fn show_opt(&self, p: Option<&Path>) -> String {
p.map(|x| self.show(x)).unwrap_or_else(|| "(没有)".to_string())
}
/// 目录是否可写:真写一个探针文件再删掉。
/// 只查 ACL 是不够的 —— 只读介质、被占用的卷、沙箱都可能让 ACL 看起来正常却写不进去。
///
/// 返回 [`Writable`] 三态而不是 `bool`:探针**删不掉**时(目录其实可写,
/// 文件却留在了人家目录里)以前是彻底静默的,现在把残留路径交回调用方。
pub fn writable(dir: &Path) -> Writable {
writable_with_token(dir, &short_token())
}
/// 路径解析报告(C# `Paths.Report()`)。
///
/// **返回行列表而不是拼好的字符串**:C# 版本把 `Environment.NewLine` 编进了
/// 返回值里,Rust 这边由调用方决定怎么呈现(控制台要 CRLF,写文件可能要别的)。
pub fn report_lines(&self) -> Vec<String> {
let dirs = self.convention_dirs();
// 2 行头部(程序根 / exe 目录)+ 每个约定目录一行。容量直接问 `dirs` 要 ——
// 以前手算的是 `CONVENTION_DIRS.len() + 2`(= 5),而实际 push 7 行,
// 常量和输出脱钩就长这样。
let mut lines = Vec::with_capacity(dirs.len() + 2);
lines.push(format!(" 程序根 : {}", self.root.display()));
lines.push(format!(" exe 目录: {}", self.exe_dir.display()));
for (name, dir) in dirs {
lines.push(format!(
" {:<5}: {} 存在={} 可写={}",
format!("{name}\\"),
dir.display(),
// 注意:必须是 .NET 的 True/False,不是 Rust 的 true/false。
// 这一处漏了,layout-test 的验收就会被 diff 抓出来。
crate::csharp_bool(dir.exists()),
crate::csharp_bool(Paths::writable(&dir).is_writable())
));
}
lines
}
/// 目录约定自检(C# `Paths.SelfTest()`)。
///
/// 返回 `(行, 异常项数)`。C# 那边异常数是静态字段 `LastSelfTestIssues`,
/// 这里直接作为返回值的一部分返回 —— 不存在「忘了读静态字段」这种事。
pub fn self_test(&self) -> (Vec<String>, usize) {
let mut lines = self.report_lines();
let mut issues = 0usize;
for (name, dir) in self.convention_dirs() {
let state = Paths::writable(&dir);
if !dir.exists() || !state.is_writable() {
lines.push(format!(" {name}\\ 不存在或不可写: {}", dir.display()));
issues += 1;
} else if let Writable::ProbeLeft(probe) = &state {
// 可写、但探针删不掉。**以前这里是彻底静默的**:`writable` 里
// `let _ = remove_file(..)` 吞掉错误照旧返回 true,用户于是会在
// 自己的目录里发现 `.write-probe-xxxxxx`,而程序、日志、报告里
// 没有一行解释它。
// **不计入 issues**:目录本身是可用的,计数一跳动,
// `--layout-test` 的退出码(`issues == 0`)就会莫名其妙变红。
lines.push(format!(
" {name}\\ 可写,但探针没能删除(可手动删掉): {}",
probe.display()
));
}
}
// 「提示」不计入异常 —— exe 单独拷出来放是允许的用法,只是程序根会变。
//
// `bin\` 取消之后这行**恒为真**,但输出原样保留:`.oracle\rust-layout-test.txt`
// 第 9 行就是它,改措辞只会换来一次没有意义的 golden 变更。
lines.push(format!(
" [提示] exe 不在 bin\\ 下(当前 {}),程序根按 exe 目录算",
self.exe_dir.display()
));
lines.push(if issues == 0 {
" 目录约定: OK".to_string()
} else {
format!(" 目录约定: {issues} 项异常")
});
(lines, issues)
}
}
/// 当前进程 exe 所在目录(等价于 C# 的 `AppDomain.CurrentDomain.BaseDirectory`)。
pub fn process_exe_dir() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."))
}
/// 去掉结尾的分隔符 —— C# 里 `AppDomain.BaseDirectory` 一定以 `\` 结尾,
/// 代码先 `TrimEnd('\\','/')` 再用,这里保持一致。
///
/// **例外:盘符根。** `C:\` 和 `C:` 在 Windows 上不是一回事:前者是根目录,
/// 后者是「当前盘上的当前目录」这种**盘符相对路径**。以前一视同仁地削尾巴,
/// exe 直接放在卷根时(`C:\nfarms.exe` → [`process_exe_dir`] 给出 `C:\`)程序根
/// 就成了 `C:`,一整套派生路径跟着变成 `C:libs` 这类**既不是绝对、也看不出
/// 相对谁**的写法(实测)。所以盘符根保留原样的结尾分隔符。
fn normalize_dir(p: &Path) -> PathBuf {
let s = p.as_os_str().to_string_lossy();
let trimmed = s.trim_end_matches(['\\', '/']);
if trimmed.is_empty() {
PathBuf::from(s.as_ref())
} else if is_drive_letter_root(trimmed) {
// 原样返回:写的是 `C:\` 就还 `C:\`,写的是 `C:/` 就还 `C:/`。
// 输入本来就是 `C:`(真的盘符相对路径)时 `s == trimmed`,等于没动。
PathBuf::from(s.as_ref())
} else {
PathBuf::from(trimmed)
}
}
/// 是不是「盘符根」那种两字符形式(`X:` 后面一个字符都不剩)。
///
/// 只在 Windows 上有这个语义 —— 别的平台上 `C:` 就是个普通文件名/目录名,
/// 按盘符处理反而会把一个正常名字改坏,所以用 `cfg!` 收窄。
fn is_drive_letter_root(s: &str) -> bool {
cfg!(windows)
&& s.len() == 2
&& s.as_bytes()[1] == b':'
&& s.as_bytes()[0].is_ascii_alphabetic()
}
/// [`Paths::writable`] 的实现,探针名里的 token 可注入。
///
/// 之所以把 token 做成参数(私有,只有测试用):「写得进去、但删不掉」那条
/// 分支必须能**稳定复现** —— 测试先以「不允许删除」的共享模式占住同名文件,
/// 再调这里。token 随机的话那个文件名根本没法提前占住。
fn writable_with_token(dir: &Path, token: &str) -> Writable {
if std::fs::create_dir_all(dir).is_err() {
return Writable::No;
}
let probe = dir.join(format!(".write-probe-{token}"));
if std::fs::write(&probe, b"ok").is_err() {
return Writable::No;
}
match std::fs::remove_file(&probe) {
Ok(()) => Writable::Yes,
// 删不掉:目录**能写**,但探针留在了用户的目录里。以前这里是
// `let _ = ...` 直接吞掉 —— 失败既没有返回值也没有日志。现在把路径
// 带回给调用方,由 `self_test` 打一行提示。
Err(_) => Writable::ProbeLeft(probe),
}
}
/// 探针文件名用的短随机串。
///
/// C# 用 `Guid.NewGuid().ToString("N").Substring(0, 6)`;这里只需要「同一目录下两次
/// 并发探测不撞名」,用时间戳纳秒 + 进程号做个 xorshift 足够,省掉一个 uuid 依赖。
fn short_token() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0) as u64;
let mut x = nanos ^ ((std::process::id() as u64) << 32 | 0x9E37_79B9);
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
format!("{:06x}", (x & 0x00FF_FFFF) as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn show_makes_paths_relative_to_the_program_root() {
let p = Paths::from_exe_dir(r"C:\deploy");
// 根自己 → `.`
assert_eq!(p.show(p.root()), ".");
// 根内 → 去掉根前缀
assert_eq!(p.show(&p.logs().join("nfarms-hub.log")), r"logs\nfarms-hub.log");
assert_eq!(p.show(&p.lib("model").join("qwen3.gguf")), r"libs\model\qwen3.gguf");
// 根外 → 原样绝对路径(硬凑 `..\..\` 只会更难读)
assert_eq!(p.show(Path::new(r"C:\Windows\notepad.exe")), r"C:\Windows\notepad.exe");
}
#[test]
fn show_does_not_confuse_a_sibling_directory_for_a_child() {
// `C:\deploy-old\...` **不是** `C:\deploy` 的子路径 —— strip_prefix 按
// 路径分量比较,所以这里不会误判成相对路径。这条要是错了,报告里会
// 出现 `-old\x` 这种鬼东西。
let p = Paths::from_exe_dir(r"C:\deploy");
assert_eq!(
p.show(Path::new(r"C:\deploy-old\x.log")),
r"C:\deploy-old\x.log"
);
}
/// Windows 上同一个目录可能被写成不同的大小写;`strip_prefix` 逐字节比较
/// 会失配,于是本该是 `logs\x.log` 的路径在报告里变成一整条绝对路径。
#[cfg(windows)]
#[test]
fn show_relativizes_paths_that_differ_only_in_case() {
let p = Paths::from_exe_dir(r"C:\deploy");
// 目录名大小写不同
assert_eq!(p.show(Path::new(r"C:\DEPLOY\logs\x.log")), r"logs\x.log");
// 盘符大小写不同;切出来的那一段保持调用方给的原样
assert_eq!(p.show(Path::new(r"c:\deploy\Logs\X.LOG")), r"Logs\X.LOG");
// 根自己(大小写不同)仍然是 `.`
assert_eq!(p.show(Path::new(r"C:\DEPLOY")), ".");
// 大小写放宽**不能**跨分量边界:同前缀的兄弟目录还在根外
assert_eq!(
p.show(Path::new(r"c:\deploy-old\x.log")),
r"c:\deploy-old\x.log"
);
}
#[test]
fn show_opt_handles_none() {
let p = Paths::from_exe_dir(r"C:\deploy");
assert_eq!(p.show_opt(None), "(没有)");
// `src\` 已取消:设置文件就在程序根,所以相对路径就是文件名本身
assert_eq!(p.show_opt(Some(&p.src().join("store.json"))), "store.json");
}
/// **程序根 == exe 目录,一律不上溯。**
///
/// 以前 exe 放 `bin\`、根要往上退一层。现在两个 exe 平铺在根目录,
/// 再上溯就会跑到程序根**外面**去 —— 设置读不到、日志写到别人家。
/// 这条测试是那个改动的哨兵。
#[test]
fn root_is_always_the_exe_directory() {
let p = Paths::from_exe_dir(r"C:\deploy");
assert_eq!(p.root(), Path::new(r"C:\deploy"));
assert_eq!(p.exe_dir(), Path::new(r"C:\deploy"));
// 即使 exe 恰好待在一个叫 bin 的目录里,也**不再**上溯
let q = Paths::from_exe_dir(r"C:\deploy\bin");
assert_eq!(q.root(), Path::new(r"C:\deploy\bin"));
}
#[test]
fn trailing_separator_is_ignored() {
// C# 的 BaseDirectory 一定带结尾反斜杠,不能在 Rust 这边变成「目录名是空串」。
let p = Paths::from_exe_dir(r"C:\deploy\");
assert_eq!(p.root(), Path::new(r"C:\deploy"));
assert_eq!(p.logs(), PathBuf::from(r"C:\deploy\logs"));
}
/// exe 直接放在卷根时,程序根必须是 `C:\` 而不是 `C:`。
///
/// `C:` 在 Windows 上是**盘符相对路径**(当前盘上的当前目录):`C:libs`
/// 会跟着「当前盘上的当前目录」漂移,报告里也看不出它到底在哪。
/// 这条测试盯住 `normalize_dir` 对盘符根的例外。
#[cfg(windows)]
#[test]
fn a_drive_root_keeps_its_separator() {
let p = Paths::from_exe_dir(r"C:\");
assert_eq!(p.root(), Path::new(r"C:\"));
assert!(p.root().is_absolute(), "`C:` 是盘符相对路径,不是绝对路径");
assert_eq!(p.libs(), PathBuf::from(r"C:\libs"));
assert_eq!(p.logs(), PathBuf::from(r"C:\logs"));
// 正斜杠写进来的盘符根同样保留(Windows 两种分隔符都认)
assert_eq!(Paths::from_exe_dir("C:/").root(), Path::new("C:/"));
assert!(Paths::from_exe_dir("C:/").root().is_absolute());
// 真正的盘符相对路径(`C:` 后面什么都没有)不在例外里,原样保留
assert_eq!(Paths::from_exe_dir("C:").root(), Path::new("C:"));
assert!(!Paths::from_exe_dir("C:").root().is_absolute());
}
/// `src\` 指向**程序根自己**,不是子目录。
#[test]
fn src_collapses_into_the_root() {
let p = Paths::from_exe_dir(r"C:\deploy");
assert_eq!(p.src(), PathBuf::from(r"C:\deploy"));
// 设置文件因此直接落在根下
assert_eq!(
p.data("settings.json"),
PathBuf::from(r"C:\deploy\settings.json")
);
}
/// 顶层约定目录只剩三个,而且不含 bin / src。
#[test]
fn convention_dirs_dropped_bin_and_src() {
assert_eq!(CONVENTION_DIRS, ["libs", "logs", "wiki"]);
let p = Paths::from_exe_dir(r"C:\deploy");
let names: Vec<&str> = p.convention_dirs().iter().map(|(n, _)| *n).collect();
assert_eq!(names, ["libs", "logs", "wiki", r"logs\bak", r"libs\live"]);
assert!(!names.contains(&"bin"));
assert!(!names.contains(&"src"));
}
/// 名单**只有一份**:顶层显示名必须就是 [`CONVENTION_DIRS`],路径必须分别
/// 等于 `libs()`/`logs()`/`wiki()`/`bak()`/`live()`。
///
/// 以前常量与函数体是各写各的两份字面量:改 `CONVENTION_DIRS`,`--layout-test`
/// 的输出一点也不变(输出走的是函数体那份)。这条测试就是不让双名单长回来。
#[test]
fn convention_dirs_agree_with_the_named_dirs() {
let p = Paths::from_exe_dir(r"C:\deploy");
// `to_vec()` 而不是 `.iter().cloned().collect()`:数组经 `Deref` 到切片,
// 一行就能拿到底层那份 `Clone` 的拷贝,也是 clippy `iter_cloned_collect`
// 要求改成的写法(`p.convention_dirs()` 是**临时数组**,借用它的迭代器
// 还要多一层生命周期噪音)。
let got: Vec<(&str, PathBuf)> = p.convention_dirs().to_vec();
assert_eq!(
got,
vec![
("libs", p.libs()),
("logs", p.logs()),
("wiki", p.wiki()),
(r"logs\bak", p.bak()),
(r"libs\live", p.live()),
]
);
let names: Vec<&str> = got.iter().map(|(n, _)| *n).collect();
assert_eq!(
&names[..CONVENTION_DIRS.len()],
&CONVENTION_DIRS[..],
"顶层的显示名必须来自常量,不能再手抄一遍"
);
}
#[test]
fn exe_elsewhere_degrades_to_its_own_dir() {
let p = Paths::from_exe_dir(r"C:\portable");
assert_eq!(p.root(), Path::new(r"C:\portable"));
assert_eq!(p.libs(), PathBuf::from(r"C:\portable\libs"));
}
#[test]
fn writable_detects_a_real_directory() {
let dir = std::env::temp_dir().join("nfarms-paths-test");
assert_eq!(Paths::writable(&dir), Writable::Yes);
assert!(Paths::writable(&dir).is_writable());
// 探针必须自己清干净,不能留下垃圾。
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(".write-probe-"))
.collect();
assert!(leftovers.is_empty(), "探针文件没删掉");
let _ = std::fs::remove_dir_all(&dir);
}
/// 「写得进去、探针却删不掉」这条路径以前被 `let _ =` 吞掉:函数照旧报
/// `true`,用户目录里却多出 `.write-probe-xxxxxx`,而没有任何解释。
///
/// 复现手法:先用**不允许删除**的共享模式占住探针文件名
/// (`FILE_SHARE_READ | FILE_SHARE_WRITE`,但不给 `FILE_SHARE_DELETE`)——
/// 覆写仍然成功,`remove_file` 会因为共享冲突失败。这正是要抓的那条分支。
/// 也是 [`writable_with_token`] 存在的唯一理由:token 随机的话这个文件名
/// 没法提前占住。
#[cfg(windows)]
#[test]
fn a_probe_that_cannot_be_deleted_is_reported_instead_of_swallowed() {
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
let dir = std::env::temp_dir().join(format!("nfarms-paths-probe-left-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let token = "deadbee";
let probe = dir.join(format!(".write-probe-{token}"));
let held = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.open(&probe)
.unwrap();
let state = writable_with_token(&dir, token);
assert_eq!(
state,
Writable::ProbeLeft(probe.clone()),
"探针删不掉必须报出来,不能再静默吞掉"
);
assert!(
state.is_writable(),
"探针留在磁盘上不等于目录不可写 —— 判成不可写会让报告说谎"
);
drop(held);
let _ = std::fs::remove_file(&probe);
let _ = std::fs::remove_dir_all(&dir);
}
}