1use crate::framework_config;
4use crate::model::{Rule, Severity};
5use anyhow::{bail, Context, Result};
6use globset::Glob;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11use std::str::FromStr;
12
13const DEFAULT_EXCLUDES: [&str; 5] = [
14 "**/node_modules/**",
15 "**/target/**",
16 "**/.features-gen/**",
17 "**/test-results/**",
18 "**/playwright-report/**",
19];
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ReporterKind {
25 Terminal,
27 Json,
29 Jsonl,
31 Html,
33 Sarif,
35}
36
37impl FromStr for ReporterKind {
38 type Err = String;
39
40 fn from_str(value: &str) -> Result<Self, Self::Err> {
41 match value {
42 "terminal" => Ok(Self::Terminal),
43 "json" => Ok(Self::Json),
44 "jsonl" => Ok(Self::Jsonl),
45 "html" => Ok(Self::Html),
46 "sarif" => Ok(Self::Sarif),
47 _ => Err(format!("unknown reporter `{value}`")),
48 }
49 }
50}
51
52#[derive(Debug, Clone, Deserialize, Default)]
53#[serde(rename_all = "camelCase", deny_unknown_fields)]
54struct RawConfig {
55 #[serde(default)]
56 definitions: Option<Vec<String>>,
57 #[serde(default)]
58 features: Option<Vec<String>>,
59 #[serde(default)]
60 exclude: Option<Vec<String>>,
61 #[serde(default)]
62 exclude_defaults: Option<bool>,
63 #[serde(default)]
64 include_hidden: Option<bool>,
65 #[serde(default)]
66 reporters: Option<Vec<ReporterKind>>,
67 #[serde(default)]
68 output: Option<PathBuf>,
69 #[serde(default)]
70 threshold: Option<f64>,
71 #[serde(default)]
72 require_features: Option<bool>,
73 #[serde(default)]
74 no_metrics: Option<bool>,
75 #[serde(default)]
76 rules: Option<BTreeMap<String, Severity>>,
77 #[serde(default)]
78 suppressions: Option<Vec<SuppressionConfig>>,
79}
80
81#[derive(Debug, Clone, Deserialize, Default)]
82#[serde(rename_all = "camelCase")]
83struct PackageJson {
84 #[serde(default)]
85 cuke_dedup: Option<RawConfig>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90pub struct SuppressionConfig {
92 pub rule: Rule,
94 pub reason: String,
96 #[serde(default)]
97 pub path: Option<String>,
99 #[serde(default)]
100 pub matcher: Option<String>,
102}
103
104#[derive(Debug, Clone, Default)]
105pub struct ConfigOverrides {
107 pub config_file: Option<PathBuf>,
109 pub definitions: Option<Vec<String>>,
111 pub features: Option<Vec<String>>,
113 pub exclude: Option<Vec<String>>,
115 pub exclude_defaults: Option<bool>,
117 pub include_hidden: Option<bool>,
119 pub reporters: Option<Vec<ReporterKind>>,
121 pub output: Option<PathBuf>,
123 pub threshold: Option<f64>,
125 pub require_features: Option<bool>,
127 pub no_metrics: Option<bool>,
129 pub rules: BTreeMap<Rule, Severity>,
131}
132
133#[derive(Debug, Clone, Serialize)]
134#[serde(rename_all = "camelCase")]
135pub struct Config {
137 pub root: PathBuf,
139 pub definitions: Vec<String>,
141 pub features: Vec<String>,
143 pub feature_pattern_origin: String,
145 pub config_warnings: Vec<String>,
147 pub config_source: String,
149 pub exclude: Vec<String>,
151 pub exclude_defaults: bool,
153 pub include_hidden: bool,
155 pub reporters: Vec<ReporterKind>,
157 pub output: PathBuf,
159 pub threshold: f64,
161 pub require_features: bool,
163 pub no_metrics: bool,
165 pub rules: BTreeMap<Rule, Severity>,
167 pub suppressions: Vec<SuppressionConfig>,
169 #[serde(skip)]
170 custom_exclude: Vec<String>,
171 #[serde(skip)]
172 output_from_project_config: bool,
173}
174
175impl Config {
176 pub fn load(root: &Path, overrides: ConfigOverrides) -> Result<Self> {
179 let root = normalize_platform_path(
180 root.canonicalize()
181 .with_context(|| format!("cannot access target directory {}", root.display()))?,
182 );
183 if !root.is_dir() {
184 bail!("target {} is not a directory", root.display());
185 }
186
187 let package_path = root.join("package.json");
188 let (package_config, package_json) = if package_path.is_file() {
189 let text = fs::read_to_string(&package_path)
190 .with_context(|| format!("failed to read {}", package_path.display()))?;
191 let value = serde_json::from_str::<serde_json::Value>(&text)
192 .with_context(|| format!("failed to parse {}", package_path.display()))?;
193 let package = serde_json::from_value::<PackageJson>(value.clone())
194 .with_context(|| format!("failed to parse {}", package_path.display()))?;
195 (package.cuke_dedup, Some(value))
196 } else {
197 (None, None)
198 };
199
200 let explicit_config = overrides.config_file.as_ref().map(|path| {
201 if path.is_absolute() {
202 path.clone()
203 } else {
204 root.join(path)
205 }
206 });
207 let mut config_warnings = Vec::new();
208 let (standalone_config, standalone_path) = if let Some(path) = explicit_config {
209 (Some(read_raw_config(&path)?), Some(path))
210 } else {
211 discover_config_file(&root, &mut config_warnings)
212 };
213
214 let package_config = if standalone_config.is_none() {
215 package_config
216 } else {
217 None
218 };
219
220 let has_explicit_features = overrides.features.is_some()
221 || standalone_config
222 .as_ref()
223 .is_some_and(|config| config.features.is_some())
224 || package_config
225 .as_ref()
226 .is_some_and(|config| config.features.is_some());
227 let mut config = Self::defaults(root);
228 config.config_warnings = config_warnings;
229 if !has_explicit_features {
230 match framework_config::detect(&config.root, package_json.as_ref()) {
231 Ok(Some(framework)) => {
232 config.features = framework.patterns;
233 config.feature_pattern_origin = format!(
234 "{} configuration {}",
235 framework.framework,
236 framework
237 .source
238 .strip_prefix(&config.root)
239 .unwrap_or(&framework.source)
240 .display()
241 );
242 config.config_warnings.extend(framework.warnings);
243 }
244 Ok(None) => {}
245 Err(error) => config.config_warnings.push(format!(
246 "could not inspect framework configuration ({error:#}); using built-in feature patterns"
247 )),
248 }
249 }
250 if let Some(raw) = package_config {
251 let replaces_features = raw.features.is_some();
252 config.apply_raw(raw)?;
253 config.config_source = "package.json#cukeDedup".to_owned();
254 if replaces_features {
255 config.feature_pattern_origin = "package.json#cukeDedup".to_owned();
256 }
257 }
258 if let Some(raw) = standalone_config {
259 let replaces_features = raw.features.is_some();
260 config.apply_raw(raw)?;
261 config.config_source = standalone_path
262 .as_ref()
263 .map(|path| config_source_name(path, &config.root))
264 .unwrap_or_else(|| ".cuke-dedup.json".to_owned());
265 if replaces_features {
266 config.feature_pattern_origin = config.config_source.clone();
267 }
268 }
269 let cli_replaces_features = overrides.features.is_some();
270 config.apply_overrides(overrides);
271 if cli_replaces_features {
272 config.feature_pattern_origin = "--features".to_owned();
273 }
274 config.validate()?;
275 Ok(config)
276 }
277
278 fn defaults(root: PathBuf) -> Self {
279 let rules = [
280 (Rule::DuplicateMatcher, Severity::Error),
281 (Rule::NormalizedMatcher, Severity::Error),
282 (Rule::AmbiguousStep, Severity::Error),
283 (Rule::DuplicateHandler, Severity::Error),
284 (Rule::NearDuplicateStep, Severity::Warning),
285 (Rule::ParameterizationCandidate, Severity::Warning),
286 (Rule::UnusedDefinition, Severity::Warning),
287 ]
288 .into_iter()
289 .collect();
290 Self {
291 root,
292 definitions: Vec::new(),
293 features: vec!["**/*.{feature,feature.md}".to_owned()],
294 feature_pattern_origin: "built-in defaults".to_owned(),
295 config_warnings: Vec::new(),
296 config_source: "built-in defaults".to_owned(),
297 exclude: DEFAULT_EXCLUDES.iter().map(ToString::to_string).collect(),
298 exclude_defaults: true,
299 include_hidden: false,
300 reporters: vec![ReporterKind::Terminal],
301 output: PathBuf::from("reports/cuke-dedup"),
302 threshold: 0.0,
303 require_features: false,
304 no_metrics: false,
305 rules,
306 suppressions: Vec::new(),
307 custom_exclude: Vec::new(),
308 output_from_project_config: false,
309 }
310 }
311
312 fn apply_raw(&mut self, raw: RawConfig) -> Result<()> {
313 if let Some(value) = raw.definitions {
314 self.definitions = value;
315 }
316 if let Some(value) = raw.features {
317 self.features = value;
318 }
319 if let Some(value) = raw.exclude_defaults {
320 self.exclude_defaults = value;
321 }
322 if let Some(value) = raw.include_hidden {
323 self.include_hidden = value;
324 }
325 if let Some(value) = raw.exclude {
326 self.custom_exclude = value;
327 }
328 self.rebuild_excludes();
329 if let Some(value) = raw.reporters {
330 self.reporters = value;
331 }
332 if let Some(value) = raw.output {
333 self.output = value;
334 self.output_from_project_config = true;
335 }
336 if let Some(value) = raw.threshold {
337 self.threshold = value;
338 }
339 if let Some(value) = raw.require_features {
340 self.require_features = value;
341 }
342 if let Some(value) = raw.no_metrics {
343 self.no_metrics = value;
344 }
345 if let Some(value) = raw.rules {
346 for (name, severity) in value {
347 let rule = name.parse::<Rule>().map_err(anyhow::Error::msg)?;
348 self.rules.insert(rule, severity);
349 }
350 }
351 if let Some(value) = raw.suppressions {
352 self.suppressions = value;
353 }
354 Ok(())
355 }
356
357 fn apply_overrides(&mut self, overrides: ConfigOverrides) {
358 if let Some(value) = overrides.definitions {
359 self.definitions = value;
360 }
361 if let Some(value) = overrides.features {
362 self.features = value;
363 }
364 if let Some(value) = overrides.exclude_defaults {
365 self.exclude_defaults = value;
366 }
367 if let Some(value) = overrides.include_hidden {
368 self.include_hidden = value;
369 }
370 if let Some(value) = overrides.exclude {
371 self.custom_exclude = value;
372 }
373 self.rebuild_excludes();
374 if let Some(value) = overrides.reporters {
375 self.reporters = value;
376 }
377 if let Some(value) = overrides.output {
378 self.output = value;
379 self.output_from_project_config = false;
380 }
381 if let Some(value) = overrides.threshold {
382 self.threshold = value;
383 }
384 if let Some(value) = overrides.require_features {
385 self.require_features = value;
386 }
387 if let Some(value) = overrides.no_metrics {
388 self.no_metrics = value;
389 }
390 self.rules.extend(overrides.rules);
391 }
392
393 fn rebuild_excludes(&mut self) {
394 self.exclude.clear();
395 if self.exclude_defaults {
396 self.exclude
397 .extend(DEFAULT_EXCLUDES.iter().map(ToString::to_string));
398 }
399 self.exclude.extend(self.custom_exclude.iter().cloned());
400 }
401
402 fn validate(&self) -> Result<()> {
403 if self.reporters.is_empty() {
404 bail!("at least one reporter must be configured");
405 }
406 if self.reporters.contains(&ReporterKind::Terminal)
407 && self.reporters.contains(&ReporterKind::Jsonl)
408 {
409 bail!("terminal and jsonl reporters cannot share stdout; select only one of them");
410 }
411 if !self.threshold.is_finite() || !(0.0..=100.0).contains(&self.threshold) {
412 bail!("threshold must be a finite percentage from 0 through 100");
413 }
414 if self.output_from_project_config {
415 validate_project_output(&self.root, &self.output)?;
416 }
417 for suppression in &self.suppressions {
418 if suppression.reason.trim().is_empty() {
419 bail!(
420 "suppression for {} must include a non-empty reason",
421 suppression.rule
422 );
423 }
424 if suppression.path.is_none() && suppression.matcher.is_none() {
425 bail!(
426 "suppression for {} must select a path or matcher",
427 suppression.rule
428 );
429 }
430 if let Some(pattern) = &suppression.path {
431 Glob::new(pattern).with_context(|| {
432 format!(
433 "invalid suppression path glob `{pattern}` for {}",
434 suppression.rule
435 )
436 })?;
437 }
438 }
439 Ok(())
440 }
441
442 pub fn severity(&self, rule: Rule) -> Severity {
444 self.rules.get(&rule).copied().unwrap_or(Severity::Off)
445 }
446
447 pub fn output_path(&self, filename: &str) -> PathBuf {
449 if self.output.is_absolute() {
450 self.output.join(filename)
451 } else {
452 self.root.join(&self.output).join(filename)
453 }
454 }
455}
456
457fn validate_project_output(root: &Path, output: &Path) -> Result<()> {
458 if output.is_absolute()
459 || output.components().any(|component| {
460 matches!(
461 component,
462 Component::ParentDir | Component::RootDir | Component::Prefix(_)
463 )
464 })
465 {
466 bail!(
467 "project configuration output `{}` must stay beneath the analyzed root; use --output for an operator-controlled path",
468 output.display()
469 );
470 }
471
472 let candidate = root.join(output);
473 let mut existing_ancestor = candidate.as_path();
474 while !existing_ancestor.exists() {
475 existing_ancestor = existing_ancestor.parent().ok_or_else(|| {
476 anyhow::anyhow!(
477 "could not resolve project configuration output `{}`",
478 output.display()
479 )
480 })?;
481 }
482 let canonical_ancestor =
483 normalize_platform_path(existing_ancestor.canonicalize().with_context(|| {
484 format!(
485 "failed to validate project configuration output {}",
486 candidate.display()
487 )
488 })?);
489 if !canonical_ancestor.starts_with(root) {
490 bail!(
491 "project configuration output `{}` resolves outside the analyzed root; use --output for an operator-controlled path",
492 output.display()
493 );
494 }
495 Ok(())
496}
497
498pub(crate) fn normalize_platform_path(path: PathBuf) -> PathBuf {
499 #[cfg(windows)]
500 {
501 let value = path.to_string_lossy();
502 if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
503 return PathBuf::from(format!(r"\\{unc}"));
504 }
505 if let Some(local) = value.strip_prefix(r"\\?\") {
506 return PathBuf::from(local);
507 }
508 }
509 path
510}
511
512fn read_raw_config(path: &Path) -> Result<RawConfig> {
513 let text =
514 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
515 serde_json::from_str::<RawConfig>(&text)
516 .with_context(|| format!("failed to parse {}", path.display()))
517}
518
519fn discover_config_file(
520 root: &Path,
521 warnings: &mut Vec<String>,
522) -> (Option<RawConfig>, Option<PathBuf>) {
523 const CANDIDATES: [&str; 4] = [
524 ".cuke-dedup.json",
525 ".config/cuke-dedup.json",
526 ".config/.cuke-dedup.json",
527 "cuke-dedup.config.json",
528 ];
529 for candidate in CANDIDATES {
530 let path = root.join(candidate);
531 if !path.is_file() {
532 continue;
533 }
534 match read_raw_config(&path) {
535 Ok(config) => return (Some(config), Some(path)),
536 Err(error) => warnings.push(format!(
537 "could not load auto-discovered config {} ({error:#}); trying the next configuration source",
538 path.display()
539 )),
540 }
541 }
542 (None, None)
543}
544
545fn config_source_name(path: &Path, root: &Path) -> String {
546 path.strip_prefix(root)
547 .unwrap_or(path)
548 .to_string_lossy()
549 .replace('\\', "/")
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use std::fs;
556
557 #[test]
558 fn standalone_config_overrides_package_and_cli_overrides_both() {
559 let directory = tempfile::tempdir().unwrap();
560 fs::write(
561 directory.path().join("package.json"),
562 r#"{"cukeDedup":{"reporters":["html"],"threshold":10,"rules":{"duplicate-matcher":"warning"}}}"#,
563 )
564 .unwrap();
565 fs::write(
566 directory.path().join("cuke-dedup.config.json"),
567 r#"{"reporters":["json"],"threshold":20,"rules":{"duplicate-matcher":"off"}}"#,
568 )
569 .unwrap();
570 let mut overrides = ConfigOverrides {
571 reporters: Some(vec![ReporterKind::Terminal]),
572 threshold: Some(30.0),
573 ..ConfigOverrides::default()
574 };
575 overrides
576 .rules
577 .insert(Rule::DuplicateMatcher, Severity::Error);
578
579 let config = Config::load(directory.path(), overrides).unwrap();
580 assert_eq!(config.reporters, vec![ReporterKind::Terminal]);
581 assert_eq!(config.threshold, 30.0);
582 assert_eq!(config.severity(Rule::DuplicateMatcher), Severity::Error);
583 }
584
585 #[test]
586 fn threshold_defaults_to_zero_and_rejects_out_of_range_values() {
587 let directory = tempfile::tempdir().unwrap();
588 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
589 assert_eq!(config.threshold, 0.0);
590
591 for threshold in [-0.1, 100.1, f64::NAN, f64::INFINITY] {
592 let error = Config::load(
593 directory.path(),
594 ConfigOverrides {
595 threshold: Some(threshold),
596 ..ConfigOverrides::default()
597 },
598 )
599 .unwrap_err();
600 assert!(error.to_string().contains("finite percentage"));
601 }
602 }
603
604 #[test]
605 fn no_metrics_is_available_from_project_config_and_cli_override() {
606 let directory = tempfile::tempdir().unwrap();
607 fs::write(
608 directory.path().join(".cuke-dedup.json"),
609 r#"{"noMetrics":true}"#,
610 )
611 .unwrap();
612 let configured = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
613 assert!(configured.no_metrics);
614
615 let overridden = Config::load(
616 directory.path(),
617 ConfigOverrides {
618 no_metrics: Some(false),
619 ..ConfigOverrides::default()
620 },
621 )
622 .unwrap();
623 assert!(!overridden.no_metrics);
624 }
625
626 #[test]
627 fn project_configuration_output_cannot_escape_the_analysis_root() {
628 let directory = tempfile::tempdir().unwrap();
629 for output in ["../outside", "nested/../../outside"] {
630 fs::write(
631 directory.path().join(".cuke-dedup.json"),
632 format!(r#"{{"output":"{output}"}}"#),
633 )
634 .unwrap();
635 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
636 assert!(error.to_string().contains("must stay beneath"), "{error:#}");
637 }
638
639 fs::write(
640 directory.path().join(".cuke-dedup.json"),
641 r#"{"output":"/tmp/cuke-dedup-project-controlled"}"#,
642 )
643 .unwrap();
644 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
645 assert!(error.to_string().contains("must stay beneath"), "{error:#}");
646 }
647
648 #[test]
649 fn command_line_output_safely_overrides_an_invalid_project_value() {
650 let directory = tempfile::tempdir().unwrap();
651 fs::write(
652 directory.path().join(".cuke-dedup.json"),
653 r#"{"output":"../../project-controlled"}"#,
654 )
655 .unwrap();
656 let operator_output = directory.path().join("../operator-controlled");
657 let config = Config::load(
658 directory.path(),
659 ConfigOverrides {
660 output: Some(operator_output.clone()),
661 ..ConfigOverrides::default()
662 },
663 )
664 .unwrap();
665 assert_eq!(config.output, operator_output);
666 }
667
668 #[cfg(unix)]
669 #[test]
670 fn project_configuration_output_cannot_follow_a_symlink_outside_the_root() {
671 use std::os::unix::fs::symlink;
672
673 let directory = tempfile::tempdir().unwrap();
674 let outside = tempfile::tempdir().unwrap();
675 symlink(outside.path(), directory.path().join("reports-link")).unwrap();
676 fs::write(
677 directory.path().join(".cuke-dedup.json"),
678 r#"{"output":"reports-link"}"#,
679 )
680 .unwrap();
681
682 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
683 assert!(error.to_string().contains("resolves outside"), "{error:#}");
684 }
685
686 #[test]
687 fn suppression_requires_a_reason() {
688 let directory = tempfile::tempdir().unwrap();
689 fs::write(
690 directory.path().join("cuke-dedup.config.json"),
691 r#"{"suppressions":[{"rule":"duplicate-handler","path":"steps.ts","reason":""}]}"#,
692 )
693 .unwrap();
694 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
695 assert!(error.to_string().contains("non-empty reason"));
696 }
697
698 #[test]
699 fn configured_excludes_extend_safety_defaults() {
700 let directory = tempfile::tempdir().unwrap();
701 fs::write(
702 directory.path().join("cuke-dedup.config.json"),
703 r#"{"exclude":["dist/**"]}"#,
704 )
705 .unwrap();
706 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
707 assert!(config.exclude.contains(&"**/node_modules/**".to_owned()));
708 assert!(config.exclude.contains(&"dist/**".to_owned()));
709 }
710
711 #[test]
712 fn exclude_layers_replace_custom_patterns_but_preserve_default_policy() {
713 let directory = tempfile::tempdir().unwrap();
714 fs::write(
715 directory.path().join("package.json"),
716 r#"{"cukeDedup":{"exclude":["package-only/**"]}}"#,
717 )
718 .unwrap();
719 fs::write(
720 directory.path().join("cuke-dedup.config.json"),
721 r#"{"exclude":["standalone-only/**"]}"#,
722 )
723 .unwrap();
724 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
725 assert!(config.exclude.contains(&"**/node_modules/**".to_owned()));
726 assert!(config.exclude.contains(&"standalone-only/**".to_owned()));
727 assert!(!config.exclude.contains(&"package-only/**".to_owned()));
728
729 let config = Config::load(
730 directory.path(),
731 ConfigOverrides {
732 exclude: Some(vec!["cli-only/**".to_owned()]),
733 ..ConfigOverrides::default()
734 },
735 )
736 .unwrap();
737 assert!(config.exclude.contains(&"cli-only/**".to_owned()));
738 assert!(!config.exclude.contains(&"standalone-only/**".to_owned()));
739 }
740
741 #[test]
742 fn default_excludes_and_hidden_directories_have_explicit_escape_hatches() {
743 let directory = tempfile::tempdir().unwrap();
744 fs::write(
745 directory.path().join("cuke-dedup.config.json"),
746 r#"{"excludeDefaults":false,"includeHidden":true}"#,
747 )
748 .unwrap();
749 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
750 assert!(!config.exclude_defaults);
751 assert!(config.exclude.is_empty());
752 assert!(config.include_hidden);
753 }
754
755 #[test]
756 fn jsonl_is_configurable_but_cannot_be_mixed_with_terminal_output() {
757 let directory = tempfile::tempdir().unwrap();
758 fs::write(
759 directory.path().join(".cuke-dedup.json"),
760 r#"{"reporters":["jsonl","json"]}"#,
761 )
762 .unwrap();
763 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
764 assert_eq!(
765 config.reporters,
766 vec![ReporterKind::Jsonl, ReporterKind::Json]
767 );
768
769 fs::write(
770 directory.path().join(".cuke-dedup.json"),
771 r#"{"reporters":["terminal","jsonl"]}"#,
772 )
773 .unwrap();
774 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
775 assert!(error
776 .to_string()
777 .contains("terminal and jsonl reporters cannot share stdout"));
778 }
779
780 #[test]
781 fn invalid_suppression_glob_is_rejected_during_config_load() {
782 let directory = tempfile::tempdir().unwrap();
783 fs::write(
784 directory.path().join("cuke-dedup.config.json"),
785 r#"{"suppressions":[{"rule":"duplicate-handler","path":"[","reason":"test"}]}"#,
786 )
787 .unwrap();
788 let error = Config::load(directory.path(), ConfigOverrides::default()).unwrap_err();
789 assert!(error.to_string().contains("invalid suppression path glob"));
790 }
791
792 #[test]
793 fn cucumber_config_supplies_defaults_below_cuke_dedup_configuration() {
794 let directory = tempfile::tempdir().unwrap();
795 fs::write(
796 directory.path().join("cucumber.json"),
797 r#"{"default":{"paths":["acceptance"]}}"#,
798 )
799 .unwrap();
800 let framework = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
801 assert_eq!(
802 framework.features,
803 vec!["acceptance/**/*.{feature,feature.md}"]
804 );
805 assert!(framework.feature_pattern_origin.starts_with("Cucumber.js"));
806
807 fs::write(
808 directory.path().join("cuke-dedup.config.json"),
809 r#"{"features":["custom/**/*.spec"]}"#,
810 )
811 .unwrap();
812 let own_config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
813 assert_eq!(own_config.features, vec!["custom/**/*.spec"]);
814 assert_eq!(own_config.feature_pattern_origin, "cuke-dedup.config.json");
815
816 let cli = Config::load(
817 directory.path(),
818 ConfigOverrides {
819 features: Some(vec!["cli/**/*.feature".to_owned()]),
820 ..ConfigOverrides::default()
821 },
822 )
823 .unwrap();
824 assert_eq!(cli.features, vec!["cli/**/*.feature"]);
825 assert_eq!(cli.feature_pattern_origin, "--features");
826 }
827
828 #[test]
829 fn playwright_bdd_literal_features_are_detected_without_executing_config() {
830 let directory = tempfile::tempdir().unwrap();
831 fs::write(
832 directory.path().join("playwright.config.ts"),
833 "const testDir = defineBddConfig({ features: ['specs/**/*.spec'] });",
834 )
835 .unwrap();
836 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
837 assert_eq!(config.features, vec!["specs/**/*.spec"]);
838 assert!(config.feature_pattern_origin.starts_with("Playwright-BDD"));
839 }
840
841 #[test]
842 fn cucumber_yaml_paths_are_detected() {
843 let directory = tempfile::tempdir().unwrap();
844 fs::write(
845 directory.path().join("cucumber.yml"),
846 "default:\n paths:\n - acceptance\n - docs/**/*.feature.md\n",
847 )
848 .unwrap();
849 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
850 assert_eq!(
851 config.features,
852 vec![
853 "acceptance/**/*.{feature,feature.md}",
854 "docs/**/*.feature.md"
855 ]
856 );
857 }
858
859 #[test]
860 fn explicit_features_skip_invalid_lower_precedence_framework_config() {
861 let directory = tempfile::tempdir().unwrap();
862 fs::write(directory.path().join("cucumber.json"), "not json").unwrap();
863 fs::write(
864 directory.path().join("cuke-dedup.config.json"),
865 r#"{"features":["custom/**/*.spec"]}"#,
866 )
867 .unwrap();
868 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
869 assert_eq!(config.features, vec!["custom/**/*.spec"]);
870 assert!(config.config_warnings.is_empty());
871 }
872
873 #[test]
874 fn jscpd_style_config_discovery_uses_one_source_in_documented_order() {
875 let directory = tempfile::tempdir().unwrap();
876 fs::create_dir_all(directory.path().join(".config")).unwrap();
877 fs::write(
878 directory.path().join("package.json"),
879 r#"{"cukeDedup":{"threshold":10}}"#,
880 )
881 .unwrap();
882 fs::write(
883 directory.path().join(".config/cuke-dedup.json"),
884 r#"{"threshold":20}"#,
885 )
886 .unwrap();
887 fs::write(
888 directory.path().join(".cuke-dedup.json"),
889 r#"{"threshold":30}"#,
890 )
891 .unwrap();
892
893 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
894 assert_eq!(config.threshold, 30.0);
895 }
896
897 #[test]
898 fn explicit_config_is_fatal_and_bypasses_auto_discovery() {
899 let directory = tempfile::tempdir().unwrap();
900 fs::write(
901 directory.path().join(".cuke-dedup.json"),
902 r#"{"threshold":10}"#,
903 )
904 .unwrap();
905 let explicit = directory.path().join("team-policy.json");
906 fs::write(&explicit, r#"{"threshold":40}"#).unwrap();
907 let config = Config::load(
908 directory.path(),
909 ConfigOverrides {
910 config_file: Some(explicit),
911 ..ConfigOverrides::default()
912 },
913 )
914 .unwrap();
915 assert_eq!(config.threshold, 40.0);
916
917 let relative = Config::load(
918 directory.path(),
919 ConfigOverrides {
920 config_file: Some(PathBuf::from("team-policy.json")),
921 ..ConfigOverrides::default()
922 },
923 )
924 .unwrap();
925 assert_eq!(relative.threshold, 40.0);
926
927 let error = Config::load(
928 directory.path(),
929 ConfigOverrides {
930 config_file: Some(directory.path().join("missing.json")),
931 ..ConfigOverrides::default()
932 },
933 )
934 .unwrap_err();
935 assert!(error.to_string().contains("failed to read"));
936 }
937
938 #[test]
939 fn malformed_auto_config_warns_and_falls_through_to_package_config() {
940 let directory = tempfile::tempdir().unwrap();
941 fs::write(directory.path().join(".cuke-dedup.json"), "not json").unwrap();
942 fs::write(
943 directory.path().join("package.json"),
944 r#"{"cukeDedup":{"threshold":25}}"#,
945 )
946 .unwrap();
947 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
948 assert_eq!(config.threshold, 25.0);
949 assert_eq!(config.config_warnings.len(), 1);
950 }
951}