nu_lint/
config.rs

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/// Lint level configuration (inspired by Clippy)
18/// - Allow: Don't report this lint
19/// - Warn: Report as a warning
20/// - Deny: Report as an error
21#[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    /// Process files sequentially instead of in parallel (useful for debugging)
54    #[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/// Lint configuration with support for set-level and individual rule
105/// configuration
106#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
107pub struct LintConfig {
108    /// Configure entire lint sets (e.g., "naming", "idioms", "pedantic")
109    #[serde(default)]
110    pub sets: HashMap<String, LintLevel>,
111
112    /// Configure individual rules (overrides set settings)
113    #[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    /// Load configuration from a TOML string.
147    ///
148    /// # Errors
149    ///
150    /// Errors when TOML string is not a valid TOML string.
151    pub fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
152        Ok(toml::from_str(toml_str)?)
153    }
154    /// Load configuration from a TOML file.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if the file cannot be read or if the TOML content is
159    /// invalid.
160    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    /// Load configuration from file or use defaults
166    #[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    /// Get the effective lint level for a specific rule
180    /// Priority (high to low):
181    /// 1. Individual rule level in config
182    /// 2. Lint set level in config (highest level if rule appears in multiple
183    ///    sets)
184    /// 3. Default level from default rule map
185    #[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/// Search for .nu-lint.toml in current directory and parent directories
226#[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        // Try to go to parent directory
237        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        // Bare rules get added to rules
368        assert_eq!(
369            config.lints.rules.get("systemd_journal_prefix"),
370            Some(&LintLevel::Warn)
371        );
372        // Structured format values are merged with bare format
373        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        // snake_case_variables is in the naming set
386        assert_eq!(
387            config.get_lint_level("snake_case_variables"),
388            LintLevel::Deny
389        );
390    }
391}