Skip to main content

deps_cli/
cli.rs

1//! Command-line argument surface for the `deps-cli` binary.
2
3use crate::report::Category;
4use clap::{Parser, Subcommand, ValueEnum};
5use std::path::PathBuf;
6
7/// Upper bound on a `--cooldown` value, mirroring
8/// [`deps_core::policy_config::FreshnessConfig`]'s own 30-day clamp on `cooldown_secs`, so a
9/// CLI-supplied override can never exceed what a `deps.toml`-supplied one could.
10const MAX_COOLDOWN_SECS: u64 = 30 * 24 * 60 * 60;
11
12/// `deps-cli`: dependency-health checks for CI, pre-commit hooks, and shell workflows.
13///
14/// # Examples
15///
16/// ```
17/// use clap::Parser;
18/// use deps_cli::cli::{Cli, Command};
19///
20/// let cli = Cli::parse_from(["deps-cli", "check", "Cargo.toml"]);
21/// let Command::Check(args) = cli.command;
22/// assert_eq!(args.paths, vec![std::path::PathBuf::from("Cargo.toml")]);
23/// ```
24#[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    /// The subcommand to run.
32    #[command(subcommand)]
33    pub command: Command,
34}
35
36/// Top-level `deps-cli` subcommands. Only `check` exists in this release.
37#[derive(Debug, Subcommand)]
38pub enum Command {
39    /// Walk PATH(s), classify every discovered manifest's dependencies through the same
40    /// pipeline `deps-lsp` uses, and report findings.
41    Check(CheckArgs),
42}
43
44/// Output format for a `check` run.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
46pub enum OutputFormat {
47    /// Human-readable table, grouped by file then severity (the default).
48    Table,
49    /// Versioned JSON document (see `crate::format::json`).
50    Json,
51    /// SARIF 2.1.0 document (see `crate::format::sarif`), for GitHub code scanning and other
52    /// SARIF consumers.
53    Sarif,
54}
55
56/// Arguments for `deps-cli check`.
57#[derive(Debug, Parser)]
58pub struct CheckArgs {
59    /// Paths to walk. Defaults to the current directory when empty.
60    pub paths: Vec<PathBuf>,
61
62    /// Output format.
63    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
64    pub format: OutputFormat,
65
66    /// Comma-separated categories that make the run exit with code 1
67    /// (`outdated,yanked,vulnerable,unsatisfiable,mutable-ref,license,deprecated`).
68    /// Defaults to `vulnerable,yanked,unsatisfiable` when omitted (FR-010). A finding that
69    /// matches none of the seven categories (e.g. an unresolved/unknown package) is always
70    /// reported but can never fail a run through this flag.
71    #[arg(long, value_delimiter = ',')]
72    pub fail_on: Vec<Category>,
73
74    /// Serve only already-cached registry data; never make a new outbound request.
75    #[arg(long)]
76    pub offline: bool,
77
78    /// Overrides `freshness.cooldown_secs` for this run. Accepts a bare number of seconds
79    /// or a suffixed duration (`30m`, `12h`, `3d`).
80    #[arg(long, value_parser = parse_cooldown)]
81    pub cooldown: Option<u64>,
82
83    /// Path to a `deps.toml` config file. Defaults to `./deps.toml` when present, and to
84    /// built-in defaults otherwise.
85    #[arg(long)]
86    pub config: Option<PathBuf>,
87
88    /// Restore `.gitignore`/`.ignore` awareness during the walk (`git`'s own default
89    /// behavior). `check` does not respect either by default (issue #1109): as a CI
90    /// security gate (`git checkout && deps-cli check .` against an untrusted fork PR), both
91    /// files are attacker-controlled input, and a one-line addition to either would otherwise
92    /// silently remove a manifest from the scan with no warning and exit code 0. Pass this
93    /// flag only when scanning a target you trust as much as your own `deps.toml`.
94    #[arg(long)]
95    pub respect_gitignore: bool,
96
97    /// Follow symlinks during the directory walk and resolve a symlinked manifest's target for
98    /// scanning (issue #1112). A symlink whose resolved, canonicalized target falls outside the
99    /// walked root is never followed regardless of this flag — see [`crate::walk::walk`]'s doc.
100    /// When this flag is not passed (the default), a symlink to a manifest-shaped file is still
101    /// detected and reported via a warning (non-zero exit code), it is simply not resolved and
102    /// scanned.
103    #[arg(long)]
104    pub follow_symlinks: bool,
105}
106
107impl CheckArgs {
108    /// The paths to walk: [`Self::paths`] verbatim, or the current directory when empty
109    /// (FR-001).
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use clap::Parser;
115    /// use deps_cli::cli::{Cli, Command};
116    ///
117    /// let cli = Cli::parse_from(["deps-cli", "check"]);
118    /// let Command::Check(args) = cli.command;
119    /// assert_eq!(args.walk_paths(), vec![std::path::PathBuf::from(".")]);
120    /// ```
121    #[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
131/// Parses a `--cooldown` value into a clamped second count.
132///
133/// Accepts a bare integer (seconds) or an integer suffixed with `s`/`m`/`h`/`d`. Clamped to
134/// `MAX_COOLDOWN_SECS` so a CLI override can never exceed `deps.toml`'s own bound.
135///
136/// # Examples
137///
138/// ```
139/// use deps_cli::cli::parse_cooldown;
140///
141/// assert_eq!(parse_cooldown("3600").unwrap(), 3600);
142/// assert_eq!(parse_cooldown("1h").unwrap(), 3600);
143/// assert_eq!(parse_cooldown("3d").unwrap(), 3 * 24 * 60 * 60);
144/// assert!(parse_cooldown("nonsense").is_err());
145/// ```
146///
147/// # Errors
148///
149/// Returns a human-readable message when `input` is empty, has a non-numeric magnitude, or
150/// uses an unrecognized unit suffix.
151pub 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}