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(long, global = true, value_delimiter = ',')]
126 exclude: Option<Vec<String>>,
127
128 #[arg(long, global = true)]
130 no_default_excludes: bool,
131
132 #[arg(long, global = true)]
134 include_hidden: bool,
135
136 #[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
138 rules: Vec<(Rule, Severity)>,
139
140 #[arg(long, global = true, value_name = "GIT_REF")]
142 changed_since: Option<String>,
143
144 #[arg(
146 long,
147 global = true,
148 value_name = "BASELINE.json",
149 group = "baseline_source"
150 )]
151 baseline: Option<PathBuf>,
152
153 #[arg(long, global = true, value_name = "GIT_REF", group = "baseline_source",
155 conflicts_with_all = ["baseline", "update_baseline"])]
156 baseline_from_ref: Option<String>,
157
158 #[arg(
160 long,
161 global = true,
162 requires = "baseline",
163 conflicts_with = "changed_since"
164 )]
165 update_baseline: bool,
166
167 #[arg(
169 long,
170 global = true,
171 value_name = "COUNT",
172 num_args = 0..=1,
173 default_missing_value = "0",
174 requires = "baseline_source",
175 conflicts_with = "update_baseline"
176 )]
177 fail_on_new: Option<usize>,
178}
179
180struct Invocation {
181 config: Config,
182 options: CheckOptions,
183}
184
185#[derive(Default)]
186struct Diagnostics {
187 warnings: Vec<String>,
188 errors: Vec<String>,
189}
190
191struct ExtractedCorpus {
192 parameter_types: Vec<source_adapter::SourceParameterType>,
193 parameter_language: Option<source_adapter::SourceLanguage>,
194 indirect_usage: source_adapter::IndirectStepUsage,
195 definitions: Vec<crate::model::StepDefinition>,
196 feature_steps: Vec<crate::model::FeatureStep>,
197 excluded_sources: Vec<(PathBuf, source_adapter::SourceExclusion)>,
198 definition_files_with_definitions: usize,
199 parsed_feature_files: usize,
200 feature_files_without_steps: usize,
201 incomplete: bool,
202}
203
204struct AnalyzedCorpus {
205 result: crate::model::AnalysisResult,
206 census: analysis::AnalysisCensus,
207 definition_files_with_definitions: usize,
208 parsed_feature_files: usize,
209 feature_files_without_steps: usize,
210 corpus_incomplete: bool,
211 run_incomplete: bool,
212}
213
214struct PhaseTimings {
215 discovery_ms: f64,
216 parsing_ms: f64,
217 analysis_ms: f64,
218}
219
220pub fn run_cli() -> Result<i32> {
222 execute(Cli::parse())
223}
224
225fn execute(cli: Cli) -> Result<i32> {
228 let invocation = resolve_invocation(cli)?;
229 let config = &invocation.config;
230 let options = &invocation.options;
231 if options.print_config {
232 println!(
233 "{}",
234 serde_json::to_string_pretty(config)
235 .context("failed to serialize effective configuration")?
236 );
237 return Ok(0);
238 }
239 let changed_files = resolve_changed_files(config, options.changed_since.as_deref())?;
240 let discovery_started = Instant::now();
241 let files = discovery::discover(config)?;
242 let discovery_ms = elapsed_ms(discovery_started);
243
244 let parsing_started = Instant::now();
245 let mut diagnostics = discovery_diagnostics(config, &files, &changed_files);
246 let extracted = extract_corpus(config, &files, &changed_files, &mut diagnostics);
247 let parsing_ms = elapsed_ms(parsing_started);
248 let analysis_started = Instant::now();
249 let mut analyzed = analyze_corpus(config, extracted, &mut diagnostics)?;
250 let analysis_ms = elapsed_ms(analysis_started);
251 apply_finding_modes(
252 &mut analyzed.result,
253 &files,
254 &changed_files,
255 analyzed.parsed_feature_files,
256 analyzed.feature_files_without_steps,
257 );
258 let fingerprints = options
260 .emit_fingerprints
261 .as_deref()
262 .map(|path| FingerprintSink::open(config, options, path))
263 .transpose()?;
264 let baseline_outcome = apply_baseline_mode(
265 config,
266 options,
267 &mut analyzed.result,
268 analyzed.run_incomplete,
269 &mut diagnostics,
270 )?;
271 if let Some(sink) = fingerprints {
272 sink.write(&config.root, &analyzed.result.definitions)?;
273 }
274 write_reports(
275 config,
276 &files,
277 &analyzed,
278 &diagnostics,
279 PhaseTimings {
280 discovery_ms,
281 parsing_ms,
282 analysis_ms,
283 },
284 )?;
285 write_diagnostics(config, options, &files, &diagnostics)?;
286 determine_exit_code(
287 config,
288 options,
289 &analyzed.result,
290 baseline_outcome,
291 &diagnostics,
292 )
293}
294
295fn resolve_invocation(cli: Cli) -> Result<Invocation> {
298 if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
299 bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
300 }
301 let root = match cli.command {
302 Some(Command::Check { path }) => path,
303 None => cli.path,
304 };
305 let options = cli.options.clone();
306 let config = Config::load_for_cli(
307 &root,
308 ConfigOverrides {
309 config_file: cli.options.config,
310 definitions: cli.options.definitions,
311 features: cli.options.features,
312 exclude: cli.options.exclude,
313 exclude_defaults: cli.options.no_default_excludes.then_some(false),
314 include_hidden: cli.options.include_hidden.then_some(true),
315 reporters: cli.options.reporters,
316 output: cli.options.output,
317 threshold: cli.options.threshold,
318 require_features: cli.options.require_features.then_some(true),
319 no_metrics: cli.options.no_metrics.then_some(true),
320 rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
321 },
322 CliConfigOverrides {
323 require_definitions: cli.options.require_definitions.then_some(true),
324 fail_on_incomplete: cli.options.fail_on_incomplete.then_some(true),
325 fail_on_unparseable: cli.options.fail_on_unparseable.then_some(true),
326 max_candidate_comparisons: cli.options.max_candidate_comparisons,
327 max_structural_class_comparisons: cli.options.max_structural_class_comparisons,
328 },
329 )?;
330 Ok(Invocation { config, options })
331}
332
333fn resolve_changed_files(
337 config: &Config,
338 changed_since: Option<&str>,
339) -> Result<Option<BTreeSet<PathBuf>>> {
340 let Some(base) = changed_since else {
341 return Ok(None);
342 };
343 modes::ensure_changed_root_is_trackable(&config.root)?;
344 Ok(Some(modes::git_changed_files(&config.root, base)?))
345}
346
347fn discovery_diagnostics(
352 config: &Config,
353 files: &discovery::DiscoveredFiles,
354 changed_files: &Option<BTreeSet<PathBuf>>,
355) -> Diagnostics {
356 let mut diagnostics = Diagnostics {
357 warnings: config.config_warnings.clone(),
358 errors: files.errors.clone(),
359 };
360 for pattern in &files.unmatched_feature_patterns {
361 diagnostics.warnings.push(format!(
362 "feature pattern `{pattern}` from {} matched no files",
363 config.feature_pattern_origin
364 ));
365 }
366 for pattern in &files.unmatched_definition_patterns {
367 diagnostics
368 .warnings
369 .push(format!("definition pattern `{pattern}` matched no files"));
370 }
371 if !files.skipped_definitions.is_empty()
372 && (!config.definitions.is_empty() || files.definitions.is_empty())
373 {
374 diagnostics
375 .warnings
376 .push(skipped_definitions_warning(&files.skipped_definitions));
377 }
378 if files.definitions.is_empty() && config.definitions.is_empty() {
379 diagnostics
380 .warnings
381 .push("automatic discovery found no supported step-definition source files".to_owned());
382 }
383 if files.features.is_empty() {
384 let message = format!(
385 "no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
386 config.feature_pattern_origin
387 );
388 if config.require_features {
389 diagnostics.errors.push(message);
390 } else if !files.definitions.is_empty() {
391 diagnostics.warnings.push(message);
392 }
393 }
394 if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
395 diagnostics.warnings.push(
396 "--changed-since found no tracked or untracked files; no findings will be reported"
397 .to_owned(),
398 );
399 }
400 diagnostics
401}
402
403fn skipped_definitions_warning(skipped: &[source_adapter::SourceFile]) -> String {
405 let registrations: Vec<_> = source_adapter::SOURCE_ADAPTER_REGISTRY
406 .iter()
407 .filter(|entry| {
408 skipped
409 .iter()
410 .any(|file| entry.adapter.language() == file.language)
411 })
412 .collect();
413 let adapters: BTreeSet<_> = registrations
414 .iter()
415 .map(|entry| entry.adapter.name())
416 .collect();
417 let suffixes: Vec<_> = registrations
418 .iter()
419 .map(|entry| format!("`{}`", entry.suffix))
420 .collect();
421 format!(
422 "{} {} source file(s) were not analyzed: opt-in languages need a definition pattern that names their suffix ({}), such as `features/**/*{}`",
423 skipped.len(),
424 adapters.into_iter().collect::<Vec<_>>().join(", "),
425 suffixes.join(", "),
426 registrations.first().map_or("", |entry| entry.suffix),
427 )
428}
429
430fn extract_corpus(
433 config: &Config,
434 files: &discovery::DiscoveredFiles,
435 changed_files: &Option<BTreeSet<PathBuf>>,
436 diagnostics: &mut Diagnostics,
437) -> ExtractedCorpus {
438 let mut corpus = ExtractedCorpus {
439 parameter_types: Vec::new(),
440 parameter_language: files.definitions.first().map(|file| file.language),
441 indirect_usage: source_adapter::IndirectStepUsage::default(),
442 definitions: Vec::new(),
443 feature_steps: Vec::new(),
444 excluded_sources: Vec::new(),
445 definition_files_with_definitions: 0,
446 parsed_feature_files: 0,
447 feature_files_without_steps: 0,
448 incomplete: false,
449 };
450 extract_definitions(config, files, changed_files, diagnostics, &mut corpus);
451 extract_feature_steps(config, files, diagnostics, &mut corpus);
452 corpus
453}
454
455fn extract_definitions(
457 config: &Config,
458 files: &discovery::DiscoveredFiles,
459 changed_files: &Option<BTreeSet<PathBuf>>,
460 diagnostics: &mut Diagnostics,
461 corpus: &mut ExtractedCorpus,
462) {
463 if let Some(first) = files.definitions.first() {
464 let domain = first.language.comparison_domain();
465 if files
466 .definitions
467 .iter()
468 .any(|file| file.language.comparison_domain() != domain)
469 {
470 corpus.incomplete = true;
471 diagnostics.errors.push("Ruby and JS/TS sources require separate analysis runs with explicit definition and feature roots".to_owned());
472 return;
473 }
474 }
475 let mut extraction_session = source_adapter::SourceExtractionSession::with_options(
476 &config.root,
477 &config.registrations,
478 &config.assertion_modules,
479 )
480 .with_dependencies(&files.dependencies)
481 .with_dependency_gaps(&files.dependency_gaps);
482 let mut prepared = BTreeSet::new();
483 for file in &files.definitions {
484 let adapter = source_adapter::adapter_for_language(file.language);
485 if prepared.insert(adapter.name()) {
486 if let Err(error) = adapter.prepare_session(&files.definitions, &mut extraction_session)
487 {
488 corpus.incomplete = true;
489 diagnostics
490 .errors
491 .push(format!("failed to prepare source evidence: {error:#}"));
492 return;
493 }
494 }
495 }
496 for file in &files.definitions {
497 let adapter = source_adapter::adapter_for_language(file.language);
501 if let Some(excluded) = adapter.inspect_source(file, &config.registrations) {
502 corpus.incomplete = true;
508 corpus.excluded_sources.push((file.path.clone(), excluded));
509 continue;
510 }
511 match adapter
512 .extract_file_with_session(file, &mut extraction_session)
513 .with_context(|| format!("failed to analyze {}", file.path.display()))
514 {
515 Ok(extracted) => {
516 if adapter.supports_indirect_usage() {
517 corpus.indirect_usage.extend(extracted.indirect_usage);
518 corpus.incomplete |= corpus.indirect_usage.unknown;
519 }
520 corpus.parameter_types.extend(extracted.parameter_types);
521 if !extracted.definitions.is_empty() {
522 corpus.definition_files_with_definitions += 1;
523 }
524 corpus.definitions.extend(extracted.definitions);
525 collect_extraction_diagnostics(
526 config,
527 file,
528 changed_files,
529 extracted.diagnostics,
530 diagnostics,
531 &mut corpus.incomplete,
532 );
533 }
534 Err(error) => {
538 if adapter.supports_indirect_usage() {
539 corpus.indirect_usage.unknown = true;
540 corpus.incomplete = true;
541 }
542 diagnostics.errors.push(format!("{error:#}"));
543 }
544 }
545 }
546 let finalization = extraction_session.finalize(&files.definitions, &mut corpus.definitions);
547 collect_finalization(finalization, corpus, diagnostics);
548 corpus.definitions.sort_by(|left, right| {
549 left.location
550 .path
551 .cmp(&right.location.path)
552 .then(left.location.line.cmp(&right.location.line))
553 .then(left.location.column.cmp(&right.location.column))
554 });
555 report_excluded_sources(config, &corpus.excluded_sources, diagnostics);
556 if corpus.definitions.is_empty() {
557 let message = if files.definitions.is_empty() {
558 "no step definitions were extracted because no definition source files were discovered"
559 .to_owned()
560 } else {
561 format!(
562 "definition extraction produced 0 definitions from {} discovered definition source file(s)",
563 files.definitions.len()
564 )
565 };
566 if config.require_definitions {
567 diagnostics.errors.push(message);
568 } else if !files.definitions.is_empty() {
569 diagnostics.warnings.push(message);
570 }
571 }
572}
573
574fn collect_finalization(
578 finalization: Result<source_adapter::SourceFinalization>,
579 corpus: &mut ExtractedCorpus,
580 diagnostics: &mut Diagnostics,
581) {
582 match finalization {
583 Ok(finalization) => {
584 if !finalization.uncertainties.is_empty() {
585 corpus.incomplete = true;
586 diagnostics.warnings.extend(
587 finalization
588 .uncertainties
589 .into_iter()
590 .map(|item| item.message),
591 );
592 corpus.definition_files_with_definitions = corpus
593 .definitions
594 .iter()
595 .map(|definition| &definition.location.path)
596 .collect::<BTreeSet<_>>()
597 .len();
598 }
599 }
600 Err(error) => {
601 corpus.incomplete = true;
602 corpus.definitions.clear();
603 corpus.definition_files_with_definitions = 0;
604 diagnostics
605 .errors
606 .push(format!("failed to finalize source evidence: {error:#}"));
607 }
608 }
609}
610
611const MAX_LISTED_EXCLUDED_SOURCES: usize = 10;
613
614fn report_excluded_sources(
620 config: &Config,
621 excluded: &[(PathBuf, source_adapter::SourceExclusion)],
622 diagnostics: &mut Diagnostics,
623) {
624 if excluded.is_empty() {
625 return;
626 }
627 let listed = excluded
628 .iter()
629 .take(MAX_LISTED_EXCLUDED_SOURCES)
630 .map(|(path, reason)| {
631 let relative = path
632 .strip_prefix(&config.root)
633 .unwrap_or(path)
634 .to_string_lossy()
635 .replace('\\', "/");
636 format!("{relative} ({})", reason.as_str())
637 })
638 .collect::<Vec<_>>()
639 .join(", ");
640 let remainder = excluded.len().saturating_sub(MAX_LISTED_EXCLUDED_SOURCES);
641 let suffix = if remainder == 0 {
642 String::new()
643 } else {
644 format!(", and {remainder} more")
645 };
646 diagnostics.warnings.push(format!(
647 "excluded {} discovered definition source file(s) as generated or non-source content: {listed}{suffix}",
648 excluded.len()
649 ));
650}
651
652fn collect_extraction_diagnostics(
657 config: &Config,
658 file: &source_adapter::SourceFile,
659 changed_files: &Option<BTreeSet<PathBuf>>,
660 extracted: Vec<source_adapter::ExtractionDiagnostic>,
661 diagnostics: &mut Diagnostics,
662 corpus_incomplete: &mut bool,
663) {
664 let changed_or_full_run = changed_files
665 .as_ref()
666 .is_none_or(|changed| changed.contains(&file.path));
667 for diagnostic in extracted {
668 let message = format!(
669 "{}: {}",
670 diagnostic.location.display(&config.root),
671 diagnostic.message
672 );
673 let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
674 let unparseable = source_adapter::is_unparseable_diagnostic(&diagnostic);
675 *corpus_incomplete |= completeness;
679 match diagnostic.level {
680 ExtractionDiagnosticLevel::Warning if unparseable && config.fail_on_unparseable => {
684 diagnostics.errors.push(message);
685 }
686 ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
687 diagnostics.warnings.push(message);
688 }
689 ExtractionDiagnosticLevel::Warning => {}
690 ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
693 }
694 }
695}
696
697fn extract_feature_steps(
701 config: &Config,
702 files: &discovery::DiscoveredFiles,
703 diagnostics: &mut Diagnostics,
704 corpus: &mut ExtractedCorpus,
705) {
706 for file in &files.features {
707 match gherkin::extract_file_with_format(&file.path, file.format) {
708 Ok(extracted) => {
709 corpus.parsed_feature_files += 1;
710 if extracted.is_empty()
711 && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
712 {
713 corpus.feature_files_without_steps += 1;
714 corpus.incomplete = true;
715 let display = file
716 .path
717 .strip_prefix(&config.root)
718 .unwrap_or(&file.path)
719 .to_string_lossy()
720 .replace('\\', "/");
721 diagnostics.warnings.push(format!(
722 "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
723 ));
724 } else {
725 corpus.feature_steps.extend(extracted);
726 }
727 }
728 Err(error) => {
730 corpus.incomplete = true;
731 diagnostics.errors.push(format!("{error:#}"));
732 }
733 }
734 }
735 if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
736 let message = if corpus.parsed_feature_files == 0 {
737 "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
738 } else {
739 format!(
740 "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
741 files.features.len() - corpus.parsed_feature_files
742 )
743 };
744 if config.require_features {
745 diagnostics.errors.push(message);
746 } else if !files.definitions.is_empty() {
747 diagnostics.warnings.push(message);
748 }
749 }
750 corpus.feature_steps.sort_by(|left, right| {
751 left.location
752 .path
753 .cmp(&right.location.path)
754 .then(left.location.line.cmp(&right.location.line))
755 .then(left.location.column.cmp(&right.location.column))
756 });
757}
758
759fn analyze_corpus(
764 config: &Config,
765 extracted: ExtractedCorpus,
766 diagnostics: &mut Diagnostics,
767) -> Result<AnalyzedCorpus> {
768 let ExtractedCorpus {
769 parameter_types,
770 parameter_language,
771 indirect_usage,
772 definitions,
773 feature_steps,
774 excluded_sources: _,
776 definition_files_with_definitions,
777 parsed_feature_files,
778 feature_files_without_steps,
779 incomplete: mut corpus_incomplete,
780 } = extracted;
781 let mut effective_config = config.clone();
782 let (patterns, type_diagnostics) = match parameter_language {
783 Some(language) => source_adapter::adapter_for_language(language)
784 .resolve_parameter_types(parameter_types, &config.parameter_types),
785 None => (config.parameter_types.clone(), Vec::new()),
786 };
787 effective_config.parameter_types = patterns;
788 corpus_incomplete |= !type_diagnostics.is_empty();
789 diagnostics.warnings.extend(type_diagnostics);
790 let config = &effective_config;
791 let (analysis, census, unmatched_suppressions) =
792 analysis::analyze_for_cli(&indirect_usage, definitions, feature_steps, config)?;
793 for index in unmatched_suppressions.indices {
794 let suppression = &config.suppressions[index];
795 diagnostics.warnings.push(format!(
796 "suppression {} for {} matched no step definitions",
797 index + 1,
798 suppression.rule
799 ));
800 }
801 if unmatched_suppressions.truncated {
802 diagnostics.warnings.push(
803 "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
804 .to_owned(),
805 );
806 }
807
808 let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
811 if config.fail_on_incomplete {
812 diagnostics.errors.extend(analysis.incomplete);
813 if corpus_incomplete {
814 diagnostics.errors.push(
815 "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
816 );
817 }
818 } else {
819 diagnostics.warnings.extend(analysis.incomplete);
820 }
821 Ok(AnalyzedCorpus {
822 result: analysis.result,
823 census,
824 definition_files_with_definitions,
825 parsed_feature_files,
826 feature_files_without_steps,
827 corpus_incomplete,
828 run_incomplete,
829 })
830}
831
832fn apply_finding_modes(
836 result: &mut crate::model::AnalysisResult,
837 files: &discovery::DiscoveredFiles,
838 changed_files: &Option<BTreeSet<PathBuf>>,
839 parsed_feature_files: usize,
840 feature_files_without_steps: usize,
841) {
842 if parsed_feature_files < files.features.len()
846 || feature_files_without_steps > 0
847 || files.features.is_empty()
848 {
849 result
850 .findings
851 .retain(|finding| finding.rule != Rule::UnusedDefinition);
852 }
853 if let Some(changed) = changed_files {
854 modes::retain_changed_findings(&mut result.findings, changed);
855 }
856}
857
858fn apply_baseline_mode(
860 config: &Config,
861 options: &CheckOptions,
862 result: &mut crate::model::AnalysisResult,
863 run_incomplete: bool,
864 diagnostics: &mut Diagnostics,
865) -> Result<Option<modes::BaselineOutcome>> {
866 if let Some(revision) = &options.baseline_from_ref {
867 let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
868 let mut base_config = config.clone();
875 base_config.root = root;
876 let files = discovery::discover(&base_config)?;
877 let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
878 let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
879 let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics)?;
880 if !files.definitions.is_empty()
888 && base.result.definitions.is_empty()
889 && !result.definitions.is_empty()
890 {
891 bail!(
892 "baseline revision `{revision}` is incomplete: it kept no step definitions while the current tree has {}, so no finding could be matched against it: {}",
893 result.definitions.len(),
894 base_diagnostics
895 .errors
896 .iter()
897 .chain(&base_diagnostics.warnings)
898 .cloned()
899 .collect::<Vec<_>>()
900 .join("; ")
901 );
902 }
903 if !base_diagnostics.errors.is_empty()
904 || (files.features.is_empty() && !base.result.definitions.is_empty())
905 {
906 bail!(
907 "baseline revision `{revision}` is incomplete: {}",
908 base_diagnostics
909 .errors
910 .iter()
911 .chain(&base_diagnostics.warnings)
912 .cloned()
913 .collect::<Vec<_>>()
914 .join("; ")
915 );
916 }
917 if base.run_incomplete {
918 diagnostics.warnings.push(format!(
919 "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"
920 ));
921 }
922 apply_finding_modes(
923 &mut base.result,
924 &files,
925 &None,
926 base.parsed_feature_files,
927 base.feature_files_without_steps,
928 );
929 return modes::apply_reference_baseline(
930 &mut result.findings,
931 &base.result.findings,
932 revision,
933 )
934 .map(Some);
935 }
936 let Some(path) = &options.baseline else {
937 return Ok(None);
938 };
939 let path = if path.is_absolute() {
940 path.clone()
941 } else {
942 config.root.join(path)
943 };
944 if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
945 diagnostics.warnings.push(format!(
946 "baseline {} was not updated because analysis is incomplete",
947 path.display()
948 ));
949 return Ok(None);
950 }
951 let outcome = if options.update_baseline {
952 modes::update_baseline(&mut result.findings, &path)?
953 } else {
954 modes::apply_baseline(&mut result.findings, &path)?
955 };
956 if options.update_baseline {
957 diagnostics.warnings.push(format!(
958 "updated baseline {}: {} added, {} removed, {} total",
959 path.display(),
960 outcome.added,
961 outcome.removed,
962 outcome.suppressed
963 ));
964 }
965 Ok(Some(outcome))
966}
967
968fn write_reports(
971 config: &Config,
972 files: &discovery::DiscoveredFiles,
973 analyzed: &AnalyzedCorpus,
974 diagnostics: &Diagnostics,
975 timings: PhaseTimings,
976) -> Result<()> {
977 let corpus = reporters::CorpusCensus {
978 definition_files: files.definitions.len(),
979 definition_files_with_definitions: analyzed.definition_files_with_definitions,
980 definitions_extracted: analyzed.result.definitions.len(),
981 feature_files: files.features.len(),
982 feature_files_parsed: analyzed.parsed_feature_files,
983 feature_files_without_steps: analyzed.feature_files_without_steps,
984 incomplete: analyzed.corpus_incomplete,
985 };
986 let metrics = reporters::ExecutionMetrics::new(
987 files.definitions.len(),
988 files.features.len(),
989 timings.discovery_ms,
990 timings.parsing_ms,
991 timings.analysis_ms,
992 );
993 let report_metadata = reporters::CliReportMetadata {
994 corpus: &corpus,
995 analysis: &analyzed.census,
996 execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
998 };
999 let terminal_color = terminal_color_enabled(
1000 io::stdout().is_terminal(),
1001 std::env::var_os("NO_COLOR").as_deref(),
1002 );
1003 let mut stdout = io::stdout().lock();
1004 reporters::write_cli_reports(
1005 &analyzed.result,
1006 config,
1007 (!config.no_metrics).then_some(&metrics),
1008 &report_metadata,
1009 &mut stdout,
1010 terminal_color,
1011 )?;
1012 Ok(())
1013}
1014
1015struct FingerprintSink {
1017 path: PathBuf,
1018 file: Option<fs::File>,
1019 created: Option<PathBuf>,
1021}
1022
1023impl FingerprintSink {
1024 fn open(config: &Config, options: &CheckOptions, path: &Path) -> Result<Self> {
1027 let path = config.root.join(path);
1028 let context = || format!("failed to write fingerprint file {}", path.display());
1029 let (file, created) = match open_destination(&path) {
1030 Ok(opened) => opened,
1031 Err(_) if fs::metadata(&path).is_ok_and(|metadata| !metadata.is_file()) => {
1033 bail!("fingerprint file {} is not a regular file", path.display())
1034 }
1035 Err(error) => return Err(error).with_context(context),
1036 };
1037 if !file.metadata().with_context(context)?.is_file() {
1040 bail!("fingerprint file {} is not a regular file", path.display());
1041 }
1042 let mut sink = Self {
1043 file: Some(file),
1044 created: created.then(|| path.clone()),
1045 path: path.clone(),
1046 };
1047 let canonical = fs::canonicalize(&path).with_context(context)?;
1050 if let Some(created) = &mut sink.created {
1052 created.clone_from(&canonical);
1053 }
1054 let written = reporters::REPORT_FILE_NAMES
1055 .iter()
1056 .map(|name| config.output_path(name))
1057 .chain(options.baseline.iter().map(|path| config.root.join(path)));
1058 for target in written {
1059 let open = sink.file.as_ref().expect("an open sink holds its file");
1061 let same = match same_file_as(open, &target) {
1062 Ok(same) => same,
1063 Err(error)
1065 if matches!(
1066 error.kind(),
1067 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
1068 ) =>
1069 {
1070 false
1071 }
1072 Err(error) => bail!(
1074 "cannot verify that fingerprint file {} is not {}: {error}",
1075 sink.path.display(),
1076 target.display()
1077 ),
1078 };
1079 if same || fs::canonicalize(&target).is_ok_and(|target| target == canonical) {
1080 bail!(
1081 "fingerprint file {} would overwrite {}; choose a different file",
1082 sink.path.display(),
1083 target.display()
1084 );
1085 }
1086 }
1087 Ok(sink)
1088 }
1089
1090 fn write(mut self, root: &Path, definitions: &[crate::model::StepDefinition]) -> Result<()> {
1092 let context = || format!("failed to write fingerprint file {}", self.path.display());
1093 let file = self.file.take().expect("an open sink holds its file");
1094 file.set_len(0).with_context(context)?;
1095 let mut file = io::BufWriter::new(file);
1096 analysis::fingerprints::write_fingerprints(definitions, root, &mut file)
1097 .with_context(context)?;
1098 file.flush().with_context(context)?;
1099 self.created = None;
1100 Ok(())
1101 }
1102}
1103
1104fn open_destination(path: &Path) -> io::Result<(fs::File, bool)> {
1109 let mut path = path.to_path_buf();
1110 loop {
1111 let created = fs::OpenOptions::new()
1112 .write(true)
1113 .create_new(true)
1114 .open(&path);
1115 let error = match created {
1116 Ok(file) => return Ok((file, true)),
1117 Err(error) => error,
1118 };
1119 if error.kind() != io::ErrorKind::AlreadyExists {
1120 return Err(error);
1121 }
1122 let mut existing = fs::OpenOptions::new();
1123 existing.write(true);
1124 #[cfg(unix)]
1125 std::os::unix::fs::OpenOptionsExt::custom_flags(&mut existing, libc::O_NONBLOCK);
1126 match existing.open(&path) {
1127 Ok(file) => return Ok((file, false)),
1128 Err(error) if error.kind() == io::ErrorKind::NotFound && path.is_symlink() => {
1129 let target = fs::read_link(&path)?;
1130 path = path
1131 .parent()
1132 .map_or(target.clone(), |parent| parent.join(&target));
1133 }
1134 Err(error) => return Err(error),
1135 }
1136 }
1137}
1138
1139#[cfg(unix)]
1142fn same_file_as(file: &fs::File, target: &Path) -> io::Result<bool> {
1143 use std::os::unix::fs::MetadataExt;
1144 let (open, other) = (file.metadata()?, fs::metadata(target)?);
1145 Ok(open.dev() == other.dev() && open.ino() == other.ino())
1146}
1147
1148#[cfg(not(unix))]
1151fn same_file_as(file: &fs::File, target: &Path) -> io::Result<bool> {
1152 Ok(same_file::Handle::from_file(file.try_clone()?)? == same_file::Handle::from_path(target)?)
1153}
1154
1155impl Drop for FingerprintSink {
1156 fn drop(&mut self) {
1158 drop(self.file.take());
1159 if let Some(created) = self.created.take() {
1160 let _ = fs::remove_file(created);
1162 }
1163 }
1164}
1165
1166fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
1168 is_terminal && !no_color.is_some_and(|value| !value.is_empty())
1169}
1170
1171fn write_diagnostics(
1174 config: &Config,
1175 options: &CheckOptions,
1176 files: &discovery::DiscoveredFiles,
1177 diagnostics: &Diagnostics,
1178) -> Result<()> {
1179 if !options.explain_discovery
1180 && diagnostics.warnings.is_empty()
1181 && diagnostics.errors.is_empty()
1182 {
1183 return Ok(());
1184 }
1185 let mut stderr = io::stderr().lock();
1186 if options.explain_discovery {
1187 writeln!(
1188 stderr,
1189 "cuke-dedup: feature patterns: {} ({})",
1190 config.features.join(", "),
1191 config.feature_pattern_origin
1192 )?;
1193 for file in &files.features {
1194 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
1195 writeln!(
1196 stderr,
1197 "cuke-dedup: feature {} [{}] via {}",
1198 relative.display(),
1199 file.format.as_str(),
1200 file.pattern
1201 )?;
1202 }
1203 for file in &files.definitions {
1204 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
1205 writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
1206 }
1207 }
1208 for warning in &diagnostics.warnings {
1209 writeln!(stderr, "cuke-dedup: warning: {warning}")?;
1210 }
1211 for error in &diagnostics.errors {
1212 writeln!(stderr, "cuke-dedup: {error}")?;
1213 }
1214 Ok(())
1215}
1216
1217fn determine_exit_code(
1220 config: &Config,
1221 options: &CheckOptions,
1222 result: &crate::model::AnalysisResult,
1223 baseline: Option<modes::BaselineOutcome>,
1224 diagnostics: &Diagnostics,
1225) -> Result<i32> {
1226 if !diagnostics.errors.is_empty() {
1227 return Ok(2);
1228 }
1229 let duplication = DuplicationThreshold::from_result(result, config.threshold);
1230 let has_non_duplication_errors = result.findings.iter().any(|finding| {
1231 finding.is_active()
1232 && finding.severity == Severity::Error
1233 && !finding.rule.contributes_to_duplication_threshold()
1234 });
1235 let new_findings_exceeded = options
1236 .fail_on_new
1237 .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
1238 Ok(i32::from(
1239 has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
1240 ))
1241}
1242
1243fn elapsed_ms(started: Instant) -> f64 {
1245 started.elapsed().as_secs_f64() * 1_000.0
1246}
1247
1248fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
1250 let (rule, severity) = value
1251 .split_once('=')
1252 .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
1253 Ok((rule.parse()?, severity.parse()?))
1254}
1255
1256fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
1258 value
1259 .parse::<usize>()
1260 .ok()
1261 .filter(|value| *value > 0)
1262 .ok_or_else(|| "expected an integer greater than zero".to_owned())
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::*;
1268
1269 #[test]
1272 fn finalization_failure_discards_provisional_definitions_and_reports_incompleteness() {
1273 let root = tempfile::tempdir().unwrap();
1274 let config = Config::load(root.path(), ConfigOverrides::default()).unwrap();
1275 std::fs::write(root.path().join("steps.ts"), "import { Given, Then } from '@cucumber/cucumber'; Given('same', () => work()); Then('same', () => work());").unwrap();
1276 let files = discovery::discover(&config).unwrap();
1277 let mut diagnostics = discovery_diagnostics(&config, &files, &None);
1278 let mut corpus = extract_corpus(&config, &files, &None, &mut diagnostics);
1279 assert_eq!(corpus.definitions.len(), 2);
1280 let before = corpus.definitions.clone();
1281 collect_finalization(Ok(Default::default()), &mut corpus, &mut diagnostics);
1282 assert_eq!(corpus.definitions, before);
1283 collect_finalization(
1284 Err(anyhow::anyhow!("adapter unavailable")),
1285 &mut corpus,
1286 &mut diagnostics,
1287 );
1288 assert!(corpus.incomplete);
1289 assert!(corpus.definitions.is_empty());
1290 assert_eq!(corpus.definition_files_with_definitions, 0);
1291 assert!(diagnostics
1292 .errors
1293 .iter()
1294 .any(|e| e == "failed to finalize source evidence: adapter unavailable"));
1295 let analyzed = analyze_corpus(&config, corpus, &mut diagnostics).unwrap();
1296 assert!(analyzed.run_incomplete);
1297 assert!(analyzed.result.findings.is_empty());
1298 }
1299
1300 #[test]
1302 fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
1303 use std::ffi::OsStr;
1304
1305 assert!(terminal_color_enabled(true, None));
1306 assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
1307 assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
1308 assert!(!terminal_color_enabled(false, None));
1309 assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
1310 }
1311
1312 #[test]
1314 fn parses_rule_override() {
1315 assert_eq!(
1316 parse_rule_override("duplicate-matcher=warning"),
1317 Ok((Rule::DuplicateMatcher, Severity::Warning))
1318 );
1319 assert!(parse_rule_override("unknown=error").is_err());
1320 }
1321
1322 #[test]
1324 fn candidate_limit_cli_values_must_be_positive() {
1325 assert_eq!(parse_positive_usize("7"), Ok(7));
1326 assert!(parse_positive_usize("0").is_err());
1327 assert!(parse_positive_usize("-1").is_err());
1328 assert!(parse_positive_usize("many").is_err());
1329 }
1330
1331 #[test]
1334 fn unparseable_diagnostics_are_tolerated_by_default_and_escalated_by_the_flag() {
1335 use crate::discovery::SourceLanguage;
1336 use crate::model::SourceLocation;
1337 use crate::source_adapter::{ExtractionDiagnostic, SourceFile};
1338
1339 let directory = tempfile::tempdir().unwrap();
1340 let file = SourceFile {
1341 path: directory.path().join("steps.ts"),
1342 language: SourceLanguage::TypeScript,
1343 };
1344 let unparseable = || {
1345 ExtractionDiagnostic::new(
1346 ExtractionDiagnosticLevel::Warning,
1347 SourceLocation::new("steps.ts", 1, 1, 1, 1),
1348 format!(
1349 "{}, so analysis is incomplete",
1350 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX
1351 ),
1352 )
1353 };
1354 let route = |config: &Config, diagnostic: ExtractionDiagnostic| {
1355 let mut diagnostics = Diagnostics {
1356 warnings: Vec::new(),
1357 errors: Vec::new(),
1358 };
1359 let mut incomplete = false;
1360 collect_extraction_diagnostics(
1361 config,
1362 &file,
1363 &None,
1364 vec![diagnostic],
1365 &mut diagnostics,
1366 &mut incomplete,
1367 );
1368 (diagnostics, incomplete)
1369 };
1370
1371 let mut config =
1372 Config::load(directory.path(), ConfigOverrides::default()).expect("config loads");
1373
1374 let (diagnostics, incomplete) = route(&config, unparseable());
1376 assert!(incomplete);
1377 assert!(diagnostics.errors.is_empty());
1378 assert_eq!(diagnostics.warnings.len(), 1);
1379
1380 config.fail_on_unparseable = true;
1383 let (diagnostics, incomplete) = route(&config, unparseable());
1384 assert!(incomplete);
1385 assert_eq!(diagnostics.errors.len(), 1);
1386 assert!(diagnostics.warnings.is_empty());
1387
1388 let dynamic = ExtractionDiagnostic::new(
1391 ExtractionDiagnosticLevel::Warning,
1392 SourceLocation::new("steps.ts", 2, 1, 2, 1),
1393 "dynamic or unsupported step matcher cannot be analyzed statically",
1394 );
1395 let (diagnostics, incomplete) = route(&config, dynamic);
1396 assert!(incomplete);
1397 assert!(diagnostics.errors.is_empty());
1398 assert_eq!(diagnostics.warnings.len(), 1);
1399 let unrelated = ExtractionDiagnostic::new(
1400 ExtractionDiagnosticLevel::Warning,
1401 SourceLocation::new("steps.ts", 2, 1, 2, 1),
1402 "unrelated extractor warning",
1403 );
1404 let (diagnostics, incomplete) = route(&config, unrelated);
1405 assert!(!incomplete);
1406 assert!(diagnostics.errors.is_empty());
1407 assert_eq!(diagnostics.warnings.len(), 1);
1408 }
1409
1410 #[test]
1414 fn typed_adapter_diagnostics_route_independently_of_language_and_wording() {
1415 use crate::model::SourceLocation;
1416 use crate::source_adapter::{
1417 ExtractionDiagnostic, ExtractionDiagnosticKind as Kind, SourceFile, SourceLanguage,
1418 };
1419 let directory = tempfile::tempdir().unwrap();
1420 let file = SourceFile {
1421 path: directory.path().join("steps.ts"),
1422 language: SourceLanguage::TypeScript,
1423 };
1424 let mut config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1425 for strict in [false, true] {
1426 config.fail_on_unparseable = strict;
1427 for (kind, message, incomplete, parse_error) in [
1428 (Kind::Other, "adapter advisory", false, false),
1429 (Kind::Incomplete, "external glue unavailable", true, false),
1430 (Kind::Unparseable, "adapter syntax failure", true, true),
1431 (
1432 Kind::Other,
1433 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1434 false,
1435 false,
1436 ),
1437 (
1438 Kind::Incomplete,
1439 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1440 true,
1441 false,
1442 ),
1443 ] {
1444 for level in [
1445 ExtractionDiagnosticLevel::Warning,
1446 ExtractionDiagnosticLevel::Error,
1447 ] {
1448 let mut diagnostics = Diagnostics {
1449 warnings: vec![],
1450 errors: vec![],
1451 };
1452 let mut actual_incomplete = false;
1453 collect_extraction_diagnostics(
1454 &config,
1455 &file,
1456 &None,
1457 vec![ExtractionDiagnostic::with_kind(
1458 kind,
1459 level,
1460 SourceLocation::new(&file.path, 1, 1, 1, 1),
1461 message,
1462 )],
1463 &mut diagnostics,
1464 &mut actual_incomplete,
1465 );
1466 assert_eq!(actual_incomplete, incomplete, "{kind:?} strict={strict}");
1467 let error =
1468 level == ExtractionDiagnosticLevel::Error || (strict && parse_error);
1469 assert_eq!(diagnostics.errors.len(), usize::from(error));
1470 assert_eq!(diagnostics.warnings.len(), usize::from(!error));
1471 }
1472 }
1473 }
1474 }
1475
1476 #[test]
1478 fn supports_implicit_and_explicit_check_forms() {
1479 let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
1480 .expect("implicit invocation");
1481 assert!(implicit.command.is_none());
1482 let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
1483 .expect("explicit invocation");
1484 assert!(matches!(explicit.command, Some(Command::Check { .. })));
1485 assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
1486 }
1487}