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