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 sets::{BUILTIN_LINT_SETS, DEFAULT_RULE_MAP},
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 = BUILTIN_LINT_SETS
93 .iter()
94 .any(|(set_name, _)| *set_name == name);
95
96 if is_set {
97 lints.sets.insert(name, level);
98 } else {
99 lints.rules.insert(name, level);
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
107pub struct LintConfig {
108 #[serde(default)]
110 pub sets: HashMap<String, LintLevel>,
111
112 #[serde(default)]
114 pub rules: HashMap<String, LintLevel>,
115}
116
117impl<'de> Deserialize<'de> for LintConfig {
118 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
119 where
120 D: serde::Deserializer<'de>,
121 {
122 #[derive(Deserialize)]
123 struct LintConfigHelper {
124 #[serde(default)]
125 sets: HashMap<String, LintLevel>,
126 #[serde(default)]
127 rules: HashMap<String, LintLevel>,
128 }
129
130 let helper = LintConfigHelper::deserialize(deserializer)?;
131
132 Ok(Self {
133 sets: helper.sets,
134 rules: helper.rules,
135 })
136 }
137}
138
139#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
140pub struct ExcludeConfig {
141 #[serde(default)]
142 pub patterns: Vec<String>,
143}
144
145impl Config {
146 pub fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
152 Ok(toml::from_str(toml_str)?)
153 }
154 pub fn load_from_file(path: &Path) -> Result<Self, LintError> {
161 let content = fs::read_to_string(path)?;
162 Self::load_from_str(&content)
163 }
164
165 #[must_use]
167 pub fn load(config_path: Option<&PathBuf>) -> Self {
168 config_path
169 .cloned()
170 .or_else(find_config_file)
171 .map_or_else(Self::default, |path| {
172 Self::load_from_file(&path).unwrap_or_else(|e| {
173 eprintln!("Error loading config from {}: {e}", path.display());
174 process::exit(2);
175 })
176 })
177 }
178
179 #[must_use]
186 pub fn get_lint_level(&self, rule_id: &'static str) -> LintLevel {
187 if let Some(level) = self.lints.rules.get(rule_id) {
188 log::debug!(
189 "Rule '{rule_id}' has individual level '{level:?}' in config, overriding set \
190 levels"
191 );
192 return *level;
193 }
194
195 let mut max_level: Option<LintLevel> = None;
196
197 for (set_name, level) in &self.lints.sets {
198 let Some((_, lint_set)) = BUILTIN_LINT_SETS
199 .iter()
200 .find(|(name, _)| *name == set_name.as_str())
201 else {
202 continue;
203 };
204
205 if !lint_set.rules.contains(&rule_id) {
206 continue;
207 }
208
209 log::debug!("Rule '{rule_id}' found in set '{set_name}' with level {level:?}");
210 max_level = Some(max_level.map_or(*level, |existing| existing.max(*level)));
211 }
212
213 max_level.unwrap_or_else(|| {
214 DEFAULT_RULE_MAP
215 .rules
216 .iter()
217 .find(|(id, _)| *id == rule_id)
218 .map(|(_, level)| level)
219 .copied()
220 .unwrap_or(LintLevel::Warn)
221 })
222 }
223}
224
225#[must_use]
227pub fn find_config_file() -> Option<PathBuf> {
228 let mut current_dir = current_dir().ok()?;
229
230 loop {
231 let config_path = current_dir.join(".nu-lint.toml");
232 if config_path.exists() && config_path.is_file() {
233 return Some(config_path);
234 }
235
236 if !current_dir.pop() {
238 break;
239 }
240 }
241
242 None
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use crate::log::instrument;
249
250 #[test]
251 fn test_load_config_simple_str() {
252 let toml_str = r#"
253 [lints.rules]
254 snake_case_variables = "deny"
255 "#;
256
257 let config = Config::load_from_str(toml_str).unwrap();
258 assert_eq!(
259 config.lints.rules.get("snake_case_variables"),
260 Some(&LintLevel::Deny)
261 );
262 }
263
264 #[test]
265 fn test_load_config_simple_str_set() {
266 let toml_str = r#"
267 [lints.sets]
268 naming = "deny"
269 "#;
270
271 let config = Config::load_from_str(toml_str).unwrap();
272 let found_set_level = config.lints.sets.iter().find(|(k, _)| **k == "naming");
273 assert!(matches!(found_set_level, Some((_, LintLevel::Deny))));
274 }
275
276 #[test]
277 fn test_load_config_load_from_set_deny() {
278 let toml_str = r#"
279 [lints.sets]
280 naming = "deny"
281 "#;
282
283 let config = Config::load_from_str(toml_str).unwrap();
284 let found_set_level = config.get_lint_level("snake_case_variables");
285 assert_eq!(found_set_level, LintLevel::Deny);
286 }
287
288 #[test]
289 fn test_load_config_load_from_set_allow() {
290 instrument();
291 let toml_str = r#"
292 [lints.sets]
293 naming = "allow"
294
295 "#;
296
297 let config = Config::load_from_str(toml_str).unwrap();
298 let found_set_level = config.get_lint_level("snake_case_variables");
299 assert_eq!(found_set_level, LintLevel::Allow);
300 }
301
302 #[test]
303 fn test_load_config_load_from_set_deny_empty() {
304 instrument();
305 let toml_str = r"
306 ";
307
308 let config = Config::load_from_str(toml_str).unwrap();
309 let found_set_level = config.get_lint_level("snake_case_variables");
310 assert_eq!(found_set_level, LintLevel::Warn);
311 }
312
313 #[test]
314 fn test_load_config_load_from_set_deny_conflict() {
315 instrument();
316 let toml_str = r#"
317 [lints.sets]
318 naming = "deny"
319 [lints.rules]
320 snake_case_variables = "allow"
321 "#;
322
323 let config = Config::load_from_str(toml_str).unwrap();
324 let found_set_level = config.get_lint_level("snake_case_variables");
325 assert_eq!(found_set_level, LintLevel::Allow);
326 }
327
328 #[test]
329 fn test_bare_rule_format() {
330 let toml_str = r#"
331 snake_case_variables = "deny"
332 systemd_journal_prefix = "warn"
333 "#;
334 let config = Config::load_from_str(toml_str).unwrap();
335 assert_eq!(
336 config.lints.rules.get("snake_case_variables"),
337 Some(&LintLevel::Deny)
338 );
339 assert_eq!(
340 config.lints.rules.get("systemd_journal_prefix"),
341 Some(&LintLevel::Warn)
342 );
343 }
344
345 #[test]
346 fn test_bare_set_format() {
347 let toml_str = r#"
348 naming = "deny"
349 performance = "warn"
350 "#;
351 let config = Config::load_from_str(toml_str).unwrap();
352 assert_eq!(config.lints.sets.get("naming"), Some(&LintLevel::Deny));
353 assert_eq!(config.lints.sets.get("performance"), Some(&LintLevel::Warn));
354 }
355
356 #[test]
357 fn test_mixed_bare_and_structured() {
358 let toml_str = r#"
359 naming = "deny"
360 systemd_journal_prefix = "warn"
361
362 [lints.rules]
363 snake_case_variables = "allow"
364 "#;
365 let config = Config::load_from_str(toml_str).unwrap();
366 assert_eq!(config.lints.sets.get("naming"), Some(&LintLevel::Deny));
367 assert_eq!(
369 config.lints.rules.get("systemd_journal_prefix"),
370 Some(&LintLevel::Warn)
371 );
372 assert_eq!(
374 config.lints.rules.get("snake_case_variables"),
375 Some(&LintLevel::Allow)
376 );
377 }
378
379 #[test]
380 fn test_bare_format_resolves_level() {
381 let toml_str = r#"
382 naming = "deny"
383 "#;
384 let config = Config::load_from_str(toml_str).unwrap();
385 assert_eq!(
387 config.get_lint_level("snake_case_variables"),
388 LintLevel::Deny
389 );
390 }
391}