dry4rust/config.rs
1// Copyright (c) 2026 Matjaz Domen Pecan
2// Copyright 2026 Umberto Gotti <umberto.gotti@umbertogotti.dev>
3// Licensed under the MIT License
4// SPDX-License-Identifier: MIT
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use toml::Value;
10use toml::from_str;
11
12use crate::analysis_config::AnalysisConfig;
13use crate::error::Result;
14use crate::file_config::FileConfig;
15use crate::threshold::Threshold;
16
17/// Configuration for cargo-dry4rust analysis.
18///
19/// The fields are public and settable, and the ones with a range carry it in
20/// their type: a [`Threshold`] cannot be built out of range, so a `Config`
21/// cannot hold a similarity threshold of five however it was assembled. The
22/// counts are `usize` and have no upper bound to state -- a floor of zero
23/// nodes admits everything, which is a choice rather than a mistake.
24#[derive(Debug, Clone)]
25pub struct Config {
26 /// Minimum number of AST nodes for a code unit to be analyzed.
27 pub min_nodes: usize,
28 /// Similarity threshold for near-duplicates.
29 pub similarity_threshold: Threshold,
30 /// Path patterns to exclude from scanning.
31 pub exclude: Vec<String>,
32 /// Exit code threshold: fail if exact duplicate count exceeds this.
33 pub max_exact_duplicates: Option<usize>,
34 /// Exit code threshold: fail if near duplicate count exceeds this.
35 pub max_near_duplicates: Option<usize>,
36 /// Exit code threshold: fail if exact duplicate percentage exceeds this.
37 pub max_exact_percent: Option<Threshold>,
38 /// Exit code threshold: fail if near duplicate percentage exceeds this.
39 pub max_near_percent: Option<Threshold>,
40 /// Minimum number of source lines for a code unit to be analyzed.
41 pub min_lines: usize,
42 /// Exclude test code (#[test] functions and #[cfg(test)] modules).
43 pub exclude_tests: bool,
44 /// Enable sub-function duplicate detection.
45 pub sub_function: bool,
46 /// Minimum number of AST nodes for a sub-function unit to be analyzed.
47 pub min_sub_nodes: usize,
48 /// Baseline of inherited duplication to judge against, if any.
49 pub baseline: Option<PathBuf>,
50 /// Root path to analyze.
51 pub root: PathBuf,
52}
53
54impl Default for Config {
55 fn default() -> Self {
56 Self {
57 min_nodes: 10,
58 similarity_threshold: Threshold::DEFAULT_SIMILARITY,
59 exclude: Vec::new(),
60 max_exact_duplicates: None,
61 max_near_duplicates: None,
62 max_exact_percent: None,
63 max_near_percent: None,
64 min_lines: 0,
65 exclude_tests: false,
66 sub_function: false,
67 min_sub_nodes: 5,
68 baseline: None,
69 root: PathBuf::from("."),
70 }
71 }
72}
73
74impl Config {
75 /// Extract the parsing-relevant subset of the configuration.
76 #[must_use]
77 pub const fn analysis_config(&self) -> AnalysisConfig {
78 AnalysisConfig {
79 min_nodes: self.min_nodes,
80 min_lines: self.min_lines,
81 }
82 }
83
84 /// Load config with the following precedence:
85 /// 1. CLI overrides (applied by the caller after this method)
86 /// 2. dry4rust.toml in the project root
87 /// 3. `[package.metadata.dry4rust]` in Cargo.toml
88 /// 4. Defaults
89 ///
90 /// A file that cannot be read or parsed is passed over, because a project
91 /// with no configuration is the ordinary case and looks the same. A file
92 /// that parses and then states an impossible value is not passed over:
93 /// the caller asked for something the tool cannot do, and saying so is
94 /// the only way they find out.
95 ///
96 /// # Errors
97 ///
98 /// Returns [`crate::error::Error::InvalidConfig`] naming the first field
99 /// whose value falls outside the range it allows.
100 pub fn load(root: &Path) -> Result<Self> {
101 let mut config = Self {
102 root: root.to_path_buf(),
103 ..Default::default()
104 };
105
106 // Cargo.toml metadata first: the lowest-priority file config
107 if let Some(file_config) = Self::from_cargo_metadata(root) {
108 config = file_config.apply_to(config)?;
109 }
110
111 // dry4rust.toml wins over it
112 if let Some(file_config) = Self::from_named_file(root) {
113 config = file_config.apply_to(config)?;
114 }
115
116 Ok(config)
117 }
118
119 fn from_cargo_metadata(root: &Path) -> Option<FileConfig> {
120 let content = fs::read_to_string(root.join("Cargo.toml")).ok()?;
121 from_str::<Value>(&content)
122 .ok()?
123 .get("package")?
124 .get("metadata")?
125 .get("dry4rust")?
126 .clone()
127 .try_into()
128 .ok()
129 }
130
131 fn from_named_file(root: &Path) -> Option<FileConfig> {
132 let content = fs::read_to_string(root.join("dry4rust.toml")).ok()?;
133 from_str(&content).ok()
134 }
135}