use crate::report::Category;
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
const MAX_COOLDOWN_SECS: u64 = 30 * 24 * 60 * 60;
#[derive(Debug, Parser)]
#[command(
name = "deps-cli",
version,
about = "Dependency-health checks for CI, pre-commit hooks, and shell workflows"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Check(CheckArgs),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Table,
Json,
Sarif,
}
#[derive(Debug, Parser)]
pub struct CheckArgs {
pub paths: Vec<PathBuf>,
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
pub format: OutputFormat,
#[arg(long, value_delimiter = ',')]
pub fail_on: Vec<Category>,
#[arg(long)]
pub offline: bool,
#[arg(long, value_parser = parse_cooldown)]
pub cooldown: Option<u64>,
#[arg(long)]
pub config: Option<PathBuf>,
#[arg(long)]
pub respect_gitignore: bool,
#[arg(long)]
pub follow_symlinks: bool,
}
impl CheckArgs {
#[must_use]
pub fn walk_paths(&self) -> Vec<PathBuf> {
if self.paths.is_empty() {
vec![PathBuf::from(".")]
} else {
self.paths.clone()
}
}
}
pub fn parse_cooldown(input: &str) -> Result<u64, String> {
let trimmed = input.trim();
let Some(last) = trimmed.chars().last() else {
return Err("cooldown must not be empty".to_string());
};
let (digits, unit) = if last.is_ascii_alphabetic() {
let prefix_len = trimmed.len() - last.len_utf8();
let digits = trimmed.get(..prefix_len).unwrap_or_default();
(digits, last.to_ascii_lowercase())
} else {
(trimmed, 's')
};
let value: u64 = digits
.parse()
.map_err(|_| format!("invalid cooldown duration: {input:?}"))?;
let seconds = match unit {
's' => Some(value),
'm' => value.checked_mul(60),
'h' => value.checked_mul(3_600),
'd' => value.checked_mul(86_400),
other => {
return Err(format!(
"unknown cooldown unit '{other}' (expected s, m, h, or d)"
));
}
}
.ok_or_else(|| format!("cooldown duration overflowed: {input:?}"))?;
Ok(seconds.min(MAX_COOLDOWN_SECS))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_walk_paths_defaults_to_current_directory() {
let cli = Cli::parse_from(["deps-cli", "check"]);
let Command::Check(args) = cli.command;
assert_eq!(args.walk_paths(), vec![PathBuf::from(".")]);
}
#[test]
fn test_walk_paths_keeps_explicit_paths() {
let cli = Cli::parse_from(["deps-cli", "check", "a/Cargo.toml", "b/package.json"]);
let Command::Check(args) = cli.command;
assert_eq!(
args.walk_paths(),
vec![
PathBuf::from("a/Cargo.toml"),
PathBuf::from("b/package.json")
]
);
}
#[test]
fn test_fail_on_parses_valid_category_list() {
let cli = Cli::parse_from(["deps-cli", "check", "--fail-on", "vulnerable,license"]);
let Command::Check(args) = cli.command;
assert_eq!(args.fail_on, vec![Category::Vulnerable, Category::License]);
}
#[test]
fn test_fail_on_mutable_ref_token_matches_fr009() {
let cli = Cli::parse_from(["deps-cli", "check", "--fail-on", "mutable-ref"]);
let Command::Check(args) = cli.command;
assert_eq!(args.fail_on, vec![Category::MutableRefPin]);
}
#[test]
fn test_fail_on_rejects_unknown_category() {
let result = Cli::try_parse_from(["deps-cli", "check", "--fail-on", "not-a-category"]);
assert!(result.is_err());
}
#[test]
fn test_format_defaults_to_table() {
let cli = Cli::parse_from(["deps-cli", "check"]);
let Command::Check(args) = cli.command;
assert_eq!(args.format, OutputFormat::Table);
}
#[test]
fn test_format_json_parses() {
let cli = Cli::parse_from(["deps-cli", "check", "--format", "json"]);
let Command::Check(args) = cli.command;
assert_eq!(args.format, OutputFormat::Json);
}
#[test]
fn test_format_sarif_parses() {
let cli = Cli::parse_from(["deps-cli", "check", "--format", "sarif"]);
let Command::Check(args) = cli.command;
assert_eq!(args.format, OutputFormat::Sarif);
}
#[test]
fn test_parse_cooldown_bare_seconds() {
assert_eq!(parse_cooldown("42").unwrap(), 42);
}
#[test]
fn test_parse_cooldown_suffixed_units() {
assert_eq!(parse_cooldown("30m").unwrap(), 1_800);
assert_eq!(parse_cooldown("2h").unwrap(), 7_200);
assert_eq!(parse_cooldown("1d").unwrap(), 86_400);
}
#[test]
fn test_parse_cooldown_clamps_to_thirty_days() {
assert_eq!(parse_cooldown("999d").unwrap(), MAX_COOLDOWN_SECS);
}
#[test]
fn test_parse_cooldown_rejects_empty() {
assert!(parse_cooldown("").is_err());
}
#[test]
fn test_parse_cooldown_rejects_unknown_unit() {
assert!(parse_cooldown("5x").is_err());
}
#[test]
fn test_parse_cooldown_rejects_non_numeric() {
assert!(parse_cooldown("abc").is_err());
}
}