Files
pixstrip/pixstrip-cli/src/main.rs
lashman f3668c45c3 Improve UX, add popover tour, metadata, and hicolor icons
- Redesign tutorial tour from modal dialogs to popovers pointing at actual UI elements
- Add beginner-friendly improvements: help buttons, tooltips, welcome wizard enhancements
- Add AppStream metainfo with screenshots, branding, categories, keywords, provides
- Update desktop file with GTK category and SingleMainWindow
- Add hicolor icon theme with all sizes (16-512px)
- Fix debounce SourceId panic in rename step
- Various step UI improvements and bug fixes
2026-03-08 14:18:15 +02:00

1984 lines
69 KiB
Rust

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<pixstrip_core::watcher::WatchFolder> {
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 <command>
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<String>,
/// Preset name to use (see 'pixstrip preset list' for available presets)
#[arg(long)]
preset: Option<String>,
/// Output directory [default: <input>/processed]
#[arg(short, long)]
output: Option<String>,
// --- Resize ---
/// Resize images. Formats: "1200" (width), "1200x900" (exact), "fit:1200x900" (fit in box)
#[arg(long, help_heading = "Resize")]
resize: Option<String>,
/// 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<String>,
/// Per-format conversion mapping (e.g., "png=webp,tiff=jpeg"). Cannot combine with --format
#[arg(long, help_heading = "Convert")]
format_map: Option<String>,
// --- Compress ---
/// Quality preset
#[arg(long, help_heading = "Compress",
value_parser = ["maximum", "max", "high", "medium", "med", "low", "web"])]
quality: Option<String>,
/// 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<u8>,
/// 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<u8>,
/// 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<u8>,
/// 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<u8>,
/// 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<u8>,
/// 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<String>,
/// 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<String>,
/// Flip/mirror images
#[arg(long, help_heading = "Transform",
value_parser = ["horizontal", "vertical", "h", "v", "none"])]
flip: Option<String>,
// --- Adjustments ---
/// Brightness adjustment (-100 to 100, 0 = no change)
#[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")]
brightness: Option<i32>,
/// Contrast adjustment (-100 to 100, 0 = no change)
#[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")]
contrast: Option<i32>,
/// Saturation adjustment (-100 to 100, 0 = no change)
#[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")]
saturation: Option<i32>,
/// 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<String>,
/// 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<u32>,
// --- Watermark ---
/// Add text watermark. Cannot combine with --watermark-image
#[arg(long, help_heading = "Watermark")]
watermark: Option<String>,
/// Add image watermark from file. Cannot combine with --watermark
#[arg(long, help_heading = "Watermark")]
watermark_image: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// Add suffix after filename (e.g., "-web" turns "photo.jpg" into "photo-web.jpg")
#[arg(long, help_heading = "Rename")]
rename_suffix: Option<String>,
/// Rename using a template. Overrides --rename-prefix/--rename-suffix. See TEMPLATE VARIABLES below
#[arg(long, help_heading = "Rename")]
rename_template: Option<String>,
/// Convert filename case
#[arg(long, help_heading = "Rename",
value_parser = ["lower", "upper", "title", "none"])]
rename_case: Option<String>,
/// Replace spaces in filenames
#[arg(long, help_heading = "Rename",
value_parser = ["underscore", "hyphen", "dot", "camelcase", "remove"])]
rename_spaces: Option<String>,
/// 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<String>,
/// Regex pattern to find in filenames (paired with --rename-regex-replace)
#[arg(long, help_heading = "Rename")]
rename_regex_find: Option<String>,
/// Replacement for regex matches (supports $1, $2 capture groups)
#[arg(long, help_heading = "Rename")]
rename_regex_replace: Option<String>,
/// 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<u32>,
/// 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 <folder>/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<String>,
preset: Option<String>,
output: Option<String>,
// Resize
resize: Option<String>,
algorithm: String,
allow_upscale: bool,
// Convert
format: Option<String>,
format_map: Option<String>,
// Compress
quality: Option<String>,
jpeg_quality: Option<u8>,
png_level: Option<u8>,
webp_quality: Option<u8>,
avif_quality: Option<u8>,
avif_speed: Option<u8>,
progressive_jpeg: bool,
// Metadata
metadata: Option<String>,
strip_gps: bool,
strip_camera: bool,
strip_software: bool,
strip_timestamps: bool,
strip_copyright: bool,
strip_metadata: bool,
// Rotation / Flip
rotate: Option<String>,
flip: Option<String>,
// Adjustments
brightness: Option<i32>,
contrast: Option<i32>,
saturation: Option<i32>,
sharpen: bool,
grayscale: bool,
sepia: bool,
crop_aspect_ratio: Option<String>,
trim_whitespace: bool,
canvas_padding: Option<u32>,
// Watermark
watermark: Option<String>,
watermark_image: Option<String>,
watermark_position: String,
watermark_opacity: f32,
watermark_font_size: f32,
watermark_font: Option<String>,
watermark_color: String,
watermark_rotation: Option<String>,
watermark_tiled: bool,
watermark_margin: u32,
watermark_scale: f32,
// Rename
rename_prefix: Option<String>,
rename_suffix: Option<String>,
rename_template: Option<String>,
rename_case: Option<String>,
rename_spaces: Option<String>,
rename_special_chars: Option<String>,
rename_regex_find: Option<String>,
rename_regex_replace: Option<String>,
rename_counter_position: String,
rename_counter_start: u32,
rename_counter_padding: u32,
// Output
overwrite: String,
preserve_dirs: bool,
output_dpi: Option<u32>,
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 <path> --preset <name>' 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<pixstrip_core::watcher::WatchFolder> = 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<PathBuf> = 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<Preset> {
// 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<ImageFormat> {
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<QualityPreset> {
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<f32, String> {
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::<u64>() 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)));
}
}