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(|s| s.to_string()).collect();
22
23 let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
24 for name in input.split(',').map(|s| s.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(|s| s.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(|s| s.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![
276 "enable".to_string(),
277 "disable".to_string(),
278 "extend-enable".to_string(),
279 "extend-disable".to_string(),
280 "include".to_string(),
281 "exclude".to_string(),
282 "respect-gitignore".to_string(),
283 "line-length".to_string(),
284 "fixable".to_string(),
285 "unfixable".to_string(),
286 "flavor".to_string(),
287 "force-exclude".to_string(),
288 "output-format".to_string(),
289 "cache-dir".to_string(),
290 "cache".to_string(),
291 ];
292
293 for (section, key, file_path) in unknown_keys {
294 let display_path = file_path.as_ref().map(|p| to_relative_display_path(p));
296
297 if section.contains("[global]") || section.contains("[tool.rumdl]") {
298 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
299 if let Some(ref path) = display_path {
300 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
301 } else {
302 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
303 }
304 } else if let Some(ref path) = display_path {
305 format!("Unknown global option in {path}: {key}")
306 } else {
307 format!("Unknown global option: {key}")
308 };
309 warnings.push(ConfigValidationWarning {
310 message,
311 rule: None,
312 key: Some(key.clone()),
313 });
314 } else if !key.is_empty() {
315 continue;
317 } else {
318 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
320 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
321 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
322 let formatted_suggestion = if suggestion.starts_with("MD") {
324 suggestion
325 } else {
326 suggestion.to_lowercase()
327 };
328 if let Some(ref path) = display_path {
329 format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
330 } else {
331 format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
332 }
333 } else if let Some(ref path) = display_path {
334 format!("Unknown rule in {path}: {rule_name}")
335 } else {
336 format!("Unknown rule in config: {rule_name}")
337 };
338 warnings.push(ConfigValidationWarning {
339 message,
340 rule: None,
341 key: None,
342 });
343 }
344 }
345 warnings
346}
347
348pub(super) fn to_relative_display_path(path: &str) -> String {
353 let file_path = Path::new(path);
354
355 if let Ok(cwd) = std::env::current_dir() {
357 if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
359 && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
360 {
361 return relative.to_string_lossy().to_string();
362 }
363
364 if let Ok(relative) = file_path.strip_prefix(&cwd) {
366 return relative.to_string_lossy().to_string();
367 }
368 }
369
370 path.to_string()
372}
373
374pub fn validate_config_sourced(
380 sourced: &SourcedConfig<ConfigLoaded>,
381 registry: &RuleRegistry,
382) -> Vec<ConfigValidationWarning> {
383 validate_config_sourced_internal(sourced, registry)
384}
385
386pub fn validate_config_sourced_validated(
390 sourced: &SourcedConfig<ConfigValidated>,
391 _registry: &RuleRegistry,
392) -> Vec<ConfigValidationWarning> {
393 sourced.validation_warnings.clone()
394}
395
396fn toml_type_name(val: &toml::Value) -> &'static str {
397 match val {
398 toml::Value::String(_) => "string",
399 toml::Value::Integer(_) => "integer",
400 toml::Value::Float(_) => "float",
401 toml::Value::Boolean(_) => "boolean",
402 toml::Value::Array(_) => "array",
403 toml::Value::Table(_) => "table",
404 toml::Value::Datetime(_) => "datetime",
405 }
406}
407
408fn levenshtein_distance(s1: &str, s2: &str) -> usize {
410 let len1 = s1.len();
411 let len2 = s2.len();
412
413 if len1 == 0 {
414 return len2;
415 }
416 if len2 == 0 {
417 return len1;
418 }
419
420 let s1_chars: Vec<char> = s1.chars().collect();
421 let s2_chars: Vec<char> = s2.chars().collect();
422
423 let mut prev_row: Vec<usize> = (0..=len2).collect();
424 let mut curr_row = vec![0; len2 + 1];
425
426 for i in 1..=len1 {
427 curr_row[0] = i;
428 for j in 1..=len2 {
429 let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
430 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
434 std::mem::swap(&mut prev_row, &mut curr_row);
435 }
436
437 prev_row[len2]
438}
439
440pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
442 let unknown_lower = unknown.to_lowercase();
443 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
446
447 for valid in valid_keys {
448 let valid_lower = valid.to_lowercase();
449 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
450
451 if distance <= max_distance {
452 if let Some((_, best_dist)) = &best_match {
453 if distance < *best_dist {
454 best_match = Some((valid.clone(), distance));
455 }
456 } else {
457 best_match = Some((valid.clone(), distance));
458 }
459 }
460 }
461
462 best_match.map(|(key, _)| key)
463}
464
465fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
466 use toml::Value::*;
467 match (expected, actual) {
468 (String(_), String(_)) => true,
469 (Integer(_), Integer(_)) => true,
470 (Float(_), Float(_)) => true,
471 (Boolean(_), Boolean(_)) => true,
472 (Array(_), Array(_)) => true,
473 (Table(_), Table(_)) => true,
474 (Datetime(_), Datetime(_)) => true,
475 (Float(_), Integer(_)) => true,
477 _ => false,
478 }
479}