stdrename/
lib.rs

1//! # stdrename
2//! `stdrename` is a small command line utility to rename all
3//! files in a folder according to a specified naming convention
4//! (camelCase, snake_case, kebab-case, etc.).
5//!
6//! See <https://github.com/Gadiguibou/stdrename> for the full documentation.
7
8use std::env;
9use std::error::Error;
10use std::ffi::OsStr;
11use std::fs;
12use std::io;
13use std::path::*;
14use std::time::Instant;
15
16use clap::{App, AppSettings, Arg, ArgGroup};
17
18use ignore::WalkBuilder;
19
20use inflector::Inflector;
21
22pub struct Config {
23    target_dir: PathBuf,
24    naming_convention: String,
25    recursive: bool,
26    include_dir: bool,
27    quiet: bool,
28    text: bool,
29}
30
31impl Config {
32    pub fn new() -> Result<Config, Box<dyn Error>> {
33        let matches = App::new("stdrename")
34        .version("v1.3.0")
35        .author("Gabriel Lacroix <lacroixgabriel@gmail.com>")
36        .about("This small utility is designed to rename all files in a folder according to a specified naming convention (camelCase, snake_case, kebab-case, etc.).")
37        .usage("stdrename [FLAGS] <convention> [TARGET]")
38        .setting(AppSettings::ArgRequiredElseHelp)
39        .setting(AppSettings::DeriveDisplayOrder)
40        .arg(
41            Arg::with_name("TARGET")
42                .help("Specifies a different target directory")
43                .required(false)
44                .index(1),
45        )
46        .arg(
47            Arg::with_name("camelCase")
48                .help("Uses the camelCase naming convention")
49                .short("c")
50                .long("camel"),
51        )
52        .arg(
53            Arg::with_name("kebab-case")
54                .help("Uses the kebab-case naming convention")
55                .short("k")
56                .long("kebab"),
57        )
58        .arg(
59            Arg::with_name("PascalCase")
60                .help("Uses the PascalCase naming convention")
61                .short("p")
62                .long("pascal"),
63        )
64        .arg(
65            Arg::with_name("SCREAMING_SNAKE_CASE")
66                .help("Uses the SCREAMING_SNAKE_CASE naming convention")
67                .long("screaming"),
68        )
69        .arg(
70            Arg::with_name("Sentence case")
71                .help("Uses the Sentence case naming convention")
72                .short("S")
73                .long("sentence"),
74        )
75        .arg(
76            Arg::with_name("snake_case")
77                .help("Uses the snake_case naming convention")
78                .short("s")
79                .long("snake"),
80        )
81        .arg(
82            Arg::with_name("Title Case")
83                .help("Uses the Title Case naming convention")
84                .short("T")
85                .long("title"),
86        )
87        .arg(
88            Arg::with_name("Train-Case")
89                .help("Uses the Train-Case naming convention")
90                .short("t")
91                .long("train"),
92        )
93        .group(
94            ArgGroup::with_name("convention")
95                .required(true)
96                .args(&["camelCase","kebab-case","PascalCase","SCREAMING_SNAKE_CASE","Sentence case","snake_case","Title Case","Train-Case"]),
97        )
98        .arg(
99            Arg::with_name("recursive")
100                .help("Makes renaming recursive, renaming files in subfolders as well")
101                .short("r")
102                .long("recursive"),
103        )
104        .arg(
105            Arg::with_name("directories")
106                .help("Renames directories as well")
107                .short("D")
108                .long("dir")
109        )
110        .arg(
111            Arg::with_name("text")
112                .help("Reads lines from stdin and translates them to the given convention in stdout until the first empty line")
113                .long("text")
114        )
115        .arg(
116            Arg::with_name("quiet")
117                .help("Suppress output")
118                .short("q")
119                .long("quiet")
120        )
121        .after_help("Full documentation available here: https://github.com/Gadiguibou/stdrename")
122        .get_matches();
123
124        let target_dir = match matches.value_of("TARGET") {
125            Some(dir) => PathBuf::from(dir),
126            None => env::current_dir()?,
127        };
128
129        let naming_convention = {
130            if matches.is_present("camelCase") {
131                "camelCase"
132            } else if matches.is_present("kebab-case") {
133                "kebab-case"
134            } else if matches.is_present("PascalCase") {
135                "PascalCase"
136            } else if matches.is_present("SCREAMING_SNAKE_CASE") {
137                "SCREAMING_SNAKE_CASE"
138            } else if matches.is_present("Sentence case") {
139                "Sentence_case"
140            } else if matches.is_present("snake_case") {
141                "snake_case"
142            } else if matches.is_present("Title Case") {
143                "Title_Case"
144            } else if matches.is_present("Train-Case") {
145                "Train-Case"
146            } else {
147                unreachable!()
148            }
149        }
150        .to_owned();
151
152        let recursive = matches.is_present("recursive");
153        let include_dir = matches.is_present("directories");
154        let quiet = matches.is_present("quiet");
155        let text = matches.is_present("text");
156
157        Ok(Config {
158            target_dir,
159            naming_convention,
160            recursive,
161            include_dir,
162            quiet,
163            text,
164        })
165    }
166}
167
168pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
169    let start_time = Instant::now();
170
171    let mut files_renamed: u64 = 0;
172
173    // If the text flag is specified, read from stdin and translate to stdout instead of renaming files
174    if config.text {
175        let stdin = io::stdin();
176
177        loop {
178            let mut input = String::new();
179
180            let len = stdin.read_line(&mut input)?;
181
182            if len == 0 || input.trim().is_empty() {
183                let running_time: f32 = start_time.elapsed().as_micros() as f32 / 1_000_000f32;
184
185                if !config.quiet {
186                    println!(
187                        "{} names translated in {} s. See you next time!\n(^ _ ^)/",
188                        files_renamed, running_time
189                    )
190                };
191
192                return Ok(());
193            } else {
194                let translation = change_naming_convention(
195                    &PathBuf::from(input.trim()),
196                    &config.naming_convention,
197                )?;
198                println!("{}", translation);
199                files_renamed += 1;
200            }
201        }
202    }
203
204    let mut walk_builder = WalkBuilder::new(&config.target_dir);
205
206    walk_builder
207        .max_depth(if !config.recursive { Some(1) } else { None })
208        .require_git(true);
209
210    // Parse different locations for a global config file depending on OS family
211    // On unix systems (MacOS or Linux), searches for ~/.config/stdrename/ignore
212    // On windows systems searches for %USERPROFILE%\AppData\Local\stdrename\ignore
213    // Outputs errors to stderr if there's one but doesn't stop program execution
214    #[cfg(unix)]
215    if let Some(home_path) = env::var_os("HOME") {
216        let config_location = format!("{}/.config/stdrename/ignore", home_path.to_string_lossy());
217        if PathBuf::from(&config_location).is_file() {
218            if let Some(e) = walk_builder.add_ignore(Path::new(&config_location)) {
219                eprintln!("Error parsing global config file: {}", e);
220            }
221        }
222    }
223    #[cfg(windows)]
224    if let Some(user_profile) = env::var_os("USERPROFILE") {
225        let config_location = format!(
226            "{}\\AppData\\Local\\stdrename\\ignore",
227            user_profile.to_string_lossy()
228        );
229        if PathBuf::from(&config_location).is_file() {
230            if let Some(e) = walk_builder.add_ignore(Path::new(&config_location)) {
231                eprintln!("Error parsing global config file: {}", e);
232            }
233        }
234    }
235
236    for entry in walk_builder.build() {
237        let entry = entry?;
238
239        let path = entry.path();
240
241        // Skips any entry that isn't a file if the "-D" flag is not specified.
242        // Always skips the target directory to prevent changing paths that the program will try to access.
243        // (and because it would be quite unexpected as well)
244        if !config.include_dir && !path.is_file() || path.eq(&config.target_dir) {
245            continue;
246        }
247
248        let new_name = change_naming_convention(&path, &config.naming_convention)?;
249        let new_path = path
250            .parent()
251            .ok_or("can't find path parent")?
252            .join(new_name);
253        if path != new_path {
254            fs::rename(&path, &new_path)?;
255            files_renamed += 1;
256        }
257    }
258    let running_time: f32 = start_time.elapsed().as_micros() as f32 / 1_000_000f32;
259
260    if !config.quiet {
261        println!(
262            "{} files renamed in {} s. See you next time!\n(^ _ ^)/",
263            files_renamed, running_time
264        )
265    };
266
267    Ok(())
268}
269
270pub fn change_naming_convention(
271    path_to_file: &Path,
272    new_naming_convention: &str,
273) -> Result<String, Box<dyn Error>> {
274    let file_stem = path_to_file
275        .file_stem()
276        .unwrap_or_else(|| OsStr::new(""))
277        .to_str()
278        .ok_or_else(|| {
279            format!(
280                "couldn't convert file stem of {:?} to valid Unicode",
281                path_to_file
282            )
283        })?;
284
285    let file_extension = path_to_file
286        .extension()
287        .unwrap_or_else(|| OsStr::new(""))
288        .to_str()
289        .ok_or_else(|| {
290            format!(
291                "couldn't convert file extension of {:?} to valid Unicode",
292                path_to_file
293            )
294        })?;
295
296    let file_stem = match new_naming_convention {
297        "camelCase" => file_stem.to_camel_case(),
298        "kebab-case" => file_stem.to_kebab_case(),
299        "PascalCase" => file_stem.to_pascal_case(),
300        "SCREAMING_SNAKE_CASE" => file_stem.to_screaming_snake_case(),
301        "Sentence_case" => file_stem.to_sentence_case(),
302        "snake_case" => file_stem.to_snake_case(),
303        "Title_Case" => file_stem.to_title_case(),
304        "Train-Case" => file_stem.to_train_case(),
305        _ => return Err(From::from("naming convention not found")),
306    };
307
308    if file_stem.is_empty() {
309        Ok(format!(".{}", file_extension))
310    } else if file_extension.is_empty() {
311        Ok(file_stem)
312    } else {
313        Ok(format!("{}.{}", file_stem, file_extension))
314    }
315}