ingredients 0.2.0

Check ingredients of published Rust crates
Documentation
//! ingredients CLI

use std::io::Write;

use clap::Parser;
use owo_colors::OwoColorize;

use ingredients::{Crate, Diff, Report, ReportOptions, Severity};

fn pretty_format_severity(severity: Severity) -> String {
    match severity {
        x @ Severity::Fatal => x.to_string().bold().on_bright_cyan().to_string(),
        x @ Severity::Error => x.to_string().red().bold().to_string(),
        x @ Severity::Warning => x.to_string().yellow().bold().to_string(),
        x @ Severity::Debug => x.to_string().green().bold().to_string(),
        x @ Severity::Info | x => x.bold().to_string(),
    }
}

const fn plural_suffix(len: usize) -> &'static str {
    if len == 1 { "" } else { "s" }
}

fn pretty_print_report(report: &Report, minimum: Severity, verbose: bool) {
    let mut out = anstream::stdout();

    for item in report.items() {
        if item.severity() < minimum {
            continue;
        }

        let severity = pretty_format_severity(item.severity());

        if verbose {
            let _ = writeln!(out, "{}: {}", severity, item.message());
            if let Some(extra) = item.extra() {
                for line in extra.lines() {
                    let _ = writeln!(out, "  {line}");
                }
                let _ = writeln!(out);
            }
        } else {
            let _ = writeln!(out, "{}: {}", severity, item.message());
        }
    }

    let fatal = report
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Fatal)
        .collect::<Vec<_>>()
        .len();
    let error = report
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Error)
        .collect::<Vec<_>>()
        .len();
    let warning = report
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Warning)
        .collect::<Vec<_>>()
        .len();

    let _ = writeln!(
        out,
        "{}: {} fatal issue{}, {} error{}, {} warning{}",
        "summary".bold(),
        fatal,
        plural_suffix(fatal),
        error,
        plural_suffix(error),
        warning,
        plural_suffix(warning),
    );
}

fn pretty_print_diff(diff: &Diff, minimum: Severity, verbose: bool) {
    let mut out = anstream::stdout();

    for item in diff.items() {
        if item.severity() < minimum {
            continue;
        }

        let severity = pretty_format_severity(item.severity());

        if verbose {
            let _ = writeln!(out, "{}: {}", severity, item.message());
            if let Some(extra) = item.extra() {
                for line in extra.lines() {
                    let _ = writeln!(out, "  {line}");
                }
                let _ = writeln!(out);
            }
        } else {
            let _ = writeln!(out, "{}: {}", severity, item.message());
        }
    }

    let fatal = diff
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Fatal)
        .collect::<Vec<_>>()
        .len();
    let error = diff
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Error)
        .collect::<Vec<_>>()
        .len();
    let warning = diff
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Warning)
        .collect::<Vec<_>>()
        .len();

    let _ = writeln!(
        out,
        "{}: {} fatal issue{}, {} error{}, {} warning{}",
        "summary".bold(),
        fatal,
        plural_suffix(fatal),
        error,
        plural_suffix(error),
        warning,
        plural_suffix(warning),
    );
}

#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]
enum Format {
    #[default]
    Plain,
    Verbose,
    Json,
}

/// Check ingredients of published Rust crates
#[derive(Debug, clap::Parser)]
#[command(disable_help_subcommand(true))]
struct Args {
    #[command(subcommand)]
    command: Command,
}

/// Compare a crate against the contents of the upstream version control system
///
/// By default, the crate archive is downloaded from crates.io. To override this
/// behaviour, pass `--file <PATH>` to use a locally downloaded file instead.
///
/// For crates that don't set `package.repository` in their metadata, a URL can
/// be provided with the `--with-repository <URL>` argument.
///
/// For crates that have been published with old versions of cargo that did not
/// yet include complete `.cargo_vcs_info.json` files, the "path in VCS" (the
/// relative path where the crate can be found in the repository) can be
/// provided with the `--with-path-in-vcs <PATH>` argument, and the VCS ref can
/// be provided with the `--with-vcs-ref <REF>` argument.
#[derive(Debug, clap::Args)]
struct ReportArgs {
    /// Name of the crate
    name: String,
    /// Version of the crate
    version: String,

