Commit Diff


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' <murilo@ijanc.org>
+//
+// 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(())
+}