#![deny(missing_docs)]
pub mod config;
pub mod error;
pub mod filter;
pub mod planner;
pub mod renamer;
pub mod sequence;
pub mod tables;
#[cfg(feature = "cli")]
pub mod walker;
pub use error::DetoxError;
pub use filter::Filter;
pub use sequence::Sequence;
use std::path::PathBuf;
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct Detox {
sequence: Sequence,
pub verbose: bool,
pub dry_run: bool,
pub recursive: bool,
pub collision_cap: u32,
}
impl Detox {
pub fn sanitize(&self, input: &str) -> String {
let bytes = self.sanitize_bytes(input.as_bytes());
String::from_utf8_lossy(&bytes).into_owned()
}
pub fn sanitize_bytes(&self, input: &[u8]) -> Vec<u8> {
self.sequence.apply(input)
}
pub fn plan(&self, path: &std::path::Path) -> Vec<RenamePlanEntry> {
let entries = if self.recursive && path.is_dir() {
#[cfg(feature = "cli")]
{
walker::recursive_walk(path)
.into_iter()
.map(|w| w.path)
.collect()
}
#[cfg(not(feature = "cli"))]
{
vec![path.to_path_buf()]
}
} else {
vec![path.to_path_buf()]
};
planner::plan_directory(&entries, &self.sequence, self.collision_cap).unwrap_or_default()
}
pub fn execute(&self, path: &std::path::Path) -> Result<DetoxReport, DetoxError> {
let plan = self.plan(path);
let mut report = DetoxReport {
planned: plan.len(),
renamed: 0,
skipped: 0,
errored: 0,
};
if self.dry_run {
report.skipped = plan.len();
return Ok(report);
}
for entry in &plan {
match renamer::rename_with_fallback(&entry.source, &entry.target) {
Ok(_) => report.renamed += 1,
Err(e) => {
report.errored += 1;
return Err(e);
}
}
}
Ok(report)
}
}
#[derive(Debug, Clone)]
pub struct DetoxBuilder {
sequence: Sequence,
verbose: bool,
dry_run: bool,
recursive: bool,
collision_cap: u32,
}
impl DetoxBuilder {
#[must_use]
pub fn new() -> Self {
DetoxBuilder {
sequence: Sequence::default(),
verbose: false,
dry_run: false,
recursive: false,
collision_cap: 1000,
}
}
#[must_use]
pub fn sequence(mut self, s: Sequence) -> Self {
self.sequence = s;
self
}
#[must_use]
pub fn verbose(mut self, on: bool) -> Self {
self.verbose = on;
self
}
#[must_use]
pub fn dry_run(mut self, on: bool) -> Self {
self.dry_run = on;
self
}
#[must_use]
pub fn recursive(mut self, on: bool) -> Self {
self.recursive = on;
self
}
#[must_use]
pub fn collision_cap(mut self, cap: u32) -> Self {
self.collision_cap = cap;
self
}
#[must_use]
pub fn build(self) -> Detox {
Detox {
sequence: self.sequence,
verbose: self.verbose,
dry_run: self.dry_run,
recursive: self.recursive,
collision_cap: self.collision_cap,
}
}
}
impl Default for DetoxBuilder {
fn default() -> Self {
Self::new()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenamePlanEntry {
pub source: PathBuf,
pub target: PathBuf,
pub collision_suffix: Option<u32>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DetoxReport {
pub planned: usize,
pub renamed: usize,
pub skipped: usize,
pub errored: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use static_assertions::{assert_impl_all, const_assert};
assert_impl_all!(Detox: Send, Sync, Clone);
assert_impl_all!(DetoxBuilder: Send, Sync, Clone);
assert_impl_all!(Sequence: Send, Sync, Clone);
assert_impl_all!(Filter: Send, Sync, Clone);
assert_impl_all!(DetoxError: Send, Sync);
const _: fn() = || {
let _ = DetoxReport::default();
};
const_assert!(std::mem::size_of::<RenamePlanEntry>() > 0);
#[test]
fn sanitize_default_sequence() {
let detox = DetoxBuilder::new().build();
assert_eq!(detox.sanitize("hello world.txt"), "hello_world.txt");
}
#[test]
fn sanitize_utf8_sequence() {
let detox = DetoxBuilder::new().sequence(Sequence::utf_8()).build();
assert_eq!(detox.sanitize("café.pdf"), "cafe.pdf");
}
#[test]
fn sanitize_bytes_parity_with_str_for_utf8_clean() {
let detox = DetoxBuilder::new().sequence(Sequence::utf_8()).build();
let input = "café.pdf";
assert_eq!(
detox.sanitize(input).as_bytes(),
detox.sanitize_bytes(input.as_bytes()).as_slice()
);
}
#[test]
fn clean_filename_unchanged() {
let detox = DetoxBuilder::new().build();
assert_eq!(detox.sanitize("clean_already.txt"), "clean_already.txt");
}
}