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:
parent
cf091f4d4b
commit
b84dd70f80
8 changed files with 228 additions and 7 deletions
20
CHANGELOG.md
20
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/),
|
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).
|
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
|
## [1.1.0] - 2025-02-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -4,7 +4,7 @@ version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "NameToUnix"
|
name = "NameToUnix"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "NameToUnix"
|
name = "NameToUnix"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Dieter Schlüter <dschlueter@kitux.de>"]
|
authors = ["Dieter Schlüter <dschlueter@kitux.de>"]
|
||||||
description = "Ein Tool zum Anpassen von Verzeichnis- und Dateinamen an Linux-Konventionen"
|
description = "Ein Tool zum Anpassen von Verzeichnis- und Dateinamen an Linux-Konventionen"
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -165,6 +165,12 @@ ntu -L
|
||||||
|
|
||||||
# List sequences with details
|
# List sequences with details
|
||||||
ntu -L -v
|
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:
|
**Note:** The following directories/files are automatically excluded:
|
||||||
|
|
@ -218,11 +224,39 @@ ntu -L
|
||||||
|
|
||||||
# Sequenzen mit Details auflisten
|
# Sequenzen mit Details auflisten
|
||||||
ntu -L -v
|
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:
|
**Hinweis:** Die folgenden Verzeichnisse/Dateien werden automatisch ausgeschlossen:
|
||||||
`.git`, `.svn`, `node_modules`, `.cache`, `__pycache__`
|
`.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
|
## Configuration File / Konfiguration
|
||||||
|
|
||||||
Erstelle eine Datei `.NameToUnix.conf` im persönlichen Arbeitsverzeichnis z. B. mit folgendem Inhalt
|
Erstelle eine Datei `.NameToUnix.conf` im persönlichen Arbeitsverzeichnis z. B. mit folgendem Inhalt
|
||||||
|
|
|
||||||
27
man/ntu.1
27
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
|
.SH NAME
|
||||||
ntu \- sanitize file and directory names to Unix conventions
|
ntu \- sanitize file and directory names to Unix conventions
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|
@ -21,6 +21,9 @@ like .tar.gz, and handles hidden files correctly.
|
||||||
.BR \-r ", " \-\-recursive
|
.BR \-r ", " \-\-recursive
|
||||||
Process directories recursively (default: only immediate children)
|
Process directories recursively (default: only immediate children)
|
||||||
.TP
|
.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"
|
.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.
|
Use a specific transformation sequence. Available sequences: default, lower, upper, minimal, utf-8. Use \fB\-L\fR to list all sequences.
|
||||||
.TP
|
.TP
|
||||||
|
|
@ -153,6 +156,28 @@ List all available sequences:
|
||||||
.TP
|
.TP
|
||||||
List sequences with details:
|
List sequences with details:
|
||||||
.B ntu \-L \-v
|
.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
|
.SH CONFIGURATION
|
||||||
.B ntu
|
.B ntu
|
||||||
looks for configuration files in the following locations (in order):
|
looks for configuration files in the following locations (in order):
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ pub struct Cli {
|
||||||
#[clap(short = 'r', long)]
|
#[clap(short = 'r', long)]
|
||||||
pub recursive: bool,
|
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)
|
/// Wählt eine Transformations-Sequenz aus (default, lower, upper, minimal, utf-8)
|
||||||
#[clap(short = 's', long, value_name = "NAME")]
|
#[clap(short = 's', long, value_name = "NAME")]
|
||||||
pub sequence: Option<String>,
|
pub sequence: Option<String>,
|
||||||
|
|
|
||||||
21
src/main.rs
21
src/main.rs
|
|
@ -118,11 +118,18 @@ fn main() -> Result<()> {
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
let walker = if args.recursive {
|
let walker = if args.recursive {
|
||||||
// Recursive: unbegrenzte Tiefe
|
// Recursive: mit optionaler max_depth
|
||||||
WalkDir::new(path)
|
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 {
|
} else {
|
||||||
// Non-recursive: max_depth(1) verarbeitet nur direkte Kinder
|
// 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
|
for entry_result in walker
|
||||||
|
|
@ -164,9 +171,12 @@ fn main() -> Result<()> {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special Files nur mit --special
|
// Special Files (Symlinks, Sockets, etc.) nur mit --special
|
||||||
let file_type = entry.file_type();
|
let file_type = entry.file_type();
|
||||||
if !args.special && (!file_type.is_file() && !file_type.is_dir()) {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,6 +204,9 @@ fn main() -> Result<()> {
|
||||||
|
|
||||||
let file_type = entry.file_type();
|
let file_type = entry.file_type();
|
||||||
if !args.special && (!file_type.is_file() && !file_type.is_dir()) {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -389,3 +389,128 @@ fn test_sequence_default_explicit() {
|
||||||
// Default: Umlaut → ASCII
|
// Default: Umlaut → ASCII
|
||||||
assert!(temp_dir.path().join("Mueller_File.txt").exists());
|
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());
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue