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