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 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)]
111pub struct SuppressionConfig {
113 pub rule: Rule,
115 pub reason: String,
117 #[serde(default)]
118 pub path: Option<String>,
120 #[serde(default)]
121 pub matcher: Option<String>,
123}
124
125#[derive(Debug, Clone, Default)]
126pub struct ConfigOverrides {
128 pub config_file: Option<PathBuf>,
130 pub definitions: Option<Vec<String>>,
132 pub features: Option<Vec<String>>,
134 pub exclude: Option<Vec<String>>,
136 pub exclude_defaults: Option<bool>,
138 pub include_hidden: Option<bool>,
140 pub reporters: Option<Vec<ReporterKind>>,
142 pub output: Option<PathBuf>,
144 pub threshold: Option<f64>,
146 pub require_features: Option<bool>,
148 pub no_metrics: Option<bool>,
150 pub rules: BTreeMap<Rule, Severity>,
152}
153
154#[derive(Debug, Clone, Default)]
155pub(crate) struct CliConfigOverrides {
156 pub(crate) require_definitions: Option<bool>,
157 pub(crate) fail_on_incomplete: Option<bool>,
159 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")]
167pub struct Config {
169 pub root: PathBuf,
171 pub definitions: Vec<String>,
173 pub features: Vec<String>,
175 pub feature_pattern_origin: String,
177 pub config_warnings: Vec<String>,
179 pub config_source: String,
181 pub exclude: Vec<String>,
183 pub exclude_defaults: bool,
185 pub include_hidden: bool,
187 pub reporters: Vec<ReporterKind>,
189 pub output: PathBuf,
191 pub threshold: f64,
193 pub near_duplicate_handler_similarity: f64,
196 pub require_features: bool,
198 pub require_definitions: bool,
200 pub fail_on_incomplete: bool,
202 pub fail_on_unparseable: bool,
205 pub registrations: Vec<String>,
207 pub assertion_modules: Vec<String>,
209 pub parameter_types: BTreeMap<String, String>,
211 pub max_candidate_comparisons: usize,
213 pub max_structural_class_comparisons: usize,
215 pub no_metrics: bool,
217 pub rules: BTreeMap<Rule, Severity>,
219 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 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 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 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 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 pub fn severity(&self, rule: Rule) -> Severity {
627 self.rules.get(&rule).copied().unwrap_or(Severity::Off)
628 }
629
630 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::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;