nu_lint/
config.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4    process,
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::lint::Severity;
10
11#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
12pub struct Config {
13    #[serde(default)]
14    pub general: GeneralConfig,
15
16    #[serde(default)]
17    pub rules: HashMap<String, RuleSeverity>,
18
19    #[serde(default)]
20    pub style: StyleConfig,
21
22    #[serde(default)]
23    pub exclude: ExcludeConfig,
24
25    #[serde(default)]
26    pub fix: FixConfig,
27}
28
29#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
30pub struct GeneralConfig {
31    pub max_severity: RuleSeverity,
32}
33
34#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq)]
35#[serde(rename_all = "lowercase")]
36pub enum RuleSeverity {
37    #[default]
38    Error,
39    Warning,
40    Info,
41    Off,
42}
43
44impl From<RuleSeverity> for Option<Severity> {
45    fn from(rule_sev: RuleSeverity) -> Self {
46        match rule_sev {
47            RuleSeverity::Error => Some(Severity::Error),
48            RuleSeverity::Warning => Some(Severity::Warning),
49            RuleSeverity::Info => Some(Severity::Info),
50            RuleSeverity::Off => None,
51        }
52    }
53}
54
55#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
56pub struct StyleConfig {
57    #[serde(default = "StyleConfig::default_line_length")]
58    pub line_length: usize,
59
60    #[serde(default = "StyleConfig::default_indent_spaces")]
61    pub indent_spaces: usize,
62}
63
64impl StyleConfig {
65    const fn default_line_length() -> usize {
66        100
67    }
68
69    const fn default_indent_spaces() -> usize {
70        4
71    }
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
75pub struct ExcludeConfig {
76    #[serde(default)]
77    pub patterns: Vec<String>,
78}
79
80#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
81pub struct FixConfig {
82    pub enabled: bool,
83
84    pub safe_only: bool,
85}
86
87impl Config {
88    /// Load configuration from a TOML file.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the file cannot be read or if the TOML content is
93    /// invalid.
94    pub fn load_from_file(path: &Path) -> Result<Self, crate::LintError> {
95        let content = std::fs::read_to_string(path)?;
96        Ok(toml::from_str(&content)?)
97    }
98
99    pub fn rule_severity(&self, rule_id: &str) -> Option<Severity> {
100        self.rules.get(rule_id).copied().and_then(Into::into)
101    }
102}
103
104/// Search for .nu-lint.toml in current directory and parent directories
105#[must_use]
106pub fn find_config_file() -> Option<PathBuf> {
107    let mut current_dir = std::env::current_dir().ok()?;
108
109    loop {
110        let config_path = current_dir.join(".nu-lint.toml");
111        if config_path.exists() && config_path.is_file() {
112            return Some(config_path);
113        }
114
115        // Try to go to parent directory
116        if !current_dir.pop() {
117            break;
118        }
119    }
120
121    None
122}
123
124/// Load configuration from file or use defaults
125#[must_use]
126pub fn load_config(config_path: Option<&PathBuf>) -> Config {
127    let path = config_path.cloned().or_else(find_config_file);
128
129    if let Some(path) = path {
130        Config::load_from_file(&path).unwrap_or_else(|e| {
131            eprintln!("Error loading config from {}: {e}", path.display());
132            process::exit(2);
133        })
134    } else {
135        Config::default()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use std::fs;
142
143    use tempfile::TempDir;
144
145    use super::*;
146    use crate::test_utils::CHDIR_MUTEX;
147
148    fn with_temp_dir<F>(f: F)
149    where
150        F: FnOnce(&TempDir),
151    {
152        let _guard = CHDIR_MUTEX.lock().unwrap();
153
154        let temp_dir = TempDir::new().unwrap();
155        let original_dir = std::env::current_dir().unwrap();
156
157        // Use catch_unwind to ensure directory is restored even if the test panics
158        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
159            std::env::set_current_dir(temp_dir.path()).unwrap();
160            f(&temp_dir);
161        }));
162
163        // Always restore directory, even if test panics
164        std::env::set_current_dir(original_dir).unwrap();
165
166        // Re-panic if the test failed
167        if let Err(e) = result {
168            std::panic::resume_unwind(e);
169        }
170    }
171
172    #[test]
173    fn test_find_config_file_in_current_dir() {
174        with_temp_dir(|temp_dir| {
175            let config_path = temp_dir.path().join(".nu-lint.toml");
176            fs::write(&config_path, "[rules]\n").unwrap();
177
178            let found = find_config_file();
179            assert!(found.is_some());
180            assert_eq!(found.unwrap(), config_path);
181        });
182    }
183
184    #[test]
185    fn test_find_config_file_in_parent_dir() {
186        let _guard = CHDIR_MUTEX.lock().unwrap();
187
188        let temp_dir = TempDir::new().unwrap();
189        let config_path = temp_dir.path().join(".nu-lint.toml");
190        let subdir = temp_dir.path().join("subdir");
191        fs::create_dir(&subdir).unwrap();
192        fs::write(&config_path, "[rules]\n").unwrap();
193
194        let original_dir = std::env::current_dir().unwrap();
195
196        // Use a closure with defer-like behavior to ensure directory is restored
197        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
198            std::env::set_current_dir(&subdir).unwrap();
199            find_config_file()
200        }));
201
202        // Always restore directory, even if test panics
203        std::env::set_current_dir(original_dir).unwrap();
204
205        let found = result.unwrap();
206        assert!(found.is_some());
207        assert_eq!(found.unwrap(), config_path);
208    }
209
210    #[test]
211    fn test_find_config_file_not_found() {
212        with_temp_dir(|_temp_dir| {
213            let found = find_config_file();
214            assert!(found.is_none());
215        });
216    }
217
218    #[test]
219    fn test_load_config_with_explicit_path() {
220        let temp_dir = TempDir::new().unwrap();
221        let config_path = temp_dir.path().join("config.toml");
222        fs::write(&config_path, "[general]\nmax_severity = \"error\"\n").unwrap();
223
224        let config = load_config(Some(&config_path));
225        assert_eq!(config.general.max_severity, RuleSeverity::Error);
226    }
227
228    #[test]
229    fn test_load_config_auto_discover() {
230        let _guard = CHDIR_MUTEX.lock().unwrap();
231
232        let temp_dir = TempDir::new().unwrap();
233        let config_path = temp_dir.path().join(".nu-lint.toml");
234        fs::write(&config_path, "[general]\nmax_severity = \"warning\"\n").unwrap();
235
236        let original_dir = std::env::current_dir().unwrap();
237
238        // Use a closure with defer-like behavior to ensure directory is restored
239        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
240            std::env::set_current_dir(temp_dir.path()).unwrap();
241            load_config(None)
242        }));
243
244        // Always restore directory, even if test panics
245        std::env::set_current_dir(original_dir).unwrap();
246
247        let config = result.unwrap();
248        assert_eq!(config.general.max_severity, RuleSeverity::Warning);
249    }
250
251    #[test]
252    fn test_load_config_default() {
253        with_temp_dir(|_temp_dir| {
254            let config = load_config(None);
255            assert_eq!(config, Config::default());
256        });
257    }
258}