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, ×tamp());
}
/// [`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"));
}
}