1#![forbid(unsafe_code)]
24#![deny(
25 missing_docs,
26 missing_debug_implementations,
27 rustdoc::missing_crate_level_docs,
28 bad_style,
30 clippy::unwrap_used
31)]
32#![warn(clippy::pedantic, clippy::nursery)]
33
34#[cfg(test)]
37mod test;
38
39use clap::{parser::MatchesError, ArgMatches};
40use color_eyre::{eyre::eyre, Report, Result};
41use paris::{info, warn};
42use regex::Regex;
43use std::{
44 collections::HashMap,
45 fs::{self, DirEntry},
46 path::PathBuf,
47 string::ToString,
48};
49
50#[derive(Debug, Clone, Default)]
52pub struct Arguments {
53 pub folder: PathBuf,
55 pub directory: bool,
57 pub verbose: bool,
59 pub origin: usize,
61 pub prefix: String,
63 pub padding: usize,
65 pub padding_direction: PaddingDirection,
67 pub match_regex: Option<Regex>,
69 pub match_rename: Option<String>,
71 pub dry_run: bool,
73}
74
75#[derive(Debug, Clone, Default)]
77pub enum PaddingDirection {
78 #[default]
80 Left,
81 Right,
83 Middle,
85}
86
87impl From<&String> for PaddingDirection {
88 fn from(a: &String) -> Self {
89 let a = a.to_lowercase();
90
91 match a.as_ref() {
92 "left" | "l" | "<" => Self::Left,
93 "right" | "r" | ">" => Self::Right,
94 "middle" | "m" | "|" => Self::Middle,
95 _ => unreachable!(
96 "If this is reached, something in validation has gone *horribly* wrong."
97 ),
98 }
99 }
100}
101
102impl From<String> for PaddingDirection {
103 fn from(a: String) -> Self {
104 let a = a.to_lowercase();
105
106 match a.as_ref() {
107 "left" | "l" | "<" => Self::Left,
108 "right" | "r" | ">" => Self::Right,
109 "middle" | "m" | "|" => Self::Middle,
110 _ => unreachable!(
111 "If this is reached, something in validation has gone *horribly* wrong."
112 ),
113 }
114 }
115}
116
117#[derive(Debug, Clone)]
119struct RenameItem {
120 pub original_path: PathBuf,
121 pub new_path: PathBuf,
122}
123
124impl TryFrom<ArgMatches> for Arguments {
125 type Error = Report;
126
127 fn try_from(a: ArgMatches) -> Result<Self, Self::Error> {
128 let folder = a
129 .get_one::<PathBuf>("folder")
130 .cloned()
131 .ok_or_else(|| Report::msg("Unable to turn 'folder' argument into path"))?;
132 let directory = a.get_flag("directory");
133 let verbose = a.get_flag("verbose");
134 let origin = a
135 .get_one::<usize>("origin")
136 .copied()
137 .ok_or_else(|| Report::msg("Unable to turn 'origin' argument into usize"))?;
138 let prefix = a
139 .get_one::<String>("prefix")
140 .cloned()
141 .ok_or_else(|| Report::msg("Unable to find 'prefix' argument or use default"))?;
142 let padding = a
143 .get_one::<usize>("padding")
144 .copied()
145 .ok_or_else(|| Report::msg("Unable to turn 'padding' argument into usize"))?;
146 let padding_direction = match a.try_get_one::<String>("padding_direction") {
147 Ok(value) => value.map_or_else(PaddingDirection::default, PaddingDirection::from),
149 Err(e) => match e {
150 MatchesError::UnknownArgument { .. } => PaddingDirection::default(),
151 _ => return Err(Report::msg("Invalid `--padding-direction argument.`")),
152 },
153 };
154 let match_regex = match a.try_get_one::<String>("match") {
155 Ok(Some(regex)) => Some(Regex::new(regex)?),
156 Ok(None) => None,
157 Err(e) => {
158 return Err(Report::msg(format!("Invalid `--match` argument: {e}")));
159 }
160 };
161 let match_rename = match a.try_get_one::<String>("match-rename") {
162 Ok(Some(a)) => Some(a.clone()),
163 Ok(None) => None,
164 Err(e) => {
165 return Err(Report::msg(format!(
166 "Invalid `--match-rename` argument: {e}"
167 )));
168 }
169 };
170 let dry_run = a.get_flag("dry-run");
171
172 Ok(Self {
173 folder,
174 directory,
175 verbose,
176 origin,
177 prefix,
178 padding,
179 padding_direction,
180 match_regex,
181 match_rename,
182 dry_run,
183 })
184 }
185}
186
187pub fn run(args: Arguments) -> Result<()> {
203 if !args.folder.exists() {
204 return Err(eyre!(format!(
205 "Folder `{}` does not exist.",
206 args.folder.to_string_lossy()
207 )));
208 }
209
210 if !args.folder.is_dir() {
211 return Err(eyre!(format!(
212 "`{}` is not a folder.",
213 args.folder.to_string_lossy()
214 )));
215 }
216
217 let read = args.folder.read_dir();
218
219 if let Err(e) = read {
220 return Err(eyre!(format!(
221 "Unable to read directory {}: {}",
222 args.folder.to_string_lossy(),
223 e
224 )));
225 }
226 let read = read.expect("Failed to read directory");
227
228 let items = match &args.match_regex {
229 Some(r) => filter_items_regex(read, args.directory, r),
230 None => filter_items(read, args.directory),
231 };
232
233 if args.match_rename.is_some() {
234 rename_regex(&items, args);
235 } else {
236 rename_normal(&items, args);
237 }
238
239 Ok(())
240}
241
242fn rename_normal(items: &[PathBuf], args: Arguments) {
244 let verbose = args.verbose;
245 let fmt = match args.padding_direction {
246 PaddingDirection::Left => {
247 "{folder}/{prefix}_{number:0>NUM}{ext}".replace("NUM", &format!("{}", args.padding))
248 }
249 PaddingDirection::Right => {
250 "{folder}/{prefix}_{number:0<NUM}{ext}".replace("NUM", &format!("{}", args.padding))
251 }
252 PaddingDirection::Middle => {
253 "{folder}/{prefix}_{number:0|NUM}{ext}".replace("NUM", &format!("{}", args.padding))
254 }
255 };
256
257 let fmt = if args.directory { fmt + "/" } else { fmt };
258
259 let mut count = args.origin;
260
261 let mut map = HashMap::new();
262
263 map.insert(
264 "folder".to_string(),
265 args.folder.to_string_lossy().to_string(),
266 );
267 map.insert("prefix".to_string(), args.prefix);
268
269 let items = items
270 .iter()
271 .map(|x| {
272 let ext = x
273 .extension()
274 .map_or_else(String::new, |x| format!(".{}", x.to_string_lossy()));
275 map.insert("number".to_string(), format!("{count}"));
276 map.insert("ext".to_string(), ext);
277 count += 1;
278
279 RenameItem {
280 original_path: x.clone(),
281 new_path: strfmt::strfmt(&fmt, &map)
282 .expect("String formatting failed")
283 .into(),
284 }
285 })
286 .filter(|x| {
287 if x.new_path.exists() {
288 warn!(
289 "File `{}` already exists, unable to rename.",
290 x.new_path.to_string_lossy()
291 );
292 false
293 } else {
294 true
295 }
296 })
297 .collect::<Vec<RenameItem>>();
298
299 let dry_run = args.dry_run;
300 for x in items {
301 if x.new_path.exists() {
302 warn!(
303 "Item `{}` already exists, unable to rename.",
304 x.new_path.to_string_lossy()
305 );
306 return;
307 }
308 if dry_run {
309 info!(
310 "[DRY RUN]: `{}` -> `{}`",
311 x.original_path.to_string_lossy(),
312 x.new_path.to_string_lossy()
313 );
314 } else {
315 match fs::rename(&x.original_path, &x.new_path) {
316 Ok(()) => {
317 if verbose {
318 info!(
319 "[DONE] `{}` -> `{}`",
320 x.original_path.to_string_lossy(),
321 x.new_path.to_string_lossy()
322 );
323 }
324 }
325 Err(e) => warn!(
326 "[FAIL] `{}` -> `{}`: {}",
327 x.original_path.to_string_lossy(),
328 x.new_path.to_string_lossy(),
329 e
330 ),
331 }
332 }
333 }
334}
335
336fn rename_regex(items: &[PathBuf], args: Arguments) {
337 let verbose = args.verbose;
338
339 let regex = args.match_regex.expect("Regex is None");
340 let match_rename = args.match_rename.expect("Match rename is None");
341 let items = items
342 .iter()
343 .map(|x| {
344 let text = x
345 .file_name()
346 .expect("there to be a filename")
347 .to_string_lossy();
348 let after = regex.replace(&text, match_rename.as_str()).to_string();
349 let mut new_x = x.clone();
350
351 new_x.set_file_name(after);
352
353 RenameItem {
354 original_path: x.clone(),
355 new_path: new_x,
356 }
357 })
358 .filter(|x| {
359 if x.new_path.exists() {
360 warn!(
361 "Item `{}` already exists, unable to rename.",
362 x.new_path.to_string_lossy()
363 );
364 false
365 } else {
366 true
367 }
368 })
369 .collect::<Vec<RenameItem>>();
370
371 let dry_run = args.dry_run;
372 for x in items {
373 if x.new_path.exists() {
374 warn!(
375 "Item `{}` already exists, unable to rename.",
376 x.new_path.to_string_lossy()
377 );
378 return;
379 }
380 if dry_run {
381 info!(
382 "[DRY RUN]: `{}` -> `{}`",
383 x.original_path.to_string_lossy(),
384 x.new_path.to_string_lossy()
385 );
386 } else {
387 match fs::rename(&x.original_path, &x.new_path) {
388 Ok(()) => {
389 if verbose {
390 info!(
391 "[DONE] `{}` -> `{}`",
392 x.original_path.to_string_lossy(),
393 x.new_path.to_string_lossy()
394 );
395 }
396 }
397 Err(e) => warn!(
398 "[FAIL] `{}` -> `{}`: {}",
399 x.original_path.to_string_lossy(),
400 x.new_path.to_string_lossy(),
401 e
402 ),
403 }
404 }
405 }
406}
407
408fn filter_items<I>(read: I, dir: bool) -> Vec<PathBuf>
409where
410 I: Iterator<Item = std::io::Result<DirEntry>>,
411{
412 let items = read.filter(|x| match x {
413 Err(e) => {
414 warn!("Unable to read item: {}", e);
415 false
416 }
417 Ok(item) => {
418 let item_type = item.file_type();
419
420 if let Err(e) = item_type {
421 warn!(
422 "Unable to get filetype of {}: {}",
423 item.file_name().to_string_lossy(),
424 e
425 );
426 return false;
427 }
428
429 let item_type = item_type.expect("item_type is None");
430
431 if dir {
432 item_type.is_dir()
433 } else {
434 item_type.is_file()
435 }
436 }
437 });
438
439 items
440 .map(|x| {
441 let x = x.expect("item is None");
442
443 x.path()
444 })
445 .collect::<Vec<PathBuf>>()
446}
447
448fn filter_items_regex<I>(read: I, dir: bool, regex: &Regex) -> Vec<PathBuf>
449where
450 I: Iterator<Item = std::io::Result<DirEntry>>,
451{
452 let items = read.filter(|x| match x {
453 Err(e) => {
454 warn!("Unable to read item: {}", e);
455 false
456 }
457 Ok(item) => {
458 let item_type = item.file_type();
459 let item_name = item.file_name();
460 let item_name = item_name.to_string_lossy();
461
462 if let Err(e) = item_type {
463 warn!("Unable to get filetype of {}: {}", item_name, e);
464 return false;
465 }
466
467 let item_type = item_type.expect("item_type is None");
468
469 regex.is_match(&item_name)
470 && if dir {
471 item_type.is_dir()
472 } else {
473 item_type.is_file()
474 }
475 }
476 });
477
478 items
479 .map(|x| {
480 let x = x.expect("item is None");
481
482 x.path()
483 })
484 .collect::<Vec<PathBuf>>()
485}