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)]
80 fail_on_unparseable: bool,
81
82 #[arg(long, global = true)]
84 require_definitions: bool,
85
86 #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
88 max_candidate_comparisons: Option<usize>,
89
90 #[arg(long, global = true, value_name = "COUNT", value_parser = parse_positive_usize)]
92 max_structural_class_comparisons: Option<usize>,
93
94 #[arg(long, global = true, value_delimiter = ',')]
96 definitions: Option<Vec<String>>,
97
98 #[arg(long, global = true, value_delimiter = ',')]
100 features: Option<Vec<String>>,
101
102 #[arg(long, global = true)]
104 explain_discovery: bool,
105
106 #[arg(long, global = true)]
108 print_config: bool,
109
110 #[arg(long, global = true)]
112 no_metrics: bool,
113
114 #[arg(long, global = true, value_delimiter = ',')]
116 exclude: Option<Vec<String>>,
117
118 #[arg(long, global = true)]
120 no_default_excludes: bool,
121
122 #[arg(long, global = true)]
124 include_hidden: bool,
125
126 #[arg(long = "rule", global = true, value_name = "RULE=SEVERITY", value_parser = parse_rule_override)]
128 rules: Vec<(Rule, Severity)>,
129
130 #[arg(long, global = true, value_name = "GIT_REF")]
132 changed_since: Option<String>,
133
134 #[arg(
136 long,
137 global = true,
138 value_name = "BASELINE.json",
139 group = "baseline_source"
140 )]
141 baseline: Option<PathBuf>,
142
143 #[arg(long, global = true, value_name = "GIT_REF", group = "baseline_source",
145 conflicts_with_all = ["baseline", "update_baseline"])]
146 baseline_from_ref: Option<String>,
147
148 #[arg(
150 long,
151 global = true,
152 requires = "baseline",
153 conflicts_with = "changed_since"
154 )]
155 update_baseline: bool,
156
157 #[arg(
159 long,
160 global = true,
161 value_name = "COUNT",
162 num_args = 0..=1,
163 default_missing_value = "0",
164 requires = "baseline_source",
165 conflicts_with = "update_baseline"
166 )]
167 fail_on_new: Option<usize>,
168}
169
170struct Invocation {
171 config: Config,
172 options: CheckOptions,
173}
174
175#[derive(Default)]
176struct Diagnostics {
177 warnings: Vec<String>,
178 errors: Vec<String>,
179}
180
181struct ExtractedCorpus {
182 definitions: Vec<crate::model::StepDefinition>,
183 feature_steps: Vec<crate::model::FeatureStep>,
184 excluded_sources: Vec<(PathBuf, crate::source_filter::ExcludedSource)>,
185 definition_files_with_definitions: usize,
186 parsed_feature_files: usize,
187 feature_files_without_steps: usize,
188 incomplete: bool,
189}
190
191struct AnalyzedCorpus {
192 result: crate::model::AnalysisResult,
193 census: analysis::AnalysisCensus,
194 definition_files_with_definitions: usize,
195 parsed_feature_files: usize,
196 feature_files_without_steps: usize,
197 corpus_incomplete: bool,
198 run_incomplete: bool,
199}
200
201struct PhaseTimings {
202 discovery_ms: f64,
203 parsing_ms: f64,
204 analysis_ms: f64,
205}
206
207pub fn run_cli() -> Result<i32> {
209 execute(Cli::parse())
210}
211
212fn execute(cli: Cli) -> Result<i32> {
213 let invocation = resolve_invocation(cli)?;
214 let config = &invocation.config;
215 let options = &invocation.options;
216 if options.print_config {
217 println!(
218 "{}",
219 serde_json::to_string_pretty(config)
220 .context("failed to serialize effective configuration")?
221 );
222 return Ok(0);
223 }
224 let changed_files = resolve_changed_files(config, options.changed_since.as_deref())?;
225 let discovery_started = Instant::now();
226 let files = discovery::discover(config)?;
227 let discovery_ms = elapsed_ms(discovery_started);
228
229 let parsing_started = Instant::now();
230 let mut diagnostics = discovery_diagnostics(config, &files, &changed_files);
231 let extracted = extract_corpus(config, &files, &changed_files, &mut diagnostics);
232 let parsing_ms = elapsed_ms(parsing_started);
233 let analysis_started = Instant::now();
234 let mut analyzed = analyze_corpus(config, extracted, &mut diagnostics)?;
235 let analysis_ms = elapsed_ms(analysis_started);
236 apply_finding_modes(
237 &mut analyzed.result,
238 &files,
239 &changed_files,
240 analyzed.parsed_feature_files,
241 analyzed.feature_files_without_steps,
242 );
243 let baseline_outcome = apply_baseline_mode(
244 config,
245 options,
246 &mut analyzed.result,
247 analyzed.run_incomplete,
248 &mut diagnostics,
249 )?;
250 write_reports(
251 config,
252 &files,
253 &analyzed,
254 &diagnostics,
255 PhaseTimings {
256 discovery_ms,
257 parsing_ms,
258 analysis_ms,
259 },
260 )?;
261 write_diagnostics(config, options, &files, &diagnostics)?;
262 determine_exit_code(
263 config,
264 options,
265 &analyzed.result,
266 baseline_outcome,
267 &diagnostics,
268 )
269}
270
271fn resolve_invocation(cli: Cli) -> Result<Invocation> {
272 if cli.command.is_some() && cli.path.as_path() != Path::new(".") {
273 bail!("do not place a path before `check`; use `cuke-dedup check <PATH>`");
274 }
275 let root = match cli.command {
276 Some(Command::Check { path }) => path,
277 None => cli.path,
278 };
279 let options = cli.options.clone();
280 let config = Config::load_for_cli(
281 &root,
282 ConfigOverrides {
283 config_file: cli.options.config,
284 definitions: cli.options.definitions,
285 features: cli.options.features,
286 exclude: cli.options.exclude,
287 exclude_defaults: cli.options.no_default_excludes.then_some(false),
288 include_hidden: cli.options.include_hidden.then_some(true),
289 reporters: cli.options.reporters,
290 output: cli.options.output,
291 threshold: cli.options.threshold,
292 require_features: cli.options.require_features.then_some(true),
293 no_metrics: cli.options.no_metrics.then_some(true),
294 rules: cli.options.rules.into_iter().collect::<BTreeMap<_, _>>(),
295 },
296 CliConfigOverrides {
297 require_definitions: cli.options.require_definitions.then_some(true),
298 fail_on_incomplete: cli.options.fail_on_incomplete.then_some(true),
299 fail_on_unparseable: cli.options.fail_on_unparseable.then_some(true),
300 max_candidate_comparisons: cli.options.max_candidate_comparisons,
301 max_structural_class_comparisons: cli.options.max_structural_class_comparisons,
302 },
303 )?;
304 Ok(Invocation { config, options })
305}
306
307fn resolve_changed_files(
308 config: &Config,
309 changed_since: Option<&str>,
310) -> Result<Option<BTreeSet<PathBuf>>> {
311 let Some(base) = changed_since else {
312 return Ok(None);
313 };
314 modes::ensure_changed_root_is_trackable(&config.root)?;
315 Ok(Some(modes::git_changed_files(&config.root, base)?))
316}
317
318fn discovery_diagnostics(
319 config: &Config,
320 files: &discovery::DiscoveredFiles,
321 changed_files: &Option<BTreeSet<PathBuf>>,
322) -> Diagnostics {
323 let mut diagnostics = Diagnostics {
324 warnings: config.config_warnings.clone(),
325 errors: files.errors.clone(),
326 };
327 for pattern in &files.unmatched_feature_patterns {
328 diagnostics.warnings.push(format!(
329 "feature pattern `{pattern}` from {} matched no files",
330 config.feature_pattern_origin
331 ));
332 }
333 for pattern in &files.unmatched_definition_patterns {
334 diagnostics
335 .warnings
336 .push(format!("definition pattern `{pattern}` matched no files"));
337 }
338 if files.definitions.is_empty() && config.definitions.is_empty() {
339 diagnostics
340 .warnings
341 .push("automatic discovery found no supported step-definition source files".to_owned());
342 }
343 if files.features.is_empty() {
344 let message = format!(
345 "no feature files matched patterns from {}; unused-definition findings are disabled for this incomplete corpus",
346 config.feature_pattern_origin
347 );
348 if config.require_features {
349 diagnostics.errors.push(message);
350 } else if !files.definitions.is_empty() {
351 diagnostics.warnings.push(message);
352 }
353 }
354 if changed_files.as_ref().is_some_and(BTreeSet::is_empty) {
355 diagnostics.warnings.push(
356 "--changed-since found no tracked or untracked files; no findings will be reported"
357 .to_owned(),
358 );
359 }
360 diagnostics
361}
362
363fn extract_corpus(
364 config: &Config,
365 files: &discovery::DiscoveredFiles,
366 changed_files: &Option<BTreeSet<PathBuf>>,
367 diagnostics: &mut Diagnostics,
368) -> ExtractedCorpus {
369 let mut corpus = ExtractedCorpus {
370 definitions: Vec::new(),
371 feature_steps: Vec::new(),
372 excluded_sources: Vec::new(),
373 definition_files_with_definitions: 0,
374 parsed_feature_files: 0,
375 feature_files_without_steps: 0,
376 incomplete: false,
377 };
378 extract_definitions(config, files, changed_files, diagnostics, &mut corpus);
379 extract_feature_steps(config, files, diagnostics, &mut corpus);
380 corpus
381}
382
383fn extract_definitions(
384 config: &Config,
385 files: &discovery::DiscoveredFiles,
386 changed_files: &Option<BTreeSet<PathBuf>>,
387 diagnostics: &mut Diagnostics,
388 corpus: &mut ExtractedCorpus,
389) {
390 let mut extraction_session = source_adapter::SourceExtractionSession::with_options(
391 &config.root,
392 &config.registrations,
393 &config.assertion_modules,
394 );
395 for file in &files.definitions {
396 if let Some(excluded) = crate::source_filter::inspect(&file.path, &config.registrations) {
400 corpus.incomplete = true;
406 corpus.excluded_sources.push((file.path.clone(), excluded));
407 continue;
408 }
409 let adapter = source_adapter::adapter_for_language(file.language);
410 match adapter
411 .extract_file_with_session(file, &mut extraction_session)
412 .with_context(|| format!("failed to analyze {}", file.path.display()))
413 {
414 Ok(extracted) => {
415 if !extracted.definitions.is_empty() {
416 corpus.definition_files_with_definitions += 1;
417 }
418 corpus.definitions.extend(extracted.definitions);
419 collect_extraction_diagnostics(
420 config,
421 file,
422 changed_files,
423 extracted.diagnostics,
424 diagnostics,
425 &mut corpus.incomplete,
426 );
427 }
428 Err(error) => diagnostics.errors.push(format!("{error:#}")),
432 }
433 }
434 corpus.definitions.sort_by(|left, right| {
435 left.location
436 .path
437 .cmp(&right.location.path)
438 .then(left.location.line.cmp(&right.location.line))
439 .then(left.location.column.cmp(&right.location.column))
440 });
441 report_excluded_sources(config, &corpus.excluded_sources, diagnostics);
442 if corpus.definitions.is_empty() {
443 let message = if files.definitions.is_empty() {
444 "no step definitions were extracted because no definition source files were discovered"
445 .to_owned()
446 } else {
447 format!(
448 "definition extraction produced 0 definitions from {} discovered definition source file(s)",
449 files.definitions.len()
450 )
451 };
452 if config.require_definitions {
453 diagnostics.errors.push(message);
454 } else if !files.definitions.is_empty() {
455 diagnostics.warnings.push(message);
456 }
457 }
458}
459
460const MAX_LISTED_EXCLUDED_SOURCES: usize = 10;
462
463fn report_excluded_sources(
469 config: &Config,
470 excluded: &[(PathBuf, crate::source_filter::ExcludedSource)],
471 diagnostics: &mut Diagnostics,
472) {
473 if excluded.is_empty() {
474 return;
475 }
476 let listed = excluded
477 .iter()
478 .take(MAX_LISTED_EXCLUDED_SOURCES)
479 .map(|(path, reason)| {
480 let relative = path
481 .strip_prefix(&config.root)
482 .unwrap_or(path)
483 .to_string_lossy()
484 .replace('\\', "/");
485 format!("{relative} ({})", reason.as_str())
486 })
487 .collect::<Vec<_>>()
488 .join(", ");
489 let remainder = excluded.len().saturating_sub(MAX_LISTED_EXCLUDED_SOURCES);
490 let suffix = if remainder == 0 {
491 String::new()
492 } else {
493 format!(", and {remainder} more")
494 };
495 diagnostics.warnings.push(format!(
496 "excluded {} discovered definition source file(s) as generated or non-source content: {listed}{suffix}",
497 excluded.len()
498 ));
499}
500
501fn collect_extraction_diagnostics(
502 config: &Config,
503 file: &source_adapter::SourceFile,
504 changed_files: &Option<BTreeSet<PathBuf>>,
505 extracted: Vec<source_adapter::ExtractionDiagnostic>,
506 diagnostics: &mut Diagnostics,
507 corpus_incomplete: &mut bool,
508) {
509 let changed_or_full_run = changed_files
510 .as_ref()
511 .is_none_or(|changed| changed.contains(&file.path));
512 for diagnostic in extracted {
513 let message = format!(
514 "{}: {}",
515 diagnostic.location.display(&config.root),
516 diagnostic.message
517 );
518 let completeness = source_adapter::is_completeness_diagnostic(&diagnostic);
519 let unparseable = source_adapter::is_unparseable_diagnostic(&diagnostic);
520 *corpus_incomplete |= completeness;
524 match diagnostic.level {
525 ExtractionDiagnosticLevel::Warning if unparseable && config.fail_on_unparseable => {
529 diagnostics.errors.push(message);
530 }
531 ExtractionDiagnosticLevel::Warning if changed_or_full_run || completeness => {
532 diagnostics.warnings.push(message);
533 }
534 ExtractionDiagnosticLevel::Warning => {}
535 ExtractionDiagnosticLevel::Error => diagnostics.errors.push(message),
538 }
539 }
540}
541
542fn extract_feature_steps(
543 config: &Config,
544 files: &discovery::DiscoveredFiles,
545 diagnostics: &mut Diagnostics,
546 corpus: &mut ExtractedCorpus,
547) {
548 for file in &files.features {
549 match gherkin::extract_file_with_format(&file.path, file.format) {
550 Ok(extracted) => {
551 corpus.parsed_feature_files += 1;
552 if extracted.is_empty()
553 && file.format == crate::gherkin::FeatureFormat::GherkinMarkdown
554 {
555 corpus.feature_files_without_steps += 1;
556 corpus.incomplete = true;
557 let display = file
558 .path
559 .strip_prefix(&config.root)
560 .unwrap_or(&file.path)
561 .to_string_lossy()
562 .replace('\\', "/");
563 diagnostics.warnings.push(format!(
564 "Gherkin Markdown file {display} parsed successfully but produced 0 feature steps; unused-definition findings are disabled for this incomplete corpus"
565 ));
566 } else {
567 corpus.feature_steps.extend(extracted);
568 }
569 }
570 Err(error) => diagnostics.errors.push(format!("{error:#}")),
572 }
573 }
574 if !files.features.is_empty() && corpus.parsed_feature_files < files.features.len() {
575 let message = if corpus.parsed_feature_files == 0 {
576 "no discovered feature file was parsed successfully; unused-definition findings are disabled for this incomplete corpus".to_owned()
577 } else {
578 format!(
579 "{} discovered feature file(s) could not be parsed; unused-definition findings are disabled for this incomplete corpus",
580 files.features.len() - corpus.parsed_feature_files
581 )
582 };
583 if config.require_features {
584 diagnostics.errors.push(message);
585 } else if !files.definitions.is_empty() {
586 diagnostics.warnings.push(message);
587 }
588 }
589 corpus.feature_steps.sort_by(|left, right| {
590 left.location
591 .path
592 .cmp(&right.location.path)
593 .then(left.location.line.cmp(&right.location.line))
594 .then(left.location.column.cmp(&right.location.column))
595 });
596}
597
598fn analyze_corpus(
599 config: &Config,
600 extracted: ExtractedCorpus,
601 diagnostics: &mut Diagnostics,
602) -> Result<AnalyzedCorpus> {
603 let ExtractedCorpus {
604 definitions,
605 feature_steps,
606 excluded_sources: _,
608 definition_files_with_definitions,
609 parsed_feature_files,
610 feature_files_without_steps,
611 incomplete: corpus_incomplete,
612 } = extracted;
613 let (analysis, census, unmatched_suppressions) =
614 analysis::analyze_for_cli(definitions, feature_steps, config)?;
615 for index in unmatched_suppressions.indices {
616 let suppression = &config.suppressions[index];
617 diagnostics.warnings.push(format!(
618 "suppression {} for {} matched no step definitions",
619 index + 1,
620 suppression.rule
621 ));
622 }
623 if unmatched_suppressions.truncated {
624 diagnostics.warnings.push(
625 "unmatched suppression validation stopped after its safety limit; analysis findings are unaffected"
626 .to_owned(),
627 );
628 }
629
630 let run_incomplete = corpus_incomplete || !analysis.incomplete.is_empty();
633 if config.fail_on_incomplete {
634 diagnostics.errors.extend(analysis.incomplete);
635 if corpus_incomplete {
636 diagnostics.errors.push(
637 "input extraction did not fully represent every discovered definition or feature file, so the analyzed corpus is incomplete".to_owned(),
638 );
639 }
640 } else {
641 diagnostics.warnings.extend(analysis.incomplete);
642 }
643 Ok(AnalyzedCorpus {
644 result: analysis.result,
645 census,
646 definition_files_with_definitions,
647 parsed_feature_files,
648 feature_files_without_steps,
649 corpus_incomplete,
650 run_incomplete,
651 })
652}
653
654fn apply_finding_modes(
655 result: &mut crate::model::AnalysisResult,
656 files: &discovery::DiscoveredFiles,
657 changed_files: &Option<BTreeSet<PathBuf>>,
658 parsed_feature_files: usize,
659 feature_files_without_steps: usize,
660) {
661 if parsed_feature_files < files.features.len()
665 || feature_files_without_steps > 0
666 || files.features.is_empty()
667 {
668 result
669 .findings
670 .retain(|finding| finding.rule != Rule::UnusedDefinition);
671 }
672 if let Some(changed) = changed_files {
673 modes::retain_changed_findings(&mut result.findings, changed);
674 }
675}
676
677fn apply_baseline_mode(
678 config: &Config,
679 options: &CheckOptions,
680 result: &mut crate::model::AnalysisResult,
681 run_incomplete: bool,
682 diagnostics: &mut Diagnostics,
683) -> Result<Option<modes::BaselineOutcome>> {
684 if let Some(revision) = &options.baseline_from_ref {
685 let (_snapshot, root) = modes::baseline_snapshot(&config.root, revision)?;
686 let mut base_config = config.clone();
693 base_config.root = root;
694 let files = discovery::discover(&base_config)?;
695 let mut base_diagnostics = discovery_diagnostics(&base_config, &files, &None);
696 let extracted = extract_corpus(&base_config, &files, &None, &mut base_diagnostics);
697 let mut base = analyze_corpus(&base_config, extracted, &mut base_diagnostics)?;
698 if !base_diagnostics.errors.is_empty()
703 || (!files.definitions.is_empty() && base.result.definitions.is_empty())
704 || (files.features.is_empty() && !base.result.definitions.is_empty())
705 {
706 bail!(
707 "baseline revision `{revision}` is incomplete: {}",
708 base_diagnostics
709 .errors
710 .iter()
711 .chain(&base_diagnostics.warnings)
712 .cloned()
713 .collect::<Vec<_>>()
714 .join("; ")
715 );
716 }
717 if base.run_incomplete {
718 diagnostics.warnings.push(format!(
719 "baseline revision `{revision}` is incomplete, so a finding it did not extract may appear as new; pass --fail-on-incomplete to reject an incomplete baseline"
720 ));
721 }
722 apply_finding_modes(
723 &mut base.result,
724 &files,
725 &None,
726 base.parsed_feature_files,
727 base.feature_files_without_steps,
728 );
729 return modes::apply_reference_baseline(
730 &mut result.findings,
731 &base.result.findings,
732 revision,
733 )
734 .map(Some);
735 }
736 let Some(path) = &options.baseline else {
737 return Ok(None);
738 };
739 let path = if path.is_absolute() {
740 path.clone()
741 } else {
742 config.root.join(path)
743 };
744 if options.update_baseline && (run_incomplete || !diagnostics.errors.is_empty()) {
745 diagnostics.warnings.push(format!(
746 "baseline {} was not updated because analysis is incomplete",
747 path.display()
748 ));
749 return Ok(None);
750 }
751 let outcome = if options.update_baseline {
752 modes::update_baseline(&mut result.findings, &path)?
753 } else {
754 modes::apply_baseline(&mut result.findings, &path)?
755 };
756 if options.update_baseline {
757 diagnostics.warnings.push(format!(
758 "updated baseline {}: {} added, {} removed, {} total",
759 path.display(),
760 outcome.added,
761 outcome.removed,
762 outcome.suppressed
763 ));
764 }
765 Ok(Some(outcome))
766}
767
768fn write_reports(
769 config: &Config,
770 files: &discovery::DiscoveredFiles,
771 analyzed: &AnalyzedCorpus,
772 diagnostics: &Diagnostics,
773 timings: PhaseTimings,
774) -> Result<()> {
775 let corpus = reporters::CorpusCensus {
776 definition_files: files.definitions.len(),
777 definition_files_with_definitions: analyzed.definition_files_with_definitions,
778 definitions_extracted: analyzed.result.definitions.len(),
779 feature_files: files.features.len(),
780 feature_files_parsed: analyzed.parsed_feature_files,
781 feature_files_without_steps: analyzed.feature_files_without_steps,
782 incomplete: analyzed.corpus_incomplete,
783 };
784 let metrics = reporters::ExecutionMetrics::new(
785 files.definitions.len(),
786 files.features.len(),
787 timings.discovery_ms,
788 timings.parsing_ms,
789 timings.analysis_ms,
790 );
791 let report_metadata = reporters::CliReportMetadata {
792 corpus: &corpus,
793 analysis: &analyzed.census,
794 execution_successful: diagnostics.errors.is_empty() && !analyzed.run_incomplete,
796 };
797 let terminal_color = terminal_color_enabled(
798 io::stdout().is_terminal(),
799 std::env::var_os("NO_COLOR").as_deref(),
800 );
801 let mut stdout = io::stdout().lock();
802 reporters::write_cli_reports(
803 &analyzed.result,
804 config,
805 (!config.no_metrics).then_some(&metrics),
806 &report_metadata,
807 &mut stdout,
808 terminal_color,
809 )?;
810 Ok(())
811}
812
813fn terminal_color_enabled(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool {
814 is_terminal && !no_color.is_some_and(|value| !value.is_empty())
815}
816
817fn write_diagnostics(
818 config: &Config,
819 options: &CheckOptions,
820 files: &discovery::DiscoveredFiles,
821 diagnostics: &Diagnostics,
822) -> Result<()> {
823 if !options.explain_discovery
824 && diagnostics.warnings.is_empty()
825 && diagnostics.errors.is_empty()
826 {
827 return Ok(());
828 }
829 let mut stderr = io::stderr().lock();
830 if options.explain_discovery {
831 writeln!(
832 stderr,
833 "cuke-dedup: feature patterns: {} ({})",
834 config.features.join(", "),
835 config.feature_pattern_origin
836 )?;
837 for file in &files.features {
838 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
839 writeln!(
840 stderr,
841 "cuke-dedup: feature {} [{}] via {}",
842 relative.display(),
843 file.format.as_str(),
844 file.pattern
845 )?;
846 }
847 for file in &files.definitions {
848 let relative = file.path.strip_prefix(&config.root).unwrap_or(&file.path);
849 writeln!(stderr, "cuke-dedup: definition {}", relative.display())?;
850 }
851 }
852 for warning in &diagnostics.warnings {
853 writeln!(stderr, "cuke-dedup: warning: {warning}")?;
854 }
855 for error in &diagnostics.errors {
856 writeln!(stderr, "cuke-dedup: {error}")?;
857 }
858 Ok(())
859}
860
861fn determine_exit_code(
862 config: &Config,
863 options: &CheckOptions,
864 result: &crate::model::AnalysisResult,
865 baseline: Option<modes::BaselineOutcome>,
866 diagnostics: &Diagnostics,
867) -> Result<i32> {
868 if !diagnostics.errors.is_empty() {
869 return Ok(2);
870 }
871 let duplication = DuplicationThreshold::from_result(result, config.threshold);
872 let has_non_duplication_errors = result.findings.iter().any(|finding| {
873 finding.is_active()
874 && finding.severity == Severity::Error
875 && !finding.rule.contributes_to_duplication_threshold()
876 });
877 let new_findings_exceeded = options
878 .fail_on_new
879 .is_some_and(|limit| baseline.is_some_and(|outcome| outcome.new_findings > limit));
880 Ok(i32::from(
881 has_non_duplication_errors || !duplication.passed || new_findings_exceeded,
882 ))
883}
884
885fn elapsed_ms(started: Instant) -> f64 {
886 started.elapsed().as_secs_f64() * 1_000.0
887}
888
889fn parse_rule_override(value: &str) -> std::result::Result<(Rule, Severity), String> {
890 let (rule, severity) = value
891 .split_once('=')
892 .ok_or_else(|| "expected RULE=SEVERITY".to_owned())?;
893 Ok((rule.parse()?, severity.parse()?))
894}
895
896fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
897 value
898 .parse::<usize>()
899 .ok()
900 .filter(|value| *value > 0)
901 .ok_or_else(|| "expected an integer greater than zero".to_owned())
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907
908 #[test]
909 fn terminal_color_requires_tty_and_allows_no_color_opt_out() {
910 use std::ffi::OsStr;
911
912 assert!(terminal_color_enabled(true, None));
913 assert!(terminal_color_enabled(true, Some(OsStr::new(""))));
914 assert!(!terminal_color_enabled(true, Some(OsStr::new("1"))));
915 assert!(!terminal_color_enabled(false, None));
916 assert!(!terminal_color_enabled(false, Some(OsStr::new(""))));
917 }
918
919 #[test]
920 fn parses_rule_override() {
921 assert_eq!(
922 parse_rule_override("duplicate-matcher=warning"),
923 Ok((Rule::DuplicateMatcher, Severity::Warning))
924 );
925 assert!(parse_rule_override("unknown=error").is_err());
926 }
927
928 #[test]
929 fn candidate_limit_cli_values_must_be_positive() {
930 assert_eq!(parse_positive_usize("7"), Ok(7));
931 assert!(parse_positive_usize("0").is_err());
932 assert!(parse_positive_usize("-1").is_err());
933 assert!(parse_positive_usize("many").is_err());
934 }
935
936 #[test]
937 fn unparseable_diagnostics_are_tolerated_by_default_and_escalated_by_the_flag() {
938 use crate::discovery::SourceLanguage;
939 use crate::model::SourceLocation;
940 use crate::source_adapter::{ExtractionDiagnostic, SourceFile};
941
942 let directory = tempfile::tempdir().unwrap();
943 let file = SourceFile {
944 path: directory.path().join("steps.ts"),
945 language: SourceLanguage::TypeScript,
946 };
947 let unparseable = || {
948 ExtractionDiagnostic::new(
949 ExtractionDiagnosticLevel::Warning,
950 SourceLocation::new("steps.ts", 1, 1, 1, 1),
951 format!(
952 "{}, so analysis is incomplete",
953 source_adapter::UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX
954 ),
955 )
956 };
957 let route = |config: &Config, diagnostic: ExtractionDiagnostic| {
958 let mut diagnostics = Diagnostics {
959 warnings: Vec::new(),
960 errors: Vec::new(),
961 };
962 let mut incomplete = false;
963 collect_extraction_diagnostics(
964 config,
965 &file,
966 &None,
967 vec![diagnostic],
968 &mut diagnostics,
969 &mut incomplete,
970 );
971 (diagnostics, incomplete)
972 };
973
974 let mut config =
975 Config::load(directory.path(), ConfigOverrides::default()).expect("config loads");
976
977 let (diagnostics, incomplete) = route(&config, unparseable());
979 assert!(incomplete);
980 assert!(diagnostics.errors.is_empty());
981 assert_eq!(diagnostics.warnings.len(), 1);
982
983 config.fail_on_unparseable = true;
986 let (diagnostics, incomplete) = route(&config, unparseable());
987 assert!(incomplete);
988 assert_eq!(diagnostics.errors.len(), 1);
989 assert!(diagnostics.warnings.is_empty());
990
991 let dynamic = ExtractionDiagnostic::new(
994 ExtractionDiagnosticLevel::Warning,
995 SourceLocation::new("steps.ts", 2, 1, 2, 1),
996 "dynamic or unsupported step matcher cannot be analyzed statically",
997 );
998 let (diagnostics, incomplete) = route(&config, dynamic);
999 assert!(!incomplete);
1000 assert!(diagnostics.errors.is_empty());
1001 assert_eq!(diagnostics.warnings.len(), 1);
1002 }
1003
1004 #[test]
1005 fn supports_implicit_and_explicit_check_forms() {
1006 let implicit = Cli::try_parse_from(["cuke-dedup", ".", "--reporters", "json"])
1007 .expect("implicit invocation");
1008 assert!(implicit.command.is_none());
1009 let explicit = Cli::try_parse_from(["cuke-dedup", "check", ".", "--reporters", "json"])
1010 .expect("explicit invocation");
1011 assert!(matches!(explicit.command, Some(Command::Check { .. })));
1012 assert!(Cli::try_parse_from(["cuke-dedup", "check"]).is_err());
1013 }
1014}