Skip to main content

provenant/
cli.rs

1use clap::{ArgGroup, Parser};
2
3use crate::output::OutputFormat;
4
5#[derive(Parser, Debug)]
6#[command(
7    author,
8    version,
9    about,
10    long_about = None,
11    group(
12        ArgGroup::new("output")
13            .required(true)
14            .args([
15                "output_json",
16                "output_json_pp",
17                "output_json_lines",
18                "output_yaml",
19                "output_csv",
20                "output_html",
21                "output_html_app",
22                "output_spdx_tv",
23                "output_spdx_rdf",
24                "output_cyclonedx",
25                "output_cyclonedx_xml",
26                "custom_output"
27            ])
28    )
29)]
30pub struct Cli {
31    /// Directory path to scan
32    #[arg(required = true)]
33    pub dir_path: Vec<String>,
34
35    /// Write scan output as compact JSON to FILE
36    #[arg(long = "json", value_name = "FILE", allow_hyphen_values = true)]
37    pub output_json: Option<String>,
38
39    /// Write scan output as pretty-printed JSON to FILE
40    #[arg(long = "json-pp", value_name = "FILE", allow_hyphen_values = true)]
41    pub output_json_pp: Option<String>,
42
43    /// Write scan output as JSON Lines to FILE
44    #[arg(long = "json-lines", value_name = "FILE", allow_hyphen_values = true)]
45    pub output_json_lines: Option<String>,
46
47    /// Write scan output as YAML to FILE
48    #[arg(long = "yaml", value_name = "FILE", allow_hyphen_values = true)]
49    pub output_yaml: Option<String>,
50
51    /// [DEPRECATED in Python] Write scan output as CSV to FILE
52    #[arg(long = "csv", value_name = "FILE", allow_hyphen_values = true)]
53    pub output_csv: Option<String>,
54
55    /// Write scan output as HTML report to FILE
56    #[arg(long = "html", value_name = "FILE", allow_hyphen_values = true)]
57    pub output_html: Option<String>,
58
59    /// [DEPRECATED in Python] Write scan output as HTML app to FILE
60    #[arg(
61        long = "html-app",
62        value_name = "FILE",
63        hide = true,
64        allow_hyphen_values = true
65    )]
66    pub output_html_app: Option<String>,
67
68    /// Write scan output as SPDX tag/value to FILE
69    #[arg(long = "spdx-tv", value_name = "FILE", allow_hyphen_values = true)]
70    pub output_spdx_tv: Option<String>,
71
72    /// Write scan output as SPDX RDF/XML to FILE
73    #[arg(long = "spdx-rdf", value_name = "FILE", allow_hyphen_values = true)]
74    pub output_spdx_rdf: Option<String>,
75
76    /// Write scan output as CycloneDX JSON to FILE
77    #[arg(long = "cyclonedx", value_name = "FILE", allow_hyphen_values = true)]
78    pub output_cyclonedx: Option<String>,
79
80    /// Write scan output as CycloneDX XML to FILE
81    #[arg(
82        long = "cyclonedx-xml",
83        value_name = "FILE",
84        allow_hyphen_values = true
85    )]
86    pub output_cyclonedx_xml: Option<String>,
87
88    /// Write scan output to FILE formatted with the custom template
89    #[arg(
90        long = "custom-output",
91        value_name = "FILE",
92        requires = "custom_template",
93        allow_hyphen_values = true
94    )]
95    pub custom_output: Option<String>,
96
97    /// Use this template FILE with --custom-output
98    #[arg(
99        long = "custom-template",
100        value_name = "FILE",
101        requires = "custom_output"
102    )]
103    pub custom_template: Option<String>,
104
105    /// Maximum recursion depth (0 means no depth limit)
106    #[arg(short, long, default_value = "0")]
107    pub max_depth: usize,
108
109    #[arg(short = 'n', long, default_value_t = default_processes(), allow_hyphen_values = true)]
110    pub processes: i32,
111
112    #[arg(long, default_value_t = 120.0)]
113    pub timeout: f64,
114
115    #[arg(short, long, conflicts_with = "verbose")]
116    pub quiet: bool,
117
118    #[arg(short, long, conflicts_with = "quiet")]
119    pub verbose: bool,
120
121    #[arg(long, conflicts_with = "full_root")]
122    pub strip_root: bool,
123
124    #[arg(long, conflicts_with = "strip_root")]
125    pub full_root: bool,
126
127    /// Exclude patterns (ScanCode-compatible alias: --ignore)
128    #[arg(long = "exclude", visible_alias = "ignore", value_delimiter = ',')]
129    pub exclude: Vec<String>,
130
131    #[arg(long, value_delimiter = ',')]
132    pub include: Vec<String>,
133
134    #[arg(long = "cache-dir", value_name = "PATH")]
135    pub cache_dir: Option<String>,
136
137    #[arg(long = "cache-clear")]
138    pub cache_clear: bool,
139
140    #[arg(long = "max-in-memory", value_name = "INT")]
141    pub max_in_memory: Option<usize>,
142
143    #[arg(long)]
144    pub from_json: bool,
145
146    /// Disable package assembly (merging related manifest/lockfiles into packages)
147    #[arg(long)]
148    pub no_assemble: bool,
149
150    #[arg(long)]
151    pub filter_clues: bool,
152
153    #[arg(long)]
154    pub only_findings: bool,
155
156    #[arg(long)]
157    pub mark_source: bool,
158
159    #[arg(short = 'c', long)]
160    pub copyright: bool,
161
162    /// Scan input for email addresses
163    #[arg(short = 'e', long)]
164    pub email: bool,
165
166    /// Report only up to INT emails found in a file. Use 0 for no limit.
167    #[arg(long, default_value_t = 50, requires = "email")]
168    pub max_email: usize,
169
170    /// Scan input for URLs
171    #[arg(short = 'u', long)]
172    pub url: bool,
173
174    /// Report only up to INT URLs found in a file. Use 0 for no limit.
175    #[arg(long, default_value_t = 50, requires = "url")]
176    pub max_url: usize,
177}
178
179fn default_processes() -> i32 {
180    let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
181    if cpus > 1 { (cpus - 1) as i32 } else { 1 }
182}
183
184#[derive(Debug, Clone)]
185pub struct OutputTarget {
186    pub format: OutputFormat,
187    pub file: String,
188    pub custom_template: Option<String>,
189}
190
191impl Cli {
192    pub fn output_targets(&self) -> Vec<OutputTarget> {
193        let mut targets = Vec::new();
194
195        if let Some(file) = &self.output_json {
196            targets.push(OutputTarget {
197                format: OutputFormat::Json,
198                file: file.clone(),
199                custom_template: None,
200            });
201        }
202
203        if let Some(file) = &self.output_json_pp {
204            targets.push(OutputTarget {
205                format: OutputFormat::JsonPretty,
206                file: file.clone(),
207                custom_template: None,
208            });
209        }
210
211        if let Some(file) = &self.output_json_lines {
212            targets.push(OutputTarget {
213                format: OutputFormat::JsonLines,
214                file: file.clone(),
215                custom_template: None,
216            });
217        }
218
219        if let Some(file) = &self.output_yaml {
220            targets.push(OutputTarget {
221                format: OutputFormat::Yaml,
222                file: file.clone(),
223                custom_template: None,
224            });
225        }
226
227        if let Some(file) = &self.output_csv {
228            targets.push(OutputTarget {
229                format: OutputFormat::Csv,
230                file: file.clone(),
231                custom_template: None,
232            });
233        }
234
235        if let Some(file) = &self.output_html {
236            targets.push(OutputTarget {
237                format: OutputFormat::Html,
238                file: file.clone(),
239                custom_template: None,
240            });
241        }
242
243        if let Some(file) = &self.output_html_app {
244            targets.push(OutputTarget {
245                format: OutputFormat::HtmlApp,
246                file: file.clone(),
247                custom_template: None,
248            });
249        }
250
251        if let Some(file) = &self.output_spdx_tv {
252            targets.push(OutputTarget {
253                format: OutputFormat::SpdxTv,
254                file: file.clone(),
255                custom_template: None,
256            });
257        }
258
259        if let Some(file) = &self.output_spdx_rdf {
260            targets.push(OutputTarget {
261                format: OutputFormat::SpdxRdf,
262                file: file.clone(),
263                custom_template: None,
264            });
265        }
266
267        if let Some(file) = &self.output_cyclonedx {
268            targets.push(OutputTarget {
269                format: OutputFormat::CycloneDxJson,
270                file: file.clone(),
271                custom_template: None,
272            });
273        }
274
275        if let Some(file) = &self.output_cyclonedx_xml {
276            targets.push(OutputTarget {
277                format: OutputFormat::CycloneDxXml,
278                file: file.clone(),
279                custom_template: None,
280            });
281        }
282
283        if let Some(file) = &self.custom_output {
284            targets.push(OutputTarget {
285                format: OutputFormat::CustomTemplate,
286                file: file.clone(),
287                custom_template: self.custom_template.clone(),
288            });
289        }
290
291        targets
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_requires_at_least_one_output_option() {
301        let parsed = Cli::try_parse_from(["provenant", "samples"]);
302        assert!(parsed.is_err());
303    }
304
305    #[test]
306    fn test_parses_json_pretty_output_option() {
307        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
308            .expect("cli parse should succeed");
309
310        assert_eq!(parsed.output_json_pp.as_deref(), Some("scan.json"));
311        assert_eq!(parsed.output_targets().len(), 1);
312        assert_eq!(parsed.output_targets()[0].format, OutputFormat::JsonPretty);
313    }
314
315    #[test]
316    fn test_allows_stdout_dash_as_output_target() {
317        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "-", "samples"])
318            .expect("cli parse should allow stdout dash output target");
319
320        assert_eq!(parsed.output_json_pp.as_deref(), Some("-"));
321    }
322
323    #[test]
324    fn test_custom_template_and_output_must_be_paired() {
325        let missing_template =
326            Cli::try_parse_from(["provenant", "--custom-output", "result.txt", "samples"]);
327        assert!(missing_template.is_err());
328
329        let missing_output =
330            Cli::try_parse_from(["provenant", "--custom-template", "tpl.tera", "samples"]);
331        assert!(missing_output.is_err());
332    }
333
334    #[test]
335    fn test_parses_processes_and_timeout_options() {
336        let parsed = Cli::try_parse_from([
337            "provenant",
338            "--json-pp",
339            "scan.json",
340            "-n",
341            "4",
342            "--timeout",
343            "30",
344            "samples",
345        ])
346        .expect("cli parse should succeed");
347
348        assert_eq!(parsed.processes, 4);
349        assert_eq!(parsed.timeout, 30.0);
350    }
351
352    #[test]
353    fn test_strip_root_conflicts_with_full_root() {
354        let parsed = Cli::try_parse_from([
355            "provenant",
356            "--json-pp",
357            "scan.json",
358            "--strip-root",
359            "--full-root",
360            "samples",
361        ]);
362        assert!(parsed.is_err());
363    }
364
365    #[test]
366    fn test_parses_include_and_only_findings_and_filter_clues() {
367        let parsed = Cli::try_parse_from([
368            "provenant",
369            "--json-pp",
370            "scan.json",
371            "--include",
372            "src/**,Cargo.toml",
373            "--only-findings",
374            "--filter-clues",
375            "samples",
376        ])
377        .expect("cli parse should succeed");
378
379        assert_eq!(parsed.include, vec!["src/**", "Cargo.toml"]);
380        assert!(parsed.only_findings);
381        assert!(parsed.filter_clues);
382    }
383
384    #[test]
385    fn test_parses_ignore_alias_for_exclude_patterns() {
386        let parsed = Cli::try_parse_from([
387            "provenant",
388            "--json-pp",
389            "scan.json",
390            "--ignore",
391            "*.git*,target/*",
392            "samples",
393        ])
394        .expect("cli parse should accept --ignore alias");
395
396        assert_eq!(parsed.exclude, vec!["*.git*", "target/*"]);
397    }
398
399    #[test]
400    fn test_quiet_conflicts_with_verbose() {
401        let parsed = Cli::try_parse_from([
402            "provenant",
403            "--json-pp",
404            "scan.json",
405            "--quiet",
406            "--verbose",
407            "samples",
408        ]);
409        assert!(parsed.is_err());
410    }
411
412    #[test]
413    fn test_parses_from_json_and_mark_source() {
414        let parsed = Cli::try_parse_from([
415            "provenant",
416            "--json-pp",
417            "scan.json",
418            "--from-json",
419            "--mark-source",
420            "sample-scan.json",
421        ])
422        .expect("cli parse should succeed");
423
424        assert!(parsed.from_json);
425        assert_eq!(parsed.dir_path, vec!["sample-scan.json"]);
426        assert!(parsed.mark_source);
427    }
428
429    #[test]
430    fn test_parses_copyright_flag() {
431        let parsed = Cli::try_parse_from([
432            "provenant",
433            "--json-pp",
434            "scan.json",
435            "--copyright",
436            "samples",
437        ])
438        .expect("cli parse should succeed");
439
440        assert!(parsed.copyright);
441    }
442
443    #[test]
444    fn test_parses_short_scan_flags() {
445        let parsed = Cli::try_parse_from([
446            "provenant",
447            "--json-pp",
448            "scan.json",
449            "-c",
450            "-e",
451            "-u",
452            "samples",
453        ])
454        .expect("cli parse should support short scan flags");
455
456        assert!(parsed.copyright);
457        assert!(parsed.email);
458        assert!(parsed.url);
459    }
460
461    #[test]
462    fn test_parses_processes_compat_values_zero_and_minus_one() {
463        let zero =
464            Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "0", "samples"])
465                .expect("cli parse should accept processes=0");
466        assert_eq!(zero.processes, 0);
467
468        let parsed =
469            Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "-1", "samples"])
470                .expect("cli parse should accept processes=-1");
471        assert_eq!(parsed.processes, -1);
472    }
473
474    #[test]
475    fn test_parses_cache_flags() {
476        let parsed = Cli::try_parse_from([
477            "provenant",
478            "--json-pp",
479            "scan.json",
480            "--cache-dir",
481            "/tmp/sc-cache",
482            "--cache-clear",
483            "--max-in-memory",
484            "5000",
485            "samples",
486        ])
487        .expect("cli parse should accept cache flags");
488
489        assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/sc-cache"));
490        assert!(parsed.cache_clear);
491        assert_eq!(parsed.max_in_memory, Some(5000));
492    }
493
494    #[test]
495    fn test_max_depth_default_matches_reference_behavior() {
496        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
497            .expect("cli parse should succeed");
498
499        assert_eq!(parsed.max_depth, 0);
500    }
501}