use clap::{Parser, Subcommand}; use pixstrip_core::discovery; use pixstrip_core::executor::PipelineExecutor; use pixstrip_core::operations::*; use pixstrip_core::pipeline::ProcessingJob; use pixstrip_core::preset::Preset; use pixstrip_core::storage::{HistoryStore, PresetStore}; use pixstrip_core::types::*; use std::path::PathBuf; #[derive(Parser)] #[command(name = "pixstrip")] #[command(about = "Batch image processor - resize, convert, compress, strip metadata, watermark, and rename")] #[command(version)] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] #[allow(clippy::large_enum_variant)] enum Commands { /// Process images with a preset or custom operations Process { /// Input files or directories #[arg(required = true)] input: Vec, /// Preset name to use #[arg(long)] preset: Option, /// Output directory #[arg(short, long)] output: Option, /// Resize to width (e.g., "1200" or "1200x900") #[arg(long)] resize: Option, /// Output format (jpeg, png, webp, avif, gif, tiff) #[arg(long)] format: Option, /// Quality preset (maximum, high, medium, low, web) #[arg(long)] quality: Option, /// Strip all metadata #[arg(long)] strip_metadata: bool, /// Rotate images (90, 180, 270, auto) #[arg(long)] rotate: Option, /// Flip images (horizontal, vertical) #[arg(long)] flip: Option, /// Add text watermark (e.g., "(c) 2026 My Name") #[arg(long)] watermark: Option, /// Watermark position (top-left, top-center, top-right, center, bottom-left, bottom-center, bottom-right) #[arg(long, default_value = "bottom-right")] watermark_position: String, /// Watermark opacity (0.0-1.0) #[arg(long, default_value = "0.5")] watermark_opacity: f32, /// Rename with prefix #[arg(long)] rename_prefix: Option, /// Rename with suffix #[arg(long)] rename_suffix: Option, /// Rename template (e.g., "{name}_{counter:3}.{ext}") #[arg(long)] rename_template: Option, /// Include subdirectories #[arg(short, long)] recursive: bool, }, /// Manage presets Preset { #[command(subcommand)] action: PresetAction, }, /// Manage watch folders Watch { #[command(subcommand)] action: WatchAction, }, /// View processing history History, /// Undo last batch operation (moves output files to trash) Undo { /// Undo the last N batches (default 1) #[arg(long, default_value = "1")] last: usize, }, } #[derive(Subcommand)] enum WatchAction { /// Add a watch folder with a linked preset Add { /// Folder path to watch path: String, /// Preset name to apply #[arg(long)] preset: String, /// Watch subdirectories recursively #[arg(short, long)] recursive: bool, }, /// List configured watch folders List, /// Remove a watch folder Remove { /// Folder path to remove path: String, }, /// Start watching configured folders (blocks until Ctrl+C) Start, } #[derive(Subcommand)] enum PresetAction { /// List all presets List, /// Export a preset to a file Export { name: String, #[arg(short, long)] output: String, }, /// Import a preset from a file Import { path: String }, } fn main() { let cli = Cli::parse(); match cli.command { Commands::Process { input, preset, output, resize, format, quality, strip_metadata, rotate, flip, watermark, watermark_position, watermark_opacity, rename_prefix, rename_suffix, rename_template, recursive, } => { cmd_process(CmdProcessArgs { input, preset, output, resize, format, quality, strip_metadata, rotate, flip, watermark, watermark_position, watermark_opacity, rename_prefix, rename_suffix, rename_template, recursive, }); } Commands::Preset { action } => match action { PresetAction::List => cmd_preset_list(), PresetAction::Export { name, output } => cmd_preset_export(&name, &output), PresetAction::Import { path } => cmd_preset_import(&path), }, Commands::Watch { action } => match action { WatchAction::Add { path, preset, recursive, } => cmd_watch_add(&path, &preset, recursive), WatchAction::List => cmd_watch_list(), WatchAction::Remove { path } => cmd_watch_remove(&path), WatchAction::Start => cmd_watch_start(), }, Commands::History => cmd_history(), Commands::Undo { last } => cmd_undo(last), } } struct CmdProcessArgs { input: Vec, preset: Option, output: Option, resize: Option, format: Option, quality: Option, strip_metadata: bool, rotate: Option, flip: Option, watermark: Option, watermark_position: String, watermark_opacity: f32, rename_prefix: Option, rename_suffix: Option, rename_template: Option, recursive: bool, } fn cmd_process(args: CmdProcessArgs) { // Collect input files let mut source_files = Vec::new(); for path_str in &args.input { let path = PathBuf::from(path_str); if path.is_dir() { let files = discovery::discover_images(&path, args.recursive); source_files.extend(files); } else if path.is_file() { source_files.push(path); } else { eprintln!("Warning: '{}' not found, skipping", path_str); } } if source_files.is_empty() { eprintln!("No images found in the specified input(s)"); std::process::exit(1); } println!("Found {} image(s)", source_files.len()); let input_dir = source_files[0] .parent() .unwrap_or_else(|| std::path::Path::new(".")) .to_path_buf(); let output_dir = args.output .as_ref() .map(PathBuf::from) .unwrap_or_else(|| input_dir.join("processed")); let mut job = if let Some(ref name) = args.preset { let preset = find_preset(name); println!("Using preset: {}", preset.name); preset.to_job(&input_dir, &output_dir) } else { ProcessingJob::new(&input_dir, &output_dir) }; // Override with CLI flags if let Some(ref resize_str) = args.resize { job.resize = Some(parse_resize(resize_str)); } if let Some(ref fmt_str) = args.format && let Some(fmt) = parse_format(fmt_str) { job.convert = Some(ConvertConfig::SingleFormat(fmt)); } if let Some(ref q_str) = args.quality && let Some(preset) = parse_quality(q_str) { job.compress = Some(CompressConfig::Preset(preset)); } if args.strip_metadata { job.metadata = Some(MetadataConfig::StripAll); } if let Some(ref rot) = args.rotate { job.rotation = Some(parse_rotation(rot)); } if let Some(ref fl) = args.flip { job.flip = Some(parse_flip(fl)); } if let Some(ref text) = args.watermark { let position = parse_watermark_position(&args.watermark_position); job.watermark = Some(WatermarkConfig::Text { text: text.clone(), position, font_size: 24.0, opacity: args.watermark_opacity, color: [255, 255, 255, 255], }); } if args.rename_prefix.is_some() || args.rename_suffix.is_some() || args.rename_template.is_some() { job.rename = Some(RenameConfig { prefix: args.rename_prefix.unwrap_or_default(), suffix: args.rename_suffix.unwrap_or_default(), counter_start: 1, counter_padding: 3, template: args.rename_template, }); } for file in &source_files { job.add_source(file); } // Execute let executor = PipelineExecutor::new(); let result = executor .execute(&job, |update| { eprint!( "\r[{}/{}] {}...", update.current, update.total, update.current_file ); }) .unwrap_or_else(|e| { eprintln!("\nProcessing failed: {}", e); std::process::exit(1); }); eprintln!(); // Print results println!(); println!("Processing complete:"); println!(" Succeeded: {}", result.succeeded); if result.failed > 0 { println!(" Failed: {}", result.failed); } println!( " Size: {} -> {} ({:.0}% reduction)", format_bytes(result.total_input_bytes), format_bytes(result.total_output_bytes), if result.total_input_bytes > 0 { (1.0 - result.total_output_bytes as f64 / result.total_input_bytes as f64) * 100.0 } else { 0.0 } ); println!(" Time: {}", format_duration(result.elapsed_ms)); println!(" Output: {}", output_dir.display()); if !result.errors.is_empty() { println!(); println!("Errors:"); for (file, err) in &result.errors { println!(" {} - {}", file, err); } } // Save to history let history = HistoryStore::new(); let output_files: Vec = source_files .iter() .map(|f| { output_dir .join(f.file_name().unwrap_or_default()) .to_string_lossy() .into() }) .collect(); let _ = history.add(pixstrip_core::storage::HistoryEntry { timestamp: chrono_timestamp(), input_dir: input_dir.to_string_lossy().into(), output_dir: output_dir.to_string_lossy().into(), preset_name: args.preset, total: result.total, succeeded: result.succeeded, failed: result.failed, total_input_bytes: result.total_input_bytes, total_output_bytes: result.total_output_bytes, elapsed_ms: result.elapsed_ms, output_files, }); } fn cmd_preset_list() { println!("Built-in presets:"); for preset in Preset::all_builtins() { println!(" {} - {}", preset.name, preset.description); } let store = PresetStore::new(); if let Ok(user_presets) = store.list() && !user_presets.is_empty() { println!(); println!("User presets:"); for preset in user_presets { println!(" {} - {}", preset.name, preset.description); } } } fn cmd_preset_export(name: &str, output: &str) { let preset = find_preset(name); let store = PresetStore::new(); match store.export_to_file(&preset, &PathBuf::from(output)) { Ok(()) => println!("Exported '{}' to '{}'", name, output), Err(e) => { eprintln!("Failed to export preset: {}", e); std::process::exit(1); } } } fn cmd_preset_import(path: &str) { let store = PresetStore::new(); match store.import_from_file(&PathBuf::from(path)) { Ok(preset) => { let name = preset.name.clone(); match store.save(&preset) { Ok(()) => println!("Imported preset '{}'", name), Err(e) => { eprintln!("Failed to save imported preset: {}", e); std::process::exit(1); } } } Err(e) => { eprintln!("Failed to import preset: {}", e); std::process::exit(1); } } } fn cmd_history() { let history = HistoryStore::new(); match history.list() { Ok(entries) => { if entries.is_empty() { println!("No processing history yet."); return; } for (i, entry) in entries.iter().enumerate().rev() { println!( "{}. [{}] {} -> {} ({}/{} succeeded, {})", i + 1, entry.timestamp, entry.input_dir, entry.output_dir, entry.succeeded, entry.total, format_duration(entry.elapsed_ms), ); } } Err(e) => { eprintln!("Failed to read history: {}", e); std::process::exit(1); } } } fn cmd_undo(count: usize) { let history = HistoryStore::new(); match history.list() { Ok(entries) => { if entries.is_empty() { println!("No processing history to undo."); return; } let to_undo = entries.iter().rev().take(count); let mut total_trashed = 0; for entry in to_undo { if entry.output_files.is_empty() { println!( "Batch from {} has no recorded output files - cannot undo", entry.timestamp ); continue; } println!( "Undoing batch: {} images from {}", entry.total, entry.input_dir ); for file_path in &entry.output_files { let path = PathBuf::from(file_path); if path.exists() { // Move to OS trash using the trash crate match trash::delete(&path) { Ok(()) => { total_trashed += 1; } Err(e) => { eprintln!(" Failed to trash {}: {}", path.display(), e); } } } } } println!("{} files moved to trash", total_trashed); } Err(e) => { eprintln!("Failed to read history: {}", e); std::process::exit(1); } } } fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) { // Verify the preset exists let _preset = find_preset(preset_name); let watch_path = PathBuf::from(path); if !watch_path.exists() { eprintln!("Watch folder does not exist: {}", path); std::process::exit(1); } // Save watch folder config let watch = pixstrip_core::watcher::WatchFolder { path: watch_path, preset_name: preset_name.to_string(), recursive, active: true, }; // Store in config let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from("~/.config")) .join("pixstrip"); let watches_path = config_dir.join("watches.json"); let mut watches: Vec = if watches_path.exists() { std::fs::read_to_string(&watches_path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } else { Vec::new() }; // Don't add duplicate paths if watches.iter().any(|w| w.path == watch.path) { println!("Watch folder already configured: {}", path); return; } watches.push(watch); let _ = std::fs::create_dir_all(&config_dir); let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap()); println!("Added watch: {} -> preset '{}'", path, preset_name); } fn cmd_watch_list() { let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from("~/.config")) .join("pixstrip"); let watches_path = config_dir.join("watches.json"); let watches: Vec = if watches_path.exists() { std::fs::read_to_string(&watches_path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } else { Vec::new() }; if watches.is_empty() { println!("No watch folders configured."); println!("Use 'pixstrip watch add --preset ' to add one."); return; } println!("Configured watch folders:"); for watch in &watches { let recursive_str = if watch.recursive { " (recursive)" } else { "" }; let status = if watch.active { "active" } else { "inactive" }; println!( " {} -> '{}' [{}]{}", watch.path.display(), watch.preset_name, status, recursive_str ); } } fn cmd_watch_remove(path: &str) { let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from("~/.config")) .join("pixstrip"); let watches_path = config_dir.join("watches.json"); let mut watches: Vec = if watches_path.exists() { std::fs::read_to_string(&watches_path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } else { Vec::new() }; let original_len = watches.len(); let target = PathBuf::from(path); watches.retain(|w| w.path != target); if watches.len() == original_len { println!("Watch folder not found: {}", path); return; } let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap()); println!("Removed watch folder: {}", path); } fn cmd_watch_start() { let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from("~/.config")) .join("pixstrip"); let watches_path = config_dir.join("watches.json"); let watches: Vec = if watches_path.exists() { std::fs::read_to_string(&watches_path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } else { Vec::new() }; let active: Vec<_> = watches.iter().filter(|w| w.active).collect(); if active.is_empty() { println!("No active watch folders. Use 'pixstrip watch add' first."); return; } println!("Starting watch on {} folder(s)...", active.len()); for w in &active { println!(" {} -> '{}'", w.path.display(), w.preset_name); } println!("Press Ctrl+C to stop."); let (tx, rx) = std::sync::mpsc::channel(); let mut watchers = Vec::new(); for watch in &active { let watcher = pixstrip_core::watcher::FolderWatcher::new(); if let Err(e) = watcher.start(watch, tx.clone()) { eprintln!("Failed to start watching {}: {}", watch.path.display(), e); continue; } watchers.push((watcher, watch.preset_name.clone())); } // Process incoming files for event in &rx { match event { pixstrip_core::watcher::WatchEvent::NewImage(path) => { println!("New image: {}", path.display()); // Find which watcher this came from and use its preset if let Some((_, preset_name)) = watchers.first() { let preset = find_preset(preset_name); let input_dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf(); let output_dir = input_dir.join("processed"); let mut job = preset.to_job(&input_dir, &output_dir); job.add_source(&path); let executor = PipelineExecutor::new(); match executor.execute(&job, |_| {}) { Ok(r) => println!(" Processed: {} -> {}", format_bytes(r.total_input_bytes), format_bytes(r.total_output_bytes)), Err(e) => eprintln!(" Failed: {}", e), } } } pixstrip_core::watcher::WatchEvent::Error(err) => { eprintln!("Watch error: {}", err); } } } for (w, _) in &watchers { w.stop(); } } // --- Helpers --- fn find_preset(name: &str) -> Preset { // Check builtins first let lower = name.to_lowercase(); for preset in Preset::all_builtins() { if preset.name.to_lowercase() == lower { return preset; } } // Check user presets let store = PresetStore::new(); if let Ok(preset) = store.load(name) { return preset; } eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name); std::process::exit(1); } fn parse_resize(s: &str) -> ResizeConfig { if let Some((w, h)) = s.split_once('x') { let width: u32 = w.parse().unwrap_or_else(|_| { eprintln!("Invalid resize width: '{}'", w); std::process::exit(1); }); let height: u32 = h.parse().unwrap_or_else(|_| { eprintln!("Invalid resize height: '{}'", h); std::process::exit(1); }); ResizeConfig::Exact(Dimensions { width, height }) } else { let width: u32 = s.parse().unwrap_or_else(|_| { eprintln!("Invalid resize value: '{}'. Use a width like '1200' or dimensions like '1200x900'", s); std::process::exit(1); }); ResizeConfig::ByWidth(width) } } fn parse_format(s: &str) -> Option { match s.to_lowercase().as_str() { "jpeg" | "jpg" => Some(ImageFormat::Jpeg), "png" => Some(ImageFormat::Png), "webp" => Some(ImageFormat::WebP), "avif" => Some(ImageFormat::Avif), "gif" => Some(ImageFormat::Gif), "tiff" | "tif" => Some(ImageFormat::Tiff), _ => { eprintln!("Unknown format: '{}'. Supported: jpeg, png, webp, avif, gif, tiff", s); None } } } fn parse_quality(s: &str) -> Option { match s.to_lowercase().as_str() { "maximum" | "max" => Some(QualityPreset::Maximum), "high" => Some(QualityPreset::High), "medium" | "med" => Some(QualityPreset::Medium), "low" => Some(QualityPreset::Low), "web" => Some(QualityPreset::WebOptimized), _ => { eprintln!("Unknown quality: '{}'. Supported: maximum, high, medium, low, web", s); None } } } fn parse_rotation(s: &str) -> Rotation { match s.to_lowercase().as_str() { "90" | "cw90" => Rotation::Cw90, "180" => Rotation::Cw180, "270" | "cw270" => Rotation::Cw270, "auto" => Rotation::AutoOrient, "none" | "0" => Rotation::None, _ => { eprintln!("Unknown rotation: '{}'. Supported: 90, 180, 270, auto, none", s); std::process::exit(1); } } } fn parse_flip(s: &str) -> Flip { match s.to_lowercase().as_str() { "horizontal" | "h" => Flip::Horizontal, "vertical" | "v" => Flip::Vertical, "none" => Flip::None, _ => { eprintln!("Unknown flip: '{}'. Supported: horizontal, vertical, none", s); std::process::exit(1); } } } fn parse_watermark_position(s: &str) -> WatermarkPosition { match s.to_lowercase().replace(' ', "-").as_str() { "top-left" | "tl" => WatermarkPosition::TopLeft, "top-center" | "tc" | "top" => WatermarkPosition::TopCenter, "top-right" | "tr" => WatermarkPosition::TopRight, "middle-left" | "ml" | "left" => WatermarkPosition::MiddleLeft, "center" | "c" | "middle" => WatermarkPosition::Center, "middle-right" | "mr" | "right" => WatermarkPosition::MiddleRight, "bottom-left" | "bl" => WatermarkPosition::BottomLeft, "bottom-center" | "bc" | "bottom" => WatermarkPosition::BottomCenter, "bottom-right" | "br" => WatermarkPosition::BottomRight, _ => { eprintln!("Unknown position: '{}'. Supported: top-left, top-center, top-right, center, bottom-left, bottom-center, bottom-right", s); std::process::exit(1); } } } fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{} B", bytes) } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } else { format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } fn format_duration(ms: u64) -> String { if ms < 1000 { format!("{}ms", ms) } else if ms < 60_000 { format!("{:.1}s", ms as f64 / 1000.0) } else { let mins = ms / 60_000; let secs = (ms % 60_000) / 1000; format!("{}m {}s", mins, secs) } } fn chrono_timestamp() -> String { // Simple timestamp without chrono dependency let now = std::time::SystemTime::now(); let duration = now .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); format!("{}", duration.as_secs()) }