Fix 26 bugs, edge cases, and consistency issues from fifth audit pass
Critical: undo toast now trashes only batch output files (not entire dir), JPEG scanline write errors propagated, selective metadata write result returned. High: zero-dimension guards in ResizeConfig/fit_within, negative aspect ratio rejection, FM integration toggle infinite recursion guard, saturating counter arithmetic in executor. Medium: PNG compression level passed to oxipng, pct mode updates job_config, external file loading updates step indicator, CLI undo removes history entries, watch config write failures reported, fast-copy path reads image dimensions for rename templates, discovery excludes unprocessable formats (heic/svg/ico/jxl), CLI warns on invalid algorithm/overwrite values, resolve_collision trailing dot fix, generation guards on all preview threads to cancel stale results, default DPI aligned to 0, watermark text width uses char count not byte length. Low: binary path escaped in Nautilus extension, file dialog filter aligned with discovery, reset_wizard clears preset_mode and output_dir.
This commit is contained in:
@@ -6,6 +6,7 @@ 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;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -67,7 +68,7 @@ enum Commands {
|
||||
watermark_position: String,
|
||||
|
||||
/// Watermark opacity (0.0-1.0)
|
||||
#[arg(long, default_value = "0.5")]
|
||||
#[arg(long, default_value = "0.5", value_parser = parse_opacity)]
|
||||
watermark_opacity: f32,
|
||||
|
||||
/// Rename with prefix
|
||||
@@ -275,7 +276,10 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
.unwrap_or_else(|| input_dir.join("processed"));
|
||||
|
||||
let mut job = if let Some(ref name) = args.preset {
|
||||
let preset = find_preset(name);
|
||||
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 {
|
||||
@@ -286,14 +290,12 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
if let Some(ref q_str) = args.quality
|
||||
&& let Some(preset) = parse_quality(q_str)
|
||||
{
|
||||
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 args.strip_metadata {
|
||||
@@ -320,13 +322,28 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
});
|
||||
}
|
||||
if args.rename_prefix.is_some() || args.rename_suffix.is_some() || args.rename_template.is_some() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
job.rename = Some(RenameConfig {
|
||||
prefix: args.rename_prefix.unwrap_or_default(),
|
||||
suffix: args.rename_suffix.unwrap_or_default(),
|
||||
counter_start: 1,
|
||||
counter_padding: 3,
|
||||
counter_enabled: true,
|
||||
counter_position: 3,
|
||||
template: args.rename_template,
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
});
|
||||
@@ -336,13 +353,21 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
"catmullrom" | "catmull-rom" => ResizeAlgorithm::CatmullRom,
|
||||
"bilinear" => ResizeAlgorithm::Bilinear,
|
||||
"nearest" => ResizeAlgorithm::Nearest,
|
||||
_ => ResizeAlgorithm::Lanczos3,
|
||||
"lanczos3" | "lanczos" => ResizeAlgorithm::Lanczos3,
|
||||
other => {
|
||||
eprintln!("Warning: unknown algorithm '{}', using lanczos3. Valid: lanczos3, catmullrom, bilinear, nearest", other);
|
||||
ResizeAlgorithm::Lanczos3
|
||||
}
|
||||
};
|
||||
|
||||
job.overwrite_behavior = match args.overwrite.to_lowercase().as_str() {
|
||||
"overwrite" | "always" => OverwriteBehavior::Overwrite,
|
||||
"skip" => OverwriteBehavior::Skip,
|
||||
_ => OverwriteBehavior::AutoRename,
|
||||
"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
|
||||
}
|
||||
};
|
||||
|
||||
for file in &source_files {
|
||||
@@ -357,6 +382,7 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
"\r[{}/{}] {}...",
|
||||
update.current, update.total, update.current_file
|
||||
);
|
||||
let _ = std::io::stderr().flush();
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("\nProcessing failed: {}", e);
|
||||
@@ -395,20 +421,39 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
|
||||
// Save to history
|
||||
let history = HistoryStore::new();
|
||||
let output_ext = match job.convert {
|
||||
Some(ConvertConfig::SingleFormat(fmt)) => fmt.extension(),
|
||||
_ => "",
|
||||
};
|
||||
let output_files: Vec<String> = source_files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
output_dir
|
||||
.join(f.file_name().unwrap_or_default())
|
||||
.to_string_lossy()
|
||||
.into()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let stem = f.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||
let ext = if output_ext.is_empty() {
|
||||
f.extension().and_then(|e| e.to_str()).unwrap_or("jpg")
|
||||
} else {
|
||||
output_ext
|
||||
};
|
||||
let name = if let Some(ref rename) = job.rename {
|
||||
if let Some(ref tmpl) = rename.template {
|
||||
pixstrip_core::operations::rename::apply_template(
|
||||
tmpl, stem, ext, rename.counter_start.saturating_add(i as u32), None,
|
||||
)
|
||||
} else {
|
||||
rename.apply_simple(stem, ext, (i as u32).saturating_add(1))
|
||||
}
|
||||
} else {
|
||||
format!("{}.{}", stem, ext)
|
||||
};
|
||||
output_dir.join(name).to_string_lossy().into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let _ = history.add(pixstrip_core::storage::HistoryEntry {
|
||||
if let Err(e) = 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(),
|
||||
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,
|
||||
@@ -417,7 +462,9 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
total_output_bytes: result.total_output_bytes,
|
||||
elapsed_ms: result.elapsed_ms,
|
||||
output_files,
|
||||
});
|
||||
}, 50, 30) {
|
||||
eprintln!("Warning: failed to save history (undo may not work): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_preset_list() {
|
||||
@@ -439,7 +486,10 @@ fn cmd_preset_list() {
|
||||
}
|
||||
|
||||
fn cmd_preset_export(name: &str, output: &str) {
|
||||
let preset = find_preset(name);
|
||||
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),
|
||||
@@ -499,18 +549,23 @@ fn cmd_history() {
|
||||
}
|
||||
|
||||
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(entries) => {
|
||||
Ok(mut entries) => {
|
||||
if entries.is_empty() {
|
||||
println!("No processing history to undo.");
|
||||
return;
|
||||
}
|
||||
|
||||
let to_undo = entries.iter().rev().take(count);
|
||||
let undo_count = count.min(entries.len());
|
||||
let to_undo = entries.split_off(entries.len() - undo_count);
|
||||
let mut total_trashed = 0;
|
||||
|
||||
for entry in to_undo {
|
||||
for entry in &to_undo {
|
||||
if entry.output_files.is_empty() {
|
||||
println!(
|
||||
"Batch from {} has no recorded output files - cannot undo",
|
||||
@@ -527,7 +582,6 @@ fn cmd_undo(count: usize) {
|
||||
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;
|
||||
@@ -540,6 +594,11 @@ fn cmd_undo(count: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove undone entries from 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) => {
|
||||
@@ -551,12 +610,19 @@ fn cmd_undo(count: usize) {
|
||||
|
||||
fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
|
||||
// Verify the preset exists
|
||||
let _preset = find_preset(preset_name);
|
||||
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);
|
||||
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 {
|
||||
@@ -568,7 +634,8 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
|
||||
|
||||
// Store in config
|
||||
let config_dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(|| std::env::temp_dir())
|
||||
.join("pixstrip");
|
||||
let watches_path = config_dir.join("watches.json");
|
||||
let mut watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() {
|
||||
@@ -587,15 +654,27 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
|
||||
}
|
||||
|
||||
watches.push(watch);
|
||||
let _ = std::fs::create_dir_all(&config_dir);
|
||||
let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap());
|
||||
if let Err(e) = std::fs::create_dir_all(&config_dir) {
|
||||
eprintln!("Warning: failed to create config directory: {}", e);
|
||||
}
|
||||
match serde_json::to_string_pretty(&watches) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&watches_path, json) {
|
||||
eprintln!("Warning: failed to write watch config: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to serialize watch config: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Added watch: {} -> preset '{}'", path, preset_name);
|
||||
}
|
||||
|
||||
fn cmd_watch_list() {
|
||||
let config_dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(|| std::env::temp_dir())
|
||||
.join("pixstrip");
|
||||
let watches_path = config_dir.join("watches.json");
|
||||
|
||||
@@ -630,7 +709,8 @@ fn cmd_watch_list() {
|
||||
|
||||
fn cmd_watch_remove(path: &str) {
|
||||
let config_dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(|| std::env::temp_dir())
|
||||
.join("pixstrip");
|
||||
let watches_path = config_dir.join("watches.json");
|
||||
|
||||
@@ -652,21 +732,33 @@ fn cmd_watch_remove(path: &str) {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap());
|
||||
if let Ok(json) = serde_json::to_string_pretty(&watches) {
|
||||
let _ = std::fs::write(&watches_path, json);
|
||||
}
|
||||
println!("Removed watch folder: {}", path);
|
||||
}
|
||||
|
||||
fn cmd_watch_start() {
|
||||
let config_dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(|| std::env::temp_dir())
|
||||
.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()
|
||||
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()
|
||||
};
|
||||
@@ -700,9 +792,15 @@ fn cmd_watch_start() {
|
||||
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);
|
||||
// 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 let Some(preset_name) = matched.as_deref().or_else(|| watchers.first().map(|(_, n)| n.as_str())) {
|
||||
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);
|
||||
@@ -728,23 +826,29 @@ fn cmd_watch_start() {
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn find_preset(name: &str) -> Preset {
|
||||
// Check builtins first
|
||||
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 preset;
|
||||
return Some(preset);
|
||||
}
|
||||
}
|
||||
|
||||
// Check user presets
|
||||
// Check user presets - try exact match first, then case-insensitive
|
||||
let store = PresetStore::new();
|
||||
if let Ok(preset) = store.load(name) {
|
||||
return preset;
|
||||
return Some(preset);
|
||||
}
|
||||
if let Ok(presets) = store.list() {
|
||||
for preset in presets {
|
||||
if preset.name.to_lowercase() == lower {
|
||||
return Some(preset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
|
||||
std::process::exit(1);
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_resize(s: &str) -> ResizeConfig {
|
||||
@@ -757,12 +861,20 @@ fn parse_resize(s: &str) -> ResizeConfig {
|
||||
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' or dimensions like '1200x900'", s);
|
||||
std::process::exit(1);
|
||||
});
|
||||
if width == 0 {
|
||||
eprintln!("Resize width must be greater than zero");
|
||||
std::process::exit(1);
|
||||
}
|
||||
ResizeConfig::ByWidth(width)
|
||||
}
|
||||
}
|
||||
@@ -840,6 +952,14 @@ fn parse_watermark_position(s: &str) -> WatermarkPosition {
|
||||
}
|
||||
}
|
||||
|
||||
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 format_bytes(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{} B", bytes)
|
||||
@@ -872,3 +992,88 @@ fn chrono_timestamp() -> String {
|
||||
.unwrap_or_default();
|
||||
format!("{}", duration.as_secs())
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_format_invalid() {
|
||||
assert_eq!(parse_format("bmp"), None);
|
||||
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");
|
||||
assert!(matches!(config, ResizeConfig::ByWidth(1200)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_resize_exact() {
|
||||
let config = parse_resize("1200x900");
|
||||
assert!(matches!(config, ResizeConfig::Exact(Dimensions { width: 1200, height: 900 })));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user