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 = "exclude-from", value_parser, global = true)]
192 exclude_from: Option<PathBuf>,
193 #[clap(long = "no-ignore", global = true)]
197 no_ignore: bool,
198 #[clap(long = "exclude-tests", global = true)]
206 exclude_tests: bool,
207}
208
209#[derive(Subcommand, Debug)]
210enum Command {
211 Metrics(StructuredArgs),
213 Ops(StructuredArgs),
215 Report(ReportArgs),
217 Dump,
219 Find(NodesArgs),
221 Count(NodesArgs),
223 Functions,
225 StripComments(StripCommentsArgs),
227 Preproc(PreprocArgs),
229 ListMetrics(ListMetricsArgs),
231 Check(CheckArgs),
235}
236
237#[derive(Args, Debug)]
240struct StructuredArgs {
241 #[clap(long, short = 'O', value_enum)]
243 output_format: Option<MetricsFormat>,
244 #[clap(long, short, value_parser)]
247 output: Option<PathBuf>,
248 #[clap(long)]
250 pretty: bool,
251}
252
253#[derive(Args, Debug)]
254struct ReportArgs {
255 #[clap(value_enum)]
257 format: ReportFormat,
258 #[clap(long, short, value_parser)]
260 output: Option<PathBuf>,
261 #[clap(long, default_value_t = 20, value_parser = clap::value_parser!(u32).range(1..))]
263 top: u32,
264 #[clap(long, default_value = "")]
266 strip_prefix: String,
267}
268
269#[derive(Args, Debug)]
270struct NodesArgs {
271 #[clap(required = true, num_args = 1..)]
273 nodes: Vec<String>,
274}
275
276#[derive(Args, Debug)]
277struct StripCommentsArgs {
278 #[clap(long)]
280 in_place: bool,
281}
282
283#[derive(Args, Debug)]
284struct PreprocArgs {
285 #[clap(long, short, value_parser)]
287 output: Option<PathBuf>,
288}
289
290#[derive(Args, Debug)]
291struct CheckArgs {
292 #[clap(long = "threshold", value_parser = parse_cli_threshold)]
298 thresholds: Vec<(String, f64)>,
299 #[clap(long, value_parser)]
307 config: Option<PathBuf>,
308 #[clap(long = "no-fail")]
312 no_fail: bool,
313 #[clap(long = "no-suppress")]
318 no_suppress: bool,
319 #[clap(long = "output-format", short = 'O', value_enum)]
324 output_format: Option<AggregatedFormat>,
325 #[clap(long, short, value_parser)]
329 output: Option<PathBuf>,
330 #[clap(long = "baseline", value_parser, conflicts_with = "write_baseline")]
335 baseline: Option<PathBuf>,
336 #[clap(
343 long = "write-baseline",
344 value_parser,
345 conflicts_with_all = ["baseline", "output_format", "output"],
346 )]
347 write_baseline: Option<PathBuf>,
348}
349
350#[derive(Args, Debug)]
351struct ListMetricsArgs {
352 #[clap(value_enum, default_value_t = ListMetricsMode::Names)]
355 mode: ListMetricsMode,
356}
357
358#[derive(Debug)]
361enum Action {
362 Dump,
363 Metrics {
364 format: Option<MetricsFormat>,
365 pretty: bool,
366 },
367 Ops {
368 format: Option<MetricsFormat>,
369 pretty: bool,
370 },
371 StripComments {
372 in_place: bool,
373 },
374 Functions,
375 Find(Arc<[String]>),
376 Count(Arc<[String]>),
377 Report,
380 PreprocProduce,
382 Check,
384}
385
386#[derive(Debug)]
387struct Config {
388 action: Action,
389 output: Option<PathBuf>,
390 language: Option<LANG>,
391 line_start: Option<usize>,
392 line_end: Option<usize>,
393 preproc_lock: Option<Arc<Mutex<PreprocResults>>>,
394 preproc: Option<Arc<PreprocResults>>,
395 count_lock: Option<Arc<Mutex<Count>>>,
396 markdown_tx: Option<Mutex<std::sync::mpsc::Sender<FunctionSummary>>>,
399 strip_prefix: String,
401 threshold_set: Option<Arc<ThresholdSet>>,
404 check_tx: Option<Mutex<std::sync::mpsc::Sender<Violation>>>,
407 files_dispatched: Option<Arc<AtomicUsize>>,
413 suppression_policy: SuppressionPolicy,
419 warning: bool,
420 skip_generated: bool,
424 report_skipped: bool,
430 exclude_tests: bool,
437}
438
439impl Config {
440 fn new(action: Action, globals: &GlobalOpts, preproc: Option<Arc<PreprocResults>>) -> Self {
445 let language = resolve_language(globals.language_type.as_deref(), &action);
446 Self {
447 action,
448 output: None,
449 language,
450 line_start: globals.line_start,
451 line_end: globals.line_end,
452 preproc_lock: None,
453 preproc,
454 count_lock: None,
455 markdown_tx: None,
456 strip_prefix: String::new(),
457 threshold_set: None,
458 check_tx: None,
459 files_dispatched: None,
460 suppression_policy: SuppressionPolicy::Honor,
461 warning: globals.warning,
462 skip_generated: !globals.no_skip_generated,
463 report_skipped: globals.report_skipped,
464 exclude_tests: globals.exclude_tests,
465 }
466 }
467
468 #[inline]
473 fn metrics_options(&self) -> MetricsOptions {
474 MetricsOptions::default().with_exclude_tests(self.exclude_tests)
475 }
476}
477
478fn mk_globset(elems: Vec<String>) -> Result<GlobSet, String> {
479 if elems.is_empty() {
480 return Ok(GlobSet::empty());
481 }
482
483 let mut globset = GlobSetBuilder::new();
484 for e in &elems {
485 if e.is_empty() {
486 continue;
487 }
488 globset.add(Glob::new(e).map_err(|err| format!("invalid glob pattern {e:?}: {err}"))?);
489 }
490 globset
491 .build()
492 .map_err(|err| format!("failed to build glob set: {err}"))
493}
494
495#[allow(deprecated)]
505fn act_on_file(path: PathBuf, cfg: &Config) -> std::io::Result<()> {
506 if let Some(counter) = &cfg.files_dispatched {
507 counter.fetch_add(1, Ordering::Relaxed);
513 }
514
515 let Some(source) = read_file_with_eol(&path)? else {
516 if cfg.warning {
517 eprintln!("warning: skipping empty file: {}", path.display());
518 }
519 return Ok(());
520 };
521
522 if cfg.skip_generated && !matches!(cfg.action, Action::PreprocProduce) && is_generated(&source)
527 {
528 if cfg.report_skipped || cfg.warning {
529 eprintln!("skipped (generated): {}", path.display());
530 }
531 return Ok(());
532 }
533
534 let Some(language) = cfg.language.or_else(|| guess_language(&source, &path).0) else {
535 if cfg.warning {
536 eprintln!(
537 "warning: skipping file with unrecognized language: {}",
538 path.display()
539 );
540 }
541 return Ok(());
542 };
543
544 let pr = cfg.preproc.clone();
545 match &cfg.action {
546 Action::Dump => {
547 let dump_cfg = DumpCfg {
548 line_start: cfg.line_start,
549 line_end: cfg.line_end,
550 };
551 action::<Dump>(&language, source, &path, pr, dump_cfg).expect(FEATURES_PINNED)
555 }
556 Action::Metrics { format, pretty } => {
557 if let Some(fmt) = format {
558 if let Ok(space) = get_function_spaces_with_options(
559 &language,
560 source,
561 &path,
562 pr,
563 cfg.metrics_options(),
564 ) {
565 match fmt.dispatch() {
566 MetricsDispatch::Generic(g) => {
567 g.dump(space, path, cfg.output.as_ref(), *pretty)?;
568 }
569 MetricsDispatch::Csv => {
570 dump_csv(&space, path, cfg.output.as_ref())?;
571 }
572 }
573 }
574 Ok(())
575 } else {
576 let metrics_cfg = MetricsCfg::new(path).with_options(cfg.metrics_options());
577 let path = metrics_cfg.path.clone();
578 action::<Metrics>(&language, source, &path, pr, metrics_cfg).expect(FEATURES_PINNED)
579 }
580 }
581 Action::Ops { format, pretty } => {
582 if let Some(fmt) = format {
583 if let Ok(ops) = get_ops(&language, source, &path, pr) {
584 match fmt.dispatch() {
590 MetricsDispatch::Generic(g) => {
591 g.dump(ops, path, cfg.output.as_ref(), *pretty)?;
592 }
593 MetricsDispatch::Csv => {}
594 }
595 }
596 Ok(())
597 } else {
598 let ops_cfg = OpsCfg { path };
599 let path = ops_cfg.path.clone();
600 action::<OpsCode>(&language, source, &path, pr, ops_cfg).expect(FEATURES_PINNED)
601 }
602 }
603 Action::StripComments { in_place } => {
604 let comment_cfg = CommentRmCfg {
605 in_place: *in_place,
606 path,
607 };
608 let path = comment_cfg.path.clone();
609 let lang = if language == LANG::Cpp {
612 LANG::Ccomment
613 } else {
614 language
615 };
616 action::<CommentRm>(&lang, source, &path, pr, comment_cfg).expect(FEATURES_PINNED)
617 }
618 Action::Functions => {
619 let fn_cfg = FunctionCfg { path: path.clone() };
620 action::<Function>(&language, source, &path, pr, fn_cfg).expect(FEATURES_PINNED)
621 }
622 Action::Find(filters) => {
623 let find_cfg = FindCfg {
624 path: path.clone(),
625 filters: Arc::clone(filters),
626 line_start: cfg.line_start,
627 line_end: cfg.line_end,
628 };
629 action::<Find>(&language, source, &path, pr, find_cfg).expect(FEATURES_PINNED)
630 }
631 Action::Count(filters) => {
632 let stats = cfg
633 .count_lock
634 .clone()
635 .expect("Count handler initializes count_lock before dispatch");
636 let count_cfg = CountCfg {
637 filters: Arc::clone(filters),
638 stats,
639 };
640 action::<Count>(&language, source, &path, pr, count_cfg).expect(FEATURES_PINNED)
641 }
642 Action::Report => {
643 if let Ok(space) = get_function_spaces_with_options(
644 &language,
645 source,
646 &path,
647 pr,
648 cfg.metrics_options(),
649 ) && let Some(ref tx) = cfg.markdown_tx
650 && !matches!(language, LANG::Preproc | LANG::Ccomment)
651 {
652 let Some(file_str) = path.to_str() else {
661 if cfg.warning {
662 eprintln!(
663 "warning: skipping non-UTF-8 path in report: {}",
664 path.display()
665 );
666 }
667 return Ok(());
668 };
669 let mut summaries = Vec::new();
670 extract_summaries(
671 &space,
672 file_str,
673 language,
674 &cfg.strip_prefix,
675 &mut summaries,
676 );
677 let Ok(sender) = tx.lock() else {
678 if cfg.warning {
679 eprintln!(
680 "warning: skipping {}: report channel lock poisoned",
681 path.display()
682 );
683 }
684 return Ok(());
685 };
686 for s in summaries {
687 let _ = sender.send(s);
688 }
689 }
690 Ok(())
691 }
692 Action::Check => {
693 if let Ok(space) = get_function_spaces_with_options(
694 &language,
695 source,
696 &path,
697 pr,
698 cfg.metrics_options(),
699 ) && let (Some(set), Some(tx)) = (cfg.threshold_set.as_ref(), cfg.check_tx.as_ref())
700 && !matches!(language, LANG::Preproc | LANG::Ccomment)
701 {
702 let mut violations = Vec::new();
708 set.evaluate_with_policy(&path, &space, cfg.suppression_policy, &mut violations);
709 if !violations.is_empty() {
710 let Ok(sender) = tx.lock() else {
711 if cfg.warning {
712 eprintln!(
713 "warning: skipping {}: check channel lock poisoned",
714 path.display()
715 );
716 }
717 return Ok(());
718 };
719 for v in violations {
725 let _ = sender.send(v);
726 }
727 }
728 }
729 Ok(())
730 }
731 Action::PreprocProduce => {
732 if let Some(preproc_lock) = &cfg.preproc_lock
733 && let Some(language) = guess_language(&source, &path).0
734 && language == LANG::Cpp
735 {
736 let mut results = preproc_lock.lock().expect("mutex not poisoned");
737 preprocess(
738 &PreprocParser::new(source, &path, None),
739 &path,
740 &mut results,
741 );
742 }
743 Ok(())
744 }
745 }
746}
747
748fn process_dir_path(all_files: &mut HashMap<String, Vec<PathBuf>>, path: &Path, cfg: &Config) {
749 if !matches!(cfg.action, Action::PreprocProduce) {
750 return;
751 }
752 let Some(fname) = path.file_name().and_then(|n| n.to_str()) else {
753 return;
754 };
755 let file_name = fname.to_string();
756 match all_files.entry(file_name) {
757 hash_map::Entry::Occupied(l) => {
758 l.into_mut().push(path.to_path_buf());
759 }
760 hash_map::Entry::Vacant(p) => {
761 p.insert(vec![path.to_path_buf()]);
762 }
763 }
764}
765
766fn resolve_language(typ: Option<&str>, action: &Action) -> Option<LANG> {
767 if matches!(action, Action::PreprocProduce) {
771 return Some(LANG::Preproc);
772 }
773 match typ.unwrap_or("") {
774 "" => None,
775 "ccomment" => Some(LANG::Ccomment),
776 "preproc" => Some(LANG::Preproc),
777 other => get_from_ext(other),
778 }
779}
780
781fn resolve_num_jobs(requested: Option<usize>) -> usize {
782 requested.map_or_else(
783 || {
784 std::cmp::max(
785 2,
786 available_parallelism()
787 .unwrap_or_else(|e| {
788 die(format_args!("could not get available parallelism: {e}"))
789 })
790 .get(),
791 ) - 1
792 },
793 |num_jobs| std::cmp::max(2, num_jobs) - 1,
794 )
795}
796
797fn load_preproc_data(path: &Path) -> Arc<PreprocResults> {
800 let data = read_file(path).unwrap_or_else(|e| die_io("read preproc data", path, e));
801 let parsed = serde_json::from_slice::<PreprocResults>(&data)
802 .unwrap_or_else(|e| die_io("parse preproc JSON from", path, e));
803 Arc::new(parsed)
804}
805
806fn read_paths_from(src: &Path) -> Result<Vec<PathBuf>, String> {
812 read_lines_from(src, "--paths-from", path_pattern_filter)
813}
814
815fn path_pattern_filter(trimmed: &str) -> Option<PathBuf> {
821 (!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
822}
823
824fn read_exclude_patterns_from(src: &Path) -> Result<Vec<String>, String> {
832 read_lines_from(src, "--exclude-from", exclude_pattern_filter)
833}
834
835fn exclude_pattern_filter(trimmed: &str) -> Option<String> {
840 (!trimmed.is_empty() && !trimmed.starts_with('#')).then(|| trimmed.to_owned())
841}
842
843fn read_lines_from<T>(
854 src: &Path,
855 flag: &str,
856 map: impl Fn(&str) -> Option<T>,
857) -> Result<Vec<T>, String> {
858 if src.as_os_str() == "-" {
859 let label = format!("{flag} -");
860 collect_lines(std::io::stdin().lock(), &label, map)
861 } else {
862 let label = format!("{flag} {}", src.display());
863 let f = std::fs::File::open(src).map_err(|e| format!("{label}: {e}"))?;
864 collect_lines(std::io::BufReader::new(f), &label, map)
865 }
866}
867
868fn collect_lines<R, T>(
885 reader: R,
886 label: &str,
887 map: impl Fn(&str) -> Option<T>,
888) -> Result<Vec<T>, String>
889where
890 R: std::io::BufRead,
891{
892 reader
893 .lines()
894 .enumerate()
895 .filter_map(|(i, r)| match r {
896 Ok(line) => {
897 map(line.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}')).map(Ok)
898 }
899 Err(e) => Some(Err(format!("{label}: read error on line {}: {e}", i + 1))),
900 })
901 .collect()
902}
903
904fn expand_seed_paths(
913 paths: Vec<PathBuf>,
914 paths_from: Option<PathBuf>,
915 no_ignore: bool,
916) -> Vec<PathBuf> {
917 use ignore::WalkBuilder;
918 let mut seeds = paths;
919 if let Some(src) = paths_from {
920 seeds.extend(read_paths_from(&src).unwrap_or_else(|e| die(e)));
921 }
922 let mut out: Vec<PathBuf> = Vec::new();
923 for seed in seeds {
924 if !seed.exists() {
925 eprintln!("Warning: File doesn't exist: {}", seed.display());
927 continue;
928 }
929 if seed.is_file() {
930 out.push(seed);
931 continue;
932 }
933 let mut wb = WalkBuilder::new(&seed);
934 wb.hidden(true)
935 .follow_links(false)
936 .require_git(false)
937 .git_ignore(!no_ignore)
938 .git_exclude(!no_ignore)
939 .git_global(!no_ignore)
940 .ignore(!no_ignore)
941 .parents(!no_ignore);
942 for entry in wb.build() {
943 let entry = entry
944 .unwrap_or_else(|e| die(format_args!("walk error in {}: {e}", seed.display())));
945 if entry.file_type().is_some_and(|t| t.is_file()) {
946 out.push(entry.into_path());
947 }
948 }
949 }
950 out
951}
952
953fn run_walk(globals: GlobalOpts, cfg: Config) -> HashMap<String, Vec<PathBuf>> {
954 let include = mk_globset(globals.include).unwrap_or_else(|e| die(e));
955 let mut exclude_patterns = globals.exclude;
956 if let Some(src) = globals.exclude_from {
957 exclude_patterns.extend(read_exclude_patterns_from(&src).unwrap_or_else(|e| die(e)));
958 }
959 let exclude = mk_globset(exclude_patterns).unwrap_or_else(|e| die(e));
960 let num_jobs = resolve_num_jobs(globals.num_jobs);
961 let paths = expand_seed_paths(globals.paths, globals.paths_from, globals.no_ignore);
962 let files_data = FilesData {
963 include,
964 exclude,
965 paths,
966 };
967 ConcurrentRunner::new(num_jobs, act_on_file)
968 .set_proc_dir_paths(process_dir_path)
969 .run(cfg, files_data)
970 .unwrap_or_else(|e| die(format_args!("{e:?}")))
971}
972
973fn load_threshold_config(path: &Path) -> BTreeMap<String, f64> {
977 let bytes = read_file(path).unwrap_or_else(|e| die_io("read threshold config", path, e));
978 let text = std::str::from_utf8(&bytes)
979 .unwrap_or_else(|e| die_io("decode UTF-8 from threshold config", path, e));
980 let cfg: ThresholdConfig =
981 toml::from_str(text).unwrap_or_else(|e| die_io("parse threshold config", path, e));
982 cfg.thresholds
983}
984
985fn load_baseline(path: &Path) -> Baseline {
988 let bytes = read_file(path).unwrap_or_else(|e| die_io("read baseline", path, e));
989 let text = std::str::from_utf8(&bytes)
990 .unwrap_or_else(|e| die_io("decode UTF-8 from baseline", path, e));
991 Baseline::from_str(text).unwrap_or_else(|e| die_io("parse baseline", path, e))
992}
993
994fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1005 if let Some(parent) = path.parent()
1006 && !parent.as_os_str().is_empty()
1007 {
1008 std::fs::create_dir_all(parent)?;
1009 }
1010 let mut tmp = path.as_os_str().to_os_string();
1011 tmp.push(".bca-tmp");
1012 let tmp = PathBuf::from(tmp);
1013 std::fs::write(&tmp, bytes)?;
1014 std::fs::rename(&tmp, path).inspect_err(|_| {
1015 let _ = std::fs::remove_file(&tmp);
1019 })
1020}
1021
1022fn run_check(globals: GlobalOpts, args: CheckArgs, preproc: Option<Arc<PreprocResults>>) {
1025 if let Some(fmt) = args.output_format
1032 && let Some(ref out) = args.output
1033 && out.exists()
1034 && out.is_dir()
1035 {
1036 die(format_args!(
1037 "--output must be a file path for `check --output-format {}`",
1038 fmt.name()
1039 ));
1040 }
1041
1042 let mut merged: BTreeMap<String, f64> = args
1043 .config
1044 .as_deref()
1045 .map(load_threshold_config)
1046 .unwrap_or_default();
1047 for (name, limit) in args.thresholds {
1049 merged.insert(name, limit);
1050 }
1051 let set = ThresholdSet::build(&merged).unwrap_or_else(|e| die(e));
1052 if set.is_empty() {
1053 die("no thresholds configured; pass --threshold or --config");
1054 }
1055 let set = Arc::new(set);
1056
1057 let (tx, rx) = std::sync::mpsc::channel();
1058 let files_dispatched = Arc::new(AtomicUsize::new(0));
1059 let cfg = Config {
1060 threshold_set: Some(Arc::clone(&set)),
1061 check_tx: Some(Mutex::new(tx)),
1062 files_dispatched: Some(Arc::clone(&files_dispatched)),
1063 suppression_policy: SuppressionPolicy::from_no_suppress(args.no_suppress),
1064 ..Config::new(Action::Check, &globals, preproc)
1065 };
1066 run_walk(globals, cfg);
1067
1068 if files_dispatched.load(Ordering::Relaxed) == 0 {
1069 die("bca check: no input files matched; check --paths, --include, --exclude");
1074 }
1075
1076 let mut violations: Vec<Violation> = rx.into_iter().collect();
1079 violations.sort_by(|a, b| {
1083 a.path
1084 .cmp(&b.path)
1085 .then(a.start_line.cmp(&b.start_line))
1086 .then(a.metric.cmp(b.metric))
1087 });
1088
1089 if let Some(path) = args.write_baseline {
1090 let file = baseline::from_violations(violations);
1091 let entry_count = file.entries.len();
1092 let text = baseline::render(&file)
1093 .unwrap_or_else(|e| die(format_args!("serialize baseline: {e}")));
1094 write_atomic(&path, text.as_bytes()).unwrap_or_else(|e| die_io("write baseline", &path, e));
1095 eprintln!(
1096 "bca: wrote {entry_count} baseline entries to {}",
1097 path.display()
1098 );
1099 return;
1100 }
1101
1102 let violations: Vec<Violation> = if let Some(path) = args.baseline.as_deref() {
1103 let baseline = load_baseline(path);
1104 let before = violations.len();
1105 let kept: Vec<Violation> = violations
1106 .into_iter()
1107 .filter(|v| !baseline.covers(v))
1108 .collect();
1109 let filtered = before - kept.len();
1110 if filtered > 0 {
1111 eprintln!("bca: filtered {filtered} violations via baseline");
1112 }
1113 kept
1114 } else {
1115 violations
1116 };
1117
1118 let mut stderr = std::io::stderr().lock();
1122 for v in &violations {
1123 let _ = writeln!(stderr, "{v}");
1124 }
1125
1126 let any_violations = !violations.is_empty();
1131 if let Some(fmt) = args.output_format {
1132 let offenders: Vec<_> = violations.into_iter().map(violation_to_offender).collect();
1133 fmt.dump(&offenders, args.output.as_deref())
1134 .unwrap_or_else(|e| die(format_args!("failed to write {}: {e}", fmt.name())));
1135 }
1136
1137 if any_violations && !args.no_fail {
1138 process::exit(2);
1139 }
1140}
1141
1142pub fn run() {
1167 let cli = match Cli::try_parse() {
1168 Ok(cli) => cli,
1169 Err(err) => {
1170 if matches!(
1171 err.kind(),
1172 clap::error::ErrorKind::UnknownArgument
1173 | clap::error::ErrorKind::InvalidSubcommand
1174 | clap::error::ErrorKind::InvalidValue
1175 | clap::error::ErrorKind::MissingSubcommand
1176 | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
1177 ) && let Some(hint) = legacy_hint(std::env::args_os())
1178 {
1179 eprintln!("{hint}");
1180 }
1181 err.exit();
1182 }
1183 };
1184
1185 let preproc = cli
1186 .globals
1187 .preproc_data
1188 .as_ref()
1189 .map(|p| load_preproc_data(p));
1190
1191 match cli.command {
1192 Command::ListMetrics(args) => {
1193 let mut buf = Vec::new();
1194 write_metrics(&mut buf, args.mode).expect("writing to Vec<u8> is infallible");
1195 write_stdout_or_die(&buf);
1196 }
1197 Command::Dump => {
1198 let cfg = Config::new(Action::Dump, &cli.globals, preproc);
1199 run_walk(cli.globals, cfg);
1200 }
1201 Command::Functions => {
1202 let cfg = Config::new(Action::Functions, &cli.globals, preproc);
1203 run_walk(cli.globals, cfg);
1204 }
1205 Command::Metrics(args) => {
1206 if matches!(args.output_format, Some(MetricsFormat::Cbor)) && args.output.is_none() {
1207 die(CBOR_STDOUT_ERROR);
1208 }
1209 if args.output_format.is_some()
1210 && let Some(ref out) = args.output
1211 && out.exists()
1212 && !out.is_dir()
1213 {
1214 die("--output must be a directory for `metrics`");
1215 }
1216 let action = Action::Metrics {
1217 format: args.output_format,
1218 pretty: args.pretty,
1219 };
1220 let cfg = Config {
1221 output: args.output,
1222 ..Config::new(action, &cli.globals, preproc)
1223 };
1224 run_walk(cli.globals, cfg);
1225 }
1226 Command::Ops(args) => {
1227 if matches!(args.output_format, Some(MetricsFormat::Cbor)) && args.output.is_none() {
1228 die(CBOR_STDOUT_ERROR);
1229 }
1230 if let Some(MetricsDispatch::Csv) = args.output_format.map(MetricsFormat::dispatch) {
1231 die(
1232 "CSV is not supported by `ops` because its column schema is metric-shaped; use `bca metrics --output-format <fmt>`",
1233 );
1234 }
1235 if args.output_format.is_some()
1236 && let Some(ref out) = args.output
1237 && out.exists()
1238 && !out.is_dir()
1239 {
1240 die("--output must be a directory for `ops`");
1241 }
1242 let action = Action::Ops {
1243 format: args.output_format,
1244 pretty: args.pretty,
1245 };
1246 let cfg = Config {
1247 output: args.output,
1248 ..Config::new(action, &cli.globals, preproc)
1249 };
1250 run_walk(cli.globals, cfg);
1251 }
1252 Command::Report(args) => {
1253 if let Some(ref output) = args.output {
1254 if output.exists() && output.is_dir() {
1255 die("--output must be a file path for `report`");
1256 }
1257 if let Some(parent) = output.parent()
1258 && !parent.as_os_str().is_empty()
1259 && !parent.exists()
1260 {
1261 die(format_args!(
1262 "parent directory of --output does not exist: {}",
1263 parent.display()
1264 ));
1265 }
1266 }
1267 let (tx, rx) = std::sync::mpsc::channel();
1268 let cfg = Config {
1269 markdown_tx: Some(Mutex::new(tx)),
1270 strip_prefix: args.strip_prefix,
1271 ..Config::new(Action::Report, &cli.globals, preproc)
1272 };
1273 run_walk(cli.globals, cfg);
1274
1275 let summaries: Vec<FunctionSummary> = rx.into_iter().collect();
1278 let report = match args.format {
1279 ReportFormat::Markdown => generate_report(&summaries, args.top as usize),
1280 ReportFormat::Html => generate_html_report(&summaries, args.top as usize),
1281 };
1282 if let Some(ref output_path) = args.output {
1283 std::fs::write(output_path, &report)
1284 .unwrap_or_else(|e| die_io("write report to", output_path, e));
1285 } else {
1286 write_stdout_or_die(report.as_bytes());
1287 }
1288 }
1289 Command::Find(args) => {
1290 let cfg = Config::new(Action::Find(args.nodes.into()), &cli.globals, preproc);
1291 run_walk(cli.globals, cfg);
1292 }
1293 Command::Count(args) => {
1294 let count_lock = Arc::new(Mutex::new(Count::default()));
1295 let cfg = Config {
1296 count_lock: Some(count_lock.clone()),
1297 ..Config::new(Action::Count(args.nodes.into()), &cli.globals, preproc)
1298 };
1299 run_walk(cli.globals, cfg);
1300
1301 let count = Arc::try_unwrap(count_lock)
1302 .expect("all worker threads have joined; Arc refcount is 1")
1303 .into_inner()
1304 .expect("mutex not poisoned");
1305 println!("{count}");
1306 }
1307 Command::StripComments(args) => {
1308 let action = Action::StripComments {
1309 in_place: args.in_place,
1310 };
1311 let cfg = Config::new(action, &cli.globals, preproc);
1312 run_walk(cli.globals, cfg);
1313 }
1314 Command::Check(args) => {
1315 run_check(cli.globals, args, preproc);
1316 }
1317 Command::Preproc(args) => {
1318 let preproc_lock = Arc::new(Mutex::new(PreprocResults::default()));
1319 let output = args.output;
1320 let cfg = Config {
1321 preproc_lock: Some(preproc_lock.clone()),
1322 ..Config::new(Action::PreprocProduce, &cli.globals, None)
1323 };
1324 let all_files = run_walk(cli.globals, cfg);
1325
1326 let mut data = Arc::try_unwrap(preproc_lock)
1327 .expect("all worker threads have joined; Arc refcount is 1")
1328 .into_inner()
1329 .expect("mutex not poisoned");
1330 fix_includes(&mut data.files, &all_files);
1331
1332 let serialized = serde_json::to_string(&data)
1333 .unwrap_or_else(|e| die(format_args!("failed to serialize preproc data: {e}")));
1334 if let Some(output_path) = output {
1335 write_file(&output_path, serialized.as_bytes())
1336 .unwrap_or_else(|e| die_io("write preproc output to", &output_path, e));
1337 } else {
1338 println!("{serialized}");
1339 }
1340 }
1341 }
1342}
1343
1344const SUBCOMMANDS: &[&str] = &[
1348 "metrics",
1349 "ops",
1350 "report",
1351 "dump",
1352 "find",
1353 "count",
1354 "functions",
1355 "strip-comments",
1356 "preproc",
1357 "list-metrics",
1358 "check",
1359];
1360
1361fn parse_output_format_value(args: &[String]) -> Option<&str> {
1367 args.iter().enumerate().find_map(|(i, a)| {
1368 let s = a.as_str();
1369 if s == "-O" || s == "--output-format" {
1370 args.get(i + 1).map(String::as_str)
1371 } else if let Some(rest) = s.strip_prefix("--output-format=") {
1372 Some(rest)
1373 } else {
1374 s.strip_prefix("-O").filter(|r| !r.is_empty())
1375 }
1376 })
1377}
1378
1379fn offender_format_migration_hint(args: &[String]) -> Option<String> {
1385 let fmt =
1386 parse_output_format_value(args).filter(|f| AggregatedFormat::from_str(f, true).is_ok())?;
1387 Some(format!(
1388 "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"
1389 ))
1390}
1391
1392fn legacy_hint(argv: impl IntoIterator<Item = OsString>) -> Option<String> {
1400 let args: Vec<String> = argv
1401 .into_iter()
1402 .skip(1) .filter_map(|s| s.into_string().ok())
1404 .collect();
1405 if args.is_empty() {
1406 return None;
1407 }
1408
1409 if let Some(sub) = args.iter().find(|a| SUBCOMMANDS.contains(&a.as_str())) {
1418 if matches!(sub.as_str(), "metrics" | "ops")
1419 && let Some(hint) = offender_format_migration_hint(&args)
1420 {
1421 return Some(hint);
1422 }
1423 return None;
1424 }
1425
1426 let action_map: &[(&str, &str)] = &[
1428 ("--metrics", "bca metrics"),
1429 ("-m", "bca metrics"),
1430 ("--ops", "bca ops"),
1431 ("--dump", "bca dump"),
1432 ("-d", "bca dump"),
1433 ("--comments", "bca strip-comments [--in-place]"),
1434 ("--function", "bca functions"),
1435 ("-F", "bca functions"),
1436 ("--find", "bca find <NODE> [<NODE>...]"),
1437 ("-f", "bca find <NODE> [<NODE>...]"),
1438 ("--count", "bca count <NODE> [<NODE>...]"),
1439 ("-C", "bca count <NODE> [<NODE>...]"),
1440 ("--list-metrics", "bca list-metrics [names|descriptions]"),
1441 (
1442 "--preproc",
1443 "bca preproc -o OUT.json (or --preproc-data on consumers)",
1444 ),
1445 ];
1446
1447 let mut lines: Vec<String> = Vec::new();
1448 let mut saw_legacy_action = false;
1449
1450 for arg in &args {
1451 let head = arg.split('=').next().unwrap_or(arg);
1452 if let Some((_, replacement)) = action_map.iter().find(|(old, _)| *old == head) {
1453 saw_legacy_action = true;
1454 lines.push(format!(" {head} -> {replacement}"));
1455 }
1456 }
1457
1458 let format_value = parse_output_format_value(&args);
1462 if format_value == Some("markdown") {
1463 saw_legacy_action = true;
1464 lines.push(String::from(
1465 " -O markdown -> bca report markdown|html [--top N] [--strip-prefix P]",
1466 ));
1467 } else if let Some(fmt) = format_value
1468 && saw_legacy_action
1469 {
1470 lines.push(format!(" -O {fmt} -> bca metrics -O {fmt}"));
1474 }
1475
1476 if !saw_legacy_action {
1477 return None;
1478 }
1479
1480 let mut hint = String::from(
1481 "note: the CLI was restructured into subcommands. See migration.md for the full mapping.\n",
1482 );
1483 for line in &lines {
1484 hint.push_str(line);
1485 hint.push('\n');
1486 }
1487 hint.push_str(" Run `bca --help` for the new command list.\n");
1488 Some(hint)
1489}
1490
1491#[cfg(test)]
1492#[allow(
1493 clippy::float_cmp,
1494 clippy::cast_precision_loss,
1495 clippy::cast_possible_truncation,
1496 clippy::cast_sign_loss,
1497 clippy::similar_names,
1498 clippy::doc_markdown,
1499 clippy::needless_raw_string_hashes,
1500 clippy::too_many_lines
1501)]
1502mod tests {
1503 use super::*;
1504
1505 fn test_config(action: Action) -> Config {
1506 Config {
1507 action,
1508 output: None,
1509 language: None,
1510 line_start: None,
1511 line_end: None,
1512 preproc_lock: None,
1513 preproc: None,
1514 count_lock: None,
1515 markdown_tx: None,
1516 strip_prefix: String::new(),
1517 threshold_set: None,
1518 check_tx: None,
1519 files_dispatched: None,
1520 suppression_policy: SuppressionPolicy::Honor,
1521 warning: false,
1522 skip_generated: true,
1523 report_skipped: false,
1524 exclude_tests: false,
1525 }
1526 }
1527
1528 #[test]
1529 fn process_dir_path_noop_outside_preproc() {
1530 let cfg = test_config(Action::Dump);
1531 let mut all_files = HashMap::new();
1532 process_dir_path(&mut all_files, Path::new("/some/file.cpp"), &cfg);
1533 assert!(all_files.is_empty());
1534 }
1535
1536 #[test]
1537 fn process_dir_path_inserts_valid_utf8_filename() {
1538 let cfg = test_config(Action::PreprocProduce);
1539 let mut all_files = HashMap::new();
1540 process_dir_path(&mut all_files, Path::new("/some/dir/foo.cpp"), &cfg);
1541 assert_eq!(all_files.len(), 1);
1542 assert_eq!(
1543 all_files["foo.cpp"],
1544 vec![PathBuf::from("/some/dir/foo.cpp")]
1545 );
1546 }
1547
1548 #[test]
1549 fn process_dir_path_groups_duplicate_filenames() {
1550 let cfg = test_config(Action::PreprocProduce);
1551 let mut all_files = HashMap::new();
1552 process_dir_path(&mut all_files, Path::new("/a/foo.cpp"), &cfg);
1553 process_dir_path(&mut all_files, Path::new("/b/foo.cpp"), &cfg);
1554 assert_eq!(all_files.len(), 1);
1555 assert_eq!(
1556 all_files["foo.cpp"],
1557 vec![PathBuf::from("/a/foo.cpp"), PathBuf::from("/b/foo.cpp")]
1558 );
1559 }
1560
1561 #[cfg(unix)]
1562 #[test]
1563 fn process_dir_path_skips_non_utf8_filename() {
1564 use std::ffi::OsStr;
1565 use std::os::unix::ffi::OsStrExt;
1566
1567 let cfg = test_config(Action::PreprocProduce);
1568 let mut all_files = HashMap::new();
1569 let bad_name = OsStr::from_bytes(b"\xff\xfe");
1570 let path = PathBuf::from("/some/dir").join(bad_name);
1571 process_dir_path(&mut all_files, &path, &cfg);
1572 assert!(all_files.is_empty());
1573 }
1574
1575 fn parse(args: &[&str]) -> clap::error::Result<Cli> {
1580 Cli::try_parse_from(std::iter::once(&"cli").chain(args.iter()))
1581 }
1582
1583 #[test]
1584 fn no_subcommand_prints_help() {
1585 assert!(parse(&[]).is_err());
1588 }
1589
1590 #[test]
1591 fn metrics_alone_parses() {
1592 assert!(parse(&["metrics"]).is_ok());
1593 }
1594
1595 #[test]
1596 fn metrics_with_format_parses() {
1597 assert!(parse(&["metrics", "-O", "json"]).is_ok());
1598 }
1599
1600 #[test]
1606 fn metrics_rejects_checkstyle_format() {
1607 assert!(parse(&["metrics", "-O", "checkstyle"]).is_err());
1608 }
1609
1610 #[test]
1611 fn metrics_rejects_sarif_format() {
1612 assert!(parse(&["metrics", "-O", "sarif"]).is_err());
1613 }
1614
1615 #[test]
1616 fn metrics_rejects_clang_warning_format() {
1617 assert!(parse(&["metrics", "-O", "clang-warning"]).is_err());
1618 }
1619
1620 #[test]
1621 fn metrics_rejects_msvc_warning_format() {
1622 assert!(parse(&["metrics", "-O", "msvc-warning"]).is_err());
1623 }
1624
1625 #[test]
1626 fn check_accepts_sarif_output_format() {
1627 assert!(parse(&["check", "--threshold", "cyclomatic=10", "-O", "sarif"]).is_ok());
1628 }
1629
1630 #[test]
1631 fn check_accepts_checkstyle_output_format() {
1632 assert!(
1633 parse(&[
1634 "check",
1635 "--threshold",
1636 "cyclomatic=10",
1637 "--output-format",
1638 "checkstyle",
1639 ])
1640 .is_ok()
1641 );
1642 }
1643
1644 #[test]
1645 fn check_rejects_per_file_format_as_output_format() {
1646 assert!(
1649 parse(&[
1650 "check",
1651 "--threshold",
1652 "cyclomatic=10",
1653 "--output-format",
1654 "json",
1655 ])
1656 .is_err()
1657 );
1658 }
1659
1660 #[test]
1666 fn metrics_rejects_markdown_format() {
1667 assert!(parse(&["metrics", "-O", "markdown"]).is_err());
1669 }
1670
1671 #[test]
1672 fn metrics_rejects_top_flag() {
1673 assert!(parse(&["metrics", "--top", "5"]).is_err());
1675 }
1676
1677 #[test]
1678 fn metrics_rejects_strip_prefix_flag() {
1679 assert!(parse(&["metrics", "--strip-prefix", "/x"]).is_err());
1680 }
1681
1682 #[test]
1683 fn report_markdown_parses() {
1684 assert!(parse(&["report", "markdown"]).is_ok());
1685 }
1686
1687 #[test]
1688 fn report_html_parses() {
1689 let cli = parse(&["report", "html"]).expect("`report html` parses");
1692 match cli.command {
1693 Command::Report(args) => assert_eq!(args.format, ReportFormat::Html),
1694 other => panic!("expected Command::Report, got {other:?}"),
1695 }
1696 }
1697
1698 #[test]
1699 fn report_requires_format() {
1700 assert!(parse(&["report"]).is_err());
1701 }
1702
1703 #[test]
1704 fn report_with_top_and_strip_prefix() {
1705 assert!(parse(&["report", "markdown", "--top", "10", "--strip-prefix", "/x/"]).is_ok());
1706 }
1707
1708 #[test]
1709 fn report_html_with_top_and_strip_prefix() {
1710 let cli = parse(&["report", "html", "--top", "10", "--strip-prefix", "/x/"])
1711 .expect("flags parse");
1712 match cli.command {
1713 Command::Report(args) => {
1714 assert_eq!(args.format, ReportFormat::Html);
1715 assert_eq!(args.top, 10);
1716 assert_eq!(args.strip_prefix, "/x/");
1717 }
1718 other => panic!("expected Command::Report, got {other:?}"),
1719 }
1720 }
1721
1722 #[test]
1723 fn report_top_zero_rejected() {
1724 assert!(parse(&["report", "markdown", "--top", "0"]).is_err());
1725 }
1726
1727 #[test]
1728 fn report_html_top_zero_rejected() {
1729 assert!(parse(&["report", "html", "--top", "0"]).is_err());
1730 }
1731
1732 #[test]
1733 fn ops_parses() {
1734 assert!(parse(&["ops", "-O", "json"]).is_ok());
1735 }
1736
1737 #[test]
1738 fn dump_parses() {
1739 assert!(parse(&["dump"]).is_ok());
1740 }
1741
1742 #[test]
1743 fn find_requires_a_node() {
1744 assert!(parse(&["find"]).is_err());
1745 assert!(parse(&["find", "call_expression"]).is_ok());
1746 }
1747
1748 #[test]
1749 fn count_requires_a_node() {
1750 assert!(parse(&["count"]).is_err());
1751 assert!(parse(&["count", "if_statement"]).is_ok());
1752 }
1753
1754 #[test]
1755 fn functions_parses() {
1756 assert!(parse(&["functions"]).is_ok());
1757 }
1758
1759 #[test]
1760 fn strip_comments_parses() {
1761 assert!(parse(&["strip-comments"]).is_ok());
1762 assert!(parse(&["strip-comments", "--in-place"]).is_ok());
1763 }
1764
1765 #[test]
1766 fn preproc_parses() {
1767 assert!(parse(&["preproc"]).is_ok());
1768 assert!(parse(&["preproc", "-o", "/tmp/x.json"]).is_ok());
1769 }
1770
1771 #[test]
1772 fn list_metrics_parses() {
1773 let cli = parse(&["list-metrics"]).expect("parses");
1774 assert!(matches!(cli.command, Command::ListMetrics(_)));
1775 }
1776
1777 #[test]
1778 fn list_metrics_with_descriptions() {
1779 let cli = parse(&["list-metrics", "descriptions"]).expect("parses");
1780 match cli.command {
1781 Command::ListMetrics(args) => assert_eq!(args.mode, ListMetricsMode::Descriptions),
1782 _ => panic!("expected ListMetrics"),
1783 }
1784 }
1785
1786 #[test]
1787 fn list_metrics_invalid_mode_rejected() {
1788 assert!(parse(&["list-metrics", "bogus"]).is_err());
1789 }
1790
1791 #[test]
1792 fn global_paths_works_before_or_after_subcommand() {
1793 assert!(parse(&["--paths", "x", "metrics"]).is_ok());
1794 assert!(parse(&["metrics", "--paths", "x"]).is_ok());
1795 }
1796
1797 fn os_args(args: &[&str]) -> Vec<OsString> {
1798 args.iter().map(|s| OsString::from(*s)).collect()
1799 }
1800
1801 #[test]
1802 fn legacy_hint_recognizes_old_metrics() {
1803 let hint = legacy_hint(os_args(&["cli", "--metrics", "-O", "markdown"])).expect("hint");
1804 assert!(hint.contains("report markdown"), "{hint}");
1805 assert!(hint.contains("--metrics"), "{hint}");
1806 }
1807
1808 #[test]
1809 fn legacy_hint_recognizes_output_format_json_with_legacy_action() {
1810 let hint = legacy_hint(os_args(&["cli", "-m", "--output-format", "json"])).expect("hint");
1813 assert!(hint.contains("metrics -O json"), "{hint}");
1814 }
1815
1816 #[test]
1817 fn legacy_hint_returns_none_for_clean_args() {
1818 let hint = legacy_hint(os_args(&["cli", "metrics", "-O", "json"]));
1821 assert!(hint.is_none());
1822 }
1823
1824 #[test]
1825 fn legacy_hint_returns_none_for_no_args() {
1826 let hint = legacy_hint(os_args(&["cli"]));
1827 assert!(hint.is_none());
1828 }
1829
1830 #[test]
1831 fn legacy_hint_recognizes_dash_o_markdown_alone() {
1832 let hint = legacy_hint(os_args(&["cli", "-O", "markdown"])).expect("hint");
1836 assert!(hint.contains("report markdown"), "{hint}");
1837 }
1838
1839 #[test]
1840 fn legacy_hint_redirects_metrics_offender_format_to_check() {
1841 let hint = legacy_hint(os_args(&["cli", "metrics", "-O", "sarif"])).expect("hint");
1845 assert!(hint.contains("bca check"), "{hint}");
1846 assert!(hint.contains("sarif"), "{hint}");
1847 }
1848
1849 #[test]
1850 fn legacy_hint_redirects_metrics_checkstyle_long_form() {
1851 let hint = legacy_hint(os_args(&[
1852 "cli",
1853 "metrics",
1854 "--output-format",
1855 "checkstyle",
1856 ]))
1857 .expect("hint");
1858 assert!(hint.contains("bca check"), "{hint}");
1859 assert!(hint.contains("checkstyle"), "{hint}");
1860 }
1861
1862 #[test]
1863 fn legacy_hint_redirects_ops_offender_format_to_check() {
1864 let hint = legacy_hint(os_args(&["cli", "ops", "-O", "clang-warning"])).expect("hint");
1866 assert!(hint.contains("bca check"), "{hint}");
1867 assert!(hint.contains("clang-warning"), "{hint}");
1868 }
1869
1870 #[test]
1871 fn legacy_hint_quiet_for_metrics_with_per_file_format() {
1872 let hint = legacy_hint(os_args(&["cli", "metrics", "-O", "json"]));
1874 assert!(hint.is_none(), "{hint:?}");
1875 }
1876
1877 #[test]
1878 fn legacy_hint_quiet_when_user_invoked_known_subcommand() {
1879 let hint = legacy_hint(os_args(&["cli", "find", "--dump"]));
1884 assert!(hint.is_none());
1885 }
1886
1887 #[test]
1888 fn legacy_hint_recognizes_dash_d() {
1889 let hint = legacy_hint(os_args(&["cli", "-d", "--paths", "."])).expect("hint");
1891 assert!(hint.contains("bca dump"), "{hint}");
1892 }
1893
1894 #[test]
1897 fn cli_is_well_formed() {
1898 use clap::CommandFactory;
1899 Cli::command().debug_assert();
1900 }
1901
1902 #[test]
1907 fn subcommands_match_command_enum() {
1908 use clap::CommandFactory;
1909 use std::collections::HashSet;
1910 let from_clap: HashSet<String> = Cli::command()
1911 .get_subcommands()
1912 .map(|c| c.get_name().to_string())
1913 .filter(|n| n != "help") .collect();
1915 let from_const: HashSet<String> = SUBCOMMANDS.iter().map(|s| (*s).to_string()).collect();
1916 assert_eq!(
1917 from_clap,
1918 from_const,
1919 "SUBCOMMANDS const drifted from Command enum: \
1920 missing from const = {missing:?}, missing from enum = {extra:?}",
1921 missing = from_clap.difference(&from_const).collect::<Vec<_>>(),
1922 extra = from_const.difference(&from_clap).collect::<Vec<_>>(),
1923 );
1924 }
1925
1926 #[test]
1927 fn collect_lines_skips_blank_and_comment_lines() {
1928 let input = concat!(
1933 "# comment at top\n",
1934 "target/\n",
1935 "\n",
1936 " # indented comment\n",
1937 "node_modules/\n",
1938 "\n",
1939 "\t\n",
1940 "**/*.snap\n",
1941 " tests/repositories/** \n",
1942 );
1943 let got = collect_lines(std::io::Cursor::new(input), "test", exclude_pattern_filter)
1944 .expect("ASCII fixture decodes cleanly");
1945 assert_eq!(
1946 got,
1947 vec![
1948 "target/",
1949 "node_modules/",
1950 "**/*.snap",
1951 "tests/repositories/**"
1952 ],
1953 "blank lines, comment lines, and surrounding whitespace must all be stripped",
1954 );
1955 }
1956
1957 #[test]
1958 fn collect_lines_treats_hash_inside_pattern_as_literal() {
1959 let input = "\
1960a/#weird/path
1961#full-line-comment
1962";
1963 let got = collect_lines(std::io::Cursor::new(input), "test", exclude_pattern_filter)
1964 .expect("ASCII fixture decodes cleanly");
1965 assert_eq!(
1966 got,
1967 vec!["a/#weird/path"],
1968 "only lines whose first non-whitespace char is `#` count as comments",
1969 );
1970 }
1971
1972 #[test]
1973 fn collect_lines_returns_empty_for_only_blanks_and_comments() {
1974 let input = "\n# only comments\n\t \n# another\n";
1975 let got = collect_lines(std::io::Cursor::new(input), "test", exclude_pattern_filter)
1976 .expect("ASCII fixture decodes cleanly");
1977 assert!(got.is_empty(), "expected empty Vec, got {got:?}");
1978 }
1979
1980 #[test]
1981 fn collect_lines_strips_bom_on_inner_lines_not_just_first() {
1982 let input = "**/a.py\n**/b.py\n\u{feff}**/inner.py\n";
1988 let got = collect_lines(std::io::Cursor::new(input), "test", exclude_pattern_filter)
1989 .expect("ASCII fixture decodes cleanly");
1990 assert_eq!(
1991 got,
1992 vec!["**/a.py", "**/b.py", "**/inner.py"],
1993 "BOM on an inner line must be stripped, not just on line 0",
1994 );
1995 }
1996
1997 #[test]
1998 fn collect_lines_strips_trailing_bom() {
1999 let input = "**/a.py\u{feff}\n";
2005 let got = collect_lines(std::io::Cursor::new(input), "test", exclude_pattern_filter)
2006 .expect("ASCII fixture decodes cleanly");
2007 assert_eq!(got, vec!["**/a.py"], "trailing BOM must be stripped");
2008 }
2009
2010 #[test]
2011 fn collect_lines_handles_bom_then_whitespace_then_pattern() {
2012 let input = "\u{feff} **/foo.rs\n";
2018 let got = collect_lines(std::io::Cursor::new(input), "test", exclude_pattern_filter)
2019 .expect("ASCII fixture decodes cleanly");
2020 assert_eq!(
2021 got,
2022 vec!["**/foo.rs"],
2023 "BOM-then-whitespace combinations must strip cleanly with no literal leading spaces",
2024 );
2025 }
2026
2027 #[test]
2028 fn path_pattern_filter_keeps_hash_prefixed_lines_as_literal_paths() {
2029 let input = "/tmp/normal/path\n#weird-but-valid-path\n";
2036 let got = collect_lines(std::io::Cursor::new(input), "test", path_pattern_filter)
2037 .expect("ASCII fixture decodes cleanly");
2038 assert_eq!(
2039 got,
2040 vec![
2041 PathBuf::from("/tmp/normal/path"),
2042 PathBuf::from("#weird-but-valid-path"),
2043 ],
2044 "`#`-prefixed lines are literal paths for `--paths-from`, NOT comments",
2045 );
2046 }
2047
2048 #[test]
2049 fn path_pattern_filter_direct_policy_check() {
2050 assert_eq!(path_pattern_filter(""), None, "blank line skipped");
2054 assert_eq!(
2055 path_pattern_filter("# foo"),
2056 Some(PathBuf::from("# foo")),
2057 "`#`-prefix retained as path char (inverse of exclude_pattern_filter)",
2058 );
2059 assert_eq!(
2060 path_pattern_filter("/tmp/x"),
2061 Some(PathBuf::from("/tmp/x")),
2062 "absolute path retained",
2063 );
2064 }
2065
2066 #[test]
2067 fn exclude_pattern_filter_direct_policy_check() {
2068 assert_eq!(exclude_pattern_filter(""), None, "blank line skipped");
2072 assert_eq!(
2073 exclude_pattern_filter("# top comment"),
2074 None,
2075 "`#`-prefix skipped"
2076 );
2077 assert_eq!(
2078 exclude_pattern_filter("**/foo.rs"),
2079 Some("**/foo.rs".to_owned()),
2080 "normal pattern retained",
2081 );
2082 assert_eq!(
2083 exclude_pattern_filter("a/#weird/path"),
2084 Some("a/#weird/path".to_owned()),
2085 "`#` mid-line is literal, only leading-`#` counts as comment",
2086 );
2087 }
2088}