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_registrations(
382        &config.root,
383        &config.registrations,
384    );
385    for file in &files.definitions {
386        let adapter = source_adapter::adapter_for_language(file.language);
387        match adapter
388            .extract_file_with_session(file, &mut extraction_session)
389            .with_context(|| format!("failed to analyze {}", file.path.display()))
390        {
391            Ok(extracted) => {
392                if !extracted.definitions.is_empty() {
393                    corpus.definition_files_with_definitions += 1;
394                }
395                corpus.definitions.extend(extracted.definitions);
396                collect_extraction_diagnostics(
397                    config,
398                    file,
399                    changed_files,
400                    extracted.diagnostics,
401                    diagnostics,
402                    &mut corpus.incomplete,
403                );
404            }
405            // Every discovered source contributes to cross-file definition comparisons, even
406            // when changed mode later filters findings. Skipping an unreadable source could turn
407            // an incomplete run into a false pass.
408            Err(error) => diagnostics.errors.push(format!("{error:#}")),
409        }
410    }
411    corpus.definitions.sort_by(|left, right| {
412        left.location
413            .path
414            .cmp(&right.location.path)
415            .then(left.location.line.cmp(&right.location.line))
416            .then(left.location.column.cmp(&right.location.column))
417    });
418    if corpus.definitions.is_empty() {
419        let message = if files.definitions.is_empty() {
420            "no step definitions were extracted because no definition source files were discovered"
421                .to_owned()
422        } else {
423            format!(
424                "definition extraction produced 0 definitions from {} discovered definition source file(s)",
425                files.definitions.len()
426            )
427        };
428        if config.require_definitions {
429            diagnostics.errors.push(message);
430        } else if !files.definitions.is_empty() {
431            diagnostics.warnings.push(message);
432        }
433    }
434}
435
436fn collect_extraction_diagnostics(
437    config: &Config,
438    file: &source_adapter::SourceFile,
439    changed_files: &Option<BTreeSet<PathBuf>>,
440    extracted: Vec<source_adapter::ExtractionDiagnostic>,
441    diagnostics: &mut Diagnostics,
442    corpus_incomplete: &mut bool,
443) {
444    let changed_or_full_run = changed_files
445        .as_ref()
446        .is_none_or(|changed| changed.contains(&file.path));
447    for diagnostic in extracted {
448        let message = format!(
449            "{}: {}",
450            diagnostic.location.display(&config.root),
451            diagnostic.message
452        );
453        let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
454        // An unresolved registration import hides every definition it would have introduced, so
455        // the corpus is incomplete regardless of which files changed.
456        *corpus_incomplete |= completeness;
457        match diagnostic.level {
458            ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
459                diagnostics.warnings.push(message);
460            }
461            ExtractionDiagnosticLevel::Warning => {}
462            // Extraction errors can mean definitions were omitted. They remain fatal for
463            // unchanged files because those definitions still participate in changed-file pairs.
464            ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
465        }
466    }
467}
468
469fn extract_feature_steps(
470    config: &Config,
471    files: &discovery::DiscoveredFiles,
472    diagnostics: &mut Diagnostics,
473    corpus: &mut ExtractedCorpus,
474) {
475    for file in &files.features {
476        match gherkin::extract_file_with_format(&file.path, file.format) {
477            Ok(extracted) => {
478                corpus.parsed_feature_files += 1;
479                if extracted.is_empty()
480                    && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
481                {
482                    corpus.feature_files_without_steps += 1;
483                    corpus.incomplete = true;
484                    let display = file
485                        .path
486                        .strip_prefix(&config.root)
487                        .unwrap_or(&file.path)
488                        .to_string_lossy()
489                        .replace('\\', "/");
490                    diagnostics.warnings.push(format!(
491                        "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
492                    ));
493                } else {
494                    corpus.feature_steps.extend(extracted);
495                }
496            }
497            // Changed definitions still depend on the full feature corpus for usage and ambiguity.
498            Err(error) => diagnostics.errors.push(format!("{error:#}")),
499        }
500    }
501    if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
502        let message = if corpus.parsed_feature_files == 0 {
503            "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
504        } else {
505            format!(
506                "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
507                files.features.len() - corpus.parsed_feature_files
508            )
509        };
510        if config.require_features {
511            diagnostics.errors.push(message);
512        } else if !files.definitions.is_empty() {
513            diagnostics.warnings.push(message);
514        }
515    }
516    corpus.feature_steps.sort_by(|left, right| {
517        left.location
518            .path
519            .cmp(&right.location.path)
520            .then(left.location.line.cmp(&right.location.line))
521            .then(left.location.column.cmp(&right.location.column))
522    });
523}
524
525fn analyze_corpus(
526    config: &Config,
527    extracted: ExtractedCorpus,
528    diagnostics: &mut Diagnostics,
529) -> Result<AnalyzedCorpus> {
530    let ExtractedCorpus {
531        definitions,
532        feature_steps,
533        definition_files_with_definitions,
534        parsed_feature_files,
535        feature_files_without_steps,
536        incomplete: corpus_incomplete,
537    } = extracted;
538    let (analysis, census, unmatched_suppressions) =
539        analysis::analyze_for_cli(definitions, feature_steps, config)?;
540    for index in unmatched_suppressions.indices {
541        let suppression = &config.suppressions[index];
542        diagnostics.warnings.push(format!(
543            "suppression {} for {} matched no step definitions",
544            index + 1,
545            suppression.rule
546        ));
547    }
548    if unmatched_suppressions.truncated {
549        diagnostics.warnings.push(
550            "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
551                .to_owned(),
552        );
553    }
554
555    // Bounded work that could not finish makes findings a subset of a complete run, but every
556    // reported finding remains valid. `--fail-on-incomplete` restores strict CI gating.
557    let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
558    if config.fail_on_incomplete {
559        diagnostics.errors.extend(analysis.incomplete);
560        if corpus_incomplete {
561            diagnostics.errors.push(
562                "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
563            );
564        }
565    } else {
566        diagnostics.warnings.extend(analysis.incomplete);
567    }
568    Ok(AnalyzedCorpus {
569        result: analysis.result,
570        census,
571        definition_files_with_definitions,
572        parsed_feature_files,
573        feature_files_without_steps,
574        corpus_incomplete,
575        run_incomplete,
576    })
577}
578
579fn apply_finding_modes(
580    result: &mut crate::model::AnalysisResult,
581    files: &discovery::DiscoveredFiles,
582    changed_files: &Option<BTreeSet<PathBuf>>,
583    parsed_feature_files: usize,
584    feature_files_without_steps: usize,
585) {
586    // A converter can produce a syntactically valid but empty document when it fails to
587    // recognize markup or a dialect. Such an input cannot prove that a definition is unused,
588    // so use the same conservative suppression as a feature parse failure.
589    if parsed_feature_files < files.features.len()
590        || feature_files_without_steps > 0
591        || files.features.is_empty()
592    {
593        result
594            .findings
595            .retain(|finding| finding.rule != Rule::UnusedDefinition);
596    }
597    if let Some(changed) = changed_files {
598        modes::retain_changed_findings(&mut result.findings, changed);
599    }
600}
601
602fn apply_baseline_mode(
603    config: &Config,
604    options: &CheckOptions,
605    result: &mut crate::model::AnalysisResult,
606    run_incomplete: bool,
607    diagnostics: &mut Diagnostics,
608) -> Result<Option<modes::BaselineOutcome>> {
609    if let Some(revision) = &options.baseline_from_ref {
610        let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
611        // Freeze the current policy instead of loading historical rule/config overrides.
612        let mut base_config = config.clone();
613        base_config.root = root;
614        base_config.fail_on_incomplete = true;
615        let files = discovery::discover(&base_config)?;
616        let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
617        let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
618        let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics)?;
619        if base.run_incomplete
620            || !base_diagnostics.errors.is_empty()
621            || (!files.definitions.is_empty() && base.result.definitions.is_empty())
622            || (files.features.is_empty() && !base.result.definitions.is_empty())
623        {
624            bail!(
625                "baseline revision `{revision}` is incomplete: {}",
626                base_diagnostics
627                    .errors
628                    .iter()
629                    .chain(&base_diagnostics.warnings)
630                    .cloned()
631                    .collect::<Vec<_>>()
632                    .join("; ")
633            );
634        }
635        apply_finding_modes(
636            &mut base.result,
637            &files,
638            &None,
639            base.parsed_feature_files,
640            base.feature_files_without_steps,
641        );
642        return modes::apply_reference_baseline(
643            &mut result.findings,
644            &base.result.findings,
645            revision,
646        )
647        .map(Some);
648    }
649    let Some(path) = &options.baseline else {
650        return Ok(None);
651    };
652    let path = if path.is_absolute() {
653        path.clone()
654    } else {
655        config.root.join(path)
656    };
657    if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
658        diagnostics.warnings.push(format!(
659            "baseline {} was not updated because analysis is incomplete",
660            path.display()
661        ));
662        return Ok(None);
663    }
664    let outcome = if options.update_baseline {
665        modes::update_baseline(&mut result.findings, &path)?
666    } else {
667        modes::apply_baseline(&mut result.findings, &path)?
668    };
669    if options.update_baseline {
670        diagnostics.warnings.push(format!(
671            "updated baseline {}: {} added, {} removed, {} total",
672            path.display(),
673            outcome.added,
674            outcome.removed,
675            outcome.suppressed
676        ));
677    }
678    Ok(Some(outcome))
679}
680
681fn write_reports(
682    config: &Config,
683    files: &discovery::DiscoveredFiles,
684    analyzed: &AnalyzedCorpus,
685    diagnostics: &Diagnostics,
686    timings: PhaseTimings,
687) -> Result<()> {
688    let corpus = reporters::CorpusCensus {
689        definition_files: files.definitions.len(),
690        definition_files_with_definitions: analyzed.definition_files_with_definitions,
691        definitions_extracted: analyzed.result.definitions.len(),
692        feature_files: files.features.len(),
693        feature_files_parsed: analyzed.parsed_feature_files,
694        feature_files_without_steps: analyzed.feature_files_without_steps,
695        incomplete: analyzed.corpus_incomplete,
696    };
697    let metrics = reporters::ExecutionMetrics::new(
698        files.definitions.len(),
699        files.features.len(),
700        timings.discovery_ms,
701        timings.parsing_ms,
702        timings.analysis_ms,
703    );
704    let report_metadata = reporters::CliReportMetadata {
705        corpus: &corpus,
706        analysis: &analyzed.census,
707        // SARIF consumers treat a successful invocation as proof the tool covered its input.
708        execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
709    };
710    let terminal_color = terminal_color_enabled(
711        io::stdout().is_terminal(),
712        std::env::var_os("NO_COLOR").as_deref(),
713    );
714    let mut stdout = io::stdout().lock();
715    reporters::write_cli_reports(
716        &analyzed.result,
717        config,
718        (!config.no_metrics).then_some(&metrics),
719        &report_metadata,
720        &mut stdout,
721        terminal_color,
722    )?;
723    Ok(())
724}
725
726fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
727    is_terminal && !no_color.is_some_and(|value| !value.is_empty())
728}
729
730fn write_diagnostics(
731    config: &Config,
732    options: &CheckOptions,
733    files: &discovery::DiscoveredFiles,
734    diagnostics: &Diagnostics,
735) -> Result<()> {
736    if !options.explain_discovery
737        && diagnostics.warnings.is_empty()
738        && diagnostics.errors.is_empty()
739    {
740        return Ok(());
741    }
742    let mut stderr = io::stderr().lock();
743    if options.explain_discovery {
744        writeln!(
745            stderr,
746            "cuke-dedup: feature patterns: {} ({})",
747            config.features.join(", "),
748            config.feature_pattern_origin
749        )?;
750        for file in &files.features {
751            let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
752            writeln!(
753                stderr,
754                "cuke-dedup: feature {} [{}] via {}",
755                relative.display(),
756                file.format.as_str(),
757                file.pattern
758            )?;
759        }
760        for file in &files.definitions {
761            let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
762            writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
763        }
764    }
765    for warning in &diagnostics.warnings {
766        writeln!(stderr, "cuke-dedup: warning: {warning}")?;
767    }
768    for error in &diagnostics.errors {
769        writeln!(stderr, "cuke-dedup: {error}")?;
770    }
771    Ok(())
772}
773
774fn determine_exit_code(
775    config: &Config,
776    options: &CheckOptions,
777    result: &crate::model::AnalysisResult,
778    baseline: Option<modes::BaselineOutcome>,
779    diagnostics: &Diagnostics,
780) -> Result<i32> {
781    if !diagnostics.errors.is_empty() {
782        return Ok(2);
783    }
784    let duplication = DuplicationThreshold::from_result(result, config.threshold);
785    let has_non_duplication_errors = result.findings.iter().any(|finding| {
786        finding.is_active()
787            && finding.severity == Severity::Error
788            && !finding.rule.contributes_to_duplication_threshold()
789    });
790    let new_findings_exceeded = options
791        .fail_on_new
792        .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
793    Ok(i32::from(
794        has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
795    ))
796}
797
798fn elapsed_ms(started: Instant) -> f64 {
799    started.elapsed().as_secs_f64() * 1_000.0
800}
801
802fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
803    let (rule, severity) = value
804        .split_once('=')
805        .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
806    Ok((rule.parse()?, severity.parse()?))
807}
808
809fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
810    value
811        .parse::<usize>()
812        .ok()
813        .filter(|value| *value > 0)
814        .ok_or_else(|| "expected an integer greater than zero".to_owned())
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820
821    #[test]
822    fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
823        use std::ffi::OsStr;
824
825        assert!(terminal_color_enabled(true, None));
826        assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
827        assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
828        assert!(!terminal_color_enabled(false, None));
829        assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
830    }
831
832    #[test]
833    fn parses_rule_override() {
834        assert_eq!(
835            parse_rule_override("duplicate-matcher=warning"),
836            Ok((Rule::DuplicateMatcher, Severity::Warning))
837        );
838        assert!(parse_rule_override("unknown=error").is_err());
839    }
840
841    #[test]
842    fn candidate_limit_cli_values_must_be_positive() {
843        assert_eq!(parse_positive_usize("7"), Ok(7));
844        assert!(parse_positive_usize("0").is_err());
845        assert!(parse_positive_usize("-1").is_err());
846        assert!(parse_positive_usize("many").is_err());
847    }
848
849    #[test]
850    fn supports_implicit_and_explicit_check_forms() {
851        let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
852            .expect("implicit invocation");
853        assert!(implicit.command.is_none());
854        let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
855            .expect("explicit invocation");
856        assert!(matches!(explicit.command, Some(Command::Check { .. })));
857        assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
858    }
859}