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 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 #[must_use]
81 pub fn default_static() -> &'static Self {
82 static DEFAULT: LazyLock<Config> = LazyLock::new(Config::default);
83 &DEFAULT
84 }
85
86 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 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 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 #[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#[must_use]
179pub fn find_config_file_from(start_dir: &Path) -> Option<PathBuf> {
180 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 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}