added --fix - fif can now rename files itself!

this alone warrants a bump to 0.4.0 imo, and now that i think about it, there's not really much keeping me from calling it 1.0...
i think i'd want to get more tests, and maybe upgrade to clap 3 stable when that happens, before calling it 1.0, though. maybe even get some sort of configuration file...
This commit is contained in:
Lynne Megido 2021-10-04 23:33:48 +10:00
parent c4fabbc0f4
commit 556ea82a06
Signed by: lynnesbian
GPG key ID: F0A184B5213D9F90
7 changed files with 123 additions and 13 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
/target
/imgs
/imgs.tar.zst
/fif_*
/old
/.mypy_cache

View file

@ -5,6 +5,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- `--fix` mode - instead of outputting a shell script or text file, fif will rename the misnamed files for you!
- By default, the user will be prompted for each rename. This behaviour can be changed with the new `p`/`--prompt`
flag: `-p always` to be prompted each time (the default), `-p error` to be prompted on errors and when a file
would be overwritten by renaming, and `-p never` to disable prompting altogether - this behaves the same as
answering "yes" to every prompt.
- The `--overwrite` flag must be specified along with `--fix` in order for fif to process renames that would cause an
existing file to be overwritten. Without it, fif will never overwrite existing files, even with `-p always`.
**Caution**: If this flag is set in combination with `--prompt never`, fif will overwrite files **without asking**!
- For a more thorough breakdown of how these flags work, see [the corresponding wiki
page](https://gitlab.com/Lynnesbian/fif/-/wikis/Fix).
### Changed
- Capped help output (`-h`/`--help`) width at 120 characters max
- Output is now sorted by filename - specifically, errors will appear first, followed by files that fif is unable to

View file

@ -23,6 +23,20 @@ pub struct Findings {
pub mime: Mime,
}
impl Findings {
pub fn recommended_extension(&self) -> Option<String> {
mime_extension_lookup(self.mime.essence_str().into()).map(|extensions| extensions[0].clone())
}
pub fn recommended_path(&self) -> Option<PathBuf> {
if let Some(ext) = self.recommended_extension() {
Some(self.file.with_extension(ext.as_str()))
} else {
None
}
}
}
impl PartialOrd<Self> for Findings {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> { Some(self.cmp(other)) }
}
@ -61,12 +75,6 @@ impl serde::Serialize for Findings {
}
}
impl Findings {
pub fn recommended_extension(&self) -> Option<String> {
mime_extension_lookup(self.mime.essence_str().into()).map(|extensions| extensions[0].clone())
}
}
#[derive(Debug, PartialEq, PartialOrd, Ord, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize))]
#[cfg_attr(feature = "json", serde(tag = "type", content = "path"))]

View file

@ -142,8 +142,8 @@ pub trait FormatSteps {
}
for finding in findings {
if let Some(ext) = finding.recommended_extension() {
self.rename(f, finding.file.as_path(), &finding.file.with_extension(ext.as_str()))?;
if let Some(name) = finding.recommended_path() {
self.rename(f, finding.file.as_path(), &name)?;
} else {
self.no_known_extension(f, finding.file.as_path())?;
}

View file

@ -17,14 +17,14 @@
#![forbid(unsafe_code)]
#![warn(trivial_casts, unused_lifetimes, unused_qualifications)]
use std::io::{stdout, BufWriter, Write};
use std::io::{stdin, stdout, BufWriter, Write};
use std::process::exit;
use cfg_if::cfg_if;
use clap::Clap;
use fif::files::{scan_directory, scan_from_walkdir};
use fif::formats::Format;
use fif::parameters::OutputFormat;
use fif::parameters::{OutputFormat, Prompt};
use fif::utils::{os_name, CLAP_LONG_VERSION};
use fif::{formats, parameters};
use itertools::Itertools;
@ -136,6 +136,69 @@ fn main() {
.collect_vec();
if args.fix {
fn ask(message: String) -> 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::Always);
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 {:#?} already exists, overwrite?", rename_to))
} else {
// handles: --prompt always --overwrite [n]
// user was prompted and replied "no"
false
}
};
if !will_rename { continue }
loop {
match std::fs::rename(&f.file, &rename_to) {
Ok(_) => {
info!("Renamed {:#?} -> {:#?}", f.file, rename_to);
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: {:#?}. Try again?", e)) {
break;
}
}
}
}
} else {
// no recommended name :c
info!("No known extension for file {:#?} of type {}", f.file, f.mime)
}
}
} else {
let mut buffered_stdout = BufWriter::new(stdout());

View file

@ -32,6 +32,13 @@ pub enum OutputFormat {
Json,
}
#[derive(Clap, PartialEq, Debug)]
pub enum Prompt {
Never,
Error,
Always
}
#[derive(Clap, Debug)]
#[allow(clippy::struct_excessive_bools)]
#[clap(
@ -48,12 +55,18 @@ pub enum OutputFormat {
max_term_width = 120
)]
pub struct Parameters {
/// Automatically rename files to use the correct extension.
/// Automatically rename files to use the correct extension, prompting the user for every rename.
#[clap(long)]
pub fix: bool,
#[clap(long)]
pub noconfirm: bool,
/// Requires --fix. Should fif prompt you `Never`, only on `Error`s and overwrites, or `Always`?
#[clap(short = 'p', long, arg_enum, requires = "fix")]
pub prompt: Option<Prompt>,
/// Requires --fix. Allow overwriting files. Warning: When used in combination with `--prompt never`, fif will
/// overwrite files without prompting!
#[clap(short = 'n', long, requires = "fix")]
pub overwrite: bool,
// NOTE: clap's comma-separated argument parser makes it impossible to specify extensions with commas in their name -
// `-e sil\,ly` is treated as ["sil", "ly"] rather than as ["silly"], no matter how i escape the comma (in bash,

View file

@ -136,6 +136,11 @@ fn simple_directory() {
assert_eq!(result.mime, IMAGE_PNG);
// 3. ensure the recommended extension for "wrong.jpg" is "png"
assert_eq!(&result.recommended_extension().unwrap(), &String::from("png"));
// 4. ensure the recommended filename for "wrong.jpg" is "wrong.png"
assert_eq!(
result.recommended_path().unwrap().file_name(),
Some(OsStr::new("wrong.png"))
);
continue;
}
@ -146,6 +151,15 @@ fn simple_directory() {
.unwrap()
.contains(&result.recommended_extension().unwrap()));
// ensure that the recommended_name function outputs something beginning with "test"
assert!(result
.recommended_path()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("test"));
// make sure the guessed mimetype is correct based on the extension of the scanned file
// because we already know that the extensions match the mimetype (as we created these files ourselves earlier in
// the test), all files with the "jpg" extension should be IMAGE_JPEGs, etc.