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 = env!("CARGO_PKG_VERSION"),
9    long_version = concat!(
10        env!("CARGO_PKG_VERSION"),
11        "\n",
12        "License detection uses data from ScanCode Toolkit (CC-BY-4.0). See NOTICE or --show_attribution."
13    ),
14    about,
15    long_about = None,
16    group(
17        ArgGroup::new("output")
18            .required(true)
19            .args([
20                "output_json",
21                "output_json_pp",
22                "output_json_lines",
23                "output_yaml",
24                "output_csv",
25                "output_html",
26                "output_html_app",
27                "output_spdx_tv",
28                "output_spdx_rdf",
29                "output_cyclonedx",
30                "output_cyclonedx_xml",
31                "custom_output",
32                "show_attribution"
33            ])
34    )
35)]
36pub struct Cli {
37    /// Directory path to scan
38    #[arg(required = false)]
39    pub dir_path: Vec<String>,
40
41    /// Write scan output as compact JSON to FILE
42    #[arg(long = "json", value_name = "FILE", allow_hyphen_values = true)]
43    pub output_json: Option<String>,
44
45    /// Write scan output as pretty-printed JSON to FILE
46    #[arg(long = "json-pp", value_name = "FILE", allow_hyphen_values = true)]
47    pub output_json_pp: Option<String>,
48
49    /// Write scan output as JSON Lines to FILE
50    #[arg(long = "json-lines", value_name = "FILE", allow_hyphen_values = true)]
51    pub output_json_lines: Option<String>,
52
53    /// Write scan output as YAML to FILE
54    #[arg(long = "yaml", value_name = "FILE", allow_hyphen_values = true)]
55    pub output_yaml: Option<String>,
56
57    /// [DEPRECATED in Python] Write scan output as CSV to FILE
58    #[arg(long = "csv", value_name = "FILE", allow_hyphen_values = true)]
59    pub output_csv: Option<String>,
60
61    /// Write scan output as HTML report to FILE
62    #[arg(long = "html", value_name = "FILE", allow_hyphen_values = true)]
63    pub output_html: Option<String>,
64
65    /// [DEPRECATED in Python] Write scan output as HTML app to FILE
66    #[arg(
67        long = "html-app",
68        value_name = "FILE",
69        hide = true,
70        allow_hyphen_values = true
71    )]
72    pub output_html_app: Option<String>,
73
74    /// Write scan output as SPDX tag/value to FILE
75    #[arg(long = "spdx-tv", value_name = "FILE", allow_hyphen_values = true)]
76    pub output_spdx_tv: Option<String>,
77
78    /// Write scan output as SPDX RDF/XML to FILE
79    #[arg(long = "spdx-rdf", value_name = "FILE", allow_hyphen_values = true)]
80    pub output_spdx_rdf: Option<String>,
81
82    /// Write scan output as CycloneDX JSON to FILE
83    #[arg(long = "cyclonedx", value_name = "FILE", allow_hyphen_values = true)]
84    pub output_cyclonedx: Option<String>,
85
86    /// Write scan output as CycloneDX XML to FILE
87    #[arg(
88        long = "cyclonedx-xml",
89        value_name = "FILE",
90        allow_hyphen_values = true
91    )]
92    pub output_cyclonedx_xml: Option<String>,
93
94    /// Write scan output to FILE formatted with the custom template
95    #[arg(
96        long = "custom-output",
97        value_name = "FILE",
98        requires = "custom_template",
99        allow_hyphen_values = true
100    )]
101    pub custom_output: Option<String>,
102
103    /// Use this template FILE with --custom-output
104    #[arg(
105        long = "custom-template",
106        value_name = "FILE",
107        requires = "custom_output"
108    )]
109    pub custom_template: Option<String>,
110
111    /// Maximum recursion depth (0 means no depth limit)
112    #[arg(short, long, default_value = "0")]
113    pub max_depth: usize,
114
115    #[arg(short = 'n', long, default_value_t = default_processes(), allow_hyphen_values = true)]
116    pub processes: i32,
117
118    #[arg(long, default_value_t = 120.0)]
119    pub timeout: f64,
120
121    #[arg(short, long, conflicts_with = "verbose")]
122    pub quiet: bool,
123
124    #[arg(short, long, conflicts_with = "quiet")]
125    pub verbose: bool,
126
127    #[arg(long, conflicts_with = "full_root")]
128    pub strip_root: bool,
129
130    #[arg(long, conflicts_with = "strip_root")]
131    pub full_root: bool,
132
133    /// Exclude patterns (ScanCode-compatible alias: --ignore)
134    #[arg(long = "exclude", visible_alias = "ignore", value_delimiter = ',')]
135    pub exclude: Vec<String>,
136
137    #[arg(long, value_delimiter = ',')]
138    pub include: Vec<String>,
139
140    #[arg(long = "cache-dir", value_name = "PATH")]
141    pub cache_dir: Option<String>,
142
143    #[arg(long = "cache-clear")]
144    pub cache_clear: bool,
145
146    #[arg(long = "max-in-memory", value_name = "INT")]
147    pub max_in_memory: Option<usize>,
148
149    #[arg(long)]
150    pub from_json: bool,
151
152    /// Scan input for application package and dependency manifests, lockfiles and related data
153    #[arg(short = 'p', long)]
154    pub package: bool,
155
156    /// Disable package assembly (merging related manifest/lockfiles into packages)
157    #[arg(long)]
158    pub no_assemble: bool,
159
160    /// Path to license rules directory containing .LICENSE and .RULE files.
161    /// If not specified, uses the built-in embedded license index.
162    #[arg(long, value_name = "PATH", requires = "license")]
163    pub license_rules_path: Option<String>,
164
165    /// Include matched text in license detection output
166    #[arg(long, requires = "license")]
167    pub include_text: bool,
168
169    #[arg(long)]
170    pub filter_clues: bool,
171
172    #[arg(long)]
173    pub only_findings: bool,
174
175    #[arg(long)]
176    pub mark_source: bool,
177
178    #[arg(long)]
179    pub classify: bool,
180
181    #[arg(long, requires = "classify")]
182    pub summary: bool,
183
184    #[arg(long = "license-clarity-score", requires = "classify")]
185    pub license_clarity_score: bool,
186
187    #[arg(long)]
188    pub tallies: bool,
189
190    #[arg(long = "tallies-key-files", requires_all = ["tallies", "classify"])]
191    pub tallies_key_files: bool,
192
193    #[arg(long = "tallies-with-details")]
194    pub tallies_with_details: bool,
195
196    #[arg(long = "facet", value_name = "<facet>=<pattern>")]
197    pub facet: Vec<String>,
198
199    #[arg(long = "tallies-by-facet", requires_all = ["facet", "tallies"])]
200    pub tallies_by_facet: bool,
201
202    #[arg(long)]
203    pub generated: bool,
204
205    /// Scan input for licenses
206    #[arg(short = 'l', long)]
207    pub license: bool,
208
209    #[arg(short = 'c', long)]
210    pub copyright: bool,
211
212    /// Scan input for email addresses
213    #[arg(short = 'e', long)]
214    pub email: bool,
215
216    /// Report only up to INT emails found in a file. Use 0 for no limit.
217    #[arg(long, default_value_t = 50, requires = "email")]
218    pub max_email: usize,
219
220    /// Scan input for URLs
221    #[arg(short = 'u', long)]
222    pub url: bool,
223
224    /// Report only up to INT URLs found in a file. Use 0 for no limit.
225    #[arg(long, default_value_t = 50, requires = "url")]
226    pub max_url: usize,
227
228    /// Show attribution notices for embedded license detection data
229    #[arg(long)]
230    pub show_attribution: bool,
231}
232
233fn default_processes() -> i32 {
234    let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
235    if cpus > 1 { (cpus - 1) as i32 } else { 1 }
236}
237
238#[derive(Debug, Clone)]
239pub struct OutputTarget {
240    pub format: OutputFormat,
241    pub file: String,
242    pub custom_template: Option<String>,
243}
244
245impl Cli {
246    pub fn output_targets(&self) -> Vec<OutputTarget> {
247        let mut targets = Vec::new();
248
249        if let Some(file) = &self.output_json {
250            targets.push(OutputTarget {
251                format: OutputFormat::Json,
252                file: file.clone(),
253                custom_template: None,
254            });
255        }
256
257        if let Some(file) = &self.output_json_pp {
258            targets.push(OutputTarget {
259                format: OutputFormat::JsonPretty,
260                file: file.clone(),
261                custom_template: None,
262            });
263        }
264
265        if let Some(file) = &self.output_json_lines {
266            targets.push(OutputTarget {
267                format: OutputFormat::JsonLines,
268                file: file.clone(),
269                custom_template: None,
270            });
271        }
272
273        if let Some(file) = &self.output_yaml {
274            targets.push(OutputTarget {
275                format: OutputFormat::Yaml,
276                file: file.clone(),
277                custom_template: None,
278            });
279        }
280
281        if let Some(file) = &self.output_csv {
282            targets.push(OutputTarget {
283                format: OutputFormat::Csv,
284                file: file.clone(),
285                custom_template: None,
286            });
287        }
288
289        if let Some(file) = &self.output_html {
290            targets.push(OutputTarget {
291                format: OutputFormat::Html,
292                file: file.clone(),
293                custom_template: None,
294            });
295        }
296
297        if let Some(file) = &self.output_html_app {
298            targets.push(OutputTarget {
299                format: OutputFormat::HtmlApp,
300                file: file.clone(),
301                custom_template: None,
302            });
303        }
304
305        if let Some(file) = &self.output_spdx_tv {
306            targets.push(OutputTarget {
307                format: OutputFormat::SpdxTv,
308                file: file.clone(),
309                custom_template: None,
310            });
311        }
312
313        if let Some(file) = &self.output_spdx_rdf {
314            targets.push(OutputTarget {
315                format: OutputFormat::SpdxRdf,
316                file: file.clone(),
317                custom_template: None,
318            });
319        }
320
321        if let Some(file) = &self.output_cyclonedx {
322            targets.push(OutputTarget {
323                format: OutputFormat::CycloneDxJson,
324                file: file.clone(),
325                custom_template: None,
326            });
327        }
328
329        if let Some(file) = &self.output_cyclonedx_xml {
330            targets.push(OutputTarget {
331                format: OutputFormat::CycloneDxXml,
332                file: file.clone(),
333                custom_template: None,
334            });
335        }
336
337        if let Some(file) = &self.custom_output {
338            targets.push(OutputTarget {
339                format: OutputFormat::CustomTemplate,
340                file: file.clone(),
341                custom_template: self.custom_template.clone(),
342            });
343        }
344
345        targets
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_requires_at_least_one_output_option() {
355        let parsed = Cli::try_parse_from(["provenant", "samples"]);
356        assert!(parsed.is_err());
357    }
358
359    #[test]
360    fn test_parses_json_pretty_output_option() {
361        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
362            .expect("cli parse should succeed");
363
364        assert_eq!(parsed.output_json_pp.as_deref(), Some("scan.json"));
365        assert_eq!(parsed.output_targets().len(), 1);
366        assert_eq!(parsed.output_targets()[0].format, OutputFormat::JsonPretty);
367    }
368
369    #[test]
370    fn test_allows_stdout_dash_as_output_target() {
371        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "-", "samples"])
372            .expect("cli parse should allow stdout dash output target");
373
374        assert_eq!(parsed.output_json_pp.as_deref(), Some("-"));
375    }
376
377    #[test]
378    fn test_custom_template_and_output_must_be_paired() {
379        let missing_template =
380            Cli::try_parse_from(["provenant", "--custom-output", "result.txt", "samples"]);
381        assert!(missing_template.is_err());
382
383        let missing_output =
384            Cli::try_parse_from(["provenant", "--custom-template", "tpl.tera", "samples"]);
385        assert!(missing_output.is_err());
386    }
387
388    #[test]
389    fn test_parses_processes_and_timeout_options() {
390        let parsed = Cli::try_parse_from([
391            "provenant",
392            "--json-pp",
393            "scan.json",
394            "-n",
395            "4",
396            "--timeout",
397            "30",
398            "samples",
399        ])
400        .expect("cli parse should succeed");
401
402        assert_eq!(parsed.processes, 4);
403        assert_eq!(parsed.timeout, 30.0);
404    }
405
406    #[test]
407    fn test_strip_root_conflicts_with_full_root() {
408        let parsed = Cli::try_parse_from([
409            "provenant",
410            "--json-pp",
411            "scan.json",
412            "--strip-root",
413            "--full-root",
414            "samples",
415        ]);
416        assert!(parsed.is_err());
417    }
418
419    #[test]
420    fn test_parses_include_and_only_findings_and_filter_clues() {
421        let parsed = Cli::try_parse_from([
422            "provenant",
423            "--json-pp",
424            "scan.json",
425            "--include",
426            "src/**,Cargo.toml",
427            "--only-findings",
428            "--filter-clues",
429            "samples",
430        ])
431        .expect("cli parse should succeed");
432
433        assert_eq!(parsed.include, vec!["src/**", "Cargo.toml"]);
434        assert!(parsed.only_findings);
435        assert!(parsed.filter_clues);
436    }
437
438    #[test]
439    fn test_parses_ignore_alias_for_exclude_patterns() {
440        let parsed = Cli::try_parse_from([
441            "provenant",
442            "--json-pp",
443            "scan.json",
444            "--ignore",
445            "*.git*,target/*",
446            "samples",
447        ])
448        .expect("cli parse should accept --ignore alias");
449
450        assert_eq!(parsed.exclude, vec!["*.git*", "target/*"]);
451    }
452
453    #[test]
454    fn test_quiet_conflicts_with_verbose() {
455        let parsed = Cli::try_parse_from([
456            "provenant",
457            "--json-pp",
458            "scan.json",
459            "--quiet",
460            "--verbose",
461            "samples",
462        ]);
463        assert!(parsed.is_err());
464    }
465
466    #[test]
467    fn test_parses_from_json_and_mark_source() {
468        let parsed = Cli::try_parse_from([
469            "provenant",
470            "--json-pp",
471            "scan.json",
472            "--from-json",
473            "--mark-source",
474            "sample-scan.json",
475        ])
476        .expect("cli parse should succeed");
477
478        assert!(parsed.from_json);
479        assert_eq!(parsed.dir_path, vec!["sample-scan.json"]);
480        assert!(parsed.mark_source);
481    }
482
483    #[test]
484    fn test_parses_classify_facet_and_tallies_by_facet() {
485        let parsed = Cli::try_parse_from([
486            "provenant",
487            "--json-pp",
488            "scan.json",
489            "--classify",
490            "--tallies",
491            "--facet",
492            "dev=*.c",
493            "--facet",
494            "tests=*/tests/*",
495            "--tallies-by-facet",
496            "samples",
497        ])
498        .expect("cli parse should succeed");
499
500        assert!(parsed.classify);
501        assert!(parsed.tallies);
502        assert_eq!(parsed.facet, vec!["dev=*.c", "tests=*/tests/*"]);
503        assert!(parsed.tallies_by_facet);
504    }
505
506    #[test]
507    fn test_tallies_by_facet_requires_facet_definitions() {
508        let parsed = Cli::try_parse_from([
509            "provenant",
510            "--json-pp",
511            "scan.json",
512            "--tallies-by-facet",
513            "samples",
514        ]);
515
516        assert!(parsed.is_err());
517    }
518
519    #[test]
520    fn test_summary_requires_classify() {
521        let parsed = Cli::try_parse_from([
522            "provenant",
523            "--json-pp",
524            "scan.json",
525            "--summary",
526            "samples",
527        ]);
528
529        assert!(parsed.is_err());
530    }
531
532    #[test]
533    fn test_tallies_key_files_requires_tallies_and_classify() {
534        let parsed = Cli::try_parse_from([
535            "provenant",
536            "--json-pp",
537            "scan.json",
538            "--tallies-key-files",
539            "samples",
540        ]);
541
542        assert!(parsed.is_err());
543    }
544
545    #[test]
546    fn test_parses_summary_tallies_and_generated_flags() {
547        let parsed = Cli::try_parse_from([
548            "provenant",
549            "--json-pp",
550            "scan.json",
551            "--classify",
552            "--summary",
553            "--license-clarity-score",
554            "--tallies",
555            "--tallies-key-files",
556            "--tallies-with-details",
557            "--generated",
558            "samples",
559        ])
560        .expect("cli parse should succeed");
561
562        assert!(parsed.classify);
563        assert!(parsed.summary);
564        assert!(parsed.license_clarity_score);
565        assert!(parsed.tallies);
566        assert!(parsed.tallies_key_files);
567        assert!(parsed.tallies_with_details);
568        assert!(parsed.generated);
569    }
570
571    #[test]
572    fn test_parses_copyright_flag() {
573        let parsed = Cli::try_parse_from([
574            "provenant",
575            "--json-pp",
576            "scan.json",
577            "--copyright",
578            "samples",
579        ])
580        .expect("cli parse should succeed");
581
582        assert!(parsed.copyright);
583    }
584
585    #[test]
586    fn test_package_flag_defaults_to_disabled() {
587        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
588            .expect("cli parse should succeed");
589
590        assert!(!parsed.package);
591    }
592
593    #[test]
594    fn test_parses_package_flag() {
595        let parsed = Cli::try_parse_from([
596            "provenant",
597            "--json-pp",
598            "scan.json",
599            "--package",
600            "samples",
601        ])
602        .expect("cli parse should succeed");
603
604        assert!(parsed.package);
605    }
606
607    #[test]
608    fn test_package_short_flag() {
609        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-p", "samples"])
610            .expect("cli parse should succeed");
611
612        assert!(parsed.package);
613    }
614
615    #[test]
616    fn test_parses_license_flag() {
617        let parsed = Cli::try_parse_from([
618            "provenant",
619            "--json-pp",
620            "scan.json",
621            "--license",
622            "samples",
623        ])
624        .expect("cli parse should succeed");
625
626        assert!(parsed.license);
627    }
628
629    #[test]
630    fn test_license_short_flag() {
631        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-l", "samples"])
632            .expect("cli parse should succeed");
633
634        assert!(parsed.license);
635    }
636
637    #[test]
638    fn test_include_text_requires_license() {
639        let result = Cli::try_parse_from([
640            "provenant",
641            "--json-pp",
642            "scan.json",
643            "--include-text",
644            "samples",
645        ]);
646        assert!(result.is_err());
647    }
648
649    #[test]
650    fn test_parses_short_scan_flags() {
651        let parsed = Cli::try_parse_from([
652            "provenant",
653            "--json-pp",
654            "scan.json",
655            "-c",
656            "-e",
657            "-u",
658            "samples",
659        ])
660        .expect("cli parse should support short scan flags");
661
662        assert!(parsed.copyright);
663        assert!(parsed.email);
664        assert!(parsed.url);
665    }
666
667    #[test]
668    fn test_parses_processes_compat_values_zero_and_minus_one() {
669        let zero =
670            Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "0", "samples"])
671                .expect("cli parse should accept processes=0");
672        assert_eq!(zero.processes, 0);
673
674        let parsed =
675            Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "-1", "samples"])
676                .expect("cli parse should accept processes=-1");
677        assert_eq!(parsed.processes, -1);
678    }
679
680    #[test]
681    fn test_parses_cache_flags() {
682        let parsed = Cli::try_parse_from([
683            "provenant",
684            "--json-pp",
685            "scan.json",
686            "--cache-dir",
687            "/tmp/sc-cache",
688            "--cache-clear",
689            "--max-in-memory",
690            "5000",
691            "samples",
692        ])
693        .expect("cli parse should accept cache flags");
694
695        assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/sc-cache"));
696        assert!(parsed.cache_clear);
697        assert_eq!(parsed.max_in_memory, Some(5000));
698    }
699
700    #[test]
701    fn test_max_depth_default_matches_reference_behavior() {
702        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
703            .expect("cli parse should succeed");
704
705        assert_eq!(parsed.max_depth, 0);
706    }
707}