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