1use 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 #[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)]
74 fail_on_incomplete: bool,
75
76 #[arg(long, global = true)]
78 require_definitions: bool,
79
80 #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
82 max_candidate_comparisons: Option<usize>,
83
84 #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
86 max_structural_class_comparisons: Option<usize>,
87
88 #[arg(long, global = true, value_delimiter = ',')]
90 definitions: Option<Vec<String>>,
91
92 #[arg(long, global = true, value_delimiter = ',')]
94 features: Option<Vec<String>>,
95
96 #[arg(long, global = true)]
98 explain_discovery: bool,
99
100 #[arg(long, global = true)]
102 print_config: bool,
103
104 #[arg(long, global = true)]
106 no_metrics: bool,
107
108 #[arg(long, global = true, value_delimiter = ',')]
110 exclude: Option<Vec<String>>,
111
112 #[arg(long, global = true)]
114 no_default_excludes: bool,
115
116 #[arg(long, global = true)]
118 include_hidden: bool,
119
120 #[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
122 rules: Vec<(Rule, Severity)>,
123
124 #[arg(long, global = true, value_name = "GIT_REF")]
126 changed_since: Option<String>,
127
128 #[arg(long, global = true, value_name = "BASELINE.json")]
130 baseline: Option<PathBuf>,
131
132 #[arg(
134 long,
135 global = true,
136 requires = "baseline",
137 conflicts_with = "changed_since"
138 )]
139 update_baseline: bool,
140
141 #[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
190pub 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 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 *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 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 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 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 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 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}