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    rules::sets::{ALL_GROUPS, RULE_LEVEL_OVERRIDES},
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 = 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/// Lint configuration with support for set-level and individual rule
103/// configuration
104#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
105pub struct LintConfig {
106    /// Configure entire lint sets (e.g., "naming", "idioms", "pedantic")
107    #[serde(default)]
108    pub sets: HashMap<String, LintLevel>,
109
110    /// Configure individual rules (overrides set settings)
111    #[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    /// Load configuration from a TOML string.
145    ///
146    /// # Errors
147    ///
148    /// Errors when TOML string is not a valid TOML string.
149    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    /// Load configuration from a TOML file.
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if the file cannot be read or if the TOML content is
157    /// invalid.
158    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    /// Load configuration from file or use defaults
167    #[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    /// Get the effective lint level for a specific rule
181    /// Priority (high to low):
182    /// 1. Individual rule level in config
183    /// 2. Lint set level in config (highest level if rule appears in multiple
184    ///    sets)
185    /// 3. Default level from default rule map
186    #[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/// Search for .nu-lint.toml starting from the given directory and walking up to
224/// parent directories
225#[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/// Search for .nu-lint.toml in current directory and parent directories
244#[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        // Bare rules get added to rules
374        assert_eq!(
375            config.lints.rules.get("systemd_journal_prefix"),
376            Some(&LintLevel::Warn)
377        );
378        // Structured format values are merged with bare format
379        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        // snake_case_variables is in the naming set
392        assert_eq!(
393            config.get_lint_level("snake_case_variables"),
394            LintLevel::Deny
395        );
396    }
397}