Skip to main content

cuke_dedup/
config.rs

1//! Configuration loading and precedence.
2
3use 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")]
23/// Destination format selected for analysis output.
24pub enum ReporterKind {
25    /// Human-readable output written to the supplied terminal stream.
26    Terminal,
27    /// Versioned machine-readable JSON output.
28    Json,
29    /// Compact JSON Lines output written to stdout for streaming consumers.
30    Jsonl,
31    /// Self-contained interactive HTML output.
32    Html,
33    /// SARIF 2.1.0 output for GitHub code scanning and other compatible tools.
34    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)]
90/// A configuration entry that suppresses matching findings for a documented reason.
91pub struct SuppressionConfig {
92    /// Rule to suppress.
93    pub rule: Rule,
94    /// Human-readable justification recorded with suppressed findings.
95    pub reason: String,
96    #[serde(default)]
97    /// Optional glob selecting source paths.
98    pub path: Option<String>,
99    #[serde(default)]
100    /// Optional exact matcher selecting step definitions.
101    pub matcher: Option<String>,
102}
103
104#[derive(Debug, Clone, Default)]
105/// Command-line or embedding overrides applied after file-based configuration.
106pub struct ConfigOverrides {
107    /// Explicit JSON configuration file, bypassing automatic config-file discovery.
108    pub config_file: Option<PathBuf>,
109    /// Replacement definition-source globs.
110    pub definitions: Option<Vec<String>>,
111    /// Replacement feature-file globs.
112    pub features: Option<Vec<String>>,
113    /// Replacement custom exclusion globs.
114    pub exclude: Option<Vec<String>>,
115    /// Whether built-in generated-directory exclusions remain enabled.
116    pub exclude_defaults: Option<bool>,
117    /// Whether discovery descends into dot-directories.
118    pub include_hidden: Option<bool>,
119    /// Replacement reporter selection.
120    pub reporters: Option<Vec<ReporterKind>>,
121    /// Replacement report output directory.
122    pub output: Option<PathBuf>,
123    /// Maximum percentage of definitions allowed in active duplication errors.
124    pub threshold: Option<f64>,
125    /// Whether a run with no discovered feature files is an operational failure.
126    pub require_features: Option<bool>,
127    /// Whether machine reports omit runtime measurements.
128    pub no_metrics: Option<bool>,
129    /// Per-rule severity overrides.
130    pub rules: BTreeMap<Rule, Severity>,
131}
132
133#[derive(Debug, Clone, Serialize)]
134#[serde(rename_all = "camelCase")]
135/// Fully resolved and validated analyzer configuration.
136pub struct Config {
137    /// Canonical repository root being analyzed.
138    pub root: PathBuf,
139    /// Globs selecting definition sources, or an empty list for automatic discovery.
140    pub definitions: Vec<String>,
141    /// Globs selecting classic Gherkin or Gherkin Markdown feature files.
142    pub features: Vec<String>,
143    /// Human-readable origin of the effective feature globs.
144    pub feature_pattern_origin: String,
145    /// Non-fatal diagnostics produced while inspecting framework configuration.
146    pub config_warnings: Vec<String>,
147    /// Standalone or package configuration source selected below CLI overrides.
148    pub config_source: String,
149    /// Globs excluded from discovery.
150    pub exclude: Vec<String>,
151    /// Whether built-in generated-directory exclusions are active.
152    pub exclude_defaults: bool,
153    /// Whether discovery descends into dot-directories.
154    pub include_hidden: bool,
155    /// Report formats emitted by the analyzer.
156    pub reporters: Vec<ReporterKind>,
157    /// Directory for file-based reports.
158    pub output: PathBuf,
159    /// Maximum percentage of definitions allowed in active duplication errors.
160    pub threshold: f64,
161    /// Whether finding no feature files is an operational failure.
162    pub require_features: bool,
163    /// Whether machine reports omit runtime measurements for reproducibility.
164    pub no_metrics: bool,
165    /// Effective severity for each analysis rule.
166    pub rules: BTreeMap<Rule, Severity>,
167    /// Explicit, reason-bearing finding suppressions.
168    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    /// Loads framework, package, and standalone configuration beneath `root`, then applies
177    /// `overrides` in precedence order.
178    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    /// Returns the configured severity for `rule`, defaulting to [`Severity::Off`].
443    pub fn severity(&self, rule: Rule) -> Severity {
444        self.rules.get(&rule).copied().unwrap_or(Severity::Off)
445    }
446
447    /// Resolves a report filename against the configured output directory.
448    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}