commit 0fd8c09dc329bca16595bcf514c7bb82af41b22a from: murilo ijanc date: Wed Nov 19 21:37:07 2025 UTC Implement walkdir and cli args commit - 9d8087ec9b81fde8bad4753db1af7800a2922c3a commit + 0fd8c09dc329bca16595bcf514c7bb82af41b22a blob - e7a11a969c037e00a796aafeff6258501ec15e9a blob + 89fa24d0731715eea869e261d34fa250f518c936 --- src/main.rs +++ src/main.rs @@ -1,3 +1,215 @@ -fn main() { - println!("Hello, world!"); +// +// Copyright (c) 2025 murilo ijanc' +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +use std::{ + fs, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use anyhow::{Context, anyhow, bail}; +use clap::{ArgAction, Parser}; +use ignore::{WalkBuilder, WalkState}; +use log::{error, info, warn}; + +const LONG_VERSION: &str = concat!( + env!("CARGO_PKG_NAME"), + " ", + env!("CARGO_PKG_VERSION"), + " (", + env!("GIT_HASH", "unknown"), + " ", + env!("BUILD_DATE", "unknown"), + ")", +); + +/// Simple Image metadata cleaner. +/// +/// Recursively walks an input directory, removes metadata from JPEG files +/// and writes the cleaned copies into an output directory, preserving the +/// directory structure. +#[derive(Debug, Parser)] +#[command( + name = "imgst", + about = "Image sanitization", + version = env!("CARGO_PKG_VERSION"), + long_version = LONG_VERSION, + author, + propagate_version = true +)] +struct Args { + /// Input directory containing original images + #[arg(short, long)] + input: PathBuf, + + /// Ouput directoryu where cleaned images will be written + #[arg(short, long)] + output: PathBuf, + + /// Number of worker threads for directory walking + #[arg(long, default_value_t = 0)] + num_threads: usize, + + /// Only print what would be done, do not write files + #[arg(long)] + dry_run: bool, + + /// Increase verbosity (use -v, -vv, ...). + /// + /// When no RUST_LOG is set, a single -v switches the log level to DEBUG. + #[arg(short, long, global = true, action = ArgAction::Count)] + verbose: u8, } + +fn main() -> anyhow::Result<()> { + env_logger::init(); + + let args = Args::parse(); + + if !args.input.is_dir() { + bail!("input path '{}' is not directory", args.input.display()); + } + + if !args.output.exists() { + fs::create_dir_all(&args.output).with_context(|| { + format!("failed to create output dir '{}'", args.output.display()) + })?; + } else if !args.output.is_dir() { + bail!( + "output path '{}' exists but is not directory", + args.input.display() + ); + } + + info!("input directory: {}", args.input.display()); + info!("output directory: {}", args.output.display()); + info!("threads : {}", args.num_threads); + if args.dry_run { + info!("running in DRY_RUN mode"); + } + + let input_root = Arc::new(args.input); + let output_root = Arc::new(args.output); + let dry_run = args.dry_run; + + // counter + let processed = Arc::new(AtomicUsize::new(0)); + let skipped = Arc::new(AtomicUsize::new(0)); + let failed = Arc::new(AtomicUsize::new(0)); + + let walker = WalkBuilder::new(&*input_root) + .hidden(false) + .follow_links(false) + .standard_filters(true) + .threads(args.num_threads) + .build_parallel(); + + walker.run(|| { + let input_root = Arc::clone(&input_root); + let output_root = Arc::clone(&output_root); + let processed = Arc::clone(&processed); + let skipped = Arc::clone(&skipped); + let failed = Arc::clone(&failed); + + Box::new(move |result| { + match result { + Ok(entry) => { + let path = entry.path(); + + // regular file + if !entry + .file_type() + .map(|ft| ft.is_file()) + .unwrap_or(false) + { + return WalkState::Continue; + } + + let ext = path + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.to_ascii_lowercase()); + + let is_jpeg = + matches!(ext.as_deref(), Some("jpg" | "jpeg")); + + if !is_jpeg { + skipped.fetch_add(1, Ordering::Relaxed); + return WalkState::Continue; + } + + match process_img(&input_root, &output_root, path, dry_run) + { + Ok(()) => { + processed.fetch_add(1, Ordering::Relaxed); + } + Err(err) => { + failed.fetch_add(1, Ordering::Relaxed); + error!( + "failed to process '{}': {err:#}", + path.display() + ); + } + } + } + Err(err) => { + failed.fetch_add(1, Ordering::Relaxed); + error!("walk error: {err}"); + } + } + + WalkState::Continue + }) + }); + + info!( + "done: processed={} skipped={} failed={}", + processed.load(Ordering::Relaxed), + skipped.load(Ordering::Relaxed), + failed.load(Ordering::Relaxed), + ); + + if failed.load(Ordering::Relaxed) > 0 { + warn!("some files failed to process"); + } + + Ok(()) +} + +fn process_img( + input_root: &Path, + output_root: &Path, + src: &Path, + dry_run: bool, +) -> anyhow::Result<()> { + let rel_path = match src.strip_prefix(input_root) { + Ok(rel) => rel.to_path_buf(), + Err(_) => { + src.file_name().map(PathBuf::from).ok_or_else(|| anyhow!(""))? + } + }; + + let dst = output_root.join(rel_path); + + if dry_run { + info!("dry-run: would clean '{}' -> '{}'", src.display(), dst.display()); + } + + Ok(()) +}