跳转至

crates\nfarms-core\src\log.rs

源码路径:crates\nfarms-core\src\log.rs · 801 行

//! 统一日志。对应 C# `src\Log.cs`。
//!
//! **按功能分文件**:主日志 `nfarms-hub.log` 记全量时间线,
//! 每个功能再写自己的 `logs\<功能>.log`(环境检测 `env.log`、界面 `gui.log`、
//! 模型 `sd.log` / `llama.log` …)。
//!
//! 日志写在**软件所在目录的相对路径 `logs\`** 下;软件目录不可写
//! (装在 Program Files、或跑在只读环境里)时自动退回 `%TEMP%\NFarmsHUB-logs`,
//! 保证任何环境都能落盘。
//!
//! 单个文件超过 [`MAX_LOG_BYTES`] 时轮转成 `<文件名>.1`(只留一份旧日志):
//! 不轮转的话 `nfarms-hub.log` 只增不减,装一次跑几年就是几 GB。

use std::borrow::Cow;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};

use crate::paths::Paths;

/// 主日志:**全量时间线**,所有功能都往里写一份。
pub const APP_LOG: &str = "nfarms-hub.log";
/// 打印代理日志(`nfos-printer.exe` **自己**的日志)。
///
/// 注意这个文件**不是** `write("printer", …)` 写的那一份:打印代理在 C# 里是用
/// `Log.WriteTo(LogPath, …)` 直接写这里的(`cs-src/PrintServer.cs:37`,`LogPath` 取
/// `Log.PrinterPath`,见 `cs-src/Log.cs:53`),而 hub 这份 `write("printer", …)`
/// 走的是本移植"按功能再写一份 `logs\<source>.log`"的约定,落在 `logs\printer.log`。
/// 两者**故意不同名**:`nfos-printer.log` 是与外部 exe 的约定
/// (`paths.rs` 的目录说明、`scripts\build-release.ps1` 都按这个名字记),
/// 把 `PRINTER_LOG` 改成 `printer.log` 去"统一",等于让 hub 和打印代理抢同一个文件。
/// 本 crate 只负责这个路径约定;写它的 exe 还没进 workspace(根 `Cargo.toml` 注「阶段 2」)。
pub const PRINTER_LOG: &str = "nfos-printer.log";

/// 单个日志文件的字节上限。
///
/// 与 GUI 日志查看器的 `LOG_MAX_BYTES`(`nfarms-gui/src/lib.rs`)取同一个值:那里
/// 超过 8 MiB 就只读尾部,而这里在**写入之前**就把文件卡在 8 MiB 以内,于是查看器
/// 永远能拿到完整文件。两个上限是一份契约,改一个必须改另一个。
pub const MAX_LOG_BYTES: u64 = 8 * 1024 * 1024;

/// 轮转出来的旧日志后缀:`nfarms-hub.log` → `nfarms-hub.log.1`。
const ROTATED_SUFFIX: &str = ".1";

/// `Log::tail` 一次从文件尾部读多少字节。
///
/// 界面是**每帧**读一次日志尾巴的(dsh 页读 40 行),所以这里不能整份读 ——
/// 日志涨到几十 MB 以后,每帧一次"整份读 + 整份解码"就是帧率随日志体积下降。
/// 40 行通常只有几 KB,64 KiB 留足了余量;真不够时 `tail` 会按需倍增窗口。
const TAIL_CHUNK_BYTES: u64 = 64 * 1024;

#[derive(Debug, Clone)]
pub struct Log {
    dir: PathBuf,
    using_temp: bool,
}

impl Log {
    /// 按目录约定定位日志目录,不可写就退回临时目录。
    pub fn detect(paths: &Paths) -> Self {
        let candidate = paths.logs();
        if try_prepare(&candidate) {
            return Self {
                dir: candidate,
                using_temp: false,
            };
        }
        let temp = std::env::temp_dir().join("NFarmsHUB-logs");
        let _ = try_prepare(&temp);
        Self {
            dir: temp,
            using_temp: true,
        }
    }

    /// 指定目录构造(测试用)。
    pub fn at(dir: impl Into<PathBuf>) -> Self {
        let dir = dir.into();
        try_prepare(&dir);
        Self {
            dir,
            using_temp: false,
        }
    }

    pub fn dir(&self) -> &Path {
        &self.dir
    }

    /// 是否退到了临时目录 —— 界面要据此标注,否则用户找不到日志。
    pub fn using_temp_dir(&self) -> bool {
        self.using_temp
    }

    pub fn path_of(&self, name: &str) -> PathBuf {
        self.dir.join(name)
    }

