- 当子目录存在 meta.json 时,直接进行 xxh128 快速校验而非重新计算 - 添加详细的错误提示(校验失败/新增文件/文件缺失) - ProgressTracker 添加 multi() 方法,支持 MultiProgress::suspend 协同输出 - 优化排序:sort_by → sort_unstable_by_key - 版本号更新至 0.5.0
269 lines
8.6 KiB
Rust
269 lines
8.6 KiB
Rust
use std::collections::BTreeMap;
|
||
use std::fs;
|
||
use std::fs::File;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use anyhow::{anyhow, Context, Result};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use super::file::{calc_xxh128_with_callback, FileMeta};
|
||
use super::progress::ProgressTracker;
|
||
use crate::constants::META_VERSION;
|
||
use crate::utils::{basename, should_skip_dir, should_skip_file};
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
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>,
|
||
}
|
||
|
||
impl DirSnapshot {
|
||
pub fn build_root(path: &Path) -> Result<Self> {
|
||
let total_files = count_files(path)?;
|
||
let tracker = ProgressTracker::new(total_files, "构建中...");
|
||
|
||
let mut node = Self::build_node(path, &tracker)?;
|
||
node.v = Some(META_VERSION.to_string());
|
||
|
||
tracker.finish("构建完成");
|
||
Ok(node)
|
||
}
|
||
|
||
pub fn from_reader<R: std::io::Read>(reader: R) -> Result<Self> {
|
||
Ok(serde_json::from_reader(reader)?)
|
||
}
|
||
|
||
fn build_node(path: &Path, tracker: &ProgressTracker) -> Result<Self> {
|
||
let dir_name = path
|
||
.file_name()
|
||
.map(basename)
|
||
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||
|
||
let mut dirs = Vec::new();
|
||
let mut files = Vec::new();
|
||
|
||
let mut entries = fs::read_dir(path)
|
||
.with_context(|| format!("无法遍历目录: {}", path.display()))?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.with_context(|| format!("读取目录失败: {}", path.display()))?;
|
||
|
||
entries.sort_unstable_by_key(|e| e.file_name());
|
||
|
||
for entry in entries {
|
||
let file_name = entry.file_name();
|
||
let name = file_name.to_string_lossy().to_string();
|
||
let full_path = entry.path();
|
||
let file_type = entry
|
||
.file_type()
|
||
.with_context(|| format!("无法读取类型: {}", full_path.display()))?;
|
||
|
||
if file_type.is_symlink() {
|
||
continue;
|
||
}
|
||
|
||
if file_type.is_dir() {
|
||
if should_skip_dir(&name) {
|
||
continue;
|
||
}
|
||
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;
|
||
}
|
||
|
||
if should_skip_file(&name) {
|
||
continue;
|
||
}
|
||
|
||
// 获取文件大小并开始跟踪
|
||
let file_size = entry.metadata()
|
||
.map(|m| m.len())
|
||
.unwrap_or(0);
|
||
tracker.start_file(file_size, &name);
|
||
|
||
let on_bytes = tracker.bytes_callback();
|
||
let on_iop = tracker.iop_callback();
|
||
let meta = FileMeta::from_path_with_callback(&full_path, on_bytes, on_iop)?;
|
||
files.push(meta);
|
||
tracker.finish_file();
|
||
}
|
||
|
||
Ok(Self {
|
||
dir_name,
|
||
dirs,
|
||
files,
|
||
v: None,
|
||
})
|
||
}
|
||
|
||
pub fn collect_file_map(&self, root: &Path) -> BTreeMap<PathBuf, FileMeta> {
|
||
let mut map = BTreeMap::new();
|
||
self.collect_into(root.to_path_buf(), &mut map);
|
||
map
|
||
}
|
||
|
||
fn collect_into(&self, current: PathBuf, map: &mut BTreeMap<PathBuf, FileMeta>) {
|
||
for file in &self.files {
|
||
map.insert(current.join(&file.basename), file.clone());
|
||
}
|
||
|
||
for dir in &self.dirs {
|
||
let next = current.join(&dir.dir_name);
|
||
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>> {
|
||
let total_files = count_files(path)?;
|
||
let tracker = ProgressTracker::new(total_files, "扫描中...");
|
||
|
||
let mut map = BTreeMap::new();
|
||
walk_dir_with_progress(path, &mut map, &tracker)?;
|
||
|
||
tracker.finish("扫描完成");
|
||
Ok(map)
|
||
}
|
||
|
||
fn count_files(path: &Path) -> Result<u64> {
|
||
let mut count = 0u64;
|
||
count_files_recursive(path, &mut count)?;
|
||
Ok(count)
|
||
}
|
||
|
||
fn count_files_recursive(path: &Path, count: &mut u64) -> Result<()> {
|
||
let entries = fs::read_dir(path)
|
||
.with_context(|| format!("无法遍历目录: {}", path.display()))?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.with_context(|| format!("读取目录失败: {}", path.display()))?;
|
||
|
||
for entry in entries {
|
||
let file_name = entry.file_name();
|
||
let name = file_name.to_string_lossy().to_string();
|
||
let full_path = entry.path();
|
||
let file_type = entry
|
||
.file_type()
|
||
.with_context(|| format!("无法读取类型: {}", full_path.display()))?;
|
||
|
||
if file_type.is_symlink() {
|
||
continue;
|
||
}
|
||
|
||
if file_type.is_dir() {
|
||
if should_skip_dir(&name) {
|
||
continue;
|
||
}
|
||
count_files_recursive(&full_path, count)?;
|
||
} else {
|
||
if !should_skip_file(&name) {
|
||
*count += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn walk_dir_with_progress(
|
||
path: &Path,
|
||
map: &mut BTreeMap<PathBuf, String>,
|
||
tracker: &ProgressTracker,
|
||
) -> Result<()> {
|
||
let mut entries = fs::read_dir(path)
|
||
.with_context(|| format!("无法遍历目录: {}", path.display()))?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.with_context(|| format!("读取目录失败: {}", path.display()))?;
|
||
entries.sort_unstable_by_key(|e| e.file_name());
|
||
|
||
for entry in entries {
|
||
let file_name = entry.file_name();
|
||
let name = file_name.to_string_lossy().to_string();
|
||
let full_path = entry.path();
|
||
let file_type = entry
|
||
.file_type()
|
||
.with_context(|| format!("无法读取类型: {}", full_path.display()))?;
|
||
|
||
if file_type.is_symlink() {
|
||
continue;
|
||
}
|
||
|
||
if file_type.is_dir() {
|
||
if should_skip_dir(&name) {
|
||
continue;
|
||
}
|
||
walk_dir_with_progress(&full_path, map, tracker)?;
|
||
continue;
|
||
}
|
||
|
||
if should_skip_file(&name) {
|
||
continue;
|
||
}
|
||
|
||
// 获取文件大小并开始跟踪
|
||
let file_size = entry.metadata()
|
||
.map(|m| m.len())
|
||
.unwrap_or(0);
|
||
tracker.start_file(file_size, &name);
|
||
|
||
let on_bytes = tracker.bytes_callback();
|
||
let on_iop = tracker.iop_callback();
|
||
let hash = calc_xxh128_with_callback(&full_path, on_bytes, on_iop)?;
|
||
map.insert(full_path, hash);
|
||
tracker.finish_file();
|
||
}
|
||
|
||
Ok(())
|
||
}
|