diff --git a/pixstrip-cli/src/main.rs b/pixstrip-cli/src/main.rs index 837b938..0d5d573 100644 --- a/pixstrip-cli/src/main.rs +++ b/pixstrip-cli/src/main.rs @@ -1,4 +1,12 @@ 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")] @@ -56,11 +64,7 @@ enum Commands { History, /// Undo last batch operation - Undo { - /// Undo the last operation - #[arg(long)] - last: bool, - }, + Undo, } #[derive(Subcommand)] @@ -74,9 +78,7 @@ enum PresetAction { output: String, }, /// Import a preset from a file - Import { - path: String, - }, + Import { path: String }, } fn main() { @@ -86,40 +88,376 @@ fn main() { Commands::Process { input, preset, - output: _, - resize: _, - format: _, - quality: _, - strip_metadata: _, - recursive: _, + output, + resize, + format, + quality, + strip_metadata, + recursive, } => { - println!("Processing {} input(s)", input.len()); - if let Some(preset) = &preset { - println!("Using preset: {}", preset); - } - println!("Processing not yet implemented"); + cmd_process( + input, + preset, + output, + resize, + format, + quality, + strip_metadata, + recursive, + ); } Commands::Preset { action } => match action { - PresetAction::List => { - println!("Available presets:"); - for preset in pixstrip_core::preset::Preset::all_builtins() { - println!(" {} - {}", preset.name, preset.description); - } - } - PresetAction::Export { name, output } => { - println!("Export preset '{}' to '{}'", name, output); - } - PresetAction::Import { path } => { - println!("Import preset from '{}'", path); - } + PresetAction::List => cmd_preset_list(), + PresetAction::Export { name, output } => cmd_preset_export(&name, &output), + PresetAction::Import { path } => cmd_preset_import(&path), }, - Commands::History => { - println!("Processing history not yet implemented"); + 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); } - Commands::Undo { last } => { - if last { - println!("Undo not yet implemented"); - } + } + + 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()) +}