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 ruby_load_paths: Option<Vec<PathBuf>>,
60 #[serde(default)]
61 definitions: Option<Vec<String>>,
62 #[serde(default)]
63 features: Option<Vec<String>>,
64 #[serde(default)]
65 exclude: Option<Vec<String>>,
66 #[serde(default)]
67 exclude_defaults: Option<bool>,
68 #[serde(default)]
69 include_hidden: Option<bool>,
70 #[serde(default)]
71 reporters: Option<Vec<ReporterKind>>,
72 #[serde(default)]
73 output: Option<PathBuf>,
74 #[serde(default)]
75 threshold: Option<f64>,
76 #[serde(default)]
77 near_duplicate_handler_similarity: Option<f64>,
78 #[serde(default)]
79 require_features: Option<bool>,
80 #[serde(default)]
81 require_definitions: Option<bool>,
82 #[serde(default)]
83 fail_on_incomplete: Option<bool>,
84 #[serde(default)]
85 fail_on_unparseable: Option<bool>,
86 #[serde(default)]
87 registrations: Option<Vec<String>>,
88 #[serde(default)]
89 assertion_modules: Option<Vec<String>>,
90 #[serde(default)]
91 parameter_types: Option<BTreeMap<String, String>>,
92 #[serde(default)]
93 max_candidate_comparisons: Option<usize>,
94 #[serde(default)]
95 max_structural_class_comparisons: Option<usize>,
96 #[serde(default)]
97 no_metrics: Option<bool>,
98 #[serde(default)]
99 rules: Option<BTreeMap<String, Severity>>,
100 #[serde(default)]
101 suppressions: Option<Vec<SuppressionConfig>>,
102}
103
104#[derive(Debug, Clone, Deserialize, Default)]
105#[serde(rename_all = "camelCase")]
106struct PackageJson {
107 #[serde(default)]
108 cuke_dedup: Option<RawConfig>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase", deny_unknown_fields)]
113pub struct SuppressionConfig {
115 pub rule: Rule,
117 pub reason: String,
119 #[serde(default)]
120 pub path: Option<String>,
122 #[serde(default)]
123 pub matcher: Option<String>,
125}
126
127#[derive(Debug, Clone, Default)]
128pub struct ConfigOverrides {
130 pub config_file: Option<PathBuf>,
132 pub definitions: Option<Vec<String>>,
134 pub features: Option<Vec<String>>,
136 pub exclude: Option<Vec<String>>,
138 pub exclude_defaults: Option<bool>,
140 pub include_hidden: Option<bool>,
142 pub reporters: Option<Vec<ReporterKind>>,
144 pub output: Option<PathBuf>,
146 pub threshold: Option<f64>,
148 pub require_features: Option<bool>,
150 pub no_metrics: Option<bool>,
152 pub rules: BTreeMap<Rule, Severity>,
154}
155
156#[derive(Debug, Clone, Default)]
157pub(crate) struct CliConfigOverrides {
158 pub(crate) require_definitions: Option<bool>,
159 pub(crate) fail_on_incomplete: Option<bool>,
161 pub(crate) fail_on_unparseable: Option<bool>,
163 pub(crate) max_candidate_comparisons: Option<usize>,
164 pub(crate) max_structural_class_comparisons: Option<usize>,
165}
166
167#[derive(Debug, Clone, Serialize)]
168#[serde(rename_all = "camelCase")]
169pub struct Config {
171 #[serde(flatten)]
172 frontend: crate::source_adapter::FrontendConfig,
173 pub root: PathBuf,
175 pub definitions: Vec<String>,
177 pub features: Vec<String>,
179 pub feature_pattern_origin: String,
181 pub config_warnings: Vec<String>,
183 pub config_source: String,
185 pub exclude: Vec<String>,
187 pub exclude_defaults: bool,
189 pub include_hidden: bool,
191 pub reporters: Vec<ReporterKind>,
193 pub output: PathBuf,
195 pub threshold: f64,
197 pub near_duplicate_handler_similarity: f64,
200 pub require_features: bool,
202 pub require_definitions: bool,
204 pub fail_on_incomplete: bool,
206 pub fail_on_unparseable: bool,
209 pub registrations: Vec<String>,
211 pub assertion_modules: Vec<String>,
213 pub parameter_types: BTreeMap<String, String>,
215 pub max_candidate_comparisons: usize,
217 pub max_structural_class_comparisons: usize,
219 pub no_metrics: bool,
221 pub rules: BTreeMap<Rule, Severity>,
223 pub suppressions: Vec<SuppressionConfig>,
225 #[serde(skip)]
226 custom_exclude: Vec<String>,
227 #[serde(skip)]
228 output_from_project_config: bool,
229}
230
231impl Config {
232 pub fn ruby_load_paths(&self) -> &[PathBuf] {
234 self.frontend.ruby_load_paths()
235 }
236
237 pub fn load(root: &Path, overrides: ConfigOverrides) -> Result<Self> {
240 Self::load_internal(root, overrides, CliConfigOverrides::default())
241 }
242
243 pub(crate) fn load_for_cli(
244 root: &Path,
245 overrides: ConfigOverrides,
246 cli_overrides: CliConfigOverrides,
247 ) -> Result<Self> {
248 Self::load_internal(root, overrides, cli_overrides)
249 }
250
251 fn load_internal(
254 root: &Path,
255 overrides: ConfigOverrides,
256 cli_overrides: CliConfigOverrides,
257 ) -> Result<Self> {
258 let root = canonical_platform_path(root)
259 .with_context(|| format!("cannot access target directory {}", root.display()))?;
260 if !root.is_dir() {
261 bail!("target {} is not a directory", root.display());
262 }
263
264 let package_path = root.join("package.json");
265 let (package_config, package_json) = if package_path.is_file() {
266 let text = read_utf8(
267 &package_path,
268 "package configuration",
269 MAX_CONFIG_INPUT_BYTES,
270 )?;
271 let value = serde_json::from_str::<serde_json::Value>(&text)
273 .with_context(|| format!("failed to parse {}", package_path.display()))?;
274 let package = serde_json::from_value::<PackageJson>(value.clone())
275 .with_context(|| format!("failed to parse {}", package_path.display()))?;
276 (package.cuke_dedup, Some(value))
277 } else {
278 (None, None)
279 };
280
281 let explicit_config = overrides.config_file.as_ref().map(|path| {
282 if path.is_absolute() {
283 path.clone()
284 } else {
285 root.join(path)
286 }
287 });
288 let mut config_warnings = Vec::new();
289 let (standalone_config, standalone_path) = if let Some(path) = explicit_config {
290 (Some(read_raw_config(&path)?), Some(path))
291 } else {
292 discover_config_file(&root, &mut config_warnings)?
293 };
294
295 let package_config = if standalone_config.is_none() {
296 package_config
297 } else {
298 None
299 };
300
301 let has_explicit_features = overrides.features.is_some()
302 || standalone_config
303 .as_ref()
304 .is_some_and(|config| config.features.is_some())
305 || package_config
306 .as_ref()
307 .is_some_and(|config| config.features.is_some());
308 let mut config = Self::defaults(root);
309 config.config_warnings = config_warnings;
310 if !has_explicit_features {
311 match framework_config::detect(&config.root, package_json.as_ref()) {
312 Ok(Some(framework)) => {
313 config.features = framework.patterns;
314 config.feature_pattern_origin = format!(
315 "{} configuration {}",
316 framework.framework,
317 framework
318 .source
319 .strip_prefix(&config.root)
320 .unwrap_or(&framework.source)
321 .display()
322 );
323 config.config_warnings.extend(framework.warnings);
324 }
325 Ok(None) => {}
326 Err(error) if is_input_limit_error(&error) => return Err(error),
327 Err(error) => config.config_warnings.push(format!(
328 "could not inspect framework configuration ({error:#}); using built-in feature patterns"
329 )),
330 }
331 }
332 if let Some(raw) = package_config {
333 let replaces_features = raw.features.is_some();
334 config.apply_raw(raw)?;
335 config.config_source = "package.json#cukeDedup".to_owned();
336 if replaces_features {
337 config.feature_pattern_origin = "package.json#cukeDedup".to_owned();
338 }
339 }
340 if let Some(raw) = standalone_config {
341 let replaces_features = raw.features.is_some();
342 config.apply_raw(raw)?;
343 config.config_source = standalone_path
344 .as_ref()
345 .map(|path| config_source_name(path, &config.root))
346 .unwrap_or_else(|| ".cuke-dedup.json".to_owned());
347 if replaces_features {
348 config.feature_pattern_origin = config.config_source.clone();
349 }
350 }
351 let cli_replaces_features = overrides.features.is_some();
352 config.apply_overrides(overrides);
353 if let Some(value) = cli_overrides.require_definitions {
354 config.require_definitions = value;
355 }
356 if let Some(value) = cli_overrides.fail_on_incomplete {
357 config.fail_on_incomplete = value;
358 }
359 if let Some(value) = cli_overrides.fail_on_unparseable {
360 config.fail_on_unparseable = value;
361 }
362 if let Some(value) = cli_overrides.max_candidate_comparisons {
363 config.max_candidate_comparisons = value;
364 }
365 if let Some(value) = cli_overrides.max_structural_class_comparisons {
366 config.max_structural_class_comparisons = value;
367 }
368 if cli_replaces_features {
369 config.feature_pattern_origin = "--features".to_owned();
370 }
371 config.validate()?;
372 Ok(config)
373 }
374
375 fn defaults(root: PathBuf) -> Self {
376 let rules = [
377 (Rule::DuplicateMatcher, Severity::Error),
378 (Rule::NormalizedMatcher, Severity::Error),
379 (Rule::AmbiguousStep, Severity::Error),
380 (Rule::OverlappingMatcher, Severity::Warning),
381 (Rule::DuplicateHandler, Severity::Error),
382 (Rule::NearDuplicateStep, Severity::Warning),
383 (Rule::ParameterizationCandidate, Severity::Warning),
384 (Rule::UnusedDefinition, Severity::Warning),
385 ]
386 .into_iter()
387 .collect();
388 Self {
389 frontend: Default::default(),
390 root,
391 definitions: Vec::new(),
392 features: vec!["**/*.{feature,feature.md}".to_owned()],
393 feature_pattern_origin: "built-in defaults".to_owned(),
394 config_warnings: Vec::new(),
395 config_source: "built-in defaults".to_owned(),
396 exclude: DEFAULT_EXCLUDES.iter().map(ToString::to_string).collect(),
397 exclude_defaults: true,
398 include_hidden: false,
399 reporters: vec![ReporterKind::Terminal],
400 output: PathBuf::from("reports/cuke-dedup"),
401 threshold: 0.0,
402 near_duplicate_handler_similarity: 0.70,
403 require_features: false,
404 require_definitions: false,
405 fail_on_incomplete: false,
406 fail_on_unparseable: false,
407 registrations: Vec::new(),
408 assertion_modules: Vec::new(),
409 parameter_types: BTreeMap::new(),
410 max_candidate_comparisons: MAX_CANDIDATE_COMPARISONS,
411 max_structural_class_comparisons: MAX_STRUCTURAL_CLASS_COMPARISONS,
412 no_metrics: false,
413 rules,
414 suppressions: Vec::new(),
415 custom_exclude: Vec::new(),
416 output_from_project_config: false,
417 }
418 }
419
420 fn apply_raw(&mut self, raw: RawConfig) -> Result<()> {
421 if let Some(paths) = raw.ruby_load_paths {
422 self.frontend.set_ruby_load_paths(paths);
423 }
424 if let Some(value) = raw.definitions {
425 self.definitions = value;
426 }
427 if let Some(value) = raw.features {
428 self.features = value;
429 }
430 if let Some(value) = raw.exclude_defaults {
431 self.exclude_defaults = value;
432 }
433 if let Some(value) = raw.include_hidden {
434 self.include_hidden = value;
435 }
436 if let Some(value) = raw.exclude {
437 self.custom_exclude = value;
438 }
439 self.rebuild_excludes();
440 if let Some(value) = raw.reporters {
441 self.reporters = value;
442 }
443 if let Some(value) = raw.output {
444 self.output = value;
445 self.output_from_project_config = true;
446 }
447 if let Some(value) = raw.threshold {
448 self.threshold = value;
449 }
450 if let Some(value) = raw.near_duplicate_handler_similarity {
451 self.near_duplicate_handler_similarity = value;
452 }
453 if let Some(value) = raw.require_features {
454 self.require_features = value;
455 }
456 if let Some(value) = raw.require_definitions {
457 self.require_definitions = value;
458 }
459 if let Some(value) = raw.fail_on_incomplete {
460 self.fail_on_incomplete = value;
461 }
462 if let Some(value) = raw.fail_on_unparseable {
463 self.fail_on_unparseable = value;
464 }
465 if let Some(value) = raw.registrations {
466 self.registrations = value;
467 }
468 if let Some(value) = raw.assertion_modules {
469 self.assertion_modules = value;
470 }
471 if let Some(value) = raw.parameter_types {
472 self.parameter_types = value;
473 }
474 if let Some(value) = raw.max_candidate_comparisons {
475 self.max_candidate_comparisons = value;
476 }
477 if let Some(value) = raw.max_structural_class_comparisons {
478 self.max_structural_class_comparisons = value;
479 }
480 if let Some(value) = raw.no_metrics {
481 self.no_metrics = value;
482 }
483 if let Some(value) = raw.rules {
484 for (name, severity) in value {
485 let rule = name.parse::<Rule>().map_err(anyhow::Error::msg)?;
486 self.rules.insert(rule, severity);
487 }
488 }
489 if let Some(value) = raw.suppressions {
490 self.suppressions = value;
491 }
492 Ok(())
493 }
494
495 fn apply_overrides(&mut self, overrides: ConfigOverrides) {
496 if let Some(value) = overrides.definitions {
497 self.definitions = value;
498 }
499 if let Some(value) = overrides.features {
500 self.features = value;
501 }
502 if let Some(value) = overrides.exclude_defaults {
503 self.exclude_defaults = value;
504 }
505 if let Some(value) = overrides.include_hidden {
506 self.include_hidden = value;
507 }
508 if let Some(value) = overrides.exclude {
509 self.custom_exclude = value;
510 }
511 self.rebuild_excludes();
512 if let Some(value) = overrides.reporters {
513 self.reporters = value;
514 }
515 if let Some(value) = overrides.output {
516 self.output = value;
517 self.output_from_project_config = false;
518 }
519 if let Some(value) = overrides.threshold {
520 self.threshold = value;
521 }
522 if let Some(value) = overrides.require_features {
523 self.require_features = value;
524 }
525 if let Some(value) = overrides.no_metrics {
526 self.no_metrics = value;
527 }
528 self.rules.extend(overrides.rules);
529 }
530
531 fn rebuild_excludes(&mut self) {
532 self.exclude.clear();
533 if self.exclude_defaults {
534 self.exclude
535 .extend(DEFAULT_EXCLUDES.iter().map(ToString::to_string));
536 }
537 self.exclude.extend(self.custom_exclude.iter().cloned());
538 }
539
540 fn validate(&self) -> Result<()> {
541 if self.reporters.is_empty() {
542 bail!("at least one reporter must be configured");
543 }
544 if self.reporters.contains(&ReporterKind::Terminal)
545 && self.reporters.contains(&ReporterKind::Jsonl)
546 {
547 bail!("terminal and jsonl reporters cannot share stdout; select only one of them");
548 }
549 if !self.threshold.is_finite() || !(0.0..=100.0).contains(&self.threshold) {
550 bail!("threshold must be a finite percentage from 0 through 100");
551 }
552 if !(0.5..=1.0).contains(&self.near_duplicate_handler_similarity) {
555 bail!("nearDuplicateHandlerSimilarity must be a value from 0.5 through 1.0");
556 }
557 for (name, pattern) in &self.parameter_types {
558 if name.is_empty() || name.contains(['{', '}']) {
559 bail!("parameter type `{name}` must not be empty or contain braces");
560 }
561 crate::resource_limits::compile_regex(pattern).with_context(|| {
562 format!("invalid regular expression for parameter type `{name}`")
563 })?;
564 }
565 for module in &self.assertion_modules {
566 if module.is_empty() || module.trim() != module {
569 bail!("assertion module `{module}` must be a module specifier without surrounding whitespace");
570 }
571 }
572 for name in &self.registrations {
573 if name.is_empty()
574 || !name
575 .chars()
576 .all(|character| character.is_alphanumeric() || matches!(character, '_' | '$'))
577 || name.starts_with(|character: char| character.is_ascii_digit())
578 {
579 bail!("registration `{name}` must be a JavaScript identifier");
580 }
581 }
582 self.validate_analysis_limits()?;
583 if self.output_from_project_config {
584 validate_project_output(&self.root, &self.output)?;
585 }
586 for suppression in &self.suppressions {
587 if suppression.reason.trim().is_empty() {
588 bail!(
589 "suppression for {} must include a non-empty reason",
590 suppression.rule
591 );
592 }
593 if suppression.reason.chars().count() > MAX_SUPPRESSION_REASON_CHARS {
594 bail!(
595 "suppression reason for {} exceeds the {}-character limit",
596 suppression.rule,
597 MAX_SUPPRESSION_REASON_CHARS
598 );
599 }
600 if suppression.path.is_none() && suppression.matcher.is_none() {
601 bail!(
602 "suppression for {} must select a path or matcher",
603 suppression.rule
604 );
605 }
606 if let Some(pattern) = &suppression.path {
607 Glob::new(pattern).with_context(|| {
608 format!(
609 "invalid suppression path glob `{pattern}` for {}",
610 suppression.rule
611 )
612 })?;
613 }
614 }
615 Ok(())
616 }
617
618 pub(crate) fn validate_analysis_limits(&self) -> Result<()> {
619 if self.max_candidate_comparisons == 0 {
620 bail!("maxCandidateComparisons must be greater than zero");
621 }
622 if self.max_candidate_comparisons > MAX_CANDIDATE_COMPARISONS {
623 bail!(
624 "maxCandidateComparisons must not exceed the hard safety limit of {MAX_CANDIDATE_COMPARISONS}"
625 );
626 }
627 if self.max_structural_class_comparisons == 0 {
628 bail!("maxStructuralClassComparisons must be greater than zero");
629 }
630 if self.max_structural_class_comparisons > MAX_STRUCTURAL_CLASS_COMPARISONS {
631 bail!(
632 "maxStructuralClassComparisons must not exceed the hard safety limit of {MAX_STRUCTURAL_CLASS_COMPARISONS}"
633 );
634 }
635 Ok(())
636 }
637
638 pub fn severity(&self, rule: Rule) -> Severity {
640 self.rules.get(&rule).copied().unwrap_or(Severity::Off)
641 }
642
643 pub fn output_path(&self, filename: &str) -> PathBuf {
645 if self.output.is_absolute() {
646 self.output.join(filename)
647 } else {
648 self.root.join(&self.output).join(filename)
649 }
650 }
651}
652
653fn validate_project_output(root: &Path, output: &Path) -> Result<()> {
654 if output.is_absolute()
655 || output.components().any(|component| {
656 matches!(
657 component,
658 Component::ParentDir | Component::RootDir | Component::Prefix(_)
659 )
660 })
661 {
662 bail!(
663 "project configuration output `{}` must stay beneath the analyzed root; use --output for an operator-controlled path",
664 output.display()
665 );
666 }
667
668 let candidate = root.join(output);
669 let mut existing_ancestor = candidate.as_path();
670 while !existing_ancestor.exists() {
671 existing_ancestor = existing_ancestor.parent().ok_or_else(|| {
672 anyhow::anyhow!(
673 "could not resolve project configuration output `{}`",
674 output.display()
675 )
676 })?;
677 }
678 let canonical_ancestor =
679 normalize_platform_path(existing_ancestor.canonicalize().with_context(|| {
680 format!(
681 "failed to validate project configuration output {}",
682 candidate.display()
683 )
684 })?);
685 if !canonical_ancestor.starts_with(root) {
686 bail!(
687 "project configuration output `{}` resolves outside the analyzed root; use --output for an operator-controlled path",
688 output.display()
689 );
690 }
691 Ok(())
692}
693
694pub(crate) fn normalize_platform_path(path: PathBuf) -> PathBuf {
697 #[cfg(windows)]
698 {
699 if let Some(plain) = strip_verbatim_prefix(&path) {
700 return plain;
701 }
702 }
703 path
704}
705
706#[cfg(windows)]
709fn strip_verbatim_prefix(path: &Path) -> Option<PathBuf> {
710 use std::ffi::OsStr;
711 use std::path::Prefix;
712 let mut components = path.components();
713 let prefix = match components.next() {
714 Some(Component::Prefix(prefix)) => prefix,
715 _ => return None, };
717 let drive;
718 let (lead, head) = match prefix.kind() {
719 Prefix::VerbatimDisk(letter) => {
720 drive = format!("{}:", char::from(letter));
721 ("", vec![OsStr::new(&drive)])
722 }
723 Prefix::VerbatimUNC(server, share) => (r"\\", vec![server, share]),
724 _ => return None, };
726 let rest: Vec<_> = components.collect();
727 let suffix_len = path.as_os_str().len() - prefix.as_os_str().len();
728 plain_verbatim_path(lead, &head, &rest, suffix_len)
729}
730
731#[cfg(any(windows, test))]
740fn plain_verbatim_path(
741 lead: &str,
742 head: &[&std::ffi::OsStr],
743 rest: &[Component<'_>],
744 suffix_len: usize,
745) -> Option<PathBuf> {
746 let names: Vec<_> = rest
747 .iter()
748 .filter_map(|component| match component {
749 Component::Normal(name) => Some(*name),
750 _ => None, })
752 .collect();
753 let mut plain = std::ffi::OsString::from(lead);
754 plain.push(join_with_separator(head));
755 let head_len = plain.len();
756 if suffix_len > 0 {
757 plain.push(r"\");
758 plain.push(join_with_separator(&names));
759 }
760 (plain.len() == head_len + suffix_len
761 && (suffix_len > 0 || !lead.is_empty())
762 && head.iter().all(|name| plain_name(name))
763 && names
764 .iter()
765 .all(|name| plain_name(name) && plain_chars(name)))
766 .then(|| plain.into())
767}
768
769#[cfg(any(windows, test))]
771fn join_with_separator(parts: &[&std::ffi::OsStr]) -> std::ffi::OsString {
772 let mut joined = std::ffi::OsString::new();
773 for (index, part) in parts.iter().enumerate() {
774 if index > 0 {
775 joined.push(r"\");
776 }
777 joined.push(part);
778 }
779 joined
780}
781
782#[cfg(any(windows, test))]
786fn plain_chars(name: &std::ffi::OsStr) -> bool {
787 name.as_encoded_bytes()
788 .iter()
789 .all(|&byte| byte >= 0x20 && !br#"<>:"|?*"#.contains(&byte))
790}
791
792#[cfg(any(windows, test))]
797fn plain_name(name: &std::ffi::OsStr) -> bool {
798 const RESERVED: [&[u8]; 32] = [
801 b"CON",
802 b"CONIN$",
803 b"CONOUT$",
804 b"PRN",
805 b"AUX",
806 b"NUL",
807 b"COM0",
808 b"COM1",
809 b"COM2",
810 b"COM3",
811 b"COM4",
812 b"COM5",
813 b"COM6",
814 b"COM7",
815 b"COM8",
816 b"COM9",
817 "COM¹".as_bytes(),
818 "COM²".as_bytes(),
819 "COM³".as_bytes(),
820 b"LPT0",
821 b"LPT1",
822 b"LPT2",
823 b"LPT3",
824 b"LPT4",
825 b"LPT5",
826 b"LPT6",
827 b"LPT7",
828 b"LPT8",
829 b"LPT9",
830 "LPT¹".as_bytes(),
831 "LPT²".as_bytes(),
832 "LPT³".as_bytes(),
833 ];
834 let bytes = name.as_encoded_bytes();
835 let stem = bytes
836 .split(|&byte| byte == b'.')
837 .next()
838 .unwrap_or_default()
839 .trim_ascii_end();
840 let device = RESERVED
841 .iter()
842 .any(|reserved| stem.eq_ignore_ascii_case(reserved));
843 !bytes.ends_with(b".") && !bytes.ends_with(b" ") && !bytes.contains(&b'/') && !device
844}
845
846pub(crate) fn canonical_platform_path(path: &Path) -> std::io::Result<PathBuf> {
849 path.canonicalize().map(normalize_platform_path)
850}
851
852fn read_raw_config(path: &Path) -> Result<RawConfig> {
853 let text = read_utf8(path, "CukeDedup configuration", MAX_CONFIG_INPUT_BYTES)?;
854 serde_json::from_str::<RawConfig>(&text)
856 .with_context(|| format!("failed to parse {}", path.display()))
857}
858
859fn discover_config_file(
860 root: &Path,
861 warnings: &mut Vec<String>,
862) -> Result<(Option<RawConfig>, Option<PathBuf>)> {
863 const CANDIDATES: [&str; 4] = [
864 ".cuke-dedup.json",
865 ".config/cuke-dedup.json",
866 ".config/.cuke-dedup.json",
867 "cuke-dedup.config.json",
868 ];
869 for candidate in CANDIDATES {
870 let path = root.join(candidate);
871 if !path.is_file() {
872 continue;
873 }
874 match read_raw_config(&path) {
875 Ok(config) => return Ok((Some(config), Some(path))),
876 Err(error) if is_input_limit_error(&error) => return Err(error),
877 Err(error) => warnings.push(format!(
878 "could not load auto-discovered config {} ({error:#}); trying the next configuration source",
879 path.display()
880 )),
881 }
882 }
883 Ok((None, None))
884}
885
886fn config_source_name(path: &Path, root: &Path) -> String {
887 path.strip_prefix(root)
888 .unwrap_or(path)
889 .to_string_lossy()
890 .replace('\\', "/")
891}
892
893#[cfg(test)]
894mod tests;