nu_lint/
config.rs

1use std::{
2    collections::{HashMap, HashSet},
3    env::current_dir,
4    fs,
5    path::{Path, PathBuf},
6    process,
7};
8
9use serde::{Deserialize, Serialize};
10
11use crate::{LintError, rule::Rule, rules::groups::ALL_GROUPS};
12
13#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq, PartialOrd, Ord)]
14#[serde(rename_all = "lowercase")]
15pub enum LintLevel {
16    Hint,
17    #[default]
18    Warning,
19    Error,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
23#[serde(default)]
24pub struct Config {
25    pub groups: HashMap<String, LintLevel>,
26    pub rules: HashMap<String, LintLevel>,
27    pub ignored: HashSet<String>,
28    pub sequential: bool,
29}
30
31impl Config {
32    /// Load configuration from a TOML string.
33    ///
34    /// # Errors
35    ///
36    /// Errors when TOML string is not a valid TOML string.
37    pub fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
38        toml::from_str(toml_str).map_err(|source| LintError::Config { source })
39    }
40    /// Load configuration from a TOML file.
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if the file cannot be read or if the TOML content is
45    /// invalid.
46    pub fn load_from_file(path: &Path) -> Result<Self, LintError> {
47        let content = fs::read_to_string(path).map_err(|source| LintError::Io {
48            path: path.to_path_buf(),
49            source,
50        })?;
51        Self::load_from_str(&content)
52    }
53
54    /// Load configuration from file or use defaults
55    #[must_use]
56    pub fn load(config_path: Option<&PathBuf>) -> Self {
57        config_path
58            .cloned()
59            .or_else(find_config_file)
60            .map_or_else(Self::default, |path| {
61                Self::load_from_file(&path).unwrap_or_else(|e| {
62                    eprintln!("Error loading config from {}: {e}", path.display());
63                    process::exit(2);
64                })
65            })
66    }
67
68    /// Get the effective lint level for a specific rule
69    #[must_use]
70    pub fn get_lint_level(&self, rule: &dyn Rule) -> Option<LintLevel> {
71        let rule_id = rule.id();
72
73        if self.ignored.contains(rule_id) {
74            return None;
75        }
76
77        if let Some(level) = self.rules.get(rule_id) {
78            log::debug!(
79                "Rule '{rule_id}' has individual level '{level:?}' in config, overriding set \
80                 levels"
81            );
82            return Some(*level);
83        }
84
85        for (set_name, level) in &self.groups {
86            let Some(lint_set) = ALL_GROUPS.iter().find(|set| set.name == set_name.as_str()) else {
87                continue;
88            };
89
90            if !lint_set.rules.iter().any(|r| r.id() == rule_id) {
91                continue;
92            }
93
94            log::debug!("Rule '{rule_id}' found in set '{set_name}' with level {level:?}");
95            return Some(*level);
96        }
97
98        Some(rule.level())
99    }
100}
101
102/// Search for .nu-lint.toml starting from the given directory and walking up to
103/// parent directories
104#[must_use]
105pub fn find_config_file_from(start_dir: &Path) -> Option<PathBuf> {
106    let mut current_dir = start_dir.to_path_buf();
107
108    loop {
109        let config_path = current_dir.join(".nu-lint.toml");
110        if config_path.exists() && config_path.is_file() {
111            return Some(config_path);
112        }
113
114        if !current_dir.pop() {
115            break;
116        }
117    }
118
119    None
120}
121
122/// Search for .nu-lint.toml in current directory and parent directories
123#[must_use]
124pub fn find_config_file() -> Option<PathBuf> {
125    current_dir()
126        .ok()
127        .and_then(|dir| find_config_file_from(&dir))
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::rules::ALL_RULES;
134
135    #[test]
136    fn test_load_config_simple_str() {
137        let toml_str = r#"
138        [rules]
139        snake_case_variables = "error"
140    "#;
141
142        let config = Config::load_from_str(toml_str).unwrap();
143        assert_eq!(
144            config.rules.get("snake_case_variables"),
145            Some(&LintLevel::Error)
146        );
147    }
148
149    #[test]
150    fn test_load_config_simple_str_set() {
151        let toml_str = r#"
152        ignored = [ "snake_case_variables" ]
153        [groups]
154        naming = "error"
155    "#;
156
157        let config = Config::load_from_str(toml_str).unwrap();
158        let found_set_level = config.groups.iter().find(|(k, _)| **k == "naming");
159        assert!(matches!(found_set_level, Some((_, LintLevel::Error))));
160        let ignored_rule = ALL_RULES
161            .iter()
162            .find(|r| r.id() == "snake_case_variables")
163            .unwrap();
164        assert_eq!(config.get_lint_level(*ignored_rule), None);
165    }
166}