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    /// Parses a lowercase reporter name; any other text is an `unknown reporter` error.
44    fn from_str(value: &str) -> Result<Self, Self::Err> {
45        match value {
46            "terminal" => Ok(Self::Terminal),
47            "json" => Ok(Self::Json),
48            "jsonl" => Ok(Self::Jsonl),
49            "html" => Ok(Self::Html),
50            "sarif" => Ok(Self::Sarif),
51            _ => Err(format!("unknown reporter `{value}`")),
52        }
53    }
54}
55
56#[derive(Debug, Clone, Deserialize, Default)]
57#[serde(rename_all = "camelCase", deny_unknown_fields)]
58struct RawConfig {
59    #[serde(default)]
60    ruby_load_paths: Option<Vec<PathBuf>>,
61    #[serde(default)]
62    definitions: Option<Vec<String>>,
63    #[serde(default)]
64    features: Option<Vec<String>>,
65    #[serde(default)]
66    exclude: Option<Vec<String>>,
67    #[serde(default)]
68    exclude_defaults: Option<bool>,
69    #[serde(default)]
70    include_hidden: Option<bool>,
71    #[serde(default)]
72    reporters: Option<Vec<ReporterKind>>,
73    #[serde(default)]
74    output: Option<PathBuf>,
75    #[serde(default)]
76    threshold: Option<f64>,
77    #[serde(default)]
78    near_duplicate_handler_similarity: Option<f64>,
79    #[serde(default)]
80    require_features: Option<bool>,
81    #[serde(default)]
82    require_definitions: Option<bool>,
83    #[serde(default)]
84    fail_on_incomplete: Option<bool>,
85    #[serde(default)]
86    fail_on_unparseable: Option<bool>,
87    #[serde(default)]
88    registrations: Option<Vec<String>>,
89    #[serde(default)]
90    assertion_modules: Option<Vec<String>>,
91    #[serde(default)]
92    parameter_types: Option<BTreeMap<String, String>>,
93    #[serde(default)]
94    max_candidate_comparisons: Option<usize>,
95    #[serde(default)]
96    max_structural_class_comparisons: Option<usize>,
97    #[serde(default)]
98    no_metrics: Option<bool>,
99    #[serde(default)]
100    rules: Option<BTreeMap<String, Severity>>,
101    #[serde(default)]
102    suppressions: Option<Vec<SuppressionConfig>>,
103}
104
105#[derive(Debug, Clone, Deserialize, Default)]
106#[serde(rename_all = "camelCase")]
107struct PackageJson {
108    #[serde(default)]
109    cuke_dedup: Option<RawConfig>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase", deny_unknown_fields)]
114/// A configuration entry that suppresses matching findings for a documented reason.
115pub struct SuppressionConfig {
116    /// Rule to suppress.
117    pub rule: Rule,
118    /// Human-readable justification recorded with suppressed findings.
119    pub reason: String,
120    #[serde(default)]
121    /// Optional glob selecting source paths.
122    pub path: Option<String>,
123    #[serde(default)]
124    /// Optional exact matcher selecting step definitions.
125    pub matcher: Option<String>,
126}
127
128#[derive(Debug, Clone, Default)]
129/// Command-line or embedding overrides applied after file-based configuration.
130pub struct ConfigOverrides {
131    /// Explicit JSON configuration file, bypassing automatic config-file discovery.
132    pub config_file: Option<PathBuf>,
133    /// Replacement definition-source globs.
134    pub definitions: Option<Vec<String>>,
135    /// Replacement feature-file globs.
136    pub features: Option<Vec<String>>,
137    /// Replacement custom exclusion globs.
138    pub exclude: Option<Vec<String>>,
139    /// Whether built-in generated-directory exclusions remain enabled.
140    pub exclude_defaults: Option<bool>,
141    /// Whether discovery descends into dot-directories.
142    pub include_hidden: Option<bool>,
143    /// Replacement reporter selection.
144    pub reporters: Option<Vec<ReporterKind>>,
145    /// Replacement report output directory.
146    pub output: Option<PathBuf>,
147    /// Maximum percentage of definitions allowed in active duplication errors.
148    pub threshold: Option<f64>,
149    /// Whether a run with no discovered feature files is an operational failure.
150    pub require_features: Option<bool>,
151    /// Whether machine reports omit runtime measurements.
152    pub no_metrics: Option<bool>,
153    /// Per-rule severity overrides.
154    pub rules: BTreeMap<Rule, Severity>,
155}
156
157#[derive(Debug, Clone, Default)]
158pub(crate) struct CliConfigOverrides {
159    pub(crate) require_definitions: Option<bool>,
160    /// Whether bounded or incomplete analysis is an operational failure.
161    pub(crate) fail_on_incomplete: Option<bool>,
162    /// Whether an unparseable source file is an operational failure rather than tolerated.
163    pub(crate) fail_on_unparseable: Option<bool>,
164    pub(crate) max_candidate_comparisons: Option<usize>,
165    pub(crate) max_structural_class_comparisons: Option<usize>,
166}
167
168#[derive(Debug, Clone, Serialize)]
169#[serde(rename_all = "camelCase")]
170/// Fully resolved and validated analyzer configuration.
171pub struct Config {
172    #[serde(flatten)]
173    frontend: crate::source_adapter::FrontendConfig,
174    /// Canonical repository root being analyzed.
175    pub root: PathBuf,
176    /// Globs selecting definition sources, or an empty list for automatic discovery.
177    pub definitions: Vec<String>,
178    /// Globs selecting classic Gherkin or Gherkin Markdown feature files.
179    pub features: Vec<String>,
180    /// Human-readable origin of the effective feature globs.
181    pub feature_pattern_origin: String,
182    /// Non-fatal diagnostics produced while inspecting framework configuration.
183    pub config_warnings: Vec<String>,
184    /// Standalone or package configuration source selected below CLI overrides.
185    pub config_source: String,
186    /// Globs excluded from discovery.
187    pub exclude: Vec<String>,
188    /// Whether built-in generated-directory exclusions are active.
189    pub exclude_defaults: bool,
190    /// Whether discovery descends into dot-directories.
191    pub include_hidden: bool,
192    /// Report formats emitted by the analyzer.
193    pub reporters: Vec<ReporterKind>,
194    /// Directory for file-based reports.
195    pub output: PathBuf,
196    /// Maximum percentage of definitions allowed in active duplication errors.
197    pub threshold: f64,
198    /// Minimum handler similarity (0.5–1.0) for a `near-duplicate-step` finding. Raising it demands
199    /// more handler evidence, suppressing systematically-named steps whose handlers differ.
200    pub near_duplicate_handler_similarity: f64,
201    /// Whether finding no feature files is an operational failure.
202    pub require_features: bool,
203    /// Whether extracting no step definitions is an operational failure.
204    pub require_definitions: bool,
205    /// Whether an incomplete corpus or truncated analysis is an operational failure.
206    pub fail_on_incomplete: bool,
207    /// Whether a source file that cannot be parsed is an operational failure rather than a
208    /// tolerated, reported incompleteness.
209    pub fail_on_unparseable: bool,
210    /// Additional local function names that register step definitions.
211    pub registrations: Vec<String>,
212    /// Additional module specifiers whose `expect` export is a trusted assertion factory.
213    pub assertion_modules: Vec<String>,
214    /// Project-defined Cucumber Expression parameter types and their regular expressions.
215    pub parameter_types: BTreeMap<String, String>,
216    /// Maximum unique definition pairs retained for analysis.
217    pub max_candidate_comparisons: usize,
218    /// Maximum structural candidate proposals considered from one handler class.
219    pub max_structural_class_comparisons: usize,
220    /// Whether machine reports omit runtime measurements for reproducibility.
221    pub no_metrics: bool,
222    /// Effective severity for each analysis rule.
223    pub rules: BTreeMap<Rule, Severity>,
224    /// Explicit, reason-bearing finding suppressions.
225    pub suppressions: Vec<SuppressionConfig>,
226    #[serde(skip)]
227    custom_exclude: Vec<String>,
228    #[serde(skip)]
229    output_from_project_config: bool,
230}
231
232impl Config {
233    /// Ordered, explicitly configured Ruby dependency source roots.
234    pub fn ruby_load_paths(&self) -> &[PathBuf] {
235        self.frontend.ruby_load_paths()
236    }
237
238    /// Loads framework, package, and standalone configuration beneath `root`, then applies
239    /// `overrides` in precedence order.
240    pub fn load(root: &Path, overrides: ConfigOverrides) -> Result<Self> {
241        Self::load_internal(root, overrides, CliConfigOverrides::default())
242    }
243
244    /// Like [`Config::load`], also applying CLI-only overrides after `overrides` and before
245    /// validation.
246    pub(crate) fn load_for_cli(
247        root: &Path,
248        overrides: ConfigOverrides,
249        cli_overrides: CliConfigOverrides,
250    ) -> Result<Self> {
251        Self::load_internal(root, overrides, cli_overrides)
252    }
253
254    /// Loads the configuration for `root` with the given overrides, resolving `root` to its
255    /// canonical, platform-normalized form first.
256    fn load_internal(
257        root: &Path,
258        overrides: ConfigOverrides,
259        cli_overrides: CliConfigOverrides,
260    ) -> Result<Self> {
261        let root = canonical_platform_path(root)
262            .with_context(|| format!("cannot access target directory {}", root.display()))?;
263        if !root.is_dir() {
264            bail!("target {} is not a directory", root.display());
265        }
266
267        let package_path = root.join("package.json");
268        let (package_config, package_json) = if package_path.is_file() {
269            let text = read_utf8(
270                &package_path,
271                "package configuration",
272                MAX_CONFIG_INPUT_BYTES,
273            )?;
274            // serde_json's default recursion limit remains enabled for untrusted config input.
275            let value = serde_json::from_str::<serde_json::Value>(&text)
276                .with_context(|| format!("failed to parse {}", package_path.display()))?;
277            let package = serde_json::from_value::<PackageJson>(value.clone())
278                .with_context(|| format!("failed to parse {}", package_path.display()))?;
279            (package.cuke_dedup, Some(value))
280        } else {
281            (None, None)
282        };
283
284        let explicit_config = overrides.config_file.as_ref().map(|path| {
285            if path.is_absolute() {
286                path.clone()
287            } else {
288                root.join(path)
289            }
290        });
291        let mut config_warnings = Vec::new();
292        let (standalone_config, standalone_path) = if let Some(path) = explicit_config {
293            (Some(read_raw_config(&path)?), Some(path))
294        } else {
295            discover_config_file(&root, &mut config_warnings)?
296        };
297
298        let package_config = if standalone_config.is_none() {
299            package_config
300        } else {
301            None
302        };
303
304        let has_explicit_features = overrides.features.is_some()
305            || standalone_config
306                .as_ref()
307                .is_some_and(|config| config.features.is_some())
308            || package_config
309                .as_ref()
310                .is_some_and(|config| config.features.is_some());
311        let mut config = Self::defaults(root);
312        config.config_warnings = config_warnings;
313        if !has_explicit_features {
314            match framework_config::detect(&config.root, package_json.as_ref()) {
315                Ok(Some(framework)) => {
316                    config.features = framework.patterns;
317                    config.feature_pattern_origin = format!(
318                        "{} configuration {}",
319                        framework.framework,
320                        framework
321                            .source
322                            .strip_prefix(&config.root)
323                            .unwrap_or(&framework.source)
324                            .display()
325                    );
326                    config.config_warnings.extend(framework.warnings);
327                }
328                Ok(None) => {}
329                Err(error) if is_input_limit_error(&error) => return Err(error),
330                Err(error) => config.config_warnings.push(format!(
331                    "could not inspect framework configuration ({error:#}); using built-in feature patterns"
332                )),
333            }
334        }
335        if let Some(raw) = package_config {
336            let replaces_features = raw.features.is_some();
337            config.apply_raw(raw)?;
338            config.config_source = "package.json#cukeDedup".to_owned();
339            if replaces_features {
340                config.feature_pattern_origin = "package.json#cukeDedup".to_owned();
341            }
342        }
343        if let Some(raw) = standalone_config {
344            let replaces_features = raw.features.is_some();
345            config.apply_raw(raw)?;
346            config.config_source = standalone_path
347                .as_ref()
348                .map(|path| config_source_name(path, &config.root))
349                .unwrap_or_else(|| ".cuke-dedup.json".to_owned());
350            if replaces_features {
351                config.feature_pattern_origin = config.config_source.clone();
352            }
353        }
354        let cli_replaces_features = overrides.features.is_some();
355        config.apply_overrides(overrides);
356        if let Some(value) = cli_overrides.require_definitions {
357            config.require_definitions = value;
358        }
359        if let Some(value) = cli_overrides.fail_on_incomplete {
360            config.fail_on_incomplete = value;
361        }
362        if let Some(value) = cli_overrides.fail_on_unparseable {
363            config.fail_on_unparseable = value;
364        }
365        if let Some(value) = cli_overrides.max_candidate_comparisons {
366            config.max_candidate_comparisons = value;
367        }
368        if let Some(value) = cli_overrides.max_structural_class_comparisons {
369            config.max_structural_class_comparisons = value;
370        }
371        if cli_replaces_features {
372            config.feature_pattern_origin = "--features".to_owned();
373        }
374        config.validate()?;
375        Ok(config)
376    }
377
378    /// Built-in configuration for `root`: terminal reporter, default excludes, zero threshold, and
379    /// the default severity for each rule.
380    fn defaults(root: PathBuf) -> Self {
381        let rules = [
382            (Rule::DuplicateMatcher, Severity::Error),
383            (Rule::NormalizedMatcher, Severity::Error),
384            (Rule::AmbiguousStep, Severity::Error),
385            (Rule::OverlappingMatcher, Severity::Warning),
386            (Rule::DuplicateHandler, Severity::Error),
387            (Rule::NearDuplicateStep, Severity::Warning),
388            (Rule::ParameterizationCandidate, Severity::Warning),
389            (Rule::UnusedDefinition, Severity::Warning),
390        ]
391        .into_iter()
392        .collect();
393        Self {
394            frontend: Default::default(),
395            root,
396            definitions: Vec::new(),
397            features: vec!["**/*.{feature,feature.md}".to_owned()],
398            feature_pattern_origin: "built-in defaults".to_owned(),
399            config_warnings: Vec::new(),
400            config_source: "built-in defaults".to_owned(),
401            exclude: DEFAULT_EXCLUDES.iter().map(ToString::to_string).collect(),
402            exclude_defaults: true,
403            include_hidden: false,
404            reporters: vec![ReporterKind::Terminal],
405            output: PathBuf::from("reports/cuke-dedup"),
406            threshold: 0.0,
407            near_duplicate_handler_similarity: 0.70,
408            require_features: false,
409            require_definitions: false,
410            fail_on_incomplete: false,
411            fail_on_unparseable: false,
412            registrations: Vec::new(),
413            assertion_modules: Vec::new(),
414            parameter_types: BTreeMap::new(),
415            max_candidate_comparisons: MAX_CANDIDATE_COMPARISONS,
416            max_structural_class_comparisons: MAX_STRUCTURAL_CLASS_COMPARISONS,
417            no_metrics: false,
418            rules,
419            suppressions: Vec::new(),
420            custom_exclude: Vec::new(),
421            output_from_project_config: false,
422        }
423    }
424
425    /// Replaces each field `raw` sets and merges its rule severities into the current map; fails on
426    /// an unknown rule name. A set `output` is marked for project-output containment checks.
427    fn apply_raw(&mut self, raw: RawConfig) -> Result<()> {
428        if let Some(paths) = raw.ruby_load_paths {
429            self.frontend.set_ruby_load_paths(paths);
430        }
431        if let Some(value) = raw.definitions {
432            self.definitions = value;
433        }
434        if let Some(value) = raw.features {
435            self.features = value;
436        }
437        if let Some(value) = raw.exclude_defaults {
438            self.exclude_defaults = value;
439        }
440        if let Some(value) = raw.include_hidden {
441            self.include_hidden = value;
442        }
443        if let Some(value) = raw.exclude {
444            self.custom_exclude = value;
445        }
446        self.rebuild_excludes();
447        if let Some(value) = raw.reporters {
448            self.reporters = value;
449        }
450        if let Some(value) = raw.output {
451            self.output = value;
452            self.output_from_project_config = true;
453        }
454        if let Some(value) = raw.threshold {
455            self.threshold = value;
456        }
457        if let Some(value) = raw.near_duplicate_handler_similarity {
458            self.near_duplicate_handler_similarity = value;
459        }
460        if let Some(value) = raw.require_features {
461            self.require_features = value;
462        }
463        if let Some(value) = raw.require_definitions {
464            self.require_definitions = value;
465        }
466        if let Some(value) = raw.fail_on_incomplete {
467            self.fail_on_incomplete = value;
468        }
469        if let Some(value) = raw.fail_on_unparseable {
470            self.fail_on_unparseable = value;
471        }
472        if let Some(value) = raw.registrations {
473            self.registrations = value;
474        }
475        if let Some(value) = raw.assertion_modules {
476            self.assertion_modules = value;
477        }
478        if let Some(value) = raw.parameter_types {
479            self.parameter_types = value;
480        }
481        if let Some(value) = raw.max_candidate_comparisons {
482            self.max_candidate_comparisons = value;
483        }
484        if let Some(value) = raw.max_structural_class_comparisons {
485            self.max_structural_class_comparisons = value;
486        }
487        if let Some(value) = raw.no_metrics {
488            self.no_metrics = value;
489        }
490        if let Some(value) = raw.rules {
491            for (name, severity) in value {
492                let rule = name.parse::<Rule>().map_err(anyhow::Error::msg)?;
493                self.rules.insert(rule, severity);
494            }
495        }
496        if let Some(value) = raw.suppressions {
497            self.suppressions = value;
498        }
499        Ok(())
500    }
501
502    /// Replaces each field `overrides` sets and merges its rule severities; an `output` override
503    /// clears the project-output containment check. `config_file` is not read here.
504    fn apply_overrides(&mut self, overrides: ConfigOverrides) {
505        if let Some(value) = overrides.definitions {
506            self.definitions = value;
507        }
508        if let Some(value) = overrides.features {
509            self.features = value;
510        }
511        if let Some(value) = overrides.exclude_defaults {
512            self.exclude_defaults = value;
513        }
514        if let Some(value) = overrides.include_hidden {
515            self.include_hidden = value;
516        }
517        if let Some(value) = overrides.exclude {
518            self.custom_exclude = value;
519        }
520        self.rebuild_excludes();
521        if let Some(value) = overrides.reporters {
522            self.reporters = value;
523        }
524        if let Some(value) = overrides.output {
525            self.output = value;
526            self.output_from_project_config = false;
527        }
528        if let Some(value) = overrides.threshold {
529            self.threshold = value;
530        }
531        if let Some(value) = overrides.require_features {
532            self.require_features = value;
533        }
534        if let Some(value) = overrides.no_metrics {
535            self.no_metrics = value;
536        }
537        self.rules.extend(overrides.rules);
538    }
539
540    /// Recomputes `exclude` as the default globs, when enabled, followed by the custom globs.
541    fn rebuild_excludes(&mut self) {
542        self.exclude.clear();
543        if self.exclude_defaults {
544            self.exclude
545                .extend(DEFAULT_EXCLUDES.iter().map(ToString::to_string));
546        }
547        self.exclude.extend(self.custom_exclude.iter().cloned());
548    }
549
550    /// Rejects invalid settings: no reporter, terminal with jsonl, out-of-range threshold or
551    /// similarity, bad parameter types, assertion modules, registrations or analysis limits, a
552    /// project-config output outside the root, and suppressions without a valid selector or reason.
553    fn validate(&self) -> Result<()> {
554        if self.reporters.is_empty() {
555            bail!("at least one reporter must be configured");
556        }
557        if self.reporters.contains(&ReporterKind::Terminal)
558            && self.reporters.contains(&ReporterKind::Jsonl)
559        {
560            bail!("terminal and jsonl reporters cannot share stdout; select only one of them");
561        }
562        if !self.threshold.is_finite() || !(0.0..=100.0).contains(&self.threshold) {
563            bail!("threshold must be a finite percentage from 0 through 100");
564        }
565        // A range check also rejects NaN (`contains` is false for it); config files cannot express a
566        // non-finite value, so no separate `is_finite` guard is reachable here.
567        if !(0.5..=1.0).contains(&self.near_duplicate_handler_similarity) {
568            bail!("nearDuplicateHandlerSimilarity must be a value from 0.5 through 1.0");
569        }
570        for (name, pattern) in &self.parameter_types {
571            if name.is_empty() || name.contains(['{', '}']) {
572                bail!("parameter type `{name}` must not be empty or contain braces");
573            }
574            crate::resource_limits::compile_regex(pattern).with_context(|| {
575                format!("invalid regular expression for parameter type `{name}`")
576            })?;
577        }
578        for module in &self.assertion_modules {
579            // A specifier is matched exactly against the import text, so surrounding whitespace or
580            // an empty entry would silently never match and quietly disable the setting.
581            if module.is_empty() || module.trim() != module {
582                bail!("assertion module `{module}` must be a module specifier without surrounding whitespace");
583            }
584        }
585        for name in &self.registrations {
586            if name.is_empty()
587                || !name
588                    .chars()
589                    .all(|character| character.is_alphanumeric() || matches!(character, '_' | '$'))
590                || name.starts_with(|character: char| character.is_ascii_digit())
591            {
592                bail!("registration `{name}` must be a JavaScript identifier");
593            }
594        }
595        self.validate_analysis_limits()?;
596        if self.output_from_project_config {
597            validate_project_output(&self.root, &self.output)?;
598        }
599        for suppression in &self.suppressions {
600            if suppression.reason.trim().is_empty() {
601                bail!(
602                    "suppression for {} must include a non-empty reason",
603                    suppression.rule
604                );
605            }
606            if suppression.reason.chars().count() > MAX_SUPPRESSION_REASON_CHARS {
607                bail!(
608                    "suppression reason for {} exceeds the {}-character limit",
609                    suppression.rule,
610                    MAX_SUPPRESSION_REASON_CHARS
611                );
612            }
613            if suppression.path.is_none() && suppression.matcher.is_none() {
614                bail!(
615                    "suppression for {} must select a path or matcher",
616                    suppression.rule
617                );
618            }
619            if let Some(pattern) = &suppression.path {
620                Glob::new(pattern).with_context(|| {
621                    format!(
622                        "invalid suppression path glob `{pattern}` for {}",
623                        suppression.rule
624                    )
625                })?;
626            }
627        }
628        Ok(())
629    }
630
631    /// Requires both comparison budgets to be nonzero and within their hard safety limits.
632    pub(crate) fn validate_analysis_limits(&self) -> Result<()> {
633        if self.max_candidate_comparisons == 0 {
634            bail!("maxCandidateComparisons must be greater than zero");
635        }
636        if self.max_candidate_comparisons > MAX_CANDIDATE_COMPARISONS {
637            bail!(
638                "maxCandidateComparisons must not exceed the hard safety limit of {MAX_CANDIDATE_COMPARISONS}"
639            );
640        }
641        if self.max_structural_class_comparisons == 0 {
642            bail!("maxStructuralClassComparisons must be greater than zero");
643        }
644        if self.max_structural_class_comparisons > MAX_STRUCTURAL_CLASS_COMPARISONS {
645            bail!(
646                "maxStructuralClassComparisons must not exceed the hard safety limit of {MAX_STRUCTURAL_CLASS_COMPARISONS}"
647            );
648        }
649        Ok(())
650    }
651
652    /// Returns the configured severity for `rule`, defaulting to [`Severity::Off`].
653    pub fn severity(&self, rule: Rule) -> Severity {
654        self.rules.get(&rule).copied().unwrap_or(Severity::Off)
655    }
656
657    /// Resolves a report filename against the configured output directory.
658    pub fn output_path(&self, filename: &str) -> PathBuf {
659        if self.output.is_absolute() {
660            self.output.join(filename)
661        } else {
662            self.root.join(&self.output).join(filename)
663        }
664    }
665}
666
667/// Rejects a project-config `output` that is absolute, has a root, prefix or `..` component, or
668/// whose nearest existing ancestor canonicalizes outside `root`.
669fn validate_project_output(root: &Path, output: &Path) -> Result<()> {
670    if output.is_absolute()
671        || output.components().any(|component| {
672            matches!(
673                component,
674                Component::ParentDir | Component::RootDir | Component::Prefix(_)
675            )
676        })
677    {
678        bail!(
679            "project configuration output `{}` must stay beneath the analyzed root; use --output for an operator-controlled path",
680            output.display()
681        );
682    }
683
684    let candidate = root.join(output);
685    let mut existing_ancestor = candidate.as_path();
686    while !existing_ancestor.exists() {
687        existing_ancestor = existing_ancestor.parent().ok_or_else(|| {
688            anyhow::anyhow!(
689                "could not resolve project configuration output `{}`",
690                output.display()
691            )
692        })?;
693    }
694    let canonical_ancestor =
695        normalize_platform_path(existing_ancestor.canonicalize().with_context(|| {
696            format!(
697                "failed to validate project configuration output {}",
698                candidate.display()
699            )
700        })?);
701    if !canonical_ancestor.starts_with(root) {
702        bail!(
703            "project configuration output `{}` resolves outside the analyzed root; use --output for an operator-controlled path",
704            output.display()
705        );
706    }
707    Ok(())
708}
709
710/// `path` without the Windows verbatim prefix, so it compares with `Config::root` and strips to
711/// a relative display path; unchanged on other platforms.
712pub(crate) fn normalize_platform_path(path: PathBuf) -> PathBuf {
713    #[cfg(windows)]
714    {
715        if let Some(plain) = strip_verbatim_prefix(&path) {
716            return plain;
717        }
718    }
719    path
720}
721
722/// `path` without its `\\?\X:` or `\\?\UNC\server\share` verbatim prefix, rebuilt from its
723/// components without any `OsStr` to `str` conversion. `None` keeps the verbatim form.
724#[cfg(windows)]
725fn strip_verbatim_prefix(path: &Path) -> Option<PathBuf> {
726    use std::ffi::OsStr;
727    use std::path::Prefix;
728    let mut components = path.components();
729    let prefix = match components.next() {
730        Some(Component::Prefix(prefix)) => prefix,
731        _ => return None, // fail-closed: a path without a prefix has nothing to strip
732    };
733    let drive;
734    let (lead, head) = match prefix.kind() {
735        Prefix::VerbatimDisk(letter) => {
736            drive = format!("{}:", char::from(letter));
737            ("", vec![OsStr::new(&drive)])
738        }
739        Prefix::VerbatimUNC(server, share) => (r"\\", vec![server, share]),
740        _ => return None, // fail-closed: device, volume and non-verbatim prefixes keep their form
741    };
742    let rest: Vec<_> = components.collect();
743    let suffix_len = path.as_os_str().len() - prefix.as_os_str().len();
744    plain_verbatim_path(lead, &head, &rest, suffix_len)
745}
746
747/// The plain form `lead` + `head` + `\` + the `Normal` names of `rest` when it names the same
748/// file: every name passes `plain_name`, and its length equals the head plus `suffix_len` (the
749/// verbatim length after the prefix), which holds only when `rest` is one root followed by
750/// `\`-separated names, so no `.`, `..`, empty or trailing component was dropped. An empty suffix
751/// is a UNC share root (`\\server\share`); a drive has none, since plain `C:` names its current
752/// directory. Length is not
753/// limited: std re-adds the verbatim prefix to long paths, and a limit would leave a long path
754/// verbatim under a plain `Config::root`, failing containment. `None` keeps the verbatim form.
755#[cfg(any(windows, test))]
756fn plain_verbatim_path(
757    lead: &str,
758    head: &[&std::ffi::OsStr],
759    rest: &[Component<'_>],
760    suffix_len: usize,
761) -> Option<PathBuf> {
762    let names: Vec<_> = rest
763        .iter()
764        .filter_map(|component| match component {
765            Component::Normal(name) => Some(*name),
766            _ => None, // fail-closed: the length check rejects any dropped component but the root
767        })
768        .collect();
769    let mut plain = std::ffi::OsString::from(lead);
770    plain.push(join_with_separator(head));
771    let head_len = plain.len();
772    if suffix_len > 0 {
773        plain.push(r"\");
774        plain.push(join_with_separator(&names));
775    }
776    (plain.len() == head_len + suffix_len
777        && (suffix_len > 0 || !lead.is_empty())
778        && head.iter().all(|name| plain_name(name))
779        && names
780            .iter()
781            .all(|name| plain_name(name) && plain_chars(name)))
782    .then(|| plain.into())
783}
784
785/// `parts` joined with `\`, built without the unstable `OsStr` slice `join`.
786#[cfg(any(windows, test))]
787fn join_with_separator(parts: &[&std::ffi::OsStr]) -> std::ffi::OsString {
788    let mut joined = std::ffi::OsString::new();
789    for (index, part) in parts.iter().enumerate() {
790        if index > 0 {
791            joined.push(r"\");
792        }
793        joined.push(part);
794    }
795    joined
796}
797
798/// Whether `name` holds no character Win32 rejects or reinterprets: the reserved `<>:"|?*`
799/// (`:` starts a stream name) and control characters below U+0020. Checked on the `names` only;
800/// the head is a drive or server name the verbatim form already validated.
801#[cfg(any(windows, test))]
802fn plain_chars(name: &std::ffi::OsStr) -> bool {
803    name.as_encoded_bytes()
804        .iter()
805        .all(|&byte| byte >= 0x20 && !br#"<>:"|?*"#.contains(&byte))
806}
807
808/// Whether Win32 keeps `name` unchanged in a plain path: not a DOS device name (superscript
809/// aliases and console handles included), no trailing dot or space, no `/`. Device checks run on
810/// the encoded bytes, so names that are not valid Unicode are checked too; `plain_chars` covers
811/// the remaining characters.
812#[cfg(any(windows, test))]
813fn plain_name(name: &std::ffi::OsStr) -> bool {
814    // Reserved names from "Naming Files, Paths, and Namespaces", and the console handles from
815    // "CreateFileW".
816    const RESERVED: [&[u8]; 32] = [
817        b"CON",
818        b"CONIN$",
819        b"CONOUT$",
820        b"PRN",
821        b"AUX",
822        b"NUL",
823        b"COM0",
824        b"COM1",
825        b"COM2",
826        b"COM3",
827        b"COM4",
828        b"COM5",
829        b"COM6",
830        b"COM7",
831        b"COM8",
832        b"COM9",
833        "COM¹".as_bytes(),
834        "COM²".as_bytes(),
835        "COM³".as_bytes(),
836        b"LPT0",
837        b"LPT1",
838        b"LPT2",
839        b"LPT3",
840        b"LPT4",
841        b"LPT5",
842        b"LPT6",
843        b"LPT7",
844        b"LPT8",
845        b"LPT9",
846        "LPT¹".as_bytes(),
847        "LPT²".as_bytes(),
848        "LPT³".as_bytes(),
849    ];
850    let bytes = name.as_encoded_bytes();
851    let stem = bytes
852        .split(|&byte| byte == b'.')
853        .next()
854        .unwrap_or_default()
855        .trim_ascii_end();
856    let device = RESERVED
857        .iter()
858        .any(|reserved| stem.eq_ignore_ascii_case(reserved));
859    !bytes.ends_with(b".") && !bytes.ends_with(b" ") && !bytes.contains(&b'/') && !device
860}
861
862/// `path` canonicalized and normalized like `Config::root`, so paths reached through the
863/// filesystem compare with, and strip to relative paths under, the analysis root on every platform.
864pub(crate) fn canonical_platform_path(path: &Path) -> std::io::Result<PathBuf> {
865    path.canonicalize().map(normalize_platform_path)
866}
867
868/// Reads and parses a standalone config file within the config byte limit; unknown keys fail.
869fn read_raw_config(path: &Path) -> Result<RawConfig> {
870    let text = read_utf8(path, "CukeDedup configuration", MAX_CONFIG_INPUT_BYTES)?;
871    // serde_json's default recursion limit remains enabled for untrusted config input.
872    serde_json::from_str::<RawConfig>(&text)
873        .with_context(|| format!("failed to parse {}", path.display()))
874}
875
876/// Returns the first candidate config file under `root` that parses. A parse or read failure is
877/// pushed to `warnings` and the next candidate is tried, except input-limit errors, which return.
878fn discover_config_file(
879    root: &Path,
880    warnings: &mut Vec<String>,
881) -> Result<(Option<RawConfig>, Option<PathBuf>)> {
882    const CANDIDATES: [&str; 4] = [
883        ".cuke-dedup.json",
884        ".config/cuke-dedup.json",
885        ".config/.cuke-dedup.json",
886        "cuke-dedup.config.json",
887    ];
888    for candidate in CANDIDATES {
889        let path = root.join(candidate);
890        if !path.is_file() {
891            continue;
892        }
893        match read_raw_config(&path) {
894            Ok(config) => return Ok((Some(config), Some(path))),
895            Err(error) if is_input_limit_error(&error) => return Err(error),
896            Err(error) => warnings.push(format!(
897                "could not load auto-discovered config {} ({error:#}); trying the next configuration source",
898                path.display()
899            )),
900        }
901    }
902    Ok((None, None))
903}
904
905/// `path` relative to `root` when beneath it, otherwise as given, with `\` replaced by `/`.
906fn config_source_name(path: &Path, root: &Path) -> String {
907    path.strip_prefix(root)
908        .unwrap_or(path)
909        .to_string_lossy()
910        .replace('\\', "/")
911}
912
913#[cfg(test)]
914mod tests;