1use clap::{ArgGroup, Parser};
2use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue};
3use std::fs;
4use std::path::Path;
5use yaml_serde::Value as YamlValue;
6
7use crate::license_detection::DEFAULT_LICENSEDB_URL_TEMPLATE;
8use crate::output::OutputFormat;
9use crate::scanner::MemoryMode;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ProcessMode {
13 Parallel(usize),
14 SequentialWithTimeouts,
15 SequentialWithoutTimeouts,
16}
17
18impl Default for ProcessMode {
19 fn default() -> Self {
20 let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
21 if cpus > 1 {
22 ProcessMode::Parallel(cpus - 1)
23 } else {
24 ProcessMode::Parallel(1)
25 }
26 }
27}
28
29impl ProcessMode {
30 fn default_value() -> Self {
31 let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
32 if cpus > 1 {
33 ProcessMode::Parallel(cpus - 1)
34 } else {
35 ProcessMode::Parallel(1)
36 }
37 }
38
39 pub fn to_i32(self) -> i32 {
40 match self {
41 ProcessMode::Parallel(n) => n as i32,
42 ProcessMode::SequentialWithTimeouts => 0,
43 ProcessMode::SequentialWithoutTimeouts => -1,
44 }
45 }
46}
47
48impl std::fmt::Display for ProcessMode {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 write!(f, "{}", self.to_i32())
51 }
52}
53
54fn parse_processes(value: &str) -> Result<ProcessMode, String> {
55 let parsed: i32 = value
56 .parse()
57 .map_err(|e| format!("invalid integer for --processes: {e}"))?;
58 if parsed > 0 {
59 Ok(ProcessMode::Parallel(
60 u32::try_from(parsed).unwrap() as usize
61 ))
62 } else if parsed == 0 {
63 Ok(ProcessMode::SequentialWithTimeouts)
64 } else {
65 Ok(ProcessMode::SequentialWithoutTimeouts)
66 }
67}
68
69const 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).";
70
71fn parse_license_policy_arg(value: &str) -> Result<String, String> {
72 let policy_path = Path::new(value);
73 let metadata = fs::metadata(policy_path).map_err(|err| {
74 format!(
75 "Failed to read license policy file {:?}: {err}",
76 policy_path
77 )
78 })?;
79 if !metadata.is_file() {
80 return Err(format!(
81 "License policy path {:?} is not a regular file",
82 policy_path
83 ));
84 }
85
86 let policy_text = fs::read_to_string(policy_path).map_err(|err| {
87 format!(
88 "Failed to read license policy file {:?}: {err}",
89 policy_path
90 )
91 })?;
92 if policy_text.trim().is_empty() {
93 return Err(format!("License policy file {:?} is empty", policy_path));
94 }
95
96 let policy_value: YamlValue = yaml_serde::from_str(&policy_text).map_err(|err| {
97 format!(
98 "Failed to parse license policy file {:?}: {err}",
99 policy_path
100 )
101 })?;
102 let has_license_policies = policy_value
103 .as_mapping()
104 .and_then(|mapping| mapping.get(YamlValue::String("license_policies".to_string())))
105 .is_some();
106 if !has_license_policies {
107 return Err(format!(
108 "License policy file {:?} is missing a 'license_policies' attribute",
109 policy_path
110 ));
111 }
112
113 Ok(value.to_string())
114}
115
116#[derive(Parser, Debug)]
117#[command(
118 author = "The Provenant contributors",
119 version = crate::version::BUILD_VERSION,
120 long_version = crate::version::build_long_version(),
121 after_help = PDF_OXIDE_LOG_HELP,
122 about,
123 long_about = None,
124 group(
125 ArgGroup::new("output")
126 .required(true)
127 .multiple(true)
128 .args([
129 "output_json",
130 "output_json_pp",
131 "output_json_lines",
132 "output_yaml",
133 "output_debian",
134 "output_html",
135 "output_spdx_tv",
136 "output_spdx_rdf",
137 "output_cyclonedx",
138 "output_cyclonedx_xml",
139 "custom_output",
140 "show_attribution"
141 ])
142 )
143)]
144pub struct Cli {
145 #[arg(required = false)]
147 pub dir_path: Vec<String>,
148
149 #[arg(long = "json", value_name = "FILE", allow_hyphen_values = true)]
151 pub output_json: Option<String>,
152
153 #[arg(long = "json-pp", value_name = "FILE", allow_hyphen_values = true)]
155 pub output_json_pp: Option<String>,
156
157 #[arg(long = "json-lines", value_name = "FILE", allow_hyphen_values = true)]
159 pub output_json_lines: Option<String>,
160
161 #[arg(long = "yaml", value_name = "FILE", allow_hyphen_values = true)]
163 pub output_yaml: Option<String>,
164
165 #[arg(
167 long = "debian",
168 value_name = "FILE",
169 allow_hyphen_values = true,
170 requires_all = ["copyright", "license", "license_text"]
171 )]
172 pub output_debian: Option<String>,
173
174 #[arg(long = "html", value_name = "FILE", allow_hyphen_values = true)]
176 pub output_html: Option<String>,
177
178 #[arg(long = "spdx-tv", value_name = "FILE", allow_hyphen_values = true)]
180 pub output_spdx_tv: Option<String>,
181
182 #[arg(long = "spdx-rdf", value_name = "FILE", allow_hyphen_values = true)]
184 pub output_spdx_rdf: Option<String>,
185
186 #[arg(long = "cyclonedx", value_name = "FILE", allow_hyphen_values = true)]
188 pub output_cyclonedx: Option<String>,
189
190 #[arg(
192 long = "cyclonedx-xml",
193 value_name = "FILE",
194 allow_hyphen_values = true
195 )]
196 pub output_cyclonedx_xml: Option<String>,
197
198 #[arg(
200 long = "custom-output",
201 value_name = "FILE",
202 requires = "custom_template",
203 allow_hyphen_values = true
204 )]
205 pub custom_output: Option<String>,
206
207 #[arg(
209 long = "custom-template",
210 value_name = "FILE",
211 requires = "custom_output"
212 )]
213 pub custom_template: Option<String>,
214
215 #[arg(short, long, default_value = "0")]
217 pub max_depth: usize,
218
219 #[arg(short = 'n', long, default_value_t = ProcessMode::default_value(), value_parser = parse_processes, allow_hyphen_values = true)]
220 pub processes: ProcessMode,
221
222 #[arg(long, default_value_t = 120.0)]
223 pub timeout: f64,
224
225 #[arg(short, long, conflicts_with = "verbose")]
226 pub quiet: bool,
227
228 #[arg(short, long, conflicts_with = "quiet")]
229 pub verbose: bool,
230
231 #[arg(long, conflicts_with = "full_root")]
232 pub strip_root: bool,
233
234 #[arg(long, conflicts_with = "strip_root")]
235 pub full_root: bool,
236
237 #[arg(long = "exclude", visible_alias = "ignore", value_delimiter = ',')]
239 pub exclude: Vec<String>,
240
241 #[arg(long, value_delimiter = ',')]
242 pub include: Vec<String>,
243
244 #[arg(long = "cache-dir", value_name = "PATH")]
245 pub cache_dir: Option<String>,
246
247 #[arg(long = "cache-clear")]
248 pub cache_clear: bool,
249
250 #[arg(long = "incremental")]
251 pub incremental: bool,
252
253 #[arg(
256 long = "max-in-memory",
257 value_name = "INT",
258 default_value_t = MemoryMode::Limit(10000),
259 value_parser = parse_max_in_memory,
260 allow_hyphen_values = true
261 )]
262 pub max_in_memory: MemoryMode,
263
264 #[arg(short = 'i', long)]
266 pub info: bool,
267
268 #[arg(long)]
270 pub from_json: bool,
271
272 #[arg(short = 'p', long)]
274 pub package: bool,
275
276 #[arg(long = "system-package")]
278 pub system_package: bool,
279
280 #[arg(long = "package-in-compiled")]
282 pub package_in_compiled: bool,
283
284 #[arg(
286 long = "package-only",
287 conflicts_with_all = ["license", "summary", "package", "system_package"]
288 )]
289 pub package_only: bool,
290
291 #[arg(long)]
293 pub no_assemble: bool,
294
295 #[arg(long, value_name = "PATH", requires = "license")]
298 pub license_rules_path: Option<String>,
299
300 #[arg(long = "license-text", requires = "license")]
302 pub license_text: bool,
303
304 #[arg(long = "license-text-diagnostics", requires = "license_text")]
305 pub license_text_diagnostics: bool,
306
307 #[arg(long = "license-diagnostics", requires = "license")]
308 pub license_diagnostics: bool,
309
310 #[arg(long = "unknown-licenses", requires = "license")]
311 pub unknown_licenses: bool,
312
313 #[arg(
314 long = "license-score",
315 default_value_t = 0,
316 requires = "license",
317 value_parser = clap::value_parser!(u8).range(0..=100)
318 )]
319 pub license_score: u8,
320
321 #[arg(
322 long = "license-url-template",
323 default_value = DEFAULT_LICENSEDB_URL_TEMPLATE,
324 requires = "license"
325 )]
326 pub license_url_template: String,
327
328 #[arg(long)]
329 pub filter_clues: bool,
330
331 #[arg(
332 long = "ignore-author",
333 value_name = "PATTERN",
334 help = "Ignore a file and all its findings if an author matches the regex PATTERN"
335 )]
336 pub ignore_author: Vec<String>,
337
338 #[arg(
339 long = "ignore-copyright-holder",
340 value_name = "PATTERN",
341 help = "Ignore a file and all its findings if a copyright holder matches the regex PATTERN"
342 )]
343 pub ignore_copyright_holder: Vec<String>,
344
345 #[arg(long)]
346 pub only_findings: bool,
347
348 #[arg(long, requires = "info")]
349 pub mark_source: bool,
350
351 #[arg(long)]
352 pub classify: bool,
353
354 #[arg(long, requires = "classify")]
355 pub summary: bool,
356
357 #[arg(long = "license-clarity-score", requires = "classify")]
358 pub license_clarity_score: bool,
359
360 #[arg(long = "license-references", requires = "license")]
361 pub license_references: bool,
362
363 #[arg(
365 long = "license-policy",
366 value_name = "FILE",
367 value_parser = parse_license_policy_arg
368 )]
369 pub license_policy: Option<String>,
370
371 #[arg(long)]
372 pub tallies: bool,
373
374 #[arg(long = "tallies-key-files", requires_all = ["tallies", "classify"])]
375 pub tallies_key_files: bool,
376
377 #[arg(long = "tallies-with-details")]
378 pub tallies_with_details: bool,
379
380 #[arg(long = "facet", value_name = "<facet>=<pattern>")]
381 pub facet: Vec<String>,
382
383 #[arg(long = "tallies-by-facet", requires_all = ["facet", "tallies"])]
384 pub tallies_by_facet: bool,
385
386 #[arg(long)]
387 pub generated: bool,
388
389 #[arg(short = 'l', long)]
391 pub license: bool,
392
393 #[arg(short = 'c', long)]
394 pub copyright: bool,
395
396 #[arg(short = 'e', long)]
398 pub email: bool,
399
400 #[arg(long, default_value_t = 50, requires = "email")]
402 pub max_email: usize,
403
404 #[arg(short = 'u', long)]
406 pub url: bool,
407
408 #[arg(long, default_value_t = 50, requires = "url")]
410 pub max_url: usize,
411
412 #[arg(
414 long,
415 conflicts_with_all = [
416 "output_json",
417 "output_json_pp",
418 "output_json_lines",
419 "output_yaml",
420 "output_debian",
421 "output_html",
422 "output_spdx_tv",
423 "output_spdx_rdf",
424 "output_cyclonedx",
425 "output_cyclonedx_xml",
426 "custom_output"
427 ]
428 )]
429 pub show_attribution: bool,
430}
431
432fn parse_max_in_memory(value: &str) -> Result<MemoryMode, String> {
433 let parsed = value
434 .parse::<i64>()
435 .map_err(|_| format!("invalid integer value: {value}"))?;
436 if parsed < -1 {
437 return Err("--max-in-memory must be -1, 0, or a positive integer".to_string());
438 }
439 match parsed {
440 -1 => Ok(MemoryMode::StreamUnlimited),
441 0 => Ok(MemoryMode::CollectFirst),
442 n if n > 0 => Ok(MemoryMode::Limit(usize::try_from(n).unwrap_or(usize::MAX))),
443 _ => Ok(MemoryMode::CollectFirst),
444 }
445}
446
447#[derive(Debug, Clone)]
448pub struct OutputTarget {
449 pub format: OutputFormat,
450 pub file: String,
451 pub custom_template: Option<String>,
452}
453
454impl Cli {
455 pub fn output_targets(&self) -> Vec<OutputTarget> {
456 let mut targets = Vec::new();
457
458 if let Some(file) = &self.output_json {
459 targets.push(OutputTarget {
460 format: OutputFormat::Json,
461 file: file.clone(),
462 custom_template: None,
463 });
464 }
465
466 if let Some(file) = &self.output_json_pp {
467 targets.push(OutputTarget {
468 format: OutputFormat::JsonPretty,
469 file: file.clone(),
470 custom_template: None,
471 });
472 }
473
474 if let Some(file) = &self.output_json_lines {
475 targets.push(OutputTarget {
476 format: OutputFormat::JsonLines,
477 file: file.clone(),
478 custom_template: None,
479 });
480 }
481
482 if let Some(file) = &self.output_yaml {
483 targets.push(OutputTarget {
484 format: OutputFormat::Yaml,
485 file: file.clone(),
486 custom_template: None,
487 });
488 }
489
490 if let Some(file) = &self.output_debian {
491 targets.push(OutputTarget {
492 format: OutputFormat::Debian,
493 file: file.clone(),
494 custom_template: None,
495 });
496 }
497
498 if let Some(file) = &self.output_html {
499 targets.push(OutputTarget {
500 format: OutputFormat::Html,
501 file: file.clone(),
502 custom_template: None,
503 });
504 }
505
506 if let Some(file) = &self.output_spdx_tv {
507 targets.push(OutputTarget {
508 format: OutputFormat::SpdxTv,
509 file: file.clone(),
510 custom_template: None,
511 });
512 }
513
514 if let Some(file) = &self.output_spdx_rdf {
515 targets.push(OutputTarget {
516 format: OutputFormat::SpdxRdf,
517 file: file.clone(),
518 custom_template: None,
519 });
520 }
521
522 if let Some(file) = &self.output_cyclonedx {
523 targets.push(OutputTarget {
524 format: OutputFormat::CycloneDxJson,
525 file: file.clone(),
526 custom_template: None,
527 });
528 }
529
530 if let Some(file) = &self.output_cyclonedx_xml {
531 targets.push(OutputTarget {
532 format: OutputFormat::CycloneDxXml,
533 file: file.clone(),
534 custom_template: None,
535 });
536 }
537
538 if let Some(file) = &self.custom_output {
539 targets.push(OutputTarget {
540 format: OutputFormat::CustomTemplate,
541 file: file.clone(),
542 custom_template: self.custom_template.clone(),
543 });
544 }
545
546 targets
547 }
548
549 pub fn output_header_options(&self) -> JsonMap<String, JsonValue> {
550 let mut options = JsonMap::new();
551 if !self.dir_path.is_empty() {
552 options.insert(
553 "input".to_string(),
554 JsonValue::Array(
555 self.dir_path
556 .iter()
557 .cloned()
558 .map(JsonValue::String)
559 .collect(),
560 ),
561 );
562 }
563
564 let mut flags = Vec::new();
565
566 push_string_option(&mut flags, "--cache-dir", self.cache_dir.as_ref());
567 push_bool_option(&mut flags, "--cache-clear", self.cache_clear);
568 push_bool_option(&mut flags, "--classify", self.classify);
569 push_string_option(&mut flags, "--custom-output", self.custom_output.as_ref());
570 push_string_option(
571 &mut flags,
572 "--custom-template",
573 self.custom_template.as_ref(),
574 );
575 push_bool_option(&mut flags, "--copyright", self.copyright);
576 push_string_option(&mut flags, "--cyclonedx", self.output_cyclonedx.as_ref());
577 push_string_option(
578 &mut flags,
579 "--cyclonedx-xml",
580 self.output_cyclonedx_xml.as_ref(),
581 );
582 push_string_option(&mut flags, "--debian", self.output_debian.as_ref());
583 push_bool_option(&mut flags, "--email", self.email);
584 push_array_option(&mut flags, "--facet", &self.facet);
585 push_bool_option(&mut flags, "--filter-clues", self.filter_clues);
586 push_bool_option(&mut flags, "--from-json", self.from_json);
587 push_bool_option(&mut flags, "--full-root", self.full_root);
588 push_bool_option(&mut flags, "--generated", self.generated);
589 push_string_option(&mut flags, "--html", self.output_html.as_ref());
590 push_array_option(&mut flags, "--ignore", &self.exclude);
591 push_array_option(&mut flags, "--ignore-author", &self.ignore_author);
592 push_array_option(
593 &mut flags,
594 "--ignore-copyright-holder",
595 &self.ignore_copyright_holder,
596 );
597 push_bool_option(&mut flags, "--incremental", self.incremental);
598 push_array_option(&mut flags, "--include", &self.include);
599 push_bool_option(&mut flags, "--info", self.info);
600 push_string_option(&mut flags, "--json", self.output_json.as_ref());
601 push_string_option(&mut flags, "--json-lines", self.output_json_lines.as_ref());
602 push_string_option(&mut flags, "--json-pp", self.output_json_pp.as_ref());
603 push_bool_option(&mut flags, "--license", self.license);
604 push_bool_option(
605 &mut flags,
606 "--license-clarity-score",
607 self.license_clarity_score,
608 );
609 push_bool_option(
610 &mut flags,
611 "--license-diagnostics",
612 self.license_diagnostics,
613 );
614 push_string_option(&mut flags, "--license-policy", self.license_policy.as_ref());
615 push_bool_option(&mut flags, "--license-references", self.license_references);
616 push_non_default_u8_option(&mut flags, "--license-score", self.license_score, 0);
617 push_bool_option(&mut flags, "--license-text", self.license_text);
618 push_bool_option(
619 &mut flags,
620 "--license-text-diagnostics",
621 self.license_text_diagnostics,
622 );
623 push_non_default_string_option(
624 &mut flags,
625 "--license-url-template",
626 &self.license_url_template,
627 DEFAULT_LICENSEDB_URL_TEMPLATE,
628 );
629 push_non_default_usize_option(&mut flags, "--max-depth", self.max_depth, 0);
630 match self.max_in_memory {
631 MemoryMode::Limit(10000) => {}
632 MemoryMode::CollectFirst => {
633 flags.push(("--max-in-memory".to_string(), JsonValue::Number(0.into())));
634 }
635 MemoryMode::StreamUnlimited => {
636 flags.push((
637 "--max-in-memory".to_string(),
638 JsonValue::Number((-1i64).into()),
639 ));
640 }
641 MemoryMode::Limit(n) => {
642 flags.push(("--max-in-memory".to_string(), JsonValue::Number(n.into())));
643 }
644 }
645 if self.email {
646 push_non_default_usize_option(&mut flags, "--max-email", self.max_email, 50);
647 }
648 if self.url {
649 push_non_default_usize_option(&mut flags, "--max-url", self.max_url, 50);
650 }
651 push_bool_option(&mut flags, "--mark-source", self.mark_source);
652 push_bool_option(&mut flags, "--no-assemble", self.no_assemble);
653 push_bool_option(&mut flags, "--only-findings", self.only_findings);
654 push_bool_option(&mut flags, "--package", self.package);
655 push_bool_option(
656 &mut flags,
657 "--package-in-compiled",
658 self.package_in_compiled,
659 );
660 push_bool_option(&mut flags, "--package-only", self.package_only);
661 push_non_default_process_mode_option(
662 &mut flags,
663 "--processes",
664 self.processes,
665 ProcessMode::default_value(),
666 );
667 push_bool_option(&mut flags, "--quiet", self.quiet);
668 push_string_option(&mut flags, "--spdx-rdf", self.output_spdx_rdf.as_ref());
669 push_string_option(&mut flags, "--spdx-tv", self.output_spdx_tv.as_ref());
670 push_bool_option(&mut flags, "--strip-root", self.strip_root);
671 push_bool_option(&mut flags, "--summary", self.summary);
672 push_bool_option(&mut flags, "--system-package", self.system_package);
673 push_bool_option(&mut flags, "--tallies", self.tallies);
674 push_bool_option(&mut flags, "--tallies-by-facet", self.tallies_by_facet);
675 push_bool_option(&mut flags, "--tallies-key-files", self.tallies_key_files);
676 push_bool_option(
677 &mut flags,
678 "--tallies-with-details",
679 self.tallies_with_details,
680 );
681 push_non_default_f64_option(&mut flags, "--timeout", self.timeout, 120.0);
682 push_bool_option(&mut flags, "--unknown-licenses", self.unknown_licenses);
683 push_bool_option(&mut flags, "--url", self.url);
684 push_bool_option(&mut flags, "--verbose", self.verbose);
685 push_string_option(&mut flags, "--yaml", self.output_yaml.as_ref());
686
687 flags.sort_by(|left, right| left.0.cmp(&right.0));
688 for (key, value) in flags {
689 options.insert(key, value);
690 }
691
692 options
693 }
694}
695
696fn push_bool_option(options: &mut Vec<(String, JsonValue)>, key: &str, enabled: bool) {
697 if enabled {
698 options.push((key.to_string(), JsonValue::Bool(true)));
699 }
700}
701
702fn push_string_option(options: &mut Vec<(String, JsonValue)>, key: &str, value: Option<&String>) {
703 if let Some(value) = value {
704 options.push((key.to_string(), JsonValue::String(value.clone())));
705 }
706}
707
708fn push_non_default_string_option(
709 options: &mut Vec<(String, JsonValue)>,
710 key: &str,
711 value: &str,
712 default: &str,
713) {
714 if value != default {
715 options.push((key.to_string(), JsonValue::String(value.to_string())));
716 }
717}
718
719fn push_array_option(options: &mut Vec<(String, JsonValue)>, key: &str, values: &[String]) {
720 if !values.is_empty() {
721 options.push((
722 key.to_string(),
723 JsonValue::Array(values.iter().cloned().map(JsonValue::String).collect()),
724 ));
725 }
726}
727
728fn push_non_default_usize_option(
729 options: &mut Vec<(String, JsonValue)>,
730 key: &str,
731 value: usize,
732 default: usize,
733) {
734 if value != default {
735 options.push((key.to_string(), JsonValue::Number(value.into())));
736 }
737}
738
739fn push_non_default_u8_option(
740 options: &mut Vec<(String, JsonValue)>,
741 key: &str,
742 value: u8,
743 default: u8,
744) {
745 if value != default {
746 options.push((key.to_string(), JsonValue::Number(value.into())));
747 }
748}
749
750fn push_non_default_process_mode_option(
751 options: &mut Vec<(String, JsonValue)>,
752 key: &str,
753 value: ProcessMode,
754 default: ProcessMode,
755) {
756 if value != default {
757 options.push((key.to_string(), JsonValue::Number(value.to_i32().into())));
758 }
759}
760
761fn push_non_default_f64_option(
762 options: &mut Vec<(String, JsonValue)>,
763 key: &str,
764 value: f64,
765 default: f64,
766) {
767 if (value - default).abs() > f64::EPSILON
768 && let Some(number) = JsonNumber::from_f64(value)
769 {
770 options.push((key.to_string(), JsonValue::Number(number)));
771 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use clap::CommandFactory;
778
779 #[test]
780 fn test_requires_at_least_one_output_option() {
781 let parsed = Cli::try_parse_from(["provenant", "samples"]);
782 assert!(parsed.is_err());
783 }
784
785 #[test]
786 fn test_parses_json_pretty_output_option() {
787 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
788 .expect("cli parse should succeed");
789
790 assert_eq!(parsed.output_json_pp.as_deref(), Some("scan.json"));
791 assert_eq!(parsed.output_targets().len(), 1);
792 assert_eq!(parsed.output_targets()[0].format, OutputFormat::JsonPretty);
793 }
794
795 #[test]
796 fn test_allows_multiple_output_options_in_one_run() {
797 let parsed = Cli::try_parse_from([
798 "provenant",
799 "--json",
800 "scan.json",
801 "--html",
802 "report.html",
803 "samples",
804 ])
805 .expect("cli parse should allow multiple outputs");
806
807 assert_eq!(parsed.output_targets().len(), 2);
808 assert_eq!(parsed.output_targets()[0].format, OutputFormat::Json);
809 assert_eq!(parsed.output_targets()[1].format, OutputFormat::Html);
810 }
811
812 #[test]
813 fn test_show_attribution_conflicts_with_output_flags() {
814 let parsed = Cli::try_parse_from([
815 "provenant",
816 "--show-attribution",
817 "--json",
818 "scan.json",
819 "samples",
820 ]);
821 assert!(parsed.is_err());
822 }
823
824 #[test]
825 fn test_output_header_options_use_scancode_style_keys() {
826 let parsed = Cli::try_parse_from([
827 "provenant",
828 "--json-pp",
829 "scan.json",
830 "--license",
831 "--package",
832 "--strip-root",
833 "--ignore",
834 "*.git*",
835 "--ignore",
836 "target/*",
837 "samples",
838 ])
839 .expect("cli parse should succeed");
840
841 let options = parsed.output_header_options();
842
843 assert_eq!(
844 options.get("input"),
845 Some(&JsonValue::Array(vec![JsonValue::String(
846 "samples".to_string()
847 )]))
848 );
849 assert_eq!(
850 options.get("--json-pp"),
851 Some(&JsonValue::String("scan.json".to_string()))
852 );
853 assert_eq!(options.get("--license"), Some(&JsonValue::Bool(true)));
854 assert_eq!(options.get("--package"), Some(&JsonValue::Bool(true)));
855 assert_eq!(options.get("--strip-root"), Some(&JsonValue::Bool(true)));
856 assert_eq!(
857 options.get("--ignore"),
858 Some(&JsonValue::Array(vec![
859 JsonValue::String("*.git*".to_string()),
860 JsonValue::String("target/*".to_string()),
861 ]))
862 );
863 }
864
865 #[test]
866 fn test_output_header_options_skip_defaults_and_include_non_defaults() {
867 let default_options =
868 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
869 .expect("default cli parse should succeed")
870 .output_header_options();
871 assert!(!default_options.contains_key("--timeout"));
872 assert!(!default_options.contains_key("--processes"));
873
874 let custom_options = Cli::try_parse_from([
875 "provenant",
876 "--json-pp",
877 "scan.json",
878 "--timeout",
879 "30",
880 "--processes",
881 "4",
882 "samples",
883 ])
884 .expect("custom cli parse should succeed")
885 .output_header_options();
886
887 assert_eq!(
888 custom_options.get("--timeout"),
889 Some(&JsonValue::Number(
890 JsonNumber::from_f64(30.0).expect("valid number")
891 ))
892 );
893 assert_eq!(
894 custom_options.get("--processes"),
895 Some(&JsonValue::Number(4.into()))
896 );
897 }
898
899 #[test]
900 fn test_allows_stdout_dash_as_output_target() {
901 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "-", "samples"])
902 .expect("cli parse should allow stdout dash output target");
903
904 assert_eq!(parsed.output_json_pp.as_deref(), Some("-"));
905 }
906
907 #[test]
908 fn test_debian_requires_license_copyright_and_license_text() {
909 let missing_license_text = Cli::try_parse_from([
910 "provenant",
911 "--debian",
912 "scan.copyright",
913 "--license",
914 "--copyright",
915 "samples",
916 ]);
917 assert!(missing_license_text.is_err());
918
919 let parsed = Cli::try_parse_from([
920 "provenant",
921 "--debian",
922 "scan.copyright",
923 "--license",
924 "--copyright",
925 "--license-text",
926 "samples",
927 ])
928 .expect("cli parse should accept debian output");
929
930 assert_eq!(parsed.output_targets().len(), 1);
931 assert_eq!(parsed.output_targets()[0].format, OutputFormat::Debian);
932 assert_eq!(parsed.output_debian.as_deref(), Some("scan.copyright"));
933 }
934
935 #[test]
936 fn test_debian_help_mentions_required_companion_flags() {
937 let command = Cli::command();
938 let debian_arg = command
939 .get_arguments()
940 .find(|arg| arg.get_long() == Some("debian"))
941 .expect("debian arg should exist");
942
943 let help = debian_arg
944 .get_help()
945 .expect("debian arg should have help text")
946 .to_string();
947
948 assert!(help.contains("requires --license, --copyright, and --license-text"));
949 }
950
951 #[test]
952 fn test_help_mentions_pdf_oxide_rust_log_escape_hatch() {
953 let help = Cli::command().render_help().to_string();
954
955 assert!(help.contains("RUST_LOG=pdf_oxide=warn"));
956 assert!(help.contains("suppresses noisy pdf_oxide logs by default"));
957 }
958
959 #[test]
960 fn test_parses_license_policy_flag() {
961 let temp = tempfile::tempdir().expect("temp dir");
962 let policy_path = temp.path().join("policy.yml");
963 std::fs::write(&policy_path, "license_policies: []\n").expect("policy written");
964
965 let parsed = Cli::try_parse_from([
966 "provenant",
967 "--json-pp",
968 "scan.json",
969 "--license-policy",
970 policy_path.to_str().expect("utf8 path"),
971 "samples",
972 ])
973 .expect("cli parse should accept license-policy");
974
975 assert_eq!(
976 parsed.license_policy.as_deref(),
977 Some(policy_path.to_str().expect("utf8 path"))
978 );
979 }
980
981 #[test]
982 fn test_rejects_invalid_license_policy_flag_value() {
983 let temp = tempfile::tempdir().expect("temp dir");
984 let policy_path = temp.path().join("policy.yml");
985 std::fs::write(&policy_path, "not_license_policies: []\n").expect("policy written");
986
987 let parsed = Cli::try_parse_from([
988 "provenant",
989 "--json-pp",
990 "scan.json",
991 "--license-policy",
992 policy_path.to_str().expect("utf8 path"),
993 "samples",
994 ]);
995
996 assert!(parsed.is_err());
997 }
998
999 #[test]
1000 fn test_custom_template_and_output_must_be_paired() {
1001 let missing_template =
1002 Cli::try_parse_from(["provenant", "--custom-output", "result.txt", "samples"]);
1003 assert!(missing_template.is_err());
1004
1005 let missing_output =
1006 Cli::try_parse_from(["provenant", "--custom-template", "tpl.tera", "samples"]);
1007 assert!(missing_output.is_err());
1008 }
1009
1010 #[test]
1011 fn test_parses_processes_and_timeout_options() {
1012 let parsed = Cli::try_parse_from([
1013 "provenant",
1014 "--json-pp",
1015 "scan.json",
1016 "-n",
1017 "4",
1018 "--timeout",
1019 "30",
1020 "samples",
1021 ])
1022 .expect("cli parse should succeed");
1023
1024 assert_eq!(parsed.processes, ProcessMode::Parallel(4));
1025 assert_eq!(parsed.timeout, 30.0);
1026 }
1027
1028 #[test]
1029 fn test_strip_root_conflicts_with_full_root() {
1030 let parsed = Cli::try_parse_from([
1031 "provenant",
1032 "--json-pp",
1033 "scan.json",
1034 "--strip-root",
1035 "--full-root",
1036 "samples",
1037 ]);
1038 assert!(parsed.is_err());
1039 }
1040
1041 #[test]
1042 fn test_parses_include_and_only_findings_and_filter_clues() {
1043 let parsed = Cli::try_parse_from([
1044 "provenant",
1045 "--json-pp",
1046 "scan.json",
1047 "--include",
1048 "src/**,Cargo.toml",
1049 "--only-findings",
1050 "--filter-clues",
1051 "samples",
1052 ])
1053 .expect("cli parse should succeed");
1054
1055 assert_eq!(parsed.include, vec!["src/**", "Cargo.toml"]);
1056 assert!(parsed.only_findings);
1057 assert!(parsed.filter_clues);
1058 }
1059
1060 #[test]
1061 fn test_parses_ignore_author_and_holder_filters() {
1062 let parsed = Cli::try_parse_from([
1063 "provenant",
1064 "--json-pp",
1065 "scan.json",
1066 "--ignore-author",
1067 "Jane.*",
1068 "--ignore-author",
1069 ".*Bot$",
1070 "--ignore-copyright-holder",
1071 "Example Corp",
1072 "samples",
1073 ])
1074 .expect("cli parse should succeed");
1075
1076 assert_eq!(parsed.ignore_author, vec!["Jane.*", ".*Bot$"]);
1077 assert_eq!(parsed.ignore_copyright_holder, vec!["Example Corp"]);
1078 }
1079
1080 #[test]
1081 fn test_parses_ignore_alias_for_exclude_patterns() {
1082 let parsed = Cli::try_parse_from([
1083 "provenant",
1084 "--json-pp",
1085 "scan.json",
1086 "--ignore",
1087 "*.git*,target/*",
1088 "samples",
1089 ])
1090 .expect("cli parse should accept --ignore alias");
1091
1092 assert_eq!(parsed.exclude, vec!["*.git*", "target/*"]);
1093 }
1094
1095 #[test]
1096 fn test_quiet_conflicts_with_verbose() {
1097 let parsed = Cli::try_parse_from([
1098 "provenant",
1099 "--json-pp",
1100 "scan.json",
1101 "--quiet",
1102 "--verbose",
1103 "samples",
1104 ]);
1105 assert!(parsed.is_err());
1106 }
1107
1108 #[test]
1109 fn test_parses_from_json_and_mark_source() {
1110 let parsed = Cli::try_parse_from([
1111 "provenant",
1112 "--json-pp",
1113 "scan.json",
1114 "--from-json",
1115 "--info",
1116 "--mark-source",
1117 "sample-scan.json",
1118 ])
1119 .expect("cli parse should succeed");
1120
1121 assert!(parsed.from_json);
1122 assert!(parsed.info);
1123 assert_eq!(parsed.dir_path, vec!["sample-scan.json"]);
1124 assert!(parsed.mark_source);
1125 }
1126
1127 #[test]
1128 fn test_mark_source_requires_info() {
1129 let parsed = Cli::try_parse_from([
1130 "provenant",
1131 "--json-pp",
1132 "scan.json",
1133 "--mark-source",
1134 "samples",
1135 ]);
1136
1137 assert!(parsed.is_err());
1138 }
1139
1140 #[test]
1141 fn test_parses_classify_facet_and_tallies_by_facet() {
1142 let parsed = Cli::try_parse_from([
1143 "provenant",
1144 "--json-pp",
1145 "scan.json",
1146 "--classify",
1147 "--tallies",
1148 "--facet",
1149 "dev=*.c",
1150 "--facet",
1151 "tests=*/tests/*",
1152 "--tallies-by-facet",
1153 "samples",
1154 ])
1155 .expect("cli parse should succeed");
1156
1157 assert!(parsed.classify);
1158 assert!(parsed.tallies);
1159 assert_eq!(parsed.facet, vec!["dev=*.c", "tests=*/tests/*"]);
1160 assert!(parsed.tallies_by_facet);
1161 }
1162
1163 #[test]
1164 fn test_tallies_by_facet_requires_facet_definitions() {
1165 let parsed = Cli::try_parse_from([
1166 "provenant",
1167 "--json-pp",
1168 "scan.json",
1169 "--tallies-by-facet",
1170 "samples",
1171 ]);
1172
1173 assert!(parsed.is_err());
1174 }
1175
1176 #[test]
1177 fn test_summary_requires_classify() {
1178 let parsed = Cli::try_parse_from([
1179 "provenant",
1180 "--json-pp",
1181 "scan.json",
1182 "--summary",
1183 "samples",
1184 ]);
1185
1186 assert!(parsed.is_err());
1187 }
1188
1189 #[test]
1190 fn test_tallies_key_files_requires_tallies_and_classify() {
1191 let parsed = Cli::try_parse_from([
1192 "provenant",
1193 "--json-pp",
1194 "scan.json",
1195 "--tallies-key-files",
1196 "samples",
1197 ]);
1198
1199 assert!(parsed.is_err());
1200 }
1201
1202 #[test]
1203 fn test_parses_summary_tallies_and_generated_flags() {
1204 let parsed = Cli::try_parse_from([
1205 "provenant",
1206 "--json-pp",
1207 "scan.json",
1208 "--classify",
1209 "--summary",
1210 "--license-clarity-score",
1211 "--tallies",
1212 "--tallies-key-files",
1213 "--tallies-with-details",
1214 "--generated",
1215 "samples",
1216 ])
1217 .expect("cli parse should succeed");
1218
1219 assert!(parsed.classify);
1220 assert!(parsed.summary);
1221 assert!(parsed.license_clarity_score);
1222 assert!(parsed.tallies);
1223 assert!(parsed.tallies_key_files);
1224 assert!(parsed.tallies_with_details);
1225 assert!(parsed.generated);
1226 }
1227
1228 #[test]
1229 fn test_parses_copyright_flag() {
1230 let parsed = Cli::try_parse_from([
1231 "provenant",
1232 "--json-pp",
1233 "scan.json",
1234 "--copyright",
1235 "samples",
1236 ])
1237 .expect("cli parse should succeed");
1238
1239 assert!(parsed.copyright);
1240 }
1241
1242 #[test]
1243 fn test_package_flag_defaults_to_disabled() {
1244 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
1245 .expect("cli parse should succeed");
1246
1247 assert!(!parsed.package);
1248 }
1249
1250 #[test]
1251 fn test_parses_system_package_flag() {
1252 let parsed = Cli::try_parse_from([
1253 "provenant",
1254 "--json-pp",
1255 "scan.json",
1256 "--system-package",
1257 "samples",
1258 ])
1259 .expect("cli parse should succeed");
1260
1261 assert!(parsed.system_package);
1262 }
1263
1264 #[test]
1265 fn test_parses_package_in_compiled_flag() {
1266 let parsed = Cli::try_parse_from([
1267 "provenant",
1268 "--json-pp",
1269 "scan.json",
1270 "--package-in-compiled",
1271 "samples",
1272 ])
1273 .expect("cli parse should succeed");
1274
1275 assert!(parsed.package_in_compiled);
1276 }
1277
1278 #[test]
1279 fn test_parses_package_only_flag() {
1280 let parsed = Cli::try_parse_from([
1281 "provenant",
1282 "--json-pp",
1283 "scan.json",
1284 "--package-only",
1285 "samples",
1286 ])
1287 .expect("cli parse should succeed");
1288
1289 assert!(parsed.package_only);
1290 }
1291
1292 #[test]
1293 fn test_package_only_conflicts_with_upstream_incompatible_flags() {
1294 let with_license = Cli::try_parse_from([
1295 "provenant",
1296 "--json-pp",
1297 "scan.json",
1298 "--package-only",
1299 "--license",
1300 "samples",
1301 ]);
1302 assert!(with_license.is_err());
1303
1304 let with_package = Cli::try_parse_from([
1305 "provenant",
1306 "--json-pp",
1307 "scan.json",
1308 "--package-only",
1309 "--package",
1310 "samples",
1311 ]);
1312 assert!(with_package.is_err());
1313 }
1314
1315 #[test]
1316 fn test_parses_package_flag() {
1317 let parsed = Cli::try_parse_from([
1318 "provenant",
1319 "--json-pp",
1320 "scan.json",
1321 "--package",
1322 "samples",
1323 ])
1324 .expect("cli parse should succeed");
1325
1326 assert!(parsed.package);
1327 }
1328
1329 #[test]
1330 fn test_package_short_flag() {
1331 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-p", "samples"])
1332 .expect("cli parse should succeed");
1333
1334 assert!(parsed.package);
1335 }
1336
1337 #[test]
1338 fn test_parses_license_flag() {
1339 let parsed = Cli::try_parse_from([
1340 "provenant",
1341 "--json-pp",
1342 "scan.json",
1343 "--license",
1344 "samples",
1345 ])
1346 .expect("cli parse should succeed");
1347
1348 assert!(parsed.license);
1349 }
1350
1351 #[test]
1352 fn test_license_short_flag() {
1353 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-l", "samples"])
1354 .expect("cli parse should succeed");
1355
1356 assert!(parsed.license);
1357 }
1358
1359 #[test]
1360 fn test_license_text_requires_license() {
1361 let result = Cli::try_parse_from([
1362 "provenant",
1363 "--json-pp",
1364 "scan.json",
1365 "--license-text",
1366 "samples",
1367 ]);
1368 assert!(result.is_err());
1369 }
1370
1371 #[test]
1372 fn test_include_text_is_rejected() {
1373 let result = Cli::try_parse_from([
1374 "provenant",
1375 "--json-pp",
1376 "scan.json",
1377 "--license",
1378 "--include-text",
1379 "samples",
1380 ]);
1381
1382 assert!(result.is_err());
1383 }
1384
1385 #[test]
1386 fn test_license_text_diagnostics_requires_license_text() {
1387 let result = Cli::try_parse_from([
1388 "provenant",
1389 "--json-pp",
1390 "scan.json",
1391 "--license",
1392 "--license-text-diagnostics",
1393 "samples",
1394 ]);
1395
1396 assert!(result.is_err());
1397 }
1398
1399 #[test]
1400 fn test_parses_license_text_and_diagnostics_flags() {
1401 let parsed = Cli::try_parse_from([
1402 "provenant",
1403 "--json-pp",
1404 "scan.json",
1405 "--license",
1406 "--license-text",
1407 "--license-text-diagnostics",
1408 "--license-diagnostics",
1409 "--unknown-licenses",
1410 "samples",
1411 ])
1412 .expect("cli parse should succeed");
1413
1414 assert!(parsed.license_text);
1415 assert!(parsed.license_text_diagnostics);
1416 assert!(parsed.license_diagnostics);
1417 assert!(parsed.unknown_licenses);
1418 assert_eq!(parsed.license_score, 0);
1419 assert_eq!(parsed.license_url_template, DEFAULT_LICENSEDB_URL_TEMPLATE);
1420 }
1421
1422 #[test]
1423 fn test_license_score_requires_license() {
1424 let result = Cli::try_parse_from([
1425 "provenant",
1426 "--json-pp",
1427 "scan.json",
1428 "--license-score",
1429 "70",
1430 "samples",
1431 ]);
1432
1433 assert!(result.is_err());
1434 }
1435
1436 #[test]
1437 fn test_license_url_template_requires_license() {
1438 let result = Cli::try_parse_from([
1439 "provenant",
1440 "--json-pp",
1441 "scan.json",
1442 "--license-url-template",
1443 "https://example.com/licenses/{}/",
1444 "samples",
1445 ]);
1446
1447 assert!(result.is_err());
1448 }
1449
1450 #[test]
1451 fn test_parses_license_score_and_url_template_flags() {
1452 let parsed = Cli::try_parse_from([
1453 "provenant",
1454 "--json-pp",
1455 "scan.json",
1456 "--license",
1457 "--license-score",
1458 "70",
1459 "--license-url-template",
1460 "https://example.com/licenses/{}/",
1461 "samples",
1462 ])
1463 .expect("cli parse should succeed");
1464
1465 assert_eq!(parsed.license_score, 70);
1466 assert_eq!(
1467 parsed.license_url_template,
1468 "https://example.com/licenses/{}/"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_rejects_license_score_above_range() {
1474 let result = Cli::try_parse_from([
1475 "provenant",
1476 "--json-pp",
1477 "scan.json",
1478 "--license",
1479 "--license-score",
1480 "101",
1481 "samples",
1482 ]);
1483
1484 assert!(result.is_err());
1485 }
1486
1487 #[test]
1488 fn test_license_references_requires_license() {
1489 let result = Cli::try_parse_from([
1490 "provenant",
1491 "--json-pp",
1492 "scan.json",
1493 "--license-references",
1494 "samples",
1495 ]);
1496
1497 assert!(result.is_err());
1498 }
1499
1500 #[test]
1501 fn test_parses_license_references_flag() {
1502 let parsed = Cli::try_parse_from([
1503 "provenant",
1504 "--json-pp",
1505 "scan.json",
1506 "--license",
1507 "--license-references",
1508 "samples",
1509 ])
1510 .expect("cli parse should succeed");
1511
1512 assert!(parsed.license_references);
1513 }
1514
1515 #[test]
1516 fn test_include_text_alias_is_not_supported() {
1517 let result = Cli::try_parse_from([
1518 "provenant",
1519 "--json-pp",
1520 "scan.json",
1521 "--license",
1522 "--include-text",
1523 "samples",
1524 ]);
1525
1526 assert!(result.is_err());
1527 }
1528
1529 #[test]
1530 fn test_parses_short_scan_flags() {
1531 let parsed = Cli::try_parse_from([
1532 "provenant",
1533 "--json-pp",
1534 "scan.json",
1535 "-c",
1536 "-e",
1537 "-u",
1538 "samples",
1539 ])
1540 .expect("cli parse should support short scan flags");
1541
1542 assert!(parsed.copyright);
1543 assert!(parsed.email);
1544 assert!(parsed.url);
1545 }
1546
1547 #[test]
1548 fn test_parses_processes_compat_values_zero_and_minus_one() {
1549 let zero =
1550 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "0", "samples"])
1551 .expect("cli parse should accept processes=0");
1552 assert_eq!(zero.processes, ProcessMode::SequentialWithTimeouts);
1553
1554 let parsed =
1555 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "-n", "-1", "samples"])
1556 .expect("cli parse should accept processes=-1");
1557 assert_eq!(parsed.processes, ProcessMode::SequentialWithoutTimeouts);
1558 }
1559
1560 #[test]
1561 fn test_parses_cache_flags() {
1562 let parsed = Cli::try_parse_from([
1563 "provenant",
1564 "--json-pp",
1565 "scan.json",
1566 "--cache-dir",
1567 "/tmp/sc-cache",
1568 "--cache-clear",
1569 "--max-in-memory",
1570 "5000",
1571 "samples",
1572 ])
1573 .expect("cli parse should accept cache flags");
1574
1575 assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/sc-cache"));
1576 assert!(parsed.cache_clear);
1577 assert!(!parsed.incremental);
1578 assert_eq!(parsed.max_in_memory, MemoryMode::Limit(5000));
1579 }
1580
1581 #[test]
1582 fn test_parses_incremental_flag() {
1583 let parsed = Cli::try_parse_from([
1584 "provenant",
1585 "--json-pp",
1586 "scan.json",
1587 "--incremental",
1588 "samples",
1589 ])
1590 .expect("cli parse should accept incremental flag");
1591
1592 assert!(parsed.incremental);
1593 }
1594
1595 #[test]
1596 fn test_max_in_memory_defaults_and_special_values() {
1597 let default_parsed =
1598 Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
1599 .expect("default max-in-memory should parse");
1600 assert_eq!(default_parsed.max_in_memory, MemoryMode::Limit(10000));
1601
1602 let disk_only = Cli::try_parse_from([
1603 "provenant",
1604 "--json-pp",
1605 "scan.json",
1606 "--max-in-memory",
1607 "-1",
1608 "samples",
1609 ])
1610 .expect("-1 should parse");
1611 assert_eq!(disk_only.max_in_memory, MemoryMode::StreamUnlimited);
1612
1613 let unlimited = Cli::try_parse_from([
1614 "provenant",
1615 "--json-pp",
1616 "scan.json",
1617 "--max-in-memory",
1618 "0",
1619 "samples",
1620 ])
1621 .expect("0 should parse");
1622 assert_eq!(unlimited.max_in_memory, MemoryMode::CollectFirst);
1623 }
1624
1625 #[test]
1626 fn test_max_in_memory_rejects_values_below_negative_one() {
1627 let result = Cli::try_parse_from([
1628 "provenant",
1629 "--json-pp",
1630 "scan.json",
1631 "--max-in-memory",
1632 "-2",
1633 "samples",
1634 ]);
1635
1636 assert!(result.is_err());
1637 }
1638
1639 #[test]
1640 fn test_max_depth_default_matches_reference_behavior() {
1641 let parsed = Cli::try_parse_from(["provenant", "--json-pp", "scan.json", "samples"])
1642 .expect("cli parse should succeed");
1643
1644 assert_eq!(parsed.max_depth, 0);
1645 }
1646}