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::io::Write; use std::path::PathBuf; fn watch_config_dir() -> PathBuf { dirs::config_dir() .or_else(|| dirs::home_dir().map(|h| h.join(".config"))) .unwrap_or_else(|| std::env::temp_dir()) .join("pixstrip") } fn load_watches(watches_path: &std::path::Path) -> Vec { std::fs::read_to_string(watches_path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } #[derive(Parser)] #[command(name = "pixstrip")] #[command(about = "Batch image processor - resize, convert, compress, strip metadata, watermark, and rename")] #[command(version)] #[command(after_long_help = "\ EXAMPLES: Resize all images in a folder to 1200px wide: pixstrip process photos/ --resize 1200 Convert PNGs to WebP at high quality: pixstrip process *.png --format webp --quality high Fit images within 1920x1080 without upscaling: pixstrip process photos/ --resize fit:1920x1080 Strip metadata for privacy and rename to lowercase: pixstrip process photos/ --metadata privacy --rename-case lower Add a tiled watermark with custom color and rotation: pixstrip process photos/ --watermark \"(c) 2026\" --watermark-tiled --watermark-rotation 45 --watermark-color ff0000 Process with a saved preset: pixstrip process photos/ --preset \"Blog Photos\" Watch a folder and auto-process new images: pixstrip watch add ~/incoming --preset \"Web Upload\" pixstrip watch start For more details on any subcommand: pixstrip help pixstrip process --help")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] #[allow(clippy::large_enum_variant)] enum Commands { /// Process images with a preset or custom operations #[command(after_long_help = "\ EXAMPLES: Resize all JPEGs in a folder to 1200px wide: pixstrip process photos/ --resize 1200 -o output/ Fit images within a 1920x1080 box (no upscaling): pixstrip process photos/ --resize fit:1920x1080 Convert all PNGs to WebP with high quality: pixstrip process *.png --format webp --quality high Per-format conversion (PNGs to WebP, TIFFs to JPEG): pixstrip process photos/ --format-map \"png=webp,tiff=jpeg\" Fine-tune compression per format: pixstrip process photos/ --jpeg-quality 90 --webp-quality 85 --avif-quality 70 Strip location and camera data, keep copyright: pixstrip process photos/ --strip-gps --strip-camera Adjust brightness/contrast and convert to grayscale: pixstrip process photos/ --brightness 10 --contrast 20 --grayscale Add a tiled diagonal watermark in red: pixstrip process photos/ --watermark \"(c) 2026\" --watermark-tiled \\ --watermark-rotation 45 --watermark-color ff0000 --watermark-opacity 0.3 Add an image watermark (logo) scaled to 15%: pixstrip process photos/ --watermark-image logo.png --watermark-scale 0.15 Rename to lowercase with hyphens, add prefix: pixstrip process photos/ --rename-prefix \"blog-\" --rename-case lower \\ --rename-spaces hyphen Rename with regex (remove \"IMG_\" prefix): pixstrip process photos/ --rename-regex-find \"^IMG_\" --rename-regex-replace \"\" Sequential numbering (replace filename with counter): pixstrip process photos/ --rename-counter-position replace-name \\ --rename-counter-start 1 --rename-counter-padding 4 Use a saved preset: pixstrip process photos/ --preset \"Blog Photos\" Override preset options (preset + custom flags): pixstrip process photos/ --preset \"Blog Photos\" --resize 800 Combine multiple operations: pixstrip process photos/ -r --resize fit:1920x1080 --format webp \\ --quality high --strip-metadata --rename-case lower \\ --watermark \"(c) 2026\" --overwrite skip TEMPLATE VARIABLES (for --rename-template): {name} Original filename (without extension) {ext} File extension {counter} Sequential number (padding from --rename-counter-padding) {counter:N} Sequential number with N-digit padding {width} Image width in pixels {height} Image height in pixels {date} File modification date (YYYY-MM-DD) Example: --rename-template \"{name}_{width}x{height}.{ext}\"")] Process { /// Input files or directories #[arg(required = true)] input: Vec, /// Preset name to use (see 'pixstrip preset list' for available presets) #[arg(long)] preset: Option, /// Output directory [default: /processed] #[arg(short, long)] output: Option, // --- Resize --- /// Resize images. Formats: "1200" (width), "1200x900" (exact), "fit:1200x900" (fit in box) #[arg(long, help_heading = "Resize")] resize: Option, /// Resize algorithm #[arg(long, default_value = "lanczos3", help_heading = "Resize", value_parser = ["lanczos3", "catmullrom", "bilinear", "nearest"])] algorithm: String, /// Allow upscaling when using fit-in-box resize mode #[arg(long, help_heading = "Resize")] allow_upscale: bool, // --- Convert --- /// Convert all images to one format #[arg(long, help_heading = "Convert", value_parser = ["jpeg", "jpg", "png", "webp", "avif", "gif", "tiff", "bmp"])] format: Option, /// Per-format conversion mapping (e.g., "png=webp,tiff=jpeg"). Cannot combine with --format #[arg(long, help_heading = "Convert")] format_map: Option, // --- Compress --- /// Quality preset #[arg(long, help_heading = "Compress", value_parser = ["maximum", "max", "high", "medium", "med", "low", "web"])] quality: Option, /// JPEG quality (1-100). Overrides --quality for JPEG output #[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")] jpeg_quality: Option, /// PNG compression level (1-12, lower = larger but faster). Overrides --quality for PNG #[arg(long, value_parser = clap::value_parser!(u8).range(1..=12), help_heading = "Compress")] png_level: Option, /// WebP quality (1-100). Overrides --quality for WebP output #[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")] webp_quality: Option, /// AVIF quality (1-100). Overrides --quality for AVIF output #[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")] avif_quality: Option, /// AVIF encoding speed (0=slowest/best, 10=fastest/worst) #[arg(long, value_parser = clap::value_parser!(u8).range(0..=10), help_heading = "Compress")] avif_speed: Option, /// Enable progressive JPEG encoding (better for web, slightly larger files) #[arg(long, help_heading = "Compress")] progressive_jpeg: bool, // --- Metadata --- /// Metadata handling mode. "privacy" strips GPS, camera, and software info #[arg(long, help_heading = "Metadata", value_parser = ["strip-all", "keep-all", "privacy"])] metadata: Option, /// Strip GPS/location data #[arg(long, help_heading = "Metadata")] strip_gps: bool, /// Strip camera make/model/settings info #[arg(long, help_heading = "Metadata")] strip_camera: bool, /// Strip software/editor tags #[arg(long, help_heading = "Metadata")] strip_software: bool, /// Strip date/time timestamps #[arg(long, help_heading = "Metadata")] strip_timestamps: bool, /// Strip copyright and author info #[arg(long, help_heading = "Metadata")] strip_copyright: bool, /// Strip all metadata (shorthand for --metadata strip-all) #[arg(long, help_heading = "Metadata")] strip_metadata: bool, // --- Rotation / Flip --- /// Rotate images. "auto" reads EXIF orientation and corrects it #[arg(long, help_heading = "Transform", value_parser = ["90", "180", "270", "auto", "none"])] rotate: Option, /// Flip/mirror images #[arg(long, help_heading = "Transform", value_parser = ["horizontal", "vertical", "h", "v", "none"])] flip: Option, // --- Adjustments --- /// Brightness adjustment (-100 to 100, 0 = no change) #[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")] brightness: Option, /// Contrast adjustment (-100 to 100, 0 = no change) #[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")] contrast: Option, /// Saturation adjustment (-100 to 100, 0 = no change) #[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")] saturation: Option, /// Apply sharpening filter #[arg(long, help_heading = "Adjustments")] sharpen: bool, /// Convert to grayscale (black and white) #[arg(long, help_heading = "Adjustments")] grayscale: bool, /// Apply sepia tone (vintage look) #[arg(long, help_heading = "Adjustments")] sepia: bool, /// Crop to aspect ratio (e.g., "16:9", "4:3", "1:1", "3:2") #[arg(long, help_heading = "Adjustments")] crop_aspect_ratio: Option, /// Auto-trim whitespace/solid-color borders #[arg(long, help_heading = "Adjustments")] trim_whitespace: bool, /// Add uniform canvas padding in pixels (applied after all other adjustments) #[arg(long, help_heading = "Adjustments")] canvas_padding: Option, // --- Watermark --- /// Add text watermark. Cannot combine with --watermark-image #[arg(long, help_heading = "Watermark")] watermark: Option, /// Add image watermark from file. Cannot combine with --watermark #[arg(long, help_heading = "Watermark")] watermark_image: Option, /// Watermark position on the image #[arg(long, default_value = "bottom-right", help_heading = "Watermark", value_parser = ["top-left", "top-center", "top-right", "center", "bottom-left", "bottom-center", "bottom-right", "tl", "tc", "tr", "c", "bl", "bc", "br"])] watermark_position: String, /// Watermark opacity (0.0 = invisible, 1.0 = fully opaque) #[arg(long, default_value = "0.5", value_parser = parse_opacity, help_heading = "Watermark")] watermark_opacity: f32, /// Font size for text watermarks in points #[arg(long, default_value = "24", help_heading = "Watermark")] watermark_font_size: f32, /// Font family name for text watermarks (searches system fonts) #[arg(long, help_heading = "Watermark")] watermark_font: Option, /// Text color as hex RGB or RGBA (e.g., "ffffff", "ff0000", "00000080") #[arg(long, default_value = "ffffff", help_heading = "Watermark")] watermark_color: String, /// Rotate watermark in degrees (45, -45, 90, or any value) #[arg(long, help_heading = "Watermark")] watermark_rotation: Option, /// Tile (repeat) the watermark across the entire image #[arg(long, help_heading = "Watermark")] watermark_tiled: bool, /// Watermark distance from image edges in pixels #[arg(long, default_value = "10", help_heading = "Watermark")] watermark_margin: u32, /// Scale for image watermarks (0.0-1.0, fraction of image size) #[arg(long, default_value = "0.25", value_parser = parse_opacity, help_heading = "Watermark")] watermark_scale: f32, // --- Rename --- /// Add prefix before filename (e.g., "blog-" turns "photo.jpg" into "blog-photo.jpg") #[arg(long, help_heading = "Rename")] rename_prefix: Option, /// Add suffix after filename (e.g., "-web" turns "photo.jpg" into "photo-web.jpg") #[arg(long, help_heading = "Rename")] rename_suffix: Option, /// Rename using a template. Overrides --rename-prefix/--rename-suffix. See TEMPLATE VARIABLES below #[arg(long, help_heading = "Rename")] rename_template: Option, /// Convert filename case #[arg(long, help_heading = "Rename", value_parser = ["lower", "upper", "title", "none"])] rename_case: Option, /// Replace spaces in filenames #[arg(long, help_heading = "Rename", value_parser = ["underscore", "hyphen", "dot", "camelcase", "remove"])] rename_spaces: Option, /// Filter special characters in filenames #[arg(long, help_heading = "Rename", value_parser = ["filesystem-safe", "web-safe", "hyphens-underscores", "hyphens", "alphanumeric", "none"])] rename_special_chars: Option, /// Regex pattern to find in filenames (paired with --rename-regex-replace) #[arg(long, help_heading = "Rename")] rename_regex_find: Option, /// Replacement for regex matches (supports $1, $2 capture groups) #[arg(long, help_heading = "Rename")] rename_regex_replace: Option, /// Where to insert the counter in the filename #[arg(long, default_value = "after-suffix", help_heading = "Rename", value_parser = ["before-prefix", "before-name", "after-name", "after-suffix", "replace-name"])] rename_counter_position: String, /// Counter starting number #[arg(long, default_value = "1", help_heading = "Rename")] rename_counter_start: u32, /// Counter zero-padding width (3 = "001", 4 = "0001") #[arg(long, default_value = "3", help_heading = "Rename")] rename_counter_padding: u32, // --- Output --- /// What to do when output file already exists #[arg(long, default_value = "auto-rename", help_heading = "Output", value_parser = ["auto-rename", "overwrite", "skip"])] overwrite: String, /// Preserve subdirectory structure from input in the output folder #[arg(long, help_heading = "Output")] preserve_dirs: bool, /// Set output DPI/resolution metadata (e.g., 72, 150, 300) #[arg(long, help_heading = "Output")] output_dpi: Option, /// Scan input directories recursively for images #[arg(short, long, help_heading = "Output")] recursive: bool, }, /// Manage presets (list, create, delete, import, export) Preset { #[command(subcommand)] action: PresetAction, }, /// Manage watch folders for automatic processing of new images Watch { #[command(subcommand)] action: WatchAction, }, /// View processing history (recent batches, file counts, sizes, and times) History, /// Undo recent batch operations by moving output files to system trash Undo { /// Number of recent batches to undo #[arg(long, default_value = "1")] last: usize, }, } #[derive(Subcommand)] enum WatchAction { /// Add a folder to watch for new images, with a preset to apply automatically Add { /// Folder path to watch for new images path: String, /// Preset to apply to new images (see 'pixstrip preset list') #[arg(long)] preset: String, /// Also watch subdirectories #[arg(short, long)] recursive: bool, }, /// List all configured watch folders and their linked presets List, /// Remove a folder from the watch list Remove { /// Folder path to stop watching path: String, }, /// Start watching all active folders. Blocks until Ctrl+C. Processed files go to /processed/ Start, } #[derive(Subcommand)] enum PresetAction { /// List all available presets (built-in and user-created) List, /// Create a new empty user preset. Edit it by exporting, modifying the JSON, and re-importing Create { /// Name for the new preset name: String, /// Optional description #[arg(long, default_value = "")] description: String, }, /// Delete a user preset. Built-in presets cannot be deleted Delete { /// Name of the preset to delete name: String, }, /// Export a preset to a JSON file for backup or sharing Export { /// Name of the preset to export name: String, /// Output file path (e.g., "my-preset.json") #[arg(short, long)] output: String, }, /// Import a preset from a JSON file Import { /// Path to the preset JSON file path: String, }, } fn main() { let cli = Cli::parse(); match cli.command { Commands::Process { input, preset, output, resize, algorithm, allow_upscale, format, format_map, quality, jpeg_quality, png_level, webp_quality, avif_quality, avif_speed, progressive_jpeg, metadata, strip_gps, strip_camera, strip_software, strip_timestamps, strip_copyright, strip_metadata, rotate, flip, brightness, contrast, saturation, sharpen, grayscale, sepia, crop_aspect_ratio, trim_whitespace, canvas_padding, watermark, watermark_image, watermark_position, watermark_opacity, watermark_font_size, watermark_font, watermark_color, watermark_rotation, watermark_tiled, watermark_margin, watermark_scale, rename_prefix, rename_suffix, rename_template, rename_case, rename_spaces, rename_special_chars, rename_regex_find, rename_regex_replace, rename_counter_position, rename_counter_start, rename_counter_padding, overwrite, preserve_dirs, output_dpi, recursive, } => { cmd_process(CmdProcessArgs { input, preset, output, resize, algorithm, allow_upscale, format, format_map, quality, jpeg_quality, png_level, webp_quality, avif_quality, avif_speed, progressive_jpeg, metadata, strip_gps, strip_camera, strip_software, strip_timestamps, strip_copyright, strip_metadata, rotate, flip, brightness, contrast, saturation, sharpen, grayscale, sepia, crop_aspect_ratio, trim_whitespace, canvas_padding, watermark, watermark_image, watermark_position, watermark_opacity, watermark_font_size, watermark_font, watermark_color, watermark_rotation, watermark_tiled, watermark_margin, watermark_scale, rename_prefix, rename_suffix, rename_template, rename_case, rename_spaces, rename_special_chars, rename_regex_find, rename_regex_replace, rename_counter_position, rename_counter_start, rename_counter_padding, overwrite, preserve_dirs, output_dpi, recursive, }); } Commands::Preset { action } => match action { PresetAction::List => cmd_preset_list(), PresetAction::Create { name, description } => cmd_preset_create(&name, &description), PresetAction::Delete { name } => cmd_preset_delete(&name), 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 resize: Option, algorithm: String, allow_upscale: bool, // Convert format: Option, format_map: Option, // Compress quality: Option, jpeg_quality: Option, png_level: Option, webp_quality: Option, avif_quality: Option, avif_speed: Option, progressive_jpeg: bool, // Metadata metadata: Option, strip_gps: bool, strip_camera: bool, strip_software: bool, strip_timestamps: bool, strip_copyright: bool, strip_metadata: bool, // Rotation / Flip rotate: Option, flip: Option, // Adjustments brightness: Option, contrast: Option, saturation: Option, sharpen: bool, grayscale: bool, sepia: bool, crop_aspect_ratio: Option, trim_whitespace: bool, canvas_padding: Option, // Watermark watermark: Option, watermark_image: Option, watermark_position: String, watermark_opacity: f32, watermark_font_size: f32, watermark_font: Option, watermark_color: String, watermark_rotation: Option, watermark_tiled: bool, watermark_margin: u32, watermark_scale: f32, // Rename rename_prefix: Option, rename_suffix: Option, rename_template: Option, rename_case: Option, rename_spaces: Option, rename_special_chars: Option, rename_regex_find: Option, rename_regex_replace: Option, rename_counter_position: String, rename_counter_start: u32, rename_counter_padding: u32, // Output overwrite: String, preserve_dirs: bool, output_dpi: 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() { if path.extension() .and_then(|e| e.to_str()) .and_then(pixstrip_core::types::ImageFormat::from_extension) .is_some() { source_files.push(path); } else { eprintln!("Warning: '{}' is not a recognized image format, skipping", path_str); } } 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).unwrap_or_else(|| { eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name); std::process::exit(1); }); println!("Using preset: {}", preset.name); preset.to_job(&input_dir, &output_dir) } else { ProcessingJob::new(&input_dir, &output_dir) }; // Override with CLI flags // --- Resize --- if let Some(ref resize_str) = args.resize { job.resize = Some(parse_resize(resize_str, args.allow_upscale)); } job.resize_algorithm = match args.algorithm.to_lowercase().as_str() { "catmullrom" | "catmull-rom" => ResizeAlgorithm::CatmullRom, "bilinear" => ResizeAlgorithm::Bilinear, "nearest" => ResizeAlgorithm::Nearest, "lanczos3" | "lanczos" => ResizeAlgorithm::Lanczos3, other => { eprintln!("Warning: unknown algorithm '{}', using lanczos3. Valid: lanczos3, catmullrom, bilinear, nearest", other); ResizeAlgorithm::Lanczos3 } }; // --- Convert --- if args.format.is_some() && args.format_map.is_some() { eprintln!("Error: cannot use both --format and --format-map at the same time"); std::process::exit(1); } if let Some(ref map_str) = args.format_map { let mappings = parse_format_map(map_str); if !mappings.is_empty() { job.convert = Some(ConvertConfig::FormatMapping(mappings)); } } else if let Some(ref fmt_str) = args.format { let fmt = parse_format(fmt_str).unwrap_or_else(|| std::process::exit(1)); job.convert = Some(ConvertConfig::SingleFormat(fmt)); } // --- Compress --- if args.jpeg_quality.is_some() || args.png_level.is_some() || args.webp_quality.is_some() || args.avif_quality.is_some() { // Per-format custom quality overrides the preset job.compress = Some(CompressConfig::Custom { jpeg_quality: args.jpeg_quality, png_level: args.png_level, webp_quality: args.webp_quality.map(|q| q as f32), avif_quality: args.avif_quality.map(|q| q as f32), }); } else if let Some(ref q_str) = args.quality { let preset = parse_quality(q_str).unwrap_or_else(|| std::process::exit(1)); job.compress = Some(CompressConfig::Preset(preset)); } if let Some(speed) = args.avif_speed { job.avif_speed = speed; } if args.progressive_jpeg { job.progressive_jpeg = true; } // --- Metadata --- if args.strip_metadata { job.metadata = Some(MetadataConfig::StripAll); } else if args.strip_gps || args.strip_camera || args.strip_software || args.strip_timestamps || args.strip_copyright { job.metadata = Some(MetadataConfig::Custom { strip_gps: args.strip_gps, strip_camera: args.strip_camera, strip_software: args.strip_software, strip_timestamps: args.strip_timestamps, strip_copyright: args.strip_copyright, }); } else if let Some(ref mode) = args.metadata { job.metadata = Some(parse_metadata_mode(mode)); } // --- Rotation / Flip --- 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)); } // --- Adjustments --- let has_adjustments = args.brightness.is_some() || args.contrast.is_some() || args.saturation.is_some() || args.sharpen || args.grayscale || args.sepia || args.crop_aspect_ratio.is_some() || args.trim_whitespace || args.canvas_padding.is_some(); if has_adjustments { job.adjustments = Some(AdjustmentsConfig { brightness: args.brightness.unwrap_or(0).clamp(-100, 100), contrast: args.contrast.unwrap_or(0).clamp(-100, 100), saturation: args.saturation.unwrap_or(0).clamp(-100, 100), sharpen: args.sharpen, grayscale: args.grayscale, sepia: args.sepia, crop_aspect_ratio: args.crop_aspect_ratio.as_deref().and_then(parse_aspect_ratio), trim_whitespace: args.trim_whitespace, canvas_padding: args.canvas_padding.unwrap_or(0), }); } // --- Watermark --- if args.watermark.is_some() && args.watermark_image.is_some() { eprintln!("Error: cannot use both --watermark (text) and --watermark-image at the same time"); std::process::exit(1); } let wm_position = parse_watermark_position(&args.watermark_position); let wm_rotation = args.watermark_rotation.as_deref().map(parse_watermark_rotation); if let Some(ref text) = args.watermark { let color = parse_hex_color(&args.watermark_color); job.watermark = Some(WatermarkConfig::Text { text: text.clone(), position: wm_position, font_size: args.watermark_font_size, opacity: args.watermark_opacity, color, font_family: args.watermark_font.clone(), rotation: wm_rotation, tiled: args.watermark_tiled, margin: args.watermark_margin, }); } else if let Some(ref img_path) = args.watermark_image { let path = PathBuf::from(img_path); if !path.exists() { eprintln!("Watermark image not found: {}", img_path); std::process::exit(1); } job.watermark = Some(WatermarkConfig::Image { path, position: wm_position, opacity: args.watermark_opacity, scale: args.watermark_scale, rotation: wm_rotation, tiled: args.watermark_tiled, margin: args.watermark_margin, }); } // --- Rename --- let has_rename = args.rename_prefix.is_some() || args.rename_suffix.is_some() || args.rename_template.is_some() || args.rename_case.is_some() || args.rename_spaces.is_some() || args.rename_special_chars.is_some() || args.rename_regex_find.is_some(); if has_rename { if let Some(ref tmpl) = args.rename_template { if args.rename_prefix.is_some() || args.rename_suffix.is_some() { eprintln!("Warning: --rename-template overrides --rename-prefix/--rename-suffix"); } if !tmpl.contains('{') { eprintln!("Warning: rename template '{}' has no placeholders. Use {{name}}, {{counter}}, {{ext}}, etc.", tmpl); } if !tmpl.contains("{ext}") && !tmpl.contains('.') { eprintln!("Warning: rename template has no {{ext}} or file extension - output files may lack extensions"); } } let case_mode = args.rename_case.as_deref().map(parse_case_mode).unwrap_or(0); let replace_spaces = args.rename_spaces.as_deref().map(parse_space_mode).unwrap_or(0); let special_chars = args.rename_special_chars.as_deref().map(parse_special_chars_mode).unwrap_or(0); let counter_position = parse_counter_position(&args.rename_counter_position); let counter_enabled = args.rename_template.is_some() || counter_position != 3; job.rename = Some(RenameConfig { prefix: args.rename_prefix.unwrap_or_default(), suffix: args.rename_suffix.unwrap_or_default(), counter_start: args.rename_counter_start, counter_padding: args.rename_counter_padding, counter_enabled, counter_position, template: args.rename_template, case_mode, replace_spaces, special_chars, regex_find: args.rename_regex_find.unwrap_or_default(), regex_replace: args.rename_regex_replace.unwrap_or_default(), }); } // --- Output --- job.overwrite_behavior = match args.overwrite.to_lowercase().as_str() { "overwrite" | "always" => OverwriteAction::Overwrite, "skip" => OverwriteAction::Skip, "auto-rename" | "autorename" | "rename" => OverwriteAction::AutoRename, other => { eprintln!("Warning: unknown overwrite mode '{}', using auto-rename. Valid: auto-rename, overwrite, skip", other); OverwriteAction::AutoRename } }; if args.preserve_dirs { job.preserve_directory_structure = true; } if let Some(dpi) = args.output_dpi { job.output_dpi = dpi; } 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 ); let _ = std::io::stderr().flush(); }) .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); } if result.total_input_bytes > 0 && result.total_output_bytes > result.total_input_bytes { let increase = (result.total_output_bytes as f64 / result.total_input_bytes as f64 - 1.0) * 100.0; println!( " Size: {} -> {} (+{:.0}% increase)", format_bytes(result.total_input_bytes), format_bytes(result.total_output_bytes), increase, ); } else { 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 - use actual output paths from the executor let history = HistoryStore::new(); if let Err(e) = history.add(pixstrip_core::storage::HistoryEntry { timestamp: unix_timestamp(), input_dir: input_dir.canonicalize().unwrap_or_else(|_| input_dir.to_path_buf()).to_string_lossy().into(), output_dir: output_dir.canonicalize().unwrap_or_else(|_| output_dir.to_path_buf()).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: result.output_files, }, 50, 30) { eprintln!("Warning: failed to save history (undo may not work): {}", e); } } 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_create(name: &str, description: &str) { let store = PresetStore::new(); // Check if name conflicts with a builtin let lower = name.to_lowercase(); if Preset::all_builtins().iter().any(|p| p.name.to_lowercase() == lower) { eprintln!("Cannot create preset '{}': conflicts with a built-in preset name", name); std::process::exit(1); } let preset = Preset { name: name.to_string(), description: description.to_string(), is_custom: true, ..Preset::default() }; match store.save(&preset) { Ok(()) => println!("Created preset '{}'", name), Err(e) => { eprintln!("Failed to create preset: {}", e); std::process::exit(1); } } } fn cmd_preset_delete(name: &str) { let store = PresetStore::new(); // Don't allow deleting builtins let lower = name.to_lowercase(); if Preset::all_builtins().iter().any(|p| p.name.to_lowercase() == lower) { eprintln!("Cannot delete built-in preset '{}'", name); std::process::exit(1); } match store.delete(name) { Ok(()) => println!("Deleted preset '{}'", name), Err(e) => { eprintln!("Failed to delete preset '{}': {}", name, e); std::process::exit(1); } } } fn cmd_preset_export(name: &str, output: &str) { let preset = find_preset(name).unwrap_or_else(|| { eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name); std::process::exit(1); }); 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, format_timestamp(&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) { if count == 0 { eprintln!("Must undo at least 1 batch"); std::process::exit(1); } let history = HistoryStore::new(); match history.list() { Ok(mut entries) => { if entries.is_empty() { println!("No processing history to undo."); return; } let undo_count = count.min(entries.len()); if count > entries.len() { eprintln!("Warning: requested {} batches but only {} available", count, entries.len()); } let to_undo = entries.split_off(entries.len() - undo_count); let mut total_trashed = 0; let mut failed_entries = Vec::new(); for entry in to_undo { if entry.output_files.is_empty() { println!( "Batch from {} has no recorded output files - cannot undo", format_timestamp(&entry.timestamp) ); failed_entries.push(entry); continue; } println!( "Undoing batch: {} images from {}", entry.total, entry.input_dir ); let mut remaining_files = Vec::new(); for file_path in &entry.output_files { let path = PathBuf::from(file_path); if path.exists() { match trash::delete(&path) { Ok(()) => { total_trashed += 1; } Err(e) => { eprintln!(" Failed to trash {}: {}", path.display(), e); remaining_files.push(file_path.clone()); } } } } // Keep entry with remaining files if some could not be trashed if !remaining_files.is_empty() { let mut kept = entry; kept.output_files = remaining_files; failed_entries.push(kept); } } // Re-add entries where trash failed so they can be retried entries.extend(failed_entries); // Write updated history if let Err(e) = history.write_all(&entries) { eprintln!("Warning: failed to update history after undo: {}", 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 if find_preset(preset_name).is_none() { eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", preset_name); std::process::exit(1); } let watch_path = PathBuf::from(path); let watch_path = watch_path.canonicalize().unwrap_or(watch_path); if !watch_path.exists() { eprintln!("Watch folder does not exist: {}", path); std::process::exit(1); } if !watch_path.is_dir() { eprintln!("Watch path is not a directory: {}", 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 = watch_config_dir(); let watches_path = config_dir.join("watches.json"); let mut watches = load_watches(&watches_path); // Don't add duplicate paths if watches.iter().any(|w| w.path == watch.path) { println!("Watch folder already configured: {}", path); return; } watches.push(watch); if let Err(e) = std::fs::create_dir_all(&config_dir) { eprintln!("Failed to create config directory: {}", e); std::process::exit(1); } match serde_json::to_string_pretty(&watches) { Ok(json) => { if let Err(e) = pixstrip_core::storage::atomic_write(&watches_path, &json) { eprintln!("Failed to write watch config: {}", e); std::process::exit(1); } } Err(e) => { eprintln!("Failed to serialize watch config: {}", e); std::process::exit(1); } } println!("Added watch: {} -> preset '{}'", path, preset_name); } fn cmd_watch_list() { let config_dir = watch_config_dir(); let watches_path = config_dir.join("watches.json"); let watches = load_watches(&watches_path); 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 = watch_config_dir(); let watches_path = config_dir.join("watches.json"); let mut watches = load_watches(&watches_path); let original_len = watches.len(); let target = PathBuf::from(path); let target_canonical = target.canonicalize().unwrap_or(target.clone()); watches.retain(|w| w.path != target && w.path != target_canonical); if watches.len() == original_len { println!("Watch folder not found: {}", path); return; } match serde_json::to_string_pretty(&watches) { Ok(json) => { if let Err(e) = pixstrip_core::storage::atomic_write(&watches_path, &json) { eprintln!("Failed to write watch config: {}", e); std::process::exit(1); } } Err(e) => { eprintln!("Failed to serialize watch config: {}", e); std::process::exit(1); } } println!("Removed watch folder: {}", path); } fn cmd_watch_start() { let config_dir = watch_config_dir(); let watches_path = config_dir.join("watches.json"); let watches: Vec = if watches_path.exists() { match std::fs::read_to_string(&watches_path) { Ok(content) => match serde_json::from_str(&content) { Ok(w) => w, Err(e) => { eprintln!("Warning: failed to parse watches.json: {}. Using empty config.", e); Vec::new() } }, Err(e) => { eprintln!("Warning: failed to read watches.json: {}", e); Vec::new() } } } 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(); // Collect output directories so we can skip files inside them (prevent infinite loop) let mut output_dirs: Vec = 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; } output_dirs.push(watch.path.join("processed")); watchers.push((watcher, watch.preset_name.clone())); } // Drop the original sender so the channel closes when all watcher threads exit drop(tx); if watchers.is_empty() { eprintln!("Failed to start any watchers. Check that watch folders exist and are accessible."); return; } // Process incoming files for event in &rx { match event { pixstrip_core::watcher::WatchEvent::NewImage(path) => { // Skip files inside output directories to prevent infinite processing loop. // Check if the file is a direct child of any "processed" subdirectory // under a watched folder. let in_output_dir = output_dirs.iter().any(|d| path.starts_with(d)) || active.iter().any(|w| { // Skip if the file's immediate parent is "processed" under the watch root path.parent() .and_then(|p| p.file_name()) .is_some_and(|name| name == "processed") && path.starts_with(&w.path) }); if in_output_dir { continue; } println!("New image: {}", path.display()); // Wait for file to be fully written (check size stability) { let mut last_size = 0u64; for _ in 0..10 { std::thread::sleep(std::time::Duration::from_millis(200)); let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); if size > 0 && size == last_size { break; } last_size = size; } } // Find which watcher owns this path and use its preset let matched = active.iter() .find(|w| path.starts_with(&w.path)) .map(|w| w.preset_name.clone()); if matched.is_none() { eprintln!(" Warning: no matching watch folder for {}, skipping", path.display()); continue; } if let Some(preset_name) = matched.as_deref() { let Some(preset) = find_preset(preset_name) else { eprintln!(" Preset '{}' not found, skipping", preset_name); continue; }; 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)); let history = HistoryStore::new(); let _ = history.add(pixstrip_core::storage::HistoryEntry { timestamp: unix_timestamp(), input_dir: input_dir.to_string_lossy().into(), output_dir: output_dir.to_string_lossy().into(), preset_name: Some(preset_name.to_string()), total: r.total, succeeded: r.succeeded, failed: r.failed, total_input_bytes: r.total_input_bytes, total_output_bytes: r.total_output_bytes, elapsed_ms: r.elapsed_ms, output_files: r.output_files, }, 50, 30); } 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) -> Option { // Check builtins first (case-insensitive) let lower = name.to_lowercase(); for preset in Preset::all_builtins() { if preset.name.to_lowercase() == lower { return Some(preset); } } // Check user presets - try exact match first, then case-insensitive let store = PresetStore::new(); if let Ok(preset) = store.load(name) { return Some(preset); } if let Ok(presets) = store.list() { for preset in presets { if preset.name.to_lowercase() == lower { return Some(preset); } } } None } fn parse_resize(s: &str, allow_upscale: bool) -> ResizeConfig { // Support "fit:WxH" for fit-in-box mode if let Some(dims) = s.strip_prefix("fit:") { let (w, h) = dims.split_once('x').unwrap_or_else(|| { eprintln!("Invalid fit-in-box syntax: '{}'. Use 'fit:1200x900'", s); std::process::exit(1); }); let width: u32 = w.parse().unwrap_or_else(|_| { eprintln!("Invalid fit width: '{}'", w); std::process::exit(1); }); let height: u32 = h.parse().unwrap_or_else(|_| { eprintln!("Invalid fit height: '{}'", h); std::process::exit(1); }); if width == 0 || height == 0 { eprintln!("Fit dimensions must be greater than zero"); std::process::exit(1); } return ResizeConfig::FitInBox { max: Dimensions { width, height }, allow_upscale, }; } 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); }); if width == 0 || height == 0 { eprintln!("Resize dimensions must be greater than zero"); 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', dimensions like '1200x900', or 'fit:1200x900'", s); std::process::exit(1); }); if width == 0 { eprintln!("Resize width must be greater than zero"); 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), "bmp" => Some(ImageFormat::Bmp), _ => { eprintln!("Unknown format: '{}'. Supported: jpeg, png, webp, avif, gif, tiff, bmp", 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 parse_opacity(s: &str) -> std::result::Result { let v: f32 = s.parse().map_err(|e: std::num::ParseFloatError| e.to_string())?; if !(0.0..=1.0).contains(&v) { return Err("opacity must be between 0.0 and 1.0".into()); } Ok(v) } fn parse_format_map(s: &str) -> Vec<(ImageFormat, ImageFormat)> { let mut mappings = Vec::new(); for pair in s.split(',') { let pair = pair.trim(); if pair.is_empty() { continue; } let (from, to) = pair.split_once('=').unwrap_or_else(|| { eprintln!("Invalid format mapping: '{}'. Use 'from=to' (e.g., 'png=webp')", pair); std::process::exit(1); }); let from_fmt = parse_format(from.trim()).unwrap_or_else(|| std::process::exit(1)); let to_fmt = parse_format(to.trim()).unwrap_or_else(|| std::process::exit(1)); if from_fmt == to_fmt { eprintln!("Warning: format mapping '{}={}' maps to itself, skipping", from.trim(), to.trim()); continue; } mappings.push((from_fmt, to_fmt)); } mappings } fn parse_metadata_mode(s: &str) -> MetadataConfig { match s.to_lowercase().replace(' ', "-").as_str() { "strip-all" | "strip" | "none" => MetadataConfig::StripAll, "keep-all" | "keep" | "all" => MetadataConfig::KeepAll, "privacy" => MetadataConfig::Privacy, other => { eprintln!("Unknown metadata mode: '{}'. Supported: strip-all, keep-all, privacy", other); eprintln!("For selective stripping, use --strip-gps, --strip-camera, etc."); std::process::exit(1); } } } fn parse_hex_color(s: &str) -> [u8; 4] { let hex = s.strip_prefix('#').unwrap_or(s); if hex.len() == 6 || hex.len() == 8 { if let Ok(val) = u32::from_str_radix(&hex[..6], 16) { let r = ((val >> 16) & 0xFF) as u8; let g = ((val >> 8) & 0xFF) as u8; let b = (val & 0xFF) as u8; let a = if hex.len() == 8 { u8::from_str_radix(&hex[6..8], 16).unwrap_or(255) } else { 255 }; return [r, g, b, a]; } } eprintln!("Warning: invalid hex color '{}', using white. Format: rrggbb or rrggbbaa", s); [255, 255, 255, 255] } fn parse_watermark_rotation(s: &str) -> WatermarkRotation { match s { "45" => WatermarkRotation::Degrees45, "-45" => WatermarkRotation::DegreesNeg45, "90" => WatermarkRotation::Degrees90, other => { let deg: f32 = other.parse().unwrap_or_else(|_| { eprintln!("Invalid watermark rotation: '{}'. Use 45, -45, 90, or a custom degree value", other); std::process::exit(1); }); WatermarkRotation::Custom(deg) } } } fn parse_aspect_ratio(s: &str) -> Option<(f64, f64)> { if let Some((w, h)) = s.split_once(':') { let w: f64 = w.parse().unwrap_or_else(|_| { eprintln!("Invalid aspect ratio width: '{}'", w); std::process::exit(1); }); let h: f64 = h.parse().unwrap_or_else(|_| { eprintln!("Invalid aspect ratio height: '{}'", h); std::process::exit(1); }); if w <= 0.0 || h <= 0.0 { eprintln!("Aspect ratio values must be positive"); std::process::exit(1); } Some((w, h)) } else { eprintln!("Invalid aspect ratio: '{}'. Use format like '16:9' or '4:3'", s); std::process::exit(1); } } fn parse_case_mode(s: &str) -> u32 { match s.to_lowercase().as_str() { "lower" | "lowercase" => 1, "upper" | "uppercase" => 2, "title" | "titlecase" => 3, "none" => 0, other => { eprintln!("Unknown case mode: '{}'. Supported: lower, upper, title, none", other); std::process::exit(1); } } } fn parse_space_mode(s: &str) -> u32 { match s.to_lowercase().as_str() { "underscore" | "underscores" => 1, "hyphen" | "hyphens" | "dash" => 2, "dot" | "dots" | "period" => 3, "camelcase" | "camel" => 4, "remove" | "none" => 5, other => { eprintln!("Unknown space mode: '{}'. Supported: underscore, hyphen, dot, camelcase, remove", other); std::process::exit(1); } } } fn parse_special_chars_mode(s: &str) -> u32 { match s.to_lowercase().replace(' ', "-").as_str() { "filesystem-safe" | "filesystem" | "safe" => 1, "web-safe" | "web" => 2, "hyphens-underscores" | "hyphens-and-underscores" => 3, "hyphens" | "hyphens-only" => 4, "alphanumeric" | "alnum" => 5, "keep" | "none" => 0, other => { eprintln!("Unknown special chars mode: '{}'. Supported: filesystem-safe, web-safe, hyphens-underscores, hyphens, alphanumeric, none", other); std::process::exit(1); } } } fn parse_counter_position(s: &str) -> u32 { match s.to_lowercase().replace(' ', "-").as_str() { "before-prefix" => 0, "before-name" => 1, "after-name" => 2, "after-suffix" => 3, "replace-name" | "replace" => 4, other => { eprintln!("Unknown counter position: '{}'. Supported: before-prefix, before-name, after-name, after-suffix, replace-name", other); 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 unix_timestamp() -> String { // Store as Unix seconds string (must match GTK format for pruning compatibility) std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() .to_string() } /// Format a Unix-seconds timestamp string as human-readable "YYYY-MM-DD HH:MM:SS" fn format_timestamp(ts: &str) -> String { let Ok(secs) = ts.parse::() else { return ts.to_string(); // already human-readable or unparseable - return as-is }; let days = secs / 86400; let time_secs = secs % 86400; let hours = time_secs / 3600; let minutes = (time_secs % 3600) / 60; let seconds = time_secs % 60; let mut d = days; let mut year = 1970u64; loop { let days_in_year = if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { 366 } else { 365 }; if d < days_in_year { break; } d -= days_in_year; year += 1; } let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; let month_days: [u64; 12] = if leap { [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] } else { [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] }; let mut month = 1u64; for md in &month_days { if d < *md { break; } d -= md; month += 1; } format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", year, month, d + 1, hours, minutes, seconds) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_format_valid() { assert_eq!(parse_format("jpeg"), Some(ImageFormat::Jpeg)); assert_eq!(parse_format("jpg"), Some(ImageFormat::Jpeg)); assert_eq!(parse_format("PNG"), Some(ImageFormat::Png)); assert_eq!(parse_format("webp"), Some(ImageFormat::WebP)); assert_eq!(parse_format("avif"), Some(ImageFormat::Avif)); assert_eq!(parse_format("bmp"), Some(ImageFormat::Bmp)); } #[test] fn parse_format_invalid() { assert_eq!(parse_format("xyz"), None); } #[test] fn parse_quality_valid() { assert_eq!(parse_quality("maximum"), Some(QualityPreset::Maximum)); assert_eq!(parse_quality("max"), Some(QualityPreset::Maximum)); assert_eq!(parse_quality("high"), Some(QualityPreset::High)); assert_eq!(parse_quality("medium"), Some(QualityPreset::Medium)); assert_eq!(parse_quality("med"), Some(QualityPreset::Medium)); assert_eq!(parse_quality("low"), Some(QualityPreset::Low)); assert_eq!(parse_quality("web"), Some(QualityPreset::WebOptimized)); } #[test] fn parse_quality_invalid() { assert_eq!(parse_quality("ultra"), None); assert_eq!(parse_quality(""), None); } #[test] fn parse_opacity_valid() { assert!(parse_opacity("0.5").is_ok()); assert!(parse_opacity("0.0").is_ok()); assert!(parse_opacity("1.0").is_ok()); } #[test] fn parse_opacity_invalid() { assert!(parse_opacity("1.5").is_err()); assert!(parse_opacity("-0.1").is_err()); assert!(parse_opacity("abc").is_err()); } #[test] fn format_bytes_ranges() { assert_eq!(format_bytes(500), "500 B"); assert_eq!(format_bytes(1024), "1.0 KB"); assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB"); } #[test] fn format_duration_ranges() { assert_eq!(format_duration(500), "500ms"); assert_eq!(format_duration(1500), "1.5s"); assert_eq!(format_duration(90_000), "1m 30s"); } #[test] fn find_preset_builtins() { assert!(find_preset("Blog Photos").is_some()); assert!(find_preset("blog photos").is_some()); assert!(find_preset("nonexistent preset xyz").is_none()); } #[test] fn parse_resize_width_only() { let config = parse_resize("1200", false); assert!(matches!(config, ResizeConfig::ByWidth(1200))); } #[test] fn parse_resize_exact() { let config = parse_resize("1200x900", false); assert!(matches!(config, ResizeConfig::Exact(Dimensions { width: 1200, height: 900 }))); } #[test] fn parse_resize_fit_in_box() { let config = parse_resize("fit:1200x900", false); assert!(matches!(config, ResizeConfig::FitInBox { max: Dimensions { width: 1200, height: 900 }, allow_upscale: false })); let config = parse_resize("fit:800x600", true); assert!(matches!(config, ResizeConfig::FitInBox { max: Dimensions { width: 800, height: 600 }, allow_upscale: true })); } #[test] fn parse_hex_color_valid() { assert_eq!(parse_hex_color("ff0000"), [255, 0, 0, 255]); assert_eq!(parse_hex_color("#00ff00"), [0, 255, 0, 255]); assert_eq!(parse_hex_color("0000ff80"), [0, 0, 255, 128]); } #[test] fn parse_case_mode_valid() { assert_eq!(parse_case_mode("lower"), 1); assert_eq!(parse_case_mode("upper"), 2); assert_eq!(parse_case_mode("title"), 3); assert_eq!(parse_case_mode("none"), 0); } #[test] fn parse_space_mode_valid() { assert_eq!(parse_space_mode("underscore"), 1); assert_eq!(parse_space_mode("hyphen"), 2); assert_eq!(parse_space_mode("dot"), 3); assert_eq!(parse_space_mode("camelcase"), 4); assert_eq!(parse_space_mode("remove"), 5); } #[test] fn parse_metadata_mode_valid() { assert!(matches!(parse_metadata_mode("strip-all"), MetadataConfig::StripAll)); assert!(matches!(parse_metadata_mode("keep-all"), MetadataConfig::KeepAll)); assert!(matches!(parse_metadata_mode("privacy"), MetadataConfig::Privacy)); } #[test] fn parse_aspect_ratio_valid() { assert_eq!(parse_aspect_ratio("16:9"), Some((16.0, 9.0))); assert_eq!(parse_aspect_ratio("1:1"), Some((1.0, 1.0))); } }