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;
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}