Skip to main content

nu_lint/
config.rs

1use std::{
2    collections::HashMap,
3    fs,
4    path::{Path, PathBuf},
5    sync::LazyLock,
6};
7
8use miette::Severity;
9use serde::{Deserialize, Serialize};
10
11use crate::{
12    LintError,
13    rule::Rule,
14    rules::{USED_RULES, groups::ALL_GROUPS},
15};
16
17#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq, PartialOrd, Ord)]
18#[serde(rename_all = "lowercase")]
19pub enum LintLevel {
20    Off,
21    Hint,
22    #[default]
23    Warning,
24    Error,
25}
26
27impl TryFrom<LintLevel> for Severity {
28    type Error = ();
29    fn try_from(value: LintLevel) -> Result<Self, ()> {
30        match value {
31            LintLevel::Off => Err(()),
32            LintLevel::Hint => Ok(Self::Advice),
33            LintLevel::Warning => Ok(Self::Warning),
34            LintLevel::Error => Ok(Self::Error),
35        }
36    }
37}
38
39#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
40#[serde(rename_all = "lowercase")]
41pub enum PipelinePlacement {
42    #[default]
43    Start,
44    End,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(default)]
49pub struct Config {
50    pub groups: HashMap<String, LintLevel>,
51    pub rules: HashMap<String, LintLevel>,
52    pub sequential: bool,
53    pub pipeline_placement: PipelinePlacement,
54    pub max_pipeline_length: usize,
55    pub skip_external_parse_errors: bool,
56    /// When true, rules recommend `get --optional` instead of `$list.0?` for
57    /// safe access. Default is false (prefer `?` syntax).
58    pub explicit_optional_access: bool,
59}
60
61impl Default for Config {
62    fn default() -> Self {
63        Self {
64            groups: HashMap::new(),
65            rules: HashMap::new(),
66            sequential: false,
67            pipeline_placement: PipelinePlacement::default(),
68            max_pipeline_length: 80,
69            skip_external_parse_errors: true,
70            explicit_optional_access: false,
71        }
72    }
73}
74
75impl Config {
76    /// Return a `&'static` reference to the default configuration.
77    ///
78    /// Useful when a `&'static Config` is needed without searching
79    /// the file system (e.g. in tests).
80    #[must_use]
81    pub fn default_static() -> &'static Self {
82        static DEFAULT: LazyLock<Config> = LazyLock::new(Config::default);
83        &DEFAULT
84    }
85
86    /// Load configuration from a TOML string.
87    ///
88    /// # Errors
89    ///
90    /// Errors when TOML string is not a valid TOML string.
91    pub(crate) fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
92        toml::from_str(toml_str).map_err(|source| LintError::Config { source })
93    }
94    /// Load configuration from a TOML file.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the file cannot be read or if the TOML content is
99    /// invalid.
100    pub(crate) fn load_from_file(path: &Path) -> Result<Self, LintError> {
101        log::debug!("Loading configuration file at {}", path.display());
102        let content = fs::read_to_string(path).map_err(|source| LintError::Io {
103            path: path.to_path_buf(),
104            source,
105        })?;
106        Self::load_from_str(&content)
107    }
108
109    /// Validate that no conflicting rules are both enabled
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if two conflicting rules are both enabled.
114    pub fn validate(&self) -> Result<(), LintError> {
115        log::debug!("Validating loaded configuration.");
116
117        for rule_id_in_config_file in self.rules.keys() {
118            if USED_RULES
119                .iter()
120                .find(|rule| rule.id() == rule_id_in_config_file)
121                .is_none()
122            {
123                return Err(LintError::RuleDoesNotExist {
124                    non_existing_id: rule_id_in_config_file.clone(),
125                });
126            }
127        }
128
129        for rule in USED_RULES {
130            if self.get_lint_level(*rule) == LintLevel::Off {
131                continue;
132            }
133
134            for conflicting_rule in rule.conflicts_with() {
135                if self.get_lint_level(*conflicting_rule) > LintLevel::Off {
136                    return Err(LintError::RuleConflict {
137                        rule_a: rule.id(),
138                        rule_b: conflicting_rule.id(),
139                    });
140                }
141            }
142        }
143        Ok(())
144    }
145
146    /// Get the effective lint level for a specific rule
147    #[must_use]
148    pub fn get_lint_level(&self, rule: &dyn Rule) -> LintLevel {
149        let rule_id = rule.id();
150
151        if let Some(level) = self.rules.get(rule_id) {
152            log::trace!(
153                "Rule '{rule_id}' has individual level '{level:?}' in config, overriding set \
154                 levels"
155            );
156            return *level;
157        }
158
159        for (set_name, level) in &self.groups {
160            let Some(lint_set) = ALL_GROUPS.iter().find(|set| set.name == set_name.as_str()) else {
161                continue;
162            };
163
164            if !lint_set.rules.iter().any(|r| r.id() == rule_id) {
165                continue;
166            }
167
168            log::trace!("Rule '{rule_id}' found in set '{set_name}' with level {level:?}");
169            return *level;
170        }
171
172        rule.level()
173    }
174}
175
176/// Search for `.nu-lint.toml` in the given directory, falling back to home
177/// directory
178#[must_use]
179pub fn find_config_file_from(start_dir: &Path) -> Option<PathBuf> {
180    // Check active directory first
181    let config_path = start_dir.join(".nu-lint.toml");
182    if config_path.exists() && config_path.is_file() {
183        return Some(config_path);
184    }
185
186    // Fall back to home directory
187    let home_config = dirs::home_dir()?.join(".nu-lint.toml");
188    if home_config.exists() && home_config.is_file() {
189        return Some(home_config);
190    }
191
192    None
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_load_config_simple_str() {
201        let toml_str = r#"
202        [rules]
203        snake_case_variables = "error"
204        other_rule = "off"
205    "#;
206
207        let config = Config::load_from_str(toml_str).unwrap();
208        assert_eq!(config.rules["snake_case_variables"], LintLevel::Error);
209
210        assert_eq!(config.rules["other_rule"], LintLevel::Off);
211    }
212
213    #[test]
214    fn test_validate_passes_with_default_config() {
215        let result = Config::default().validate();
216        assert!(result.is_ok());
217    }
218}