    /// Output format
    #[clap(long, default_value = "plain")]
    format: Format,
    /// Minimum severity
    #[clap(long, default_value = "info")]
    severity: Severity,

    /// Path to local crate archive to use instead of downloading from crates.io
    #[clap(long, short = 'f')]
    file: Option<String>,

    /// Provide repository URL if package.repository is missing or wrong
    #[clap(long, short = 'u')]
    with_repository: Option<String>,
    /// Provide "path in VCS" if `.cargo_vcs_info.json` is missing or incomplete
    #[clap(long, short = 'p')]
    with_path_in_vcs: Option<String>,
    /// Provide VCS ref if `.cargo_vcs_info.json` is missing or incomplete
    #[clap(long, short = 'r')]
    with_vcs_ref: Option<String>,
}

/// Compare two versions of a crate
///
/// By default, the crate archives are downloaded from crates.io. To override
/// this behaviour, pass `--old_file <PATH>` and `--new-file <PATH>` to use
/// locally downloaded files instead.
#[derive(Debug, clap::Args)]
struct DiffArgs {
    /// Name of the crate
    name: String,
    /// Old version of the crate
    old_version: String,
    /// New version of the crate
    new_version: String,

    /// Output format
    #[clap(long, default_value = "plain")]
    format: Format,
    /// Minimum severity
    #[clap(long, default_value = "info")]
    severity: Severity,

    /// Path to local crate archive for old crate version
    #[clap(long)]
    old_file: Option<String>,
    /// Path to local crate archive for new crate version
    #[clap(long)]
    new_file: Option<String>,
}

#[derive(Debug, clap::Subcommand)]
enum Command {
    Report(ReportArgs),
    Diff(DiffArgs),
}

#[expect(clippy::print_stdout)]
async fn report(args: ReportArgs) -> anyhow::Result<()> {
    let mut options = ReportOptions::default();
    options.repository = args.with_repository;
    options.path_in_vcs = args.with_path_in_vcs;
    options.vcs_ref = args.with_vcs_ref;

    let krate = if let Some(path) = args.file {
        Crate::local(&args.name, &args.version, &path)?
    } else {
        Crate::download(&args.name, &args.version).await?
    };

    let report = if options.is_empty() {
        krate.report().await?
    } else {
        krate.report_with_options(options).await?
    };

    match args.format {
        Format::Plain => pretty_print_report(&report, args.severity, false),
        Format::Verbose => pretty_print_report(&report, args.severity, true),
        Format::Json => println!("{}", report.to_json()),
    }

    Ok(())
}

#[expect(clippy::print_stdout)]
async fn diff(args: DiffArgs) -> anyhow::Result<()> {
    let old_crate = if let Some(old_file) = args.old_file {
        Crate::local(&args.name, &args.old_version, &old_file)?
    } else {
        Crate::download(&args.name, &args.old_version).await?
    };
    let new_crate = if let Some(new_file) = args.new_file {
        Crate::local(&args.name, &args.new_version, &new_file)?
    } else {
        Crate::download(&args.name, &args.new_version).await?
    };

    let diff = old_crate.diff(&new_crate)?;

    match args.format {
        Format::Plain => pretty_print_diff(&diff, args.severity, false),
        Format::Verbose => pretty_print_diff(&diff, args.severity, true),
        Format::Json => println!("{}", diff.to_json()),
    }

    Ok(())
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    env_logger::init_from_env("INGREDIENTS_LOG");

    let args = Args::parse();

    match args.command {
        Command::Report(args) => report(args).await?,
        Command::Diff(args) => diff(args).await?,
    }

    Ok(())
}