Skip to main content

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}