use crate::config::Config; use glob::Pattern; use log::{debug, warn}; use once_cell::sync::Lazy; use regex::{Captures, Regex}; use std::ffi::OsStr; use std::path::Path; use unicode_segmentation::UnicodeSegmentation; use walkdir::DirEntry; // Regex-Patterns als statische Variablen für bessere Performance static RE_INVALID: Lazy = Lazy::new(|| Regex::new(r"[^\w.\-]").unwrap()); static RE_ADJACENT: Lazy = Lazy::new(|| Regex::new(r"_\.|\._").unwrap()); static RE_MULTI: Lazy = Lazy::new(|| Regex::new(r"[_\.]{2,}").unwrap()); // Bekannte Doppel-Extensions (z.B. .tar.gz) const DOUBLE_EXTENSIONS: &[&str] = &[ ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar.lz", ".tar.Z", ]; /// Trennt Dateiname in Basis und Extension, berücksichtigt Doppel-Extensions fn split_filename(filename: &str) -> (String, String) { // Prüfe auf bekannte Doppel-Extensions for double_ext in DOUBLE_EXTENSIONS { if filename.ends_with(double_ext) { let base_len = filename.len() - double_ext.len(); if base_len > 0 { return ( filename[..base_len].to_string(), double_ext.to_string(), ); } } } // Standard-Fall: nur letzte Extension match filename.rsplit_once('.') { Some((b, e)) if !b.is_empty() => (b.to_string(), format!(".{e}")), _ => (filename.to_string(), String::new()), } } /// Bereinigt den übergebenen Dateinamen oder Verzeichnisnamen. pub fn clean_filename(name: &OsStr, config: &Config, verbose: bool) -> Option { let original = name.to_string_lossy(); // Versteckte Dateien (mit führendem Punkt) korrekt behandeln let (hidden_prefix, rest) = match original.strip_prefix('.') { Some(rest) => (".", rest), None => ("", original.as_ref()), }; // Stamm und Extension trennen (nur im Rest, nicht im hidden_prefix) let (mut base, mut ext) = split_filename(rest); // Platzhalter (C++, c++, C#, c#) anlegen base = preserve_special_identifiers(&base); ext = preserve_special_identifiers(&ext); // 1) Konfig-Replacements zuerst for (k, v) in &config.replacements { base = base.replace(k, v); } // 2) Dann erst hart-codierte Ersetzungen anwenden base = apply_hardcoded_replacements(&base); // 3) Emojis und hochgestellte Zeichen ersetzen base = replace_emojis_and_superscript(&base); // 4) Entfernen/Ersetzen aller übrigen ungültigen Zeichen base = RE_INVALID.replace_all(&base, "_").to_string(); // Ungültige Kombinationen aus Punkt und Unterstrich base = RE_ADJACENT.replace_all(&base, ".").to_string(); // Mehrfache Punkte/Unterstriche auf einen reduzieren base = RE_MULTI .replace_all( &base, |caps: &Captures| { if caps[0].contains('.') { "." } else { "_" } }, ) .to_string(); // Führender Punkt soll bleiben, führende Unterstriche sollen verschwinden base = trim_leading_underscores_preserve_leading_dot(&base); // Überflüssige Unterstriche und Punkte am Ende beseitigen base = base.trim_end_matches('_').to_string(); base = base.trim_end_matches('.').to_string(); // Falls komplett geleert, "unnamed" if base.is_empty() { base = "unnamed".to_string(); } // Endgültigen Dateinamen zusammenbauen (hidden_prefix wieder hinzufügen) let mut result = format!("{}{}{}", hidden_prefix, base, ext); // Platzhalter zurückverwandeln result = restore_special_identifiers(&result); // Falls --verbose und sich der Name geändert hat if verbose && result != original { debug!("Transformiert: '{}' -> '{}'", original, result); } // Keine Änderung -> None zurückgeben if result == *original { None } else { Some(result) } } /// Schützt spezielle Identifikatoren vor der Umwandlung fn preserve_special_identifiers(input: &str) -> String { input .replace("C++", "CPLUSPLUS") .replace("c++", "cplusplus") .replace("C#", "CSHARP") .replace("c#", "csharp") } /// Stellt spezielle Identifikatoren wieder her fn restore_special_identifiers(input: &str) -> String { input .replace("CPLUSPLUS", "C++") .replace("cplusplus", "c++") .replace("CSHARP", "C#") .replace("csharp", "c#") } /// Fasst alle fest eingebauten Ersetzungen zusammen. fn apply_hardcoded_replacements(input: &str) -> String { input .replace('\'', "") // Apostroph entfernen .replace("ˆ", "_") } /// Entfernt am Anfang nur Unterstriche, einen führenden Punkt (.) bewahrt es. fn trim_leading_underscores_preserve_leading_dot(s: &str) -> String { let mut chars = s.chars().peekable(); let mut result = String::new(); if let Some('.') = chars.peek() { // Nimm den Punkt result.push('.'); chars.next(); // Entferne anschließend führende Unterstriche hinter dem Punkt while let Some('_') = chars.peek() { chars.next(); } } else { // Entferne führende Unterstriche while let Some('_') = chars.peek() { chars.next(); } } // Restliche Zeichen anfügen result.extend(chars); result } /// Ersetzt Emojis und hochgestellte Zeichen (z. B. ²³⁴) durch '_'. fn replace_emojis_and_superscript(input: &str) -> String { input .graphemes(true) .map(|g| { if emojis::get(g).is_some() || is_superscript(g) { "_".to_string() } else { g.to_string() } }) .collect() } /// Prüft, ob alle Zeichen ein Superscript sind (z. B. ²³⁴). fn is_superscript(g: &str) -> bool { g.chars().all(|c| { c == '\u{00AA}' || c == '\u{00BA}' || ('\u{00B2}'..='\u{00B3}').contains(&c) || c == '\u{00B9}' || ('\u{2070}'..='\u{209F}').contains(&c) }) } /// Prüft, ob der Pfad aufgrund der Ausschlussmuster ignoriert werden soll. pub fn is_excluded(entry: &DirEntry, patterns: &[Pattern]) -> bool { let path = entry.path(); patterns.iter().any(|p| p.matches_path(path)) } /// Überprüft, ob eine Umbenennungsoperation sicher ist pub fn is_safe_rename(src: &Path, dst: &Path, force: bool) -> bool { if src.exists() && dst.exists() && !force { warn!( "Ziel existiert bereits und --force nicht gesetzt: {}", dst.display() ); return false; } // Prüfen auf zusätzliche Sicherheitsrisiken // z.B. Systemdateien, schreibgeschützte Verzeichnisse, etc. true } #[cfg(test)] mod tests { use super::*; use std::ffi::OsStr; fn make_test_config() -> Config { let mut replacements = std::collections::HashMap::new(); replacements.insert("ä".to_string(), "ae".to_string()); replacements.insert("ö".to_string(), "oe".to_string()); replacements.insert("ü".to_string(), "ue".to_string()); replacements.insert("ß".to_string(), "ss".to_string()); Config { replacements } } #[test] fn test_clean_filename_basic() { let config = Config::default(); // Spaces should become underscores assert_eq!( clean_filename(OsStr::new("test file.txt"), &config, false), Some("test_file.txt".to_string()) ); // Parentheses should become underscores assert_eq!( clean_filename(OsStr::new("file (1).txt"), &config, false), Some("file_1.txt".to_string()) ); // Multiple underscores should be collapsed assert_eq!( clean_filename(OsStr::new("test__file.txt"), &config, false), Some("test_file.txt".to_string()) ); } #[test] fn test_clean_filename_hidden_files() { let config = Config::default(); // Hidden files should keep their leading dot assert_eq!( clean_filename(OsStr::new(".gitignore"), &config, false), None // No change needed ); // Hidden files with spaces assert_eq!( clean_filename(OsStr::new(".my config"), &config, false), Some(".my_config".to_string()) ); // Hidden files with extension assert_eq!( clean_filename(OsStr::new(".test file.txt"), &config, false), Some(".test_file.txt".to_string()) ); // Multiple leading dots assert_eq!( clean_filename(OsStr::new("...strange"), &config, false), Some(".unnamed.strange".to_string()) ); } #[test] fn test_clean_filename_umlauts() { let config = make_test_config(); // German umlauts assert_eq!( clean_filename(OsStr::new("Müller.pdf"), &config, false), Some("Mueller.pdf".to_string()) ); assert_eq!( clean_filename(OsStr::new("schön.txt"), &config, false), Some("schoen.txt".to_string()) ); assert_eq!( clean_filename(OsStr::new("Größe.doc"), &config, false), Some("Groesse.doc".to_string()) ); } #[test] fn test_clean_filename_extensions() { let config = Config::default(); // Single extension assert_eq!( clean_filename(OsStr::new("test file.txt"), &config, false), Some("test_file.txt".to_string()) ); // Double extension with spaces in base name assert_eq!( clean_filename(OsStr::new("my archive.tar.gz"), &config, false), Some("my_archive.tar.gz".to_string()) ); // Other double extensions assert_eq!( clean_filename(OsStr::new("backup file.tar.bz2"), &config, false), Some("backup_file.tar.bz2".to_string()) ); assert_eq!( clean_filename(OsStr::new("data set.tar.xz"), &config, false), Some("data_set.tar.xz".to_string()) ); // Multiple dots (not a double extension) assert_eq!( clean_filename(OsStr::new("foo..bar.txt"), &config, false), Some("foo.bar.txt".to_string()) ); } #[test] fn test_split_filename() { // Double extensions assert_eq!( split_filename("archive.tar.gz"), ("archive".to_string(), ".tar.gz".to_string()) ); assert_eq!( split_filename("backup.tar.bz2"), ("backup".to_string(), ".tar.bz2".to_string()) ); // Single extension assert_eq!( split_filename("file.txt"), ("file".to_string(), ".txt".to_string()) ); // No extension assert_eq!( split_filename("README"), ("README".to_string(), String::new()) ); } #[test] fn test_clean_filename_special_identifiers() { let config = Config::default(); // C++ should be preserved assert_eq!( clean_filename(OsStr::new("test C++.txt"), &config, false), Some("test_C++.txt".to_string()) ); // C# should be preserved assert_eq!( clean_filename(OsStr::new("guide C#.pdf"), &config, false), Some("guide_C#.pdf".to_string()) ); } #[test] fn test_clean_filename_no_change_needed() { let config = Config::default(); // Already clean filenames should return None assert_eq!( clean_filename(OsStr::new("clean_file.txt"), &config, false), None ); assert_eq!( clean_filename(OsStr::new("another-file.pdf"), &config, false), None ); } #[test] fn test_clean_filename_empty_after_cleaning() { let config = Config::default(); // File with only special chars should become "unnamed" assert_eq!( clean_filename(OsStr::new("###.txt"), &config, false), Some("unnamed.txt".to_string()) ); } #[test] fn test_clean_filename_apostrophe() { let config = Config::default(); // Apostrophes should be removed (not replaced with underscore) assert_eq!( clean_filename(OsStr::new("O'Reilly.pdf"), &config, false), Some("OReilly.pdf".to_string()) ); } }