fb2-clean 0.6.0

Simple GUI/CLI utility to remove images from fb2 books
Documentation
use crate::{Config, Input, InputFile, InputFileType, Lang, Msg, Output, Tags, msg};
use clap::{
    Arg, ArgAction, ArgMatches, Command, CommandFactory, Error, FromArgMatches, Parser,
    builder::{TypedValueParser, ValueParser},
    error::{ContextKind, ContextValue, ErrorKind},
    value_parser,
};
use std::{ffi::OsStr, fs, path::Path, str::FromStr};

type Result<T> = std::result::Result<T, clap::Error>;

impl Parser for Config {}

impl FromArgMatches for Config {
    fn from_arg_matches(m: &ArgMatches) -> Result<Config> {
        Self::from_arg_matches_mut(&mut m.clone())
    }

    fn update_from_arg_matches(&mut self, m: &ArgMatches) -> Result<()> {
        self.update_from_arg_matches_mut(&mut m.clone())
    }

    fn from_arg_matches_mut(m: &mut ArgMatches) -> Result<Config> {
        if let Some(lang) = m.remove_one::<Lang>("lang") {
            let _ = Msg::set_lang(lang);
        }

        if m.get_flag("help") {
            let mut cmd = Config::command();
            if let Err(_) = cmd.print_help() {
                println!("{}", cmd.render_help());
            }
            std::process::exit(0);
        }

        let input = match m.remove_many::<InputPath>("input") {
            None => Input::default(),
            Some(xs) if xs.len() == 0 => Input::default(),
            Some(xs) => {
                let mut paths: Vec<_> = xs.into_iter().collect();

                if paths.len() > 1 && paths.iter().any(|p| p.is_dir()) {
                    return Err(Error::raw(
                        ErrorKind::TooManyValues,
                        "directory must be only 1",
                    ));
                }

                if paths[0].is_dir() {
                    Input::Dir(paths.pop().unwrap().into_boxed_path())
                } else {
                    Input::Files(paths.into_iter().map(|p| p.into_input_file()).collect())
                }
            }
        };

        let output = match m.remove_one::<Output>("output") {
            Some(o) => o,
            None => Output::try_from_input(&input).unwrap_or_default(),
        };

        Ok(Config {
            input,
            output,
            recursive: *m
                .get_one::<u8>("recursive")
                .unwrap_or(&Self::default_recursive()),
            tags: m
                .remove_one::<Tags>("tags")
                .unwrap_or_else(|| Tags::default()),
            zip: m.get_flag("zip"),
            unzip: m.get_flag("unzip"),
            overwrite: m.get_flag("overwrite"),
            exit_on_err: m.get_flag("exit-on-err"),
            jobs: *m.get_one::<u64>("jobs").unwrap_or(&Self::default_jobs()),
        })
    }

    fn update_from_arg_matches_mut(&mut self, m: &mut ArgMatches) -> Result<()> {
        *self = Self::from_arg_matches_mut(m)?;
        Ok(())
    }
}

