1use core::fmt::{self, Display};
2use std::{
3 collections::HashMap,
4 env::current_dir,
5 fs,
6 path::{Path, PathBuf},
7 process,
8};
9
10use serde::{Deserialize, Serialize};
11
12use crate::{
13 LintError,
14 rules::sets::{ALL_GROUPS, RULE_LEVEL_OVERRIDES},
15};
16
17#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq, PartialOrd, Ord)]
22#[serde(rename_all = "lowercase")]
23pub enum LintLevel {
24 Allow,
25 #[default]
26 Warn,
27 Deny,
28}
29
30impl Display for LintLevel {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 match self {
33 Self::Allow => write!(f, "allow"),
34 Self::Warn => write!(f, "warn"),
35 Self::Deny => write!(f, "deny"),
36 }
37 }
38}
39
40#[derive(Deserialize)]
41#[serde(untagged)]
42enum ConfigField {
43 Lints(LintConfig),
44 Sequential(bool),
45 Level(LintLevel),
46}
47
48#[derive(Debug, Clone, Serialize, Default, PartialEq)]
49pub struct Config {
50 #[serde(default)]
51 pub lints: LintConfig,
52
53 #[serde(default)]
55 pub sequential: bool,
56}
57
58impl<'de> Deserialize<'de> for Config {
59 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
60 where
61 D: serde::Deserializer<'de>,
62 {
63 let map = HashMap::<String, ConfigField>::deserialize(deserializer)?;
64
65 let mut lints = None;
66 let mut sequential = None;
67 let mut bare_items = HashMap::new();
68
69 for (key, value) in map {
70 match (key.as_str(), value) {
71 ("lints", ConfigField::Lints(l)) => lints = Some(l),
72 ("sequential", ConfigField::Sequential(s)) => sequential = Some(s),
73 (_, ConfigField::Level(level)) => {
74 bare_items.insert(key, level);
75 }
76 _ => {}
77 }
78 }
79
80 let mut lints = lints.unwrap_or_default();
81 merge_bare_items_into_lints(&mut lints, bare_items);
82
83 Ok(Self {
84 lints,
85 sequential: sequential.unwrap_or(false),
86 })
87 }
88}
89
90fn merge_bare_items_into_lints(lints: &mut LintConfig, bare_items: HashMap<String, LintLevel>) {
91 for (name, level) in bare_items {
92 let is_set = ALL_GROUPS.iter().any(|set| set.name == name);
93
94 if is_set {
95 lints.sets.insert(name, level);
96 } else {
97 lints.rules.insert(name, level);
98 }
99 }
100}
101
102#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
105pub struct LintConfig {
106 #[serde(default)]
108 pub sets: HashMap<String, LintLevel>,
109
110 #[serde(default)]
112 pub rules: HashMap<String, LintLevel>,
113}
114
115impl<'de> Deserialize<'de> for LintConfig {
116 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
117 where
118 D: serde::Deserializer<'de>,
119 {
120 #[derive(Deserialize)]
121 struct LintConfigHelper {
122 #[serde(default)]
123 sets: HashMap<String, LintLevel>,
124 #[serde(default)]
125 rules: HashMap<String, LintLevel>,
126 }
127
128 let helper = LintConfigHelper::deserialize(deserializer)?;
129
130 Ok(Self {
131 sets: helper.sets,
132 rules: helper.rules,
133 })
134 }
135}
136
137#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
138pub struct ExcludeConfig {
139 #[serde(default)]
140 pub patterns: Vec<String>,
141}
142
143impl Config {
144 pub fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
150 toml::from_str(toml_str).map_err(|source| LintError::Config { source })
151 }
152 pub fn load_from_file(path: &Path) -> Result<Self, LintError> {
159 let content = fs::read_to_string(path).map_err(|source| LintError::Io {
160 path: path.to_path_buf(),
161 source,
162 })?;
163 Self::load_from_str(&content)
164 }
165
166 #[must_use]
168 pub fn load(config_path: Option<&PathBuf>) -> Self {
169 config_path
170 .cloned()
171 .or_else(find_config_file)
172 .map_or_else(Self::default, |path| {
173 Self::load_from_file(&path).unwrap_or_else(|e| {
174 eprintln!("Error loading config from {}: {e}", path.display());
175 process::exit(2);
176 })
177 })
178 }
179
180 #[must_use]
187 pub fn get_lint_level(&self, rule_id: &'static str) -> LintLevel {
188 if let Some(level) = self.lints.rules.get(rule_id) {
189 log::debug!(
190 "Rule '{rule_id}' has individual level '{level:?}' in config, overriding set \
191 levels"
192 );
193 return *level;
194 }
195
196 let mut max_level: Option<LintLevel> = None;
197
198 for (set_name, level) in &self.lints.sets {
199 let Some(lint_set) = ALL_GROUPS.iter().find(|set| set.name == set_name.as_str()) else {
200 continue;
201 };
202
203 if !lint_set.rules.iter().any(|rule| rule.id == rule_id) {
204 continue;
205 }
206
207 log::debug!("Rule '{rule_id}' found in set '{set_name}' with level {level:?}");
208 max_level = Some(max_level.map_or(*level, |existing| existing.max(*level)));
209 }
210
211 max_level.unwrap_or_else(|| {
212 RULE_LEVEL_OVERRIDES
213 .rules
214 .iter()
215 .find(|(rule, _)| rule.id == rule_id)
216 .map(|(_, level)| level)
217 .copied()
218 .unwrap_or(LintLevel::Warn)
219 })
220 }
221}
222
223#[must_use]
226pub fn find_config_file_from(start_dir: &Path) -> Option<PathBuf> {
227 let mut current_dir = start_dir.to_path_buf();
228
229 loop {
230 let config_path = current_dir.join(".nu-lint.toml");
231 if config_path.exists() && config_path.is_file() {
232 return Some(config_path);
233 }
234
235 if !current_dir.pop() {
236 break;
237 }
238 }
239
240 None
241}
242
243#[must_use]
245pub fn find_config_file() -> Option<PathBuf> {
246 current_dir()
247 .ok()
248 .and_then(|dir| find_config_file_from(&dir))
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::log::instrument;
255
256 #[test]
257 fn test_load_config_simple_str() {
258 let toml_str = r#"
259 [lints.rules]
260 snake_case_variables = "deny"
261 "#;
262
263 let config = Config::load_from_str(toml_str).unwrap();
264 assert_eq!(
265 config.lints.rules.get("snake_case_variables"),
266 Some(&LintLevel::Deny)
267 );
268 }
269
270 #[test]
271 fn test_load_config_simple_str_set() {
272 let toml_str = r#"
273 [lints.sets]
274 naming = "deny"
275 "#;
276
277 let config = Config::load_from_str(toml_str).unwrap();
278 let found_set_level = config.lints.sets.iter().find(|(k, _)| **k == "naming");
279 assert!(matches!(found_set_level, Some((_, LintLevel::Deny))));
280 }
281
282 #[test]
283 fn test_load_config_load_from_set_deny() {
284 let toml_str = r#"
285 [lints.sets]
286 naming = "deny"
287 "#;
288
289 let config = Config::load_from_str(toml_str).unwrap();
290 let found_set_level = config.get_lint_level("snake_case_variables");
291 assert_eq!(found_set_level, LintLevel::Deny);
292 }
293
294 #[test]
295 fn test_load_config_load_from_set_allow() {
296 instrument();
297 let toml_str = r#"
298 [lints.sets]
299 naming = "allow"
300
301 "#;
302
303 let config = Config::load_from_str(toml_str).unwrap();
304 let found_set_level = config.get_lint_level("snake_case_variables");
305 assert_eq!(found_set_level, LintLevel::Allow);
306 }
307
308 #[test]
309 fn test_load_config_load_from_set_deny_empty() {
310 instrument();
311 let toml_str = r"
312 ";
313
314 let config = Config::load_from_str(toml_str).unwrap();
315 let found_set_level = config.get_lint_level("snake_case_variables");
316 assert_eq!(found_set_level, LintLevel::Warn);
317 }
318
319 #[test]
320 fn test_load_config_load_from_set_deny_conflict() {
321 instrument();
322 let toml_str = r#"
323 [lints.sets]
324 naming = "deny"
325 [lints.rules]
326 snake_case_variables = "allow"
327 "#;
328
329 let config = Config::load_from_str(toml_str).unwrap();
330 let found_set_level = config.get_lint_level("snake_case_variables");
331 assert_eq!(found_set_level, LintLevel::Allow);
332 }
333
334 #[test]
335 fn test_bare_rule_format() {
336 let toml_str = r#"
337 snake_case_variables = "deny"
338 systemd_journal_prefix = "warn"
339 "#;
340 let config = Config::load_from_str(toml_str).unwrap();
341 assert_eq!(
342 config.lints.rules.get("snake_case_variables"),
343 Some(&LintLevel::Deny)
344 );
345 assert_eq!(
346 config.lints.rules.get("systemd_journal_prefix"),
347 Some(&LintLevel::Warn)
348 );
349 }
350
351 #[test]
352 fn test_bare_set_format() {
353 let toml_str = r#"
354 naming = "deny"
355 performance = "warn"
356 "#;
357 let config = Config::load_from_str(toml_str).unwrap();
358 assert_eq!(config.lints.sets.get("naming"), Some(&LintLevel::Deny));
359 assert_eq!(config.lints.sets.get("performance"), Some(&LintLevel::Warn));
360 }
361
362 #[test]
363 fn test_mixed_bare_and_structured() {
364 let toml_str = r#"
365 naming = "deny"
366 systemd_journal_prefix = "warn"
367
368 [lints.rules]
369 snake_case_variables = "allow"
370 "#;
371 let config = Config::load_from_str(toml_str).unwrap();
372 assert_eq!(config.lints.sets.get("naming"), Some(&LintLevel::Deny));
373 assert_eq!(
375 config.lints.rules.get("systemd_journal_prefix"),
376 Some(&LintLevel::Warn)
377 );
378 assert_eq!(
380 config.lints.rules.get("snake_case_variables"),
381 Some(&LintLevel::Allow)
382 );
383 }
384
385 #[test]
386 fn test_bare_format_resolves_level() {
387 let toml_str = r#"
388 naming = "deny"
389 "#;
390 let config = Config::load_from_str(toml_str).unwrap();
391 assert_eq!(
393 config.get_lint_level("snake_case_variables"),
394 LintLevel::Deny
395 );
396 }
397}