1use std::collections::{BTreeMap, HashSet};
5
6use serde::{Deserialize, Serialize};
7use wowlab_fs::{
8 file,
9 path::{Path, PathBuf},
10};
11
12use crate::{ParamDefault, ParamType, RuleParam};
13
14#[derive(Debug, Deserialize, Serialize)]
15struct RawRuleConfig {
16 enabled: bool,
17 #[serde(default)]
18 ignore: Vec<String>,
19 #[serde(default, flatten)]
20 params: BTreeMap<String, toml::Value>,
21}
22
23#[derive(Debug, Deserialize, Serialize)]
24struct RawConfig {
25 #[serde(default)]
26 glob_sets: BTreeMap<String, Vec<String>>,
27 rules: BTreeMap<String, RawRuleConfig>,
28}
29
30#[derive(Clone, Debug, Serialize)]
32pub(crate) struct RuleConfig {
34 pub(crate) enabled: bool,
35 pub(crate) ignore: Vec<String>,
36 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
37 pub(crate) params: BTreeMap<String, toml::Value>,
38}
39
40#[derive(Debug, Serialize)]
42pub struct Config {
43 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
44 glob_sets: BTreeMap<String, Vec<String>>,
45 pub(crate) rules: BTreeMap<String, RuleConfig>,
46 #[serde(skip)]
47 workspace: super::workspace::WorkspaceContext,
48}
49
50#[derive(Debug, thiserror::Error)]
52#[error("{kind}")]
53pub struct ConfigError {
54 #[source]
55 kind: ConfigErrorKind,
56}
57
58#[derive(Debug, thiserror::Error)]
59enum ConfigErrorKind {
60 #[error("failed to read {}: file does not exist", path.display())]
61 Missing { path: PathBuf },
62 #[error("{source}")]
63 Read {
64 #[source]
65 source: wowlab_fs::error::Error,
66 },
67 #[error("failed to parse {}: {source}", path.display())]
68 Parse {
69 path: PathBuf,
70 #[source]
71 source: toml::de::Error,
72 },
73 #[error("{message}")]
74 Resolve { message: String },
75}
76
77impl ConfigError {
78 const fn new(kind: ConfigErrorKind) -> Self {
79 Self { kind }
80 }
81}
82
83impl Config {
84 pub fn load(path: &Path) -> Result<Config, ConfigError> {
90 let contents = file::read_text_if_exists(path)
91 .map_err(|source| ConfigError::new(ConfigErrorKind::Read { source }))?
92 .ok_or_else(|| {
93 ConfigError::new(ConfigErrorKind::Missing {
94 path: path.to_path_buf(),
95 })
96 })?;
97 let raw: RawConfig = toml::from_str(&contents).map_err(|source| {
98 ConfigError::new(ConfigErrorKind::Parse {
99 path: path.to_path_buf(),
100 source,
101 })
102 })?;
103
104 Self::resolve(raw)
105 }
106
107 pub fn is_enabled(&self, name: &str) -> bool {
108 self.rules.get(name).is_some_and(|r| r.enabled)
109 }
110
111 pub fn ignore_patterns(&self, name: &str) -> &[String] {
112 self.rules.get(name).map_or(&[], |r| &r.ignore)
113 }
114
115 pub fn get_str_array(&self, rule: &str, param: &RuleParam) -> Vec<String> {
117 let configured = self
118 .rules
119 .get(rule)
120 .and_then(|r| r.params.get(param.name))
121 .and_then(|v| v.as_array());
122
123 configured.map_or_else(
124 || match ¶m.default {
125 ParamDefault::StringArray(d) => d.iter().map(ToString::to_string).collect(),
126 ParamDefault::Int(_) => {
127 unreachable!("get_str_array is only called for StringArray params")
128 }
129 },
130 |arr| {
131 arr.iter()
132 .filter_map(|v| v.as_str().map(String::from))
133 .collect()
134 },
135 )
136 }
137
138 pub fn validate(
140 &self,
141 registered: &[(&str, &'static [RuleParam])],
142 ) -> (Vec<String>, Vec<String>) {
143 let mut errors = Vec::new();
144 let mut warnings = Vec::new();
145 let registered_set: HashSet<&str> = registered.iter().map(|(name, _)| *name).collect();
146
147 for (name, params) in registered {
148 let Some(rule_cfg) = self.rules.get(*name) else {
149 warnings.push(format!(
151 "rule `{name}` is registered but missing from tidy.toml — \
152 using defaults (run `tidy --init` to regenerate)"
153 ));
154 continue;
155 };
156
157 for p in *params {
158 Self::validate_param(name, p, rule_cfg, &mut errors);
159 }
160
161 let known: HashSet<&str> = params.iter().map(|p| p.name).collect();
162
163 for key in rule_cfg.params.keys() {
164 if !known.contains(key.as_str()) {
165 errors.push(format!(
167 "rule `{name}` has unknown param `{key}` in tidy.toml — \
168 remove it or check for typos"
169 ));
170 }
171 }
172 }
173
174 for name in self.rules.keys() {
175 if !registered_set.contains(name.as_str()) {
176 warnings.push(format!(
178 "tidy.toml contains unknown rule `{name}` — \
179 remove it or check for typos"
180 ));
181 }
182 }
183
184 (errors, warnings)
185 }
186
187 pub fn backfill_defaults(&mut self, registered: &[(&str, &'static [RuleParam])]) {
188 for (name, params) in registered {
189 if !self.rules.contains_key(*name) {
190 let mut param_map = BTreeMap::new();
191
192 for p in *params {
193 let val = match &p.default {
194 ParamDefault::Int(d) => toml::Value::Integer(*d),
195 ParamDefault::StringArray(d) => toml::Value::Array(
196 d.iter()
197 .map(|s| toml::Value::String(s.to_string()))
198 .collect(),
199 ),
200 };
201
202 param_map.insert(p.name.to_string(), val);
203 }
204
205 self.rules.insert(
206 name.to_string(),
207 RuleConfig {
208 enabled: true,
209 ignore: Vec::new(),
210 params: param_map,
211 },
212 );
213 }
214 }
215 }
216
217 #[must_use]
218 pub fn generate_default(registered: &[(&str, &'static [RuleParam])]) -> Config {
219 let mut rules = BTreeMap::new();
220
221 for (name, params) in registered {
222 let mut param_map = BTreeMap::new();
223
224 for p in *params {
225 let val = match &p.default {
226 ParamDefault::Int(d) => toml::Value::Integer(*d),
227 ParamDefault::StringArray(d) => toml::Value::Array(
228 d.iter()
229 .map(|s| toml::Value::String(s.to_string()))
230 .collect(),
231 ),
232 };
233
234 param_map.insert(p.name.to_string(), val);
235 }
236
237 rules.insert(
238 name.to_string(),
239 RuleConfig {
240 enabled: true,
241 ignore: Vec::new(),
242 params: param_map,
243 },
244 );
245 }
246
247 Config {
248 glob_sets: BTreeMap::new(),
249 rules,
250 workspace: super::workspace::WorkspaceContext::default(),
251 }
252 }
253
254 pub fn to_toml_string(&self) -> String {
260 let header = "\
261# tidy.toml — lint rule configuration.
262#
263# Every registered rule MUST appear here. Omitting a rule is an error.
264# Set `enabled = false` to disable a rule without removing its config.
265# Run `tidy --init` to generate a fresh config with all rules.
266#
267# [glob_sets] defines named pattern collections.
268# Rules reference them by name in their `ignore` list.
269
270";
271 let body = toml::to_string_pretty(self).expect("config is serializable");
272
273 format!("{header}{body}")
274 }
275
276 pub fn resolved_rules(&self, rules: &[crate::RuleMeta]) -> Vec<crate::ConfigRule> {
278 rules
279 .iter()
280 .map(|rule| {
281 let configured = self.rules.get(rule.name);
282 let non_empty_params = configured
283 .map(|config| &config.params)
284 .filter(|params| !params.is_empty());
285 let params = non_empty_params.map(|params| {
286 params
287 .iter()
288 .map(|(name, value)| {
289 (name.clone(), crate::ConfigValue::from(toml_to_json(value)))
290 })
291 .collect()
292 });
293
294 crate::ConfigRule {
295 name: rule.name.to_owned(),
296 description: rule.description.to_owned(),
297 severity: rule.severity.as_str().to_owned(),
298 category: rule.kind.as_str().to_owned(),
299 fixable: rule.fixable,
300 enabled: configured.is_some_and(|config| config.enabled),
301 ignore: configured
302 .map(|config| config.ignore.clone())
303 .unwrap_or_default(),
304 params,
305 }
306 })
307 .collect()
308 }
309
310 pub(crate) fn get_i64(&self, rule: &str, param: &RuleParam) -> i64 {
312 self.rules
313 .get(rule)
314 .and_then(|r| r.params.get(param.name))
315 .and_then(toml::Value::as_integer)
316 .unwrap_or_else(|| match param.default {
317 ParamDefault::Int(d) => d,
318 ParamDefault::StringArray(_) => {
319 unreachable!("get_i64 is only called for Int params")
320 }
321 })
322 }
323
324 pub(crate) fn get_u64(&self, rule: &str, param: &RuleParam) -> u64 {
326 u64::try_from(self.get_i64(rule, param)).unwrap_or_default()
327 }
328
329 pub(crate) fn get_usize(&self, rule: &str, param: &RuleParam) -> usize {
331 usize::try_from(self.get_i64(rule, param)).unwrap_or_default()
332 }
333
334 pub(crate) fn workspace(&self) -> &super::workspace::WorkspaceContext {
335 &self.workspace
336 }
337
338 fn resolve(raw: RawConfig) -> Result<Config, ConfigError> {
339 let mut rules = BTreeMap::new();
340 let mut errors = Vec::new();
341
342 for (rule_name, raw_rule) in &raw.rules {
343 let mut ignore = Vec::new();
344
345 for set_name in &raw_rule.ignore {
346 match raw.glob_sets.get(set_name) {
347 Some(patterns) => {
348 for p in patterns {
349 if !ignore.contains(p) {
350 ignore.push(p.clone());
352 }
353 }
354 }
355 None => {
356 errors.push(format!(
358 "rule `{rule_name}` references unknown glob set `{set_name}` — \
359 add it to [glob_sets] or fix the typo"
360 ));
361 }
362 }
363 }
364
365 rules.insert(
366 rule_name.clone(),
368 RuleConfig {
369 enabled: raw_rule.enabled,
370 ignore,
371 params: raw_rule.params.clone(),
373 },
374 );
375 }
376
377 if !errors.is_empty() {
378 return Err(ConfigError::new(ConfigErrorKind::Resolve {
379 message: errors.join("\n"),
380 }));
381 }
382
383 Ok(Config {
384 glob_sets: raw.glob_sets,
385 rules,
386 workspace: super::workspace::WorkspaceContext::default(),
387 })
388 }
389
390 fn validate_param(rule: &str, param: &RuleParam, cfg: &RuleConfig, errors: &mut Vec<String>) {
391 match cfg.params.get(param.name) {
392 Some(val) => {
393 let ok = match param.param_type {
394 ParamType::Int => val.as_integer().is_some(),
395 ParamType::StringArray => val
396 .as_array()
397 .is_some_and(|values| values.iter().all(|value| value.as_str().is_some())),
398 };
399
400 if !ok {
401 let expected = match param.param_type {
402 ParamType::Int => "an integer",
403 ParamType::StringArray => "an array of strings",
404 };
405
406 errors.push(format!(
407 "rule `{rule}` param `{}` must be {expected}",
408 param.name
409 ));
410 }
411 }
412 None => {
413 errors.push(format!(
414 "rule `{rule}` is missing required param `{}` — \
415 add it to tidy.toml or run `tidy --init` to regenerate",
416 param.name
417 ));
418 }
419 }
420 }
421}
422
423fn toml_to_json(value: &toml::Value) -> serde_json::Value {
424 serde_json::to_value(value).unwrap_or(serde_json::Value::Null)
425}
426
427#[cfg(test)]
428mod tests;