1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::fs::File;
4use std::io::Read;
5use std::path::Path;
6
7use crate::Severity;
8
9const MAX_CONFIG_SIZE: u64 = 1024 * 1024;
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
13pub struct Config {
14 #[serde(default)]
15 pub rules: HashMap<String, RuleSeverity>,
16
17 #[serde(default)]
18 pub output: OutputConfig,
19
20 #[serde(default)]
21 pub database: DatabaseConfig,
22}
23
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "lowercase")]
26pub enum RuleSeverity {
27 Deny,
28 Warn,
29 Allow,
30}
31
32impl From<RuleSeverity> for Option<Severity> {
33 fn from(rs: RuleSeverity) -> Option<Severity> {
34 match rs {
35 RuleSeverity::Deny => Some(Severity::Error),
36 RuleSeverity::Warn => Some(Severity::Warning),
37 RuleSeverity::Allow => None,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct OutputConfig {
44 #[serde(default = "default_format")]
45 pub format: String,
46
47 #[serde(default = "default_color")]
48 pub color: String,
49}
50
51fn default_format() -> String {
52 "console".to_string()
53}
54
55fn default_color() -> String {
56 "auto".to_string()
57}
58
59impl Default for OutputConfig {
60 fn default() -> Self {
61 Self {
62 format: default_format(),
63 color: default_color(),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69pub struct DatabaseConfig {
70 pub orm: Option<String>,
71}
72
73impl Config {
74 pub fn load_or_default(path: &Path) -> anyhow::Result<Self> {
85 if !path.exists() {
87 anyhow::bail!("Path does not exist: {}", path.display());
88 }
89
90 let dir_path = if path.is_file() {
92 path.parent().unwrap_or(path)
93 } else {
94 path
95 };
96
97 let config_path = dir_path.join("cargo-perf.toml");
98
99 let mut file = match File::open(&config_path) {
101 Ok(f) => f,
102 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
103 return Ok(Config::default());
104 }
105 Err(e) => return Err(e.into()),
106 };
107
108 let metadata = file.metadata()?;
110 if metadata.len() > MAX_CONFIG_SIZE {
111 anyhow::bail!(
112 "Config file too large ({} bytes, max {} bytes): {}",
113 metadata.len(),
114 MAX_CONFIG_SIZE,
115 config_path.display()
116 );
117 }
118
119 let mut content = String::with_capacity(metadata.len() as usize);
121 file.read_to_string(&mut content)?;
122
123 let config: Config = toml::from_str(&content)?;
124
125 Self::validate_rule_ids(&config);
127
128 Ok(config)
129 }
130
131 fn validate_rule_ids(config: &Config) {
133 use crate::rules::registry;
134
135 for rule_id in config.rules.keys() {
136 if !registry::has_rule(rule_id) {
137 eprintln!(
138 "Warning: Unknown rule '{}' in cargo-perf.toml (will be ignored)",
139 rule_id
140 );
141 }
142 }
143 }
144
145 pub fn rule_severity(&self, rule_id: &str, default: Severity) -> Option<Severity> {
147 match self.rules.get(rule_id) {
148 Some(RuleSeverity::Allow) => None,
149 Some(RuleSeverity::Warn) => Some(Severity::Warning),
150 Some(RuleSeverity::Deny) => Some(Severity::Error),
151 None => Some(default),
152 }
153 }
154
155 pub fn default_toml() -> &'static str {
157 r#"# cargo-perf configuration
158# Schema: https://raw.githubusercontent.com/cschuman/cargo-perf/main/cargo-perf.schema.json
159# Docs: https://github.com/cschuman/cargo-perf
160
161[rules]
162# Set rule severity: "deny" (error), "warn" (warning), "allow" (ignore)
163# async-block-in-async = "deny"
164# lock-across-await = "deny"
165# clone-in-hot-loop = "warn"
166# vec-no-capacity = "allow"
167
168[output]
169format = "console" # "console", "json", "sarif"
170color = "auto" # "auto", "always", "never"
171
172[database]
173# orm = "sqlx" # "sqlx", "diesel", "sea-orm"
174"#
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use tempfile::TempDir;
182
183 #[test]
184 fn test_default_config() {
185 let config = Config::default();
186 assert!(config.rules.is_empty());
187 assert_eq!(config.output.format, "console");
189 assert_eq!(config.output.color, "auto");
190 }
191
192 #[test]
193 fn test_rule_severity_default() {
194 let config = Config::default();
195 assert_eq!(
197 config.rule_severity("unknown-rule", Severity::Warning),
198 Some(Severity::Warning)
199 );
200 }
201
202 #[test]
203 fn test_rule_severity_deny() {
204 let mut config = Config::default();
205 config
206 .rules
207 .insert("test-rule".to_string(), RuleSeverity::Deny);
208 assert_eq!(
209 config.rule_severity("test-rule", Severity::Warning),
210 Some(Severity::Error)
211 );
212 }
213
214 #[test]
215 fn test_rule_severity_allow() {
216 let mut config = Config::default();
217 config
218 .rules
219 .insert("test-rule".to_string(), RuleSeverity::Allow);
220 assert_eq!(config.rule_severity("test-rule", Severity::Warning), None);
221 }
222
223 #[test]
224 fn test_load_or_default_nonexistent_path() {
225 let result = Config::load_or_default(Path::new("/nonexistent/path"));
226 assert!(result.is_err());
227 }
228
229 #[test]
230 fn test_load_or_default_no_config_file() {
231 let tmp = TempDir::new().unwrap();
232 let config = Config::load_or_default(tmp.path()).unwrap();
233 assert!(config.rules.is_empty());
234 }
235
236 #[test]
237 fn test_load_or_default_with_config_file() {
238 let tmp = TempDir::new().unwrap();
239 let config_content = r#"
240[rules]
241async-block-in-async = "deny"
242clone-in-hot-loop = "allow"
243"#;
244 std::fs::write(tmp.path().join("cargo-perf.toml"), config_content).unwrap();
245
246 let config = Config::load_or_default(tmp.path()).unwrap();
247 assert_eq!(
248 config.rule_severity("async-block-in-async", Severity::Warning),
249 Some(Severity::Error)
250 );
251 assert_eq!(
252 config.rule_severity("clone-in-hot-loop", Severity::Warning),
253 None
254 );
255 }
256
257 #[test]
258 fn test_load_or_default_with_file_path() {
259 let tmp = TempDir::new().unwrap();
260 let config_content = r#"
261[rules]
262test-rule = "warn"
263"#;
264 std::fs::write(tmp.path().join("cargo-perf.toml"), config_content).unwrap();
265 let file_path = tmp.path().join("some_file.rs");
266 std::fs::write(&file_path, "").unwrap();
267
268 let config = Config::load_or_default(&file_path).unwrap();
270 assert_eq!(
271 config.rule_severity("test-rule", Severity::Error),
272 Some(Severity::Warning)
273 );
274 }
275
276 #[test]
277 fn test_load_invalid_config() {
278 let tmp = TempDir::new().unwrap();
279 std::fs::write(tmp.path().join("cargo-perf.toml"), "invalid { toml").unwrap();
280 let result = Config::load_or_default(tmp.path());
281 assert!(result.is_err());
282 }
283
284 #[test]
285 fn test_rule_severity_from_conversion() {
286 assert_eq!(
287 Option::<Severity>::from(RuleSeverity::Deny),
288 Some(Severity::Error)
289 );
290 assert_eq!(
291 Option::<Severity>::from(RuleSeverity::Warn),
292 Some(Severity::Warning)
293 );
294 assert_eq!(Option::<Severity>::from(RuleSeverity::Allow), None);
295 }
296}