跳转至

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);
    }
}