Skip to main content

cuke_dedup/
config.rs

1//! Configuration loading and precedence.
2
3use 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/// Destination format selected for analysis output.
26#[non_exhaustive]
27pub enum ReporterKind {
28    /// Human-readable output written to the supplied terminal stream.
29    Terminal,
30    /// Versioned machine-readable JSON output.
31    Json,
32    /// Compact JSON Lines output written to stdout for streaming consumers.
33    Jsonl,
34    /// Self-contained interactive HTML output.
35    Html,
36    /// SARIF 2.1.0 output for GitHub code scanning and other compatible tools.
37    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)]
105/// A configuration entry that suppresses matching findings for a documented reason.
106pub struct SuppressionConfig {
107    /// Rule to suppress.
108    pub rule: Rule,
109    /// Human-readable justification recorded with suppressed findings.
110    pub reason: String,
111    #[serde(default)]
112    /// Optional glob selecting source paths.
113    pub path: Option<String>,
114    #[serde(default)]
115    /// Optional exact matcher selecting step definitions.
116    pub matcher: Option<String>,
117}
118
119#[derive(Debug, Clone, Default)]
120/// Command-line or embedding overrides applied after file-based configuration.
121pub struct ConfigOverrides {
122    /// Explicit JSON configuration file, bypassing automatic config-file discovery.
123    pub config_file: Option<PathBuf>,
124    /// Replacement definition-source globs.
125    pub definitions: Option<Vec<String>>,
126    /// Replacement feature-file globs.
127    pub features: Option<Vec<String>>,
128    /// Replacement custom exclusion globs.
129    pub exclude: Option<Vec<String>>,
130    /// Whether built-in generated-directory exclusions remain enabled.
131    pub exclude_defaults: Option<bool>,
132    /// Whether discovery descends into dot-directories.
133    pub include_hidden: Option<bool>,
134    /// Replacement reporter selection.
135    pub reporters: Option<Vec<ReporterKind>>,
136    /// Replacement report output directory.
137    pub output: Option<PathBuf>,
138    /// Maximum percentage of definitions allowed in active duplication errors.
139    pub threshold: Option<f64>,
140    /// Whether a run with no discovered feature files is an operational failure.
141    pub require_features: Option<bool>,
142    /// Whether machine reports omit runtime measurements.
143    pub no_metrics: Option<bool>,
144    /// Per-rule severity overrides.
145    pub rules: BTreeMap<Rule, Severity>,
146}
147
148#[derive(Debug, Clone, Default)]
149pub(crate) struct CliConfigOverrides {
150    pub(crate) require_definitions: Option<bool>,
151    /// Whether bounded or incomplete analysis is an operational failure.
152    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")]
159/// Fully resolved and validated analyzer configuration.
160pub struct Config {
161    /// Canonical repository root being analyzed.
162    pub root: PathBuf,
163    /// Globs selecting definition sources, or an empty list for automatic discovery.
164    pub definitions: Vec<String>,
165    /// Globs selecting classic Gherkin or Gherkin Markdown feature files.
166    pub features: Vec<String>,
167    /// Human-readable origin of the effective feature globs.
168    pub feature_pattern_origin: String,
169    /// Non-fatal diagnostics produced while inspecting framework configuration.
170    pub config_warnings: Vec<String>,
171    /// Standalone or package configuration source selected below CLI overrides.
172    pub config_source: String,
173    /// Globs excluded from discovery.
174    pub exclude: Vec<String>,
175    /// Whether built-in generated-directory exclusions are active.
176    pub exclude_defaults: bool,
177    /// Whether discovery descends into dot-directories.
178    pub include_hidden: bool,
179    /// Report formats emitted by the analyzer.
180    pub reporters: Vec<ReporterKind>,
181    /// Directory for file-based reports.
182    pub output: PathBuf,
183    /// Maximum percentage of definitions allowed in active duplication errors.
184    pub threshold: f64,
185    /// Whether finding no feature files is an operational failure.
186    pub require_features: bool,
187    /// Whether extracting no step definitions is an operational failure.
188    pub require_definitions: bool,
189    /// Whether an incomplete corpus or truncated analysis is an operational failure.
190    pub fail_on_incomplete: bool,
191    /// Additional local function names that register step definitions.
192    pub registrations: Vec<String>,
193    /// Project-defined Cucumber Expression parameter types and their regular expressions.
194    pub parameter_types: BTreeMap<String, String>,
195    /// Maximum unique definition pairs retained for analysis.
196    pub max_candidate_comparisons: usize,
197    /// Maximum structural candidate proposals considered from one handler class.
198    pub max_structural_class_comparisons: usize,
199    /// Whether machine reports omit runtime measurements for reproducibility.
200    pub no_metrics: bool,
201    /// Effective severity for each analysis rule.
202    pub rules: BTreeMap<Rule, Severity>,
203    /// Explicit, reason-bearing finding suppressions.
204    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    /// Loads framework, package, and standalone configuration beneath `root`, then applies
213    /// `overrides` in precedence order.
214    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            // serde_json's default recursion limit remains enabled for untrusted config input.
247            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    /// Returns the configured severity for `rule`, defaulting to [`Severity::Off`].
583    pub fn severity(&self, rule: Rule) -> Severity {
584        self.rules.get(&rule).copied().unwrap_or(Severity::Off)
585    }
586
587    /// Resolves a report filename against the configured output directory.
588    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's default recursion limit remains enabled for untrusted config input.
655    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}