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)] 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) #[arg(long)] format: Option, /// Quality preset (maximum, high, medium, low, web) #[arg(long)] quality: Option, /// Strip all metadata #[arg(long)] strip_metadata: bool, /// Include subdirectories #[arg(short, long)] recursive: bool, }, /// Manage presets Preset { #[command(subcommand)] action: PresetAction, }, /// View processing history History, /// Undo last batch operation Undo, } #[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, recursive, } => { cmd_process( input, preset, output, resize, format, quality, strip_metadata, 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::History => cmd_history(), Commands::Undo => cmd_undo(), } } #[allow(clippy::too_many_arguments)] fn cmd_process( input: Vec, preset_name: Option, output: Option, resize: Option, format: Option, quality: Option, strip_metadata: bool, recursive: bool, ) { // Collect input files let mut source_files = Vec::new(); for path_str in &input { let path = PathBuf::from(path_str); if path.is_dir() { let files = discovery::discover_images(&path, 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()); // Determine input directory (use parent of first file) let input_dir = source_files[0] .parent() .unwrap_or_else(|| std::path::Path::new(".")) .to_path_buf(); // Determine output directory let output_dir = output .map(PathBuf::from) .unwrap_or_else(|| input_dir.join("processed")); // Build job from preset or CLI args let mut job = if let Some(ref name) = preset_name { 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) = resize { job.resize = Some(parse_resize(resize_str)); } if let Some(ref fmt_str) = format && let Some(fmt) = parse_format(fmt_str) { job.convert = Some(ConvertConfig::SingleFormat(fmt)); } if let Some(ref q_str) = quality && let Some(preset) = parse_quality(q_str) { job.compress = Some(CompressConfig::Preset(preset)); } if strip_metadata { job.metadata = Some(MetadataConfig::StripAll); } // Add sources 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, 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() { let history = HistoryStore::new(); match history.list() { Ok(entries) => { if let Some(last) = entries.last() { println!("Last operation: {} images from {}", last.total, last.input_dir); println!("Output files would be moved to trash."); println!("(Undo with file deletion not yet implemented - requires gio trash support)"); } else { println!("No processing history to undo."); } } Err(e) => { eprintln!("Failed to read history: {}", e); std::process::exit(1); } } } // --- 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 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()) }