Skip to main content

provenant/
cli.rs

1use clap::{ArgGroup, Parser};
2use std::fs;
3use std::path::Path;
4use yaml_serde::Value;
5
6use crate::cache::CacheKind;
7use crate::license_detection::DEFAULT_LICENSEDB_URL_TEMPLATE;
8use crate::output::OutputFormat;
9
10fn parse_license_policy_arg(value: &str) -> Result<String, String> {
11    let policy_path = Path::new(value);
12    let metadata = fs::metadata(policy_path).map_err(|err| {
13        format!(
14            "Failed to read license policy file {:?}: {err}",
15            policy_path
16        )
17    })?;
18    if !metadata.is_file() {
19        return Err(format!(
20            "License policy path {:?} is not a regular file",
21            policy_path
22        ));
23    }
24
25    let policy_text = fs::read_to_string(policy_path).map_err(|err| {
26        format!(
27            "Failed to read license policy file {:?}: {err}",
28            policy_path
29        )
30    })?;
31    if policy_text.trim().is_empty() {
32        return Err(format!("License policy file {:?} is empty", policy_path));
33    }
34
35    let policy_value: Value = yaml_serde::from_str(&policy_text).map_err(|err| {
36        format!(
37            "Failed to parse license policy file {:?}: {err}",
38            policy_path
39        )
40    })?;
41    let has_license_policies = policy_value
42        .as_mapping()
43        .and_then(|mapping| mapping.get(Value::String("license_policies".to_string())))
44        .is_some();
45    if !has_license_policies {
46        return Err(format!(
47            "License policy file {:?} is missing a 'license_policies' attribute",
48            policy_path
49        ));
50    }
51
52    Ok(value.to_string())
53}
54
55#[derive(Parser, Debug)]
56#[command(
57    author,
58    version = env!("CARGO_PKG_VERSION"),
59    long_version = concat!(
60        env!("CARGO_PKG_VERSION"),
61        "\n",
62        "License detection uses data from ScanCode Toolkit (CC-BY-4.0). See NOTICE or --show_attribution."
63    ),
64    about,
65    long_about = None,
66    group(
67        ArgGroup::new("output")
68            .required(true)
69            .args([
70                "output_json",
71                "output_json_pp",
72                "output_json_lines",
73                "output_yaml",
74                "output_csv",
75                "output_debian",
76                "output_html",
77                "output_html_app",
78                "output_spdx_tv",
79                "output_spdx_rdf",
80                "output_cyclonedx",
81                "output_cyclonedx_xml",
82                "custom_output",
83                "show_attribution"
84            ])
85    )
86)]
87pub struct Cli {
88    /// File or directory paths to scan
89    #[arg(required = false)]
90    pub dir_path: Vec<String>,
91
92    /// Write scan output as compact JSON to FILE
93    #[arg(long = "json", value_name = "FILE", allow_hyphen_values = true)]
94    pub output_json: Option<String>,
95
96    /// Write scan output as pretty-printed JSON to FILE
97    #[arg(long = "json-pp", value_name = "FILE", allow_hyphen_values = true)]
98    pub output_json_pp: Option<String>,
99
100    /// Write scan output as JSON Lines to FILE
101    #[arg(long = "json-lines", value_name = "FILE", allow_hyphen_values = true)]
102    pub output_json_lines: Option<String>,
103
104    /// Write scan output as YAML to FILE
105    #[arg(long = "yaml", value_name = "FILE", allow_hyphen_values = true)]
106    pub output_yaml: Option<String>,
107
108    /// [DEPRECATED in Python] Write scan output as CSV to FILE
109    #[arg(long = "csv", value_name = "FILE", allow_hyphen_values = true)]
110    pub output_csv: Option<String>,
111
112    /// Write scan output in machine-readable Debian copyright format to FILE (requires --license, --copyright, and --license-text)
113    #[arg(
114        long = "debian",
115        value_name = "FILE",
116        allow_hyphen_values = true,
117        requires_all = ["copyright", "license", "license_text"]
118    )]
119    pub output_debian: Option<String>,
120
121    /// Write scan output as HTML report to FILE
122    #[arg(long = "html", value_name = "FILE", allow_hyphen_values = true)]
123    pub output_html: Option<String>,
124
125    /// [DEPRECATED in Python] Write scan output as HTML app to FILE
126    #[arg(
127        long = "html-app",
128        value_name = "FILE",
129        hide = true,
130        allow_hyphen_values = true
131    )]
132    pub output_html_app: Option<String>,
133
134    /// Write scan output as SPDX tag/value to FILE
135    #[arg(long = "spdx-tv", value_name = "FILE", allow_hyphen_values = true)]
136    pub output_spdx_tv: Option<String>,
137
138    /// Write scan output as SPDX RDF/XML to FILE
139    #[arg(long = "spdx-rdf", value_name = "FILE", allow_hyphen_values = true)]
140    pub output_spdx_rdf: Option<String>,
141
142    /// Write scan output as CycloneDX JSON to FILE
143    #[arg(long = "cyclonedx", value_name = "FILE", allow_hyphen_values = true)]
144    pub output_cyclonedx: Option<String>,
145
146    /// Write scan output as CycloneDX XML to FILE
147    #[arg(
148        long = "cyclonedx-xml",
149        value_name = "FILE",
150        allow_hyphen_values = true
151    )]
152    pub output_cyclonedx_xml: Option<String>,
153
154    /// Write scan output to FILE formatted with the custom template
155    #[arg(
156        long = "custom-output",
157        value_name = "FILE",
158        requires = "custom_template",
159        allow_hyphen_values = true
160    )]
161    pub custom_output: Option<String>,
162
163    /// Use this template FILE with --custom-output
164    #[arg(
165        long = "custom-template",
166        value_name = "FILE",
167        requires = "custom_output"
168    )]
169    pub custom_template: Option<String>,
170
171    /// Maximum recursion depth (0 means no depth limit)
172    #[arg(short, long, default_value = "0")]
173    pub max_depth: usize,
174
175    #[arg(short = 'n', long, default_value_t = default_processes(), allow_hyphen_values = true)]
176    pub processes: i32,
177
178    #[arg(long, default_value_t = 120.0)]
179    pub timeout: f64,
180
181    #[arg(short, long, conflicts_with = "verbose")]
182    pub quiet: bool,
183
184    #[arg(short, long, conflicts_with = "quiet")]
185    pub verbose: bool,
186
187    #[arg(long, conflicts_with = "full_root")]
188    pub strip_root: bool,
189
190    #[arg(long, conflicts_with = "strip_root")]
191    pub full_root: bool,
192
193    /// Exclude patterns (ScanCode-compatible alias: --ignore)
194    #[arg(long = "exclude", visible_alias = "ignore", value_delimiter = ',')]
195    pub exclude: Vec<String>,
196
197    #[arg(long, value_delimiter = ',')]
198    pub include: Vec<String>,
199
200    #[arg(long = "cache-dir", value_name = "PATH")]
201    pub cache_dir: Option<String>,
202
203    #[arg(
204        long = "cache",
205        value_name = "KIND",
206        value_enum,
207        value_delimiter = ',',
208        help = "Enable the persistent scan-results cache"
209    )]
210    pub cache: Vec<CacheKind>,
211
212    #[arg(long = "cache-clear")]
213    pub cache_clear: bool,
214
215    /// Maximum number of file and directory scan details kept in memory.
216    /// Use 0 for unlimited memory or -1 for disk-only spill during the scan.
217    #[arg(
218        long = "max-in-memory",
219        value_name = "INT",
220        default_value_t = 10000,
221        value_parser = parse_max_in_memory,
222        allow_hyphen_values = true
223    )]
224    pub max_in_memory: i64,
225
226    /// Collect file information such as checksums, type hints, and source/script flags.
227    #[arg(short = 'i', long)]
228    pub info: bool,
229
230    /// Load one or more existing ScanCode-style JSON scans instead of rescanning inputs.
231    #[arg(long)]
232    pub from_json: bool,
233
234    /// Scan input for application package and dependency manifests, lockfiles and related data
235    #[arg(short = 'p', long)]
236    pub package: bool,
237
238    /// Scan input for installed system package databases (RPM, dpkg, apk, etc.)
239    #[arg(long = "system-package")]
240    pub system_package: bool,
241
242    /// Scan supported compiled Go and Rust binaries for embedded package metadata.
243    #[arg(long = "package-in-compiled")]
244    pub package_in_compiled: bool,
245
246    /// Scan for system and application package data and skip license/copyright detection and top-level package creation.
247    #[arg(
248        long = "package-only",
249        conflicts_with_all = ["license", "summary", "package", "system_package"]
250    )]
251    pub package_only: bool,
252
253    /// Disable package assembly (merging related manifest/lockfiles into packages)
254    #[arg(long)]
255    pub no_assemble: bool,
256
257    /// Path to license rules directory containing .LICENSE and .RULE files.
258    /// If not specified, uses the built-in embedded license index.
259    #[arg(long, value_name = "PATH", requires = "license")]
260    pub license_rules_path: Option<String>,
261
262    /// Include matched text in license detection output
263    #[arg(long = "license-text", requires = "license")]
264    pub license_text: bool,
265
266    #[arg(long = "license-text-diagnostics", requires = "license_text")]
267    pub license_text_diagnostics: bool,
268
269    #[arg(long = "license-diagnostics", requires = "license")]
270    pub license_diagnostics: bool,
271
272    #[arg(long = "unknown-licenses", requires = "license")]
273    pub unknown_licenses: bool,
274
275    #[arg(
276        long = "license-score",
277        default_value_t = 0,
278        requires = "license",
279        value_parser = clap::value_parser!(u8).range(0..=100)
280    )]
281    pub license_score: u8,
282
283    #[arg(
284        long = "license-url-template",
285        default_value = DEFAULT_LICENSEDB_URL_TEMPLATE,
286        requires = "license"
287    )]
288    pub license_url_template: String,
289
290    #[arg(long)]
291    pub filter_clues: bool,
292
293    #[arg(
294        long = "ignore-author",
295        value_name = "PATTERN",
296        help = "Ignore a file and all its findings if an author matches the regex PATTERN"
297    )]
298    pub ignore_author: Vec<String>,
299
300    #[arg(
301        long = "ignore-copyright-holder",
302        value_name = "PATTERN",
303        help = "Ignore a file and all its findings if a copyright holder matches the regex PATTERN"
304    )]
305    pub ignore_copyright_holder: Vec<String>,
306
307    #[arg(long)]
308    pub only_findings: bool,
309
310    #[arg(long, requires = "info")]
311    pub mark_source: bool,
312
313    #[arg(long)]
314    pub classify: bool,
315
316    #[arg(long, requires = "classify")]
317    pub summary: bool,
318
319    #[arg(long = "license-clarity-score", requires = "classify")]
320    pub license_clarity_score: bool,
321
322    #[arg(long = "license-references", requires = "license")]
323    pub license_references: bool,
324
325    /// Evaluate file license detections against a YAML license policy file.
326    #[arg(
327        long = "license-policy",
328        value_name = "FILE",
329        value_parser = parse_license_policy_arg
330    )]
331    pub license_policy: Option<String>,
332
333    #[arg(long)]
334    pub tallies: bool,
335
336    #[arg(long = "tallies-key-files", requires_all = ["tallies", "classify"])]
337    pub tallies_key_files: bool,
338
339    #[arg(long = "tallies-with-details")]
340    pub tallies_with_details: bool,
341
342    #[arg(long = "facet", value_name = "<facet>=<pattern>")]
343    pub facet: Vec<String>,
344
345    #[arg(long = "tallies-by-facet", requires_all = ["facet", "tallies"])]
346    pub tallies_by_facet: bool,
347
348    #[arg(long)]
349    pub generated: bool,
350
351    /// Scan input for licenses
352    #[arg(short = 'l', long)]
353    pub license: bool,
354
355    #[arg(short = 'c', long)]
356    pub copyright: bool,
357
358    /// Scan input for email addresses
359    #[arg(short = 'e', long)]
360    pub email: bool,
361
362    /// Report only up to INT emails found in a file. Use 0 for no limit.
363    #[arg(long, default_value_t = 50, requires = "email")]
364    pub max_email: usize,
365
366    /// Scan input for URLs
367    #[arg(short = 'u', long)]
368    pub url: bool,
369
370    /// Report only up to INT URLs found in a file. Use 0 for no limit.
371    #[arg(long, default_value_t = 50, requires = "url")]
372    pub max_url: usize,
373
374    /// Show attribution notices for embedded license detection data
375    #[arg(long)]
376    pub show_attribution: bool,
377}
378
379fn default_processes() -> i32 {
380    let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
381    if cpus > 1 { (cpus - 1) as i32 } else { 1 }
382}
383
384fn parse_max_in_memory(value: &str) -> Result<i64, String> {
385    let parsed = value
386        .parse::<i64>()
387        .map_err(|_| format!("invalid integer value: {value}"))?;
388    if parsed < -1 {
389        return Err("--max-in-memory must be -1, 0, or a positive integer".to_string());
390    }
391    Ok(parsed)
392}
393
394#[derive(Debug, Clone)]
395pub struct OutputTarget {
396    pub format: OutputFormat,
397    pub file: String,
398    pub custom_template: Option<String>,
399}
400
401impl Cli {
402    pub fn output_targets(&self) -> Vec<OutputTarget> {
403        let mut targets = Vec::new();
404
405        if let Some(file) = &self.output_json {
406            targets.push(OutputTarget {
407                format: OutputFormat::Json,
408                file: file.clone(),
409                custom_template: None,
410            });
411        }
412
413        if let Some(file) = &self.output_json_pp {
414            targets.push(OutputTarget {
415                format: OutputFormat::JsonPretty,
416                file: file.clone(),
417                custom_template: None,
418            });
419        }
420
421        if let Some(file) = &self.output_json_lines {
422            targets.push(OutputTarget {
423                format: OutputFormat::JsonLines,
424                file: file.clone(),
425                custom_template: None,
426            });
427        }
428
429        if let Some(file) = &self.output_yaml {
430            targets.push(OutputTarget {
431                format: OutputFormat::Yaml,
432                file: file.clone(),
433                custom_template: None,
434            });
435        }
436
437        if let Some(file) = &self.output_csv {
438            targets.push(OutputTarget {
439                format: OutputFormat::Csv,
440                file: file.clone(),
441                custom_template: None,
442            });
443        }
444
445        if let Some(file) = &self.output_debian {
446            targets.push(OutputTarget {
447                format: OutputFormat::Debian,
448                file: file.clone(),
449                custom_template: None,
450            });
451        }
452
453        if let Some(file) = &self.output_html {
454            targets.push(OutputTarget {
455                format: OutputFormat::Html,
456                file: file.clone(),
457                custom_template: None,
458            });
459        }
460
461        if let Some(file) = &self.output_html_app {
462            targets.push(OutputTarget {
463                format: OutputFormat::HtmlApp,
464                file: file.clone(),
465                custom_template: None,
466            });
467        }
468
469        if let Some(file) = &self.output_spdx_tv {
470            targets.push(OutputTarget {
471                format: OutputFormat::SpdxTv,
472                file: file.clone(),
473                custom_template: None,
474            });
475        }
476
477        if let Some(file) = &self.output_spdx_rdf {
478            targets.push(OutputTarget {
479                format: OutputFormat::SpdxRdf,
480                file: file.clone(),
481                custom_template: None,
482            });
483        }
484
485        if let Some(file) = &self.output_cyclonedx {
486            targets.push(OutputTarget {
487                format: OutputFormat::CycloneDxJson,
488                file: file.clone(),
489                custom_template: None,
490            });
491        }
492
493        if let Some(file) = &self.output_cyclonedx_xml {
494            targets.push(OutputTarget {
495                format: OutputFormat::CycloneDxXml,
496                file: file.clone(),
497                custom_template: None,
498            });
499        }
500
501        if let Some(file) = &self.custom_output {
502            targets.push(OutputTarget {
503                format: OutputFormat::CustomTemplate,
504                file: file.clone(),
505                custom_template: self.custom_template.clone(),
506            });
507        }
508
509        targets
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use clap::CommandFactory;
517
518    #[test]
519    fn test_requires_at_least_one_output_option() {
520        let parsed = Cli::try_parse_from(["provenant", "samples"]);
521        assert!(parsed.is_err());
522    }
523
524    #[test]
525    fn test_parses_json_pretty_output_option() {
526        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
527            .expect("cli parse should succeed");
528
529        assert_eq!(parsed.output_json_pp.as_deref(), Some("scan.json"));
530        assert_eq!(parsed.output_targets().len(), 1);
531        assert_eq!(parsed.output_targets()[0].format, OutputFormat::JsonPretty);
532    }
533
534    #[test]
535    fn test_allows_stdout_dash_as_output_target() {
536        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "-", "samples"])
537            .expect("cli parse should allow stdout dash output target");
538
539        assert_eq!(parsed.output_json_pp.as_deref(), Some("-"));
540    }
541
542    #[test]
543    fn test_debian_requires_license_copyright_and_license_text() {
544        let missing_license_text = Cli::try_parse_from([
545            "provenant",
546            "--debian",
547            "scan.copyright",
548            "--license",
549            "--copyright",
550            "samples",
551        ]);
552        assert!(missing_license_text.is_err());
553
554        let parsed = Cli::try_parse_from([
555            "provenant",
556            "--debian",
557            "scan.copyright",
558            "--license",
559            "--copyright",
560            "--license-text",
561            "samples",
562        ])
563        .expect("cli parse should accept debian output");
564
565        assert_eq!(parsed.output_targets().len(), 1);
566        assert_eq!(parsed.output_targets()[0].format, OutputFormat::Debian);
567        assert_eq!(parsed.output_debian.as_deref(), Some("scan.copyright"));
568    }
569
570    #[test]
571    fn test_debian_help_mentions_required_companion_flags() {
572        let command = Cli::command();
573        let debian_arg = command
574            .get_arguments()
575            .find(|arg| arg.get_long() == Some("debian"))
576            .expect("debian arg should exist");
577
578        let help = debian_arg
579            .get_help()
580            .expect("debian arg should have help text")
581            .to_string();
582
583        assert!(help.contains("requires --license, --copyright, and --license-text"));
584    }
585
586    #[test]
587    fn test_parses_license_policy_flag() {
588        let temp = tempfile::tempdir().expect("temp dir");
589        let policy_path = temp.path().join("policy.yml");
590        std::fs::write(&policy_path, "license_policies: []\n").expect("policy written");
591
592        let parsed = Cli::try_parse_from([
593            "provenant",
594            "--json-pp",
595            "scan.json",
596            "--license-policy",
597            policy_path.to_str().expect("utf8 path"),
598            "samples",
599        ])
600        .expect("cli parse should accept license-policy");
601
602        assert_eq!(
603            parsed.license_policy.as_deref(),
604            Some(policy_path.to_str().expect("utf8 path"))
605        );
606    }
607
608    #[test]
609    fn test_rejects_invalid_license_policy_flag_value() {
610        let temp = tempfile::tempdir().expect("temp dir");
611        let policy_path = temp.path().join("policy.yml");
612        std::fs::write(&policy_path, "not_license_policies: []\n").expect("policy written");
613
614        let parsed = Cli::try_parse_from([
615            "provenant",
616            "--json-pp",
617            "scan.json",
618            "--license-policy",
619            policy_path.to_str().expect("utf8 path"),
620            "samples",
621        ]);
622
623        assert!(parsed.is_err());
624    }
625
626    #[test]
627    fn test_custom_template_and_output_must_be_paired() {
628        let missing_template =
629            Cli::try_parse_from(["provenant", "--custom-output", "result.txt", "samples"]);
630        assert!(missing_template.is_err());
631
632        let missing_output =
633            Cli::try_parse_from(["provenant", "--custom-template", "tpl.tera", "samples"]);
634        assert!(missing_output.is_err());
635    }
636
637    #[test]
638    fn test_parses_processes_and_timeout_options() {
639        let parsed = Cli::try_parse_from([
640            "provenant",
641            "--json-pp",
642            "scan.json",
643            "-n",
644            "4",
645            "--timeout",
646            "30",
647            "samples",
648        ])
649        .expect("cli parse should succeed");
650
651        assert_eq!(parsed.processes, 4);
652        assert_eq!(parsed.timeout, 30.0);
653    }
654
655    #[test]
656    fn test_strip_root_conflicts_with_full_root() {
657        let parsed = Cli::try_parse_from([
658            "provenant",
659            "--json-pp",
660            "scan.json",
661            "--strip-root",
662            "--full-root",
663            "samples",
664        ]);
665        assert!(parsed.is_err());
666    }
667
668    #[test]
669    fn test_parses_include_and_only_findings_and_filter_clues() {
670        let parsed = Cli::try_parse_from([
671            "provenant",
672            "--json-pp",
673            "scan.json",
674            "--include",
675            "src/**,Cargo.toml",
676            "--only-findings",
677            "--filter-clues",
678            "samples",
679        ])
680        .expect("cli parse should succeed");
681
682        assert_eq!(parsed.include, vec!["src/**", "Cargo.toml"]);
683        assert!(parsed.only_findings);
684        assert!(parsed.filter_clues);
685    }
686
687    #[test]
688    fn test_parses_ignore_author_and_holder_filters() {
689        let parsed = Cli::try_parse_from([
690            "provenant",
691            "--json-pp",
692            "scan.json",
693            "--ignore-author",
694            "Jane.*",
695            "--ignore-author",
696            ".*Bot$",
697            "--ignore-copyright-holder",
698            "Example Corp",
699            "samples",
700        ])
701        .expect("cli parse should succeed");
702
703        assert_eq!(parsed.ignore_author, vec!["Jane.*", ".*Bot$"]);
704        assert_eq!(parsed.ignore_copyright_holder, vec!["Example Corp"]);
705    }
706
707    #[test]
708    fn test_parses_ignore_alias_for_exclude_patterns() {
709        let parsed = Cli::try_parse_from([
710            "provenant",
711            "--json-pp",
712            "scan.json",
713            "--ignore",
714            "*.git*,target/*",
715            "samples",
716        ])
717        .expect("cli parse should accept --ignore alias");
718
719        assert_eq!(parsed.exclude, vec!["*.git*", "target/*"]);
720    }
721
722    #[test]
723    fn test_quiet_conflicts_with_verbose() {
724        let parsed = Cli::try_parse_from([
725            "provenant",
726            "--json-pp",
727            "scan.json",
728            "--quiet",
729            "--verbose",
730            "samples",
731        ]);
732        assert!(parsed.is_err());
733    }
734
735    #[test]
736    fn test_parses_from_json_and_mark_source() {
737        let parsed = Cli::try_parse_from([
738            "provenant",
739            "--json-pp",
740            "scan.json",
741            "--from-json",
742            "--info",
743            "--mark-source",
744            "sample-scan.json",
745        ])
746        .expect("cli parse should succeed");
747
748        assert!(parsed.from_json);
749        assert!(parsed.info);
750        assert_eq!(parsed.dir_path, vec!["sample-scan.json"]);
751        assert!(parsed.mark_source);
752    }
753
754    #[test]
755    fn test_mark_source_requires_info() {
756        let parsed = Cli::try_parse_from([
757            "provenant",
758            "--json-pp",
759            "scan.json",
760            "--mark-source",
761            "samples",
762        ]);
763
764        assert!(parsed.is_err());
765    }
766
767    #[test]
768    fn test_parses_classify_facet_and_tallies_by_facet() {
769        let parsed = Cli::try_parse_from([
770            "provenant",
771            "--json-pp",
772            "scan.json",
773            "--classify",
774            "--tallies",
775            "--facet",
776            "dev=*.c",
777            "--facet",
778            "tests=*/tests/*",
779            "--tallies-by-facet",
780            "samples",
781        ])
782        .expect("cli parse should succeed");
783
784        assert!(parsed.classify);
785        assert!(parsed.tallies);
786        assert_eq!(parsed.facet, vec!["dev=*.c", "tests=*/tests/*"]);
787        assert!(parsed.tallies_by_facet);
788    }
789
790    #[test]
791    fn test_tallies_by_facet_requires_facet_definitions() {
792        let parsed = Cli::try_parse_from([
793            "provenant",
794            "--json-pp",
795            "scan.json",
796            "--tallies-by-facet",
797            "samples",
798        ]);
799
800        assert!(parsed.is_err());
801    }
802
803    #[test]
804    fn test_summary_requires_classify() {
805        let parsed = Cli::try_parse_from([
806            "provenant",
807            "--json-pp",
808            "scan.json",
809            "--summary",
810            "samples",
811        ]);
812
813        assert!(parsed.is_err());
814    }
815
816    #[test]
817    fn test_tallies_key_files_requires_tallies_and_classify() {
818        let parsed = Cli::try_parse_from([
819            "provenant",
820            "--json-pp",
821            "scan.json",
822            "--tallies-key-files",
823            "samples",
824        ]);
825
826        assert!(parsed.is_err());
827    }
828
829    #[test]
830    fn test_parses_summary_tallies_and_generated_flags() {
831        let parsed = Cli::try_parse_from([
832            "provenant",
833            "--json-pp",
834            "scan.json",
835            "--classify",
836            "--summary",
837            "--license-clarity-score",
838            "--tallies",
839            "--tallies-key-files",
840            "--tallies-with-details",
841            "--generated",
842            "samples",
843        ])
844        .expect("cli parse should succeed");
845
846        assert!(parsed.classify);
847        assert!(parsed.summary);
848        assert!(parsed.license_clarity_score);
849        assert!(parsed.tallies);
850        assert!(parsed.tallies_key_files);
851        assert!(parsed.tallies_with_details);
852        assert!(parsed.generated);
853    }
854
855    #[test]
856    fn test_parses_copyright_flag() {
857        let parsed = Cli::try_parse_from([
858            "provenant",
859            "--json-pp",
860            "scan.json",
861            "--copyright",
862            "samples",
863        ])
864        .expect("cli parse should succeed");
865
866        assert!(parsed.copyright);
867    }
868
869    #[test]
870    fn test_package_flag_defaults_to_disabled() {
871        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
872            .expect("cli parse should succeed");
873
874        assert!(!parsed.package);
875    }
876
877    #[test]
878    fn test_parses_system_package_flag() {
879        let parsed = Cli::try_parse_from([
880            "provenant",
881            "--json-pp",
882            "scan.json",
883            "--system-package",
884            "samples",
885        ])
886        .expect("cli parse should succeed");
887
888        assert!(parsed.system_package);
889    }
890
891    #[test]
892    fn test_parses_package_in_compiled_flag() {
893        let parsed = Cli::try_parse_from([
894            "provenant",
895            "--json-pp",
896            "scan.json",
897            "--package-in-compiled",
898            "samples",
899        ])
900        .expect("cli parse should succeed");
901
902        assert!(parsed.package_in_compiled);
903    }
904
905    #[test]
906    fn test_parses_package_only_flag() {
907        let parsed = Cli::try_parse_from([
908            "provenant",
909            "--json-pp",
910            "scan.json",
911            "--package-only",
912            "samples",
913        ])
914        .expect("cli parse should succeed");
915
916        assert!(parsed.package_only);
917    }
918
919    #[test]
920    fn test_package_only_conflicts_with_upstream_incompatible_flags() {
921        let with_license = Cli::try_parse_from([
922            "provenant",
923            "--json-pp",
924            "scan.json",
925            "--package-only",
926            "--license",
927            "samples",
928        ]);
929        assert!(with_license.is_err());
930
931        let with_package = Cli::try_parse_from([
932            "provenant",
933            "--json-pp",
934            "scan.json",
935            "--package-only",
936            "--package",
937            "samples",
938        ]);
939        assert!(with_package.is_err());
940    }
941
942    #[test]
943    fn test_parses_package_flag() {
944        let parsed = Cli::try_parse_from([
945            "provenant",
946            "--json-pp",
947            "scan.json",
948            "--package",
949            "samples",
950        ])
951        .expect("cli parse should succeed");
952
953        assert!(parsed.package);
954    }
955
956    #[test]
957    fn test_package_short_flag() {
958        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-p", "samples"])
959            .expect("cli parse should succeed");
960
961        assert!(parsed.package);
962    }
963
964    #[test]
965    fn test_parses_license_flag() {
966        let parsed = Cli::try_parse_from([
967            "provenant",
968            "--json-pp",
969            "scan.json",
970            "--license",
971            "samples",
972        ])
973        .expect("cli parse should succeed");
974
975        assert!(parsed.license);
976    }
977
978    #[test]
979    fn test_license_short_flag() {
980        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-l", "samples"])
981            .expect("cli parse should succeed");
982
983        assert!(parsed.license);
984    }
985
986    #[test]
987    fn test_license_text_requires_license() {
988        let result = Cli::try_parse_from([
989            "provenant",
990            "--json-pp",
991            "scan.json",
992            "--license-text",
993            "samples",
994        ]);
995        assert!(result.is_err());
996    }
997
998    #[test]
999    fn test_include_text_is_rejected() {
1000        let result = Cli::try_parse_from([
1001            "provenant",
1002            "--json-pp",
1003            "scan.json",
1004            "--license",
1005            "--include-text",
1006            "samples",
1007        ]);
1008
1009        assert!(result.is_err());
1010    }
1011
1012    #[test]
1013    fn test_license_text_diagnostics_requires_license_text() {
1014        let result = Cli::try_parse_from([
1015            "provenant",
1016            "--json-pp",
1017            "scan.json",
1018            "--license",
1019            "--license-text-diagnostics",
1020            "samples",
1021        ]);
1022
1023        assert!(result.is_err());
1024    }
1025
1026    #[test]
1027    fn test_parses_license_text_and_diagnostics_flags() {
1028        let parsed = Cli::try_parse_from([
1029            "provenant",
1030            "--json-pp",
1031            "scan.json",
1032            "--license",
1033            "--license-text",
1034            "--license-text-diagnostics",
1035            "--license-diagnostics",
1036            "--unknown-licenses",
1037            "samples",
1038        ])
1039        .expect("cli parse should succeed");
1040
1041        assert!(parsed.license_text);
1042        assert!(parsed.license_text_diagnostics);
1043        assert!(parsed.license_diagnostics);
1044        assert!(parsed.unknown_licenses);
1045        assert_eq!(parsed.license_score, 0);
1046        assert_eq!(parsed.license_url_template, DEFAULT_LICENSEDB_URL_TEMPLATE);
1047    }
1048
1049    #[test]
1050    fn test_license_score_requires_license() {
1051        let result = Cli::try_parse_from([
1052            "provenant",
1053            "--json-pp",
1054            "scan.json",
1055            "--license-score",
1056            "70",
1057            "samples",
1058        ]);
1059
1060        assert!(result.is_err());
1061    }
1062
1063    #[test]
1064    fn test_license_url_template_requires_license() {
1065        let result = Cli::try_parse_from([
1066            "provenant",
1067            "--json-pp",
1068            "scan.json",
1069            "--license-url-template",
1070            "https://example.com/licenses/{}/",
1071            "samples",
1072        ]);
1073
1074        assert!(result.is_err());
1075    }
1076
1077    #[test]
1078    fn test_parses_license_score_and_url_template_flags() {
1079        let parsed = Cli::try_parse_from([
1080            "provenant",
1081            "--json-pp",
1082            "scan.json",
1083            "--license",
1084            "--license-score",
1085            "70",
1086            "--license-url-template",
1087            "https://example.com/licenses/{}/",
1088            "samples",
1089        ])
1090        .expect("cli parse should succeed");
1091
1092        assert_eq!(parsed.license_score, 70);
1093        assert_eq!(
1094            parsed.license_url_template,
1095            "https://example.com/licenses/{}/"
1096        );
1097    }
1098
1099    #[test]
1100    fn test_rejects_license_score_above_range() {
1101        let result = Cli::try_parse_from([
1102            "provenant",
1103            "--json-pp",
1104            "scan.json",
1105            "--license",
1106            "--license-score",
1107            "101",
1108            "samples",
1109        ]);
1110
1111        assert!(result.is_err());
1112    }
1113
1114    #[test]
1115    fn test_license_references_requires_license() {
1116        let result = Cli::try_parse_from([
1117            "provenant",
1118            "--json-pp",
1119            "scan.json",
1120            "--license-references",
1121            "samples",
1122        ]);
1123
1124        assert!(result.is_err());
1125    }
1126
1127    #[test]
1128    fn test_parses_license_references_flag() {
1129        let parsed = Cli::try_parse_from([
1130            "provenant",
1131            "--json-pp",
1132            "scan.json",
1133            "--license",
1134            "--license-references",
1135            "samples",
1136        ])
1137        .expect("cli parse should succeed");
1138
1139        assert!(parsed.license_references);
1140    }
1141
1142    #[test]
1143    fn test_include_text_alias_is_not_supported() {
1144        let result = Cli::try_parse_from([
1145            "provenant",
1146            "--json-pp",
1147            "scan.json",
1148            "--license",
1149            "--include-text",
1150            "samples",
1151        ]);
1152
1153        assert!(result.is_err());
1154    }
1155
1156    #[test]
1157    fn test_parses_short_scan_flags() {
1158        let parsed = Cli::try_parse_from([
1159            "provenant",
1160            "--json-pp",
1161            "scan.json",
1162            "-c",
1163            "-e",
1164            "-u",
1165            "samples",
1166        ])
1167        .expect("cli parse should support short scan flags");
1168
1169        assert!(parsed.copyright);
1170        assert!(parsed.email);
1171        assert!(parsed.url);
1172    }
1173
1174    #[test]
1175    fn test_parses_processes_compat_values_zero_and_minus_one() {
1176        let zero =
1177            Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "0", "samples"])
1178                .expect("cli parse should accept processes=0");
1179        assert_eq!(zero.processes, 0);
1180
1181        let parsed =
1182            Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "-1", "samples"])
1183                .expect("cli parse should accept processes=-1");
1184        assert_eq!(parsed.processes, -1);
1185    }
1186
1187    #[test]
1188    fn test_parses_cache_flags() {
1189        let parsed = Cli::try_parse_from([
1190            "provenant",
1191            "--json-pp",
1192            "scan.json",
1193            "--cache",
1194            "scan-results",
1195            "--cache-dir",
1196            "/tmp/sc-cache",
1197            "--cache-clear",
1198            "--max-in-memory",
1199            "5000",
1200            "samples",
1201        ])
1202        .expect("cli parse should accept cache flags");
1203
1204        assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/sc-cache"));
1205        assert_eq!(parsed.cache, vec![CacheKind::ScanResults]);
1206        assert!(parsed.cache_clear);
1207        assert_eq!(parsed.max_in_memory, 5000);
1208    }
1209
1210    #[test]
1211    fn test_max_in_memory_defaults_and_special_values() {
1212        let default_parsed =
1213            Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
1214                .expect("default max-in-memory should parse");
1215        assert_eq!(default_parsed.max_in_memory, 10000);
1216
1217        let disk_only = Cli::try_parse_from([
1218            "provenant",
1219            "--json-pp",
1220            "scan.json",
1221            "--max-in-memory",
1222            "-1",
1223            "samples",
1224        ])
1225        .expect("-1 should parse");
1226        assert_eq!(disk_only.max_in_memory, -1);
1227
1228        let unlimited = Cli::try_parse_from([
1229            "provenant",
1230            "--json-pp",
1231            "scan.json",
1232            "--max-in-memory",
1233            "0",
1234            "samples",
1235        ])
1236        .expect("0 should parse");
1237        assert_eq!(unlimited.max_in_memory, 0);
1238    }
1239
1240    #[test]
1241    fn test_max_in_memory_rejects_values_below_negative_one() {
1242        let result = Cli::try_parse_from([
1243            "provenant",
1244            "--json-pp",
1245            "scan.json",
1246            "--max-in-memory",
1247            "-2",
1248            "samples",
1249        ]);
1250
1251        assert!(result.is_err());
1252    }
1253
1254    #[test]
1255    fn test_parses_cache_alias_flag() {
1256        let parsed = Cli::try_parse_from([
1257            "provenant",
1258            "--json-pp",
1259            "scan.json",
1260            "--cache",
1261            "scan",
1262            "samples",
1263        ])
1264        .expect("cli parse should accept cache=scan alias");
1265
1266        assert_eq!(parsed.cache, vec![CacheKind::ScanResults]);
1267    }
1268
1269    #[test]
1270    fn test_max_depth_default_matches_reference_behavior() {
1271        let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
1272            .expect("cli parse should succeed");
1273
1274        assert_eq!(parsed.max_depth, 0);
1275    }
1276}