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