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

@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2025-02-12
### Added
- **`--max-depth N` option**: Limit recursion depth to N levels (requires `-r/--recursive`)
- **Explicit symlink handling**: Symlinks are no longer followed (`follow_links(false)`)
- Default: Symlinks are completely skipped (not renamed, not followed)
- With `--special`: Only the symlink name is sanitized, target remains unchanged
- Prevents infinite loops from circular symlinks
- Prevents unintended changes outside target directory
- **Verbose symlink logging**: Shows which symlinks are skipped when using `-v`
### Changed
- `WalkDir` now explicitly uses `follow_links(false)` for safety
- Improved verbose logging to indicate when max-depth is active
### Technical
- Added 5 new integration tests for max-depth and symlink behavior
- All tests passing (30 total: 25 existing + 5 new)
- Unix-specific symlink tests use `#[cfg(unix)]` attribute
## [1.1.0] - 2025-02-10
### Added

2
Cargo.lock generated
View file

@ -4,7 +4,7 @@ version = 4
[[package]]
name = "NameToUnix"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"assert_cmd",

View file

@ -1,6 +1,6 @@
[package]
name = "NameToUnix"
version = "1.1.0"
version = "1.2.0"
edition = "2021"
authors = ["Dieter Schlüter <dschlueter@kitux.de>"]
description = "Ein Tool zum Anpassen von Verzeichnis- und Dateinamen an Linux-Konventionen"

View file

@ -165,6 +165,12 @@ ntu -L
# List sequences with details
ntu -L -v
# Limit recursion depth
ntu -r --max-depth 3 /path/to/files
# Rename symlink names (but don't follow them)
ntu -r --special /path/to/files
```
**Note:** The following directories/files are automatically excluded:
@ -218,11 +224,39 @@ ntu -L
# Sequenzen mit Details auflisten
ntu -L -v
# Rekursionstiefe begrenzen
ntu -r --max-depth 3 /pfad/zu/dateien
# Symlink-Namen bereinigen (aber nicht folgen)
ntu -r --special /pfad/zu/dateien
```
**Hinweis:** Die folgenden Verzeichnisse/Dateien werden automatisch ausgeschlossen:
`.git`, `.svn`, `node_modules`, `.cache`, `__pycache__`
## Symlink Behavior / Symlink-Verhalten
By default, `ntu` **does not process symbolic links**:
- Symlinks are **skipped** (not renamed, not followed)
- This prevents unintended changes outside the target directory
- No risk of infinite loops from circular symlinks
With `--special` flag:
- Symlink **names** are sanitized (e.g., `link to file``link_to_file`)
- Symlinks are still **not followed** (targets remain unchanged)
- Safe: only the link itself is renamed, not what it points to
**Standardmäßig verarbeitet `ntu` symbolische Links NICHT**:
- Symlinks werden **übersprungen** (weder umbenannt noch gefolgt)
- Dies verhindert unbeabsichtigte Änderungen außerhalb des Zielverzeichnisses
- Keine Gefahr von Endlosschleifen durch zirkuläre Links
Mit `--special` Flag:
- Symlink-**Namen** werden bereinigt (z.B. `link zu datei``link_zu_datei`)
- Symlinks werden weiterhin **nicht gefolgt** (Ziele bleiben unverändert)
- Sicher: nur der Link selbst wird umbenannt, nicht worauf er zeigt
## Configuration File / Konfiguration
Erstelle eine Datei `.NameToUnix.conf` im persönlichen Arbeitsverzeichnis z. B. mit folgendem Inhalt

View file

@ -1,4 +1,4 @@
.TH NTU 1 "2025-02-10" "NameToUnix 1.1.0" "User Commands"
.TH NTU 1 "2025-02-12" "NameToUnix 1.2.0" "User Commands"
.SH NAME
ntu \- sanitize file and directory names to Unix conventions
.SH SYNOPSIS
@ -21,6 +21,9 @@ like .tar.gz, and handles hidden files correctly.
.BR \-r ", " \-\-recursive
Process directories recursively (default: only immediate children)
.TP
.BR \-\-max\-depth " \fIN\fR"
Limit recursion to N levels deep (requires \fB\-r\fR). Useful for processing only a few levels of a deep directory tree (e.g., limiting node_modules traversal).
.TP
.BR \-s ", " \-\-sequence " \fINAME\fR"
Use a specific transformation sequence. Available sequences: default, lower, upper, minimal, utf-8. Use \fB\-L\fR to list all sequences.
.TP
@ -153,6 +156,28 @@ List all available sequences:
.TP
List sequences with details:
.B ntu \-L \-v
.TP
Limit recursion depth to 3 levels:
.B ntu \-r \-\-max\-depth 3 /path/to/files
.TP
Process symlink names (but don't follow them):
.B ntu \-r \-\-special /path/to/files
.SH SYMLINK BEHAVIOR
By default, \fBntu\fR does not process symbolic links:
.IP \(bu 2
Symlinks are skipped (not renamed, not followed)
.IP \(bu 2
Prevents unintended changes outside the target directory
.IP \(bu 2
No risk of infinite loops from circular symlinks
.PP
With the \fB\-\-special\fR flag:
.IP \(bu 2
Symlink names are sanitized (e.g., "link to file" \(-> "link_to_file")
.IP \(bu 2
Symlinks are still not followed (targets remain unchanged)
.IP \(bu 2
Safe: only the link itself is renamed, not what it points to
.SH CONFIGURATION
.B ntu
looks for configuration files in the following locations (in order):

View file

@ -21,6 +21,10 @@ pub struct Cli {
#[clap(short = 'r', long)]
pub recursive: bool,
/// Maximale Rekursionstiefe (erfordert --recursive)
#[clap(long, value_name = "N", requires = "recursive")]
pub max_depth: Option<usize>,
/// Wählt eine Transformations-Sequenz aus (default, lower, upper, minimal, utf-8)
#[clap(short = 's', long, value_name = "NAME")]
pub sequence: Option<String>,

View file

@ -118,11 +118,18 @@ fn main() -> Result<()> {
let mut entries = Vec::new();
let walker = if args.recursive {
// Recursive: unbegrenzte Tiefe
WalkDir::new(path)
// Recursive: mit optionaler max_depth
let mut w = WalkDir::new(path).follow_links(false); // Symlinks nicht folgen
if let Some(depth) = args.max_depth {
w = w.max_depth(depth);
if args.verbose {
info!("Maximale Rekursionstiefe: {}", depth);
}
}
w
} else {
// Non-recursive: max_depth(1) verarbeitet nur direkte Kinder
WalkDir::new(path).max_depth(1)
WalkDir::new(path).max_depth(1).follow_links(false)
};
for entry_result in walker
@ -164,9 +171,12 @@ fn main() -> Result<()> {
return None;
}
// Special Files nur mit --special
// Special Files (Symlinks, Sockets, etc.) nur mit --special
let file_type = entry.file_type();
if !args.special && (!file_type.is_file() && !file_type.is_dir()) {
if args.verbose && file_type.is_symlink() {
debug!("Überspringe Symlink: {} (nutze --special um Symlink-Namen zu bereinigen)", old_path.display());
}
return None;
}
@ -194,6 +204,9 @@ fn main() -> Result<()> {
let file_type = entry.file_type();
if !args.special && (!file_type.is_file() && !file_type.is_dir()) {
if args.verbose && file_type.is_symlink() {
debug!("Überspringe Symlink: {} (nutze --special um Symlink-Namen zu bereinigen)", old_path.display());
}
return None;
}

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());
}