From a03508695dfe39a78f7b8b3151a312242a91d49d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20P=C3=A9rex?= Date: Mon, 26 May 2025 18:43:00 +0100 Subject: [PATCH] refactor: improve logging and test organization BREAKING CHANGE: Logging output now goes to either file or console, not both Logging changes: - Rename MultiWriter to LogWriter for clarity - Change logging to write to either file or console exclusively - Improve log initialization message format - Add better documentation for logging behavior Test organization: - Move tests from directory_processor.rs to separate test modules - Follow modern Rust convention using tests.rs instead of mod.rs - Create proper test directory structure under src/tests/ - Make necessary struct fields pub(crate) for testing This commit improves code organization and makes logging behavior more conventional by directing output to a single destination. --- Cargo.lock | 446 ++++++++++++++++++++++++++ Cargo.toml | 5 + src/directory_processor.rs | 246 +++++++------- src/error.rs | 16 + src/logging.rs | 120 +++++++ src/main.rs | 43 ++- src/tests.rs | 3 + src/tests/directory_processor_test.rs | 32 ++ 8 files changed, 788 insertions(+), 123 deletions(-) create mode 100644 src/error.rs create mode 100644 src/logging.rs create mode 100644 src/tests.rs create mode 100644 src/tests/directory_processor_test.rs diff --git a/Cargo.lock b/Cargo.lock index 122dc7e..6abc521 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,30 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.18" @@ -52,12 +76,53 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + [[package]] name = "bitflags" version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "cc" +version = "1.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.38" @@ -104,11 +169,22 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "dir-odt-to-pdf" version = "0.1.0" dependencies = [ + "chrono", "clap", + "env_logger", + "log", + "tempfile", + "thiserror", "which", ] @@ -118,12 +194,35 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_home" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "errno" version = "0.3.12" @@ -134,18 +233,94 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "jiff" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a194df1107f33c79f4f93d02c80798520551949d59dfad22b6157048a88cca93" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c6e1db7ed32c6c71b759497fae34bf7933636f75a251b9e736555da426f6442" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.172" @@ -158,12 +333,48 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -182,6 +393,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "rustix" version = "1.0.7" @@ -195,6 +441,38 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "strsim" version = "0.11.1" @@ -212,6 +490,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -224,6 +535,73 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + [[package]] name = "which" version = "7.0.3" @@ -236,6 +614,65 @@ dependencies = [ "winsafe", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -314,3 +751,12 @@ name = "winsafe" version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] diff --git a/Cargo.toml b/Cargo.toml index b65b45d..dbbd38c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,8 @@ edition = "2024" [dependencies] clap = { version = "4.5.38", features = ["derive"] } which = "7.0.3" +log = "0.4" +env_logger = "0.11.8" +thiserror = "2.0.12" +tempfile = "3.8" +chrono = "0.4" diff --git a/src/directory_processor.rs b/src/directory_processor.rs index 8ccc8c3..683b30e 100644 --- a/src/directory_processor.rs +++ b/src/directory_processor.rs @@ -1,17 +1,27 @@ +use crate::error::{ProcessError, Result}; use crate::tools; use crate::{FILES_TO_CONVERT, FILES_TO_COPY, PATHS_TO_IGNORE}; +use log::{debug, info, warn}; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; +/// DirectoryProcessor handles the conversion of documents from a source directory +/// to a target directory, managing file conversions, copies, and cleanup. +#[derive(Debug)] pub struct DirectoryProcessor { - source_dir: PathBuf, - target_dir: PathBuf, - source_files: HashSet, + pub(crate) source_dir: PathBuf, + pub(crate) target_dir: PathBuf, + pub(crate) source_files: HashSet, } impl DirectoryProcessor { + /// Creates a new DirectoryProcessor instance. + /// + /// # Arguments + /// * `source` - The source directory containing files to process + /// * `target` - The target directory where processed files will be placed pub fn new(source: PathBuf, target: PathBuf) -> Self { Self { source_dir: source, @@ -20,7 +30,8 @@ impl DirectoryProcessor { } } - fn needs_copy_or_conversion(source_path: &Path, dest_path: &Path) -> bool { + /// Determines if a file needs to be copied or converted based on modification times. + pub(crate) fn needs_copy_or_conversion(source_path: &Path, dest_path: &Path) -> bool { if !dest_path.exists() { return true; } @@ -36,18 +47,17 @@ impl DirectoryProcessor { source_modified > dest_modified } - fn get_source_files(&mut self, current_dir: &Path) -> Result<(), String> { - let entries = fs::read_dir(current_dir) - .map_err(|e| format!("Error reading directory {}: {}", current_dir.display(), e))?; + /// Collects all source files that need processing. + fn get_source_files(&mut self, current_dir: &Path) -> Result<()> { + let entries = fs::read_dir(current_dir).map_err(ProcessError::Io)?; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { self.get_source_files(&path)?; } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if FILES_TO_CONVERT.contains(&ext.to_lowercase().as_str()) - || FILES_TO_COPY.contains(&ext.to_lowercase().as_str()) - { + let ext = ext.to_lowercase(); + if FILES_TO_CONVERT.contains(&ext.as_str()) || FILES_TO_COPY.contains(&ext.as_str()) { if let Ok(rel_path) = path.strip_prefix(&self.source_dir) { self.source_files.insert(rel_path.to_path_buf()); } @@ -57,16 +67,15 @@ impl DirectoryProcessor { Ok(()) } - fn clean_target_directory(&self, current_dir: &Path) -> Result { - let entries = fs::read_dir(current_dir) - .map_err(|e| format!("Error reading directory {}: {}", current_dir.display(), e))?; - + /// Cleans up the target directory by removing obsolete files and empty directories. + fn clean_target_directory(&self, current_dir: &Path) -> Result { + let entries = fs::read_dir(current_dir).map_err(ProcessError::Io)?; let mut is_empty = true; for entry in entries.flatten() { let path = entry.path(); - // Check if path should be ignored (both files and directories) + // Check if path should be ignored if let Some(name) = path.file_name().and_then(|n| n.to_str()) { if PATHS_TO_IGNORE.iter().any(|&ignore| name.contains(ignore)) { is_empty = false; @@ -75,47 +84,32 @@ impl DirectoryProcessor { } if path.is_dir() { - // Recursively check subdirectories - let subdir_empty = self.clean_target_directory(&path)?; - - if subdir_empty { - if let Err(e) = fs::remove_dir(&path) { - eprintln!( - "Warning: Could not remove empty directory {}: {}", - path.display(), - e - ); - } else { - println!("Removed empty directory: {}", path.display()); + match self.clean_target_directory(&path) { + Ok(subdir_empty) => { + if subdir_empty { + if let Err(e) = fs::remove_dir(&path) { + warn!("Could not remove empty directory {}: {}", path.display(), e); + } else { + debug!("Removed empty directory: {}", path.display()); + } + } else { + is_empty = false; + } + } + Err(e) => { + warn!("Error cleaning directory {}: {}", path.display(), e); + is_empty = false; } - } else { - is_empty = false; } } else { - let rel_path = path.strip_prefix(&self.target_dir).map_err(|e| { - format!("Error getting relative path for {}: {}", path.display(), e) - })?; - - // Check if this file should exist based on source files - let should_exist = if let Some(ext) = rel_path.extension().and_then(|e| e.to_str()) { - if ext == "pdf" { - // For PDF files, check if any corresponding source file exists - FILES_TO_CONVERT - .iter() - .any(|&ext| self.source_files.contains(&rel_path.with_extension(ext))) - } else { - // For other files, check if they exist in source - self.source_files.contains(rel_path) - } - } else { - false - }; + let rel_path = path.strip_prefix(&self.target_dir).map_err(ProcessError::StripPrefix)?; + let should_exist = self.should_file_exist(rel_path); if !should_exist { if let Err(e) = fs::remove_file(&path) { - eprintln!("Warning: Could not remove file {}: {}", path.display(), e); + warn!("Could not remove file {}: {}", path.display(), e); } else { - println!("Removed obsolete file: {}", path.display()); + debug!("Removed obsolete file: {}", path.display()); } } else { is_empty = false; @@ -126,98 +120,106 @@ impl DirectoryProcessor { Ok(is_empty) } - fn process_directory( - &self, - current_source: &Path, - current_target: &Path, - ) -> Result<(), String> { - // Create destination directory if it doesn't exist - if let Err(e) = fs::create_dir_all(current_target) { - return Err(format!("Error creating destination directory: {}", e)); + /// Determines if a file in the target directory should exist based on source files. + fn should_file_exist(&self, rel_path: &Path) -> bool { + if let Some(ext) = rel_path.extension().and_then(|e| e.to_str()) { + if ext == "pdf" { + // For PDF files, check if any corresponding source file exists + FILES_TO_CONVERT + .iter() + .any(|&ext| self.source_files.contains(&rel_path.with_extension(ext))) + } else { + // For other files, check if they exist in source + self.source_files.contains(rel_path) + } + } else { + false } + } - let entries = fs::read_dir(current_source).map_err(|e| { - format!( - "Error reading source directory {}: {}", - current_source.display(), - e - ) - })?; + /// Processes a single directory, converting or copying files as needed. + fn process_directory(&self, current_source: &Path, current_target: &Path) -> Result<()> { + fs::create_dir_all(current_target).map_err(ProcessError::Io)?; + + let entries = fs::read_dir(current_source).map_err(ProcessError::Io)?; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { - // Get the relative path from source to the current subdirectory let relative_path = path .strip_prefix(&self.source_dir) - .map_err(|e| format!("Error getting relative path: {}", e))?; - - // Create the corresponding destination subdirectory + .map_err(ProcessError::StripPrefix)?; let dest_subdir = self.target_dir.join(relative_path); - - // Recursively process the subdirectory self.process_directory(&path, &dest_subdir)?; - } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - // Get the relative path from source to the current file - let relative_path = path - .strip_prefix(current_source) - .map_err(|e| format!("Error getting relative path: {}", e))?; - - if FILES_TO_CONVERT.contains(&ext.to_lowercase().as_str()) { - // Construct the PDF path in the current target directory - let pdf_path = current_target.join(relative_path.with_extension("pdf")); - - if Self::needs_copy_or_conversion(&path, &pdf_path) { - match tools::convert_file( - &path, - &pdf_path.parent().unwrap_or(current_target), - ) { - Ok(_) => println!( - "Converted: {} -> {}", - path.strip_prefix(&self.source_dir).unwrap().display(), - pdf_path.strip_prefix(&self.target_dir).unwrap().display() - ), - Err(e) => eprintln!( - "Error: {} - {}", - path.strip_prefix(&self.source_dir).unwrap().display(), - e - ), - } - } - } else if FILES_TO_COPY.contains(&ext.to_lowercase().as_str()) { - // For files to copy directly - let dest_path = current_target.join(relative_path); - if Self::needs_copy_or_conversion(&path, &dest_path) { - match fs::copy(&path, &dest_path) { - Ok(_) => println!( - "Copied file: {} -> {}", - path.strip_prefix(&self.source_dir).unwrap().display(), - dest_path.strip_prefix(&self.target_dir).unwrap().display() - ), - Err(e) => eprintln!( - "Error copying file: {} - {}", - path.strip_prefix(&self.source_dir).unwrap().display(), - e - ), - } - } - } + } else { + self.process_file(&path, current_source, current_target)?; } } Ok(()) } - pub fn process(&mut self) -> Result<(), String> { - // Process all files - self.process_directory(&self.source_dir.to_owned(), &self.target_dir.to_owned())?; + /// Processes a single file, either converting it to PDF or copying it. + fn process_file(&self, path: &Path, current_source: &Path, current_target: &Path) -> Result<()> { + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + let relative_path = path + .strip_prefix(current_source) + .map_err(ProcessError::StripPrefix)?; - // Get list of source files for cleaning + let ext = ext.to_lowercase(); + if FILES_TO_CONVERT.contains(&ext.as_str()) { + self.convert_file(path, relative_path, current_target)?; + } else if FILES_TO_COPY.contains(&ext.as_str()) { + self.copy_file(path, relative_path, current_target)?; + } + } + Ok(()) + } + + /// Converts a file to PDF format. + fn convert_file(&self, path: &Path, relative_path: &Path, current_target: &Path) -> Result<()> { + let pdf_path = current_target.join(relative_path.with_extension("pdf")); + + if Self::needs_copy_or_conversion(path, &pdf_path) { + tools::convert_file(path, pdf_path.parent().unwrap_or(current_target)) + .map_err(|e| ProcessError::Processing(e))?; + + info!( + "Converted: {} -> {}", + path.strip_prefix(&self.source_dir).unwrap().display(), + pdf_path.strip_prefix(&self.target_dir).unwrap().display() + ); + } + Ok(()) + } + + /// Copies a file to the target directory. + fn copy_file(&self, path: &Path, relative_path: &Path, current_target: &Path) -> Result<()> { + let dest_path = current_target.join(relative_path); + if Self::needs_copy_or_conversion(path, &dest_path) { + fs::copy(path, &dest_path).map_err(ProcessError::Io)?; + info!( + "Copied file: {} -> {}", + path.strip_prefix(&self.source_dir).unwrap().display(), + dest_path.strip_prefix(&self.target_dir).unwrap().display() + ); + } + Ok(()) + } + + /// Processes all files in the source directory, converting or copying them as needed, + /// and then cleans up the target directory. + pub fn process(&mut self) -> Result<()> { + debug!("Collecting source files"); self.get_source_files(&self.source_dir.to_owned())?; - - // Finally clean target directory + + debug!("Starting directory processing"); + self.process_directory(&self.source_dir.to_owned(), &self.target_dir.to_owned())?; + + debug!("Cleaning target directory"); self.clean_target_directory(&self.target_dir.to_owned())?; - + + info!("Directory processing completed successfully"); Ok(()) } } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..716128f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,16 @@ +use std::io; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ProcessError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("Path strip error: {0}")] + StripPrefix(#[from] std::path::StripPrefixError), + + #[error("Directory processing error: {0}")] + Processing(String), +} + +pub type Result = std::result::Result; \ No newline at end of file diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..3029b9d --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,120 @@ +use env_logger::{Builder, Target}; +use log::LevelFilter; +use std::fs::OpenOptions; +use std::io::{self, Write}; +use std::path::PathBuf; + +/// Custom writer that writes either to stderr or to a file +pub(crate) struct LogWriter { + file: Option, +} + +impl LogWriter { + fn new(file: Option) -> Self { + Self { file } + } +} + +impl Write for LogWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + match &mut self.file { + Some(file) => file.write_all(buf)?, // Write to file if specified + None => io::stderr().write_all(buf)?, // Otherwise write to stderr + } + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + match &mut self.file { + Some(file) => file.flush()?, + None => io::stderr().flush()?, + } + Ok(()) + } +} + +pub struct LogConfig { + pub log_file: Option, + pub log_level: String, + pub append_log: bool, +} + +/// Initialize logging to either stderr or file (if specified) +pub fn init_logging(config: &LogConfig) -> Result<(), Box> { + let mut builder = Builder::from_default_env(); + + // Set log level from command line argument + let level = match config.log_level.to_lowercase().as_str() { + "error" => LevelFilter::Error, + "warn" => LevelFilter::Warn, + "info" => LevelFilter::Info, + "debug" => LevelFilter::Debug, + "trace" => LevelFilter::Trace, + _ => LevelFilter::Info, + }; + builder.filter_level(level); + + // Format with timestamps, module path, and line numbers for debug/trace + builder.format(move |buf, record| { + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + if level >= LevelFilter::Debug { + writeln!( + buf, + "{} [{}] [{}:{}] - {}", + timestamp, + record.level(), + record.module_path().unwrap_or("unknown"), + record.line().unwrap_or(0), + record.args() + ) + } else { + writeln!( + buf, + "{} [{}] - {}", + timestamp, + record.level(), + record.args() + ) + } + }); + + // Set up the writer for either file or console output + let log_file = if let Some(log_path) = &config.log_file { + let file = OpenOptions::new() + .create(true) + .write(true) + .append(config.append_log) + .truncate(!config.append_log) + .open(log_path)?; + + // Write header to log file if not appending + if !config.append_log { + writeln!( + &file, + "=== Log started at {} ===", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + )?; + } + Some(file) + } else { + None + }; + + // Create and set the writer + let writer = LogWriter::new(log_file); + builder.target(Target::Pipe(Box::new(writer))); + + builder.init(); + + // Log initial message with configuration info + log::info!( + "Logging initialized (level: {}, output: {})", + config.log_level, + config.log_file + .as_ref() + .map(|p| format!("file: {}", p.display())) + .unwrap_or_else(|| "console".to_string()) + ); + + Ok(()) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index fb137f1..b4dae3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,16 @@ use clap::Parser; use std::path::PathBuf; +use log::{info, debug, error}; mod directory_processor; mod tools; +mod error; +mod logging; +#[cfg(test)] +mod tests; use directory_processor::DirectoryProcessor; +use logging::{LogConfig, init_logging}; pub const FILES_TO_COPY: [&str; 10] = [ "jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp", "avif", "txt", "md", @@ -24,13 +30,48 @@ struct Args { #[arg(help = "Target directory for PDFs converted files")] dest: PathBuf, + + #[arg(long, help = "Log file path (optional)")] + log_file: Option, + + #[arg( + long, + help = "Log level (error, warn, info, debug, trace)", + default_value = "info" + )] + log_level: String, + + #[arg( + long, + help = "Append to log file instead of overwriting", + default_value_t = false + )] + append_log: bool, } fn main() { let args = Args::parse(); + let log_config = LogConfig { + log_file: args.log_file.to_owned(), + log_level: args.log_level.to_owned(), + append_log: args.append_log.to_owned(), + }; + + if let Err(e) = init_logging(&log_config) { + eprintln!("Failed to initialize logging: {}", e); + std::process::exit(1); + } + + info!("Starting document conversion"); + debug!("Source directory: {}", args.source.display()); + debug!("Target directory: {}", args.dest.display()); + let mut processor = DirectoryProcessor::new(args.source, args.dest); if let Err(e) = processor.process() { - eprintln!("Error processing directory: {}", e); + error!("Error processing directory: {}", e); + std::process::exit(1); } + + info!("Document conversion completed successfully"); } diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..afb7692 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,3 @@ +mod directory_processor_test; + +// Add other test modules here as needed \ No newline at end of file diff --git a/src/tests/directory_processor_test.rs b/src/tests/directory_processor_test.rs new file mode 100644 index 0000000..c3edf64 --- /dev/null +++ b/src/tests/directory_processor_test.rs @@ -0,0 +1,32 @@ +use std::path::PathBuf; +use std::fs; +use tempfile::TempDir; +use crate::directory_processor::DirectoryProcessor; + +#[test] +fn test_needs_copy_or_conversion() { + let temp_dir = TempDir::new().unwrap(); + let source = temp_dir.path().join("source.txt"); + let dest = temp_dir.path().join("dest.txt"); + + // Test non-existent destination + assert!(DirectoryProcessor::needs_copy_or_conversion(&source, &dest)); + + // Create files and test modification times + fs::write(&source, "test").unwrap(); + fs::write(&dest, "test").unwrap(); + + // Files created very close together should not need copying + assert!(!DirectoryProcessor::needs_copy_or_conversion(&source, &dest)); +} + +#[test] +fn test_new_processor() { + let source = PathBuf::from("/source"); + let target = PathBuf::from("/target"); + let processor = DirectoryProcessor::new(source.clone(), target.clone()); + + assert_eq!(processor.source_dir, source); + assert_eq!(processor.target_dir, target); + assert!(processor.source_files.is_empty()); +} \ No newline at end of file