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;
23/// assert_eq!(args.paths, vec![std::path::PathBuf::from("Cargo.toml")]);
24/// ```
25#[derive(Debug, Parser)]
26#[command(
27 name = "deps-cli",
28 version,
29 about = "Dependency-health checks for CI, pre-commit hooks, and shell workflows"
30)]
31pub struct Cli {
32 /// The subcommand to run.
33 #[command(subcommand)]
34 pub command: Command,
35}
36
37/// Top-level `deps-cli` subcommands. Only `check` exists in this release.
38#[derive(Debug, Subcommand)]
39pub enum Command {
40 /// Walk PATH(s), classify every discovered manifest's dependencies through the same
41 /// pipeline `deps-lsp` uses, and report findings.
42 Check(CheckArgs),
43}
44
45/// Output format for a `check` run.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
47pub enum OutputFormat {
48 /// Human-readable table, grouped by file then severity (the default).
49 Table,
50 /// Versioned JSON document (see `crate::format::json`).
51 Json,
52 /// SARIF 2.1.0 document (see `crate::format::sarif`), for GitHub code scanning and other
53 /// SARIF consumers.
54 Sarif,
55}
56
57/// Arguments for `deps-cli check`.
58#[derive(Debug, Parser)]
59pub struct CheckArgs {
60 /// Paths to walk. Defaults to the current directory when empty.
61 pub paths: Vec<PathBuf>,
62
63 /// Output format.
64 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
65 pub format: OutputFormat,
66
67 /// Comma-separated categories that make the run exit with code 1
68 /// (`outdated,yanked,vulnerable,unsatisfiable,mutable-ref,license,deprecated`).
69 /// Defaults to `vulnerable,yanked,unsatisfiable` when omitted (FR-010). A finding that
70 /// matches none of the seven categories (e.g. an unresolved/unknown package) is always
71 /// reported but can never fail a run through this flag.
72 #[arg(long, value_delimiter = ',')]
73 pub fail_on: Vec<Category>,
74
75 /// Serve only already-cached registry data; never make a new outbound request.
76 #[arg(long)]
77 pub offline: bool,
78
79 /// Overrides `freshness.cooldown_secs` for this run. Accepts a bare number of seconds
80 /// or a suffixed duration (`30m`, `12h`, `3d`).
81 #[arg(long, value_parser = parse_cooldown)]
82 pub cooldown: Option<u64>,
83
84 /// Path to a `deps.toml` config file. Defaults to `./deps.toml` when present, and to
85 /// built-in defaults otherwise.
86 #[arg(long)]
87 pub config: Option<PathBuf>,
88
89 /// Restore `.gitignore`/`.ignore` awareness during the walk (`git`'s own default
90 /// behavior). `check` does not respect either by default (issue #1109): as a CI
91 /// security gate (`git checkout && deps-cli check .` against an untrusted fork PR), both
92 /// files are attacker-controlled input, and a one-line addition to either would otherwise
93 /// silently remove a manifest from the scan with no warning and exit code 0. Pass this
94 /// flag only when scanning a target you trust as much as your own `deps.toml`.
95 #[arg(long)]
96 pub respect_gitignore: bool,
97
98 /// Follow symlinks during the directory walk and resolve a symlinked manifest's target for
99 /// scanning (issue #1112). A symlink whose resolved, canonicalized target falls outside the
100 /// walked root is never followed regardless of this flag — see [`crate::walk::walk`]'s doc.
101 /// When this flag is not passed (the default), a symlink to a manifest-shaped file is still
102 /// detected and reported via a warning (non-zero exit code), it is simply not resolved and
103 /// scanned.
104 #[arg(long)]
105 pub follow_symlinks: bool,
106}
107
108impl CheckArgs {
109 /// The paths to walk: [`Self::paths`] verbatim, or the current directory when empty
110 /// (FR-001).
111 ///
112 /// # Examples
113 ///
114 /// ```
115 /// use clap::Parser;
116 /// use deps_cli::cli::{Cli, Command};
117 ///
118 /// let cli = Cli::parse_from(["deps-cli", "check"]);
119 /// let Command::Check(args) = cli.command;
120 /// assert_eq!(args.walk_paths(), vec![std::path::PathBuf::from(".")]);
121 /// ```
122 #[must_use]
123 pub fn walk_paths(&self) -> Vec<PathBuf> {
124 if self.paths.is_empty() {
125 vec![PathBuf::from(".")]
126 } else {
127 self.paths.clone()
128 }
129 }
130
131 /// [`Self::respect_gitignore`] translated to [`GitignorePolicy`] — the transposition-proof
132 /// type `walk::walk` and `run_check` take, so a `bool` never has to travel past this point.
133 #[must_use]
134 pub fn gitignore_policy(&self) -> GitignorePolicy {
135 if self.respect_gitignore {
136 GitignorePolicy::Respect
137 } else {
138 GitignorePolicy::Ignore
139 }
140 }
141
142 /// [`Self::follow_symlinks`] translated to [`SymlinkPolicy`] — the transposition-proof
143 /// type `walk::walk` and `run_check` take, so a `bool` never has to travel past this point.
144 #[must_use]
145 pub fn symlink_policy(&self) -> SymlinkPolicy {
146 if self.follow_symlinks {
147 SymlinkPolicy::Follow
148 } else {
149 SymlinkPolicy::Skip
150 }
151 }
152}
153
154/// Parses a `--cooldown` value into a clamped second count.
155///
156/// Accepts a bare integer (seconds) or an integer suffixed with `s`/`m`/`h`/`d`. Clamped to
157/// `MAX_COOLDOWN_SECS` so a CLI override can never exceed `deps.toml`'s own bound.
158///
159/// # Examples
160///
161/// ```
162/// use deps_cli::cli::parse_cooldown;
163///
164/// assert_eq!(parse_cooldown("3600").unwrap(), 3600);
165/// assert_eq!(parse_cooldown("1h").unwrap(), 3600);
166/// assert_eq!(parse_cooldown("3d").unwrap(), 3 * 24 * 60 * 60);
167/// assert!(parse_cooldown("nonsense").is_err());
168/// ```
169///
170/// # Errors
171///
172/// Returns a human-readable message when `input` is empty, has a non-numeric magnitude, or
173/// uses an unrecognized unit suffix.
174pub fn parse_cooldown(input: &str) -> Result<u64, String> {
175 let trimmed = input.trim();
176 let Some(last) = trimmed.chars().last() else {
177 return Err("cooldown must not be empty".to_string());
178 };
179
180 let (digits, unit) = if last.is_ascii_alphabetic() {
181 let prefix_len = trimmed.len() - last.len_utf8();
182 let digits = trimmed.get(..prefix_len).unwrap_or_default();
183 (digits, last.to_ascii_lowercase())
184 } else {
185 (trimmed, 's')
186 };
187
188 let value: u64 = digits
189 .parse()
190 .map_err(|_| format!("invalid cooldown duration: {input:?}"))?;
191
192 let seconds = match unit {
193 's' => Some(value),
194 'm' => value.checked_mul(60),
195 'h' => value.checked_mul(3_600),
196 'd' => value.checked_mul(86_400),
197 other => {
198 return Err(format!(
199 "unknown cooldown unit '{other}' (expected s, m, h, or d)"
200 ));
201 }
202 }
203 .ok_or_else(|| format!("cooldown duration overflowed: {input:?}"))?;
204
205 Ok(seconds.min(MAX_COOLDOWN_SECS))
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn test_walk_paths_defaults_to_current_directory() {
214 let cli = Cli::parse_from(["deps-cli", "check"]);
215 let Command::Check(args) = cli.command;
216 assert_eq!(args.walk_paths(), vec![PathBuf::from(".")]);
217 }
218
219 #[test]
220 fn test_walk_paths_keeps_explicit_paths() {
221 let cli = Cli::parse_from(["deps-cli", "check", "a/Cargo.toml", "b/package.json"]);
222 let Command::Check(args) = cli.command;
223 assert_eq!(
224 args.walk_paths(),
225 vec![
226 PathBuf::from("a/Cargo.toml"),
227 PathBuf::from("b/package.json")
228 ]
229 );
230 }
231
232 #[test]
233 fn test_fail_on_parses_valid_category_list() {
234 let cli = Cli::parse_from(["deps-cli", "check", "--fail-on", "vulnerable,license"]);
235 let Command::Check(args) = cli.command;
236 assert_eq!(args.fail_on, vec![Category::Vulnerable, Category::License]);
237 }
238
239 #[test]
240 fn test_fail_on_mutable_ref_token_matches_fr009() {
241 let cli = Cli::parse_from(["deps-cli", "check", "--fail-on", "mutable-ref"]);
242 let Command::Check(args) = cli.command;
243 assert_eq!(args.fail_on, vec![Category::MutableRefPin]);
244 }
245
246 #[test]
247 fn test_fail_on_rejects_unknown_category() {
248 let result = Cli::try_parse_from(["deps-cli", "check", "--fail-on", "not-a-category"]);
249 assert!(result.is_err());
250 }
251
252 #[test]
253 fn test_format_defaults_to_table() {
254 let cli = Cli::parse_from(["deps-cli", "check"]);
255 let Command::Check(args) = cli.command;
256 assert_eq!(args.format, OutputFormat::Table);
257 }
258
259 #[test]
260 fn test_format_json_parses() {
261 let cli = Cli::parse_from(["deps-cli", "check", "--format", "json"]);
262 let Command::Check(args) = cli.command;
263 assert_eq!(args.format, OutputFormat::Json);
264 }
265
266 #[test]
267 fn test_format_sarif_parses() {
268 let cli = Cli::parse_from(["deps-cli", "check", "--format", "sarif"]);
269 let Command::Check(args) = cli.command;
270 assert_eq!(args.format, OutputFormat::Sarif);
271 }
272
273 #[test]
274 fn test_parse_cooldown_bare_seconds() {
275 assert_eq!(parse_cooldown("42").unwrap(), 42);
276 }
277
278 #[test]
279 fn test_parse_cooldown_suffixed_units() {
280 assert_eq!(parse_cooldown("30m").unwrap(), 1_800);
281 assert_eq!(parse_cooldown("2h").unwrap(), 7_200);
282 assert_eq!(parse_cooldown("1d").unwrap(), 86_400);
283 }
284
285 #[test]
286 fn test_parse_cooldown_clamps_to_thirty_days() {
287 assert_eq!(parse_cooldown("999d").unwrap(), MAX_COOLDOWN_SECS);
288 }
289
290 #[test]
291 fn test_parse_cooldown_rejects_empty() {
292 assert!(parse_cooldown("").is_err());
293 }
294
295 #[test]
296 fn test_parse_cooldown_rejects_unknown_unit() {
297 assert!(parse_cooldown("5x").is_err());
298 }
299
300 #[test]
301 fn test_parse_cooldown_rejects_non_numeric() {
302 assert!(parse_cooldown("abc").is_err());
303 }
304}