Skip to main content

provenant/
cli.rs

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