Skip to main content

cuke_dedup/
cli.rs

1//! Command-line parsing and analysis orchestration.
2
3use crate::analysis;
4use crate::config::{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    /// Glob patterns selecting step-definition source files.
73    #[arg(long, global = true, value_delimiter = ',')]
74    definitions: Option<Vec<String>>,
75
76    /// Glob patterns selecting Gherkin feature files.
77    #[arg(long, global = true, value_delimiter = ',')]
78    features: Option<Vec<String>>,
79
80    /// Print the effective feature source and every discovered input.
81    #[arg(long, global = true)]
82    explain_discovery: bool,
83
84    /// Print the merged effective configuration as JSON and exit.
85    #[arg(long, global = true)]
86    print_config: bool,
87
88    /// Omit runtime timing metrics from machine reports for reproducible output.
89    #[arg(long, global = true)]
90    no_metrics: bool,
91
92    /// Glob patterns excluded from discovery.
93    #[arg(long, global = true, value_delimiter = ',')]
94    exclude: Option<Vec<String>>,
95
96    /// Disable built-in exclusions such as node_modules and target.
97    #[arg(long, global = true)]
98    no_default_excludes: bool,
99
100    /// Descend into dot-directories such as .steps.
101    #[arg(long, global = true)]
102    include_hidden: bool,
103
104    /// Override a rule severity, for example duplicate-matcher=warning.
105    #[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
106    rules: Vec<(Rule, Severity)>,
107
108    /// Report only findings involving files changed since this Git revision.
109    #[arg(long, global = true, value_name = "GIT_REF")]
110    changed_since: Option<String>,
111
112    /// Suppress findings already present in a versioned semantic baseline.
113    #[arg(long, global = true, value_name = "BASELINE.json")]
114    baseline: Option<PathBuf>,
115
116    /// Rewrite the configured baseline from the current complete analysis.
117    #[arg(
118        long,
119        global = true,
120        requires = "baseline",
121        conflicts_with = "changed_since"
122    )]
123    update_baseline: bool,
124
125    /// Fail when more than COUNT findings are new relative to the baseline.
126    #[arg(
127        long,
128        global = true,
129        value_name = "COUNT",
130        num_args = 0..=1,
131        default_missing_value = "0",
132        requires = "baseline",
133        conflicts_with = "update_baseline"
134    )]
135    fail_on_new: Option<usize>,
136}
137
138/// Parses the process arguments, runs the configured analysis, and returns its exit code.
139pub fn run_cli() -> Result<i32> {
140    execute(Cli::parse())
141}
142
143fn execute(cli: Cli) -> Result<i32> {
144    if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
145        bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
146    }
147    let root = match cli.command {
148        Some(Command::Check { path }) => path,
149        None => cli.path,
150    };
151    let overrides = ConfigOverrides {
152        config_file: cli.options.config,
153        definitions: cli.options.definitions,
154        features: cli.options.features,
155        exclude: cli.options.exclude,
156        exclude_defaults: cli.options.no_default_excludes.then_some(false),
157        include_hidden: cli.options.include_hidden.then_some(true),
158        reporters: cli.options.reporters,
159        output: cli.options.output,
160        threshold: cli.options.threshold,
161        require_features: cli.options.require_features.then_some(true),
162        no_metrics: cli.options.no_metrics.then_some(true),
163        rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
164    };
165    let config = Config::load(&root, overrides)?;
166    if cli.options.print_config {
167        println!(
168            "{}",
169            serde_json::to_string_pretty(&config)
170                .context("failed to serialize effective configuration")?
171        );
172        return Ok(0);
173    }
174    let changed_files = if let Some(base) = cli.options.changed_since.as_deref() {
175        modes::ensure_changed_root_is_trackable(&config.root)?;
176        Some(modes::git_changed_files(&config.root, base)?)
177    } else {
178        None
179    };
180    let discovery_started = Instant::now();
181    let files = discovery::discover(&config)?;
182    let discovery_ms = elapsed_ms(discovery_started);
183
184    let parsing_started = Instant::now();
185    let mut definitions = Vec::new();
186    let mut operational_errors = files.errors.clone();
187    let mut operational_warnings = config.config_warnings.clone();
188    for pattern in &files.unmatched_feature_patterns {
189        operational_warnings.push(format!(
190            "feature pattern `{pattern}` from {} matched no files",
191            config.feature_pattern_origin
192        ));
193    }
194    for pattern in &files.unmatched_definition_patterns {
195        operational_warnings.push(format!("definition pattern `{pattern}` matched no files"));
196    }
197    if files.definitions.is_empty() && config.definitions.is_empty() {
198        operational_warnings
199            .push("automatic discovery found no supported step-definition source files".to_owned());
200    }
201    if files.features.is_empty() {
202        let message = format!(
203            "no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
204            config.feature_pattern_origin
205        );
206        if config.require_features {
207            operational_errors.push(message);
208        } else if !files.definitions.is_empty() {
209            operational_warnings.push(message);
210        }
211    }
212    if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
213        operational_warnings.push(
214            "--changed-since found no tracked or untracked files; no findings will be reported"
215                .to_owned(),
216        );
217    }
218    for file in &files.definitions {
219        let adapter = source_adapter::adapter_for_language(file.language);
220        match adapter
221            .extract_file(file)
222            .with_context(|| format!("failed to analyze {}", file.path.display()))
223        {
224            Ok(extracted) => {
225                definitions.extend(extracted.definitions);
226                if changed_files
227                    .as_ref()
228                    .is_none_or(|changed| changed.contains(&file.path))
229                {
230                    for diagnostic in extracted.diagnostics {
231                        let message = format!(
232                            "{}: {}",
233                            diagnostic.location.display(&config.root),
234                            diagnostic.message
235                        );
236                        match diagnostic.level {
237                            ExtractionDiagnosticLevel::Warning => {
238                                operational_warnings.push(message)
239                            }
240                            ExtractionDiagnosticLevel::Error => operational_errors.push(message),
241                        }
242                    }
243                }
244            }
245            Err(error)
246                if changed_files
247                    .as_ref()
248                    .is_none_or(|changed| changed.contains(&file.path)) =>
249            {
250                operational_errors.push(format!("{error:#}"));
251            }
252            Err(_) => {}
253        }
254    }
255    definitions.sort_by(|left, right| {
256        left.location
257            .path
258            .cmp(&right.location.path)
259            .then(left.location.line.cmp(&right.location.line))
260            .then(left.location.column.cmp(&right.location.column))
261    });
262    for index in analysis::unmatched_suppressions(&config, &definitions) {
263        let suppression = &config.suppressions[index];
264        operational_warnings.push(format!(
265            "suppression {} for {} matched no step definitions",
266            index + 1,
267            suppression.rule
268        ));
269    }
270
271    let mut feature_steps = Vec::new();
272    let mut parsed_feature_files = 0_usize;
273    for file in &files.features {
274        match gherkin::extract_file_with_format(&file.path, file.format) {
275            Ok(extracted) => {
276                parsed_feature_files += 1;
277                feature_steps.extend(extracted);
278            }
279            Err(error)
280                if changed_files
281                    .as_ref()
282                    .is_none_or(|changed| changed.contains(&file.path)) =>
283            {
284                operational_errors.push(format!("{error:#}"));
285            }
286            Err(_) => {}
287        }
288    }
289    if !files.features.is_empty() && parsed_feature_files < files.features.len() {
290        let message = if parsed_feature_files == 0 {
291            "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
292        } else {
293            format!(
294                "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
295                files.features.len() - parsed_feature_files
296            )
297        };
298        if config.require_features {
299            operational_errors.push(message);
300        } else if !files.definitions.is_empty() {
301            operational_warnings.push(message);
302        }
303    }
304    feature_steps.sort_by(|left, right| {
305        left.location
306            .path
307            .cmp(&right.location.path)
308            .then(left.location.line.cmp(&right.location.line))
309            .then(left.location.column.cmp(&right.location.column))
310    });
311    let parsing_ms = elapsed_ms(parsing_started);
312
313    let analysis_started = Instant::now();
314    let mut result = analysis::analyze(definitions, feature_steps, &config)?;
315    let analysis_ms = elapsed_ms(analysis_started);
316    if parsed_feature_files < files.features.len() || files.features.is_empty() {
317        result
318            .findings
319            .retain(|finding| finding.rule != Rule::UnusedDefinition);
320    }
321    if let Some(changed) = &changed_files {
322        modes::retain_changed_findings(&mut result.findings, changed);
323    }
324    let mut baseline_outcome = None;
325    if let Some(path) = &cli.options.baseline {
326        let path = if path.is_absolute() {
327            path.clone()
328        } else {
329            config.root.join(path)
330        };
331        let outcome = if cli.options.update_baseline {
332            modes::update_baseline(&mut result.findings, &path)?
333        } else {
334            modes::apply_baseline(&mut result.findings, &path)?
335        };
336        if cli.options.update_baseline {
337            operational_warnings.push(format!(
338                "updated baseline {}: {} added, {} removed, {} total",
339                path.display(),
340                outcome.added,
341                outcome.removed,
342                outcome.suppressed
343            ));
344        }
345        baseline_outcome = Some(outcome);
346    }
347    let metrics = reporters::ExecutionMetrics {
348        definition_files: files.definitions.len(),
349        feature_files: files.features.len(),
350        files_discovered: files.definitions.len() + files.features.len(),
351        discovery_ms,
352        parsing_ms,
353        analysis_ms,
354    };
355    let mut stdout = io::stdout().lock();
356    if config.no_metrics {
357        reporters::write_reports(&result, &config, &mut stdout)?;
358    } else {
359        reporters::write_reports_with_metrics(&result, &config, &metrics, &mut stdout)?;
360    }
361    if cli.options.explain_discovery
362        || !operational_warnings.is_empty()
363        || !operational_errors.is_empty()
364    {
365        let mut stderr = io::stderr().lock();
366        if cli.options.explain_discovery {
367            writeln!(
368                stderr,
369                "cuke-dedup: feature patterns: {} ({})",
370                config.features.join(", "),
371                config.feature_pattern_origin
372            )?;
373            for file in &files.features {
374                let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
375                writeln!(
376                    stderr,
377                    "cuke-dedup: feature {} [{}] via {}",
378                    relative.display(),
379                    file.format.as_str(),
380                    file.pattern
381                )?;
382            }
383            for file in &files.definitions {
384                let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
385                writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
386            }
387        }
388        for warning in operational_warnings {
389            writeln!(stderr, "cuke-dedup: warning: {warning}")?;
390        }
391        for error in &operational_errors {
392            writeln!(stderr, "cuke-dedup: {error}")?;
393        }
394    }
395    if !operational_errors.is_empty() {
396        return Ok(2);
397    }
398    let duplication = DuplicationThreshold::from_result(&result, config.threshold);
399    let has_non_duplication_errors = result.findings.iter().any(|finding| {
400        finding.is_active()
401            && finding.severity == Severity::Error
402            && !finding.rule.contributes_to_duplication_threshold()
403    });
404    let new_findings_exceeded = cli
405        .options
406        .fail_on_new
407        .is_some_and(|limit| baseline_outcome.is_some_and(|outcome| outcome.new_findings > limit));
408    Ok(i32::from(
409        has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
410    ))
411}
412
413fn elapsed_ms(started: Instant) -> f64 {
414    started.elapsed().as_secs_f64() * 1_000.0
415}
416
417fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
418    let (rule, severity) = value
419        .split_once('=')
420        .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
421    Ok((rule.parse()?, severity.parse()?))
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn parses_rule_override() {
430        assert_eq!(
431            parse_rule_override("duplicate-matcher=warning"),
432            Ok((Rule::DuplicateMatcher, Severity::Warning))
433        );
434        assert!(parse_rule_override("unknown=error").is_err());
435    }
436
437    #[test]
438    fn supports_implicit_and_explicit_check_forms() {
439        let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
440            .expect("implicit invocation");
441        assert!(implicit.command.is_none());
442        let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
443            .expect("explicit invocation");
444        assert!(matches!(explicit.command, Some(Command::Check { .. })));
445        assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
446    }
447}