use std::path::{Path, PathBuf}; pub fn apply_template( template: &str, name: &str, ext: &str, counter: u32, dimensions: Option<(u32, u32)>, ) -> String { apply_template_full(template, name, ext, counter, dimensions, None, None, None) } pub fn apply_template_full( template: &str, name: &str, ext: &str, counter: u32, dimensions: Option<(u32, u32)>, original_ext: Option<&str>, source_path: Option<&Path>, exif_info: Option<&ExifRenameInfo>, ) -> String { let mut result = template.to_string(); result = result.replace("{name}", name); result = result.replace("{ext}", ext); result = result.replace("{original_ext}", original_ext.unwrap_or(ext)); // Handle {counter:N} with padding if let Some(start) = result.find("{counter:") { if let Some(end) = result[start..].find('}') { let spec = &result[start + 9..start + end]; if let Ok(padding) = spec.parse::() { let padding = padding.min(10); let counter_str = format!("{:0>width$}", counter, width = padding); result = format!("{}{}{}", &result[..start], counter_str, &result[start + end + 1..]); } } } else { result = result.replace("{counter}", &counter.to_string()); } if let Some((w, h)) = dimensions { result = result.replace("{width}", &w.to_string()); result = result.replace("{height}", &h.to_string()); } // {date} - today's date if result.contains("{date}") { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let days = now / 86400; // Simple date calculation (good enough for filenames) let (y, m, d) = days_to_ymd(days); result = result.replace("{date}", &format!("{:04}-{:02}-{:02}", y, m, d)); } // EXIF-based variables if let Some(info) = exif_info { result = result.replace("{exif_date}", &info.date_taken); result = result.replace("{camera}", &info.camera_model); } else if result.contains("{exif_date}") || result.contains("{camera}") { // Try to read EXIF from source file let info = source_path.map(read_exif_rename_info).unwrap_or_default(); result = result.replace("{exif_date}", &info.date_taken); result = result.replace("{camera}", &info.camera_model); } result } #[derive(Default)] pub struct ExifRenameInfo { pub date_taken: String, pub camera_model: String, } fn read_exif_rename_info(path: &Path) -> ExifRenameInfo { let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(path) else { return ExifRenameInfo::default(); }; let mut info = ExifRenameInfo::default(); let endian = metadata.get_endian(); // Read DateTimeOriginal if let Some(tag) = metadata.get_tag(&little_exif::exif_tag::ExifTag::DateTimeOriginal(String::new())) { let bytes = tag.value_as_u8_vec(endian); if let Ok(s) = std::str::from_utf8(&bytes) { // Convert "2026:03:06 14:30:00" to "2026-03-06" let clean = s.trim_end_matches('\0'); if let Some(date_part) = clean.split(' ').next() { info.date_taken = date_part.replace(':', "-"); } } } // Read camera Model if let Some(tag) = metadata.get_tag(&little_exif::exif_tag::ExifTag::Model(String::new())) { let bytes = tag.value_as_u8_vec(endian); if let Ok(s) = std::str::from_utf8(&bytes) { info.camera_model = s.trim_end_matches('\0') .replace(' ', "_") .replace('/', "-") .to_string(); } } if info.date_taken.is_empty() { info.date_taken = "unknown-date".to_string(); } if info.camera_model.is_empty() { info.camera_model = "unknown-camera".to_string(); } info } fn days_to_ymd(total_days: u64) -> (u64, u64, u64) { // Simplified Gregorian calendar calculation let mut days = total_days; let mut year = 1970u64; loop { if year > 9999 { break; } let days_in_year = if is_leap(year) { 366 } else { 365 }; if days < days_in_year { break; } days -= days_in_year; year += 1; } let months_days: [u64; 12] = if is_leap(year) { [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 &months_days { if days < *md { break; } days -= md; month += 1; } (year, month, days + 1) } fn is_leap(year: u64) -> bool { (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 } /// Apply space replacement on a filename /// mode: 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove pub fn apply_space_replacement(name: &str, mode: u32) -> String { match mode { 1 => name.replace(' ', "_"), 2 => name.replace(' ', "-"), 3 => name.replace(' ', "."), 4 => { let mut result = String::with_capacity(name.len()); let mut capitalize_next = false; for ch in name.chars() { if ch == ' ' { capitalize_next = true; } else if capitalize_next { result.extend(ch.to_uppercase()); capitalize_next = false; } else { result.push(ch); } } result } 5 => name.replace(' ', ""), _ => name.to_string(), } } /// Apply special character filtering on a filename /// mode: 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only pub fn apply_special_chars(name: &str, mode: u32) -> String { match mode { 1 => name.chars().filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')).collect(), 2 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect(), 3 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect(), 4 => name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect(), 5 => name.chars().filter(|c| c.is_ascii_alphanumeric()).collect(), _ => name.to_string(), } } /// Apply case conversion to a filename (without extension) /// case_mode: 0=none, 1=lowercase, 2=uppercase, 3=title case pub fn apply_case_conversion(name: &str, case_mode: u32) -> String { match case_mode { 1 => name.to_lowercase(), 2 => name.to_uppercase(), 3 => { // Title case: capitalize first letter of each word, preserve original separators let mut result = String::with_capacity(name.len()); let mut capitalize_next = true; for c in name.chars() { if c == '_' || c == '-' || c == ' ' { result.push(c); capitalize_next = true; } else if capitalize_next { for uc in c.to_uppercase() { result.push(uc); } capitalize_next = false; } else { for lc in c.to_lowercase() { result.push(lc); } } } result } _ => name.to_string(), } } /// Pre-compile a regex for batch use. Returns None (with message) if invalid. pub fn compile_rename_regex(find: &str) -> Option { if find.is_empty() { return None; } regex::RegexBuilder::new(find) .size_limit(1 << 16) .dfa_size_limit(1 << 16) .build() .ok() } /// Apply regex find-and-replace on a filename pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String { if find.is_empty() { return name.to_string(); } match regex::RegexBuilder::new(find) .size_limit(1 << 16) .dfa_size_limit(1 << 16) .build() { Ok(re) => re.replace_all(name, replace).into_owned(), Err(_) => name.to_string(), } } /// Apply a pre-compiled regex find-and-replace on a filename pub fn apply_regex_replace_compiled(name: &str, re: ®ex::Regex, replace: &str) -> String { re.replace_all(name, replace).into_owned() } pub fn resolve_collision(path: &Path) -> PathBuf { // Use create_new (O_CREAT|O_EXCL) for atomic reservation, preventing TOCTOU races match std::fs::OpenOptions::new().write(true).create_new(true).open(path) { Ok(_) => return path.to_path_buf(), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} Err(_) => return path.to_path_buf(), // other errors (e.g. permission) - let caller handle } let parent = path.parent().unwrap_or(Path::new(".")); let stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("file"); let ext = path .extension() .and_then(|e| e.to_str()) .unwrap_or(""); for i in 1..1000 { let candidate = if ext.is_empty() { parent.join(format!("{}_{}", stem, i)) } else { parent.join(format!("{}_{}.{}", stem, i, ext)) }; match std::fs::OpenOptions::new().write(true).create_new(true).open(&candidate) { Ok(_) => return candidate, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(_) => continue, } } // Fallback with timestamp for uniqueness let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); if ext.is_empty() { parent.join(format!("{}_{}", stem, ts)) } else { parent.join(format!("{}_{}.{}", stem, ts, ext)) } }