    pub fn app_path(&self) -> PathBuf {
        self.path_of(APP_LOG)
    }

    pub fn printer_path(&self) -> PathBuf {
        self.path_of(PRINTER_LOG)
    }

    /// 写日志。
    ///
    /// **按功能分文件**:除了主日志 `nfarms-hub.log`,还写一份到 `logs\<source>.log`。
    ///
    /// 为什么**两个都写**,而不是只留分文件:
    ///
    /// - 分文件是为了**排错时直接打开对应的那个** —— 单看环境检测的结果,
    ///   不用先把全量日志 findstr 过一遍;
    /// - 主日志是为了**出事时能按时间线看整体** —— 只分不合并,
    ///   就再也拼不出「那一刻这台机器上到底发生了什么」。
    ///
    /// 两者用途不同,都不能省。代价是磁盘上多一份副本,日志量不大,可以接受。
    pub fn write(&self, source: &str, msg: &str) {
        self.write_with_ts(source, msg, &timestamp());
    }

    /// [`Log::write`] 的实现:时间戳由调用方给。
    ///
    /// 一次 `write` 要写主日志和分文件两份,**两份必须带同一个时间戳** ——
    /// 各自去取系统时间的话,跨秒时同一件事在两个文件里差一秒,而"分文件 + 主日志
    /// 对着时间线看"正是这两个文件存在的全部理由,差一秒就白分了。
    /// 把时间戳提成参数还有个好处:测试能喂一个固定值,不必靠"两次取时间恰好落在
    /// 同一秒"这种碰运气的断言。
    fn write_with_ts(&self, source: &str, msg: &str, ts: &str) {
        self.write_to(&self.app_path(), source, msg, ts);
        if let Some(p) = self.function_path(source) {
            self.write_to(&p, source, msg, ts);
        }
    }

