1use crate::analysis;
4use crate::config::{CliConfigOverrides, Config, ConfigOverrides, ReporterKind};
5use crate::discovery;
6use crate::model::{DuplicationThreshold, Rule, Severity};
7use crate::source_adapter::ExtractionDiagnosticLevel;
8use crate::{gherkin, modes, reporters, source_adapter};
9use anyhow::{bail, Context, Result};
10use clap::{Args, Parser, Subcommand};
11use std::collections::{BTreeMap, BTreeSet};
12use std::fs;
13use std::io::{self, IsTerminal, Write};
14use std::path::{Path, PathBuf};
15use std::time::Instant;
16
17#[derive(Debug, Parser)]
18#[command(
19 name = "cuke-dedup",
20 version,
21 about = "Detect duplicate and reusable Cucumber step definitions",
22 after_help = "Examples:\n cuke-dedup .\n cuke-dedup check .\n cuke-dedup . --reporters terminal,json,html\n cuke-dedup . --reporters jsonl"
23)]
24struct Cli {
25 #[arg(value_name = "PATH", default_value = ".")]
27 path: PathBuf,
28
29 #[command(subcommand)]
30 command: Option<Command>,
31
32 #[command(flatten)]
33 options: CheckOptions,
34}
35
36#[derive(Debug, Subcommand)]
37enum Command {
38 Check {
40 #[arg(value_name = "PATH")]
42 path: PathBuf,
43 },
44}
45
46#[derive(Debug, Clone, Args, Default)]
47struct CheckOptions {
48 #[arg(long, short = 'c', global = true, value_name = "FILE")]
50 config: Option<PathBuf>,
51
52 #[arg(long, global = true, value_delimiter = ',')]
54 reporters: Option<Vec<ReporterKind>>,
55
56 #[arg(long, global = true, value_name = "DIR")]
58 output: Option<PathBuf>,
59
60 #[arg(
62 long,
63 global = true,
64 value_name = "PERCENT",
65 allow_hyphen_values = true
66 )]
67 threshold: Option<f64>,
68
69 #[arg(long, global = true)]
71 require_features: bool,
72
73 #[arg(long, global = true)]
75 fail_on_incomplete: bool,
76
77 #[arg(long, global = true)]
81 fail_on_unparseable: bool,
82
83 #[arg(long, global = true)]
85 require_definitions: bool,
86
87 #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
89 max_candidate_comparisons: Option<usize>,
90
91 #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
93 max_structural_class_comparisons: Option<usize>,
94
95 #[arg(long, global = true, value_delimiter = ',')]
97 definitions: Option<Vec<String>>,
98
99 #[arg(long, global = true, value_delimiter = ',')]
101 features: Option<Vec<String>>,
102
103 #[arg(long, global = true)]
105 explain_discovery: bool,
106
107 #[arg(long, global = true)]
109 print_config: bool,
110
111 #[arg(long, global = true)]
113 no_metrics: bool,
114
115 #[arg(
117 long,
118 global = true,
119 value_name = "FILE",
120 conflicts_with = "print_config"
121 )]
122 emit_fingerprints: Option<PathBuf>,
123
124 #[arg(
126 long,
127 global = true,
128 value_name = "FILE",
129 conflicts_with = "print_config"
130 )]
131 emit_pairs: Option<PathBuf>,
132
133 #[arg(long, global = true, value_delimiter = ',')]
135 exclude: Option<Vec<String>>,
136
137 #[arg(long, global = true)]
139 no_default_excludes: bool,
140
141 #[arg(long, global = true)]
143 include_hidden: bool,
144
145 #[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
147 rules: Vec<(Rule, Severity)>,
148
149 #[arg(long, global = true, value_name = "GIT_REF")]
151 changed_since: Option<String>,
152
153 #[arg(
155 long,
156 global = true,
157 value_name = "BASELINE.json",
158 group = "baseline_source"
159 )]
160 baseline: Option<PathBuf>,
161
162 #[arg(long, global = true, value_name = "GIT_REF", group = "baseline_source",
164 conflicts_with_all = ["baseline", "update_baseline"])]
165 baseline_from_ref: Option<String>,
166
167 #[arg(
169 long,
170 global = true,
171 requires = "baseline",
172 conflicts_with = "changed_since"
173 )]
174 update_baseline: bool,
175
176 #[arg(
178 long,
179 global = true,
180 value_name = "COUNT",
181 num_args = 0..=1,
182 default_missing_value = "0",
183 requires = "baseline_source",
184 conflicts_with = "update_baseline"
185 )]
186 fail_on_new: Option<usize>,
187}
188
189struct Invocation {
190 config: Config,
191 options: CheckOptions,
192}
193
194#[derive(Default)]
195struct Diagnostics {
196 warnings: Vec<String>,
197 errors: Vec<String>,
198}
199
200struct ExtractedCorpus {
201 parameter_types: Vec<source_adapter::SourceParameterType>,
202 parameter_language: Option<source_adapter::SourceLanguage>,
203 indirect_usage: source_adapter::IndirectStepUsage,
204 definitions: Vec<crate::model::StepDefinition>,
205 feature_steps: Vec<crate::model::FeatureStep>,
206 excluded_sources: Vec<(PathBuf, source_adapter::SourceExclusion)>,
207 definition_files_with_definitions: usize,
208 parsed_feature_files: usize,
209 feature_files_without_steps: usize,
210 incomplete: bool,
211}
212
213struct AnalyzedCorpus {
214 result: crate::model::AnalysisResult,
215 census: analysis::AnalysisCensus,
216 definition_files_with_definitions: usize,
217 parsed_feature_files: usize,
218 feature_files_without_steps: usize,
219 corpus_incomplete: bool,
220 run_incomplete: bool,
221 pair_records: Vec<analysis::pair_records::PairRecord>,
222}
223
224struct PhaseTimings {
225 discovery_ms: f64,
226 parsing_ms: f64,
227 analysis_ms: f64,
228}
229
230pub fn run_cli() -> Result<i32> {
232 execute(Cli::parse())
233}
234
235fn execute(cli: Cli) -> Result<i32> {
238 let invocation = resolve_invocation(cli)?;
239 let config = &invocation.config;
240 let options = &invocation.options;
241 if options.print_config {
242 println!(
243 "{}",
244 serde_json::to_string_pretty(config)
245 .context("failed to serialize effective configuration")?
246 );
247 return Ok(0);
248 }
249 let changed_files = resolve_changed_files(config, options.changed_since.as_deref())?;
250 let discovery_started = Instant::now();
251 let files = discovery::discover(config)?;
252 let discovery_ms = elapsed_ms(discovery_started);
253
254 let parsing_started = Instant::now();
255 let mut diagnostics = discovery_diagnostics(config, &files, &changed_files);
256 let extracted = extract_corpus(config, &files, &changed_files, &mut diagnostics);
257 let parsing_ms = elapsed_ms(parsing_started);
258 let analysis_started = Instant::now();
259 let collect_pairs = options.emit_pairs.is_some();
260 let mut analyzed = analyze_corpus(config, extracted, &mut diagnostics, collect_pairs)?;
261 let analysis_ms = elapsed_ms(analysis_started);
262 apply_finding_modes(
263 &mut analyzed.result,
264 &files,
265 &changed_files,
266 analyzed.parsed_feature_files,
267 analyzed.feature_files_without_steps,
268 );
269 let fingerprints = options
271 .emit_fingerprints
272 .as_deref()
273 .map(|path| EmitSink::open(config, options, path, "fingerprint file", None))
274 .transpose()?;
275 let pairs = options
276 .emit_pairs
277 .as_deref()
278 .map(|path| EmitSink::open(config, options, path, "pair file", fingerprints.as_ref()))
279 .transpose()?;
280 let baseline_outcome = apply_baseline_mode(
281 config,
282 options,
283 &mut analyzed.result,
284 analyzed.run_incomplete,
285 &mut diagnostics,
286 )?;
287 if let Some(sink) = fingerprints {
288 sink.write(|out| {
289 analysis::fingerprints::write_fingerprints(
290 &analyzed.result.definitions,
291 &config.root,
292 out,
293 )
294 })?;
295 }
296 if let Some(sink) = pairs {
297 sink.write(|out| {
298 analysis::pair_records::write_pairs(
299 &analyzed.result.definitions,
300 &analyzed.pair_records,
301 &config.root,
302 out,
303 )
304 })?;
305 }
306 write_reports(
307 config,
308 &files,
309 &analyzed,
310 &diagnostics,
311 PhaseTimings {
312 discovery_ms,
313 parsing_ms,
314 analysis_ms,
315 },
316 )?;
317 write_diagnostics(config, options, &files, &diagnostics)?;
318 determine_exit_code(
319 config,
320 options,
321 &analyzed.result,
322 baseline_outcome,
323 &diagnostics,
324 )
325}
326
327fn resolve_invocation(cli: Cli) -> Result<Invocation> {
330 if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
331 bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
332 }
333 let root = match cli.command {
334 Some(Command::Check { path }) => path,
335 None => cli.path,
336 };
337 let options = cli.options.clone();
338 let config = Config::load_for_cli(
339 &root,
340 ConfigOverrides {
341 config_file: cli.options.config,
342 definitions: cli.options.definitions,
343 features: cli.options.features,
344 exclude: cli.options.exclude,
345 exclude_defaults: cli.options.no_default_excludes.then_some(false),
346 include_hidden: cli.options.include_hidden.then_some(true),
347 reporters: cli.options.reporters,
348 output: cli.options.output,
349 threshold: cli.options.threshold,
350 require_features: cli.options.require_features.then_some(true),
351 no_metrics: cli.options.no_metrics.then_some(true),
352 rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
353 },
354 CliConfigOverrides {
355 require_definitions: cli.options.require_definitions.then_some(true),
356 fail_on_incomplete: cli.options.fail_on_incomplete.then_some(true),
357 fail_on_unparseable: cli.options.fail_on_unparseable.then_some(true),
358 max_candidate_comparisons: cli.options.max_candidate_comparisons,
359 max_structural_class_comparisons: cli.options.max_structural_class_comparisons,
360 },
361 )?;
362 Ok(Invocation { config, options })
363}
364
365fn resolve_changed_files(
369 config: &Config,
370 changed_since: Option<&str>,
371) -> Result<Option<BTreeSet<PathBuf>>> {
372 let Some(base) = changed_since else {
373 return Ok(None);
374 };
375 modes::ensure_changed_root_is_trackable(&config.root)?;
376 Ok(Some(modes::git_changed_files(&config.root, base)?))
377}
378
379fn discovery_diagnostics(
384 config: &Config,
385 files: &discovery::DiscoveredFiles,
386 changed_files: &Option<BTreeSet<PathBuf>>,
387) -> Diagnostics {
388 let mut diagnostics = Diagnostics {
389 warnings: config.config_warnings.clone(),
390 errors: files.errors.clone(),
391 };
392 for pattern in &files.unmatched_feature_patterns {
393 diagnostics.warnings.push(format!(
394 "feature pattern `{pattern}` from {} matched no files",
395 config.feature_pattern_origin
396 ));
397 }
398 for pattern in &files.unmatched_definition_patterns {
399 diagnostics
400 .warnings
401 .push(format!("definition pattern `{pattern}` matched no files"));
402 }
403 if !files.skipped_definitions.is_empty()
404 && (!config.definitions.is_empty() || files.definitions.is_empty())
405 {
406 diagnostics
407 .warnings
408 .push(skipped_definitions_warning(&files.skipped_definitions));
409 }
410 if files.definitions.is_empty() && config.definitions.is_empty() {
411 diagnostics
412 .warnings
413 .push("automatic discovery found no supported step-definition source files".to_owned());
414 }
415 if files.features.is_empty() {
416 let message = format!(
417 "no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
418 config.feature_pattern_origin
419 );
420 if config.require_features {
421 diagnostics.errors.push(message);
422 } else if !files.definitions.is_empty() {
423 diagnostics.warnings.push(message);
424 }
425 }
426 if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
427 diagnostics.warnings.push(
428 "--changed-since found no tracked or untracked files; no findings will be reported"
429 .to_owned(),
430 );
431 }
432 diagnostics
433}
434
435fn skipped_definitions_warning(skipped: &[source_adapter::SourceFile]) -> String {
437 let registrations: Vec<_> = source_adapter::SOURCE_ADAPTER_REGISTRY
438 .iter()
439 .filter(|entry| {
440 skipped
441 .iter()
442 .any(|file| entry.adapter.language() == file.language)
443 })
444 .collect();
445 let adapters: BTreeSet<_> = registrations
446 .iter()
447 .map(|entry| entry.adapter.name())
448 .collect();
449 let suffixes: Vec<_> = registrations
450 .iter()
451 .map(|entry| format!("`{}`", entry.suffix))
452 .collect();
453 format!(
454 "{} {} source file(s) were not analyzed: opt-in languages need a definition pattern that names their suffix ({}), such as `features/**/*{}`",
455 skipped.len(),
456 adapters.into_iter().collect::<Vec<_>>().join(", "),
457 suffixes.join(", "),
458 registrations.first().map_or("", |entry| entry.suffix),
459 )
460}
461
462fn extract_corpus(
465 config: &Config,
466 files: &discovery::DiscoveredFiles,
467 changed_files: &Option<BTreeSet<PathBuf>>,
468 diagnostics: &mut Diagnostics,
469) -> ExtractedCorpus {
470 let mut corpus = ExtractedCorpus {
471 parameter_types: Vec::new(),
472 parameter_language: files.definitions.first().map(|file| file.language),
473 indirect_usage: source_adapter::IndirectStepUsage::default(),
474 definitions: Vec::new(),
475 feature_steps: Vec::new(),
476 excluded_sources: Vec::new(),
477 definition_files_with_definitions: 0,
478 parsed_feature_files: 0,
479 feature_files_without_steps: 0,
480 incomplete: false,
481 };
482 extract_definitions(config, files, changed_files, diagnostics, &mut corpus);
483 extract_feature_steps(config, files, diagnostics, &mut corpus);
484 corpus
485}
486
487fn extract_definitions(
489 config: &Config,
490 files: &discovery::DiscoveredFiles,
491 changed_files: &Option<BTreeSet<PathBuf>>,
492 diagnostics: &mut Diagnostics,
493 corpus: &mut ExtractedCorpus,
494) {
495 if let Some(first) = files.definitions.first() {
496 let domain = first.language.comparison_domain();
497 if files
498 .definitions
499 .iter()
500 .any(|file| file.language.comparison_domain() != domain)
501 {
502 corpus.incomplete = true;
503 diagnostics.errors.push("Ruby and JS/TS sources require separate analysis runs with explicit definition and feature roots".to_owned());
504 return;
505 }
506 }
507 let mut extraction_session = source_adapter::SourceExtractionSession::with_options(
508 &config.root,
509 &config.registrations,
510 &config.assertion_modules,
511 )
512 .with_dependencies(&files.dependencies)
513 .with_dependency_gaps(&files.dependency_gaps);
514 let mut prepared = BTreeSet::new();
515 for file in &files.definitions {
516 let adapter = source_adapter::adapter_for_language(file.language);
517 if prepared.insert(adapter.name()) {
518 if let Err(error) = adapter.prepare_session(&files.definitions, &mut extraction_session)
519 {
520 corpus.incomplete = true;
521 diagnostics
522 .errors
523 .push(format!("failed to prepare source evidence: {error:#}"));
524 return;
525 }
526 }
527 }
528 for file in &files.definitions {
529 let adapter = source_adapter::adapter_for_language(file.language);
533 if let Some(excluded) = adapter.inspect_source(file, &config.registrations) {
534 corpus.incomplete = true;
540 corpus.excluded_sources.push((file.path.clone(), excluded));
541 continue;
542 }
543 match adapter
544 .extract_file_with_session(file, &mut extraction_session)
545 .with_context(|| format!("failed to analyze {}", file.path.display()))
546 {
547 Ok(extracted) => {
548 if adapter.supports_indirect_usage() {
549 corpus.indirect_usage.extend(extracted.indirect_usage);
550 corpus.incomplete |= corpus.indirect_usage.unknown;
551 }
552 corpus.parameter_types.extend(extracted.parameter_types);
553 if !extracted.definitions.is_empty() {
554 corpus.definition_files_with_definitions += 1;
555 }
556 corpus.definitions.extend(extracted.definitions);
557 collect_extraction_diagnostics(
558 config,
559 file,
560 changed_files,
561 extracted.diagnostics,
562 diagnostics,
563 &mut corpus.incomplete,
564 );
565 }
566 Err(error) => {
570 if adapter.supports_indirect_usage() {
571 corpus.indirect_usage.unknown = true;
572 corpus.incomplete = true;
573 }
574 diagnostics.errors.push(format!("{error:#}"));
575 }
576 }
577 }
578 let finalization = extraction_session.finalize(&files.definitions, &mut corpus.definitions);
579 collect_finalization(finalization, corpus, diagnostics);
580 corpus.definitions.sort_by(|left, right| {
581 left.location
582 .path
583 .cmp(&right.location.path)
584 .then(left.location.line.cmp(&right.location.line))
585 .then(left.location.column.cmp(&right.location.column))
586 });
587 report_excluded_sources(config, &corpus.excluded_sources, diagnostics);
588 if corpus.definitions.is_empty() {
589 let message = if files.definitions.is_empty() {
590 "no step definitions were extracted because no definition source files were discovered"
591 .to_owned()
592 } else {
593 format!(
594 "definition extraction produced 0 definitions from {} discovered definition source file(s)",
595 files.definitions.len()
596 )
597 };
598 if config.require_definitions {
599 diagnostics.errors.push(message);
600 } else if !files.definitions.is_empty() {
601 diagnostics.warnings.push(message);
602 }
603 }
604}
605
606fn collect_finalization(
610 finalization: Result<source_adapter::SourceFinalization>,
611 corpus: &mut ExtractedCorpus,
612 diagnostics: &mut Diagnostics,
613) {
614 match finalization {
615 Ok(finalization) => {
616 if !finalization.uncertainties.is_empty() {
617 corpus.incomplete = true;
618 diagnostics.warnings.extend(
619 finalization
620 .uncertainties
621 .into_iter()
622 .map(|item| item.message),
623 );
624 corpus.definition_files_with_definitions = corpus
625 .definitions
626 .iter()
627 .map(|definition| &definition.location.path)
628 .collect::<BTreeSet<_>>()
629 .len();
630 }
631 }
632 Err(error) => {
633 corpus.incomplete = true;
634 corpus.definitions.clear();
635 corpus.definition_files_with_definitions = 0;
636 diagnostics
637 .errors
638 .push(format!("failed to finalize source evidence: {error:#}"));
639 }
640 }
641}
642
643const MAX_LISTED_EXCLUDED_SOURCES: usize = 10;
645
646fn report_excluded_sources(
652 config: &Config,
653 excluded: &[(PathBuf, source_adapter::SourceExclusion)],
654 diagnostics: &mut Diagnostics,
655) {
656 if excluded.is_empty() {
657 return;
658 }
659 let listed = excluded
660 .iter()
661 .take(MAX_LISTED_EXCLUDED_SOURCES)
662 .map(|(path, reason)| {
663 let relative = path
664 .strip_prefix(&config.root)
665 .unwrap_or(path)
666 .to_string_lossy()
667 .replace('\\', "/");
668 format!("{relative} ({})", reason.as_str())
669 })
670 .collect::<Vec<_>>()
671 .join(", ");
672 let remainder = excluded.len().saturating_sub(MAX_LISTED_EXCLUDED_SOURCES);
673 let suffix = if remainder == 0 {
674 String::new()
675 } else {
676 format!(", and {remainder} more")
677 };
678 diagnostics.warnings.push(format!(
679 "excluded {} discovered definition source file(s) as generated or non-source content: {listed}{suffix}",
680 excluded.len()
681 ));
682}
683
684fn collect_extraction_diagnostics(
689 config: &Config,
690 file: &source_adapter::SourceFile,
691 changed_files: &Option<BTreeSet<PathBuf>>,
692 extracted: Vec<source_adapter::ExtractionDiagnostic>,
693 diagnostics: &mut Diagnostics,
694 corpus_incomplete: &mut bool,
695) {
696 let changed_or_full_run = changed_files
697 .as_ref()
698 .is_none_or(|changed| changed.contains(&file.path));
699 for diagnostic in extracted {
700 let message = format!(
701 "{}: {}",
702 diagnostic.location.display(&config.root),
703 diagnostic.message
704 );
705 let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
706 let unparseable = source_adapter::is_unparseable_diagnostic(&diagnostic);
707 *corpus_incomplete |= completeness;
711 match diagnostic.level {
712 ExtractionDiagnosticLevel::Warning if unparseable && config.fail_on_unparseable => {
716 diagnostics.errors.push(message);
717 }
718 ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
719 diagnostics.warnings.push(message);
720 }
721 ExtractionDiagnosticLevel::Warning => {}
722 ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
725 }
726 }
727}
728
729fn extract_feature_steps(
733 config: &Config,
734 files: &discovery::DiscoveredFiles,
735 diagnostics: &mut Diagnostics,
736 corpus: &mut ExtractedCorpus,
737) {
738 for file in &files.features {
739 match gherkin::extract_file_with_format(&file.path, file.format) {
740 Ok(extracted) => {
741 corpus.parsed_feature_files += 1;
742 if extracted.is_empty()
743 && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
744 {
745 corpus.feature_files_without_steps += 1;
746 corpus.incomplete = true;
747 let display = file
748 .path
749 .strip_prefix(&config.root)
750 .unwrap_or(&file.path)
751 .to_string_lossy()
752 .replace('\\', "/");
753 diagnostics.warnings.push(format!(
754 "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
755 ));
756 } else {
757 corpus.feature_steps.extend(extracted);
758 }
759 }
760 Err(error) => {
762 corpus.incomplete = true;
763 diagnostics.errors.push(format!("{error:#}"));
764 }
765 }
766 }
767 if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
768 let message = if corpus.parsed_feature_files == 0 {
769 "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
770 } else {
771 format!(
772 "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
773 files.features.len() - corpus.parsed_feature_files
774 )
775 };
776 if config.require_features {
777 diagnostics.errors.push(message);
778 } else if !files.definitions.is_empty() {
779 diagnostics.warnings.push(message);
780 }
781 }
782 corpus.feature_steps.sort_by(|left, right| {
783 left.location
784 .path
785 .cmp(&right.location.path)
786 .then(left.location.line.cmp(&right.location.line))
787 .then(left.location.column.cmp(&right.location.column))
788 });
789}
790
791fn analyze_corpus(
796 config: &Config,
797 extracted: ExtractedCorpus,
798 diagnostics: &mut Diagnostics,
799 collect_pairs: bool,
800) -> Result<AnalyzedCorpus> {
801 let ExtractedCorpus {
802 parameter_types,
803 parameter_language,
804 indirect_usage,
805 definitions,
806 feature_steps,
807 excluded_sources: _,
809 definition_files_with_definitions,
810 parsed_feature_files,
811 feature_files_without_steps,
812 incomplete: mut corpus_incomplete,
813 } = extracted;
814 let mut effective_config = config.clone();
815 let (patterns, type_diagnostics) = match parameter_language {
816 Some(language) => source_adapter::adapter_for_language(language)
817 .resolve_parameter_types(parameter_types, &config.parameter_types),
818 None => (config.parameter_types.clone(), Vec::new()),
819 };
820 effective_config.parameter_types = patterns;
821 corpus_incomplete |= !type_diagnostics.is_empty();
822 diagnostics.warnings.extend(type_diagnostics);
823 let config = &effective_config;
824 let mut pair_records = Vec::new();
825 let sink = collect_pairs.then_some(&mut pair_records);
826 let analyzed =
827 analysis::analyze_for_cli(&indirect_usage, definitions, feature_steps, config, sink);
828 let (analysis, census, unmatched_suppressions) = analyzed?;
829 for index in unmatched_suppressions.indices {
830 let suppression = &config.suppressions[index];
831 diagnostics.warnings.push(format!(
832 "suppression {} for {} matched no step definitions",
833 index + 1,
834 suppression.rule
835 ));
836 }
837 if unmatched_suppressions.truncated {
838 diagnostics.warnings.push(
839 "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
840 .to_owned(),
841 );
842 }
843
844 let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
847 if config.fail_on_incomplete {
848 diagnostics.errors.extend(analysis.incomplete);
849 if corpus_incomplete {
850 diagnostics.errors.push(
851 "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
852 );
853 }
854 } else {
855 diagnostics.warnings.extend(analysis.incomplete);
856 }
857 Ok(AnalyzedCorpus {
858 result: analysis.result,
859 census,
860 definition_files_with_definitions,
861 parsed_feature_files,
862 feature_files_without_steps,
863 corpus_incomplete,
864 run_incomplete,
865 pair_records,
866 })
867}
868
869fn apply_finding_modes(
873 result: &mut crate::model::AnalysisResult,
874 files: &discovery::DiscoveredFiles,
875 changed_files: &Option<BTreeSet<PathBuf>>,
876 parsed_feature_files: usize,
877 feature_files_without_steps: usize,
878) {
879 if parsed_feature_files < files.features.len()
883 || feature_files_without_steps > 0
884 || files.features.is_empty()
885 {
886 result
887 .findings
888 .retain(|finding| finding.rule != Rule::UnusedDefinition);
889 }
890 if let Some(changed) = changed_files {
891 modes::retain_changed_findings(&mut result.findings, changed);
892 }
893}
894
895fn apply_baseline_mode(
897 config: &Config,
898 options: &CheckOptions,
899 result: &mut crate::model::AnalysisResult,
900 run_incomplete: bool,
901 diagnostics: &mut Diagnostics,
902) -> Result<Option<modes::BaselineOutcome>> {
903 if let Some(revision) = &options.baseline_from_ref {
904 let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
905 let mut base_config = config.clone();
912 base_config.root = root;
913 let files = discovery::discover(&base_config)?;
914 let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
915 let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
916 let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics, false)?;
917 if !files.definitions.is_empty()
925 && base.result.definitions.is_empty()
926 && !result.definitions.is_empty()
927 {
928 bail!(
929 "baseline revision `{revision}` is incomplete: it kept no step definitions while the current tree has {}, so no finding could be matched against it: {}",
930 result.definitions.len(),
931 base_diagnostics
932 .errors
933 .iter()
934 .chain(&base_diagnostics.warnings)
935 .cloned()
936 .collect::<Vec<_>>()
937 .join("; ")
938 );
939 }
940 if !base_diagnostics.errors.is_empty()
941 || (files.features.is_empty() && !base.result.definitions.is_empty())
942 {
943 bail!(
944 "baseline revision `{revision}` is incomplete: {}",
945 base_diagnostics
946 .errors
947 .iter()
948 .chain(&base_diagnostics.warnings)
949 .cloned()
950 .collect::<Vec<_>>()
951 .join("; ")
952 );
953 }
954 if base.run_incomplete {
955 diagnostics.warnings.push(format!(
956 "baseline revision `{revision}` is incomplete, so a finding it did not extract may appear as new; pass --fail-on-incomplete to reject an incomplete baseline"
957 ));
958 }
959 apply_finding_modes(
960 &mut base.result,
961 &files,
962 &None,
963 base.parsed_feature_files,
964 base.feature_files_without_steps,
965 );
966 return modes::apply_reference_baseline(
967 &mut result.findings,
968 &base.result.findings,
969 revision,
970 )
971 .map(Some);
972 }
973 let Some(path) = &options.baseline else {
974 return Ok(None);
975 };
976 let path = if path.is_absolute() {
977 path.clone()
978 } else {
979 config.root.join(path)
980 };
981 if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
982 diagnostics.warnings.push(format!(
983 "baseline {} was not updated because analysis is incomplete",
984 path.display()
985 ));
986 return Ok(None);
987 }
988 let outcome = if options.update_baseline {
989 modes::update_baseline(&mut result.findings, &path)?
990 } else {
991 modes::apply_baseline(&mut result.findings, &path)?
992 };
993 if options.update_baseline {
994 diagnostics.warnings.push(format!(
995 "updated baseline {}: {} added, {} removed, {} total",
996 path.display(),
997 outcome.added,
998 outcome.removed,
999 outcome.suppressed
1000 ));
1001 }
1002 Ok(Some(outcome))
1003}
1004
1005fn write_reports(
1008 config: &Config,
1009 files: &discovery::DiscoveredFiles,
1010 analyzed: &AnalyzedCorpus,
1011 diagnostics: &Diagnostics,
1012 timings: PhaseTimings,
1013) -> Result<()> {
1014 let corpus = reporters::CorpusCensus {
1015 definition_files: files.definitions.len(),
1016 definition_files_with_definitions: analyzed.definition_files_with_definitions,
1017 definitions_extracted: analyzed.result.definitions.len(),
1018 feature_files: files.features.len(),
1019 feature_files_parsed: analyzed.parsed_feature_files,
1020 feature_files_without_steps: analyzed.feature_files_without_steps,
1021 incomplete: analyzed.corpus_incomplete,
1022 };
1023 let metrics = reporters::ExecutionMetrics::new(
1024 files.definitions.len(),
1025 files.features.len(),
1026 timings.discovery_ms,
1027 timings.parsing_ms,
1028 timings.analysis_ms,
1029 );
1030 let report_metadata = reporters::CliReportMetadata {
1031 corpus: &corpus,
1032 analysis: &analyzed.census,
1033 execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
1035 };
1036 let terminal_color = terminal_color_enabled(
1037 io::stdout().is_terminal(),
1038 std::env::var_os("NO_COLOR").as_deref(),
1039 );
1040 let mut stdout = io::stdout().lock();
1041 reporters::write_cli_reports(
1042 &analyzed.result,
1043 config,
1044 (!config.no_metrics).then_some(&metrics),
1045 &report_metadata,
1046 &mut stdout,
1047 terminal_color,
1048 )?;
1049 Ok(())
1050}
1051
1052struct EmitSink {
1055 path: PathBuf,
1056 label: &'static str,
1058 file: Option<fs::File>,
1059 created: Option<PathBuf>,
1061}
1062
1063impl EmitSink {
1064 fn open(
1068 config: &Config,
1069 options: &CheckOptions,
1070 path: &Path,
1071 label: &'static str,
1072 earlier: Option<&Self>,
1073 ) -> Result<Self> {
1074 let path = config.root.join(path);
1075 let context = || format!("failed to write {label} {}", path.display());
1076 let (file, created) = match open_destination(&path) {
1077 Ok(opened) => opened,
1078 Err(_) if fs::metadata(&path).is_ok_and(|metadata| !metadata.is_file()) => {
1080 bail!("{label} {} is not a regular file", path.display())
1081 }
1082 Err(error) => return Err(error).with_context(context),
1083 };
1084 if !file.metadata().with_context(context)?.is_file() {
1087 bail!("{label} {} is not a regular file", path.display());
1088 }
1089 let mut sink = Self {
1090 file: Some(file),
1091 created: created.then(|| path.clone()),
1092 path: path.clone(),
1093 label,
1094 };
1095 let canonical = fs::canonicalize(&path).with_context(context)?;
1098 if let Some(created) = &mut sink.created {
1100 created.clone_from(&canonical);
1101 }
1102 let written = reporters::REPORT_FILE_NAMES
1103 .iter()
1104 .map(|name| config.output_path(name))
1105 .chain(options.baseline.iter().map(|path| config.root.join(path)))
1106 .chain(earlier.map(|sink| sink.path.clone()));
1107 for target in written {
1108 let open = sink.file.as_ref().expect("an open sink holds its file");
1110 let same = match same_file_as(open, &target) {
1111 Ok(same) => same,
1112 Err(error)
1114 if matches!(
1115 error.kind(),
1116 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
1117 ) =>
1118 {
1119 false
1120 }
1121 Err(error) => bail!(
1123 "cannot verify that {} {} is not {}: {error}",
1124 sink.label,
1125 sink.path.display(),
1126 target.display()
1127 ),
1128 };
1129 if same || fs::canonicalize(&target).is_ok_and(|target| target == canonical) {
1130 bail!(
1131 "{} {} would overwrite {}; choose a different file",
1132 sink.label,
1133 sink.path.display(),
1134 target.display()
1135 );
1136 }
1137 }
1138 Ok(sink)
1139 }
1140
1141 fn write(mut self, fill: impl FnOnce(&mut dyn Write) -> Result<()>) -> Result<()> {
1143 let context = || format!("failed to write {} {}", self.label, self.path.display());
1144 let file = self.file.take().expect("an open sink holds its file");
1145 file.set_len(0).with_context(context)?;
1146 let mut file = io::BufWriter::new(file);
1147 fill(&mut file).with_context(context)?;
1148 file.flush().with_context(context)?;
1149 self.created = None;
1150 Ok(())
1151 }
1152}
1153
1154fn open_destination(path: &Path) -> io::Result<(fs::File, bool)> {
1159 let mut path = path.to_path_buf();
1160 loop {
1161 let created = fs::OpenOptions::new()
1162 .write(true)
1163 .create_new(true)
1164 .open(&path);
1165 let error = match created {
1166 Ok(file) => return Ok((file, true)),
1167 Err(error) => error,
1168 };
1169 if error.kind() != io::ErrorKind::AlreadyExists {
1170 return Err(error);
1171 }
1172 let mut existing = fs::OpenOptions::new();
1173 existing.write(true);
1174 #[cfg(unix)]
1175 std::os::unix::fs::OpenOptionsExt::custom_flags(&mut existing, libc::O_NONBLOCK);
1176 match existing.open(&path) {
1177 Ok(file) => return Ok((file, false)),
1178 Err(error) if error.kind() == io::ErrorKind::NotFound && path.is_symlink() => {
1179 let target = fs::read_link(&path)?;
1180 path = path
1181 .parent()
1182 .map_or(target.clone(), |parent| parent.join(&target));
1183 }
1184 Err(error) => return Err(error),
1185 }
1186 }
1187}
1188
1189#[cfg(unix)]
1192fn same_file_as(file: &fs::File, target: &Path) -> io::Result<bool> {
1193 use std::os::unix::fs::MetadataExt;
1194 let (open, other) = (file.metadata()?, fs::metadata(target)?);
1195 Ok(open.dev() == other.dev() && open.ino() == other.ino())
1196}
1197
1198#[cfg(not(unix))]
1201fn same_file_as(file: &fs::File, target: &Path) -> io::Result<bool> {
1202 Ok(same_file::Handle::from_file(file.try_clone()?)? == same_file::Handle::from_path(target)?)
1203}
1204
1205impl Drop for EmitSink {
1206 fn drop(&mut self) {
1208 drop(self.file.take());
1209 if let Some(created) = self.created.take() {
1210 let _ = fs::remove_file(created);
1212 }
1213 }
1214}
1215
1216fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
1218 is_terminal && !no_color.is_some_and(|value| !value.is_empty())
1219}
1220
1221fn write_diagnostics(
1224 config: &Config,
1225 options: &CheckOptions,
1226 files: &discovery::DiscoveredFiles,
1227 diagnostics: &Diagnostics,
1228) -> Result<()> {
1229 if !options.explain_discovery
1230 && diagnostics.warnings.is_empty()
1231 && diagnostics.errors.is_empty()
1232 {
1233 return Ok(());
1234 }
1235 let mut stderr = io::stderr().lock();
1236 if options.explain_discovery {
1237 writeln!(
1238 stderr,
1239 "cuke-dedup: feature patterns: {} ({})",
1240 config.features.join(", "),
1241 config.feature_pattern_origin
1242 )?;
1243 for file in &files.features {
1244 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
1245 writeln!(
1246 stderr,
1247 "cuke-dedup: feature {} [{}] via {}",
1248 relative.display(),
1249 file.format.as_str(),
1250 file.pattern
1251 )?;
1252 }
1253 for file in &files.definitions {
1254 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
1255 writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
1256 }
1257 }
1258 for warning in &diagnostics.warnings {
1259 writeln!(stderr, "cuke-dedup: warning: {warning}")?;
1260 }
1261 for error in &diagnostics.errors {
1262 writeln!(stderr, "cuke-dedup: {error}")?;
1263 }
1264 Ok(())
1265}
1266
1267fn determine_exit_code(
1270 config: &Config,
1271 options: &CheckOptions,
1272 result: &crate::model::AnalysisResult,
1273 baseline: Option<modes::BaselineOutcome>,
1274 diagnostics: &Diagnostics,
1275) -> Result<i32> {
1276 if !diagnostics.errors.is_empty() {
1277 return Ok(2);
1278 }
1279 let duplication = DuplicationThreshold::from_result(result, config.threshold);
1280 let has_non_duplication_errors = result.findings.iter().any(|finding| {
1281 finding.is_active()
1282 && finding.severity == Severity::Error
1283 && !finding.rule.contributes_to_duplication_threshold()
1284 });
1285 let new_findings_exceeded = options
1286 .fail_on_new
1287 .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
1288 Ok(i32::from(
1289 has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
1290 ))
1291}
1292
1293fn elapsed_ms(started: Instant) -> f64 {
1295 started.elapsed().as_secs_f64() * 1_000.0
1296}
1297
1298fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
1300 let (rule, severity) = value
1301 .split_once('=')
1302 .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
1303 Ok((rule.parse()?, severity.parse()?))
1304}
1305
1306fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
1308 value
1309 .parse::<usize>()
1310 .ok()
1311 .filter(|value| *value > 0)
1312 .ok_or_else(|| "expected an integer greater than zero".to_owned())
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317 use super::*;
1318
1319 #[test]
1322 fn finalization_failure_discards_provisional_definitions_and_reports_incompleteness() {
1323 let root = tempfile::tempdir().unwrap();
1324 let config = Config::load(root.path(), ConfigOverrides::default()).unwrap();
1325 std::fs::write(root.path().join("steps.ts"), "import { Given, Then } from '@cucumber/cucumber'; Given('same', () => work()); Then('same', () => work());").unwrap();
1326 let files = discovery::discover(&config).unwrap();
1327 let mut diagnostics = discovery_diagnostics(&config, &files, &None);
1328 let mut corpus = extract_corpus(&config, &files, &None, &mut diagnostics);
1329 assert_eq!(corpus.definitions.len(), 2);
1330 let before = corpus.definitions.clone();
1331 collect_finalization(Ok(Default::default()), &mut corpus, &mut diagnostics);
1332 assert_eq!(corpus.definitions, before);
1333 collect_finalization(
1334 Err(anyhow::anyhow!("adapter unavailable")),
1335 &mut corpus,
1336 &mut diagnostics,
1337 );
1338 assert!(corpus.incomplete);
1339 assert!(corpus.definitions.is_empty());
1340 assert_eq!(corpus.definition_files_with_definitions, 0);
1341 assert!(diagnostics
1342 .errors
1343 .iter()
1344 .any(|e| e == "failed to finalize source evidence: adapter unavailable"));
1345 let analyzed = analyze_corpus(&config, corpus, &mut diagnostics, false).unwrap();
1346 assert!(analyzed.run_incomplete);
1347 assert!(analyzed.result.findings.is_empty());
1348 }
1349
1350 #[test]
1352 fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
1353 use std::ffi::OsStr;
1354
1355 assert!(terminal_color_enabled(true, None));
1356 assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
1357 assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
1358 assert!(!terminal_color_enabled(false, None));
1359 assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
1360 }
1361
1362 #[test]
1364 fn parses_rule_override() {
1365 assert_eq!(
1366 parse_rule_override("duplicate-matcher=warning"),
1367 Ok((Rule::DuplicateMatcher, Severity::Warning))
1368 );
1369 assert!(parse_rule_override("unknown=error").is_err());
1370 }
1371
1372 #[test]
1374 fn candidate_limit_cli_values_must_be_positive() {
1375 assert_eq!(parse_positive_usize("7"), Ok(7));
1376 assert!(parse_positive_usize("0").is_err());
1377 assert!(parse_positive_usize("-1").is_err());
1378 assert!(parse_positive_usize("many").is_err());
1379 }
1380
1381 #[test]
1384 fn unparseable_diagnostics_are_tolerated_by_default_and_escalated_by_the_flag() {
1385 use crate::discovery::SourceLanguage;
1386 use crate::model::SourceLocation;
1387 use crate::source_adapter::{ExtractionDiagnostic, SourceFile};
1388
1389 let directory = tempfile::tempdir().unwrap();
1390 let file = SourceFile {
1391 path: directory.path().join("steps.ts"),
1392 language: SourceLanguage::TypeScript,
1393 };
1394 let unparseable = || {
1395 ExtractionDiagnostic::new(
1396 ExtractionDiagnosticLevel::Warning,
1397 SourceLocation::new("steps.ts", 1, 1, 1, 1),
1398 format!(
1399 "{}, so analysis is incomplete",
1400 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX
1401 ),
1402 )
1403 };
1404 let route = |config: &Config, diagnostic: ExtractionDiagnostic| {
1405 let mut diagnostics = Diagnostics {
1406 warnings: Vec::new(),
1407 errors: Vec::new(),
1408 };
1409 let mut incomplete = false;
1410 collect_extraction_diagnostics(
1411 config,
1412 &file,
1413 &None,
1414 vec![diagnostic],
1415 &mut diagnostics,
1416 &mut incomplete,
1417 );
1418 (diagnostics, incomplete)
1419 };
1420
1421 let mut config =
1422 Config::load(directory.path(), ConfigOverrides::default()).expect("config loads");
1423
1424 let (diagnostics, incomplete) = route(&config, unparseable());
1426 assert!(incomplete);
1427 assert!(diagnostics.errors.is_empty());
1428 assert_eq!(diagnostics.warnings.len(), 1);
1429
1430 config.fail_on_unparseable = true;
1433 let (diagnostics, incomplete) = route(&config, unparseable());
1434 assert!(incomplete);
1435 assert_eq!(diagnostics.errors.len(), 1);
1436 assert!(diagnostics.warnings.is_empty());
1437
1438 let dynamic = ExtractionDiagnostic::new(
1441 ExtractionDiagnosticLevel::Warning,
1442 SourceLocation::new("steps.ts", 2, 1, 2, 1),
1443 "dynamic or unsupported step matcher cannot be analyzed statically",
1444 );
1445 let (diagnostics, incomplete) = route(&config, dynamic);
1446 assert!(incomplete);
1447 assert!(diagnostics.errors.is_empty());
1448 assert_eq!(diagnostics.warnings.len(), 1);
1449 let unrelated = ExtractionDiagnostic::new(
1450 ExtractionDiagnosticLevel::Warning,
1451 SourceLocation::new("steps.ts", 2, 1, 2, 1),
1452 "unrelated extractor warning",
1453 );
1454 let (diagnostics, incomplete) = route(&config, unrelated);
1455 assert!(!incomplete);
1456 assert!(diagnostics.errors.is_empty());
1457 assert_eq!(diagnostics.warnings.len(), 1);
1458 }
1459
1460 #[test]
1464 fn typed_adapter_diagnostics_route_independently_of_language_and_wording() {
1465 use crate::model::SourceLocation;
1466 use crate::source_adapter::{
1467 ExtractionDiagnostic, ExtractionDiagnosticKind as Kind, SourceFile, SourceLanguage,
1468 };
1469 let directory = tempfile::tempdir().unwrap();
1470 let file = SourceFile {
1471 path: directory.path().join("steps.ts"),
1472 language: SourceLanguage::TypeScript,
1473 };
1474 let mut config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1475 for strict in [false, true] {
1476 config.fail_on_unparseable = strict;
1477 for (kind, message, incomplete, parse_error) in [
1478 (Kind::Other, "adapter advisory", false, false),
1479 (Kind::Incomplete, "external glue unavailable", true, false),
1480 (Kind::Unparseable, "adapter syntax failure", true, true),
1481 (
1482 Kind::Other,
1483 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1484 false,
1485 false,
1486 ),
1487 (
1488 Kind::Incomplete,
1489 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1490 true,
1491 false,
1492 ),
1493 ] {
1494 for level in [
1495 ExtractionDiagnosticLevel::Warning,
1496 ExtractionDiagnosticLevel::Error,
1497 ] {
1498 let mut diagnostics = Diagnostics {
1499 warnings: vec![],
1500 errors: vec![],
1501 };
1502 let mut actual_incomplete = false;
1503 collect_extraction_diagnostics(
1504 &config,
1505 &file,
1506 &None,
1507 vec![ExtractionDiagnostic::with_kind(
1508 kind,
1509 level,
1510 SourceLocation::new(&file.path, 1, 1, 1, 1),
1511 message,
1512 )],
1513 &mut diagnostics,
1514 &mut actual_incomplete,
1515 );
1516 assert_eq!(actual_incomplete, incomplete, "{kind:?} strict={strict}");
1517 let error =
1518 level == ExtractionDiagnosticLevel::Error || (strict && parse_error);
1519 assert_eq!(diagnostics.errors.len(), usize::from(error));
1520 assert_eq!(diagnostics.warnings.len(), usize::from(!error));
1521 }
1522 }
1523 }
1524 }
1525
1526 #[test]
1528 fn supports_implicit_and_explicit_check_forms() {
1529 let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
1530 .expect("implicit invocation");
1531 assert!(implicit.command.is_none());
1532 let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
1533 .expect("explicit invocation");
1534 assert!(matches!(explicit.command, Some(Command::Check { .. })));
1535 assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
1536 }
1537}