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 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#[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 if !current_dir.pop() {
117 break;
118 }
119 }
120
121 None
122}
123
124#[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 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 std::env::set_current_dir(original_dir).unwrap();
165
166 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 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
198 std::env::set_current_dir(&subdir).unwrap();
199 find_config_file()
200 }));
201
202 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 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 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}