    /// 某个功能的日志文件:`logs\<source>.log`。
    ///
    /// `source` 会**变成文件名**,所以必须消毒。它现在都是 `"env"` / `"gui"`
    /// 这类内部常量,但**消毒不能等到有人把用户输入传进来才加** ——
    /// 那时候就是一次路径穿越。规则:只留 ASCII 字母数字和 `-` `_`,
    /// 其余(含 `.` 和路径分隔符)一律换成 `_`;空串返回 `None`(只写主日志)。
    ///
    /// 该映射**不单射**:`a/b` 与 `a_b` 都会落到 `a_b.log`,两个来源于是共用一份
    /// 分文件。这是**已知取舍**,不是漏判:要做到单射就得引入转义表(文件名会变得
    /// 认不出来),而眼下 source 全是内部常量,撞名只可能发生在未来引入用户输入的
    /// 那一天 —— 那一天要先决定转义规则,再动这里。
    ///
    /// 另外注意 `source == "printer"` 落到的是 `logs\printer.log`,而 [`PRINTER_LOG`]
    /// 指的是 `logs\nfos-printer.log`(打印代理 exe 自己写的日志),两者是**两个**
    /// 文件,别去"统一"它们(理由见 [`PRINTER_LOG`] 的注释)。
    pub fn function_path(&self, source: &str) -> Option<PathBuf> {
        let safe: String = source
            .chars()
            .map(|c| {
                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                    c
                } else {
                    '_'
                }
            })
            .collect();
        if safe.is_empty() {
            return None;
        }
        Some(self.path_of(&format!("{safe}.log")))
    }

    /// 写指定日志文件(`ts` 由调用方给,见 `Log::write_with_ts`)。
    ///
    /// 任何失败都吞掉 —— 日志写不进去不该拖垮主流程(这一点与 C# 版一致)。
    ///
    /// **每行独立 open → append → close**,不保留常开句柄,也没有跨进程写锁。
    /// 取舍:常开句柄会让外部工具(记事本、`tail -f`)打不开正在写的日志,也会让
    /// 轮转的 `rename` 在 Windows 上因句柄被占而失败;代价是每行一次 open/close
    /// 系统调用,在日志这个量级上可以忽略。C# 版同样是每次 open/append/close。
    ///
    /// `msg` 里的换行会被压成一个空格(见 `one_line`):读侧(`tail`、GUI 的分页
    /// 查看器、`findstr` 排查)全部按"一行一条"工作,放任调用方把整段命令输出塞进来,
    /// 一条日志就变成十几行,分页页码和"第几件事"一起对不上。
    pub fn write_to(&self, file: &Path, source: &str, msg: &str, ts: &str) {
        // C# 用 `Environment.NewLine`,在 Windows 上是 CRLF。
        // 日志是要被用户和工具直接看的,格式保持一致。
        let line = format!("[{}] [{}] {}\r\n", ts, source, one_line(msg));
        rotate_if_full(file, line.len() as u64);
        if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(file) {
            let _ = f.write_all(line.as_bytes());
        }
    }

    /// 读日志尾部若干行(界面/`--logs` 展示用)。
    ///
    /// **只从文件尾读一块,不再整份进内存。** 调用点里有一处是每帧执行的
    /// (dsh 页的"控制台输出"),旧实现每次都把整个日志读成 `String` 再切行,
    /// 日志一大帧率就跟着掉。窗口不够 `max_lines` 行时按需倍增,直到盖住整个
    /// 文件 —— 结果与整份读逐字一致。
    ///
    /// 两种情况仍走整份读,都保住了旧行为:
    /// - 文件本身不超过一个窗口(小文件、测试里的文件):同一条解码与报错路径;
    /// - 窗口已经扩到全文:只有"整份文件都不是 UTF-8"才会到这一步,
    ///   报出的错与旧实现相同。
    ///
    /// 有一处**有意的行为变化**:窗口之外的坏字节不再让整条尾巴变成错误文案。
    /// 日志里出现非法 UTF-8 一般是被别的工具动过,这时给用户看尾巴比什么都不给
    /// 更有用;而且要"发现"它就得把大文件整个读进来,正是这里要避免的。
    pub fn tail(file: &Path, max_lines: usize) -> String {
        // 旧实现里 max_lines == 0 取到的是空切片,拼出来就是空串。
        if max_lines == 0 {
            return String::new();
        }
        let mut f = match File::open(file) {
            Ok(f) => f,
            // C# 版文件不存在时返回空串,不报错。
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return String::new(),
            Err(e) => return format!("(读取日志失败: {e})"),
        };
        // 长度以"文件末尾"为准:日志是并发追加的,元数据里的长度可能已经过期。
        let len = match f.seek(SeekFrom::End(0)) {
            Ok(n) => n,
            Err(e) => return format!("(读取日志失败: {e})"),
        };
        if len <= TAIL_CHUNK_BYTES {
            return read_whole(file, max_lines);
        }
        let mut window = TAIL_CHUNK_BYTES;
        loop {
            let start = len - window.min(len);
            let buf = match read_from_end(&mut f, start) {
                Ok(b) => b,
                Err(e) => return format!("(读取日志失败: {e})"),
            };
            // 文件在两帧之间被轮转/截短了(比刚量到的长度还短):这一帧没东西可读,
            // 下一帧按新的长度重来,不能在这里死循环。
            if buf.is_empty() {
                return String::new();
            }
            match String::from_utf8(buf) {
                Ok(text) => {
                    if start == 0 {
                        return last_lines(&text, max_lines);
                    }
                    // 窗口从文件中间开始时,首行是被窗口切掉一半的。
                    // 要凑够 max_lines 行**完整**数据,窗口里至少得有 max_lines + 1 段
                    // (第一段就是那半截,要丢掉);否则扩窗重读 —— 宁可多读一次,
                    // 也不能把一行日志切成"半截 + 下一行"。
                    if text.lines().count() > max_lines
                        && let Some(nl) = text.find('\n')
                    {
                        return last_lines(&text[nl + 1..], max_lines);
                    }
                    window = window.saturating_mul(2).min(len);
                }
                // 窗口边界正好切开一个多字节字符:扩窗重读,别把这个文件当成坏的。
                Err(_) if window < len => window = window.saturating_mul(2).min(len),
                Err(_) => return read_whole(file, max_lines),
            }
        }
    }
}

/// 整份读一个日志文件再取尾部 —— 小文件与兜底路径共用。
fn read_whole(file: &Path, max_lines: usize) -> String {
    match std::fs::read_to_string(file) {
        Ok(t) => last_lines(&t, max_lines),
        Err(e) => format!("(读取日志失败: {e})"),
    }
}

/// 从 `start` 读到文件末尾。
///
/// 不设读取上限:日志只追加,而 `start` 是按**刚量到**的长度算出来的,
/// 两帧之间最多多出几行,实际读到的量就是窗口大小再加那几行。
fn read_from_end(f: &mut File, start: u64) -> std::io::Result<Vec<u8>> {
    f.seek(SeekFrom::Start(start))?;
    let mut buf = Vec::new();
    f.read_to_end(&mut buf)?;
    Ok(buf)
}

