cargo-upkeep 0.1.7

Unified Rust project maintenance CLI (cargo subcommand)
//! CLI parsing and logging setup.

pub mod commands;

use crate::core::error::{ErrorCode, Result, UpkeepError};
use clap::{Args, Parser, Subcommand};
use tracing_subscriber::EnvFilter;

#[derive(Debug, Parser)]
#[command(
    name = "cargo-upkeep",
    version,
    about = "Unified Rust project maintenance CLI",
    arg_required_else_help = true
)]
pub struct Cli {
    #[arg(short, long, global = true)]
    pub verbose: bool,
    #[arg(long, global = true)]
    pub log_level: Option<String>,
    #[arg(long, global = true)]
    pub json: bool,
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Debug, Subcommand)]
pub enum Command {
    #[command(subcommand, about = "Run upkeep subcommands")]
    Upkeep(UpkeepCommand),
    #[command(about = "Detect workspace, tooling, and CI")]
    Detect,
    #[command(about = "Report RustSec vulnerabilities")]
    Audit,
    Deps {
        #[arg(
            long,
            help = "Include RustSec advisories for direct workspace deps (requires Cargo.lock)"
        )]
        security: bool,
    },
    #[command(about = "Compute project quality score")]
    Quality,
    #[command(about = "Find unused dependencies")]
    Unused,
    #[command(
        name = "unsafe-code",
        alias = "unsafe",
        about = "Report unsafe code usage"
    )]
    UnsafeCode,
    #[command(about = "Render dependency tree with filters")]
    Tree(TreeArgs),
}

#[derive(Debug, Subcommand)]
pub enum UpkeepCommand {
    #[command(about = "Detect workspace, tooling, and CI")]
    Detect,
    #[command(about = "Report RustSec vulnerabilities")]
    Audit,
    Deps {
        #[arg(
            long,
            help = "Include RustSec advisories for direct workspace deps (requires Cargo.lock)"
        )]
        security: bool,
    },
    #[command(about = "Compute project quality score")]
    Quality,
    #[command(about = "Find unused dependencies")]
    Unused,
    #[command(
        name = "unsafe-code",
        alias = "unsafe",
        about = "Report unsafe code usage"
    )]
    UnsafeCode,
    #[command(about = "Render dependency tree with filters")]
    Tree(TreeArgs),
}

#[derive(Debug, Args)]
pub struct TreeArgs {
    #[arg(long, help = "Limit recursion depth")]
    pub depth: Option<usize>,
    #[arg(long, help = "Only show duplicate crates")]
    pub duplicates: bool,
    #[arg(long, help = "Invert tree to show reverse dependencies")]
    pub invert: Option<String>,
    #[arg(long, help = "Include enabled features")]
    pub features: bool,
    #[arg(long = "no-dev", help = "Exclude dev-dependencies")]
    pub no_dev: bool,
}

pub fn init_logging(verbose: bool, log_level: Option<&str>) -> Result<()> {
    let filter = match log_level {
        Some(level) => EnvFilter::try_new(level).map_err(|err| {
            UpkeepError::context(
                ErrorCode::Config,
                format!("invalid log level filter: {level}"),
                err,
            )
        })?,
        None => {
            if verbose {
                EnvFilter::new("info")
            } else {
                EnvFilter::new("warn")
            }
        }
    };

    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .with_writer(std::io::stderr)
        .try_init()
        .map_err(|err| {
            UpkeepError::message(
                ErrorCode::Config,
                format!("failed to initialize logging: {err}"),
            )
        })?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{Cli, Command, TreeArgs, UpkeepCommand};
    use crate::core::error::ErrorCode;
    use clap::{error::ErrorKind, Parser};

    #[test]
    fn parses_upkeep_subcommand() {
        let cli = Cli::try_parse_from(["cargo-upkeep", "upkeep", "detect"]).unwrap();
        match cli.command {
            Command::Upkeep(UpkeepCommand::Detect) => {}
            _ => panic!("unexpected subcommand"),
        }
    }

    #[test]
    fn parses_direct_subcommand() {
        let cli = Cli::try_parse_from(["cargo-upkeep", "detect"]).unwrap();
        match cli.command {
            Command::Detect => {}
            _ => panic!("unexpected subcommand"),
        }
    }

    #[test]
    fn parses_tree_flags() {
        let cli = Cli::try_parse_from([
            "cargo-upkeep",
            "tree",
            "--depth",
            "2",
            "--duplicates",
            "--invert",
            "serde",
            "--features",
            "--no-dev",
        ])
        .unwrap();

        match cli.command {
            Command::Tree(args) => {
                assert_eq!(args.depth, Some(2));
                assert!(args.duplicates);
                assert_eq!(args.invert.as_deref(), Some("serde"));
                assert!(args.features);
                assert!(args.no_dev);
            }
            _ => panic!("unexpected subcommand"),
        }
    }

    #[test]
    fn parses_tree_upkeep_flags() {
        let cli = Cli::try_parse_from(["cargo-upkeep", "upkeep", "tree", "--depth", "1"]).unwrap();

        match cli.command {
            Command::Upkeep(UpkeepCommand::Tree(TreeArgs { depth, .. })) => {
                assert_eq!(depth, Some(1));
            }
            _ => panic!("unexpected subcommand"),
        }
    }

    #[test]
    fn parses_global_flags() {
        let cli = Cli::try_parse_from([
            "cargo-upkeep",
            "--json",
            "--verbose",
            "--log-level",
            "debug",
            "detect",
        ])
        .unwrap();

        assert!(cli.json);
        assert!(cli.verbose);
        assert_eq!(cli.log_level.as_deref(), Some("debug"));
    }

    #[test]
    fn parses_unsafe_aliases() {
        let cli = Cli::try_parse_from(["cargo-upkeep", "unsafe"]).unwrap();
        assert!(matches!(cli.command, Command::UnsafeCode));

        let cli = Cli::try_parse_from(["cargo-upkeep", "unsafe-code"]).unwrap();
        assert!(matches!(cli.command, Command::UnsafeCode));
    }

    #[test]
    fn missing_subcommand_returns_error() {
        let err = Cli::try_parse_from(["cargo-upkeep"]).unwrap_err();
        assert_eq!(
            err.kind(),
            ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
        );
    }

    #[test]
    fn unknown_flag_returns_error() {
        let err = Cli::try_parse_from(["cargo-upkeep", "--nope", "detect"]).unwrap_err();
        assert_eq!(err.kind(), ErrorKind::UnknownArgument);
    }

    #[test]
    fn unknown_subcommand_returns_error() {
        let err = Cli::try_parse_from(["cargo-upkeep", "DETECT"]).unwrap_err();
        assert_eq!(err.kind(), ErrorKind::InvalidSubcommand);
    }

    #[test]
    fn init_logging_invalid_level_returns_error() {
        let err = super::init_logging(false, Some("info=bogus")).unwrap_err();
        assert_eq!(err.code(), ErrorCode::Config);
        assert!(err
            .to_string()
            .contains("invalid log level filter: info=bogus"));
    }
}