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