Files
pixstrip/pixstrip-cli/src/main.rs

841 lines
25 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::path::PathBuf;
#[derive(Parser)]
#[command(name = "pixstrip")]
#[command(about = "Batch image processor - resize, convert, compress, strip metadata, watermark, and rename")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
enum Commands {
/// Process images with a preset or custom operations
Process {
/// Input files or directories
#[arg(required = true)]
input: Vec<String>,
/// Preset name to use
#[arg(long)]
preset: Option<String>,
/// Output directory
#[arg(short, long)]
output: Option<String>,
/// Resize to width (e.g., "1200" or "1200x900")
#[arg(long)]
resize: Option<String>,
/// Output format (jpeg, png, webp, avif, gif, tiff)
#[arg(long)]
format: Option<String>,
/// Quality preset (maximum, high, medium, low, web)
#[arg(long)]
quality: Option<String>,
/// Strip all metadata
#[arg(long)]
strip_metadata: bool,
/// Rotate images (90, 180, 270, auto)
#[arg(long)]
rotate: Option<String>,
/// Flip images (horizontal, vertical)
#[arg(long)]
flip: Option<String>,
/// Add text watermark (e.g., "(c) 2026 My Name")
#[arg(long)]
watermark: Option<String>,
/// 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<String>,
/// Rename with suffix
#[arg(long)]
rename_suffix: Option<String>,
/// Rename template (e.g., "{name}_{counter:3}.{ext}")
#[arg(long)]
rename_template: Option<String>,
/// Include subdirectories
#[arg(short, long)]
recursive: bool,
},
/// Manage presets
Preset {
#[command(subcommand)]
action: PresetAction,
},
/// Manage watch folders
Watch {
#[command(subcommand)]
action: WatchAction,
},
/// View processing history
History,
/// Undo last batch operation (moves output files to trash)
Undo {
/// Undo the last N batches (default 1)
#[arg(long, default_value = "1")]
last: usize,
},
}
#[derive(Subcommand)]
enum WatchAction {
/// Add a watch folder with a linked preset
Add {
/// Folder path to watch
path: String,
/// Preset name to apply
#[arg(long)]
preset: String,
/// Watch subdirectories recursively
#[arg(short, long)]
recursive: bool,
},
/// List configured watch folders
List,
/// Remove a watch folder
Remove {
/// Folder path to remove
path: String,
},
/// Start watching configured folders (blocks until Ctrl+C)
Start,
}
#[derive(Subcommand)]
enum PresetAction {
/// List all presets
List,
/// Export a preset to a file
Export {
name: String,
#[arg(short, long)]
output: String,
},
/// Import a preset from a file
Import { path: String },
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Process {
input,
preset,
output,
resize,
format,
quality,
strip_metadata,
rotate,
flip,
watermark,
watermark_position,
watermark_opacity,
rename_prefix,
rename_suffix,
rename_template,
recursive,
} => {
cmd_process(CmdProcessArgs {
input,
preset,
output,
resize,
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(),
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: Option<String>,
format: Option<String>,
quality: Option<String>,
strip_metadata: bool,
rotate: Option<String>,
flip: Option<String>,
watermark: Option<String>,
watermark_position: String,
watermark_opacity: f32,
rename_prefix: Option<String>,
rename_suffix: Option<String>,
rename_template: Option<String>,
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() {
source_files.push(path);
} else {
eprintln!("Warning: '{}' not found, skipping", path_str);
}
}
if source_files.is_empty() {
eprintln!("No images found in the specified input(s)");
std::process::exit(1);
}
println!("Found {} image(s)", source_files.len());
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);
println!("Using preset: {}", preset.name);
preset.to_job(&input_dir, &output_dir)
} else {
ProcessingJob::new(&input_dir, &output_dir)
};
// Override with CLI flags
if let Some(ref resize_str) = args.resize {
job.resize = Some(parse_resize(resize_str));
}
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) = args.quality
&& let Some(preset) = parse_quality(q_str)
{
job.compress = Some(CompressConfig::Preset(preset));
}
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,
});
}
for file in &source_files {
job.add_source(file);
}
// Execute
let executor = PipelineExecutor::new();
let result = executor
.execute(&job, |update| {
eprint!(
"\r[{}/{}] {}...",
update.current, update.total, update.current_file
);
})
.unwrap_or_else(|e| {
eprintln!("\nProcessing failed: {}", e);
std::process::exit(1);
});
eprintln!();
// Print results
println!();
println!("Processing complete:");
println!(" Succeeded: {}", result.succeeded);
if result.failed > 0 {
println!(" Failed: {}", result.failed);
}
println!(
" Size: {} -> {} ({:.0}% reduction)",
format_bytes(result.total_input_bytes),
format_bytes(result.total_output_bytes),
if result.total_input_bytes > 0 {
(1.0 - result.total_output_bytes as f64 / result.total_input_bytes as f64) * 100.0
} else {
0.0
}
);
println!(" Time: {}", format_duration(result.elapsed_ms));
println!(" Output: {}", output_dir.display());
if !result.errors.is_empty() {
println!();
println!("Errors:");
for (file, err) in &result.errors {
println!(" {} - {}", file, err);
}
}
// Save to history
let history = HistoryStore::new();
let output_files: Vec<String> = source_files
.iter()
.map(|f| {
output_dir
.join(f.file_name().unwrap_or_default())
.to_string_lossy()
.into()
})
.collect();
let _ = history.add(pixstrip_core::storage::HistoryEntry {
timestamp: chrono_timestamp(),
input_dir: input_dir.to_string_lossy().into(),
output_dir: output_dir.to_string_lossy().into(),
preset_name: 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,
});
}
fn cmd_preset_list() {
println!("Built-in presets:");
for preset in Preset::all_builtins() {
println!(" {} - {}", preset.name, preset.description);
}
let store = PresetStore::new();
if let Ok(user_presets) = store.list()
&& !user_presets.is_empty()
{
println!();
println!("User presets:");
for preset in user_presets {
println!(" {} - {}", preset.name, preset.description);
}
}
}
fn cmd_preset_export(name: &str, output: &str) {
let preset = find_preset(name);
let store = PresetStore::new();
match store.export_to_file(&preset, &PathBuf::from(output)) {
Ok(()) => println!("Exported '{}' to '{}'", name, output),
Err(e) => {
eprintln!("Failed to export preset: {}", e);
std::process::exit(1);
}
}
}
fn cmd_preset_import(path: &str) {
let store = PresetStore::new();
match store.import_from_file(&PathBuf::from(path)) {
Ok(preset) => {
let name = preset.name.clone();
match store.save(&preset) {
Ok(()) => println!("Imported preset '{}'", name),
Err(e) => {
eprintln!("Failed to save imported preset: {}", e);
std::process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to import preset: {}", e);
std::process::exit(1);
}
}
}
fn cmd_history() {
let history = HistoryStore::new();
match history.list() {
Ok(entries) => {
if entries.is_empty() {
println!("No processing history yet.");
return;
}
for (i, entry) in entries.iter().enumerate().rev() {
println!(
"{}. [{}] {} -> {} ({}/{} succeeded, {})",
i + 1,
entry.timestamp,
entry.input_dir,
entry.output_dir,
entry.succeeded,
entry.total,
format_duration(entry.elapsed_ms),
);
}
}
Err(e) => {
eprintln!("Failed to read history: {}", e);
std::process::exit(1);
}
}
}
fn cmd_undo(count: usize) {
let history = HistoryStore::new();
match history.list() {
Ok(entries) => {
if entries.is_empty() {
println!("No processing history to undo.");
return;
}
let to_undo = entries.iter().rev().take(count);
let mut total_trashed = 0;
for entry in to_undo {
if entry.output_files.is_empty() {
println!(
"Batch from {} has no recorded output files - cannot undo",
entry.timestamp
);
continue;
}
println!(
"Undoing batch: {} images from {}",
entry.total, entry.input_dir
);
for file_path in &entry.output_files {
let path = PathBuf::from(file_path);
if path.exists() {
// Move to OS trash using the trash crate
match trash::delete(&path) {
Ok(()) => {
total_trashed += 1;
}
Err(e) => {
eprintln!(" Failed to trash {}: {}", path.display(), 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
let _preset = find_preset(preset_name);
let watch_path = PathBuf::from(path);
if !watch_path.exists() {
eprintln!("Watch folder does not exist: {}", 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 = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("pixstrip");
let watches_path = config_dir.join("watches.json");
let mut watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() {
std::fs::read_to_string(&watches_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
} else {
Vec::new()
};
// Don't add duplicate paths
if watches.iter().any(|w| w.path == watch.path) {
println!("Watch folder already configured: {}", path);
return;
}
watches.push(watch);
let _ = std::fs::create_dir_all(&config_dir);
let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap());
println!("Added watch: {} -> preset '{}'", path, preset_name);
}
fn cmd_watch_list() {
let config_dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("pixstrip");
let watches_path = config_dir.join("watches.json");
let watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() {
std::fs::read_to_string(&watches_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
} else {
Vec::new()
};
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 = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("pixstrip");
let watches_path = config_dir.join("watches.json");
let mut watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() {
std::fs::read_to_string(&watches_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
} else {
Vec::new()
};
let original_len = watches.len();
let target = PathBuf::from(path);
watches.retain(|w| w.path != target);
if watches.len() == original_len {
println!("Watch folder not found: {}", path);
return;
}
let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap());
println!("Removed watch folder: {}", path);
}
fn cmd_watch_start() {
let config_dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("pixstrip");
let watches_path = config_dir.join("watches.json");
let watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() {
std::fs::read_to_string(&watches_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
} 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();
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;
}
watchers.push((watcher, watch.preset_name.clone()));
}
// Process incoming files
for event in &rx {
match event {
pixstrip_core::watcher::WatchEvent::NewImage(path) => {
println!("New image: {}", path.display());
// Find which watcher this came from and use its preset
if let Some((_, preset_name)) = watchers.first() {
let preset = find_preset(preset_name);
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)),
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) -> Preset {
// Check builtins first
let lower = name.to_lowercase();
for preset in Preset::all_builtins() {
if preset.name.to_lowercase() == lower {
return preset;
}
}
// Check user presets
let store = PresetStore::new();
if let Ok(preset) = store.load(name) {
return preset;
}
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
std::process::exit(1);
}
fn parse_resize(s: &str) -> ResizeConfig {
if let Some((w, h)) = s.split_once('x') {
let width: u32 = w.parse().unwrap_or_else(|_| {
eprintln!("Invalid resize width: '{}'", w);
std::process::exit(1);
});
let height: u32 = h.parse().unwrap_or_else(|_| {
eprintln!("Invalid resize height: '{}'", h);
std::process::exit(1);
});
ResizeConfig::Exact(Dimensions { width, height })
} else {
let width: u32 = s.parse().unwrap_or_else(|_| {
eprintln!("Invalid resize value: '{}'. Use a width like '1200' or dimensions like '1200x900'", s);
std::process::exit(1);
});
ResizeConfig::ByWidth(width)
}
}
fn parse_format(s: &str) -> Option<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),
_ => {
eprintln!("Unknown format: '{}'. Supported: jpeg, png, webp, avif, gif, tiff", 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 format_bytes(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
fn format_duration(ms: u64) -> String {
if ms < 1000 {
format!("{}ms", ms)
} else if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
let mins = ms / 60_000;
let secs = (ms % 60_000) / 1000;
format!("{}m {}s", mins, secs)
}
}
fn chrono_timestamp() -> String {
// Simple timestamp without chrono dependency
let now = std::time::SystemTime::now();
let duration = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}", duration.as_secs())
}