diff --git a/.gitignore b/.gitignore index ea8c4bf..2f7896d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -/target +target/ diff --git a/Cargo.lock b/Cargo.lock index ff47c3d..5a804fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,7 +1,390 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed2k" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f994dfc4b4a48e6230596a86a2de4dfa988f67e2c6caf6c5888d27bfe3e8a378" +dependencies = [ + "digest", + "md4", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "l-s" -version = "0.1.0" +version = "0.1.1" +dependencies = [ + "anyhow", + "clap", + "ed2k", + "md-5", + "serde", + "serde_json", + "sha1", + "sha2", + "xxhash-rust", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "md4" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda" +dependencies = [ + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" diff --git a/Cargo.toml b/Cargo.toml index d41a67b..d66a955 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "l-s" -version = "0.1.0" +version = "0.1.1" authors = ["licsber "] edition = "2021" @@ -11,3 +11,12 @@ readme = "README.md" license = "AGPL-3.0-only" [dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } +ed2k = "1.0.1" +md-5 = "0.10" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha1 = "0.10" +sha2 = "0.10" +xxhash-rust = { version = "0.8", features = ["xxh3"] } diff --git a/README.md b/README.md index 9bf26e8..fc196d8 100644 --- a/README.md +++ b/README.md @@ -40,29 +40,29 @@ Summary any file‘s meta. ```json { - "dir_name": "test", - "dirs": [ + "dir_name": "test", + "dirs": [ + { + "dir_name": "child", + "dirs": [], + "files": [ { - "dir_name": "j", - "dirs": [], - "files": [ - { - "basename": "CW-NAS-J6-230729.iso", - "size": 20000768, - "friendly_size": "19.07MB", - "mtime": 1763445083, - "head_115": "0F62212861F9AB6213A793AA74B33C8856AA9D45", - "head_baidu": "FE3C25567C3681EEAA1EAAE7804460CF", - "ed2k": "D3F1330B892884240142296E0A670EE4", - "md5": "AB14F900F1ABBF6A723EB8F7D6DDABC1", - "sha1": "B1D31D15A866EE0C3DCC9972FB92EE6FA2BE4D4A", - "sha256": "E217F816094C5EE4CF366E6F168851C13B27D8CC9C42F392B637CAD1C20A8510", - "xxh128": "F4A47B42150480BE1193EA7DA0DFD083" - } - ] + "basename": "233", + "size": 4, + "friendly_size": "4.00B", + "mtime": 1763654099, + "head_115": "28AAB5A575FA1138E2CE5B1366AE697685775011", + "head_baidu": "1490AAA92CB684B2110DDB29D7A1AC15", + "ed2k": "6CB03133656BDB8DFC780EBBD4FF47CC", + "md5": "9F3D9739B11C2A4B08EA48512AC467F6", + "sha1": "10E25C6EC9A30C731BF82E5DBA37BC693E9F615D", + "sha256": "5F8064636753265C7F1B1EE075DF77E1AE9BCE7E94831DE583784A0C13EB902F", + "xxh128": "B92C6051418D532F7E933C08C44C4C88" } - ], - "files": [], - "v": "2022-10-24" + ] + } + ], + "files": [], + "v": "2022-10-24" } ``` diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..5966967 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; +use std::{env, fs}; + +use anyhow::{Context, Result}; +use clap::Parser; + +/// 负责解析命令行参数 +#[derive(Debug, Parser)] +// 这个宏定义命令行工具的信息,比如作者、版本和描述,用于帮助信息输出 +#[command(author, version, about = "汇总文件或文件夹的元信息")] +pub struct Cli { + /// 需要扫描的目标路径,默认当前工作目录 + pub path: Option, +} + +impl Cli { + pub fn resolve_path(&self) -> Result { + let candidate = match &self.path { + Some(p) => p.clone(), + None => env::current_dir().context("无法获取当前工作目录")?, + }; + + fs::canonicalize(&candidate) + .with_context(|| format!("无法解析路径: {}", candidate.display())) + } +} diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..c9af035 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,22 @@ +pub const DEFAULT_BUFFER_SIZE: usize = 4 * 1024 * 1024; +pub const HEAD_115_BYTES: usize = 128 * 1024; +pub const HEAD_BAIDU_BYTES: usize = 256 * 1024; +pub const META_VERSION: &str = "2025-11-21"; + +pub const SKIP_DIR_NAMES: &[&str] = &[ + "@Recently-Snapshot", + "@Recycle", + ".@__thumb", + "@Transcode", + "meta", + "$RECYCLE.BIN", +]; + +pub const SKIP_FILE_NAMES: &[&str] = &[ + ".DS_Store", + "licsber-bak.json", + "meta.json", + "meta-old.json", + "Thumbs.db", + "desktop.ini", +]; diff --git a/src/head_hash.rs b/src/head_hash.rs new file mode 100644 index 0000000..73271ce --- /dev/null +++ b/src/head_hash.rs @@ -0,0 +1,43 @@ +use md5::{digest::Digest, Md5}; +use sha1::Sha1; + +use crate::utils::hex_upper; + +pub struct HeadChunk { + buffer: Vec, + filled: usize, +} + +impl HeadChunk { + pub fn new(size: usize) -> Self { + Self { + buffer: vec![0u8; size], + filled: 0, + } + } + + pub fn feed(&mut self, data: &[u8]) { + if self.filled >= self.buffer.len() { + return; + } + let take = (self.buffer.len() - self.filled).min(data.len()); + self.buffer[self.filled..self.filled + take].copy_from_slice(&data[..take]); + self.filled += take; + } + + pub fn as_slice(&self) -> &[u8] { + &self.buffer + } +} + +pub fn calc_head_115(chunk: &[u8]) -> String { + let mut hasher = Sha1::default(); + hasher.update(chunk); + hex_upper(hasher.finalize()) +} + +pub fn calc_head_baidu(chunk: &[u8]) -> String { + let mut hasher = Md5::default(); + hasher.update(chunk); + hex_upper(hasher.finalize()) +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..906b225 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,97 @@ -fn main() { - println!("Hello, world!"); +mod cli; +mod constants; +mod head_hash; +mod meta; +mod utils; + +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use anyhow::{Context, Result}; +use clap::Parser; +use meta::{DirSnapshot, FileMeta}; + +fn main() -> Result<()> { + let started = Instant::now(); + let cli = cli::Cli::parse(); + let target = cli.resolve_path()?; + println!("目标: {}", target.display()); + + if target.is_dir() { + process_dir(&target)?; + } else { + process_file(&target)?; + } + + println!("耗时: {:?}", started.elapsed()); + Ok(()) +} + +fn process_file(path: &Path) -> Result<()> { + let meta = FileMeta::from_path(path)?; + let meta_dir = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) + .join("meta"); + fs::create_dir_all(&meta_dir) + .with_context(|| format!("无法创建目录: {}", meta_dir.display()))?; + + let save_path = meta_dir.join(format!("{}.json", meta.basename)); + if !save_path.exists() { + let json = meta.to_pretty_json()?; + println!("{}", json); + fs::write(&save_path, json)?; + return Ok(()); + } + + let existing = File::open(&save_path) + .with_context(|| format!("无法读取历史元数据: {}", save_path.display()))?; + let old_meta = FileMeta::from_reader(existing)?; + if meta.matches(&old_meta) { + println!("校验通过."); + } else { + println!("校验失败!"); + println!("现校验文件:"); + println!("{}", meta.to_pretty_json()?); + println!("原校验文件:"); + println!("{}", old_meta.to_pretty_json()?); + } + + Ok(()) +} + +fn process_dir(path: &Path) -> Result<()> { + let save_path = path.join("meta.json"); + let old_path = path.join("meta-old.json"); + let has_old = save_path.exists(); + + if has_old { + if old_path.exists() { + fs::remove_file(&old_path)?; + } + fs::rename(&save_path, &old_path) + .with_context(|| format!("无法备份旧文件: {}", save_path.display()))?; + } + + let snapshot = DirSnapshot::build_root(path)?; + let json = serde_json::to_string_pretty(&snapshot)?; + let mut file = + File::create(&save_path).with_context(|| format!("无法写入: {}", save_path.display()))?; + file.write_all(json.as_bytes())?; + + if has_old { + let old_meta = FileMeta::from_path(&old_path)?; + let new_meta = FileMeta::from_path(&save_path)?; + if old_meta.matches(&new_meta) { + println!("校验通过."); + fs::remove_file(&old_path)?; + } else { + println!("校验失败!"); + } + } + + Ok(()) } diff --git a/src/meta/file.rs b/src/meta/file.rs new file mode 100644 index 0000000..e1b6983 --- /dev/null +++ b/src/meta/file.rs @@ -0,0 +1,123 @@ +use std::fs::{self, File}; +use std::io::Read; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use ed2k::digest::Digest; +use ed2k::Ed2k; +use md5::Md5; +use serde::{Deserialize, Serialize}; +use sha1::Sha1; +use sha2::Sha256; +use xxhash_rust::xxh3::Xxh3; + +use crate::constants::{DEFAULT_BUFFER_SIZE, HEAD_115_BYTES, HEAD_BAIDU_BYTES}; +use crate::head_hash::{calc_head_115, calc_head_baidu, HeadChunk}; +use crate::utils::{basename, friendly_size, hex_upper}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileMeta { + pub basename: String, + pub size: u64, + pub friendly_size: String, + pub mtime: i64, + pub head_115: String, + pub head_baidu: String, + pub ed2k: String, + pub md5: String, + pub sha1: String, + pub sha256: String, + pub xxh128: String, +} + +impl FileMeta { + pub fn from_path(path: &Path) -> Result { + let info = + fs::metadata(path).with_context(|| format!("无法读取文件信息: {}", path.display()))?; + if !info.is_file() { + return Err(anyhow!("{} 不是文件", path.display())); + } + + let basename_str = basename( + path.file_name() + .ok_or_else(|| anyhow!("{} 缺少文件名", path.display()))?, + ); + let size = info.len(); + let friendly = friendly_size(size); + let mtime = info + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let mut file = + File::open(path).with_context(|| format!("无法打开文件: {}", path.display()))?; + + let mut buffer = vec![0u8; DEFAULT_BUFFER_SIZE]; + let mut md5_hasher = Md5::new(); + let mut sha1_hasher = Sha1::new(); + let mut sha256_hasher = Sha256::new(); + let mut xxh_hasher = Xxh3::new(); + let mut ed2k_hasher = Ed2k::new(); + let mut head115 = HeadChunk::new(HEAD_115_BYTES); + let mut head_baidu = HeadChunk::new(HEAD_BAIDU_BYTES); + + loop { + let read_len = file.read(&mut buffer)?; + if read_len == 0 { + break; + } + let chunk = &buffer[..read_len]; + md5_hasher.update(chunk); + sha1_hasher.update(chunk); + sha256_hasher.update(chunk); + xxh_hasher.update(chunk); + ed2k_hasher.update(chunk); + + head115.feed(chunk); + head_baidu.feed(chunk); + } + + let head_115 = calc_head_115(head115.as_slice()); + let head_baidu = calc_head_baidu(head_baidu.as_slice()); + + let md5_hex = hex_upper(md5_hasher.finalize()); + let sha1_hex = hex_upper(sha1_hasher.finalize()); + let sha256_hex = hex_upper(sha256_hasher.finalize()); + let xxh_hex = hex_upper(xxh_hasher.digest128().to_be_bytes()); + let ed2k_hex = hex_upper(ed2k_hasher.finalize()); + + Ok(Self { + basename: basename_str, + size, + friendly_size: friendly, + mtime, + head_115, + head_baidu, + ed2k: ed2k_hex, + md5: md5_hex, + sha1: sha1_hex, + sha256: sha256_hex, + xxh128: xxh_hex, + }) + } + + pub fn from_reader(reader: R) -> Result { + Ok(serde_json::from_reader(reader)?) + } + + pub fn to_pretty_json(&self) -> Result { + Ok(serde_json::to_string_pretty(self)?) + } + + pub fn matches(&self, other: &Self) -> bool { + self.size == other.size + && self.ed2k == other.ed2k + && self.md5 == other.md5 + && self.sha1 == other.sha1 + && self.sha256 == other.sha256 + && self.xxh128 == other.xxh128 + } +} diff --git a/src/meta/mod.rs b/src/meta/mod.rs new file mode 100644 index 0000000..d521503 --- /dev/null +++ b/src/meta/mod.rs @@ -0,0 +1,5 @@ +mod file; +mod tree; + +pub use file::FileMeta; +pub use tree::DirSnapshot; diff --git a/src/meta/tree.rs b/src/meta/tree.rs new file mode 100644 index 0000000..3f7d5aa --- /dev/null +++ b/src/meta/tree.rs @@ -0,0 +1,76 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::file::FileMeta; +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, + pub files: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub v: Option, +} + +impl DirSnapshot { + pub fn build_root(path: &Path) -> Result { + let mut node = Self::build_node(path)?; + node.v = Some(META_VERSION.to_string()); + Ok(node) + } + + fn build_node(path: &Path) -> Result { + 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::, _>>() + .with_context(|| format!("读取目录失败: {}", path.display()))?; + + entries.sort_by(|a, b| a.file_name().cmp(&b.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_dir() { + if should_skip_dir(&name) { + continue; + } + println!("目录: {}", full_path.display()); + dirs.push(Self::build_node(&full_path)?); + continue; + } + + if should_skip_file(&name) { + continue; + } + + let meta = FileMeta::from_path(&full_path)?; + println!("文件: {} {}", meta.friendly_size, full_path.display()); + files.push(meta); + } + + Ok(Self { + dir_name, + dirs, + files, + v: None, + }) + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..3684036 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,68 @@ +use std::ffi::OsStr; + +use crate::constants::{SKIP_DIR_NAMES, SKIP_FILE_NAMES}; + +pub fn friendly_size(size: u64) -> String { + const UNITS: [(&str, u64); 5] = [ + ("B", 1), + ("KB", 1024), + ("MB", 1024 * 1024), + ("GB", 1024 * 1024 * 1024), + ("TB", 1024 * 1024 * 1024 * 1024), + ]; + + if size == 0 { + return "0B".to_string(); + } + + let mut value = size as f64; + let mut unit = "B"; + for (label, threshold) in UNITS.iter().rev() { + if size >= *threshold { + value = size as f64 / *threshold as f64; + unit = label; + break; + } + } + + format!("{:.2}{}", value, unit) +} + +pub fn basename(path: &OsStr) -> String { + path.to_string_lossy().to_string() +} + +pub fn should_skip_dir(name: &str) -> bool { + SKIP_DIR_NAMES + .iter() + .any(|item| item.eq_ignore_ascii_case(name)) +} + +pub fn should_skip_file(name: &str) -> bool { + SKIP_FILE_NAMES + .iter() + .any(|item| item.eq_ignore_ascii_case(name)) + || name.starts_with("._") + || name.starts_with("Thumb_") +} + +pub fn hex_upper(bytes: impl AsRef<[u8]>) -> String { + bytes + .as_ref() + .iter() + .map(|b| format!("{:02X}", b)) + .collect::() +} + +#[cfg(test)] +mod tests { + use super::friendly_size; + + #[test] + fn friendly_size_formats_units() { + assert_eq!(friendly_size(0), "0B"); + assert_eq!(friendly_size(1), "1.00B"); + assert_eq!(friendly_size(1024), "1.00KB"); + assert_eq!(friendly_size(1024 * 1024), "1.00MB"); + } +}