1use 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 #[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 Check {
39 #[arg(value_name = "PATH")]
41 path: PathBuf,
42 },
43}
44
45#[derive(Debug, Clone, Args, Default)]
46struct CheckOptions {
47 #[arg(long, short = 'c', global = true, value_name = "FILE")]
49 config: Option<PathBuf>,
50
51 #[arg(long, global = true, value_delimiter = ',')]
53 reporters: Option<Vec<ReporterKind>>,
54
55 #[arg(long, global = true, value_name = "DIR")]
57 output: Option<PathBuf>,
58
59 #[arg(
61 long,
62 global = true,
63 value_name = "PERCENT",
64 allow_hyphen_values = true
65 )]
66 threshold: Option<f64>,
67
68 #[arg(long, global = true)]
70 require_features: bool,
71
72 #[arg(long, global = true, value_delimiter = ',')]
74 definitions: Option<Vec<String>>,
75
76 #[arg(long, global = true, value_delimiter = ',')]
78 features: Option<Vec<String>>,
79
80 #[arg(long, global = true)]
82 explain_discovery: bool,
83
84 #[arg(long, global = true)]
86 print_config: bool,
87
88 #[arg(long, global = true)]
90 no_metrics: bool,
91
92 #[arg(long, global = true, value_delimiter = ',')]
94 exclude: Option<Vec<String>>,
95
96 #[arg(long, global = true)]
98 no_default_excludes: bool,
99
100 #[arg(long, global = true)]
102 include_hidden: bool,
103
104 #[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
106 rules: Vec<(Rule, Severity)>,
107
108 #[arg(long, global = true, value_name = "GIT_REF")]
110 changed_since: Option<String>,
111
112 #[arg(long, global = true, value_name = "BASELINE.json")]
114 baseline: Option<PathBuf>,
115
116 #[arg(
118 long,
119 global = true,
120 requires = "baseline",
121 conflicts_with = "changed_since"
122 )]
123 update_baseline: bool,
124
125 #[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
138pub 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}