1use crate::rule::Rule;
6use crate::rules;
7use lazy_static::lazy_static;
8use log;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::collections::{BTreeSet, HashMap};
12use std::fs;
13use std::io;
14use std::path::Path;
15use toml_edit::DocumentMut;
16
17lazy_static! {
18 static ref MARKDOWNLINT_KEY_MAP: HashMap<&'static str, &'static str> = {
20 let mut m = HashMap::new();
21 m.insert("ul-style", "md004");
24 m.insert("code-block-style", "md046");
25 m.insert("ul-indent", "md007"); m.insert("line-length", "md013"); m
29 };
30}
31
32pub fn normalize_key(key: &str) -> String {
34 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
36 key.to_ascii_uppercase()
37 } else {
38 key.replace('_', "-").to_ascii_lowercase()
39 }
40}
41
42#[derive(Debug, Serialize, Deserialize, Default, PartialEq)]
44pub struct RuleConfig {
45 #[serde(flatten)]
47 pub values: BTreeMap<String, toml::Value>,
48}
49
50#[derive(Debug, Serialize, Deserialize, Default, PartialEq)]
52pub struct Config {
53 #[serde(default)]
55 pub global: GlobalConfig,
56
57 #[serde(flatten)]
59 pub rules: BTreeMap<String, RuleConfig>,
60}
61
62#[derive(Debug, Serialize, Deserialize, PartialEq)]
64#[serde(default)]
65pub struct GlobalConfig {
66 #[serde(default)]
68 pub enable: Vec<String>,
69
70 #[serde(default)]
72 pub disable: Vec<String>,
73
74 #[serde(default)]
76 pub exclude: Vec<String>,
77
78 #[serde(default)]
80 pub include: Vec<String>,
81
82 #[serde(default = "default_respect_gitignore")]
84 pub respect_gitignore: bool,
85
86 #[serde(default = "default_line_length")]
88 pub line_length: u64,
89
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub output_format: Option<String>,
93
94 #[serde(default)]
97 pub fixable: Vec<String>,
98
99 #[serde(default)]
102 pub unfixable: Vec<String>,
103}
104
105fn default_respect_gitignore() -> bool {
106 true
107}
108
109fn default_line_length() -> u64 {
110 80
111}
112
113impl Default for GlobalConfig {
115 fn default() -> Self {
116 Self {
117 enable: Vec::new(),
118 disable: Vec::new(),
119 exclude: Vec::new(),
120 include: Vec::new(),
121 respect_gitignore: true,
122 line_length: 80,
123 output_format: None,
124 fixable: Vec::new(),
125 unfixable: Vec::new(),
126 }
127 }
128}
129
130const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
131 ".markdownlint.json",
132 ".markdownlint.jsonc",
133 ".markdownlint.yaml",
134 ".markdownlint.yml",
135 "markdownlint.json",
136 "markdownlint.jsonc",
137 "markdownlint.yaml",
138 "markdownlint.yml",
139];
140
141pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
143 if Path::new(path).exists() {
145 return Err(ConfigError::FileExists { path: path.to_string() });
146 }
147
148 let default_config = r#"# rumdl configuration file
150
151# Global configuration options
152[global]
153# List of rules to disable (uncomment and modify as needed)
154# disable = ["MD013", "MD033"]
155
156# List of rules to enable exclusively (if provided, only these rules will run)
157# enable = ["MD001", "MD003", "MD004"]
158
159# List of file/directory patterns to include for linting (if provided, only these will be linted)
160# include = [
161# "docs/*.md",
162# "src/**/*.md",
163# "README.md"
164# ]
165
166# List of file/directory patterns to exclude from linting
167exclude = [
168 # Common directories to exclude
169 ".git",
170 ".github",
171 "node_modules",
172 "vendor",
173 "dist",
174 "build",
175
176 # Specific files or patterns
177 "CHANGELOG.md",
178 "LICENSE.md",
179]
180
181# Respect .gitignore files when scanning directories (default: true)
182respect_gitignore = true
183
184# Rule-specific configurations (uncomment and modify as needed)
185
186# [MD003]
187# style = "atx" # Heading style (atx, atx_closed, setext)
188
189# [MD004]
190# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
191
192# [MD007]
193# indent = 4 # Unordered list indentation
194
195# [MD013]
196# line_length = 100 # Line length
197# code_blocks = false # Exclude code blocks from line length check
198# tables = false # Exclude tables from line length check
199# headings = true # Include headings in line length check
200
201# [MD044]
202# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
203# code_blocks_excluded = true # Exclude code blocks from proper name check
204"#;
205
206 match fs::write(path, default_config) {
208 Ok(_) => Ok(()),
209 Err(err) => Err(ConfigError::IoError {
210 source: err,
211 path: path.to_string(),
212 }),
213 }
214}
215
216#[derive(Debug, thiserror::Error)]
218pub enum ConfigError {
219 #[error("Failed to read config file at {path}: {source}")]
221 IoError { source: io::Error, path: String },
222
223 #[error("Failed to parse config: {0}")]
225 ParseError(String),
226
227 #[error("Configuration file already exists at {path}")]
229 FileExists { path: String },
230}
231
232pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
236 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
239
240 let key_variants = [
242 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
247
248 for variant in &key_variants {
250 if let Some(value) = rule_config.values.get(variant)
251 && let Ok(result) = T::deserialize(value.clone())
252 {
253 return Some(result);
254 }
255 }
256
257 None
258}
259
260pub fn generate_pyproject_config() -> String {
262 let config_content = r#"
263[tool.rumdl]
264# Global configuration options
265line-length = 100
266disable = []
267exclude = [
268 # Common directories to exclude
269 ".git",
270 ".github",
271 "node_modules",
272 "vendor",
273 "dist",
274 "build",
275]
276respect-gitignore = true
277
278# Rule-specific configurations (uncomment and modify as needed)
279
280# [tool.rumdl.MD003]
281# style = "atx" # Heading style (atx, atx_closed, setext)
282
283# [tool.rumdl.MD004]
284# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
285
286# [tool.rumdl.MD007]
287# indent = 4 # Unordered list indentation
288
289# [tool.rumdl.MD013]
290# line_length = 100 # Line length
291# code_blocks = false # Exclude code blocks from line length check
292# tables = false # Exclude tables from line length check
293# headings = true # Include headings in line length check
294
295# [tool.rumdl.MD044]
296# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
297# code_blocks_excluded = true # Exclude code blocks from proper name check
298"#;
299
300 config_content.to_string()
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use std::fs;
307 use tempfile::tempdir;
308
309 #[test]
310 fn test_pyproject_toml_root_level_config() {
311 let temp_dir = tempdir().unwrap();
312 let config_path = temp_dir.path().join("pyproject.toml");
313
314 let content = r#"
316[tool.rumdl]
317line-length = 120
318disable = ["MD033"]
319enable = ["MD001", "MD004"]
320include = ["docs/*.md"]
321exclude = ["node_modules"]
322respect-gitignore = true
323 "#;
324
325 fs::write(&config_path, content).unwrap();
326
327 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
329 let config: Config = sourced.into(); assert_eq!(config.global.disable, vec!["MD033".to_string()]);
333 assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
334 assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
336 assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
337 assert!(config.global.respect_gitignore);
338
339 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
341 assert_eq!(line_length, Some(120));
342 }
343
344 #[test]
345 fn test_pyproject_toml_snake_case_and_kebab_case() {
346 let temp_dir = tempdir().unwrap();
347 let config_path = temp_dir.path().join("pyproject.toml");
348
349 let content = r#"
351[tool.rumdl]
352line-length = 150
353respect_gitignore = true
354 "#;
355
356 fs::write(&config_path, content).unwrap();
357
358 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
360 let config: Config = sourced.into(); assert!(config.global.respect_gitignore);
364 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
365 assert_eq!(line_length, Some(150));
366 }
367
368 #[test]
369 fn test_md013_key_normalization_in_rumdl_toml() {
370 let temp_dir = tempdir().unwrap();
371 let config_path = temp_dir.path().join(".rumdl.toml");
372 let config_content = r#"
373[MD013]
374line_length = 111
375line-length = 222
376"#;
377 fs::write(&config_path, config_content).unwrap();
378 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
380 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
381 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
383 assert_eq!(keys, vec!["line-length"]);
384 let val = &rule_cfg.values["line-length"].value;
385 assert_eq!(val.as_integer(), Some(222));
386 let config: Config = sourced.clone().into();
388 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
389 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
390 assert_eq!(v1, Some(222));
391 assert_eq!(v2, Some(222));
392 }
393
394 #[test]
395 fn test_md013_section_case_insensitivity() {
396 let temp_dir = tempdir().unwrap();
397 let config_path = temp_dir.path().join(".rumdl.toml");
398 let config_content = r#"
399[md013]
400line-length = 101
401
402[Md013]
403line-length = 102
404
405[MD013]
406line-length = 103
407"#;
408 fs::write(&config_path, config_content).unwrap();
409 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
411 let config: Config = sourced.clone().into();
412 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
414 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
415 assert_eq!(keys, vec!["line-length"]);
416 let val = &rule_cfg.values["line-length"].value;
417 assert_eq!(val.as_integer(), Some(103));
418 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
419 assert_eq!(v, Some(103));
420 }
421
422 #[test]
423 fn test_md013_key_snake_and_kebab_case() {
424 let temp_dir = tempdir().unwrap();
425 let config_path = temp_dir.path().join(".rumdl.toml");
426 let config_content = r#"
427[MD013]
428line_length = 201
429line-length = 202
430"#;
431 fs::write(&config_path, config_content).unwrap();
432 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
434 let config: Config = sourced.clone().into();
435 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
436 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
437 assert_eq!(keys, vec!["line-length"]);
438 let val = &rule_cfg.values["line-length"].value;
439 assert_eq!(val.as_integer(), Some(202));
440 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
441 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
442 assert_eq!(v1, Some(202));
443 assert_eq!(v2, Some(202));
444 }
445
446 #[test]
447 fn test_unknown_rule_section_is_ignored() {
448 let temp_dir = tempdir().unwrap();
449 let config_path = temp_dir.path().join(".rumdl.toml");
450 let config_content = r#"
451[MD999]
452foo = 1
453bar = 2
454[MD013]
455line-length = 303
456"#;
457 fs::write(&config_path, config_content).unwrap();
458 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
460 let config: Config = sourced.clone().into();
461 assert!(!sourced.rules.contains_key("MD999"));
463 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
465 assert_eq!(v, Some(303));
466 }
467
468 #[test]
469 fn test_invalid_toml_syntax() {
470 let temp_dir = tempdir().unwrap();
471 let config_path = temp_dir.path().join(".rumdl.toml");
472
473 let config_content = r#"
475[MD013]
476line-length = "unclosed string
477"#;
478 fs::write(&config_path, config_content).unwrap();
479
480 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
481 assert!(result.is_err());
482 match result.unwrap_err() {
483 ConfigError::ParseError(msg) => {
484 assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
486 }
487 _ => panic!("Expected ParseError"),
488 }
489 }
490
491 #[test]
492 fn test_wrong_type_for_config_value() {
493 let temp_dir = tempdir().unwrap();
494 let config_path = temp_dir.path().join(".rumdl.toml");
495
496 let config_content = r#"
498[MD013]
499line-length = "not a number"
500"#;
501 fs::write(&config_path, config_content).unwrap();
502
503 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
504 let config: Config = sourced.into();
505
506 let rule_config = config.rules.get("MD013").unwrap();
508 let value = rule_config.values.get("line-length").unwrap();
509 assert!(matches!(value, toml::Value::String(_)));
510 }
511
512 #[test]
513 fn test_empty_config_file() {
514 let temp_dir = tempdir().unwrap();
515 let config_path = temp_dir.path().join(".rumdl.toml");
516
517 fs::write(&config_path, "").unwrap();
519
520 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
521 let config: Config = sourced.into();
522
523 assert_eq!(config.global.line_length, 80);
525 assert!(config.global.respect_gitignore);
526 assert!(config.rules.is_empty());
527 }
528
529 #[test]
530 fn test_malformed_pyproject_toml() {
531 let temp_dir = tempdir().unwrap();
532 let config_path = temp_dir.path().join("pyproject.toml");
533
534 let content = r#"
536[tool.rumdl
537line-length = 120
538"#;
539 fs::write(&config_path, content).unwrap();
540
541 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
542 assert!(result.is_err());
543 }
544
545 #[test]
546 fn test_conflicting_config_values() {
547 let temp_dir = tempdir().unwrap();
548 let config_path = temp_dir.path().join(".rumdl.toml");
549
550 let config_content = r#"
552[global]
553enable = ["MD013"]
554disable = ["MD013"]
555"#;
556 fs::write(&config_path, config_content).unwrap();
557
558 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
559 let config: Config = sourced.into();
560
561 assert!(config.global.enable.contains(&"MD013".to_string()));
563 assert!(config.global.disable.contains(&"MD013".to_string()));
564 }
565
566 #[test]
567 fn test_invalid_rule_names() {
568 let temp_dir = tempdir().unwrap();
569 let config_path = temp_dir.path().join(".rumdl.toml");
570
571 let config_content = r#"
572[global]
573enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
574disable = ["MD-001", "MD_002"]
575"#;
576 fs::write(&config_path, config_content).unwrap();
577
578 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
579 let config: Config = sourced.into();
580
581 assert_eq!(config.global.enable.len(), 4);
583 assert_eq!(config.global.disable.len(), 2);
584 }
585
586 #[test]
587 fn test_deeply_nested_config() {
588 let temp_dir = tempdir().unwrap();
589 let config_path = temp_dir.path().join(".rumdl.toml");
590
591 let config_content = r#"
593[MD013]
594line-length = 100
595[MD013.nested]
596value = 42
597"#;
598 fs::write(&config_path, config_content).unwrap();
599
600 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
601 let config: Config = sourced.into();
602
603 let rule_config = config.rules.get("MD013").unwrap();
604 assert_eq!(
605 rule_config.values.get("line-length").unwrap(),
606 &toml::Value::Integer(100)
607 );
608 assert!(!rule_config.values.contains_key("nested"));
610 }
611
612 #[test]
613 fn test_unicode_in_config() {
614 let temp_dir = tempdir().unwrap();
615 let config_path = temp_dir.path().join(".rumdl.toml");
616
617 let config_content = r#"
618[global]
619include = ["文档/*.md", "ドキュメント/*.md"]
620exclude = ["测试/*", "🚀/*"]
621
622[MD013]
623line-length = 80
624message = "行太长了 🚨"
625"#;
626 fs::write(&config_path, config_content).unwrap();
627
628 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
629 let config: Config = sourced.into();
630
631 assert_eq!(config.global.include.len(), 2);
632 assert_eq!(config.global.exclude.len(), 2);
633 assert!(config.global.include[0].contains("文档"));
634 assert!(config.global.exclude[1].contains("🚀"));
635
636 let rule_config = config.rules.get("MD013").unwrap();
637 let message = rule_config.values.get("message").unwrap();
638 if let toml::Value::String(s) = message {
639 assert!(s.contains("行太长了"));
640 assert!(s.contains("🚨"));
641 }
642 }
643
644 #[test]
645 fn test_extremely_long_values() {
646 let temp_dir = tempdir().unwrap();
647 let config_path = temp_dir.path().join(".rumdl.toml");
648
649 let long_string = "a".repeat(10000);
650 let config_content = format!(
651 r#"
652[global]
653exclude = ["{long_string}"]
654
655[MD013]
656line-length = 999999999
657"#
658 );
659
660 fs::write(&config_path, config_content).unwrap();
661
662 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
663 let config: Config = sourced.into();
664
665 assert_eq!(config.global.exclude[0].len(), 10000);
666 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
667 assert_eq!(line_length, Some(999999999));
668 }
669
670 #[test]
671 fn test_config_with_comments() {
672 let temp_dir = tempdir().unwrap();
673 let config_path = temp_dir.path().join(".rumdl.toml");
674
675 let config_content = r#"
676[global]
677# This is a comment
678enable = ["MD001"] # Enable MD001
679# disable = ["MD002"] # This is commented out
680
681[MD013] # Line length rule
682line-length = 100 # Set to 100 characters
683# ignored = true # This setting is commented out
684"#;
685 fs::write(&config_path, config_content).unwrap();
686
687 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
688 let config: Config = sourced.into();
689
690 assert_eq!(config.global.enable, vec!["MD001"]);
691 assert!(config.global.disable.is_empty()); let rule_config = config.rules.get("MD013").unwrap();
694 assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
696 }
697
698 #[test]
699 fn test_arrays_in_rule_config() {
700 let temp_dir = tempdir().unwrap();
701 let config_path = temp_dir.path().join(".rumdl.toml");
702
703 let config_content = r#"
704[MD002]
705levels = [1, 2, 3]
706tags = ["important", "critical"]
707mixed = [1, "two", true]
708"#;
709 fs::write(&config_path, config_content).unwrap();
710
711 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
712 let config: Config = sourced.into();
713
714 let rule_config = config.rules.get("MD002").expect("MD002 config should exist");
716
717 assert!(rule_config.values.contains_key("levels"));
719 assert!(rule_config.values.contains_key("tags"));
720 assert!(rule_config.values.contains_key("mixed"));
721
722 if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
724 assert_eq!(levels.len(), 3);
725 assert_eq!(levels[0], toml::Value::Integer(1));
726 assert_eq!(levels[1], toml::Value::Integer(2));
727 assert_eq!(levels[2], toml::Value::Integer(3));
728 } else {
729 panic!("levels should be an array");
730 }
731
732 if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
733 assert_eq!(tags.len(), 2);
734 assert_eq!(tags[0], toml::Value::String("important".to_string()));
735 assert_eq!(tags[1], toml::Value::String("critical".to_string()));
736 } else {
737 panic!("tags should be an array");
738 }
739
740 if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
741 assert_eq!(mixed.len(), 3);
742 assert_eq!(mixed[0], toml::Value::Integer(1));
743 assert_eq!(mixed[1], toml::Value::String("two".to_string()));
744 assert_eq!(mixed[2], toml::Value::Boolean(true));
745 } else {
746 panic!("mixed should be an array");
747 }
748 }
749
750 #[test]
751 fn test_normalize_key_edge_cases() {
752 assert_eq!(normalize_key("MD001"), "MD001");
754 assert_eq!(normalize_key("md001"), "MD001");
755 assert_eq!(normalize_key("Md001"), "MD001");
756 assert_eq!(normalize_key("mD001"), "MD001");
757
758 assert_eq!(normalize_key("line_length"), "line-length");
760 assert_eq!(normalize_key("line-length"), "line-length");
761 assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
762 assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
763
764 assert_eq!(normalize_key("MD"), "md"); assert_eq!(normalize_key("MD00"), "md00"); assert_eq!(normalize_key("MD0001"), "md0001"); assert_eq!(normalize_key("MDabc"), "mdabc"); assert_eq!(normalize_key("MD00a"), "md00a"); assert_eq!(normalize_key(""), "");
771 assert_eq!(normalize_key("_"), "-");
772 assert_eq!(normalize_key("___"), "---");
773 }
774
775 #[test]
776 fn test_missing_config_file() {
777 let temp_dir = tempdir().unwrap();
778 let config_path = temp_dir.path().join("nonexistent.toml");
779
780 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
781 assert!(result.is_err());
782 match result.unwrap_err() {
783 ConfigError::IoError { .. } => {}
784 _ => panic!("Expected IoError for missing file"),
785 }
786 }
787
788 #[test]
789 #[cfg(unix)]
790 fn test_permission_denied_config() {
791 use std::os::unix::fs::PermissionsExt;
792
793 let temp_dir = tempdir().unwrap();
794 let config_path = temp_dir.path().join(".rumdl.toml");
795
796 fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
797
798 let mut perms = fs::metadata(&config_path).unwrap().permissions();
800 perms.set_mode(0o000);
801 fs::set_permissions(&config_path, perms).unwrap();
802
803 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
804
805 let mut perms = fs::metadata(&config_path).unwrap().permissions();
807 perms.set_mode(0o644);
808 fs::set_permissions(&config_path, perms).unwrap();
809
810 assert!(result.is_err());
811 match result.unwrap_err() {
812 ConfigError::IoError { .. } => {}
813 _ => panic!("Expected IoError for permission denied"),
814 }
815 }
816
817 #[test]
818 fn test_circular_reference_detection() {
819 let temp_dir = tempdir().unwrap();
822 let config_path = temp_dir.path().join(".rumdl.toml");
823
824 let mut config_content = String::from("[MD001]\n");
825 for i in 0..100 {
826 config_content.push_str(&format!("key{i} = {i}\n"));
827 }
828
829 fs::write(&config_path, config_content).unwrap();
830
831 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
832 let config: Config = sourced.into();
833
834 let rule_config = config.rules.get("MD001").unwrap();
835 assert_eq!(rule_config.values.len(), 100);
836 }
837
838 #[test]
839 fn test_special_toml_values() {
840 let temp_dir = tempdir().unwrap();
841 let config_path = temp_dir.path().join(".rumdl.toml");
842
843 let config_content = r#"
844[MD001]
845infinity = inf
846neg_infinity = -inf
847not_a_number = nan
848datetime = 1979-05-27T07:32:00Z
849local_date = 1979-05-27
850local_time = 07:32:00
851"#;
852 fs::write(&config_path, config_content).unwrap();
853
854 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
855 let config: Config = sourced.into();
856
857 if let Some(rule_config) = config.rules.get("MD001") {
859 if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
861 assert!(f.is_infinite() && f.is_sign_positive());
862 }
863 if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
864 assert!(f.is_infinite() && f.is_sign_negative());
865 }
866 if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
867 assert!(f.is_nan());
868 }
869
870 if let Some(val) = rule_config.values.get("datetime") {
872 assert!(matches!(val, toml::Value::Datetime(_)));
873 }
874 }
876 }
877
878 #[test]
879 fn test_default_config_passes_validation() {
880 use crate::rules;
881
882 let temp_dir = tempdir().unwrap();
883 let config_path = temp_dir.path().join(".rumdl.toml");
884 let config_path_str = config_path.to_str().unwrap();
885
886 create_default_config(config_path_str).unwrap();
888
889 let sourced =
891 SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
892
893 let all_rules = rules::all_rules(&Config::default());
895 let registry = RuleRegistry::from_rules(&all_rules);
896
897 let warnings = validate_config_sourced(&sourced, ®istry);
899
900 if !warnings.is_empty() {
902 for warning in &warnings {
903 eprintln!("Config validation warning: {}", warning.message);
904 if let Some(rule) = &warning.rule {
905 eprintln!(" Rule: {rule}");
906 }
907 if let Some(key) = &warning.key {
908 eprintln!(" Key: {key}");
909 }
910 }
911 }
912 assert!(
913 warnings.is_empty(),
914 "Default config from rumdl init should pass validation without warnings"
915 );
916 }
917}
918
919#[derive(Debug, Clone, Copy, PartialEq, Eq)]
920pub enum ConfigSource {
921 Default,
922 RumdlToml,
923 PyprojectToml,
924 Cli,
925 Markdownlint,
927}
928
929#[derive(Debug, Clone)]
930pub struct ConfigOverride<T> {
931 pub value: T,
932 pub source: ConfigSource,
933 pub file: Option<String>,
934 pub line: Option<usize>,
935}
936
937#[derive(Debug, Clone)]
938pub struct SourcedValue<T> {
939 pub value: T,
940 pub source: ConfigSource,
941 pub overrides: Vec<ConfigOverride<T>>,
942}
943
944impl<T: Clone> SourcedValue<T> {
945 pub fn new(value: T, source: ConfigSource) -> Self {
946 Self {
947 value: value.clone(),
948 source,
949 overrides: vec![ConfigOverride {
950 value,
951 source,
952 file: None,
953 line: None,
954 }],
955 }
956 }
957
958 pub fn merge_override(
962 &mut self,
963 new_value: T,
964 new_source: ConfigSource,
965 new_file: Option<String>,
966 new_line: Option<usize>,
967 ) {
968 fn source_precedence(src: ConfigSource) -> u8 {
970 match src {
971 ConfigSource::Default => 0,
972 ConfigSource::PyprojectToml => 1,
973 ConfigSource::Markdownlint => 2,
974 ConfigSource::RumdlToml => 3,
975 ConfigSource::Cli => 4,
976 }
977 }
978
979 if source_precedence(new_source) >= source_precedence(self.source) {
980 self.value = new_value.clone();
981 self.source = new_source;
982 self.overrides.push(ConfigOverride {
983 value: new_value,
984 source: new_source,
985 file: new_file,
986 line: new_line,
987 });
988 }
989 }
990
991 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
992 self.value = value.clone();
995 self.source = source;
996 self.overrides.push(ConfigOverride {
997 value,
998 source,
999 file,
1000 line,
1001 });
1002 }
1003}
1004
1005#[derive(Debug, Clone)]
1006pub struct SourcedGlobalConfig {
1007 pub enable: SourcedValue<Vec<String>>,
1008 pub disable: SourcedValue<Vec<String>>,
1009 pub exclude: SourcedValue<Vec<String>>,
1010 pub include: SourcedValue<Vec<String>>,
1011 pub respect_gitignore: SourcedValue<bool>,
1012 pub line_length: SourcedValue<u64>,
1013 pub output_format: Option<SourcedValue<String>>,
1014 pub fixable: SourcedValue<Vec<String>>,
1015 pub unfixable: SourcedValue<Vec<String>>,
1016}
1017
1018impl Default for SourcedGlobalConfig {
1019 fn default() -> Self {
1020 SourcedGlobalConfig {
1021 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1022 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1023 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
1024 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
1025 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
1026 line_length: SourcedValue::new(80, ConfigSource::Default),
1027 output_format: None,
1028 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1029 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1030 }
1031 }
1032}
1033
1034#[derive(Debug, Default, Clone)]
1035pub struct SourcedRuleConfig {
1036 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
1037}
1038
1039#[derive(Debug, Default, Clone)]
1042pub struct SourcedConfigFragment {
1043 pub global: SourcedGlobalConfig,
1044 pub rules: BTreeMap<String, SourcedRuleConfig>,
1045 }
1047
1048#[derive(Debug, Default, Clone)]
1049pub struct SourcedConfig {
1050 pub global: SourcedGlobalConfig,
1051 pub rules: BTreeMap<String, SourcedRuleConfig>,
1052 pub loaded_files: Vec<String>,
1053 pub unknown_keys: Vec<(String, String)>, }
1055
1056impl SourcedConfig {
1057 fn merge(&mut self, fragment: SourcedConfigFragment) {
1060 self.global.enable.merge_override(
1062 fragment.global.enable.value,
1063 fragment.global.enable.source,
1064 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
1065 fragment.global.enable.overrides.first().and_then(|o| o.line),
1066 );
1067 self.global.disable.merge_override(
1068 fragment.global.disable.value,
1069 fragment.global.disable.source,
1070 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
1071 fragment.global.disable.overrides.first().and_then(|o| o.line),
1072 );
1073 self.global.include.merge_override(
1074 fragment.global.include.value,
1075 fragment.global.include.source,
1076 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
1077 fragment.global.include.overrides.first().and_then(|o| o.line),
1078 );
1079 self.global.exclude.merge_override(
1080 fragment.global.exclude.value,
1081 fragment.global.exclude.source,
1082 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
1083 fragment.global.exclude.overrides.first().and_then(|o| o.line),
1084 );
1085 self.global.respect_gitignore.merge_override(
1086 fragment.global.respect_gitignore.value,
1087 fragment.global.respect_gitignore.source,
1088 fragment
1089 .global
1090 .respect_gitignore
1091 .overrides
1092 .first()
1093 .and_then(|o| o.file.clone()),
1094 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
1095 );
1096 self.global.line_length.merge_override(
1097 fragment.global.line_length.value,
1098 fragment.global.line_length.source,
1099 fragment
1100 .global
1101 .line_length
1102 .overrides
1103 .first()
1104 .and_then(|o| o.file.clone()),
1105 fragment.global.line_length.overrides.first().and_then(|o| o.line),
1106 );
1107 self.global.fixable.merge_override(
1108 fragment.global.fixable.value,
1109 fragment.global.fixable.source,
1110 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
1111 fragment.global.fixable.overrides.first().and_then(|o| o.line),
1112 );
1113 self.global.unfixable.merge_override(
1114 fragment.global.unfixable.value,
1115 fragment.global.unfixable.source,
1116 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
1117 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
1118 );
1119
1120 if let Some(output_format_fragment) = fragment.global.output_format {
1122 if let Some(ref mut output_format) = self.global.output_format {
1123 output_format.merge_override(
1124 output_format_fragment.value,
1125 output_format_fragment.source,
1126 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
1127 output_format_fragment.overrides.first().and_then(|o| o.line),
1128 );
1129 } else {
1130 self.global.output_format = Some(output_format_fragment);
1131 }
1132 }
1133
1134 for (rule_name, rule_fragment) in fragment.rules {
1136 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
1138 for (key, sourced_value_fragment) in rule_fragment.values {
1139 let sv_entry = rule_entry
1140 .values
1141 .entry(key.clone())
1142 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
1143 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
1144 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
1145 sv_entry.merge_override(
1146 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
1151 }
1152 }
1153 }
1154
1155 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
1157 Self::load_with_discovery(config_path, cli_overrides, false)
1158 }
1159
1160 fn discover_config_upward() -> Option<std::path::PathBuf> {
1163 use std::env;
1164
1165 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1166 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
1169 Ok(dir) => dir,
1170 Err(e) => {
1171 log::debug!("[rumdl-config] Failed to get current directory: {e}");
1172 return None;
1173 }
1174 };
1175
1176 let mut current_dir = start_dir.clone();
1177 let mut depth = 0;
1178
1179 loop {
1180 if depth >= MAX_DEPTH {
1181 log::debug!("[rumdl-config] Maximum traversal depth reached");
1182 break;
1183 }
1184
1185 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
1186
1187 for config_name in CONFIG_FILES {
1189 let config_path = current_dir.join(config_name);
1190
1191 if config_path.exists() {
1192 if *config_name == "pyproject.toml" {
1194 if let Ok(content) = std::fs::read_to_string(&config_path) {
1195 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1196 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1197 return Some(config_path);
1198 }
1199 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
1200 continue;
1201 }
1202 } else {
1203 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1204 return Some(config_path);
1205 }
1206 }
1207 }
1208
1209 if current_dir.join(".git").exists() {
1211 log::debug!("[rumdl-config] Stopping at .git directory");
1212 break;
1213 }
1214
1215 match current_dir.parent() {
1217 Some(parent) => {
1218 current_dir = parent.to_owned();
1219 depth += 1;
1220 }
1221 None => {
1222 log::debug!("[rumdl-config] Reached filesystem root");
1223 break;
1224 }
1225 }
1226 }
1227
1228 None
1229 }
1230
1231 pub fn load_with_discovery(
1234 config_path: Option<&str>,
1235 cli_overrides: Option<&SourcedGlobalConfig>,
1236 skip_auto_discovery: bool,
1237 ) -> Result<Self, ConfigError> {
1238 use std::env;
1239 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1240 if config_path.is_none() {
1241 if skip_auto_discovery {
1242 log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
1243 } else {
1244 log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
1245 }
1246 } else {
1247 log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
1248 }
1249 let mut sourced_config = SourcedConfig::default();
1250
1251 if let Some(path) = config_path {
1253 let path_obj = Path::new(path);
1254 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
1255 log::debug!("[rumdl-config] Trying to load config file: {filename}");
1256 let path_str = path.to_string();
1257
1258 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
1260
1261 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
1262 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1263 source: e,
1264 path: path_str.clone(),
1265 })?;
1266 if filename == "pyproject.toml" {
1267 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1268 sourced_config.merge(fragment);
1269 sourced_config.loaded_files.push(path_str.clone());
1270 }
1271 } else {
1272 let fragment = parse_rumdl_toml(&content, &path_str)?;
1273 sourced_config.merge(fragment);
1274 sourced_config.loaded_files.push(path_str.clone());
1275 }
1276 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
1277 || path_str.ends_with(".json")
1278 || path_str.ends_with(".jsonc")
1279 || path_str.ends_with(".yaml")
1280 || path_str.ends_with(".yml")
1281 {
1282 let fragment = load_from_markdownlint(&path_str)?;
1284 sourced_config.merge(fragment);
1285 sourced_config.loaded_files.push(path_str.clone());
1286 } else {
1288 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1290 source: e,
1291 path: path_str.clone(),
1292 })?;
1293 let fragment = parse_rumdl_toml(&content, &path_str)?;
1294 sourced_config.merge(fragment);
1295 sourced_config.loaded_files.push(path_str.clone());
1296 }
1297 }
1298
1299 if !skip_auto_discovery && config_path.is_none() {
1301 if let Some(config_file) = Self::discover_config_upward() {
1303 let path_str = config_file.display().to_string();
1304 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
1305
1306 log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
1307
1308 if filename == "pyproject.toml" {
1309 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1310 source: e,
1311 path: path_str.clone(),
1312 })?;
1313 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1314 sourced_config.merge(fragment);
1315 sourced_config.loaded_files.push(path_str);
1316 }
1317 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
1318 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1319 source: e,
1320 path: path_str.clone(),
1321 })?;
1322 let fragment = parse_rumdl_toml(&content, &path_str)?;
1323 sourced_config.merge(fragment);
1324 sourced_config.loaded_files.push(path_str);
1325 }
1326 } else {
1327 log::debug!("[rumdl-config] No configuration file found via upward traversal");
1328
1329 for filename in MARKDOWNLINT_CONFIG_FILES {
1331 if std::path::Path::new(filename).exists() {
1332 match load_from_markdownlint(filename) {
1333 Ok(fragment) => {
1334 sourced_config.merge(fragment);
1335 sourced_config.loaded_files.push(filename.to_string());
1336 break; }
1338 Err(_e) => {
1339 }
1341 }
1342 }
1343 }
1344 }
1345 }
1346
1347 if let Some(cli) = cli_overrides {
1349 sourced_config
1350 .global
1351 .enable
1352 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
1353 sourced_config
1354 .global
1355 .disable
1356 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
1357 sourced_config
1358 .global
1359 .exclude
1360 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
1361 sourced_config
1362 .global
1363 .include
1364 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
1365 sourced_config.global.respect_gitignore.merge_override(
1366 cli.respect_gitignore.value,
1367 ConfigSource::Cli,
1368 None,
1369 None,
1370 );
1371 sourced_config
1372 .global
1373 .fixable
1374 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
1375 sourced_config
1376 .global
1377 .unfixable
1378 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
1379 }
1381
1382 Ok(sourced_config)
1385 }
1386}
1387
1388impl From<SourcedConfig> for Config {
1389 fn from(sourced: SourcedConfig) -> Self {
1390 let mut rules = BTreeMap::new();
1391 for (rule_name, sourced_rule_cfg) in sourced.rules {
1392 let normalized_rule_name = rule_name.to_ascii_uppercase();
1394 let mut values = BTreeMap::new();
1395 for (key, sourced_val) in sourced_rule_cfg.values {
1396 values.insert(key, sourced_val.value);
1397 }
1398 rules.insert(normalized_rule_name, RuleConfig { values });
1399 }
1400 let global = GlobalConfig {
1401 enable: sourced.global.enable.value,
1402 disable: sourced.global.disable.value,
1403 exclude: sourced.global.exclude.value,
1404 include: sourced.global.include.value,
1405 respect_gitignore: sourced.global.respect_gitignore.value,
1406 line_length: sourced.global.line_length.value,
1407 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1408 fixable: sourced.global.fixable.value,
1409 unfixable: sourced.global.unfixable.value,
1410 };
1411 Config { global, rules }
1412 }
1413}
1414
1415pub struct RuleRegistry {
1417 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
1419}
1420
1421impl RuleRegistry {
1422 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
1424 let mut rule_schemas = std::collections::BTreeMap::new();
1425 for rule in rules {
1426 if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
1427 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name, table);
1429 } else {
1430 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name, toml::map::Map::new());
1432 }
1433 }
1434 RuleRegistry { rule_schemas }
1435 }
1436
1437 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
1439 self.rule_schemas.keys().cloned().collect()
1440 }
1441
1442 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
1444 self.rule_schemas.get(rule).map(|schema| {
1445 let mut all_keys = std::collections::BTreeSet::new();
1446
1447 for key in schema.keys() {
1449 all_keys.insert(key.clone());
1450 }
1451
1452 for key in schema.keys() {
1454 all_keys.insert(key.replace('_', "-"));
1456 all_keys.insert(key.replace('-', "_"));
1458 all_keys.insert(normalize_key(key));
1460 }
1461
1462 all_keys
1463 })
1464 }
1465
1466 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
1468 if let Some(schema) = self.rule_schemas.get(rule) {
1469 if let Some(value) = schema.get(key) {
1471 return Some(value);
1472 }
1473
1474 let key_variants = [
1476 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
1480
1481 for variant in &key_variants {
1482 if let Some(value) = schema.get(variant) {
1483 return Some(value);
1484 }
1485 }
1486 }
1487 None
1488 }
1489}
1490
1491#[derive(Debug, Clone)]
1493pub struct ConfigValidationWarning {
1494 pub message: String,
1495 pub rule: Option<String>,
1496 pub key: Option<String>,
1497}
1498
1499pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
1501 let mut warnings = Vec::new();
1502 let known_rules = registry.rule_names();
1503 for rule in sourced.rules.keys() {
1505 if !known_rules.contains(rule) {
1506 warnings.push(ConfigValidationWarning {
1507 message: format!("Unknown rule in config: {rule}"),
1508 rule: Some(rule.clone()),
1509 key: None,
1510 });
1511 }
1512 }
1513 for (rule, rule_cfg) in &sourced.rules {
1515 if let Some(valid_keys) = registry.config_keys_for(rule) {
1516 for key in rule_cfg.values.keys() {
1517 if !valid_keys.contains(key) {
1518 warnings.push(ConfigValidationWarning {
1519 message: format!("Unknown option for rule {rule}: {key}"),
1520 rule: Some(rule.clone()),
1521 key: Some(key.clone()),
1522 });
1523 } else {
1524 if let Some(expected) = registry.expected_value_for(rule, key) {
1526 let actual = &rule_cfg.values[key].value;
1527 if !toml_value_type_matches(expected, actual) {
1528 warnings.push(ConfigValidationWarning {
1529 message: format!(
1530 "Type mismatch for {}.{}: expected {}, got {}",
1531 rule,
1532 key,
1533 toml_type_name(expected),
1534 toml_type_name(actual)
1535 ),
1536 rule: Some(rule.clone()),
1537 key: Some(key.clone()),
1538 });
1539 }
1540 }
1541 }
1542 }
1543 }
1544 }
1545 for (section, key) in &sourced.unknown_keys {
1547 if section.contains("[global]") {
1548 warnings.push(ConfigValidationWarning {
1549 message: format!("Unknown global option: {key}"),
1550 rule: None,
1551 key: Some(key.clone()),
1552 });
1553 }
1554 }
1555 warnings
1556}
1557
1558fn toml_type_name(val: &toml::Value) -> &'static str {
1559 match val {
1560 toml::Value::String(_) => "string",
1561 toml::Value::Integer(_) => "integer",
1562 toml::Value::Float(_) => "float",
1563 toml::Value::Boolean(_) => "boolean",
1564 toml::Value::Array(_) => "array",
1565 toml::Value::Table(_) => "table",
1566 toml::Value::Datetime(_) => "datetime",
1567 }
1568}
1569
1570fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
1571 use toml::Value::*;
1572 match (expected, actual) {
1573 (String(_), String(_)) => true,
1574 (Integer(_), Integer(_)) => true,
1575 (Float(_), Float(_)) => true,
1576 (Boolean(_), Boolean(_)) => true,
1577 (Array(_), Array(_)) => true,
1578 (Table(_), Table(_)) => true,
1579 (Datetime(_), Datetime(_)) => true,
1580 (Float(_), Integer(_)) => true,
1582 _ => false,
1583 }
1584}
1585
1586fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
1588 let doc: toml::Value =
1589 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
1590 let mut fragment = SourcedConfigFragment::default();
1591 let source = ConfigSource::PyprojectToml;
1592 let file = Some(path.to_string());
1593
1594 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
1596 && let Some(rumdl_table) = rumdl_config.as_table()
1597 {
1598 if let Some(enable) = rumdl_table.get("enable")
1600 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
1601 {
1602 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1604 fragment
1605 .global
1606 .enable
1607 .push_override(normalized_values, source, file.clone(), None);
1608 }
1609 if let Some(disable) = rumdl_table.get("disable")
1610 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
1611 {
1612 let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
1614 fragment
1615 .global
1616 .disable
1617 .push_override(normalized_values, source, file.clone(), None);
1618 }
1619 if let Some(include) = rumdl_table.get("include")
1620 && let Ok(values) = Vec::<String>::deserialize(include.clone())
1621 {
1622 fragment
1623 .global
1624 .include
1625 .push_override(values, source, file.clone(), None);
1626 }
1627 if let Some(exclude) = rumdl_table.get("exclude")
1628 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
1629 {
1630 fragment
1631 .global
1632 .exclude
1633 .push_override(values, source, file.clone(), None);
1634 }
1635 if let Some(respect_gitignore) = rumdl_table
1636 .get("respect-gitignore")
1637 .or_else(|| rumdl_table.get("respect_gitignore"))
1638 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
1639 {
1640 fragment
1641 .global
1642 .respect_gitignore
1643 .push_override(value, source, file.clone(), None);
1644 }
1645 if let Some(output_format) = rumdl_table
1646 .get("output-format")
1647 .or_else(|| rumdl_table.get("output_format"))
1648 && let Ok(value) = String::deserialize(output_format.clone())
1649 {
1650 if fragment.global.output_format.is_none() {
1651 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
1652 } else {
1653 fragment
1654 .global
1655 .output_format
1656 .as_mut()
1657 .unwrap()
1658 .push_override(value, source, file.clone(), None);
1659 }
1660 }
1661 if let Some(fixable) = rumdl_table.get("fixable")
1662 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
1663 {
1664 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1665 fragment
1666 .global
1667 .fixable
1668 .push_override(normalized_values, source, file.clone(), None);
1669 }
1670 if let Some(unfixable) = rumdl_table.get("unfixable")
1671 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
1672 {
1673 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1674 fragment
1675 .global
1676 .unfixable
1677 .push_override(normalized_values, source, file.clone(), None);
1678 }
1679
1680 let mut found_line_length_val: Option<toml::Value> = None;
1682 for key in ["line-length", "line_length"].iter() {
1683 if let Some(val) = rumdl_table.get(*key) {
1684 if val.is_integer() {
1686 found_line_length_val = Some(val.clone());
1687 break;
1688 } else {
1689 }
1691 }
1692 }
1693 if let Some(line_length_val) = found_line_length_val {
1694 let norm_md013_key = normalize_key("MD013"); let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
1696 let norm_line_length_key = normalize_key("line-length"); let sv = rule_entry
1698 .values
1699 .entry(norm_line_length_key)
1700 .or_insert_with(|| SourcedValue::new(line_length_val.clone(), ConfigSource::Default));
1701 sv.push_override(line_length_val, source, file.clone(), None);
1702 }
1703
1704 for (key, value) in rumdl_table {
1706 let norm_rule_key = normalize_key(key);
1707
1708 if [
1710 "enable",
1711 "disable",
1712 "include",
1713 "exclude",
1714 "respect_gitignore",
1715 "respect-gitignore", "line_length",
1717 "line-length",
1718 "output_format",
1719 "output-format",
1720 "fixable",
1721 "unfixable",
1722 ]
1723 .contains(&norm_rule_key.as_str())
1724 {
1725 continue;
1726 }
1727
1728 let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
1732 if norm_rule_key_upper.len() == 5
1733 && norm_rule_key_upper.starts_with("MD")
1734 && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
1735 && value.is_table()
1736 {
1737 if let Some(rule_config_table) = value.as_table() {
1738 let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
1740 for (rk, rv) in rule_config_table {
1741 let norm_rk = normalize_key(rk); let toml_val = rv.clone();
1744
1745 let sv = rule_entry
1746 .values
1747 .entry(norm_rk.clone())
1748 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
1749 sv.push_override(toml_val, source, file.clone(), None);
1750 }
1751 }
1752 } else {
1753 }
1757 }
1758 }
1759
1760 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
1762 for (key, value) in tool_table.iter() {
1763 if let Some(rule_name) = key.strip_prefix("rumdl.") {
1764 let norm_rule_name = normalize_key(rule_name);
1765 if norm_rule_name.len() == 5
1766 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
1767 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
1768 && let Some(rule_table) = value.as_table()
1769 {
1770 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
1771 for (rk, rv) in rule_table {
1772 let norm_rk = normalize_key(rk);
1773 let toml_val = rv.clone();
1774 let sv = rule_entry
1775 .values
1776 .entry(norm_rk.clone())
1777 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
1778 sv.push_override(toml_val, source, file.clone(), None);
1779 }
1780 }
1781 }
1782 }
1783 }
1784
1785 if let Some(doc_table) = doc.as_table() {
1787 for (key, value) in doc_table.iter() {
1788 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
1789 let norm_rule_name = normalize_key(rule_name);
1790 if norm_rule_name.len() == 5
1791 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
1792 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
1793 && let Some(rule_table) = value.as_table()
1794 {
1795 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
1796 for (rk, rv) in rule_table {
1797 let norm_rk = normalize_key(rk);
1798 let toml_val = rv.clone();
1799 let sv = rule_entry
1800 .values
1801 .entry(norm_rk.clone())
1802 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
1803 sv.push_override(toml_val, source, file.clone(), None);
1804 }
1805 }
1806 }
1807 }
1808 }
1809
1810 let has_any = !fragment.global.enable.value.is_empty()
1812 || !fragment.global.disable.value.is_empty()
1813 || !fragment.global.include.value.is_empty()
1814 || !fragment.global.exclude.value.is_empty()
1815 || !fragment.global.fixable.value.is_empty()
1816 || !fragment.global.unfixable.value.is_empty()
1817 || fragment.global.output_format.is_some()
1818 || !fragment.rules.is_empty();
1819 if has_any { Ok(Some(fragment)) } else { Ok(None) }
1820}
1821
1822fn parse_rumdl_toml(content: &str, path: &str) -> Result<SourcedConfigFragment, ConfigError> {
1824 let doc = content
1825 .parse::<DocumentMut>()
1826 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
1827 let mut fragment = SourcedConfigFragment::default();
1828 let source = ConfigSource::RumdlToml;
1829 let file = Some(path.to_string());
1830
1831 let all_rules = rules::all_rules(&Config::default());
1833 let registry = RuleRegistry::from_rules(&all_rules);
1834 let known_rule_names: BTreeSet<String> = registry
1835 .rule_names()
1836 .into_iter()
1837 .map(|s| s.to_ascii_uppercase())
1838 .collect();
1839
1840 if let Some(global_item) = doc.get("global")
1842 && let Some(global_table) = global_item.as_table()
1843 {
1844 for (key, value_item) in global_table.iter() {
1845 let norm_key = normalize_key(key);
1846 match norm_key.as_str() {
1847 "enable" | "disable" | "include" | "exclude" => {
1848 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
1849 let values: Vec<String> = formatted_array
1851 .iter()
1852 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
1854 .collect();
1855
1856 let final_values = if norm_key == "enable" || norm_key == "disable" {
1858 values.into_iter().map(|s| normalize_key(&s)).collect()
1860 } else {
1861 values
1862 };
1863
1864 match norm_key.as_str() {
1865 "enable" => fragment
1866 .global
1867 .enable
1868 .push_override(final_values, source, file.clone(), None),
1869 "disable" => {
1870 fragment
1871 .global
1872 .disable
1873 .push_override(final_values, source, file.clone(), None)
1874 }
1875 "include" => {
1876 fragment
1877 .global
1878 .include
1879 .push_override(final_values, source, file.clone(), None)
1880 }
1881 "exclude" => {
1882 fragment
1883 .global
1884 .exclude
1885 .push_override(final_values, source, file.clone(), None)
1886 }
1887 _ => unreachable!(), }
1889 } else {
1890 log::warn!(
1891 "[WARN] Expected array for global key '{}' in {}, found {}",
1892 key,
1893 path,
1894 value_item.type_name()
1895 );
1896 }
1897 }
1898 "respect_gitignore" | "respect-gitignore" => {
1899 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
1901 let val = *formatted_bool.value();
1902 fragment
1903 .global
1904 .respect_gitignore
1905 .push_override(val, source, file.clone(), None);
1906 } else {
1907 log::warn!(
1908 "[WARN] Expected boolean for global key '{}' in {}, found {}",
1909 key,
1910 path,
1911 value_item.type_name()
1912 );
1913 }
1914 }
1915 "line_length" | "line-length" => {
1916 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
1918 let val = *formatted_int.value() as u64;
1919 fragment
1920 .global
1921 .line_length
1922 .push_override(val, source, file.clone(), None);
1923 } else {
1924 log::warn!(
1925 "[WARN] Expected integer for global key '{}' in {}, found {}",
1926 key,
1927 path,
1928 value_item.type_name()
1929 );
1930 }
1931 }
1932 "output_format" | "output-format" => {
1933 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
1935 let val = formatted_string.value().clone();
1936 if fragment.global.output_format.is_none() {
1937 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
1938 } else {
1939 fragment.global.output_format.as_mut().unwrap().push_override(
1940 val,
1941 source,
1942 file.clone(),
1943 None,
1944 );
1945 }
1946 } else {
1947 log::warn!(
1948 "[WARN] Expected string for global key '{}' in {}, found {}",
1949 key,
1950 path,
1951 value_item.type_name()
1952 );
1953 }
1954 }
1955 "fixable" => {
1956 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
1957 let values: Vec<String> = formatted_array
1958 .iter()
1959 .filter_map(|item| item.as_str())
1960 .map(normalize_key)
1961 .collect();
1962 fragment
1963 .global
1964 .fixable
1965 .push_override(values, source, file.clone(), None);
1966 } else {
1967 log::warn!(
1968 "[WARN] Expected array for global key '{}' in {}, found {}",
1969 key,
1970 path,
1971 value_item.type_name()
1972 );
1973 }
1974 }
1975 "unfixable" => {
1976 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
1977 let values: Vec<String> = formatted_array
1978 .iter()
1979 .filter_map(|item| item.as_str())
1980 .map(normalize_key)
1981 .collect();
1982 fragment
1983 .global
1984 .unfixable
1985 .push_override(values, source, file.clone(), None);
1986 } else {
1987 log::warn!(
1988 "[WARN] Expected array for global key '{}' in {}, found {}",
1989 key,
1990 path,
1991 value_item.type_name()
1992 );
1993 }
1994 }
1995 _ => {
1996 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
1999 }
2000 }
2001 }
2002 }
2003
2004 for (key, item) in doc.iter() {
2006 let norm_rule_name = key.to_ascii_uppercase();
2007 if !known_rule_names.contains(&norm_rule_name) {
2008 continue;
2009 }
2010 if let Some(tbl) = item.as_table() {
2011 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
2012 for (rk, rv_item) in tbl.iter() {
2013 let norm_rk = normalize_key(rk);
2014 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
2015 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
2016 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
2017 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
2018 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
2019 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
2020 Some(toml_edit::Value::Array(formatted_array)) => {
2021 let mut values = Vec::new();
2023 for item in formatted_array.iter() {
2024 match item {
2025 toml_edit::Value::String(formatted) => {
2026 values.push(toml::Value::String(formatted.value().clone()))
2027 }
2028 toml_edit::Value::Integer(formatted) => {
2029 values.push(toml::Value::Integer(*formatted.value()))
2030 }
2031 toml_edit::Value::Float(formatted) => {
2032 values.push(toml::Value::Float(*formatted.value()))
2033 }
2034 toml_edit::Value::Boolean(formatted) => {
2035 values.push(toml::Value::Boolean(*formatted.value()))
2036 }
2037 toml_edit::Value::Datetime(formatted) => {
2038 values.push(toml::Value::Datetime(*formatted.value()))
2039 }
2040 _ => {
2041 log::warn!(
2042 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
2043 );
2044 }
2045 }
2046 }
2047 Some(toml::Value::Array(values))
2048 }
2049 Some(toml_edit::Value::InlineTable(_)) => {
2050 log::warn!(
2051 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
2052 );
2053 None
2054 }
2055 None => {
2056 log::warn!(
2057 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
2058 );
2059 None
2060 }
2061 };
2062 if let Some(toml_val) = maybe_toml_val {
2063 let sv = rule_entry
2064 .values
2065 .entry(norm_rk.clone())
2066 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2067 sv.push_override(toml_val, source, file.clone(), None);
2068 }
2069 }
2070 } else if item.is_value() {
2071 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
2072 }
2073 }
2074
2075 Ok(fragment)
2076}
2077
2078fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2080 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
2082 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
2083 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
2084}