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/),
|
||||
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
2
Cargo.lock
generated
|
|
@ -4,7 +4,7 @@ version = 4
|
|||
|
||||
[[package]]
|
||||
name = "NameToUnix"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
34
README.md
34
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
|
||||
|
|
|
|||
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
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
21
src/main.rs
21
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue