1#![allow(
21 clippy::too_many_lines,
22 clippy::struct_excessive_bools,
23 clippy::similar_names,
24 clippy::needless_pass_by_value,
25 clippy::missing_panics_doc
31)]
32mod baseline;
33mod check_format;
34mod format_util;
35mod formats;
36mod html_report;
37mod markdown_report;
38mod metric_catalog;
39mod thresholds;
40
41use std::collections::{BTreeMap, HashMap, hash_map};
42use std::ffi::OsString;
43use std::fmt::Display;
44use std::io::{ErrorKind, Write};
45use std::path::{Path, PathBuf};
46use std::process;
47use std::sync::atomic::{AtomicUsize, Ordering};
48use std::sync::{Arc, Mutex};
49use std::thread::available_parallelism;
50
51use clap::{Args, Parser, Subcommand, ValueEnum};
52use globset::{Glob, GlobSet, GlobSetBuilder};
53
54use baseline::Baseline;
55use check_format::{AggregatedFormat, violation_to_offender};
56use formats::{CBOR_STDOUT_ERROR, MetricsDispatch, MetricsFormat, ReportFormat, dump_csv};
57use html_report::generate_html_report;
58use markdown_report::{FunctionSummary, extract_summaries, generate_report};
59use metric_catalog::{ListMetricsMode, write_metrics};
60use thresholds::{ThresholdConfig, ThresholdSet, Violation, parse_cli_threshold};
61
62use big_code_analysis::LANG;
63use big_code_analysis::ParserTrait;
64
65const FEATURES_PINNED: &str = "CLI pins big-code-analysis features = [\"all-languages\"]";
72use big_code_analysis::{
73 CommentRm, CommentRmCfg, ConcurrentRunner, Count, CountCfg, Dump, DumpCfg, FilesData, Find,
74 FindCfg, Function, FunctionCfg, Metrics, MetricsCfg, MetricsOptions, OpsCfg, OpsCode,
75 PreprocParser, PreprocResults, SuppressionPolicy,
76};
77#[allow(deprecated)]
85use big_code_analysis::get_function_spaces_with_options;
86use big_code_analysis::{
87 action, fix_includes, get_from_ext, get_ops, guess_language, is_generated, preprocess,
88 read_file, read_file_with_eol, write_file,
89};
90
91fn die(msg: impl Display) -> ! {
92 eprintln!("Error: {msg}");
93 process::exit(1);
94}
95
96fn die_io(verb: &str, path: &Path, err: impl Display) -> ! {
100 die(format_args!("failed to {verb} {}: {err}", path.display()))
101}
102
103fn write_stdout_or_die(bytes: &[u8]) {
106 if let Err(e) = std::io::stdout().lock().write_all(bytes)
107 && e.kind() != ErrorKind::BrokenPipe
108 {
109 die(e);
110 }
111}
112
113#[derive(Parser, Debug)]
121#[clap(
122 name = "bca",
123 version,
124 author,
125 about = "Analyze source code.",
126 subcommand_required = true,
127 arg_required_else_help = true,
128 after_help = "Migrating from the flag-style CLI? See the migration guide:\n big-code-analysis-book/src/migration.md"
129)]
130pub struct Cli {
131 #[clap(flatten)]
132 globals: GlobalOpts,
133 #[command(subcommand)]
134 command: Command,
135}
136
137#[derive(Args, Debug, Default)]
138struct GlobalOpts {
139 #[clap(long, short, value_parser, global = true)]
141 paths: Vec<PathBuf>,
142 #[clap(long, short = 'I', num_args(0..), global = true)]
144 include: Vec<String>,
145 #[clap(long, short = 'X', num_args(0..), global = true)]
147 exclude: Vec<String>,
148 #[clap(long, short = 'j', global = true)]
150 num_jobs: Option<usize>,
151 #[clap(long, short = 'l', global = true)]
153 language_type: Option<String>,
154 #[clap(long = "ls", global = true)]
156 line_start: Option<usize>,
157 #[clap(long = "le", global = true)]
159 line_end: Option<usize>,
160 #[clap(long, short, global = true)]
162 warning: bool,
163 #[clap(long, global = true)]
167 no_skip_generated: bool,
168 #[clap(long, global = true)]
172 report_skipped: bool,
173 #[clap(long, value_parser, global = true)]
176 preproc_data: Option<PathBuf>,
177 #[clap(long = "paths-from", value_parser, global = true)]
183 paths_from: Option<PathBuf>,
184 #[clap(long = "no-ignore", global = true)]
188 no_ignore: bool,
189 #[clap(long = "exclude-tests", global = true)]
197 exclude_tests: bool,
198}
199
200#[derive(Subcommand, Debug)]
201enum Command {
202 Metrics(StructuredArgs),
204 Ops(StructuredArgs),
206 Report(ReportArgs),
208 Dump,
210 Find(NodesArgs),
212 Count(NodesArgs),
214 Functions,
216 StripComments(StripCommentsArgs),
218 Preproc(PreprocArgs),
220 ListMetrics(ListMetricsArgs),
222 Check(CheckArgs),
226}
227
228#[derive(Args, Debug)]
231struct StructuredArgs {
232 #[clap(long, short = 'O', value_enum)]
234 output_format: Option<MetricsFormat>,
235 #[clap(long, short, value_parser)]
238 output: Option<PathBuf>,
239 #[clap(long)]
241 pretty: bool,
242}
243
244#[derive(Args, Debug)]
245struct ReportArgs {
246 #[clap(value_enum)]
248 format: ReportFormat,
249 #[clap(long, short, value_parser)]
251 output: Option<PathBuf>,
252 #[clap(long, default_value_t = 20, value_parser = clap::value_parser!(u32).range(1..))]
254 top: u32,
255 #[clap(long, default_value = "")]
257 strip_prefix: String,
258}
259
260#[derive(Args, Debug)]
261struct NodesArgs {
262 #[clap(required = true, num_args = 1..)]
264 nodes: Vec<String>,
265}
266
267#[derive(Args, Debug)]
268struct StripCommentsArgs {
269 #[clap(long)]
271 in_place: bool,
272}
273
274#[derive(Args, Debug)]
275struct PreprocArgs {
276 #[clap(long, short, value_parser)]
278 output: Option<PathBuf>,
279}
280
281#[derive(Args, Debug)]
282struct CheckArgs {
283 #[clap(long = "threshold", value_parser = parse_cli_threshold)]
289 thresholds: Vec<(String, f64)>,
290 #[clap(long, value_parser)]
298 config: Option<PathBuf>,
299 #[clap(long = "no-fail")]
303 no_fail: bool,
304 #[clap(long = "no-suppress")]
309 no_suppress: bool,
310 #[clap(long = "output-format", short = 'O', value_enum)]
315 output_format: Option<AggregatedFormat>,
316 #[clap(long, short, value_parser)]
320 output: Option<PathBuf>,
321 #[clap(long = "baseline", value_parser, conflicts_with = "write_baseline")]
326 baseline: Option<PathBuf>,
327 #[clap(
334 long = "write-baseline",
335 value_parser,
336 conflicts_with_all = ["baseline", "output_format", "output"],
337 )]
338 write_baseline: Option<PathBuf>,
339}
340
341#[derive(Args, Debug)]
342struct ListMetricsArgs {
343 #[clap(value_enum, default_value_t = ListMetricsMode::Names)]
346 mode: ListMetricsMode,
347}
348
349#[derive(Debug)]
352enum Action {
353 Dump,
354 Metrics {
355 format: Option<MetricsFormat>,
356 pretty: bool,
357 },
358 Ops {
359 format: Option<MetricsFormat>,
360 pretty: bool,
361 },
362 StripComments {
363 in_place: bool,
364 },
365 Functions,
366 Find(Arc<[String]>),
367 Count(Arc<[String]>),
368 Report,
371 PreprocProduce,
373 Check,
375}
376
377#[derive(Debug)]
378struct Config {
379 action: Action,
380 output: Option<PathBuf>,
381 language: Option<LANG>,
382 line_start: Option<usize>,
383 line_end: Option<usize>,
384 preproc_lock: Option<Arc<Mutex<PreprocResults>>>,
385 preproc: Option<Arc<PreprocResults>>,
386 count_lock: Option<Arc<Mutex<Count>>>,
387 markdown_tx: Option<Mutex<std::sync::mpsc::Sender<FunctionSummary>>>,
390 strip_prefix: String,
392 threshold_set: Option<Arc<ThresholdSet>>,
395 check_tx: Option<Mutex<std::sync::mpsc::Sender<Violation>>>,
398 files_dispatched: Option<Arc<AtomicUsize>>,
404 suppression_policy: SuppressionPolicy,
410 warning: bool,
411 skip_generated: bool,
415 report_skipped: bool,
421 exclude_tests: bool,
428}
429
430impl Config {
431 fn new(action: Action, globals: &GlobalOpts, preproc: Option<Arc<PreprocResults>>) -> Self {
436 let language = resolve_language(globals.language_type.as_deref(), &action);
437 Self {
438 action,
439 output: None,
440 language,
441 line_start: globals.line_start,
442 line_end: globals.line_end,
443 preproc_lock: None,
444 preproc,
445 count_lock: None,
446 markdown_tx: None,
447 strip_prefix: String::new(),
448 threshold_set: None,
449 check_tx: None,
450 files_dispatched: None,
451 suppression_policy: SuppressionPolicy::Honor,
452 warning: globals.warning,
453 skip_generated: !globals.no_skip_generated,
454 report_skipped: globals.report_skipped,
455 exclude_tests: globals.exclude_tests,
456 }
457 }
458
459 #[inline]
464 fn metrics_options(&self) -> MetricsOptions {
465 MetricsOptions::default().with_exclude_tests(self.exclude_tests)
466 }
467}
468
469fn mk_globset(elems: Vec<String>) -> Result<GlobSet, String> {
470 if elems.is_empty() {
471 return Ok(GlobSet::empty());
472 }
473
474 let mut globset = GlobSetBuilder::new();
475 for e in &elems {
476 if e.is_empty() {
477 continue;
478 }
479 globset.add(Glob::new(e).map_err(|err| format!("invalid glob pattern {e:?}: {err}"))?);
480 }
481 globset
482 .build()
483 .map_err(|err| format!("failed to build glob set: {err}"))
484}
485
486#[allow(deprecated)]
496fn act_on_file(path: PathBuf, cfg: &Config) -> std::io::Result<()> {
497 if let Some(counter) = &cfg.files_dispatched {
498 counter.fetch_add(1, Ordering::Relaxed);
504 }
505
506 let Some(source) = read_file_with_eol(&path)? else {
507 if cfg.warning {
508 eprintln!("warning: skipping empty file: {}", path.display());
509 }
510 return Ok(());
511 };
512
513 if cfg.skip_generated && !matches!(cfg.action, Action::PreprocProduce) && is_generated(&source)
518 {
519 if cfg.report_skipped || cfg.warning {
520 eprintln!("skipped (generated): {}", path.display());
521 }
522 return Ok(());
523 }
524
525 let Some(language) = cfg.language.or_else(|| guess_language(&source, &path).0) else {
526 if cfg.warning {
527 eprintln!(
528 "warning: skipping file with unrecognized language: {}",
529 path.display()
530 );
531 }
532 return Ok(());
533 };
534
535 let pr = cfg.preproc.clone();
536 match &cfg.action {
537 Action::Dump => {
538 let dump_cfg = DumpCfg {
539 line_start: cfg.line_start,
540 line_end: cfg.line_end,
541 };
542 action::<Dump>(&language, source, &path, pr, dump_cfg).expect(FEATURES_PINNED)
546 }
547 Action::Metrics { format, pretty } => {
548 if let Some(fmt) = format {
549 if let Ok(space) = get_function_spaces_with_options(
550 &language,
551 source,
552 &path,
553 pr,
554 cfg.metrics_options(),
555 ) {
556 match fmt.dispatch() {
557 MetricsDispatch::Generic(g) => {
558 g.dump(space, path, cfg.output.as_ref(), *pretty)?;
559 }
560 MetricsDispatch::Csv => {
561 dump_csv(&space, path, cfg.output.as_ref())?;
562 }
563 }
564 }
565 Ok(())
566 } else {
567 let metrics_cfg = MetricsCfg::new(path).with_options(cfg.metrics_options());
568 let path = metrics_cfg.path.clone();
569 action::<Metrics>(&language, source, &path, pr, metrics_cfg).expect(FEATURES_PINNED)
570 }
571 }
572 Action::Ops { format, pretty } => {
573 if let Some(fmt) = format {
574 if let Ok(ops) = get_ops(&language, source, &path, pr) {
575 match fmt.dispatch() {
581 MetricsDispatch::Generic(g) => {
582 g.dump(ops, path, cfg.output.as_ref(), *pretty)?;
583 }
584 MetricsDispatch::Csv => {}
585 }
586 }
587 Ok(())
588 } else {
589 let ops_cfg = OpsCfg { path };
590 let path = ops_cfg.path.clone();
591 action::<OpsCode>(&language, source, &path, pr, ops_cfg).expect(FEATURES_PINNED)
592 }
593 }
594 Action::StripComments { in_place } => {
595 let comment_cfg = CommentRmCfg {
596 in_place: *in_place,
597 path,
598 };
599 let path = comment_cfg.path.clone();
600 let lang = if language == LANG::Cpp {
603 LANG::Ccomment
604 } else {
605 language
606 };
607 action::<CommentRm>(&lang, source, &path, pr, comment_cfg).expect(FEATURES_PINNED)
608 }
609 Action::Functions => {
610 let fn_cfg = FunctionCfg { path: path.clone() };
611 action::<Function>(&language, source, &path, pr, fn_cfg).expect(FEATURES_PINNED)
612 }
613 Action::Find(filters) => {
614 let find_cfg = FindCfg {
615 path: path.clone(),
616 filters: Arc::clone(filters),
617 line_start: cfg.line_start,
618 line_end: cfg.line_end,
619 };
620 action::<Find>(&language, source, &path, pr, find_cfg).expect(FEATURES_PINNED)
621 }
622 Action::Count(filters) => {
623 let stats = cfg
624 .count_lock
625 .clone()
626 .expect("Count handler initializes count_lock before dispatch");
627 let count_cfg = CountCfg {
628 filters: Arc::clone(filters),
629 stats,
630 };
631 action::<Count>(&language, source, &path, pr, count_cfg).expect(FEATURES_PINNED)
632 }
633 Action::Report => {
634 if let Ok(space) = get_function_spaces_with_options(
635 &language,
636 source,
637 &path,
638 pr,
639 cfg.metrics_options(),
640 ) && let Some(ref tx) = cfg.markdown_tx
641 && !matches!(language, LANG::Preproc | LANG::Ccomment)
642 {
643 let Some(file_str) = path.to_str() else {
652 if cfg.warning {
653 eprintln!(
654 "warning: skipping non-UTF-8 path in report: {}",
655 path.display()
656 );
657 }
658 return Ok(());
659 };
660 let mut summaries = Vec::new();
661 extract_summaries(
662 &space,
663 file_str,
664 language,
665 &cfg.strip_prefix,
666 &mut summaries,
667 );
668 let Ok(sender) = tx.lock() else {
669 if cfg.warning {
670 eprintln!(
671 "warning: skipping {}: report channel lock poisoned",
672 path.display()
673 );
674 }
675 return Ok(());
676 };
677 for s in summaries {
678 let _ = sender.send(s);
679 }
680 }
681 Ok(())
682 }
683 Action::Check => {
684 if let Ok(space) = get_function_spaces_with_options(
685 &language,
686 source,
687 &path,
688 pr,
689 cfg.metrics_options(),
690 ) && let (Some(set), Some(tx)) = (cfg.threshold_set.as_ref(), cfg.check_tx.as_ref())
691 && !matches!(language, LANG::Preproc | LANG::Ccomment)
692 {
693 let mut violations = Vec::new();
699 set.evaluate_with_policy(&path, &space, cfg.suppression_policy, &mut violations);
700 if !violations.is_empty() {
701 let Ok(sender) = tx.lock() else {
702 if cfg.warning {
703 eprintln!(
704 "warning: skipping {}: check channel lock poisoned",
705 path.display()
706 );
707 }
708 return Ok(());
709 };
710 for v in violations {
716 let _ = sender.send(v);
717 }
718 }
719 }
720 Ok(())
721 }
722 Action::PreprocProduce => {
723 if let Some(preproc_lock) = &cfg.preproc_lock
724 && let Some(language) = guess_language(&source, &path).0
725 && language == LANG::Cpp
726 {
727 let mut results = preproc_lock.lock().expect("mutex not poisoned");
728 preprocess(
729 &PreprocParser::new(source, &path, None),
730 &path,
731 &mut results,
732 );
733 }
734 Ok(())
735 }
736 }
737}
738
739fn process_dir_path(all_files: &mut HashMap<String, Vec<PathBuf>>, path: &Path, cfg: &Config) {
740 if !matches!(cfg.action, Action::PreprocProduce) {
741 return;
742 }
743 let Some(fname) = path.file_name().and_then(|n| n.to_str()) else {
744 return;
745 };
746 let file_name = fname.to_string();
747 match all_files.entry(file_name) {
748 hash_map::Entry::Occupied(l) => {
749 l.into_mut().push(path.to_path_buf());
750 }
751 hash_map::Entry::Vacant(p) => {
752 p.insert(vec![path.to_path_buf()]);
753 }
754 }
755}
756
757fn resolve_language(typ: Option<&str>, action: &Action) -> Option<LANG> {
758 if matches!(action, Action::PreprocProduce) {
762 return Some(LANG::Preproc);
763 }
764 match typ.unwrap_or("") {
765 "" => None,
766 "ccomment" => Some(LANG::Ccomment),
767 "preproc" => Some(LANG::Preproc),
768 other => get_from_ext(other),
769 }
770}
771
772fn resolve_num_jobs(requested: Option<usize>) -> usize {
773 requested.map_or_else(
774 || {
775 std::cmp::max(
776 2,
777 available_parallelism()
778 .unwrap_or_else(|e| {
779 die(format_args!("could not get available parallelism: {e}"))
780 })
781 .get(),
782 ) - 1
783 },
784 |num_jobs| std::cmp::max(2, num_jobs) - 1,
785 )
786}
787
788fn load_preproc_data(path: &Path) -> Arc<PreprocResults> {
791 let data = read_file(path).unwrap_or_else(|e| die_io("read preproc data", path, e));
792 let parsed = serde_json::from_slice::<PreprocResults>(&data)
793 .unwrap_or_else(|e| die_io("parse preproc JSON from", path, e));
794 Arc::new(parsed)
795}
796
797fn read_paths_from(src: &Path) -> Vec<PathBuf> {
801 if src.as_os_str() == "-" {
802 collect_path_lines(std::io::stdin().lock(), "--paths-from -")
803 } else {
804 let label = format!("--paths-from {}", src.display());
805 let f = std::fs::File::open(src).unwrap_or_else(|e| die(format_args!("{label}: {e}")));
806 collect_path_lines(std::io::BufReader::new(f), &label)
807 }
808}
809
810fn collect_path_lines<R: std::io::BufRead>(reader: R, label: &str) -> Vec<PathBuf> {
814 reader
815 .lines()
816 .enumerate()
817 .filter_map(|(i, r)| {
818 let line = r.unwrap_or_else(|e| {
819 die(format_args!("{label}: read error on line {}: {e}", i + 1))
820 });
821 let trimmed = line.trim();
822 (!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
823 })
824 .collect()
825}
826
827fn expand_seed_paths(
836 paths: Vec<PathBuf>,
837 paths_from: Option<PathBuf>,
838 no_ignore: bool,
839) -> Vec<PathBuf> {
840 use ignore::WalkBuilder;
841 let mut seeds = paths;
842 if let Some(src) = paths_from {
843 seeds.extend(read_paths_from(&src));
844 }
845 let mut out: Vec<PathBuf> = Vec::new();
846 for seed in seeds {
847 if !seed.exists() {
848 eprintln!("Warning: File doesn't exist: {}", seed.display());
850 continue;
851 }
852 if seed.is_file() {
853 out.push(seed);
854 continue;
855 }
856 let mut wb = WalkBuilder::new(&seed);
857 wb.hidden(true)
858 .follow_links(false)
859 .require_git(false)
860 .git_ignore(!no_ignore)
861 .git_exclude(!no_ignore)
862 .git_global(!no_ignore)
863 .ignore(!no_ignore)
864 .parents(!no_ignore);
865 for entry in wb.build() {
866 let entry = entry
867 .unwrap_or_else(|e| die(format_args!("walk error in {}: {e}", seed.display())));
868 if entry.file_type().is_some_and(|t| t.is_file()) {
869 out.push(entry.into_path());
870 }
871 }
872 }
873 out
874}
875
876fn run_walk(globals: GlobalOpts, cfg: Config) -> HashMap<String, Vec<PathBuf>> {
877 let include = mk_globset(globals.include).unwrap_or_else(|e| die(e));
878 let exclude = mk_globset(globals.exclude).unwrap_or_else(|e| die(e));
879 let num_jobs = resolve_num_jobs(globals.num_jobs);
880 let paths = expand_seed_paths(globals.paths, globals.paths_from, globals.no_ignore);
881 let files_data = FilesData {
882 include,
883 exclude,
884 paths,
885 };
886 ConcurrentRunner::new(num_jobs, act_on_file)
887 .set_proc_dir_paths(process_dir_path)
888 .run(cfg, files_data)
889 .unwrap_or_else(|e| die(format_args!("{e:?}")))
890}
891
892fn load_threshold_config(path: &Path) -> BTreeMap<String, f64> {
896 let bytes = read_file(path).unwrap_or_else(|e| die_io("read threshold config", path, e));
897 let text = std::str::from_utf8(&bytes)
898 .unwrap_or_else(|e| die_io("decode UTF-8 from threshold config", path, e));
899 let cfg: ThresholdConfig =
900 toml::from_str(text).unwrap_or_else(|e| die_io("parse threshold config", path, e));
901 cfg.thresholds
902}
903
904fn load_baseline(path: &Path) -> Baseline {
907 let bytes = read_file(path).unwrap_or_else(|e| die_io("read baseline", path, e));
908 let text = std::str::from_utf8(&bytes)
909 .unwrap_or_else(|e| die_io("decode UTF-8 from baseline", path, e));
910 Baseline::from_str(text).unwrap_or_else(|e| die_io("parse baseline", path, e))
911}
912
913fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
924 if let Some(parent) = path.parent()
925 && !parent.as_os_str().is_empty()
926 {
927 std::fs::create_dir_all(parent)?;
928 }
929 let mut tmp = path.as_os_str().to_os_string();
930 tmp.push(".bca-tmp");
931 let tmp = PathBuf::from(tmp);
932 std::fs::write(&tmp, bytes)?;
933 std::fs::rename(&tmp, path).inspect_err(|_| {
934 let _ = std::fs::remove_file(&tmp);
938 })
939}
940
941fn run_check(globals: GlobalOpts, args: CheckArgs, preproc: Option<Arc<PreprocResults>>) {
944 if let Some(fmt) = args.output_format
951 && let Some(ref out) = args.output
952 && out.exists()
953 && out.is_dir()
954 {
955 die(format_args!(
956 "--output must be a file path for `check --output-format {}`",
957 fmt.name()
958 ));
959 }
960
961 let mut merged: BTreeMap<String, f64> = args
962 .config
963 .as_deref()
964 .map(load_threshold_config)
965 .unwrap_or_default();
966 for (name, limit) in args.thresholds {
968 merged.insert(name, limit);
969 }
970 let set = ThresholdSet::build(&merged).unwrap_or_else(|e| die(e));
971 if set.is_empty() {
972 die("no thresholds configured; pass --threshold or --config");
973 }
974 let set = Arc::new(set);
975
976 let (tx, rx) = std::sync::mpsc::channel();
977 let files_dispatched = Arc::new(AtomicUsize::new(0));
978 let cfg = Config {
979 threshold_set: Some(Arc::clone(&set)),
980 check_tx: Some(Mutex::new(tx)),
981 files_dispatched: Some(Arc::clone(&files_dispatched)),
982 suppression_policy: SuppressionPolicy::from_no_suppress(args.no_suppress),
983 ..Config::new(Action::Check, &globals, preproc)
984 };
985 run_walk(globals, cfg);
986
987 if files_dispatched.load(Ordering::Relaxed) == 0 {
988 die("bca check: no input files matched; check --paths, --include, --exclude");
993 }
994
995 let mut violations: Vec<Violation> = rx.into_iter().collect();
998 violations.sort_by(|a, b| {
1002 a.path
1003 .cmp(&b.path)
1004 .then(a.start_line.cmp(&b.start_line))
1005 .then(a.metric.cmp(b.metric))
1006 });
1007
1008 if let Some(path) = args.write_baseline {
1009 let file = baseline::from_violations(violations);
1010 let entry_count = file.entries.len();
1011 let text = baseline::render(&file)
1012 .unwrap_or_else(|e| die(format_args!("serialize baseline: {e}")));
1013 write_atomic(&path, text.as_bytes()).unwrap_or_else(|e| die_io("write baseline", &path, e));
1014 eprintln!(
1015 "bca: wrote {entry_count} baseline entries to {}",
1016 path.display()
1017 );
1018 return;
1019 }
1020
1021 let violations: Vec<Violation> = if let Some(path) = args.baseline.as_deref() {
1022 let baseline = load_baseline(path);
1023 let before = violations.len();
1024 let kept: Vec<Violation> = violations
1025 .into_iter()
1026 .filter(|v| !baseline.covers(v))
1027 .collect();
1028 let filtered = before - kept.len();
1029 if filtered > 0 {
1030 eprintln!("bca: filtered {filtered} violations via baseline");
1031 }
1032 kept
1033 } else {
1034 violations
1035 };
1036
1037 let mut stderr = std::io::stderr().lock();
1041 for v in &violations {
1042 let _ = writeln!(stderr, "{v}");
1043 }
1044
1045 let any_violations = !violations.is_empty();
1050 if let Some(fmt) = args.output_format {
1051 let offenders: Vec<_> = violations.into_iter().map(violation_to_offender).collect();
1052 fmt.dump(&offenders, args.output.as_deref())
1053 .unwrap_or_else(|e| die(format_args!("failed to write {}: {e}", fmt.name())));
1054 }
1055
1056 if any_violations && !args.no_fail {
1057 process::exit(2);
1058 }
1059}
1060
1061pub fn run() {
1086 let cli = match Cli::try_parse() {
1087 Ok(cli) => cli,
1088 Err(err) => {
1089 if matches!(
1090 err.kind(),
1091 clap::error::ErrorKind::UnknownArgument
1092 | clap::error::ErrorKind::InvalidSubcommand
1093 | clap::error::ErrorKind::InvalidValue
1094 | clap::error::ErrorKind::MissingSubcommand
1095 | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
1096 ) && let Some(hint) = legacy_hint(std::env::args_os())
1097 {
1098 eprintln!("{hint}");
1099 }
1100 err.exit();
1101 }
1102 };
1103
1104 let preproc = cli
1105 .globals
1106 .preproc_data
1107 .as_ref()
1108 .map(|p| load_preproc_data(p));
1109
1110 match cli.command {
1111 Command::ListMetrics(args) => {
1112 let mut buf = Vec::new();
1113 write_metrics(&mut buf, args.mode).expect("writing to Vec<u8> is infallible");
1114 write_stdout_or_die(&buf);
1115 }
1116 Command::Dump => {
1117 let cfg = Config::new(Action::Dump, &cli.globals, preproc);
1118 run_walk(cli.globals, cfg);
1119 }
1120 Command::Functions => {
1121 let cfg = Config::new(Action::Functions, &cli.globals, preproc);
1122 run_walk(cli.globals, cfg);
1123 }
1124 Command::Metrics(args) => {
1125 if matches!(args.output_format, Some(MetricsFormat::Cbor)) && args.output.is_none() {
1126 die(CBOR_STDOUT_ERROR);
1127 }
1128 if args.output_format.is_some()
1129 && let Some(ref out) = args.output
1130 && out.exists()
1131 && !out.is_dir()
1132 {
1133 die("--output must be a directory for `metrics`");
1134 }
1135 let action = Action::Metrics {
1136 format: args.output_format,
1137 pretty: args.pretty,
1138 };
1139 let cfg = Config {
1140 output: args.output,
1141 ..Config::new(action, &cli.globals, preproc)
1142 };
1143 run_walk(cli.globals, cfg);
1144 }
1145 Command::Ops(args) => {
1146 if matches!(args.output_format, Some(MetricsFormat::Cbor)) && args.output.is_none() {
1147 die(CBOR_STDOUT_ERROR);
1148 }
1149 if let Some(MetricsDispatch::Csv) = args.output_format.map(MetricsFormat::dispatch) {
1150 die(
1151 "CSV is not supported by `ops` because its column schema is metric-shaped; use `bca metrics --output-format <fmt>`",
1152 );
1153 }
1154 if args.output_format.is_some()
1155 && let Some(ref out) = args.output
1156 && out.exists()
1157 && !out.is_dir()
1158 {
1159 die("--output must be a directory for `ops`");
1160 }
1161 let action = Action::Ops {
1162 format: args.output_format,
1163 pretty: args.pretty,
1164 };
1165 let cfg = Config {
1166 output: args.output,
1167 ..Config::new(action, &cli.globals, preproc)
1168 };
1169 run_walk(cli.globals, cfg);
1170 }
1171 Command::Report(args) => {
1172 if let Some(ref output) = args.output {
1173 if output.exists() && output.is_dir() {
1174 die("--output must be a file path for `report`");
1175 }
1176 if let Some(parent) = output.parent()
1177 && !parent.as_os_str().is_empty()
1178 && !parent.exists()
1179 {
1180 die(format_args!(
1181 "parent directory of --output does not exist: {}",
1182 parent.display()
1183 ));
1184 }
1185 }
1186 let (tx, rx) = std::sync::mpsc::channel();
1187 let cfg = Config {
1188 markdown_tx: Some(Mutex::new(tx)),
1189 strip_prefix: args.strip_prefix,
1190 ..Config::new(Action::Report, &cli.globals, preproc)
1191 };
1192 run_walk(cli.globals, cfg);
1193
1194 let summaries: Vec<FunctionSummary> = rx.into_iter().collect();
1197 let report = match args.format {
1198 ReportFormat::Markdown => generate_report(&summaries, args.top as usize),
1199 ReportFormat::Html => generate_html_report(&summaries, args.top as usize),
1200 };
1201 if let Some(ref output_path) = args.output {
1202 std::fs::write(output_path, &report)
1203 .unwrap_or_else(|e| die_io("write report to", output_path, e));
1204 } else {
1205 write_stdout_or_die(report.as_bytes());
1206 }
1207 }
1208 Command::Find(args) => {
1209 let cfg = Config::new(Action::Find(args.nodes.into()), &cli.globals, preproc);
1210 run_walk(cli.globals, cfg);
1211 }
1212 Command::Count(args) => {
1213 let count_lock = Arc::new(Mutex::new(Count::default()));
1214 let cfg = Config {
1215 count_lock: Some(count_lock.clone()),
1216 ..Config::new(Action::Count(args.nodes.into()), &cli.globals, preproc)
1217 };
1218 run_walk(cli.globals, cfg);
1219
1220 let count = Arc::try_unwrap(count_lock)
1221 .expect("all worker threads have joined; Arc refcount is 1")
1222 .into_inner()
1223 .expect("mutex not poisoned");
1224 println!("{count}");
1225 }
1226 Command::StripComments(args) => {
1227 let action = Action::StripComments {
1228 in_place: args.in_place,
1229 };
1230 let cfg = Config::new(action, &cli.globals, preproc);
1231 run_walk(cli.globals, cfg);
1232 }
1233 Command::Check(args) => {
1234 run_check(cli.globals, args, preproc);
1235 }
1236 Command::Preproc(args) => {
1237 let preproc_lock = Arc::new(Mutex::new(PreprocResults::default()));
1238 let output = args.output;
1239 let cfg = Config {
1240 preproc_lock: Some(preproc_lock.clone()),
1241 ..Config::new(Action::PreprocProduce, &cli.globals, None)
1242 };
1243 let all_files = run_walk(cli.globals, cfg);
1244
1245 let mut data = Arc::try_unwrap(preproc_lock)
1246 .expect("all worker threads have joined; Arc refcount is 1")
1247 .into_inner()
1248 .expect("mutex not poisoned");
1249 fix_includes(&mut data.files, &all_files);
1250
1251 let serialized = serde_json::to_string(&data)
1252 .unwrap_or_else(|e| die(format_args!("failed to serialize preproc data: {e}")));
1253 if let Some(output_path) = output {
1254 write_file(&output_path, serialized.as_bytes())
1255 .unwrap_or_else(|e| die_io("write preproc output to", &output_path, e));
1256 } else {
1257 println!("{serialized}");
1258 }
1259 }
1260 }
1261}
1262
1263const SUBCOMMANDS: &[&str] = &[
1267 "metrics",
1268 "ops",
1269 "report",
1270 "dump",
1271 "find",
1272 "count",
1273 "functions",
1274 "strip-comments",
1275 "preproc",
1276 "list-metrics",
1277 "check",
1278];
1279
1280fn parse_output_format_value(args: &[String]) -> Option<&str> {
1286 args.iter().enumerate().find_map(|(i, a)| {
1287 let s = a.as_str();
1288 if s == "-O" || s == "--output-format" {
1289 args.get(i + 1).map(String::as_str)
1290 } else if let Some(rest) = s.strip_prefix("--output-format=") {
1291 Some(rest)
1292 } else {
1293 s.strip_prefix("-O").filter(|r| !r.is_empty())
1294 }
1295 })
1296}
1297
1298fn offender_format_migration_hint(args: &[String]) -> Option<String> {
1304 let fmt =
1305 parse_output_format_value(args).filter(|f| AggregatedFormat::from_str(f, true).is_ok())?;
1306 Some(format!(
1307 "note: -O {fmt} moved to `bca check` in #235; offender formats are no longer accepted on `bca metrics` / `bca ops`.\n bca metrics -O {fmt} ... -> bca check --threshold <metric>=<limit> --output-format {fmt} [--output FILE]\n Run `bca check --help` for the threshold and output-format flags.\n"
1308 ))
1309}
1310
1311fn legacy_hint(argv: impl IntoIterator<Item = OsString>) -> Option<String> {
1319 let args: Vec<String> = argv
1320 .into_iter()
1321 .skip(1) .filter_map(|s| s.into_string().ok())
1323 .collect();
1324 if args.is_empty() {
1325 return None;
1326 }
1327
1328 if let Some(sub) = args.iter().find(|a| SUBCOMMANDS.contains(&a.as_str())) {
1337 if matches!(sub.as_str(), "metrics" | "ops")
1338 && let Some(hint) = offender_format_migration_hint(&args)
1339 {
1340 return Some(hint);
1341 }
1342 return None;
1343 }
1344
1345 let action_map: &[(&str, &str)] = &[
1347 ("--metrics", "bca metrics"),
1348 ("-m", "bca metrics"),
1349 ("--ops", "bca ops"),
1350 ("--dump", "bca dump"),
1351 ("-d", "bca dump"),
1352 ("--comments", "bca strip-comments [--in-place]"),
1353 ("--function", "bca functions"),
1354 ("-F", "bca functions"),
1355 ("--find", "bca find <NODE> [<NODE>...]"),
1356 ("-f", "bca find <NODE> [<NODE>...]"),
1357 ("--count", "bca count <NODE> [<NODE>...]"),
1358 ("-C", "bca count <NODE> [<NODE>...]"),
1359 ("--list-metrics", "bca list-metrics [names|descriptions]"),
1360 (
1361 "--preproc",
1362 "bca preproc -o OUT.json (or --preproc-data on consumers)",
1363 ),
1364 ];
1365
1366 let mut lines: Vec<String> = Vec::new();
1367 let mut saw_legacy_action = false;
1368
1369 for arg in &args {
1370 let head = arg.split('=').next().unwrap_or(arg);
1371 if let Some((_, replacement)) = action_map.iter().find(|(old, _)| *old == head) {
1372 saw_legacy_action = true;
1373 lines.push(format!(" {head} -> {replacement}"));
1374 }
1375 }
1376
1377 let format_value = parse_output_format_value(&args);
1381 if format_value == Some("markdown") {
1382 saw_legacy_action = true;
1383 lines.push(String::from(
1384 " -O markdown -> bca report markdown|html [--top N] [--strip-prefix P]",
1385 ));
1386 } else if let Some(fmt) = format_value
1387 && saw_legacy_action
1388 {
1389 lines.push(format!(" -O {fmt} -> bca metrics -O {fmt}"));
1393 }
1394
1395 if !saw_legacy_action {
1396 return None;
1397 }
1398
1399 let mut hint = String::from(
1400 "note: the CLI was restructured into subcommands. See migration.md for the full mapping.\n",
1401 );
1402 for line in &lines {
1403 hint.push_str(line);
1404 hint.push('\n');
1405 }
1406 hint.push_str(" Run `bca --help` for the new command list.\n");
1407 Some(hint)
1408}
1409
1410#[cfg(test)]
1411#[allow(
1412 clippy::float_cmp,
1413 clippy::cast_precision_loss,
1414 clippy::cast_possible_truncation,
1415 clippy::cast_sign_loss,
1416 clippy::similar_names,
1417 clippy::doc_markdown,
1418 clippy::needless_raw_string_hashes,
1419 clippy::too_many_lines
1420)]
1421mod tests {
1422 use super::*;
1423
1424 fn test_config(action: Action) -> Config {
1425 Config {
1426 action,
1427 output: None,
1428 language: None,
1429 line_start: None,
1430 line_end: None,
1431 preproc_lock: None,
1432 preproc: None,
1433 count_lock: None,
1434 markdown_tx: None,
1435 strip_prefix: String::new(),
1436 threshold_set: None,
1437 check_tx: None,
1438 files_dispatched: None,
1439 suppression_policy: SuppressionPolicy::Honor,
1440 warning: false,
1441 skip_generated: true,
1442 report_skipped: false,
1443 exclude_tests: false,
1444 }
1445 }
1446
1447 #[test]
1448 fn process_dir_path_noop_outside_preproc() {
1449 let cfg = test_config(Action::Dump);
1450 let mut all_files = HashMap::new();
1451 process_dir_path(&mut all_files, Path::new("/some/file.cpp"), &cfg);
1452 assert!(all_files.is_empty());
1453 }
1454
1455 #[test]
1456 fn process_dir_path_inserts_valid_utf8_filename() {
1457 let cfg = test_config(Action::PreprocProduce);
1458 let mut all_files = HashMap::new();
1459 process_dir_path(&mut all_files, Path::new("/some/dir/foo.cpp"), &cfg);
1460 assert_eq!(all_files.len(), 1);
1461 assert_eq!(
1462 all_files["foo.cpp"],
1463 vec![PathBuf::from("/some/dir/foo.cpp")]
1464 );
1465 }
1466
1467 #[test]
1468 fn process_dir_path_groups_duplicate_filenames() {
1469 let cfg = test_config(Action::PreprocProduce);
1470 let mut all_files = HashMap::new();
1471 process_dir_path(&mut all_files, Path::new("/a/foo.cpp"), &cfg);
1472 process_dir_path(&mut all_files, Path::new("/b/foo.cpp"), &cfg);
1473 assert_eq!(all_files.len(), 1);
1474 assert_eq!(
1475 all_files["foo.cpp"],
1476 vec![PathBuf::from("/a/foo.cpp"), PathBuf::from("/b/foo.cpp")]
1477 );
1478 }
1479
1480 #[cfg(unix)]
1481 #[test]
1482 fn process_dir_path_skips_non_utf8_filename() {
1483 use std::ffi::OsStr;
1484 use std::os::unix::ffi::OsStrExt;
1485
1486 let cfg = test_config(Action::PreprocProduce);
1487 let mut all_files = HashMap::new();
1488 let bad_name = OsStr::from_bytes(b"\xff\xfe");
1489 let path = PathBuf::from("/some/dir").join(bad_name);
1490 process_dir_path(&mut all_files, &path, &cfg);
1491 assert!(all_files.is_empty());
1492 }
1493
1494 fn parse(args: &[&str]) -> clap::error::Result<Cli> {
1499 Cli::try_parse_from(std::iter::once(&"cli").chain(args.iter()))
1500 }
1501
1502 #[test]
1503 fn no_subcommand_prints_help() {
1504 assert!(parse(&[]).is_err());
1507 }
1508
1509 #[test]
1510 fn metrics_alone_parses() {
1511 assert!(parse(&["metrics"]).is_ok());
1512 }
1513
1514 #[test]
1515 fn metrics_with_format_parses() {
1516 assert!(parse(&["metrics", "-O", "json"]).is_ok());
1517 }
1518
1519 #[test]
1525 fn metrics_rejects_checkstyle_format() {
1526 assert!(parse(&["metrics", "-O", "checkstyle"]).is_err());
1527 }
1528
1529 #[test]
1530 fn metrics_rejects_sarif_format() {
1531 assert!(parse(&["metrics", "-O", "sarif"]).is_err());
1532 }
1533
1534 #[test]
1535 fn metrics_rejects_clang_warning_format() {
1536 assert!(parse(&["metrics", "-O", "clang-warning"]).is_err());
1537 }
1538
1539 #[test]
1540 fn metrics_rejects_msvc_warning_format() {
1541 assert!(parse(&["metrics", "-O", "msvc-warning"]).is_err());
1542 }
1543
1544 #[test]
1545 fn check_accepts_sarif_output_format() {
1546 assert!(parse(&["check", "--threshold", "cyclomatic=10", "-O", "sarif"]).is_ok());
1547 }
1548
1549 #[test]
1550 fn check_accepts_checkstyle_output_format() {
1551 assert!(
1552 parse(&[
1553 "check",
1554 "--threshold",
1555 "cyclomatic=10",
1556 "--output-format",
1557 "checkstyle",
1558 ])
1559 .is_ok()
1560 );
1561 }
1562
1563 #[test]
1564 fn check_rejects_per_file_format_as_output_format() {
1565 assert!(
1568 parse(&[
1569 "check",
1570 "--threshold",
1571 "cyclomatic=10",
1572 "--output-format",
1573 "json",
1574 ])
1575 .is_err()
1576 );
1577 }
1578
1579 #[test]
1585 fn metrics_rejects_markdown_format() {
1586 assert!(parse(&["metrics", "-O", "markdown"]).is_err());
1588 }
1589
1590 #[test]
1591 fn metrics_rejects_top_flag() {
1592 assert!(parse(&["metrics", "--top", "5"]).is_err());
1594 }
1595
1596 #[test]
1597 fn metrics_rejects_strip_prefix_flag() {
1598 assert!(parse(&["metrics", "--strip-prefix", "/x"]).is_err());
1599 }
1600
1601 #[test]
1602 fn report_markdown_parses() {
1603 assert!(parse(&["report", "markdown"]).is_ok());
1604 }
1605
1606 #[test]
1607 fn report_html_parses() {
1608 let cli = parse(&["report", "html"]).expect("`report html` parses");
1611 match cli.command {
1612 Command::Report(args) => assert_eq!(args.format, ReportFormat::Html),
1613 other => panic!("expected Command::Report, got {other:?}"),
1614 }
1615 }
1616
1617 #[test]
1618 fn report_requires_format() {
1619 assert!(parse(&["report"]).is_err());
1620 }
1621
1622 #[test]
1623 fn report_with_top_and_strip_prefix() {
1624 assert!(parse(&["report", "markdown", "--top", "10", "--strip-prefix", "/x/"]).is_ok());
1625 }
1626
1627 #[test]
1628 fn report_html_with_top_and_strip_prefix() {
1629 let cli = parse(&["report", "html", "--top", "10", "--strip-prefix", "/x/"])
1630 .expect("flags parse");
1631 match cli.command {
1632 Command::Report(args) => {
1633 assert_eq!(args.format, ReportFormat::Html);
1634 assert_eq!(args.top, 10);
1635 assert_eq!(args.strip_prefix, "/x/");
1636 }
1637 other => panic!("expected Command::Report, got {other:?}"),
1638 }
1639 }
1640
1641 #[test]
1642 fn report_top_zero_rejected() {
1643 assert!(parse(&["report", "markdown", "--top", "0"]).is_err());
1644 }
1645
1646 #[test]
1647 fn report_html_top_zero_rejected() {
1648 assert!(parse(&["report", "html", "--top", "0"]).is_err());
1649 }
1650
1651 #[test]
1652 fn ops_parses() {
1653 assert!(parse(&["ops", "-O", "json"]).is_ok());
1654 }
1655
1656 #[test]
1657 fn dump_parses() {
1658 assert!(parse(&["dump"]).is_ok());
1659 }
1660
1661 #[test]
1662 fn find_requires_a_node() {
1663 assert!(parse(&["find"]).is_err());
1664 assert!(parse(&["find", "call_expression"]).is_ok());
1665 }
1666
1667 #[test]
1668 fn count_requires_a_node() {
1669 assert!(parse(&["count"]).is_err());
1670 assert!(parse(&["count", "if_statement"]).is_ok());
1671 }
1672
1673 #[test]
1674 fn functions_parses() {
1675 assert!(parse(&["functions"]).is_ok());
1676 }
1677
1678 #[test]
1679 fn strip_comments_parses() {
1680 assert!(parse(&["strip-comments"]).is_ok());
1681 assert!(parse(&["strip-comments", "--in-place"]).is_ok());
1682 }
1683
1684 #[test]
1685 fn preproc_parses() {
1686 assert!(parse(&["preproc"]).is_ok());
1687 assert!(parse(&["preproc", "-o", "/tmp/x.json"]).is_ok());
1688 }
1689
1690 #[test]
1691 fn list_metrics_parses() {
1692 let cli = parse(&["list-metrics"]).expect("parses");
1693 assert!(matches!(cli.command, Command::ListMetrics(_)));
1694 }
1695
1696 #[test]
1697 fn list_metrics_with_descriptions() {
1698 let cli = parse(&["list-metrics", "descriptions"]).expect("parses");
1699 match cli.command {
1700 Command::ListMetrics(args) => assert_eq!(args.mode, ListMetricsMode::Descriptions),
1701 _ => panic!("expected ListMetrics"),
1702 }
1703 }
1704
1705 #[test]
1706 fn list_metrics_invalid_mode_rejected() {
1707 assert!(parse(&["list-metrics", "bogus"]).is_err());
1708 }
1709
1710 #[test]
1711 fn global_paths_works_before_or_after_subcommand() {
1712 assert!(parse(&["--paths", "x", "metrics"]).is_ok());
1713 assert!(parse(&["metrics", "--paths", "x"]).is_ok());
1714 }
1715
1716 fn os_args(args: &[&str]) -> Vec<OsString> {
1717 args.iter().map(|s| OsString::from(*s)).collect()
1718 }
1719
1720 #[test]
1721 fn legacy_hint_recognizes_old_metrics() {
1722 let hint = legacy_hint(os_args(&["cli", "--metrics", "-O", "markdown"])).expect("hint");
1723 assert!(hint.contains("report markdown"), "{hint}");
1724 assert!(hint.contains("--metrics"), "{hint}");
1725 }
1726
1727 #[test]
1728 fn legacy_hint_recognizes_output_format_json_with_legacy_action() {
1729 let hint = legacy_hint(os_args(&["cli", "-m", "--output-format", "json"])).expect("hint");
1732 assert!(hint.contains("metrics -O json"), "{hint}");
1733 }
1734
1735 #[test]
1736 fn legacy_hint_returns_none_for_clean_args() {
1737 let hint = legacy_hint(os_args(&["cli", "metrics", "-O", "json"]));
1740 assert!(hint.is_none());
1741 }
1742
1743 #[test]
1744 fn legacy_hint_returns_none_for_no_args() {
1745 let hint = legacy_hint(os_args(&["cli"]));
1746 assert!(hint.is_none());
1747 }
1748
1749 #[test]
1750 fn legacy_hint_recognizes_dash_o_markdown_alone() {
1751 let hint = legacy_hint(os_args(&["cli", "-O", "markdown"])).expect("hint");
1755 assert!(hint.contains("report markdown"), "{hint}");
1756 }
1757
1758 #[test]
1759 fn legacy_hint_redirects_metrics_offender_format_to_check() {
1760 let hint = legacy_hint(os_args(&["cli", "metrics", "-O", "sarif"])).expect("hint");
1764 assert!(hint.contains("bca check"), "{hint}");
1765 assert!(hint.contains("sarif"), "{hint}");
1766 }
1767
1768 #[test]
1769 fn legacy_hint_redirects_metrics_checkstyle_long_form() {
1770 let hint = legacy_hint(os_args(&[
1771 "cli",
1772 "metrics",
1773 "--output-format",
1774 "checkstyle",
1775 ]))
1776 .expect("hint");
1777 assert!(hint.contains("bca check"), "{hint}");
1778 assert!(hint.contains("checkstyle"), "{hint}");
1779 }
1780
1781 #[test]
1782 fn legacy_hint_redirects_ops_offender_format_to_check() {
1783 let hint = legacy_hint(os_args(&["cli", "ops", "-O", "clang-warning"])).expect("hint");
1785 assert!(hint.contains("bca check"), "{hint}");
1786 assert!(hint.contains("clang-warning"), "{hint}");
1787 }
1788
1789 #[test]
1790 fn legacy_hint_quiet_for_metrics_with_per_file_format() {
1791 let hint = legacy_hint(os_args(&["cli", "metrics", "-O", "json"]));
1793 assert!(hint.is_none(), "{hint:?}");
1794 }
1795
1796 #[test]
1797 fn legacy_hint_quiet_when_user_invoked_known_subcommand() {
1798 let hint = legacy_hint(os_args(&["cli", "find", "--dump"]));
1803 assert!(hint.is_none());
1804 }
1805
1806 #[test]
1807 fn legacy_hint_recognizes_dash_d() {
1808 let hint = legacy_hint(os_args(&["cli", "-d", "--paths", "."])).expect("hint");
1810 assert!(hint.contains("bca dump"), "{hint}");
1811 }
1812
1813 #[test]
1816 fn cli_is_well_formed() {
1817 use clap::CommandFactory;
1818 Cli::command().debug_assert();
1819 }
1820
1821 #[test]
1826 fn subcommands_match_command_enum() {
1827 use clap::CommandFactory;
1828 use std::collections::HashSet;
1829 let from_clap: HashSet<String> = Cli::command()
1830 .get_subcommands()
1831 .map(|c| c.get_name().to_string())
1832 .filter(|n| n != "help") .collect();
1834 let from_const: HashSet<String> = SUBCOMMANDS.iter().map(|s| (*s).to_string()).collect();
1835 assert_eq!(
1836 from_clap,
1837 from_const,
1838 "SUBCOMMANDS const drifted from Command enum: \
1839 missing from const = {missing:?}, missing from enum = {extra:?}",
1840 missing = from_clap.difference(&from_const).collect::<Vec<_>>(),
1841 extra = from_const.difference(&from_clap).collect::<Vec<_>>(),
1842 );
1843 }
1844}