l-s 0.5.0

Summary any file‘s meta.
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(())
}