1use std::{
2 collections::HashMap,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de::IntoDeserializer};
8
9use crate::{
10 LintError,
11 rule::Rule,
12 rules::{USED_RULES, groups::ALL_GROUPS},
13};
14
15#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq, PartialOrd, Ord)]
16#[serde(rename_all = "lowercase")]
17pub enum LintLevel {
18 Hint,
19 #[default]
20 Warning,
21 Error,
22}
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub struct ToggledLevel(pub Option<LintLevel>);
27
28impl Serialize for ToggledLevel {
29 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
30 match self.0 {
31 Some(level) => level.serialize(serializer),
32 None => serializer.serialize_str("off"),
33 }
34 }
35}
36
37impl<'de> Deserialize<'de> for ToggledLevel {
38 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
39 let s = String::deserialize(deserializer)?;
40 match s.as_str() {
41 "off" => Ok(Self(None)),
42 _ => LintLevel::deserialize(s.into_deserializer()).map(|l| Self(Some(l))),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
48#[serde(rename_all = "lowercase")]
49pub enum PipelinePlacement {
50 #[default]
51 Start,
52 End,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(default)]
57pub struct Config {
58 pub groups: HashMap<String, LintLevel>,
59 pub rules: HashMap<String, ToggledLevel>,
60 pub sequential: bool,
61 pub pipeline_placement: PipelinePlacement,
62 pub max_pipeline_length: usize,
63 pub skip_external_parse_errors: bool,
64 pub explicit_optional_access: bool,
67}
68
69impl Default for Config {
70 fn default() -> Self {
71 Self {
72 groups: HashMap::new(),
73 rules: HashMap::new(),
74 sequential: false,
75 pipeline_placement: PipelinePlacement::default(),
76 max_pipeline_length: 80,
77 skip_external_parse_errors: true,
78 explicit_optional_access: false,
79 }
80 }
81}
82
83impl Config {
84 pub(crate) fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
90 toml::from_str(toml_str).map_err(|source| LintError::Config { source })
91 }
92 pub(crate) fn load_from_file(path: &Path) -> Result<Self, LintError> {
99 log::debug!("Loading configuration file at {}", path.display());
100 let content = fs::read_to_string(path).map_err(|source| LintError::Io {
101 path: path.to_path_buf(),
102 source,
103 })?;
104 Self::load_from_str(&content)
105 }
106
107 pub fn validate(&self) -> Result<(), LintError> {
113 log::debug!("Validating loaded configuration.");
114
115 for rule_id_in_config_file in self.rules.keys() {
116 if USED_RULES
117 .iter()
118 .find(|rule| rule.id() == rule_id_in_config_file)
119 .is_none()
120 {
121 return Err(LintError::RuleDoesNotExist {
122 non_existing_id: rule_id_in_config_file.clone(),
123 });
124 }
125 }
126
127 for rule in USED_RULES {
128 if self.get_lint_level(*rule).is_none() {
129 continue;
130 }
131
132 for conflicting_rule in rule.conflicts_with() {
133 if self.get_lint_level(*conflicting_rule).is_some() {
134 return Err(LintError::RuleConflict {
135 rule_a: rule.id(),
136 rule_b: conflicting_rule.id(),
137 });
138 }
139 }
140 }
141 Ok(())
142 }
143
144 #[must_use]
146 pub fn get_lint_level(&self, rule: &dyn Rule) -> Option<LintLevel> {
147 let rule_id = rule.id();
148
149 if let Some(ToggledLevel(level)) = self.rules.get(rule_id) {
150 log::debug!(
151 "Rule '{rule_id}' has individual level '{level:?}' in config, overriding set \
152 levels"
153 );
154 return *level;
155 }
156
157 for (set_name, level) in &self.groups {
158 let Some(lint_set) = ALL_GROUPS.iter().find(|set| set.name == set_name.as_str()) else {
159 continue;
160 };
161
162 if !lint_set.rules.iter().any(|r| r.id() == rule_id) {
163 continue;
164 }
165
166 log::debug!("Rule '{rule_id}' found in set '{set_name}' with level {level:?}");
167 return Some(*level);
168 }
169
170 rule.level()
171 }
172}
173
174#[must_use]
177pub fn find_config_file_from(start_dir: &Path) -> Option<PathBuf> {
178 let mut current_dir = start_dir.to_path_buf();
179
180 loop {
181 let config_path = current_dir.join(".nu-lint.toml");
182 if config_path.exists() && config_path.is_file() {
183 return Some(config_path);
184 }
185
186 if !current_dir.pop() {
187 break;
188 }
189 }
190
191 None
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn test_load_config_simple_str() {
200 let toml_str = r#"
201 [rules]
202 snake_case_variables = "error"
203 other_rule = "off"
204 "#;
205
206 let config = Config::load_from_str(toml_str).unwrap();
207 assert_eq!(
208 config.rules["snake_case_variables"],
209 ToggledLevel(Some(LintLevel::Error))
210 );
211
212 assert_eq!(config.rules["other_rule"], ToggledLevel(None));
213 }
214
215 #[test]
216 fn test_validate_passes_with_default_config() {
217 let result = Config::default().validate();
218 assert!(result.is_ok());
219 }
220}