nu_lint/
config.rs

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/// Lint level configuration (inspired by Clippy)
19/// - Allow: Don't report this lint
20/// - Warn: Report as a warning
21/// - Deny: Report as an error
22#[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/// Lint configuration with support for set-level and individual rule
51/// configuration
52#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
53pub struct LintConfig {
54    /// Configure entire lint sets (e.g., "naming", "idioms", "pedantic")
55    #[serde(default)]
56    pub sets: HashMap<String, LintLevel>,
57
58    /// Configure individual rules (overrides set settings)
59    #[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    /// Load configuration from a TOML file.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if the file cannot be read or if the TOML content is
115    /// invalid.
116    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    /// Load configuration from file or use defaults
122    #[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    /// Get the effective lint level for a specific rule
136    /// Priority (high to low):
137    /// 1. Individual rule level in config
138    /// 2. Lint set level in config (highest level if rule appears in multiple
139    ///    sets)
140    /// 3. Default level from default rule map
141    #[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/// Search for .nu-lint.toml in current directory and parent directories
177#[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        // Try to go to parent directory
188        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}