1use crate::framework_config;
4use crate::model::{Rule, Severity};
5use crate::resource_limits::{
6 is_input_limit_error, read_utf8, MAX_CANDIDATE_COMPARISONS, MAX_CONFIG_INPUT_BYTES,
7 MAX_STRUCTURAL_CLASS_COMPARISONS, MAX_SUPPRESSION_REASON_CHARS,
8};
9use anyhow::{bail, Context, Result};
10use globset::Glob;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::{Component, Path, PathBuf};
14use std::str::FromStr;
15
16const DEFAULT_EXCLUDES: [&str; 5] = [
17 "**/node_modules/**",
18 "**/target/**",
19 "**/.features-gen/**",
20 "**/test-results/**",
21 "**/playwright-report/**",
22];
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25#[non_exhaustive]
27pub enum ReporterKind {
28 Terminal,
30 Json,
32 Jsonl,
34 Html,
36 Sarif,
38}
39
40impl FromStr for ReporterKind {
41 type Err = String;
42
43 fn from_str(value: &str) -> Result<Self, Self::Err> {
44 match value {
45 "terminal" => Ok(Self::Terminal),
46 "json" => Ok(Self::Json),
47 "jsonl" => Ok(Self::Jsonl),
48 "html" => Ok(Self::Html),
49 "sarif" => Ok(Self::Sarif),
50 _ => Err(format!("unknown reporter `{value}`")),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Deserialize, Default)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57struct RawConfig {
58 #[serde(default)]
59 definitions: Option<Vec<String>>,
60 #[serde(default)]
61 features: Option<Vec<String>>,
62 #[serde(default)]
63 exclude: Option<Vec<String>>,
64 #[serde(default)]
65 exclude_defaults: Option<bool>,
66 #[serde(default)]
67 include_hidden: Option<bool>,
68 #[serde(default)]
69 reporters: Option<Vec<ReporterKind>>,
70 #[serde(default)]
71 output: Option<PathBuf>,
72 #[serde(default)]
73 threshold: Option<f64>,
74 #[serde(default)]
75 require_features: Option<bool>,
76 #[serde(default)]
77 require_definitions: Option<bool>,
78 #[serde(default)]
79 fail_on_incomplete: Option<bool>,
80 #[serde(default)]
81 registrations: Option<Vec<String>>,
82 #[serde(default)]
83 parameter_types: Option<BTreeMap<String, String>>,
84 #[serde(default)]
85 max_candidate_comparisons: Option<usize>,
86 #[serde(default)]
87 max_structural_class_comparisons: Option<usize>,
88 #[serde(default)]
89 no_metrics: Option<bool>,
90 #[serde(default)]
91 rules: Option<BTreeMap<String, Severity>>,
92 #[serde(default)]
93 suppressions: Option<Vec<SuppressionConfig>>,
94}
95
96#[derive(Debug, Clone, Deserialize, Default)]
97#[serde(rename_all = "camelCase")]
98struct PackageJson {
99 #[serde(default)]
100 cuke_dedup: Option<RawConfig>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase", deny_unknown_fields)]
105pub struct SuppressionConfig {
107 pub rule: Rule,
109 pub reason: String,
111 #[serde(default)]
112 pub path: Option<String>,
114 #[serde(default)]
115 pub matcher: Option<String>,
117}
118
119#[derive(Debug, Clone, Default)]
120pub struct ConfigOverrides {
122 pub config_file: Option<PathBuf>,
124 pub definitions: Option<Vec<String>>,
126 pub features: Option<Vec<String>>,
128 pub exclude: Option<Vec<String>>,
130 pub exclude_defaults: Option<bool>,
132 pub include_hidden: Option<bool>,
134 pub reporters: Option<Vec<ReporterKind>>,
136 pub output: Option<PathBuf>,
138 pub threshold: Option<f64>,
140 pub require_features: Option<bool>,
142 pub no_metrics: Option<bool>,
144 pub rules: BTreeMap<Rule, Severity>,
146}
147
148#[derive(Debug, Clone, Default)]
149pub(crate) struct CliConfigOverrides {
150 pub(crate) require_definitions: Option<bool>,
151 pub(crate) fail_on_incomplete: Option<bool>,
153 pub(crate) max_candidate_comparisons: Option<usize>,
154 pub(crate) max_structural_class_comparisons: Option<usize>,
155}
156
157#[derive(Debug, Clone, Serialize)]
158#[serde(rename_all = "camelCase")]
159pub struct Config {
161 pub root: PathBuf,
163 pub definitions: Vec<String>,
165 pub features: Vec<String>,
167 pub feature_pattern_origin: String,
169 pub config_warnings: Vec<String>,
171 pub config_source: String,
173 pub exclude: Vec<String>,
175 pub exclude_defaults: bool,
177 pub include_hidden: bool,
179 pub reporters: Vec<ReporterKind>,
181 pub output: PathBuf,
183 pub threshold: f64,
185 pub require_features: bool,
187 pub require_definitions: bool,
189 pub fail_on_incomplete: bool,
191 pub registrations: Vec<String>,
193 pub parameter_types: BTreeMap<String, String>,
195 pub max_candidate_comparisons: usize,
197 pub max_structural_class_comparisons: usize,
199 pub no_metrics: bool,
201 pub rules: BTreeMap<Rule, Severity>,
203 pub suppressions: Vec<SuppressionConfig>,
205 #[serde(skip)]
206 custom_exclude: Vec<String>,
207 #[serde(skip)]
208 output_from_project_config: bool,
209}
210
211impl Config {
212 pub fn load(root: &Path, overrides: ConfigOverrides) -> Result<Self> {
215 Self::load_internal(root, overrides, CliConfigOverrides::default())
216 }
217
218 pub(crate) fn load_for_cli(
219 root: &Path,
220 overrides: ConfigOverrides,
221 cli_overrides: CliConfigOverrides,
222 ) -> Result<Self> {
223 Self::load_internal(root, overrides, cli_overrides)
224 }
225
226 fn load_internal(
227 root: &Path,
228 overrides: ConfigOverrides,
229 cli_overrides: CliConfigOverrides,
230 ) -> Result<Self> {
231 let root = normalize_platform_path(
232 root.canonicalize()
233 .with_context(|| format!("cannot access target directory {}", root.display()))?,
234 );
235 if !root.is_dir() {
236 bail!("target {} is not a directory", root.display());
237 }
238
239 let package_path = root.join("package.json");
240 let (package_config, package_json) = if package_path.is_file() {
241 let text = read_utf8(
242 &package_path,
243 "package configuration",
244 MAX_CONFIG_INPUT_BYTES,
245 )?;
246 let value = serde_json::from_str::<serde_json::Value>(&text)
248 .with_context(|| format!("failed to parse {}", package_path.display()))?;
249 let package = serde_json::from_value::<PackageJson>(value.clone())
250 .with_context(|| format!("failed to parse {}", package_path.display()))?;
251 (package.cuke_dedup, Some(value))
252 } else {
253 (None, None)
254 };
255
256 let explicit_config = overrides.config_file.as_ref().map(|path| {
257 if path.is_absolute() {
258 path.clone()
259 } else {
260 root.join(path)
261 }
262 });
263 let mut config_warnings = Vec::new();
264 let (standalone_config, standalone_path) = if let Some(path) = explicit_config {
265 (Some(read_raw_config(&path)?), Some(path))
266 } else {
267 discover_config_file(&root, &mut config_warnings)?
268 };
269
270 let package_config = if standalone_config.is_none() {
271 package_config
272 } else {
273 None
274 };
275
276 let has_explicit_features = overrides.features.is_some()
277 || standalone_config
278 .as_ref()
279 .is_some_and(|config| config.features.is_some())
280 || package_config
281 .as_ref()
282 .is_some_and(|config| config.features.is_some());
283 let mut config = Self::defaults(root);
284 config.config_warnings = config_warnings;
285 if !has_explicit_features {
286 match framework_config::detect(&config.root, package_json.as_ref()) {
287 Ok(Some(framework)) => {
288 config.features = framework.patterns;
289 config.feature_pattern_origin = format!(
290 "{} configuration {}",
291 framework.framework,
292 framework
293 .source
294 .strip_prefix(&config.root)
295 .unwrap_or(&framework.source)
296 .display()
297 );
298 config.config_warnings.extend(framework.warnings);
299 }
300 Ok(None) => {}
301 Err(error) if is_input_limit_error(&error) => return Err(error),
302 Err(error) => config.config_warnings.push(format!(
303 "could not inspect framework configuration ({error:#}); using built-in feature patterns"
304 )),
305 }
306 }
307 if let Some(raw) = package_config {
308 let replaces_features = raw.features.is_some();
309 config.apply_raw(raw)?;
310 config.config_source = "package.json#cukeDedup".to_owned();
311 if replaces_features {
312 config.feature_pattern_origin = "package.json#cukeDedup".to_owned();
313 }
314 }
315 if let Some(raw) = standalone_config {
316 let replaces_features = raw.features.is_some();
317 config.apply_raw(raw)?;
318 config.config_source = standalone_path
319 .as_ref()
320 .map(|path| config_source_name(path, &config.root))
321 .unwrap_or_else(|| ".cuke-dedup.json".to_owned());
322 if replaces_features {
323 config.feature_pattern_origin = config.config_source.clone();
324 }
325 }
326 let cli_replaces_features = overrides.features.is_some();
327 config.apply_overrides(overrides);
328 if let Some(value) = cli_overrides.require_definitions {
329 config.require_definitions = value;
330 }
331 if let Some(value) = cli_overrides.fail_on_incomplete {
332 config.fail_on_incomplete = value;
333 }
334 if let Some(value) = cli_overrides.max_candidate_comparisons {
335 config.max_candidate_comparisons = value;
336 }
337 if let Some(value) = cli_overrides.max_structural_class_comparisons {
338 config.max_structural_class_comparisons = value;
339 }
340 if cli_replaces_features {
341 config.feature_pattern_origin = "--features".to_owned();
342 }
343 config.validate()?;
344 Ok(config)
345 }
346
347 fn defaults(root: PathBuf) -> Self {
348 let rules = [
349 (Rule::DuplicateMatcher, Severity::Error),
350 (Rule::NormalizedMatcher, Severity::Error),
351 (Rule::AmbiguousStep, Severity::Error),
352 (Rule::OverlappingMatcher, Severity::Warning),
353 (Rule::DuplicateHandler, Severity::Error),
354 (Rule::NearDuplicateStep, Severity::Warning),
355 (Rule::ParameterizationCandidate, Severity::Warning),
356 (Rule::UnusedDefinition, Severity::Warning),
357 ]
358 .into_iter()
359 .collect();
360 Self {
361 root,
362 definitions: Vec::new(),
363 features: vec!["**/*.{feature,feature.md}".to_owned()],
364 feature_pattern_origin: "built-in defaults".to_owned(),
365 config_warnings: Vec::new(),
366 config_source: "built-in defaults".to_owned(),
367 exclude: DEFAULT_EXCLUDES.iter().map(ToString::to_string).collect(),
368 exclude_defaults: true,
369 include_hidden: false,
370 reporters: vec![ReporterKind::Terminal],
371 output: PathBuf::from("reports/cuke-dedup"),
372 threshold: 0.0,
373 require_features: false,
374 require_definitions: false,
375 fail_on_incomplete: false,
376 registrations: Vec::new(),
377 parameter_types: BTreeMap::new(),
378 max_candidate_comparisons: MAX_CANDIDATE_COMPARISONS,
379 max_structural_class_comparisons: MAX_STRUCTURAL_CLASS_COMPARISONS,
380 no_metrics: false,
381 rules,
382 suppressions: Vec::new(),
383 custom_exclude: Vec::new(),
384 output_from_project_config: false,
385 }
386 }
387
388 fn apply_raw(&mut self, raw: RawConfig) -> Result<()> {
389 if let Some(value) = raw.definitions {
390 self.definitions = value;
391 }
392 if let Some(value) = raw.features {
393 self.features = value;
394 }
395 if let Some(value) = raw.exclude_defaults {
396 self.exclude_defaults = value;
397 }
398 if let Some(value) = raw.include_hidden {
399 self.include_hidden = value;
400 }
401 if let Some(value) = raw.exclude {
402 self.custom_exclude = value;
403 }
404 self.rebuild_excludes();
405 if let Some(value) = raw.reporters {
406 self.reporters = value;
407 }
408 if let Some(value) = raw.output {
409 self.output = value;
410 self.output_from_project_config = true;
411 }
412 if let Some(value) = raw.threshold {
413 self.threshold = value;
414 }
415 if let Some(value) = raw.require_features {
416 self.require_features = value;
417 }
418 if let Some(value) = raw.require_definitions {
419 self.require_definitions = value;
420 }
421 if let Some(value) = raw.fail_on_incomplete {
422 self.fail_on_incomplete = value;
423 }
424 if let Some(value) = raw.registrations {
425 self.registrations = value;
426 }
427 if let Some(value) = raw.parameter_types {
428 self.parameter_types = value;
429 }
430 if let Some(value) = raw.max_candidate_comparisons {
431 self.max_candidate_comparisons = value;
432 }
433 if let Some(value) = raw.max_structural_class_comparisons {
434 self.max_structural_class_comparisons = value;
435 }
436 if let Some(value) = raw.no_metrics {
437 self.no_metrics = value;
438 }
439 if let Some(value) = raw.rules {
440 for (name, severity) in value {
441 let rule = name.parse::<Rule>().map_err(anyhow::Error::msg)?;
442 self.rules.insert(rule, severity);
443 }
444 }
445 if let Some(value) = raw.suppressions {
446 self.suppressions = value;
447 }
448 Ok(())
449 }
450
451 fn apply_overrides(&mut self, overrides: ConfigOverrides) {
452 if let Some(value) = overrides.definitions {
453 self.definitions = value;
454 }
455 if let Some(value) = overrides.features {
456 self.features = value;
457 }
458 if let Some(value) = overrides.exclude_defaults {
459 self.exclude_defaults = value;
460 }
461 if let Some(value) = overrides.include_hidden {
462 self.include_hidden = value;
463 }
464 if let Some(value) = overrides.exclude {
465 self.custom_exclude = value;
466 }
467 self.rebuild_excludes();
468 if let Some(value) = overrides.reporters {
469 self.reporters = value;
470 }
471 if let Some(value) = overrides.output {
472 self.output = value;
473 self.output_from_project_config = false;
474 }
475 if let Some(value) = overrides.threshold {
476 self.threshold = value;
477 }
478 if let Some(value) = overrides.require_features {
479 self.require_features = value;
480 }
481 if let Some(value) = overrides.no_metrics {
482 self.no_metrics = value;
483 }
484 self.rules.extend(overrides.rules);
485 }
486
487 fn rebuild_excludes(&mut self) {
488 self.exclude.clear();
489 if self.exclude_defaults {
490 self.exclude
491 .extend(DEFAULT_EXCLUDES.iter().map(ToString::to_string));
492 }
493 self.exclude.extend(self.custom_exclude.iter().cloned());
494 }
495
496 fn validate(&self) -> Result<()> {
497 if self.reporters.is_empty() {
498 bail!("at least one reporter must be configured");
499 }
500 if self.reporters.contains(&ReporterKind::Terminal)
501 && self.reporters.contains(&ReporterKind::Jsonl)
502 {
503 bail!("terminal and jsonl reporters cannot share stdout; select only one of them");
504 }
505 if !self.threshold.is_finite() || !(0.0..=100.0).contains(&self.threshold) {
506 bail!("threshold must be a finite percentage from 0 through 100");
507 }
508 for (name, pattern) in &self.parameter_types {
509 if name.is_empty() || name.contains(['{', '}']) {
510 bail!("parameter type `{name}` must not be empty or contain braces");
511 }
512 crate::resource_limits::compile_regex(pattern).with_context(|| {
513 format!("invalid regular expression for parameter type `{name}`")
514 })?;
515 }
516 for name in &self.registrations {
517 if name.is_empty()
518 || !name
519 .chars()
520 .all(|character| character.is_alphanumeric() || matches!(character, '_' | '$'))
521 || name.starts_with(|character: char| character.is_ascii_digit())
522 {
523 bail!("registration `{name}` must be a JavaScript identifier");
524 }
525 }
526 self.validate_analysis_limits()?;
527 if self.output_from_project_config {
528 validate_project_output(&self.root, &self.output)?;
529 }
530 for suppression in &self.suppressions {
531 if suppression.reason.trim().is_empty() {
532 bail!(
533 "suppression for {} must include a non-empty reason",
534 suppression.rule
535 );
536 }
537 if suppression.reason.chars().count() > MAX_SUPPRESSION_REASON_CHARS {
538 bail!(
539 "suppression reason for {} exceeds the {}-character limit",
540 suppression.rule,
541 MAX_SUPPRESSION_REASON_CHARS
542 );
543 }
544 if suppression.path.is_none() && suppression.matcher.is_none() {
545 bail!(
546 "suppression for {} must select a path or matcher",
547 suppression.rule
548 );
549 }
550 if let Some(pattern) = &suppression.path {
551 Glob::new(pattern).with_context(|| {
552 format!(
553 "invalid suppression path glob `{pattern}` for {}",
554 suppression.rule
555 )
556 })?;
557 }
558 }
559 Ok(())
560 }
561
562 pub(crate) fn validate_analysis_limits(&self) -> Result<()> {
563 if self.max_candidate_comparisons == 0 {
564 bail!("maxCandidateComparisons must be greater than zero");
565 }
566 if self.max_candidate_comparisons > MAX_CANDIDATE_COMPARISONS {
567 bail!(
568 "maxCandidateComparisons must not exceed the hard safety limit of {MAX_CANDIDATE_COMPARISONS}"
569 );
570 }
571 if self.max_structural_class_comparisons == 0 {
572 bail!("maxStructuralClassComparisons must be greater than zero");
573 }
574 if self.max_structural_class_comparisons > MAX_STRUCTURAL_CLASS_COMPARISONS {
575 bail!(
576 "maxStructuralClassComparisons must not exceed the hard safety limit of {MAX_STRUCTURAL_CLASS_COMPARISONS}"
577 );
578 }
579 Ok(())
580 }
581
582 pub fn severity(&self, rule: Rule) -> Severity {
584 self.rules.get(&rule).copied().unwrap_or(Severity::Off)
585 }
586
587 pub fn output_path(&self, filename: &str) -> PathBuf {
589 if self.output.is_absolute() {
590 self.output.join(filename)
591 } else {
592 self.root.join(&self.output).join(filename)
593 }
594 }
595}
596
597fn validate_project_output(root: &Path, output: &Path) -> Result<()> {
598 if output.is_absolute()
599 || output.components().any(|component| {
600 matches!(
601 component,
602 Component::ParentDir | Component::RootDir | Component::Prefix(_)
603 )
604 })
605 {
606 bail!(
607 "project configuration output `{}` must stay beneath the analyzed root; use --output for an operator-controlled path",
608 output.display()
609 );
610 }
611
612 let candidate = root.join(output);
613 let mut existing_ancestor = candidate.as_path();
614 while !existing_ancestor.exists() {
615 existing_ancestor = existing_ancestor.parent().ok_or_else(|| {
616 anyhow::anyhow!(
617 "could not resolve project configuration output `{}`",
618 output.display()
619 )
620 })?;
621 }
622 let canonical_ancestor =
623 normalize_platform_path(existing_ancestor.canonicalize().with_context(|| {
624 format!(
625 "failed to validate project configuration output {}",
626 candidate.display()
627 )
628 })?);
629 if !canonical_ancestor.starts_with(root) {
630 bail!(
631 "project configuration output `{}` resolves outside the analyzed root; use --output for an operator-controlled path",
632 output.display()
633 );
634 }
635 Ok(())
636}
637
638pub(crate) fn normalize_platform_path(path: PathBuf) -> PathBuf {
639 #[cfg(windows)]
640 {
641 let value = path.to_string_lossy();
642 if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
643 return PathBuf::from(format!(r"\\{unc}"));
644 }
645 if let Some(local) = value.strip_prefix(r"\\?\") {
646 return PathBuf::from(local);
647 }
648 }
649 path
650}
651
652fn read_raw_config(path: &Path) -> Result<RawConfig> {
653 let text = read_utf8(path, "CukeDedup configuration", MAX_CONFIG_INPUT_BYTES)?;
654 serde_json::from_str::<RawConfig>(&text)
656 .with_context(|| format!("failed to parse {}", path.display()))
657}
658
659fn discover_config_file(
660 root: &Path,
661 warnings: &mut Vec<String>,
662) -> Result<(Option<RawConfig>, Option<PathBuf>)> {
663 const CANDIDATES: [&str; 4] = [
664 ".cuke-dedup.json",
665 ".config/cuke-dedup.json",
666 ".config/.cuke-dedup.json",
667 "cuke-dedup.config.json",
668 ];
669 for candidate in CANDIDATES {
670 let path = root.join(candidate);
671 if !path.is_file() {
672 continue;
673 }
674 match read_raw_config(&path) {
675 Ok(config) => return Ok((Some(config), Some(path))),
676 Err(error) if is_input_limit_error(&error) => return Err(error),
677 Err(error) => warnings.push(format!(
678 "could not load auto-discovered config {} ({error:#}); trying the next configuration source",
679 path.display()
680 )),
681 }
682 }
683 Ok((None, None))
684}
685
686fn config_source_name(path: &Path, root: &Path) -> String {
687 path.strip_prefix(root)
688 .unwrap_or(path)
689 .to_string_lossy()
690 .replace('\\', "/")
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use std::fs;
697
698 #[test]
699 fn standalone_config_overrides_package_and_cli_overrides_both() {
700 let directory = tempfile::tempdir().unwrap();
701 fs::write(
702 directory.path().join("package.json"),
703 r#"{"cukeDedup":{"reporters":["html"],"threshold":10,"rules":{"duplicate-matcher":"warning"}}}"#,
704 )
705 .unwrap();
706 fs::write(
707 directory.path().join("cuke-dedup.config.json"),
708 r#"{"reporters":["json"],"threshold":20,"rules":{"duplicate-matcher":"off"}}"#,
709 )
710 .unwrap();
711 let mut overrides = ConfigOverrides {
712 reporters: Some(vec![ReporterKind::Terminal]),
713 threshold: Some(30.0),
714 ..ConfigOverrides::default()
715 };
716 overrides
717 .rules
718 .insert(Rule::DuplicateMatcher, Severity::Error);
719
720 let config = Config::load(directory.path(), overrides).unwrap();
721 assert_eq!(config.reporters, vec![ReporterKind::Terminal]);
722 assert_eq!(config.threshold, 30.0);
723 assert_eq!(config.severity(Rule::DuplicateMatcher), Severity::Error);
724 }
725
726 #[test]
727 fn threshold_defaults_to_zero_and_rejects_out_of_range_values() {
728 let directory = tempfile::tempdir().unwrap();
729 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
730 assert_eq!(config.threshold, 0.0);
731
732 for threshold in [-0.1, 100.1, f64::NAN, f64::INFINITY] {
733 let error = Config::load(
734 directory.path(),
735 ConfigOverrides {
736 threshold: Some(threshold),
737 ..ConfigOverrides::default()
738 },
739 )
740 .unwrap_err();
741 assert!(error.to_string().contains("finite percentage"));
742 }
743 }
744
745 #[test]
746 fn configuration_rejects_empty_or_malformed_public_selectors() {
747 assert!("unknown"
748 .parse::<ReporterKind>()
749 .unwrap_err()
750 .contains("unknown reporter"));
751 let directory = tempfile::tempdir().unwrap();
752 let path = directory.path().join(".cuke-dedup.json");
753 for (source, expected) in [
754 (r#"{"reporters":[]}"#, "at least one reporter"),
755 (r#"{"parameterTypes":{"":"x"}}"#, "parameter type"),
756 (r#"{"parameterTypes":{"bad{type}":"x"}}"#, "parameter type"),
757 (
758 r#"{"parameterTypes":{"colour":"("}}"#,
759 "invalid regular expression",
760 ),
761 (r#"{"registrations":[""]}"#, "JavaScript identifier"),
762 (r#"{"registrations":["1step"]}"#, "JavaScript identifier"),
763 (
764 r#"{"registrations":["step-name"]}"#,
765 "JavaScript identifier",
766 ),
767 (
768 r#"{"suppressions":[{"rule":"duplicate-handler","reason":"because"}]}"#,
769 "select a path or matcher",
770 ),
771 ] {
772 fs::write(&path, source).unwrap();
773 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
774 assert!(error.to_string().contains(expected), "{source}: {error:#}");
775 }
776
777 fs::remove_file(path).unwrap();
778 let ordinary_file = directory.path().join("not-a-directory");
779 fs::write(&ordinary_file, "x").unwrap();
780 assert!(Config::load(&ordinary_file, ConfigOverrides::default())
781 .unwrap_err()
782 .to_string()
783 .contains("not a directory"));
784 }
785
786 #[test]
787 fn raw_configuration_applies_every_boolean_and_collection_field() {
788 let directory = tempfile::tempdir().unwrap();
789 fs::write(
790 directory.path().join(".cuke-dedup.json"),
791 r#"{
792 "definitions":["steps/**/*.ts"],
793 "features":["specs/**/*.feature"],
794 "excludeDefaults":false,
795 "includeHidden":true,
796 "exclude":["generated/**"],
797 "reporters":["html"],
798 "output":"artifacts",
799 "threshold":12,
800 "requireFeatures":true,
801 "requireDefinitions":true,
802 "failOnIncomplete":true,
803 "registrations":["step"],
804 "parameterTypes":{"colour":"red|green"},
805 "noMetrics":true,
806 "rules":{"unused-definition":"off"},
807 "suppressions":[{"rule":"unused-definition","matcher":"legacy","reason":"migration"}]
808 }"#,
809 )
810 .unwrap();
811 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
812 assert_eq!(config.definitions, ["steps/**/*.ts"]);
813 assert_eq!(config.features, ["specs/**/*.feature"]);
814 assert_eq!(config.exclude, ["generated/**"]);
815 assert!(!config.exclude_defaults);
816 assert!(config.include_hidden);
817 assert_eq!(config.reporters, [ReporterKind::Html]);
818 assert_eq!(
819 config.output_path("report.html"),
820 config.root.join("artifacts/report.html")
821 );
822 assert!(config.require_features && config.require_definitions && config.fail_on_incomplete);
823 assert_eq!(config.registrations, ["step"]);
824 assert_eq!(config.parameter_types["colour"], "red|green");
825 assert!(config.no_metrics);
826 assert_eq!(config.severity(Rule::UnusedDefinition), Severity::Off);
827 assert_eq!(config.suppressions.len(), 1);
828 }
829
830 #[test]
831 fn no_metrics_is_available_from_project_config_and_cli_override() {
832 let directory = tempfile::tempdir().unwrap();
833 fs::write(
834 directory.path().join(".cuke-dedup.json"),
835 r#"{"noMetrics":true}"#,
836 )
837 .unwrap();
838 let configured = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
839 assert!(configured.no_metrics);
840
841 let overridden = Config::load(
842 directory.path(),
843 ConfigOverrides {
844 no_metrics: Some(false),
845 ..ConfigOverrides::default()
846 },
847 )
848 .unwrap();
849 assert!(!overridden.no_metrics);
850 }
851
852 #[test]
853 fn require_definitions_is_available_from_project_config() {
854 let directory = tempfile::tempdir().unwrap();
855 fs::write(
856 directory.path().join(".cuke-dedup.json"),
857 r#"{"requireDefinitions":true}"#,
858 )
859 .unwrap();
860 let configured = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
861 assert!(configured.require_definitions);
862 }
863
864 #[test]
865 fn candidate_limits_can_be_lowered_but_cannot_bypass_hard_safety_ceilings() {
866 let directory = tempfile::tempdir().unwrap();
867 fs::write(
868 directory.path().join(".cuke-dedup.json"),
869 r#"{"maxCandidateComparisons":42,"maxStructuralClassComparisons":7}"#,
870 )
871 .unwrap();
872 let configured = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
873 assert_eq!(configured.max_candidate_comparisons, 42);
874 assert_eq!(configured.max_structural_class_comparisons, 7);
875
876 for (invalid, expected) in [
877 (
878 r#"{"maxCandidateComparisons":0}"#,
879 "maxCandidateComparisons must be greater than zero",
880 ),
881 (
882 r#"{"maxStructuralClassComparisons":0}"#,
883 "maxStructuralClassComparisons must be greater than zero",
884 ),
885 (
886 r#"{"maxCandidateComparisons":2000001}"#,
887 "maxCandidateComparisons must not exceed the hard safety limit of 2000000",
888 ),
889 (
890 r#"{"maxStructuralClassComparisons":250001}"#,
891 "maxStructuralClassComparisons must not exceed the hard safety limit of 250000",
892 ),
893 ] {
894 fs::write(directory.path().join(".cuke-dedup.json"), invalid).unwrap();
895 assert!(Config::load(directory.path(), ConfigOverrides::default())
896 .unwrap_err()
897 .to_string()
898 .contains(expected));
899 }
900
901 fs::write(
902 directory.path().join(".cuke-dedup.json"),
903 r#"{"maxCandidateComparisons":2000000,"maxStructuralClassComparisons":250000}"#,
904 )
905 .unwrap();
906 let exact_limits = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
907 assert_eq!(exact_limits.max_candidate_comparisons, 2_000_000);
908 assert_eq!(exact_limits.max_structural_class_comparisons, 250_000);
909 }
910
911 #[test]
912 fn project_configuration_output_cannot_escape_the_analysis_root() {
913 let directory = tempfile::tempdir().unwrap();
914 for output in ["../outside", "nested/../../outside"] {
915 fs::write(
916 directory.path().join(".cuke-dedup.json"),
917 format!(r#"{{"output":"{output}"}}"#),
918 )
919 .unwrap();
920 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
921 assert!(error.to_string().contains("must stay beneath"), "{error:#}");
922 }
923
924 fs::write(
925 directory.path().join(".cuke-dedup.json"),
926 r#"{"output":"/tmp/cuke-dedup-project-controlled"}"#,
927 )
928 .unwrap();
929 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
930 assert!(error.to_string().contains("must stay beneath"), "{error:#}");
931 }
932
933 #[test]
934 fn command_line_output_safely_overrides_an_invalid_project_value() {
935 let directory = tempfile::tempdir().unwrap();
936 fs::write(
937 directory.path().join(".cuke-dedup.json"),
938 r#"{"output":"../../project-controlled"}"#,
939 )
940 .unwrap();
941 let operator_output = directory.path().join("../operator-controlled");
942 let config = Config::load(
943 directory.path(),
944 ConfigOverrides {
945 output: Some(operator_output.clone()),
946 ..ConfigOverrides::default()
947 },
948 )
949 .unwrap();
950 assert_eq!(config.output, operator_output);
951 }
952
953 #[cfg(unix)]
954 #[test]
955 fn project_configuration_output_cannot_follow_a_symlink_outside_the_root() {
956 use std::os::unix::fs::symlink;
957
958 let directory = tempfile::tempdir().unwrap();
959 let outside = tempfile::tempdir().unwrap();
960 symlink(outside.path(), directory.path().join("reports-link")).unwrap();
961 fs::write(
962 directory.path().join(".cuke-dedup.json"),
963 r#"{"output":"reports-link"}"#,
964 )
965 .unwrap();
966
967 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
968 assert!(error.to_string().contains("resolves outside"), "{error:#}");
969 }
970
971 #[test]
972 fn suppression_requires_a_reason() {
973 let directory = tempfile::tempdir().unwrap();
974 fs::write(
975 directory.path().join("cuke-dedup.config.json"),
976 r#"{"suppressions":[{"rule":"duplicate-handler","path":"steps.ts","reason":""}]}"#,
977 )
978 .unwrap();
979 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
980 assert!(error.to_string().contains("non-empty reason"));
981 }
982
983 #[test]
984 fn suppression_reasons_have_a_bounded_payload() {
985 let directory = tempfile::tempdir().unwrap();
986 let reason = "x".repeat(MAX_SUPPRESSION_REASON_CHARS + 1);
987 fs::write(
988 directory.path().join("cuke-dedup.config.json"),
989 format!(
990 r#"{{"suppressions":[{{"rule":"duplicate-handler","path":"steps.ts","reason":"{reason}"}}]}}"#
991 ),
992 )
993 .unwrap();
994 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
995 assert!(error.to_string().contains("512-character limit"));
996 }
997
998 #[test]
999 fn configured_excludes_extend_safety_defaults() {
1000 let directory = tempfile::tempdir().unwrap();
1001 fs::write(
1002 directory.path().join("cuke-dedup.config.json"),
1003 r#"{"exclude":["dist/**"]}"#,
1004 )
1005 .unwrap();
1006 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1007 assert!(config.exclude.contains(&"**/node_modules/**".to_owned()));
1008 assert!(config.exclude.contains(&"dist/**".to_owned()));
1009 }
1010
1011 #[test]
1012 fn exclude_layers_replace_custom_patterns_but_preserve_default_policy() {
1013 let directory = tempfile::tempdir().unwrap();
1014 fs::write(
1015 directory.path().join("package.json"),
1016 r#"{"cukeDedup":{"exclude":["package-only/**"]}}"#,
1017 )
1018 .unwrap();
1019 fs::write(
1020 directory.path().join("cuke-dedup.config.json"),
1021 r#"{"exclude":["standalone-only/**"]}"#,
1022 )
1023 .unwrap();
1024 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1025 assert!(config.exclude.contains(&"**/node_modules/**".to_owned()));
1026 assert!(config.exclude.contains(&"standalone-only/**".to_owned()));
1027 assert!(!config.exclude.contains(&"package-only/**".to_owned()));
1028
1029 let config = Config::load(
1030 directory.path(),
1031 ConfigOverrides {
1032 exclude: Some(vec!["cli-only/**".to_owned()]),
1033 ..ConfigOverrides::default()
1034 },
1035 )
1036 .unwrap();
1037 assert!(config.exclude.contains(&"cli-only/**".to_owned()));
1038 assert!(!config.exclude.contains(&"standalone-only/**".to_owned()));
1039 }
1040
1041 #[test]
1042 fn default_excludes_and_hidden_directories_have_explicit_escape_hatches() {
1043 let directory = tempfile::tempdir().unwrap();
1044 fs::write(
1045 directory.path().join("cuke-dedup.config.json"),
1046 r#"{"excludeDefaults":false,"includeHidden":true}"#,
1047 )
1048 .unwrap();
1049 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1050 assert!(!config.exclude_defaults);
1051 assert!(config.exclude.is_empty());
1052 assert!(config.include_hidden);
1053 }
1054
1055 #[test]
1056 fn jsonl_is_configurable_but_cannot_be_mixed_with_terminal_output() {
1057 let directory = tempfile::tempdir().unwrap();
1058 fs::write(
1059 directory.path().join(".cuke-dedup.json"),
1060 r#"{"reporters":["jsonl","json"]}"#,
1061 )
1062 .unwrap();
1063 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1064 assert_eq!(
1065 config.reporters,
1066 vec![ReporterKind::Jsonl, ReporterKind::Json]
1067 );
1068
1069 fs::write(
1070 directory.path().join(".cuke-dedup.json"),
1071 r#"{"reporters":["terminal","jsonl"]}"#,
1072 )
1073 .unwrap();
1074 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
1075 assert!(error
1076 .to_string()
1077 .contains("terminal and jsonl reporters cannot share stdout"));
1078 }
1079
1080 #[test]
1081 fn invalid_suppression_glob_is_rejected_during_config_load() {
1082 let directory = tempfile::tempdir().unwrap();
1083 fs::write(
1084 directory.path().join("cuke-dedup.config.json"),
1085 r#"{"suppressions":[{"rule":"duplicate-handler","path":"[","reason":"test"}]}"#,
1086 )
1087 .unwrap();
1088 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
1089 assert!(error.to_string().contains("invalid suppression path glob"));
1090 }
1091
1092 #[test]
1093 fn cucumber_config_supplies_defaults_below_cuke_dedup_configuration() {
1094 let directory = tempfile::tempdir().unwrap();
1095 fs::write(
1096 directory.path().join("cucumber.json"),
1097 r#"{"default":{"paths":["acceptance"]}}"#,
1098 )
1099 .unwrap();
1100 let framework = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1101 assert_eq!(
1102 framework.features,
1103 vec!["acceptance/**/*.{feature,feature.md}"]
1104 );
1105 assert!(framework.feature_pattern_origin.starts_with("Cucumber.js"));
1106
1107 fs::write(
1108 directory.path().join("cuke-dedup.config.json"),
1109 r#"{"features":["custom/**/*.spec"]}"#,
1110 )
1111 .unwrap();
1112 let own_config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1113 assert_eq!(own_config.features, vec!["custom/**/*.spec"]);
1114 assert_eq!(own_config.feature_pattern_origin, "cuke-dedup.config.json");
1115
1116 let cli = Config::load(
1117 directory.path(),
1118 ConfigOverrides {
1119 features: Some(vec!["cli/**/*.feature".to_owned()]),
1120 ..ConfigOverrides::default()
1121 },
1122 )
1123 .unwrap();
1124 assert_eq!(cli.features, vec!["cli/**/*.feature"]);
1125 assert_eq!(cli.feature_pattern_origin, "--features");
1126 }
1127
1128 #[test]
1129 fn playwright_bdd_literal_features_are_detected_without_executing_config() {
1130 let directory = tempfile::tempdir().unwrap();
1131 fs::write(
1132 directory.path().join("playwright.config.ts"),
1133 "const testDir = defineBddConfig({ features: ['specs/**/*.spec'] });",
1134 )
1135 .unwrap();
1136 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1137 assert_eq!(config.features, vec!["specs/**/*.spec"]);
1138 assert!(config.feature_pattern_origin.starts_with("Playwright-BDD"));
1139 }
1140
1141 #[test]
1142 fn cucumber_yaml_paths_are_detected() {
1143 let directory = tempfile::tempdir().unwrap();
1144 fs::write(
1145 directory.path().join("cucumber.yml"),
1146 "default:\n paths:\n - acceptance\n - docs/**/*.feature.md\n",
1147 )
1148 .unwrap();
1149 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1150 assert_eq!(
1151 config.features,
1152 vec![
1153 "acceptance/**/*.{feature,feature.md}",
1154 "docs/**/*.feature.md"
1155 ]
1156 );
1157 }
1158
1159 #[test]
1160 fn explicit_features_skip_invalid_lower_precedence_framework_config() {
1161 let directory = tempfile::tempdir().unwrap();
1162 fs::write(directory.path().join("cucumber.json"), "not json").unwrap();
1163 fs::write(
1164 directory.path().join("cuke-dedup.config.json"),
1165 r#"{"features":["custom/**/*.spec"]}"#,
1166 )
1167 .unwrap();
1168 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1169 assert_eq!(config.features, vec!["custom/**/*.spec"]);
1170 assert!(config.config_warnings.is_empty());
1171 }
1172
1173 #[test]
1174 fn jscpd_style_config_discovery_uses_one_source_in_documented_order() {
1175 let directory = tempfile::tempdir().unwrap();
1176 fs::create_dir_all(directory.path().join(".config")).unwrap();
1177 fs::write(
1178 directory.path().join("package.json"),
1179 r#"{"cukeDedup":{"threshold":10}}"#,
1180 )
1181 .unwrap();
1182 fs::write(
1183 directory.path().join(".config/cuke-dedup.json"),
1184 r#"{"threshold":20}"#,
1185 )
1186 .unwrap();
1187 fs::write(
1188 directory.path().join(".cuke-dedup.json"),
1189 r#"{"threshold":30}"#,
1190 )
1191 .unwrap();
1192
1193 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1194 assert_eq!(config.threshold, 30.0);
1195 }
1196
1197 #[test]
1198 fn explicit_config_is_fatal_and_bypasses_auto_discovery() {
1199 let directory = tempfile::tempdir().unwrap();
1200 fs::write(
1201 directory.path().join(".cuke-dedup.json"),
1202 r#"{"threshold":10}"#,
1203 )
1204 .unwrap();
1205 let explicit = directory.path().join("team-policy.json");
1206 fs::write(&explicit, r#"{"threshold":40}"#).unwrap();
1207 let config = Config::load(
1208 directory.path(),
1209 ConfigOverrides {
1210 config_file: Some(explicit),
1211 ..ConfigOverrides::default()
1212 },
1213 )
1214 .unwrap();
1215 assert_eq!(config.threshold, 40.0);
1216
1217 let relative = Config::load(
1218 directory.path(),
1219 ConfigOverrides {
1220 config_file: Some(PathBuf::from("team-policy.json")),
1221 ..ConfigOverrides::default()
1222 },
1223 )
1224 .unwrap();
1225 assert_eq!(relative.threshold, 40.0);
1226
1227 let error = Config::load(
1228 directory.path(),
1229 ConfigOverrides {
1230 config_file: Some(directory.path().join("missing.json")),
1231 ..ConfigOverrides::default()
1232 },
1233 )
1234 .unwrap_err();
1235 assert!(error.to_string().contains("failed to read"));
1236 }
1237
1238 #[test]
1239 fn malformed_auto_config_warns_and_falls_through_to_package_config() {
1240 let directory = tempfile::tempdir().unwrap();
1241 fs::write(directory.path().join(".cuke-dedup.json"), "not json").unwrap();
1242 fs::write(
1243 directory.path().join("package.json"),
1244 r#"{"cukeDedup":{"threshold":25}}"#,
1245 )
1246 .unwrap();
1247 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
1248 assert_eq!(config.threshold, 25.0);
1249 assert_eq!(config.config_warnings.len(), 1);
1250 }
1251}