1use 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 #[default]
56 Auto,
57 Always,
59 Never,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, ValueEnum, Default)]
64pub enum DisplayModeArg {
65 #[value(name = "text")]
67 Text,
68 #[default]
70 #[value(name = "table")]
71 Table,
72}
73
74pub 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 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 #[arg(long, value_enum, default_value = "auto", global = true)]
109 pub color: ColorChoice,
110
111 #[command(flatten)]
113 pub audit_args: AuditArgs,
114}
115
116#[derive(Debug, Subcommand)]
117pub enum Commands {
118 Resolvers(ResolversArgs),
120 CheckVersion(CheckVersionArgs),
122 #[command(subcommand)]
124 Config(ConfigCommands),
125}
126
127#[derive(Debug, Subcommand)]
128pub enum ConfigCommands {
129 Init(ConfigInitArgs),
131 Validate(ConfigValidateArgs),
133 Show(ConfigShowArgs),
135 Path(ConfigPathArgs),
137}
138
139#[derive(Debug, Clone, Parser)]
140pub struct AuditArgs {
141 #[arg(value_name = "PATH", default_value = ".")]
143 pub path: std::path::PathBuf,
144
145 #[arg(long, value_enum, default_value = "human")]
147 pub format: AuditFormat,
148
149 #[arg(long, value_enum, default_value = "medium")]
151 pub fail_on: SeverityLevel,
152
153 #[arg(long = "ignore", value_name = "ID")]
155 pub ignore_ids: Vec<String>,
156
157 #[arg(long = "ignore-while-no-fix", value_name = "ID")]
159 pub ignore_while_no_fix: Vec<String>,
160
161 #[arg(long, short, value_name = "FILE")]
163 pub output: Option<std::path::PathBuf>,
164
165 #[arg(long)]
167 pub exclude_extra: bool,
168
169 #[arg(long)]
171 pub direct_only: bool,
172
173 #[arg(long)]
175 pub include_withdrawn: bool,
176
177 #[arg(long)]
179 pub no_cache: bool,
180
181 #[arg(long, value_name = "DIR")]
183 pub cache_dir: Option<std::path::PathBuf>,
184
185 #[arg(long, value_name = "HOURS", default_value = "24")]
187 pub resolution_cache_ttl: u64,
188
189 #[arg(long)]
191 pub no_resolution_cache: bool,
192
193 #[arg(long)]
195 pub clear_resolution_cache: bool,
196
197 #[arg(long = "sources", value_name = "SOURCE")]
199 pub sources: Vec<String>,
200
201 #[arg(long = "service-url", value_name = "URL")]
204 pub service_url: Option<String>,
205
206 #[arg(long, value_enum, default_value = "uv")]
208 pub resolver: ResolverTypeArg,
209
210 #[arg(long = "requirements-files", value_name = "FILE", num_args = 1..)]
212 pub requirements_files: Vec<std::path::PathBuf>,
213
214 #[command(flatten)]
216 pub verbosity: AppVerbosity,
217
218 #[arg(skip)]
220 pub config_quiet: bool,
221
222 #[arg(skip)]
224 pub ignore_packages: Vec<String>,
225
226 #[arg(skip)]
229 pub group_fail_on: std::collections::BTreeMap<String, SeverityLevel>,
230
231 #[arg(long = "no-fail-on-partial")]
234 pub no_fail_on_partial: bool,
235
236 #[arg(long, conflicts_with = "compact")]
238 pub detailed: bool,
239
240 #[arg(long, conflicts_with = "detailed")]
242 pub compact: bool,
243
244 #[arg(long, value_enum)]
246 pub display: Option<DisplayModeArg>,
247
248 #[arg(long, value_name = "FILE")]
250 pub config: Option<std::path::PathBuf>,
251
252 #[arg(long)]
254 pub no_config: bool,
255
256 #[arg(long)]
259 pub no_maintenance_check: bool,
260
261 #[arg(long)]
263 pub forbid_archived: bool,
264
265 #[arg(long)]
267 pub forbid_deprecated: bool,
268
269 #[arg(long)]
271 pub forbid_quarantined: bool,
272
273 #[arg(long)]
275 pub forbid_unmaintained: bool,
276
277 #[arg(long)]
279 pub maintenance_direct_only: bool,
280
281 #[arg(long, value_name = "HOURS", default_value = "1")]
283 pub maintenance_cache_ttl: u64,
284
285 #[arg(long)]
287 pub no_fail_on_unknown: bool,
288
289 #[arg(long)]
291 pub no_ci_detect: bool,
292
293 #[arg(long)]
296 pub no_resolver: bool,
297
298 #[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 #[arg(long)]
310 pub include_scripts: bool,
311}
312
313impl AuditArgs {
314 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 pub fn display_mode(&self) -> crate::DisplayMode {
328 self.display.unwrap_or(DisplayModeArg::Table).into()
329 }
330
331 pub fn is_quiet(&self) -> bool {
333 self.config_quiet || crate::logging::is_quiet(&self.verbosity)
334 }
335
336 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 pub fn maintenance_enabled(&self) -> bool {
355 !self.no_maintenance_check
356 }
357
358 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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}