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_options(
382 &config.root,
383 &config.registrations,
384 &config.assertion_modules,
385 );
386 for file in &files.definitions {
387 let adapter = source_adapter::adapter_for_language(file.language);
388 match adapter
389 .extract_file_with_session(file, &mut extraction_session)
390 .with_context(|| format!("failed to analyze {}", file.path.display()))
391 {
392 Ok(extracted) => {
393 if !extracted.definitions.is_empty() {
394 corpus.definition_files_with_definitions += 1;
395 }
396 corpus.definitions.extend(extracted.definitions);
397 collect_extraction_diagnostics(
398 config,
399 file,
400 changed_files,
401 extracted.diagnostics,
402 diagnostics,
403 &mut corpus.incomplete,
404 );
405 }
406 Err(error) => diagnostics.errors.push(format!("{error:#}")),
410 }
411 }
412 corpus.definitions.sort_by(|left, right| {
413 left.location
414 .path
415 .cmp(&right.location.path)
416 .then(left.location.line.cmp(&right.location.line))
417 .then(left.location.column.cmp(&right.location.column))
418 });
419 if corpus.definitions.is_empty() {
420 let message = if files.definitions.is_empty() {
421 "no step definitions were extracted because no definition source files were discovered"
422 .to_owned()
423 } else {
424 format!(
425 "definition extraction produced 0 definitions from {} discovered definition source file(s)",
426 files.definitions.len()
427 )
428 };
429 if config.require_definitions {
430 diagnostics.errors.push(message);
431 } else if !files.definitions.is_empty() {
432 diagnostics.warnings.push(message);
433 }
434 }
435}
436
437fn collect_extraction_diagnostics(
438 config: &Config,
439 file: &source_adapter::SourceFile,
440 changed_files: &Option<BTreeSet<PathBuf>>,
441 extracted: Vec<source_adapter::ExtractionDiagnostic>,
442 diagnostics: &mut Diagnostics,
443 corpus_incomplete: &mut bool,
444) {
445 let changed_or_full_run = changed_files
446 .as_ref()
447 .is_none_or(|changed| changed.contains(&file.path));
448 for diagnostic in extracted {
449 let message = format!(
450 "{}: {}",
451 diagnostic.location.display(&config.root),
452 diagnostic.message
453 );
454 let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
455 *corpus_incomplete |= completeness;
458 match diagnostic.level {
459 ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
460 diagnostics.warnings.push(message);
461 }
462 ExtractionDiagnosticLevel::Warning => {}
463 ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
466 }
467 }
468}
469
470fn extract_feature_steps(
471 config: &Config,
472 files: &discovery::DiscoveredFiles,
473 diagnostics: &mut Diagnostics,
474 corpus: &mut ExtractedCorpus,
475) {
476 for file in &files.features {
477 match gherkin::extract_file_with_format(&file.path, file.format) {
478 Ok(extracted) => {
479 corpus.parsed_feature_files += 1;
480 if extracted.is_empty()
481 && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
482 {
483 corpus.feature_files_without_steps += 1;
484 corpus.incomplete = true;
485 let display = file
486 .path
487 .strip_prefix(&config.root)
488 .unwrap_or(&file.path)
489 .to_string_lossy()
490 .replace('\\', "/");
491 diagnostics.warnings.push(format!(
492 "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
493 ));
494 } else {
495 corpus.feature_steps.extend(extracted);
496 }
497 }
498 Err(error) => diagnostics.errors.push(format!("{error:#}")),
500 }
501 }
502 if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
503 let message = if corpus.parsed_feature_files == 0 {
504 "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
505 } else {
506 format!(
507 "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
508 files.features.len() - corpus.parsed_feature_files
509 )
510 };
511 if config.require_features {
512 diagnostics.errors.push(message);
513 } else if !files.definitions.is_empty() {
514 diagnostics.warnings.push(message);
515 }
516 }
517 corpus.feature_steps.sort_by(|left, right| {
518 left.location
519 .path
520 .cmp(&right.location.path)
521 .then(left.location.line.cmp(&right.location.line))
522 .then(left.location.column.cmp(&right.location.column))
523 });
524}
525
526fn analyze_corpus(
527 config: &Config,
528 extracted: ExtractedCorpus,
529 diagnostics: &mut Diagnostics,
530) -> Result<AnalyzedCorpus> {
531 let ExtractedCorpus {
532 definitions,
533 feature_steps,
534 definition_files_with_definitions,
535 parsed_feature_files,
536 feature_files_without_steps,
537 incomplete: corpus_incomplete,
538 } = extracted;
539 let (analysis, census, unmatched_suppressions) =
540 analysis::analyze_for_cli(definitions, feature_steps, config)?;
541 for index in unmatched_suppressions.indices {
542 let suppression = &config.suppressions[index];
543 diagnostics.warnings.push(format!(
544 "suppression {} for {} matched no step definitions",
545 index + 1,
546 suppression.rule
547 ));
548 }
549 if unmatched_suppressions.truncated {
550 diagnostics.warnings.push(
551 "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
552 .to_owned(),
553 );
554 }
555
556 let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
559 if config.fail_on_incomplete {
560 diagnostics.errors.extend(analysis.incomplete);
561 if corpus_incomplete {
562 diagnostics.errors.push(
563 "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
564 );
565 }
566 } else {
567 diagnostics.warnings.extend(analysis.incomplete);
568 }
569 Ok(AnalyzedCorpus {
570 result: analysis.result,
571 census,
572 definition_files_with_definitions,
573 parsed_feature_files,
574 feature_files_without_steps,
575 corpus_incomplete,
576 run_incomplete,
577 })
578}
579
580fn apply_finding_modes(
581 result: &mut crate::model::AnalysisResult,
582 files: &discovery::DiscoveredFiles,
583 changed_files: &Option<BTreeSet<PathBuf>>,
584 parsed_feature_files: usize,
585 feature_files_without_steps: usize,
586) {
587 if parsed_feature_files < files.features.len()
591 || feature_files_without_steps > 0
592 || files.features.is_empty()
593 {
594 result
595 .findings
596 .retain(|finding| finding.rule != Rule::UnusedDefinition);
597 }
598 if let Some(changed) = changed_files {
599 modes::retain_changed_findings(&mut result.findings, changed);
600 }
601}
602
603fn apply_baseline_mode(
604 config: &Config,
605 options: &CheckOptions,
606 result: &mut crate::model::AnalysisResult,
607 run_incomplete: bool,
608 diagnostics: &mut Diagnostics,
609) -> Result<Option<modes::BaselineOutcome>> {
610 if let Some(revision) = &options.baseline_from_ref {
611 let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
612 let mut base_config = config.clone();
614 base_config.root = root;
615 base_config.fail_on_incomplete = true;
616 let files = discovery::discover(&base_config)?;
617 let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
618 let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
619 let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics)?;
620 if base.run_incomplete
621 || !base_diagnostics.errors.is_empty()
622 || (!files.definitions.is_empty() && base.result.definitions.is_empty())
623 || (files.features.is_empty() && !base.result.definitions.is_empty())
624 {
625 bail!(
626 "baseline revision `{revision}` is incomplete: {}",
627 base_diagnostics
628 .errors
629 .iter()
630 .chain(&base_diagnostics.warnings)
631 .cloned()
632 .collect::<Vec<_>>()
633 .join("; ")
634 );
635 }
636 apply_finding_modes(
637 &mut base.result,
638 &files,
639 &None,
640 base.parsed_feature_files,
641 base.feature_files_without_steps,
642 );
643 return modes::apply_reference_baseline(
644 &mut result.findings,
645 &base.result.findings,
646 revision,
647 )
648 .map(Some);
649 }
650 let Some(path) = &options.baseline else {
651 return Ok(None);
652 };
653 let path = if path.is_absolute() {
654 path.clone()
655 } else {
656 config.root.join(path)
657 };
658 if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
659 diagnostics.warnings.push(format!(
660 "baseline {} was not updated because analysis is incomplete",
661 path.display()
662 ));
663 return Ok(None);
664 }
665 let outcome = if options.update_baseline {
666 modes::update_baseline(&mut result.findings, &path)?
667 } else {
668 modes::apply_baseline(&mut result.findings, &path)?
669 };
670 if options.update_baseline {
671 diagnostics.warnings.push(format!(
672 "updated baseline {}: {} added, {} removed, {} total",
673 path.display(),
674 outcome.added,
675 outcome.removed,
676 outcome.suppressed
677 ));
678 }
679 Ok(Some(outcome))
680}
681
682fn write_reports(
683 config: &Config,
684 files: &discovery::DiscoveredFiles,
685 analyzed: &AnalyzedCorpus,
686 diagnostics: &Diagnostics,
687 timings: PhaseTimings,
688) -> Result<()> {
689 let corpus = reporters::CorpusCensus {
690 definition_files: files.definitions.len(),
691 definition_files_with_definitions: analyzed.definition_files_with_definitions,
692 definitions_extracted: analyzed.result.definitions.len(),
693 feature_files: files.features.len(),
694 feature_files_parsed: analyzed.parsed_feature_files,
695 feature_files_without_steps: analyzed.feature_files_without_steps,
696 incomplete: analyzed.corpus_incomplete,
697 };
698 let metrics = reporters::ExecutionMetrics::new(
699 files.definitions.len(),
700 files.features.len(),
701 timings.discovery_ms,
702 timings.parsing_ms,
703 timings.analysis_ms,
704 );
705 let report_metadata = reporters::CliReportMetadata {
706 corpus: &corpus,
707 analysis: &analyzed.census,
708 execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
710 };
711 let terminal_color = terminal_color_enabled(
712 io::stdout().is_terminal(),
713 std::env::var_os("NO_COLOR").as_deref(),
714 );
715 let mut stdout = io::stdout().lock();
716 reporters::write_cli_reports(
717 &analyzed.result,
718 config,
719 (!config.no_metrics).then_some(&metrics),
720 &report_metadata,
721 &mut stdout,
722 terminal_color,
723 )?;
724 Ok(())
725}
726
727fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
728 is_terminal && !no_color.is_some_and(|value| !value.is_empty())
729}
730
731fn write_diagnostics(
732 config: &Config,
733 options: &CheckOptions,
734 files: &discovery::DiscoveredFiles,
735 diagnostics: &Diagnostics,
736) -> Result<()> {
737 if !options.explain_discovery
738 && diagnostics.warnings.is_empty()
739 && diagnostics.errors.is_empty()
740 {
741 return Ok(());
742 }
743 let mut stderr = io::stderr().lock();
744 if options.explain_discovery {
745 writeln!(
746 stderr,
747 "cuke-dedup: feature patterns: {} ({})",
748 config.features.join(", "),
749 config.feature_pattern_origin
750 )?;
751 for file in &files.features {
752 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
753 writeln!(
754 stderr,
755 "cuke-dedup: feature {} [{}] via {}",
756 relative.display(),
757 file.format.as_str(),
758 file.pattern
759 )?;
760 }
761 for file in &files.definitions {
762 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
763 writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
764 }
765 }
766 for warning in &diagnostics.warnings {
767 writeln!(stderr, "cuke-dedup: warning: {warning}")?;
768 }
769 for error in &diagnostics.errors {
770 writeln!(stderr, "cuke-dedup: {error}")?;
771 }
772 Ok(())
773}
774
775fn determine_exit_code(
776 config: &Config,
777 options: &CheckOptions,
778 result: &crate::model::AnalysisResult,
779 baseline: Option<modes::BaselineOutcome>,
780 diagnostics: &Diagnostics,
781) -> Result<i32> {
782 if !diagnostics.errors.is_empty() {
783 return Ok(2);
784 }
785 let duplication = DuplicationThreshold::from_result(result, config.threshold);
786 let has_non_duplication_errors = result.findings.iter().any(|finding| {
787 finding.is_active()
788 && finding.severity == Severity::Error
789 && !finding.rule.contributes_to_duplication_threshold()
790 });
791 let new_findings_exceeded = options
792 .fail_on_new
793 .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
794 Ok(i32::from(
795 has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
796 ))
797}
798
799fn elapsed_ms(started: Instant) -> f64 {
800 started.elapsed().as_secs_f64() * 1_000.0
801}
802
803fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
804 let (rule, severity) = value
805 .split_once('=')
806 .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
807 Ok((rule.parse()?, severity.parse()?))
808}
809
810fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
811 value
812 .parse::<usize>()
813 .ok()
814 .filter(|value| *value > 0)
815 .ok_or_else(|| "expected an integer greater than zero".to_owned())
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821
822 #[test]
823 fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
824 use std::ffi::OsStr;
825
826 assert!(terminal_color_enabled(true, None));
827 assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
828 assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
829 assert!(!terminal_color_enabled(false, None));
830 assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
831 }
832
833 #[test]
834 fn parses_rule_override() {
835 assert_eq!(
836 parse_rule_override("duplicate-matcher=warning"),
837 Ok((Rule::DuplicateMatcher, Severity::Warning))
838 );
839 assert!(parse_rule_override("unknown=error").is_err());
840 }
841
842 #[test]
843 fn candidate_limit_cli_values_must_be_positive() {
844 assert_eq!(parse_positive_usize("7"), Ok(7));
845 assert!(parse_positive_usize("0").is_err());
846 assert!(parse_positive_usize("-1").is_err());
847 assert!(parse_positive_usize("many").is_err());
848 }
849
850 #[test]
851 fn supports_implicit_and_explicit_check_forms() {
852 let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
853 .expect("implicit invocation");
854 assert!(implicit.command.is_none());
855 let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
856 .expect("explicit invocation");
857 assert!(matches!(explicit.command, Some(Command::Check { .. })));
858 assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
859 }
860}