1use crate::report::Category;
4use clap::{Parser, Subcommand, ValueEnum};
5use std::path::PathBuf;
6
7const MAX_COOLDOWN_SECS: u64 = 30 * 24 * 60 * 60;
11
12#[derive(Debug, Parser)]
25#[command(
26 name = "deps-cli",
27 version,
28 about = "Dependency-health checks for CI, pre-commit hooks, and shell workflows"
29)]
30pub struct Cli {
31 #[command(subcommand)]
33 pub command: Command,
34}
35
36#[derive(Debug, Subcommand)]
38pub enum Command {
39 Check(CheckArgs),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
46pub enum OutputFormat {
47 Table,
49 Json,
51 Sarif,
54}
55
56#[derive(Debug, Parser)]
58pub struct CheckArgs {
59 pub paths: Vec<PathBuf>,
61
62 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
64 pub format: OutputFormat,
65
66 #[arg(long, value_delimiter = ',')]
72 pub fail_on: Vec<Category>,
73
74 #[arg(long)]
76 pub offline: bool,
77
78 #[arg(long, value_parser = parse_cooldown)]
81 pub cooldown: Option<u64>,
82
83 #[arg(long)]
86 pub config: Option<PathBuf>,
87
88 #[arg(long)]
95 pub respect_gitignore: bool,
96
97 #[arg(long)]
104 pub follow_symlinks: bool,
105}
106
107impl CheckArgs {
108 #[must_use]
122 pub fn walk_paths(&self) -> Vec<PathBuf> {
123 if self.paths.is_empty() {
124 vec![PathBuf::from(".")]
125 } else {
126 self.paths.clone()
127 }
128 }
129}
130
131pub fn parse_cooldown(input: &str) -> Result<u64, String> {
152 let trimmed = input.trim();
153 let Some(last) = trimmed.chars().last() else {
154 return Err("cooldown must not be empty".to_string());
155 };
156
157 let (digits, unit) = if last.is_ascii_alphabetic() {
158 let prefix_len = trimmed.len() - last.len_utf8();
159 let digits = trimmed.get(..prefix_len).unwrap_or_default();
160 (digits, last.to_ascii_lowercase())
161 } else {
162 (trimmed, 's')
163 };
164
165 let value: u64 = digits
166 .parse()
167 .map_err(|_| format!("invalid cooldown duration: {input:?}"))?;
168
169 let seconds = match unit {
170 's' => Some(value),
171 'm' => value.checked_mul(60),
172 'h' => value.checked_mul(3_600),
173 'd' => value.checked_mul(86_400),
174 other => {
175 return Err(format!(
176 "unknown cooldown unit '{other}' (expected s, m, h, or d)"
177 ));
178 }
179 }
180 .ok_or_else(|| format!("cooldown duration overflowed: {input:?}"))?;
181
182 Ok(seconds.min(MAX_COOLDOWN_SECS))
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn test_walk_paths_defaults_to_current_directory() {
191 let cli = Cli::parse_from(["deps-cli", "check"]);
192 let Command::Check(args) = cli.command;
193 assert_eq!(args.walk_paths(), vec![PathBuf::from(".")]);
194 }
195
196 #[test]
197 fn test_walk_paths_keeps_explicit_paths() {
198 let cli = Cli::parse_from(["deps-cli", "check", "a/Cargo.toml", "b/package.json"]);
199 let Command::Check(args) = cli.command;
200 assert_eq!(
201 args.walk_paths(),
202 vec![
203 PathBuf::from("a/Cargo.toml"),
204 PathBuf::from("b/package.json")
205 ]
206 );
207 }
208
209 #[test]
210 fn test_fail_on_parses_valid_category_list() {
211 let cli = Cli::parse_from(["deps-cli", "check", "--fail-on", "vulnerable,license"]);
212 let Command::Check(args) = cli.command;
213 assert_eq!(args.fail_on, vec![Category::Vulnerable, Category::License]);
214 }
215
216 #[test]
217 fn test_fail_on_mutable_ref_token_matches_fr009() {
218 let cli = Cli::parse_from(["deps-cli", "check", "--fail-on", "mutable-ref"]);
219 let Command::Check(args) = cli.command;
220 assert_eq!(args.fail_on, vec![Category::MutableRefPin]);
221 }
222
223 #[test]
224 fn test_fail_on_rejects_unknown_category() {
225 let result = Cli::try_parse_from(["deps-cli", "check", "--fail-on", "not-a-category"]);
226 assert!(result.is_err());
227 }
228
229 #[test]
230 fn test_format_defaults_to_table() {
231 let cli = Cli::parse_from(["deps-cli", "check"]);
232 let Command::Check(args) = cli.command;
233 assert_eq!(args.format, OutputFormat::Table);
234 }
235
236 #[test]
237 fn test_format_json_parses() {
238 let cli = Cli::parse_from(["deps-cli", "check", "--format", "json"]);
239 let Command::Check(args) = cli.command;
240 assert_eq!(args.format, OutputFormat::Json);
241 }
242
243 #[test]
244 fn test_format_sarif_parses() {
245 let cli = Cli::parse_from(["deps-cli", "check", "--format", "sarif"]);
246 let Command::Check(args) = cli.command;
247 assert_eq!(args.format, OutputFormat::Sarif);
248 }
249
250 #[test]
251 fn test_parse_cooldown_bare_seconds() {
252 assert_eq!(parse_cooldown("42").unwrap(), 42);
253 }
254
255 #[test]
256 fn test_parse_cooldown_suffixed_units() {
257 assert_eq!(parse_cooldown("30m").unwrap(), 1_800);
258 assert_eq!(parse_cooldown("2h").unwrap(), 7_200);
259 assert_eq!(parse_cooldown("1d").unwrap(), 86_400);
260 }
261
262 #[test]
263 fn test_parse_cooldown_clamps_to_thirty_days() {
264 assert_eq!(parse_cooldown("999d").unwrap(), MAX_COOLDOWN_SECS);
265 }
266
267 #[test]
268 fn test_parse_cooldown_rejects_empty() {
269 assert!(parse_cooldown("").is_err());
270 }
271
272 #[test]
273 fn test_parse_cooldown_rejects_unknown_unit() {
274 assert!(parse_cooldown("5x").is_err());
275 }
276
277 #[test]
278 fn test_parse_cooldown_rejects_non_numeric() {
279 assert!(parse_cooldown("abc").is_err());
280 }
281}