feat: 0.5.0 新增子目录增量校验功能

- 当子目录存在 meta.json 时,直接进行 xxh128 快速校验而非重新计算
- 添加详细的错误提示(校验失败/新增文件/文件缺失)
- ProgressTracker 添加 multi() 方法,支持 MultiProgress::suspend 协同输出
- 优化排序:sort_by → sort_unstable_by_key
- 版本号更新至 0.5.0
This commit is contained in:
2026-04-03 21:22:12 +08:00
parent 650e92ad5f
commit d7da1c325f
4 changed files with 66 additions and 11 deletions

View File

@@ -1,8 +1,9 @@
use std::collections::BTreeMap;
use std::fs;
use std::fs::File;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use super::file::{calc_xxh128_with_callback, FileMeta};
@@ -15,6 +16,7 @@ pub struct DirSnapshot {
pub dir_name: String,
pub dirs: Vec<DirSnapshot>,
pub files: Vec<FileMeta>,
// 如果 Option::is_none即该字段为 None序列化为 JSON 时会跳过(不输出)该字段
#[serde(skip_serializing_if = "Option::is_none")]
pub v: Option<String>,
}
@@ -49,7 +51,7 @@ impl DirSnapshot {
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("读取目录失败: {}", path.display()))?;
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
entries.sort_unstable_by_key(|e| e.file_name());
for entry in entries {
let file_name = entry.file_name();
@@ -67,7 +69,12 @@ impl DirSnapshot {
if should_skip_dir(&name) {
continue;
}
dirs.push(Self::build_node(&full_path, tracker)?);
let sub_meta = full_path.join("meta.json");
if sub_meta.exists() {
dirs.push(Self::verify_and_load(&full_path, tracker)?);
} else {
dirs.push(Self::build_node(&full_path, tracker)?);
}
continue;
}
@@ -112,6 +119,51 @@ impl DirSnapshot {
dir.collect_into(next, map);
}
}
/// 加载子目录的 meta.json 并通过 xxh128 快速校验。
/// 校验通过则返回已有的 DirSnapshot否则返回 Err 终止流程。
fn verify_and_load(path: &Path, tracker: &ProgressTracker) -> Result<Self> {
let meta_path = path.join("meta.json");
let meta_file = File::open(&meta_path)
.with_context(|| format!("无法读取: {}", meta_path.display()))?;
let snapshot: Self = serde_json::from_reader(meta_file)
.with_context(|| format!("无法解析: {}", meta_path.display()))?;
let mut stored = snapshot.collect_file_map(path);
let mut current = BTreeMap::new();
walk_dir_with_progress(path, &mut current, tracker)?;
for (file_path, hash) in current {
if let Some(meta) = stored.remove(&file_path) {
if hash != meta.xxh128 {
return Err(anyhow!(
"校验失败: {}\n 期望: {}\n 当前: {}",
file_path.display(),
meta.xxh128,
hash
));
}
} else {
return Err(anyhow!("文件新增: {}", file_path.display()));
}
}
if let Some((missing_path, _)) = stored.into_iter().next() {
return Err(anyhow!("文件缺失: {}", missing_path.display()));
}
// 须通过 MultiProgress::suspend先清屏进度区再打印否则 stderr 上的独立 eprintln
// 会被下一次 tick 的光标移动覆盖,看起来像「只有进度条在往上刷」。
let msg = format!("✓ 校验通过: {}", path.display());
if let Some(multi) = tracker.multi() {
multi.suspend(|| {
eprintln!("{msg}");
});
} else {
eprintln!("{msg}");
}
Ok(snapshot)
}
}
pub fn scan_dir_xxh128(path: &Path) -> Result<BTreeMap<PathBuf, String>> {
@@ -173,7 +225,7 @@ fn walk_dir_with_progress(
.with_context(|| format!("无法遍历目录: {}", path.display()))?
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("读取目录失败: {}", path.display()))?;
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
entries.sort_unstable_by_key(|e| e.file_name());
for entry in entries {
let file_name = entry.file_name();