feat: Add --max-depth option and safe symlink handling (v1.2.0)

## Neue Features

- **--max-depth N**: Begrenzt Rekursionstiefe auf N Ebenen (erfordert -r)
  - Nützlich für sehr tiefe Verzeichnisbäume (z.B. node_modules)
  - Verhindert unnötige Traversierung tiefer Strukturen

- **Explizites Symlink-Handling**:
  - Standard: Symlinks werden komplett übersprungen (sicher)
  - Mit --special: Nur Symlink-Namen werden bereinigt, Ziel bleibt unangetastet
  - follow_links(false) explizit gesetzt zur Vermeidung von Endlosschleifen
  - Verhindert unbeabsichtigte Änderungen außerhalb des Zielverzeichnisses

- **Verbose Symlink-Logging**: Zeigt mit -v welche Symlinks übersprungen werden

## Tests

- 5 neue Integration-Tests hinzugefügt:
  - test_max_depth_option
  - test_max_depth_requires_recursive
  - test_symlinks_default_behavior (Unix only)
  - test_symlinks_with_special_flag (Unix only)
  - test_symlinks_not_followed (Unix only)

- Alle 30 Tests bestehen (25 bestehende + 5 neue)

## Dokumentation

- README.md: Neue Beispiele und "Symlink Behavior" Sektion
- CHANGELOG.md: v1.2.0 Eintrag mit allen Änderungen
- man/ntu.1: --max-depth Option und SYMLINK BEHAVIOR Sektion
- CLAUDE.md: Aktualisierte Code-Architektur Dokumentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-02-12 13:39:41 +01:00
commit b84dd70f80
8 changed files with 228 additions and 7 deletions

View file

@ -389,3 +389,128 @@ fn test_sequence_default_explicit() {
// Default: Umlaut → ASCII
assert!(temp_dir.path().join("Mueller_File.txt").exists());
}
#[test]
fn test_max_depth_option() {
let temp_dir = TempDir::new().unwrap();
// Erstelle Verzeichnisstruktur mit 4 Ebenen
// depth 0: temp_dir (root)
// depth 1: level 1
// depth 2: level 2
// depth 3: level 3
let level1 = temp_dir.path().join("level 1");
let level2 = level1.join("level 2");
let level3 = level2.join("level 3");
fs::create_dir_all(&level3).unwrap();
fs::write(level1.join("file 1.txt"), "content1").unwrap();
fs::write(level2.join("file 2.txt"), "content2").unwrap();
fs::write(level3.join("file 3.txt"), "content3").unwrap();
let mut cmd = Command::new(cargo_bin!("ntu"));
cmd.arg("-r")
.arg("--max-depth")
.arg("3") // Gehe bis depth 3
.arg(temp_dir.path());
cmd.assert().success();
// Level 1, 2 und deren Dateien sollten umbenannt sein
assert!(temp_dir.path().join("level_1").exists());
assert!(temp_dir.path().join("level_1/file_1.txt").exists());
assert!(temp_dir.path().join("level_1/level_2").exists());
assert!(temp_dir.path().join("level_1/level_2/file_2.txt").exists());
assert!(temp_dir.path().join("level_1/level_2/level_3").exists());
// Level 3/file sollte NICHT umbenannt sein (depth 4 > max 3)
assert!(temp_dir.path().join("level_1/level_2/level_3/file 3.txt").exists());
}
#[test]
fn test_max_depth_requires_recursive() {
let temp_dir = TempDir::new().unwrap();
// --max-depth ohne -r sollte fehlschlagen
let mut cmd = Command::new(cargo_bin!("ntu"));
cmd.arg("--max-depth")
.arg("2")
.arg(temp_dir.path());
let assert = cmd.assert().failure();
// Prüfe dass die Fehlermeldung "required" oder "recursive" enthält
assert.stderr(
predicate::str::contains("required")
.or(predicate::str::contains("recursive"))
);
}
#[test]
#[cfg(unix)]
fn test_symlinks_default_behavior() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("real file.txt");
let link_path = temp_dir.path().join("link to file");
fs::write(&file_path, "content").unwrap();
symlink(&file_path, &link_path).unwrap();
let mut cmd = Command::new(cargo_bin!("ntu"));
cmd.arg(temp_dir.path());
cmd.assert().success();
// Echte Datei sollte umbenannt sein
assert!(temp_dir.path().join("real_file.txt").exists());
// Symlink sollte NICHT umbenannt sein (default behavior)
// Verwende symlink_metadata() um zu prüfen ob der Link selbst existiert
let link_unchanged = temp_dir.path().join("link to file");
assert!(link_unchanged.symlink_metadata().is_ok());
assert!(!temp_dir.path().join("link_to_file").symlink_metadata().is_ok());
}
#[test]
#[cfg(unix)]
fn test_symlinks_with_special_flag() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("real_file.txt");
let link_path = temp_dir.path().join("link to file");
fs::write(&file_path, "content").unwrap();
symlink(&file_path, &link_path).unwrap();
let mut cmd = Command::new(cargo_bin!("ntu"));
cmd.arg("--special").arg(temp_dir.path());
cmd.assert().success();
// Symlink sollte MIT --special umbenannt sein
assert!(temp_dir.path().join("link_to_file").exists());
}
#[test]
#[cfg(unix)]
fn test_symlinks_not_followed() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
let target_file = target_dir.path().join("target file.txt");
fs::write(&target_file, "content").unwrap();
let link_path = temp_dir.path().join("link_to_dir");
symlink(target_dir.path(), &link_path).unwrap();
let mut cmd = Command::new(cargo_bin!("ntu"));
cmd.arg("-r").arg("--special").arg(temp_dir.path());
cmd.assert().success();
// Symlink-Name sollte bereinigt sein
assert!(temp_dir.path().join("link_to_dir").exists());
// Aber das Ziel sollte NICHT verändert sein (Link nicht gefolgt)
assert!(target_dir.path().join("target file.txt").exists());
assert!(!target_dir.path().join("target_file.txt").exists());
}