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::{
27 DEFAULT_THRESHOLD, LENIENT_THRESHOLD, STRICT_THRESHOLD, ThresholdConfig, is_valid_threshold,
28};
29use crate::domain::types::{AnalysisDiagnostics, ComplexityMetric};
30use crate::domain::view::{self, GroupKey, SortKey};
31use crate::ports::{ComplexityPort, CoveragePort, ParseDiagnostic};
32
33mod delta_args;
34mod view_args;
35
36#[derive(Debug, Clone, Copy, ValueEnum)]
40pub enum MetricArg {
41 Cognitive,
43 Cyclomatic,
45}
46
47impl From<MetricArg> for ComplexityMetric {
48 fn from(arg: MetricArg) -> Self {
49 match arg {
50 MetricArg::Cognitive => ComplexityMetric::Cognitive,
51 MetricArg::Cyclomatic => ComplexityMetric::Cyclomatic,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, ValueEnum)]
58pub enum FormatArg {
59 Table,
61 Json,
63 Markdown,
65 Csv,
67 Sarif,
69 Advice,
71 ScorecardRow,
74 Html,
78}
79
80#[derive(Debug, Clone)]
86pub struct FormatSpec {
87 pub format: FormatArg,
88 pub output: Option<PathBuf>,
89}
90
91impl std::str::FromStr for FormatSpec {
92 type Err = String;
93
94 fn from_str(spec: &str) -> Result<Self, Self::Err> {
95 let (fmt_str, output) = match spec.split_once(':') {
96 Some((f, path)) if !path.is_empty() => (f, Some(PathBuf::from(path))),
97 Some((_, _)) => return Err(format!("empty file path in `--format {spec}`")),
98 None => (spec, None),
99 };
100 let format = FormatArg::from_str(fmt_str, true)
101 .map_err(|e| format!("invalid format `{fmt_str}`: {e}"))?;
102 Ok(FormatSpec { format, output })
103 }
104}
105
106fn parse_format_spec(s: &str) -> Result<FormatSpec, String> {
108 s.parse()
109}
110
111#[derive(Debug, Clone, Copy, ValueEnum)]
117pub enum SortKeyArg {
118 Crap,
120 Coverage,
122 Complexity,
124 Path,
126}
127
128impl From<SortKeyArg> for SortKey {
129 fn from(arg: SortKeyArg) -> Self {
130 match arg {
131 SortKeyArg::Crap => SortKey::Crap,
132 SortKeyArg::Coverage => SortKey::Coverage,
133 SortKeyArg::Complexity => SortKey::Complexity,
134 SortKeyArg::Path => SortKey::Path,
135 }
136 }
137}
138
139impl From<SortKey> for SortKeyArg {
150 fn from(key: SortKey) -> Self {
151 match key {
152 SortKey::Crap => SortKeyArg::Crap,
153 SortKey::Coverage => SortKeyArg::Coverage,
154 SortKey::Complexity => SortKeyArg::Complexity,
155 SortKey::Path => SortKeyArg::Path,
156 }
157 }
158}
159
160#[derive(Debug, Clone, Copy, ValueEnum)]
165pub enum GroupByArg {
166 File,
168}
169
170impl From<GroupByArg> for GroupKey {
171 fn from(arg: GroupByArg) -> Self {
172 match arg {
173 GroupByArg::File => GroupKey::File,
174 }
175 }
176}
177
178impl From<GroupKey> for GroupByArg {
181 fn from(key: GroupKey) -> Self {
182 match key {
183 GroupKey::File => GroupByArg::File,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Copy, ValueEnum)]
190pub enum DeltaSortKeyArg {
191 ScoreDelta,
193 CurrentCrap,
195 BaselineCrap,
197 Path,
199}
200
201impl From<DeltaSortKeyArg> for crate::domain::delta::DeltaSortKey {
202 fn from(arg: DeltaSortKeyArg) -> Self {
203 use crate::domain::delta::DeltaSortKey;
204 match arg {
205 DeltaSortKeyArg::ScoreDelta => DeltaSortKey::ScoreDelta,
206 DeltaSortKeyArg::CurrentCrap => DeltaSortKey::CurrentCrap,
207 DeltaSortKeyArg::BaselineCrap => DeltaSortKey::BaselineCrap,
208 DeltaSortKeyArg::Path => DeltaSortKey::Path,
209 }
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
215pub enum DeltaKindArg {
216 Added,
217 Removed,
218 Modified,
219}
220
221impl From<DeltaKindArg> for crate::domain::delta::ChangeKind {
222 fn from(arg: DeltaKindArg) -> Self {
223 use crate::domain::delta::ChangeKind;
224 match arg {
225 DeltaKindArg::Added => ChangeKind::Added,
226 DeltaKindArg::Removed => ChangeKind::Removed,
227 DeltaKindArg::Modified => ChangeKind::Modified,
228 }
229 }
230}
231
232#[derive(Debug, Clone, Copy, Default, ValueEnum)]
234pub enum ColorArg {
235 #[default]
237 Auto,
238 Always,
240 Never,
242}
243
244#[derive(Debug, Clone, Copy, ValueEnum)]
248pub enum ShellArg {
249 Bash,
250 Zsh,
251 Fish,
252 Powershell,
253 Elvish,
254 Nushell,
255}
256
257#[derive(Debug, Subcommand)]
260pub enum Command {
261 Completions {
263 #[arg(value_enum)]
264 shell: ShellArg,
265 },
266}
267
268#[derive(Debug, Args)]
269#[command(next_help_heading = "Input")]
270pub struct InputArgs {
271 #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
274 pub coverage: Option<PathBuf>,
275
276 #[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)]
278 pub src: Option<PathBuf>,
279
280 #[arg(long, value_enum)]
282 pub metric: Option<MetricArg>,
283
284 #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
286 pub config: Option<PathBuf>,
287
288 #[arg(long, value_name = "NAME")]
297 pub view: Option<String>,
298
299 #[arg(long, value_name = "FILE", value_hint = ValueHint::FilePath)]
312 pub baseline: Option<PathBuf>,
313}
314
315#[derive(Debug, Args)]
316#[command(next_help_heading = "Output")]
317pub struct OutputArgs {
318 #[arg(
327 short,
328 long,
329 value_delimiter = ',',
330 default_value = "table",
331 value_parser = parse_format_spec
332 )]
333 pub format: Vec<FormatSpec>,
334
335 #[arg(long, allow_hyphen_values = true, group = "threshold_select")]
339 pub threshold: Option<f64>,
340
341 #[arg(long, group = "threshold_select")]
343 pub strict: bool,
344
345 #[arg(long, group = "threshold_select")]
347 pub lenient: bool,
348
349 #[arg(long)]
359 pub no_fail: bool,
360
361 #[arg(long, requires = "baseline")]
372 pub delta_gate: bool,
373
374 #[arg(long)]
382 pub minimal_view: bool,
383}
384
385#[derive(Debug, Args)]
386#[command(next_help_heading = "Filtering")]
387pub struct FilterArgs {
388 #[arg(long, action = clap::ArgAction::Append)]
394 pub exclude: Vec<String>,
395
396 #[arg(long)]
401 pub no_gitignore: bool,
402
403 #[arg(long, value_name = "REF")]
408 pub diff: Option<String>,
409
410 #[arg(long)]
418 pub only_failing: bool,
419
420 #[arg(long, allow_hyphen_values = true, value_name = "PCT")]
426 pub min_coverage: Option<f64>,
427
428 #[arg(long, allow_hyphen_values = true, value_name = "PCT")]
430 pub max_coverage: Option<f64>,
431
432 #[arg(long, value_enum, value_name = "KEY")]
442 pub sort_by: Option<SortKeyArg>,
443
444 #[arg(long, allow_hyphen_values = true, value_name = "N")]
453 pub top: Option<u32>,
454
455 #[arg(long, value_enum, value_name = "KEY")]
465 pub group_by: Option<GroupByArg>,
466
467 #[arg(long, allow_hyphen_values = true, value_name = "N")]
475 pub delta_top: Option<u32>,
476
477 #[arg(long, value_enum, value_name = "KEY")]
485 pub delta_sort: Option<DeltaSortKeyArg>,
486
487 #[arg(long, value_delimiter = ',', value_name = "KINDS")]
490 pub delta_only: Vec<DeltaKindArg>,
491}
492
493#[derive(Debug, Args)]
494#[command(next_help_heading = "Display")]
495pub struct DisplayArgs {
496 #[arg(long, value_enum, default_value_t = ColorArg::Auto)]
498 pub color: ColorArg,
499
500 #[arg(short, long)]
502 pub verbose: bool,
503
504 #[arg(short, long)]
506 pub quiet: bool,
507
508 #[arg(long)]
512 pub breakdown: bool,
513
514 #[arg(long)]
518 pub explain: bool,
519
520 #[arg(long)]
528 pub md_full_table: bool,
529
530 #[arg(long, value_name = "N", default_value_t = 10)]
536 pub md_top: usize,
537}
538
539#[derive(Debug, Parser)]
558#[command(
559 version,
560 author,
561 about = "CRAP score analyzer for Rust",
562 long_about = "CRAP (Change Risk Anti-Patterns) score analyzer for Rust codebases.\n\n\
563 Combines complexity analysis (via syn) with line coverage data \
564 (LCOV from cargo-llvm-cov) to identify functions that are both \
565 complex and under-tested.\n\n\
566 Default metric is cognitive complexity (not cyclomatic), which \
567 better captures Rust idioms like match arms and nested control flow.",
568 after_help = "\
569EXAMPLES:
570 crap4rs --coverage lcov.info
571 crap4rs --coverage lcov.info --threshold 15 --metric cyclomatic
572 crap4rs --coverage lcov.info --format json | jq '.functions[] | select(.exceeds)'
573 crap4rs --coverage lcov.info --only-failing
574 crap4rs --coverage lcov.info --exclude \"tests/**\" --exclude \"benches/**\"
575
576INVESTIGATION PATTERNS:
577 # First-run scan: keep the report short
578 crap4rs --coverage lcov.info --top 20
579
580 # Worst partially-covered functions, sorted by coverage ascending,
581 # never fail the build — useful when investigating an untested codebase
582 crap4rs --coverage lcov.info --min-coverage 1 --max-coverage 90 --sort-by coverage --top 10 --no-fail
583
584 # Saved view preset: bake a flag set under [views.ci] in crap4rs.toml,
585 # then invoke it by name. CLI flags override preset values.
586 crap4rs --coverage lcov.info --view ci
587
588 # GitHub Code Scanning: emit SARIF and let upload-sarif annotate the PR
589 # diff inline. Use --no-fail so the gate exit code doesn't skip the
590 # upload step on regressions.
591 crap4rs --coverage lcov.info --format sarif --no-fail > crap.sarif
592
593COMPARING TWO ANALYSES (issue #81):
594 # Capture a baseline (e.g., from main):
595 crap4rs --coverage lcov.info --format json > baseline.json
596
597 # Then compare the working tree to it (informational by default):
598 crap4rs --coverage lcov.info --baseline baseline.json
599
600 # CI usage: fail the build when new threshold violations land
601 crap4rs --coverage lcov.info --baseline baseline.json --delta-gate
602
603 # PR-comment scorecard (markdown — drop into the comment body verbatim)
604 crap4rs --coverage lcov.info --baseline baseline.json --format markdown"
605)]
606pub struct Cli {
607 #[command(flatten)]
608 pub input: InputArgs,
609
610 #[command(flatten)]
611 pub output: OutputArgs,
612
613 #[command(flatten)]
614 pub filter: FilterArgs,
615
616 #[command(flatten)]
617 pub display: DisplayArgs,
618
619 #[command(subcommand)]
620 pub command: Option<Command>,
621}
622
623pub fn parse_args(tool_version: &str, long_version: &str) -> Cli {
645 let cmd = build_command(tool_version, long_version);
646 let matches = cmd.get_matches();
647 Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
648}
649
650fn current_bin_name() -> String {
658 std::env::args()
659 .next()
660 .and_then(|first| {
661 std::path::PathBuf::from(first)
667 .file_stem()
668 .map(|os| os.to_string_lossy().into_owned())
669 })
670 .unwrap_or_else(|| "crap4rs".to_string())
671}
672
673fn build_command(tool_version: &str, long_version: &str) -> clap::Command {
678 let bin_static: &'static str = Box::leak(current_bin_name().into_boxed_str());
679 let version_static: &'static str = Box::leak(tool_version.to_string().into_boxed_str());
680 let long_version_static: &'static str = Box::leak(long_version.to_string().into_boxed_str());
681 Cli::command()
682 .name(bin_static)
683 .bin_name(bin_static)
684 .version(version_static)
685 .long_version(long_version_static)
686}
687
688pub fn run<P: ParseDiagnostic + std::fmt::Display>(
698 cli: Cli,
699 complexity: &dyn ComplexityPort,
700 coverage: &dyn CoveragePort<Diagnostic = P>,
701 tool_version: &str,
702) -> ExitCode {
703 match run_inner(cli, complexity, coverage, tool_version) {
704 Ok(true) => ExitCode::from(0),
705 Ok(false) => ExitCode::from(1),
706 Err(e) => {
707 eprintln!("error: {e:#}");
708 ExitCode::from(2)
709 }
710 }
711}
712
713fn run_inner<P: ParseDiagnostic + std::fmt::Display>(
714 mut cli: Cli,
715 complexity: &dyn ComplexityPort,
716 coverage: &dyn CoveragePort<Diagnostic = P>,
717 tool_version: &str,
718) -> Result<bool> {
719 if let Some(Command::Completions { shell }) = cli.command {
720 emit_completions(shell, ¤t_bin_name());
721 return Ok(true);
722 }
723
724 let prep = prepare_pipeline(&mut cli, complexity, coverage)?;
725
726 let spec = view_args::build_view_spec(&cli);
731 let view = view::apply(&prep.analysis.result, spec);
732
733 let delta_spec = delta_args::build_delta_view_spec(&cli);
739 let delta_view: Option<DeltaView<'_>> = prep
740 .delta_state
741 .as_ref()
742 .map(move |s| delta::apply(&s.delta, delta_spec));
743
744 if !cli.display.quiet {
745 print_formatted_output(
746 &cli,
747 &view,
748 delta_view.as_ref(),
749 prep.delta_state.as_ref(),
750 &prep.analysis,
751 &prep.inputs,
752 tool_version,
753 )?;
754 }
755
756 Ok(compute_exit_code(
766 &cli,
767 prep.analysis.result.passed,
768 prep.delta_state.as_ref(),
769 ))
770}
771
772struct EffectiveInputs {
778 src: PathBuf,
779 metric: ComplexityMetric,
780 threshold_config: ThresholdConfig,
781 threshold: f64,
782 exclude: Vec<String>,
783}
784
785struct PipelinePrep<P: ParseDiagnostic> {
791 inputs: EffectiveInputs,
792 analysis: AnalysisOutput<P>,
793 delta_state: Option<DeltaState<P>>,
794}
795
796fn merge_effective_inputs(cli: &Cli, file_config: &Option<FileConfig>) -> EffectiveInputs {
797 let src = cli
798 .input
799 .src
800 .clone()
801 .or_else(|| file_config.as_ref().and_then(|c| c.src.clone()))
802 .unwrap_or_else(|| PathBuf::from("src"));
803 let metric: ComplexityMetric = cli
804 .input
805 .metric
806 .map(Into::into)
807 .or_else(|| file_config.as_ref().and_then(|c| c.metric))
808 .unwrap_or_default();
809 let (threshold_config, threshold) = merge_threshold(cli, file_config);
810 let exclude = merge_exclude(cli, file_config);
811 EffectiveInputs {
812 src,
813 metric,
814 threshold_config,
815 threshold,
816 exclude,
817 }
818}
819
820fn validate_runtime_inputs<'a>(cli: &'a Cli, inputs: &EffectiveInputs) -> Result<&'a Path> {
821 let Some(coverage_path) = cli.input.coverage.as_deref() else {
825 bail!(
826 "--coverage <FILE> is required (run `crap4rs --help` for usage, or `crap4rs completions <SHELL>` for shell completion scripts)"
827 );
828 };
829
830 validate_inputs(coverage_path, &inputs.src, inputs.threshold)?;
831 preflight_checks(coverage_path, &inputs.src)?;
832
833 if let Some(diff_ref) = cli.filter.diff.as_deref() {
834 validate_diff_ref(diff_ref)?;
835 preflight_git_worktree(&inputs.src)?;
836 }
837
838 Ok(coverage_path)
839}
840
841fn build_analyze_options(cli: &Cli, inputs: &EffectiveInputs, coverage: &Path) -> AnalyzeOptions {
842 AnalyzeOptions {
843 src: inputs.src.clone(),
844 coverage: coverage.to_path_buf(),
845 threshold_config: inputs.threshold_config.clone(),
846 metric: inputs.metric,
847 exclude: inputs.exclude.clone(),
848 respect_gitignore: !cli.filter.no_gitignore,
849 diff_ref: cli.filter.diff.clone(),
850 compute_diagnostics: cli
851 .output
852 .format
853 .iter()
854 .any(|s| matches!(s.format, FormatArg::Advice | FormatArg::Sarif)),
855 ..AnalyzeOptions::default()
856 }
857}
858
859fn apply_diagnostics<P: ParseDiagnostic + std::fmt::Display>(
860 cli: &Cli,
861 diagnostics: &AnalysisDiagnostics<P>,
862) {
863 warn_if_issues(diagnostics);
865 if cli.display.verbose {
866 print_diagnostics(diagnostics);
867 }
868}
869
870fn prepare_pipeline<P: ParseDiagnostic + std::fmt::Display>(
874 cli: &mut Cli,
875 complexity: &dyn ComplexityPort,
876 coverage: &dyn CoveragePort<Diagnostic = P>,
877) -> Result<PipelinePrep<P>> {
878 validate_display_flags(cli)?;
879 apply_color(cli.display.color);
880
881 let file_config = load_file_config(cli)?;
883
884 view_args::resolve_view_preset(cli, file_config.as_ref())?;
889 view_args::validate_view_args(cli)?;
890
891 let inputs = merge_effective_inputs(cli, &file_config);
892 let coverage_path = validate_runtime_inputs(cli, &inputs)?;
893 let options = build_analyze_options(cli, &inputs, coverage_path);
894
895 let analysis = crate::core::analyze(&options, complexity, coverage)?;
896 apply_diagnostics(cli, &analysis.diagnostics);
897
898 let delta_state = load_delta_state(cli, &analysis.result)?;
903
904 Ok(PipelinePrep {
905 inputs,
906 analysis,
907 delta_state,
908 })
909}
910
911fn format_as_json<P: ParseDiagnostic>(
914 cli: &Cli,
915 view: &view::AnalysisView<'_>,
916 delta_view: Option<&DeltaView<'_>>,
917 delta_state: Option<&DeltaState<P>>,
918 analysis: &AnalysisOutput<P>,
919 inputs: &EffectiveInputs,
920 tool_version: &str,
921) -> Result<String> {
922 let delta_ctx = delta_state.zip(delta_view).map(|(s, dv)| DeltaContext {
923 view: dv,
924 baseline_tool_version: &s.snapshot.tool_version,
925 baseline_timestamp: &s.snapshot.timestamp,
926 baseline_diagnostics: s.snapshot.diagnostics.as_ref(),
927 });
928 let config = reporters::json::JsonConfig {
929 tool_version: tool_version.to_string(),
930 metric: inputs.metric,
931 threshold: inputs.threshold,
932 timestamp: now_unix_epoch(),
933 diagnostics: cli.display.verbose.then_some(&analysis.diagnostics),
934 diff_ref: cli.filter.diff.as_deref(),
935 minimal_view: cli.output.minimal_view,
936 delta: delta_ctx,
937 };
938 reporters::json::format_json(view, &config).map_err(Into::into)
939}
940
941fn format_as_scorecard_row<P: ParseDiagnostic>(
946 delta_state: Option<&DeltaState<P>>,
947 result: &crate::domain::types::AnalysisResult,
948 threshold: f64,
949) -> String {
950 let baseline_result = delta_state.map(|s| &s.snapshot.result);
951 let delta_inputs = delta_state.map(|s| (&s.delta.summary, s.delta.changes.as_slice()));
952 let row_data = crate::domain::summary::project_crap_delta_row(
953 result,
954 baseline_result,
955 delta_inputs,
956 threshold.round() as u32,
957 );
958 reporters::format_scorecard_row(&row_data)
959}
960
961#[allow(clippy::too_many_arguments)]
968fn render_format<P: ParseDiagnostic>(
969 cli: &Cli,
970 spec: &FormatSpec,
971 view: &view::AnalysisView<'_>,
972 delta_view: Option<&DeltaView<'_>>,
973 delta_state: Option<&DeltaState<P>>,
974 analysis: &AnalysisOutput<P>,
975 inputs: &EffectiveInputs,
976 tool_version: &str,
977) -> Result<String> {
978 Ok(match spec.format {
979 FormatArg::Table => reporters::format_table_with_explain(
980 view,
981 delta_view,
982 inputs.threshold,
983 cli.display.breakdown,
984 cli.display.explain,
985 tool_version,
986 ),
987 FormatArg::Json | FormatArg::Advice => format_as_json(
988 cli,
989 view,
990 delta_view,
991 delta_state,
992 analysis,
993 inputs,
994 tool_version,
995 )?,
996 FormatArg::Markdown => reporters::format_markdown(
997 view,
998 delta_view,
999 inputs.threshold,
1000 cli.display.breakdown,
1001 cli.display.explain,
1002 cli.display.md_full_table,
1003 cli.display.md_top,
1004 tool_version,
1005 ),
1006 FormatArg::Csv => reporters::format_csv(view, delta_view, inputs.metric),
1007 FormatArg::Sarif => reporters::format_sarif(view, tool_version),
1013 FormatArg::ScorecardRow => {
1014 format_as_scorecard_row(delta_state, &analysis.result, inputs.threshold)
1015 }
1016 FormatArg::Html => reporters::format_html(view, inputs.threshold, tool_version),
1017 })
1018}
1019
1020fn print_formatted_output<P: ParseDiagnostic>(
1021 cli: &Cli,
1022 view: &view::AnalysisView<'_>,
1023 delta_view: Option<&DeltaView<'_>>,
1024 delta_state: Option<&DeltaState<P>>,
1025 analysis: &AnalysisOutput<P>,
1026 inputs: &EffectiveInputs,
1027 tool_version: &str,
1028) -> Result<()> {
1029 for spec in &cli.output.format {
1030 let output = render_format(
1031 cli,
1032 spec,
1033 view,
1034 delta_view,
1035 delta_state,
1036 analysis,
1037 inputs,
1038 tool_version,
1039 )?;
1040 match &spec.output {
1041 Some(path) => std::fs::write(path, &output)
1042 .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?,
1043 None => print!("{output}"),
1044 }
1045 }
1046
1047 if cli
1052 .output
1053 .format
1054 .iter()
1055 .any(|s| matches!(s.format, FormatArg::Advice))
1056 {
1057 let mut stderr = std::io::stderr();
1058 let _ = reporters::render_advice_summary(view, &mut stderr);
1059 }
1060
1061 Ok(())
1062}
1063
1064fn compute_exit_code<P: ParseDiagnostic>(
1065 cli: &Cli,
1066 passed: bool,
1067 delta_state: Option<&DeltaState<P>>,
1068) -> bool {
1069 let delta_passed = delta_state.map(|s| s.delta.summary.passed).unwrap_or(true);
1070 let combined_passed = passed && (!cli.output.delta_gate || delta_passed);
1071 combined_passed || cli.output.no_fail
1072}
1073
1074struct DeltaState<P: ParseDiagnostic> {
1083 snapshot: BaselineSnapshot<P>,
1084 delta: AnalysisDelta,
1085}
1086
1087fn load_delta_state<P: ParseDiagnostic>(
1088 cli: &Cli,
1089 current: &crate::domain::types::AnalysisResult,
1090) -> Result<Option<DeltaState<P>>> {
1091 let Some(path) = cli.input.baseline.as_ref() else {
1092 return Ok(None);
1093 };
1094 let snapshot = baseline::load::<P>(path).map_err(|e| anyhow::anyhow!("{e}"))?;
1095 let delta = delta::compute(snapshot.result.clone(), current.clone());
1098 Ok(Some(DeltaState { snapshot, delta }))
1099}
1100
1101fn validate_display_flags(cli: &Cli) -> Result<()> {
1102 let any_table = cli
1103 .output
1104 .format
1105 .iter()
1106 .any(|s| matches!(s.format, FormatArg::Table));
1107 if cli.display.explain && any_table && !cli.display.breakdown {
1108 bail!("--explain requires --breakdown for table output");
1109 }
1110 validate_format_destinations(&cli.output.format)?;
1111 Ok(())
1112}
1113
1114fn validate_format_destinations(specs: &[FormatSpec]) -> Result<()> {
1117 if specs.len() > 1 {
1118 let stdout_specs: Vec<_> = specs
1119 .iter()
1120 .filter(|s| s.output.is_none())
1121 .map(|s| format_arg_kebab(s.format).to_string())
1122 .collect();
1123 if !stdout_specs.is_empty() {
1124 bail!(
1125 "multi-format `--format` requires every entry to specify a file (e.g. `json:envelope.json`); stdout-only entries: {}",
1126 stdout_specs.join(", ")
1127 );
1128 }
1129 }
1130 Ok(())
1131}
1132
1133fn format_arg_kebab(arg: FormatArg) -> String {
1137 use clap::ValueEnum;
1138 arg.to_possible_value()
1139 .map(|v| v.get_name().to_string())
1140 .unwrap_or_else(|| format!("{arg:?}").to_lowercase())
1141}
1142
1143fn load_file_config(cli: &Cli) -> Result<Option<FileConfig>> {
1146 if let Some(path) = &cli.input.config {
1147 Ok(Some(config::load_config(path)?))
1148 } else {
1149 match config::discover_config()? {
1150 Some(path) => Ok(Some(config::load_config(&path)?)),
1151 None => Ok(None),
1152 }
1153 }
1154}
1155
1156fn merge_threshold(cli: &Cli, file_config: &Option<FileConfig>) -> (ThresholdConfig, f64) {
1166 let global = cli
1167 .output
1168 .threshold
1169 .or_else(|| cli.output.strict.then_some(STRICT_THRESHOLD))
1170 .or_else(|| cli.output.lenient.then_some(LENIENT_THRESHOLD))
1171 .or_else(|| {
1172 file_config
1173 .as_ref()
1174 .and_then(|c| c.preset)
1175 .map(|p| p.threshold())
1176 })
1177 .or_else(|| file_config.as_ref().and_then(|c| c.threshold))
1178 .unwrap_or(DEFAULT_THRESHOLD);
1179
1180 let overrides = file_config
1181 .as_ref()
1182 .map(|fc| fc.overrides.clone())
1183 .unwrap_or_default();
1184
1185 let config = ThresholdConfig { global, overrides };
1186 (config, global)
1187}
1188
1189fn merge_exclude(cli: &Cli, file_config: &Option<FileConfig>) -> Vec<String> {
1190 let mut exclude = cli.filter.exclude.clone();
1191 if let Some(fc) = file_config
1192 && let Some(fc_exclude) = &fc.exclude
1193 {
1194 let seen: std::collections::HashSet<String> = exclude.iter().cloned().collect();
1195 for pattern in fc_exclude {
1196 if !seen.contains(pattern) {
1197 exclude.push(pattern.clone());
1198 }
1199 }
1200 }
1201 exclude
1202}
1203
1204fn validate_inputs(
1207 coverage: &std::path::Path,
1208 src: &std::path::Path,
1209 threshold: f64,
1210) -> Result<()> {
1211 match std::fs::metadata(coverage) {
1212 Ok(m) if m.is_file() => {}
1213 Ok(_) => bail!(
1214 "coverage path is not a file: {}\n \
1215 hint: pass --coverage pointing to an LCOV file, not a directory",
1216 coverage.display()
1217 ),
1218 Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1219 "coverage file not found: {}\n \
1220 hint: run `cargo llvm-cov --lcov --output-path lcov.info` first",
1221 coverage.display()
1222 ),
1223 Err(e) => bail!(
1224 "cannot access coverage file: {}: {e}\n \
1225 hint: check file permissions",
1226 coverage.display()
1227 ),
1228 }
1229 match std::fs::metadata(src) {
1230 Ok(m) if m.is_dir() => {}
1231 Ok(_) => bail!(
1232 "source path is not a directory: {}\n \
1233 hint: pass --src <DIR> pointing to your Rust source root",
1234 src.display()
1235 ),
1236 Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
1237 "source directory not found: {}\n \
1238 hint: pass --src <DIR> pointing to your Rust source root",
1239 src.display()
1240 ),
1241 Err(e) => bail!(
1242 "cannot access source directory: {}: {e}\n \
1243 hint: check directory permissions",
1244 src.display()
1245 ),
1246 }
1247 if !is_valid_threshold(threshold) {
1248 bail!(
1249 "threshold must be a finite positive number, got: {}",
1250 threshold
1251 );
1252 }
1253 Ok(())
1254}
1255
1256fn validate_diff_ref(diff_ref: &str) -> Result<()> {
1259 if diff_ref.is_empty() {
1260 bail!("invalid diff ref: ref must not be empty");
1261 }
1262 if diff_ref.starts_with('-') {
1263 bail!(
1264 "invalid diff ref: {diff_ref}\n \
1265 hint: ref must not start with a dash"
1266 );
1267 }
1268 Ok(())
1269}
1270
1271fn preflight_git_worktree(src: &Path) -> Result<()> {
1272 let output = std::process::Command::new("git")
1273 .current_dir(src)
1274 .args(["rev-parse", "--is-inside-work-tree"])
1275 .output();
1276
1277 match output {
1278 Ok(o) if o.status.success() => Ok(()),
1279 Ok(o) => {
1280 let stderr = String::from_utf8_lossy(&o.stderr);
1281 bail!(
1282 "not inside a git work tree\n \
1283 hint: --diff requires a git repository\n \
1284 git: {stderr}",
1285 );
1286 }
1287 Err(e) => bail!(
1288 "not inside a git work tree\n \
1289 hint: --diff requires git to be installed\n \
1290 error: {e}",
1291 ),
1292 }
1293}
1294
1295fn preflight_checks(coverage: &std::path::Path, src: &std::path::Path) -> Result<()> {
1298 check_coverage_has_data(coverage)?;
1299 check_src_has_rust_files(src)?;
1300 Ok(())
1301}
1302
1303fn check_coverage_has_data(path: &std::path::Path) -> Result<()> {
1304 use std::io::{BufRead, BufReader};
1305
1306 let file = std::fs::File::open(path)?;
1307 let reader = BufReader::new(file);
1308 let mut in_sf_block = false;
1309
1310 for line in reader.lines() {
1311 let line = line?;
1312 if line.starts_with("SF:") {
1313 in_sf_block = true;
1314 continue;
1315 }
1316 if in_sf_block
1317 && let Some(rest) = line.strip_prefix("DA:")
1318 && let Some((line_no, hits)) = rest.split_once(',')
1319 && line_no.parse::<usize>().is_ok()
1320 && hits.split(',').next().unwrap_or("").parse::<u64>().is_ok()
1321 {
1322 return Ok(());
1323 }
1324 }
1325 bail!(
1326 "no coverage data found in {}\n \
1327 hint: ensure tests ran with coverage enabled (`cargo llvm-cov --lcov`)",
1328 path.display()
1329 );
1330}
1331
1332fn check_src_has_rust_files(path: &std::path::Path) -> Result<()> {
1333 fn has_rs_files(dir: &std::path::Path) -> std::io::Result<bool> {
1334 for entry in std::fs::read_dir(dir)? {
1335 let entry = entry?;
1336 let ft = entry.file_type()?;
1337 if ft.is_file() && entry.path().extension().is_some_and(|ext| ext == "rs") {
1338 return Ok(true);
1339 }
1340 if ft.is_dir() && has_rs_files(&entry.path())? {
1341 return Ok(true);
1342 }
1343 }
1344 Ok(false)
1345 }
1346
1347 if !has_rs_files(path)? {
1348 bail!(
1349 "no Rust source files found in {}\n \
1350 hint: check that --src points to a directory containing .rs files",
1351 path.display()
1352 );
1353 }
1354 Ok(())
1355}
1356
1357fn now_unix_epoch() -> String {
1360 let secs = SystemTime::now()
1361 .duration_since(SystemTime::UNIX_EPOCH)
1362 .unwrap_or_default()
1363 .as_secs();
1364 format!("{secs}")
1365}
1366
1367fn majority_zero_coverage(files_analyzed: usize, files_zero_coverage: usize) -> bool {
1370 files_analyzed > 0 && files_zero_coverage * 2 > files_analyzed
1371}
1372
1373fn warn_if_issues<P: ParseDiagnostic>(diag: &AnalysisDiagnostics<P>) {
1374 if !diag.parse_diagnostics.is_empty() {
1375 eprintln!(
1376 "warning: {} LCOV parse issue(s) encountered (use --verbose for details)",
1377 diag.parse_diagnostics.len()
1378 );
1379 }
1380 if diag.files_unparseable > 0 {
1381 eprintln!(
1382 "warning: {} source file(s) could not be parsed (use --verbose for details)",
1383 diag.files_unparseable
1384 );
1385 }
1386 if majority_zero_coverage(diag.files_analyzed, diag.files_zero_coverage) {
1387 eprintln!(
1388 "warning: in {}/{} analyzed files, all analyzed functions have 0% line coverage",
1389 diag.files_zero_coverage, diag.files_analyzed
1390 );
1391 eprintln!(
1392 " hint: `cargo llvm-cov --lib` does not cover integration-only code (handlers, Tauri entry, BDD tests)"
1393 );
1394 eprintln!(
1395 " hint: use --exclude to skip uncoverable paths (e.g., --exclude \"services/api/src/**\")"
1396 );
1397 }
1398}
1399
1400fn print_diagnostics<P: ParseDiagnostic + std::fmt::Display>(diag: &AnalysisDiagnostics<P>) {
1401 eprintln!(
1402 "verbose: file discovery: {} files found, {} unparseable",
1403 diag.files_found, diag.files_unparseable
1404 );
1405 eprintln!(
1406 "verbose: complexity: {} functions extracted",
1407 diag.functions_extracted
1408 );
1409 eprintln!(
1410 "verbose: matching: {} matched with coverage, {} without coverage data",
1411 diag.functions_matched, diag.functions_no_coverage
1412 );
1413 eprintln!(
1414 "verbose: coverage: {} files analyzed, {} where all analyzed functions have 0% line coverage",
1415 diag.files_analyzed, diag.files_zero_coverage
1416 );
1417 if !diag.parse_diagnostics.is_empty() {
1418 eprintln!(
1419 "verbose: LCOV parse diagnostics ({}):",
1420 diag.parse_diagnostics.len()
1421 );
1422 for d in &diag.parse_diagnostics {
1423 eprintln!(" {d}");
1424 }
1425 }
1426}
1427
1428fn emit_completions(shell: ShellArg, bin_name: &str) {
1439 let mut cmd = Cli::command();
1440 let stdout = &mut std::io::stdout();
1441 match shell {
1442 ShellArg::Bash => clap_complete::generate(ClapShell::Bash, &mut cmd, bin_name, stdout),
1443 ShellArg::Zsh => clap_complete::generate(ClapShell::Zsh, &mut cmd, bin_name, stdout),
1444 ShellArg::Fish => clap_complete::generate(ClapShell::Fish, &mut cmd, bin_name, stdout),
1445 ShellArg::Powershell => {
1446 clap_complete::generate(ClapShell::PowerShell, &mut cmd, bin_name, stdout)
1447 }
1448 ShellArg::Elvish => clap_complete::generate(ClapShell::Elvish, &mut cmd, bin_name, stdout),
1449 ShellArg::Nushell => {
1450 clap_complete::generate(clap_complete_nushell::Nushell, &mut cmd, bin_name, stdout)
1451 }
1452 }
1453}
1454
1455fn apply_color(choice: ColorArg) {
1458 match choice {
1459 ColorArg::Auto => colored::control::unset_override(),
1460 ColorArg::Always => colored::control::set_override(true),
1461 ColorArg::Never => colored::control::set_override(false),
1462 }
1463}
1464
1465#[cfg(test)]
1468mod tests {
1469 use super::*;
1470 use std::path::Path;
1471
1472 fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
1473 let mut full = vec!["crap4rs"];
1474 full.extend_from_slice(args);
1475 Cli::try_parse_from(full)
1476 }
1477
1478 #[test]
1479 fn no_args_parses_with_coverage_none() {
1480 let cli = parse(&[]).unwrap();
1485 assert!(cli.input.coverage.is_none());
1486 assert!(cli.command.is_none());
1487 }
1488
1489 #[test]
1490 fn minimal_valid_args() {
1491 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1492 assert_eq!(cli.input.coverage.as_deref(), Some(Path::new("lcov.info")));
1493 assert_eq!(cli.input.src, None);
1494 }
1495
1496 #[test]
1497 fn completions_subcommand_does_not_require_coverage() {
1498 let cli = parse(&["completions", "bash"]).unwrap();
1499 assert!(matches!(
1500 cli.command,
1501 Some(Command::Completions {
1502 shell: ShellArg::Bash
1503 })
1504 ));
1505 assert!(cli.input.coverage.is_none());
1506 }
1507
1508 #[test]
1509 fn default_metric_is_none() {
1510 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1511 assert!(cli.input.metric.is_none());
1512 }
1513
1514 #[test]
1515 fn default_format_is_table() {
1516 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1517 assert_eq!(cli.output.format.len(), 1);
1518 assert!(matches!(cli.output.format[0].format, FormatArg::Table));
1519 assert!(cli.output.format[0].output.is_none());
1520 }
1521
1522 #[test]
1523 fn default_threshold_is_none() {
1524 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1525 assert!(cli.output.threshold.is_none());
1526 }
1527
1528 #[test]
1529 fn default_color_is_auto() {
1530 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1531 assert!(matches!(cli.display.color, ColorArg::Auto));
1532 }
1533
1534 #[test]
1535 fn metric_cyclomatic() {
1536 let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
1537 assert!(matches!(cli.input.metric, Some(MetricArg::Cyclomatic)));
1538 }
1539
1540 #[test]
1541 fn format_json() {
1542 let cli = parse(&["--coverage", "lcov.info", "--format", "json"]).unwrap();
1543 assert_eq!(cli.output.format.len(), 1);
1544 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1545 assert!(cli.output.format[0].output.is_none());
1546 }
1547
1548 #[test]
1549 fn format_sarif() {
1550 let cli = parse(&["--coverage", "lcov.info", "--format", "sarif"]).unwrap();
1551 assert_eq!(cli.output.format.len(), 1);
1552 assert!(matches!(cli.output.format[0].format, FormatArg::Sarif));
1553 }
1554
1555 #[test]
1556 fn format_with_file_destination() {
1557 let cli = parse(&["--coverage", "lcov.info", "--format", "json:env.json"]).unwrap();
1558 assert_eq!(cli.output.format.len(), 1);
1559 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1560 assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
1561 }
1562
1563 #[test]
1564 fn format_multi_with_files() {
1565 let cli = parse(&[
1566 "--coverage",
1567 "lcov.info",
1568 "--format",
1569 "json:env.json,markdown:report.md",
1570 ])
1571 .unwrap();
1572 assert_eq!(cli.output.format.len(), 2);
1573 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1574 assert_eq!(cli.output.format[0].output, Some(PathBuf::from("env.json")));
1575 assert!(matches!(cli.output.format[1].format, FormatArg::Markdown));
1576 assert_eq!(
1577 cli.output.format[1].output,
1578 Some(PathBuf::from("report.md"))
1579 );
1580 }
1581
1582 #[test]
1583 fn format_multi_without_files_rejected() {
1584 let cli = parse(&["--coverage", "lcov.info", "--format", "json,markdown"]).unwrap();
1585 let err = validate_display_flags(&cli).unwrap_err();
1586 let msg = err.to_string();
1587 assert!(msg.contains("multi-format"));
1588 assert!(msg.contains("file"));
1589 }
1590
1591 #[test]
1592 fn format_empty_path_rejected() {
1593 let err = parse(&["--coverage", "lcov.info", "--format", "json:"]).unwrap_err();
1594 let msg = format!("{err}");
1595 assert!(msg.contains("empty file path"));
1596 }
1597
1598 #[test]
1599 fn custom_threshold() {
1600 let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.5"]).unwrap();
1601 assert_eq!(cli.output.threshold, Some(15.5));
1602 }
1603
1604 #[test]
1605 fn custom_src() {
1606 let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
1607 assert_eq!(cli.input.src, Some(PathBuf::from("crates/")));
1608 }
1609
1610 #[test]
1611 fn exclude_repeatable() {
1612 let cli = parse(&[
1613 "--coverage",
1614 "lcov.info",
1615 "--exclude",
1616 "tests/**",
1617 "--exclude",
1618 "benches/**",
1619 ])
1620 .unwrap();
1621 assert_eq!(cli.filter.exclude, vec!["tests/**", "benches/**"]);
1622 }
1623
1624 #[test]
1625 fn no_gitignore_flag() {
1626 let cli = parse(&["--coverage", "lcov.info", "--no-gitignore"]).unwrap();
1627 assert!(cli.filter.no_gitignore);
1628 }
1629
1630 #[test]
1631 fn only_failing_flag() {
1632 let cli = parse(&["--coverage", "lcov.info", "--only-failing"]).unwrap();
1633 assert!(cli.filter.only_failing);
1634 }
1635
1636 #[test]
1637 fn group_by_file_parses() {
1638 let cli = parse(&["--coverage", "lcov.info", "--group-by", "file"]).unwrap();
1639 assert!(matches!(cli.filter.group_by, Some(GroupByArg::File)));
1640 }
1641
1642 #[test]
1643 fn group_by_absence_is_none() {
1644 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1645 assert!(cli.filter.group_by.is_none());
1646 }
1647
1648 #[test]
1649 fn group_by_invalid_value_rejected() {
1650 let err = parse(&["--coverage", "lcov.info", "--group-by", "module"]).unwrap_err();
1651 let msg = err.to_string();
1652 assert!(msg.contains("invalid value"), "expected clap error: {msg}");
1653 assert!(
1654 msg.contains("--group-by") || msg.contains("module"),
1655 "error should attribute to --group-by: {msg}"
1656 );
1657 }
1658
1659 #[test]
1660 fn group_by_arg_to_domain_file() {
1661 let domain: GroupKey = GroupByArg::File.into();
1662 assert_eq!(domain, GroupKey::File);
1663 }
1664
1665 #[test]
1666 fn verbose_flag() {
1667 let cli = parse(&["--coverage", "lcov.info", "-v"]).unwrap();
1668 assert!(cli.display.verbose);
1669 }
1670
1671 #[test]
1672 fn quiet_flag() {
1673 let cli = parse(&["--coverage", "lcov.info", "-q"]).unwrap();
1674 assert!(cli.display.quiet);
1675 }
1676
1677 #[test]
1678 fn color_always() {
1679 let cli = parse(&["--coverage", "lcov.info", "--color", "always"]).unwrap();
1680 assert!(matches!(cli.display.color, ColorArg::Always));
1681 }
1682
1683 #[test]
1684 fn color_never() {
1685 let cli = parse(&["--coverage", "lcov.info", "--color", "never"]).unwrap();
1686 assert!(matches!(cli.display.color, ColorArg::Never));
1687 }
1688
1689 #[test]
1690 fn invalid_metric_rejected() {
1691 let err = parse(&["--coverage", "lcov.info", "--metric", "halstead"]).unwrap_err();
1692 assert!(err.to_string().contains("invalid value"));
1693 }
1694
1695 #[test]
1696 fn invalid_format_rejected() {
1697 let err = parse(&["--coverage", "lcov.info", "--format", "xml"]).unwrap_err();
1698 assert!(err.to_string().contains("invalid value"));
1699 }
1700
1701 #[test]
1702 fn metric_arg_to_domain_cognitive() {
1703 let domain: ComplexityMetric = MetricArg::Cognitive.into();
1704 assert_eq!(domain, ComplexityMetric::Cognitive);
1705 }
1706
1707 #[test]
1708 fn metric_arg_to_domain_cyclomatic() {
1709 let domain: ComplexityMetric = MetricArg::Cyclomatic.into();
1710 assert_eq!(domain, ComplexityMetric::Cyclomatic);
1711 }
1712
1713 #[test]
1714 fn validate_missing_coverage_file() {
1715 let err = validate_inputs(
1716 Path::new("nonexistent.info"),
1717 Path::new("src"),
1718 DEFAULT_THRESHOLD,
1719 )
1720 .unwrap_err();
1721 let msg = format!("{err:#}");
1722 assert!(msg.contains("coverage file not found"));
1723 assert!(msg.contains("cargo llvm-cov"));
1724 }
1725
1726 #[test]
1727 fn validate_missing_src_dir() {
1728 let err = validate_inputs(
1729 Path::new("Cargo.toml"),
1730 Path::new("nonexistent_dir"),
1731 DEFAULT_THRESHOLD,
1732 )
1733 .unwrap_err();
1734 let msg = format!("{err:#}");
1735 assert!(msg.contains("source directory not found"));
1736 }
1737
1738 #[test]
1739 fn validate_negative_threshold() {
1740 let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), -5.0).unwrap_err();
1741 let msg = format!("{err:#}");
1742 assert!(msg.contains("threshold must be a finite positive number"));
1743 }
1744
1745 #[test]
1746 fn validate_zero_threshold() {
1747 let err = validate_inputs(Path::new("Cargo.toml"), Path::new("src"), 0.0).unwrap_err();
1748 let msg = format!("{err:#}");
1749 assert!(msg.contains("threshold must be a finite positive number"));
1750 }
1751
1752 #[test]
1753 fn validate_infinity_threshold() {
1754 let err =
1755 validate_inputs(Path::new("Cargo.toml"), Path::new("src"), f64::INFINITY).unwrap_err();
1756 let msg = format!("{err:#}");
1757 assert!(msg.contains("threshold must be a finite positive number"));
1758 }
1759
1760 #[test]
1761 fn validate_src_is_file_not_dir() {
1762 let err = validate_inputs(
1763 Path::new("Cargo.toml"),
1764 Path::new("Cargo.toml"),
1765 DEFAULT_THRESHOLD,
1766 )
1767 .unwrap_err();
1768 let msg = format!("{err:#}");
1769 assert!(msg.contains("source path is not a directory"));
1770 }
1771
1772 #[test]
1773 fn validate_coverage_is_dir_not_file() {
1774 let err =
1775 validate_inputs(Path::new("src"), Path::new("src"), DEFAULT_THRESHOLD).unwrap_err();
1776 let msg = format!("{err:#}");
1777 assert!(msg.contains("coverage path is not a file"));
1778 }
1779
1780 #[test]
1781 fn format_short_flag() {
1782 let cli = parse(&["--coverage", "lcov.info", "-f", "json"]).unwrap();
1783 assert!(matches!(cli.output.format[0].format, FormatArg::Json));
1784 }
1785
1786 #[test]
1787 fn config_flag_accepts_path() {
1788 let cli = parse(&["--coverage", "lcov.info", "--config", "my-config.toml"]).unwrap();
1789 assert_eq!(cli.input.config, Some(PathBuf::from("my-config.toml")));
1790 }
1791
1792 #[test]
1793 fn config_flag_defaults_to_none() {
1794 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1795 assert_eq!(cli.input.config, None);
1796 }
1797
1798 #[test]
1799 fn view_flag_accepts_name() {
1800 let cli = parse(&["--coverage", "lcov.info", "--view", "ci"]).unwrap();
1801 assert_eq!(cli.input.view, Some("ci".to_string()));
1802 }
1803
1804 #[test]
1805 fn view_flag_defaults_to_none() {
1806 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1807 assert_eq!(cli.input.view, None);
1808 }
1809
1810 #[test]
1811 fn merge_threshold_cli_overrides_config() {
1812 let cli = parse(&["--coverage", "lcov.info", "--threshold", "15.0"]).unwrap();
1813 let file_config = Some(FileConfig {
1814 threshold: Some(10.0),
1815 ..FileConfig::default()
1816 });
1817 let (config, display) = merge_threshold(&cli, &file_config);
1818 assert_eq!(config.global, 15.0);
1819 assert_eq!(display, 15.0);
1820 }
1821
1822 #[test]
1823 fn merge_threshold_uses_config_when_cli_default() {
1824 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1825 let file_config = Some(FileConfig {
1826 threshold: Some(12.0),
1827 ..FileConfig::default()
1828 });
1829 let (config, display) = merge_threshold(&cli, &file_config);
1830 assert_eq!(config.global, 12.0);
1831 assert_eq!(display, 12.0);
1832 }
1833
1834 #[test]
1835 fn merge_threshold_preserves_overrides() {
1836 use crate::domain::threshold::ThresholdOverride;
1837 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1838 let file_config = Some(FileConfig {
1839 threshold: Some(10.0),
1840 overrides: vec![ThresholdOverride {
1841 pattern: "domain/**".to_string(),
1842 threshold: 5.0,
1843 }],
1844 ..FileConfig::default()
1845 });
1846 let (config, _) = merge_threshold(&cli, &file_config);
1847 assert_eq!(config.overrides.len(), 1);
1848 assert_eq!(config.overrides[0].pattern, "domain/**");
1849 }
1850
1851 #[test]
1852 fn merge_threshold_no_config() {
1853 let cli = parse(&["--coverage", "lcov.info", "--threshold", "20.0"]).unwrap();
1854 let (config, display) = merge_threshold(&cli, &None);
1855 assert_eq!(config.global, 20.0);
1856 assert!(config.overrides.is_empty());
1857 assert_eq!(display, 20.0);
1858 }
1859
1860 #[test]
1861 fn merge_threshold_explicit_default_overrides_config() {
1862 let cli = parse(&["--coverage", "lcov.info", "--threshold", "8.0"]).unwrap();
1865 let file_config = Some(FileConfig {
1866 threshold: Some(12.0),
1867 ..FileConfig::default()
1868 });
1869 let (config, display) = merge_threshold(&cli, &file_config);
1870 assert_eq!(
1871 config.global, 8.0,
1872 "explicit CLI default must override config"
1873 );
1874 assert_eq!(display, 8.0);
1875 }
1876
1877 #[test]
1878 fn merge_threshold_no_cli_no_config_uses_hardcoded_default() {
1879 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1880 let (config, display) = merge_threshold(&cli, &None);
1881 assert_eq!(config.global, DEFAULT_THRESHOLD);
1882 assert_eq!(display, DEFAULT_THRESHOLD);
1883 }
1884
1885 #[test]
1886 fn merge_exclude_combines_cli_and_config() {
1887 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
1888 let file_config = Some(FileConfig {
1889 exclude: Some(vec!["benches/**".to_string()]),
1890 ..FileConfig::default()
1891 });
1892 let exclude = merge_exclude(&cli, &file_config);
1893 assert_eq!(exclude, vec!["tests/**", "benches/**"]);
1894 }
1895
1896 #[test]
1897 fn merge_exclude_deduplicates() {
1898 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
1899 let file_config = Some(FileConfig {
1900 exclude: Some(vec!["tests/**".to_string()]),
1901 ..FileConfig::default()
1902 });
1903 let exclude = merge_exclude(&cli, &file_config);
1904 assert_eq!(exclude, vec!["tests/**"]);
1905 }
1906
1907 #[test]
1910 fn diff_flag_accepts_ref() {
1911 let cli = parse(&["--coverage", "lcov.info", "--diff", "main"]).unwrap();
1912 assert_eq!(cli.filter.diff, Some("main".to_string()));
1913 }
1914
1915 #[test]
1916 fn diff_flag_defaults_to_none() {
1917 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1918 assert_eq!(cli.filter.diff, None);
1919 }
1920
1921 #[test]
1922 fn diff_flag_accepts_commit_sha() {
1923 let cli = parse(&["--coverage", "lcov.info", "--diff", "abc123"]).unwrap();
1924 assert_eq!(cli.filter.diff, Some("abc123".to_string()));
1925 }
1926
1927 #[test]
1928 fn diff_flag_accepts_head_tilde() {
1929 let cli = parse(&["--coverage", "lcov.info", "--diff", "HEAD~1"]).unwrap();
1930 assert_eq!(cli.filter.diff, Some("HEAD~1".to_string()));
1931 }
1932
1933 #[test]
1934 fn validate_diff_ref_rejects_empty_string() {
1935 let err = validate_diff_ref("").unwrap_err();
1936 let msg = format!("{err:#}");
1937 assert!(msg.contains("must not be empty"));
1938 }
1939
1940 #[test]
1941 fn validate_diff_ref_rejects_dash_prefix() {
1942 let err = validate_diff_ref("--malicious").unwrap_err();
1943 let msg = format!("{err:#}");
1944 assert!(msg.contains("invalid diff ref"));
1945 assert!(msg.contains("must not start with a dash"));
1946 }
1947
1948 #[test]
1949 fn validate_diff_ref_accepts_normal_ref() {
1950 assert!(validate_diff_ref("main").is_ok());
1951 assert!(validate_diff_ref("HEAD~1").is_ok());
1952 assert!(validate_diff_ref("abc123").is_ok());
1953 }
1954
1955 #[test]
1956 fn preflight_git_worktree_passes_in_git_repo() {
1957 let tmp = tempfile::tempdir().unwrap();
1961 let status = std::process::Command::new("git")
1962 .arg("init")
1963 .arg("--quiet")
1964 .current_dir(tmp.path())
1965 .status()
1966 .expect("git init");
1967 assert!(status.success(), "git init failed");
1968 assert!(preflight_git_worktree(tmp.path()).is_ok());
1969 }
1970
1971 #[test]
1972 fn breakdown_flag_parsed() {
1973 let cli = parse(&["--coverage", "lcov.info", "--breakdown"]).unwrap();
1974 assert!(cli.display.breakdown);
1975 }
1976
1977 #[test]
1978 fn breakdown_flag_default_false() {
1979 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1980 assert!(!cli.display.breakdown);
1981 }
1982
1983 #[test]
1984 fn explain_flag_parsed() {
1985 let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
1986 assert!(cli.display.explain);
1987 }
1988
1989 #[test]
1990 fn explain_flag_default_false() {
1991 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
1992 assert!(!cli.display.explain);
1993 }
1994
1995 #[test]
1996 fn explain_requires_breakdown_for_table_output() {
1997 let cli = parse(&["--coverage", "lcov.info", "--explain"]).unwrap();
1998 let err = validate_display_flags(&cli).unwrap_err();
1999 let msg = err.to_string();
2000 assert!(msg.contains("--breakdown"));
2001 assert!(msg.contains("--explain"));
2002 }
2003
2004 #[test]
2005 fn explain_allowed_for_json_output() {
2006 let cli = parse(&["--coverage", "lcov.info", "--format", "json", "--explain"]).unwrap();
2007 assert!(validate_display_flags(&cli).is_ok());
2008 }
2009
2010 #[test]
2011 fn color_overrides_set_global_state() {
2012 apply_color(ColorArg::Never);
2016 assert!(!colored::control::SHOULD_COLORIZE.should_colorize());
2017
2018 apply_color(ColorArg::Always);
2019 assert!(colored::control::SHOULD_COLORIZE.should_colorize());
2020
2021 apply_color(ColorArg::Auto);
2022 }
2023
2024 #[test]
2027 fn preflight_empty_coverage_file() {
2028 let dir = tempfile::tempdir().unwrap();
2029 let cov = dir.path().join("empty.info");
2030 std::fs::write(&cov, "").unwrap();
2031
2032 let err = check_coverage_has_data(&cov).unwrap_err();
2033 let msg = format!("{err:#}");
2034 assert!(msg.contains("no coverage data found"));
2035 assert!(msg.contains("cargo llvm-cov"));
2036 }
2037
2038 #[test]
2039 fn preflight_coverage_no_da_lines() {
2040 let dir = tempfile::tempdir().unwrap();
2041 let cov = dir.path().join("no_da.info");
2042 std::fs::write(&cov, "SF:src/main.rs\nend_of_record\n").unwrap();
2043
2044 let err = check_coverage_has_data(&cov).unwrap_err();
2045 let msg = format!("{err:#}");
2046 assert!(msg.contains("no coverage data found"));
2047 }
2048
2049 #[test]
2050 fn preflight_coverage_with_da_lines_passes() {
2051 let dir = tempfile::tempdir().unwrap();
2052 let cov = dir.path().join("good.info");
2053 std::fs::write(&cov, "SF:src/main.rs\nDA:1,5\nend_of_record\n").unwrap();
2054
2055 assert!(check_coverage_has_data(&cov).is_ok());
2056 }
2057
2058 #[test]
2059 fn preflight_coverage_da_outside_sf_block_rejected() {
2060 let dir = tempfile::tempdir().unwrap();
2061 let cov = dir.path().join("orphan_da.info");
2062 std::fs::write(&cov, "DA:1,5\nend_of_record\n").unwrap();
2063
2064 let err = check_coverage_has_data(&cov).unwrap_err();
2065 let msg = format!("{err:#}");
2066 assert!(msg.contains("no coverage data found"));
2067 }
2068
2069 #[test]
2070 fn preflight_coverage_malformed_da_rejected() {
2071 let dir = tempfile::tempdir().unwrap();
2072 let cov = dir.path().join("bad_da.info");
2073 std::fs::write(&cov, "SF:src/main.rs\nDA:not_a_number\nend_of_record\n").unwrap();
2074
2075 let err = check_coverage_has_data(&cov).unwrap_err();
2076 let msg = format!("{err:#}");
2077 assert!(msg.contains("no coverage data found"));
2078 }
2079
2080 #[test]
2081 fn preflight_src_dir_no_rust_files() {
2082 let dir = tempfile::tempdir().unwrap();
2083 std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
2084
2085 let err = check_src_has_rust_files(dir.path()).unwrap_err();
2086 let msg = format!("{err:#}");
2087 assert!(msg.contains("no Rust source files found"));
2088 }
2089
2090 #[test]
2091 fn preflight_src_dir_empty() {
2092 let dir = tempfile::tempdir().unwrap();
2093
2094 let err = check_src_has_rust_files(dir.path()).unwrap_err();
2095 let msg = format!("{err:#}");
2096 assert!(msg.contains("no Rust source files found"));
2097 }
2098
2099 #[test]
2100 fn preflight_src_dir_with_rs_files_passes() {
2101 let dir = tempfile::tempdir().unwrap();
2102 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
2103
2104 assert!(check_src_has_rust_files(dir.path()).is_ok());
2105 }
2106
2107 #[test]
2108 fn preflight_src_dir_nested_rs_files_passes() {
2109 let dir = tempfile::tempdir().unwrap();
2110 let nested = dir.path().join("sub");
2111 std::fs::create_dir(&nested).unwrap();
2112 std::fs::write(nested.join("lib.rs"), "pub fn foo() {}").unwrap();
2113
2114 assert!(check_src_has_rust_files(dir.path()).is_ok());
2115 }
2116
2117 #[test]
2120 fn strict_flag_parses() {
2121 let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2122 assert!(cli.output.strict);
2123 }
2124
2125 #[test]
2126 fn lenient_flag_parses() {
2127 let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2128 assert!(cli.output.lenient);
2129 }
2130
2131 #[test]
2132 fn strict_and_threshold_mutually_exclusive() {
2133 parse(&["--coverage", "lcov.info", "--strict", "--threshold", "20"]).unwrap_err();
2134 }
2135
2136 #[test]
2137 fn strict_and_lenient_mutually_exclusive() {
2138 parse(&["--coverage", "lcov.info", "--strict", "--lenient"]).unwrap_err();
2139 }
2140
2141 #[test]
2142 fn merge_threshold_strict_flag() {
2143 use crate::domain::threshold::STRICT_THRESHOLD;
2144 let cli = parse(&["--coverage", "lcov.info", "--strict"]).unwrap();
2145 let (config, display) = merge_threshold(&cli, &None);
2146 assert_eq!(config.global, STRICT_THRESHOLD);
2147 assert_eq!(display, STRICT_THRESHOLD);
2148 }
2149
2150 #[test]
2151 fn merge_threshold_lenient_flag() {
2152 use crate::domain::threshold::LENIENT_THRESHOLD;
2153 let cli = parse(&["--coverage", "lcov.info", "--lenient"]).unwrap();
2154 let (config, display) = merge_threshold(&cli, &None);
2155 assert_eq!(config.global, LENIENT_THRESHOLD);
2156 assert_eq!(display, LENIENT_THRESHOLD);
2157 }
2158
2159 #[test]
2160 fn merge_threshold_toml_preset_used_when_no_cli_flag() {
2161 use crate::domain::threshold::{STRICT_THRESHOLD, ThresholdPreset};
2162 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2163 let file_config = Some(FileConfig {
2164 preset: Some(ThresholdPreset::Strict),
2165 ..FileConfig::default()
2166 });
2167 let (config, _) = merge_threshold(&cli, &file_config);
2168 assert_eq!(config.global, STRICT_THRESHOLD);
2169 }
2170
2171 #[test]
2172 fn merge_threshold_cli_threshold_overrides_toml_preset() {
2173 use crate::domain::threshold::ThresholdPreset;
2174 let cli = parse(&["--coverage", "lcov.info", "--threshold", "50.0"]).unwrap();
2175 let file_config = Some(FileConfig {
2176 preset: Some(ThresholdPreset::Strict),
2177 ..FileConfig::default()
2178 });
2179 let (config, _) = merge_threshold(&cli, &file_config);
2180 assert_eq!(config.global, 50.0);
2181 }
2182
2183 #[test]
2186 fn zero_coverage_warn_triggers_above_50_percent() {
2187 assert!(majority_zero_coverage(10, 6));
2188 assert!(majority_zero_coverage(1, 1));
2189 assert!(majority_zero_coverage(3, 2));
2190 }
2191
2192 #[test]
2193 fn zero_coverage_warn_does_not_trigger_at_exactly_50_percent() {
2194 assert!(!majority_zero_coverage(10, 5));
2195 assert!(!majority_zero_coverage(2, 1));
2196 }
2197
2198 #[test]
2199 fn zero_coverage_warn_does_not_trigger_when_no_files() {
2200 assert!(!majority_zero_coverage(0, 0));
2201 }
2202
2203 #[test]
2206 fn merge_effective_inputs_default_src() {
2207 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2208 let inputs = merge_effective_inputs(&cli, &None);
2209 assert_eq!(inputs.src, PathBuf::from("src"));
2210 }
2211
2212 #[test]
2213 fn merge_effective_inputs_cli_src_wins_over_config() {
2214 let cli = parse(&["--coverage", "lcov.info", "--src", "crates/"]).unwrap();
2215 let file_config = Some(FileConfig {
2216 src: Some(PathBuf::from("from-config/")),
2217 ..FileConfig::default()
2218 });
2219 let inputs = merge_effective_inputs(&cli, &file_config);
2220 assert_eq!(inputs.src, PathBuf::from("crates/"));
2221 }
2222
2223 #[test]
2224 fn merge_effective_inputs_config_src_when_cli_absent() {
2225 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2226 let file_config = Some(FileConfig {
2227 src: Some(PathBuf::from("from-config/")),
2228 ..FileConfig::default()
2229 });
2230 let inputs = merge_effective_inputs(&cli, &file_config);
2231 assert_eq!(inputs.src, PathBuf::from("from-config/"));
2232 }
2233
2234 #[test]
2235 fn merge_effective_inputs_default_metric_is_cognitive() {
2236 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2237 let inputs = merge_effective_inputs(&cli, &None);
2238 assert!(matches!(inputs.metric, ComplexityMetric::Cognitive));
2239 }
2240
2241 #[test]
2242 fn merge_effective_inputs_cli_metric_overrides_config() {
2243 let cli = parse(&["--coverage", "lcov.info", "--metric", "cyclomatic"]).unwrap();
2244 let file_config = Some(FileConfig {
2245 metric: Some(ComplexityMetric::Cognitive),
2246 ..FileConfig::default()
2247 });
2248 let inputs = merge_effective_inputs(&cli, &file_config);
2249 assert!(matches!(inputs.metric, ComplexityMetric::Cyclomatic));
2250 }
2251
2252 #[test]
2253 fn merge_effective_inputs_threshold_default() {
2254 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2255 let inputs = merge_effective_inputs(&cli, &None);
2256 assert_eq!(inputs.threshold, DEFAULT_THRESHOLD);
2257 }
2258
2259 #[test]
2260 fn merge_effective_inputs_exclude_combines_cli_and_config() {
2261 let cli = parse(&["--coverage", "lcov.info", "--exclude", "tests/**"]).unwrap();
2262 let file_config = Some(FileConfig {
2263 exclude: Some(vec!["benches/**".to_string()]),
2264 ..FileConfig::default()
2265 });
2266 let inputs = merge_effective_inputs(&cli, &file_config);
2267 assert_eq!(inputs.exclude, vec!["tests/**", "benches/**"]);
2268 }
2269
2270 #[test]
2278 fn compute_exit_code_passing_no_delta() {
2279 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2280 assert!(compute_exit_code::<
2281 crate::test_strategies::DummyParseDiagnostic,
2282 >(&cli, true, None));
2283 }
2284
2285 #[test]
2286 fn compute_exit_code_failing_no_delta() {
2287 let cli = parse(&["--coverage", "lcov.info"]).unwrap();
2288 assert!(!compute_exit_code::<
2289 crate::test_strategies::DummyParseDiagnostic,
2290 >(&cli, false, None));
2291 }
2292
2293 #[test]
2294 fn compute_exit_code_no_fail_overrides_failure() {
2295 let cli = parse(&["--coverage", "lcov.info", "--no-fail"]).unwrap();
2296 assert!(compute_exit_code::<
2297 crate::test_strategies::DummyParseDiagnostic,
2298 >(&cli, false, None));
2299 }
2300
2301 #[test]
2302 fn compute_exit_code_delta_gate_without_runtime_baseline_treats_delta_as_passed() {
2303 let cli = parse(&[
2311 "--coverage",
2312 "lcov.info",
2313 "--delta-gate",
2314 "--baseline",
2315 "/dev/null",
2316 ])
2317 .unwrap();
2318 assert!(compute_exit_code::<
2319 crate::test_strategies::DummyParseDiagnostic,
2320 >(&cli, true, None));
2321 }
2322
2323 #[test]
2324 fn compute_exit_code_no_fail_with_delta_gate() {
2325 let cli = parse(&[
2328 "--coverage",
2329 "lcov.info",
2330 "--delta-gate",
2331 "--baseline",
2332 "/dev/null",
2333 "--no-fail",
2334 ])
2335 .unwrap();
2336 assert!(compute_exit_code::<
2337 crate::test_strategies::DummyParseDiagnostic,
2338 >(&cli, false, None));
2339 }
2340}