projclean 0.6.0

Project cache & build cleaner
mod app;
mod common;
mod config;
mod fs;

use std::{
    env,
    fs::canonicalize,
    path::{Path, PathBuf},
    process,
    sync::{
        atomic::{AtomicBool, Ordering},
        mpsc::channel,
        Arc,
    },
    thread,
};

use anyhow::{anyhow, bail, Context, Result};
use clap::{Arg, ArgAction, Command};
use crossbeam_utils::sync::WaitGroup;

use app::run;
use config::Config;
use fs::{delete_all, ls, search};

use common::{human_readable_folder_size, Message, PathItem, PathState};
use inquire::{formatter::MultiOptionFormatter, MultiSelect};

const RULES: [[&str; 2]; 7] = [
    ["node_modules", "js"],
    ["target@Cargo.toml", "rust"],
    ["^(Debug|Release)$@\\.sln$", "vs"],
    ["^(build|xcuserdata|DerivedData)$@Podfile", "ios"],
    ["build@build.gradle", "android"],
    ["target@pom.xml", "java"],
    ["vendor@composer.json", "php"],
];

fn main() {
    let running = Arc::new(AtomicBool::new(true));
    let running2 = running.clone();
    ctrlc::set_handler(move || {
        running2.store(false, Ordering::SeqCst);
    })
    .expect("Error setting Ctrl-C handler");

    if let Err(err) = start(running) {
        eprintln!("{err}");
        process::exit(1);
    }
}

fn start(running: Arc<AtomicBool>) -> Result<()> {
    let matches = command().get_matches();

    let config = init_config(&matches)?;

    let entry = set_working_dir(&matches)?;

    let (tx, rx) = channel();
    let tx2 = tx.clone();

    thread::spawn(move || search(entry, config, tx2, running));
    if matches.get_flag("force") {
        let wg = WaitGroup::new();
        delete_all(rx, wg.clone())?;
        wg.wait();
    } else if matches.get_flag("print") {
        ls(rx)?;
    } else {
        run(rx, tx)?;
    }
    Ok(())
}

fn command() -> Command {
    Command::new(env!("CARGO_CRATE_NAME"))
        .version(env!("CARGO_PKG_VERSION"))
        .author(env!("CARGO_PKG_AUTHORS"))
        .about(concat!(
            env!("CARGO_PKG_DESCRIPTION"),
            " - ",
            env!("CARGO_PKG_REPOSITORY")
        ))
        .arg(
            Arg::new("cwd")
                .short('C')
                .long("cwd")
                .value_name("DIR")
                .default_value(".")
                .action(ArgAction::Set)
                .help("Start searching from DIR"),
        )
        .arg(
            Arg::new("force")
                .short('f')
                .long("force")
                .action(ArgAction::SetTrue)
                .help("Delete found targets without entering tui"),
        )
        .arg(
            Arg::new("print")
                .short('p')
                .long("print")
                .action(ArgAction::SetTrue)
                .help("Print found targets only"),
        )
        .arg(
            Arg::new("rules")
                .help("Search rules, like node_modules or target@Cargo.toml")
                .value_name("RULES")
                .action(ArgAction::Append),
        )
}

fn init_config(matches: &clap::ArgMatches) -> Result<Config> {
    let mut config = Config::default();

    let rules = if let Some(values) = matches.get_many::<String>("rules") {
        values.map(|v| v.to_string()).collect()
    } else {
        select_rules()?
    };
    for rule in rules {
        config.add_rule(&rule)?;
    }

    Ok(config)
}

fn set_working_dir(matches: &clap::ArgMatches) -> Result<PathBuf> {
    if let Some(current_dir) = matches.get_one::<String>("cwd") {
        let current_dir = Path::new(current_dir);

        if !is_existing_directory(current_dir) {
            return Err(anyhow!(
                "The '--file' path '{}' is not a directory.",
                current_dir.to_string_lossy()
            ));
        }
        let base_directory = canonicalize(current_dir).unwrap();
        env::set_current_dir(&base_directory).with_context(|| {
            format!(
                "Cannot set '{}' as the current working directory",
                base_directory.to_string_lossy()
            )
        })?;
        Ok(base_directory)
    } else {
        let current_dir = env::current_dir()?;
        Ok(current_dir)
    }
}

fn select_rules() -> Result<Vec<String>> {
    let options = RULES
        .map(|[rule, name]| format!("{name:<16}{rule}"))
        .to_vec();

    let to_rules = |selections: &[String]| {
        selections
            .iter()
            .map(|sel| {
                options
                    .iter()
                    .enumerate()
                    .find(|(_, v)| sel == *v)
                    .map(|(i, _)| RULES[i][0].to_string())
                    .unwrap()
            })
            .collect::<Vec<String>>()
    };

    let formatter: MultiOptionFormatter<String> = &|a| {
        to_rules(
            &a.iter()
                .map(|v| v.value.to_string())
                .collect::<Vec<String>>(),
        )
        .join(" ")
    };
    let selections = MultiSelect::new("Select search rules:", options.clone())
        .with_formatter(formatter)
        .without_help_message()
        .prompt()
        .unwrap_or_default();

    if selections.is_empty() {
        bail!("You did not select any rule :(")
    }
    Ok(to_rules(&selections))
}

fn is_existing_directory(path: &Path) -> bool {
    path.is_dir() && path.exists()
}