deps_cli/cli.rs
1//! Command-line argument surface for the `deps-cli` binary.
2
3use crate::report::Category;
4use crate::walk::{GitignorePolicy, SymlinkPolicy};
5use clap::{Parser, Subcommand, ValueEnum};
6use std::path::PathBuf;
7
8/// Upper bound on a `--cooldown` value, mirroring
9/// [`deps_core::policy_config::FreshnessConfig`]'s own 30-day clamp on `cooldown_secs`, so a
10/// CLI-supplied override can never exceed what a `deps.toml`-supplied one could.
11const MAX_COOLDOWN_SECS: u64 = 30 * 24 * 60 * 60;
12
13/// `deps-cli`: dependency-health checks for CI, pre-commit hooks, and shell workflows.
14///
15/// # Examples
16///
17/// ```
18/// use clap::Parser;
19/// use deps_cli::cli::{Cli, Command};
20///
21/// let cli = Cli::parse_from(["deps-cli", "check", "Cargo.toml"]);
22/// let Command::Check(args) = cli.command else {
23/// unreachable!()
24/// };
25/// assert_eq!(args.paths, vec![std::path::PathBuf::from("Cargo.toml")]);
26/// ```
27#[derive(Debug, Parser)]
28#[command(
29 name = "deps-cli",
30 version,
31 about = "Dependency-health checks for CI, pre-commit hooks, and shell workflows"
32)]
33pub struct Cli {
34 /// The subcommand to run.
35 #[command(subcommand)]
36 pub command: Command,
37}
38
39/// Top-level `deps-cli` subcommands.
40#[derive(Debug, Subcommand)]
41pub enum Command {
42 /// Walk PATH(s), classify every discovered manifest's dependencies through the same
43 /// pipeline `deps-lsp` uses, and report findings.
44 Check(CheckArgs),
45 /// Plan and write back version-requirement edits for one manifest's outdated or
46 /// vulnerable dependencies (spec 068, #1329).
47 Update(UpdateArgs),
48}
49
50/// Output format for a `check` run.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
52pub enum OutputFormat {
53 /// Human-readable table, grouped by file then severity (the default).
54 Table,
55 /// Versioned JSON document (see `crate::format::json`).
56 Json,
57 /// SARIF 2.1.0 document (see `crate::format::sarif`), for GitHub code scanning and other
58 /// SARIF consumers.
59 Sarif,
60}
61
62/// Arguments for `deps-cli check`.
63#[derive(Debug, Parser)]
64pub struct CheckArgs {
65 /// Paths to walk. Defaults to the current directory when empty.
66 pub paths: Vec<PathBuf>,
67
68 /// Output format.
69 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
70 pub format: OutputFormat,
71
72 /// Comma-separated categories that make the run exit with code 1
73 /// (`outdated,yanked,vulnerable,unsatisfiable,mutable-ref,license,deprecated`).
74 /// Defaults to `vulnerable,yanked,unsatisfiable` when omitted (FR-010). A finding that
75 /// matches none of the seven categories (e.g. an unresolved/unknown package) is always
76 /// reported but can never fail a run through this flag.
77 #[arg(long, value_delimiter = ',')]
78 pub fail_on: Vec<Category>,
79
80 /// Serve only already-cached registry data; never make a new outbound request.
81 #[arg(long)]
82 pub offline: bool,
83
84 /// Overrides `freshness.cooldown_secs` for this run. Accepts a bare number of seconds
85 /// or a suffixed duration (`30m`, `12h`, `3d`).
86 #[arg(long, value_parser = parse_cooldown)]
87 pub cooldown: Option<u64>,
88
89 /// Path to a `deps.toml` config file. Defaults to `./deps.toml` when present, and to
90 /// built-in defaults otherwise.
91 #[arg(long)]
92 pub config: Option<PathBuf>,
93
94 /// Restore `.gitignore`/`.ignore` awareness during the walk (`git`'s own default
95 /// behavior). `check` does not respect either by default (issue #1109): as a CI
96 /// security gate (`git checkout && deps-cli check .` against an untrusted fork PR), both
97 /// files are attacker-controlled input, and a one-line addition to either would otherwise
98 /// silently remove a manifest from the scan with no warning and exit code 0. Pass this
99 /// flag only when scanning a target you trust as much as your own `deps.toml`.
100 #[arg(long)]
101 pub respect_gitignore: bool,
102
103 /// Follow symlinks during the directory walk and resolve a symlinked manifest's target for
104 /// scanning (issue #1112). A symlink whose resolved, canonicalized target falls outside the
105 /// walked root is never followed regardless of this flag — see [`crate::walk::walk`]'s doc.
106 /// When this flag is not passed (the default), a symlink to a manifest-shaped file is still
107 /// detected and reported via a warning (non-zero exit code), it is simply not resolved and
108 /// scanned.
109 #[arg(long)]
110 pub follow_symlinks: bool,
111}
112
113impl CheckArgs {
114 /// The paths to walk: [`Self::paths`] verbatim, or the current directory when empty
115 /// (FR-001).
116 ///
117 /// # Examples
118 ///
119 /// ```
120 /// use clap::Parser;
121 /// use deps_cli::cli::{Cli, Command};
122 ///
123 /// let cli = Cli::parse_from(["deps-cli", "check"]);
124 /// let Command::Check(args) = cli.command else {
125 /// unreachable!()
126 /// };
127 /// assert_eq!(args.walk_paths(), vec![std::path::PathBuf::from(".")]);
128 /// ```
129 #[must_use]
130 pub fn walk_paths(&self) -> Vec<PathBuf> {
131 if self.paths.is_empty() {
132 vec![PathBuf::from(".")]
133 } else {
134 self.paths.clone()
135 }
136 }
137
138 /// [`Self::respect_gitignore`] translated to [`GitignorePolicy`] — the transposition-proof
139 /// type `walk::walk` and `run_check` take, so a `bool` never has to travel past this point.
140 #[must_use]
141 pub fn gitignore_policy(&self) -> GitignorePolicy {
142 if self.respect_gitignore {
143 GitignorePolicy::Respect
144 } else {
145 GitignorePolicy::Ignore
146 }
147 }
148
149 /// [`Self::follow_symlinks`] translated to [`SymlinkPolicy`] — the transposition-proof
150 /// type `walk::walk` and `run_check` take, so a `bool` never has to travel past this point.
151 #[must_use]
152 pub fn symlink_policy(&self) -> SymlinkPolicy {
153 if self.follow_symlinks {
154 SymlinkPolicy::Follow
155 } else {
156 SymlinkPolicy::Skip
157 }
158 }
159}
160
161/// Output format for an `update` run.
162///
163/// A separate enum from [`OutputFormat`] (not `sarif`-capable, per NFR-005's "duplicate,
164/// don't share" precedent): `update` has no diagnostic-finding concept for SARIF to
165/// describe, so accepting `--format sarif` only to reject it at runtime would be a worse UX
166/// than never accepting it syntactically at all.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
168pub enum UpdateOutputFormat {
169 /// Human-readable table, one line per item (the default).
170 Table,
171 /// Versioned JSON document (see `crate::format::json::render_update`).
172 Json,
173}
174
175/// Arguments for `deps-cli update` (spec 068, #1329).
176///
177/// `--config`/`--offline`/`--cooldown` clap attributes are **duplicated** from
178/// [`CheckArgs`]'s equivalents rather than shared via a `CommonArgs` flatten (NFR-005) — this
179/// keeps `CheckArgs`' existing public field layout and clap surface untouched. No
180/// `--respect-gitignore`/`--follow-symlinks` (Out of Scope: an explicitly named manifest path
181/// is already an explicit choice).
182#[derive(Debug, Parser)]
183pub struct UpdateArgs {
184 /// The single manifest to update (FR-002 — a directory, a glob expanding to more than
185 /// one path, or a path no ecosystem recognizes is an execution error).
186 pub manifest: PathBuf,
187
188 /// Narrows the update set to only the named dependencies (repeatable), matched after
189 /// `formatter.normalize_package_name` on both sides (FR-005).
190 #[arg(long, action = clap::ArgAction::Append)]
191 pub package: Vec<String>,
192
193 /// Targets only OSV-`Vulnerable` dependencies, via `recommended_fix()` rather than
194 /// `latest`; overrides every `[update].ignore` rule (FR-008 through FR-015). For a
195 /// registry that does not report yank status at all, the yank check is inert for that
196 /// ecosystem — a documented limitation (FR-013), not a bug: such a dependency can still
197 /// be classified `applied` even though its yanked status was never actually checked.
198 #[arg(long)]
199 pub security_only: bool,
200
201 /// Plans and reports without writing the manifest.
202 #[arg(long)]
203 pub dry_run: bool,
204
205 /// Output format.
206 #[arg(long, value_enum, default_value_t = UpdateOutputFormat::Table)]
207 pub format: UpdateOutputFormat,
208
209 /// Path to a `deps.toml` config file — the only way `[update].ignore` rules are loaded
210 /// (FR-007: `update` never auto-discovers a `deps.toml`).
211 #[arg(long)]
212 pub config: Option<PathBuf>,
213
214 /// Serve only already-cached registry data; never make a new outbound request.
215 #[arg(long)]
216 pub offline: bool,
217
218 /// Overrides `freshness.cooldown_secs` for this run. Accepts a bare number of seconds
219 /// or a suffixed duration (`30m`, `12h`, `3d`). No effect under `--security-only`
220 /// (FR-014).
221 #[arg(long, value_parser = parse_cooldown)]
222 pub cooldown: Option<u64>,
223}
224
225/// Parses a `--cooldown` value into a clamped second count.
226///
227/// Accepts a bare integer (seconds) or an integer suffixed with `s`/`m`/`h`/`d`. Clamped to
228/// `MAX_COOLDOWN_SECS` so a CLI override can never exceed `deps.toml`'s own bound.
229///
230/// # Examples
231///
232/// ```
233/// use deps_cli::cli::parse_cooldown;
234///
235/// assert_eq!(parse_cooldown("3600").unwrap(), 3600);
236/// assert_eq!(parse_cooldown("1h").unwrap(), 3600);
237/// assert_eq!(parse_cooldown("3d").unwrap(), 3 * 24 * 60 * 60);
238/// assert!(parse_cooldown("nonsense").is_err());
239/// ```
240///
241/// # Errors
242///
243/// Returns a human-readable message when `input` is empty, has a non-numeric magnitude, or
244/// uses an unrecognized unit suffix.
245pub fn parse_cooldown(input: &str) -> Result<u64, String> {
246 let trimmed = input.trim();
247 let Some(last) = trimmed.chars().last() else {
248 return Err("cooldown must not be empty".to_string());
249 };
250
251 let (digits, unit) = if last.is_ascii_alphabetic() {
252 let prefix_len = trimmed.len() - last.len_utf8();
253 let digits = trimmed.get(..prefix_len).unwrap_or_default();
254 (digits, last.to_ascii_lowercase())
255 } else {
256 (trimmed, 's')
257 };
258
259 let value: u64 = digits
260 .parse()
261 .map_err(|_| format!("invalid cooldown duration: {input:?}"))?;
262
263 let seconds = match unit {
264 's' => Some(value),
265 'm' => value.checked_mul(60),
266 'h' => value.checked_mul(3_600),
267 'd' => value.checked_mul(86_400),
268 other => {
269 return Err(format!(
270 "unknown cooldown unit '{other}' (expected s, m, h, or d)"
271 ));
272 }
273 }
274 .ok_or_else(|| format!("cooldown duration overflowed: {input:?}"))?;
275
276 Ok(seconds.min(MAX_COOLDOWN_SECS))
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn test_walk_paths_defaults_to_current_directory() {
285 let cli = Cli::parse_from(["deps-cli", "check"]);
286 let Command::Check(args) = cli.command else {
287 unreachable!()
288 };
289 assert_eq!(args.walk_paths(), vec![PathBuf::from(".")]);
290 }
291
292 #[test]
293 fn test_walk_paths_keeps_explicit_paths() {
294 let cli = Cli::parse_from(["deps-cli", "check", "a/Cargo.toml", "b/package.json"]);
295 let Command::Check(args) = cli.command else {
296 unreachable!()
297 };
298 assert_eq!(
299 args.walk_paths(),
300 vec![
301 PathBuf::from("a/Cargo.toml"),
302 PathBuf::from("b/package.json")
303 ]
304 );
305 }
306
307 #[test]
308 fn test_fail_on_parses_valid_category_list() {
309 let cli = Cli::parse_from(["deps-cli", "check", "--fail-on", "vulnerable,license"]);
310 let Command::Check(args) = cli.command else {
311 unreachable!()
312 };
313 assert_eq!(args.fail_on, vec![Category::Vulnerable, Category::License]);
314 }
315
316 #[test]
317 fn test_fail_on_mutable_ref_token_matches_fr009() {
318 let cli = Cli::parse_from(["deps-cli", "check", "--fail-on", "mutable-ref"]);
319 let Command::Check(args) = cli.command else {
320 unreachable!()
321 };
322 assert_eq!(args.fail_on, vec![Category::MutableRefPin]);
323 }
324
325 #[test]
326 fn test_fail_on_rejects_unknown_category() {
327 let result = Cli::try_parse_from(["deps-cli", "check", "--fail-on", "not-a-category"]);
328 assert!(result.is_err());
329 }
330
331 #[test]
332 fn test_format_defaults_to_table() {
333 let cli = Cli::parse_from(["deps-cli", "check"]);
334 let Command::Check(args) = cli.command else {
335 unreachable!()
336 };
337 assert_eq!(args.format, OutputFormat::Table);
338 }
339
340 #[test]
341 fn test_format_json_parses() {
342 let cli = Cli::parse_from(["deps-cli", "check", "--format", "json"]);
343 let Command::Check(args) = cli.command else {
344 unreachable!()
345 };
346 assert_eq!(args.format, OutputFormat::Json);
347 }
348
349 #[test]
350 fn test_format_sarif_parses() {
351 let cli = Cli::parse_from(["deps-cli", "check", "--format", "sarif"]);
352 let Command::Check(args) = cli.command else {
353 unreachable!()
354 };
355 assert_eq!(args.format, OutputFormat::Sarif);
356 }
357
358 #[test]
359 fn test_parse_cooldown_bare_seconds() {
360 assert_eq!(parse_cooldown("42").unwrap(), 42);
361 }
362
363 #[test]
364 fn test_parse_cooldown_suffixed_units() {
365 assert_eq!(parse_cooldown("30m").unwrap(), 1_800);
366 assert_eq!(parse_cooldown("2h").unwrap(), 7_200);
367 assert_eq!(parse_cooldown("1d").unwrap(), 86_400);
368 }
369
370 #[test]
371 fn test_parse_cooldown_clamps_to_thirty_days() {
372 assert_eq!(parse_cooldown("999d").unwrap(), MAX_COOLDOWN_SECS);
373 }
374
375 #[test]
376 fn test_parse_cooldown_rejects_empty() {
377 assert!(parse_cooldown("").is_err());
378 }
379
380 #[test]
381 fn test_parse_cooldown_rejects_unknown_unit() {
382 assert!(parse_cooldown("5x").is_err());
383 }
384
385 #[test]
386 fn test_parse_cooldown_rejects_non_numeric() {
387 assert!(parse_cooldown("abc").is_err());
388 }
389
390 // --- Command::Update (spec 068, T008) ---
391
392 #[test]
393 fn test_update_no_flags_parses_and_reaches_command_update() {
394 let cli = Cli::parse_from(["deps-cli", "update", "Cargo.toml"]);
395 let Command::Update(args) = cli.command else {
396 unreachable!()
397 };
398 assert_eq!(args.manifest, PathBuf::from("Cargo.toml"));
399 assert!(args.package.is_empty());
400 assert!(!args.security_only);
401 assert!(!args.dry_run);
402 assert_eq!(args.format, UpdateOutputFormat::Table);
403 assert!(args.config.is_none());
404 assert!(!args.offline);
405 assert!(args.cooldown.is_none());
406 }
407
408 #[test]
409 fn test_update_repeatable_package_flag() {
410 let cli = Cli::parse_from([
411 "deps-cli",
412 "update",
413 "--package",
414 "serde",
415 "--package",
416 "tokio",
417 "Cargo.toml",
418 ]);
419 let Command::Update(args) = cli.command else {
420 unreachable!()
421 };
422 assert_eq!(args.package, vec!["serde".to_string(), "tokio".to_string()]);
423 }
424
425 #[test]
426 fn test_update_security_only_and_dry_run_flags() {
427 let cli = Cli::parse_from([
428 "deps-cli",
429 "update",
430 "--security-only",
431 "--dry-run",
432 "Cargo.toml",
433 ]);
434 let Command::Update(args) = cli.command else {
435 unreachable!()
436 };
437 assert!(args.security_only);
438 assert!(args.dry_run);
439 }
440
441 #[test]
442 fn test_update_config_offline_cooldown_flags() {
443 let cli = Cli::parse_from([
444 "deps-cli",
445 "update",
446 "--config",
447 "deps.toml",
448 "--offline",
449 "--cooldown",
450 "1h",
451 "Cargo.toml",
452 ]);
453 let Command::Update(args) = cli.command else {
454 unreachable!()
455 };
456 assert_eq!(args.config, Some(PathBuf::from("deps.toml")));
457 assert!(args.offline);
458 assert_eq!(args.cooldown, Some(3_600));
459 }
460
461 #[test]
462 fn test_update_requires_a_manifest_argument() {
463 let result = Cli::try_parse_from(["deps-cli", "update"]);
464 assert!(result.is_err());
465 }
466
467 /// `update` has no SARIF concept — `--format sarif` must be rejected at parse time, not
468 /// accepted and rejected later at runtime.
469 #[test]
470 fn test_update_format_sarif_is_rejected_at_parse_time() {
471 let result = Cli::try_parse_from(["deps-cli", "update", "--format", "sarif", "Cargo.toml"]);
472 assert!(result.is_err());
473 }
474
475 #[test]
476 fn test_update_format_json_parses() {
477 let cli = Cli::parse_from(["deps-cli", "update", "--format", "json", "Cargo.toml"]);
478 let Command::Update(args) = cli.command else {
479 unreachable!()
480 };
481 assert_eq!(args.format, UpdateOutputFormat::Json);
482 }
483}