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"));
}
}