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