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 no step definitions are extracted.
77    #[arg(long, global = true)]
78    require_definitions: bool,
79
80    /// Maximum unique definition pairs retained, up to the hard safety ceiling.
81    #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
82    max_candidate_comparisons: Option<usize>,
83
84    /// Maximum structural proposals per handler class, up to the hard safety ceiling.
85    #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
86    max_structural_class_comparisons: Option<usize>,
87
88    /// Glob patterns selecting step-definition source files.
89    #[arg(long, global = true, value_delimiter = ',')]
90    definitions: Option<Vec<String>>,
91
92    /// Glob patterns selecting Gherkin feature files.
93    #[arg(long, global = true, value_delimiter = ',')]
94    features: Option<Vec<String>>,
95
96    /// Print the effective feature source and every discovered input.
97    #[arg(long, global = true)]
98    explain_discovery: bool,
99
100    /// Print the merged effective configuration as JSON and exit.
101    #[arg(long, global = true)]
102    print_config: bool,
103
104    /// Omit runtime timing metrics from machine reports for reproducible output.
105    #[arg(long, global = true)]
106    no_metrics: bool,
107
108    /// Glob patterns excluded from discovery.
109    #[arg(long, global = true, value_delimiter = ',')]
110    exclude: Option<Vec<String>>,
111
112    /// Disable built-in exclusions such as node_modules and target.
113    #[arg(long, global = true)]
114    no_default_excludes: bool,
115
116    /// Descend into dot-directories such as .steps.
117    #[arg(long, global = true)]
118    include_hidden: bool,
119
120    /// Override a rule severity, for example duplicate-matcher=warning.
121    #[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
122    rules: Vec<(Rule, Severity)>,
123
124    /// Report only findings involving files changed since this Git revision.
125    #[arg(long, global = true, value_name = "GIT_REF")]
126    changed_since: Option<String>,
127
128    /// Suppress findings already present in a versioned semantic baseline.
129    #[arg(
130        long,
131        global = true,
132        value_name = "BASELINE.json",
133        group = "baseline_source"
134    )]
135    baseline: Option<PathBuf>,
136
137    /// Compare with a fetched Git revision using the current analysis configuration.
138    #[arg(long, global = true, value_name = "GIT_REF", group = "baseline_source",
139        conflicts_with_all = ["baseline", "update_baseline"])]
140    baseline_from_ref: Option<String>,
141
142    /// Rewrite the configured baseline from the current complete analysis.
143    #[arg(
144        long,
145        global = true,
146        requires = "baseline",
147        conflicts_with = "changed_since"
148    )]
149    update_baseline: bool,
150
151    /// Fail when more than COUNT findings are new relative to the baseline.
152    #[arg(
153        long,
154        global = true,
155        value_name = "COUNT",
156        num_args = 0..=1,
157        default_missing_value = "0",
158        requires = "baseline_source",
159        conflicts_with = "update_baseline"
160    )]
161    fail_on_new: Option<usize>,
162}
163
164struct Invocation {
165    config: Config,
166    options: CheckOptions,
167}
168
169#[derive(Default)]
170struct Diagnostics {
171    warnings: Vec<String>,
172    errors: Vec<String>,
173}
174
175struct ExtractedCorpus {
176    definitions: Vec<crate::model::StepDefinition>,
177    feature_steps: Vec<crate::model::FeatureStep>,
178    definition_files_with_definitions: usize,
179    parsed_feature_files: usize,
180    feature_files_without_steps: usize,
181    incomplete: bool,
182}
183
184struct AnalyzedCorpus {
185    result: crate::model::AnalysisResult,
186    census: analysis::AnalysisCensus,
187    definition_files_with_definitions: usize,
188    parsed_feature_files: usize,
189    feature_files_without_steps: usize,
190    corpus_incomplete: bool,
191    run_incomplete: bool,
192}
193
194struct PhaseTimings {
195    discovery_ms: f64,
196    parsing_ms: f64,
197    analysis_ms: f64,
198}
199
200/// Parses the process arguments, runs the configured analysis, and returns its exit code.
201pub fn run_cli() -> Result<i32> {
202    execute(Cli::parse())
203}
204
205fn execute(cli: Cli) -> Result<i32> {
206    let invocation = resolve_invocation(cli)?;
207    let config = &invocation.config;
208    let options = &invocation.options;
209    if options.print_config {
210        println!(
211            "{}",
212            serde_json::to_string_pretty(config)
213                .context("failed to serialize effective configuration")?
214        );
215        return Ok(0);
216    }
217    let changed_files = resolve_changed_files(config, options.changed_since.as_deref())?;
218    let discovery_started = Instant::now();
219    let files = discovery::discover(config)?;
220    let discovery_ms = elapsed_ms(discovery_started);
221
222    let parsing_started = Instant::now();
223    let mut diagnostics = discovery_diagnostics(config, &files, &changed_files);
224    let extracted = extract_corpus(config, &files, &changed_files, &mut diagnostics);
225    let parsing_ms = elapsed_ms(parsing_started);
226    let analysis_started = Instant::now();
227    let mut analyzed = analyze_corpus(config, extracted, &mut diagnostics)?;
228    let analysis_ms = elapsed_ms(analysis_started);
229    apply_finding_modes(
230        &mut analyzed.result,
231        &files,
232        &changed_files,
233        analyzed.parsed_feature_files,
234        analyzed.feature_files_without_steps,
235    );
236    let baseline_outcome = apply_baseline_mode(
237        config,
238        options,
239        &mut analyzed.result,
240        analyzed.run_incomplete,
241        &mut diagnostics,
242    )?;
243    write_reports(
244        config,
245        &files,
246        &analyzed,
247        &diagnostics,
248        PhaseTimings {
249            discovery_ms,
250            parsing_ms,
251            analysis_ms,
252        },
253    )?;
254    write_diagnostics(config, options, &files, &diagnostics)?;
255    determine_exit_code(
256        config,
257        options,
258        &analyzed.result,
259        baseline_outcome,
260        &diagnostics,
261    )
262}
263
264fn resolve_invocation(cli: Cli) -> Result<Invocation> {
265    if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
266        bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
267    }
268    let root = match cli.command {
269        Some(Command::Check { path }) => path,
270        None => cli.path,
271    };
272    let options = cli.options.clone();
273    let config = Config::load_for_cli(
274        &root,
275        ConfigOverrides {
276            config_file: cli.options.config,
277            definitions: cli.options.definitions,
278            features: cli.options.features,
279            exclude: cli.options.exclude,
280            exclude_defaults: cli.options.no_default_excludes.then_some(false),
281            include_hidden: cli.options.include_hidden.then_some(true),
282            reporters: cli.options.reporters,
283            output: cli.options.output,
284            threshold: cli.options.threshold,
285            require_features: cli.options.require_features.then_some(true),
286            no_metrics: cli.options.no_metrics.then_some(true),
287            rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
288        },
289        CliConfigOverrides {
290            require_definitions: cli.options.require_definitions.then_some(true),
291            fail_on_incomplete: cli.options.fail_on_incomplete.then_some(true),
292            max_candidate_comparisons: cli.options.max_candidate_comparisons,
293            max_structural_class_comparisons: cli.options.max_structural_class_comparisons,
294        },
295    )?;
296    Ok(Invocation { config, options })
297}
298
299fn resolve_changed_files(
300    config: &Config,
301    changed_since: Option<&str>,
302) -> Result<Option<BTreeSet<PathBuf>>> {
303    let Some(base) = changed_since else {
304        return Ok(None);
305    };
306    modes::ensure_changed_root_is_trackable(&config.root)?;
307    Ok(Some(modes::git_changed_files(&config.root, base)?))
308}
309
310fn discovery_diagnostics(
311    config: &Config,
312    files: &discovery::DiscoveredFiles,
313    changed_files: &Option<BTreeSet<PathBuf>>,
314) -> Diagnostics {
315    let mut diagnostics = Diagnostics {
316        warnings: config.config_warnings.clone(),
317        errors: files.errors.clone(),
318    };
319    for pattern in &files.unmatched_feature_patterns {
320        diagnostics.warnings.push(format!(
321            "feature pattern `{pattern}` from {} matched no files",
322            config.feature_pattern_origin
323        ));
324    }
325    for pattern in &files.unmatched_definition_patterns {
326        diagnostics
327            .warnings
328            .push(format!("definition pattern `{pattern}` matched no files"));
329    }
330    if files.definitions.is_empty() && config.definitions.is_empty() {
331        diagnostics
332            .warnings
333            .push("automatic discovery found no supported step-definition source files".to_owned());
334    }
335    if files.features.is_empty() {
336        let message = format!(
337            "no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
338            config.feature_pattern_origin
339        );
340        if config.require_features {
341            diagnostics.errors.push(message);
342        } else if !files.definitions.is_empty() {
343            diagnostics.warnings.push(message);
344        }
345    }
346    if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
347        diagnostics.warnings.push(
348            "--changed-since found no tracked or untracked files; no findings will be reported"
349                .to_owned(),
350        );
351    }
352    diagnostics
353}
354
355fn extract_corpus(
356    config: &Config,
357    files: &discovery::DiscoveredFiles,
358    changed_files: &Option<BTreeSet<PathBuf>>,
359    diagnostics: &mut Diagnostics,
360) -> ExtractedCorpus {
361    let mut corpus = ExtractedCorpus {
362        definitions: Vec::new(),
363        feature_steps: Vec::new(),
364        definition_files_with_definitions: 0,
365        parsed_feature_files: 0,
366        feature_files_without_steps: 0,
367        incomplete: false,
368    };
369    extract_definitions(config, files, changed_files, diagnostics, &mut corpus);
370    extract_feature_steps(config, files, diagnostics, &mut corpus);
371    corpus
372}
373
374fn extract_definitions(
375    config: &Config,
376    files: &discovery::DiscoveredFiles,
377    changed_files: &Option<BTreeSet<PathBuf>>,
378    diagnostics: &mut Diagnostics,
379    corpus: &mut ExtractedCorpus,
380) {
381    let mut extraction_session = source_adapter::SourceExtractionSession::with_options(
382        &config.root,
383        &config.registrations,
384        &config.assertion_modules,
385    );
386    for file in &files.definitions {
387        let adapter = source_adapter::adapter_for_language(file.language);
388        match adapter
389            .extract_file_with_session(file, &mut extraction_session)
390            .with_context(|| format!("failed to analyze {}", file.path.display()))
391        {
392            Ok(extracted) => {
393                if !extracted.definitions.is_empty() {
394                    corpus.definition_files_with_definitions += 1;
395                }
396                corpus.definitions.extend(extracted.definitions);
397                collect_extraction_diagnostics(
398                    config,
399                    file,
400                    changed_files,
401                    extracted.diagnostics,
402                    diagnostics,
403                    &mut corpus.incomplete,
404                );
405            }
406            // Every discovered source contributes to cross-file definition comparisons, even
407            // when changed mode later filters findings. Skipping an unreadable source could turn
408            // an incomplete run into a false pass.
409            Err(error) => diagnostics.errors.push(format!("{error:#}")),
410        }
411    }
412    corpus.definitions.sort_by(|left, right| {
413        left.location
414            .path
415            .cmp(&right.location.path)
416            .then(left.location.line.cmp(&right.location.line))
417            .then(left.location.column.cmp(&right.location.column))
418    });
419    if corpus.definitions.is_empty() {
420        let message = if files.definitions.is_empty() {
421            "no step definitions were extracted because no definition source files were discovered"
422                .to_owned()
423        } else {
424            format!(
425                "definition extraction produced 0 definitions from {} discovered definition source file(s)",
426                files.definitions.len()
427            )
428        };
429        if config.require_definitions {
430            diagnostics.errors.push(message);
431        } else if !files.definitions.is_empty() {
432            diagnostics.warnings.push(message);
433        }
434    }
435}
436
437fn collect_extraction_diagnostics(
438    config: &Config,
439    file: &source_adapter::SourceFile,
440    changed_files: &Option<BTreeSet<PathBuf>>,
441    extracted: Vec<source_adapter::ExtractionDiagnostic>,
442    diagnostics: &mut Diagnostics,
443    corpus_incomplete: &mut bool,
444) {
445    let changed_or_full_run = changed_files
446        .as_ref()
447        .is_none_or(|changed| changed.contains(&file.path));
448    for diagnostic in extracted {
449        let message = format!(
450            "{}: {}",
451            diagnostic.location.display(&config.root),
452            diagnostic.message
453        );
454        let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
455        // An unresolved registration import hides every definition it would have introduced, so
456        // the corpus is incomplete regardless of which files changed.
457        *corpus_incomplete |= completeness;
458        match diagnostic.level {
459            ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
460                diagnostics.warnings.push(message);
461            }
462            ExtractionDiagnosticLevel::Warning => {}
463            // Extraction errors can mean definitions were omitted. They remain fatal for
464            // unchanged files because those definitions still participate in changed-file pairs.
465            ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
466        }
467    }
468}
469
470fn extract_feature_steps(
471    config: &Config,
472    files: &discovery::DiscoveredFiles,
473    diagnostics: &mut Diagnostics,
474    corpus: &mut ExtractedCorpus,
475) {
476    for file in &files.features {
477        match gherkin::extract_file_with_format(&file.path, file.format) {
478            Ok(extracted) => {
479                corpus.parsed_feature_files += 1;
480                if extracted.is_empty()
481                    && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
482                {
483                    corpus.feature_files_without_steps += 1;
484                    corpus.incomplete = true;
485                    let display = file
486                        .path
487                        .strip_prefix(&config.root)
488                        .unwrap_or(&file.path)
489                        .to_string_lossy()
490                        .replace('\\', "/");
491                    diagnostics.warnings.push(format!(
492                        "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
493                    ));
494                } else {
495                    corpus.feature_steps.extend(extracted);
496                }
497            }
498            // Changed definitions still depend on the full feature corpus for usage and ambiguity.
499            Err(error) => diagnostics.errors.push(format!("{error:#}")),
500        }
501    }
502    if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
503        let message = if corpus.parsed_feature_files == 0 {
504            "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
505        } else {
506            format!(
507                "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
508                files.features.len() - corpus.parsed_feature_files
509            )
510        };
511        if config.require_features {
512            diagnostics.errors.push(message);
513        } else if !files.definitions.is_empty() {
514            diagnostics.warnings.push(message);
515        }
516    }
517    corpus.feature_steps.sort_by(|left, right| {
518        left.location
519            .path
520            .cmp(&right.location.path)
521            .then(left.location.line.cmp(&right.location.line))
522            .then(left.location.column.cmp(&right.location.column))
523    });
524}
525
526fn analyze_corpus(
527    config: &Config,
528    extracted: ExtractedCorpus,
529    diagnostics: &mut Diagnostics,
530) -> Result<AnalyzedCorpus> {
531    let ExtractedCorpus {
532        definitions,
533        feature_steps,
534        definition_files_with_definitions,
535        parsed_feature_files,
536        feature_files_without_steps,
537        incomplete: corpus_incomplete,
538    } = extracted;
539    let (analysis, census, unmatched_suppressions) =
540        analysis::analyze_for_cli(definitions, feature_steps, config)?;
541    for index in unmatched_suppressions.indices {
542        let suppression = &config.suppressions[index];
543        diagnostics.warnings.push(format!(
544            "suppression {} for {} matched no step definitions",
545            index + 1,
546            suppression.rule
547        ));
548    }
549    if unmatched_suppressions.truncated {
550        diagnostics.warnings.push(
551            "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
552                .to_owned(),
553        );
554    }
555
556    // Bounded work that could not finish makes findings a subset of a complete run, but every
557    // reported finding remains valid. `--fail-on-incomplete` restores strict CI gating.
558    let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
559    if config.fail_on_incomplete {
560        diagnostics.errors.extend(analysis.incomplete);
561        if corpus_incomplete {
562            diagnostics.errors.push(
563                "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
564            );
565        }
566    } else {
567        diagnostics.warnings.extend(analysis.incomplete);
568    }
569    Ok(AnalyzedCorpus {
570        result: analysis.result,
571        census,
572        definition_files_with_definitions,
573        parsed_feature_files,
574        feature_files_without_steps,
575        corpus_incomplete,
576        run_incomplete,
577    })
578}
579
580fn apply_finding_modes(
581    result: &mut crate::model::AnalysisResult,
582    files: &discovery::DiscoveredFiles,
583    changed_files: &Option<BTreeSet<PathBuf>>,
584    parsed_feature_files: usize,
585    feature_files_without_steps: usize,
586) {
587    // A converter can produce a syntactically valid but empty document when it fails to
588    // recognize markup or a dialect. Such an input cannot prove that a definition is unused,
589    // so use the same conservative suppression as a feature parse failure.
590    if parsed_feature_files < files.features.len()
591        || feature_files_without_steps > 0
592        || files.features.is_empty()
593    {
594        result
595            .findings
596            .retain(|finding| finding.rule != Rule::UnusedDefinition);
597    }
598    if let Some(changed) = changed_files {
599        modes::retain_changed_findings(&mut result.findings, changed);
600    }
601}
602
603fn apply_baseline_mode(
604    config: &Config,
605    options: &CheckOptions,
606    result: &mut crate::model::AnalysisResult,
607    run_incomplete: bool,
608    diagnostics: &mut Diagnostics,
609) -> Result<Option<modes::BaselineOutcome>> {
610    if let Some(revision) = &options.baseline_from_ref {
611        let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
612        // Freeze the current policy instead of loading historical rule/config overrides.
613        let mut base_config = config.clone();
614        base_config.root = root;
615        base_config.fail_on_incomplete = true;
616        let files = discovery::discover(&base_config)?;
617        let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
618        let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
619        let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics)?;
620        if base.run_incomplete
621            || !base_diagnostics.errors.is_empty()
622            || (!files.definitions.is_empty() && base.result.definitions.is_empty())
623            || (files.features.is_empty() && !base.result.definitions.is_empty())
624        {
625            bail!(
626                "baseline revision `{revision}` is incomplete: {}",
627                base_diagnostics
628                    .errors
629                    .iter()
630                    .chain(&base_diagnostics.warnings)
631                    .cloned()
632                    .collect::<Vec<_>>()
633                    .join("; ")
634            );
635        }
636        apply_finding_modes(
637            &mut base.result,
638            &files,
639            &None,
640            base.parsed_feature_files,
641            base.feature_files_without_steps,
642        );
643        return modes::apply_reference_baseline(
644            &mut result.findings,
645            &base.result.findings,
646            revision,
647        )
648        .map(Some);
649    }
650    let Some(path) = &options.baseline else {
651        return Ok(None);
652    };
653    let path = if path.is_absolute() {
654        path.clone()
655    } else {
656        config.root.join(path)
657    };
658    if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
659        diagnostics.warnings.push(format!(
660            "baseline {} was not updated because analysis is incomplete",
661            path.display()
662        ));
663        return Ok(None);
664    }
665    let outcome = if options.update_baseline {
666        modes::update_baseline(&mut result.findings, &path)?
667    } else {
668        modes::apply_baseline(&mut result.findings, &path)?
669    };
670    if options.update_baseline {
671        diagnostics.warnings.push(format!(
672            "updated baseline {}: {} added, {} removed, {} total",
673            path.display(),
674            outcome.added,
675            outcome.removed,
676            outcome.suppressed
677        ));
678    }
679    Ok(Some(outcome))
680}
681
682fn write_reports(
683    config: &Config,
684    files: &discovery::DiscoveredFiles,
685    analyzed: &AnalyzedCorpus,
686    diagnostics: &Diagnostics,
687    timings: PhaseTimings,
688) -> Result<()> {
689    let corpus = reporters::CorpusCensus {
690        definition_files: files.definitions.len(),
691        definition_files_with_definitions: analyzed.definition_files_with_definitions,
692        definitions_extracted: analyzed.result.definitions.len(),
693        feature_files: files.features.len(),
694        feature_files_parsed: analyzed.parsed_feature_files,
695        feature_files_without_steps: analyzed.feature_files_without_steps,
696        incomplete: analyzed.corpus_incomplete,
697    };
698    let metrics = reporters::ExecutionMetrics::new(
699        files.definitions.len(),
700        files.features.len(),
701        timings.discovery_ms,
702        timings.parsing_ms,
703        timings.analysis_ms,
704    );
705    let report_metadata = reporters::CliReportMetadata {
706        corpus: &corpus,
707        analysis: &analyzed.census,
708        // SARIF consumers treat a successful invocation as proof the tool covered its input.
709        execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
710    };
711    let terminal_color = terminal_color_enabled(
712        io::stdout().is_terminal(),
713        std::env::var_os("NO_COLOR").as_deref(),
714    );
715    let mut stdout = io::stdout().lock();
716    reporters::write_cli_reports(
717        &analyzed.result,
718        config,
719        (!config.no_metrics).then_some(&metrics),
720        &report_metadata,
721        &mut stdout,
722        terminal_color,
723    )?;
724    Ok(())
725}
726
727fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
728    is_terminal && !no_color.is_some_and(|value| !value.is_empty())
729}
730
731fn write_diagnostics(
732    config: &Config,
733    options: &CheckOptions,
734    files: &discovery::DiscoveredFiles,
735    diagnostics: &Diagnostics,
736) -> Result<()> {
737    if !options.explain_discovery
738        && diagnostics.warnings.is_empty()
739        && diagnostics.errors.is_empty()
740    {
741        return Ok(());
742    }
743    let mut stderr = io::stderr().lock();
744    if options.explain_discovery {
745        writeln!(
746            stderr,
747            "cuke-dedup: feature patterns: {} ({})",
748            config.features.join(", "),
749            config.feature_pattern_origin
750        )?;
751        for file in &files.features {
752            let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
753            writeln!(
754                stderr,
755                "cuke-dedup: feature {} [{}] via {}",
756                relative.display(),
757                file.format.as_str(),
758                file.pattern
759            )?;
760        }
761        for file in &files.definitions {
762            let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
763            writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
764        }
765    }
766    for warning in &diagnostics.warnings {
767        writeln!(stderr, "cuke-dedup: warning: {warning}")?;
768    }
769    for error in &diagnostics.errors {
770        writeln!(stderr, "cuke-dedup: {error}")?;
771    }
772    Ok(())
773}
774
775fn determine_exit_code(
776    config: &Config,
777    options: &CheckOptions,
778    result: &crate::model::AnalysisResult,
779    baseline: Option<modes::BaselineOutcome>,
780    diagnostics: &Diagnostics,
781) -> Result<i32> {
782    if !diagnostics.errors.is_empty() {
783        return Ok(2);
784    }
785    let duplication = DuplicationThreshold::from_result(result, config.threshold);
786    let has_non_duplication_errors = result.findings.iter().any(|finding| {
787        finding.is_active()
788            && finding.severity == Severity::Error
789            && !finding.rule.contributes_to_duplication_threshold()
790    });
791    let new_findings_exceeded = options
792        .fail_on_new
793        .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
794    Ok(i32::from(
795        has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
796    ))
797}
798
799fn elapsed_ms(started: Instant) -> f64 {
800    started.elapsed().as_secs_f64() * 1_000.0
801}
802
803fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
804    let (rule, severity) = value
805        .split_once('=')
806        .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
807    Ok((rule.parse()?, severity.parse()?))
808}
809
810fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
811    value
812        .parse::<usize>()
813        .ok()
814        .filter(|value| *value > 0)
815        .ok_or_else(|| "expected an integer greater than zero".to_owned())
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    #[test]
823    fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
824        use std::ffi::OsStr;
825
826        assert!(terminal_color_enabled(true, None));
827        assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
828        assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
829        assert!(!terminal_color_enabled(false, None));
830        assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
831    }
832
833    #[test]
834    fn parses_rule_override() {
835        assert_eq!(
836            parse_rule_override("duplicate-matcher=warning"),
837            Ok((Rule::DuplicateMatcher, Severity::Warning))
838        );
839        assert!(parse_rule_override("unknown=error").is_err());
840    }
841
842    #[test]
843    fn candidate_limit_cli_values_must_be_positive() {
844        assert_eq!(parse_positive_usize("7"), Ok(7));
845        assert!(parse_positive_usize("0").is_err());
846        assert!(parse_positive_usize("-1").is_err());
847        assert!(parse_positive_usize("many").is_err());
848    }
849
850    #[test]
851    fn supports_implicit_and_explicit_check_forms() {
852        let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
853            .expect("implicit invocation");
854        assert!(implicit.command.is_none());
855        let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
856            .expect("explicit invocation");
857        assert!(matches!(explicit.command, Some(Command::Check { .. })));
858        assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
859    }
860}