/// 取文本末尾 `max_lines` 行,行间用 CRLF 连接。
///
/// C# 是逐行 `AppendLine`(CRLF)拼完再 `TrimEnd()`,这里保持一致。
///
/// 因此这会**规范化行尾**:把 LF 或孤立 CR 结尾的文件读出来,行间一律变成 CRLF。
/// 对本程序自己写的日志是恒等变换(写侧就是 CRLF),只有被外部工具改过行尾的文件
/// 才会看出差别 —— 那时给的是"能看的行",而不是原始字节。要忠实回显就得改成按
/// 字节切片原样返回,那会和 C# 的输出格式分叉,所以这里选择保持格式一致。
fn last_lines(text: &str, max_lines: usize) -> String {
    let text = crate::strip_bom(text);
    let lines: Vec<&str> = text.lines().collect();
    let from = lines.len().saturating_sub(max_lines);
    lines[from..].join("\r\n").trim_end().to_string()
}

/// 把 `msg` 里的换行压成一个空格,让"一条日志"真的只占一行。
///
/// 调用点里有不少是把命令输出、异常链整段丢进来的,而读侧(`tail`、GUI 的分页
/// 查看器、`findstr`)全部按"一行一条"工作 —— 不压的话一条日志变十几行,分页页码、
/// `tail` 的行数、人工排查时的"第几件事"会一起失准。
/// 规则:**连续的**换行/回车合成一个空格,首尾的换行直接丢掉(避免行首/行尾多出空格);
/// 只在真的含换行时才分配(`Cow`),绝大多数单行字面量不付代价。
fn one_line(msg: &str) -> Cow<'_, str> {
    if !msg.contains('\r') && !msg.contains('\n') {
        return Cow::Borrowed(msg);
    }
    let mut out = String::with_capacity(msg.len());
    let mut pending_break = false;
    for c in msg.chars() {
        if c == '\r' || c == '\n' {
            pending_break = true;
            continue;
        }
        if pending_break && !out.is_empty() {
            out.push(' ');
        }
        pending_break = false;
        out.push(c);
    }
    Cow::Owned(out)
}

/// 写这一行会让文件超过 [`MAX_LOG_BYTES`] 时,先把旧文件改名成 `<名字>.1`。
///
/// 只留**一份**旧日志:排查看的是最近发生了什么,`.2`/`.3` 只是占盘。判据含
/// `incoming`,所以活动文件永远不会超过上限 —— 这正是 GUI 查看器(超过
/// `LOG_MAX_BYTES` 只读尾部)能永远拿到完整文件的前提。
///
/// 改名失败(别处占着句柄、目录突然只读)时**什么都不做,继续追加**:宁可这一次
/// 不轮转,也不能因为轮转失败把日志丢掉。`std::fs::rename` 在 Windows 上等价于
/// `MoveFileEx(MOVEFILE_REPLACE_EXISTING)`,会直接覆盖上一份 `.1`。
fn rotate_if_full(file: &Path, incoming: u64) {
    let Ok(meta) = std::fs::metadata(file) else {
        // 文件还不存在(或读不到元数据)= 没有可轮转的东西,直接写新的。
        return;
    };
    if !meta.is_file() || meta.len() + incoming <= MAX_LOG_BYTES {
        return;
    }
    let mut rotated = file.as_os_str().to_os_string();
    rotated.push(ROTATED_SUFFIX);
    let _ = std::fs::rename(file, PathBuf::from(rotated));
}

/// `yyyy-MM-dd HH:mm:ss` 本地时间。
fn timestamp() -> String {
    crate::time::local_now()
}

/// 建目录 + 真写一个探针文件,确认「不只是存在,而是真的能写」。
fn try_prepare(dir: &Path) -> bool {
    if std::fs::create_dir_all(dir).is_err() {
        return false;
    }
    let probe = dir.join(probe_name());
    match OpenOptions::new().create(true).append(true).open(&probe) {
        Ok(_) => {
            let _ = std::fs::remove_file(&probe);
            true
        }
        Err(_) => false,
    }
}

