// SPDX-FileCopyrightText: 2021-2022 Lynnesbian // SPDX-License-Identifier: GPL-3.0-or-later // fif - a command-line tool for detecting and optionally correcting files with incorrect extensions. #![forbid(unsafe_code)] #![warn( trivial_casts, unused_lifetimes, unused_qualifications, missing_copy_implementations, unused_allocation )] use std::io::{stdin, stdout, BufWriter, Write}; use std::process::exit; use cfg_if::cfg_if; use clap::Parser; use fif::files::{scan_directory, scan_from_walkdir}; use fif::formats::{self, Format}; use fif::parameters::{self, OutputFormat, Prompt}; use fif::utils::{os_name, CLAP_LONG_VERSION}; use itertools::Itertools; use log::{debug, error, info, trace, warn, Level}; #[cfg(test)] mod tests; #[doc(hidden)] #[allow(clippy::cognitive_complexity, clippy::too_many_lines)] fn main() { let args: parameters::Parameters = parameters::Parameters::parse(); let mut builder = env_logger::Builder::new(); builder .filter_level(args.get_verbosity()) // set default log level .parse_default_env() // set log level from RUST_LOG .parse_env("FIF_LOG") // set log level from FIF_LOG .format(|buf, r| { let mut style = buf.default_level_style(r.level()); // use bold for warnings and errors style.set_bold(r.level() <= Level::Warn); // only use the first character of the log level name let abbreviation = style.value(r.level().to_string().chars().next().unwrap()); // e.g. [D] Debug message writeln!(buf, "[{}] {}", abbreviation, r.args()) }) .init(); trace!( "fif {}, running on {} {}", CLAP_LONG_VERSION.as_str(), std::env::consts::ARCH, os_name() ); debug!("Iterating directory: {:?}", args.dir); let extensions = args.extensions(); let excludes = args.excluded_extensions(); if let Some(extensions) = &extensions { debug!("Checking files with extensions: {:?}", extensions); } else if let Some(excludes) = &excludes { debug!("Skipping files with extensions: {:?}", excludes); } else { debug!("Checking files regardless of extensions"); } let entries = match scan_directory(&args.dir, extensions.as_ref(), excludes.as_ref(), &args.get_scan_opts()) { // no need to log anything for fatal errors - fif will already have printed something obvious like // "[ERROR] /fake/path: No such file or directory (os error 2)". we can assume that if this has happened, the dir // given as input doesn't exist or is otherwise unreadable. None => exit(exitcode::NOINPUT), Some(e) => e, }; if entries.is_empty() { warn!("No files matching requested options found."); exit(exitcode::OK); } trace!("Found {} items to check", entries.len()); cfg_if! { if #[cfg(feature = "multi-threaded")] { let use_threads = args.jobs != 1; if use_threads { // 0 is a special case - it should be understood to mean "all available host CPUs" let jobs = if args.jobs == 0 { num_cpus::get() } else { args.jobs }; // set up the global thread pool with the requested number of threads rayon::ThreadPoolBuilder::new().num_threads(jobs).build_global().unwrap(); trace!("Multithreading enabled, using {} threads", jobs); } else { trace!("Multithreading disabled at runtime"); } } else { // `multi-threading` feature disabled let use_threads = false; trace!("Multithreading disabled at compile time"); } } let (findings, errors) = scan_from_walkdir(&entries, args.canonical_paths, use_threads); trace!("Scanning complete"); if findings.is_empty() && errors.is_empty() { info!("All files have valid extensions!"); exit(exitcode::OK); } // remove files that already have the correct extension, then sort - first by whether or not they have a // recommended_extension() (with None before Some(ext)), then by filename let findings = findings .into_iter() .filter(|f| !f.valid) .sorted_unstable() .collect_vec(); // sort errors (File errors before Mime errors), then log a warning for each error let errors = errors .into_iter() .sorted_unstable() .map(|e| { warn!("{}", &e); e }) .collect_vec(); if args.fix { fn ask(message: &str) -> bool { let mut buf = String::with_capacity(1); print!("{message} [y/N] "); // flush stdout to ensure message is displayed stdout().flush().expect("Failed to flush stdout"); if let Err(e) = stdin().read_line(&mut buf) { // something went wrong while reading input - just exit error!("{}", e); exit(exitcode::IOERR) } buf.starts_with('y') || buf.starts_with('Y') } let prompt = args.prompt.unwrap_or(Prompt::Error); let mut renamed = 0_u32; // files that were successfully renamed let mut skipped = 0_u32; // files that were skipped over without trying to rename let mut failed = 0_u32; // files that fif failed to rename - e.g. files that are exclusively locked for f in findings { if let Some(rename_to) = f.recommended_path() { let will_rename = { if !args.overwrite && rename_to.exists() { // handles: --prompt never, --prompt error, --prompt always // user didn't specify --overwrite, and the destination exists info!("Not renaming {:#?}: Target {:#?} exists", f.file, rename_to); false } else if prompt == Prompt::Never { // handles: --prompt never --overwrite // user specified --prompt never in conjunction with --overwrite, so always rename true } else if prompt == Prompt::Error || ask(&format!("Rename {:#?} to {:#?}?", &f.file, &rename_to)) { // handles: --prompt error --overwrite, --prompt always --overwrite [y] // if the target exists, prompt before renaming; otherwise, just rename !rename_to.exists() || ask(&format!("Destination {rename_to:#?} already exists, overwrite?")) } else { // handles: --prompt always --overwrite [n] // user was prompted and replied "no" false } }; if !will_rename { skipped += 1; continue; } loop { // until file is renamed successfully match std::fs::rename(&f.file, &rename_to) { Ok(()) => { info!("Renamed {:#?} -> {:#?}", f.file, rename_to); renamed += 1; break; } Err(e) => { warn!("Couldn't rename {:#?} to {:#?}: {:#?}", f.file, rename_to, e); // if the user passed --prompt never, continue to the next file // otherwise, prompt user to retry move, retrying until the rename succeeds or they respond "N" if prompt == Prompt::Never || !ask(&format!("Error while renaming file: {e:#?}. Try again?")) { failed += 1; break; } } } } } else { // no recommended name :c info!("No known extension for file {:#?} of type {}", f.file, f.mime); skipped += 1; } } info!( "Processed {} files: Renamed {}, skipped {}, failed to rename {}", renamed + skipped + failed, renamed, skipped, failed ); } else { let mut buffered_stdout = BufWriter::new(stdout()); if match args.output_format { // TODO: simplify this to something like formats::write_all(args.output_format, ...) OutputFormat::Sh => formats::Shell.write_all(&mut buffered_stdout, &findings, &errors), OutputFormat::PowerShell => formats::PowerShell.write_all(&mut buffered_stdout, &findings, &errors), #[cfg(feature = "json")] OutputFormat::Json => formats::Json.write_all(&mut buffered_stdout, &findings, &errors), OutputFormat::Text => formats::Text.write_all(&mut buffered_stdout, &findings, &errors), } .is_err() { error!("Failed to write to stdout."); exit(exitcode::IOERR); } if buffered_stdout.flush().is_err() { error!("Failed to flush stdout."); exit(exitcode::IOERR); } } debug!("Done"); }