1use std::{
2 collections::{HashMap, HashSet},
3 env::current_dir,
4 fs,
5 path::{Path, PathBuf},
6 process,
7};
8
9use serde::{Deserialize, Serialize};
10
11use crate::{LintError, rule::Rule, rules::groups::ALL_GROUPS};
12
13#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq, PartialOrd, Ord)]
14#[serde(rename_all = "lowercase")]
15pub enum LintLevel {
16 Hint,
17 #[default]
18 Warning,
19 Error,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
23#[serde(default)]
24pub struct Config {
25 pub groups: HashMap<String, LintLevel>,
26 pub rules: HashMap<String, LintLevel>,
27 pub ignored: HashSet<String>,
28 pub sequential: bool,
29}
30
31impl Config {
32 pub fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
38 toml::from_str(toml_str).map_err(|source| LintError::Config { source })
39 }
40 pub fn load_from_file(path: &Path) -> Result<Self, LintError> {
47 let content = fs::read_to_string(path).map_err(|source| LintError::Io {
48 path: path.to_path_buf(),
49 source,
50 })?;
51 Self::load_from_str(&content)
52 }
53
54 #[must_use]
56 pub fn load(config_path: Option<&PathBuf>) -> Self {
57 config_path
58 .cloned()
59 .or_else(find_config_file)
60 .map_or_else(Self::default, |path| {
61 Self::load_from_file(&path).unwrap_or_else(|e| {
62 eprintln!("Error loading config from {}: {e}", path.display());
63 process::exit(2);
64 })
65 })
66 }
67
68 #[must_use]
70 pub fn get_lint_level(&self, rule: &dyn Rule) -> Option<LintLevel> {
71 let rule_id = rule.id();
72
73 if self.ignored.contains(rule_id) {
74 return None;
75 }
76
77 if let Some(level) = self.rules.get(rule_id) {
78 log::debug!(
79 "Rule '{rule_id}' has individual level '{level:?}' in config, overriding set \
80 levels"
81 );
82 return Some(*level);
83 }
84
85 for (set_name, level) in &self.groups {
86 let Some(lint_set) = ALL_GROUPS.iter().find(|set| set.name == set_name.as_str()) else {
87 continue;
88 };
89
90 if !lint_set.rules.iter().any(|r| r.id() == rule_id) {
91 continue;
92 }
93
94 log::debug!("Rule '{rule_id}' found in set '{set_name}' with level {level:?}");
95 return Some(*level);
96 }
97
98 Some(rule.level())
99 }
100}
101
102#[must_use]
105pub fn find_config_file_from(start_dir: &Path) -> Option<PathBuf> {
106 let mut current_dir = start_dir.to_path_buf();
107
108 loop {
109 let config_path = current_dir.join(".nu-lint.toml");
110 if config_path.exists() && config_path.is_file() {
111 return Some(config_path);
112 }
113
114 if !current_dir.pop() {
115 break;
116 }
117 }
118
119 None
120}
121
122#[must_use]
124pub fn find_config_file() -> Option<PathBuf> {
125 current_dir()
126 .ok()
127 .and_then(|dir| find_config_file_from(&dir))
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::rules::ALL_RULES;
134
135 #[test]
136 fn test_load_config_simple_str() {
137 let toml_str = r#"
138 [rules]
139 snake_case_variables = "error"
140 "#;
141
142 let config = Config::load_from_str(toml_str).unwrap();
143 assert_eq!(
144 config.rules.get("snake_case_variables"),
145 Some(&LintLevel::Error)
146 );
147 }
148
149 #[test]
150 fn test_load_config_simple_str_set() {
151 let toml_str = r#"
152 ignored = [ "snake_case_variables" ]
153 [groups]
154 naming = "error"
155 "#;
156
157 let config = Config::load_from_str(toml_str).unwrap();
158 let found_set_level = config.groups.iter().find(|(k, _)| **k == "naming");
159 assert!(matches!(found_set_level, Some((_, LintLevel::Error))));
160 let ignored_rule = ALL_RULES
161 .iter()
162 .find(|r| r.id() == "snake_case_variables")
163 .unwrap();
164 assert_eq!(config.get_lint_level(*ignored_rule), None);
165 }
166}