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 tool_version: &'static str,
676 pub long_version: &'static str,
679 pub about: &'static str,
681 pub long_about: &'static str,
684 pub after_help: &'static str,
687 pub coverage_hint: &'static str,
692 pub extensions: &'static [&'static str],
697 pub tool_info_uri: &'static str,
701 pub rule_help_uri: &'static str,
705 pub config_file_name: &'static str,
711 pub default_excludes: &'static [&'static str],
725 pub forced_excludes: &'static [&'static str],
739 pub default_metric: ComplexityMetric,
749}
750
751impl AdapterMeta {
752 pub fn extensions_owned(&self) -> Vec<String> {
756 self.extensions.iter().map(|e| (*e).to_string()).collect()
757 }
758
759 pub(crate) fn debug_assert_required_fields(&self) {
768 debug_assert!(
769 !self.tool_name.is_empty(),
770 "AdapterMeta.tool_name must not be empty"
771 );
772 debug_assert!(
773 !self.tool_version.is_empty(),
774 "AdapterMeta.tool_version must not be empty"
775 );
776 debug_assert!(
777 !self.long_version.is_empty(),
778 "AdapterMeta.long_version must not be empty"
779 );
780 debug_assert!(
781 !self.about.is_empty(),
782 "AdapterMeta.about must not be empty"
783 );
784 debug_assert!(
785 !self.long_about.is_empty(),
786 "AdapterMeta.long_about must not be empty"
787 );
788 debug_assert!(
789 !self.coverage_hint.is_empty(),
790 "AdapterMeta.coverage_hint must not be empty"
791 );
792 debug_assert!(
793 !self.tool_info_uri.is_empty(),
794 "AdapterMeta.tool_info_uri must not be empty"
795 );
796 debug_assert!(
797 !self.rule_help_uri.is_empty(),
798 "AdapterMeta.rule_help_uri must not be empty"
799 );
800 debug_assert!(
801 !self.config_file_name.is_empty(),
802 "AdapterMeta.config_file_name must not be empty"
803 );
804 }
805}
806
807pub fn parse_args(meta: &AdapterMeta) -> Cli {
827 meta.debug_assert_required_fields();
828 let cmd = build_command(meta);
829 let matches = cmd.get_matches();
830 Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
831}
832
833fn current_bin_name(meta_fallback: &str) -> String {
842 std::env::args()
843 .next()
844 .and_then(|first| {
845 std::path::PathBuf::from(first)
851 .file_stem()
852 .map(|os| os.to_string_lossy().into_owned())
853 })
854 .unwrap_or_else(|| meta_fallback.to_string())
855}
856
857fn build_command(meta: &AdapterMeta) -> clap::Command {
871 let bin_name = current_bin_name(meta.tool_name);
872 let mut cmd = Cli::command()
873 .name(bin_name.clone())
874 .bin_name(bin_name)
875 .version(meta.tool_version)
876 .long_version(meta.long_version)
877 .about(meta.about)
878 .long_about(meta.long_about);
879 if !meta.after_help.is_empty() {
880 cmd = cmd.after_help(meta.after_help);
881 }
882 cmd = cmd.mut_arg("metric", |arg| {
889 arg.help(format!(
890 "Complexity metric to use [default: {}]",
891 meta.default_metric
892 ))
893 });
894 cmd
895}
896
897pub fn run<P, F>(
926 cli: Cli,
927 complexity: &dyn ComplexityPort,
928 coverage_factory: F,
929 meta: &AdapterMeta,
930) -> ExitCode
931where
932 P: ParseDiagnostic + std::fmt::Display + 'static,
933 F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
934{
935 match run_inner(cli, complexity, coverage_factory, meta) {
936 Ok(true) => ExitCode::from(0),
937 Ok(false) => ExitCode::from(1),
938 Err(e) => {
939 render_error(&e, meta);
940 ExitCode::from(2)
941 }
942 }
943}
944
945fn render_error(err: &anyhow::Error, meta: &AdapterMeta) {
957 if let Some(crap_err) = err.downcast_ref::<crate::domain::types::CrapError>()
958 && let crate::domain::types::CrapError::MetricNotSupported { metric } = crap_err
959 {
960 eprintln!(
968 "{}: complexity metric `{}` is not yet supported. Use `--metric {}` (the default for {}) or track support at {}.",
969 meta.tool_name, metric, meta.default_metric, meta.tool_name, meta.tool_info_uri,
970 );
971 return;
972 }
973 eprintln!("error: {err:#}");
974}
975
976fn run_inner<P, F>(
977 mut cli: Cli,
978 complexity: &dyn ComplexityPort,
979 coverage_factory: F,
980 meta: &AdapterMeta,
981) -> Result<bool>
982where
983 P: ParseDiagnostic + std::fmt::Display + 'static,
984 F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
985{
986 match cli.command {
987 Some(Command::Completions { shell }) => {
988 emit_completions(shell, ¤t_bin_name(meta.tool_name));
989 return Ok(true);
990 }
991 Some(Command::Init {
992 force,
993 non_interactive,
994 }) => {
995 init::handle_init(force, non_interactive, meta)?;
996 return Ok(true);
997 }
998 None => {}
999 }
1000
1001 let prep = prepare_pipeline(&mut cli, complexity, coverage_factory, meta)?;
1002
1003 let spec = view_args::build_view_spec(&cli);
1008 let view = view::apply(&prep.analysis.result, spec);
1009
1010 let delta_spec = delta_args::build_delta_view_spec(&cli);
1016 let delta_view: Option<DeltaView<'_>> = prep
1017 .delta_state
1018 .as_ref()
1019 .map(move |s| delta::apply(&s.delta, delta_spec));
1020
1021 if !cli.display.quiet {
1022 print_formatted_output(
1023 &cli,
1024 &view,
1025 delta_view.as_ref(),
1026 prep.delta_state.as_ref(),
1027 &prep.analysis,
1028 &prep.inputs,
1029 meta,
1030 )?;
1031 }
1032
1033 Ok(compute_exit_code(
1043 &cli,
1044 prep.analysis.result.passed,
1045 prep.delta_state.as_ref(),
1046 ))
1047}
1048
1049struct EffectiveInputs {
1056 src: PathBuf,
1057 metric: ComplexityMetric,
1058 threshold_config: ThresholdConfig,
1059 threshold: f64,
1060 exclude: Vec<String>,
1061 annotation_limit: usize,
1067}
1068
1069struct PipelinePrep<P: ParseDiagnostic> {
1075 inputs: EffectiveInputs,
1076 analysis: AnalysisOutput<P>,
1077 delta_state: Option<DeltaState<P>>,
1078}
1079
1080fn merge_effective_inputs(
1086 cli: &Cli,
1087 file_config: &Option<FileConfig>,
1088 meta: &AdapterMeta,
1089) -> EffectiveInputs {
1090 let src = cli
1091 .input
1092 .src
1093 .clone()
1094 .or_else(|| file_config.as_ref().and_then(|c| c.src.clone()))
1095 .unwrap_or_else(|| PathBuf::from("src"));
1096 let metric: ComplexityMetric = cli
1097 .input
1098 .metric
1099 .map(Into::into)
1100 .or_else(|| file_config.as_ref().and_then(|c| c.metric))
1101 .unwrap_or(meta.default_metric);
1102 let (threshold_config, threshold) = merge_threshold(cli, file_config, metric);
1103 let exclude = merge_exclude(cli, file_config, meta);
1104 let annotation_limit = cli
1105 .output
1106 .annotation_limit
1107 .or_else(|| file_config.as_ref().and_then(|c| c.output.annotation_limit))
1108 .unwrap_or(10) as usize;
1109 EffectiveInputs {
1110 src,
1111 metric,
1112 threshold_config,
1113 threshold,
1114 exclude,
1115 annotation_limit,
1116 }
1117}
1118
1119fn validate_runtime_inputs<'a>(
1120 cli: &'a Cli,
1121 inputs: &EffectiveInputs,
1122 meta: &AdapterMeta,
1123) -> Result<&'a Path> {
1124 let Some(coverage_path) = cli.input.coverage.as_deref() else {
1128 bail!(
1129 "--coverage <FILE> is required (run `{name} --help` for usage, or `{name} completions <SHELL>` for shell completion scripts)",
1130 name = meta.tool_name,
1131 );
1132 };
1133
1134 validate_inputs(
1135 coverage_path,
1136 &inputs.src,
1137 inputs.threshold,
1138 meta.coverage_hint,
1139 )?;
1140
1141 if let Some(diff_ref) = cli.filter.diff.as_deref() {
1142 validate_diff_ref(diff_ref)?;
1143 preflight_git_worktree(&inputs.src)?;
1144 }
1145
1146 Ok(coverage_path)
1147}
1148
1149fn build_analyze_options(
1150 cli: &Cli,
1151 inputs: &EffectiveInputs,
1152 coverage: &Path,
1153 meta: &AdapterMeta,
1154) -> AnalyzeOptions {
1155 AnalyzeOptions {
1156 src: inputs.src.clone(),
1157 coverage: coverage.to_path_buf(),
1158 threshold_config: inputs.threshold_config.clone(),
1159 metric: inputs.metric,
1160 exclude: inputs.exclude.clone(),
1161 respect_gitignore: !cli.filter.no_gitignore,
1162 diff_ref: cli.filter.diff.clone(),
1163 extensions: meta.extensions_owned(),
1164 compute_diagnostics: cli
1165 .output
1166 .format
1167 .iter()
1168 .any(|s| matches!(s.format, FormatArg::Advice | FormatArg::Sarif)),
1169 ..AnalyzeOptions::default()
1170 }
1171}
1172
1173fn apply_diagnostics<P: ParseDiagnostic + std::fmt::Display>(
1174 cli: &Cli,
1175 diagnostics: &AnalysisDiagnostics<P>,
1176) {
1177 warn_if_issues(diagnostics);
1179 if cli.display.verbose {
1180 print_diagnostics(diagnostics);
1181 }
1182}
1183
1184fn prepare_pipeline<P, F>(
1194 cli: &mut Cli,
1195 complexity: &dyn ComplexityPort,
1196 coverage_factory: F,
1197 meta: &AdapterMeta,
1198) -> Result<PipelinePrep<P>>
1199where
1200 P: ParseDiagnostic + std::fmt::Display + 'static,
1201 F: FnOnce(&Path) -> Box<dyn CoveragePort<Diagnostic = P>>,
1202{
1203 validate_display_flags(cli)?;
1204 apply_color(cli.display.color);
1205
1206 let (file_config, config_path) = load_file_config(cli, meta.config_file_name)?.unzip();
1211
1212 view_args::resolve_view_preset(
1217 cli,
1218 file_config.as_ref(),
1219 config_path.as_deref(),
1220 meta.config_file_name,
1221 )?;
1222 view_args::validate_view_args(cli)?;
1223
1224 let inputs = merge_effective_inputs(cli, &file_config, meta);
1225 let coverage_path = validate_runtime_inputs(cli, &inputs, meta)?;
1226
1227 let src_canonical = crate::core::canonicalize_src(&inputs.src);
1234 let coverage = coverage_factory(&src_canonical);
1235
1236 preflight_checks(coverage_path, &*coverage, meta)?;
1241
1242 let options = build_analyze_options(cli, &inputs, coverage_path, meta);
1243
1244 let analysis = crate::core::analyze(&options, complexity, &*coverage)?;
1245 apply_diagnostics(cli, &analysis.diagnostics);
1246
1247 let delta_state = load_delta_state(cli, &analysis.result)?;
1252
1253 Ok(PipelinePrep {
1254 inputs,
1255 analysis,
1256 delta_state,
1257 })
1258}
1259
1260fn format_as_json<P: ParseDiagnostic>(
1263 cli: &Cli,
1264 view: &view::AnalysisView<'_>,
1265 delta_view: Option<&DeltaView<'_>>,
1266 delta_state: Option<&DeltaState<P>>,
1267 analysis: &AnalysisOutput<P>,
1268 inputs: &EffectiveInputs,
1269 meta: &AdapterMeta,
1270) -> Result<String> {
1271 let delta_ctx = delta_state.zip(delta_view).map(|(s, dv)| DeltaContext {
1272 view: dv,
1273 baseline_tool_version: &s.snapshot.tool_version,
1274 baseline_timestamp: &s.snapshot.timestamp,
1275 baseline_diagnostics: s.snapshot.diagnostics.as_ref(),
1276 });
1277 let config = reporters::json::JsonConfig {
1278 tool_version: meta.tool_version.to_string(),
1279 metric: inputs.metric,
1280 threshold: inputs.threshold,
1281 timestamp: now_unix_epoch(),
1282 diagnostics: cli.display.verbose.then_some(&analysis.diagnostics),
1283 diff_ref: cli.filter.diff.as_deref(),
1284 minimal_view: cli.output.minimal_view,
1285 delta: delta_ctx,
1286 };
1287 reporters::json::format_json(view, &config).map_err(Into::into)
1288}
1289
1290fn format_as_scorecard_row<P: ParseDiagnostic>(
1295 delta_state: Option<&DeltaState<P>>,
1296 result: &crate::domain::types::AnalysisResult,
1297 threshold: f64,
1298) -> String {
1299 let baseline_result = delta_state.map(|s| &s.snapshot.result);
1300 let delta_inputs = delta_state.map(|s| (&s.delta.summary, s.delta.changes.as_slice()));
1301 let row_data = crate::domain::summary::project_crap_delta_row(
1302 result,
1303 baseline_result,
1304 delta_inputs,
1305 threshold.round() as u32,
1306 );
1307 reporters::format_scorecard_row(&row_data)
1308}
1309
1310#[allow(clippy::too_many_arguments)]
1317fn render_format<P: ParseDiagnostic>(
1318 cli: &Cli,
1319 spec: &FormatSpec,
1320 view: &view::AnalysisView<'_>,
1321 delta_view: Option<&DeltaView<'_>>,
1322 delta_state: Option<&DeltaState<P>>,
1323 analysis: &AnalysisOutput<P>,
1324 inputs: &EffectiveInputs,
1325 meta: &AdapterMeta,
1326) -> Result<String> {
1327 Ok(match spec.format {
1328 FormatArg::Table => reporters::format_table_with_explain(
1329 view,
1330 delta_view,
1331 inputs.threshold,
1332 cli.display.breakdown,
1333 cli.display.explain,
1334 meta.tool_name,
1335 meta.tool_version,
1336 ),
1337 FormatArg::Json | FormatArg::Advice => {
1338 format_as_json(cli, view, delta_view, delta_state, analysis, inputs, meta)?
1339 }
1340 FormatArg::Markdown => reporters::format_markdown(
1341 view,
1342 delta_view,
1343 inputs.threshold,
1344 cli.display.breakdown,
1345 cli.display.explain,
1346 cli.display.md_full_table,
1347 cli.display.md_top,
1348 meta.tool_name,
1349 meta.tool_version,
1350 ),
1351 FormatArg::Csv => reporters::format_csv(view, delta_view, inputs.metric),
1352 FormatArg::Sarif => reporters::format_sarif(
1358 view,
1359 meta.tool_name,
1360 meta.tool_version,
1361 meta.tool_info_uri,
1362 meta.rule_help_uri,
1363 ),
1364 FormatArg::ScorecardRow => {
1365 format_as_scorecard_row(delta_state, &analysis.result, inputs.threshold)
1366 }
1367 FormatArg::Html => {
1368 reporters::format_html(view, inputs.threshold, meta.tool_name, meta.tool_version)
1369 }
1370 FormatArg::GithubAnnotations => reporters::format_github_annotations(
1376 view,
1377 meta.tool_name,
1378 meta.tool_version,
1379 inputs.annotation_limit,
1380 ),
1381 })
1382}
1383
1384fn print_formatted_output<P: ParseDiagnostic>(
1385 cli: &Cli,
1386 view: &view::AnalysisView<'_>,
1387 delta_view: Option<&DeltaView<'_>>,
1388 delta_state: Option<&DeltaState<P>>,
1389 analysis: &AnalysisOutput<P>,
1390 inputs: &EffectiveInputs,
1391 meta: &AdapterMeta,
1392) -> Result<()> {
1393 if cli.output.summary {
1399 let line = reporters::format_summary_line(view.full, inputs.threshold);
1400 println!("{line}");
1401 return Ok(());
1402 }
1403
1404 for spec in &cli.output.format {
1405 let output = render_format(
1406 cli,
1407 spec,
1408 view,
1409 delta_view,
1410 delta_state,
1411 analysis,
1412 inputs,
1413 meta,
1414 )?;
1415 match &spec.output {
1416 Some(path) => std::fs::write(path, &output)
1417 .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?,
1418 None => print!("{output}"),
1419 }
1420 }
1421
1422 if cli
1427 .output
1428 .format
1429 .iter()
1430 .any(|s| matches!(s.format, FormatArg::Advice))
1431 {
1432 let mut stderr = std::io::stderr();
1433 let _ = reporters::render_advice_summary(view, &mut stderr);
1434 }
1435
1436 Ok(())
1437}
1438
1439fn compute_exit_code<P: ParseDiagnostic>(
1440 cli: &Cli,
1441 passed: bool,
1442 delta_state: Option<&DeltaState<P>>,
1443) -> bool {
1444 let delta_passed = delta_state.map(|s| s.delta.summary.passed).unwrap_or(true);
1445 let combined_passed = passed && (!cli.output.delta_gate || delta_passed);
1446 combined_passed || cli.output.no_fail
1447}
1448
1449struct DeltaState<P: ParseDiagnostic> {
1458 snapshot: BaselineSnapshot<P>,
1459 delta: AnalysisDelta,
1460}
1461
1462fn load_delta_state<P: ParseDiagnostic>(
1463 cli: &Cli,
1464 current: &crate::domain::types::AnalysisResult,
1465) -> Result<Option<DeltaState<P>>> {
1466 let Some(path) = cli.input.baseline.as_ref() else {
1467 return Ok(None);
1468 };
1469 let snapshot = baseline::load::<P>(path).map_err(|e| anyhow::anyhow!("{e}"))?;
1470 let delta = delta::compute(snapshot.result.clone(), current.clone());
1473 Ok(Some(DeltaState { snapshot, delta }))
1474}
1475
1476fn validate_display_flags(cli: &Cli) -> Result<()> {
1477 let any_table = cli
1478 .output
1479 .format
1480 .iter()
1481 .any(|s| matches!(s.format, FormatArg::Table));
1482 if cli.display.explain && any_table && !cli.display.breakdown {
1483 bail!("--explain requires --breakdown for table output");
1484 }
1485 validate_format_destinations(&cli.output.format)?;
1486 Ok(())
1487}
1488
1489fn validate_format_destinations(specs: &[FormatSpec]) -> Result<()> {
1498 if specs.len() > 1 {
1499 let stdout_specs: Vec<_> = specs
1500 .iter()
1501 .filter(|s| s.output.is_none())
1502 .map(|s| format_arg_kebab(s.format).to_string())
1503 .collect();
1504 if stdout_specs.len() > 1 {
1505 bail!(
1506 "multi-format `--format` allows at most one stdout entry (the rest must specify a file, e.g. `json:envelope.json`); stdout entries: {}",
1507 stdout_specs.join(", ")
1508 );
1509 }
1510 }
1511 Ok(())
1512}
1513
1514fn format_arg_kebab(arg: FormatArg) -> String {
1518 use clap::ValueEnum;
1519 arg.to_possible_value()
1520 .map(|v| v.get_name().to_string())
1521 .unwrap_or_else(|| format!("{arg:?}").to_lowercase())
1522}
1523
1524fn load_file_config(
1532 cli: &Cli,
1533 config_file_name: &str,
1534) -> Result<Option<(FileConfig, std::path::PathBuf)>> {
1535 if let Some(path) = &cli.input.config {
1536 let cfg = config::load_config(path)?;
1537 Ok(Some((cfg, path.clone())))
1538 } else {
1539 match config::discover_config(config_file_name)? {
1540 Some(path) => {
1541 let cfg = config::load_config(&path)?;
1542 Ok(Some((cfg, path)))
1543 }
1544 None => Ok(None),
1545 }
1546 }
1547}
1548
1549fn merge_threshold(
1571 cli: &Cli,
1572 file_config: &Option<FileConfig>,
1573 metric: ComplexityMetric,
1574) -> (ThresholdConfig, f64) {
1575 let global = cli
1576 .output
1577 .threshold
1578 .or_else(|| {
1579 cli.output
1580 .strict
1581 .then(|| ThresholdPreset::Strict.threshold(metric))
1582 })
1583 .or_else(|| {
1584 cli.output
1585 .lenient
1586 .then(|| ThresholdPreset::Lenient.threshold(metric))
1587 })
1588 .or_else(|| file_config.as_ref().and_then(|c| c.threshold))
1589 .or_else(|| {
1590 file_config
1591 .as_ref()
1592 .and_then(|c| c.preset)
1593 .map(|p| p.threshold(metric))
1594 })
1595 .unwrap_or(ThresholdPreset::Default.threshold(metric));
1596
1597 let overrides = file_config
1598 .as_ref()
1599 .map(|fc| fc.overrides.clone())
1600 .unwrap_or_default();
1601
1602 let config = ThresholdConfig { global, overrides };
1603 (config, global)
1604}
1605
1606fn merge_exclude(cli: &Cli, file_config: &Option<FileConfig>, meta: &AdapterMeta) -> Vec<String> {
1615 let mut exclude: Vec<String> = meta
1616 .forced_excludes
1617 .iter()
1618 .map(|s| (*s).to_string())
1619 .collect();
1620 let mut seen: std::collections::HashSet<String> = exclude.iter().cloned().collect();
1621 for pattern in &cli.filter.exclude {
1622 if seen.insert(pattern.clone()) {
1623 exclude.push(pattern.clone());
1624 }
1625 }
1626 if let Some(fc) = file_config
1627 && let Some(fc_exclude) = &fc.exclude
1628 {
1629 for pattern in fc_exclude {
1630 if seen.insert(pattern.clone()) {
1631 exclude.push(pattern.clone());
1632 }
1633 }
1634 }
1635 exclude
1636}
1637
1638fn validate_inputs(
1641 coverage: &std::path::Path,
1642 src: &std::path::Path,
1643 threshold: f64,
1644 coverage_hint: &str,
1645) -> Result<()> {
1646 match std::fs::metadata(coverage) {
1647 Ok(m) if m.is_file() => {}
1648 Ok(_) => bail!(
1649 "coverage path is not a file: {}\n \
1650 hint: pass --coverage pointing to a coverage file, not a directory",
1651 coverage.display()
1652 ),
1653 Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1654 "coverage file not found: {}\n hint: {coverage_hint}",
1655 coverage.display()
1656 ),
1657 Err(e) => bail!(
1658 "cannot access coverage file: {}: {e}\n \
1659 hint: check file permissions",
1660 coverage.display()
1661 ),
1662 }
1663 match std::fs::metadata(src) {
1664 Ok(m) if m.is_dir() => {}
1665 Ok(_) => bail!(
1666 "source path is not a directory: {}\n \
1667 hint: pass --src <DIR> pointing to your source root",
1668 src.display()
1669 ),
1670 Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1671 "source directory not found: {}\n \
1672 hint: pass --src <DIR> pointing to your source root",
1673 src.display()
1674 ),
1675 Err(e) => bail!(
1676 "cannot access source directory: {}: {e}\n \
1677 hint: check directory permissions",
1678 src.display()
1679 ),
1680 }
1681 if !is_valid_threshold(threshold) {
1682 bail!(
1683 "threshold must be a finite positive number, got: {}",
1684 threshold
1685 );
1686 }
1687 Ok(())
1688}
1689
1690fn validate_diff_ref(diff_ref: &str) -> Result<()> {
1693 if diff_ref.is_empty() {
1694 bail!("invalid diff ref: ref must not be empty");
1695 }
1696 if diff_ref.starts_with('-') {
1697 bail!(
1698 "invalid diff ref: {diff_ref}\n \
1699 hint: ref must not start with a dash"
1700 );
1701 }
1702 Ok(())
1703}
1704
1705fn preflight_git_worktree(src: &Path) -> Result<()> {
1706 let output = std::process::Command::new("git")
1707 .current_dir(src)
1708 .args(["rev-parse", "--is-inside-work-tree"])
1709 .output();
1710
1711 match output {
1712 Ok(o) if o.status.success() => Ok(()),
1713 Ok(o) => {
1714 let stderr = String::from_utf8_lossy(&o.stderr);
1715 bail!(
1716 "not inside a git work tree\n \
1717 hint: --diff requires a git repository\n \
1718 git: {stderr}",
1719 );
1720 }
1721 Err(e) => bail!(
1722 "not inside a git work tree\n \
1723 hint: --diff requires git to be installed\n \
1724 error: {e}",
1725 ),
1726 }
1727}
1728
1729fn preflight_checks<P>(
1736 coverage: &std::path::Path,
1737 coverage_port: &dyn CoveragePort<Diagnostic = P>,
1738 meta: &AdapterMeta,
1739) -> Result<()>
1740where
1741 P: ParseDiagnostic,
1742{
1743 check_coverage_has_data(coverage, coverage_port, meta.coverage_hint)
1744}
1745
1746fn check_coverage_has_data<P>(
1747 path: &std::path::Path,
1748 coverage_port: &dyn CoveragePort<Diagnostic = P>,
1749 coverage_hint: &str,
1750) -> Result<()>
1751where
1752 P: ParseDiagnostic,
1753{
1754 if let Err(reason) = coverage_port.validate(path) {
1764 bail!(
1765 "no coverage data found in {} ({reason})\n hint: {}",
1766 path.display(),
1767 coverage_hint,
1768 );
1769 }
1770 Ok(())
1771}
1772
1773fn now_unix_epoch() -> String {
1776 let secs = SystemTime::now()
1777 .duration_since(SystemTime::UNIX_EPOCH)
1778 .unwrap_or_default()
1779 .as_secs();
1780 format!("{secs}")
1781}
1782
1783fn majority_zero_coverage(files_analyzed: usize, files_zero_coverage: usize) -> bool {
1786 files_analyzed > 0 && files_zero_coverage * 2 > files_analyzed
1787}
1788
1789fn warn_if_issues<P: ParseDiagnostic>(diag: &AnalysisDiagnostics<P>) {
1790 if !diag.parse_diagnostics.is_empty() {
1791 eprintln!(
1792 "warning: {} coverage parse issue(s) encountered (use --verbose for details)",
1793 diag.parse_diagnostics.len()
1794 );
1795 }
1796 if diag.files_unparseable > 0 {
1797 eprintln!(
1798 "warning: {} source file(s) could not be parsed (use --verbose for details)",
1799 diag.files_unparseable
1800 );
1801 }
1802 if majority_zero_coverage(diag.files_analyzed, diag.files_zero_coverage) {
1803 eprintln!(
1804 "warning: in {}/{} analyzed files, all analyzed functions have 0% line coverage",
1805 diag.files_zero_coverage, diag.files_analyzed
1806 );
1807 eprintln!(
1808 " hint: `cargo llvm-cov --lib` does not cover integration-only code (handlers, Tauri entry, BDD tests)"
1809 );
1810 eprintln!(
1811 " hint: use --exclude to skip uncoverable paths (e.g., --exclude \"services/api/src/**\")"
1812 );
1813 }
1814}
1815
1816fn print_diagnostics<P: ParseDiagnostic + std::fmt::Display>(diag: &AnalysisDiagnostics<P>) {
1817 eprintln!(
1818 "verbose: file discovery: {} files found, {} unparseable",
1819 diag.files_found, diag.files_unparseable
1820 );
1821 eprintln!(
1822 "verbose: complexity: {} functions extracted",
1823 diag.functions_extracted
1824 );
1825 eprintln!(
1826 "verbose: matching: {} matched with coverage, {} without coverage data",
1827 diag.functions_matched, diag.functions_no_coverage
1828 );
1829 eprintln!(
1830 "verbose: coverage: {} files analyzed, {} where all analyzed functions have 0% line coverage",
1831 diag.files_analyzed, diag.files_zero_coverage
1832 );
1833 if !diag.parse_diagnostics.is_empty() {
1834 eprintln!(
1835 "verbose: coverage parse diagnostics ({}):",
1836 diag.parse_diagnostics.len()
1837 );
1838 for d in &diag.parse_diagnostics {
1839 eprintln!(" {d}");
1840 }
1841 }
1842}
1843
1844fn emit_completions(shell: ShellArg, bin_name: &str) {
1855 let mut cmd = Cli::command();
1856 let stdout = &mut std::io::stdout();
1857 match shell {
1858 ShellArg::Bash => clap_complete::generate(ClapShell::Bash, &mut cmd, bin_name, stdout),
1859 ShellArg::Zsh => clap_complete::generate(ClapShell::Zsh, &mut cmd, bin_name, stdout),
1860 ShellArg::Fish => clap_complete::generate(ClapShell::Fish, &mut cmd, bin_name, stdout),
1861 ShellArg::Powershell => {
1862 clap_complete::generate(ClapShell::PowerShell, &mut cmd, bin_name, stdout)
1863 }
1864 ShellArg::Elvish => clap_complete::generate(ClapShell::Elvish, &mut cmd, bin_name, stdout),
1865 ShellArg::Nushell => {
1866 clap_complete::generate(clap_complete_nushell::Nushell, &mut cmd, bin_name, stdout)
1867 }
1868 }
1869}
1870
1871fn apply_color(choice: ColorArg) {
1874 match choice {
1875 ColorArg::Auto => colored::control::unset_override(),
1876 ColorArg::Always => colored::control::set_override(true),
1877 ColorArg::Never => colored::control::set_override(false),
1878 }
1879}
1880
1881#[cfg(test)]
1884mod tests {
1885 use super::*;
1886 use crate::domain::threshold::DEFAULT_THRESHOLD;
1890 use std::path::Path;
1891
1892 fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
1893 let mut full = vec!["test-adapter"];
1898 full.extend_from_slice(args);
1899 Cli::try_parse_from(full)
1900 }
1901
1902 #[test]
1903 fn no_args_parses_with_coverage_none() {
1904 let cli = parse(&[]).unwrap();
1909 assert!(cli.input.coverage.is_none());
1910 assert!(cli.command.is_none());
1911 }
1912
1913 #[test]
1914 fn minimal_valid_args() {
1915 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1916 assert_eq!(cli.input.coverage.as_deref(), Some(Path::new("lcov.info")));
1917 assert_eq!(cli.input.src, None);
1918 }
1919
1920 #[test]
1921 fn completions_subcommand_does_not_require_coverage() {
1922 let cli = parse(&["completions", "bash"]).unwrap();
1923 assert!(matches!(
1924 cli.command,
1925 Some(Command::Completions {
1926 shell: ShellArg::Bash
1927 })
1928 ));
1929 assert!(cli.input.coverage.is_none());
1930 }
1931
1932 #[test]
1933 fn default_metric_is_none() {
1934 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1935 assert!(cli.input.metric.is_none());
1936 }
1937
1938 #[test]
1939 fn default_format_is_table() {
1940 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1941 assert_eq!(cli.output.format.len(), 1);
1942 assert!(matches!(cli.output.format[0].format, FormatArg::Table));
1943 assert!(cli.output.format[0].output.is_none());
1944 }
1945
1946 #[test]
1947 fn default_threshold_is_none() {
1948 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1949 assert!(cli.output.threshold.is_none());
1950 }
1951
1952 #[test]
1953 fn default_color_is_auto() {
1954 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1955 assert!(matches!(cli.display.color, ColorArg::Auto));
1956 }
1957
1958 #[test]
1959 fn metric_cyclomatic() {
1960 let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
1961 assert!(matches!(cli.input.metric, Some(MetricArg::Cyclomatic)));
1962 }
1963
1964 #[test]
1965 fn format_json() {
1966 let cli = parse(&["--coverage", "lcov.info", "--format", "json"]).unwrap();
1967 assert_eq!(cli.output.format.len(), 1);
1968 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1969 assert!(cli.output.format[0].output.is_none());
1970 }
1971
1972 #[test]
1973 fn format_sarif() {
1974 let cli = parse(&["--coverage", "lcov.info", "--format", "sarif"]).unwrap();
1975 assert_eq!(cli.output.format.len(), 1);
1976 assert!(matches!(cli.output.format[0].format, FormatArg::Sarif));
1977 }
1978
1979 #[test]
1980 fn format_with_file_destination() {
1981 let cli = parse(&["--coverage", "lcov.info", "--format", "json:env.json"]).unwrap();
1982 assert_eq!(cli.output.format.len(), 1);
1983 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1984 assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
1985 }
1986
1987 #[test]
1988 fn format_multi_with_files() {
1989 let cli = parse(&[
1990 "--coverage",
1991 "lcov.info",
1992 "--format",
1993 "json:env.json,markdown:report.md",
1994 ])
1995 .unwrap();
1996 assert_eq!(cli.output.format.len(), 2);
1997 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1998 assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
1999 assert!(matches!(cli.output.format[1].format, FormatArg::Markdown));
2000 assert_eq!(
2001 cli.output.format[1].output,
2002 Some(PathBuf::from("report.md"))
2003 );
2004 }
2005
2006 #[test]
2007 fn format_multi_with_two_stdout_specs_rejected() {
2008 let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown"]).unwrap();
2009 let err = validate_display_flags(&cli).unwrap_err();
2010 let msg = err.to_string();
2011 assert!(msg.contains("multi-format"), "got: {msg}");
2012 assert!(msg.contains("stdout"), "got: {msg}");
2013 assert!(
2014 msg.contains("json"),
2015 "msg should name the stdout specs: {msg}"
2016 );
2017 assert!(
2018 msg.contains("markdown"),
2019 "msg should name the stdout specs: {msg}"
2020 );
2021 }
2022
2023 #[test]
2024 fn format_multi_with_single_stdout_plus_file_accepted() {
2025 let cli = parse(&[
2030 "--coverage",
2031 "lcov.info",
2032 "--format",
2033 "markdown:scorecard.md,github-annotations",
2034 ])
2035 .unwrap();
2036 assert!(validate_display_flags(&cli).is_ok());
2037 }
2038
2039 #[test]
2040 fn format_multi_with_three_stdout_specs_rejected() {
2041 let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown,csv"]).unwrap();
2042 let err = validate_display_flags(&cli).unwrap_err();
2043 let msg = err.to_string();
2044 assert!(
2045 msg.contains("at most one stdout"),
2046 "rejection must name the rule, got: {msg}"
2047 );
2048 }
2049
2050 #[test]
2051 fn format_empty_path_rejected() {
2052 let err = parse(&["--coverage", "lcov.info", "--format", "json:"]).unwrap_err();
2053 let msg = format!("{err}");
2054 assert!(msg.contains("empty file path"));
2055 }
2056
2057 #[test]
2058 fn custom_threshold() {
2059 let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.5"]).unwrap();
2060 assert_eq!(cli.output.threshold, Some(15.5));
2061 }
2062
2063 #[test]
2064 fn custom_src() {
2065 let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
2066 assert_eq!(cli.input.src, Some(PathBuf::from("crates/")));
2067 }
2068
2069 #[test]
2070 fn exclude_repeatable() {
2071 let cli = parse(&[
2072 "--coverage",
2073 "lcov.info",
2074 "--exclude",
2075 "tests/**",
2076 "--exclude",
2077 "benches/**",
2078 ])
2079 .unwrap();
2080 assert_eq!(cli.filter.exclude, vec!["tests/**", "benches/**"]);
2081 }
2082
2083 #[test]
2084 fn no_gitignore_flag() {
2085 let cli = parse(&["--coverage", "lcov.info", "--no-gitignore"]).unwrap();
2086 assert!(cli.filter.no_gitignore);
2087 }
2088
2089 #[test]
2090 fn only_failing_flag() {
2091 let cli = parse(&["--coverage", "lcov.info", "--only-failing"]).unwrap();
2092 assert!(cli.filter.only_failing);
2093 }
2094
2095 #[test]
2096 fn group_by_file_parses() {
2097 let cli = parse(&["--coverage", "lcov.info", "--group-by", "file"]).unwrap();
2098 assert!(matches!(cli.filter.group_by, Some(GroupByArg::File)));
2099 }
2100
2101 #[test]
2102 fn group_by_absence_is_none() {
2103 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2104 assert!(cli.filter.group_by.is_none());
2105 }
2106
2107 #[test]
2108 fn group_by_invalid_value_rejected() {
2109 let err = parse(&["--coverage", "lcov.info", "--group-by", "module"]).unwrap_err();
2110 let msg = err.to_string();
2111 assert!(msg.contains("invalid value"), "expected clap error: {msg}");
2112 assert!(
2113 msg.contains("--group-by") || msg.contains("module"),
2114 "error should attribute to --group-by: {msg}"
2115 );
2116 }
2117
2118 #[test]
2119 fn group_by_arg_to_domain_file() {
2120 let domain: GroupKey = GroupByArg::File.into();
2121 assert_eq!(domain, GroupKey::File);
2122 }
2123
2124 #[test]
2125 fn verbose_flag() {
2126 let cli = parse(&["--coverage", "lcov.info", "-v"]).unwrap();
2127 assert!(cli.display.verbose);
2128 }
2129
2130 #[test]
2131 fn quiet_flag() {
2132 let cli = parse(&["--coverage", "lcov.info", "-q"]).unwrap();
2133 assert!(cli.display.quiet);
2134 }
2135
2136 #[test]
2137 fn color_always() {
2138 let cli = parse(&["--coverage", "lcov.info", "--color", "always"]).unwrap();
2139 assert!(matches!(cli.display.color, ColorArg::Always));
2140 }
2141
2142 #[test]
2143 fn color_never() {
2144 let cli = parse(&["--coverage", "lcov.info", "--color", "never"]).unwrap();
2145 assert!(matches!(cli.display.color, ColorArg::Never));
2146 }
2147
2148 #[test]
2149 fn invalid_metric_rejected() {
2150 let err = parse(&["--coverage", "lcov.info", "--metric", "halstead"]).unwrap_err();
2151 assert!(err.to_string().contains("invalid value"));
2152 }
2153
2154 #[test]
2155 fn invalid_format_rejected() {
2156 let err = parse(&["--coverage", "lcov.info", "--format", "xml"]).unwrap_err();
2157 assert!(err.to_string().contains("invalid value"));
2158 }
2159
2160 #[test]
2161 fn metric_arg_to_domain_cognitive() {
2162 let domain: ComplexityMetric = MetricArg::Cognitive.into();
2163 assert_eq!(domain, ComplexityMetric::Cognitive);
2164 }
2165
2166 #[test]
2167 fn metric_arg_to_domain_cyclomatic() {
2168 let domain: ComplexityMetric = MetricArg::Cyclomatic.into();
2169 assert_eq!(domain, ComplexityMetric::Cyclomatic);
2170 }
2171
2172 #[test]
2173 fn validate_missing_coverage_file_uses_adapter_hint() {
2174 let err = validate_inputs(
2175 Path::new("nonexistent.info"),
2176 Path::new("src"),
2177 DEFAULT_THRESHOLD,
2178 "run `cargo llvm-cov --lcov --output-path lcov.info` first",
2179 )
2180 .unwrap_err();
2181 let msg = format!("{err:#}");
2182 assert!(msg.contains("coverage file not found"));
2183 assert!(msg.contains("cargo llvm-cov"));
2185 }
2186
2187 #[test]
2188 fn validate_missing_src_dir() {
2189 let err = validate_inputs(
2190 Path::new("Cargo.toml"),
2191 Path::new("nonexistent_dir"),
2192 DEFAULT_THRESHOLD,
2193 "test-hint",
2194 )
2195 .unwrap_err();
2196 let msg = format!("{err:#}");
2197 assert!(msg.contains("source directory not found"));
2198 }
2199
2200 #[test]
2201 fn validate_negative_threshold() {
2202 let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), -5.0, "test-hint")
2203 .unwrap_err();
2204 let msg = format!("{err:#}");
2205 assert!(msg.contains("threshold must be a finite positive number"));
2206 }
2207
2208 #[test]
2209 fn validate_zero_threshold() {
2210 let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), 0.0, "test-hint")
2211 .unwrap_err();
2212 let msg = format!("{err:#}");
2213 assert!(msg.contains("threshold must be a finite positive number"));
2214 }
2215
2216 #[test]
2217 fn validate_infinity_threshold() {
2218 let err = validate_inputs(
2219 Path::new("Cargo.toml"),
2220 Path::new("src"),
2221 f64::INFINITY,
2222 "test-hint",
2223 )
2224 .unwrap_err();
2225 let msg = format!("{err:#}");
2226 assert!(msg.contains("threshold must be a finite positive number"));
2227 }
2228
2229 #[test]
2230 fn validate_src_is_file_not_dir() {
2231 let err = validate_inputs(
2232 Path::new("Cargo.toml"),
2233 Path::new("Cargo.toml"),
2234 DEFAULT_THRESHOLD,
2235 "test-hint",
2236 )
2237 .unwrap_err();
2238 let msg = format!("{err:#}");
2239 assert!(msg.contains("source path is not a directory"));
2240 }
2241
2242 #[test]
2243 fn validate_coverage_is_dir_not_file() {
2244 let err = validate_inputs(
2245 Path::new("src"),
2246 Path::new("src"),
2247 DEFAULT_THRESHOLD,
2248 "test-hint",
2249 )
2250 .unwrap_err();
2251 let msg = format!("{err:#}");
2252 assert!(msg.contains("coverage path is not a file"));
2253 }
2254
2255 #[test]
2256 fn format_short_flag() {
2257 let cli = parse(&["--coverage", "lcov.info", "-f", "json"]).unwrap();
2258 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
2259 }
2260
2261 #[test]
2262 fn config_flag_accepts_path() {
2263 let cli = parse(&["--coverage", "lcov.info", "--config", "my-config.toml"]).unwrap();
2264 assert_eq!(cli.input.config, Some(PathBuf::from("my-config.toml")));
2265 }
2266
2267 #[test]
2268 fn config_flag_defaults_to_none() {
2269 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2270 assert_eq!(cli.input.config, None);
2271 }
2272
2273 #[test]
2274 fn view_flag_accepts_name() {
2275 let cli = parse(&["--coverage", "lcov.info", "--view", "ci"]).unwrap();
2276 assert_eq!(cli.input.view, Some("ci".to_string()));
2277 }
2278
2279 #[test]
2280 fn view_flag_defaults_to_none() {
2281 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2282 assert_eq!(cli.input.view, None);
2283 }
2284
2285 #[test]
2286 fn merge_threshold_cli_overrides_config() {
2287 let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
2288 let file_config = Some(FileConfig {
2289 threshold: Some(10.0),
2290 ..FileConfig::default()
2291 });
2292 let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2293 assert_eq!(config.global, 15.0);
2294 assert_eq!(display, 15.0);
2295 }
2296
2297 #[test]
2298 fn merge_threshold_uses_config_when_cli_default() {
2299 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2300 let file_config = Some(FileConfig {
2301 threshold: Some(12.0),
2302 ..FileConfig::default()
2303 });
2304 let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2305 assert_eq!(config.global, 12.0);
2306 assert_eq!(display, 12.0);
2307 }
2308
2309 #[test]
2310 fn merge_threshold_preserves_overrides() {
2311 use crate::domain::threshold::ThresholdOverride;
2312 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2313 let file_config = Some(FileConfig {
2314 threshold: Some(10.0),
2315 overrides: vec![ThresholdOverride {
2316 pattern: "domain/**".to_string(),
2317 threshold: 5.0,
2318 }],
2319 ..FileConfig::default()
2320 });
2321 let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2322 assert_eq!(config.overrides.len(), 1);
2323 assert_eq!(config.overrides[0].pattern, "domain/**");
2324 }
2325
2326 #[test]
2327 fn merge_threshold_no_config() {
2328 let cli = parse(&["--coverage", "lcov.info", "--threshold", "20.0"]).unwrap();
2329 let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2330 assert_eq!(config.global, 20.0);
2331 assert!(config.overrides.is_empty());
2332 assert_eq!(display, 20.0);
2333 }
2334
2335 #[test]
2336 fn merge_threshold_explicit_default_overrides_config() {
2337 let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
2340 let file_config = Some(FileConfig {
2341 threshold: Some(12.0),
2342 ..FileConfig::default()
2343 });
2344 let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2345 assert_eq!(
2346 config.global, 15.0,
2347 "explicit CLI default must override config"
2348 );
2349 assert_eq!(display, 15.0);
2350 }
2351
2352 #[test]
2353 fn merge_threshold_no_flag_default_is_metric_keyed() {
2354 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2359 let (cog, cog_disp) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2360 assert_eq!(cog.global, 15.0);
2361 assert_eq!(cog_disp, 15.0);
2362 let (cyc, cyc_disp) = merge_threshold(&cli, &None, ComplexityMetric::Cyclomatic);
2363 assert_eq!(cyc.global, 15.0);
2364 assert_eq!(cyc_disp, 15.0);
2365 }
2366
2367 #[test]
2368 fn merge_threshold_strict_lenient_are_metric_keyed() {
2369 let strict = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2373 assert_eq!(
2374 merge_threshold(&strict, &None, ComplexityMetric::Cognitive).1,
2375 8.0
2376 );
2377 assert_eq!(
2378 merge_threshold(&strict, &None, ComplexityMetric::Cyclomatic).1,
2379 8.0
2380 );
2381 let lenient = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2382 assert_eq!(
2383 merge_threshold(&lenient, &None, ComplexityMetric::Cognitive).1,
2384 25.0
2385 );
2386 assert_eq!(
2387 merge_threshold(&lenient, &None, ComplexityMetric::Cyclomatic).1,
2388 25.0
2389 );
2390 }
2391
2392 #[test]
2393 fn merge_exclude_combines_cli_and_config() {
2394 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2395 let file_config = Some(FileConfig {
2396 exclude: Some(vec!["benches/**".to_string()]),
2397 ..FileConfig::default()
2398 });
2399 let exclude = merge_exclude(&cli, &file_config, &fake_meta());
2400 assert_eq!(exclude, vec!["tests/**", "benches/**"]);
2401 }
2402
2403 #[test]
2404 fn merge_exclude_deduplicates() {
2405 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2406 let file_config = Some(FileConfig {
2407 exclude: Some(vec!["tests/**".to_string()]),
2408 ..FileConfig::default()
2409 });
2410 let exclude = merge_exclude(&cli, &file_config, &fake_meta());
2411 assert_eq!(exclude, vec!["tests/**"]);
2412 }
2413
2414 #[test]
2420 fn merge_exclude_prepends_forced_excludes_from_adapter_meta() {
2421 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2422 let file_config = Some(FileConfig {
2423 exclude: Some(vec!["benches/**".to_string()]),
2424 ..FileConfig::default()
2425 });
2426 let meta = AdapterMeta {
2427 forced_excludes: &["**/*.d.ts"],
2428 ..fake_meta()
2429 };
2430 let exclude = merge_exclude(&cli, &file_config, &meta);
2431 assert_eq!(exclude, vec!["**/*.d.ts", "tests/**", "benches/**"]);
2432 }
2433
2434 #[test]
2438 fn merge_exclude_with_empty_forced_excludes_matches_legacy_behavior() {
2439 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2440 let file_config = Some(FileConfig {
2441 exclude: Some(vec!["benches/**".to_string()]),
2442 ..FileConfig::default()
2443 });
2444 let meta = AdapterMeta {
2445 forced_excludes: &[],
2446 ..fake_meta()
2447 };
2448 let exclude = merge_exclude(&cli, &file_config, &meta);
2449 assert_eq!(exclude, vec!["tests/**", "benches/**"]);
2450 }
2451
2452 #[test]
2455 fn merge_exclude_forced_excludes_deduplicates_against_cli_and_config() {
2456 let cli = parse(&["--coverage", "lcov.info", "--exclude", "**/*.d.ts"]).unwrap();
2457 let file_config = Some(FileConfig {
2458 exclude: Some(vec!["**/*.d.ts".to_string(), "benches/**".to_string()]),
2459 ..FileConfig::default()
2460 });
2461 let meta = AdapterMeta {
2462 forced_excludes: &["**/*.d.ts"],
2463 ..fake_meta()
2464 };
2465 let exclude = merge_exclude(&cli, &file_config, &meta);
2466 assert_eq!(exclude, vec!["**/*.d.ts", "benches/**"]);
2467 }
2468
2469 #[test]
2472 fn diff_flag_accepts_ref() {
2473 let cli = parse(&["--coverage", "lcov.info", "--diff", "main"]).unwrap();
2474 assert_eq!(cli.filter.diff, Some("main".to_string()));
2475 }
2476
2477 #[test]
2478 fn diff_flag_defaults_to_none() {
2479 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2480 assert_eq!(cli.filter.diff, None);
2481 }
2482
2483 #[test]
2484 fn diff_flag_accepts_commit_sha() {
2485 let cli = parse(&["--coverage", "lcov.info", "--diff", "abc123"]).unwrap();
2486 assert_eq!(cli.filter.diff, Some("abc123".to_string()));
2487 }
2488
2489 #[test]
2490 fn diff_flag_accepts_head_tilde() {
2491 let cli = parse(&["--coverage", "lcov.info", "--diff", "HEAD~1"]).unwrap();
2492 assert_eq!(cli.filter.diff, Some("HEAD~1".to_string()));
2493 }
2494
2495 #[test]
2496 fn validate_diff_ref_rejects_empty_string() {
2497 let err = validate_diff_ref("").unwrap_err();
2498 let msg = format!("{err:#}");
2499 assert!(msg.contains("must not be empty"));
2500 }
2501
2502 #[test]
2503 fn validate_diff_ref_rejects_dash_prefix() {
2504 let err = validate_diff_ref("--malicious").unwrap_err();
2505 let msg = format!("{err:#}");
2506 assert!(msg.contains("invalid diff ref"));
2507 assert!(msg.contains("must not start with a dash"));
2508 }
2509
2510 #[test]
2511 fn validate_diff_ref_accepts_normal_ref() {
2512 assert!(validate_diff_ref("main").is_ok());
2513 assert!(validate_diff_ref("HEAD~1").is_ok());
2514 assert!(validate_diff_ref("abc123").is_ok());
2515 }
2516
2517 #[test]
2518 fn preflight_git_worktree_passes_in_git_repo() {
2519 let tmp = tempfile::tempdir().unwrap();
2523 let status = std::process::Command::new("git")
2524 .arg("init")
2525 .arg("--quiet")
2526 .current_dir(tmp.path())
2527 .status()
2528 .expect("git init");
2529 assert!(status.success(), "git init failed");
2530 assert!(preflight_git_worktree(tmp.path()).is_ok());
2531 }
2532
2533 #[test]
2534 fn breakdown_flag_parsed() {
2535 let cli = parse(&["--coverage", "lcov.info", "--breakdown"]).unwrap();
2536 assert!(cli.display.breakdown);
2537 }
2538
2539 #[test]
2540 fn breakdown_flag_default_false() {
2541 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2542 assert!(!cli.display.breakdown);
2543 }
2544
2545 #[test]
2546 fn explain_flag_parsed() {
2547 let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
2548 assert!(cli.display.explain);
2549 }
2550
2551 #[test]
2552 fn explain_flag_default_false() {
2553 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2554 assert!(!cli.display.explain);
2555 }
2556
2557 #[test]
2558 fn explain_requires_breakdown_for_table_output() {
2559 let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
2560 let err = validate_display_flags(&cli).unwrap_err();
2561 let msg = err.to_string();
2562 assert!(msg.contains("--breakdown"));
2563 assert!(msg.contains("--explain"));
2564 }
2565
2566 #[test]
2567 fn explain_allowed_for_json_output() {
2568 let cli = parse(&["--coverage", "lcov.info", "--format", "json", "--explain"]).unwrap();
2569 assert!(validate_display_flags(&cli).is_ok());
2570 }
2571
2572 #[test]
2573 fn color_overrides_set_global_state() {
2574 apply_color(ColorArg::Never);
2578 assert!(!colored::control::SHOULD_COLORIZE.should_colorize());
2579
2580 apply_color(ColorArg::Always);
2581 assert!(colored::control::SHOULD_COLORIZE.should_colorize());
2582
2583 apply_color(ColorArg::Auto);
2584 }
2585
2586 const TEST_COVERAGE_HINT: &str =
2592 "ensure tests ran with coverage enabled (test-tool's `--coverage` flag)";
2593
2594 struct StubCoveragePort {
2598 validate_result: Result<(), String>,
2599 }
2600
2601 impl CoveragePort for StubCoveragePort {
2602 type Diagnostic = crate::test_strategies::DummyParseDiagnostic;
2603
2604 fn parse(
2605 &self,
2606 _path: &std::path::Path,
2607 ) -> Result<crate::ports::ParseOutput<Self::Diagnostic>, crate::domain::types::CrapError>
2608 {
2609 unreachable!("preflight tests never invoke parse")
2610 }
2611
2612 fn validate(&self, _path: &std::path::Path) -> Result<(), String> {
2613 self.validate_result.clone()
2614 }
2615 }
2616
2617 fn stub_ok() -> StubCoveragePort {
2618 StubCoveragePort {
2619 validate_result: Ok(()),
2620 }
2621 }
2622
2623 fn stub_err(reason: &str) -> StubCoveragePort {
2624 StubCoveragePort {
2625 validate_result: Err(reason.to_string()),
2626 }
2627 }
2628
2629 #[test]
2630 fn preflight_surfaces_hint_when_adapter_reports_no_data() {
2631 let dir = tempfile::tempdir().unwrap();
2632 let cov = dir.path().join("empty.info");
2633 std::fs::write(&cov, "").unwrap();
2634
2635 let err =
2636 check_coverage_has_data(&cov, &stub_err("no records"), TEST_COVERAGE_HINT).unwrap_err();
2637 let msg = format!("{err:#}");
2638 assert!(msg.contains("no coverage data found"));
2639 assert!(msg.contains("no records"), "expected reason in msg: {msg}");
2643 assert!(msg.contains(TEST_COVERAGE_HINT));
2644 }
2645
2646 #[test]
2647 fn preflight_passes_when_adapter_accepts_data() {
2648 let dir = tempfile::tempdir().unwrap();
2649 let cov = dir.path().join("ok.info");
2650 std::fs::write(&cov, "any contents — adapter decides").unwrap();
2651
2652 assert!(check_coverage_has_data(&cov, &stub_ok(), TEST_COVERAGE_HINT).is_ok());
2653 }
2654
2655 #[test]
2658 fn strict_flag_parses() {
2659 let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2660 assert!(cli.output.strict);
2661 }
2662
2663 #[test]
2664 fn lenient_flag_parses() {
2665 let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2666 assert!(cli.output.lenient);
2667 }
2668
2669 #[test]
2670 fn strict_and_threshold_mutually_exclusive() {
2671 parse(&["--coverage", "lcov.info", "--strict", "--threshold", "20"]).unwrap_err();
2672 }
2673
2674 #[test]
2675 fn strict_and_lenient_mutually_exclusive() {
2676 parse(&["--coverage", "lcov.info", "--strict", "--lenient"]).unwrap_err();
2677 }
2678
2679 #[test]
2680 fn merge_threshold_strict_flag() {
2681 use crate::domain::threshold::STRICT_THRESHOLD;
2682 let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2683 let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2684 assert_eq!(config.global, STRICT_THRESHOLD);
2685 assert_eq!(display, STRICT_THRESHOLD);
2686 }
2687
2688 #[test]
2689 fn merge_threshold_lenient_flag() {
2690 use crate::domain::threshold::LENIENT_THRESHOLD;
2691 let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2692 let (config, display) = merge_threshold(&cli, &None, ComplexityMetric::Cognitive);
2693 assert_eq!(config.global, LENIENT_THRESHOLD);
2694 assert_eq!(display, LENIENT_THRESHOLD);
2695 }
2696
2697 #[test]
2698 fn merge_threshold_toml_preset_used_when_no_cli_flag() {
2699 use crate::domain::threshold::{STRICT_THRESHOLD, ThresholdPreset};
2700 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2701 let file_config = Some(FileConfig {
2702 preset: Some(ThresholdPreset::Strict),
2703 ..FileConfig::default()
2704 });
2705 let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2706 assert_eq!(config.global, STRICT_THRESHOLD);
2707 }
2708
2709 #[test]
2710 fn merge_threshold_config_literal_overrides_config_preset() {
2711 use crate::domain::threshold::ThresholdPreset;
2717 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2718 let file_config = Some(FileConfig {
2719 preset: Some(ThresholdPreset::Strict),
2720 threshold: Some(99.0),
2721 ..FileConfig::default()
2722 });
2723 let (config, display) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2724 assert_eq!(config.global, 99.0);
2725 assert_eq!(display, 99.0);
2726 }
2727
2728 #[test]
2729 fn merge_threshold_cli_threshold_overrides_toml_preset() {
2730 use crate::domain::threshold::ThresholdPreset;
2731 let cli = parse(&["--coverage", "lcov.info", "--threshold", "50.0"]).unwrap();
2732 let file_config = Some(FileConfig {
2733 preset: Some(ThresholdPreset::Strict),
2734 ..FileConfig::default()
2735 });
2736 let (config, _) = merge_threshold(&cli, &file_config, ComplexityMetric::Cognitive);
2737 assert_eq!(config.global, 50.0);
2738 }
2739
2740 #[test]
2743 fn zero_coverage_warn_triggers_above_50_percent() {
2744 assert!(majority_zero_coverage(10, 6));
2745 assert!(majority_zero_coverage(1, 1));
2746 assert!(majority_zero_coverage(3, 2));
2747 }
2748
2749 #[test]
2750 fn zero_coverage_warn_does_not_trigger_at_exactly_50_percent() {
2751 assert!(!majority_zero_coverage(10, 5));
2752 assert!(!majority_zero_coverage(2, 1));
2753 }
2754
2755 #[test]
2756 fn zero_coverage_warn_does_not_trigger_when_no_files() {
2757 assert!(!majority_zero_coverage(0, 0));
2758 }
2759
2760 #[test]
2763 fn merge_effective_inputs_default_src() {
2764 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2765 let inputs = merge_effective_inputs(&cli, &None, &fake_meta());
2766 assert_eq!(inputs.src, PathBuf::from("src"));
2767 }
2768
2769 #[test]
2770 fn merge_effective_inputs_cli_src_wins_over_config() {
2771 let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
2772 let file_config = Some(FileConfig {
2773 src: Some(PathBuf::from("from-config/")),
2774 ..FileConfig::default()
2775 });
2776 let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2777 assert_eq!(inputs.src, PathBuf::from("crates/"));
2778 }
2779
2780 #[test]
2781 fn merge_effective_inputs_config_src_when_cli_absent() {
2782 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2783 let file_config = Some(FileConfig {
2784 src: Some(PathBuf::from("from-config/")),
2785 ..FileConfig::default()
2786 });
2787 let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2788 assert_eq!(inputs.src, PathBuf::from("from-config/"));
2789 }
2790
2791 #[test]
2792 fn merge_effective_inputs_uses_adapter_default_metric_cognitive() {
2793 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2799 let meta = AdapterMeta {
2800 default_metric: ComplexityMetric::Cognitive,
2801 ..fake_meta()
2802 };
2803 let inputs = merge_effective_inputs(&cli, &None, &meta);
2804 assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
2805 }
2806
2807 #[test]
2808 fn merge_effective_inputs_uses_adapter_default_metric_cyclomatic() {
2809 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2813 let meta = AdapterMeta {
2814 default_metric: ComplexityMetric::Cyclomatic,
2815 ..fake_meta()
2816 };
2817 let inputs = merge_effective_inputs(&cli, &None, &meta);
2818 assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2819 }
2820
2821 #[test]
2822 fn merge_effective_inputs_cli_metric_overrides_config() {
2823 let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
2824 let file_config = Some(FileConfig {
2825 metric: Some(ComplexityMetric::Cognitive),
2826 ..FileConfig::default()
2827 });
2828 let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2829 assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2830 }
2831
2832 #[test]
2833 fn merge_effective_inputs_default_threshold_follows_adapter_metric_cognitive() {
2834 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2838 let meta = AdapterMeta {
2839 default_metric: ComplexityMetric::Cognitive,
2840 ..fake_meta()
2841 };
2842 let inputs = merge_effective_inputs(&cli, &None, &meta);
2843 assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
2844 assert_eq!(inputs.threshold, 15.0);
2845 }
2846
2847 #[test]
2848 fn merge_effective_inputs_default_threshold_follows_adapter_metric_cyclomatic() {
2849 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2854 let meta = AdapterMeta {
2855 default_metric: ComplexityMetric::Cyclomatic,
2856 ..fake_meta()
2857 };
2858 let inputs = merge_effective_inputs(&cli, &None, &meta);
2859 assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2860 assert_eq!(inputs.threshold, 15.0);
2861 }
2862
2863 #[test]
2864 fn merge_effective_inputs_exclude_combines_cli_and_config() {
2865 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2866 let file_config = Some(FileConfig {
2867 exclude: Some(vec!["benches/**".to_string()]),
2868 ..FileConfig::default()
2869 });
2870 let inputs = merge_effective_inputs(&cli, &file_config, &fake_meta());
2871 assert_eq!(inputs.exclude, vec!["tests/**", "benches/**"]);
2872 }
2873
2874 #[test]
2875 fn merge_effective_inputs_config_metric_wins_over_adapter_default() {
2876 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2880 let file_config = Some(FileConfig {
2881 metric: Some(ComplexityMetric::Cyclomatic),
2882 ..FileConfig::default()
2883 });
2884 let meta = AdapterMeta {
2885 default_metric: ComplexityMetric::Cognitive,
2886 ..fake_meta()
2887 };
2888 let inputs = merge_effective_inputs(&cli, &file_config, &meta);
2889 assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2890 }
2891
2892 #[test]
2900 fn compute_exit_code_passing_no_delta() {
2901 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2902 assert!(compute_exit_code::<
2903 crate::test_strategies::DummyParseDiagnostic,
2904 >(&cli, true, None));
2905 }
2906
2907 #[test]
2908 fn compute_exit_code_failing_no_delta() {
2909 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2910 assert!(!compute_exit_code::<
2911 crate::test_strategies::DummyParseDiagnostic,
2912 >(&cli, false, None));
2913 }
2914
2915 #[test]
2916 fn compute_exit_code_no_fail_overrides_failure() {
2917 let cli = parse(&["--coverage", "lcov.info", "--no-fail"]).unwrap();
2918 assert!(compute_exit_code::<
2919 crate::test_strategies::DummyParseDiagnostic,
2920 >(&cli, false, None));
2921 }
2922
2923 #[test]
2924 fn compute_exit_code_delta_gate_without_runtime_baseline_treats_delta_as_passed() {
2925 let cli = parse(&[
2933 "--coverage",
2934 "lcov.info",
2935 "--delta-gate",
2936 "--baseline",
2937 "/dev/null",
2938 ])
2939 .unwrap();
2940 assert!(compute_exit_code::<
2941 crate::test_strategies::DummyParseDiagnostic,
2942 >(&cli, true, None));
2943 }
2944
2945 #[test]
2946 fn compute_exit_code_no_fail_with_delta_gate() {
2947 let cli = parse(&[
2950 "--coverage",
2951 "lcov.info",
2952 "--delta-gate",
2953 "--baseline",
2954 "/dev/null",
2955 "--no-fail",
2956 ])
2957 .unwrap();
2958 assert!(compute_exit_code::<
2959 crate::test_strategies::DummyParseDiagnostic,
2960 >(&cli, false, None));
2961 }
2962
2963 fn fake_meta() -> AdapterMeta {
2966 AdapterMeta {
2967 tool_name: "fake-adapter",
2968 tool_version: "9.9.9",
2969 long_version: "9.9.9 (test 2099-01-01)",
2970 about: "Fake adapter for tests",
2971 long_about: "Fake adapter for tests — verifies AdapterMeta plumbing without binding crap-core to any real adapter.",
2972 after_help: "",
2973 coverage_hint: "no coverage tool — fake adapter",
2974 extensions: &["fake"],
2975 tool_info_uri: "https://example.invalid/fake-adapter",
2976 rule_help_uri: "https://example.invalid/fake-adapter#rules",
2977 config_file_name: "fake-adapter.toml",
2978 default_excludes: &["fixtures/**"],
2979 forced_excludes: &[],
2984 default_metric: ComplexityMetric::Cognitive,
2989 }
2990 }
2991
2992 #[test]
2993 fn adapter_meta_extensions_owned_roundtrips_to_owned_strings() {
2994 let meta = AdapterMeta {
2995 extensions: &["ts", "tsx", "js"],
2996 ..fake_meta()
2997 };
2998 let owned = meta.extensions_owned();
2999 assert_eq!(
3000 owned,
3001 vec!["ts".to_string(), "tsx".to_string(), "js".to_string()]
3002 );
3003 let back: Vec<&str> = owned.iter().map(String::as_str).collect();
3005 assert_eq!(back, &["ts", "tsx", "js"]);
3006 }
3007
3008 #[test]
3009 fn adapter_meta_extensions_owned_handles_empty_slice() {
3010 let meta = AdapterMeta {
3013 extensions: &[],
3014 ..fake_meta()
3015 };
3016 assert!(meta.extensions_owned().is_empty());
3017 }
3018
3019 #[test]
3020 #[should_panic(expected = "tool_name must not be empty")]
3021 fn adapter_meta_debug_assert_trips_on_empty_tool_name() {
3022 let meta = AdapterMeta {
3023 tool_name: "",
3024 ..fake_meta()
3025 };
3026 meta.debug_assert_required_fields();
3027 }
3028
3029 #[test]
3030 #[should_panic(expected = "config_file_name must not be empty")]
3031 fn adapter_meta_debug_assert_trips_on_empty_config_file_name() {
3032 let meta = AdapterMeta {
3033 config_file_name: "",
3034 ..fake_meta()
3035 };
3036 meta.debug_assert_required_fields();
3037 }
3038
3039 #[test]
3040 fn adapter_meta_debug_assert_passes_on_all_fields_set() {
3041 fake_meta().debug_assert_required_fields();
3044 }
3045}