1use std::path::{Path, PathBuf};
13use std::process::ExitCode;
14use std::time::SystemTime;
15
16use anyhow::{Result, bail};
17use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum, ValueHint};
18use clap_complete::Shell as ClapShell;
19
20use crate::adapters::baseline::{self, BaselineSnapshot};
21use crate::adapters::config::{self, FileConfig};
22use crate::adapters::reporters;
23use crate::adapters::reporters::json::DeltaContext;
24use crate::core::{AnalysisOutput, AnalyzeOptions};
25use crate::domain::delta::{self, AnalysisDelta, DeltaView};
26use crate::domain::threshold::{ThresholdConfig, ThresholdPreset, is_valid_threshold};
27use crate::domain::types::{AnalysisDiagnostics, ComplexityMetric};
28use crate::domain::view::{self, GroupKey, SortKey};
29use crate::ports::{ComplexityPort, CoveragePort, ParseDiagnostic};
30
31mod delta_args;
32mod init;
33mod view_args;
34
35#[derive(Debug, Clone, Copy, ValueEnum)]
39pub enum MetricArg {
40 Cognitive,
42 Cyclomatic,
44}
45
46impl From<MetricArg> for ComplexityMetric {
47 fn from(arg: MetricArg) -> Self {
48 match arg {
49 MetricArg::Cognitive => ComplexityMetric::Cognitive,
50 MetricArg::Cyclomatic => ComplexityMetric::Cyclomatic,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, ValueEnum)]
57pub enum FormatArg {
58 Table,
60 Json,
62 Markdown,
64 Csv,
66 Sarif,
68 Advice,
70 ScorecardRow,
75 Html,
79 GithubAnnotations,
83}
84
85#[derive(Debug, Clone)]
91pub struct FormatSpec {
92 pub format: FormatArg,
93 pub output: Option<PathBuf>,
94}
95
96impl std::str::FromStr for FormatSpec {
97 type Err = String;
98
99 fn from_str(spec: &str) -> Result<Self, Self::Err> {
100 let (fmt_str, output) = match spec.split_once(':') {
101 Some((f, path)) if !path.is_empty() => (f, Some(PathBuf::from(path))),
102 Some((_, _)) => return Err(format!("empty file path in `--format {spec}`")),
103 None => (spec, None),
104 };
105 let format = FormatArg::from_str(fmt_str, true)
106 .map_err(|e| format!("invalid format `{fmt_str}`: {e}"))?;
107 Ok(FormatSpec { format, output })
108 }
109}
110
111fn parse_format_spec(s: &str) -> Result<FormatSpec, String> {
113 s.parse()
114}
115
116#[derive(Debug, Clone, Copy, ValueEnum)]
122pub enum SortKeyArg {
123 Crap,
125 Coverage,
127 Complexity,
129 Path,
131}
132
133impl From<SortKeyArg> for SortKey {
134 fn from(arg: SortKeyArg) -> Self {
135 match arg {
136 SortKeyArg::Crap => SortKey::Crap,
137 SortKeyArg::Coverage => SortKey::Coverage,
138 SortKeyArg::Complexity => SortKey::Complexity,
139 SortKeyArg::Path => SortKey::Path,
140 }
141 }
142}
143
144impl From<SortKey> for SortKeyArg {
155 fn from(key: SortKey) -> Self {
156 match key {
157 SortKey::Crap => SortKeyArg::Crap,
158 SortKey::Coverage => SortKeyArg::Coverage,
159 SortKey::Complexity => SortKeyArg::Complexity,
160 SortKey::Path => SortKeyArg::Path,
161 }
162 }
163}
164
165#[derive(Debug, Clone, Copy, ValueEnum)]
170pub enum GroupByArg {
171 File,
173}
174
175impl From<GroupByArg> for GroupKey {
176 fn from(arg: GroupByArg) -> Self {
177 match arg {
178 GroupByArg::File => GroupKey::File,
179 }
180 }
181}
182
183impl From<GroupKey> for GroupByArg {
186 fn from(key: GroupKey) -> Self {
187 match key {
188 GroupKey::File => GroupByArg::File,
189 }
190 }
191}
192
193#[derive(Debug, Clone, Copy, ValueEnum)]
195pub enum DeltaSortKeyArg {
196 ScoreDelta,
198 CurrentCrap,
200 BaselineCrap,
202 Path,
204}
205
206impl From<DeltaSortKeyArg> for crate::domain::delta::DeltaSortKey {
207 fn from(arg: DeltaSortKeyArg) -> Self {
208 use crate::domain::delta::DeltaSortKey;
209 match arg {
210 DeltaSortKeyArg::ScoreDelta => DeltaSortKey::ScoreDelta,
211 DeltaSortKeyArg::CurrentCrap => DeltaSortKey::CurrentCrap,
212 DeltaSortKeyArg::BaselineCrap => DeltaSortKey::BaselineCrap,
213 DeltaSortKeyArg::Path => DeltaSortKey::Path,
214 }
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
220pub enum DeltaKindArg {
221 Added,
222 Removed,
223 Modified,
224}
225
226impl From<DeltaKindArg> for crate::domain::delta::ChangeKind {
227 fn from(arg: DeltaKindArg) -> Self {
228 use crate::domain::delta::ChangeKind;
229 match arg {
230 DeltaKindArg::Added => ChangeKind::Added,
231 DeltaKindArg::Removed => ChangeKind::Removed,
232 DeltaKindArg::Modified => ChangeKind::Modified,
233 }
234 }
235}
236
237#[derive(Debug, Clone, Copy, Default, ValueEnum)]
239pub enum ColorArg {
240 #[default]
242 Auto,
243 Always,
245 Never,
247}
248
249#[derive(Debug, Clone, Copy, ValueEnum)]
253pub enum ShellArg {
254 Bash,
255 Zsh,
256 Fish,
257 Powershell,
258 Elvish,
259 Nushell,
260}
261
262#[derive(Debug, Subcommand)]
265pub enum Command {
266 Completions {
268 #[arg(value_enum)]
269 shell: ShellArg,
270 },
271 Init {
277 #[arg(long)]
279 force: bool,
280 #[arg(long)]
283 non_interactive: bool,
284 },
285}
286
287#[derive(Debug, Args)]
288#[command(next_help_heading = "Input")]
289pub struct InputArgs {
290 #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
293 pub coverage: Option<PathBuf>,
294
295 #[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)]
297 pub src: Option<PathBuf>,
298
299 #[arg(long, value_enum)]
301 pub metric: Option<MetricArg>,
302
303 #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
305 pub config: Option<PathBuf>,
306
307 #[arg(long, value_name = "NAME")]
316 pub view: Option<String>,
317
318 #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
331 pub baseline: Option<PathBuf>,
332}
333
334#[derive(Debug, Args)]
335#[command(next_help_heading = "Output")]
336pub struct OutputArgs {
337 #[arg(
350 short,
351 long,
352 value_delimiter = ',',
353 default_value = "table",
354 value_parser = parse_format_spec
355 )]
356 pub format: Vec<FormatSpec>,
357
358 #[arg(long, allow_hyphen_values = true, group = "threshold_select")]
365 pub threshold: Option<f64>,
366
367 #[arg(long, group = "threshold_select")]
370 pub strict: bool,
371
372 #[arg(long, group = "threshold_select")]
375 pub lenient: bool,
376
377 #[arg(long)]
387 pub no_fail: bool,
388
389 #[arg(long, requires = "baseline")]
400 pub delta_gate: bool,
401
402 #[arg(long)]
410 pub minimal_view: bool,
411
412 #[arg(long)]
423 pub summary: bool,
424
425 #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=100))]
444 pub annotation_limit: Option<u32>,
445}
446
447#[derive(Debug, Args)]
448#[command(next_help_heading = "Filtering")]
449pub struct FilterArgs {
450 #[arg(long, action = clap::ArgAction::Append)]
456 pub exclude: Vec<String>,
457
458 #[arg(long)]
463 pub no_gitignore: bool,
464
465 #[arg(long, value_name = "REF")]
470 pub diff: Option<String>,
471
472 #[arg(long)]
480 pub only_failing: bool,
481
482 #[arg(long, allow_hyphen_values = true, value_name = "PCT")]
488 pub min_coverage: Option<f64>,
489
490 #[arg(long, allow_hyphen_values = true, value_name = "PCT")]
492 pub max_coverage: Option<f64>,
493
494 #[arg(long, value_enum, value_name = "KEY")]
504 pub sort_by: Option<SortKeyArg>,
505
506 #[arg(long, allow_hyphen_values = true, value_name = "N")]
515 pub top: Option<u32>,
516
517 #[arg(long, value_enum, value_name = "KEY")]
527 pub group_by: Option<GroupByArg>,
528
529 #[arg(long, allow_hyphen_values = true, value_name = "N")]
537 pub delta_top: Option<u32>,
538
539 #[arg(long, value_enum, value_name = "KEY")]
547 pub delta_sort: Option<DeltaSortKeyArg>,
548
549 #[arg(long, value_delimiter = ',', value_name = "KINDS")]
552 pub delta_only: Vec<DeltaKindArg>,
553}
554
555#[derive(Debug, Args)]
556#[command(next_help_heading = "Display")]
557pub struct DisplayArgs {
558 #[arg(long, value_enum, default_value_t = ColorArg::Auto)]
560 pub color: ColorArg,
561
562 #[arg(short, long)]
564 pub verbose: bool,
565
566 #[arg(short, long)]
568 pub quiet: bool,
569
570 #[arg(long)]
574 pub breakdown: bool,
575
576 #[arg(long)]
580 pub explain: bool,
581
582 #[arg(long)]
590 pub md_full_table: bool,
591
592 #[arg(long, value_name = "N", default_value_t = 10)]
598 pub md_top: usize,
599}
600
601#[derive(Debug, Parser)]
625#[command(
626 version,
627 author,
628 about = "CRAP score analyzer",
629 long_about = "CRAP (Change Risk Anti-Patterns) score analyzer. \
630 Combines complexity analysis with line-coverage data to \
631 identify functions that are both complex and under-tested. \
632 Adapter-specific binaries (crap4rs for Rust, crap4ts for \
633 TypeScript) wire language-specific complexity walkers and \
634 coverage parsers behind the same orchestrator."
635)]
636pub struct Cli {
637 #[command(flatten)]
638 pub input: InputArgs,
639
640 #[command(flatten)]
641 pub output: OutputArgs,
642
643 #[command(flatten)]
644 pub filter: FilterArgs,
645
646 #[command(flatten)]
647 pub display: DisplayArgs,
648
649 #[command(subcommand)]
650 pub command: Option<Command>,
651}
652
653#[derive(Debug, Clone, Copy)]
668pub struct AdapterMeta {
669 pub tool_name: &'static str,
673 pub display_name: &'static str,
681 pub tool_version: &'static str,
684 pub long_version: &'static str,
687 pub about: &'static str,
689 pub long_about: &'static str,
692 pub after_help: &'static str,
695 pub coverage_hint: &'static str,
700 pub extensions: &'static [&'static str],
705 pub tool_info_uri: &'static str,
709 pub rule_help_uri: &'static str,
713 pub config_file_name: &'static str,
719 pub default_excludes: &'static [&'static str],
733 pub forced_excludes: &'static [&'static str],
747 pub default_metric: ComplexityMetric,
757}
758
759impl AdapterMeta {
760 pub fn extensions_owned(&self) -> Vec<String> {
764 self.extensions.iter().map(|e| (*e).to_string()).collect()
765 }
766
767 pub(crate) fn debug_assert_required_fields(&self) {
776 debug_assert!(
777 !self.tool_name.is_empty(),
778 "AdapterMeta.tool_name must not be empty"
779 );
780 debug_assert!(
781 !self.display_name.is_empty(),
782 "AdapterMeta.display_name must not be empty"
783 );
784 debug_assert!(
785 !self.tool_version.is_empty(),
786 "AdapterMeta.tool_version must not be empty"
787 );
788 debug_assert!(
789 !self.long_version.is_empty(),
790 "AdapterMeta.long_version must not be empty"
791 );
792 debug_assert!(
793 !self.about.is_empty(),
794 "AdapterMeta.about must not be empty"
795 );
796 debug_assert!(
797 !self.long_about.is_empty(),
798 "AdapterMeta.long_about must not be empty"
799 );
800 debug_assert!(
801 !self.coverage_hint.is_empty(),
802 "AdapterMeta.coverage_hint must not be empty"
803 );
804 debug_assert!(
805 !self.tool_info_uri.is_empty(),
806 "AdapterMeta.tool_info_uri must not be empty"
807 );
808 debug_assert!(
809 !self.rule_help_uri.is_empty(),
810 "AdapterMeta.rule_help_uri must not be empty"
811 );
812 debug_assert!(
813 !self.config_file_name.is_empty(),
814 "AdapterMeta.config_file_name must not be empty"
815 );
816 }
817}
818
819pub fn parse_args(meta: &AdapterMeta) -> Cli {
839 meta.debug_assert_required_fields();
840 let cmd = build_command(meta);
841 let matches = cmd.get_matches();
842 Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
843}
844
845fn current_bin_name(meta_fallback: &str) -> String {
854 std::env::args()
855 .next()
856 .and_then(|first| {
857 std::path::PathBuf::from(first)
863 .file_stem()
864 .map(|os| os.to_string_lossy().into_owned())
865 })
866 .unwrap_or_else(|| meta_fallback.to_string())
867}
868
869fn build_command(meta: &AdapterMeta) -> clap::Command {
883 let bin_name = current_bin_name(meta.tool_name);
884 let mut cmd = Cli::command()
885 .name(bin_name.clone())
886 .bin_name(bin_name)
887 .version(meta.tool_version)
888 .long_version(meta.long_version)
889 .about(meta.about)
890 .long_about(meta.long_about);
891 if !meta.after_help.is_empty() {
892 cmd = cmd.after_help(meta.after_help);
893 }
894 cmd = cmd.mut_arg("metric", |arg| {
901 arg.help(format!(
902 "Complexity metric to use [default: {}]",
903 meta.default_metric
904 ))
905 });
906 cmd
907}
908
909pub fn run<P, F>(
938 cli: Cli,
939 complexity: &dyn ComplexityPort,
940 coverage_factory: F,
941 meta: &AdapterMeta,
942) -> ExitCode
943where
944 P: ParseDiagnostic + std::fmt::Display + 'static,
945 F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
946{
947 match run_inner(cli, complexity, coverage_factory, meta) {
948 Ok(true) => ExitCode::from(0),
949 Ok(false) => ExitCode::from(1),
950 Err(e) => {
951 render_error(&e, meta);
952 ExitCode::from(2)
953 }
954 }
955}
956
957fn render_error(err: &anyhow::Error, meta: &AdapterMeta) {
969 if let Some(crap_err) = err.downcast_ref::<crate::domain::types::CrapError>()
970 && let crate::domain::types::CrapError::MetricNotSupported { metric } = crap_err
971 {
972 eprintln!(
980 "{}: complexity metric `{}` is not yet supported. Use `--metric {}` (the default for {}) or track support at {}.",
981 meta.tool_name, metric, meta.default_metric, meta.tool_name, meta.tool_info_uri,
982 );
983 return;
984 }
985 eprintln!("error: {err:#}");
986}
987
988fn run_inner<P, F>(
989 mut cli: Cli,
990 complexity: &dyn ComplexityPort,
991 coverage_factory: F,
992 meta: &AdapterMeta,
993) -> Result<bool>
994where
995 P: ParseDiagnostic + std::fmt::Display + 'static,
996 F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
997{
998 match cli.command {
999 Some(Command::Completions { shell }) => {
1000 emit_completions(shell, ¤t_bin_name(meta.tool_name));
1001 return Ok(true);
1002 }
1003 Some(Command::Init {
1004 force,
1005 non_interactive,
1006 }) => {
1007 init::handle_init(force, non_interactive, meta)?;
1008 return Ok(true);
1009 }
1010 None => {}
1011 }
1012
1013 let prep = prepare_pipeline(&mut cli, complexity, coverage_factory, meta)?;
1014
1015 let spec = view_args::build_view_spec(&cli);
1020 let view = view::apply(&prep.analysis.result, spec);
1021
1022 let delta_spec = delta_args::build_delta_view_spec(&cli);
1028 let delta_view: Option<DeltaView<'_>> = prep
1029 .delta_state
1030 .as_ref()
1031 .map(move |s| delta::apply(&s.delta, delta_spec));
1032
1033 if !cli.display.quiet {
1034 print_formatted_output(
1035 &cli,
1036 &view,
1037 delta_view.as_ref(),
1038 prep.delta_state.as_ref(),
1039 &prep.analysis,
1040 &prep.inputs,
1041 meta,
1042 )?;
1043 }
1044
1045 Ok(compute_exit_code(
1055 &cli,
1056 prep.analysis.result.passed,
1057 prep.delta_state.as_ref(),
1058 ))
1059}
1060
1061struct EffectiveInputs {
1068 src: PathBuf,
1069 metric: ComplexityMetric,
1070 threshold_config: ThresholdConfig,
1071 threshold: f64,
1072 exclude: Vec<String>,
1073 annotation_limit: usize,
1079}
1080
1081struct PipelinePrep<P: ParseDiagnostic> {
1087 inputs: EffectiveInputs,
1088 analysis: AnalysisOutput<P>,
1089 delta_state: Option<DeltaState<P>>,
1090}
1091
1092fn merge_effective_inputs(
1098 cli: &Cli,
1099 file_config: &Option<FileConfig>,
1100 meta: &AdapterMeta,
1101) -> EffectiveInputs {
1102 let src = cli
1103 .input
1104 .src
1105 .clone()
1106 .or_else(|| file_config.as_ref().and_then(|c| c.src.clone()))
1107 .unwrap_or_else(|| PathBuf::from("src"));
1108 let metric: ComplexityMetric = cli
1109 .input
1110 .metric
1111 .map(Into::into)
1112 .or_else(|| file_config.as_ref().and_then(|c| c.metric))
1113 .unwrap_or(meta.default_metric);
1114 let (threshold_config, threshold) = merge_threshold(cli, file_config, metric);
1115 let exclude = merge_exclude(cli, file_config, meta);
1116 let annotation_limit = cli
1117 .output
1118 .annotation_limit
1119 .or_else(|| file_config.as_ref().and_then(|c| c.output.annotation_limit))
1120 .unwrap_or(10) as usize;
1121 EffectiveInputs {
1122 src,
1123 metric,
1124 threshold_config,
1125 threshold,
1126 exclude,
1127 annotation_limit,
1128 }
1129}
1130
1131fn validate_runtime_inputs<'a>(
1132 cli: &'a Cli,
1133 inputs: &EffectiveInputs,
1134 meta: &AdapterMeta,
1135) -> Result<&'a Path> {
1136 let Some(coverage_path) = cli.input.coverage.as_deref() else {
1140 bail!(
1141 "--coverage <FILE> is required (run `{name} --help` for usage, or `{name} completions <SHELL>` for shell completion scripts)",
1142 name = meta.tool_name,
1143 );
1144 };
1145
1146 validate_inputs(
1147 coverage_path,
1148 &inputs.src,
1149 inputs.threshold,
1150 meta.coverage_hint,
1151 )?;
1152
1153 if let Some(diff_ref) = cli.filter.diff.as_deref() {
1154 validate_diff_ref(diff_ref)?;
1155 preflight_git_worktree(&inputs.src)?;
1156 }
1157
1158 Ok(coverage_path)
1159}
1160
1161fn build_analyze_options(
1162 cli: &Cli,
1163 inputs: &EffectiveInputs,
1164 coverage: &Path,
1165 meta: &AdapterMeta,
1166) -> AnalyzeOptions {
1167 AnalyzeOptions {
1168 src: inputs.src.clone(),
1169 coverage: coverage.to_path_buf(),
1170 threshold_config: inputs.threshold_config.clone(),
1171 metric: inputs.metric,
1172 exclude: inputs.exclude.clone(),
1173 respect_gitignore: !cli.filter.no_gitignore,
1174 diff_ref: cli.filter.diff.clone(),
1175 extensions: meta.extensions_owned(),
1176 compute_diagnostics: cli
1177 .output
1178 .format
1179 .iter()
1180 .any(|s| matches!(s.format, FormatArg::Advice | FormatArg::Sarif)),
1181 ..AnalyzeOptions::default()
1182 }
1183}
1184
1185fn apply_diagnostics<P: ParseDiagnostic + std::fmt::Display>(
1186 cli: &Cli,
1187 diagnostics: &AnalysisDiagnostics<P>,
1188) {
1189 warn_if_issues(diagnostics);
1191 if cli.display.verbose {
1192 print_diagnostics(diagnostics);
1193 }
1194}
1195
1196fn prepare_pipeline<P, F>(
1206 cli: &mut Cli,
1207 complexity: &dyn ComplexityPort,
1208 coverage_factory: F,
1209 meta: &AdapterMeta,
1210) -> Result<PipelinePrep<P>>
1211where
1212 P: ParseDiagnostic + std::fmt::Display + 'static,
1213 F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
1214{
1215 validate_display_flags(cli)?;
1216 apply_color(cli.display.color);
1217
1218 let (file_config, config_path) = load_file_config(cli, meta.config_file_name)?.unzip();
1223
1224 view_args::resolve_view_preset(
1229 cli,
1230 file_config.as_ref(),
1231 config_path.as_deref(),
1232 meta.config_file_name,
1233 )?;
1234 view_args::validate_view_args(cli)?;
1235
1236 let inputs = merge_effective_inputs(cli, &file_config, meta);
1237 let coverage_path = validate_runtime_inputs(cli, &inputs, meta)?;
1238
1239 let src_canonical = crate::core::canonicalize_src(&inputs.src);
1246 let coverage = coverage_factory(&src_canonical);
1247
1248 preflight_checks(coverage_path, &*coverage, meta)?;
1253
1254 let options = build_analyze_options(cli, &inputs, coverage_path, meta);
1255
1256 let analysis = crate::core::analyze(&options, complexity, &*coverage)?;
1257 apply_diagnostics(cli, &analysis.diagnostics);
1258
1259 let delta_state = load_delta_state(cli, &analysis.result)?;
1264
1265 Ok(PipelinePrep {
1266 inputs,
1267 analysis,
1268 delta_state,
1269 })
1270}
1271
1272fn format_as_json<P: ParseDiagnostic>(
1275 cli: &Cli,
1276 view: &view::AnalysisView<'_>,
1277 delta_view: Option<&DeltaView<'_>>,
1278 delta_state: Option<&DeltaState<P>>,
1279 analysis: &AnalysisOutput<P>,
1280 inputs: &EffectiveInputs,
1281 meta: &AdapterMeta,
1282) -> Result<String> {
1283 let delta_ctx = delta_state.zip(delta_view).map(|(s, dv)| DeltaContext {
1284 view: dv,
1285 baseline_tool_version: &s.snapshot.tool_version,
1286 baseline_timestamp: &s.snapshot.timestamp,
1287 baseline_diagnostics: s.snapshot.diagnostics.as_ref(),
1288 });
1289 let config = reporters::json::JsonConfig {
1290 tool_version: meta.tool_version.to_string(),
1291 metric: inputs.metric,
1292 threshold: inputs.threshold,
1293 timestamp: now_unix_epoch(),
1294 diagnostics: cli.display.verbose.then_some(&analysis.diagnostics),
1295 diff_ref: cli.filter.diff.as_deref(),
1296 minimal_view: cli.output.minimal_view,
1297 delta: delta_ctx,
1298 };
1299 reporters::json::format_json(view, &config).map_err(Into::into)
1300}
1301
1302fn format_as_scorecard_row<P: ParseDiagnostic>(
1307 delta_state: Option<&DeltaState<P>>,
1308 result: &crate::domain::types::AnalysisResult,
1309 threshold: f64,
1310) -> String {
1311 let baseline_result = delta_state.map(|s| &s.snapshot.result);
1312 let delta_inputs = delta_state.map(|s| (&s.delta.summary, s.delta.changes.as_slice()));
1313 let row_data = crate::domain::summary::project_crap_delta_row(
1314 result,
1315 baseline_result,
1316 delta_inputs,
1317 threshold.round() as u32,
1318 );
1319 reporters::format_scorecard_row(&row_data)
1320}
1321
1322#[allow(clippy::too_many_arguments)]
1329fn render_format<P: ParseDiagnostic>(
1330 cli: &Cli,
1331 spec: &FormatSpec,
1332 view: &view::AnalysisView<'_>,
1333 delta_view: Option<&DeltaView<'_>>,
1334 delta_state: Option<&DeltaState<P>>,
1335 analysis: &AnalysisOutput<P>,
1336 inputs: &EffectiveInputs,
1337 meta: &AdapterMeta,
1338) -> Result<String> {
1339 Ok(match spec.format {
1340 FormatArg::Table => reporters::format_table_with_explain(
1341 view,
1342 delta_view,
1343 inputs.threshold,
1344 cli.display.breakdown,
1345 cli.display.explain,
1346 meta.tool_name,
1347 meta.tool_version,
1348 ),
1349 FormatArg::Json | FormatArg::Advice => {
1350 format_as_json(cli, view, delta_view, delta_state, analysis, inputs, meta)?
1351 }
1352 FormatArg::Markdown => reporters::format_markdown(
1353 view,
1354 delta_view,
1355 inputs.threshold,
1356 cli.display.breakdown,
1357 cli.display.explain,
1358 cli.display.md_full_table,
1359 cli.display.md_top,
1360 meta,
1361 inputs.metric,
1362 ),
1363 FormatArg::Csv => reporters::format_csv(view, delta_view, inputs.metric),
1364 FormatArg::Sarif => reporters::format_sarif(
1370 view,
1371 meta.tool_name,
1372 meta.tool_version,
1373 meta.tool_info_uri,
1374 meta.rule_help_uri,
1375 ),
1376 FormatArg::ScorecardRow => {
1377 format_as_scorecard_row(delta_state, &analysis.result, inputs.threshold)
1378 }
1379 FormatArg::Html => reporters::format_html(view, inputs.threshold, meta, inputs.metric),
1380 FormatArg::GithubAnnotations => reporters::format_github_annotations(
1386 view,
1387 meta.tool_name,
1388 meta.tool_version,
1389 inputs.annotation_limit,
1390 ),
1391 })
1392}
1393
1394fn print_formatted_output<P: ParseDiagnostic>(
1395 cli: &Cli,
1396 view: &view::AnalysisView<'_>,
1397 delta_view: Option<&DeltaView<'_>>,
1398 delta_state: Option<&DeltaState<P>>,
1399 analysis: &AnalysisOutput<P>,
1400 inputs: &EffectiveInputs,
1401 meta: &AdapterMeta,
1402) -> Result<()> {
1403 if cli.output.summary {
1409 let line = reporters::format_summary_line(view.full, inputs.threshold);
1410 println!("{line}");
1411 return Ok(());
1412 }
1413
1414 for spec in &cli.output.format {
1415 let output = render_format(
1416 cli,
1417 spec,
1418 view,
1419 delta_view,
1420 delta_state,
1421 analysis,
1422 inputs,
1423 meta,
1424 )?;
1425 match &spec.output {
1426 Some(path) => std::fs::write(path, &output)
1427 .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?,
1428 None => print!("{output}"),
1429 }
1430 }
1431
1432 if cli
1437 .output
1438 .format
1439 .iter()
1440 .any(|s| matches!(s.format, FormatArg::Advice))
1441 {
1442 let mut stderr = std::io::stderr();
1443 let _ = reporters::render_advice_summary(view, &mut stderr);
1444 }
1445
1446 Ok(())
1447}
1448
1449fn compute_exit_code<P: ParseDiagnostic>(
1450 cli: &Cli,
1451 passed: bool,
1452 delta_state: Option<&DeltaState<P>>,
1453) -> bool {
1454 let delta_passed = delta_state.map(|s| s.delta.summary.passed).unwrap_or(true);
1455 let combined_passed = passed && (!cli.output.delta_gate || delta_passed);
1456 combined_passed || cli.output.no_fail
1457}
1458
1459struct DeltaState<P: ParseDiagnostic> {
1468 snapshot: BaselineSnapshot<P>,
1469 delta: AnalysisDelta,
1470}
1471
1472fn load_delta_state<P: ParseDiagnostic>(
1473 cli: &Cli,
1474 current: &crate::domain::types::AnalysisResult,
1475) -> Result<Option<DeltaState<P>>> {
1476 let Some(path) = cli.input.baseline.as_ref() else {
1477 return Ok(None);
1478 };
1479 let snapshot = baseline::load::<P>(path).map_err(|e| anyhow::anyhow!("{e}"))?;
1480 let delta = delta::compute(snapshot.result.clone(), current.clone());
1483 Ok(Some(DeltaState { snapshot, delta }))
1484}
1485
1486fn validate_display_flags(cli: &Cli) -> Result<()> {
1487 let any_table = cli
1488 .output
1489 .format
1490 .iter()
1491 .any(|s| matches!(s.format, FormatArg::Table));
1492 if cli.display.explain && any_table && !cli.display.breakdown {
1493 bail!("--explain requires --breakdown for table output");
1494 }
1495 validate_format_destinations(&cli.output.format)?;
1496 Ok(())
1497}
1498
1499fn validate_format_destinations(specs: &[FormatSpec]) -> Result<()> {
1508 if specs.len() > 1 {
1509 let stdout_specs: Vec<_> = specs
1510 .iter()
1511 .filter(|s| s.output.is_none())
1512 .map(|s| format_arg_kebab(s.format).to_string())
1513 .collect();
1514 if stdout_specs.len() > 1 {
1515 bail!(
1516 "multi-format `--format` allows at most one stdout entry (the rest must specify a file, e.g. `json:envelope.json`); stdout entries: {}",
1517 stdout_specs.join(", ")
1518 );
1519 }
1520 }
1521 Ok(())
1522}
1523
1524fn format_arg_kebab(arg: FormatArg) -> String {
1528 use clap::ValueEnum;
1529 arg.to_possible_value()
1530 .map(|v| v.get_name().to_string())
1531 .unwrap_or_else(|| format!("{arg:?}").to_lowercase())
1532}
1533
1534fn load_file_config(
1542 cli: &Cli,
1543 config_file_name: &str,
1544) -> Result<Option<(FileConfig, std::path::PathBuf)>> {
1545 if let Some(path) = &cli.input.config {
1546 let cfg = config::load_config(path)?;
1547 Ok(Some((cfg, path.clone())))
1548 } else {
1549 match config::discover_config(config_file_name)? {
1550 Some(path) => {
1551 let cfg = config::load_config(&path)?;
1552 Ok(Some((cfg, path)))
1553 }
1554 None => Ok(None),
1555 }
1556 }
1557}
1558
1559fn merge_threshold(
1581 cli: &Cli,
1582 file_config: &Option<FileConfig>,
1583 metric: ComplexityMetric,
1584) -> (ThresholdConfig, f64) {
1585 let global = cli
1586 .output
1587 .threshold
1588 .or_else(|| {
1589 cli.output
1590 .strict
1591 .then(|| ThresholdPreset::Strict.threshold(metric))
1592 })
1593 .or_else(|| {
1594 cli.output
1595 .lenient
1596 .then(|| ThresholdPreset::Lenient.threshold(metric))
1597 })
1598 .or_else(|| file_config.as_ref().and_then(|c| c.threshold))
1599 .or_else(|| {
1600 file_config
1601 .as_ref()
1602 .and_then(|c| c.preset)
1603 .map(|p| p.threshold(metric))
1604 })
1605 .unwrap_or(ThresholdPreset::Default.threshold(metric));
1606
1607 let overrides = file_config
1608 .as_ref()
1609 .map(|fc| fc.overrides.clone())
1610 .unwrap_or_default();
1611
1612 let config = ThresholdConfig { global, overrides };
1613 (config, global)
1614}
1615
1616fn merge_exclude(cli: &Cli, file_config: &Option<FileConfig>, meta: &AdapterMeta) -> Vec<String> {
1625 let mut exclude: Vec<String> = meta
1626 .forced_excludes
1627 .iter()
1628 .map(|s| (*s).to_string())
1629 .collect();
1630 let mut seen: std::collections::HashSet<String> = exclude.iter().cloned().collect();
1631 for pattern in &cli.filter.exclude {
1632 if seen.insert(pattern.clone()) {
1633 exclude.push(pattern.clone());
1634 }
1635 }
1636 if let Some(fc) = file_config
1637 && let Some(fc_exclude) = &fc.exclude
1638 {
1639 for pattern in fc_exclude {
1640 if seen.insert(pattern.clone()) {
1641 exclude.push(pattern.clone());
1642 }
1643 }
1644 }
1645 exclude
1646}
1647
1648fn validate_inputs(
1651 coverage: &std::path::Path,
1652 src: &std::path::Path,
1653 threshold: f64,
1654 coverage_hint: &str,
1655) -> Result<()> {
1656 match std::fs::metadata(coverage) {
1657 Ok(m) if m.is_file() => {}
1658 Ok(_) => bail!(
1659 "coverage path is not a file: {}\n \
1660 hint: pass --coverage pointing to a coverage file, not a directory",
1661 coverage.display()
1662 ),
1663 Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1664 "coverage file not found: {}\n hint: {coverage_hint}",
1665 coverage.display()
1666 ),
1667 Err(e) => bail!(
1668 "cannot access coverage file: {}: {e}\n \
1669 hint: check file permissions",
1670 coverage.display()
1671 ),
1672 }
1673 match std::fs::metadata(src) {
1674 Ok(m) if m.is_dir() => {}
1675 Ok(_) => bail!(
1676 "source path is not a directory: {}\n \
1677 hint: pass --src <DIR> pointing to your source root",
1678 src.display()
1679 ),
1680 Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1681 "source directory not found: {}\n \
1682 hint: pass --src <DIR> pointing to your source root",
1683 src.display()
1684 ),
1685 Err(e) => bail!(
1686 "cannot access source directory: {}: {e}\n \
1687 hint: check directory permissions",
1688 src.display()
1689 ),
1690 }
1691 if !is_valid_threshold(threshold) {
1692 bail!(
1693 "threshold must be a finite positive number, got: {}",
1694 threshold
1695 );
1696 }
1697 Ok(())
1698}
1699
1700fn validate_diff_ref(diff_ref: &str) -> Result<()> {
1703 if diff_ref.is_empty() {
1704 bail!("invalid diff ref: ref must not be empty");
1705 }
1706 if diff_ref.starts_with('-') {
1707 bail!(
1708 "invalid diff ref: {diff_ref}\n \
1709 hint: ref must not start with a dash"
1710 );
1711 }
1712 Ok(())
1713}
1714
1715fn preflight_git_worktree(src: &Path) -> Result<()> {
1716 let output = std::process::Command::new("git")
1717 .current_dir(src)
1718 .args(["rev-parse", "--is-inside-work-tree"])
1719 .output();
1720
1721 match output {
1722 Ok(o) if o.status.success() => Ok(()),
1723 Ok(o) => {
1724 let stderr = String::from_utf8_lossy(&o.stderr);
1725 bail!(
1726 "not inside a git work tree\n \
1727 hint: --diff requires a git repository\n \
1728 git: {stderr}",
1729 );
1730 }
1731 Err(e) => bail!(
1732 "not inside a git work tree\n \
1733 hint: --diff requires git to be installed\n \
1734 error: {e}",
1735 ),
1736 }
1737}
1738
1739fn preflight_checks<P>(
1746 coverage: &std::path::Path,
1747 coverage_port: &dyn CoveragePort<Diagnostic = P>,
1748 meta: &AdapterMeta,
1749) -> Result<()>
1750where
1751 P: ParseDiagnostic,
1752{
1753 check_coverage_has_data(coverage, coverage_port, meta.coverage_hint)
1754}
1755
1756fn check_coverage_has_data<P>(
1757 path: &std::path::Path,
1758 coverage_port: &dyn CoveragePort<Diagnostic = P>,
1759 coverage_hint: &str,
1760) -> Result<()>
1761where
1762 P: ParseDiagnostic,
1763{
1764 if let Err(reason) = coverage_port.validate(path) {
1774 bail!(
1775 "no coverage data found in {} ({reason})\n hint: {}",
1776 path.display(),
1777 coverage_hint,
1778 );
1779 }
1780 Ok(())
1781}
1782
1783fn now_unix_epoch() -> String {
1786 let secs = SystemTime::now()
1787 .duration_since(SystemTime::UNIX_EPOCH)
1788 .unwrap_or_default()
1789 .as_secs();
1790 format!("{secs}")
1791}
1792
1793fn majority_zero_coverage(files_analyzed: usize, files_zero_coverage: usize) -> bool {
1796 files_analyzed > 0 && files_zero_coverage * 2 > files_analyzed
1797}
1798
1799fn warn_if_issues<P: ParseDiagnostic>(diag: &AnalysisDiagnostics<P>) {
1800 if !diag.parse_diagnostics.is_empty() {
1801 eprintln!(
1802 "warning: {} coverage parse issue(s) encountered (use --verbose for details)",
1803 diag.parse_diagnostics.len()
1804 );
1805 }
1806 if diag.files_unparseable > 0 {
1807 eprintln!(
1808 "warning: {} source file(s) could not be parsed (use --verbose for details)",
1809 diag.files_unparseable
1810 );
1811 }
1812 if majority_zero_coverage(diag.files_analyzed, diag.files_zero_coverage) {
1813 eprintln!(
1814 "warning: in {}/{} analyzed files, all analyzed functions have 0% line coverage",
1815 diag.files_zero_coverage, diag.files_analyzed
1816 );
1817 eprintln!(
1818 " hint: `cargo llvm-cov --lib` does not cover integration-only code (handlers, Tauri entry, BDD tests)"
1819 );
1820 eprintln!(
1821 " hint: use --exclude to skip uncoverable paths (e.g., --exclude \"services/api/src/**\")"
1822 );
1823 }
1824}
1825
1826fn print_diagnostics<P: ParseDiagnostic + std::fmt::Display>(diag: &AnalysisDiagnostics<P>) {
1827 eprintln!(
1828 "verbose: file discovery: {} files found, {} unparseable",
1829 diag.files_found, diag.files_unparseable
1830 );
1831 eprintln!(
1832 "verbose: complexity: {} functions extracted",
1833 diag.functions_extracted
1834 );
1835 eprintln!(
1836 "verbose: matching: {} matched with coverage, {} without coverage data",
1837 diag.functions_matched, diag.functions_no_coverage
1838 );
1839 eprintln!(
1840 "verbose: coverage: {} files analyzed, {} where all analyzed functions have 0% line coverage",
1841 diag.files_analyzed, diag.files_zero_coverage
1842 );
1843 if !diag.parse_diagnostics.is_empty() {
1844 eprintln!(
1845 "verbose: coverage parse diagnostics ({}):",
1846 diag.parse_diagnostics.len()
1847 );
1848 for d in &diag.parse_diagnostics {
1849 eprintln!(" {d}");
1850 }
1851 }
1852}
1853
1854fn emit_completions(shell: ShellArg, bin_name: &str) {
1865 let mut cmd = Cli::command();
1866 let stdout = &mut std::io::stdout();
1867 match shell {
1868 ShellArg::Bash => clap_complete::generate(ClapShell::Bash, &mut cmd, bin_name, stdout),
1869 ShellArg::Zsh => clap_complete::generate(ClapShell::Zsh, &mut cmd, bin_name, stdout),
1870 ShellArg::Fish => clap_complete::generate(ClapShell::Fish, &mut cmd, bin_name, stdout),
1871 ShellArg::Powershell => {
1872 clap_complete::generate(ClapShell::PowerShell, &mut cmd, bin_name, stdout)
1873 }
1874 ShellArg::Elvish => clap_complete::generate(ClapShell::Elvish, &mut cmd, bin_name, stdout),
1875 ShellArg::Nushell => {
1876 clap_complete::generate(clap_complete_nushell::Nushell, &mut cmd, bin_name, stdout)
1877 }
1878 }
1879}
1880
1881fn apply_color(choice: ColorArg) {
1884 match choice {
1885 ColorArg::Auto => colored::control::unset_override(),
1886 ColorArg::Always => colored::control::set_override(true),
1887 ColorArg::Never => colored::control::set_override(false),
1888 }
1889}
1890
1891#[cfg(test)]
1894mod tests {
1895 use super::*;
1896 use crate::domain::threshold::DEFAULT_THRESHOLD;
1900 use std::path::Path;
1901
1902 fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
1903 let mut full = vec!["test-adapter"];
1908 full.extend_from_slice(args);
1909 Cli::try_parse_from(full)
1910 }
1911
1912 #[test]
1913 fn no_args_parses_with_coverage_none() {
1914 let cli = parse(&[]).unwrap();
1919 assert!(cli.input.coverage.is_none());
1920 assert!(cli.command.is_none());
1921 }
1922
1923 #[test]
1924 fn minimal_valid_args() {
1925 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1926 assert_eq!(cli.input.coverage.as_deref(), Some(Path::new("lcov.info")));
1927 assert_eq!(cli.input.src, None);
1928 }
1929
1930 #[test]
1931 fn completions_subcommand_does_not_require_coverage() {
1932 let cli = parse(&["completions", "bash"]).unwrap();
1933 assert!(matches!(
1934 cli.command,
1935 Some(Command::Completions {
1936 shell: ShellArg::Bash
1937 })
1938 ));
1939 assert!(cli.input.coverage.is_none());
1940 }
1941
1942 #[test]
1943 fn default_metric_is_none() {
1944 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1945 assert!(cli.input.metric.is_none());
1946 }
1947
1948 #[test]
1949 fn default_format_is_table() {
1950 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1951 assert_eq!(cli.output.format.len(), 1);
1952 assert!(matches!(cli.output.format[0].format, FormatArg::Table));
1953 assert!(cli.output.format[0].output.is_none());
1954 }
1955
1956 #[test]
1957 fn default_threshold_is_none() {
1958 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1959 assert!(cli.output.threshold.is_none());
1960 }
1961
1962 #[test]
1963 fn default_color_is_auto() {
1964 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1965 assert!(matches!(cli.display.color, ColorArg::Auto));
1966 }
1967
1968 #[test]
1969 fn metric_cyclomatic() {
1970 let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
1971 assert!(matches!(cli.input.metric, Some(MetricArg::Cyclomatic)));
1972 }
1973
1974 #[test]
1975 fn format_json() {
1976 let cli = parse(&["--coverage", "lcov.info", "--format", "json"]).unwrap();
1977 assert_eq!(cli.output.format.len(), 1);
1978 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1979 assert!(cli.output.format[0].output.is_none());
1980 }
1981
1982 #[test]
1983 fn format_sarif() {
1984 let cli = parse(&["--coverage", "lcov.info", "--format", "sarif"]).unwrap();
1985 assert_eq!(cli.output.format.len(), 1);
1986 assert!(matches!(cli.output.format[0].format, FormatArg::Sarif));
1987 }
1988
1989 #[test]
1990 fn format_with_file_destination() {
1991 let cli = parse(&["--coverage", "lcov.info", "--format", "json:env.json"]).unwrap();
1992 assert_eq!(cli.output.format.len(), 1);
1993 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1994 assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
1995 }
1996
1997 #[test]
1998 fn format_multi_with_files() {
1999 let cli = parse(&[
2000 "--coverage",
2001 "lcov.info",
2002 "--format",
2003 "json:env.json,markdown:report.md",
2004 ])
2005 .unwrap();
2006 assert_eq!(cli.output.format.len(), 2);
2007 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
2008 assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
2009 assert!(matches!(cli.output.format[1].format, FormatArg::Markdown));
2010 assert_eq!(
2011 cli.output.format[1].output,
2012 Some(PathBuf::from("report.md"))
2013 );
2014 }
2015
2016 #[test]
2017 fn format_multi_with_two_stdout_specs_rejected() {
2018 let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown"]).unwrap();
2019 let err = validate_display_flags(&cli).unwrap_err();
2020 let msg = err.to_string();
2021 assert!(msg.contains("multi-format"), "got: {msg}");
2022 assert!(msg.contains("stdout"), "got: {msg}");
2023 assert!(
2024 msg.contains("json"),
2025 "msg should name the stdout specs: {msg}"
2026 );
2027 assert!(
2028 msg.contains("markdown"),
2029 "msg should name the stdout specs: {msg}"
2030 );
2031 }
2032
2033 #[test]
2034 fn format_multi_with_single_stdout_plus_file_accepted() {
2035 let cli = parse(&[
2040 "--coverage",
2041 "lcov.info",
2042 "--format",
2043 "markdown:scorecard.md,github-annotations",
2044 ])
2045 .unwrap();
2046 assert!(validate_display_flags(&cli).is_ok());
2047 }
2048
2049 #[test]
2050 fn format_multi_with_three_stdout_specs_rejected() {
2051 let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown,csv"]).unwrap();
2052 let err = validate_display_flags(&cli).unwrap_err();
2053 let msg = err.to_string();
2054 assert!(
2055 msg.contains("at most one stdout"),
2056 "rejection must name the rule, got: {msg}"
2057 );
2058 }
2059
2060 #[test]
2061 fn format_empty_path_rejected() {
2062 let err = parse(&["--coverage", "lcov.info", "--format", "json:"]).unwrap_err();
2063 let msg = format!("{err}");
2064 assert!(msg.contains("empty file path"));
2065 }
2066
2067 #[test]
2068 fn custom_threshold() {
2069 let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.5"]).unwrap();
2070 assert_eq!(cli.output.threshold, Some(15.5));
2071 }
2072
2073 #[test]
2074 fn custom_src() {
2075 let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
2076 assert_eq!(cli.input.src, Some(PathBuf::from("crates/")));
2077 }
2078
2079 #[test]
2080 fn exclude_repeatable() {
2081 let cli = parse(&[
2082 "--coverage",
2083 "lcov.info",
2084 "--exclude",
2085 "tests/**",
2086 "--exclude",
2087 "benches/**",
2088 ])
2089 .unwrap();
2090 assert_eq!(cli.filter.exclude, vec!["tests/**", "benches/**"]);
2091 }
2092
2093 #[test]
2094 fn no_gitignore_flag() {
2095 let cli = parse(&["--coverage", "lcov.info", "--no-gitignore"]).unwrap();
2096 assert!(cli.filter.no_gitignore);
2097 }
2098
2099 #[test]
2100 fn only_failing_flag() {
2101 let cli = parse(&["--coverage", "lcov.info", "--only-failing"]).unwrap();
2102 assert!(cli.filter.only_failing);
2103 }
2104
2105 #[test]
2106 fn group_by_file_parses() {
2107 let cli = parse(&["--coverage", "lcov.info", "--group-by", "file"]).unwrap();
2108 assert!(matches!(cli.filter.group_by, Some(GroupByArg::File)));
2109 }
2110
2111 #[test]
2112 fn group_by_absence_is_none() {
2113 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2114 assert!(cli.filter.group_by.is_none());
2115 }
2116
2117 #[test]
2118 fn group_by_invalid_value_rejected() {
2119 let err = parse(&["--coverage", "lcov.info", "--group-by", "module"]).unwrap_err();
2120 let msg = err.to_string();
2121 assert!(msg.contains("invalid value"), "expected clap error: {msg}");
2122 assert!(
2123 msg.contains("--group-by") || msg.contains("module"),
2124 "error should attribute to --group-by: {msg}"
2125 );
2126 }
2127
2128 #[test]
2129 fn group_by_arg_to_domain_file() {
2130 let domain: GroupKey = GroupByArg::File.into();
2131 assert_eq!(domain, GroupKey::File);
2132 }
2133
2134 #[test]
2135 fn verbose_flag() {
2136 let cli = parse(&["--coverage", "lcov.info", "-v"]).unwrap();
2137 assert!(cli.display.verbose);
2138 }
2139
2140 #[test]
2141 fn quiet_flag() {
2142 let cli = parse(&["--coverage", "lcov.info", "-q"]).unwrap();
2143 assert!(cli.display.quiet);
2144 }
2145
2146 #[test]
2147 fn color_always() {
2148 let cli = parse(&["--coverage", "lcov.info", "--color", "always"]).unwrap();
2149 assert!(matches!(cli.display.color, ColorArg::Always));
2150 }
2151
2152 #[test]
2153 fn color_never() {
2154 let cli = parse(&["--coverage", "lcov.info", "--color", "never"]).unwrap();
2155 assert!(matches!(cli.display.color, ColorArg::Never));
2156 }
2157
2158 #[test]
2159 fn invalid_metric_rejected() {
2160 let err = parse(&["--coverage", "lcov.info", "--metric", "halstead"]).unwrap_err();
2161 assert!(err.to_string().contains("invalid value"));
2162 }
2163
2164 #[test]
2165 fn invalid_format_rejected() {
2166 let err = parse(&["--coverage", "lcov.info", "--format", "xml"]).unwrap_err();
2167 assert!(err.to_string().contains("invalid value"));
2168 }
2169
2170 #[test]
2171 fn metric_arg_to_domain_cognitive() {
2172 let domain: ComplexityMetric = MetricArg::Cognitive.into();
2173 assert_eq!(domain, ComplexityMetric::Cognitive);
2174 }
2175
2176 #[test]
2177 fn metric_arg_to_domain_cyclomatic() {
2178 let domain: ComplexityMetric = MetricArg::Cyclomatic.into();
2179 assert_eq!(domain, ComplexityMetric::Cyclomatic);
2180 }
2181
2182 #[test]
2183 fn validate_missing_coverage_file_uses_adapter_hint() {
2184 let err = validate_inputs(
2185 Path::new("nonexistent.info"),
2186 Path::new("src"),
2187 DEFAULT_THRESHOLD,
2188 "run `cargo llvm-cov --lcov --output-path lcov.info` first",
2189 )
2190 .unwrap_err();
2191 let msg = format!("{err:#}");
2192 assert!(msg.contains("coverage file not found"));
2193 assert!(msg.contains("cargo llvm-cov"));
2195 }
2196
2197 #[test]
2198 fn validate_missing_src_dir() {
2199 let err = validate_inputs(
2200 Path::new("Cargo.toml"),
2201 Path::new("nonexistent_dir"),
2202 DEFAULT_THRESHOLD,
2203 "test-hint",
2204 )
2205 .unwrap_err();
2206 let msg = format!("{err:#}");
2207 assert!(msg.contains("source directory not found"));
2208 }
2209
2210 #[test]
2211 fn validate_negative_threshold() {
2212 let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), -5.0, "test-hint")
2213 .unwrap_err();
2214 let msg = format!("{err:#}");
2215 assert!(msg.contains("threshold must be a finite positive number"));
2216 }
2217
2218 #[test]
2219 fn validate_zero_threshold() {
2220 let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), 0.0, "test-hint")
2221 .unwrap_err();
2222 let msg = format!("{err:#}");
2223 assert!(msg.contains("threshold must be a finite positive number"));
2224 }
2225
2226 #[test]
2227 fn validate_infinity_threshold() {
2228 let err = validate_inputs(
2229 Path::new("Cargo.toml"),
2230 Path::new("src"),
2231 f64::INFINITY,
2232 "test-hint",
2233 )
2234 .unwrap_err();
2235 let msg = format!("{err:#}");
2236 assert!(msg.contains("threshold must be a finite positive number"));
2237 }
2238
2239 #[test]
2240 fn validate_src_is_file_not_dir() {
2241 let err = validate_inputs(
2242 Path::new("Cargo.toml"),
2243 Path::new("Cargo.toml"),
2244 DEFAULT_THRESHOLD,
2245 "test-hint",
2246 )
2247 .unwrap_err();
2248 let msg = format!("{err:#}");
2249 assert!(msg.contains("source path is not a directory"));
2250 }
2251
2252 #[test]
2253 fn validate_coverage_is_dir_not_file() {
2254 let err = validate_inputs(
2255 Path::new("src"),
2256 Path::new("src"),
2257 DEFAULT_THRESHOLD,
2258 "test-hint",
2259 )
2260 .unwrap_err();
2261 let msg = format!("{err:#}");
2262 assert!(msg.contains("coverage path is not a file"));
2263 }
2264
2265 #[test]
2266 fn format_short_flag() {
2267 let cli = parse(&["--coverage", "lcov.info", "-f", "json"]).unwrap();
2268 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
2269 }
2270
2271 #[test]
2272 fn config_flag_accepts_path() {
2273 let cli = parse(&["--coverage", "lcov.info", "--config", "my-config.toml"]).unwrap();
2274 assert_eq!(cli.input.config, Some(PathBuf::from("my-config.toml")));
2275 }
2276
2277 #[test]
2278 fn config_flag_defaults_to_none() {
2279 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2280 assert_eq!(cli.input.config, None);
2281 }
2282
2283 #[test]
2284 fn view_flag_accepts_name() {
2285 let cli = parse(&["--coverage", "lcov.info", "--view", "ci"]).unwrap();
2286 assert_eq!(cli.input.view, Some("ci".to_string()));
2287 }
2288
2289 #[test]
2290 fn view_flag_defaults_to_none() {
2291 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2292 assert_eq!(cli.input.view, None);
2293 }
2294
2295 #[test]
2296 fn merge_threshold_cli_overrides_config() {
2297 let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
2298 let file_config = Some(FileConfig {
2299 threshold: Some(10.0),
2300 ..FileConfig::default()
2301 });
2302 let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2303 assert_eq!(config.global, 15.0);
2304 assert_eq!(display, 15.0);
2305 }
2306
2307 #[test]
2308 fn merge_threshold_uses_config_when_cli_default() {
2309 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2310 let file_config = Some(FileConfig {
2311 threshold: Some(12.0),
2312 ..FileConfig::default()
2313 });
2314 let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2315 assert_eq!(config.global, 12.0);
2316 assert_eq!(display, 12.0);
2317 }
2318
2319 #[test]
2320 fn merge_threshold_preserves_overrides() {
2321 use crate::domain::threshold::ThresholdOverride;
2322 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2323 let file_config = Some(FileConfig {
2324 threshold: Some(10.0),
2325 overrides: vec![ThresholdOverride {
2326 pattern: "domain/**".to_string(),
2327 threshold: 5.0,
2328 }],
2329 ..FileConfig::default()
2330 });
2331 let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2332 assert_eq!(config.overrides.len(), 1);
2333 assert_eq!(config.overrides[0].pattern, "domain/**");
2334 }
2335
2336 #[test]
2337 fn merge_threshold_no_config() {
2338 let cli = parse(&["--coverage", "lcov.info", "--threshold", "20.0"]).unwrap();
2339 let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2340 assert_eq!(config.global, 20.0);
2341 assert!(config.overrides.is_empty());
2342 assert_eq!(display, 20.0);
2343 }
2344
2345 #[test]
2346 fn merge_threshold_explicit_default_overrides_config() {
2347 let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
2350 let file_config = Some(FileConfig {
2351 threshold: Some(12.0),
2352 ..FileConfig::default()
2353 });
2354 let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2355 assert_eq!(
2356 config.global, 15.0,
2357 "explicit CLI default must override config"
2358 );
2359 assert_eq!(display, 15.0);
2360 }
2361
2362 #[test]
2363 fn merge_threshold_no_flag_default_is_metric_keyed() {
2364 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2369 let (cog, cog_disp) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2370 assert_eq!(cog.global, 15.0);
2371 assert_eq!(cog_disp, 15.0);
2372 let (cyc, cyc_disp) = merge_threshold(&cli, &None, ComplexityMetric::Cyclomatic);
2373 assert_eq!(cyc.global, 15.0);
2374 assert_eq!(cyc_disp, 15.0);
2375 }
2376
2377 #[test]
2378 fn merge_threshold_strict_lenient_are_metric_keyed() {
2379 let strict = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2383 assert_eq!(
2384 merge_threshold(&strict, &None, ComplexityMetric::Cognitive).1,
2385 8.0
2386 );
2387 assert_eq!(
2388 merge_threshold(&strict, &None, ComplexityMetric::Cyclomatic).1,
2389 8.0
2390 );
2391 let lenient = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2392 assert_eq!(
2393 merge_threshold(&lenient, &None, ComplexityMetric::Cognitive).1,
2394 25.0
2395 );
2396 assert_eq!(
2397 merge_threshold(&lenient, &None, ComplexityMetric::Cyclomatic).1,
2398 25.0
2399 );
2400 }
2401
2402 #[test]
2403 fn merge_exclude_combines_cli_and_config() {
2404 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2405 let file_config = Some(FileConfig {
2406 exclude: Some(vec!["benches/**".to_string()]),
2407 ..FileConfig::default()
2408 });
2409 let exclude = merge_exclude(&cli, &file_config, &fake_meta());
2410 assert_eq!(exclude, vec!["tests/**", "benches/**"]);
2411 }
2412
2413 #[test]
2414 fn merge_exclude_deduplicates() {
2415 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2416 let file_config = Some(FileConfig {
2417 exclude: Some(vec!["tests/**".to_string()]),
2418 ..FileConfig::default()
2419 });
2420 let exclude = merge_exclude(&cli, &file_config, &fake_meta());
2421 assert_eq!(exclude, vec!["tests/**"]);
2422 }
2423
2424 #[test]
2430 fn merge_exclude_prepends_forced_excludes_from_adapter_meta() {
2431 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2432 let file_config = Some(FileConfig {
2433 exclude: Some(vec!["benches/**".to_string()]),
2434 ..FileConfig::default()
2435 });
2436 let meta = AdapterMeta {
2437 forced_excludes: &["**/*.d.ts"],
2438 ..fake_meta()
2439 };
2440 let exclude = merge_exclude(&cli, &file_config, &meta);
2441 assert_eq!(exclude, vec!["**/*.d.ts", "tests/**", "benches/**"]);
2442 }
2443
2444 #[test]
2448 fn merge_exclude_with_empty_forced_excludes_matches_legacy_behavior() {
2449 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2450 let file_config = Some(FileConfig {
2451 exclude: Some(vec!["benches/**".to_string()]),
2452 ..FileConfig::default()
2453 });
2454 let meta = AdapterMeta {
2455 forced_excludes: &[],
2456 ..fake_meta()
2457 };
2458 let exclude = merge_exclude(&cli, &file_config, &meta);
2459 assert_eq!(exclude, vec!["tests/**", "benches/**"]);
2460 }
2461
2462 #[test]
2465 fn merge_exclude_forced_excludes_deduplicates_against_cli_and_config() {
2466 let cli = parse(&["--coverage", "lcov.info", "--exclude", "**/*.d.ts"]).unwrap();
2467 let file_config = Some(FileConfig {
2468 exclude: Some(vec!["**/*.d.ts".to_string(), "benches/**".to_string()]),
2469 ..FileConfig::default()
2470 });
2471 let meta = AdapterMeta {
2472 forced_excludes: &["**/*.d.ts"],
2473 ..fake_meta()
2474 };
2475 let exclude = merge_exclude(&cli, &file_config, &meta);
2476 assert_eq!(exclude, vec!["**/*.d.ts", "benches/**"]);
2477 }
2478
2479 #[test]
2482 fn diff_flag_accepts_ref() {
2483 let cli = parse(&["--coverage", "lcov.info", "--diff", "main"]).unwrap();
2484 assert_eq!(cli.filter.diff, Some("main".to_string()));
2485 }
2486
2487 #[test]
2488 fn diff_flag_defaults_to_none() {
2489 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2490 assert_eq!(cli.filter.diff, None);
2491 }
2492
2493 #[test]
2494 fn diff_flag_accepts_commit_sha() {
2495 let cli = parse(&["--coverage", "lcov.info", "--diff", "abc123"]).unwrap();
2496 assert_eq!(cli.filter.diff, Some("abc123".to_string()));
2497 }
2498
2499 #[test]
2500 fn diff_flag_accepts_head_tilde() {
2501 let cli = parse(&["--coverage", "lcov.info", "--diff", "HEAD~1"]).unwrap();
2502 assert_eq!(cli.filter.diff, Some("HEAD~1".to_string()));
2503 }
2504
2505 #[test]
2506 fn validate_diff_ref_rejects_empty_string() {
2507 let err = validate_diff_ref("").unwrap_err();
2508 let msg = format!("{err:#}");
2509 assert!(msg.contains("must not be empty"));
2510 }
2511
2512 #[test]
2513 fn validate_diff_ref_rejects_dash_prefix() {
2514 let err = validate_diff_ref("--malicious").unwrap_err();
2515 let msg = format!("{err:#}");
2516 assert!(msg.contains("invalid diff ref"));
2517 assert!(msg.contains("must not start with a dash"));
2518 }
2519
2520 #[test]
2521 fn validate_diff_ref_accepts_normal_ref() {
2522 assert!(validate_diff_ref("main").is_ok());
2523 assert!(validate_diff_ref("HEAD~1").is_ok());
2524 assert!(validate_diff_ref("abc123").is_ok());
2525 }
2526
2527 #[test]
2528 fn preflight_git_worktree_passes_in_git_repo() {
2529 let tmp = tempfile::tempdir().unwrap();
2533 let status = std::process::Command::new("git")
2534 .arg("init")
2535 .arg("--quiet")
2536 .current_dir(tmp.path())
2537 .status()
2538 .expect("git init");
2539 assert!(status.success(), "git init failed");
2540 assert!(preflight_git_worktree(tmp.path()).is_ok());
2541 }
2542
2543 #[test]
2544 fn breakdown_flag_parsed() {
2545 let cli = parse(&["--coverage", "lcov.info", "--breakdown"]).unwrap();
2546 assert!(cli.display.breakdown);
2547 }
2548
2549 #[test]
2550 fn breakdown_flag_default_false() {
2551 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2552 assert!(!cli.display.breakdown);
2553 }
2554
2555 #[test]
2556 fn explain_flag_parsed() {
2557 let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
2558 assert!(cli.display.explain);
2559 }
2560
2561 #[test]
2562 fn explain_flag_default_false() {
2563 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2564 assert!(!cli.display.explain);
2565 }
2566
2567 #[test]
2568 fn explain_requires_breakdown_for_table_output() {
2569 let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
2570 let err = validate_display_flags(&cli).unwrap_err();
2571 let msg = err.to_string();
2572 assert!(msg.contains("--breakdown"));
2573 assert!(msg.contains("--explain"));
2574 }
2575
2576 #[test]
2577 fn explain_allowed_for_json_output() {
2578 let cli = parse(&["--coverage", "lcov.info", "--format", "json", "--explain"]).unwrap();
2579 assert!(validate_display_flags(&cli).is_ok());
2580 }
2581
2582 #[test]
2583 fn color_overrides_set_global_state() {
2584 apply_color(ColorArg::Never);
2588 assert!(!colored::control::SHOULD_COLORIZE.should_colorize());
2589
2590 apply_color(ColorArg::Always);
2591 assert!(colored::control::SHOULD_COLORIZE.should_colorize());
2592
2593 apply_color(ColorArg::Auto);
2594 }
2595
2596 const TEST_COVERAGE_HINT: &str =
2602 "ensure tests ran with coverage enabled (test-tool's `--coverage` flag)";
2603
2604 struct StubCoveragePort {
2608 validate_result: Result<(), String>,
2609 }
2610
2611 impl CoveragePort for StubCoveragePort {
2612 type Diagnostic = crate::test_strategies::DummyParseDiagnostic;
2613
2614 fn parse(
2615 &self,
2616 _path: &std::path::Path,
2617 ) -> Result<crate::ports::ParseOutput<Self::Diagnostic>, crate::domain::types::CrapError>
2618 {
2619 unreachable!("preflight tests never invoke parse")
2620 }
2621
2622 fn validate(&self, _path: &std::path::Path) -> Result<(), String> {
2623 self.validate_result.clone()
2624 }
2625 }
2626
2627 fn stub_ok() -> StubCoveragePort {
2628 StubCoveragePort {
2629 validate_result: Ok(()),
2630 }
2631 }
2632
2633 fn stub_err(reason: &str) -> StubCoveragePort {
2634 StubCoveragePort {
2635 validate_result: Err(reason.to_string()),
2636 }
2637 }
2638
2639 #[test]
2640 fn preflight_surfaces_hint_when_adapter_reports_no_data() {
2641 let dir = tempfile::tempdir().unwrap();
2642 let cov = dir.path().join("empty.info");
2643 std::fs::write(&cov, "").unwrap();
2644
2645 let err =
2646 check_coverage_has_data(&cov, &stub_err("no records"), TEST_COVERAGE_HINT).unwrap_err();
2647 let msg = format!("{err:#}");
2648 assert!(msg.contains("no coverage data found"));
2649 assert!(msg.contains("no records"), "expected reason in msg: {msg}");
2653 assert!(msg.contains(TEST_COVERAGE_HINT));
2654 }
2655
2656 #[test]
2657 fn preflight_passes_when_adapter_accepts_data() {
2658 let dir = tempfile::tempdir().unwrap();
2659 let cov = dir.path().join("ok.info");
2660 std::fs::write(&cov, "any contents — adapter decides").unwrap();
2661
2662 assert!(check_coverage_has_data(&cov, &stub_ok(), TEST_COVERAGE_HINT).is_ok());
2663 }
2664
2665 #[test]
2668 fn strict_flag_parses() {
2669 let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2670 assert!(cli.output.strict);
2671 }
2672
2673 #[test]
2674 fn lenient_flag_parses() {
2675 let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2676 assert!(cli.output.lenient);
2677 }
2678
2679 #[test]
2680 fn strict_and_threshold_mutually_exclusive() {
2681 parse(&["--coverage", "lcov.info", "--strict", "--threshold", "20"]).unwrap_err();
2682 }
2683
2684 #[test]
2685 fn strict_and_lenient_mutually_exclusive() {
2686 parse(&["--coverage", "lcov.info", "--strict", "--lenient"]).unwrap_err();
2687 }
2688
2689 #[test]
2690 fn merge_threshold_strict_flag() {
2691 use crate::domain::threshold::STRICT_THRESHOLD;
2692 let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2693 let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2694 assert_eq!(config.global, STRICT_THRESHOLD);
2695 assert_eq!(display, STRICT_THRESHOLD);
2696 }
2697
2698 #[test]
2699 fn merge_threshold_lenient_flag() {
2700 use crate::domain::threshold::LENIENT_THRESHOLD;
2701 let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2702 let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2703 assert_eq!(config.global, LENIENT_THRESHOLD);
2704 assert_eq!(display, LENIENT_THRESHOLD);
2705 }
2706
2707 #[test]
2708 fn merge_threshold_toml_preset_used_when_no_cli_flag() {
2709 use crate::domain::threshold::{STRICT_THRESHOLD, ThresholdPreset};
2710 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2711 let file_config = Some(FileConfig {
2712 preset: Some(ThresholdPreset::Strict),
2713 ..FileConfig::default()
2714 });
2715 let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2716 assert_eq!(config.global, STRICT_THRESHOLD);
2717 }
2718
2719 #[test]
2720 fn merge_threshold_config_literal_overrides_config_preset() {
2721 use crate::domain::threshold::ThresholdPreset;
2727 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2728 let file_config = Some(FileConfig {
2729 preset: Some(ThresholdPreset::Strict),
2730 threshold: Some(99.0),
2731 ..FileConfig::default()
2732 });
2733 let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2734 assert_eq!(config.global, 99.0);
2735 assert_eq!(display, 99.0);
2736 }
2737
2738 #[test]
2739 fn merge_threshold_cli_threshold_overrides_toml_preset() {
2740 use crate::domain::threshold::ThresholdPreset;
2741 let cli = parse(&["--coverage", "lcov.info", "--threshold", "50.0"]).unwrap();
2742 let file_config = Some(FileConfig {
2743 preset: Some(ThresholdPreset::Strict),
2744 ..FileConfig::default()
2745 });
2746 let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2747 assert_eq!(config.global, 50.0);
2748 }
2749
2750 #[test]
2753 fn zero_coverage_warn_triggers_above_50_percent() {
2754 assert!(majority_zero_coverage(10, 6));
2755 assert!(majority_zero_coverage(1, 1));
2756 assert!(majority_zero_coverage(3, 2));
2757 }
2758
2759 #[test]
2760 fn zero_coverage_warn_does_not_trigger_at_exactly_50_percent() {
2761 assert!(!majority_zero_coverage(10, 5));
2762 assert!(!majority_zero_coverage(2, 1));
2763 }
2764
2765 #[test]
2766 fn zero_coverage_warn_does_not_trigger_when_no_files() {
2767 assert!(!majority_zero_coverage(0, 0));
2768 }
2769
2770 #[test]
2773 fn merge_effective_inputs_default_src() {
2774 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2775 let inputs = merge_effective_inputs(&cli, &None, &fake_meta());
2776 assert_eq!(inputs.src, PathBuf::from("src"));
2777 }
2778
2779 #[test]
2780 fn merge_effective_inputs_cli_src_wins_over_config() {
2781 let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
2782 let file_config = Some(FileConfig {
2783 src: Some(PathBuf::from("from-config/")),
2784 ..FileConfig::default()
2785 });
2786 let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2787 assert_eq!(inputs.src, PathBuf::from("crates/"));
2788 }
2789
2790 #[test]
2791 fn merge_effective_inputs_config_src_when_cli_absent() {
2792 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2793 let file_config = Some(FileConfig {
2794 src: Some(PathBuf::from("from-config/")),
2795 ..FileConfig::default()
2796 });
2797 let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2798 assert_eq!(inputs.src, PathBuf::from("from-config/"));
2799 }
2800
2801 #[test]
2802 fn merge_effective_inputs_uses_adapter_default_metric_cognitive() {
2803 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2809 let meta = AdapterMeta {
2810 default_metric: ComplexityMetric::Cognitive,
2811 ..fake_meta()
2812 };
2813 let inputs = merge_effective_inputs(&cli, &None, &meta);
2814 assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
2815 }
2816
2817 #[test]
2818 fn merge_effective_inputs_uses_adapter_default_metric_cyclomatic() {
2819 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2823 let meta = AdapterMeta {
2824 default_metric: ComplexityMetric::Cyclomatic,
2825 ..fake_meta()
2826 };
2827 let inputs = merge_effective_inputs(&cli, &None, &meta);
2828 assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2829 }
2830
2831 #[test]
2832 fn merge_effective_inputs_cli_metric_overrides_config() {
2833 let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
2834 let file_config = Some(FileConfig {
2835 metric: Some(ComplexityMetric::Cognitive),
2836 ..FileConfig::default()
2837 });
2838 let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2839 assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2840 }
2841
2842 #[test]
2843 fn merge_effective_inputs_default_threshold_follows_adapter_metric_cognitive() {
2844 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2848 let meta = AdapterMeta {
2849 default_metric: ComplexityMetric::Cognitive,
2850 ..fake_meta()
2851 };
2852 let inputs = merge_effective_inputs(&cli, &None, &meta);
2853 assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
2854 assert_eq!(inputs.threshold, 15.0);
2855 }
2856
2857 #[test]
2858 fn merge_effective_inputs_default_threshold_follows_adapter_metric_cyclomatic() {
2859 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2864 let meta = AdapterMeta {
2865 default_metric: ComplexityMetric::Cyclomatic,
2866 ..fake_meta()
2867 };
2868 let inputs = merge_effective_inputs(&cli, &None, &meta);
2869 assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2870 assert_eq!(inputs.threshold, 15.0);
2871 }
2872
2873 #[test]
2874 fn merge_effective_inputs_exclude_combines_cli_and_config() {
2875 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2876 let file_config = Some(FileConfig {
2877 exclude: Some(vec!["benches/**".to_string()]),
2878 ..FileConfig::default()
2879 });
2880 let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2881 assert_eq!(inputs.exclude, vec!["tests/**", "benches/**"]);
2882 }
2883
2884 #[test]
2885 fn merge_effective_inputs_config_metric_wins_over_adapter_default() {
2886 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2890 let file_config = Some(FileConfig {
2891 metric: Some(ComplexityMetric::Cyclomatic),
2892 ..FileConfig::default()
2893 });
2894 let meta = AdapterMeta {
2895 default_metric: ComplexityMetric::Cognitive,
2896 ..fake_meta()
2897 };
2898 let inputs = merge_effective_inputs(&cli, &file_config, &meta);
2899 assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2900 }
2901
2902 #[test]
2910 fn compute_exit_code_passing_no_delta() {
2911 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2912 assert!(compute_exit_code::<
2913 crate::test_strategies::DummyParseDiagnostic,
2914 >(&cli, true, None));
2915 }
2916
2917 #[test]
2918 fn compute_exit_code_failing_no_delta() {
2919 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2920 assert!(!compute_exit_code::<
2921 crate::test_strategies::DummyParseDiagnostic,
2922 >(&cli, false, None));
2923 }
2924
2925 #[test]
2926 fn compute_exit_code_no_fail_overrides_failure() {
2927 let cli = parse(&["--coverage", "lcov.info", "--no-fail"]).unwrap();
2928 assert!(compute_exit_code::<
2929 crate::test_strategies::DummyParseDiagnostic,
2930 >(&cli, false, None));
2931 }
2932
2933 #[test]
2934 fn compute_exit_code_delta_gate_without_runtime_baseline_treats_delta_as_passed() {
2935 let cli = parse(&[
2943 "--coverage",
2944 "lcov.info",
2945 "--delta-gate",
2946 "--baseline",
2947 "/dev/null",
2948 ])
2949 .unwrap();
2950 assert!(compute_exit_code::<
2951 crate::test_strategies::DummyParseDiagnostic,
2952 >(&cli, true, None));
2953 }
2954
2955 #[test]
2956 fn compute_exit_code_no_fail_with_delta_gate() {
2957 let cli = parse(&[
2960 "--coverage",
2961 "lcov.info",
2962 "--delta-gate",
2963 "--baseline",
2964 "/dev/null",
2965 "--no-fail",
2966 ])
2967 .unwrap();
2968 assert!(compute_exit_code::<
2969 crate::test_strategies::DummyParseDiagnostic,
2970 >(&cli, false, None));
2971 }
2972
2973 fn fake_meta() -> AdapterMeta {
2976 AdapterMeta {
2977 tool_name: "fake-adapter",
2978 display_name: "Fake",
2979 tool_version: "9.9.9",
2980 long_version: "9.9.9 (test 2099-01-01)",
2981 about: "Fake adapter for tests",
2982 long_about: "Fake adapter for tests — verifies AdapterMeta plumbing without binding crap-core to any real adapter.",
2983 after_help: "",
2984 coverage_hint: "no coverage tool — fake adapter",
2985 extensions: &["fake"],
2986 tool_info_uri: "https://example.invalid/fake-adapter",
2987 rule_help_uri: "https://example.invalid/fake-adapter#rules",
2988 config_file_name: "fake-adapter.toml",
2989 default_excludes: &["fixtures/**"],
2990 forced_excludes: &[],
2995 default_metric: ComplexityMetric::Cognitive,
3000 }
3001 }
3002
3003 #[test]
3004 fn adapter_meta_extensions_owned_roundtrips_to_owned_strings() {
3005 let meta = AdapterMeta {
3006 extensions: &["ts", "tsx", "js"],
3007 ..fake_meta()
3008 };
3009 let owned = meta.extensions_owned();
3010 assert_eq!(
3011 owned,
3012 vec!["ts".to_string(), "tsx".to_string(), "js".to_string()]
3013 );
3014 let back: Vec<&str> = owned.iter().map(String::as_str).collect();
3016 assert_eq!(back, &["ts", "tsx", "js"]);
3017 }
3018
3019 #[test]
3020 fn adapter_meta_extensions_owned_handles_empty_slice() {
3021 let meta = AdapterMeta {
3024 extensions: &[],
3025 ..fake_meta()
3026 };
3027 assert!(meta.extensions_owned().is_empty());
3028 }
3029
3030 #[test]
3031 #[should_panic(expected = "tool_name must not be empty")]
3032 fn adapter_meta_debug_assert_trips_on_empty_tool_name() {
3033 let meta = AdapterMeta {
3034 tool_name: "",
3035 ..fake_meta()
3036 };
3037 meta.debug_assert_required_fields();
3038 }
3039
3040 #[test]
3041 #[should_panic(expected = "config_file_name must not be empty")]
3042 fn adapter_meta_debug_assert_trips_on_empty_config_file_name() {
3043 let meta = AdapterMeta {
3044 config_file_name: "",
3045 ..fake_meta()
3046 };
3047 meta.debug_assert_required_fields();
3048 }
3049
3050 #[test]
3051 fn adapter_meta_debug_assert_passes_on_all_fields_set() {
3052 fake_meta().debug_assert_required_fields();
3055 }
3056}