/// 探针文件名。
///
/// 带进程号:固定名(`.w`)在两个实例同时首次启动时,先到的那个会把另一个刚创建的
/// 探针 `remove_file` 掉,`try_prepare` 的返回值(以及"这个目录能不能写"的判断)
/// 就成了噪声。带 pid 之后各写各的,互不干扰。
fn probe_name() -> String {
    format!(".w-{}", std::process::id())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn writes_one_crlf_terminated_line_per_call() {
        let dir = std::env::temp_dir().join("nfarms-log-test");
        let _ = std::fs::remove_dir_all(&dir);
        let log = Log::at(&dir);
        log.write("store", "hello");
        log.write("store", "world");

        let raw = std::fs::read_to_string(log.app_path()).unwrap();
        assert!(raw.ends_with("\r\n"), "日志行必须以 CRLF 结尾");
        let lines: Vec<&str> = raw.lines().collect();
        assert_eq!(lines.len(), 2);
        assert!(lines[0].starts_with('[') && lines[0].ends_with("] [store] hello"));
        assert!(lines[1].ends_with("] [store] world"));
        // 格式:[yyyy-MM-dd HH:mm:ss] [source] msg
        let ts = &lines[0][1..20]; // 去掉开头的 '[' 后正好是 19 字符的时间戳
        assert_eq!(ts.len(), 19);
        assert_eq!(ts.as_bytes()[4], b'-');
        assert_eq!(ts.as_bytes()[10], b' ');
        assert_eq!(ts.as_bytes()[13], b':');
        assert_eq!(lines[0].as_bytes()[20], b']');
        assert!(ts[..4].chars().all(|c| c.is_ascii_digit()));

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// **按功能分文件**是这一版的核心行为,不能只靠「能编过」。
    #[test]
    fn writes_both_the_timeline_and_a_per_function_file() {
        let dir = std::env::temp_dir().join("nfarms-log-split-test");
        let _ = std::fs::remove_dir_all(&dir);
        let log = Log::at(&dir);
        log.write("env", "检测完成");
        log.write("gui", "切到页面");

        // 主日志:全量时间线,两条都在
        let app = std::fs::read_to_string(log.app_path()).unwrap();
        assert_eq!(app.lines().count(), 2, "主日志应当是全量时间线");

        // 功能日志:各写各的,互不混入
        let env = std::fs::read_to_string(log.function_path("env").unwrap()).unwrap();
        assert_eq!(env.lines().count(), 1);
        assert!(env.contains("检测完成"));
        assert!(!env.contains("切到页面"), "env.log 里不该有 gui 的行");

        let gui = std::fs::read_to_string(log.function_path("gui").unwrap()).unwrap();
        assert!(gui.contains("切到页面") && !gui.contains("检测完成"));

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// `source` 会变成文件名 —— 消毒不到位就是路径穿越。
    #[test]
    fn function_name_is_sanitized_against_path_traversal() {
        let log = Log::at(std::env::temp_dir().join("nfarms-log-sanitize-test"));
        for evil in ["../../etc/passwd", r"..\..\windows\system32", "a/b", ".."] {
            let p = log.function_path(evil).unwrap();
            assert_eq!(p.parent(), Some(log.dir()), "{evil} 逃出了日志目录");
            let name = p.file_name().unwrap().to_string_lossy().to_string();
            assert!(!name.contains('/') && !name.contains('\\'), "文件名里还有分隔符: {name}");
            assert!(!name.contains(".."), "文件名里还有 ..: {name}");
        }
        // 空来源只写主日志,不产生奇怪的文件名
        assert!(log.function_path("").is_none());
    }
    #[test]
    fn tail_returns_empty_for_missing_file() {
        let missing = std::env::temp_dir().join("nfarms-log-does-not-exist.log");
        let _ = std::fs::remove_file(&missing);
        assert_eq!(Log::tail(&missing, 20), "");
    }

    #[test]
    fn tail_keeps_only_the_last_n_lines() {
        let dir = std::env::temp_dir().join("nfarms-log-tail-test");
        let _ = std::fs::remove_dir_all(&dir);
        let log = Log::at(&dir);
        for i in 0..10 {
            log.write("t", &format!("line{i}"));
        }
        let tail = Log::tail(&log.app_path(), 3);
        let lines: Vec<&str> = tail.lines().collect();
        assert_eq!(lines.len(), 3);
        assert!(lines[0].ends_with("line7"));
        assert!(lines[2].ends_with("line9"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 界面每帧都读一次日志尾巴 —— 大日志不能再整份进内存。
    ///
    /// 这份日志在**开头**埋了非法 UTF-8:整份读会当场报错返回错误文案,
    /// 只读尾部则正常给出最后几行。这条断言同时证明了窗口没有读到文件开头。
    #[test]
    fn tail_of_a_large_file_reads_only_the_end() {
        let dir = std::env::temp_dir().join("nfarms-log-tail-window-test");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let file = dir.join("big.log");

        let mut bytes: Vec<u8> = Vec::new();
        bytes.extend_from_slice(b"[2026-01-01 00:00:00] [x] \xff\xfe bad bytes here\r\n");
        while (bytes.len() as u64) < TAIL_CHUNK_BYTES + 4096 {
            bytes.extend_from_slice(b"[2026-01-01 00:00:00] [x] padding padding padding\r\n");
        }
        for i in 0..3 {
            bytes.extend_from_slice(format!("[2026-01-01 00:00:0{i}] [x] tail{i}\r\n").as_bytes());
        }
        std::fs::write(&file, &bytes).unwrap();

        let out = Log::tail(&file, 3);
        assert!(
            !out.contains("读取日志失败"),
            "窗口外的坏字节不该让整条尾巴变成错误: {out}"
        );
        assert_eq!(
            out,
            "[2026-01-01 00:00:00] [x] tail0\r\n\
             [2026-01-01 00:00:01] [x] tail1\r\n\
             [2026-01-01 00:00:02] [x] tail2"
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 一行比窗口还长时,窗口必须倍增到能装下 `max_lines` 行完整数据。
    #[test]
    fn tail_widens_the_window_when_a_line_exceeds_one_chunk() {
        let dir = std::env::temp_dir().join("nfarms-log-tail-widen-test");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let file = dir.join("long.log");

        let big = "x".repeat(40 * 1024);
        let mut text = String::new();
        for i in 0..3 {
            text.push_str(&format!("L{i}{big}\r\n"));
        }
        std::fs::write(&file, text.as_str()).unwrap();

        let out = Log::tail(&file, 2);
        let lines: Vec<&str> = out.lines().collect();
        assert_eq!(lines.len(), 2, "应当给出最后两行");
        assert_eq!(lines[0], format!("L1{big}"), "第一行必须完整,不能被窗口切掉头");
        assert_eq!(lines[1], format!("L2{big}"));

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 窗口边界正好落在汉字的中间时,不能把它当成"文件坏了",也不能少给行。
    #[test]
    fn tail_does_not_split_a_multibyte_character_at_the_window_edge() {
        let dir = std::env::temp_dir().join("nfarms-log-tail-utf8-test");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let file = dir.join("utf8.log");

        let mut content = String::new();
        while content.len() < 16 * 1024 {
            content.push_str("[x] head line\r\n");
        }
        content.push_str(&"中".repeat(24_000));
        // 从文件尾往前一个窗口就是起点。它落在汉字的**续字节**上才是要考的路径,
        // 否则(整字节对齐)这条用例考不到东西 —— 补几个 'A' 把起点推进去,
        // 补进去的字节算在期望值里。
        let mut pad = 0usize;
        loop {
            let start = content.len() - TAIL_CHUNK_BYTES as usize;
            if content.as_bytes()[start] & 0xC0 == 0x80 || pad >= 8 {
                break;
            }
            content.push('A');
            pad += 1;
        }
        let start = content.len() - TAIL_CHUNK_BYTES as usize;
        assert_eq!(
            content.as_bytes()[start] & 0xC0,
            0x80,
            "构造失败:窗口起点没落在汉字中间"
        );
        std::fs::write(&file, content.as_str()).unwrap();

        let out = Log::tail(&file, 2);
        assert!(
            !out.contains("读取日志失败"),
            "边界切开多字节字符不等于文件坏了: {out}"
        );
        assert_eq!(
            out,
            format!("[x] head line\r\n{}{}", "中".repeat(24_000), "A".repeat(pad))
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// `max_lines == 0` 在旧实现里就是空切片,别在新路径上变成"全部"。
    #[test]
    fn tail_of_zero_lines_is_empty() {
        let dir = std::env::temp_dir().join("nfarms-log-tail-zero-test");
        let _ = std::fs::remove_dir_all(&dir);
        let log = Log::at(&dir);
        for i in 0..5 {
            log.write("t", &format!("line{i}"));
        }
        assert_eq!(Log::tail(&log.app_path(), 0), "");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// `tail` 会**规范化行尾**:外部工具写的 LF 文件读出来行间是 CRLF。
    ///
    /// 这是有意的(对齐 C# 的逐行 `AppendLine` + 拼接),不是漏判。断言钉住它,
    /// 免得有人把"忠实回显原始字节"当成顺手修复,把两份日志的格式弄分叉。
    #[test]
    fn tail_normalizes_line_endings_to_crlf() {
        let dir = std::env::temp_dir().join("nfarms-log-tail-crlf-test");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let file = dir.join("lf.log");
        std::fs::write(&file, "a\nb\nc\n").unwrap();
        assert_eq!(Log::tail(&file, 2), "b\r\nc");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 一次 `write()` 的两个文件必须带**同一个**时间戳。
    ///
    /// 旧实现让 `write_to` 各自现取系统时间,跨秒时同一件事在两份日志里差一秒 ——
    /// 而"分文件 + 主日志对着时间线看"正是这两份日志存在的理由。这里喂固定时间戳:
    /// 谁把时间戳改回在 `write_to` 里现取,这条断言立刻红(不会是"碰巧同一秒")。
    #[test]
    fn one_write_uses_the_same_timestamp_for_both_files() {
        let dir = std::env::temp_dir().join("nfarms-log-timestamp-test");
        let _ = std::fs::remove_dir_all(&dir);
        let log = Log::at(&dir);
        let ts = "2000-01-02 03:04:05";
        log.write_with_ts("env", "同一秒", ts);

        assert_eq!(
            std::fs::read_to_string(log.app_path()).unwrap(),
            format!("[{ts}] [env] 同一秒\r\n")
        );
        assert_eq!(
            std::fs::read_to_string(log.function_path("env").unwrap()).unwrap(),
            format!("[{ts}] [env] 同一秒\r\n")
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 多行消息必须压成一行:否则一条日志十几行,分页/`tail`/`findstr` 全部失准。
    #[test]
    fn multiline_messages_are_collapsed_to_one_line() {
        let dir = std::env::temp_dir().join("nfarms-log-oneline-test");
        let _ = std::fs::remove_dir_all(&dir);
        let log = Log::at(&dir);
        log.write("cmd", "第一行\r\n第二行\n第三行\r第四行\n");

        let raw = std::fs::read_to_string(log.app_path()).unwrap();
        assert_eq!(raw.lines().count(), 1, "一条日志只能占一行: {raw:?}");
        assert_eq!(raw.matches("\r\n").count(), 1, "只该有一个行尾: {raw:?}");
        assert!(
            raw.ends_with("] [cmd] 第一行 第二行 第三行 第四行\r\n"),
            "{raw:?}"
        );
        // 分文件走同一条路径,行数口径必须一致
        let function = std::fs::read_to_string(log.function_path("cmd").unwrap()).unwrap();
        assert_eq!(function.lines().count(), 1, "{function:?}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// `one_line` 的边界:连续换行只留一个空格,首尾换行直接丢掉。
    #[test]
    fn one_line_collapses_breaks_and_drops_the_edges() {
        assert_eq!(one_line("a\r\n\r\nb").as_ref(), "a b");
        assert_eq!(one_line("a\rb").as_ref(), "a b");
        assert_eq!(one_line("\n a \n").as_ref(), " a ");
        assert_eq!(one_line("没有换行").as_ref(), "没有换行");
        assert_eq!(one_line("\r\n").as_ref(), "");
    }

    /// 到上限时必须轮转:`<主日志>.1` 留下旧内容,活动日志从新的一行开始。
    ///
    /// 用 `set_len` 把文件撑到上限 —— 判据只看元数据长度,没必要真写 8 MiB。
    #[test]
    fn rotates_the_app_log_before_it_can_exceed_the_limit() {
        let dir = std::env::temp_dir().join("nfarms-log-rotate-test");
        let _ = std::fs::remove_dir_all(&dir);
        let log = Log::at(&dir);
        let app = log.app_path();
        let rotated = dir.join(format!("{APP_LOG}{ROTATED_SUFFIX}"));
        {
            let f = std::fs::File::create(&app).unwrap();
            f.set_len(MAX_LOG_BYTES).unwrap();
        }

        log.write("t", "轮转之后");

        assert!(rotated.is_file(), "到上限必须留下 {:?}", rotated);
        assert_eq!(std::fs::metadata(&rotated).unwrap().len(), MAX_LOG_BYTES);
        let live = std::fs::read_to_string(&app).unwrap();
        assert_eq!(live.lines().count(), 1, "{live:?}");
        assert!(live.ends_with("] [t] 轮转之后\r\n"), "{live:?}");
        // 分文件刚建出来,离上限还远,不该跟着轮转
        assert!(!dir.join(format!("t.log{ROTATED_SUFFIX}")).exists());
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 只保留**一份**旧日志:第二次轮转覆盖 `.1`,不会堆出 `.2`/`.3`。
    #[test]
    fn rotation_keeps_exactly_one_old_file() {
        let dir = std::env::temp_dir().join("nfarms-log-rotate-one-test");
        let _ = std::fs::remove_dir_all(&dir);
        let log = Log::at(&dir);
        let app = log.app_path();
        let rotated = dir.join(format!("{APP_LOG}{ROTATED_SUFFIX}"));

        // 第一轮:旧文件填满 'A',轮转后 `.1` 里应当就是这些 'A'
        std::fs::write(&app, vec![b'A'; MAX_LOG_BYTES as usize]).unwrap();
        log.write("t", "第一轮");
        assert_eq!(std::fs::metadata(&rotated).unwrap().len(), MAX_LOG_BYTES);
        assert_eq!(std::fs::read(&rotated).unwrap()[0], b'A');
        assert!(
            std::fs::read_to_string(&app)
                .unwrap()
                .ends_with("] [t] 第一轮\r\n")
        );

        // 第二轮:活动文件又到上限,`.1` 必须被**覆盖**(内容不再是 'A')
        {
            let f = std::fs::File::create(&app).unwrap();
            f.set_len(MAX_LOG_BYTES).unwrap();
        }
        log.write("t", "第二轮");
        let rotated_bytes = std::fs::read(&rotated).unwrap();
        assert_eq!(rotated_bytes.len() as u64, MAX_LOG_BYTES);
        assert_ne!(rotated_bytes[0], b'A', "旧的 .1 必须被覆盖,而不是被追加");
        assert!(
            !dir.join(format!("{APP_LOG}{ROTATED_SUFFIX}{ROTATED_SUFFIX}"))
                .exists(),
            "只留一份旧日志,不许出现 .2"
        );
        let live = std::fs::read_to_string(&app).unwrap();
        assert_eq!(live.lines().count(), 1, "{live:?}");
        assert!(live.ends_with("] [t] 第二轮\r\n"), "{live:?}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 没到上限时一行都不许轮转(防止"每次写都轮转"这种改法混进来)。
    #[test]
    fn does_not_rotate_while_under_the_limit() {
        let dir = std::env::temp_dir().join("nfarms-log-no-rotate-test");
        let _ = std::fs::remove_dir_all(&dir);
        let log = Log::at(&dir);
        for i in 0..50 {
            log.write("t", &format!("line{i}"));
        }
        assert_eq!(
            std::fs::read_to_string(log.app_path())
                .unwrap()
                .lines()
                .count(),
            50
        );
        assert!(!dir.join(format!("{APP_LOG}{ROTATED_SUFFIX}")).exists());
        assert!(!dir.join(format!("t.log{ROTATED_SUFFIX}")).exists());
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 探针名带进程号,而且探针必须被清掉。
    ///
    /// 固定名 `.w` 在两个实例同时首次启动时会互相删探针,把"这个目录能不能写"
    /// 的判断变成噪声;这条断言就是给"改回固定名"上的刹车。
    #[test]
    fn probe_is_per_process_and_cleaned_up() {
        let dir = std::env::temp_dir().join("nfarms-log-probe-test");
        let _ = std::fs::remove_dir_all(&dir);
        assert!(try_prepare(&dir), "空目录应当可写");

        let name = probe_name();
        assert!(name.starts_with(".w-"), "探针名要带前缀: {name}");
        assert!(
            name.contains(&std::process::id().to_string()),
            "探针名要带进程号: {name}"
        );

        let leftovers: Vec<String> = std::fs::read_dir(&dir)
            .unwrap()
            .filter_map(|e| e.ok())
            .map(|e| e.file_name().to_string_lossy().to_string())
            .collect();
        assert!(leftovers.is_empty(), "探针必须被删掉,实际留下: {leftovers:?}");

        // 目录根本建不出来时必须老实返回 false(而不是"假装能写")
        let blocker = dir.join("blocker");
        std::fs::write(&blocker, b"x").unwrap();
        assert!(!try_prepare(&blocker.join("sub")));

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 打印代理日志与 `write("printer", …)` 的分文件**故意是两个文件**。
    ///
    /// `nfos-printer.log` 是打印代理 exe 自己写的日志(与外部程序的约定),
    /// `write("printer", …)` 走本移植"按功能再写一份"的约定,落在 `logs\printer.log`。
    /// 把两者"统一"成一个名字会让 hub 和打印代理抢同一个文件,这条断言是那种改法的刹车。
    #[test]
    fn printer_log_is_not_the_printer_function_log() {
        let log = Log::at(std::env::temp_dir().join("nfarms-log-printer-test"));
        let printer = log.printer_path();
        assert_eq!(printer.file_name().unwrap(), PRINTER_LOG);
        assert_eq!(
            log.function_path("printer").unwrap().file_name().unwrap(),
            "printer.log"
        );
        assert_ne!(printer, log.function_path("printer").unwrap());
    }

    /// 消毒**不单射**:不同 source 可能落到同一个分文件。
    ///
    /// 这是 `function_path` 注释里写明的取舍。断言放在这里,是为了让"注释说一套、
    /// 代码做一套"当场露馅 —— 真要改成单射,连注释一起改。
    #[test]
    fn sanitizing_can_map_two_different_sources_to_one_file() {
        let log = Log::at(std::env::temp_dir().join("nfarms-log-collide-test"));
        assert_eq!(log.function_path("a/b"), log.function_path("a_b"));
    }
}