muren 0.1.1

Command-line utility for filename manipulations.
Documentation
use colored::Colorize;
use regex::Regex;
use std::fmt::{Display, Formatter, Result};
use std::fs::rename;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::process::{self, exit};

extern crate unidecode;
use unidecode::unidecode;

pub struct RenameIntent {
    old_name: PathBuf,
    new_name: PathBuf,
}

impl RenameIntent {
    /// Is the new name different from the old one?
    fn is_changed(&self) -> bool {
        self.old_name != self.new_name
    }
}

impl Display for RenameIntent {
    fn fmt(&self, f: &mut Formatter) -> Result {
        if self.is_changed() {
            write!(
                f,
                "{0}{1}",
                self.old_name.to_string_lossy().red(),
                self.new_name.to_string_lossy().green()
            )
        } else {
            write!(f, "{0} =", self.old_name.to_string_lossy(),)
        }
    }
}

pub enum RenameCommand {
    SetExtension(String),
    Remove(String),
    Prefix(String),
    FixExtension(bool),
    Normalize,
    Replace(String, String, bool),
    ChangeCase(bool),
}

pub struct Config {
    pub command: RenameCommand,
    pub dry: bool,
    pub files: Vec<PathBuf>,
    pub auto_confirm: bool,
    pub show_unchanged: bool,
}

fn confirm_intents(intents: &Vec<RenameIntent>) -> bool {
    println!("The following files will be renamed:");
    print_intents(intents, false);
    println!("Do you want to continue? [y/N] ");
    let mut input = String::new();
    std::io::stdin().read_line(&mut input).unwrap();
    input.trim().to_lowercase() == "y"
}

/// Print all renames
fn print_intents(intents: &Vec<RenameIntent>, show_unchanged: bool) {
    for intent in intents {
        if intent.is_changed() || show_unchanged {
            println!("{}", intent);
        }
    }
}

/// Find a new name for a single file
fn suggest_rename(path: &PathBuf, command: &RenameCommand) -> RenameIntent {
    RenameIntent {
        old_name: path.clone(),
        new_name: match &command {
            RenameCommand::SetExtension(extension) => {
                let mut new_name = path.clone();
                new_name.set_extension(extension);
                new_name
            }
            RenameCommand::Remove(pattern) => {
                let new_name = path.to_string_lossy().replace(pattern, "");
                PathBuf::from(new_name)
            }
            RenameCommand::Prefix(prefix) => {
                let mut new_name = prefix.clone();
                new_name.push_str(path.to_string_lossy().to_string().as_str());
                PathBuf::from(new_name)
            }
            RenameCommand::Normalize => {
                let path_str = path.to_string_lossy().to_string();
                let new_name = unidecode(&path_str).replace(' ', "_"); //#.to_lowercase();
                PathBuf::from(new_name)
            }
            RenameCommand::FixExtension(append) => {
                let possible_extensions = find_extensions_from_content(path);
                let mut new_name = path.clone();
                if !has_correct_extension(path, &possible_extensions) {
                    let mut new_extension = possible_extensions[0].clone();
                    if *append {
                        let old_extension = new_name.extension();
                        if old_extension.is_some() {
                            new_extension.insert(0, '.');
                            new_extension.insert_str(0, old_extension.unwrap().to_str().unwrap())
                        }
                    }
                    new_name.set_extension(new_extension);
                };
                new_name
            }
            RenameCommand::Replace(pattern, replacement, is_regex) => {
                let path_str = path.to_string_lossy().to_string();
                let new_name = if *is_regex {
                    let re = Regex::new(pattern).unwrap();
                    re.replace_all(&path_str, replacement).to_string()
                } else {
                    path_str.replace(pattern, replacement)
                };
                PathBuf::from(new_name)
            }
            RenameCommand::ChangeCase(upper) => {
                let path_str = path.to_string_lossy().to_string();
                let new_name = match upper {
                    true => path_str.to_uppercase(),
                    false => path_str.to_lowercase(),
                };
                PathBuf::from(new_name)
            }
        }}
}

