use crate::report::Category;
use crate::walk::{GitignorePolicy, SymlinkPolicy};
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),
Update(UpdateArgs),
}
#[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()
}
}
#[must_use]
pub fn gitignore_policy(&self) -> GitignorePolicy {
if self.respect_gitignore {
GitignorePolicy::Respect
} else {
GitignorePolicy::Ignore
}
}
#[must_use]
pub fn symlink_policy(&self) -> SymlinkPolicy {
if self.follow_symlinks {
SymlinkPolicy::Follow
} else {
SymlinkPolicy::Skip
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum UpdateOutputFormat {
Table,
Json,
}
#[derive(Debug, Parser)]
pub struct UpdateArgs {
pub manifest: PathBuf,
#[arg(long, action = clap::ArgAction::Append)]
pub package: Vec<String>,
#[arg(long)]
pub security_only: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long, value_enum, default_value_t = UpdateOutputFormat::Table)]
pub format: UpdateOutputFormat,
#[arg(long)]
pub config: Option<PathBuf>,
#[arg(long)]
pub offline: bool,
#[arg(long, value_parser = parse_cooldown)]
pub cooldown: Option<u64>,
}
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 else {
unreachable!()
};
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 else {
unreachable!()
};
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 else {
unreachable!()
};
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 else {
unreachable!()
};
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 else {
unreachable!()
};
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 else {
unreachable!()
};
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 else {
unreachable!()
};
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());
}
#[test]
fn test_update_no_flags_parses_and_reaches_command_update() {
let cli = Cli::parse_from(["deps-cli", "update", "Cargo.toml"]);
let Command::Update(args) = cli.command else {
unreachable!()
};
assert_eq!(args.manifest, PathBuf::from("Cargo.toml"));
assert!(args.package.is_empty());
assert!(!args.security_only);
assert!(!args.dry_run);
assert_eq!(args.format, UpdateOutputFormat::Table);
assert!(args.config.is_none());
assert!(!args.offline);
assert!(args.cooldown.is_none());
}
#[test]
fn test_update_repeatable_package_flag() {
let cli = Cli::parse_from([
"deps-cli",
"update",
"--package",
"serde",
"--package",
"tokio",
"Cargo.toml",
]);
let Command::Update(args) = cli.command else {
unreachable!()
};
assert_eq!(args.package, vec!["serde".to_string(), "tokio".to_string()]);
}
#[test]
fn test_update_security_only_and_dry_run_flags() {
let cli = Cli::parse_from([
"deps-cli",
"update",
"--security-only",
"--dry-run",
"Cargo.toml",
]);
let Command::Update(args) = cli.command else {
unreachable!()
};
assert!(args.security_only);
assert!(args.dry_run);
}
#[test]
fn test_update_config_offline_cooldown_flags() {
let cli = Cli::parse_from([
"deps-cli",
"update",
"--config",
"deps.toml",
"--offline",
"--cooldown",
"1h",
"Cargo.toml",
]);
let Command::Update(args) = cli.command else {
unreachable!()
};
assert_eq!(args.config, Some(PathBuf::from("deps.toml")));
assert!(args.offline);
assert_eq!(args.cooldown, Some(3_600));
}
#[test]
fn test_update_requires_a_manifest_argument() {
let result = Cli::try_parse_from(["deps-cli", "update"]);
assert!(result.is_err());
}
#[test]
fn test_update_format_sarif_is_rejected_at_parse_time() {
let result = Cli::try_parse_from(["deps-cli", "update", "--format", "sarif", "Cargo.toml"]);
assert!(result.is_err());
}
#[test]
fn test_update_format_json_parses() {
let cli = Cli::parse_from(["deps-cli", "update", "--format", "json", "Cargo.toml"]);
let Command::Update(args) = cli.command else {
unreachable!()
};
assert_eq!(args.format, UpdateOutputFormat::Json);
}
}