sacad 3.0.0-beta.8

Smart Automatic Cover Art Downloader
Documentation
//! Command line interface

use std::path::PathBuf;

use clap::Parser;
use strum::VariantArray as _;

/// Command line arguments for `sacad` binary
#[derive(Parser, Debug)]
#[command(version, about)]
pub struct SacadArgs {
    /// Search query
    #[clap(flatten)]
    pub query: SearchQuery,
    /// Search options
    #[clap(flatten)]
    pub search_opts: SearchOptions,
    /// Output image file path
    pub output_filepath: PathBuf,
    /// Image conversion options
    #[clap(flatten)]
    pub image_proc: ImageProcessingArgs,
    /// Level of logging output
    #[clap(short, long, value_enum, ignore_case = true, default_value_t = Verbosity::Info)]
    pub verbosity: Verbosity,
}

/// Command line arguments for `sacad_r` binary
#[derive(Parser, Debug)]
#[command(version, about)]
pub struct SacadRecursiveArgs {
    /// Music library directory to recursively analyze
    pub lib_root_dir: PathBuf,
    /// Search options
    #[clap(flatten)]
    pub search_opts: SearchOptions,
    /// Cover image path pattern.
    /// {artist} and {album} are replaced by their tag value.
    /// You can set an absolute path, otherwise destination
    /// directory is relative to the audio files.
    /// Use single character '+' to embed JPEG into audio files.
    #[clap(value_parser = CoverOutput::from_arg)]
    pub output: CoverOutput,
    /// Ignore existing covers and force search and download for all files
    #[clap(short, long)]
    pub ignore_existing: bool,
    /// Image conversion options
    #[clap(flatten)]
    pub image_proc: ImageProcessingArgs,
    /// Level of logging output
    #[clap(short, long, value_enum, ignore_case = true, default_value_t = Verbosity::Info)]
    pub verbosity: Verbosity,
}

/// Level of logging output
#[derive(Debug, Copy, Clone, clap::ValueEnum)]
#[expect(missing_docs)]
pub enum Verbosity {
    Error,
    Warn,
    Info,
    Debug,
    Trace,
}

impl From<Verbosity> for log::Level {
    fn from(v: Verbosity) -> Self {
        match v {
            Verbosity::Error => Self::Error,
            Verbosity::Warn => Self::Warn,
            Verbosity::Info => Self::Info,
            Verbosity::Debug => Self::Debug,
            Verbosity::Trace => Self::Trace,
        }
    }
}

impl From<Verbosity> for log::LevelFilter {
    fn from(v: Verbosity) -> Self {
        log::Level::from(v).to_level_filter()
    }
}

/// Cover output destination
#[derive(Clone, Debug)]
pub enum CoverOutput {
    /// Cover will be embedded in audio file(s)
    Embed,
    /// Cover will be named according to this pattern in the album directory
    Pattern(CoverOutputPattern<String>),
}

impl CoverOutput {
    #[expect(clippy::unnecessary_wraps)]
    fn from_arg(s: &str) -> Result<Self, std::convert::Infallible> {
        if s == "+" {
            Ok(CoverOutput::Embed)
        } else {
            Ok(CoverOutput::Pattern(CoverOutputPattern(s.to_owned())))
        }
    }
}

/// A file path with replaceable tag patterns
#[derive(Clone, Debug)]
pub struct CoverOutputPattern<S>(pub S);

/// Command line arguments related to the search query
#[derive(Parser, Debug)]
pub struct SearchQuery {
    /// Artist to search for, None for various artists
    #[clap(required = true)]
    pub artist: Option<String>,
    /// Album to search for
    pub album: String,
}

/// Command line arguments related to search options
#[derive(Parser, Debug)]
pub struct SearchOptions {
    /// Target image size
    pub size: u32,
    /// Tolerate this percentage of size difference with the target size.
    /// Note that covers with size above or close to the target size will still be preferred if available
    #[clap(short = 't', long = "size-tolerance", default_value_t = 25)]
    pub size_tolerance_prct: u32,
    /// Cover sources to use, if not set use all of them.
    /// Use multiple times to search from several sources.
    #[clap(short = 's', long, default_values_t = SourceName::VARIANTS.to_vec())]
    pub cover_sources: Vec<SourceName>,
}

impl SearchOptions {
    /// Return true if cover size matches minimum from query
    pub(crate) fn matches_min_size(&self, size: u32) -> bool {
        let min_size = self.size - self.size * self.size_tolerance_prct / 100;
        size >= min_size
    }

    /// Return true if cover size matches query or requires resize
    pub(crate) fn matches_max_size(&self, size: u32) -> bool {
        debug_assert!(self.matches_min_size(size));
        let max_size = self.size + self.size * self.size_tolerance_prct / 100;
        size <= max_size
    }
}

/// Command line arguments related to output image processing
#[derive(Parser, Debug)]
pub struct ImageProcessingArgs {
    /// Preserve source image format if possible.
    #[clap(short, long)]
    pub preserve_format: bool,
}

/// Cover source name
#[derive(
    Debug,
    Copy,
    Clone,
    Eq,
    PartialEq,
    Hash,
    strum::EnumString,
    strum::VariantArray,
    strum::AsRefStr,
    strum::Display,
)]
#[strum(serialize_all = "lowercase")]
#[expect(missing_docs)]
pub enum SourceName {
    CoverArtArchive,
    Deezer,
    Discogs,
    Itunes,
    LastFm,
}

#[cfg(test)]
mod tests {
    use clap::CommandFactory as _;

    use super::*;

    #[test]
    fn parse_log_level() {
        let args =
            SacadArgs::parse_from(["sacad", "-v", "debug", "artist", "album", "600", "c.jpg"]);
        assert!(matches!(args.verbosity, Verbosity::Debug));
        assert_eq!(log::Level::from(args.verbosity), log::Level::Debug);
    }

    #[test]
    fn parse_log_level_case_insensitive() {
        for value in ["debug", "DEBUG", "Debug", "dEbUg"] {
            let args =
                SacadArgs::parse_from(["sacad", "-v", value, "artist", "album", "600", "c.jpg"]);
            assert!(
                matches!(args.verbosity, Verbosity::Debug),
                "failed for {value:?}"
            );
        }
    }

    #[test]
    fn default_log_level() {
        let args = SacadArgs::parse_from(["sacad", "artist", "album", "600", "c.jpg"]);
        assert!(matches!(args.verbosity, Verbosity::Info));
    }

    #[test]
    fn help_lists_verbosity_values() {
        let mut help = Vec::new();
        SacadArgs::command().write_long_help(&mut help).unwrap();
        let help = String::from_utf8(help).unwrap();
        for level in ["error", "warn", "info", "debug", "trace"] {
            assert!(help.contains(level));
        }
    }
}