Implement fully functional CLI with process, preset, history, and undo commands

Process command discovers images, builds pipeline from preset or CLI
flags (--resize, --format, --quality, --strip-metadata, --recursive),
executes with progress output, prints results with size savings, and
saves to history. Preset list/export/import use the storage module.
This commit is contained in:
2026-03-06 11:13:33 +02:00
parent 1587764b1e
commit 06860163f4

View File

@@ -1,4 +1,12 @@
use clap::{Parser, Subcommand}; 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)] #[derive(Parser)]
#[command(name = "pixstrip")] #[command(name = "pixstrip")]
@@ -56,11 +64,7 @@ enum Commands {
History, History,
/// Undo last batch operation /// Undo last batch operation
Undo { Undo,
/// Undo the last operation
#[arg(long)]
last: bool,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -74,9 +78,7 @@ enum PresetAction {
output: String, output: String,
}, },
/// Import a preset from a file /// Import a preset from a file
Import { Import { path: String },
path: String,
},
} }
fn main() { fn main() {
@@ -86,40 +88,376 @@ fn main() {
Commands::Process { Commands::Process {
input, input,
preset, preset,
output: _, output,
resize: _, resize,
format: _, format,
quality: _, quality,
strip_metadata: _, strip_metadata,
recursive: _, recursive,
} => { } => {
println!("Processing {} input(s)", input.len()); cmd_process(
if let Some(preset) = &preset { input,
println!("Using preset: {}", preset); preset,
} output,
println!("Processing not yet implemented"); resize,
format,
quality,
strip_metadata,
recursive,
);
} }
Commands::Preset { action } => match action { Commands::Preset { action } => match action {
PresetAction::List => { PresetAction::List => cmd_preset_list(),
println!("Available presets:"); PresetAction::Export { name, output } => cmd_preset_export(&name, &output),
for preset in pixstrip_core::preset::Preset::all_builtins() { PresetAction::Import { path } => cmd_preset_import(&path),
},
Commands::History => cmd_history(),
Commands::Undo => cmd_undo(),
}
}
#[allow(clippy::too_many_arguments)]
fn cmd_process(
input: Vec<String>,
preset_name: Option<String>,
output: Option<String>,
resize: Option<String>,
format: Option<String>,
quality: Option<String>,
strip_metadata: bool,
recursive: bool,
) {
// Collect input files
let mut source_files = Vec::new();
for path_str in &input {
let path = PathBuf::from(path_str);
if path.is_dir() {
let files = discovery::discover_images(&path, recursive);
source_files.extend(files);
} else if path.is_file() {
source_files.push(path);
} else {
eprintln!("Warning: '{}' not found, skipping", path_str);
}
}
if source_files.is_empty() {
eprintln!("No images found in the specified input(s)");
std::process::exit(1);
}
println!("Found {} image(s)", source_files.len());
// Determine input directory (use parent of first file)
let input_dir = source_files[0]
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf();
// Determine output directory
let output_dir = output
.map(PathBuf::from)
.unwrap_or_else(|| input_dir.join("processed"));
// Build job from preset or CLI args
let mut job = if let Some(ref name) = preset_name {
let preset = find_preset(name);
println!("Using preset: {}", preset.name);
preset.to_job(&input_dir, &output_dir)
} else {
ProcessingJob::new(&input_dir, &output_dir)
};
// Override with CLI flags
if let Some(ref resize_str) = resize {
job.resize = Some(parse_resize(resize_str));
}
if let Some(ref fmt_str) = format
&& let Some(fmt) = parse_format(fmt_str)
{
job.convert = Some(ConvertConfig::SingleFormat(fmt));
}
if let Some(ref q_str) = quality
&& let Some(preset) = parse_quality(q_str)
{
job.compress = Some(CompressConfig::Preset(preset));
}
if strip_metadata {
job.metadata = Some(MetadataConfig::StripAll);
}
// Add sources
for file in &source_files {
job.add_source(file);
}
// Execute
let executor = PipelineExecutor::new();
let result = executor
.execute(&job, |update| {
eprint!(
"\r[{}/{}] {}...",
update.current, update.total, update.current_file
);
})
.unwrap_or_else(|e| {
eprintln!("\nProcessing failed: {}", e);
std::process::exit(1);
});
eprintln!();
// Print results
println!();
println!("Processing complete:");
println!(" Succeeded: {}", result.succeeded);
if result.failed > 0 {
println!(" Failed: {}", result.failed);
}
println!(
" Size: {} -> {} ({:.0}% reduction)",
format_bytes(result.total_input_bytes),
format_bytes(result.total_output_bytes),
if result.total_input_bytes > 0 {
(1.0 - result.total_output_bytes as f64 / result.total_input_bytes as f64) * 100.0
} else {
0.0
}
);
println!(" Time: {}", format_duration(result.elapsed_ms));
println!(" Output: {}", output_dir.display());
if !result.errors.is_empty() {
println!();
println!("Errors:");
for (file, err) in &result.errors {
println!(" {} - {}", file, err);
}
}
// Save to history
let history = HistoryStore::new();
let output_files: Vec<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,
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); println!(" {} - {}", preset.name, preset.description);
} }
} }
PresetAction::Export { name, output } => {
println!("Export preset '{}' to '{}'", name, output);
} }
PresetAction::Import { path } => {
println!("Import preset from '{}'", path); fn cmd_preset_export(name: &str, output: &str) {
} let preset = find_preset(name);
}, let store = PresetStore::new();
Commands::History => { match store.export_to_file(&preset, &PathBuf::from(output)) {
println!("Processing history not yet implemented"); Ok(()) => println!("Exported '{}' to '{}'", name, output),
} Err(e) => {
Commands::Undo { last } => { eprintln!("Failed to export preset: {}", e);
if last { std::process::exit(1);
println!("Undo not yet implemented");
} }
} }
} }
fn cmd_preset_import(path: &str) {
let store = PresetStore::new();
match store.import_from_file(&PathBuf::from(path)) {
Ok(preset) => {
let name = preset.name.clone();
match store.save(&preset) {
Ok(()) => println!("Imported preset '{}'", name),
Err(e) => {
eprintln!("Failed to save imported preset: {}", e);
std::process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to import preset: {}", e);
std::process::exit(1);
}
}
}
fn cmd_history() {
let history = HistoryStore::new();
match history.list() {
Ok(entries) => {
if entries.is_empty() {
println!("No processing history yet.");
return;
}
for (i, entry) in entries.iter().enumerate().rev() {
println!(
"{}. [{}] {} -> {} ({}/{} succeeded, {})",
i + 1,
entry.timestamp,
entry.input_dir,
entry.output_dir,
entry.succeeded,
entry.total,
format_duration(entry.elapsed_ms),
);
}
}
Err(e) => {
eprintln!("Failed to read history: {}", e);
std::process::exit(1);
}
}
}
fn cmd_undo() {
let history = HistoryStore::new();
match history.list() {
Ok(entries) => {
if let Some(last) = entries.last() {
println!("Last operation: {} images from {}", last.total, last.input_dir);
println!("Output files would be moved to trash.");
println!("(Undo with file deletion not yet implemented - requires gio trash support)");
} else {
println!("No processing history to undo.");
}
}
Err(e) => {
eprintln!("Failed to read history: {}", e);
std::process::exit(1);
}
}
}
// --- Helpers ---
fn find_preset(name: &str) -> Preset {
// Check builtins first
let lower = name.to_lowercase();
for preset in Preset::all_builtins() {
if preset.name.to_lowercase() == lower {
return preset;
}
}
// Check user presets
let store = PresetStore::new();
if let Ok(preset) = store.load(name) {
return preset;
}
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
std::process::exit(1);
}
fn parse_resize(s: &str) -> ResizeConfig {
if let Some((w, h)) = s.split_once('x') {
let width: u32 = w.parse().unwrap_or_else(|_| {
eprintln!("Invalid resize width: '{}'", w);
std::process::exit(1);
});
let height: u32 = h.parse().unwrap_or_else(|_| {
eprintln!("Invalid resize height: '{}'", h);
std::process::exit(1);
});
ResizeConfig::Exact(Dimensions { width, height })
} else {
let width: u32 = s.parse().unwrap_or_else(|_| {
eprintln!("Invalid resize value: '{}'. Use a width like '1200' or dimensions like '1200x900'", s);
std::process::exit(1);
});
ResizeConfig::ByWidth(width)
}
}
fn parse_format(s: &str) -> Option<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 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())
} }