1use 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#[non_exhaustive]
27pub enum ReporterKind {
28 Terminal,
30 Json,
32 Jsonl,
34 Html,
36 Sarif,
38}
39
40impl FromStr for ReporterKind {
41 type Err = String;
42
43 fn from_str(value: &str) -> Result<Self, Self::Err> {
44 match value {
45 "terminal" => Ok(Self::Terminal),
46 "json" => Ok(Self::Json),
47 "jsonl" => Ok(Self::Jsonl),
48 "html" => Ok(Self::Html),
49 "sarif" => Ok(Self::Sarif),
50 _ => Err(format!("unknown reporter `{value}`")),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Deserialize, Default)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57struct RawConfig {
58 #[serde(default)]
59 definitions: Option<Vec<String>>,
60 #[serde(default)]
61 features: Option<Vec<String>>,
62 #[serde(default)]
63 exclude: Option<Vec<String>>,
64 #[serde(default)]
65 exclude_defaults: Option<bool>,
66 #[serde(default)]
67 include_hidden: Option<bool>,
68 #[serde(default)]
69 reporters: Option<Vec<ReporterKind>>,
70 #[serde(default)]
71 output: Option<PathBuf>,
72 #[serde(default)]
73 threshold: Option<f64>,
74 #[serde(default)]
75 require_features: Option<bool>,
76 #[serde(default)]
77 require_definitions: Option<bool>,
78 #[serde(default)]
79 fail_on_incomplete: Option<bool>,
80 #[serde(default)]
81 registrations: Option<Vec<String>>,
82 #[serde(default)]
83 assertion_modules: Option<Vec<String>>,
84 #[serde(default)]
85 parameter_types: Option<BTreeMap<String, String>>,
86 #[serde(default)]
87 max_candidate_comparisons: Option<usize>,
88 #[serde(default)]
89 max_structural_class_comparisons: Option<usize>,
90 #[serde(default)]
91 no_metrics: Option<bool>,
92 #[serde(default)]
93 rules: Option<BTreeMap<String, Severity>>,
94 #[serde(default)]
95 suppressions: Option<Vec<SuppressionConfig>>,
96}
97
98#[derive(Debug, Clone, Deserialize, Default)]
99#[serde(rename_all = "camelCase")]
100struct PackageJson {
101 #[serde(default)]
102 cuke_dedup: Option<RawConfig>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase", deny_unknown_fields)]
107pub struct SuppressionConfig {
109 pub rule: Rule,
111 pub reason: String,
113 #[serde(default)]
114 pub path: Option<String>,
116 #[serde(default)]
117 pub matcher: Option<String>,
119}
120
121#[derive(Debug, Clone, Default)]
122pub struct ConfigOverrides {
124 pub config_file: Option<PathBuf>,
126 pub definitions: Option<Vec<String>>,
128 pub features: Option<Vec<String>>,
130 pub exclude: Option<Vec<String>>,
132 pub exclude_defaults: Option<bool>,
134 pub include_hidden: Option<bool>,
136 pub reporters: Option<Vec<ReporterKind>>,
138 pub output: Option<PathBuf>,
140 pub threshold: Option<f64>,
142 pub require_features: Option<bool>,
144 pub no_metrics: Option<bool>,
146 pub rules: BTreeMap<Rule, Severity>,
148}
149
150#[derive(Debug, Clone, Default)]
151pub(crate) struct CliConfigOverrides {
152 pub(crate) require_definitions: Option<bool>,
153 pub(crate) fail_on_incomplete: Option<bool>,
155 pub(crate) max_candidate_comparisons: Option<usize>,
156 pub(crate) max_structural_class_comparisons: Option<usize>,
157}
158
159#[derive(Debug, Clone, Serialize)]
160#[serde(rename_all = "camelCase")]
161pub struct Config {
163 pub root: PathBuf,
165 pub definitions: Vec<String>,
167 pub features: Vec<String>,
169 pub feature_pattern_origin: String,
171 pub config_warnings: Vec<String>,
173 pub config_source: String,
175 pub exclude: Vec<String>,
177 pub exclude_defaults: bool,
179 pub include_hidden: bool,
181 pub reporters: Vec<ReporterKind>,
183 pub output: PathBuf,
185 pub threshold: f64,
187 pub require_features: bool,
189 pub require_definitions: bool,
191 pub fail_on_incomplete: bool,
193 pub registrations: Vec<String>,
195 pub assertion_modules: Vec<String>,
197 pub parameter_types: BTreeMap<String, String>,
199 pub max_candidate_comparisons: usize,
201 pub max_structural_class_comparisons: usize,
203 pub no_metrics: bool,
205 pub rules: BTreeMap<Rule, Severity>,
207 pub suppressions: Vec<SuppressionConfig>,
209 #[serde(skip)]
210 custom_exclude: Vec<String>,
211 #[serde(skip)]
212 output_from_project_config: bool,
213}
214
215impl Config {
216 pub fn load(root: &Path, overrides: ConfigOverrides) -> Result<Self> {
219 Self::load_internal(root, overrides, CliConfigOverrides::default())
220 }
221
222 pub(crate) fn load_for_cli(
223 root: &Path,
224 overrides: ConfigOverrides,
225 cli_overrides: CliConfigOverrides,
226 ) -> Result<Self> {
227 Self::load_internal(root, overrides, cli_overrides)
228 }
229
230 fn load_internal(
231 root: &Path,
232 overrides: ConfigOverrides,
233 cli_overrides: CliConfigOverrides,
234 ) -> Result<Self> {
235 let root = normalize_platform_path(
236 root.canonicalize()
237 .with_context(|| format!("cannot access target directory {}", root.display()))?,
238 );
239 if !root.is_dir() {
240 bail!("target {} is not a directory", root.display());
241 }
242
243 let package_path = root.join("package.json");
244 let (package_config, package_json) = if package_path.is_file() {
245 let text = read_utf8(
246 &package_path,
247 "package configuration",
248 MAX_CONFIG_INPUT_BYTES,
249 )?;
250 let value = serde_json::from_str::<serde_json::Value>(&text)
252 .with_context(|| format!("failed to parse {}", package_path.display()))?;
253 let package = serde_json::from_value::<PackageJson>(value.clone())
254 .with_context(|| format!("failed to parse {}", package_path.display()))?;
255 (package.cuke_dedup, Some(value))
256 } else {
257 (None, None)
258 };
259
260 let explicit_config = overrides.config_file.as_ref().map(|path| {
261 if path.is_absolute() {
262 path.clone()
263 } else {
264 root.join(path)
265 }
266 });
267 let mut config_warnings = Vec::new();
268 let (standalone_config, standalone_path) = if let Some(path) = explicit_config {
269 (Some(read_raw_config(&path)?), Some(path))
270 } else {
271 discover_config_file(&root, &mut config_warnings)?
272 };
273
274 let package_config = if standalone_config.is_none() {
275 package_config
276 } else {
277 None
278 };
279
280 let has_explicit_features = overrides.features.is_some()
281 || standalone_config
282 .as_ref()
283 .is_some_and(|config| config.features.is_some())
284 || package_config
285 .as_ref()
286 .is_some_and(|config| config.features.is_some());
287 let mut config = Self::defaults(root);
288 config.config_warnings = config_warnings;
289 if !has_explicit_features {
290 match framework_config::detect(&config.root, package_json.as_ref()) {
291 Ok(Some(framework)) => {
292 config.features = framework.patterns;
293 config.feature_pattern_origin = format!(
294 "{} configuration {}",
295 framework.framework,
296 framework
297 .source
298 .strip_prefix(&config.root)
299 .unwrap_or(&framework.source)
300 .display()
301 );
302 config.config_warnings.extend(framework.warnings);
303 }
304 Ok(None) => {}
305 Err(error) if is_input_limit_error(&error) => return Err(error),
306 Err(error) => config.config_warnings.push(format!(
307 "could not inspect framework configuration ({error:#}); using built-in feature patterns"
308 )),
309 }
310 }
311 if let Some(raw) = package_config {
312 let replaces_features = raw.features.is_some();
313 config.apply_raw(raw)?;
314 config.config_source = "package.json#cukeDedup".to_owned();
315 if replaces_features {
316 config.feature_pattern_origin = "package.json#cukeDedup".to_owned();
317 }
318 }
319 if let Some(raw) = standalone_config {
320 let replaces_features = raw.features.is_some();
321 config.apply_raw(raw)?;
322 config.config_source = standalone_path
323 .as_ref()
324 .map(|path| config_source_name(path, &config.root))
325 .unwrap_or_else(|| ".cuke-dedup.json".to_owned());
326 if replaces_features {
327 config.feature_pattern_origin = config.config_source.clone();
328 }
329 }
330 let cli_replaces_features = overrides.features.is_some();
331 config.apply_overrides(overrides);
332 if let Some(value) = cli_overrides.require_definitions {
333 config.require_definitions = value;
334 }
335 if let Some(value) = cli_overrides.fail_on_incomplete {
336 config.fail_on_incomplete = value;
337 }
338 if let Some(value) = cli_overrides.max_candidate_comparisons {
339 config.max_candidate_comparisons = value;
340 }
341 if let Some(value) = cli_overrides.max_structural_class_comparisons {
342 config.max_structural_class_comparisons = value;
343 }
344 if cli_replaces_features {
345 config.feature_pattern_origin = "--features".to_owned();
346 }
347 config.validate()?;
348 Ok(config)
349 }
350
351 fn defaults(root: PathBuf) -> Self {
352 let rules = [
353 (Rule::DuplicateMatcher, Severity::Error),
354 (Rule::NormalizedMatcher, Severity::Error),
355 (Rule::AmbiguousStep, Severity::Error),
356 (Rule::OverlappingMatcher, Severity::Warning),
357 (Rule::DuplicateHandler, Severity::Error),
358 (Rule::NearDuplicateStep, Severity::Warning),
359 (Rule::ParameterizationCandidate, Severity::Warning),
360 (Rule::UnusedDefinition, Severity::Warning),
361 ]
362 .into_iter()
363 .collect();
364 Self {
365 root,
366 definitions: Vec::new(),
367 features: vec!["**/*.{feature,feature.md}".to_owned()],
368 feature_pattern_origin: "built-in defaults".to_owned(),
369 config_warnings: Vec::new(),
370 config_source: "built-in defaults".to_owned(),
371 exclude: DEFAULT_EXCLUDES.iter().map(ToString::to_string).collect(),
372 exclude_defaults: true,
373 include_hidden: false,
374 reporters: vec![ReporterKind::Terminal],
375 output: PathBuf::from("reports/cuke-dedup"),
376 threshold: 0.0,
377 require_features: false,
378 require_definitions: false,
379 fail_on_incomplete: false,
380 registrations: Vec::new(),
381 assertion_modules: Vec::new(),
382 parameter_types: BTreeMap::new(),
383 max_candidate_comparisons: MAX_CANDIDATE_COMPARISONS,
384 max_structural_class_comparisons: MAX_STRUCTURAL_CLASS_COMPARISONS,
385 no_metrics: false,
386 rules,
387 suppressions: Vec::new(),
388 custom_exclude: Vec::new(),
389 output_from_project_config: false,
390 }
391 }
392
393 fn apply_raw(&mut self, raw: RawConfig) -> Result<()> {
394 if let Some(value) = raw.definitions {
395 self.definitions = value;
396 }
397 if let Some(value) = raw.features {
398 self.features = value;
399 }
400 if let Some(value) = raw.exclude_defaults {
401 self.exclude_defaults = value;
402 }
403 if let Some(value) = raw.include_hidden {
404 self.include_hidden = value;
405 }
406 if let Some(value) = raw.exclude {
407 self.custom_exclude = value;
408 }
409 self.rebuild_excludes();
410 if let Some(value) = raw.reporters {
411 self.reporters = value;
412 }
413 if let Some(value) = raw.output {
414 self.output = value;
415 self.output_from_project_config = true;
416 }
417 if let Some(value) = raw.threshold {
418 self.threshold = value;
419 }
420 if let Some(value) = raw.require_features {
421 self.require_features = value;
422 }
423 if let Some(value) = raw.require_definitions {
424 self.require_definitions = value;
425 }
426 if let Some(value) = raw.fail_on_incomplete {
427 self.fail_on_incomplete = value;
428 }
429 if let Some(value) = raw.registrations {
430 self.registrations = value;
431 }
432 if let Some(value) = raw.assertion_modules {
433 self.assertion_modules = value;
434 }
435 if let Some(value) = raw.parameter_types {
436 self.parameter_types = value;
437 }
438 if let Some(value) = raw.max_candidate_comparisons {
439 self.max_candidate_comparisons = value;
440 }
441 if let Some(value) = raw.max_structural_class_comparisons {
442 self.max_structural_class_comparisons = value;
443 }
444 if let Some(value) = raw.no_metrics {
445 self.no_metrics = value;
446 }
447 if let Some(value) = raw.rules {
448 for (name, severity) in value {
449 let rule = name.parse::<Rule>().map_err(anyhow::Error::msg)?;
450 self.rules.insert(rule, severity);
451 }
452 }
453 if let Some(value) = raw.suppressions {
454 self.suppressions = value;
455 }
456 Ok(())
457 }
458
459 fn apply_overrides(&mut self, overrides: ConfigOverrides) {
460 if let Some(value) = overrides.definitions {
461 self.definitions = value;
462 }
463 if let Some(value) = overrides.features {
464 self.features = value;
465 }
466 if let Some(value) = overrides.exclude_defaults {
467 self.exclude_defaults = value;
468 }
469 if let Some(value) = overrides.include_hidden {
470 self.include_hidden = value;
471 }
472 if let Some(value) = overrides.exclude {
473 self.custom_exclude = value;
474 }
475 self.rebuild_excludes();
476 if let Some(value) = overrides.reporters {
477 self.reporters = value;
478 }
479 if let Some(value) = overrides.output {
480 self.output = value;
481 self.output_from_project_config = false;
482 }
483 if let Some(value) = overrides.threshold {
484 self.threshold = value;
485 }
486 if let Some(value) = overrides.require_features {
487 self.require_features = value;
488 }
489 if let Some(value) = overrides.no_metrics {
490 self.no_metrics = value;
491 }
492 self.rules.extend(overrides.rules);
493 }
494
495 fn rebuild_excludes(&mut self) {
496 self.exclude.clear();
497 if self.exclude_defaults {
498 self.exclude
499 .extend(DEFAULT_EXCLUDES.iter().map(ToString::to_string));
500 }
501 self.exclude.extend(self.custom_exclude.iter().cloned());
502 }
503
504 fn validate(&self) -> Result<()> {
505 if self.reporters.is_empty() {
506 bail!("at least one reporter must be configured");
507 }
508 if self.reporters.contains(&ReporterKind::Terminal)
509 && self.reporters.contains(&ReporterKind::Jsonl)
510 {
511 bail!("terminal and jsonl reporters cannot share stdout; select only one of them");
512 }
513 if !self.threshold.is_finite() || !(0.0..=100.0).contains(&self.threshold) {
514 bail!("threshold must be a finite percentage from 0 through 100");
515 }
516 for (name, pattern) in &self.parameter_types {
517 if name.is_empty() || name.contains(['{', '}']) {
518 bail!("parameter type `{name}` must not be empty or contain braces");
519 }
520 crate::resource_limits::compile_regex(pattern).with_context(|| {
521 format!("invalid regular expression for parameter type `{name}`")
522 })?;
523 }
524 for module in &self.assertion_modules {
525 if module.is_empty() || module.trim() != module {
528 bail!("assertion module `{module}` must be a module specifier without surrounding whitespace");
529 }
530 }
531 for name in &self.registrations {
532 if name.is_empty()
533 || !name
534 .chars()
535 .all(|character| character.is_alphanumeric() || matches!(character, '_' | '$'))
536 || name.starts_with(|character: char| character.is_ascii_digit())
537 {
538 bail!("registration `{name}` must be a JavaScript identifier");
539 }
540 }
541 self.validate_analysis_limits()?;
542 if self.output_from_project_config {
543 validate_project_output(&self.root, &self.output)?;
544 }
545 for suppression in &self.suppressions {
546 if suppression.reason.trim().is_empty() {
547 bail!(
548 "suppression for {} must include a non-empty reason",
549 suppression.rule
550 );
551 }
552 if suppression.reason.chars().count() > MAX_SUPPRESSION_REASON_CHARS {
553 bail!(
554 "suppression reason for {} exceeds the {}-character limit",
555 suppression.rule,
556 MAX_SUPPRESSION_REASON_CHARS
557 );
558 }
559 if suppression.path.is_none() && suppression.matcher.is_none() {
560 bail!(
561 "suppression for {} must select a path or matcher",
562 suppression.rule
563 );
564 }
565 if let Some(pattern) = &suppression.path {
566 Glob::new(pattern).with_context(|| {
567 format!(
568 "invalid suppression path glob `{pattern}` for {}",
569 suppression.rule
570 )
571 })?;
572 }
573 }
574 Ok(())
575 }
576
577 pub(crate) fn validate_analysis_limits(&self) -> Result<()> {
578 if self.max_candidate_comparisons == 0 {
579 bail!("maxCandidateComparisons must be greater than zero");
580 }
581 if self.max_candidate_comparisons > MAX_CANDIDATE_COMPARISONS {
582 bail!(
583 "maxCandidateComparisons must not exceed the hard safety limit of {MAX_CANDIDATE_COMPARISONS}"
584 );
585 }
586 if self.max_structural_class_comparisons == 0 {
587 bail!("maxStructuralClassComparisons must be greater than zero");
588 }
589 if self.max_structural_class_comparisons > MAX_STRUCTURAL_CLASS_COMPARISONS {
590 bail!(
591 "maxStructuralClassComparisons must not exceed the hard safety limit of {MAX_STRUCTURAL_CLASS_COMPARISONS}"
592 );
593 }
594 Ok(())
595 }
596
597 pub fn severity(&self, rule: Rule) -> Severity {
599 self.rules.get(&rule).copied().unwrap_or(Severity::Off)
600 }
601
602 pub fn output_path(&self, filename: &str) -> PathBuf {
604 if self.output.is_absolute() {
605 self.output.join(filename)
606 } else {
607 self.root.join(&self.output).join(filename)
608 }
609 }
610}
611
612fn validate_project_output(root: &Path, output: &Path) -> Result<()> {
613 if output.is_absolute()
614 || output.components().any(|component| {
615 matches!(
616 component,
617 Component::ParentDir | Component::RootDir | Component::Prefix(_)
618 )
619 })
620 {
621 bail!(
622 "project configuration output `{}` must stay beneath the analyzed root; use --output for an operator-controlled path",
623 output.display()
624 );
625 }
626
627 let candidate = root.join(output);
628 let mut existing_ancestor = candidate.as_path();
629 while !existing_ancestor.exists() {
630 existing_ancestor = existing_ancestor.parent().ok_or_else(|| {
631 anyhow::anyhow!(
632 "could not resolve project configuration output `{}`",
633 output.display()
634 )
635 })?;
636 }
637 let canonical_ancestor =
638 normalize_platform_path(existing_ancestor.canonicalize().with_context(|| {
639 format!(
640 "failed to validate project configuration output {}",
641 candidate.display()
642 )
643 })?);
644 if !canonical_ancestor.starts_with(root) {
645 bail!(
646 "project configuration output `{}` resolves outside the analyzed root; use --output for an operator-controlled path",
647 output.display()
648 );
649 }
650 Ok(())
651}
652
653pub(crate) fn normalize_platform_path(path: PathBuf) -> PathBuf {
654 #[cfg(windows)]
655 {
656 let value = path.to_string_lossy();
657 if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
658 return PathBuf::from(format!(r"\\{unc}"));
659 }
660 if let Some(local) = value.strip_prefix(r"\\?\") {
661 return PathBuf::from(local);
662 }
663 }
664 path
665}
666
667fn read_raw_config(path: &Path) -> Result<RawConfig> {
668 let text = read_utf8(path, "CukeDedup configuration", MAX_CONFIG_INPUT_BYTES)?;
669 serde_json::from_str::<RawConfig>(&text)
671 .with_context(|| format!("failed to parse {}", path.display()))
672}
673
674fn discover_config_file(
675 root: &Path,
676 warnings: &mut Vec<String>,
677) -> Result<(Option<RawConfig>, Option<PathBuf>)> {
678 const CANDIDATES: [&str; 4] = [
679 ".cuke-dedup.json",
680 ".config/cuke-dedup.json",
681 ".config/.cuke-dedup.json",
682 "cuke-dedup.config.json",
683 ];
684 for candidate in CANDIDATES {
685 let path = root.join(candidate);
686 if !path.is_file() {
687 continue;
688 }
689 match read_raw_config(&path) {
690 Ok(config) => return Ok((Some(config), Some(path))),
691 Err(error) if is_input_limit_error(&error) => return Err(error),
692 Err(error) => warnings.push(format!(
693 "could not load auto-discovered config {} ({error:#}); trying the next configuration source",
694 path.display()
695 )),
696 }
697 }
698 Ok((None, None))
699}
700
701fn config_source_name(path: &Path, root: &Path) -> String {
702 path.strip_prefix(root)
703 .unwrap_or(path)
704 .to_string_lossy()
705 .replace('\\', "/")
706}
707
708#[cfg(test)]
709mod tests;