diff --git a/pixstrip-cli/src/main.rs b/pixstrip-cli/src/main.rs index 0d5d573..acd4811 100644 --- a/pixstrip-cli/src/main.rs +++ b/pixstrip-cli/src/main.rs @@ -18,6 +18,7 @@ struct Cli { } #[derive(Subcommand)] +#[allow(clippy::large_enum_variant)] enum Commands { /// Process images with a preset or custom operations Process { @@ -37,7 +38,7 @@ enum Commands { #[arg(long)] resize: Option, - /// Output format (jpeg, png, webp, avif) + /// Output format (jpeg, png, webp, avif, gif, tiff) #[arg(long)] format: Option, @@ -49,6 +50,38 @@ enum Commands { #[arg(long)] strip_metadata: bool, + /// Rotate images (90, 180, 270, auto) + #[arg(long)] + rotate: Option, + + /// Flip images (horizontal, vertical) + #[arg(long)] + flip: Option, + + /// Add text watermark (e.g., "(c) 2026 My Name") + #[arg(long)] + watermark: Option, + + /// Watermark position (top-left, top-center, top-right, center, bottom-left, bottom-center, bottom-right) + #[arg(long, default_value = "bottom-right")] + watermark_position: String, + + /// Watermark opacity (0.0-1.0) + #[arg(long, default_value = "0.5")] + watermark_opacity: f32, + + /// Rename with prefix + #[arg(long)] + rename_prefix: Option, + + /// Rename with suffix + #[arg(long)] + rename_suffix: Option, + + /// Rename template (e.g., "{name}_{counter:3}.{ext}") + #[arg(long)] + rename_template: Option, + /// Include subdirectories #[arg(short, long)] recursive: bool, @@ -93,9 +126,17 @@ fn main() { format, quality, strip_metadata, + rotate, + flip, + watermark, + watermark_position, + watermark_opacity, + rename_prefix, + rename_suffix, + rename_template, recursive, } => { - cmd_process( + cmd_process(CmdProcessArgs { input, preset, output, @@ -103,8 +144,16 @@ fn main() { format, quality, strip_metadata, + rotate, + flip, + watermark, + watermark_position, + watermark_opacity, + rename_prefix, + rename_suffix, + rename_template, recursive, - ); + }); } Commands::Preset { action } => match action { PresetAction::List => cmd_preset_list(), @@ -116,23 +165,32 @@ fn main() { } } -#[allow(clippy::too_many_arguments)] -fn cmd_process( +struct CmdProcessArgs { input: Vec, - preset_name: Option, + preset: Option, output: Option, resize: Option, format: Option, quality: Option, strip_metadata: bool, + rotate: Option, + flip: Option, + watermark: Option, + watermark_position: String, + watermark_opacity: f32, + rename_prefix: Option, + rename_suffix: Option, + rename_template: Option, recursive: bool, -) { +} + +fn cmd_process(args: CmdProcessArgs) { // Collect input files let mut source_files = Vec::new(); - for path_str in &input { + for path_str in &args.input { let path = PathBuf::from(path_str); if path.is_dir() { - let files = discovery::discover_images(&path, recursive); + let files = discovery::discover_images(&path, args.recursive); source_files.extend(files); } else if path.is_file() { source_files.push(path); @@ -148,19 +206,17 @@ fn cmd_process( println!("Found {} image(s)", source_files.len()); - // Determine input directory (use parent of first file) let input_dir = source_files[0] .parent() .unwrap_or_else(|| std::path::Path::new(".")) .to_path_buf(); - // Determine output directory - let output_dir = output + let output_dir = args.output + .as_ref() .map(PathBuf::from) .unwrap_or_else(|| input_dir.join("processed")); - // Build job from preset or CLI args - let mut job = if let Some(ref name) = preset_name { + let mut job = if let Some(ref name) = args.preset { let preset = find_preset(name); println!("Using preset: {}", preset.name); preset.to_job(&input_dir, &output_dir) @@ -169,24 +225,48 @@ fn cmd_process( }; // Override with CLI flags - if let Some(ref resize_str) = resize { + if let Some(ref resize_str) = args.resize { job.resize = Some(parse_resize(resize_str)); } - if let Some(ref fmt_str) = format + if let Some(ref fmt_str) = args.format && let Some(fmt) = parse_format(fmt_str) { job.convert = Some(ConvertConfig::SingleFormat(fmt)); } - if let Some(ref q_str) = quality + if let Some(ref q_str) = args.quality && let Some(preset) = parse_quality(q_str) { job.compress = Some(CompressConfig::Preset(preset)); } - if strip_metadata { + if args.strip_metadata { job.metadata = Some(MetadataConfig::StripAll); } + if let Some(ref rot) = args.rotate { + job.rotation = Some(parse_rotation(rot)); + } + if let Some(ref fl) = args.flip { + job.flip = Some(parse_flip(fl)); + } + if let Some(ref text) = args.watermark { + let position = parse_watermark_position(&args.watermark_position); + job.watermark = Some(WatermarkConfig::Text { + text: text.clone(), + position, + font_size: 24.0, + opacity: args.watermark_opacity, + color: [255, 255, 255, 255], + }); + } + if args.rename_prefix.is_some() || args.rename_suffix.is_some() || args.rename_template.is_some() { + job.rename = Some(RenameConfig { + prefix: args.rename_prefix.unwrap_or_default(), + suffix: args.rename_suffix.unwrap_or_default(), + counter_start: 1, + counter_padding: 3, + template: args.rename_template, + }); + } - // Add sources for file in &source_files { job.add_source(file); } @@ -251,7 +331,7 @@ fn cmd_process( timestamp: chrono_timestamp(), input_dir: input_dir.to_string_lossy().into(), output_dir: output_dir.to_string_lossy().into(), - preset_name, + preset_name: args.preset, total: result.total, succeeded: result.succeeded, failed: result.failed, @@ -429,6 +509,50 @@ fn parse_quality(s: &str) -> Option { } } +fn parse_rotation(s: &str) -> Rotation { + match s.to_lowercase().as_str() { + "90" | "cw90" => Rotation::Cw90, + "180" => Rotation::Cw180, + "270" | "cw270" => Rotation::Cw270, + "auto" => Rotation::AutoOrient, + "none" | "0" => Rotation::None, + _ => { + eprintln!("Unknown rotation: '{}'. Supported: 90, 180, 270, auto, none", s); + std::process::exit(1); + } + } +} + +fn parse_flip(s: &str) -> Flip { + match s.to_lowercase().as_str() { + "horizontal" | "h" => Flip::Horizontal, + "vertical" | "v" => Flip::Vertical, + "none" => Flip::None, + _ => { + eprintln!("Unknown flip: '{}'. Supported: horizontal, vertical, none", s); + std::process::exit(1); + } + } +} + +fn parse_watermark_position(s: &str) -> WatermarkPosition { + match s.to_lowercase().replace(' ', "-").as_str() { + "top-left" | "tl" => WatermarkPosition::TopLeft, + "top-center" | "tc" | "top" => WatermarkPosition::TopCenter, + "top-right" | "tr" => WatermarkPosition::TopRight, + "middle-left" | "ml" | "left" => WatermarkPosition::MiddleLeft, + "center" | "c" | "middle" => WatermarkPosition::Center, + "middle-right" | "mr" | "right" => WatermarkPosition::MiddleRight, + "bottom-left" | "bl" => WatermarkPosition::BottomLeft, + "bottom-center" | "bc" | "bottom" => WatermarkPosition::BottomCenter, + "bottom-right" | "br" => WatermarkPosition::BottomRight, + _ => { + eprintln!("Unknown position: '{}'. Supported: top-left, top-center, top-right, center, bottom-left, bottom-center, bottom-right", s); + std::process::exit(1); + } + } +} + fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{} B", bytes)