From b84dd70f80fb2d7301dccf3bf91cb0b089cd9dc4 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 12 Feb 2026 13:39:41 +0100 Subject: [PATCH] feat: Add --max-depth option and safe symlink handling (v1.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- CHANGELOG.md | 20 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 34 ++++++++++ man/ntu.1 | 27 +++++++- src/cli.rs | 4 ++ src/main.rs | 21 +++++-- tests/integration_tests.rs | 125 +++++++++++++++++++++++++++++++++++++ 8 files changed, 228 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c3b9f..387098f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index ae9009e..639f95e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "NameToUnix" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index e80c408..fb44f02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "NameToUnix" -version = "1.1.0" +version = "1.2.0" edition = "2021" authors = ["Dieter Schlüter "] description = "Ein Tool zum Anpassen von Verzeichnis- und Dateinamen an Linux-Konventionen" diff --git a/README.md b/README.md index bf9d03b..dd27175 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/man/ntu.1 b/man/ntu.1 index aefea88..1e6e94e 100644 --- a/man/ntu.1 +++ b/man/ntu.1 @@ -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): diff --git a/src/cli.rs b/src/cli.rs index afe4b30..aed5a77 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, + /// Wählt eine Transformations-Sequenz aus (default, lower, upper, minimal, utf-8) #[clap(short = 's', long, value_name = "NAME")] pub sequence: Option, diff --git a/src/main.rs b/src/main.rs index 2cd0a47..195c702 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 2143f89..9c1e241 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -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()); +}