fn suggest_renames(files: &[PathBuf], command: &RenameCommand) -> Vec<RenameIntent> {
    files
        .iter()
        .map(|path| suggest_rename(
            path,
            command,
        ))
        .collect()
}

fn infer_mimetype(path: &Path, mime_type: bool) -> Option<String> {
    // TODO: Do something on windows :see_no_evil:
    let mut cmd = process::Command::new("file");
    let cmd_with_args = cmd.arg(path).arg("--brief");
    let cmd_with_args = if mime_type {
        cmd_with_args.arg("--mime-type")
    } else {
        cmd_with_args
    };

    let output = cmd_with_args.output();
    match output {
        Ok(output) => {
            let output_str = String::from_utf8(output.stdout).unwrap();
            let mime_type = match output_str.strip_suffix('\n') {
                Some(s) => String::from(s),
                None => output_str,
            };
            Some(mime_type)
        }
        Err(e) => match e.kind() {
            ErrorKind::NotFound => {
                eprintln!("Error: `file` probably not installed");
                exit(-1);
            }
            _ => panic!("{e}"),
        },
    }
}

fn find_extensions_from_content(path: &Path) -> Vec<String> {
    let mime_type_based = match infer_mimetype(path, true) {
        None => vec![],
        Some(mime_type) => {
            let mime_type_str = mime_type.as_str();
            match mime_type_str {
                "application/pdf" => vec![String::from("pdf")],
                "image/jpeg" => vec![String::from("jpeg"), String::from("jpg")],
                "image/png" => vec![String::from("png")],
                "text/csv" => vec![String::from("csv")],
                "text/html" => vec![String::from("html"), String::from("htm")],
                "text/x-script.python" => vec![String::from("py"), String::from("pyw")],
                _other => vec![],
            }
        }
    };

    let mut description_based = match infer_mimetype(path, false) {
        None => vec![],
        Some(description) => {
            let description_str = description.as_str();
            match description_str {
                "Apache Parquet" => vec![String::from("parquet"), String::from("pq")],
                _other => vec![],
            }
        }
    };

    let mut extensions = mime_type_based.clone();
    extensions.append(&mut description_based);
    extensions
}

fn has_correct_extension(path: &Path, possible_extensions: &[String]) -> bool {
    if possible_extensions.is_empty() {
        true
    } else {
        let current_extension = path.extension();
        if current_extension.is_none() {
            false
        } else {
            let extension = current_extension.unwrap().to_ascii_lowercase();
            let extension_str = String::from(extension.to_string_lossy());
            possible_extensions.contains(&extension_str)
        }
    }
}

fn try_rename(path: &Path, new_name: &Path) -> bool {
    match rename(path, new_name) {
        Ok(_) => {
            println!(
                "{0} {1}{2}",
                "".green(),
                path.to_string_lossy().red(),
                new_name.to_string_lossy().green()
            );
            true
        }
        Err(_) => {
            eprintln!(
                "{0} {1}{2}",
                "".red(),
                path.to_string_lossy().red(),
                new_name.to_string_lossy().green()
            );
            false
        }
    }
}

fn process_command(
    command: &RenameCommand,
    files: &[PathBuf],
    dry: bool,
    auto_confirm: bool,
    show_unchanged: bool,
) {
    let intents = suggest_renames(files, command);
    if dry {
        print_intents(&intents, show_unchanged);
    } else {
        let confirmed = auto_confirm || {
            let changed_count = intents.iter().filter(|i| i.is_changed()).count();
            (changed_count == 0) || confirm_intents(&intents)
        };

        let mut renamed_count = 0;
        if confirmed {
            for intent in intents {
                if intent.is_changed() {
                    let renamed = try_rename(&intent.old_name, &intent.new_name);
                    renamed_count += renamed as i32;
                }
                if show_unchanged {
                    println!("{}", intent)
                }
            }
            println!("{renamed_count} files renamed.");
        }
    };
}

pub fn run(config: &Config) {
    process_command(
        &config.command,
        &config.files,
        config.dry,
        config.auto_confirm,
        config.show_unchanged,
    );
}