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