1use super::flavor::{ConfigLoaded, ConfigValidated};
2use super::registry::{RULE_ALIAS_MAP, RuleRegistry, is_valid_rule_name, resolve_rule_name_alias};
3use super::source_tracking::{ConfigValidationWarning, SourcedConfig, SourcedRuleConfig};
4use std::collections::BTreeMap;
5use std::path::Path;
6
7pub fn validate_cli_rule_names(
13 enable: Option<&str>,
14 disable: Option<&str>,
15 extend_enable: Option<&str>,
16 extend_disable: Option<&str>,
17) -> Vec<ConfigValidationWarning> {
18 let mut warnings = Vec::new();
19 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
20
21 let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
22 for name in input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
23 if name.eq_ignore_ascii_case("all") {
25 continue;
26 }
27 if resolve_rule_name_alias(name).is_none() {
28 let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
29 let formatted = if suggestion.starts_with("MD") {
30 suggestion
31 } else {
32 suggestion.to_lowercase()
33 };
34 format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
35 } else {
36 format!("Unknown rule in {flag_name}: {name}")
37 };
38 warnings.push(ConfigValidationWarning {
39 message,
40 rule: Some(name.to_string()),
41 key: None,
42 });
43 }
44 }
45 };
46
47 if let Some(e) = enable {
48 validate_list(e, "--enable", &mut warnings);
49 }
50 if let Some(d) = disable {
51 validate_list(d, "--disable", &mut warnings);
52 }
53 if let Some(ee) = extend_enable {
54 validate_list(ee, "--extend-enable", &mut warnings);
55 }
56 if let Some(ed) = extend_disable {
57 validate_list(ed, "--extend-disable", &mut warnings);
58 }
59
60 warnings
61}
62
63pub(super) fn validate_config_sourced_internal<S>(
66 sourced: &SourcedConfig<S>,
67 registry: &RuleRegistry,
68) -> Vec<ConfigValidationWarning> {
69 let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
70
71 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
73
74 for rule_name in &sourced.global.enable.value {
75 if !is_valid_rule_name(rule_name) {
76 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
77 let formatted = if suggestion.starts_with("MD") {
78 suggestion
79 } else {
80 suggestion.to_lowercase()
81 };
82 format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
83 } else {
84 format!("Unknown rule in global.enable: {rule_name}")
85 };
86 warnings.push(ConfigValidationWarning {
87 message,
88 rule: Some(rule_name.clone()),
89 key: None,
90 });
91 }
92 }
93
94 for rule_name in &sourced.global.disable.value {
95 if !is_valid_rule_name(rule_name) {
96 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
97 let formatted = if suggestion.starts_with("MD") {
98 suggestion
99 } else {
100 suggestion.to_lowercase()
101 };
102 format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
103 } else {
104 format!("Unknown rule in global.disable: {rule_name}")
105 };
106 warnings.push(ConfigValidationWarning {
107 message,
108 rule: Some(rule_name.clone()),
109 key: None,
110 });
111 }
112 }
113
114 for rule_name in &sourced.global.extend_enable.value {
115 if !is_valid_rule_name(rule_name) {
116 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
117 let formatted = if suggestion.starts_with("MD") {
118 suggestion
119 } else {
120 suggestion.to_lowercase()
121 };
122 format!("Unknown rule in global.extend-enable: {rule_name} (did you mean: {formatted}?)")
123 } else {
124 format!("Unknown rule in global.extend-enable: {rule_name}")
125 };
126 warnings.push(ConfigValidationWarning {
127 message,
128 rule: Some(rule_name.clone()),
129 key: None,
130 });
131 }
132 }
133
134 for rule_name in &sourced.global.extend_disable.value {
135 if !is_valid_rule_name(rule_name) {
136 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
137 let formatted = if suggestion.starts_with("MD") {
138 suggestion
139 } else {
140 suggestion.to_lowercase()
141 };
142 format!("Unknown rule in global.extend-disable: {rule_name} (did you mean: {formatted}?)")
143 } else {
144 format!("Unknown rule in global.extend-disable: {rule_name}")
145 };
146 warnings.push(ConfigValidationWarning {
147 message,
148 rule: Some(rule_name.clone()),
149 key: None,
150 });
151 }
152 }
153
154 warnings
155}
156
157fn validate_config_sourced_impl(
159 rules: &BTreeMap<String, SourcedRuleConfig>,
160 unknown_keys: &[(String, String, Option<String>)],
161 registry: &RuleRegistry,
162) -> Vec<ConfigValidationWarning> {
163 let mut warnings = Vec::new();
164 let known_rules = registry.rule_names();
165 for rule in rules.keys() {
167 if !known_rules.contains(rule) {
168 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
170 let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
171 let formatted_suggestion = if suggestion.starts_with("MD") {
173 suggestion
174 } else {
175 suggestion.to_lowercase()
176 };
177 format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
178 } else {
179 format!("Unknown rule in config: {rule}")
180 };
181 warnings.push(ConfigValidationWarning {
182 message,
183 rule: Some(rule.clone()),
184 key: None,
185 });
186 }
187 }
188 for (rule, rule_cfg) in rules {
190 if let Some(valid_keys) = registry.config_keys_for(rule) {
191 for key in rule_cfg.values.keys() {
192 if !valid_keys.contains(key) {
193 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
194 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
195 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
196 } else {
197 format!("Unknown option for rule {rule}: {key}")
198 };
199 warnings.push(ConfigValidationWarning {
200 message,
201 rule: Some(rule.clone()),
202 key: Some(key.clone()),
203 });
204 } else {
205 if let Some(expected) = registry.expected_value_for(rule, key) {
207 let actual = &rule_cfg.values[key].value;
208 if !toml_value_type_matches(expected, actual) {
209 warnings.push(ConfigValidationWarning {
210 message: format!(
211 "Type mismatch for {}.{}: expected {}, got {}",
212 rule,
213 key,
214 toml_type_name(expected),
215 toml_type_name(actual)
216 ),
217 rule: Some(rule.clone()),
218 key: Some(key.clone()),
219 });
220 }
221 }
222 }
223 }
224 }
225 }
226 let known_global_keys = vec![
228 "enable".to_string(),
229 "disable".to_string(),
230 "extend-enable".to_string(),
231 "extend-disable".to_string(),
232 "include".to_string(),
233 "exclude".to_string(),
234 "respect-gitignore".to_string(),
235 "line-length".to_string(),
236 "fixable".to_string(),
237 "unfixable".to_string(),
238 "flavor".to_string(),
239 "force-exclude".to_string(),
240 "output-format".to_string(),
241 "cache-dir".to_string(),
242 "cache".to_string(),
243 ];
244
245 for (section, key, file_path) in unknown_keys {
246 let display_path = file_path.as_ref().map(|p| to_relative_display_path(p));
248
249 if section.contains("[global]") || section.contains("[tool.rumdl]") {
250 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
251 if let Some(ref path) = display_path {
252 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
253 } else {
254 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
255 }
256 } else if let Some(ref path) = display_path {
257 format!("Unknown global option in {path}: {key}")
258 } else {
259 format!("Unknown global option: {key}")
260 };
261 warnings.push(ConfigValidationWarning {
262 message,
263 rule: None,
264 key: Some(key.clone()),
265 });
266 } else if !key.is_empty() {
267 continue;
269 } else {
270 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
272 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
273 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
274 let formatted_suggestion = if suggestion.starts_with("MD") {
276 suggestion
277 } else {
278 suggestion.to_lowercase()
279 };
280 if let Some(ref path) = display_path {
281 format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
282 } else {
283 format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
284 }
285 } else if let Some(ref path) = display_path {
286 format!("Unknown rule in {path}: {rule_name}")
287 } else {
288 format!("Unknown rule in config: {rule_name}")
289 };
290 warnings.push(ConfigValidationWarning {
291 message,
292 rule: None,
293 key: None,
294 });
295 }
296 }
297 warnings
298}
299
300pub(super) fn to_relative_display_path(path: &str) -> String {
305 let file_path = Path::new(path);
306
307 if let Ok(cwd) = std::env::current_dir() {
309 if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
311 && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
312 {
313 return relative.to_string_lossy().to_string();
314 }
315
316 if let Ok(relative) = file_path.strip_prefix(&cwd) {
318 return relative.to_string_lossy().to_string();
319 }
320 }
321
322 path.to_string()
324}
325
326pub fn validate_config_sourced(
332 sourced: &SourcedConfig<ConfigLoaded>,
333 registry: &RuleRegistry,
334) -> Vec<ConfigValidationWarning> {
335 validate_config_sourced_internal(sourced, registry)
336}
337
338pub fn validate_config_sourced_validated(
342 sourced: &SourcedConfig<ConfigValidated>,
343 _registry: &RuleRegistry,
344) -> Vec<ConfigValidationWarning> {
345 sourced.validation_warnings.clone()
346}
347
348fn toml_type_name(val: &toml::Value) -> &'static str {
349 match val {
350 toml::Value::String(_) => "string",
351 toml::Value::Integer(_) => "integer",
352 toml::Value::Float(_) => "float",
353 toml::Value::Boolean(_) => "boolean",
354 toml::Value::Array(_) => "array",
355 toml::Value::Table(_) => "table",
356 toml::Value::Datetime(_) => "datetime",
357 }
358}
359
360fn levenshtein_distance(s1: &str, s2: &str) -> usize {
362 let len1 = s1.len();
363 let len2 = s2.len();
364
365 if len1 == 0 {
366 return len2;
367 }
368 if len2 == 0 {
369 return len1;
370 }
371
372 let s1_chars: Vec<char> = s1.chars().collect();
373 let s2_chars: Vec<char> = s2.chars().collect();
374
375 let mut prev_row: Vec<usize> = (0..=len2).collect();
376 let mut curr_row = vec![0; len2 + 1];
377
378 for i in 1..=len1 {
379 curr_row[0] = i;
380 for j in 1..=len2 {
381 let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
382 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
386 std::mem::swap(&mut prev_row, &mut curr_row);
387 }
388
389 prev_row[len2]
390}
391
392pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
394 let unknown_lower = unknown.to_lowercase();
395 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
398
399 for valid in valid_keys {
400 let valid_lower = valid.to_lowercase();
401 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
402
403 if distance <= max_distance {
404 if let Some((_, best_dist)) = &best_match {
405 if distance < *best_dist {
406 best_match = Some((valid.clone(), distance));
407 }
408 } else {
409 best_match = Some((valid.clone(), distance));
410 }
411 }
412 }
413
414 best_match.map(|(key, _)| key)
415}
416
417fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
418 use toml::Value::*;
419 match (expected, actual) {
420 (String(_), String(_)) => true,
421 (Integer(_), Integer(_)) => true,
422 (Float(_), Float(_)) => true,
423 (Boolean(_), Boolean(_)) => true,
424 (Array(_), Array(_)) => true,
425 (Table(_), Table(_)) => true,
426 (Datetime(_), Datetime(_)) => true,
427 (Float(_), Integer(_)) => true,
429 _ => false,
430 }
431}