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    assertion_modules: Option<Vec<String>>,
84    #[serde(default)]
85    parameter_types: Option<BTreeMap<String, String>>,
86    #[serde(default)]
87    max_candidate_comparisons: Option<usize>,
88    #[serde(default)]
89    max_structural_class_comparisons: Option<usize>,
90    #[serde(default)]
91    no_metrics: Option<bool>,
92    #[serde(default)]
93    rules: Option<BTreeMap<String, Severity>>,
94    #[serde(default)]
95    suppressions: Option<Vec<SuppressionConfig>>,
96}
97
98#[derive(Debug, Clone, Deserialize, Default)]
99#[serde(rename_all = "camelCase")]
100struct PackageJson {
101    #[serde(default)]
102    cuke_dedup: Option<RawConfig>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase", deny_unknown_fields)]
107/// A configuration entry that suppresses matching findings for a documented reason.
108pub struct SuppressionConfig {
109    /// Rule to suppress.
110    pub rule: Rule,
111    /// Human-readable justification recorded with suppressed findings.
112    pub reason: String,
113    #[serde(default)]
114    /// Optional glob selecting source paths.
115    pub path: Option<String>,
116    #[serde(default)]
117    /// Optional exact matcher selecting step definitions.
118    pub matcher: Option<String>,
119}
120
121#[derive(Debug, Clone, Default)]
122/// Command-line or embedding overrides applied after file-based configuration.
123pub struct ConfigOverrides {
124    /// Explicit JSON configuration file, bypassing automatic config-file discovery.
125    pub config_file: Option<PathBuf>,
126    /// Replacement definition-source globs.
127    pub definitions: Option<Vec<String>>,
128    /// Replacement feature-file globs.
129    pub features: Option<Vec<String>>,
130    /// Replacement custom exclusion globs.
131    pub exclude: Option<Vec<String>>,
132    /// Whether built-in generated-directory exclusions remain enabled.
133    pub exclude_defaults: Option<bool>,
134    /// Whether discovery descends into dot-directories.
135    pub include_hidden: Option<bool>,
136    /// Replacement reporter selection.
137    pub reporters: Option<Vec<ReporterKind>>,
138    /// Replacement report output directory.
139    pub output: Option<PathBuf>,
140    /// Maximum percentage of definitions allowed in active duplication errors.
141    pub threshold: Option<f64>,
142    /// Whether a run with no discovered feature files is an operational failure.
143    pub require_features: Option<bool>,
144    /// Whether machine reports omit runtime measurements.
145    pub no_metrics: Option<bool>,
146    /// Per-rule severity overrides.
147    pub rules: BTreeMap<Rule, Severity>,
148}
149
150#[derive(Debug, Clone, Default)]
151pub(crate) struct CliConfigOverrides {
152    pub(crate) require_definitions: Option<bool>,
153    /// Whether bounded or incomplete analysis is an operational failure.
154    pub(crate) fail_on_incomplete: Option<bool>,
155    pub(crate) max_candidate_comparisons: Option<usize>,
156    pub(crate) max_structural_class_comparisons: Option<usize>,
157}
158
159#[derive(Debug, Clone, Serialize)]
160#[serde(rename_all = "camelCase")]
161/// Fully resolved and validated analyzer configuration.
162pub struct Config {
163    /// Canonical repository root being analyzed.
164    pub root: PathBuf,
165    /// Globs selecting definition sources, or an empty list for automatic discovery.
166    pub definitions: Vec<String>,
167    /// Globs selecting classic Gherkin or Gherkin Markdown feature files.
168    pub features: Vec<String>,
169    /// Human-readable origin of the effective feature globs.
170    pub feature_pattern_origin: String,
171    /// Non-fatal diagnostics produced while inspecting framework configuration.
172    pub config_warnings: Vec<String>,
173    /// Standalone or package configuration source selected below CLI overrides.
174    pub config_source: String,
175    /// Globs excluded from discovery.
176    pub exclude: Vec<String>,
177    /// Whether built-in generated-directory exclusions are active.
178    pub exclude_defaults: bool,
179    /// Whether discovery descends into dot-directories.
180    pub include_hidden: bool,
181    /// Report formats emitted by the analyzer.
182    pub reporters: Vec<ReporterKind>,
183    /// Directory for file-based reports.
184    pub output: PathBuf,
185    /// Maximum percentage of definitions allowed in active duplication errors.
186    pub threshold: f64,
187    /// Whether finding no feature files is an operational failure.
188    pub require_features: bool,
189    /// Whether extracting no step definitions is an operational failure.
190    pub require_definitions: bool,
191    /// Whether an incomplete corpus or truncated analysis is an operational failure.
192    pub fail_on_incomplete: bool,
193    /// Additional local function names that register step definitions.
194    pub registrations: Vec<String>,
195    /// Additional module specifiers whose `expect` export is a trusted assertion factory.
196    pub assertion_modules: Vec<String>,
197    /// Project-defined Cucumber Expression parameter types and their regular expressions.
198    pub parameter_types: BTreeMap<String, String>,
199    /// Maximum unique definition pairs retained for analysis.
200    pub max_candidate_comparisons: usize,
201    /// Maximum structural candidate proposals considered from one handler class.
202    pub max_structural_class_comparisons: usize,
203    /// Whether machine reports omit runtime measurements for reproducibility.
204    pub no_metrics: bool,
205    /// Effective severity for each analysis rule.
206    pub rules: BTreeMap<Rule, Severity>,
207    /// Explicit, reason-bearing finding suppressions.
208    pub suppressions: Vec<SuppressionConfig>,
209    #[serde(skip)]
210    custom_exclude: Vec<String>,
211    #[serde(skip)]
212    output_from_project_config: bool,
213}
214
215impl Config {
216    /// Loads framework, package, and standalone configuration beneath `root`, then applies
217    /// `overrides` in precedence order.
218    pub fn load(root: &Path, overrides: ConfigOverrides) -> Result<Self> {
219        Self::load_internal(root, overrides, CliConfigOverrides::default())
220    }
221
222    pub(crate) fn load_for_cli(
223        root: &Path,
224        overrides: ConfigOverrides,
225        cli_overrides: CliConfigOverrides,
226    ) -> Result<Self> {
227        Self::load_internal(root, overrides, cli_overrides)
228    }
229
230    fn load_internal(
231        root: &Path,
232        overrides: ConfigOverrides,
233        cli_overrides: CliConfigOverrides,
234    ) -> Result<Self> {
235        let root = normalize_platform_path(
236            root.canonicalize()
237                .with_context(|| format!("cannot access target directory {}", root.display()))?,
238        );
239        if !root.is_dir() {
240            bail!("target {} is not a directory", root.display());
241        }
242
243        let package_path = root.join("package.json");
244        let (package_config, package_json) = if package_path.is_file() {
245            let text = read_utf8(
246                &package_path,
247                "package configuration",
248                MAX_CONFIG_INPUT_BYTES,
249            )?;
250            // serde_json's default recursion limit remains enabled for untrusted config input.
251            let value = serde_json::from_str::<serde_json::Value>(&text)
252                .with_context(|| format!("failed to parse {}", package_path.display()))?;
253            let package = serde_json::from_value::<PackageJson>(value.clone())
254                .with_context(|| format!("failed to parse {}", package_path.display()))?;
255            (package.cuke_dedup, Some(value))
256        } else {
257            (None, None)
258        };
259
260        let explicit_config = overrides.config_file.as_ref().map(|path| {
261            if path.is_absolute() {
262                path.clone()
263            } else {
264                root.join(path)
265            }
266        });
267        let mut config_warnings = Vec::new();
268        let (standalone_config, standalone_path) = if let Some(path) = explicit_config {
269            (Some(read_raw_config(&path)?), Some(path))
270        } else {
271            discover_config_file(&root, &mut config_warnings)?
272        };
273
274        let package_config = if standalone_config.is_none() {
275            package_config
276        } else {
277            None
278        };
279
280        let has_explicit_features = overrides.features.is_some()
281            || standalone_config
282                .as_ref()
283                .is_some_and(|config| config.features.is_some())
284            || package_config
285                .as_ref()
286                .is_some_and(|config| config.features.is_some());
287        let mut config = Self::defaults(root);
288        config.config_warnings = config_warnings;
289        if !has_explicit_features {
290            match framework_config::detect(&config.root, package_json.as_ref()) {
291                Ok(Some(framework)) => {
292                    config.features = framework.patterns;
293                    config.feature_pattern_origin = format!(
294                        "{} configuration {}",
295                        framework.framework,
296                        framework
297                            .source
298                            .strip_prefix(&config.root)
299                            .unwrap_or(&framework.source)
300                            .display()
301                    );
302                    config.config_warnings.extend(framework.warnings);
303                }
304                Ok(None) => {}
305                Err(error) if is_input_limit_error(&error) => return Err(error),
306                Err(error) => config.config_warnings.push(format!(
307                    "could not inspect framework configuration ({error:#}); using built-in feature patterns"
308                )),
309            }
310        }
311        if let Some(raw) = package_config {
312            let replaces_features = raw.features.is_some();
313            config.apply_raw(raw)?;
314            config.config_source = "package.json#cukeDedup".to_owned();
315            if replaces_features {
316                config.feature_pattern_origin = "package.json#cukeDedup".to_owned();
317            }
318        }
319        if let Some(raw) = standalone_config {
320            let replaces_features = raw.features.is_some();
321            config.apply_raw(raw)?;
322            config.config_source = standalone_path
323                .as_ref()
324                .map(|path| config_source_name(path, &config.root))
325                .unwrap_or_else(|| ".cuke-dedup.json".to_owned());
326            if replaces_features {
327                config.feature_pattern_origin = config.config_source.clone();
328            }
329        }
330        let cli_replaces_features = overrides.features.is_some();
331        config.apply_overrides(overrides);
332        if let Some(value) = cli_overrides.require_definitions {
333            config.require_definitions = value;
334        }
335        if let Some(value) = cli_overrides.fail_on_incomplete {
336            config.fail_on_incomplete = value;
337        }
338        if let Some(value) = cli_overrides.max_candidate_comparisons {
339            config.max_candidate_comparisons = value;
340        }
341        if let Some(value) = cli_overrides.max_structural_class_comparisons {
342            config.max_structural_class_comparisons = value;
343        }
344        if cli_replaces_features {
345            config.feature_pattern_origin = "--features".to_owned();
346        }
347        config.validate()?;
348        Ok(config)
349    }
350
351    fn defaults(root: PathBuf) -> Self {
352        let rules = [
353            (Rule::DuplicateMatcher, Severity::Error),
354            (Rule::NormalizedMatcher, Severity::Error),
355            (Rule::AmbiguousStep, Severity::Error),
356            (Rule::OverlappingMatcher, Severity::Warning),
357            (Rule::DuplicateHandler, Severity::Error),
358            (Rule::NearDuplicateStep, Severity::Warning),
359            (Rule::ParameterizationCandidate, Severity::Warning),
360            (Rule::UnusedDefinition, Severity::Warning),
361        ]
362        .into_iter()
363        .collect();
364        Self {
365            root,
366            definitions: Vec::new(),
367            features: vec!["**/*.{feature,feature.md}".to_owned()],
368            feature_pattern_origin: "built-in defaults".to_owned(),
369            config_warnings: Vec::new(),
370            config_source: "built-in defaults".to_owned(),
371            exclude: DEFAULT_EXCLUDES.iter().map(ToString::to_string).collect(),
372            exclude_defaults: true,
373            include_hidden: false,
374            reporters: vec![ReporterKind::Terminal],
375            output: PathBuf::from("reports/cuke-dedup"),
376            threshold: 0.0,
377            require_features: false,
378            require_definitions: false,
379            fail_on_incomplete: false,
380            registrations: Vec::new(),
381            assertion_modules: Vec::new(),
382            parameter_types: BTreeMap::new(),
383            max_candidate_comparisons: MAX_CANDIDATE_COMPARISONS,
384            max_structural_class_comparisons: MAX_STRUCTURAL_CLASS_COMPARISONS,
385            no_metrics: false,
386            rules,
387            suppressions: Vec::new(),
388            custom_exclude: Vec::new(),
389            output_from_project_config: false,
390        }
391    }
392
393    fn apply_raw(&mut self, raw: RawConfig) -> Result<()> {
394        if let Some(value) = raw.definitions {
395            self.definitions = value;
396        }
397        if let Some(value) = raw.features {
398            self.features = value;
399        }
400        if let Some(value) = raw.exclude_defaults {
401            self.exclude_defaults = value;
402        }
403        if let Some(value) = raw.include_hidden {
404            self.include_hidden = value;
405        }
406        if let Some(value) = raw.exclude {
407            self.custom_exclude = value;
408        }
409        self.rebuild_excludes();
410        if let Some(value) = raw.reporters {
411            self.reporters = value;
412        }
413        if let Some(value) = raw.output {
414            self.output = value;
415            self.output_from_project_config = true;
416        }
417        if let Some(value) = raw.threshold {
418            self.threshold = value;
419        }
420        if let Some(value) = raw.require_features {
421            self.require_features = value;
422        }
423        if let Some(value) = raw.require_definitions {
424            self.require_definitions = value;
425        }
426        if let Some(value) = raw.fail_on_incomplete {
427            self.fail_on_incomplete = value;
428        }
429        if let Some(value) = raw.registrations {
430            self.registrations = value;
431        }
432        if let Some(value) = raw.assertion_modules {
433            self.assertion_modules = value;
434        }
435        if let Some(value) = raw.parameter_types {
436            self.parameter_types = value;
437        }
438        if let Some(value) = raw.max_candidate_comparisons {
439            self.max_candidate_comparisons = value;
440        }
441        if let Some(value) = raw.max_structural_class_comparisons {
442            self.max_structural_class_comparisons = value;
443        }
444        if let Some(value) = raw.no_metrics {
445            self.no_metrics = value;
446        }
447        if let Some(value) = raw.rules {
448            for (name, severity) in value {
449                let rule = name.parse::<Rule>().map_err(anyhow::Error::msg)?;
450                self.rules.insert(rule, severity);
451            }
452        }
453        if let Some(value) = raw.suppressions {
454            self.suppressions = value;
455        }
456        Ok(())
457    }
458
459    fn apply_overrides(&mut self, overrides: ConfigOverrides) {
460        if let Some(value) = overrides.definitions {
461            self.definitions = value;
462        }
463        if let Some(value) = overrides.features {
464            self.features = value;
465        }
466        if let Some(value) = overrides.exclude_defaults {
467            self.exclude_defaults = value;
468        }
469        if let Some(value) = overrides.include_hidden {
470            self.include_hidden = value;
471        }
472        if let Some(value) = overrides.exclude {
473            self.custom_exclude = value;
474        }
475        self.rebuild_excludes();
476        if let Some(value) = overrides.reporters {
477            self.reporters = value;
478        }
479        if let Some(value) = overrides.output {
480            self.output = value;
481            self.output_from_project_config = false;
482        }
483        if let Some(value) = overrides.threshold {
484            self.threshold = value;
485        }
486        if let Some(value) = overrides.require_features {
487            self.require_features = value;
488        }
489        if let Some(value) = overrides.no_metrics {
490            self.no_metrics = value;
491        }
492        self.rules.extend(overrides.rules);
493    }
494
495    fn rebuild_excludes(&mut self) {
496        self.exclude.clear();
497        if self.exclude_defaults {
498            self.exclude
499                .extend(DEFAULT_EXCLUDES.iter().map(ToString::to_string));
500        }
501        self.exclude.extend(self.custom_exclude.iter().cloned());
502    }
503
504    fn validate(&self) -> Result<()> {
505        if self.reporters.is_empty() {
506            bail!("at least one reporter must be configured");
507        }
508        if self.reporters.contains(&ReporterKind::Terminal)
509            && self.reporters.contains(&ReporterKind::Jsonl)
510        {
511            bail!("terminal and jsonl reporters cannot share stdout; select only one of them");
512        }
513        if !self.threshold.is_finite() || !(0.0..=100.0).contains(&self.threshold) {
514            bail!("threshold must be a finite percentage from 0 through 100");
515        }
516        for (name, pattern) in &self.parameter_types {
517            if name.is_empty() || name.contains(['{', '}']) {
518                bail!("parameter type `{name}` must not be empty or contain braces");
519            }
520            crate::resource_limits::compile_regex(pattern).with_context(|| {
521                format!("invalid regular expression for parameter type `{name}`")
522            })?;
523        }
524        for module in &self.assertion_modules {
525            // A specifier is matched exactly against the import text, so surrounding whitespace or
526            // an empty entry would silently never match and quietly disable the setting.
527            if module.is_empty() || module.trim() != module {
528                bail!("assertion module `{module}` must be a module specifier without surrounding whitespace");
529            }
530        }
531        for name in &self.registrations {
532            if name.is_empty()
533                || !name
534                    .chars()
535                    .all(|character| character.is_alphanumeric() || matches!(character, '_' | '$'))
536                || name.starts_with(|character: char| character.is_ascii_digit())
537            {
538                bail!("registration `{name}` must be a JavaScript identifier");
539            }
540        }
541        self.validate_analysis_limits()?;
542        if self.output_from_project_config {
543            validate_project_output(&self.root, &self.output)?;
544        }
545        for suppression in &self.suppressions {
546            if suppression.reason.trim().is_empty() {
547                bail!(
548                    "suppression for {} must include a non-empty reason",
549                    suppression.rule
550                );
551            }
552            if suppression.reason.chars().count() > MAX_SUPPRESSION_REASON_CHARS {
553                bail!(
554                    "suppression reason for {} exceeds the {}-character limit",
555                    suppression.rule,
556                    MAX_SUPPRESSION_REASON_CHARS
557                );
558            }
559            if suppression.path.is_none() && suppression.matcher.is_none() {
560                bail!(
561                    "suppression for {} must select a path or matcher",
562                    suppression.rule
563                );
564            }
565            if let Some(pattern) = &suppression.path {
566                Glob::new(pattern).with_context(|| {
567                    format!(
568                        "invalid suppression path glob `{pattern}` for {}",
569                        suppression.rule
570                    )
571                })?;
572            }
573        }
574        Ok(())
575    }
576
577    pub(crate) fn validate_analysis_limits(&self) -> Result<()> {
578        if self.max_candidate_comparisons == 0 {
579            bail!("maxCandidateComparisons must be greater than zero");
580        }
581        if self.max_candidate_comparisons > MAX_CANDIDATE_COMPARISONS {
582            bail!(
583                "maxCandidateComparisons must not exceed the hard safety limit of {MAX_CANDIDATE_COMPARISONS}"
584            );
585        }
586        if self.max_structural_class_comparisons == 0 {
587            bail!("maxStructuralClassComparisons must be greater than zero");
588        }
589        if self.max_structural_class_comparisons > MAX_STRUCTURAL_CLASS_COMPARISONS {
590            bail!(
591                "maxStructuralClassComparisons must not exceed the hard safety limit of {MAX_STRUCTURAL_CLASS_COMPARISONS}"
592            );
593        }
594        Ok(())
595    }
596
597    /// Returns the configured severity for `rule`, defaulting to [`Severity::Off`].
598    pub fn severity(&self, rule: Rule) -> Severity {
599        self.rules.get(&rule).copied().unwrap_or(Severity::Off)
600    }
601
602    /// Resolves a report filename against the configured output directory.
603    pub fn output_path(&self, filename: &str) -> PathBuf {
604        if self.output.is_absolute() {
605            self.output.join(filename)
606        } else {
607            self.root.join(&self.output).join(filename)
608        }
609    }
610}
611
612fn validate_project_output(root: &Path, output: &Path) -> Result<()> {
613    if output.is_absolute()
614        || output.components().any(|component| {
615            matches!(
616                component,
617                Component::ParentDir | Component::RootDir | Component::Prefix(_)
618            )
619        })
620    {
621        bail!(
622            "project configuration output `{}` must stay beneath the analyzed root; use --output for an operator-controlled path",
623            output.display()
624        );
625    }
626
627    let candidate = root.join(output);
628    let mut existing_ancestor = candidate.as_path();
629    while !existing_ancestor.exists() {
630        existing_ancestor = existing_ancestor.parent().ok_or_else(|| {
631            anyhow::anyhow!(
632                "could not resolve project configuration output `{}`",
633                output.display()
634            )
635        })?;
636    }
637    let canonical_ancestor =
638        normalize_platform_path(existing_ancestor.canonicalize().with_context(|| {
639            format!(
640                "failed to validate project configuration output {}",
641                candidate.display()
642            )
643        })?);
644    if !canonical_ancestor.starts_with(root) {
645        bail!(
646            "project configuration output `{}` resolves outside the analyzed root; use --output for an operator-controlled path",
647            output.display()
648        );
649    }
650    Ok(())
651}
652
653pub(crate) fn normalize_platform_path(path: PathBuf) -> PathBuf {
654    #[cfg(windows)]
655    {
656        let value = path.to_string_lossy();
657        if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
658            return PathBuf::from(format!(r"\\{unc}"));
659        }
660        if let Some(local) = value.strip_prefix(r"\\?\") {
661            return PathBuf::from(local);
662        }
663    }
664    path
665}
666
667fn read_raw_config(path: &Path) -> Result<RawConfig> {
668    let text = read_utf8(path, "CukeDedup configuration", MAX_CONFIG_INPUT_BYTES)?;
669    // serde_json's default recursion limit remains enabled for untrusted config input.
670    serde_json::from_str::<RawConfig>(&text)
671        .with_context(|| format!("failed to parse {}", path.display()))
672}
673
674fn discover_config_file(
675    root: &Path,
676    warnings: &mut Vec<String>,
677) -> Result<(Option<RawConfig>, Option<PathBuf>)> {
678    const CANDIDATES: [&str; 4] = [
679        ".cuke-dedup.json",
680        ".config/cuke-dedup.json",
681        ".config/.cuke-dedup.json",
682        "cuke-dedup.config.json",
683    ];
684    for candidate in CANDIDATES {
685        let path = root.join(candidate);
686        if !path.is_file() {
687            continue;
688        }
689        match read_raw_config(&path) {
690            Ok(config) => return Ok((Some(config), Some(path))),
691            Err(error) if is_input_limit_error(&error) => return Err(error),
692            Err(error) => warnings.push(format!(
693                "could not load auto-discovered config {} ({error:#}); trying the next configuration source",
694                path.display()
695            )),
696        }
697    }
698    Ok((None, None))
699}
700
701fn config_source_name(path: &Path, root: &Path) -> String {
702    path.strip_prefix(root)
703        .unwrap_or(path)
704        .to_string_lossy()
705        .replace('\\', "/")
706}
707
708#[cfg(test)]
709mod tests;