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::io::{self, IsTerminal, Write};
13use std::path::{Path, PathBuf};
14use std::time::Instant;
15
16#[derive(Debug, Parser)]
17#[command(
18 name = "cuke-dedup",
19 version,
20 about = "Detect duplicate and reusable Cucumber step definitions",
21 after_help = "Examples:\n cuke-dedup .\n cuke-dedup check .\n cuke-dedup . --reporters terminal,json,html\n cuke-dedup . --reporters jsonl"
22)]
23struct Cli {
24 #[arg(value_name = "PATH", default_value = ".")]
26 path: PathBuf,
27
28 #[command(subcommand)]
29 command: Option<Command>,
30
31 #[command(flatten)]
32 options: CheckOptions,
33}
34
35#[derive(Debug, Subcommand)]
36enum Command {
37 Check {
39 #[arg(value_name = "PATH")]
41 path: PathBuf,
42 },
43}
44
45#[derive(Debug, Clone, Args, Default)]
46struct CheckOptions {
47 #[arg(long, short = 'c', global = true, value_name = "FILE")]
49 config: Option<PathBuf>,
50
51 #[arg(long, global = true, value_delimiter = ',')]
53 reporters: Option<Vec<ReporterKind>>,
54
55 #[arg(long, global = true, value_name = "DIR")]
57 output: Option<PathBuf>,
58
59 #[arg(
61 long,
62 global = true,
63 value_name = "PERCENT",
64 allow_hyphen_values = true
65 )]
66 threshold: Option<f64>,
67
68 #[arg(long, global = true)]
70 require_features: bool,
71
72 #[arg(long, global = true)]
74 fail_on_incomplete: bool,
75
76 #[arg(long, global = true)]
80 fail_on_unparseable: bool,
81
82 #[arg(long, global = true)]
84 require_definitions: bool,
85
86 #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
88 max_candidate_comparisons: Option<usize>,
89
90 #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
92 max_structural_class_comparisons: Option<usize>,
93
94 #[arg(long, global = true, value_delimiter = ',')]
96 definitions: Option<Vec<String>>,
97
98 #[arg(long, global = true, value_delimiter = ',')]
100 features: Option<Vec<String>>,
101
102 #[arg(long, global = true)]
104 explain_discovery: bool,
105
106 #[arg(long, global = true)]
108 print_config: bool,
109
110 #[arg(long, global = true)]
112 no_metrics: bool,
113
114 #[arg(long, global = true, value_delimiter = ',')]
116 exclude: Option<Vec<String>>,
117
118 #[arg(long, global = true)]
120 no_default_excludes: bool,
121
122 #[arg(long, global = true)]
124 include_hidden: bool,
125
126 #[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
128 rules: Vec<(Rule, Severity)>,
129
130 #[arg(long, global = true, value_name = "GIT_REF")]
132 changed_since: Option<String>,
133
134 #[arg(
136 long,
137 global = true,
138 value_name = "BASELINE.json",
139 group = "baseline_source"
140 )]
141 baseline: Option<PathBuf>,
142
143 #[arg(long, global = true, value_name = "GIT_REF", group = "baseline_source",
145 conflicts_with_all = ["baseline", "update_baseline"])]
146 baseline_from_ref: Option<String>,
147
148 #[arg(
150 long,
151 global = true,
152 requires = "baseline",
153 conflicts_with = "changed_since"
154 )]
155 update_baseline: bool,
156
157 #[arg(
159 long,
160 global = true,
161 value_name = "COUNT",
162 num_args = 0..=1,
163 default_missing_value = "0",
164 requires = "baseline_source",
165 conflicts_with = "update_baseline"
166 )]
167 fail_on_new: Option<usize>,
168}
169
170struct Invocation {
171 config: Config,
172 options: CheckOptions,
173}
174
175#[derive(Default)]
176struct Diagnostics {
177 warnings: Vec<String>,
178 errors: Vec<String>,
179}
180
181struct ExtractedCorpus {
182 parameter_types: Vec<source_adapter::SourceParameterType>,
183 parameter_language: Option<source_adapter::SourceLanguage>,
184 indirect_usage: source_adapter::IndirectStepUsage,
185 definitions: Vec<crate::model::StepDefinition>,
186 feature_steps: Vec<crate::model::FeatureStep>,
187 excluded_sources: Vec<(PathBuf, source_adapter::SourceExclusion)>,
188 definition_files_with_definitions: usize,
189 parsed_feature_files: usize,
190 feature_files_without_steps: usize,
191 incomplete: bool,
192}
193
194struct AnalyzedCorpus {
195 result: crate::model::AnalysisResult,
196 census: analysis::AnalysisCensus,
197 definition_files_with_definitions: usize,
198 parsed_feature_files: usize,
199 feature_files_without_steps: usize,
200 corpus_incomplete: bool,
201 run_incomplete: bool,
202}
203
204struct PhaseTimings {
205 discovery_ms: f64,
206 parsing_ms: f64,
207 analysis_ms: f64,
208}
209
210pub fn run_cli() -> Result<i32> {
212 execute(Cli::parse())
213}
214
215fn execute(cli: Cli) -> Result<i32> {
216 let invocation = resolve_invocation(cli)?;
217 let config = &invocation.config;
218 let options = &invocation.options;
219 if options.print_config {
220 println!(
221 "{}",
222 serde_json::to_string_pretty(config)
223 .context("failed to serialize effective configuration")?
224 );
225 return Ok(0);
226 }
227 let changed_files = resolve_changed_files(config, options.changed_since.as_deref())?;
228 let discovery_started = Instant::now();
229 let files = discovery::discover(config)?;
230 let discovery_ms = elapsed_ms(discovery_started);
231
232 let parsing_started = Instant::now();
233 let mut diagnostics = discovery_diagnostics(config, &files, &changed_files);
234 let extracted = extract_corpus(config, &files, &changed_files, &mut diagnostics);
235 let parsing_ms = elapsed_ms(parsing_started);
236 let analysis_started = Instant::now();
237 let mut analyzed = analyze_corpus(config, extracted, &mut diagnostics)?;
238 let analysis_ms = elapsed_ms(analysis_started);
239 apply_finding_modes(
240 &mut analyzed.result,
241 &files,
242 &changed_files,
243 analyzed.parsed_feature_files,
244 analyzed.feature_files_without_steps,
245 );
246 let baseline_outcome = apply_baseline_mode(
247 config,
248 options,
249 &mut analyzed.result,
250 analyzed.run_incomplete,
251 &mut diagnostics,
252 )?;
253 write_reports(
254 config,
255 &files,
256 &analyzed,
257 &diagnostics,
258 PhaseTimings {
259 discovery_ms,
260 parsing_ms,
261 analysis_ms,
262 },
263 )?;
264 write_diagnostics(config, options, &files, &diagnostics)?;
265 determine_exit_code(
266 config,
267 options,
268 &analyzed.result,
269 baseline_outcome,
270 &diagnostics,
271 )
272}
273
274fn resolve_invocation(cli: Cli) -> Result<Invocation> {
275 if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
276 bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
277 }
278 let root = match cli.command {
279 Some(Command::Check { path }) => path,
280 None => cli.path,
281 };
282 let options = cli.options.clone();
283 let config = Config::load_for_cli(
284 &root,
285 ConfigOverrides {
286 config_file: cli.options.config,
287 definitions: cli.options.definitions,
288 features: cli.options.features,
289 exclude: cli.options.exclude,
290 exclude_defaults: cli.options.no_default_excludes.then_some(false),
291 include_hidden: cli.options.include_hidden.then_some(true),
292 reporters: cli.options.reporters,
293 output: cli.options.output,
294 threshold: cli.options.threshold,
295 require_features: cli.options.require_features.then_some(true),
296 no_metrics: cli.options.no_metrics.then_some(true),
297 rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
298 },
299 CliConfigOverrides {
300 require_definitions: cli.options.require_definitions.then_some(true),
301 fail_on_incomplete: cli.options.fail_on_incomplete.then_some(true),
302 fail_on_unparseable: cli.options.fail_on_unparseable.then_some(true),
303 max_candidate_comparisons: cli.options.max_candidate_comparisons,
304 max_structural_class_comparisons: cli.options.max_structural_class_comparisons,
305 },
306 )?;
307 Ok(Invocation { config, options })
308}
309
310fn resolve_changed_files(
311 config: &Config,
312 changed_since: Option<&str>,
313) -> Result<Option<BTreeSet<PathBuf>>> {
314 let Some(base) = changed_since else {
315 return Ok(None);
316 };
317 modes::ensure_changed_root_is_trackable(&config.root)?;
318 Ok(Some(modes::git_changed_files(&config.root, base)?))
319}
320
321fn discovery_diagnostics(
322 config: &Config,
323 files: &discovery::DiscoveredFiles,
324 changed_files: &Option<BTreeSet<PathBuf>>,
325) -> Diagnostics {
326 let mut diagnostics = Diagnostics {
327 warnings: config.config_warnings.clone(),
328 errors: files.errors.clone(),
329 };
330 for pattern in &files.unmatched_feature_patterns {
331 diagnostics.warnings.push(format!(
332 "feature pattern `{pattern}` from {} matched no files",
333 config.feature_pattern_origin
334 ));
335 }
336 for pattern in &files.unmatched_definition_patterns {
337 diagnostics
338 .warnings
339 .push(format!("definition pattern `{pattern}` matched no files"));
340 }
341 if !files.skipped_definitions.is_empty()
342 && (!config.definitions.is_empty() || files.definitions.is_empty())
343 {
344 diagnostics
345 .warnings
346 .push(skipped_definitions_warning(&files.skipped_definitions));
347 }
348 if files.definitions.is_empty() && config.definitions.is_empty() {
349 diagnostics
350 .warnings
351 .push("automatic discovery found no supported step-definition source files".to_owned());
352 }
353 if files.features.is_empty() {
354 let message = format!(
355 "no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
356 config.feature_pattern_origin
357 );
358 if config.require_features {
359 diagnostics.errors.push(message);
360 } else if !files.definitions.is_empty() {
361 diagnostics.warnings.push(message);
362 }
363 }
364 if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
365 diagnostics.warnings.push(
366 "--changed-since found no tracked or untracked files; no findings will be reported"
367 .to_owned(),
368 );
369 }
370 diagnostics
371}
372
373fn skipped_definitions_warning(skipped: &[source_adapter::SourceFile]) -> String {
374 let registrations: Vec<_> = source_adapter::SOURCE_ADAPTER_REGISTRY
375 .iter()
376 .filter(|entry| {
377 skipped
378 .iter()
379 .any(|file| entry.adapter.language() == file.language)
380 })
381 .collect();
382 let adapters: BTreeSet<_> = registrations
383 .iter()
384 .map(|entry| entry.adapter.name())
385 .collect();
386 let suffixes: Vec<_> = registrations
387 .iter()
388 .map(|entry| format!("`{}`", entry.suffix))
389 .collect();
390 format!(
391 "{} {} source file(s) were not analyzed: opt-in languages need a definition pattern that names their suffix ({}), such as `features/**/*{}`",
392 skipped.len(),
393 adapters.into_iter().collect::<Vec<_>>().join(", "),
394 suffixes.join(", "),
395 registrations.first().map_or("", |entry| entry.suffix),
396 )
397}
398
399fn extract_corpus(
400 config: &Config,
401 files: &discovery::DiscoveredFiles,
402 changed_files: &Option<BTreeSet<PathBuf>>,
403 diagnostics: &mut Diagnostics,
404) -> ExtractedCorpus {
405 let mut corpus = ExtractedCorpus {
406 parameter_types: Vec::new(),
407 parameter_language: files.definitions.first().map(|file| file.language),
408 indirect_usage: source_adapter::IndirectStepUsage::default(),
409 definitions: Vec::new(),
410 feature_steps: Vec::new(),
411 excluded_sources: Vec::new(),
412 definition_files_with_definitions: 0,
413 parsed_feature_files: 0,
414 feature_files_without_steps: 0,
415 incomplete: false,
416 };
417 extract_definitions(config, files, changed_files, diagnostics, &mut corpus);
418 extract_feature_steps(config, files, diagnostics, &mut corpus);
419 corpus
420}
421
422fn extract_definitions(
424 config: &Config,
425 files: &discovery::DiscoveredFiles,
426 changed_files: &Option<BTreeSet<PathBuf>>,
427 diagnostics: &mut Diagnostics,
428 corpus: &mut ExtractedCorpus,
429) {
430 if let Some(first) = files.definitions.first() {
431 let domain = first.language.comparison_domain();
432 if files
433 .definitions
434 .iter()
435 .any(|file| file.language.comparison_domain() != domain)
436 {
437 corpus.incomplete = true;
438 diagnostics.errors.push("Ruby and JS/TS sources require separate analysis runs with explicit definition and feature roots".to_owned());
439 return;
440 }
441 }
442 let mut extraction_session = source_adapter::SourceExtractionSession::with_options(
443 &config.root,
444 &config.registrations,
445 &config.assertion_modules,
446 )
447 .with_dependencies(&files.dependencies)
448 .with_dependency_gaps(&files.dependency_gaps);
449 let mut prepared = BTreeSet::new();
450 for file in &files.definitions {
451 let adapter = source_adapter::adapter_for_language(file.language);
452 if prepared.insert(adapter.name()) {
453 if let Err(error) = adapter.prepare_session(&files.definitions, &mut extraction_session)
454 {
455 corpus.incomplete = true;
456 diagnostics
457 .errors
458 .push(format!("failed to prepare source evidence: {error:#}"));
459 return;
460 }
461 }
462 }
463 for file in &files.definitions {
464 let adapter = source_adapter::adapter_for_language(file.language);
468 if let Some(excluded) = adapter.inspect_source(file, &config.registrations) {
469 corpus.incomplete = true;
475 corpus.excluded_sources.push((file.path.clone(), excluded));
476 continue;
477 }
478 match adapter
479 .extract_file_with_session(file, &mut extraction_session)
480 .with_context(|| format!("failed to analyze {}", file.path.display()))
481 {
482 Ok(extracted) => {
483 if adapter.supports_indirect_usage() {
484 corpus.indirect_usage.extend(extracted.indirect_usage);
485 corpus.incomplete |= corpus.indirect_usage.unknown;
486 }
487 corpus.parameter_types.extend(extracted.parameter_types);
488 if !extracted.definitions.is_empty() {
489 corpus.definition_files_with_definitions += 1;
490 }
491 corpus.definitions.extend(extracted.definitions);
492 collect_extraction_diagnostics(
493 config,
494 file,
495 changed_files,
496 extracted.diagnostics,
497 diagnostics,
498 &mut corpus.incomplete,
499 );
500 }
501 Err(error) => {
505 if adapter.supports_indirect_usage() {
506 corpus.indirect_usage.unknown = true;
507 corpus.incomplete = true;
508 }
509 diagnostics.errors.push(format!("{error:#}"));
510 }
511 }
512 }
513 let finalization = extraction_session.finalize(&files.definitions, &mut corpus.definitions);
514 collect_finalization(finalization, corpus, diagnostics);
515 corpus.definitions.sort_by(|left, right| {
516 left.location
517 .path
518 .cmp(&right.location.path)
519 .then(left.location.line.cmp(&right.location.line))
520 .then(left.location.column.cmp(&right.location.column))
521 });
522 report_excluded_sources(config, &corpus.excluded_sources, diagnostics);
523 if corpus.definitions.is_empty() {
524 let message = if files.definitions.is_empty() {
525 "no step definitions were extracted because no definition source files were discovered"
526 .to_owned()
527 } else {
528 format!(
529 "definition extraction produced 0 definitions from {} discovered definition source file(s)",
530 files.definitions.len()
531 )
532 };
533 if config.require_definitions {
534 diagnostics.errors.push(message);
535 } else if !files.definitions.is_empty() {
536 diagnostics.warnings.push(message);
537 }
538 }
539}
540
541fn collect_finalization(
542 finalization: Result<source_adapter::SourceFinalization>,
543 corpus: &mut ExtractedCorpus,
544 diagnostics: &mut Diagnostics,
545) {
546 match finalization {
547 Ok(finalization) => {
548 if !finalization.uncertainties.is_empty() {
549 corpus.incomplete = true;
550 diagnostics.warnings.extend(
551 finalization
552 .uncertainties
553 .into_iter()
554 .map(|item| item.message),
555 );
556 corpus.definition_files_with_definitions = corpus
557 .definitions
558 .iter()
559 .map(|definition| &definition.location.path)
560 .collect::<BTreeSet<_>>()
561 .len();
562 }
563 }
564 Err(error) => {
565 corpus.incomplete = true;
566 corpus.definitions.clear();
567 corpus.definition_files_with_definitions = 0;
568 diagnostics
569 .errors
570 .push(format!("failed to finalize source evidence: {error:#}"));
571 }
572 }
573}
574
575const MAX_LISTED_EXCLUDED_SOURCES: usize = 10;
577
578fn report_excluded_sources(
584 config: &Config,
585 excluded: &[(PathBuf, source_adapter::SourceExclusion)],
586 diagnostics: &mut Diagnostics,
587) {
588 if excluded.is_empty() {
589 return;
590 }
591 let listed = excluded
592 .iter()
593 .take(MAX_LISTED_EXCLUDED_SOURCES)
594 .map(|(path, reason)| {
595 let relative = path
596 .strip_prefix(&config.root)
597 .unwrap_or(path)
598 .to_string_lossy()
599 .replace('\\', "/");
600 format!("{relative} ({})", reason.as_str())
601 })
602 .collect::<Vec<_>>()
603 .join(", ");
604 let remainder = excluded.len().saturating_sub(MAX_LISTED_EXCLUDED_SOURCES);
605 let suffix = if remainder == 0 {
606 String::new()
607 } else {
608 format!(", and {remainder} more")
609 };
610 diagnostics.warnings.push(format!(
611 "excluded {} discovered definition source file(s) as generated or non-source content: {listed}{suffix}",
612 excluded.len()
613 ));
614}
615
616fn collect_extraction_diagnostics(
617 config: &Config,
618 file: &source_adapter::SourceFile,
619 changed_files: &Option<BTreeSet<PathBuf>>,
620 extracted: Vec<source_adapter::ExtractionDiagnostic>,
621 diagnostics: &mut Diagnostics,
622 corpus_incomplete: &mut bool,
623) {
624 let changed_or_full_run = changed_files
625 .as_ref()
626 .is_none_or(|changed| changed.contains(&file.path));
627 for diagnostic in extracted {
628 let message = format!(
629 "{}: {}",
630 diagnostic.location.display(&config.root),
631 diagnostic.message
632 );
633 let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
634 let unparseable = source_adapter::is_unparseable_diagnostic(&diagnostic);
635 *corpus_incomplete |= completeness;
639 match diagnostic.level {
640 ExtractionDiagnosticLevel::Warning if unparseable && config.fail_on_unparseable => {
644 diagnostics.errors.push(message);
645 }
646 ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
647 diagnostics.warnings.push(message);
648 }
649 ExtractionDiagnosticLevel::Warning => {}
650 ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
653 }
654 }
655}
656
657fn extract_feature_steps(
658 config: &Config,
659 files: &discovery::DiscoveredFiles,
660 diagnostics: &mut Diagnostics,
661 corpus: &mut ExtractedCorpus,
662) {
663 for file in &files.features {
664 match gherkin::extract_file_with_format(&file.path, file.format) {
665 Ok(extracted) => {
666 corpus.parsed_feature_files += 1;
667 if extracted.is_empty()
668 && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
669 {
670 corpus.feature_files_without_steps += 1;
671 corpus.incomplete = true;
672 let display = file
673 .path
674 .strip_prefix(&config.root)
675 .unwrap_or(&file.path)
676 .to_string_lossy()
677 .replace('\\', "/");
678 diagnostics.warnings.push(format!(
679 "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
680 ));
681 } else {
682 corpus.feature_steps.extend(extracted);
683 }
684 }
685 Err(error) => {
687 corpus.incomplete = true;
688 diagnostics.errors.push(format!("{error:#}"));
689 }
690 }
691 }
692 if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
693 let message = if corpus.parsed_feature_files == 0 {
694 "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
695 } else {
696 format!(
697 "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
698 files.features.len() - corpus.parsed_feature_files
699 )
700 };
701 if config.require_features {
702 diagnostics.errors.push(message);
703 } else if !files.definitions.is_empty() {
704 diagnostics.warnings.push(message);
705 }
706 }
707 corpus.feature_steps.sort_by(|left, right| {
708 left.location
709 .path
710 .cmp(&right.location.path)
711 .then(left.location.line.cmp(&right.location.line))
712 .then(left.location.column.cmp(&right.location.column))
713 });
714}
715
716fn analyze_corpus(
717 config: &Config,
718 extracted: ExtractedCorpus,
719 diagnostics: &mut Diagnostics,
720) -> Result<AnalyzedCorpus> {
721 let ExtractedCorpus {
722 parameter_types,
723 parameter_language,
724 indirect_usage,
725 definitions,
726 feature_steps,
727 excluded_sources: _,
729 definition_files_with_definitions,
730 parsed_feature_files,
731 feature_files_without_steps,
732 incomplete: mut corpus_incomplete,
733 } = extracted;
734 let mut effective_config = config.clone();
735 let (patterns, type_diagnostics) = match parameter_language {
736 Some(language) => source_adapter::adapter_for_language(language)
737 .resolve_parameter_types(parameter_types, &config.parameter_types),
738 None => (config.parameter_types.clone(), Vec::new()),
739 };
740 effective_config.parameter_types = patterns;
741 corpus_incomplete |= !type_diagnostics.is_empty();
742 diagnostics.warnings.extend(type_diagnostics);
743 let config = &effective_config;
744 let (analysis, census, unmatched_suppressions) =
745 analysis::analyze_for_cli(&indirect_usage, definitions, feature_steps, config)?;
746 for index in unmatched_suppressions.indices {
747 let suppression = &config.suppressions[index];
748 diagnostics.warnings.push(format!(
749 "suppression {} for {} matched no step definitions",
750 index + 1,
751 suppression.rule
752 ));
753 }
754 if unmatched_suppressions.truncated {
755 diagnostics.warnings.push(
756 "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
757 .to_owned(),
758 );
759 }
760
761 let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
764 if config.fail_on_incomplete {
765 diagnostics.errors.extend(analysis.incomplete);
766 if corpus_incomplete {
767 diagnostics.errors.push(
768 "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
769 );
770 }
771 } else {
772 diagnostics.warnings.extend(analysis.incomplete);
773 }
774 Ok(AnalyzedCorpus {
775 result: analysis.result,
776 census,
777 definition_files_with_definitions,
778 parsed_feature_files,
779 feature_files_without_steps,
780 corpus_incomplete,
781 run_incomplete,
782 })
783}
784
785fn apply_finding_modes(
786 result: &mut crate::model::AnalysisResult,
787 files: &discovery::DiscoveredFiles,
788 changed_files: &Option<BTreeSet<PathBuf>>,
789 parsed_feature_files: usize,
790 feature_files_without_steps: usize,
791) {
792 if parsed_feature_files < files.features.len()
796 || feature_files_without_steps > 0
797 || files.features.is_empty()
798 {
799 result
800 .findings
801 .retain(|finding| finding.rule != Rule::UnusedDefinition);
802 }
803 if let Some(changed) = changed_files {
804 modes::retain_changed_findings(&mut result.findings, changed);
805 }
806}
807
808fn apply_baseline_mode(
810 config: &Config,
811 options: &CheckOptions,
812 result: &mut crate::model::AnalysisResult,
813 run_incomplete: bool,
814 diagnostics: &mut Diagnostics,
815) -> Result<Option<modes::BaselineOutcome>> {
816 if let Some(revision) = &options.baseline_from_ref {
817 let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
818 let mut base_config = config.clone();
825 base_config.root = root;
826 let files = discovery::discover(&base_config)?;
827 let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
828 let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
829 let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics)?;
830 if !files.definitions.is_empty()
838 && base.result.definitions.is_empty()
839 && !result.definitions.is_empty()
840 {
841 bail!(
842 "baseline revision `{revision}` is incomplete: it kept no step definitions while the current tree has {}, so no finding could be matched against it: {}",
843 result.definitions.len(),
844 base_diagnostics
845 .errors
846 .iter()
847 .chain(&base_diagnostics.warnings)
848 .cloned()
849 .collect::<Vec<_>>()
850 .join("; ")
851 );
852 }
853 if !base_diagnostics.errors.is_empty()
854 || (files.features.is_empty() && !base.result.definitions.is_empty())
855 {
856 bail!(
857 "baseline revision `{revision}` is incomplete: {}",
858 base_diagnostics
859 .errors
860 .iter()
861 .chain(&base_diagnostics.warnings)
862 .cloned()
863 .collect::<Vec<_>>()
864 .join("; ")
865 );
866 }
867 if base.run_incomplete {
868 diagnostics.warnings.push(format!(
869 "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"
870 ));
871 }
872 apply_finding_modes(
873 &mut base.result,
874 &files,
875 &None,
876 base.parsed_feature_files,
877 base.feature_files_without_steps,
878 );
879 return modes::apply_reference_baseline(
880 &mut result.findings,
881 &base.result.findings,
882 revision,
883 )
884 .map(Some);
885 }
886 let Some(path) = &options.baseline else {
887 return Ok(None);
888 };
889 let path = if path.is_absolute() {
890 path.clone()
891 } else {
892 config.root.join(path)
893 };
894 if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
895 diagnostics.warnings.push(format!(
896 "baseline {} was not updated because analysis is incomplete",
897 path.display()
898 ));
899 return Ok(None);
900 }
901 let outcome = if options.update_baseline {
902 modes::update_baseline(&mut result.findings, &path)?
903 } else {
904 modes::apply_baseline(&mut result.findings, &path)?
905 };
906 if options.update_baseline {
907 diagnostics.warnings.push(format!(
908 "updated baseline {}: {} added, {} removed, {} total",
909 path.display(),
910 outcome.added,
911 outcome.removed,
912 outcome.suppressed
913 ));
914 }
915 Ok(Some(outcome))
916}
917
918fn write_reports(
919 config: &Config,
920 files: &discovery::DiscoveredFiles,
921 analyzed: &AnalyzedCorpus,
922 diagnostics: &Diagnostics,
923 timings: PhaseTimings,
924) -> Result<()> {
925 let corpus = reporters::CorpusCensus {
926 definition_files: files.definitions.len(),
927 definition_files_with_definitions: analyzed.definition_files_with_definitions,
928 definitions_extracted: analyzed.result.definitions.len(),
929 feature_files: files.features.len(),
930 feature_files_parsed: analyzed.parsed_feature_files,
931 feature_files_without_steps: analyzed.feature_files_without_steps,
932 incomplete: analyzed.corpus_incomplete,
933 };
934 let metrics = reporters::ExecutionMetrics::new(
935 files.definitions.len(),
936 files.features.len(),
937 timings.discovery_ms,
938 timings.parsing_ms,
939 timings.analysis_ms,
940 );
941 let report_metadata = reporters::CliReportMetadata {
942 corpus: &corpus,
943 analysis: &analyzed.census,
944 execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
946 };
947 let terminal_color = terminal_color_enabled(
948 io::stdout().is_terminal(),
949 std::env::var_os("NO_COLOR").as_deref(),
950 );
951 let mut stdout = io::stdout().lock();
952 reporters::write_cli_reports(
953 &analyzed.result,
954 config,
955 (!config.no_metrics).then_some(&metrics),
956 &report_metadata,
957 &mut stdout,
958 terminal_color,
959 )?;
960 Ok(())
961}
962
963fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
964 is_terminal && !no_color.is_some_and(|value| !value.is_empty())
965}
966
967fn write_diagnostics(
968 config: &Config,
969 options: &CheckOptions,
970 files: &discovery::DiscoveredFiles,
971 diagnostics: &Diagnostics,
972) -> Result<()> {
973 if !options.explain_discovery
974 && diagnostics.warnings.is_empty()
975 && diagnostics.errors.is_empty()
976 {
977 return Ok(());
978 }
979 let mut stderr = io::stderr().lock();
980 if options.explain_discovery {
981 writeln!(
982 stderr,
983 "cuke-dedup: feature patterns: {} ({})",
984 config.features.join(", "),
985 config.feature_pattern_origin
986 )?;
987 for file in &files.features {
988 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
989 writeln!(
990 stderr,
991 "cuke-dedup: feature {} [{}] via {}",
992 relative.display(),
993 file.format.as_str(),
994 file.pattern
995 )?;
996 }
997 for file in &files.definitions {
998 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
999 writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
1000 }
1001 }
1002 for warning in &diagnostics.warnings {
1003 writeln!(stderr, "cuke-dedup: warning: {warning}")?;
1004 }
1005 for error in &diagnostics.errors {
1006 writeln!(stderr, "cuke-dedup: {error}")?;
1007 }
1008 Ok(())
1009}
1010
1011fn determine_exit_code(
1012 config: &Config,
1013 options: &CheckOptions,
1014 result: &crate::model::AnalysisResult,
1015 baseline: Option<modes::BaselineOutcome>,
1016 diagnostics: &Diagnostics,
1017) -> Result<i32> {
1018 if !diagnostics.errors.is_empty() {
1019 return Ok(2);
1020 }
1021 let duplication = DuplicationThreshold::from_result(result, config.threshold);
1022 let has_non_duplication_errors = result.findings.iter().any(|finding| {
1023 finding.is_active()
1024 && finding.severity == Severity::Error
1025 && !finding.rule.contributes_to_duplication_threshold()
1026 });
1027 let new_findings_exceeded = options
1028 .fail_on_new
1029 .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
1030 Ok(i32::from(
1031 has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
1032 ))
1033}
1034
1035fn elapsed_ms(started: Instant) -> f64 {
1036 started.elapsed().as_secs_f64() * 1_000.0
1037}
1038
1039fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
1040 let (rule, severity) = value
1041 .split_once('=')
1042 .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
1043 Ok((rule.parse()?, severity.parse()?))
1044}
1045
1046fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
1047 value
1048 .parse::<usize>()
1049 .ok()
1050 .filter(|value| *value > 0)
1051 .ok_or_else(|| "expected an integer greater than zero".to_owned())
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057
1058 #[test]
1059 fn finalization_failure_discards_provisional_definitions_and_reports_incompleteness() {
1060 let root = tempfile::tempdir().unwrap();
1061 let config = Config::load(root.path(), ConfigOverrides::default()).unwrap();
1062 std::fs::write(root.path().join("steps.ts"), "import { Given, Then } from '@cucumber/cucumber'; Given('same', () => work()); Then('same', () => work());").unwrap();
1063 let files = discovery::discover(&config).unwrap();
1064 let mut diagnostics = discovery_diagnostics(&config, &files, &None);
1065 let mut corpus = extract_corpus(&config, &files, &None, &mut diagnostics);
1066 assert_eq!(corpus.definitions.len(), 2);
1067 let before = corpus.definitions.clone();
1068 collect_finalization(Ok(Default::default()), &mut corpus, &mut diagnostics);
1069 assert_eq!(corpus.definitions, before);
1070 collect_finalization(
1071 Err(anyhow::anyhow!("adapter unavailable")),
1072 &mut corpus,
1073 &mut diagnostics,
1074 );
1075 assert!(corpus.incomplete);
1076 assert!(corpus.definitions.is_empty());
1077 assert_eq!(corpus.definition_files_with_definitions, 0);
1078 assert!(diagnostics
1079 .errors
1080 .iter()
1081 .any(|e| e == "failed to finalize source evidence: adapter unavailable"));
1082 let analyzed = analyze_corpus(&config, corpus, &mut diagnostics).unwrap();
1083 assert!(analyzed.run_incomplete);
1084 assert!(analyzed.result.findings.is_empty());
1085 }
1086
1087 #[test]
1088 fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
1089 use std::ffi::OsStr;
1090
1091 assert!(terminal_color_enabled(true, None));
1092 assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
1093 assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
1094 assert!(!terminal_color_enabled(false, None));
1095 assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
1096 }
1097
1098 #[test]
1099 fn parses_rule_override() {
1100 assert_eq!(
1101 parse_rule_override("duplicate-matcher=warning"),
1102 Ok((Rule::DuplicateMatcher, Severity::Warning))
1103 );
1104 assert!(parse_rule_override("unknown=error").is_err());
1105 }
1106
1107 #[test]
1108 fn candidate_limit_cli_values_must_be_positive() {
1109 assert_eq!(parse_positive_usize("7"), Ok(7));
1110 assert!(parse_positive_usize("0").is_err());
1111 assert!(parse_positive_usize("-1").is_err());
1112 assert!(parse_positive_usize("many").is_err());
1113 }
1114
1115 #[test]
1116 fn unparseable_diagnostics_are_tolerated_by_default_and_escalated_by_the_flag() {
1117 use crate::discovery::SourceLanguage;
1118 use crate::model::SourceLocation;
1119 use crate::source_adapter::{ExtractionDiagnostic, SourceFile};
1120
1121 let directory = tempfile::tempdir().unwrap();
1122 let file = SourceFile {
1123 path: directory.path().join("steps.ts"),
1124 language: SourceLanguage::TypeScript,
1125 };
1126 let unparseable = || {
1127 ExtractionDiagnostic::new(
1128 ExtractionDiagnosticLevel::Warning,
1129 SourceLocation::new("steps.ts", 1, 1, 1, 1),
1130 format!(
1131 "{}, so analysis is incomplete",
1132 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX
1133 ),
1134 )
1135 };
1136 let route = |config: &Config, diagnostic: ExtractionDiagnostic| {
1137 let mut diagnostics = Diagnostics {
1138 warnings: Vec::new(),
1139 errors: Vec::new(),
1140 };
1141 let mut incomplete = false;
1142 collect_extraction_diagnostics(
1143 config,
1144 &file,
1145 &None,
1146 vec![diagnostic],
1147 &mut diagnostics,
1148 &mut incomplete,
1149 );
1150 (diagnostics, incomplete)
1151 };
1152
1153 let mut config =
1154 Config::load(directory.path(), ConfigOverrides::default()).expect("config loads");
1155
1156 let (diagnostics, incomplete) = route(&config, unparseable());
1158 assert!(incomplete);
1159 assert!(diagnostics.errors.is_empty());
1160 assert_eq!(diagnostics.warnings.len(), 1);
1161
1162 config.fail_on_unparseable = true;
1165 let (diagnostics, incomplete) = route(&config, unparseable());
1166 assert!(incomplete);
1167 assert_eq!(diagnostics.errors.len(), 1);
1168 assert!(diagnostics.warnings.is_empty());
1169
1170 let dynamic = ExtractionDiagnostic::new(
1173 ExtractionDiagnosticLevel::Warning,
1174 SourceLocation::new("steps.ts", 2, 1, 2, 1),
1175 "dynamic or unsupported step matcher cannot be analyzed statically",
1176 );
1177 let (diagnostics, incomplete) = route(&config, dynamic);
1178 assert!(incomplete);
1179 assert!(diagnostics.errors.is_empty());
1180 assert_eq!(diagnostics.warnings.len(), 1);
1181 let unrelated = ExtractionDiagnostic::new(
1182 ExtractionDiagnosticLevel::Warning,
1183 SourceLocation::new("steps.ts", 2, 1, 2, 1),
1184 "unrelated extractor warning",
1185 );
1186 let (diagnostics, incomplete) = route(&config, unrelated);
1187 assert!(!incomplete);
1188 assert!(diagnostics.errors.is_empty());
1189 assert_eq!(diagnostics.warnings.len(), 1);
1190 }
1191
1192 #[test]
1193 fn typed_adapter_diagnostics_route_independently_of_language_and_wording() {
1194 use crate::model::SourceLocation;
1195 use crate::source_adapter::{
1196 ExtractionDiagnostic, ExtractionDiagnosticKind as Kind, SourceFile, SourceLanguage,
1197 };
1198 let directory = tempfile::tempdir().unwrap();
1199 let file = SourceFile {
1200 path: directory.path().join("steps.ts"),
1201 language: SourceLanguage::TypeScript,
1202 };
1203 let mut config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1204 for strict in [false, true] {
1205 config.fail_on_unparseable = strict;
1206 for (kind, message, incomplete, parse_error) in [
1207 (Kind::Other, "adapter advisory", false, false),
1208 (Kind::Incomplete, "external glue unavailable", true, false),
1209 (Kind::Unparseable, "adapter syntax failure", true, true),
1210 (
1211 Kind::Other,
1212 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1213 false,
1214 false,
1215 ),
1216 (
1217 Kind::Incomplete,
1218 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1219 true,
1220 false,
1221 ),
1222 ] {
1223 for level in [
1224 ExtractionDiagnosticLevel::Warning,
1225 ExtractionDiagnosticLevel::Error,
1226 ] {
1227 let mut diagnostics = Diagnostics {
1228 warnings: vec![],
1229 errors: vec![],
1230 };
1231 let mut actual_incomplete = false;
1232 collect_extraction_diagnostics(
1233 &config,
1234 &file,
1235 &None,
1236 vec![ExtractionDiagnostic::with_kind(
1237 kind,
1238 level,
1239 SourceLocation::new(&file.path, 1, 1, 1, 1),
1240 message,
1241 )],
1242 &mut diagnostics,
1243 &mut actual_incomplete,
1244 );
1245 assert_eq!(actual_incomplete, incomplete, "{kind:?} strict={strict}");
1246 let error =
1247 level == ExtractionDiagnosticLevel::Error || (strict && parse_error);
1248 assert_eq!(diagnostics.errors.len(), usize::from(error));
1249 assert_eq!(diagnostics.warnings.len(), usize::from(!error));
1250 }
1251 }
1252 }
1253 }
1254
1255 #[test]
1256 fn supports_implicit_and_explicit_check_forms() {
1257 let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
1258 .expect("implicit invocation");
1259 assert!(implicit.command.is_none());
1260 let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
1261 .expect("explicit invocation");
1262 assert!(matches!(explicit.command, Some(Command::Check { .. })));
1263 assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
1264 }
1265}