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::fs;
13use std::io::{self, IsTerminal, Write};
14use std::path::{Path, PathBuf};
15use std::time::Instant;
16
17#[derive(Debug, Parser)]
18#[command(
19    name = "cuke-dedup",
20    version,
21    about = "Detect duplicate and reusable Cucumber step definitions",
22    after_help = "Examples:\n  cuke-dedup .\n  cuke-dedup check .\n  cuke-dedup . --reporters terminal,json,html\n  cuke-dedup . --reporters jsonl"
23)]
24struct Cli {
25    /// Directory to analyze when no explicit command is used.
26    #[arg(value_name = "PATH", default_value = ".")]
27    path: PathBuf,
28
29    #[command(subcommand)]
30    command: Option<Command>,
31
32    #[command(flatten)]
33    options: CheckOptions,
34}
35
36#[derive(Debug, Subcommand)]
37enum Command {
38    /// Analyze a repository for duplicate and reusable step definitions.
39    Check {
40        /// Directory to analyze.
41        #[arg(value_name = "PATH")]
42        path: PathBuf,
43    },
44}
45
46#[derive(Debug, Clone, Args, Default)]
47struct CheckOptions {
48    /// Explicit CukeDedup JSON configuration file.
49    #[arg(long, short = 'c', global = true, value_name = "FILE")]
50    config: Option<PathBuf>,
51
52    /// Reporters to run (terminal, json, jsonl, html, sarif).
53    #[arg(long, global = true, value_delimiter = ',')]
54    reporters: Option<Vec<ReporterKind>>,
55
56    /// Directory in which file-based reports are written.
57    #[arg(long, global = true, value_name = "DIR")]
58    output: Option<PathBuf>,
59
60    /// Maximum percentage of definitions allowed in active duplication errors.
61    #[arg(
62        long,
63        global = true,
64        value_name = "PERCENT",
65        allow_hyphen_values = true
66    )]
67    threshold: Option<f64>,
68
69    /// Fail with exit code 2 when no feature files are discovered.
70    #[arg(long, global = true)]
71    require_features: bool,
72
73    /// Fail with exit code 2 when the corpus or comparison set is incomplete.
74    #[arg(long, global = true)]
75    fail_on_incomplete: bool,
76
77    /// Fail with exit code 2 when a discovered source file cannot be parsed. By default an
78    /// unparseable file still contributes its error-recovered definitions, and the run is reported
79    /// as incomplete rather than aborted.
80    #[arg(long, global = true)]
81    fail_on_unparseable: bool,
82
83    /// Fail with exit code 2 when no step definitions are extracted.
84    #[arg(long, global = true)]
85    require_definitions: bool,
86
87    /// Maximum unique definition pairs retained, up to the hard safety ceiling.
88    #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
89    max_candidate_comparisons: Option<usize>,
90
91    /// Maximum structural proposals per handler class, up to the hard safety ceiling.
92    #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
93    max_structural_class_comparisons: Option<usize>,
94
95    /// Glob patterns selecting step-definition source files.
96    #[arg(long, global = true, value_delimiter = ',')]
97    definitions: Option<Vec<String>>,
98
99    /// Glob patterns selecting Gherkin feature files.
100    #[arg(long, global = true, value_delimiter = ',')]
101    features: Option<Vec<String>>,
102
103    /// Print the effective feature source and every discovered input.
104    #[arg(long, global = true)]
105    explain_discovery: bool,
106
107    /// Print the merged effective configuration as JSON and exit.
108    #[arg(long, global = true)]
109    print_config: bool,
110
111    /// Omit runtime timing metrics from machine reports for reproducible output.
112    #[arg(long, global = true)]
113    no_metrics: bool,
114
115    /// Write one JSON line of comparison fingerprints per analyzed definition to FILE.
116    #[arg(
117        long,
118        global = true,
119        value_name = "FILE",
120        conflicts_with = "print_config"
121    )]
122    emit_fingerprints: Option<PathBuf>,
123
124    /// Write one JSON line of verification evidence per evaluated definition pair to FILE.
125    #[arg(
126        long,
127        global = true,
128        value_name = "FILE",
129        conflicts_with = "print_config"
130    )]
131    emit_pairs: Option<PathBuf>,
132
133    /// Glob patterns excluded from discovery.
134    #[arg(long, global = true, value_delimiter = ',')]
135    exclude: Option<Vec<String>>,
136
137    /// Disable built-in exclusions such as node_modules and target.
138    #[arg(long, global = true)]
139    no_default_excludes: bool,
140
141    /// Descend into dot-directories such as .steps.
142    #[arg(long, global = true)]
143    include_hidden: bool,
144
145    /// Override a rule severity, for example duplicate-matcher=warning.
146    #[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
147    rules: Vec<(Rule, Severity)>,
148
149    /// Report only findings involving files changed since this Git revision.
150    #[arg(long, global = true, value_name = "GIT_REF")]
151    changed_since: Option<String>,
152
153    /// Suppress findings already present in a versioned semantic baseline.
154    #[arg(
155        long,
156        global = true,
157        value_name = "BASELINE.json",
158        group = "baseline_source"
159    )]
160    baseline: Option<PathBuf>,
161
162    /// Compare with a fetched Git revision using the current analysis configuration.
163    #[arg(long, global = true, value_name = "GIT_REF", group = "baseline_source",
164        conflicts_with_all = ["baseline", "update_baseline"])]
165    baseline_from_ref: Option<String>,
166
167    /// Rewrite the configured baseline from the current complete analysis.
168    #[arg(
169        long,
170        global = true,
171        requires = "baseline",
172        conflicts_with = "changed_since"
173    )]
174    update_baseline: bool,
175
176    /// Fail when more than COUNT findings are new relative to the baseline.
177    #[arg(
178        long,
179        global = true,
180        value_name = "COUNT",
181        num_args = 0..=1,
182        default_missing_value = "0",
183        requires = "baseline_source",
184        conflicts_with = "update_baseline"
185    )]
186    fail_on_new: Option<usize>,
187}
188
189struct Invocation {
190    config: Config,
191    options: CheckOptions,
192}
193
194#[derive(Default)]
195struct Diagnostics {
196    warnings: Vec<String>,
197    errors: Vec<String>,
198}
199
200struct ExtractedCorpus {
201    parameter_types: Vec<source_adapter::SourceParameterType>,
202    parameter_language: Option<source_adapter::SourceLanguage>,
203    indirect_usage: source_adapter::IndirectStepUsage,
204    definitions: Vec<crate::model::StepDefinition>,
205    feature_steps: Vec<crate::model::FeatureStep>,
206    excluded_sources: Vec<(PathBuf, source_adapter::SourceExclusion)>,
207    definition_files_with_definitions: usize,
208    parsed_feature_files: usize,
209    feature_files_without_steps: usize,
210    incomplete: bool,
211}
212
213struct AnalyzedCorpus {
214    result: crate::model::AnalysisResult,
215    census: analysis::AnalysisCensus,
216    definition_files_with_definitions: usize,
217    parsed_feature_files: usize,
218    feature_files_without_steps: usize,
219    corpus_incomplete: bool,
220    run_incomplete: bool,
221    pair_records: Vec<analysis::pair_records::PairRecord>,
222}
223
224struct PhaseTimings {
225    discovery_ms: f64,
226    parsing_ms: f64,
227    analysis_ms: f64,
228}
229
230/// Parses the process arguments, runs the configured analysis, and returns its exit code.
231pub fn run_cli() -> Result<i32> {
232    execute(Cli::parse())
233}
234
235/// Runs discovery, extraction, analysis, finding modes and baseline, writes reports and stderr
236/// diagnostics, then returns the exit code; `--print-config` prints the config and returns 0.
237fn execute(cli: Cli) -> Result<i32> {
238    let invocation = resolve_invocation(cli)?;
239    let config = &invocation.config;
240    let options = &invocation.options;
241    if options.print_config {
242        println!(
243            "{}",
244            serde_json::to_string_pretty(config)
245                .context("failed to serialize effective configuration")?
246        );
247        return Ok(0);
248    }
249    let changed_files = resolve_changed_files(config, options.changed_since.as_deref())?;
250    let discovery_started = Instant::now();
251    let files = discovery::discover(config)?;
252    let discovery_ms = elapsed_ms(discovery_started);
253
254    let parsing_started = Instant::now();
255    let mut diagnostics = discovery_diagnostics(config, &files, &changed_files);
256    let extracted = extract_corpus(config, &files, &changed_files, &mut diagnostics);
257    let parsing_ms = elapsed_ms(parsing_started);
258    let analysis_started = Instant::now();
259    let collect_pairs = options.emit_pairs.is_some();
260    let mut analyzed = analyze_corpus(config, extracted, &mut diagnostics, collect_pairs)?;
261    let analysis_ms = elapsed_ms(analysis_started);
262    apply_finding_modes(
263        &mut analyzed.result,
264        &files,
265        &changed_files,
266        analyzed.parsed_feature_files,
267        analyzed.feature_files_without_steps,
268    );
269    // Validated before the baseline is rewritten, so a rejected destination changes no file.
270    let fingerprints = options
271        .emit_fingerprints
272        .as_deref()
273        .map(|path| EmitSink::open(config, options, path, "fingerprint file", None))
274        .transpose()?;
275    let pairs = options
276        .emit_pairs
277        .as_deref()
278        .map(|path| EmitSink::open(config, options, path, "pair file", fingerprints.as_ref()))
279        .transpose()?;
280    let baseline_outcome = apply_baseline_mode(
281        config,
282        options,
283        &mut analyzed.result,
284        analyzed.run_incomplete,
285        &mut diagnostics,
286    )?;
287    if let Some(sink) = fingerprints {
288        sink.write(|out| {
289            analysis::fingerprints::write_fingerprints(
290                &analyzed.result.definitions,
291                &config.root,
292                out,
293            )
294        })?;
295    }
296    if let Some(sink) = pairs {
297        sink.write(|out| {
298            analysis::pair_records::write_pairs(
299                &analyzed.result.definitions,
300                &analyzed.pair_records,
301                &config.root,
302                out,
303            )
304        })?;
305    }
306    write_reports(
307        config,
308        &files,
309        &analyzed,
310        &diagnostics,
311        PhaseTimings {
312            discovery_ms,
313            parsing_ms,
314            analysis_ms,
315        },
316    )?;
317    write_diagnostics(config, options, &files, &diagnostics)?;
318    determine_exit_code(
319        config,
320        options,
321        &analyzed.result,
322        baseline_outcome,
323        &diagnostics,
324    )
325}
326
327/// Selects the root from `check <PATH>` or the bare path and loads the config with CLI overrides;
328/// a non-default path before `check` is an error. Boolean flags override only when set.
329fn resolve_invocation(cli: Cli) -> Result<Invocation> {
330    if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
331        bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
332    }
333    let root = match cli.command {
334        Some(Command::Check { path }) => path,
335        None => cli.path,
336    };
337    let options = cli.options.clone();
338    let config = Config::load_for_cli(
339        &root,
340        ConfigOverrides {
341            config_file: cli.options.config,
342            definitions: cli.options.definitions,
343            features: cli.options.features,
344            exclude: cli.options.exclude,
345            exclude_defaults: cli.options.no_default_excludes.then_some(false),
346            include_hidden: cli.options.include_hidden.then_some(true),
347            reporters: cli.options.reporters,
348            output: cli.options.output,
349            threshold: cli.options.threshold,
350            require_features: cli.options.require_features.then_some(true),
351            no_metrics: cli.options.no_metrics.then_some(true),
352            rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
353        },
354        CliConfigOverrides {
355            require_definitions: cli.options.require_definitions.then_some(true),
356            fail_on_incomplete: cli.options.fail_on_incomplete.then_some(true),
357            fail_on_unparseable: cli.options.fail_on_unparseable.then_some(true),
358            max_candidate_comparisons: cli.options.max_candidate_comparisons,
359            max_structural_class_comparisons: cli.options.max_structural_class_comparisons,
360        },
361    )?;
362    Ok(Invocation { config, options })
363}
364
365/// Files added, copied, modified or renamed since `changed_since`, plus untracked files Git does
366/// not ignore, or `None` when the flag is absent. Deleted files are excluded. Fails when the root
367/// is not trackable by Git or the changed-file query fails.
368fn resolve_changed_files(
369    config: &Config,
370    changed_since: Option<&str>,
371) -> Result<Option<BTreeSet<PathBuf>>> {
372    let Some(base) = changed_since else {
373        return Ok(None);
374    };
375    modes::ensure_changed_root_is_trackable(&config.root)?;
376    Ok(Some(modes::git_changed_files(&config.root, base)?))
377}
378
379/// Seeds diagnostics with config warnings and discovery errors, then warns on unmatched patterns,
380/// skipped opt-in sources (when definition globs are set or nothing else was found), automatic
381/// discovery finding no sources, and an empty changed set. No feature files is an error under
382/// `require_features`, a warning when definitions exist, and silent otherwise.
383fn discovery_diagnostics(
384    config: &Config,
385    files: &discovery::DiscoveredFiles,
386    changed_files: &Option<BTreeSet<PathBuf>>,
387) -> Diagnostics {
388    let mut diagnostics = Diagnostics {
389        warnings: config.config_warnings.clone(),
390        errors: files.errors.clone(),
391    };
392    for pattern in &files.unmatched_feature_patterns {
393        diagnostics.warnings.push(format!(
394            "feature pattern `{pattern}` from {} matched no files",
395            config.feature_pattern_origin
396        ));
397    }
398    for pattern in &files.unmatched_definition_patterns {
399        diagnostics
400            .warnings
401            .push(format!("definition pattern `{pattern}` matched no files"));
402    }
403    if !files.skipped_definitions.is_empty()
404        && (!config.definitions.is_empty() || files.definitions.is_empty())
405    {
406        diagnostics
407            .warnings
408            .push(skipped_definitions_warning(&files.skipped_definitions));
409    }
410    if files.definitions.is_empty() && config.definitions.is_empty() {
411        diagnostics
412            .warnings
413            .push("automatic discovery found no supported step-definition source files".to_owned());
414    }
415    if files.features.is_empty() {
416        let message = format!(
417            "no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
418            config.feature_pattern_origin
419        );
420        if config.require_features {
421            diagnostics.errors.push(message);
422        } else if !files.definitions.is_empty() {
423            diagnostics.warnings.push(message);
424        }
425    }
426    if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
427        diagnostics.warnings.push(
428            "--changed-since found no tracked or untracked files; no findings will be reported"
429                .to_owned(),
430        );
431    }
432    diagnostics
433}
434
435/// Warning naming the count, adapters and suffixes of opt-in sources that were not analyzed.
436fn skipped_definitions_warning(skipped: &[source_adapter::SourceFile]) -> String {
437    let registrations: Vec<_> = source_adapter::SOURCE_ADAPTER_REGISTRY
438        .iter()
439        .filter(|entry| {
440            skipped
441                .iter()
442                .any(|file| entry.adapter.language() == file.language)
443        })
444        .collect();
445    let adapters: BTreeSet<_> = registrations
446        .iter()
447        .map(|entry| entry.adapter.name())
448        .collect();
449    let suffixes: Vec<_> = registrations
450        .iter()
451        .map(|entry| format!("`{}`", entry.suffix))
452        .collect();
453    format!(
454        "{} {} source file(s) were not analyzed: opt-in languages need a definition pattern that names their suffix ({}), such as `features/**/*{}`",
455        skipped.len(),
456        adapters.into_iter().collect::<Vec<_>>().join(", "),
457        suffixes.join(", "),
458        registrations.first().map_or("", |entry| entry.suffix),
459    )
460}
461
462/// Extracts definitions, then feature steps, into a corpus whose parameter-type language is that
463/// of the first discovered definition file.
464fn extract_corpus(
465    config: &Config,
466    files: &discovery::DiscoveredFiles,
467    changed_files: &Option<BTreeSet<PathBuf>>,
468    diagnostics: &mut Diagnostics,
469) -> ExtractedCorpus {
470    let mut corpus = ExtractedCorpus {
471        parameter_types: Vec::new(),
472        parameter_language: files.definitions.first().map(|file| file.language),
473        indirect_usage: source_adapter::IndirectStepUsage::default(),
474        definitions: Vec::new(),
475        feature_steps: Vec::new(),
476        excluded_sources: Vec::new(),
477        definition_files_with_definitions: 0,
478        parsed_feature_files: 0,
479        feature_files_without_steps: 0,
480        incomplete: false,
481    };
482    extract_definitions(config, files, changed_files, diagnostics, &mut corpus);
483    extract_feature_steps(config, files, diagnostics, &mut corpus);
484    corpus
485}
486
487/// Extracts definitions through one shared session that carries discovered edges and gaps.
488fn extract_definitions(
489    config: &Config,
490    files: &discovery::DiscoveredFiles,
491    changed_files: &Option<BTreeSet<PathBuf>>,
492    diagnostics: &mut Diagnostics,
493    corpus: &mut ExtractedCorpus,
494) {
495    if let Some(first) = files.definitions.first() {
496        let domain = first.language.comparison_domain();
497        if files
498            .definitions
499            .iter()
500            .any(|file| file.language.comparison_domain() != domain)
501        {
502            corpus.incomplete = true;
503            diagnostics.errors.push("Ruby and JS/TS sources require separate analysis runs with explicit definition and feature roots".to_owned());
504            return;
505        }
506    }
507    let mut extraction_session = source_adapter::SourceExtractionSession::with_options(
508        &config.root,
509        &config.registrations,
510        &config.assertion_modules,
511    )
512    .with_dependencies(&files.dependencies)
513    .with_dependency_gaps(&files.dependency_gaps);
514    let mut prepared = BTreeSet::new();
515    for file in &files.definitions {
516        let adapter = source_adapter::adapter_for_language(file.language);
517        if prepared.insert(adapter.name()) {
518            if let Err(error) = adapter.prepare_session(&files.definitions, &mut extraction_session)
519            {
520                corpus.incomplete = true;
521                diagnostics
522                    .errors
523                    .push(format!("failed to prepare source evidence: {error:#}"));
524                return;
525            }
526        }
527    }
528    for file in &files.definitions {
529        // Generated bundles, compressed payloads and binary blobs reach here whenever they carry
530        // a source extension. They cannot contain an authored definition, so they are excluded
531        // before parsing rather than analyzed and discarded.
532        let adapter = source_adapter::adapter_for_language(file.language);
533        if let Some(excluded) = adapter.inspect_source(file, &config.registrations) {
534            // Every one of these signals is a heuristic over file content, not a proof: a NUL
535            // byte or an archive header can appear inside an authored comment or string, and
536            // minified geometry cannot be told apart from one very long authored line. So the
537            // analyzed corpus no longer represents everything discovered, and
538            // `--fail-on-incomplete` must fail closed rather than let a duplicate pass the gate.
539            corpus.incomplete = true;
540            corpus.excluded_sources.push((file.path.clone(), excluded));
541            continue;
542        }
543        match adapter
544            .extract_file_with_session(file, &mut extraction_session)
545            .with_context(|| format!("failed to analyze {}", file.path.display()))
546        {
547            Ok(extracted) => {
548                if adapter.supports_indirect_usage() {
549                    corpus.indirect_usage.extend(extracted.indirect_usage);
550                    corpus.incomplete |= corpus.indirect_usage.unknown;
551                }
552                corpus.parameter_types.extend(extracted.parameter_types);
553                if !extracted.definitions.is_empty() {
554                    corpus.definition_files_with_definitions += 1;
555                }
556                corpus.definitions.extend(extracted.definitions);
557                collect_extraction_diagnostics(
558                    config,
559                    file,
560                    changed_files,
561                    extracted.diagnostics,
562                    diagnostics,
563                    &mut corpus.incomplete,
564                );
565            }
566            // Every discovered source contributes to cross-file definition comparisons, even
567            // when changed mode later filters findings. Skipping an unreadable source could turn
568            // an incomplete run into a false pass.
569            Err(error) => {
570                if adapter.supports_indirect_usage() {
571                    corpus.indirect_usage.unknown = true;
572                    corpus.incomplete = true;
573                }
574                diagnostics.errors.push(format!("{error:#}"));
575            }
576        }
577    }
578    let finalization = extraction_session.finalize(&files.definitions, &mut corpus.definitions);
579    collect_finalization(finalization, corpus, diagnostics);
580    corpus.definitions.sort_by(|left, right| {
581        left.location
582            .path
583            .cmp(&right.location.path)
584            .then(left.location.line.cmp(&right.location.line))
585            .then(left.location.column.cmp(&right.location.column))
586    });
587    report_excluded_sources(config, &corpus.excluded_sources, diagnostics);
588    if corpus.definitions.is_empty() {
589        let message = if files.definitions.is_empty() {
590            "no step definitions were extracted because no definition source files were discovered"
591                .to_owned()
592        } else {
593            format!(
594                "definition extraction produced 0 definitions from {} discovered definition source file(s)",
595                files.definitions.len()
596            )
597        };
598        if config.require_definitions {
599            diagnostics.errors.push(message);
600        } else if !files.definitions.is_empty() {
601            diagnostics.warnings.push(message);
602        }
603    }
604}
605
606/// Applies the session finalization: uncertainties mark the corpus incomplete, become warnings and
607/// recount files with definitions; an error clears every definition, marks the corpus incomplete
608/// and is recorded as an error.
609fn collect_finalization(
610    finalization: Result<source_adapter::SourceFinalization>,
611    corpus: &mut ExtractedCorpus,
612    diagnostics: &mut Diagnostics,
613) {
614    match finalization {
615        Ok(finalization) => {
616            if !finalization.uncertainties.is_empty() {
617                corpus.incomplete = true;
618                diagnostics.warnings.extend(
619                    finalization
620                        .uncertainties
621                        .into_iter()
622                        .map(|item| item.message),
623                );
624                corpus.definition_files_with_definitions = corpus
625                    .definitions
626                    .iter()
627                    .map(|definition| &definition.location.path)
628                    .collect::<BTreeSet<_>>()
629                    .len();
630            }
631        }
632        Err(error) => {
633            corpus.incomplete = true;
634            corpus.definitions.clear();
635            corpus.definition_files_with_definitions = 0;
636            diagnostics
637                .errors
638                .push(format!("failed to finalize source evidence: {error:#}"));
639        }
640    }
641}
642
643/// Maximum excluded paths named individually before the warning summarizes the remainder.
644const MAX_LISTED_EXCLUDED_SOURCES: usize = 10;
645
646/// Records excluded sources as a warning so an exclusion is never silent.
647///
648/// The exclusion is a heuristic over file content, so a reader has to be able to see which files
649/// it removed and why. It stays a warning rather than an error because generated output in a
650/// repository is ordinary and must not fail an otherwise clean run.
651fn report_excluded_sources(
652    config: &Config,
653    excluded: &[(PathBuf, source_adapter::SourceExclusion)],
654    diagnostics: &mut Diagnostics,
655) {
656    if excluded.is_empty() {
657        return;
658    }
659    let listed = excluded
660        .iter()
661        .take(MAX_LISTED_EXCLUDED_SOURCES)
662        .map(|(path, reason)| {
663            let relative = path
664                .strip_prefix(&config.root)
665                .unwrap_or(path)
666                .to_string_lossy()
667                .replace('\\', "/");
668            format!("{relative} ({})", reason.as_str())
669        })
670        .collect::<Vec<_>>()
671        .join(", ");
672    let remainder = excluded.len().saturating_sub(MAX_LISTED_EXCLUDED_SOURCES);
673    let suffix = if remainder == 0 {
674        String::new()
675    } else {
676        format!(", and {remainder} more")
677    };
678    diagnostics.warnings.push(format!(
679        "excluded {} discovered definition source file(s) as generated or non-source content: {listed}{suffix}",
680        excluded.len()
681    ));
682}
683
684/// Routes one file's extraction diagnostics. Completeness kinds mark the corpus incomplete;
685/// error-level diagnostics are errors; unparseable warnings become errors under
686/// `fail_on_unparseable`; other warnings from unchanged files in changed mode are dropped unless
687/// they affect completeness.
688fn collect_extraction_diagnostics(
689    config: &Config,
690    file: &source_adapter::SourceFile,
691    changed_files: &Option<BTreeSet<PathBuf>>,
692    extracted: Vec<source_adapter::ExtractionDiagnostic>,
693    diagnostics: &mut Diagnostics,
694    corpus_incomplete: &mut bool,
695) {
696    let changed_or_full_run = changed_files
697        .as_ref()
698        .is_none_or(|changed| changed.contains(&file.path));
699    for diagnostic in extracted {
700        let message = format!(
701            "{}: {}",
702            diagnostic.location.display(&config.root),
703            diagnostic.message
704        );
705        let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
706        let unparseable = source_adapter::is_unparseable_diagnostic(&diagnostic);
707        // An unresolved registration import — or a file that could not be parsed — hides every
708        // definition it would have introduced, so the corpus is incomplete regardless of which
709        // files changed.
710        *corpus_incomplete |= completeness;
711        match diagnostic.level {
712            // An unparseable file is tolerated by default: its error-recovered definitions still
713            // contribute and the corpus is marked incomplete above. `--fail-on-unparseable`
714            // restores the hard stop without also failing on other kinds of incompleteness.
715            ExtractionDiagnosticLevel::Warning if unparseable && config.fail_on_unparseable => {
716                diagnostics.errors.push(message);
717            }
718            ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
719                diagnostics.warnings.push(message);
720            }
721            ExtractionDiagnosticLevel::Warning => {}
722            // Extraction errors can mean definitions were omitted. They remain fatal for
723            // unchanged files because those definitions still participate in changed-file pairs.
724            ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
725        }
726    }
727}
728
729/// Parses each feature file; a parse failure is an error and marks the corpus incomplete, as does
730/// a Gherkin Markdown file with no steps (a warning). Unparsed files add an error under
731/// `require_features`, else a warning when definitions exist. Steps are sorted by location.
732fn extract_feature_steps(
733    config: &Config,
734    files: &discovery::DiscoveredFiles,
735    diagnostics: &mut Diagnostics,
736    corpus: &mut ExtractedCorpus,
737) {
738    for file in &files.features {
739        match gherkin::extract_file_with_format(&file.path, file.format) {
740            Ok(extracted) => {
741                corpus.parsed_feature_files += 1;
742                if extracted.is_empty()
743                    && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
744                {
745                    corpus.feature_files_without_steps += 1;
746                    corpus.incomplete = true;
747                    let display = file
748                        .path
749                        .strip_prefix(&config.root)
750                        .unwrap_or(&file.path)
751                        .to_string_lossy()
752                        .replace('\\', "/");
753                    diagnostics.warnings.push(format!(
754                        "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
755                    ));
756                } else {
757                    corpus.feature_steps.extend(extracted);
758                }
759            }
760            // Changed definitions still depend on the full feature corpus for usage and ambiguity.
761            Err(error) => {
762                corpus.incomplete = true;
763                diagnostics.errors.push(format!("{error:#}"));
764            }
765        }
766    }
767    if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
768        let message = if corpus.parsed_feature_files == 0 {
769            "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
770        } else {
771            format!(
772                "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
773                files.features.len() - corpus.parsed_feature_files
774            )
775        };
776        if config.require_features {
777            diagnostics.errors.push(message);
778        } else if !files.definitions.is_empty() {
779            diagnostics.warnings.push(message);
780        }
781    }
782    corpus.feature_steps.sort_by(|left, right| {
783        left.location
784            .path
785            .cmp(&right.location.path)
786            .then(left.location.line.cmp(&right.location.line))
787            .then(left.location.column.cmp(&right.location.column))
788    });
789}
790
791/// Resolves parameter types with the first definition language's adapter (their diagnostics mark
792/// the corpus incomplete), analyzes the corpus and warns on unmatched suppressions. Under
793/// `fail_on_incomplete`, analysis gaps and an incomplete corpus are errors; otherwise analysis
794/// gaps are warnings. Analysis failures are returned.
795fn analyze_corpus(
796    config: &Config,
797    extracted: ExtractedCorpus,
798    diagnostics: &mut Diagnostics,
799    collect_pairs: bool,
800) -> Result<AnalyzedCorpus> {
801    let ExtractedCorpus {
802        parameter_types,
803        parameter_language,
804        indirect_usage,
805        definitions,
806        feature_steps,
807        // Reported during extraction; analysis works only from what survived exclusion.
808        excluded_sources: _,
809        definition_files_with_definitions,
810        parsed_feature_files,
811        feature_files_without_steps,
812        incomplete: mut corpus_incomplete,
813    } = extracted;
814    let mut effective_config = config.clone();
815    let (patterns, type_diagnostics) = match parameter_language {
816        Some(language) => source_adapter::adapter_for_language(language)
817            .resolve_parameter_types(parameter_types, &config.parameter_types),
818        None => (config.parameter_types.clone(), Vec::new()),
819    };
820    effective_config.parameter_types = patterns;
821    corpus_incomplete |= !type_diagnostics.is_empty();
822    diagnostics.warnings.extend(type_diagnostics);
823    let config = &effective_config;
824    let mut pair_records = Vec::new();
825    let sink = collect_pairs.then_some(&mut pair_records);
826    let analyzed =
827        analysis::analyze_for_cli(&indirect_usage, definitions, feature_steps, config, sink);
828    let (analysis, census, unmatched_suppressions) = analyzed?;
829    for index in unmatched_suppressions.indices {
830        let suppression = &config.suppressions[index];
831        diagnostics.warnings.push(format!(
832            "suppression {} for {} matched no step definitions",
833            index + 1,
834            suppression.rule
835        ));
836    }
837    if unmatched_suppressions.truncated {
838        diagnostics.warnings.push(
839            "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
840                .to_owned(),
841        );
842    }
843
844    // Bounded work that could not finish makes findings a subset of a complete run, but every
845    // reported finding remains valid. `--fail-on-incomplete` restores strict CI gating.
846    let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
847    if config.fail_on_incomplete {
848        diagnostics.errors.extend(analysis.incomplete);
849        if corpus_incomplete {
850            diagnostics.errors.push(
851                "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
852            );
853        }
854    } else {
855        diagnostics.warnings.extend(analysis.incomplete);
856    }
857    Ok(AnalyzedCorpus {
858        result: analysis.result,
859        census,
860        definition_files_with_definitions,
861        parsed_feature_files,
862        feature_files_without_steps,
863        corpus_incomplete,
864        run_incomplete,
865        pair_records,
866    })
867}
868
869/// Drops unused-definition findings when a feature file failed to parse, a Gherkin Markdown file
870/// yielded no steps, or no feature file was found; then keeps only changed-file findings in
871/// changed mode.
872fn apply_finding_modes(
873    result: &mut crate::model::AnalysisResult,
874    files: &discovery::DiscoveredFiles,
875    changed_files: &Option<BTreeSet<PathBuf>>,
876    parsed_feature_files: usize,
877    feature_files_without_steps: usize,
878) {
879    // A converter can produce a syntactically valid but empty document when it fails to
880    // recognize markup or a dialect. Such an input cannot prove that a definition is unused,
881    // so use the same conservative suppression as a feature parse failure.
882    if parsed_feature_files < files.features.len()
883        || feature_files_without_steps > 0
884        || files.features.is_empty()
885    {
886        result
887            .findings
888            .retain(|finding| finding.rule != Rule::UnusedDefinition);
889    }
890    if let Some(changed) = changed_files {
891        modes::retain_changed_findings(&mut result.findings, changed);
892    }
893}
894
895/// Subtracts the findings of a baseline (a Git revision or a baseline file) from the current run.
896fn apply_baseline_mode(
897    config: &Config,
898    options: &CheckOptions,
899    result: &mut crate::model::AnalysisResult,
900    run_incomplete: bool,
901    diagnostics: &mut Diagnostics,
902) -> Result<Option<modes::BaselineOutcome>> {
903    if let Some(revision) = &options.baseline_from_ref {
904        let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
905        // Freeze the current policy instead of loading historical rule/config overrides. The
906        // baseline revision inherits the run's own strictness rather than being forced strict: an
907        // incomplete history (a vendored bundle excluded, an unparseable file, a bounded analysis)
908        // still yields a sound subtraction for the findings it did extract, so it is tolerated by
909        // default and reported. `--fail-on-incomplete` flows through `base_config` to surface that
910        // incompleteness as an error and reject the baseline.
911        let mut base_config = config.clone();
912        base_config.root = root;
913        let files = discovery::discover(&base_config)?;
914        let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
915        let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
916        let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics, false)?;
917        // A baseline that produced a hard error, or that has definitions but no features to anchor
918        // usage, cannot be subtracted at all — those always bail. A baseline that kept no
919        // definitions while the current tree has some cannot match any finding, so every current
920        // finding would appear new; that bails too, whatever emptied it. When both are empty there
921        // is nothing to compare and nothing can appear new, so the run follows the plain-run
922        // policy. Soft incompleteness only bails under `--fail-on-incomplete`, which routes through
923        // `base_diagnostics.errors` above.
924        if !files.definitions.is_empty()
925            && base.result.definitions.is_empty()
926            && !result.definitions.is_empty()
927        {
928            bail!(
929                "baseline revision `{revision}` is incomplete: it kept no step definitions while the current tree has {}, so no finding could be matched against it: {}",
930                result.definitions.len(),
931                base_diagnostics
932                    .errors
933                    .iter()
934                    .chain(&base_diagnostics.warnings)
935                    .cloned()
936                    .collect::<Vec<_>>()
937                    .join("; ")
938            );
939        }
940        if !base_diagnostics.errors.is_empty()
941            || (files.features.is_empty() && !base.result.definitions.is_empty())
942        {
943            bail!(
944                "baseline revision `{revision}` is incomplete: {}",
945                base_diagnostics
946                    .errors
947                    .iter()
948                    .chain(&base_diagnostics.warnings)
949                    .cloned()
950                    .collect::<Vec<_>>()
951                    .join("; ")
952            );
953        }
954        if base.run_incomplete {
955            diagnostics.warnings.push(format!(
956                "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"
957            ));
958        }
959        apply_finding_modes(
960            &mut base.result,
961            &files,
962            &None,
963            base.parsed_feature_files,
964            base.feature_files_without_steps,
965        );
966        return modes::apply_reference_baseline(
967            &mut result.findings,
968            &base.result.findings,
969            revision,
970        )
971        .map(Some);
972    }
973    let Some(path) = &options.baseline else {
974        return Ok(None);
975    };
976    let path = if path.is_absolute() {
977        path.clone()
978    } else {
979        config.root.join(path)
980    };
981    if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
982        diagnostics.warnings.push(format!(
983            "baseline {} was not updated because analysis is incomplete",
984            path.display()
985        ));
986        return Ok(None);
987    }
988    let outcome = if options.update_baseline {
989        modes::update_baseline(&mut result.findings, &path)?
990    } else {
991        modes::apply_baseline(&mut result.findings, &path)?
992    };
993    if options.update_baseline {
994        diagnostics.warnings.push(format!(
995            "updated baseline {}: {} added, {} removed, {} total",
996            path.display(),
997            outcome.added,
998            outcome.removed,
999            outcome.suppressed
1000        ));
1001    }
1002    Ok(Some(outcome))
1003}
1004
1005/// Writes the selected reports with corpus census and, unless `no_metrics`, timing metrics; the
1006/// run counts as successful for SARIF only with no errors and a complete run.
1007fn write_reports(
1008    config: &Config,
1009    files: &discovery::DiscoveredFiles,
1010    analyzed: &AnalyzedCorpus,
1011    diagnostics: &Diagnostics,
1012    timings: PhaseTimings,
1013) -> Result<()> {
1014    let corpus = reporters::CorpusCensus {
1015        definition_files: files.definitions.len(),
1016        definition_files_with_definitions: analyzed.definition_files_with_definitions,
1017        definitions_extracted: analyzed.result.definitions.len(),
1018        feature_files: files.features.len(),
1019        feature_files_parsed: analyzed.parsed_feature_files,
1020        feature_files_without_steps: analyzed.feature_files_without_steps,
1021        incomplete: analyzed.corpus_incomplete,
1022    };
1023    let metrics = reporters::ExecutionMetrics::new(
1024        files.definitions.len(),
1025        files.features.len(),
1026        timings.discovery_ms,
1027        timings.parsing_ms,
1028        timings.analysis_ms,
1029    );
1030    let report_metadata = reporters::CliReportMetadata {
1031        corpus: &corpus,
1032        analysis: &analyzed.census,
1033        // SARIF consumers treat a successful invocation as proof the tool covered its input.
1034        execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
1035    };
1036    let terminal_color = terminal_color_enabled(
1037        io::stdout().is_terminal(),
1038        std::env::var_os("NO_COLOR").as_deref(),
1039    );
1040    let mut stdout = io::stdout().lock();
1041    reporters::write_cli_reports(
1042        &analyzed.result,
1043        config,
1044        (!config.no_metrics).then_some(&metrics),
1045        &report_metadata,
1046        &mut stdout,
1047        terminal_color,
1048    )?;
1049    Ok(())
1050}
1051
1052/// Validated `--emit-fingerprints` or `--emit-pairs` destination, opened but not yet truncated or
1053/// written.
1054struct EmitSink {
1055    path: PathBuf,
1056    /// Names the destination in errors.
1057    label: &'static str,
1058    file: Option<fs::File>,
1059    /// The file this run created, to remove if the run fails before writing it.
1060    created: Option<PathBuf>,
1061}
1062
1063impl EmitSink {
1064    /// Opens `path`, resolved against the root, without truncating it. Fails on a non-regular file
1065    /// and on any file this run writes (each report, `earlier`) or reads back later (the
1066    /// `--baseline` file).
1067    fn open(
1068        config: &Config,
1069        options: &CheckOptions,
1070        path: &Path,
1071        label: &'static str,
1072        earlier: Option<&Self>,
1073    ) -> Result<Self> {
1074        let path = config.root.join(path);
1075        let context = || format!("failed to write {label} {}", path.display());
1076        let (file, created) = match open_destination(&path) {
1077            Ok(opened) => opened,
1078            // An open that fails on a directory or a FIFO without a reader still names the cause.
1079            Err(_) if fs::metadata(&path).is_ok_and(|metadata| !metadata.is_file()) => {
1080                bail!("{label} {} is not a regular file", path.display())
1081            }
1082            Err(error) => return Err(error).with_context(context),
1083        };
1084        // fail-closed: a device or FIFO would fail, block or leak into another stream. The handle is
1085        // checked, not the path, so nothing swapped in after the open is trusted.
1086        if !file.metadata().with_context(context)?.is_file() {
1087            bail!("{label} {} is not a regular file", path.display());
1088        }
1089        let mut sink = Self {
1090            file: Some(file),
1091            created: created.then(|| path.clone()),
1092            path: path.clone(),
1093            label,
1094        };
1095        // Comparing after opening resolves `..`, symlink and case-insensitive aliases, including of
1096        // a report or baseline not yet written.
1097        let canonical = fs::canonicalize(&path).with_context(context)?;
1098        // Opening a symlink to a missing file creates its target; that target is what to remove.
1099        if let Some(created) = &mut sink.created {
1100            created.clone_from(&canonical);
1101        }
1102        let written = reporters::REPORT_FILE_NAMES
1103            .iter()
1104            .map(|name| config.output_path(name))
1105            .chain(options.baseline.iter().map(|path| config.root.join(path)))
1106            .chain(earlier.map(|sink| sink.path.clone()));
1107        for target in written {
1108            // Identity as well as path: a hard link to the baseline has a different canonical path.
1109            let open = sink.file.as_ref().expect("an open sink holds its file");
1110            let same = match same_file_as(open, &target) {
1111                Ok(same) => same,
1112                // An absent target, or one under a file (ENOTDIR), has nothing to overwrite.
1113                Err(error)
1114                    if matches!(
1115                        error.kind(),
1116                        io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
1117                    ) =>
1118                {
1119                    false
1120                }
1121                // fail-closed: a target whose identity cannot be read may be this very file.
1122                Err(error) => bail!(
1123                    "cannot verify that {} {} is not {}: {error}",
1124                    sink.label,
1125                    sink.path.display(),
1126                    target.display()
1127                ),
1128            };
1129            if same || fs::canonicalize(&target).is_ok_and(|target| target == canonical) {
1130                bail!(
1131                    "{} {} would overwrite {}; choose a different file",
1132                    sink.label,
1133                    sink.path.display(),
1134                    target.display()
1135                );
1136            }
1137        }
1138        Ok(sink)
1139    }
1140
1141    /// Replaces the file's content with the records `fill` writes.
1142    fn write(mut self, fill: impl FnOnce(&mut dyn Write) -> Result<()>) -> Result<()> {
1143        let context = || format!("failed to write {} {}", self.label, self.path.display());
1144        let file = self.file.take().expect("an open sink holds its file");
1145        file.set_len(0).with_context(context)?;
1146        let mut file = io::BufWriter::new(file);
1147        fill(&mut file).with_context(context)?;
1148        file.flush().with_context(context)?;
1149        self.created = None;
1150        Ok(())
1151    }
1152}
1153
1154/// Opens `path` for writing without truncating it, and whether this call created the file. Only an
1155/// exclusive create counts as created, so a file another process makes meanwhile is never removed.
1156/// An existing file opens non-blocking, so a FIFO fails instead of waiting for a reader; a dangling
1157/// symlink is followed to the target it would create.
1158fn open_destination(path: &Path) -> io::Result<(fs::File, bool)> {
1159    let mut path = path.to_path_buf();
1160    loop {
1161        let created = fs::OpenOptions::new()
1162            .write(true)
1163            .create_new(true)
1164            .open(&path);
1165        let error = match created {
1166            Ok(file) => return Ok((file, true)),
1167            Err(error) => error,
1168        };
1169        if error.kind() != io::ErrorKind::AlreadyExists {
1170            return Err(error);
1171        }
1172        let mut existing = fs::OpenOptions::new();
1173        existing.write(true);
1174        #[cfg(unix)]
1175        std::os::unix::fs::OpenOptionsExt::custom_flags(&mut existing, libc::O_NONBLOCK);
1176        match existing.open(&path) {
1177            Ok(file) => return Ok((file, false)),
1178            Err(error) if error.kind() == io::ErrorKind::NotFound && path.is_symlink() => {
1179                let target = fs::read_link(&path)?;
1180                path = path
1181                    .parent()
1182                    .map_or(target.clone(), |parent| parent.join(&target));
1183            }
1184            Err(error) => return Err(error),
1185        }
1186    }
1187}
1188
1189/// Whether `target` is the file already open as `file`, read from metadata: device and inode on Unix,
1190/// so no file is opened (a write-only target or a FIFO neither fails nor blocks).
1191#[cfg(unix)]
1192fn same_file_as(file: &fs::File, target: &Path) -> io::Result<bool> {
1193    use std::os::unix::fs::MetadataExt;
1194    let (open, other) = (file.metadata()?, fs::metadata(target)?);
1195    Ok(open.dev() == other.dev() && open.ino() == other.ino())
1196}
1197
1198/// Whether `target` is the file already open as `file`: volume and file index, through a handle
1199/// opened with no access rights.
1200#[cfg(not(unix))]
1201fn same_file_as(file: &fs::File, target: &Path) -> io::Result<bool> {
1202    Ok(same_file::Handle::from_file(file.try_clone()?)? == same_file::Handle::from_path(target)?)
1203}
1204
1205impl Drop for EmitSink {
1206    /// Removes a file this run created but did not finish writing, so a failed run leaves none.
1207    fn drop(&mut self) {
1208        drop(self.file.take());
1209        if let Some(created) = self.created.take() {
1210            // The run is already failing; a leftover empty file is the only cost of a failed removal.
1211            let _ = fs::remove_file(created);
1212        }
1213    }
1214}
1215
1216/// Whether terminal output uses color: stdout is a TTY and `NO_COLOR` is unset or empty.
1217fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
1218    is_terminal && !no_color.is_some_and(|value| !value.is_empty())
1219}
1220
1221/// Writes `--explain-discovery` output, then warnings and errors, to stderr; writes nothing when
1222/// none apply.
1223fn write_diagnostics(
1224    config: &Config,
1225    options: &CheckOptions,
1226    files: &discovery::DiscoveredFiles,
1227    diagnostics: &Diagnostics,
1228) -> Result<()> {
1229    if !options.explain_discovery
1230        && diagnostics.warnings.is_empty()
1231        && diagnostics.errors.is_empty()
1232    {
1233        return Ok(());
1234    }
1235    let mut stderr = io::stderr().lock();
1236    if options.explain_discovery {
1237        writeln!(
1238            stderr,
1239            "cuke-dedup: feature patterns: {} ({})",
1240            config.features.join(", "),
1241            config.feature_pattern_origin
1242        )?;
1243        for file in &files.features {
1244            let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
1245            writeln!(
1246                stderr,
1247                "cuke-dedup: feature {} [{}] via {}",
1248                relative.display(),
1249                file.format.as_str(),
1250                file.pattern
1251            )?;
1252        }
1253        for file in &files.definitions {
1254            let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
1255            writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
1256        }
1257    }
1258    for warning in &diagnostics.warnings {
1259        writeln!(stderr, "cuke-dedup: warning: {warning}")?;
1260    }
1261    for error in &diagnostics.errors {
1262        writeln!(stderr, "cuke-dedup: {error}")?;
1263    }
1264    Ok(())
1265}
1266
1267/// 2 when any diagnostic error exists; 1 when an active error finding outside the duplication
1268/// threshold exists, the threshold fails, or new findings exceed `--fail-on-new`; otherwise 0.
1269fn determine_exit_code(
1270    config: &Config,
1271    options: &CheckOptions,
1272    result: &crate::model::AnalysisResult,
1273    baseline: Option<modes::BaselineOutcome>,
1274    diagnostics: &Diagnostics,
1275) -> Result<i32> {
1276    if !diagnostics.errors.is_empty() {
1277        return Ok(2);
1278    }
1279    let duplication = DuplicationThreshold::from_result(result, config.threshold);
1280    let has_non_duplication_errors = result.findings.iter().any(|finding| {
1281        finding.is_active()
1282            && finding.severity == Severity::Error
1283            && !finding.rule.contributes_to_duplication_threshold()
1284    });
1285    let new_findings_exceeded = options
1286        .fail_on_new
1287        .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
1288    Ok(i32::from(
1289        has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
1290    ))
1291}
1292
1293/// Milliseconds elapsed since `started`, with fractional precision.
1294fn elapsed_ms(started: Instant) -> f64 {
1295    started.elapsed().as_secs_f64() * 1_000.0
1296}
1297
1298/// Parses `RULE=SEVERITY` at the first `=`; a missing `=`, unknown rule or unknown severity fails.
1299fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
1300    let (rule, severity) = value
1301        .split_once('=')
1302        .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
1303    Ok((rule.parse()?, severity.parse()?))
1304}
1305
1306/// Parses an integer greater than zero; zero, negative and non-numeric input are rejected.
1307fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
1308    value
1309        .parse::<usize>()
1310        .ok()
1311        .filter(|value| *value > 0)
1312        .ok_or_else(|| "expected an integer greater than zero".to_owned())
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317    use super::*;
1318
1319    /// A clean finalization keeps definitions; a failed one clears them, zeroes the file count,
1320    /// records the error, and leaves an incomplete run with no findings.
1321    #[test]
1322    fn finalization_failure_discards_provisional_definitions_and_reports_incompleteness() {
1323        let root = tempfile::tempdir().unwrap();
1324        let config = Config::load(root.path(), ConfigOverrides::default()).unwrap();
1325        std::fs::write(root.path().join("steps.ts"), "import { Given, Then } from '@cucumber/cucumber'; Given('same', () => work()); Then('same', () => work());").unwrap();
1326        let files = discovery::discover(&config).unwrap();
1327        let mut diagnostics = discovery_diagnostics(&config, &files, &None);
1328        let mut corpus = extract_corpus(&config, &files, &None, &mut diagnostics);
1329        assert_eq!(corpus.definitions.len(), 2);
1330        let before = corpus.definitions.clone();
1331        collect_finalization(Ok(Default::default()), &mut corpus, &mut diagnostics);
1332        assert_eq!(corpus.definitions, before);
1333        collect_finalization(
1334            Err(anyhow::anyhow!("adapter unavailable")),
1335            &mut corpus,
1336            &mut diagnostics,
1337        );
1338        assert!(corpus.incomplete);
1339        assert!(corpus.definitions.is_empty());
1340        assert_eq!(corpus.definition_files_with_definitions, 0);
1341        assert!(diagnostics
1342            .errors
1343            .iter()
1344            .any(|e| e == "failed to finalize source evidence: adapter unavailable"));
1345        let analyzed = analyze_corpus(&config, corpus, &mut diagnostics, false).unwrap();
1346        assert!(analyzed.run_incomplete);
1347        assert!(analyzed.result.findings.is_empty());
1348    }
1349
1350    /// Color requires a TTY and is disabled by a non-empty `NO_COLOR`, but not by an empty one.
1351    #[test]
1352    fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
1353        use std::ffi::OsStr;
1354
1355        assert!(terminal_color_enabled(true, None));
1356        assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
1357        assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
1358        assert!(!terminal_color_enabled(false, None));
1359        assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
1360    }
1361
1362    /// A known rule and severity parse; an unknown rule name is rejected.
1363    #[test]
1364    fn parses_rule_override() {
1365        assert_eq!(
1366            parse_rule_override("duplicate-matcher=warning"),
1367            Ok((Rule::DuplicateMatcher, Severity::Warning))
1368        );
1369        assert!(parse_rule_override("unknown=error").is_err());
1370    }
1371
1372    /// Accepts a positive integer and rejects zero, a negative number and non-numeric text.
1373    #[test]
1374    fn candidate_limit_cli_values_must_be_positive() {
1375        assert_eq!(parse_positive_usize("7"), Ok(7));
1376        assert!(parse_positive_usize("0").is_err());
1377        assert!(parse_positive_usize("-1").is_err());
1378        assert!(parse_positive_usize("many").is_err());
1379    }
1380
1381    /// An unparseable warning is an incomplete warning by default and an error under the flag; a
1382    /// dynamic-matcher warning stays an incomplete warning, and an unrelated one stays complete.
1383    #[test]
1384    fn unparseable_diagnostics_are_tolerated_by_default_and_escalated_by_the_flag() {
1385        use crate::discovery::SourceLanguage;
1386        use crate::model::SourceLocation;
1387        use crate::source_adapter::{ExtractionDiagnostic, SourceFile};
1388
1389        let directory = tempfile::tempdir().unwrap();
1390        let file = SourceFile {
1391            path: directory.path().join("steps.ts"),
1392            language: SourceLanguage::TypeScript,
1393        };
1394        let unparseable = || {
1395            ExtractionDiagnostic::new(
1396                ExtractionDiagnosticLevel::Warning,
1397                SourceLocation::new("steps.ts", 1, 1, 1, 1),
1398                format!(
1399                    "{}, so analysis is incomplete",
1400                    source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX
1401                ),
1402            )
1403        };
1404        let route = |config: &Config, diagnostic: ExtractionDiagnostic| {
1405            let mut diagnostics = Diagnostics {
1406                warnings: Vec::new(),
1407                errors: Vec::new(),
1408            };
1409            let mut incomplete = false;
1410            collect_extraction_diagnostics(
1411                config,
1412                &file,
1413                &None,
1414                vec![diagnostic],
1415                &mut diagnostics,
1416                &mut incomplete,
1417            );
1418            (diagnostics, incomplete)
1419        };
1420
1421        let mut config =
1422            Config::load(directory.path(), ConfigOverrides::default()).expect("config loads");
1423
1424        // Default: an unparseable file is a completeness warning, not an error.
1425        let (diagnostics, incomplete) = route(&config, unparseable());
1426        assert!(incomplete);
1427        assert!(diagnostics.errors.is_empty());
1428        assert_eq!(diagnostics.warnings.len(), 1);
1429
1430        // `--fail-on-unparseable`: the same diagnostic becomes a hard error, still marking the
1431        // corpus incomplete.
1432        config.fail_on_unparseable = true;
1433        let (diagnostics, incomplete) = route(&config, unparseable());
1434        assert!(incomplete);
1435        assert_eq!(diagnostics.errors.len(), 1);
1436        assert!(diagnostics.warnings.is_empty());
1437
1438        // An unresolved matcher is incomplete, but is not a syntax error. The syntax-only
1439        // flag must leave this diagnostic at warning severity.
1440        let dynamic = ExtractionDiagnostic::new(
1441            ExtractionDiagnosticLevel::Warning,
1442            SourceLocation::new("steps.ts", 2, 1, 2, 1),
1443            "dynamic or unsupported step matcher cannot be analyzed statically",
1444        );
1445        let (diagnostics, incomplete) = route(&config, dynamic);
1446        assert!(incomplete);
1447        assert!(diagnostics.errors.is_empty());
1448        assert_eq!(diagnostics.warnings.len(), 1);
1449        let unrelated = ExtractionDiagnostic::new(
1450            ExtractionDiagnosticLevel::Warning,
1451            SourceLocation::new("steps.ts", 2, 1, 2, 1),
1452            "unrelated extractor warning",
1453        );
1454        let (diagnostics, incomplete) = route(&config, unrelated);
1455        assert!(!incomplete);
1456        assert!(diagnostics.errors.is_empty());
1457        assert_eq!(diagnostics.warnings.len(), 1);
1458    }
1459
1460    /// Routing follows the diagnostic kind, not its text: Incomplete and Unparseable kinds mark the
1461    /// corpus incomplete, Error level always errors, and Unparseable warnings error only in strict
1462    /// mode.
1463    #[test]
1464    fn typed_adapter_diagnostics_route_independently_of_language_and_wording() {
1465        use crate::model::SourceLocation;
1466        use crate::source_adapter::{
1467            ExtractionDiagnostic, ExtractionDiagnosticKind as Kind, SourceFile, SourceLanguage,
1468        };
1469        let directory = tempfile::tempdir().unwrap();
1470        let file = SourceFile {
1471            path: directory.path().join("steps.ts"),
1472            language: SourceLanguage::TypeScript,
1473        };
1474        let mut config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1475        for strict in [false, true] {
1476            config.fail_on_unparseable = strict;
1477            for (kind, message, incomplete, parse_error) in [
1478                (Kind::Other, "adapter advisory", false, false),
1479                (Kind::Incomplete, "external glue unavailable", true, false),
1480                (Kind::Unparseable, "adapter syntax failure", true, true),
1481                (
1482                    Kind::Other,
1483                    source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1484                    false,
1485                    false,
1486                ),
1487                (
1488                    Kind::Incomplete,
1489                    source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1490                    true,
1491                    false,
1492                ),
1493            ] {
1494                for level in [
1495                    ExtractionDiagnosticLevel::Warning,
1496                    ExtractionDiagnosticLevel::Error,
1497                ] {
1498                    let mut diagnostics = Diagnostics {
1499                        warnings: vec![],
1500                        errors: vec![],
1501                    };
1502                    let mut actual_incomplete = false;
1503                    collect_extraction_diagnostics(
1504                        &config,
1505                        &file,
1506                        &None,
1507                        vec![ExtractionDiagnostic::with_kind(
1508                            kind,
1509                            level,
1510                            SourceLocation::new(&file.path, 1, 1, 1, 1),
1511                            message,
1512                        )],
1513                        &mut diagnostics,
1514                        &mut actual_incomplete,
1515                    );
1516                    assert_eq!(actual_incomplete, incomplete, "{kind:?} strict={strict}");
1517                    let error =
1518                        level == ExtractionDiagnosticLevel::Error || (strict && parse_error);
1519                    assert_eq!(diagnostics.errors.len(), usize::from(error));
1520                    assert_eq!(diagnostics.warnings.len(), usize::from(!error));
1521                }
1522            }
1523        }
1524    }
1525
1526    /// Both `cuke-dedup PATH` and `cuke-dedup check PATH` parse; `check` without a path fails.
1527    #[test]
1528    fn supports_implicit_and_explicit_check_forms() {
1529        let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
1530            .expect("implicit invocation");
1531        assert!(implicit.command.is_none());
1532        let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
1533            .expect("explicit invocation");
1534        assert!(matches!(explicit.command, Some(Command::Check { .. })));
1535        assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
1536    }
1537}