fif/src/main.rs

240 lines
7.5 KiB
Rust
Raw Normal View History

// SPDX-FileCopyrightText: 2021-2022 Lynnesbian
// SPDX-License-Identifier: GPL-3.0-or-later
2021-10-05 14:24:08 +00:00
// fif - a command-line tool for detecting and optionally correcting files with incorrect extensions.
#![forbid(unsafe_code)]
2021-10-05 15:41:08 +00:00
#![warn(
trivial_casts,
unused_lifetimes,
unused_qualifications,
missing_copy_implementations,
unused_allocation
)]
use std::io::{stdin, stdout, BufWriter, Write};
2021-04-20 05:20:10 +00:00
use std::process::exit;
use cfg_if::cfg_if;
2022-01-01 03:28:45 +00:00
use clap::Parser;
2021-08-28 07:59:04 +00:00
use fif::files::{scan_directory, scan_from_walkdir};
2021-10-04 18:45:05 +00:00
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};
2021-02-06 03:24:13 +00:00
#[cfg(test)]
mod tests;
2021-02-04 11:22:19 +00:00
#[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()
);
2021-05-08 00:10:51 +00:00
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");
}
2021-06-18 05:36:05 +00:00
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.
2021-06-18 05:36:05 +00:00
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);
2021-04-28 13:19:04 +00:00
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!("{} [y/N] ", message);
// 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
2022-09-04 03:48:54 +00:00
} 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
2022-09-04 03:48:54 +00:00
!rename_to.exists() || ask(&format!("Destination {:#?} already exists, overwrite?", rename_to))
} 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"
2022-09-04 03:48:54 +00:00
if prompt == Prompt::Never || !ask(&format!("Error while renaming file: {:#?}. Try again?", e)) {
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());
2021-11-05 16:43:08 +00:00
if match args.output_format {
2021-10-04 18:45:05 +00:00
// 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),
2021-11-22 22:38:43 +00:00
}
.is_err()
{
error!("Failed to write to stdout.");
exit(exitcode::IOERR);
}
2021-11-22 22:38:43 +00:00
if buffered_stdout.flush().is_err() {
error!("Failed to flush stdout.");
exit(exitcode::IOERR);
}
}
debug!("Done");
}