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