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> {
218 let invocation = resolve_invocation(cli)?;
219 let config = &invocation.config;
220 let options = &invocation.options;
221 if options.print_config {
222 println!(
223 "{}",
224 serde_json::to_string_pretty(config)
225 .context("failed to serialize effective configuration")?
226 );
227 return Ok(0);
228 }
229 let changed_files = resolve_changed_files(config, options.changed_since.as_deref())?;
230 let discovery_started = Instant::now();
231 let files = discovery::discover(config)?;
232 let discovery_ms = elapsed_ms(discovery_started);
233
234 let parsing_started = Instant::now();
235 let mut diagnostics = discovery_diagnostics(config, &files, &changed_files);
236 let extracted = extract_corpus(config, &files, &changed_files, &mut diagnostics);
237 let parsing_ms = elapsed_ms(parsing_started);
238 let analysis_started = Instant::now();
239 let mut analyzed = analyze_corpus(config, extracted, &mut diagnostics)?;
240 let analysis_ms = elapsed_ms(analysis_started);
241 apply_finding_modes(
242 &mut analyzed.result,
243 &files,
244 &changed_files,
245 analyzed.parsed_feature_files,
246 analyzed.feature_files_without_steps,
247 );
248 let baseline_outcome = apply_baseline_mode(
249 config,
250 options,
251 &mut analyzed.result,
252 analyzed.run_incomplete,
253 &mut diagnostics,
254 )?;
255 write_reports(
256 config,
257 &files,
258 &analyzed,
259 &diagnostics,
260 PhaseTimings {
261 discovery_ms,
262 parsing_ms,
263 analysis_ms,
264 },
265 )?;
266 write_diagnostics(config, options, &files, &diagnostics)?;
267 determine_exit_code(
268 config,
269 options,
270 &analyzed.result,
271 baseline_outcome,
272 &diagnostics,
273 )
274}
275
276fn resolve_invocation(cli: Cli) -> Result<Invocation> {
279 if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
280 bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
281 }
282 let root = match cli.command {
283 Some(Command::Check { path }) => path,
284 None => cli.path,
285 };
286 let options = cli.options.clone();
287 let config = Config::load_for_cli(
288 &root,
289 ConfigOverrides {
290 config_file: cli.options.config,
291 definitions: cli.options.definitions,
292 features: cli.options.features,
293 exclude: cli.options.exclude,
294 exclude_defaults: cli.options.no_default_excludes.then_some(false),
295 include_hidden: cli.options.include_hidden.then_some(true),
296 reporters: cli.options.reporters,
297 output: cli.options.output,
298 threshold: cli.options.threshold,
299 require_features: cli.options.require_features.then_some(true),
300 no_metrics: cli.options.no_metrics.then_some(true),
301 rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
302 },
303 CliConfigOverrides {
304 require_definitions: cli.options.require_definitions.then_some(true),
305 fail_on_incomplete: cli.options.fail_on_incomplete.then_some(true),
306 fail_on_unparseable: cli.options.fail_on_unparseable.then_some(true),
307 max_candidate_comparisons: cli.options.max_candidate_comparisons,
308 max_structural_class_comparisons: cli.options.max_structural_class_comparisons,
309 },
310 )?;
311 Ok(Invocation { config, options })
312}
313
314fn resolve_changed_files(
318 config: &Config,
319 changed_since: Option<&str>,
320) -> Result<Option<BTreeSet<PathBuf>>> {
321 let Some(base) = changed_since else {
322 return Ok(None);
323 };
324 modes::ensure_changed_root_is_trackable(&config.root)?;
325 Ok(Some(modes::git_changed_files(&config.root, base)?))
326}
327
328fn discovery_diagnostics(
333 config: &Config,
334 files: &discovery::DiscoveredFiles,
335 changed_files: &Option<BTreeSet<PathBuf>>,
336) -> Diagnostics {
337 let mut diagnostics = Diagnostics {
338 warnings: config.config_warnings.clone(),
339 errors: files.errors.clone(),
340 };
341 for pattern in &files.unmatched_feature_patterns {
342 diagnostics.warnings.push(format!(
343 "feature pattern `{pattern}` from {} matched no files",
344 config.feature_pattern_origin
345 ));
346 }
347 for pattern in &files.unmatched_definition_patterns {
348 diagnostics
349 .warnings
350 .push(format!("definition pattern `{pattern}` matched no files"));
351 }
352 if !files.skipped_definitions.is_empty()
353 && (!config.definitions.is_empty() || files.definitions.is_empty())
354 {
355 diagnostics
356 .warnings
357 .push(skipped_definitions_warning(&files.skipped_definitions));
358 }
359 if files.definitions.is_empty() && config.definitions.is_empty() {
360 diagnostics
361 .warnings
362 .push("automatic discovery found no supported step-definition source files".to_owned());
363 }
364 if files.features.is_empty() {
365 let message = format!(
366 "no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
367 config.feature_pattern_origin
368 );
369 if config.require_features {
370 diagnostics.errors.push(message);
371 } else if !files.definitions.is_empty() {
372 diagnostics.warnings.push(message);
373 }
374 }
375 if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
376 diagnostics.warnings.push(
377 "--changed-since found no tracked or untracked files; no findings will be reported"
378 .to_owned(),
379 );
380 }
381 diagnostics
382}
383
384fn skipped_definitions_warning(skipped: &[source_adapter::SourceFile]) -> String {
386 let registrations: Vec<_> = source_adapter::SOURCE_ADAPTER_REGISTRY
387 .iter()
388 .filter(|entry| {
389 skipped
390 .iter()
391 .any(|file| entry.adapter.language() == file.language)
392 })
393 .collect();
394 let adapters: BTreeSet<_> = registrations
395 .iter()
396 .map(|entry| entry.adapter.name())
397 .collect();
398 let suffixes: Vec<_> = registrations
399 .iter()
400 .map(|entry| format!("`{}`", entry.suffix))
401 .collect();
402 format!(
403 "{} {} source file(s) were not analyzed: opt-in languages need a definition pattern that names their suffix ({}), such as `features/**/*{}`",
404 skipped.len(),
405 adapters.into_iter().collect::<Vec<_>>().join(", "),
406 suffixes.join(", "),
407 registrations.first().map_or("", |entry| entry.suffix),
408 )
409}
410
411fn extract_corpus(
414 config: &Config,
415 files: &discovery::DiscoveredFiles,
416 changed_files: &Option<BTreeSet<PathBuf>>,
417 diagnostics: &mut Diagnostics,
418) -> ExtractedCorpus {
419 let mut corpus = ExtractedCorpus {
420 parameter_types: Vec::new(),
421 parameter_language: files.definitions.first().map(|file| file.language),
422 indirect_usage: source_adapter::IndirectStepUsage::default(),
423 definitions: Vec::new(),
424 feature_steps: Vec::new(),
425 excluded_sources: Vec::new(),
426 definition_files_with_definitions: 0,
427 parsed_feature_files: 0,
428 feature_files_without_steps: 0,
429 incomplete: false,
430 };
431 extract_definitions(config, files, changed_files, diagnostics, &mut corpus);
432 extract_feature_steps(config, files, diagnostics, &mut corpus);
433 corpus
434}
435
436fn extract_definitions(
438 config: &Config,
439 files: &discovery::DiscoveredFiles,
440 changed_files: &Option<BTreeSet<PathBuf>>,
441 diagnostics: &mut Diagnostics,
442 corpus: &mut ExtractedCorpus,
443) {
444 if let Some(first) = files.definitions.first() {
445 let domain = first.language.comparison_domain();
446 if files
447 .definitions
448 .iter()
449 .any(|file| file.language.comparison_domain() != domain)
450 {
451 corpus.incomplete = true;
452 diagnostics.errors.push("Ruby and JS/TS sources require separate analysis runs with explicit definition and feature roots".to_owned());
453 return;
454 }
455 }
456 let mut extraction_session = source_adapter::SourceExtractionSession::with_options(
457 &config.root,
458 &config.registrations,
459 &config.assertion_modules,
460 )
461 .with_dependencies(&files.dependencies)
462 .with_dependency_gaps(&files.dependency_gaps);
463 let mut prepared = BTreeSet::new();
464 for file in &files.definitions {
465 let adapter = source_adapter::adapter_for_language(file.language);
466 if prepared.insert(adapter.name()) {
467 if let Err(error) = adapter.prepare_session(&files.definitions, &mut extraction_session)
468 {
469 corpus.incomplete = true;
470 diagnostics
471 .errors
472 .push(format!("failed to prepare source evidence: {error:#}"));
473 return;
474 }
475 }
476 }
477 for file in &files.definitions {
478 let adapter = source_adapter::adapter_for_language(file.language);
482 if let Some(excluded) = adapter.inspect_source(file, &config.registrations) {
483 corpus.incomplete = true;
489 corpus.excluded_sources.push((file.path.clone(), excluded));
490 continue;
491 }
492 match adapter
493 .extract_file_with_session(file, &mut extraction_session)
494 .with_context(|| format!("failed to analyze {}", file.path.display()))
495 {
496 Ok(extracted) => {
497 if adapter.supports_indirect_usage() {
498 corpus.indirect_usage.extend(extracted.indirect_usage);
499 corpus.incomplete |= corpus.indirect_usage.unknown;
500 }
501 corpus.parameter_types.extend(extracted.parameter_types);
502 if !extracted.definitions.is_empty() {
503 corpus.definition_files_with_definitions += 1;
504 }
505 corpus.definitions.extend(extracted.definitions);
506 collect_extraction_diagnostics(
507 config,
508 file,
509 changed_files,
510 extracted.diagnostics,
511 diagnostics,
512 &mut corpus.incomplete,
513 );
514 }
515 Err(error) => {
519 if adapter.supports_indirect_usage() {
520 corpus.indirect_usage.unknown = true;
521 corpus.incomplete = true;
522 }
523 diagnostics.errors.push(format!("{error:#}"));
524 }
525 }
526 }
527 let finalization = extraction_session.finalize(&files.definitions, &mut corpus.definitions);
528 collect_finalization(finalization, corpus, diagnostics);
529 corpus.definitions.sort_by(|left, right| {
530 left.location
531 .path
532 .cmp(&right.location.path)
533 .then(left.location.line.cmp(&right.location.line))
534 .then(left.location.column.cmp(&right.location.column))
535 });
536 report_excluded_sources(config, &corpus.excluded_sources, diagnostics);
537 if corpus.definitions.is_empty() {
538 let message = if files.definitions.is_empty() {
539 "no step definitions were extracted because no definition source files were discovered"
540 .to_owned()
541 } else {
542 format!(
543 "definition extraction produced 0 definitions from {} discovered definition source file(s)",
544 files.definitions.len()
545 )
546 };
547 if config.require_definitions {
548 diagnostics.errors.push(message);
549 } else if !files.definitions.is_empty() {
550 diagnostics.warnings.push(message);
551 }
552 }
553}
554
555fn collect_finalization(
559 finalization: Result<source_adapter::SourceFinalization>,
560 corpus: &mut ExtractedCorpus,
561 diagnostics: &mut Diagnostics,
562) {
563 match finalization {
564 Ok(finalization) => {
565 if !finalization.uncertainties.is_empty() {
566 corpus.incomplete = true;
567 diagnostics.warnings.extend(
568 finalization
569 .uncertainties
570 .into_iter()
571 .map(|item| item.message),
572 );
573 corpus.definition_files_with_definitions = corpus
574 .definitions
575 .iter()
576 .map(|definition| &definition.location.path)
577 .collect::<BTreeSet<_>>()
578 .len();
579 }
580 }
581 Err(error) => {
582 corpus.incomplete = true;
583 corpus.definitions.clear();
584 corpus.definition_files_with_definitions = 0;
585 diagnostics
586 .errors
587 .push(format!("failed to finalize source evidence: {error:#}"));
588 }
589 }
590}
591
592const MAX_LISTED_EXCLUDED_SOURCES: usize = 10;
594
595fn report_excluded_sources(
601 config: &Config,
602 excluded: &[(PathBuf, source_adapter::SourceExclusion)],
603 diagnostics: &mut Diagnostics,
604) {
605 if excluded.is_empty() {
606 return;
607 }
608 let listed = excluded
609 .iter()
610 .take(MAX_LISTED_EXCLUDED_SOURCES)
611 .map(|(path, reason)| {
612 let relative = path
613 .strip_prefix(&config.root)
614 .unwrap_or(path)
615 .to_string_lossy()
616 .replace('\\', "/");
617 format!("{relative} ({})", reason.as_str())
618 })
619 .collect::<Vec<_>>()
620 .join(", ");
621 let remainder = excluded.len().saturating_sub(MAX_LISTED_EXCLUDED_SOURCES);
622 let suffix = if remainder == 0 {
623 String::new()
624 } else {
625 format!(", and {remainder} more")
626 };
627 diagnostics.warnings.push(format!(
628 "excluded {} discovered definition source file(s) as generated or non-source content: {listed}{suffix}",
629 excluded.len()
630 ));
631}
632
633fn collect_extraction_diagnostics(
638 config: &Config,
639 file: &source_adapter::SourceFile,
640 changed_files: &Option<BTreeSet<PathBuf>>,
641 extracted: Vec<source_adapter::ExtractionDiagnostic>,
642 diagnostics: &mut Diagnostics,
643 corpus_incomplete: &mut bool,
644) {
645 let changed_or_full_run = changed_files
646 .as_ref()
647 .is_none_or(|changed| changed.contains(&file.path));
648 for diagnostic in extracted {
649 let message = format!(
650 "{}: {}",
651 diagnostic.location.display(&config.root),
652 diagnostic.message
653 );
654 let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
655 let unparseable = source_adapter::is_unparseable_diagnostic(&diagnostic);
656 *corpus_incomplete |= completeness;
660 match diagnostic.level {
661 ExtractionDiagnosticLevel::Warning if unparseable && config.fail_on_unparseable => {
665 diagnostics.errors.push(message);
666 }
667 ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
668 diagnostics.warnings.push(message);
669 }
670 ExtractionDiagnosticLevel::Warning => {}
671 ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
674 }
675 }
676}
677
678fn extract_feature_steps(
682 config: &Config,
683 files: &discovery::DiscoveredFiles,
684 diagnostics: &mut Diagnostics,
685 corpus: &mut ExtractedCorpus,
686) {
687 for file in &files.features {
688 match gherkin::extract_file_with_format(&file.path, file.format) {
689 Ok(extracted) => {
690 corpus.parsed_feature_files += 1;
691 if extracted.is_empty()
692 && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
693 {
694 corpus.feature_files_without_steps += 1;
695 corpus.incomplete = true;
696 let display = file
697 .path
698 .strip_prefix(&config.root)
699 .unwrap_or(&file.path)
700 .to_string_lossy()
701 .replace('\\', "/");
702 diagnostics.warnings.push(format!(
703 "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
704 ));
705 } else {
706 corpus.feature_steps.extend(extracted);
707 }
708 }
709 Err(error) => {
711 corpus.incomplete = true;
712 diagnostics.errors.push(format!("{error:#}"));
713 }
714 }
715 }
716 if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
717 let message = if corpus.parsed_feature_files == 0 {
718 "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
719 } else {
720 format!(
721 "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
722 files.features.len() - corpus.parsed_feature_files
723 )
724 };
725 if config.require_features {
726 diagnostics.errors.push(message);
727 } else if !files.definitions.is_empty() {
728 diagnostics.warnings.push(message);
729 }
730 }
731 corpus.feature_steps.sort_by(|left, right| {
732 left.location
733 .path
734 .cmp(&right.location.path)
735 .then(left.location.line.cmp(&right.location.line))
736 .then(left.location.column.cmp(&right.location.column))
737 });
738}
739
740fn analyze_corpus(
745 config: &Config,
746 extracted: ExtractedCorpus,
747 diagnostics: &mut Diagnostics,
748) -> Result<AnalyzedCorpus> {
749 let ExtractedCorpus {
750 parameter_types,
751 parameter_language,
752 indirect_usage,
753 definitions,
754 feature_steps,
755 excluded_sources: _,
757 definition_files_with_definitions,
758 parsed_feature_files,
759 feature_files_without_steps,
760 incomplete: mut corpus_incomplete,
761 } = extracted;
762 let mut effective_config = config.clone();
763 let (patterns, type_diagnostics) = match parameter_language {
764 Some(language) => source_adapter::adapter_for_language(language)
765 .resolve_parameter_types(parameter_types, &config.parameter_types),
766 None => (config.parameter_types.clone(), Vec::new()),
767 };
768 effective_config.parameter_types = patterns;
769 corpus_incomplete |= !type_diagnostics.is_empty();
770 diagnostics.warnings.extend(type_diagnostics);
771 let config = &effective_config;
772 let (analysis, census, unmatched_suppressions) =
773 analysis::analyze_for_cli(&indirect_usage, definitions, feature_steps, config)?;
774 for index in unmatched_suppressions.indices {
775 let suppression = &config.suppressions[index];
776 diagnostics.warnings.push(format!(
777 "suppression {} for {} matched no step definitions",
778 index + 1,
779 suppression.rule
780 ));
781 }
782 if unmatched_suppressions.truncated {
783 diagnostics.warnings.push(
784 "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
785 .to_owned(),
786 );
787 }
788
789 let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
792 if config.fail_on_incomplete {
793 diagnostics.errors.extend(analysis.incomplete);
794 if corpus_incomplete {
795 diagnostics.errors.push(
796 "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
797 );
798 }
799 } else {
800 diagnostics.warnings.extend(analysis.incomplete);
801 }
802 Ok(AnalyzedCorpus {
803 result: analysis.result,
804 census,
805 definition_files_with_definitions,
806 parsed_feature_files,
807 feature_files_without_steps,
808 corpus_incomplete,
809 run_incomplete,
810 })
811}
812
813fn apply_finding_modes(
817 result: &mut crate::model::AnalysisResult,
818 files: &discovery::DiscoveredFiles,
819 changed_files: &Option<BTreeSet<PathBuf>>,
820 parsed_feature_files: usize,
821 feature_files_without_steps: usize,
822) {
823 if parsed_feature_files < files.features.len()
827 || feature_files_without_steps > 0
828 || files.features.is_empty()
829 {
830 result
831 .findings
832 .retain(|finding| finding.rule != Rule::UnusedDefinition);
833 }
834 if let Some(changed) = changed_files {
835 modes::retain_changed_findings(&mut result.findings, changed);
836 }
837}
838
839fn apply_baseline_mode(
841 config: &Config,
842 options: &CheckOptions,
843 result: &mut crate::model::AnalysisResult,
844 run_incomplete: bool,
845 diagnostics: &mut Diagnostics,
846) -> Result<Option<modes::BaselineOutcome>> {
847 if let Some(revision) = &options.baseline_from_ref {
848 let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
849 let mut base_config = config.clone();
856 base_config.root = root;
857 let files = discovery::discover(&base_config)?;
858 let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
859 let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
860 let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics)?;
861 if !files.definitions.is_empty()
869 && base.result.definitions.is_empty()
870 && !result.definitions.is_empty()
871 {
872 bail!(
873 "baseline revision `{revision}` is incomplete: it kept no step definitions while the current tree has {}, so no finding could be matched against it: {}",
874 result.definitions.len(),
875 base_diagnostics
876 .errors
877 .iter()
878 .chain(&base_diagnostics.warnings)
879 .cloned()
880 .collect::<Vec<_>>()
881 .join("; ")
882 );
883 }
884 if !base_diagnostics.errors.is_empty()
885 || (files.features.is_empty() && !base.result.definitions.is_empty())
886 {
887 bail!(
888 "baseline revision `{revision}` is incomplete: {}",
889 base_diagnostics
890 .errors
891 .iter()
892 .chain(&base_diagnostics.warnings)
893 .cloned()
894 .collect::<Vec<_>>()
895 .join("; ")
896 );
897 }
898 if base.run_incomplete {
899 diagnostics.warnings.push(format!(
900 "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"
901 ));
902 }
903 apply_finding_modes(
904 &mut base.result,
905 &files,
906 &None,
907 base.parsed_feature_files,
908 base.feature_files_without_steps,
909 );
910 return modes::apply_reference_baseline(
911 &mut result.findings,
912 &base.result.findings,
913 revision,
914 )
915 .map(Some);
916 }
917 let Some(path) = &options.baseline else {
918 return Ok(None);
919 };
920 let path = if path.is_absolute() {
921 path.clone()
922 } else {
923 config.root.join(path)
924 };
925 if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
926 diagnostics.warnings.push(format!(
927 "baseline {} was not updated because analysis is incomplete",
928 path.display()
929 ));
930 return Ok(None);
931 }
932 let outcome = if options.update_baseline {
933 modes::update_baseline(&mut result.findings, &path)?
934 } else {
935 modes::apply_baseline(&mut result.findings, &path)?
936 };
937 if options.update_baseline {
938 diagnostics.warnings.push(format!(
939 "updated baseline {}: {} added, {} removed, {} total",
940 path.display(),
941 outcome.added,
942 outcome.removed,
943 outcome.suppressed
944 ));
945 }
946 Ok(Some(outcome))
947}
948
949fn write_reports(
952 config: &Config,
953 files: &discovery::DiscoveredFiles,
954 analyzed: &AnalyzedCorpus,
955 diagnostics: &Diagnostics,
956 timings: PhaseTimings,
957) -> Result<()> {
958 let corpus = reporters::CorpusCensus {
959 definition_files: files.definitions.len(),
960 definition_files_with_definitions: analyzed.definition_files_with_definitions,
961 definitions_extracted: analyzed.result.definitions.len(),
962 feature_files: files.features.len(),
963 feature_files_parsed: analyzed.parsed_feature_files,
964 feature_files_without_steps: analyzed.feature_files_without_steps,
965 incomplete: analyzed.corpus_incomplete,
966 };
967 let metrics = reporters::ExecutionMetrics::new(
968 files.definitions.len(),
969 files.features.len(),
970 timings.discovery_ms,
971 timings.parsing_ms,
972 timings.analysis_ms,
973 );
974 let report_metadata = reporters::CliReportMetadata {
975 corpus: &corpus,
976 analysis: &analyzed.census,
977 execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
979 };
980 let terminal_color = terminal_color_enabled(
981 io::stdout().is_terminal(),
982 std::env::var_os("NO_COLOR").as_deref(),
983 );
984 let mut stdout = io::stdout().lock();
985 reporters::write_cli_reports(
986 &analyzed.result,
987 config,
988 (!config.no_metrics).then_some(&metrics),
989 &report_metadata,
990 &mut stdout,
991 terminal_color,
992 )?;
993 Ok(())
994}
995
996fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
998 is_terminal && !no_color.is_some_and(|value| !value.is_empty())
999}
1000
1001fn write_diagnostics(
1004 config: &Config,
1005 options: &CheckOptions,
1006 files: &discovery::DiscoveredFiles,
1007 diagnostics: &Diagnostics,
1008) -> Result<()> {
1009 if !options.explain_discovery
1010 && diagnostics.warnings.is_empty()
1011 && diagnostics.errors.is_empty()
1012 {
1013 return Ok(());
1014 }
1015 let mut stderr = io::stderr().lock();
1016 if options.explain_discovery {
1017 writeln!(
1018 stderr,
1019 "cuke-dedup: feature patterns: {} ({})",
1020 config.features.join(", "),
1021 config.feature_pattern_origin
1022 )?;
1023 for file in &files.features {
1024 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
1025 writeln!(
1026 stderr,
1027 "cuke-dedup: feature {} [{}] via {}",
1028 relative.display(),
1029 file.format.as_str(),
1030 file.pattern
1031 )?;
1032 }
1033 for file in &files.definitions {
1034 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
1035 writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
1036 }
1037 }
1038 for warning in &diagnostics.warnings {
1039 writeln!(stderr, "cuke-dedup: warning: {warning}")?;
1040 }
1041 for error in &diagnostics.errors {
1042 writeln!(stderr, "cuke-dedup: {error}")?;
1043 }
1044 Ok(())
1045}
1046
1047fn determine_exit_code(
1050 config: &Config,
1051 options: &CheckOptions,
1052 result: &crate::model::AnalysisResult,
1053 baseline: Option<modes::BaselineOutcome>,
1054 diagnostics: &Diagnostics,
1055) -> Result<i32> {
1056 if !diagnostics.errors.is_empty() {
1057 return Ok(2);
1058 }
1059 let duplication = DuplicationThreshold::from_result(result, config.threshold);
1060 let has_non_duplication_errors = result.findings.iter().any(|finding| {
1061 finding.is_active()
1062 && finding.severity == Severity::Error
1063 && !finding.rule.contributes_to_duplication_threshold()
1064 });
1065 let new_findings_exceeded = options
1066 .fail_on_new
1067 .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
1068 Ok(i32::from(
1069 has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
1070 ))
1071}
1072
1073fn elapsed_ms(started: Instant) -> f64 {
1075 started.elapsed().as_secs_f64() * 1_000.0
1076}
1077
1078fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
1080 let (rule, severity) = value
1081 .split_once('=')
1082 .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
1083 Ok((rule.parse()?, severity.parse()?))
1084}
1085
1086fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
1088 value
1089 .parse::<usize>()
1090 .ok()
1091 .filter(|value| *value > 0)
1092 .ok_or_else(|| "expected an integer greater than zero".to_owned())
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::*;
1098
1099 #[test]
1102 fn finalization_failure_discards_provisional_definitions_and_reports_incompleteness() {
1103 let root = tempfile::tempdir().unwrap();
1104 let config = Config::load(root.path(), ConfigOverrides::default()).unwrap();
1105 std::fs::write(root.path().join("steps.ts"), "import { Given, Then } from '@cucumber/cucumber'; Given('same', () => work()); Then('same', () => work());").unwrap();
1106 let files = discovery::discover(&config).unwrap();
1107 let mut diagnostics = discovery_diagnostics(&config, &files, &None);
1108 let mut corpus = extract_corpus(&config, &files, &None, &mut diagnostics);
1109 assert_eq!(corpus.definitions.len(), 2);
1110 let before = corpus.definitions.clone();
1111 collect_finalization(Ok(Default::default()), &mut corpus, &mut diagnostics);
1112 assert_eq!(corpus.definitions, before);
1113 collect_finalization(
1114 Err(anyhow::anyhow!("adapter unavailable")),
1115 &mut corpus,
1116 &mut diagnostics,
1117 );
1118 assert!(corpus.incomplete);
1119 assert!(corpus.definitions.is_empty());
1120 assert_eq!(corpus.definition_files_with_definitions, 0);
1121 assert!(diagnostics
1122 .errors
1123 .iter()
1124 .any(|e| e == "failed to finalize source evidence: adapter unavailable"));
1125 let analyzed = analyze_corpus(&config, corpus, &mut diagnostics).unwrap();
1126 assert!(analyzed.run_incomplete);
1127 assert!(analyzed.result.findings.is_empty());
1128 }
1129
1130 #[test]
1132 fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
1133 use std::ffi::OsStr;
1134
1135 assert!(terminal_color_enabled(true, None));
1136 assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
1137 assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
1138 assert!(!terminal_color_enabled(false, None));
1139 assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
1140 }
1141
1142 #[test]
1144 fn parses_rule_override() {
1145 assert_eq!(
1146 parse_rule_override("duplicate-matcher=warning"),
1147 Ok((Rule::DuplicateMatcher, Severity::Warning))
1148 );
1149 assert!(parse_rule_override("unknown=error").is_err());
1150 }
1151
1152 #[test]
1154 fn candidate_limit_cli_values_must_be_positive() {
1155 assert_eq!(parse_positive_usize("7"), Ok(7));
1156 assert!(parse_positive_usize("0").is_err());
1157 assert!(parse_positive_usize("-1").is_err());
1158 assert!(parse_positive_usize("many").is_err());
1159 }
1160
1161 #[test]
1164 fn unparseable_diagnostics_are_tolerated_by_default_and_escalated_by_the_flag() {
1165 use crate::discovery::SourceLanguage;
1166 use crate::model::SourceLocation;
1167 use crate::source_adapter::{ExtractionDiagnostic, SourceFile};
1168
1169 let directory = tempfile::tempdir().unwrap();
1170 let file = SourceFile {
1171 path: directory.path().join("steps.ts"),
1172 language: SourceLanguage::TypeScript,
1173 };
1174 let unparseable = || {
1175 ExtractionDiagnostic::new(
1176 ExtractionDiagnosticLevel::Warning,
1177 SourceLocation::new("steps.ts", 1, 1, 1, 1),
1178 format!(
1179 "{}, so analysis is incomplete",
1180 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX
1181 ),
1182 )
1183 };
1184 let route = |config: &Config, diagnostic: ExtractionDiagnostic| {
1185 let mut diagnostics = Diagnostics {
1186 warnings: Vec::new(),
1187 errors: Vec::new(),
1188 };
1189 let mut incomplete = false;
1190 collect_extraction_diagnostics(
1191 config,
1192 &file,
1193 &None,
1194 vec![diagnostic],
1195 &mut diagnostics,
1196 &mut incomplete,
1197 );
1198 (diagnostics, incomplete)
1199 };
1200
1201 let mut config =
1202 Config::load(directory.path(), ConfigOverrides::default()).expect("config loads");
1203
1204 let (diagnostics, incomplete) = route(&config, unparseable());
1206 assert!(incomplete);
1207 assert!(diagnostics.errors.is_empty());
1208 assert_eq!(diagnostics.warnings.len(), 1);
1209
1210 config.fail_on_unparseable = true;
1213 let (diagnostics, incomplete) = route(&config, unparseable());
1214 assert!(incomplete);
1215 assert_eq!(diagnostics.errors.len(), 1);
1216 assert!(diagnostics.warnings.is_empty());
1217
1218 let dynamic = ExtractionDiagnostic::new(
1221 ExtractionDiagnosticLevel::Warning,
1222 SourceLocation::new("steps.ts", 2, 1, 2, 1),
1223 "dynamic or unsupported step matcher cannot be analyzed statically",
1224 );
1225 let (diagnostics, incomplete) = route(&config, dynamic);
1226 assert!(incomplete);
1227 assert!(diagnostics.errors.is_empty());
1228 assert_eq!(diagnostics.warnings.len(), 1);
1229 let unrelated = ExtractionDiagnostic::new(
1230 ExtractionDiagnosticLevel::Warning,
1231 SourceLocation::new("steps.ts", 2, 1, 2, 1),
1232 "unrelated extractor warning",
1233 );
1234 let (diagnostics, incomplete) = route(&config, unrelated);
1235 assert!(!incomplete);
1236 assert!(diagnostics.errors.is_empty());
1237 assert_eq!(diagnostics.warnings.len(), 1);
1238 }
1239
1240 #[test]
1244 fn typed_adapter_diagnostics_route_independently_of_language_and_wording() {
1245 use crate::model::SourceLocation;
1246 use crate::source_adapter::{
1247 ExtractionDiagnostic, ExtractionDiagnosticKind as Kind, SourceFile, SourceLanguage,
1248 };
1249 let directory = tempfile::tempdir().unwrap();
1250 let file = SourceFile {
1251 path: directory.path().join("steps.ts"),
1252 language: SourceLanguage::TypeScript,
1253 };
1254 let mut config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1255 for strict in [false, true] {
1256 config.fail_on_unparseable = strict;
1257 for (kind, message, incomplete, parse_error) in [
1258 (Kind::Other, "adapter advisory", false, false),
1259 (Kind::Incomplete, "external glue unavailable", true, false),
1260 (Kind::Unparseable, "adapter syntax failure", true, true),
1261 (
1262 Kind::Other,
1263 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1264 false,
1265 false,
1266 ),
1267 (
1268 Kind::Incomplete,
1269 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1270 true,
1271 false,
1272 ),
1273 ] {
1274 for level in [
1275 ExtractionDiagnosticLevel::Warning,
1276 ExtractionDiagnosticLevel::Error,
1277 ] {
1278 let mut diagnostics = Diagnostics {
1279 warnings: vec![],
1280 errors: vec![],
1281 };
1282 let mut actual_incomplete = false;
1283 collect_extraction_diagnostics(
1284 &config,
1285 &file,
1286 &None,
1287 vec![ExtractionDiagnostic::with_kind(
1288 kind,
1289 level,
1290 SourceLocation::new(&file.path, 1, 1, 1, 1),
1291 message,
1292 )],
1293 &mut diagnostics,
1294 &mut actual_incomplete,
1295 );
1296 assert_eq!(actual_incomplete, incomplete, "{kind:?} strict={strict}");
1297 let error =
1298 level == ExtractionDiagnosticLevel::Error || (strict && parse_error);
1299 assert_eq!(diagnostics.errors.len(), usize::from(error));
1300 assert_eq!(diagnostics.warnings.len(), usize::from(!error));
1301 }
1302 }
1303 }
1304 }
1305
1306 #[test]
1308 fn supports_implicit_and_explicit_check_forms() {
1309 let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
1310 .expect("implicit invocation");
1311 assert!(implicit.command.is_none());
1312 let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
1313 .expect("explicit invocation");
1314 assert!(matches!(explicit.command, Some(Command::Check { .. })));
1315 assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
1316 }
1317}