Skip to main content

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}