2025-03-18 03:05:18 +01:00
|
|
|
|
use crate::config::Config;
|
|
|
|
|
|
use glob::Pattern;
|
|
|
|
|
|
use log::{debug, warn};
|
|
|
|
|
|
use once_cell::sync::Lazy;
|
|
|
|
|
|
use regex::{Captures, Regex};
|
|
|
|
|
|
use std::ffi::OsStr;
|
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
use unicode_segmentation::UnicodeSegmentation;
|
|
|
|
|
|
use walkdir::DirEntry;
|
|
|
|
|
|
|
|
|
|
|
|
// Regex-Patterns als statische Variablen für bessere Performance
|
|
|
|
|
|
static RE_INVALID: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^\w.\-]").unwrap());
|
|
|
|
|
|
static RE_ADJACENT: Lazy<Regex> = Lazy::new(|| Regex::new(r"_\.|\._").unwrap());
|
|
|
|
|
|
static RE_MULTI: Lazy<Regex> = Lazy::new(|| Regex::new(r"[_\.]{2,}").unwrap());
|
|
|
|
|
|
|
2026-02-10 10:13:47 +01:00
|
|
|
|
// Bekannte Doppel-Extensions (z.B. .tar.gz)
|
|
|
|
|
|
const DOUBLE_EXTENSIONS: &[&str] = &[
|
|
|
|
|
|
".tar.gz",
|
|
|
|
|
|
".tar.bz2",
|
|
|
|
|
|
".tar.xz",
|
|
|
|
|
|
".tar.zst",
|
|
|
|
|
|
".tar.lz",
|
|
|
|
|
|
".tar.Z",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/// Trennt Dateiname in Basis und Extension, berücksichtigt Doppel-Extensions
|
|
|
|
|
|
fn split_filename(filename: &str) -> (String, String) {
|
|
|
|
|
|
// Prüfe auf bekannte Doppel-Extensions
|
|
|
|
|
|
for double_ext in DOUBLE_EXTENSIONS {
|
|
|
|
|
|
if filename.ends_with(double_ext) {
|
|
|
|
|
|
let base_len = filename.len() - double_ext.len();
|
|
|
|
|
|
if base_len > 0 {
|
|
|
|
|
|
return (
|
|
|
|
|
|
filename[..base_len].to_string(),
|
|
|
|
|
|
double_ext.to_string(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Standard-Fall: nur letzte Extension
|
|
|
|
|
|
match filename.rsplit_once('.') {
|
|
|
|
|
|
Some((b, e)) if !b.is_empty() => (b.to_string(), format!(".{e}")),
|
|
|
|
|
|
_ => (filename.to_string(), String::new()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-18 03:05:18 +01:00
|
|
|
|
/// Bereinigt den übergebenen Dateinamen oder Verzeichnisnamen.
|
|
|
|
|
|
pub fn clean_filename(name: &OsStr, config: &Config, verbose: bool) -> Option<String> {
|
|
|
|
|
|
let original = name.to_string_lossy();
|
|
|
|
|
|
|
2026-02-10 09:07:30 +01:00
|
|
|
|
// Versteckte Dateien (mit führendem Punkt) korrekt behandeln
|
|
|
|
|
|
let (hidden_prefix, rest) = match original.strip_prefix('.') {
|
|
|
|
|
|
Some(rest) => (".", rest),
|
|
|
|
|
|
None => ("", original.as_ref()),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Stamm und Extension trennen (nur im Rest, nicht im hidden_prefix)
|
2026-02-10 10:13:47 +01:00
|
|
|
|
let (mut base, mut ext) = split_filename(rest);
|
2025-03-18 03:05:18 +01:00
|
|
|
|
|
|
|
|
|
|
// Platzhalter (C++, c++, C#, c#) anlegen
|
|
|
|
|
|
base = preserve_special_identifiers(&base);
|
|
|
|
|
|
ext = preserve_special_identifiers(&ext);
|
|
|
|
|
|
|
|
|
|
|
|
// 1) Konfig-Replacements zuerst
|
|
|
|
|
|
for (k, v) in &config.replacements {
|
|
|
|
|
|
base = base.replace(k, v);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2) Dann erst hart-codierte Ersetzungen anwenden
|
|
|
|
|
|
base = apply_hardcoded_replacements(&base);
|
|
|
|
|
|
|
|
|
|
|
|
// 3) Emojis und hochgestellte Zeichen ersetzen
|
|
|
|
|
|
base = replace_emojis_and_superscript(&base);
|
|
|
|
|
|
|
|
|
|
|
|
// 4) Entfernen/Ersetzen aller übrigen ungültigen Zeichen
|
|
|
|
|
|
base = RE_INVALID.replace_all(&base, "_").to_string();
|
|
|
|
|
|
|
|
|
|
|
|
// Ungültige Kombinationen aus Punkt und Unterstrich
|
|
|
|
|
|
base = RE_ADJACENT.replace_all(&base, ".").to_string();
|
|
|
|
|
|
|
|
|
|
|
|
// Mehrfache Punkte/Unterstriche auf einen reduzieren
|
|
|
|
|
|
base = RE_MULTI
|
|
|
|
|
|
.replace_all(
|
|
|
|
|
|
&base,
|
|
|
|
|
|
|caps: &Captures| {
|
|
|
|
|
|
if caps[0].contains('.') {
|
|
|
|
|
|
"."
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"_"
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
|
|
// Führender Punkt soll bleiben, führende Unterstriche sollen verschwinden
|
|
|
|
|
|
base = trim_leading_underscores_preserve_leading_dot(&base);
|
|
|
|
|
|
|
|
|
|
|
|
// Überflüssige Unterstriche und Punkte am Ende beseitigen
|
|
|
|
|
|
base = base.trim_end_matches('_').to_string();
|
|
|
|
|
|
base = base.trim_end_matches('.').to_string();
|
|
|
|
|
|
|
|
|
|
|
|
// Falls komplett geleert, "unnamed"
|
|
|
|
|
|
if base.is_empty() {
|
|
|
|
|
|
base = "unnamed".to_string();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 09:07:30 +01:00
|
|
|
|
// Endgültigen Dateinamen zusammenbauen (hidden_prefix wieder hinzufügen)
|
|
|
|
|
|
let mut result = format!("{}{}{}", hidden_prefix, base, ext);
|
2025-03-18 03:05:18 +01:00
|
|
|
|
|
|
|
|
|
|
// Platzhalter zurückverwandeln
|
|
|
|
|
|
result = restore_special_identifiers(&result);
|
|
|
|
|
|
|
|
|
|
|
|
// Falls --verbose und sich der Name geändert hat
|
|
|
|
|
|
if verbose && result != original {
|
|
|
|
|
|
debug!("Transformiert: '{}' -> '{}'", original, result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Keine Änderung -> None zurückgeben
|
|
|
|
|
|
if result == *original {
|
|
|
|
|
|
None
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Some(result)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Schützt spezielle Identifikatoren vor der Umwandlung
|
|
|
|
|
|
fn preserve_special_identifiers(input: &str) -> String {
|
|
|
|
|
|
input
|
|
|
|
|
|
.replace("C++", "CPLUSPLUS")
|
|
|
|
|
|
.replace("c++", "cplusplus")
|
|
|
|
|
|
.replace("C#", "CSHARP")
|
|
|
|
|
|
.replace("c#", "csharp")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Stellt spezielle Identifikatoren wieder her
|
|
|
|
|
|
fn restore_special_identifiers(input: &str) -> String {
|
|
|
|
|
|
input
|
|
|
|
|
|
.replace("CPLUSPLUS", "C++")
|
|
|
|
|
|
.replace("cplusplus", "c++")
|
|
|
|
|
|
.replace("CSHARP", "C#")
|
|
|
|
|
|
.replace("csharp", "c#")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Fasst alle fest eingebauten Ersetzungen zusammen.
|
|
|
|
|
|
fn apply_hardcoded_replacements(input: &str) -> String {
|
|
|
|
|
|
input
|
|
|
|
|
|
.replace('\'', "") // Apostroph entfernen
|
|
|
|
|
|
.replace("ˆ", "_")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Entfernt am Anfang nur Unterstriche, einen führenden Punkt (.) bewahrt es.
|
|
|
|
|
|
fn trim_leading_underscores_preserve_leading_dot(s: &str) -> String {
|
|
|
|
|
|
let mut chars = s.chars().peekable();
|
|
|
|
|
|
let mut result = String::new();
|
|
|
|
|
|
|
|
|
|
|
|
if let Some('.') = chars.peek() {
|
|
|
|
|
|
// Nimm den Punkt
|
|
|
|
|
|
result.push('.');
|
|
|
|
|
|
chars.next();
|
|
|
|
|
|
|
|
|
|
|
|
// Entferne anschließend führende Unterstriche hinter dem Punkt
|
|
|
|
|
|
while let Some('_') = chars.peek() {
|
|
|
|
|
|
chars.next();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Entferne führende Unterstriche
|
|
|
|
|
|
while let Some('_') = chars.peek() {
|
|
|
|
|
|
chars.next();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Restliche Zeichen anfügen
|
|
|
|
|
|
result.extend(chars);
|
|
|
|
|
|
result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Ersetzt Emojis und hochgestellte Zeichen (z. B. ²³⁴) durch '_'.
|
|
|
|
|
|
fn replace_emojis_and_superscript(input: &str) -> String {
|
|
|
|
|
|
input
|
|
|
|
|
|
.graphemes(true)
|
|
|
|
|
|
.map(|g| {
|
2026-02-10 09:07:30 +01:00
|
|
|
|
if emojis::get(g).is_some() || is_superscript(g) {
|
2025-03-18 03:05:18 +01:00
|
|
|
|
"_".to_string()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
g.to_string()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Prüft, ob alle Zeichen ein Superscript sind (z. B. ²³⁴).
|
|
|
|
|
|
fn is_superscript(g: &str) -> bool {
|
|
|
|
|
|
g.chars().all(|c| {
|
|
|
|
|
|
c == '\u{00AA}'
|
|
|
|
|
|
|| c == '\u{00BA}'
|
2026-02-10 09:07:30 +01:00
|
|
|
|
|| ('\u{00B2}'..='\u{00B3}').contains(&c)
|
2025-03-18 03:05:18 +01:00
|
|
|
|
|| c == '\u{00B9}'
|
2026-02-10 09:07:30 +01:00
|
|
|
|
|| ('\u{2070}'..='\u{209F}').contains(&c)
|
2025-03-18 03:05:18 +01:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Prüft, ob der Pfad aufgrund der Ausschlussmuster ignoriert werden soll.
|
|
|
|
|
|
pub fn is_excluded(entry: &DirEntry, patterns: &[Pattern]) -> bool {
|
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
|
patterns.iter().any(|p| p.matches_path(path))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Überprüft, ob eine Umbenennungsoperation sicher ist
|
|
|
|
|
|
pub fn is_safe_rename(src: &Path, dst: &Path, force: bool) -> bool {
|
|
|
|
|
|
if src.exists() && dst.exists() && !force {
|
|
|
|
|
|
warn!(
|
|
|
|
|
|
"Ziel existiert bereits und --force nicht gesetzt: {}",
|
|
|
|
|
|
dst.display()
|
|
|
|
|
|
);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prüfen auf zusätzliche Sicherheitsrisiken
|
|
|
|
|
|
// z.B. Systemdateien, schreibgeschützte Verzeichnisse, etc.
|
|
|
|
|
|
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
2026-02-10 10:12:40 +01:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use std::ffi::OsStr;
|
|
|
|
|
|
|
|
|
|
|
|
fn make_test_config() -> Config {
|
|
|
|
|
|
let mut replacements = std::collections::HashMap::new();
|
|
|
|
|
|
replacements.insert("ä".to_string(), "ae".to_string());
|
|
|
|
|
|
replacements.insert("ö".to_string(), "oe".to_string());
|
|
|
|
|
|
replacements.insert("ü".to_string(), "ue".to_string());
|
|
|
|
|
|
replacements.insert("ß".to_string(), "ss".to_string());
|
|
|
|
|
|
Config { replacements }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_clean_filename_basic() {
|
|
|
|
|
|
let config = Config::default();
|
|
|
|
|
|
|
|
|
|
|
|
// Spaces should become underscores
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("test file.txt"), &config, false),
|
|
|
|
|
|
Some("test_file.txt".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Parentheses should become underscores
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("file (1).txt"), &config, false),
|
|
|
|
|
|
Some("file_1.txt".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Multiple underscores should be collapsed
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("test__file.txt"), &config, false),
|
|
|
|
|
|
Some("test_file.txt".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_clean_filename_hidden_files() {
|
|
|
|
|
|
let config = Config::default();
|
|
|
|
|
|
|
|
|
|
|
|
// Hidden files should keep their leading dot
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new(".gitignore"), &config, false),
|
|
|
|
|
|
None // No change needed
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Hidden files with spaces
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new(".my config"), &config, false),
|
|
|
|
|
|
Some(".my_config".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Hidden files with extension
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new(".test file.txt"), &config, false),
|
|
|
|
|
|
Some(".test_file.txt".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Multiple leading dots
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("...strange"), &config, false),
|
|
|
|
|
|
Some(".unnamed.strange".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_clean_filename_umlauts() {
|
|
|
|
|
|
let config = make_test_config();
|
|
|
|
|
|
|
|
|
|
|
|
// German umlauts
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("Müller.pdf"), &config, false),
|
|
|
|
|
|
Some("Mueller.pdf".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("schön.txt"), &config, false),
|
|
|
|
|
|
Some("schoen.txt".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("Größe.doc"), &config, false),
|
|
|
|
|
|
Some("Groesse.doc".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_clean_filename_extensions() {
|
|
|
|
|
|
let config = Config::default();
|
|
|
|
|
|
|
|
|
|
|
|
// Single extension
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("test file.txt"), &config, false),
|
|
|
|
|
|
Some("test_file.txt".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-10 10:13:47 +01:00
|
|
|
|
// Double extension with spaces in base name
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("my archive.tar.gz"), &config, false),
|
|
|
|
|
|
Some("my_archive.tar.gz".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Other double extensions
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("backup file.tar.bz2"), &config, false),
|
|
|
|
|
|
Some("backup_file.tar.bz2".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-10 10:12:40 +01:00
|
|
|
|
assert_eq!(
|
2026-02-10 10:13:47 +01:00
|
|
|
|
clean_filename(OsStr::new("data set.tar.xz"), &config, false),
|
|
|
|
|
|
Some("data_set.tar.xz".to_string())
|
2026-02-10 10:12:40 +01:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-10 10:13:47 +01:00
|
|
|
|
// Multiple dots (not a double extension)
|
2026-02-10 10:12:40 +01:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("foo..bar.txt"), &config, false),
|
|
|
|
|
|
Some("foo.bar.txt".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 10:13:47 +01:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_split_filename() {
|
|
|
|
|
|
// Double extensions
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
split_filename("archive.tar.gz"),
|
|
|
|
|
|
("archive".to_string(), ".tar.gz".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
split_filename("backup.tar.bz2"),
|
|
|
|
|
|
("backup".to_string(), ".tar.bz2".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Single extension
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
split_filename("file.txt"),
|
|
|
|
|
|
("file".to_string(), ".txt".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// No extension
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
split_filename("README"),
|
|
|
|
|
|
("README".to_string(), String::new())
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 10:12:40 +01:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_clean_filename_special_identifiers() {
|
|
|
|
|
|
let config = Config::default();
|
|
|
|
|
|
|
|
|
|
|
|
// C++ should be preserved
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("test C++.txt"), &config, false),
|
|
|
|
|
|
Some("test_C++.txt".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// C# should be preserved
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("guide C#.pdf"), &config, false),
|
|
|
|
|
|
Some("guide_C#.pdf".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_clean_filename_no_change_needed() {
|
|
|
|
|
|
let config = Config::default();
|
|
|
|
|
|
|
|
|
|
|
|
// Already clean filenames should return None
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("clean_file.txt"), &config, false),
|
|
|
|
|
|
None
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("another-file.pdf"), &config, false),
|
|
|
|
|
|
None
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_clean_filename_empty_after_cleaning() {
|
|
|
|
|
|
let config = Config::default();
|
|
|
|
|
|
|
|
|
|
|
|
// File with only special chars should become "unnamed"
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("###.txt"), &config, false),
|
|
|
|
|
|
Some("unnamed.txt".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_clean_filename_apostrophe() {
|
|
|
|
|
|
let config = Config::default();
|
|
|
|
|
|
|
|
|
|
|
|
// Apostrophes should be removed (not replaced with underscore)
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
clean_filename(OsStr::new("O'Reilly.pdf"), &config, false),
|
|
|
|
|
|
Some("OReilly.pdf".to_string())
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|