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
215/// Runs discovery, extraction, analysis, finding modes and baseline, writes reports and stderr
216/// diagnostics, then returns the exit code; `--print-config` prints the config and returns 0.
217fn execute(cli: Cli) -> Result<i32> {
218    let invocation = resolve_invocation(cli)?;
219    let config = &invocation.config;
220    let options = &invocation.options;
221    if options.print_config {
222        println!(
223            "{}",
224            serde_json::to_string_pretty(config)
225                .context("failed to serialize effective configuration")?
226        );
227        return Ok(0);
228    }
229    let changed_files = resolve_changed_files(config, options.changed_since.as_deref())?;
230    let discovery_started = Instant::now();
231    let files = discovery::discover(config)?;
232    let discovery_ms = elapsed_ms(discovery_started);
233
234    let parsing_started = Instant::now();
235    let mut diagnostics = discovery_diagnostics(config, &files, &changed_files);
236    let extracted = extract_corpus(config, &files, &changed_files, &mut diagnostics);
237    let parsing_ms = elapsed_ms(parsing_started);
238    let analysis_started = Instant::now();
239    let mut analyzed = analyze_corpus(config, extracted, &mut diagnostics)?;
240    let analysis_ms = elapsed_ms(analysis_started);
241    apply_finding_modes(
242        &mut analyzed.result,
243        &files,
244        &changed_files,
245        analyzed.parsed_feature_files,
246        analyzed.feature_files_without_steps,
247    );
248    let baseline_outcome = apply_baseline_mode(
249        config,
250        options,
251        &mut analyzed.result,
252        analyzed.run_incomplete,
253        &mut diagnostics,
254    )?;
255    write_reports(
256        config,
257        &files,
258        &analyzed,
259        &diagnostics,
260        PhaseTimings {
261            discovery_ms,
262            parsing_ms,
263            analysis_ms,
264        },
265    )?;
266    write_diagnostics(config, options, &files, &diagnostics)?;
267    determine_exit_code(
268        config,
269        options,
270        &analyzed.result,
271        baseline_outcome,
272        &diagnostics,
273    )
274}
275
276/// Selects the root from `check <PATH>` or the bare path and loads the config with CLI overrides;
277/// a non-default path before `check` is an error. Boolean flags override only when set.
278fn resolve_invocation(cli: Cli) -> Result<Invocation> {
279    if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
280        bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
281    }
282    let root = match cli.command {
283        Some(Command::Check { path }) => path,
284        None => cli.path,
285    };
286    let options = cli.options.clone();
287    let config = Config::load_for_cli(
288        &root,
289        ConfigOverrides {
290            config_file: cli.options.config,
291            definitions: cli.options.definitions,
292            features: cli.options.features,
293            exclude: cli.options.exclude,
294            exclude_defaults: cli.options.no_default_excludes.then_some(false),
295            include_hidden: cli.options.include_hidden.then_some(true),
296            reporters: cli.options.reporters,
297            output: cli.options.output,
298            threshold: cli.options.threshold,
299            require_features: cli.options.require_features.then_some(true),
300            no_metrics: cli.options.no_metrics.then_some(true),
301            rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
302        },
303        CliConfigOverrides {
304            require_definitions: cli.options.require_definitions.then_some(true),
305            fail_on_incomplete: cli.options.fail_on_incomplete.then_some(true),
306            fail_on_unparseable: cli.options.fail_on_unparseable.then_some(true),
307            max_candidate_comparisons: cli.options.max_candidate_comparisons,
308            max_structural_class_comparisons: cli.options.max_structural_class_comparisons,
309        },
310    )?;
311    Ok(Invocation { config, options })
312}
313
314/// Files added, copied, modified or renamed since `changed_since`, plus untracked files Git does
315/// not ignore, or `None` when the flag is absent. Deleted files are excluded. Fails when the root
316/// is not trackable by Git or the changed-file query fails.
317fn resolve_changed_files(
318    config: &Config,
319    changed_since: Option<&str>,
320) -> Result<Option<BTreeSet<PathBuf>>> {
321    let Some(base) = changed_since else {
322        return Ok(None);
323    };
324    modes::ensure_changed_root_is_trackable(&config.root)?;
325    Ok(Some(modes::git_changed_files(&config.root, base)?))
326}
327
328/// Seeds diagnostics with config warnings and discovery errors, then warns on unmatched patterns,
329/// skipped opt-in sources (when definition globs are set or nothing else was found), automatic
330/// discovery finding no sources, and an empty changed set. No feature files is an error under
331/// `require_features`, a warning when definitions exist, and silent otherwise.
332fn discovery_diagnostics(
333    config: &Config,
334    files: &discovery::DiscoveredFiles,
335    changed_files: &Option<BTreeSet<PathBuf>>,
336) -> Diagnostics {
337    let mut diagnostics = Diagnostics {
338        warnings: config.config_warnings.clone(),
339        errors: files.errors.clone(),
340    };
341    for pattern in &files.unmatched_feature_patterns {
342        diagnostics.warnings.push(format!(
343            "feature pattern `{pattern}` from {} matched no files",
344            config.feature_pattern_origin
345        ));
346    }
347    for pattern in &files.unmatched_definition_patterns {
348        diagnostics
349            .warnings
350            .push(format!("definition pattern `{pattern}` matched no files"));
351    }
352    if !files.skipped_definitions.is_empty()
353        && (!config.definitions.is_empty() || files.definitions.is_empty())
354    {
355        diagnostics
356            .warnings
357            .push(skipped_definitions_warning(&files.skipped_definitions));
358    }
359    if files.definitions.is_empty() && config.definitions.is_empty() {
360        diagnostics
361            .warnings
362            .push("automatic discovery found no supported step-definition source files".to_owned());
363    }
364    if files.features.is_empty() {
365        let message = format!(
366            "no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
367            config.feature_pattern_origin
368        );
369        if config.require_features {
370            diagnostics.errors.push(message);
371        } else if !files.definitions.is_empty() {
372            diagnostics.warnings.push(message);
373        }
374    }
375    if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
376        diagnostics.warnings.push(
377            "--changed-since found no tracked or untracked files; no findings will be reported"
378                .to_owned(),
379        );
380    }
381    diagnostics
382}
383
384/// Warning naming the count, adapters and suffixes of opt-in sources that were not analyzed.
385fn skipped_definitions_warning(skipped: &[source_adapter::SourceFile]) -> String {
386    let registrations: Vec<_> = source_adapter::SOURCE_ADAPTER_REGISTRY
387        .iter()
388        .filter(|entry| {
389            skipped
390                .iter()
391                .any(|file| entry.adapter.language() == file.language)
392        })
393        .collect();
394    let adapters: BTreeSet<_> = registrations
395        .iter()
396        .map(|entry| entry.adapter.name())
397        .collect();
398    let suffixes: Vec<_> = registrations
399        .iter()
400        .map(|entry| format!("`{}`", entry.suffix))
401        .collect();
402    format!(
403        "{} {} source file(s) were not analyzed: opt-in languages need a definition pattern that names their suffix ({}), such as `features/**/*{}`",
404        skipped.len(),
405        adapters.into_iter().collect::<Vec<_>>().join(", "),
406        suffixes.join(", "),
407        registrations.first().map_or("", |entry| entry.suffix),
408    )
409}
410
411/// Extracts definitions, then feature steps, into a corpus whose parameter-type language is that
412/// of the first discovered definition file.
413fn extract_corpus(
414    config: &Config,
415    files: &discovery::DiscoveredFiles,
416    changed_files: &Option<BTreeSet<PathBuf>>,
417    diagnostics: &mut Diagnostics,
418) -> ExtractedCorpus {
419    let mut corpus = ExtractedCorpus {
420        parameter_types: Vec::new(),
421        parameter_language: files.definitions.first().map(|file| file.language),
422        indirect_usage: source_adapter::IndirectStepUsage::default(),
423        definitions: Vec::new(),
424        feature_steps: Vec::new(),
425        excluded_sources: Vec::new(),
426        definition_files_with_definitions: 0,
427        parsed_feature_files: 0,
428        feature_files_without_steps: 0,
429        incomplete: false,
430    };
431    extract_definitions(config, files, changed_files, diagnostics, &mut corpus);
432    extract_feature_steps(config, files, diagnostics, &mut corpus);
433    corpus
434}
435
436/// Extracts definitions through one shared session that carries discovered edges and gaps.
437fn extract_definitions(
438    config: &Config,
439    files: &discovery::DiscoveredFiles,
440    changed_files: &Option<BTreeSet<PathBuf>>,
441    diagnostics: &mut Diagnostics,
442    corpus: &mut ExtractedCorpus,
443) {
444    if let Some(first) = files.definitions.first() {
445        let domain = first.language.comparison_domain();
446        if files
447            .definitions
448            .iter()
449            .any(|file| file.language.comparison_domain() != domain)
450        {
451            corpus.incomplete = true;
452            diagnostics.errors.push("Ruby and JS/TS sources require separate analysis runs with explicit definition and feature roots".to_owned());
453            return;
454        }
455    }
456    let mut extraction_session = source_adapter::SourceExtractionSession::with_options(
457        &config.root,
458        &config.registrations,
459        &config.assertion_modules,
460    )
461    .with_dependencies(&files.dependencies)
462    .with_dependency_gaps(&files.dependency_gaps);
463    let mut prepared = BTreeSet::new();
464    for file in &files.definitions {
465        let adapter = source_adapter::adapter_for_language(file.language);
466        if prepared.insert(adapter.name()) {
467            if let Err(error) = adapter.prepare_session(&files.definitions, &mut extraction_session)
468            {
469                corpus.incomplete = true;
470                diagnostics
471                    .errors
472                    .push(format!("failed to prepare source evidence: {error:#}"));
473                return;
474            }
475        }
476    }
477    for file in &files.definitions {
478        // Generated bundles, compressed payloads and binary blobs reach here whenever they carry
479        // a source extension. They cannot contain an authored definition, so they are excluded
480        // before parsing rather than analyzed and discarded.
481        let adapter = source_adapter::adapter_for_language(file.language);
482        if let Some(excluded) = adapter.inspect_source(file, &config.registrations) {
483            // Every one of these signals is a heuristic over file content, not a proof: a NUL
484            // byte or an archive header can appear inside an authored comment or string, and
485            // minified geometry cannot be told apart from one very long authored line. So the
486            // analyzed corpus no longer represents everything discovered, and
487            // `--fail-on-incomplete` must fail closed rather than let a duplicate pass the gate.
488            corpus.incomplete = true;
489            corpus.excluded_sources.push((file.path.clone(), excluded));
490            continue;
491        }
492        match adapter
493            .extract_file_with_session(file, &mut extraction_session)
494            .with_context(|| format!("failed to analyze {}", file.path.display()))
495        {
496            Ok(extracted) => {
497                if adapter.supports_indirect_usage() {
498                    corpus.indirect_usage.extend(extracted.indirect_usage);
499                    corpus.incomplete |= corpus.indirect_usage.unknown;
500                }
501                corpus.parameter_types.extend(extracted.parameter_types);
502                if !extracted.definitions.is_empty() {
503                    corpus.definition_files_with_definitions += 1;
504                }
505                corpus.definitions.extend(extracted.definitions);
506                collect_extraction_diagnostics(
507                    config,
508                    file,
509                    changed_files,
510                    extracted.diagnostics,
511                    diagnostics,
512                    &mut corpus.incomplete,
513                );
514            }
515            // Every discovered source contributes to cross-file definition comparisons, even
516            // when changed mode later filters findings. Skipping an unreadable source could turn
517            // an incomplete run into a false pass.
518            Err(error) => {
519                if adapter.supports_indirect_usage() {
520                    corpus.indirect_usage.unknown = true;
521                    corpus.incomplete = true;
522                }
523                diagnostics.errors.push(format!("{error:#}"));
524            }
525        }
526    }
527    let finalization = extraction_session.finalize(&files.definitions, &mut corpus.definitions);
528    collect_finalization(finalization, corpus, diagnostics);
529    corpus.definitions.sort_by(|left, right| {
530        left.location
531            .path
532            .cmp(&right.location.path)
533            .then(left.location.line.cmp(&right.location.line))
534            .then(left.location.column.cmp(&right.location.column))
535    });
536    report_excluded_sources(config, &corpus.excluded_sources, diagnostics);
537    if corpus.definitions.is_empty() {
538        let message = if files.definitions.is_empty() {
539            "no step definitions were extracted because no definition source files were discovered"
540                .to_owned()
541        } else {
542            format!(
543                "definition extraction produced 0 definitions from {} discovered definition source file(s)",
544                files.definitions.len()
545            )
546        };
547        if config.require_definitions {
548            diagnostics.errors.push(message);
549        } else if !files.definitions.is_empty() {
550            diagnostics.warnings.push(message);
551        }
552    }
553}
554
555/// Applies the session finalization: uncertainties mark the corpus incomplete, become warnings and
556/// recount files with definitions; an error clears every definition, marks the corpus incomplete
557/// and is recorded as an error.
558fn collect_finalization(
559    finalization: Result<source_adapter::SourceFinalization>,
560    corpus: &mut ExtractedCorpus,
561    diagnostics: &mut Diagnostics,
562) {
563    match finalization {
564        Ok(finalization) => {
565            if !finalization.uncertainties.is_empty() {
566                corpus.incomplete = true;
567                diagnostics.warnings.extend(
568                    finalization
569                        .uncertainties
570                        .into_iter()
571                        .map(|item| item.message),
572                );
573                corpus.definition_files_with_definitions = corpus
574                    .definitions
575                    .iter()
576                    .map(|definition| &definition.location.path)
577                    .collect::<BTreeSet<_>>()
578                    .len();
579            }
580        }
581        Err(error) => {
582            corpus.incomplete = true;
583            corpus.definitions.clear();
584            corpus.definition_files_with_definitions = 0;
585            diagnostics
586                .errors
587                .push(format!("failed to finalize source evidence: {error:#}"));
588        }
589    }
590}
591
592/// Maximum excluded paths named individually before the warning summarizes the remainder.
593const MAX_LISTED_EXCLUDED_SOURCES: usize = 10;
594
595/// Records excluded sources as a warning so an exclusion is never silent.
596///
597/// The exclusion is a heuristic over file content, so a reader has to be able to see which files
598/// it removed and why. It stays a warning rather than an error because generated output in a
599/// repository is ordinary and must not fail an otherwise clean run.
600fn report_excluded_sources(
601    config: &Config,
602    excluded: &[(PathBuf, source_adapter::SourceExclusion)],
603    diagnostics: &mut Diagnostics,
604) {
605    if excluded.is_empty() {
606        return;
607    }
608    let listed = excluded
609        .iter()
610        .take(MAX_LISTED_EXCLUDED_SOURCES)
611        .map(|(path, reason)| {
612            let relative = path
613                .strip_prefix(&config.root)
614                .unwrap_or(path)
615                .to_string_lossy()
616                .replace('\\', "/");
617            format!("{relative} ({})", reason.as_str())
618        })
619        .collect::<Vec<_>>()
620        .join(", ");
621    let remainder = excluded.len().saturating_sub(MAX_LISTED_EXCLUDED_SOURCES);
622    let suffix = if remainder == 0 {
623        String::new()
624    } else {
625        format!(", and {remainder} more")
626    };
627    diagnostics.warnings.push(format!(
628        "excluded {} discovered definition source file(s) as generated or non-source content: {listed}{suffix}",
629        excluded.len()
630    ));
631}
632
633/// Routes one file's extraction diagnostics. Completeness kinds mark the corpus incomplete;
634/// error-level diagnostics are errors; unparseable warnings become errors under
635/// `fail_on_unparseable`; other warnings from unchanged files in changed mode are dropped unless
636/// they affect completeness.
637fn collect_extraction_diagnostics(
638    config: &Config,
639    file: &source_adapter::SourceFile,
640    changed_files: &Option<BTreeSet<PathBuf>>,
641    extracted: Vec<source_adapter::ExtractionDiagnostic>,
642    diagnostics: &mut Diagnostics,
643    corpus_incomplete: &mut bool,
644) {
645    let changed_or_full_run = changed_files
646        .as_ref()
647        .is_none_or(|changed| changed.contains(&file.path));
648    for diagnostic in extracted {
649        let message = format!(
650            "{}: {}",
651            diagnostic.location.display(&config.root),
652            diagnostic.message
653        );
654        let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
655        let unparseable = source_adapter::is_unparseable_diagnostic(&diagnostic);
656        // An unresolved registration import — or a file that could not be parsed — hides every
657        // definition it would have introduced, so the corpus is incomplete regardless of which
658        // files changed.
659        *corpus_incomplete |= completeness;
660        match diagnostic.level {
661            // An unparseable file is tolerated by default: its error-recovered definitions still
662            // contribute and the corpus is marked incomplete above. `--fail-on-unparseable`
663            // restores the hard stop without also failing on other kinds of incompleteness.
664            ExtractionDiagnosticLevel::Warning if unparseable && config.fail_on_unparseable => {
665                diagnostics.errors.push(message);
666            }
667            ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
668                diagnostics.warnings.push(message);
669            }
670            ExtractionDiagnosticLevel::Warning => {}
671            // Extraction errors can mean definitions were omitted. They remain fatal for
672            // unchanged files because those definitions still participate in changed-file pairs.
673            ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
674        }
675    }
676}
677
678/// Parses each feature file; a parse failure is an error and marks the corpus incomplete, as does
679/// a Gherkin Markdown file with no steps (a warning). Unparsed files add an error under
680/// `require_features`, else a warning when definitions exist. Steps are sorted by location.
681fn extract_feature_steps(
682    config: &Config,
683    files: &discovery::DiscoveredFiles,
684    diagnostics: &mut Diagnostics,
685    corpus: &mut ExtractedCorpus,
686) {
687    for file in &files.features {
688        match gherkin::extract_file_with_format(&file.path, file.format) {
689            Ok(extracted) => {
690                corpus.parsed_feature_files += 1;
691                if extracted.is_empty()
692                    && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
693                {
694                    corpus.feature_files_without_steps += 1;
695                    corpus.incomplete = true;
696                    let display = file
697                        .path
698                        .strip_prefix(&config.root)
699                        .unwrap_or(&file.path)
700                        .to_string_lossy()
701                        .replace('\\', "/");
702                    diagnostics.warnings.push(format!(
703                        "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
704                    ));
705                } else {
706                    corpus.feature_steps.extend(extracted);
707                }
708            }
709            // Changed definitions still depend on the full feature corpus for usage and ambiguity.
710            Err(error) => {
711                corpus.incomplete = true;
712                diagnostics.errors.push(format!("{error:#}"));
713            }
714        }
715    }
716    if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
717        let message = if corpus.parsed_feature_files == 0 {
718            "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
719        } else {
720            format!(
721                "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
722                files.features.len() - corpus.parsed_feature_files
723            )
724        };
725        if config.require_features {
726            diagnostics.errors.push(message);
727        } else if !files.definitions.is_empty() {
728            diagnostics.warnings.push(message);
729        }
730    }
731    corpus.feature_steps.sort_by(|left, right| {
732        left.location
733            .path
734            .cmp(&right.location.path)
735            .then(left.location.line.cmp(&right.location.line))
736            .then(left.location.column.cmp(&right.location.column))
737    });
738}
739
740/// Resolves parameter types with the first definition language's adapter (their diagnostics mark
741/// the corpus incomplete), analyzes the corpus and warns on unmatched suppressions. Under
742/// `fail_on_incomplete`, analysis gaps and an incomplete corpus are errors; otherwise analysis
743/// gaps are warnings. Analysis failures are returned.
744fn analyze_corpus(
745    config: &Config,
746    extracted: ExtractedCorpus,
747    diagnostics: &mut Diagnostics,
748) -> Result<AnalyzedCorpus> {
749    let ExtractedCorpus {
750        parameter_types,
751        parameter_language,
752        indirect_usage,
753        definitions,
754        feature_steps,
755        // Reported during extraction; analysis works only from what survived exclusion.
756        excluded_sources: _,
757        definition_files_with_definitions,
758        parsed_feature_files,
759        feature_files_without_steps,
760        incomplete: mut corpus_incomplete,
761    } = extracted;
762    let mut effective_config = config.clone();
763    let (patterns, type_diagnostics) = match parameter_language {
764        Some(language) => source_adapter::adapter_for_language(language)
765            .resolve_parameter_types(parameter_types, &config.parameter_types),
766        None => (config.parameter_types.clone(), Vec::new()),
767    };
768    effective_config.parameter_types = patterns;
769    corpus_incomplete |= !type_diagnostics.is_empty();
770    diagnostics.warnings.extend(type_diagnostics);
771    let config = &effective_config;
772    let (analysis, census, unmatched_suppressions) =
773        analysis::analyze_for_cli(&indirect_usage, definitions, feature_steps, config)?;
774    for index in unmatched_suppressions.indices {
775        let suppression = &config.suppressions[index];
776        diagnostics.warnings.push(format!(
777            "suppression {} for {} matched no step definitions",
778            index + 1,
779            suppression.rule
780        ));
781    }
782    if unmatched_suppressions.truncated {
783        diagnostics.warnings.push(
784            "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
785                .to_owned(),
786        );
787    }
788
789    // Bounded work that could not finish makes findings a subset of a complete run, but every
790    // reported finding remains valid. `--fail-on-incomplete` restores strict CI gating.
791    let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
792    if config.fail_on_incomplete {
793        diagnostics.errors.extend(analysis.incomplete);
794        if corpus_incomplete {
795            diagnostics.errors.push(
796                "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
797            );
798        }
799    } else {
800        diagnostics.warnings.extend(analysis.incomplete);
801    }
802    Ok(AnalyzedCorpus {
803        result: analysis.result,
804        census,
805        definition_files_with_definitions,
806        parsed_feature_files,
807        feature_files_without_steps,
808        corpus_incomplete,
809        run_incomplete,
810    })
811}
812
813/// Drops unused-definition findings when a feature file failed to parse, a Gherkin Markdown file
814/// yielded no steps, or no feature file was found; then keeps only changed-file findings in
815/// changed mode.
816fn apply_finding_modes(
817    result: &mut crate::model::AnalysisResult,
818    files: &discovery::DiscoveredFiles,
819    changed_files: &Option<BTreeSet<PathBuf>>,
820    parsed_feature_files: usize,
821    feature_files_without_steps: usize,
822) {
823    // A converter can produce a syntactically valid but empty document when it fails to
824    // recognize markup or a dialect. Such an input cannot prove that a definition is unused,
825    // so use the same conservative suppression as a feature parse failure.
826    if parsed_feature_files < files.features.len()
827        || feature_files_without_steps > 0
828        || files.features.is_empty()
829    {
830        result
831            .findings
832            .retain(|finding| finding.rule != Rule::UnusedDefinition);
833    }
834    if let Some(changed) = changed_files {
835        modes::retain_changed_findings(&mut result.findings, changed);
836    }
837}
838
839/// Subtracts the findings of a baseline (a Git revision or a baseline file) from the current run.
840fn apply_baseline_mode(
841    config: &Config,
842    options: &CheckOptions,
843    result: &mut crate::model::AnalysisResult,
844    run_incomplete: bool,
845    diagnostics: &mut Diagnostics,
846) -> Result<Option<modes::BaselineOutcome>> {
847    if let Some(revision) = &options.baseline_from_ref {
848        let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
849        // Freeze the current policy instead of loading historical rule/config overrides. The
850        // baseline revision inherits the run's own strictness rather than being forced strict: an
851        // incomplete history (a vendored bundle excluded, an unparseable file, a bounded analysis)
852        // still yields a sound subtraction for the findings it did extract, so it is tolerated by
853        // default and reported. `--fail-on-incomplete` flows through `base_config` to surface that
854        // incompleteness as an error and reject the baseline.
855        let mut base_config = config.clone();
856        base_config.root = root;
857        let files = discovery::discover(&base_config)?;
858        let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
859        let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
860        let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics)?;
861        // A baseline that produced a hard error, or that has definitions but no features to anchor
862        // usage, cannot be subtracted at all — those always bail. A baseline that kept no
863        // definitions while the current tree has some cannot match any finding, so every current
864        // finding would appear new; that bails too, whatever emptied it. When both are empty there
865        // is nothing to compare and nothing can appear new, so the run follows the plain-run
866        // policy. Soft incompleteness only bails under `--fail-on-incomplete`, which routes through
867        // `base_diagnostics.errors` above.
868        if !files.definitions.is_empty()
869            && base.result.definitions.is_empty()
870            && !result.definitions.is_empty()
871        {
872            bail!(
873                "baseline revision `{revision}` is incomplete: it kept no step definitions while the current tree has {}, so no finding could be matched against it: {}",
874                result.definitions.len(),
875                base_diagnostics
876                    .errors
877                    .iter()
878                    .chain(&base_diagnostics.warnings)
879                    .cloned()
880                    .collect::<Vec<_>>()
881                    .join("; ")
882            );
883        }
884        if !base_diagnostics.errors.is_empty()
885            || (files.features.is_empty() && !base.result.definitions.is_empty())
886        {
887            bail!(
888                "baseline revision `{revision}` is incomplete: {}",
889                base_diagnostics
890                    .errors
891                    .iter()
892                    .chain(&base_diagnostics.warnings)
893                    .cloned()
894                    .collect::<Vec<_>>()
895                    .join("; ")
896            );
897        }
898        if base.run_incomplete {
899            diagnostics.warnings.push(format!(
900                "baseline revision `{revision}` is incomplete, so a finding it did not extract may appear as new; pass --fail-on-incomplete to reject an incomplete baseline"
901            ));
902        }
903        apply_finding_modes(
904            &mut base.result,
905            &files,
906            &None,
907            base.parsed_feature_files,
908            base.feature_files_without_steps,
909        );
910        return modes::apply_reference_baseline(
911            &mut result.findings,
912            &base.result.findings,
913            revision,
914        )
915        .map(Some);
916    }
917    let Some(path) = &options.baseline else {
918        return Ok(None);
919    };
920    let path = if path.is_absolute() {
921        path.clone()
922    } else {
923        config.root.join(path)
924    };
925    if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
926        diagnostics.warnings.push(format!(
927            "baseline {} was not updated because analysis is incomplete",
928            path.display()
929        ));
930        return Ok(None);
931    }
932    let outcome = if options.update_baseline {
933        modes::update_baseline(&mut result.findings, &path)?
934    } else {
935        modes::apply_baseline(&mut result.findings, &path)?
936    };
937    if options.update_baseline {
938        diagnostics.warnings.push(format!(
939            "updated baseline {}: {} added, {} removed, {} total",
940            path.display(),
941            outcome.added,
942            outcome.removed,
943            outcome.suppressed
944        ));
945    }
946    Ok(Some(outcome))
947}
948
949/// Writes the selected reports with corpus census and, unless `no_metrics`, timing metrics; the
950/// run counts as successful for SARIF only with no errors and a complete run.
951fn write_reports(
952    config: &Config,
953    files: &discovery::DiscoveredFiles,
954    analyzed: &AnalyzedCorpus,
955    diagnostics: &Diagnostics,
956    timings: PhaseTimings,
957) -> Result<()> {
958    let corpus = reporters::CorpusCensus {
959        definition_files: files.definitions.len(),
960        definition_files_with_definitions: analyzed.definition_files_with_definitions,
961        definitions_extracted: analyzed.result.definitions.len(),
962        feature_files: files.features.len(),
963        feature_files_parsed: analyzed.parsed_feature_files,
964        feature_files_without_steps: analyzed.feature_files_without_steps,
965        incomplete: analyzed.corpus_incomplete,
966    };
967    let metrics = reporters::ExecutionMetrics::new(
968        files.definitions.len(),
969        files.features.len(),
970        timings.discovery_ms,
971        timings.parsing_ms,
972        timings.analysis_ms,
973    );
974    let report_metadata = reporters::CliReportMetadata {
975        corpus: &corpus,
976        analysis: &analyzed.census,
977        // SARIF consumers treat a successful invocation as proof the tool covered its input.
978        execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
979    };
980    let terminal_color = terminal_color_enabled(
981        io::stdout().is_terminal(),
982        std::env::var_os("NO_COLOR").as_deref(),
983    );
984    let mut stdout = io::stdout().lock();
985    reporters::write_cli_reports(
986        &analyzed.result,
987        config,
988        (!config.no_metrics).then_some(&metrics),
989        &report_metadata,
990        &mut stdout,
991        terminal_color,
992    )?;
993    Ok(())
994}
995
996/// Whether terminal output uses color: stdout is a TTY and `NO_COLOR` is unset or empty.
997fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
998    is_terminal && !no_color.is_some_and(|value| !value.is_empty())
999}
1000
1001/// Writes `--explain-discovery` output, then warnings and errors, to stderr; writes nothing when
1002/// none apply.
1003fn write_diagnostics(
1004    config: &Config,
1005    options: &CheckOptions,
1006    files: &discovery::DiscoveredFiles,
1007    diagnostics: &Diagnostics,
1008) -> Result<()> {
1009    if !options.explain_discovery
1010        && diagnostics.warnings.is_empty()
1011        && diagnostics.errors.is_empty()
1012    {
1013        return Ok(());
1014    }
1015    let mut stderr = io::stderr().lock();
1016    if options.explain_discovery {
1017        writeln!(
1018            stderr,
1019            "cuke-dedup: feature patterns: {} ({})",
1020            config.features.join(", "),
1021            config.feature_pattern_origin
1022        )?;
1023        for file in &files.features {
1024            let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
1025            writeln!(
1026                stderr,
1027                "cuke-dedup: feature {} [{}] via {}",
1028                relative.display(),
1029                file.format.as_str(),
1030                file.pattern
1031            )?;
1032        }
1033        for file in &files.definitions {
1034            let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
1035            writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
1036        }
1037    }
1038    for warning in &diagnostics.warnings {
1039        writeln!(stderr, "cuke-dedup: warning: {warning}")?;
1040    }
1041    for error in &diagnostics.errors {
1042        writeln!(stderr, "cuke-dedup: {error}")?;
1043    }
1044    Ok(())
1045}
1046
1047/// 2 when any diagnostic error exists; 1 when an active error finding outside the duplication
1048/// threshold exists, the threshold fails, or new findings exceed `--fail-on-new`; otherwise 0.
1049fn determine_exit_code(
1050    config: &Config,
1051    options: &CheckOptions,
1052    result: &crate::model::AnalysisResult,
1053    baseline: Option<modes::BaselineOutcome>,
1054    diagnostics: &Diagnostics,
1055) -> Result<i32> {
1056    if !diagnostics.errors.is_empty() {
1057        return Ok(2);
1058    }
1059    let duplication = DuplicationThreshold::from_result(result, config.threshold);
1060    let has_non_duplication_errors = result.findings.iter().any(|finding| {
1061        finding.is_active()
1062            && finding.severity == Severity::Error
1063            && !finding.rule.contributes_to_duplication_threshold()
1064    });
1065    let new_findings_exceeded = options
1066        .fail_on_new
1067        .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
1068    Ok(i32::from(
1069        has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
1070    ))
1071}
1072
1073/// Milliseconds elapsed since `started`, with fractional precision.
1074fn elapsed_ms(started: Instant) -> f64 {
1075    started.elapsed().as_secs_f64() * 1_000.0
1076}
1077
1078/// Parses `RULE=SEVERITY` at the first `=`; a missing `=`, unknown rule or unknown severity fails.
1079fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
1080    let (rule, severity) = value
1081        .split_once('=')
1082        .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
1083    Ok((rule.parse()?, severity.parse()?))
1084}
1085
1086/// Parses an integer greater than zero; zero, negative and non-numeric input are rejected.
1087fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
1088    value
1089        .parse::<usize>()
1090        .ok()
1091        .filter(|value| *value > 0)
1092        .ok_or_else(|| "expected an integer greater than zero".to_owned())
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097    use super::*;
1098
1099    /// A clean finalization keeps definitions; a failed one clears them, zeroes the file count,
1100    /// records the error, and leaves an incomplete run with no findings.
1101    #[test]
1102    fn finalization_failure_discards_provisional_definitions_and_reports_incompleteness() {
1103        let root = tempfile::tempdir().unwrap();
1104        let config = Config::load(root.path(), ConfigOverrides::default()).unwrap();
1105        std::fs::write(root.path().join("steps.ts"), "import { Given, Then } from '@cucumber/cucumber'; Given('same', () => work()); Then('same', () => work());").unwrap();
1106        let files = discovery::discover(&config).unwrap();
1107        let mut diagnostics = discovery_diagnostics(&config, &files, &None);
1108        let mut corpus = extract_corpus(&config, &files, &None, &mut diagnostics);
1109        assert_eq!(corpus.definitions.len(), 2);
1110        let before = corpus.definitions.clone();
1111        collect_finalization(Ok(Default::default()), &mut corpus, &mut diagnostics);
1112        assert_eq!(corpus.definitions, before);
1113        collect_finalization(
1114            Err(anyhow::anyhow!("adapter unavailable")),
1115            &mut corpus,
1116            &mut diagnostics,
1117        );
1118        assert!(corpus.incomplete);
1119        assert!(corpus.definitions.is_empty());
1120        assert_eq!(corpus.definition_files_with_definitions, 0);
1121        assert!(diagnostics
1122            .errors
1123            .iter()
1124            .any(|e| e == "failed to finalize source evidence: adapter unavailable"));
1125        let analyzed = analyze_corpus(&config, corpus, &mut diagnostics).unwrap();
1126        assert!(analyzed.run_incomplete);
1127        assert!(analyzed.result.findings.is_empty());
1128    }
1129
1130    /// Color requires a TTY and is disabled by a non-empty `NO_COLOR`, but not by an empty one.
1131    #[test]
1132    fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
1133        use std::ffi::OsStr;
1134
1135        assert!(terminal_color_enabled(true, None));
1136        assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
1137        assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
1138        assert!(!terminal_color_enabled(false, None));
1139        assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
1140    }
1141
1142    /// A known rule and severity parse; an unknown rule name is rejected.
1143    #[test]
1144    fn parses_rule_override() {
1145        assert_eq!(
1146            parse_rule_override("duplicate-matcher=warning"),
1147            Ok((Rule::DuplicateMatcher, Severity::Warning))
1148        );
1149        assert!(parse_rule_override("unknown=error").is_err());
1150    }
1151
1152    /// Accepts a positive integer and rejects zero, a negative number and non-numeric text.
1153    #[test]
1154    fn candidate_limit_cli_values_must_be_positive() {
1155        assert_eq!(parse_positive_usize("7"), Ok(7));
1156        assert!(parse_positive_usize("0").is_err());
1157        assert!(parse_positive_usize("-1").is_err());
1158        assert!(parse_positive_usize("many").is_err());
1159    }
1160
1161    /// An unparseable warning is an incomplete warning by default and an error under the flag; a
1162    /// dynamic-matcher warning stays an incomplete warning, and an unrelated one stays complete.
1163    #[test]
1164    fn unparseable_diagnostics_are_tolerated_by_default_and_escalated_by_the_flag() {
1165        use crate::discovery::SourceLanguage;
1166        use crate::model::SourceLocation;
1167        use crate::source_adapter::{ExtractionDiagnostic, SourceFile};
1168
1169        let directory = tempfile::tempdir().unwrap();
1170        let file = SourceFile {
1171            path: directory.path().join("steps.ts"),
1172            language: SourceLanguage::TypeScript,
1173        };
1174        let unparseable = || {
1175            ExtractionDiagnostic::new(
1176                ExtractionDiagnosticLevel::Warning,
1177                SourceLocation::new("steps.ts", 1, 1, 1, 1),
1178                format!(
1179                    "{}, so analysis is incomplete",
1180                    source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX
1181                ),
1182            )
1183        };
1184        let route = |config: &Config, diagnostic: ExtractionDiagnostic| {
1185            let mut diagnostics = Diagnostics {
1186                warnings: Vec::new(),
1187                errors: Vec::new(),
1188            };
1189            let mut incomplete = false;
1190            collect_extraction_diagnostics(
1191                config,
1192                &file,
1193                &None,
1194                vec![diagnostic],
1195                &mut diagnostics,
1196                &mut incomplete,
1197            );
1198            (diagnostics, incomplete)
1199        };
1200
1201        let mut config =
1202            Config::load(directory.path(), ConfigOverrides::default()).expect("config loads");
1203
1204        // Default: an unparseable file is a completeness warning, not an error.
1205        let (diagnostics, incomplete) = route(&config, unparseable());
1206        assert!(incomplete);
1207        assert!(diagnostics.errors.is_empty());
1208        assert_eq!(diagnostics.warnings.len(), 1);
1209
1210        // `--fail-on-unparseable`: the same diagnostic becomes a hard error, still marking the
1211        // corpus incomplete.
1212        config.fail_on_unparseable = true;
1213        let (diagnostics, incomplete) = route(&config, unparseable());
1214        assert!(incomplete);
1215        assert_eq!(diagnostics.errors.len(), 1);
1216        assert!(diagnostics.warnings.is_empty());
1217
1218        // An unresolved matcher is incomplete, but is not a syntax error. The syntax-only
1219        // flag must leave this diagnostic at warning severity.
1220        let dynamic = ExtractionDiagnostic::new(
1221            ExtractionDiagnosticLevel::Warning,
1222            SourceLocation::new("steps.ts", 2, 1, 2, 1),
1223            "dynamic or unsupported step matcher cannot be analyzed statically",
1224        );
1225        let (diagnostics, incomplete) = route(&config, dynamic);
1226        assert!(incomplete);
1227        assert!(diagnostics.errors.is_empty());
1228        assert_eq!(diagnostics.warnings.len(), 1);
1229        let unrelated = ExtractionDiagnostic::new(
1230            ExtractionDiagnosticLevel::Warning,
1231            SourceLocation::new("steps.ts", 2, 1, 2, 1),
1232            "unrelated extractor warning",
1233        );
1234        let (diagnostics, incomplete) = route(&config, unrelated);
1235        assert!(!incomplete);
1236        assert!(diagnostics.errors.is_empty());
1237        assert_eq!(diagnostics.warnings.len(), 1);
1238    }
1239
1240    /// Routing follows the diagnostic kind, not its text: Incomplete and Unparseable kinds mark the
1241    /// corpus incomplete, Error level always errors, and Unparseable warnings error only in strict
1242    /// mode.
1243    #[test]
1244    fn typed_adapter_diagnostics_route_independently_of_language_and_wording() {
1245        use crate::model::SourceLocation;
1246        use crate::source_adapter::{
1247            ExtractionDiagnostic, ExtractionDiagnosticKind as Kind, SourceFile, SourceLanguage,
1248        };
1249        let directory = tempfile::tempdir().unwrap();
1250        let file = SourceFile {
1251            path: directory.path().join("steps.ts"),
1252            language: SourceLanguage::TypeScript,
1253        };
1254        let mut config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1255        for strict in [false, true] {
1256            config.fail_on_unparseable = strict;
1257            for (kind, message, incomplete, parse_error) in [
1258                (Kind::Other, "adapter advisory", false, false),
1259                (Kind::Incomplete, "external glue unavailable", true, false),
1260                (Kind::Unparseable, "adapter syntax failure", true, true),
1261                (
1262                    Kind::Other,
1263                    source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1264                    false,
1265                    false,
1266                ),
1267                (
1268                    Kind::Incomplete,
1269                    source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
1270                    true,
1271                    false,
1272                ),
1273            ] {
1274                for level in [
1275                    ExtractionDiagnosticLevel::Warning,
1276                    ExtractionDiagnosticLevel::Error,
1277                ] {
1278                    let mut diagnostics = Diagnostics {
1279                        warnings: vec![],
1280                        errors: vec![],
1281                    };
1282                    let mut actual_incomplete = false;
1283                    collect_extraction_diagnostics(
1284                        &config,
1285                        &file,
1286                        &None,
1287                        vec![ExtractionDiagnostic::with_kind(
1288                            kind,
1289                            level,
1290                            SourceLocation::new(&file.path, 1, 1, 1, 1),
1291                            message,
1292                        )],
1293                        &mut diagnostics,
1294                        &mut actual_incomplete,
1295                    );
1296                    assert_eq!(actual_incomplete, incomplete, "{kind:?} strict={strict}");
1297                    let error =
1298                        level == ExtractionDiagnosticLevel::Error || (strict && parse_error);
1299                    assert_eq!(diagnostics.errors.len(), usize::from(error));
1300                    assert_eq!(diagnostics.warnings.len(), usize::from(!error));
1301                }
1302            }
1303        }
1304    }
1305
1306    /// Both `cuke-dedup PATH` and `cuke-dedup check PATH` parse; `check` without a path fails.
1307    #[test]
1308    fn supports_implicit_and_explicit_check_forms() {
1309        let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
1310            .expect("implicit invocation");
1311        assert!(implicit.command.is_none());
1312        let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
1313            .expect("explicit invocation");
1314        assert!(matches!(explicit.command, Some(Command::Check { .. })));
1315        assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
1316    }
1317}