Skip to main content

wowlab_tidy/infra/
config.rs

1// #t(file: rust_alloc_in_loop) config validation error messages
2// #t(file: rust_default_hasher, rust_missing_capacity, rust_vec_string_field) cold config-load path; hashing, preallocation, and boxed slices are not bottlenecks
3
4use 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/// Resolved rule configuration (glob set names expanded to patterns).
31#[derive(Clone, Debug, Serialize)]
32// #t(rust_similar_structs) resolved patterns and raw glob-set names are distinct configuration states
33pub(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/// Top-level tidy configuration loaded from `tidy.toml`.
41#[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/// Failure to load or resolve tidy configuration.
51#[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    /// Load, parse, and resolve a config file (expands glob set references).
85    ///
86    /// # Errors
87    ///
88    /// Returns an error when the file cannot be read, parsed, or resolved against its glob sets.
89    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    // #t(rust_getter_prefix) keyed param lookup mirrors the toml::Value getter family
116    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 &param.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    /// Validate config against registered rules, returning `(errors, warnings)`.
139    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                // #t(rust_alloc_in_loop) error messages are rare
150                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                    // #t(rust_alloc_in_loop) error messages are rare
166                    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                // #t(rust_alloc_in_loop) error messages are rare
177                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    /// Serializes this configuration with the standard explanatory header.
255    ///
256    /// # Panics
257    ///
258    /// Panics if the resolved configuration cannot be serialized as TOML.
259    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    /// Merge resolved configuration with registered rule metadata.
277    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    // #t(rust_getter_prefix) keyed param lookup mirrors the toml::Value getter family
311    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    // #t(rust_getter_prefix) keyed param lookup mirrors the toml::Value getter family
325    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    // #t(rust_getter_prefix) keyed param lookup mirrors the toml::Value getter family
330    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                                // #t(rust_clone_in_loop) need owned String for ignore vec, patterns come from a different collection
351                                ignore.push(p.clone());
352                            }
353                        }
354                    }
355                    None => {
356                        // #t(rust_alloc_in_loop) error messages are rare, clarity over performance
357                        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                // #t(rust_clone_in_loop) need owned key for BTreeMap insert
367                rule_name.clone(),
368                RuleConfig {
369                    enabled: raw_rule.enabled,
370                    ignore,
371                    // #t(rust_clone_in_loop) need owned BTreeMap for each resolved rule
372                    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;