Skip to main content

cuke_dedup/
cli.rs

1//! Command-line parsing and analysis orchestration.
2
3use 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    /// Directory to analyze when no explicit command is used.
25    #[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    /// Analyze a repository for duplicate and reusable step definitions.
38    Check {
39        /// Directory to analyze.
40        #[arg(value_name = "PATH")]
41        path: PathBuf,
42    },
43}
44
45#[derive(Debug, Clone, Args, Default)]
46struct CheckOptions {
47    /// Explicit CukeDedup JSON configuration file.
48    #[arg(long, short = 'c', global = true, value_name = "FILE")]
49    config: Option<PathBuf>,
50
51    /// Reporters to run (terminal, json, jsonl, html, sarif).
52    #[arg(long, global = true, value_delimiter = ',')]
53    reporters: Option<Vec<ReporterKind>>,
54
55    /// Directory in which file-based reports are written.
56    #[arg(long, global = true, value_name = "DIR")]
57    output: Option<PathBuf>,
58
59    /// Maximum percentage of definitions allowed in active duplication errors.
60    #[arg(
61        long,
62        global = true,
63        value_name = "PERCENT",
64        allow_hyphen_values = true
65    )]
66    threshold: Option<f64>,
67
68    /// Fail with exit code 2 when no feature files are discovered.
69    #[arg(long, global = true)]
70    require_features: bool,
71
72    /// Fail with exit code 2 when the corpus or comparison set is incomplete.
73    #[arg(long, global = true)]
74    fail_on_incomplete: bool,
75
76    /// Fail with exit code 2 when a discovered source file cannot be parsed. By default an
77    /// unparseable file still contributes its error-recovered definitions, and the run is reported
78    /// as incomplete rather than aborted.
79    #[arg(long, global = true)]
80    fail_on_unparseable: bool,
81
82    /// Fail with exit code 2 when no step definitions are extracted.
83    #[arg(long, global = true)]
84    require_definitions: bool,
85
86    /// Maximum unique definition pairs retained, up to the hard safety ceiling.
87    #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
88    max_candidate_comparisons: Option<usize>,
89
90    /// Maximum structural proposals per handler class, up to the hard safety ceiling.
91    #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
92    max_structural_class_comparisons: Option<usize>,
93
94    /// Glob patterns selecting step-definition source files.
95    #[arg(long, global = true, value_delimiter = ',')]
96    definitions: Option<Vec<String>>,
97
98    /// Glob patterns selecting Gherkin feature files.
99    #[arg(long, global = true, value_delimiter = ',')]
100    features: Option<Vec<String>>,
101
102    /// Print the effective feature source and every discovered input.
103    #[arg(long, global = true)]
104    explain_discovery: bool,
105
106    /// Print the merged effective configuration as JSON and exit.
107    #[arg(long, global = true)]
108    print_config: bool,
109
110    /// Omit runtime timing metrics from machine reports for reproducible output.
111    #[arg(long, global = true)]
112    no_metrics: bool,
113
114    /// Glob patterns excluded from discovery.
115    #[arg(long, global = true, value_delimiter = ',')]
116    exclude: Option<Vec<String>>,
117
118    /// Disable built-in exclusions such as node_modules and target.
119    #[arg(long, global = true)]
120    no_default_excludes: bool,
121
122    /// Descend into dot-directories such as .steps.
123    #[arg(long, global = true)]
124    include_hidden: bool,
125
126    /// Override a rule severity, for example duplicate-matcher=warning.
127    #[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
128    rules: Vec<(Rule, Severity)>,
129
130    /// Report only findings involving files changed since this Git revision.
131    #[arg(long, global = true, value_name = "GIT_REF")]
132    changed_since: Option<String>,
133
134    /// Suppress findings already present in a versioned semantic baseline.
135    #[arg(
136        long,
137        global = true,
138        value_name = "BASELINE.json",
139        group = "baseline_source"
140    )]
141    baseline: Option<PathBuf>,
142
143    /// Compare with a fetched Git revision using the current analysis configuration.
144    #[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    /// Rewrite the configured baseline from the current complete analysis.
149    #[arg(
150        long,
151        global = true,
152        requires = "baseline",
153        conflicts_with = "changed_since"
154    )]
155    update_baseline: bool,
156
157    /// Fail when more than COUNT findings are new relative to the baseline.
158    #[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
210/// Parses the process arguments, runs the configured analysis, and returns its exit code.
211pub 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
422/// Extracts definitions through one shared session that carries discovered edges and gaps.
423fn 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        // Generated bundles, compressed payloads and binary blobs reach here whenever they carry
465        // a source extension. They cannot contain an authored definition, so they are excluded
466        // before parsing rather than analyzed and discarded.
467        let adapter = source_adapter::adapter_for_language(file.language);
468        if let Some(excluded) = adapter.inspect_source(file, &config.registrations) {
469            // Every one of these signals is a heuristic over file content, not a proof: a NUL
470            // byte or an archive header can appear inside an authored comment or string, and
471            // minified geometry cannot be told apart from one very long authored line. So the
472            // analyzed corpus no longer represents everything discovered, and
473            // `--fail-on-incomplete` must fail closed rather than let a duplicate pass the gate.
474            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            // Every discovered source contributes to cross-file definition comparisons, even
502            // when changed mode later filters findings. Skipping an unreadable source could turn
503            // an incomplete run into a false pass.
504            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
575/// Maximum excluded paths named individually before the warning summarizes the remainder.
576const MAX_LISTED_EXCLUDED_SOURCES: usize = 10;
577
578/// Records excluded sources as a warning so an exclusion is never silent.
579///
580/// The exclusion is a heuristic over file content, so a reader has to be able to see which files
581/// it removed and why. It stays a warning rather than an error because generated output in a
582/// repository is ordinary and must not fail an otherwise clean run.
583fn 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        // An unresolved registration import — or a file that could not be parsed — hides every
636        // definition it would have introduced, so the corpus is incomplete regardless of which
637        // files changed.
638        *corpus_incomplete |= completeness;
639        match diagnostic.level {
640            // An unparseable file is tolerated by default: its error-recovered definitions still
641            // contribute and the corpus is marked incomplete above. `--fail-on-unparseable`
642            // restores the hard stop without also failing on other kinds of incompleteness.
643            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            // Extraction errors can mean definitions were omitted. They remain fatal for
651            // unchanged files because those definitions still participate in changed-file pairs.
652            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            // Changed definitions still depend on the full feature corpus for usage and ambiguity.
686            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        // Reported during extraction; analysis works only from what survived exclusion.
728        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    // Bounded work that could not finish makes findings a subset of a complete run, but every
762    // reported finding remains valid. `--fail-on-incomplete` restores strict CI gating.
763    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    // A converter can produce a syntactically valid but empty document when it fails to
793    // recognize markup or a dialect. Such an input cannot prove that a definition is unused,
794    // so use the same conservative suppression as a feature parse failure.
795    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
808/// Subtracts the findings of a baseline (a Git revision or a baseline file) from the current run.
809fn 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        // Freeze the current policy instead of loading historical rule/config overrides. The
819        // baseline revision inherits the run's own strictness rather than being forced strict: an
820        // incomplete history (a vendored bundle excluded, an unparseable file, a bounded analysis)
821        // still yields a sound subtraction for the findings it did extract, so it is tolerated by
822        // default and reported. `--fail-on-incomplete` flows through `base_config` to surface that
823        // incompleteness as an error and reject the baseline.
824        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        // A baseline that produced a hard error, or that has definitions but no features to anchor
831        // usage, cannot be subtracted at all — those always bail. A baseline that kept no
832        // definitions while the current tree has some cannot match any finding, so every current
833        // finding would appear new; that bails too, whatever emptied it. When both are empty there
834        // is nothing to compare and nothing can appear new, so the run follows the plain-run
835        // policy. Soft incompleteness only bails under `--fail-on-incomplete`, which routes through
836        // `base_diagnostics.errors` above.
837        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        // SARIF consumers treat a successful invocation as proof the tool covered its input.
945        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        // Default: an unparseable file is a completeness warning, not an error.
1157        let (diagnostics, incomplete) = route(&config, unparseable());
1158        assert!(incomplete);
1159        assert!(diagnostics.errors.is_empty());
1160        assert_eq!(diagnostics.warnings.len(), 1);
1161
1162        // `--fail-on-unparseable`: the same diagnostic becomes a hard error, still marking the
1163        // corpus incomplete.
1164        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        // An unresolved matcher is incomplete, but is not a syntax error. The syntax-only
1171        // flag must leave this diagnostic at warning severity.
1172        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}