rena/
lib.rs

1/*
2MIT License
3Copyright (c) 2020-2023 Lyssieth
4
5Permission is hereby granted, free of charge, to any person obtaining a copy
6of this software and associated documentation files (the "Software"), to deal
7in the Software without restriction, including without limitation the rights
8to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9copies of the Software, and to permit persons to whom the Software is
10furnished to do so, subject to the following conditions:
11
12The above copyright notice and this permission notice shall be included in all
13copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21SOFTWARE.
22*/
23#![forbid(unsafe_code)]
24#![deny(
25    missing_docs,
26    missing_debug_implementations,
27    rustdoc::missing_crate_level_docs,
28    // unused,
29    bad_style,
30    clippy::unwrap_used
31)]
32#![warn(clippy::pedantic, clippy::nursery)]
33
34//! Rena is a crate fo bulk renaming of files.
35
36#[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/// All the arguments after being turned into their respective types.
51#[derive(Debug, Clone, Default)]
52pub struct Arguments {
53    /// Folder in which to act
54    pub folder: PathBuf,
55    /// Whether to run on directories instead of files
56    pub directory: bool,
57    /// Whether to output more logging information
58    pub verbose: bool,
59    /// If renaming numerically, what number to start with
60    pub origin: usize,
61    /// The prefix for the item's name
62    pub prefix: String,
63    /// How much padding the number should have
64    pub padding: usize,
65    /// Which direction the number should be padded in
66    pub padding_direction: PaddingDirection,
67    /// A Regex to filter input items
68    pub match_regex: Option<Regex>,
69    /// When renaming, this is used to apply regex capture groups
70    pub match_rename: Option<String>,
71    /// Whether to not actually execute any rename operations
72    pub dry_run: bool,
73}
74
75/// Direction in which to pad.
76#[derive(Debug, Clone, Default)]
77pub enum PaddingDirection {
78    /// Pad left (00001)
79    #[default]
80    Left,
81    /// Pad right (10000)
82    Right,
83    /// Pad middle (00100)
84    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/// Just an intermediary struct to contain some data.
118#[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            // For some reason the default wasn't working here so I removed it and made it manually default
148            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
187/// Runs rena with the given arguments.
188///
189///
190/// # Errors
191///
192/// Returns an error in the following circumstances:
193///
194/// - The target doesn't exist
195/// - The target is not a directory
196/// - We can't read the directory's contents
197///
198/// # Panics
199///
200/// We currently verify that the result of [`read_dir()`] is not `Err` before
201/// unwrapping it, so this shouldn't ever panic.
202pub 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
242// Janky, but it works. I think. We'll see, hopefully.
243fn 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}