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    definitions: Vec<crate::model::StepDefinition>,
183    feature_steps: Vec<crate::model::FeatureStep>,
184    excluded_sources: Vec<(PathBuf, crate::source_filter::ExcludedSource)>,
185    definition_files_with_definitions: usize,
186    parsed_feature_files: usize,
187    feature_files_without_steps: usize,
188    incomplete: bool,
189}
190
191struct AnalyzedCorpus {
192    result: crate::model::AnalysisResult,
193    census: analysis::AnalysisCensus,
194    definition_files_with_definitions: usize,
195    parsed_feature_files: usize,
196    feature_files_without_steps: usize,
197    corpus_incomplete: bool,
198    run_incomplete: bool,
199}
200
201struct PhaseTimings {
202    discovery_ms: f64,
203    parsing_ms: f64,
204    analysis_ms: f64,
205}
206
207/// Parses the process arguments, runs the configured analysis, and returns its exit code.
208pub fn run_cli() -> Result<i32> {
209    execute(Cli::parse())
210}
211
212fn execute(cli: Cli) -> Result<i32> {
213    let invocation = resolve_invocation(cli)?;
214    let config = &invocation.config;
215    let options = &invocation.options;
216    if options.print_config {
217        println!(
218            "{}",
219            serde_json::to_string_pretty(config)
220                .context("failed to serialize effective configuration")?
221        );
222        return Ok(0);
223    }
224    let changed_files = resolve_changed_files(config, options.changed_since.as_deref())?;
225    let discovery_started = Instant::now();
226    let files = discovery::discover(config)?;
227    let discovery_ms = elapsed_ms(discovery_started);
228
229    let parsing_started = Instant::now();
230    let mut diagnostics = discovery_diagnostics(config, &files, &changed_files);
231    let extracted = extract_corpus(config, &files, &changed_files, &mut diagnostics);
232    let parsing_ms = elapsed_ms(parsing_started);
233    let analysis_started = Instant::now();
234    let mut analyzed = analyze_corpus(config, extracted, &mut diagnostics)?;
235    let analysis_ms = elapsed_ms(analysis_started);
236    apply_finding_modes(
237        &mut analyzed.result,
238        &files,
239        &changed_files,
240        analyzed.parsed_feature_files,
241        analyzed.feature_files_without_steps,
242    );
243    let baseline_outcome = apply_baseline_mode(
244        config,
245        options,
246        &mut analyzed.result,
247        analyzed.run_incomplete,
248        &mut diagnostics,
249    )?;
250    write_reports(
251        config,
252        &files,
253        &analyzed,
254        &diagnostics,
255        PhaseTimings {
256            discovery_ms,
257            parsing_ms,
258            analysis_ms,
259        },
260    )?;
261    write_diagnostics(config, options, &files, &diagnostics)?;
262    determine_exit_code(
263        config,
264        options,
265        &analyzed.result,
266        baseline_outcome,
267        &diagnostics,
268    )
269}
270
271fn resolve_invocation(cli: Cli) -> Result<Invocation> {
272    if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
273        bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
274    }
275    let root = match cli.command {
276        Some(Command::Check { path }) => path,
277        None => cli.path,
278    };
279    let options = cli.options.clone();
280    let config = Config::load_for_cli(
281        &root,
282        ConfigOverrides {
283            config_file: cli.options.config,
284            definitions: cli.options.definitions,
285            features: cli.options.features,
286            exclude: cli.options.exclude,
287            exclude_defaults: cli.options.no_default_excludes.then_some(false),
288            include_hidden: cli.options.include_hidden.then_some(true),
289            reporters: cli.options.reporters,
290            output: cli.options.output,
291            threshold: cli.options.threshold,
292            require_features: cli.options.require_features.then_some(true),
293            no_metrics: cli.options.no_metrics.then_some(true),
294            rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
295        },
296        CliConfigOverrides {
297            require_definitions: cli.options.require_definitions.then_some(true),
298            fail_on_incomplete: cli.options.fail_on_incomplete.then_some(true),
299            fail_on_unparseable: cli.options.fail_on_unparseable.then_some(true),
300            max_candidate_comparisons: cli.options.max_candidate_comparisons,
301            max_structural_class_comparisons: cli.options.max_structural_class_comparisons,
302        },
303    )?;
304    Ok(Invocation { config, options })
305}
306
307fn resolve_changed_files(
308    config: &Config,
309    changed_since: Option<&str>,
310) -> Result<Option<BTreeSet<PathBuf>>> {
311    let Some(base) = changed_since else {
312        return Ok(None);
313    };
314    modes::ensure_changed_root_is_trackable(&config.root)?;
315    Ok(Some(modes::git_changed_files(&config.root, base)?))
316}
317
318fn discovery_diagnostics(
319    config: &Config,
320    files: &discovery::DiscoveredFiles,
321    changed_files: &Option<BTreeSet<PathBuf>>,
322) -> Diagnostics {
323    let mut diagnostics = Diagnostics {
324        warnings: config.config_warnings.clone(),
325        errors: files.errors.clone(),
326    };
327    for pattern in &files.unmatched_feature_patterns {
328        diagnostics.warnings.push(format!(
329            "feature pattern `{pattern}` from {} matched no files",
330            config.feature_pattern_origin
331        ));
332    }
333    for pattern in &files.unmatched_definition_patterns {
334        diagnostics
335            .warnings
336            .push(format!("definition pattern `{pattern}` matched no files"));
337    }
338    if files.definitions.is_empty() && config.definitions.is_empty() {
339        diagnostics
340            .warnings
341            .push("automatic discovery found no supported step-definition source files".to_owned());
342    }
343    if files.features.is_empty() {
344        let message = format!(
345            "no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
346            config.feature_pattern_origin
347        );
348        if config.require_features {
349            diagnostics.errors.push(message);
350        } else if !files.definitions.is_empty() {
351            diagnostics.warnings.push(message);
352        }
353    }
354    if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
355        diagnostics.warnings.push(
356            "--changed-since found no tracked or untracked files; no findings will be reported"
357                .to_owned(),
358        );
359    }
360    diagnostics
361}
362
363fn extract_corpus(
364    config: &Config,
365    files: &discovery::DiscoveredFiles,
366    changed_files: &Option<BTreeSet<PathBuf>>,
367    diagnostics: &mut Diagnostics,
368) -> ExtractedCorpus {
369    let mut corpus = ExtractedCorpus {
370        definitions: Vec::new(),
371        feature_steps: Vec::new(),
372        excluded_sources: Vec::new(),
373        definition_files_with_definitions: 0,
374        parsed_feature_files: 0,
375        feature_files_without_steps: 0,
376        incomplete: false,
377    };
378    extract_definitions(config, files, changed_files, diagnostics, &mut corpus);
379    extract_feature_steps(config, files, diagnostics, &mut corpus);
380    corpus
381}
382
383fn extract_definitions(
384    config: &Config,
385    files: &discovery::DiscoveredFiles,
386    changed_files: &Option<BTreeSet<PathBuf>>,
387    diagnostics: &mut Diagnostics,
388    corpus: &mut ExtractedCorpus,
389) {
390    let mut extraction_session = source_adapter::SourceExtractionSession::with_options(
391        &config.root,
392        &config.registrations,
393        &config.assertion_modules,
394    );
395    for file in &files.definitions {
396        // Generated bundles, compressed payloads and binary blobs reach here whenever they carry
397        // a source extension. They cannot contain an authored definition, so they are excluded
398        // before parsing rather than analyzed and discarded.
399        if let Some(excluded) = crate::source_filter::inspect(&file.path, &config.registrations) {
400            // Every one of these signals is a heuristic over file content, not a proof: a NUL
401            // byte or an archive header can appear inside an authored comment or string, and
402            // minified geometry cannot be told apart from one very long authored line. So the
403            // analyzed corpus no longer represents everything discovered, and
404            // `--fail-on-incomplete` must fail closed rather than let a duplicate pass the gate.
405            corpus.incomplete = true;
406            corpus.excluded_sources.push((file.path.clone(), excluded));
407            continue;
408        }
409        let adapter = source_adapter::adapter_for_language(file.language);
410        match adapter
411            .extract_file_with_session(file, &mut extraction_session)
412            .with_context(|| format!("failed to analyze {}", file.path.display()))
413        {
414            Ok(extracted) => {
415                if !extracted.definitions.is_empty() {
416                    corpus.definition_files_with_definitions += 1;
417                }
418                corpus.definitions.extend(extracted.definitions);
419                collect_extraction_diagnostics(
420                    config,
421                    file,
422                    changed_files,
423                    extracted.diagnostics,
424                    diagnostics,
425                    &mut corpus.incomplete,
426                );
427            }
428            // Every discovered source contributes to cross-file definition comparisons, even
429            // when changed mode later filters findings. Skipping an unreadable source could turn
430            // an incomplete run into a false pass.
431            Err(error) => diagnostics.errors.push(format!("{error:#}")),
432        }
433    }
434    corpus.definitions.sort_by(|left, right| {
435        left.location
436            .path
437            .cmp(&right.location.path)
438            .then(left.location.line.cmp(&right.location.line))
439            .then(left.location.column.cmp(&right.location.column))
440    });
441    report_excluded_sources(config, &corpus.excluded_sources, diagnostics);
442    if corpus.definitions.is_empty() {
443        let message = if files.definitions.is_empty() {
444            "no step definitions were extracted because no definition source files were discovered"
445                .to_owned()
446        } else {
447            format!(
448                "definition extraction produced 0 definitions from {} discovered definition source file(s)",
449                files.definitions.len()
450            )
451        };
452        if config.require_definitions {
453            diagnostics.errors.push(message);
454        } else if !files.definitions.is_empty() {
455            diagnostics.warnings.push(message);
456        }
457    }
458}
459
460/// Maximum excluded paths named individually before the warning summarizes the remainder.
461const MAX_LISTED_EXCLUDED_SOURCES: usize = 10;
462
463/// Records excluded sources as a warning so an exclusion is never silent.
464///
465/// The exclusion is a heuristic over file content, so a reader has to be able to see which files
466/// it removed and why. It stays a warning rather than an error because generated output in a
467/// repository is ordinary and must not fail an otherwise clean run.
468fn report_excluded_sources(
469    config: &Config,
470    excluded: &[(PathBuf, crate::source_filter::ExcludedSource)],
471    diagnostics: &mut Diagnostics,
472) {
473    if excluded.is_empty() {
474        return;
475    }
476    let listed = excluded
477        .iter()
478        .take(MAX_LISTED_EXCLUDED_SOURCES)
479        .map(|(path, reason)| {
480            let relative = path
481                .strip_prefix(&config.root)
482                .unwrap_or(path)
483                .to_string_lossy()
484                .replace('\\', "/");
485            format!("{relative} ({})", reason.as_str())
486        })
487        .collect::<Vec<_>>()
488        .join(", ");
489    let remainder = excluded.len().saturating_sub(MAX_LISTED_EXCLUDED_SOURCES);
490    let suffix = if remainder == 0 {
491        String::new()
492    } else {
493        format!(", and {remainder} more")
494    };
495    diagnostics.warnings.push(format!(
496        "excluded {} discovered definition source file(s) as generated or non-source content: {listed}{suffix}",
497        excluded.len()
498    ));
499}
500
501fn collect_extraction_diagnostics(
502    config: &Config,
503    file: &source_adapter::SourceFile,
504    changed_files: &Option<BTreeSet<PathBuf>>,
505    extracted: Vec<source_adapter::ExtractionDiagnostic>,
506    diagnostics: &mut Diagnostics,
507    corpus_incomplete: &mut bool,
508) {
509    let changed_or_full_run = changed_files
510        .as_ref()
511        .is_none_or(|changed| changed.contains(&file.path));
512    for diagnostic in extracted {
513        let message = format!(
514            "{}: {}",
515            diagnostic.location.display(&config.root),
516            diagnostic.message
517        );
518        let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
519        let unparseable = source_adapter::is_unparseable_diagnostic(&diagnostic);
520        // An unresolved registration import — or a file that could not be parsed — hides every
521        // definition it would have introduced, so the corpus is incomplete regardless of which
522        // files changed.
523        *corpus_incomplete |= completeness;
524        match diagnostic.level {
525            // An unparseable file is tolerated by default: its error-recovered definitions still
526            // contribute and the corpus is marked incomplete above. `--fail-on-unparseable`
527            // restores the hard stop without also failing on other kinds of incompleteness.
528            ExtractionDiagnosticLevel::Warning if unparseable && config.fail_on_unparseable => {
529                diagnostics.errors.push(message);
530            }
531            ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
532                diagnostics.warnings.push(message);
533            }
534            ExtractionDiagnosticLevel::Warning => {}
535            // Extraction errors can mean definitions were omitted. They remain fatal for
536            // unchanged files because those definitions still participate in changed-file pairs.
537            ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
538        }
539    }
540}
541
542fn extract_feature_steps(
543    config: &Config,
544    files: &discovery::DiscoveredFiles,
545    diagnostics: &mut Diagnostics,
546    corpus: &mut ExtractedCorpus,
547) {
548    for file in &files.features {
549        match gherkin::extract_file_with_format(&file.path, file.format) {
550            Ok(extracted) => {
551                corpus.parsed_feature_files += 1;
552                if extracted.is_empty()
553                    && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
554                {
555                    corpus.feature_files_without_steps += 1;
556                    corpus.incomplete = true;
557                    let display = file
558                        .path
559                        .strip_prefix(&config.root)
560                        .unwrap_or(&file.path)
561                        .to_string_lossy()
562                        .replace('\\', "/");
563                    diagnostics.warnings.push(format!(
564                        "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
565                    ));
566                } else {
567                    corpus.feature_steps.extend(extracted);
568                }
569            }
570            // Changed definitions still depend on the full feature corpus for usage and ambiguity.
571            Err(error) => diagnostics.errors.push(format!("{error:#}")),
572        }
573    }
574    if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
575        let message = if corpus.parsed_feature_files == 0 {
576            "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
577        } else {
578            format!(
579                "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
580                files.features.len() - corpus.parsed_feature_files
581            )
582        };
583        if config.require_features {
584            diagnostics.errors.push(message);
585        } else if !files.definitions.is_empty() {
586            diagnostics.warnings.push(message);
587        }
588    }
589    corpus.feature_steps.sort_by(|left, right| {
590        left.location
591            .path
592            .cmp(&right.location.path)
593            .then(left.location.line.cmp(&right.location.line))
594            .then(left.location.column.cmp(&right.location.column))
595    });
596}
597
598fn analyze_corpus(
599    config: &Config,
600    extracted: ExtractedCorpus,
601    diagnostics: &mut Diagnostics,
602) -> Result<AnalyzedCorpus> {
603    let ExtractedCorpus {
604        definitions,
605        feature_steps,
606        // Reported during extraction; analysis works only from what survived exclusion.
607        excluded_sources: _,
608        definition_files_with_definitions,
609        parsed_feature_files,
610        feature_files_without_steps,
611        incomplete: corpus_incomplete,
612    } = extracted;
613    let (analysis, census, unmatched_suppressions) =
614        analysis::analyze_for_cli(definitions, feature_steps, config)?;
615    for index in unmatched_suppressions.indices {
616        let suppression = &config.suppressions[index];
617        diagnostics.warnings.push(format!(
618            "suppression {} for {} matched no step definitions",
619            index + 1,
620            suppression.rule
621        ));
622    }
623    if unmatched_suppressions.truncated {
624        diagnostics.warnings.push(
625            "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
626                .to_owned(),
627        );
628    }
629
630    // Bounded work that could not finish makes findings a subset of a complete run, but every
631    // reported finding remains valid. `--fail-on-incomplete` restores strict CI gating.
632    let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
633    if config.fail_on_incomplete {
634        diagnostics.errors.extend(analysis.incomplete);
635        if corpus_incomplete {
636            diagnostics.errors.push(
637                "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
638            );
639        }
640    } else {
641        diagnostics.warnings.extend(analysis.incomplete);
642    }
643    Ok(AnalyzedCorpus {
644        result: analysis.result,
645        census,
646        definition_files_with_definitions,
647        parsed_feature_files,
648        feature_files_without_steps,
649        corpus_incomplete,
650        run_incomplete,
651    })
652}
653
654fn apply_finding_modes(
655    result: &mut crate::model::AnalysisResult,
656    files: &discovery::DiscoveredFiles,
657    changed_files: &Option<BTreeSet<PathBuf>>,
658    parsed_feature_files: usize,
659    feature_files_without_steps: usize,
660) {
661    // A converter can produce a syntactically valid but empty document when it fails to
662    // recognize markup or a dialect. Such an input cannot prove that a definition is unused,
663    // so use the same conservative suppression as a feature parse failure.
664    if parsed_feature_files < files.features.len()
665        || feature_files_without_steps > 0
666        || files.features.is_empty()
667    {
668        result
669            .findings
670            .retain(|finding| finding.rule != Rule::UnusedDefinition);
671    }
672    if let Some(changed) = changed_files {
673        modes::retain_changed_findings(&mut result.findings, changed);
674    }
675}
676
677fn apply_baseline_mode(
678    config: &Config,
679    options: &CheckOptions,
680    result: &mut crate::model::AnalysisResult,
681    run_incomplete: bool,
682    diagnostics: &mut Diagnostics,
683) -> Result<Option<modes::BaselineOutcome>> {
684    if let Some(revision) = &options.baseline_from_ref {
685        let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
686        // Freeze the current policy instead of loading historical rule/config overrides. The
687        // baseline revision inherits the run's own strictness rather than being forced strict: an
688        // incomplete history (a vendored bundle excluded, an unparseable file, a bounded analysis)
689        // still yields a sound subtraction for the findings it did extract, so it is tolerated by
690        // default and reported. `--fail-on-incomplete` flows through `base_config` to surface that
691        // incompleteness as an error and reject the baseline.
692        let mut base_config = config.clone();
693        base_config.root = root;
694        let files = discovery::discover(&base_config)?;
695        let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
696        let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
697        let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics)?;
698        // A baseline that produced a hard error, or that discovered definition files yet extracted
699        // no definitions, or that has no features to anchor usage, cannot be subtracted at all —
700        // those always bail. Soft incompleteness only bails under `--fail-on-incomplete`, which
701        // routes through `base_diagnostics.errors` above.
702        if !base_diagnostics.errors.is_empty()
703            || (!files.definitions.is_empty() && base.result.definitions.is_empty())
704            || (files.features.is_empty() && !base.result.definitions.is_empty())
705        {
706            bail!(
707                "baseline revision `{revision}` is incomplete: {}",
708                base_diagnostics
709                    .errors
710                    .iter()
711                    .chain(&base_diagnostics.warnings)
712                    .cloned()
713                    .collect::<Vec<_>>()
714                    .join("; ")
715            );
716        }
717        if base.run_incomplete {
718            diagnostics.warnings.push(format!(
719                "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"
720            ));
721        }
722        apply_finding_modes(
723            &mut base.result,
724            &files,
725            &None,
726            base.parsed_feature_files,
727            base.feature_files_without_steps,
728        );
729        return modes::apply_reference_baseline(
730            &mut result.findings,
731            &base.result.findings,
732            revision,
733        )
734        .map(Some);
735    }
736    let Some(path) = &options.baseline else {
737        return Ok(None);
738    };
739    let path = if path.is_absolute() {
740        path.clone()
741    } else {
742        config.root.join(path)
743    };
744    if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
745        diagnostics.warnings.push(format!(
746            "baseline {} was not updated because analysis is incomplete",
747            path.display()
748        ));
749        return Ok(None);
750    }
751    let outcome = if options.update_baseline {
752        modes::update_baseline(&mut result.findings, &path)?
753    } else {
754        modes::apply_baseline(&mut result.findings, &path)?
755    };
756    if options.update_baseline {
757        diagnostics.warnings.push(format!(
758            "updated baseline {}: {} added, {} removed, {} total",
759            path.display(),
760            outcome.added,
761            outcome.removed,
762            outcome.suppressed
763        ));
764    }
765    Ok(Some(outcome))
766}
767
768fn write_reports(
769    config: &Config,
770    files: &discovery::DiscoveredFiles,
771    analyzed: &AnalyzedCorpus,
772    diagnostics: &Diagnostics,
773    timings: PhaseTimings,
774) -> Result<()> {
775    let corpus = reporters::CorpusCensus {
776        definition_files: files.definitions.len(),
777        definition_files_with_definitions: analyzed.definition_files_with_definitions,
778        definitions_extracted: analyzed.result.definitions.len(),
779        feature_files: files.features.len(),
780        feature_files_parsed: analyzed.parsed_feature_files,
781        feature_files_without_steps: analyzed.feature_files_without_steps,
782        incomplete: analyzed.corpus_incomplete,
783    };
784    let metrics = reporters::ExecutionMetrics::new(
785        files.definitions.len(),
786        files.features.len(),
787        timings.discovery_ms,
788        timings.parsing_ms,
789        timings.analysis_ms,
790    );
791    let report_metadata = reporters::CliReportMetadata {
792        corpus: &corpus,
793        analysis: &analyzed.census,
794        // SARIF consumers treat a successful invocation as proof the tool covered its input.
795        execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
796    };
797    let terminal_color = terminal_color_enabled(
798        io::stdout().is_terminal(),
799        std::env::var_os("NO_COLOR").as_deref(),
800    );
801    let mut stdout = io::stdout().lock();
802    reporters::write_cli_reports(
803        &analyzed.result,
804        config,
805        (!config.no_metrics).then_some(&metrics),
806        &report_metadata,
807        &mut stdout,
808        terminal_color,
809    )?;
810    Ok(())
811}
812
813fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
814    is_terminal && !no_color.is_some_and(|value| !value.is_empty())
815}
816
817fn write_diagnostics(
818    config: &Config,
819    options: &CheckOptions,
820    files: &discovery::DiscoveredFiles,
821    diagnostics: &Diagnostics,
822) -> Result<()> {
823    if !options.explain_discovery
824        && diagnostics.warnings.is_empty()
825        && diagnostics.errors.is_empty()
826    {
827        return Ok(());
828    }
829    let mut stderr = io::stderr().lock();
830    if options.explain_discovery {
831        writeln!(
832            stderr,
833            "cuke-dedup: feature patterns: {} ({})",
834            config.features.join(", "),
835            config.feature_pattern_origin
836        )?;
837        for file in &files.features {
838            let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
839            writeln!(
840                stderr,
841                "cuke-dedup: feature {} [{}] via {}",
842                relative.display(),
843                file.format.as_str(),
844                file.pattern
845            )?;
846        }
847        for file in &files.definitions {
848            let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
849            writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
850        }
851    }
852    for warning in &diagnostics.warnings {
853        writeln!(stderr, "cuke-dedup: warning: {warning}")?;
854    }
855    for error in &diagnostics.errors {
856        writeln!(stderr, "cuke-dedup: {error}")?;
857    }
858    Ok(())
859}
860
861fn determine_exit_code(
862    config: &Config,
863    options: &CheckOptions,
864    result: &crate::model::AnalysisResult,
865    baseline: Option<modes::BaselineOutcome>,
866    diagnostics: &Diagnostics,
867) -> Result<i32> {
868    if !diagnostics.errors.is_empty() {
869        return Ok(2);
870    }
871    let duplication = DuplicationThreshold::from_result(result, config.threshold);
872    let has_non_duplication_errors = result.findings.iter().any(|finding| {
873        finding.is_active()
874            && finding.severity == Severity::Error
875            && !finding.rule.contributes_to_duplication_threshold()
876    });
877    let new_findings_exceeded = options
878        .fail_on_new
879        .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
880    Ok(i32::from(
881        has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
882    ))
883}
884
885fn elapsed_ms(started: Instant) -> f64 {
886    started.elapsed().as_secs_f64() * 1_000.0
887}
888
889fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
890    let (rule, severity) = value
891        .split_once('=')
892        .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
893    Ok((rule.parse()?, severity.parse()?))
894}
895
896fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
897    value
898        .parse::<usize>()
899        .ok()
900        .filter(|value| *value > 0)
901        .ok_or_else(|| "expected an integer greater than zero".to_owned())
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907
908    #[test]
909    fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
910        use std::ffi::OsStr;
911
912        assert!(terminal_color_enabled(true, None));
913        assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
914        assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
915        assert!(!terminal_color_enabled(false, None));
916        assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
917    }
918
919    #[test]
920    fn parses_rule_override() {
921        assert_eq!(
922            parse_rule_override("duplicate-matcher=warning"),
923            Ok((Rule::DuplicateMatcher, Severity::Warning))
924        );
925        assert!(parse_rule_override("unknown=error").is_err());
926    }
927
928    #[test]
929    fn candidate_limit_cli_values_must_be_positive() {
930        assert_eq!(parse_positive_usize("7"), Ok(7));
931        assert!(parse_positive_usize("0").is_err());
932        assert!(parse_positive_usize("-1").is_err());
933        assert!(parse_positive_usize("many").is_err());
934    }
935
936    #[test]
937    fn unparseable_diagnostics_are_tolerated_by_default_and_escalated_by_the_flag() {
938        use crate::discovery::SourceLanguage;
939        use crate::model::SourceLocation;
940        use crate::source_adapter::{ExtractionDiagnostic, SourceFile};
941
942        let directory = tempfile::tempdir().unwrap();
943        let file = SourceFile {
944            path: directory.path().join("steps.ts"),
945            language: SourceLanguage::TypeScript,
946        };
947        let unparseable = || {
948            ExtractionDiagnostic::new(
949                ExtractionDiagnosticLevel::Warning,
950                SourceLocation::new("steps.ts", 1, 1, 1, 1),
951                format!(
952                    "{}, so analysis is incomplete",
953                    source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX
954                ),
955            )
956        };
957        let route = |config: &Config, diagnostic: ExtractionDiagnostic| {
958            let mut diagnostics = Diagnostics {
959                warnings: Vec::new(),
960                errors: Vec::new(),
961            };
962            let mut incomplete = false;
963            collect_extraction_diagnostics(
964                config,
965                &file,
966                &None,
967                vec![diagnostic],
968                &mut diagnostics,
969                &mut incomplete,
970            );
971            (diagnostics, incomplete)
972        };
973
974        let mut config =
975            Config::load(directory.path(), ConfigOverrides::default()).expect("config loads");
976
977        // Default: an unparseable file is a completeness warning, not an error.
978        let (diagnostics, incomplete) = route(&config, unparseable());
979        assert!(incomplete);
980        assert!(diagnostics.errors.is_empty());
981        assert_eq!(diagnostics.warnings.len(), 1);
982
983        // `--fail-on-unparseable`: the same diagnostic becomes a hard error, still marking the
984        // corpus incomplete.
985        config.fail_on_unparseable = true;
986        let (diagnostics, incomplete) = route(&config, unparseable());
987        assert!(incomplete);
988        assert_eq!(diagnostics.errors.len(), 1);
989        assert!(diagnostics.warnings.is_empty());
990
991        // Opposite-answer control: an unrelated warning is neither escalated by the flag nor
992        // treated as a completeness signal, so the flag targets only the unparseable case.
993        let dynamic = ExtractionDiagnostic::new(
994            ExtractionDiagnosticLevel::Warning,
995            SourceLocation::new("steps.ts", 2, 1, 2, 1),
996            "dynamic or unsupported step matcher cannot be analyzed statically",
997        );
998        let (diagnostics, incomplete) = route(&config, dynamic);
999        assert!(!incomplete);
1000        assert!(diagnostics.errors.is_empty());
1001        assert_eq!(diagnostics.warnings.len(), 1);
1002    }
1003
1004    #[test]
1005    fn supports_implicit_and_explicit_check_forms() {
1006        let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
1007            .expect("implicit invocation");
1008        assert!(implicit.command.is_none());
1009        let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
1010            .expect("explicit invocation");
1011        assert!(matches!(explicit.command, Some(Command::Check { .. })));
1012        assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
1013    }
1014}