use crate::analysis;
use crate::config::{CliConfigOverrides, Config, ConfigOverrides, ReporterKind};
use crate::discovery;
use crate::model::{DuplicationThreshold, Rule, Severity};
use crate::source_adapter::ExtractionDiagnosticLevel;
use crate::{gherkin, modes, reporters, source_adapter};
use anyhow::{bail, Context, Result};
use clap::{Args, Parser, Subcommand};
use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Debug, Parser)]
#[command(
name = "cuke-dedup",
version,
about = "Detect duplicate and reusable Cucumber step definitions",
after_help = "Examples:\n cuke-dedup .\n cuke-dedup check .\n cuke-dedup . --reporters terminal,json,html\n cuke-dedup . --reporters jsonl"
)]
struct Cli {
#[arg(value_name = "PATH", default_value = ".")]
path: PathBuf,
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
options: CheckOptions,
}
#[derive(Debug, Subcommand)]
enum Command {
Check {
#[arg(value_name = "PATH")]
path: PathBuf,
},
}
#[derive(Debug, Clone, Args, Default)]
struct CheckOptions {
#[arg(long, short = 'c', global = true, value_name = "FILE")]
config: Option<PathBuf>,
#[arg(long, global = true, value_delimiter = ',')]
reporters: Option<Vec<ReporterKind>>,
#[arg(long, global = true, value_name = "DIR")]
output: Option<PathBuf>,
#[arg(
long,
global = true,
value_name = "PERCENT",
allow_hyphen_values = true
)]
threshold: Option<f64>,
#[arg(long, global = true)]
require_features: bool,
#[arg(long, global = true)]
fail_on_incomplete: bool,
#[arg(long, global = true)]
fail_on_unparseable: bool,
#[arg(long, global = true)]
require_definitions: bool,
#[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
max_candidate_comparisons: Option<usize>,
#[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
max_structural_class_comparisons: Option<usize>,
#[arg(long, global = true, value_delimiter = ',')]
definitions: Option<Vec<String>>,
#[arg(long, global = true, value_delimiter = ',')]
features: Option<Vec<String>>,
#[arg(long, global = true)]
explain_discovery: bool,
#[arg(long, global = true)]
print_config: bool,
#[arg(long, global = true)]
no_metrics: bool,
#[arg(long, global = true, value_delimiter = ',')]
exclude: Option<Vec<String>>,
#[arg(long, global = true)]
no_default_excludes: bool,
#[arg(long, global = true)]
include_hidden: bool,
#[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
rules: Vec<(Rule, Severity)>,
#[arg(long, global = true, value_name = "GIT_REF")]
changed_since: Option<String>,
#[arg(
long,
global = true,
value_name = "BASELINE.json",
group = "baseline_source"
)]
baseline: Option<PathBuf>,
#[arg(long, global = true, value_name = "GIT_REF", group = "baseline_source",
conflicts_with_all = ["baseline", "update_baseline"])]
baseline_from_ref: Option<String>,
#[arg(
long,
global = true,
requires = "baseline",
conflicts_with = "changed_since"
)]
update_baseline: bool,
#[arg(
long,
global = true,
value_name = "COUNT",
num_args = 0..=1,
default_missing_value = "0",
requires = "baseline_source",
conflicts_with = "update_baseline"
)]
fail_on_new: Option<usize>,
}
struct Invocation {
config: Config,
options: CheckOptions,
}
#[derive(Default)]
struct Diagnostics {
warnings: Vec<String>,
errors: Vec<String>,
}
struct ExtractedCorpus {
parameter_types: Vec<source_adapter::SourceParameterType>,
parameter_language: Option<source_adapter::SourceLanguage>,
indirect_usage: source_adapter::IndirectStepUsage,
definitions: Vec<crate::model::StepDefinition>,
feature_steps: Vec<crate::model::FeatureStep>,
excluded_sources: Vec<(PathBuf, source_adapter::SourceExclusion)>,
definition_files_with_definitions: usize,
parsed_feature_files: usize,
feature_files_without_steps: usize,
incomplete: bool,
}
struct AnalyzedCorpus {
result: crate::model::AnalysisResult,
census: analysis::AnalysisCensus,
definition_files_with_definitions: usize,
parsed_feature_files: usize,
feature_files_without_steps: usize,
corpus_incomplete: bool,
run_incomplete: bool,
}
struct PhaseTimings {
discovery_ms: f64,
parsing_ms: f64,
analysis_ms: f64,
}
pub fn run_cli() -> Result<i32> {
execute(Cli::parse())
}
fn execute(cli: Cli) -> Result<i32> {
let invocation = resolve_invocation(cli)?;
let config = &invocation.config;
let options = &invocation.options;
if options.print_config {
println!(
"{}",
serde_json::to_string_pretty(config)
.context("failed to serialize effective configuration")?
);
return Ok(0);
}
let changed_files = resolve_changed_files(config, options.changed_since.as_deref())?;
let discovery_started = Instant::now();
let files = discovery::discover(config)?;
let discovery_ms = elapsed_ms(discovery_started);
let parsing_started = Instant::now();
let mut diagnostics = discovery_diagnostics(config, &files, &changed_files);
let extracted = extract_corpus(config, &files, &changed_files, &mut diagnostics);
let parsing_ms = elapsed_ms(parsing_started);
let analysis_started = Instant::now();
let mut analyzed = analyze_corpus(config, extracted, &mut diagnostics)?;
let analysis_ms = elapsed_ms(analysis_started);
apply_finding_modes(
&mut analyzed.result,
&files,
&changed_files,
analyzed.parsed_feature_files,
analyzed.feature_files_without_steps,
);
let baseline_outcome = apply_baseline_mode(
config,
options,
&mut analyzed.result,
analyzed.run_incomplete,
&mut diagnostics,
)?;
write_reports(
config,
&files,
&analyzed,
&diagnostics,
PhaseTimings {
discovery_ms,
parsing_ms,
analysis_ms,
},
)?;
write_diagnostics(config, options, &files, &diagnostics)?;
determine_exit_code(
config,
options,
&analyzed.result,
baseline_outcome,
&diagnostics,
)
}
fn resolve_invocation(cli: Cli) -> Result<Invocation> {
if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
}
let root = match cli.command {
Some(Command::Check { path }) => path,
None => cli.path,
};
let options = cli.options.clone();
let config = Config::load_for_cli(
&root,
ConfigOverrides {
config_file: cli.options.config,
definitions: cli.options.definitions,
features: cli.options.features,
exclude: cli.options.exclude,
exclude_defaults: cli.options.no_default_excludes.then_some(false),
include_hidden: cli.options.include_hidden.then_some(true),
reporters: cli.options.reporters,
output: cli.options.output,
threshold: cli.options.threshold,
require_features: cli.options.require_features.then_some(true),
no_metrics: cli.options.no_metrics.then_some(true),
rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
},
CliConfigOverrides {
require_definitions: cli.options.require_definitions.then_some(true),
fail_on_incomplete: cli.options.fail_on_incomplete.then_some(true),
fail_on_unparseable: cli.options.fail_on_unparseable.then_some(true),
max_candidate_comparisons: cli.options.max_candidate_comparisons,
max_structural_class_comparisons: cli.options.max_structural_class_comparisons,
},
)?;
Ok(Invocation { config, options })
}
fn resolve_changed_files(
config: &Config,
changed_since: Option<&str>,
) -> Result<Option<BTreeSet<PathBuf>>> {
let Some(base) = changed_since else {
return Ok(None);
};
modes::ensure_changed_root_is_trackable(&config.root)?;
Ok(Some(modes::git_changed_files(&config.root, base)?))
}
fn discovery_diagnostics(
config: &Config,
files: &discovery::DiscoveredFiles,
changed_files: &Option<BTreeSet<PathBuf>>,
) -> Diagnostics {
let mut diagnostics = Diagnostics {
warnings: config.config_warnings.clone(),
errors: files.errors.clone(),
};
for pattern in &files.unmatched_feature_patterns {
diagnostics.warnings.push(format!(
"feature pattern `{pattern}` from {} matched no files",
config.feature_pattern_origin
));
}
for pattern in &files.unmatched_definition_patterns {
diagnostics
.warnings
.push(format!("definition pattern `{pattern}` matched no files"));
}
if !files.skipped_definitions.is_empty()
&& (!config.definitions.is_empty() || files.definitions.is_empty())
{
diagnostics
.warnings
.push(skipped_definitions_warning(&files.skipped_definitions));
}
if files.definitions.is_empty() && config.definitions.is_empty() {
diagnostics
.warnings
.push("automatic discovery found no supported step-definition source files".to_owned());
}
if files.features.is_empty() {
let message = format!(
"no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
config.feature_pattern_origin
);
if config.require_features {
diagnostics.errors.push(message);
} else if !files.definitions.is_empty() {
diagnostics.warnings.push(message);
}
}
if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
diagnostics.warnings.push(
"--changed-since found no tracked or untracked files; no findings will be reported"
.to_owned(),
);
}
diagnostics
}
fn skipped_definitions_warning(skipped: &[source_adapter::SourceFile]) -> String {
let registrations: Vec<_> = source_adapter::SOURCE_ADAPTER_REGISTRY
.iter()
.filter(|entry| {
skipped
.iter()
.any(|file| entry.adapter.language() == file.language)
})
.collect();
let adapters: BTreeSet<_> = registrations
.iter()
.map(|entry| entry.adapter.name())
.collect();
let suffixes: Vec<_> = registrations
.iter()
.map(|entry| format!("`{}`", entry.suffix))
.collect();
format!(
"{} {} source file(s) were not analyzed: opt-in languages need a definition pattern that names their suffix ({}), such as `features/**/*{}`",
skipped.len(),
adapters.into_iter().collect::<Vec<_>>().join(", "),
suffixes.join(", "),
registrations.first().map_or("", |entry| entry.suffix),
)
}
fn extract_corpus(
config: &Config,
files: &discovery::DiscoveredFiles,
changed_files: &Option<BTreeSet<PathBuf>>,
diagnostics: &mut Diagnostics,
) -> ExtractedCorpus {
let mut corpus = ExtractedCorpus {
parameter_types: Vec::new(),
parameter_language: files.definitions.first().map(|file| file.language),
indirect_usage: source_adapter::IndirectStepUsage::default(),
definitions: Vec::new(),
feature_steps: Vec::new(),
excluded_sources: Vec::new(),
definition_files_with_definitions: 0,
parsed_feature_files: 0,
feature_files_without_steps: 0,
incomplete: false,
};
extract_definitions(config, files, changed_files, diagnostics, &mut corpus);
extract_feature_steps(config, files, diagnostics, &mut corpus);
corpus
}
fn extract_definitions(
config: &Config,
files: &discovery::DiscoveredFiles,
changed_files: &Option<BTreeSet<PathBuf>>,
diagnostics: &mut Diagnostics,
corpus: &mut ExtractedCorpus,
) {
if let Some(first) = files.definitions.first() {
let domain = first.language.comparison_domain();
if files
.definitions
.iter()
.any(|file| file.language.comparison_domain() != domain)
{
corpus.incomplete = true;
diagnostics.errors.push("Ruby and JS/TS sources require separate analysis runs with explicit definition and feature roots".to_owned());
return;
}
}
let mut extraction_session = source_adapter::SourceExtractionSession::with_options(
&config.root,
&config.registrations,
&config.assertion_modules,
)
.with_dependencies(&files.dependencies)
.with_dependency_gaps(&files.dependency_gaps);
let mut prepared = BTreeSet::new();
for file in &files.definitions {
let adapter = source_adapter::adapter_for_language(file.language);
if prepared.insert(adapter.name()) {
if let Err(error) = adapter.prepare_session(&files.definitions, &mut extraction_session)
{
corpus.incomplete = true;
diagnostics
.errors
.push(format!("failed to prepare source evidence: {error:#}"));
return;
}
}
}
for file in &files.definitions {
let adapter = source_adapter::adapter_for_language(file.language);
if let Some(excluded) = adapter.inspect_source(file, &config.registrations) {
corpus.incomplete = true;
corpus.excluded_sources.push((file.path.clone(), excluded));
continue;
}
match adapter
.extract_file_with_session(file, &mut extraction_session)
.with_context(|| format!("failed to analyze {}", file.path.display()))
{
Ok(extracted) => {
if adapter.supports_indirect_usage() {
corpus.indirect_usage.extend(extracted.indirect_usage);
corpus.incomplete |= corpus.indirect_usage.unknown;
}
corpus.parameter_types.extend(extracted.parameter_types);
if !extracted.definitions.is_empty() {
corpus.definition_files_with_definitions += 1;
}
corpus.definitions.extend(extracted.definitions);
collect_extraction_diagnostics(
config,
file,
changed_files,
extracted.diagnostics,
diagnostics,
&mut corpus.incomplete,
);
}
Err(error) => {
if adapter.supports_indirect_usage() {
corpus.indirect_usage.unknown = true;
corpus.incomplete = true;
}
diagnostics.errors.push(format!("{error:#}"));
}
}
}
let finalization = extraction_session.finalize(&files.definitions, &mut corpus.definitions);
collect_finalization(finalization, corpus, diagnostics);
corpus.definitions.sort_by(|left, right| {
left.location
.path
.cmp(&right.location.path)
.then(left.location.line.cmp(&right.location.line))
.then(left.location.column.cmp(&right.location.column))
});
report_excluded_sources(config, &corpus.excluded_sources, diagnostics);
if corpus.definitions.is_empty() {
let message = if files.definitions.is_empty() {
"no step definitions were extracted because no definition source files were discovered"
.to_owned()
} else {
format!(
"definition extraction produced 0 definitions from {} discovered definition source file(s)",
files.definitions.len()
)
};
if config.require_definitions {
diagnostics.errors.push(message);
} else if !files.definitions.is_empty() {
diagnostics.warnings.push(message);
}
}
}
fn collect_finalization(
finalization: Result<source_adapter::SourceFinalization>,
corpus: &mut ExtractedCorpus,
diagnostics: &mut Diagnostics,
) {
match finalization {
Ok(finalization) => {
if !finalization.uncertainties.is_empty() {
corpus.incomplete = true;
diagnostics.warnings.extend(
finalization
.uncertainties
.into_iter()
.map(|item| item.message),
);
corpus.definition_files_with_definitions = corpus
.definitions
.iter()
.map(|definition| &definition.location.path)
.collect::<BTreeSet<_>>()
.len();
}
}
Err(error) => {
corpus.incomplete = true;
corpus.definitions.clear();
corpus.definition_files_with_definitions = 0;
diagnostics
.errors
.push(format!("failed to finalize source evidence: {error:#}"));
}
}
}
const MAX_LISTED_EXCLUDED_SOURCES: usize = 10;
fn report_excluded_sources(
config: &Config,
excluded: &[(PathBuf, source_adapter::SourceExclusion)],
diagnostics: &mut Diagnostics,
) {
if excluded.is_empty() {
return;
}
let listed = excluded
.iter()
.take(MAX_LISTED_EXCLUDED_SOURCES)
.map(|(path, reason)| {
let relative = path
.strip_prefix(&config.root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
format!("{relative} ({})", reason.as_str())
})
.collect::<Vec<_>>()
.join(", ");
let remainder = excluded.len().saturating_sub(MAX_LISTED_EXCLUDED_SOURCES);
let suffix = if remainder == 0 {
String::new()
} else {
format!(", and {remainder} more")
};
diagnostics.warnings.push(format!(
"excluded {} discovered definition source file(s) as generated or non-source content: {listed}{suffix}",
excluded.len()
));
}
fn collect_extraction_diagnostics(
config: &Config,
file: &source_adapter::SourceFile,
changed_files: &Option<BTreeSet<PathBuf>>,
extracted: Vec<source_adapter::ExtractionDiagnostic>,
diagnostics: &mut Diagnostics,
corpus_incomplete: &mut bool,
) {
let changed_or_full_run = changed_files
.as_ref()
.is_none_or(|changed| changed.contains(&file.path));
for diagnostic in extracted {
let message = format!(
"{}: {}",
diagnostic.location.display(&config.root),
diagnostic.message
);
let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
let unparseable = source_adapter::is_unparseable_diagnostic(&diagnostic);
*corpus_incomplete |= completeness;
match diagnostic.level {
ExtractionDiagnosticLevel::Warning if unparseable && config.fail_on_unparseable => {
diagnostics.errors.push(message);
}
ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
diagnostics.warnings.push(message);
}
ExtractionDiagnosticLevel::Warning => {}
ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
}
}
}
fn extract_feature_steps(
config: &Config,
files: &discovery::DiscoveredFiles,
diagnostics: &mut Diagnostics,
corpus: &mut ExtractedCorpus,
) {
for file in &files.features {
match gherkin::extract_file_with_format(&file.path, file.format) {
Ok(extracted) => {
corpus.parsed_feature_files += 1;
if extracted.is_empty()
&& file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
{
corpus.feature_files_without_steps += 1;
corpus.incomplete = true;
let display = file
.path
.strip_prefix(&config.root)
.unwrap_or(&file.path)
.to_string_lossy()
.replace('\\', "/");
diagnostics.warnings.push(format!(
"Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
));
} else {
corpus.feature_steps.extend(extracted);
}
}
Err(error) => {
corpus.incomplete = true;
diagnostics.errors.push(format!("{error:#}"));
}
}
}
if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
let message = if corpus.parsed_feature_files == 0 {
"no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
} else {
format!(
"{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
files.features.len() - corpus.parsed_feature_files
)
};
if config.require_features {
diagnostics.errors.push(message);
} else if !files.definitions.is_empty() {
diagnostics.warnings.push(message);
}
}
corpus.feature_steps.sort_by(|left, right| {
left.location
.path
.cmp(&right.location.path)
.then(left.location.line.cmp(&right.location.line))
.then(left.location.column.cmp(&right.location.column))
});
}
fn analyze_corpus(
config: &Config,
extracted: ExtractedCorpus,
diagnostics: &mut Diagnostics,
) -> Result<AnalyzedCorpus> {
let ExtractedCorpus {
parameter_types,
parameter_language,
indirect_usage,
definitions,
feature_steps,
excluded_sources: _,
definition_files_with_definitions,
parsed_feature_files,
feature_files_without_steps,
incomplete: mut corpus_incomplete,
} = extracted;
let mut effective_config = config.clone();
let (patterns, type_diagnostics) = match parameter_language {
Some(language) => source_adapter::adapter_for_language(language)
.resolve_parameter_types(parameter_types, &config.parameter_types),
None => (config.parameter_types.clone(), Vec::new()),
};
effective_config.parameter_types = patterns;
corpus_incomplete |= !type_diagnostics.is_empty();
diagnostics.warnings.extend(type_diagnostics);
let config = &effective_config;
let (analysis, census, unmatched_suppressions) =
analysis::analyze_for_cli(&indirect_usage, definitions, feature_steps, config)?;
for index in unmatched_suppressions.indices {
let suppression = &config.suppressions[index];
diagnostics.warnings.push(format!(
"suppression {} for {} matched no step definitions",
index + 1,
suppression.rule
));
}
if unmatched_suppressions.truncated {
diagnostics.warnings.push(
"unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
.to_owned(),
);
}
let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
if config.fail_on_incomplete {
diagnostics.errors.extend(analysis.incomplete);
if corpus_incomplete {
diagnostics.errors.push(
"input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
);
}
} else {
diagnostics.warnings.extend(analysis.incomplete);
}
Ok(AnalyzedCorpus {
result: analysis.result,
census,
definition_files_with_definitions,
parsed_feature_files,
feature_files_without_steps,
corpus_incomplete,
run_incomplete,
})
}
fn apply_finding_modes(
result: &mut crate::model::AnalysisResult,
files: &discovery::DiscoveredFiles,
changed_files: &Option<BTreeSet<PathBuf>>,
parsed_feature_files: usize,
feature_files_without_steps: usize,
) {
if parsed_feature_files < files.features.len()
|| feature_files_without_steps > 0
|| files.features.is_empty()
{
result
.findings
.retain(|finding| finding.rule != Rule::UnusedDefinition);
}
if let Some(changed) = changed_files {
modes::retain_changed_findings(&mut result.findings, changed);
}
}
fn apply_baseline_mode(
config: &Config,
options: &CheckOptions,
result: &mut crate::model::AnalysisResult,
run_incomplete: bool,
diagnostics: &mut Diagnostics,
) -> Result<Option<modes::BaselineOutcome>> {
if let Some(revision) = &options.baseline_from_ref {
let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
let mut base_config = config.clone();
base_config.root = root;
let files = discovery::discover(&base_config)?;
let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics)?;
if !files.definitions.is_empty()
&& base.result.definitions.is_empty()
&& !result.definitions.is_empty()
{
bail!(
"baseline revision `{revision}` is incomplete: it kept no step definitions while the current tree has {}, so no finding could be matched against it: {}",
result.definitions.len(),
base_diagnostics
.errors
.iter()
.chain(&base_diagnostics.warnings)
.cloned()
.collect::<Vec<_>>()
.join("; ")
);
}
if !base_diagnostics.errors.is_empty()
|| (files.features.is_empty() && !base.result.definitions.is_empty())
{
bail!(
"baseline revision `{revision}` is incomplete: {}",
base_diagnostics
.errors
.iter()
.chain(&base_diagnostics.warnings)
.cloned()
.collect::<Vec<_>>()
.join("; ")
);
}
if base.run_incomplete {
diagnostics.warnings.push(format!(
"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"
));
}
apply_finding_modes(
&mut base.result,
&files,
&None,
base.parsed_feature_files,
base.feature_files_without_steps,
);
return modes::apply_reference_baseline(
&mut result.findings,
&base.result.findings,
revision,
)
.map(Some);
}
let Some(path) = &options.baseline else {
return Ok(None);
};
let path = if path.is_absolute() {
path.clone()
} else {
config.root.join(path)
};
if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
diagnostics.warnings.push(format!(
"baseline {} was not updated because analysis is incomplete",
path.display()
));
return Ok(None);
}
let outcome = if options.update_baseline {
modes::update_baseline(&mut result.findings, &path)?
} else {
modes::apply_baseline(&mut result.findings, &path)?
};
if options.update_baseline {
diagnostics.warnings.push(format!(
"updated baseline {}: {} added, {} removed, {} total",
path.display(),
outcome.added,
outcome.removed,
outcome.suppressed
));
}
Ok(Some(outcome))
}
fn write_reports(
config: &Config,
files: &discovery::DiscoveredFiles,
analyzed: &AnalyzedCorpus,
diagnostics: &Diagnostics,
timings: PhaseTimings,
) -> Result<()> {
let corpus = reporters::CorpusCensus {
definition_files: files.definitions.len(),
definition_files_with_definitions: analyzed.definition_files_with_definitions,
definitions_extracted: analyzed.result.definitions.len(),
feature_files: files.features.len(),
feature_files_parsed: analyzed.parsed_feature_files,
feature_files_without_steps: analyzed.feature_files_without_steps,
incomplete: analyzed.corpus_incomplete,
};
let metrics = reporters::ExecutionMetrics::new(
files.definitions.len(),
files.features.len(),
timings.discovery_ms,
timings.parsing_ms,
timings.analysis_ms,
);
let report_metadata = reporters::CliReportMetadata {
corpus: &corpus,
analysis: &analyzed.census,
execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
};
let terminal_color = terminal_color_enabled(
io::stdout().is_terminal(),
std::env::var_os("NO_COLOR").as_deref(),
);
let mut stdout = io::stdout().lock();
reporters::write_cli_reports(
&analyzed.result,
config,
(!config.no_metrics).then_some(&metrics),
&report_metadata,
&mut stdout,
terminal_color,
)?;
Ok(())
}
fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
is_terminal && !no_color.is_some_and(|value| !value.is_empty())
}
fn write_diagnostics(
config: &Config,
options: &CheckOptions,
files: &discovery::DiscoveredFiles,
diagnostics: &Diagnostics,
) -> Result<()> {
if !options.explain_discovery
&& diagnostics.warnings.is_empty()
&& diagnostics.errors.is_empty()
{
return Ok(());
}
let mut stderr = io::stderr().lock();
if options.explain_discovery {
writeln!(
stderr,
"cuke-dedup: feature patterns: {} ({})",
config.features.join(", "),
config.feature_pattern_origin
)?;
for file in &files.features {
let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
writeln!(
stderr,
"cuke-dedup: feature {} [{}] via {}",
relative.display(),
file.format.as_str(),
file.pattern
)?;
}
for file in &files.definitions {
let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
}
}
for warning in &diagnostics.warnings {
writeln!(stderr, "cuke-dedup: warning: {warning}")?;
}
for error in &diagnostics.errors {
writeln!(stderr, "cuke-dedup: {error}")?;
}
Ok(())
}
fn determine_exit_code(
config: &Config,
options: &CheckOptions,
result: &crate::model::AnalysisResult,
baseline: Option<modes::BaselineOutcome>,
diagnostics: &Diagnostics,
) -> Result<i32> {
if !diagnostics.errors.is_empty() {
return Ok(2);
}
let duplication = DuplicationThreshold::from_result(result, config.threshold);
let has_non_duplication_errors = result.findings.iter().any(|finding| {
finding.is_active()
&& finding.severity == Severity::Error
&& !finding.rule.contributes_to_duplication_threshold()
});
let new_findings_exceeded = options
.fail_on_new
.is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
Ok(i32::from(
has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
))
}
fn elapsed_ms(started: Instant) -> f64 {
started.elapsed().as_secs_f64() * 1_000.0
}
fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
let (rule, severity) = value
.split_once('=')
.ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
Ok((rule.parse()?, severity.parse()?))
}
fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
value
.parse::<usize>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| "expected an integer greater than zero".to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finalization_failure_discards_provisional_definitions_and_reports_incompleteness() {
let root = tempfile::tempdir().unwrap();
let config = Config::load(root.path(), ConfigOverrides::default()).unwrap();
std::fs::write(root.path().join("steps.ts"), "import { Given, Then } from '@cucumber/cucumber'; Given('same', () => work()); Then('same', () => work());").unwrap();
let files = discovery::discover(&config).unwrap();
let mut diagnostics = discovery_diagnostics(&config, &files, &None);
let mut corpus = extract_corpus(&config, &files, &None, &mut diagnostics);
assert_eq!(corpus.definitions.len(), 2);
let before = corpus.definitions.clone();
collect_finalization(Ok(Default::default()), &mut corpus, &mut diagnostics);
assert_eq!(corpus.definitions, before);
collect_finalization(
Err(anyhow::anyhow!("adapter unavailable")),
&mut corpus,
&mut diagnostics,
);
assert!(corpus.incomplete);
assert!(corpus.definitions.is_empty());
assert_eq!(corpus.definition_files_with_definitions, 0);
assert!(diagnostics
.errors
.iter()
.any(|e| e == "failed to finalize source evidence: adapter unavailable"));
let analyzed = analyze_corpus(&config, corpus, &mut diagnostics).unwrap();
assert!(analyzed.run_incomplete);
assert!(analyzed.result.findings.is_empty());
}
#[test]
fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
use std::ffi::OsStr;
assert!(terminal_color_enabled(true, None));
assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
assert!(!terminal_color_enabled(false, None));
assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
}
#[test]
fn parses_rule_override() {
assert_eq!(
parse_rule_override("duplicate-matcher=warning"),
Ok((Rule::DuplicateMatcher, Severity::Warning))
);
assert!(parse_rule_override("unknown=error").is_err());
}
#[test]
fn candidate_limit_cli_values_must_be_positive() {
assert_eq!(parse_positive_usize("7"), Ok(7));
assert!(parse_positive_usize("0").is_err());
assert!(parse_positive_usize("-1").is_err());
assert!(parse_positive_usize("many").is_err());
}
#[test]
fn unparseable_diagnostics_are_tolerated_by_default_and_escalated_by_the_flag() {
use crate::discovery::SourceLanguage;
use crate::model::SourceLocation;
use crate::source_adapter::{ExtractionDiagnostic, SourceFile};
let directory = tempfile::tempdir().unwrap();
let file = SourceFile {
path: directory.path().join("steps.ts"),
language: SourceLanguage::TypeScript,
};
let unparseable = || {
ExtractionDiagnostic::new(
ExtractionDiagnosticLevel::Warning,
SourceLocation::new("steps.ts", 1, 1, 1, 1),
format!(
"{}, so analysis is incomplete",
source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX
),
)
};
let route = |config: &Config, diagnostic: ExtractionDiagnostic| {
let mut diagnostics = Diagnostics {
warnings: Vec::new(),
errors: Vec::new(),
};
let mut incomplete = false;
collect_extraction_diagnostics(
config,
&file,
&None,
vec![diagnostic],
&mut diagnostics,
&mut incomplete,
);
(diagnostics, incomplete)
};
let mut config =
Config::load(directory.path(), ConfigOverrides::default()).expect("config loads");
let (diagnostics, incomplete) = route(&config, unparseable());
assert!(incomplete);
assert!(diagnostics.errors.is_empty());
assert_eq!(diagnostics.warnings.len(), 1);
config.fail_on_unparseable = true;
let (diagnostics, incomplete) = route(&config, unparseable());
assert!(incomplete);
assert_eq!(diagnostics.errors.len(), 1);
assert!(diagnostics.warnings.is_empty());
let dynamic = ExtractionDiagnostic::new(
ExtractionDiagnosticLevel::Warning,
SourceLocation::new("steps.ts", 2, 1, 2, 1),
"dynamic or unsupported step matcher cannot be analyzed statically",
);
let (diagnostics, incomplete) = route(&config, dynamic);
assert!(incomplete);
assert!(diagnostics.errors.is_empty());
assert_eq!(diagnostics.warnings.len(), 1);
let unrelated = ExtractionDiagnostic::new(
ExtractionDiagnosticLevel::Warning,
SourceLocation::new("steps.ts", 2, 1, 2, 1),
"unrelated extractor warning",
);
let (diagnostics, incomplete) = route(&config, unrelated);
assert!(!incomplete);
assert!(diagnostics.errors.is_empty());
assert_eq!(diagnostics.warnings.len(), 1);
}
#[test]
fn typed_adapter_diagnostics_route_independently_of_language_and_wording() {
use crate::model::SourceLocation;
use crate::source_adapter::{
ExtractionDiagnostic, ExtractionDiagnosticKind as Kind, SourceFile, SourceLanguage,
};
let directory = tempfile::tempdir().unwrap();
let file = SourceFile {
path: directory.path().join("steps.ts"),
language: SourceLanguage::TypeScript,
};
let mut config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
for strict in [false, true] {
config.fail_on_unparseable = strict;
for (kind, message, incomplete, parse_error) in [
(Kind::Other, "adapter advisory", false, false),
(Kind::Incomplete, "external glue unavailable", true, false),
(Kind::Unparseable, "adapter syntax failure", true, true),
(
Kind::Other,
source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
false,
false,
),
(
Kind::Incomplete,
source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
true,
false,
),
] {
for level in [
ExtractionDiagnosticLevel::Warning,
ExtractionDiagnosticLevel::Error,
] {
let mut diagnostics = Diagnostics {
warnings: vec![],
errors: vec![],
};
let mut actual_incomplete = false;
collect_extraction_diagnostics(
&config,
&file,
&None,
vec![ExtractionDiagnostic::with_kind(
kind,
level,
SourceLocation::new(&file.path, 1, 1, 1, 1),
message,
)],
&mut diagnostics,
&mut actual_incomplete,
);
assert_eq!(actual_incomplete, incomplete, "{kind:?} strict={strict}");
let error =
level == ExtractionDiagnosticLevel::Error || (strict && parse_error);
assert_eq!(diagnostics.errors.len(), usize::from(error));
assert_eq!(diagnostics.warnings.len(), usize::from(!error));
}
}
}
}
#[test]
fn supports_implicit_and_explicit_check_forms() {
let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
.expect("implicit invocation");
assert!(implicit.command.is_none());
let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
.expect("explicit invocation");
assert!(matches!(explicit.command, Some(Command::Check { .. })));
assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
}
}