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, 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 #[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(
130 long,
131 global = true,
132 value_name = "BASELINE.json",
133 group = "baseline_source"
134 )]
135 baseline: Option<PathBuf>,
136
137 #[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 #[arg(
144 long,
145 global = true,
146 requires = "baseline",
147 conflicts_with = "changed_since"
148 )]
149 update_baseline: bool,
150
151 #[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
200pub 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 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 *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 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 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 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 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 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 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}