Skip to main content

pysentry/
cli.rs

1// SPDX-License-Identifier: MIT
2
3//! CLI interface definitions shared between binary and Python bindings
4
5use crate::logging::AppVerbosity;
6use crate::types::ResolverType;
7use anyhow::Result;
8use clap::{Parser, Subcommand, ValueEnum};
9
10#[derive(Debug, Clone, PartialEq, ValueEnum)]
11pub enum AuditFormat {
12    #[value(name = "human")]
13    Human,
14    #[value(name = "json")]
15    Json,
16    #[value(name = "sarif")]
17    Sarif,
18    #[value(name = "markdown")]
19    Markdown,
20}
21
22#[derive(Debug, Clone, PartialEq, ValueEnum)]
23pub enum SeverityLevel {
24    #[value(name = "low")]
25    Low,
26    #[value(name = "medium")]
27    Medium,
28    #[value(name = "high")]
29    High,
30    #[value(name = "critical")]
31    Critical,
32}
33
34#[derive(Debug, Clone, ValueEnum, PartialEq)]
35pub enum VulnerabilitySourceType {
36    #[value(name = "pypa")]
37    Pypa,
38    #[value(name = "pypi")]
39    Pypi,
40    #[value(name = "osv")]
41    Osv,
42}
43
44#[derive(Debug, Clone, PartialEq, ValueEnum)]
45pub enum ResolverTypeArg {
46    #[value(name = "uv")]
47    Uv,
48    #[value(name = "pip-tools")]
49    PipTools,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, ValueEnum, Default)]
53pub enum ColorChoice {
54    /// Auto-detect: use colors when stdout is a terminal and NO_COLOR is unset
55    #[default]
56    Auto,
57    /// Always emit ANSI color codes
58    Always,
59    /// Never emit ANSI color codes
60    Never,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, ValueEnum, Default)]
64pub enum DisplayModeArg {
65    /// Traditional text-based formatting (indented lines, manual spacing)
66    #[value(name = "text")]
67    Text,
68    /// Structured table rendering (default, compact mode only)
69    #[default]
70    #[value(name = "table")]
71    Table,
72}
73
74/// Resolve an `OutputStyles` instance from a `ColorChoice`.
75///
76/// `Always` → colorized (forces ANSI on); `Never` → plain (forces ANSI off);
77/// `Auto` delegates entirely to `supports-color`, which handles `NO_COLOR`
78/// (any value, including empty), `FORCE_COLOR`, `isatty`, CI environments,
79/// and `TERM=dumb` per the terminal standards specs.
80pub fn resolve_styles(color: ColorChoice) -> crate::output::OutputStyles {
81    match color {
82        ColorChoice::Always => {
83            owo_colors::set_override(true);
84            crate::output::OutputStyles::colorized()
85        }
86        ColorChoice::Never => {
87            owo_colors::set_override(false);
88            crate::output::OutputStyles::default()
89        }
90        ColorChoice::Auto => {
91            // supports-color handles NO_COLOR, FORCE_COLOR, isatty, CI, TERM=dumb
92            crate::output::OutputStyles::colorized()
93        }
94    }
95}
96
97#[derive(Parser)]
98#[command(
99    name = "pysentry",
100    about = "Security vulnerability auditing for Python packages",
101    version
102)]
103pub struct Cli {
104    #[command(subcommand)]
105    pub command: Option<Commands>,
106
107    /// Control color output
108    #[arg(long, value_enum, default_value = "auto", global = true)]
109    pub color: ColorChoice,
110
111    /// Audit arguments (used when no subcommand specified)
112    #[command(flatten)]
113    pub audit_args: AuditArgs,
114}
115
116#[derive(Debug, Subcommand)]
117pub enum Commands {
118    /// Check available dependency resolvers
119    Resolvers(ResolversArgs),
120    /// Check if a newer version is available
121    CheckVersion(CheckVersionArgs),
122    /// Configuration management
123    #[command(subcommand)]
124    Config(ConfigCommands),
125}
126
127#[derive(Debug, Subcommand)]
128pub enum ConfigCommands {
129    /// Initialize a new configuration file
130    Init(ConfigInitArgs),
131    /// Validate configuration file
132    Validate(ConfigValidateArgs),
133    /// Show effective configuration
134    Show(ConfigShowArgs),
135    /// Show configuration file path
136    Path(ConfigPathArgs),
137}
138
139#[derive(Debug, Clone, Parser)]
140pub struct AuditArgs {
141    /// Path to the project directory to audit
142    #[arg(value_name = "PATH", default_value = ".")]
143    pub path: std::path::PathBuf,
144
145    /// Output format
146    #[arg(long, value_enum, default_value = "human")]
147    pub format: AuditFormat,
148
149    /// Fail (exit non-zero) if vulnerabilities of this level or higher are found
150    #[arg(long, value_enum, default_value = "medium")]
151    pub fail_on: SeverityLevel,
152
153    /// Vulnerability IDs to ignore (can be specified multiple times)
154    #[arg(long = "ignore", value_name = "ID")]
155    pub ignore_ids: Vec<String>,
156
157    /// Vulnerability IDs to ignore only while no fix is available (can be specified multiple times)
158    #[arg(long = "ignore-while-no-fix", value_name = "ID")]
159    pub ignore_while_no_fix: Vec<String>,
160
161    /// Output file path (defaults to stdout)
162    #[arg(long, short, value_name = "FILE")]
163    pub output: Option<std::path::PathBuf>,
164
165    /// Exclude extra dependencies (dev, optional, etc - only include main dependencies)
166    #[arg(long)]
167    pub exclude_extra: bool,
168
169    /// Only check direct dependencies (exclude transitive)
170    #[arg(long)]
171    pub direct_only: bool,
172
173    /// Include withdrawn vulnerabilities in results
174    #[arg(long)]
175    pub include_withdrawn: bool,
176
177    /// Disable caching
178    #[arg(long)]
179    pub no_cache: bool,
180
181    /// Custom cache directory
182    #[arg(long, value_name = "DIR")]
183    pub cache_dir: Option<std::path::PathBuf>,
184
185    /// Resolution cache TTL in hours (default: 24)
186    #[arg(long, value_name = "HOURS", default_value = "24")]
187    pub resolution_cache_ttl: u64,
188
189    /// Disable resolution caching only
190    #[arg(long)]
191    pub no_resolution_cache: bool,
192
193    /// Clear resolution cache on startup
194    #[arg(long)]
195    pub clear_resolution_cache: bool,
196
197    /// Vulnerability data sources (can be specified multiple times or comma-separated)
198    #[arg(long = "sources", value_name = "SOURCE")]
199    pub sources: Vec<String>,
200
201    /// Override the OSV API base URL (custom/self-hosted OSV-compatible endpoint).
202    /// Only valid with `--sources osv`.
203    #[arg(long = "service-url", value_name = "URL")]
204    pub service_url: Option<String>,
205
206    /// Dependency resolver for requirements.txt files
207    #[arg(long, value_enum, default_value = "uv")]
208    pub resolver: ResolverTypeArg,
209
210    /// Specific requirements files to audit (disables auto-discovery)
211    #[arg(long = "requirements-files", value_name = "FILE", num_args = 1..)]
212    pub requirements_files: Vec<std::path::PathBuf>,
213
214    /// Verbosity level: use -v, -vv, -vvv for more output, -q for quiet
215    #[command(flatten)]
216    pub verbosity: AppVerbosity,
217
218    /// Set to true when `output.quiet = true` is read from config (not a CLI arg).
219    #[arg(skip)]
220    pub config_quiet: bool,
221
222    /// Package names to suppress entirely, from `[ignore].packages` (config-only).
223    #[arg(skip)]
224    pub ignore_packages: Vec<String>,
225
226    /// Per-group fail thresholds from `[groups.<name>]` (config-only), keyed by the
227    /// PEP 735-normalized group name so lookups match graph attribution.
228    #[arg(skip)]
229    pub group_fail_on: std::collections::BTreeMap<String, SeverityLevel>,
230
231    /// Continue with the sources that succeeded instead of failing when a
232    /// vulnerability source cannot be fetched (default: fail-closed on any error).
233    #[arg(long = "no-fail-on-partial")]
234    pub no_fail_on_partial: bool,
235
236    /// Show detailed vulnerability descriptions (full text instead of truncated)
237    #[arg(long, conflicts_with = "compact")]
238    pub detailed: bool,
239
240    /// Compact output: summary + one-liner per vulnerability, no descriptions
241    #[arg(long, conflicts_with = "detailed")]
242    pub compact: bool,
243
244    /// Display mode for human output. Only affects compact mode (`--compact`).
245    #[arg(long, value_enum)]
246    pub display: Option<DisplayModeArg>,
247
248    /// Custom configuration file path
249    #[arg(long, value_name = "FILE")]
250    pub config: Option<std::path::PathBuf>,
251
252    /// Disable configuration file loading
253    #[arg(long)]
254    pub no_config: bool,
255
256    // PEP 792 Project Status Markers options
257    /// Disable PEP 792 project status checks
258    #[arg(long)]
259    pub no_maintenance_check: bool,
260
261    /// Fail on archived packages (not receiving updates)
262    #[arg(long)]
263    pub forbid_archived: bool,
264
265    /// Fail on deprecated packages (obsolete)
266    #[arg(long)]
267    pub forbid_deprecated: bool,
268
269    /// Fail on quarantined packages (malware/compromised)
270    #[arg(long)]
271    pub forbid_quarantined: bool,
272
273    /// Fail on any unmaintained packages (enables --forbid-archived, --forbid-deprecated, --forbid-quarantined)
274    #[arg(long)]
275    pub forbid_unmaintained: bool,
276
277    /// Only check direct dependencies for maintenance status (skip transitive)
278    #[arg(long)]
279    pub maintenance_direct_only: bool,
280
281    /// Maintenance status cache TTL in hours (default: 1)
282    #[arg(long, value_name = "HOURS", default_value = "1")]
283    pub maintenance_cache_ttl: u64,
284
285    /// Don't fail on vulnerabilities with unknown level
286    #[arg(long)]
287    pub no_fail_on_unknown: bool,
288
289    /// Disable automatic CI environment detection
290    #[arg(long)]
291    pub no_ci_detect: bool,
292
293    /// Skip dependency resolution; audit pinned packages (package==version) as-is.
294    /// Unpinned packages are skipped. Implies --direct-only.
295    #[arg(long)]
296    pub no_resolver: bool,
297
298    /// Include only the named dependency group(s). Repeatable. Conflicts with --exclude-extra.
299    #[arg(
300        long = "group",
301        value_name = "NAME",
302        action = clap::ArgAction::Append,
303        value_delimiter = ',',
304        conflicts_with = "exclude_extra"
305    )]
306    pub groups: Vec<String>,
307
308    /// Also scan PEP 723 Python scripts found under the project directory
309    #[arg(long)]
310    pub include_scripts: bool,
311}
312
313impl AuditArgs {
314    /// Resolve the effective detail level from --compact / --detailed flags.
315    /// Compact is the default; `--detailed` is the only opt-in (clap keeps them mutually
316    /// exclusive on the CLI, and `detail_level` resolves detailed-wins if both survive a
317    /// config merge).
318    pub fn detail_level(&self) -> crate::DetailLevel {
319        if self.detailed {
320            crate::DetailLevel::Detailed
321        } else {
322            crate::DetailLevel::Compact
323        }
324    }
325
326    /// Resolve the effective display mode from --display flag.
327    pub fn display_mode(&self) -> crate::DisplayMode {
328        self.display.unwrap_or(DisplayModeArg::Table).into()
329    }
330
331    /// Check if quiet mode is enabled (either via -q flag or config).
332    pub fn is_quiet(&self) -> bool {
333        self.config_quiet || crate::logging::is_quiet(&self.verbosity)
334    }
335
336    /// Check if verbose mode is enabled (via -v flags or config).
337    pub fn is_verbose(&self) -> bool {
338        crate::logging::is_verbose(&self.verbosity)
339    }
340
341    fn include_all_dependencies(&self) -> bool {
342        !self.exclude_extra
343    }
344
345    pub fn include_dev(&self) -> bool {
346        self.include_all_dependencies()
347    }
348
349    pub fn include_optional(&self) -> bool {
350        self.include_all_dependencies()
351    }
352
353    /// Check if maintenance checks are enabled
354    pub fn maintenance_enabled(&self) -> bool {
355        !self.no_maintenance_check
356    }
357
358    /// Create a MaintenanceCheckConfig from CLI args
359    pub fn maintenance_check_config(&self) -> crate::MaintenanceCheckConfig {
360        crate::MaintenanceCheckConfig {
361            forbid_archived: self.forbid_archived || self.forbid_unmaintained,
362            forbid_deprecated: self.forbid_deprecated || self.forbid_unmaintained,
363            forbid_quarantined: self.forbid_quarantined || self.forbid_unmaintained,
364            check_direct_only: self.maintenance_direct_only,
365        }
366    }
367
368    pub fn ci_environment(&self) -> crate::ci::CiEnvironment {
369        if self.no_ci_detect {
370            crate::ci::CiEnvironment::None
371        } else {
372            crate::ci::detect()
373        }
374    }
375
376    pub fn scope_description(&self) -> String {
377        if !self.groups.is_empty() {
378            format!("main + groups [{}]", self.groups.join(", "))
379        } else if self.include_all_dependencies() {
380            "all (main + dev,optional,prod,etc)".to_string()
381        } else {
382            "main only (extras excluded)".to_string()
383        }
384    }
385
386    pub fn resolve_sources(&self) -> Result<Vec<VulnerabilitySourceType>, String> {
387        if self.sources.is_empty() {
388            return Ok(vec![
389                VulnerabilitySourceType::Pypa,
390                VulnerabilitySourceType::Pypi,
391                VulnerabilitySourceType::Osv,
392            ]);
393        }
394
395        let mut resolved_sources = Vec::new();
396        for source_arg in &self.sources {
397            for source_str in source_arg.split(',') {
398                let source_str = source_str.trim();
399                if source_str.is_empty() {
400                    continue;
401                }
402                let source_type = match source_str {
403                    "pypa" => VulnerabilitySourceType::Pypa,
404                    "pypi" => VulnerabilitySourceType::Pypi,
405                    "osv" => VulnerabilitySourceType::Osv,
406                    _ => {
407                        return Err(format!(
408                            "Invalid vulnerability source: '{source_str}'. Valid sources: pypa, pypi, osv"
409                        ))
410                    }
411                };
412                if !resolved_sources.contains(&source_type) {
413                    resolved_sources.push(source_type);
414                }
415            }
416        }
417
418        Ok(resolved_sources)
419    }
420}
421
422#[derive(Debug, Parser)]
423pub struct ResolversArgs {
424    /// Verbosity level: use -v, -vv, -vvv for more output, -q for quiet
425    #[command(flatten)]
426    pub verbosity: AppVerbosity,
427}
428
429impl ResolversArgs {
430    pub fn is_verbose(&self) -> bool {
431        crate::logging::is_verbose(&self.verbosity)
432    }
433
434    pub fn is_quiet(&self) -> bool {
435        crate::logging::is_quiet(&self.verbosity)
436    }
437}
438
439#[derive(Debug, Parser)]
440pub struct CheckVersionArgs {
441    /// Verbosity level: use -v, -vv, -vvv for more output, -q for quiet
442    #[command(flatten)]
443    pub verbosity: AppVerbosity,
444}
445
446impl CheckVersionArgs {
447    pub fn is_verbose(&self) -> bool {
448        crate::logging::is_verbose(&self.verbosity)
449    }
450
451    pub fn is_quiet(&self) -> bool {
452        crate::logging::is_quiet(&self.verbosity)
453    }
454}
455
456#[derive(Debug, Parser)]
457pub struct ConfigInitArgs {
458    #[arg(long, short, value_name = "FILE")]
459    pub output: Option<std::path::PathBuf>,
460
461    #[arg(long)]
462    pub force: bool,
463
464    #[arg(long)]
465    pub minimal: bool,
466
467    /// Verbosity level: use -v, -vv, -vvv for more output, -q for quiet
468    #[command(flatten)]
469    pub verbosity: AppVerbosity,
470}
471
472impl ConfigInitArgs {
473    pub fn is_verbose(&self) -> bool {
474        crate::logging::is_verbose(&self.verbosity)
475    }
476
477    pub fn is_quiet(&self) -> bool {
478        crate::logging::is_quiet(&self.verbosity)
479    }
480}
481
482#[derive(Debug, Parser)]
483pub struct ConfigValidateArgs {
484    #[arg(value_name = "FILE")]
485    pub config: Option<std::path::PathBuf>,
486
487    /// Verbosity level: use -v, -vv, -vvv for more output, -q for quiet
488    #[command(flatten)]
489    pub verbosity: AppVerbosity,
490}
491
492impl ConfigValidateArgs {
493    pub fn is_verbose(&self) -> bool {
494        crate::logging::is_verbose(&self.verbosity)
495    }
496
497    pub fn is_quiet(&self) -> bool {
498        crate::logging::is_quiet(&self.verbosity)
499    }
500}
501
502#[derive(Debug, Parser)]
503pub struct ConfigShowArgs {
504    #[arg(long, value_name = "FILE")]
505    pub config: Option<std::path::PathBuf>,
506
507    #[arg(long)]
508    pub toml: bool,
509
510    /// Verbosity level: use -v, -vv, -vvv for more output, -q for quiet
511    #[command(flatten)]
512    pub verbosity: AppVerbosity,
513}
514
515impl ConfigShowArgs {
516    pub fn is_verbose(&self) -> bool {
517        crate::logging::is_verbose(&self.verbosity)
518    }
519
520    pub fn is_quiet(&self) -> bool {
521        crate::logging::is_quiet(&self.verbosity)
522    }
523}
524
525#[derive(Debug, Parser)]
526pub struct ConfigPathArgs {
527    /// Verbosity level: use -v, -vv, -vvv for more output, -q for quiet
528    #[command(flatten)]
529    pub verbosity: AppVerbosity,
530}
531
532impl ConfigPathArgs {
533    pub fn is_verbose(&self) -> bool {
534        crate::logging::is_verbose(&self.verbosity)
535    }
536}
537
538impl From<AuditFormat> for crate::AuditFormat {
539    fn from(format: AuditFormat) -> Self {
540        match format {
541            AuditFormat::Human => crate::AuditFormat::Human,
542            AuditFormat::Json => crate::AuditFormat::Json,
543            AuditFormat::Sarif => crate::AuditFormat::Sarif,
544            AuditFormat::Markdown => crate::AuditFormat::Markdown,
545        }
546    }
547}
548
549impl From<SeverityLevel> for crate::SeverityLevel {
550    fn from(level: SeverityLevel) -> Self {
551        match level {
552            SeverityLevel::Low => crate::SeverityLevel::Low,
553            SeverityLevel::Medium => crate::SeverityLevel::Medium,
554            SeverityLevel::High => crate::SeverityLevel::High,
555            SeverityLevel::Critical => crate::SeverityLevel::Critical,
556        }
557    }
558}
559
560impl std::str::FromStr for SeverityLevel {
561    type Err = String;
562
563    /// Parses the canonical level strings (the same set `Config::validate_level`
564    /// accepts). Single source of truth for level parsing — do not hand-roll the
565    /// string match elsewhere.
566    fn from_str(s: &str) -> Result<Self, Self::Err> {
567        match s {
568            "low" => Ok(Self::Low),
569            "medium" => Ok(Self::Medium),
570            "high" => Ok(Self::High),
571            "critical" => Ok(Self::Critical),
572            other => Err(format!(
573                "invalid severity level '{other}' (expected low, medium, high, or critical)"
574            )),
575        }
576    }
577}
578
579impl From<VulnerabilitySourceType> for crate::VulnerabilitySourceType {
580    fn from(source: VulnerabilitySourceType) -> Self {
581        match source {
582            VulnerabilitySourceType::Pypa => crate::VulnerabilitySourceType::Pypa,
583            VulnerabilitySourceType::Pypi => crate::VulnerabilitySourceType::Pypi,
584            VulnerabilitySourceType::Osv => crate::VulnerabilitySourceType::Osv,
585        }
586    }
587}
588
589impl From<ResolverTypeArg> for ResolverType {
590    fn from(resolver: ResolverTypeArg) -> Self {
591        match resolver {
592            ResolverTypeArg::Uv => ResolverType::Uv,
593            ResolverTypeArg::PipTools => ResolverType::PipTools,
594        }
595    }
596}
597
598impl From<DisplayModeArg> for crate::DisplayMode {
599    fn from(mode: DisplayModeArg) -> Self {
600        match mode {
601            DisplayModeArg::Text => crate::DisplayMode::Text,
602            DisplayModeArg::Table => crate::DisplayMode::Table,
603        }
604    }
605}
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use crate::DetailLevel;
610
611    fn parse_audit_args(args: &[&str]) -> AuditArgs {
612        let cli = Cli::try_parse_from(std::iter::once("pysentry").chain(args.iter().copied()))
613            .expect("valid CLI args");
614        cli.audit_args
615    }
616
617    #[test]
618    fn test_detail_level_defaults_to_compact() {
619        let args = parse_audit_args(&["."]);
620        assert_eq!(args.detail_level(), DetailLevel::Compact);
621    }
622
623    #[test]
624    fn test_detail_level_compact() {
625        let args = parse_audit_args(&["--compact", "."]);
626        assert_eq!(args.detail_level(), DetailLevel::Compact);
627    }
628
629    #[test]
630    fn test_detail_level_detailed() {
631        let args = parse_audit_args(&["--detailed", "."]);
632        assert_eq!(args.detail_level(), DetailLevel::Detailed);
633    }
634
635    #[test]
636    fn test_display_defaults_to_table() {
637        let args = parse_audit_args(&["."]);
638        assert_eq!(args.display, None);
639    }
640
641    #[test]
642    fn test_display_text_flag() {
643        let args = parse_audit_args(&["--display", "text", "."]);
644        assert_eq!(args.display, Some(DisplayModeArg::Text));
645    }
646
647    #[test]
648    fn test_no_resolver_flag_parsed() {
649        let args = parse_audit_args(&["--no-resolver", "."]);
650        assert!(args.no_resolver);
651    }
652
653    #[test]
654    fn test_no_resolver_default_is_false() {
655        let args = parse_audit_args(&["."]);
656        assert!(!args.no_resolver);
657    }
658
659    #[test]
660    fn test_no_resolver_without_requirements_files_has_empty_requirements_files() {
661        let args = parse_audit_args(&["--no-resolver", "."]);
662        // Standalone --no-resolver must NOT auto-populate requirements_files at arg-parse time.
663        // The downstream conditions `|| audit_args.no_resolver` exist precisely because
664        // requirements_files is empty in this case.
665        assert!(args.requirements_files.is_empty());
666        assert!(args.no_resolver);
667    }
668
669    #[test]
670    fn test_no_resolver_with_requirements_files() {
671        let args = parse_audit_args(&["--no-resolver", "--requirements-files", "req.txt", "."]);
672        assert!(!args.requirements_files.is_empty());
673        assert!(args.no_resolver);
674    }
675
676    #[test]
677    fn test_group_flag_empty() {
678        let args = parse_audit_args(&["."]);
679        assert!(args.groups.is_empty());
680    }
681
682    #[test]
683    fn test_group_flag_single() {
684        let args = parse_audit_args(&["--group", "polars", "."]);
685        assert_eq!(args.groups, vec!["polars"]);
686    }
687
688    #[test]
689    fn test_group_flag_repeat() {
690        let args = parse_audit_args(&["--group", "polars", "--group", "extras", "."]);
691        assert_eq!(args.groups, vec!["polars", "extras"]);
692    }
693
694    #[test]
695    fn test_group_flag_comma_separated() {
696        let args = parse_audit_args(&["--group", "polars,extras", "."]);
697        assert_eq!(args.groups, vec!["polars", "extras"]);
698    }
699
700    #[test]
701    fn test_group_conflicts_with_exclude_extra() {
702        let result = Cli::try_parse_from(["pysentry", "--group", "polars", "--exclude-extra", "."]);
703        assert!(result.is_err());
704    }
705
706    #[test]
707    fn test_include_scripts_flag_parsed() {
708        let args = parse_audit_args(&["--include-scripts", "."]);
709        assert!(args.include_scripts);
710    }
711}