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 fixable: Option<&str>,
18 unfixable: Option<&str>,
19) -> Vec<ConfigValidationWarning> {
20 let mut warnings = Vec::new();
21 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
22
23 let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
24 for name in input.split(',').map(str::trim).filter(|s| !s.is_empty()) {
25 if name.eq_ignore_ascii_case("all") {
27 continue;
28 }
29 if resolve_rule_name_alias(name).is_none() {
30 let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
31 let formatted = if suggestion.starts_with("MD") {
32 suggestion
33 } else {
34 suggestion.to_lowercase()
35 };
36 format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
37 } else {
38 format!("Unknown rule in {flag_name}: {name}")
39 };
40 warnings.push(ConfigValidationWarning {
41 message,
42 rule: Some(name.to_string()),
43 key: None,
44 });
45 }
46 }
47 };
48
49 if let Some(e) = enable {
50 validate_list(e, "--enable", &mut warnings);
51 }
52 if let Some(d) = disable {
53 validate_list(d, "--disable", &mut warnings);
54 }
55 if let Some(ee) = extend_enable {
56 validate_list(ee, "--extend-enable", &mut warnings);
57 }
58 if let Some(ed) = extend_disable {
59 validate_list(ed, "--extend-disable", &mut warnings);
60 }
61 if let Some(f) = fixable {
62 validate_list(f, "--fixable", &mut warnings);
63 }
64 if let Some(u) = unfixable {
65 validate_list(u, "--unfixable", &mut warnings);
66 }
67
68 warnings
69}
70
71pub(super) fn validate_config_sourced_internal<S>(
74 sourced: &SourcedConfig<S>,
75 registry: &RuleRegistry,
76) -> Vec<ConfigValidationWarning> {
77 let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
78
79 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
81
82 for rule_name in &sourced.global.enable.value {
83 if !is_valid_rule_name(rule_name) {
84 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
85 let formatted = if suggestion.starts_with("MD") {
86 suggestion
87 } else {
88 suggestion.to_lowercase()
89 };
90 format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
91 } else {
92 format!("Unknown rule in global.enable: {rule_name}")
93 };
94 warnings.push(ConfigValidationWarning {
95 message,
96 rule: Some(rule_name.clone()),
97 key: None,
98 });
99 }
100 }
101
102 for rule_name in &sourced.global.disable.value {
103 if !is_valid_rule_name(rule_name) {
104 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
105 let formatted = if suggestion.starts_with("MD") {
106 suggestion
107 } else {
108 suggestion.to_lowercase()
109 };
110 format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
111 } else {
112 format!("Unknown rule in global.disable: {rule_name}")
113 };
114 warnings.push(ConfigValidationWarning {
115 message,
116 rule: Some(rule_name.clone()),
117 key: None,
118 });
119 }
120 }
121
122 for rule_name in &sourced.global.extend_enable.value {
123 if !is_valid_rule_name(rule_name) {
124 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
125 let formatted = if suggestion.starts_with("MD") {
126 suggestion
127 } else {
128 suggestion.to_lowercase()
129 };
130 format!("Unknown rule in global.extend-enable: {rule_name} (did you mean: {formatted}?)")
131 } else {
132 format!("Unknown rule in global.extend-enable: {rule_name}")
133 };
134 warnings.push(ConfigValidationWarning {
135 message,
136 rule: Some(rule_name.clone()),
137 key: None,
138 });
139 }
140 }
141
142 for rule_name in &sourced.global.extend_disable.value {
143 if !is_valid_rule_name(rule_name) {
144 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
145 let formatted = if suggestion.starts_with("MD") {
146 suggestion
147 } else {
148 suggestion.to_lowercase()
149 };
150 format!("Unknown rule in global.extend-disable: {rule_name} (did you mean: {formatted}?)")
151 } else {
152 format!("Unknown rule in global.extend-disable: {rule_name}")
153 };
154 warnings.push(ConfigValidationWarning {
155 message,
156 rule: Some(rule_name.clone()),
157 key: None,
158 });
159 }
160 }
161
162 for rule_name in &sourced.global.fixable.value {
163 if !is_valid_rule_name(rule_name) {
164 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
165 let formatted = if suggestion.starts_with("MD") {
166 suggestion
167 } else {
168 suggestion.to_lowercase()
169 };
170 format!("Unknown rule in global.fixable: {rule_name} (did you mean: {formatted}?)")
171 } else {
172 format!("Unknown rule in global.fixable: {rule_name}")
173 };
174 warnings.push(ConfigValidationWarning {
175 message,
176 rule: Some(rule_name.clone()),
177 key: None,
178 });
179 }
180 }
181
182 for rule_name in &sourced.global.unfixable.value {
183 if !is_valid_rule_name(rule_name) {
184 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
185 let formatted = if suggestion.starts_with("MD") {
186 suggestion
187 } else {
188 suggestion.to_lowercase()
189 };
190 format!("Unknown rule in global.unfixable: {rule_name} (did you mean: {formatted}?)")
191 } else {
192 format!("Unknown rule in global.unfixable: {rule_name}")
193 };
194 warnings.push(ConfigValidationWarning {
195 message,
196 rule: Some(rule_name.clone()),
197 key: None,
198 });
199 }
200 }
201
202 warnings
203}
204
205fn validate_config_sourced_impl(
207 rules: &BTreeMap<String, SourcedRuleConfig>,
208 unknown_keys: &[(String, String, Option<String>)],
209 registry: &RuleRegistry,
210) -> Vec<ConfigValidationWarning> {
211 let mut warnings = Vec::new();
212 let known_rules = registry.rule_names();
213 for rule in rules.keys() {
215 if !known_rules.contains(rule) {
216 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
218 let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
219 let formatted_suggestion = if suggestion.starts_with("MD") {
221 suggestion
222 } else {
223 suggestion.to_lowercase()
224 };
225 format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
226 } else {
227 format!("Unknown rule in config: {rule}")
228 };
229 warnings.push(ConfigValidationWarning {
230 message,
231 rule: Some(rule.clone()),
232 key: None,
233 });
234 }
235 }
236 for (rule, rule_cfg) in rules {
238 if let Some(valid_keys) = registry.config_keys_for(rule) {
239 for key in rule_cfg.values.keys() {
240 if !valid_keys.contains(key) {
241 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
242 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
243 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
244 } else {
245 format!("Unknown option for rule {rule}: {key}")
246 };
247 warnings.push(ConfigValidationWarning {
248 message,
249 rule: Some(rule.clone()),
250 key: Some(key.clone()),
251 });
252 } else {
253 if let Some(expected) = registry.expected_value_for(rule, key) {
255 let actual = &rule_cfg.values[key].value;
256 if !toml_value_type_matches(expected, actual) {
257 warnings.push(ConfigValidationWarning {
258 message: format!(
259 "Type mismatch for {}.{}: expected {}, got {}",
260 rule,
261 key,
262 toml_type_name(expected),
263 toml_type_name(actual)
264 ),
265 rule: Some(rule.clone()),
266 key: Some(key.clone()),
267 });
268 }
269 }
270 }
271 }
272 }
273 }
274 let known_global_keys: Vec<String> = super::global_keys::GLOBAL_VALUE_KEYS
279 .iter()
280 .map(|k| (*k).to_string())
281 .chain(
282 ["per-file-ignores", "per-file-flavor", "extends"]
283 .into_iter()
284 .map(str::to_string),
285 )
286 .collect();
287
288 for (section, key, display_name) in unknown_keys {
289 let display_path = display_name.as_ref();
291
292 if section.contains("[global]") || section.contains("[tool.rumdl]") {
293 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
294 if let Some(path) = display_path {
295 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
296 } else {
297 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
298 }
299 } else if let Some(path) = display_path {
300 format!("Unknown global option in {path}: {key}")
301 } else {
302 format!("Unknown global option: {key}")
303 };
304 warnings.push(ConfigValidationWarning {
305 message,
306 rule: None,
307 key: Some(key.clone()),
308 });
309 } else if !key.is_empty() {
310 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
314 let message = if let Some(path) = display_path {
315 format!("Unknown option for rule {rule_name} in {path}: {key}")
316 } else {
317 format!("Unknown option for rule {rule_name}: {key}")
318 };
319 warnings.push(ConfigValidationWarning {
320 message,
321 rule: Some(rule_name.to_string()),
322 key: Some(key.clone()),
323 });
324 } else {
325 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
327 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
328 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
329 let formatted_suggestion = if suggestion.starts_with("MD") {
331 suggestion
332 } else {
333 suggestion.to_lowercase()
334 };
335 if let Some(path) = display_path {
336 format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
337 } else {
338 format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
339 }
340 } else if let Some(path) = display_path {
341 format!("Unknown rule in {path}: {rule_name}")
342 } else {
343 format!("Unknown rule in config: {rule_name}")
344 };
345 warnings.push(ConfigValidationWarning {
346 message,
347 rule: None,
348 key: None,
349 });
350 }
351 }
352 warnings
353}
354
355pub(super) fn to_relative_display_path(path: &str) -> String {
361 let file_path = Path::new(path);
362
363 if let Ok(cwd) = std::env::current_dir() {
365 if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
367 && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
368 {
369 return normalize_separators(relative.to_string_lossy().to_string());
370 }
371
372 if let Ok(relative) = file_path.strip_prefix(&cwd) {
374 return normalize_separators(relative.to_string_lossy().to_string());
375 }
376 }
377
378 normalize_separators(path.to_string())
380}
381
382fn normalize_separators(path: String) -> String {
388 if cfg!(windows) { path.replace('\\', "/") } else { path }
389}
390
391pub fn validate_config_sourced(
397 sourced: &SourcedConfig<ConfigLoaded>,
398 registry: &RuleRegistry,
399) -> Vec<ConfigValidationWarning> {
400 validate_config_sourced_internal(sourced, registry)
401}
402
403pub fn validate_config_sourced_validated(
407 sourced: &SourcedConfig<ConfigValidated>,
408 _registry: &RuleRegistry,
409) -> Vec<ConfigValidationWarning> {
410 sourced.validation_warnings.clone()
411}
412
413fn toml_type_name(val: &toml::Value) -> &'static str {
414 match val {
415 toml::Value::String(_) => "string",
416 toml::Value::Integer(_) => "integer",
417 toml::Value::Float(_) => "float",
418 toml::Value::Boolean(_) => "boolean",
419 toml::Value::Array(_) => "array",
420 toml::Value::Table(_) => "table",
421 toml::Value::Datetime(_) => "datetime",
422 }
423}
424
425fn levenshtein_distance(s1: &str, s2: &str) -> usize {
427 let len1 = s1.len();
428 let len2 = s2.len();
429
430 if len1 == 0 {
431 return len2;
432 }
433 if len2 == 0 {
434 return len1;
435 }
436
437 let s1_chars: Vec<char> = s1.chars().collect();
438 let s2_chars: Vec<char> = s2.chars().collect();
439
440 let mut prev_row: Vec<usize> = (0..=len2).collect();
441 let mut curr_row = vec![0; len2 + 1];
442
443 for i in 1..=len1 {
444 curr_row[0] = i;
445 for j in 1..=len2 {
446 let cost = usize::from(s1_chars[i - 1] != s2_chars[j - 1]);
447 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
451 std::mem::swap(&mut prev_row, &mut curr_row);
452 }
453
454 prev_row[len2]
455}
456
457pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
459 let unknown_lower = unknown.to_lowercase();
460 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
463
464 for valid in valid_keys {
465 let valid_lower = valid.to_lowercase();
466 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
467
468 if distance <= max_distance {
469 if let Some((_, best_dist)) = &best_match {
470 if distance < *best_dist {
471 best_match = Some((valid.clone(), distance));
472 }
473 } else {
474 best_match = Some((valid.clone(), distance));
475 }
476 }
477 }
478
479 best_match.map(|(key, _)| key)
480}
481
482fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
483 use toml::Value::{Array, Boolean, Datetime, Float, Integer, String, Table};
484 match (expected, actual) {
485 (String(_), String(_)) => true,
486 (Integer(_), Integer(_)) => true,
487 (Float(_), Float(_)) => true,
488 (Boolean(_), Boolean(_)) => true,
489 (Array(_), Array(_)) => true,
490 (Table(_), Table(_)) => true,
491 (Datetime(_), Datetime(_)) => true,
492 (Float(_), Integer(_)) => true,
494 _ => false,
495 }
496}