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