- Add -s/--sequence option to select transformation sequences - Add -L flag to list all available sequences - Implement 5 hardcoded sequences: default, lower, upper, minimal, utf-8 - Refactor clean_filename() to support sequence-based transformations - Update all tests to pass sequence parameter (25 tests passing) - Add 8 new integration tests for sequence functionality - Update documentation (README, CHANGELOG, manpage) - Update shell completions (bash, zsh, fish) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
326 lines
11 KiB
Rust
326 lines
11 KiB
Rust
// Verwende nun die Module
|
|
mod cli;
|
|
mod config;
|
|
mod sanitizer;
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::Parser;
|
|
use cli::Cli;
|
|
use colored::*;
|
|
use config::Config;
|
|
use glob::Pattern;
|
|
use indicatif::{ProgressBar, ProgressStyle};
|
|
use log::{debug, error, info};
|
|
use rayon::prelude::*;
|
|
use sanitizer::{clean_filename, is_excluded, is_safe_rename, Sequence};
|
|
use std::fs;
|
|
use std::io::IsTerminal;
|
|
use std::path::PathBuf;
|
|
use walkdir::WalkDir;
|
|
|
|
// Standard-Ausschlussmuster (ähnlich wie detox)
|
|
const DEFAULT_EXCLUDES: &[&str] = &[
|
|
".git",
|
|
".git/**",
|
|
".svn",
|
|
".svn/**",
|
|
"node_modules",
|
|
"node_modules/**",
|
|
".cache",
|
|
".cache/**",
|
|
"__pycache__",
|
|
"__pycache__/**",
|
|
];
|
|
|
|
// Schwellwert für parallele Verarbeitung (bei weniger Dateien lohnt sich Overhead nicht)
|
|
const PARALLEL_THRESHOLD: usize = 100;
|
|
|
|
/// Repräsentiert eine geplante Umbenennungsoperation
|
|
#[derive(Debug)]
|
|
struct RenameOperation {
|
|
old_path: PathBuf,
|
|
new_path: PathBuf,
|
|
}
|
|
|
|
/// Prüft ob farbige Ausgabe aktiviert sein soll
|
|
fn should_use_color(no_color_flag: bool) -> bool {
|
|
!no_color_flag && std::io::stdout().is_terminal()
|
|
}
|
|
|
|
/// Startpunkt des Programms
|
|
fn main() -> Result<()> {
|
|
// Initialisiere Logger
|
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
|
|
|
// Argumente parsen
|
|
let args = Cli::parse();
|
|
|
|
// Farben konfigurieren
|
|
if !should_use_color(args.no_color) {
|
|
colored::control::set_override(false);
|
|
}
|
|
|
|
// -L Option: Liste Sequences und beende
|
|
if args.list_sequences {
|
|
list_sequences(&args);
|
|
return Ok(());
|
|
}
|
|
|
|
// Sequence auswählen
|
|
let sequence = if let Some(seq_name) = &args.sequence {
|
|
Sequence::find(seq_name).ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"Unbekannte Sequence: '{}'. Nutze -L um verfügbare Sequences anzuzeigen.",
|
|
seq_name
|
|
)
|
|
})?
|
|
} else {
|
|
Sequence::default()
|
|
};
|
|
|
|
if args.verbose {
|
|
info!("Verwende Sequence: {}", sequence.name);
|
|
}
|
|
|
|
// Config-Datei laden: entweder --conf oder Standard-Hierarchie
|
|
let config = if let Some(config_path) = &args.config_file {
|
|
Config::from_file(config_path, args.verbose)
|
|
.with_context(|| format!("Fehler beim Laden der Konfiguration: {}", config_path.display()))?
|
|
} else {
|
|
Config::from_default_locations(args.verbose)?
|
|
};
|
|
|
|
// Ausschlussmuster (Glob-Patterns) vorbereiten - Default-Excludes + User-Excludes
|
|
let mut all_excludes = DEFAULT_EXCLUDES
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect::<Vec<_>>();
|
|
all_excludes.extend(args.exclude.clone());
|
|
|
|
let exclude_patterns = all_excludes
|
|
.iter()
|
|
.map(|pattern| {
|
|
Pattern::new(pattern)
|
|
.with_context(|| format!("Ungültiges Ausschlussmuster: {}", pattern))
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
|
|
if args.verbose && !exclude_patterns.is_empty() {
|
|
debug!("Folgende Exclude-Pattern werden genutzt:");
|
|
for p in &exclude_patterns {
|
|
debug!(" {}", p.as_str());
|
|
}
|
|
}
|
|
|
|
// Für alle angegebenen Pfade
|
|
for path in &args.paths {
|
|
// Alle Einträge sammeln, damit zuerst die tiefsten umbenannt werden
|
|
let mut entries = Vec::new();
|
|
|
|
let walker = if args.recursive {
|
|
// Recursive: unbegrenzte Tiefe
|
|
WalkDir::new(path)
|
|
} else {
|
|
// Non-recursive: max_depth(1) verarbeitet nur direkte Kinder
|
|
WalkDir::new(path).max_depth(1)
|
|
};
|
|
|
|
for entry_result in walker
|
|
.into_iter()
|
|
.filter_entry(|e| !is_excluded(e, &exclude_patterns))
|
|
{
|
|
if let Ok(entry) = entry_result {
|
|
entries.push(entry);
|
|
} else if let Err(e) = entry_result {
|
|
error!("{}", format!("Fehler beim Durchlaufen von {}: {}", path.display(), e).red());
|
|
}
|
|
}
|
|
|
|
// Aufsteigend nach Tiefe sortieren, dann umkehren => tiefste Einträge zuerst
|
|
entries.sort_by_key(|e| e.depth());
|
|
entries.reverse();
|
|
|
|
// Fortschrittsbalken bei größeren Dateimengen
|
|
let progress_bar = if !args.quiet && entries.len() > 50 {
|
|
let bar = ProgressBar::new(entries.len() as u64);
|
|
bar.set_style(ProgressStyle::default_bar()
|
|
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}")?
|
|
.progress_chars("#>-"));
|
|
Some(bar)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Berechne Umbenennungen (parallel bei vielen Dateien)
|
|
let rename_ops: Vec<RenameOperation> = if entries.len() >= PARALLEL_THRESHOLD {
|
|
// Parallel mit rayon
|
|
entries
|
|
.par_iter()
|
|
.filter_map(|entry| {
|
|
let old_path = entry.path();
|
|
|
|
// Ebenentiefe 0 -> überspringen
|
|
if entry.depth() == 0 && entry.file_type().is_dir() && !args.modify_root {
|
|
return None;
|
|
}
|
|
|
|
// Special Files nur mit --special
|
|
let file_type = entry.file_type();
|
|
if !args.special && (!file_type.is_file() && !file_type.is_dir()) {
|
|
return None;
|
|
}
|
|
|
|
// Dateiname ermitteln und bereinigen
|
|
let filename = old_path.file_name()?;
|
|
let new_name = clean_filename(filename, &config, &sequence, false)?;
|
|
let new_path = old_path.with_file_name(&new_name);
|
|
|
|
Some(RenameOperation {
|
|
old_path: old_path.to_path_buf(),
|
|
new_path,
|
|
})
|
|
})
|
|
.collect()
|
|
} else {
|
|
// Sequenziell bei wenigen Dateien
|
|
entries
|
|
.iter()
|
|
.filter_map(|entry| {
|
|
let old_path = entry.path();
|
|
|
|
if entry.depth() == 0 && entry.file_type().is_dir() && !args.modify_root {
|
|
return None;
|
|
}
|
|
|
|
let file_type = entry.file_type();
|
|
if !args.special && (!file_type.is_file() && !file_type.is_dir()) {
|
|
return None;
|
|
}
|
|
|
|
let filename = old_path.file_name()?;
|
|
let new_name = clean_filename(filename, &config, &sequence, false)?;
|
|
let new_path = old_path.with_file_name(&new_name);
|
|
|
|
Some(RenameOperation {
|
|
old_path: old_path.to_path_buf(),
|
|
new_path,
|
|
})
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
// Statistiken
|
|
let total_processed = entries.len();
|
|
let total_planned = rename_ops.len();
|
|
let mut renamed_count = 0;
|
|
let mut skipped_count = 0;
|
|
|
|
// Umbenennungen sequenziell ausführen
|
|
for op in rename_ops {
|
|
if let Some(bar) = &progress_bar {
|
|
bar.inc(1);
|
|
}
|
|
|
|
if !args.quiet {
|
|
if args.dry_run {
|
|
info!("{} {} {}",
|
|
op.old_path.display().to_string().dimmed(),
|
|
"->".yellow(),
|
|
op.new_path.display().to_string().yellow()
|
|
);
|
|
} else {
|
|
info!("{} {} {}",
|
|
op.old_path.display().to_string().dimmed(),
|
|
"->".green(),
|
|
op.new_path.display().to_string().green()
|
|
);
|
|
}
|
|
}
|
|
|
|
if args.verbose {
|
|
debug!("Rename: {:?} -> {:?}", op.old_path, op.new_path);
|
|
}
|
|
|
|
if !args.dry_run {
|
|
if is_safe_rename(&op.old_path, &op.new_path, args.force) {
|
|
match fs::rename(&op.old_path, &op.new_path) {
|
|
Ok(_) => renamed_count += 1,
|
|
Err(e) => {
|
|
error!("{}",
|
|
format!(
|
|
"Fehler beim Umbenennen: {} -> {}: {}",
|
|
op.old_path.display(),
|
|
op.new_path.display(),
|
|
e
|
|
).red()
|
|
);
|
|
skipped_count += 1;
|
|
}
|
|
}
|
|
} else {
|
|
skipped_count += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(bar) = &progress_bar {
|
|
bar.finish_with_message("Verarbeitung abgeschlossen");
|
|
}
|
|
|
|
// Zusammenfassung ausgeben (außer im quiet mode)
|
|
if !args.quiet {
|
|
info!("");
|
|
info!("{}", format!("=== Zusammenfassung für {} ===", path.display()).cyan().bold());
|
|
info!("Verarbeitete Dateien/Verzeichnisse: {}", total_processed.to_string().cyan());
|
|
info!("Umbenennungen geplant: {}", total_planned.to_string().cyan());
|
|
if args.dry_run {
|
|
info!("Modus: {}", "Dry-run (keine Änderungen)".yellow());
|
|
} else {
|
|
info!("Erfolgreich umbenannt: {}", renamed_count.to_string().green().bold());
|
|
if skipped_count > 0 {
|
|
info!("Übersprungen/Fehler: {}", skipped_count.to_string().red());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Listet alle verfügbaren Sequences auf
|
|
fn list_sequences(args: &Cli) {
|
|
println!("Verfügbare Sequences:");
|
|
println!();
|
|
|
|
for seq in Sequence::all() {
|
|
println!(" {}", seq.name.bold());
|
|
|
|
if args.verbose {
|
|
println!(" Description: {}", seq.description);
|
|
println!(
|
|
" Umlauts → ASCII: {}",
|
|
if seq.apply_umlauts { "yes" } else { "no" }
|
|
);
|
|
println!(" Case transform: {:?}", seq.apply_case);
|
|
println!(
|
|
" Emoji handling: {}",
|
|
if seq.apply_emojis {
|
|
"replace"
|
|
} else {
|
|
"keep"
|
|
}
|
|
);
|
|
println!(
|
|
" Mode: {}",
|
|
if seq.minimal_mode { "minimal" } else { "full" }
|
|
);
|
|
} else {
|
|
println!(" {}", seq.description);
|
|
}
|
|
println!();
|
|
}
|
|
|
|
if !args.verbose {
|
|
println!("Nutze -L -v für detaillierte Informationen über jede Sequence.");
|
|
}
|
|
}
|