impl CommandFactory for Config {
    fn command() -> Command {
        Command::new(env!("CARGO_PKG_NAME"))
            .version(concat!("v", env!("CARGO_PKG_VERSION")))
            .disable_help_flag(true)
            .disable_version_flag(true)
            .override_usage(concat!(env!("CARGO_PKG_NAME"), " [options]"))
            .arg(
                Arg::new("input")
                    .short('i')
                    .long("input")
                    .value_name("path")
                    .help(msg!(HelpInput))
                    .action(ArgAction::Append)
                    .value_parser(ValueParser::new(InputPathParser)),
            )
            .arg(
                Arg::new("output")
                    .short('o')
                    .long("output")
                    .value_name("dir")
                    .help(msg!(HelpOutput))
                    .value_parser(ValueParser::new(OutputParser)),
            )
            .arg(
                Arg::new("recursive")
                    .short('r')
                    .long("recursive")
                    .value_name("n")
                    .help(msg!(HelpRecursive))
                    .value_parser(value_parser!(u8)),
            )
            .arg(
                Arg::new("tags")
                    .short('t')
                    .long("tags")
                    .value_name("n[,m...]")
                    .help(msg!(HelpTags))
                    .value_parser(ValueParser::new(TagsParser)),
            )
            .arg(
                Arg::new("zip")
                    .short('z')
                    .long("zip")
                    .help(msg!(HelpZip))
                    .action(ArgAction::SetTrue),
            )
            .arg(
                Arg::new("unzip")
                    .short('Z')
                    .long("unzip")
                    .alias("no-zip")
                    .help(msg!(HelpUnzip))
                    .conflicts_with("zip")
                    .action(ArgAction::SetTrue),
            )
            .arg(
                Arg::new("overwrite")
                    .short('w')
                    .long("overwrite")
                    .help(msg!(HelpOverwrite))
                    .action(ArgAction::SetTrue),
            )
            .arg(
                Arg::new("exit-on-err")
                    .short('e')
                    .long("exit-on-err")
                    .alias("exit-on-error")
                    .help(msg!(HelpExitOnError))
                    .action(ArgAction::SetTrue),
            )
            .arg(
                Arg::new("jobs")
                    .short('j')
                    .long("jobs")
                    .value_name("n")
                    .help(msg!(HelpJobs))
                    .value_parser(value_parser!(u64).range(1..)),
            )
            .next_help_heading("Other")
            .arg(
                Arg::new("lang")
                    .short('l')
                    .long("lang")
                    .alias("language")
                    .value_name("lng")
                    .help(msg!(HelpLang))
                    .value_parser(ValueParser::new(Lang::from_str)),
            )
            .arg(
                Arg::new("version")
                    .short('V')
                    .long("version")
                    .help(msg!(HelpVersion))
                    .action(ArgAction::Version),
            )
            .arg(
                Arg::new("help")
                    .short('h')
                    .long("help")
                    .help(msg!(HelpHelp))
                    .action(ArgAction::SetTrue),
            )
    }

    fn command_for_update() -> Command {
        Self::command()
    }
}

macro_rules! ty_parser {
    ($parser:ident, $ty:ty, $try_from_os_str:path) => {
        #[derive(Clone)]
        pub(super) struct $parser;

        impl TypedValueParser for $parser {
            type Value = $ty;

            fn parse_ref(
                &self,
                cmd: &Command,
                arg: Option<&Arg>,
                value: &OsStr,
            ) -> Result<Self::Value> {
                $try_from_os_str(value).map_err(|e| {
                    let mut err = Error::new(ErrorKind::InvalidValue).with_cmd(&cmd);

                    if let Some(arg) = arg {
                        err.insert(
                            ContextKind::InvalidArg,
                            ContextValue::String(arg.to_string()),
                        );
                    }
                    err.insert(
                        ContextKind::InvalidValue,
                        ContextValue::String(format!(
                            "'{}' (reason: {})",
                            value.to_string_lossy(),
                            e
                        )),
                    );

                    err
                })
            }
        }
    };
}

ty_parser!(InputPathParser, InputPath, InputPath::new);
ty_parser!(OutputParser, Output, Output::new);
ty_parser!(TagsParser, Tags, Tags::fallible_new);

impl Tags {
    /// Always returns `Ok`.
    fn fallible_new(os: impl AsRef<OsStr>) -> std::result::Result<Tags, String> {
        Ok(Self::new(os))
    }
}

#[derive(Clone, Debug, PartialEq)]
pub enum InputPath {
    Dir(Box<Path>),
    File(InputFile),
}

impl InputPath {
    pub(crate) fn new(path: impl AsRef<Path>) -> crate::Result<InputPath> {
        let path = fs::canonicalize(path)?.into_boxed_path();

        if path.is_dir() {
            Ok(Self::Dir(path))
        } else if let Some(ty) = InputFileType::get_new(&path) {
            Ok(Self::File(InputFile { ty, path }))
        } else {
            Err("file has unsupported extension".into())
        }
    }

    pub(crate) fn is_dir(&self) -> bool {
        matches!(self, Self::Dir(_))
    }

    pub(crate) fn into_boxed_path(self) -> Box<Path> {
        match self {
            Self::Dir(p) => p,
            Self::File(InputFile { path, .. }) => path,
        }
    }

    // panic on Self::Dir
    pub(crate) fn into_input_file(self) -> InputFile {
        match self {
            Self::Dir(_) => panic!("must be InputPath::File"),
            Self::File(f) => f,
        }
    }
}