1#![allow(clippy::missing_errors_doc, reason = "Necessary for testing.")]
2use core::fmt::{self, Display};
3use std::{
4 collections::HashMap,
5 env::current_dir,
6 fs,
7 path::{Path, PathBuf},
8 process,
9};
10
11use serde::{Deserialize, Serialize};
12
13use crate::{
14 LintError,
15 sets::{BUILTIN_LINT_SETS, DEFAULT_RULE_MAP},
16};
17
18#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq, PartialOrd, Ord)]
23#[serde(rename_all = "lowercase")]
24pub enum LintLevel {
25 Allow,
26 #[default]
27 Warn,
28 Deny,
29}
30
31impl Display for LintLevel {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 match self {
34 Self::Allow => write!(f, "allow"),
35 Self::Warn => write!(f, "warn"),
36 Self::Deny => write!(f, "deny"),
37 }
38 }
39}
40
41#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
42pub struct Config {
43 #[serde(default)]
44 pub lints: LintConfig,
45
46 #[serde(default)]
47 pub fix: FixConfig,
48}
49
50#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
53pub struct LintConfig {
54 #[serde(default)]
56 pub sets: HashMap<String, LintLevel>,
57
58 #[serde(default)]
60 pub rules: HashMap<String, LintLevel>,
61}
62
63impl<'de> Deserialize<'de> for LintConfig {
64 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
65 where
66 D: serde::Deserializer<'de>,
67 {
68 #[derive(Deserialize)]
69 struct LintConfigHelper {
70 #[serde(default)]
71 sets: HashMap<String, LintLevel>,
72 #[serde(default)]
73 rules: HashMap<String, LintLevel>,
74 }
75
76 let helper = LintConfigHelper::deserialize(deserializer)?;
77
78 Ok(Self {
79 sets: helper.sets,
80 rules: helper.rules,
81 })
82 }
83}
84
85impl Default for LintConfig {
86 fn default() -> Self {
87 Self {
88 sets: HashMap::new(),
89 rules: DEFAULT_RULE_MAP.rules.clone(),
90 }
91 }
92}
93
94#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
95pub struct ExcludeConfig {
96 #[serde(default)]
97 pub patterns: Vec<String>,
98}
99
100#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
101pub struct FixConfig {
102 #[serde(default)]
103 pub enabled: bool,
104}
105
106impl Config {
107 pub fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
108 Ok(toml::from_str(toml_str)?)
109 }
110 pub fn load_from_file(path: &Path) -> Result<Self, LintError> {
117 let content = fs::read_to_string(path)?;
118 Self::load_from_str(&content)
119 }
120
121 #[must_use]
123 pub fn load(config_path: Option<&PathBuf>) -> Self {
124 config_path
125 .cloned()
126 .or_else(find_config_file)
127 .map_or_else(Self::default, |path| {
128 Self::load_from_file(&path).unwrap_or_else(|e| {
129 eprintln!("Error loading config from {}: {e}", path.display());
130 process::exit(2);
131 })
132 })
133 }
134
135 #[must_use]
142 pub fn get_lint_level(&self, rule_id: &str) -> LintLevel {
143 if let Some(level) = self.lints.rules.get(rule_id) {
144 log::debug!(
145 "Rule '{rule_id}' has individual level '{level:?}' in config, overriding set \
146 levels"
147 );
148 return *level;
149 }
150
151 let mut max_level: Option<LintLevel> = None;
152
153 for (set_name, level) in &self.lints.sets {
154 let Some(lint_set) = BUILTIN_LINT_SETS.get(set_name.as_str()) else {
155 continue;
156 };
157
158 if !lint_set.rules.contains(rule_id) {
159 continue;
160 }
161
162 log::debug!("Rule '{rule_id}' found in set '{set_name}' with level {level:?}");
163 max_level = Some(max_level.map_or(*level, |existing| existing.max(*level)));
164 }
165
166 max_level.unwrap_or_else(|| {
167 DEFAULT_RULE_MAP
168 .rules
169 .get(rule_id)
170 .copied()
171 .unwrap_or(LintLevel::Warn)
172 })
173 }
174}
175
176#[must_use]
178pub fn find_config_file() -> Option<PathBuf> {
179 let mut current_dir = current_dir().ok()?;
180
181 loop {
182 let config_path = current_dir.join(".nu-lint.toml");
183 if config_path.exists() && config_path.is_file() {
184 return Some(config_path);
185 }
186
187 if !current_dir.pop() {
189 break;
190 }
191 }
192
193 None
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::log::instrument;
200
201 #[test]
202 fn test_load_config_simple_str() {
203 let toml_str = r#"
204 [lints.rules]
205 snake_case_variables = "deny"
206 "#;
207
208 let config = Config::load_from_str(toml_str).unwrap();
209 assert_eq!(
210 config.lints.rules.get("snake_case_variables"),
211 Some(&LintLevel::Deny)
212 );
213 }
214
215 #[test]
216 fn test_load_config_simple_str_set() {
217 let toml_str = r#"
218 [lints.sets]
219 naming = "deny"
220 "#;
221
222 let config = Config::load_from_str(toml_str).unwrap();
223 let found_set_level = config.lints.sets.iter().find(|(k, _)| **k == "naming");
224 assert!(matches!(found_set_level, Some((_, LintLevel::Deny))));
225 }
226
227 #[test]
228 fn test_load_config_load_from_set_deny() {
229 let toml_str = r#"
230 [lints.sets]
231 naming = "deny"
232 "#;
233
234 let config = Config::load_from_str(toml_str).unwrap();
235 let found_set_level = config.get_lint_level("snake_case_variables");
236 assert_eq!(found_set_level, LintLevel::Deny);
237 }
238
239 #[test]
240 fn test_load_config_load_from_set_allow() {
241 instrument();
242 let toml_str = r#"
243 [lints.sets]
244 naming = "allow"
245
246 "#;
247
248 let config = Config::load_from_str(toml_str).unwrap();
249 let found_set_level = config.get_lint_level("snake_case_variables");
250 assert_eq!(found_set_level, LintLevel::Allow);
251 }
252
253 #[test]
254 fn test_load_config_load_from_set_deny_empty() {
255 instrument();
256 let toml_str = r"
257 ";
258
259 let config = Config::load_from_str(toml_str).unwrap();
260 let found_set_level = config.get_lint_level("snake_case_variables");
261 assert_eq!(found_set_level, LintLevel::Warn);
262 }
263
264 #[test]
265 fn test_load_config_load_from_set_deny_conflict() {
266 instrument();
267 let toml_str = r#"
268 [lints.sets]
269 naming = "deny"
270 [lints.rules]
271 snake_case_variables = "allow"
272 "#;
273
274 let config = Config::load_from_str(toml_str).unwrap();
275 let found_set_level = config.get_lint_level("snake_case_variables");
276 assert_eq!(found_set_level, LintLevel::Allow);
277 }
278
279 #[test]
280 fn test_fix_config_default() {
281 let toml_str = "";
282 let config = Config::load_from_str(toml_str).unwrap();
283 assert!(!config.fix.enabled);
284 }
285
286 #[test]
287 fn test_fix_config_enabled() {
288 let toml_str = r"
289 [fix]
290 enabled = true
291 ";
292 let config = Config::load_from_str(toml_str).unwrap();
293 assert!(config.fix.enabled);
294 }
295
296 #[test]
297 fn test_fix_config_mixed() {
298 let toml_str = r#"
299 [lints.rules]
300 snake_case_variables = "deny"
301
302 [fix]
303 enabled = true
304 "#;
305 let config = Config::load_from_str(toml_str).unwrap();
306 assert!(config.fix.enabled);
307 assert_eq!(
308 config.lints.rules.get("snake_case_variables"),
309 Some(&LintLevel::Deny)
310 );
311 }
312}