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::fmt;
13use std::fs;
14use std::io;
15use std::path::Path;
16use std::str::FromStr;
17use toml_edit::DocumentMut;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
21#[serde(rename_all = "lowercase")]
22pub enum MarkdownFlavor {
23 #[serde(rename = "standard", alias = "none", alias = "")]
25 #[default]
26 Standard,
27 #[serde(rename = "mkdocs")]
29 MkDocs,
30 }
34
35impl fmt::Display for MarkdownFlavor {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 MarkdownFlavor::Standard => write!(f, "standard"),
39 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
40 }
41 }
42}
43
44impl FromStr for MarkdownFlavor {
45 type Err = String;
46
47 fn from_str(s: &str) -> Result<Self, Self::Err> {
48 match s.to_lowercase().as_str() {
49 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
50 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
51 "gfm" | "github" => {
53 eprintln!("Warning: GFM flavor not yet implemented, using standard");
54 Ok(MarkdownFlavor::Standard)
55 }
56 "commonmark" => {
57 eprintln!("Warning: CommonMark flavor not yet implemented, using standard");
58 Ok(MarkdownFlavor::Standard)
59 }
60 _ => Err(format!("Unknown markdown flavor: {s}")),
61 }
62 }
63}
64
65lazy_static! {
66 static ref MARKDOWNLINT_KEY_MAP: HashMap<&'static str, &'static str> = {
68 let mut m = HashMap::new();
69 m.insert("ul-style", "md004");
72 m.insert("code-block-style", "md046");
73 m.insert("ul-indent", "md007"); m.insert("line-length", "md013"); m
77 };
78}
79
80pub fn normalize_key(key: &str) -> String {
82 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
84 key.to_ascii_uppercase()
85 } else {
86 key.replace('_', "-").to_ascii_lowercase()
87 }
88}
89
90#[derive(Debug, Serialize, Deserialize, Default, PartialEq)]
92pub struct RuleConfig {
93 #[serde(flatten)]
95 pub values: BTreeMap<String, toml::Value>,
96}
97
98#[derive(Debug, Serialize, Deserialize, Default, PartialEq)]
100pub struct Config {
101 #[serde(default)]
103 pub global: GlobalConfig,
104
105 #[serde(flatten)]
107 pub rules: BTreeMap<String, RuleConfig>,
108}
109
110impl Config {
111 pub fn is_mkdocs_flavor(&self) -> bool {
113 self.global.flavor == MarkdownFlavor::MkDocs
114 }
115
116 pub fn markdown_flavor(&self) -> MarkdownFlavor {
122 self.global.flavor
123 }
124
125 pub fn is_mkdocs_project(&self) -> bool {
127 self.is_mkdocs_flavor()
128 }
129}
130
131#[derive(Debug, Serialize, Deserialize, PartialEq)]
133#[serde(default)]
134pub struct GlobalConfig {
135 #[serde(default)]
137 pub enable: Vec<String>,
138
139 #[serde(default)]
141 pub disable: Vec<String>,
142
143 #[serde(default)]
145 pub exclude: Vec<String>,
146
147 #[serde(default)]
149 pub include: Vec<String>,
150
151 #[serde(default = "default_respect_gitignore")]
153 pub respect_gitignore: bool,
154
155 #[serde(default = "default_line_length")]
157 pub line_length: u64,
158
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub output_format: Option<String>,
162
163 #[serde(default)]
166 pub fixable: Vec<String>,
167
168 #[serde(default)]
171 pub unfixable: Vec<String>,
172
173 #[serde(default)]
176 pub flavor: MarkdownFlavor,
177}
178
179fn default_respect_gitignore() -> bool {
180 true
181}
182
183fn default_line_length() -> u64 {
184 80
185}
186
187impl Default for GlobalConfig {
189 fn default() -> Self {
190 Self {
191 enable: Vec::new(),
192 disable: Vec::new(),
193 exclude: Vec::new(),
194 include: Vec::new(),
195 respect_gitignore: true,
196 line_length: 80,
197 output_format: None,
198 fixable: Vec::new(),
199 unfixable: Vec::new(),
200 flavor: MarkdownFlavor::default(),
201 }
202 }
203}
204
205const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
206 ".markdownlint.json",
207 ".markdownlint.jsonc",
208 ".markdownlint.yaml",
209 ".markdownlint.yml",
210 "markdownlint.json",
211 "markdownlint.jsonc",
212 "markdownlint.yaml",
213 "markdownlint.yml",
214];
215
216pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
218 if Path::new(path).exists() {
220 return Err(ConfigError::FileExists { path: path.to_string() });
221 }
222
223 let default_config = r#"# rumdl configuration file
225
226# Global configuration options
227[global]
228# List of rules to disable (uncomment and modify as needed)
229# disable = ["MD013", "MD033"]
230
231# List of rules to enable exclusively (if provided, only these rules will run)
232# enable = ["MD001", "MD003", "MD004"]
233
234# List of file/directory patterns to include for linting (if provided, only these will be linted)
235# include = [
236# "docs/*.md",
237# "src/**/*.md",
238# "README.md"
239# ]
240
241# List of file/directory patterns to exclude from linting
242exclude = [
243 # Common directories to exclude
244 ".git",
245 ".github",
246 "node_modules",
247 "vendor",
248 "dist",
249 "build",
250
251 # Specific files or patterns
252 "CHANGELOG.md",
253 "LICENSE.md",
254]
255
256# Respect .gitignore files when scanning directories (default: true)
257respect_gitignore = true
258
259# Markdown flavor/dialect (uncomment to enable)
260# Options: mkdocs, gfm, commonmark
261# flavor = "mkdocs"
262
263# Rule-specific configurations (uncomment and modify as needed)
264
265# [MD003]
266# style = "atx" # Heading style (atx, atx_closed, setext)
267
268# [MD004]
269# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
270
271# [MD007]
272# indent = 4 # Unordered list indentation
273
274# [MD013]
275# line_length = 100 # Line length
276# code_blocks = false # Exclude code blocks from line length check
277# tables = false # Exclude tables from line length check
278# headings = true # Include headings in line length check
279
280# [MD044]
281# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
282# code_blocks_excluded = true # Exclude code blocks from proper name check
283"#;
284
285 match fs::write(path, default_config) {
287 Ok(_) => Ok(()),
288 Err(err) => Err(ConfigError::IoError {
289 source: err,
290 path: path.to_string(),
291 }),
292 }
293}
294
295#[derive(Debug, thiserror::Error)]
297pub enum ConfigError {
298 #[error("Failed to read config file at {path}: {source}")]
300 IoError { source: io::Error, path: String },
301
302 #[error("Failed to parse config: {0}")]
304 ParseError(String),
305
306 #[error("Configuration file already exists at {path}")]
308 FileExists { path: String },
309}
310
311pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
315 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
318
319 let key_variants = [
321 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
326
327 for variant in &key_variants {
329 if let Some(value) = rule_config.values.get(variant)
330 && let Ok(result) = T::deserialize(value.clone())
331 {
332 return Some(result);
333 }
334 }
335
336 None
337}
338
339pub fn generate_pyproject_config() -> String {
341 let config_content = r#"
342[tool.rumdl]
343# Global configuration options
344line-length = 100
345disable = []
346exclude = [
347 # Common directories to exclude
348 ".git",
349 ".github",
350 "node_modules",
351 "vendor",
352 "dist",
353 "build",
354]
355respect-gitignore = true
356
357# Rule-specific configurations (uncomment and modify as needed)
358
359# [tool.rumdl.MD003]
360# style = "atx" # Heading style (atx, atx_closed, setext)
361
362# [tool.rumdl.MD004]
363# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
364
365# [tool.rumdl.MD007]
366# indent = 4 # Unordered list indentation
367
368# [tool.rumdl.MD013]
369# line_length = 100 # Line length
370# code_blocks = false # Exclude code blocks from line length check
371# tables = false # Exclude tables from line length check
372# headings = true # Include headings in line length check
373
374# [tool.rumdl.MD044]
375# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
376# code_blocks_excluded = true # Exclude code blocks from proper name check
377"#;
378
379 config_content.to_string()
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use std::fs;
386 use tempfile::tempdir;
387
388 #[test]
389 fn test_flavor_loading() {
390 let temp_dir = tempdir().unwrap();
391 let config_path = temp_dir.path().join(".rumdl.toml");
392 let config_content = r#"
393[global]
394flavor = "mkdocs"
395disable = ["MD001"]
396"#;
397 fs::write(&config_path, config_content).unwrap();
398
399 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
401 let config: Config = sourced.into();
402
403 assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
405 assert!(config.is_mkdocs_flavor());
406 assert!(config.is_mkdocs_project()); assert_eq!(config.global.disable, vec!["MD001".to_string()]);
408 }
409
410 #[test]
411 fn test_pyproject_toml_root_level_config() {
412 let temp_dir = tempdir().unwrap();
413 let config_path = temp_dir.path().join("pyproject.toml");
414
415 let content = r#"
417[tool.rumdl]
418line-length = 120
419disable = ["MD033"]
420enable = ["MD001", "MD004"]
421include = ["docs/*.md"]
422exclude = ["node_modules"]
423respect-gitignore = true
424 "#;
425
426 fs::write(&config_path, content).unwrap();
427
428 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
430 let config: Config = sourced.into(); assert_eq!(config.global.disable, vec!["MD033".to_string()]);
434 assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
435 assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
437 assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
438 assert!(config.global.respect_gitignore);
439
440 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
442 assert_eq!(line_length, Some(120));
443 }
444
445 #[test]
446 fn test_pyproject_toml_snake_case_and_kebab_case() {
447 let temp_dir = tempdir().unwrap();
448 let config_path = temp_dir.path().join("pyproject.toml");
449
450 let content = r#"
452[tool.rumdl]
453line-length = 150
454respect_gitignore = true
455 "#;
456
457 fs::write(&config_path, content).unwrap();
458
459 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
461 let config: Config = sourced.into(); assert!(config.global.respect_gitignore);
465 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
466 assert_eq!(line_length, Some(150));
467 }
468
469 #[test]
470 fn test_md013_key_normalization_in_rumdl_toml() {
471 let temp_dir = tempdir().unwrap();
472 let config_path = temp_dir.path().join(".rumdl.toml");
473 let config_content = r#"
474[MD013]
475line_length = 111
476line-length = 222
477"#;
478 fs::write(&config_path, config_content).unwrap();
479 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
481 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
482 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
484 assert_eq!(keys, vec!["line-length"]);
485 let val = &rule_cfg.values["line-length"].value;
486 assert_eq!(val.as_integer(), Some(222));
487 let config: Config = sourced.clone().into();
489 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
490 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
491 assert_eq!(v1, Some(222));
492 assert_eq!(v2, Some(222));
493 }
494
495 #[test]
496 fn test_md013_section_case_insensitivity() {
497 let temp_dir = tempdir().unwrap();
498 let config_path = temp_dir.path().join(".rumdl.toml");
499 let config_content = r#"
500[md013]
501line-length = 101
502
503[Md013]
504line-length = 102
505
506[MD013]
507line-length = 103
508"#;
509 fs::write(&config_path, config_content).unwrap();
510 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
512 let config: Config = sourced.clone().into();
513 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
515 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
516 assert_eq!(keys, vec!["line-length"]);
517 let val = &rule_cfg.values["line-length"].value;
518 assert_eq!(val.as_integer(), Some(103));
519 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
520 assert_eq!(v, Some(103));
521 }
522
523 #[test]
524 fn test_md013_key_snake_and_kebab_case() {
525 let temp_dir = tempdir().unwrap();
526 let config_path = temp_dir.path().join(".rumdl.toml");
527 let config_content = r#"
528[MD013]
529line_length = 201
530line-length = 202
531"#;
532 fs::write(&config_path, config_content).unwrap();
533 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
535 let config: Config = sourced.clone().into();
536 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
537 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
538 assert_eq!(keys, vec!["line-length"]);
539 let val = &rule_cfg.values["line-length"].value;
540 assert_eq!(val.as_integer(), Some(202));
541 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
542 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
543 assert_eq!(v1, Some(202));
544 assert_eq!(v2, Some(202));
545 }
546
547 #[test]
548 fn test_unknown_rule_section_is_ignored() {
549 let temp_dir = tempdir().unwrap();
550 let config_path = temp_dir.path().join(".rumdl.toml");
551 let config_content = r#"
552[MD999]
553foo = 1
554bar = 2
555[MD013]
556line-length = 303
557"#;
558 fs::write(&config_path, config_content).unwrap();
559 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
561 let config: Config = sourced.clone().into();
562 assert!(!sourced.rules.contains_key("MD999"));
564 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
566 assert_eq!(v, Some(303));
567 }
568
569 #[test]
570 fn test_invalid_toml_syntax() {
571 let temp_dir = tempdir().unwrap();
572 let config_path = temp_dir.path().join(".rumdl.toml");
573
574 let config_content = r#"
576[MD013]
577line-length = "unclosed string
578"#;
579 fs::write(&config_path, config_content).unwrap();
580
581 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
582 assert!(result.is_err());
583 match result.unwrap_err() {
584 ConfigError::ParseError(msg) => {
585 assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
587 }
588 _ => panic!("Expected ParseError"),
589 }
590 }
591
592 #[test]
593 fn test_wrong_type_for_config_value() {
594 let temp_dir = tempdir().unwrap();
595 let config_path = temp_dir.path().join(".rumdl.toml");
596
597 let config_content = r#"
599[MD013]
600line-length = "not a number"
601"#;
602 fs::write(&config_path, config_content).unwrap();
603
604 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
605 let config: Config = sourced.into();
606
607 let rule_config = config.rules.get("MD013").unwrap();
609 let value = rule_config.values.get("line-length").unwrap();
610 assert!(matches!(value, toml::Value::String(_)));
611 }
612
613 #[test]
614 fn test_empty_config_file() {
615 let temp_dir = tempdir().unwrap();
616 let config_path = temp_dir.path().join(".rumdl.toml");
617
618 fs::write(&config_path, "").unwrap();
620
621 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
622 let config: Config = sourced.into();
623
624 assert_eq!(config.global.line_length, 80);
626 assert!(config.global.respect_gitignore);
627 assert!(config.rules.is_empty());
628 }
629
630 #[test]
631 fn test_malformed_pyproject_toml() {
632 let temp_dir = tempdir().unwrap();
633 let config_path = temp_dir.path().join("pyproject.toml");
634
635 let content = r#"
637[tool.rumdl
638line-length = 120
639"#;
640 fs::write(&config_path, content).unwrap();
641
642 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
643 assert!(result.is_err());
644 }
645
646 #[test]
647 fn test_conflicting_config_values() {
648 let temp_dir = tempdir().unwrap();
649 let config_path = temp_dir.path().join(".rumdl.toml");
650
651 let config_content = r#"
653[global]
654enable = ["MD013"]
655disable = ["MD013"]
656"#;
657 fs::write(&config_path, config_content).unwrap();
658
659 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
660 let config: Config = sourced.into();
661
662 assert!(config.global.enable.contains(&"MD013".to_string()));
664 assert!(config.global.disable.contains(&"MD013".to_string()));
665 }
666
667 #[test]
668 fn test_invalid_rule_names() {
669 let temp_dir = tempdir().unwrap();
670 let config_path = temp_dir.path().join(".rumdl.toml");
671
672 let config_content = r#"
673[global]
674enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
675disable = ["MD-001", "MD_002"]
676"#;
677 fs::write(&config_path, config_content).unwrap();
678
679 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
680 let config: Config = sourced.into();
681
682 assert_eq!(config.global.enable.len(), 4);
684 assert_eq!(config.global.disable.len(), 2);
685 }
686
687 #[test]
688 fn test_deeply_nested_config() {
689 let temp_dir = tempdir().unwrap();
690 let config_path = temp_dir.path().join(".rumdl.toml");
691
692 let config_content = r#"
694[MD013]
695line-length = 100
696[MD013.nested]
697value = 42
698"#;
699 fs::write(&config_path, config_content).unwrap();
700
701 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
702 let config: Config = sourced.into();
703
704 let rule_config = config.rules.get("MD013").unwrap();
705 assert_eq!(
706 rule_config.values.get("line-length").unwrap(),
707 &toml::Value::Integer(100)
708 );
709 assert!(!rule_config.values.contains_key("nested"));
711 }
712
713 #[test]
714 fn test_unicode_in_config() {
715 let temp_dir = tempdir().unwrap();
716 let config_path = temp_dir.path().join(".rumdl.toml");
717
718 let config_content = r#"
719[global]
720include = ["文档/*.md", "ドキュメント/*.md"]
721exclude = ["测试/*", "🚀/*"]
722
723[MD013]
724line-length = 80
725message = "行太长了 🚨"
726"#;
727 fs::write(&config_path, config_content).unwrap();
728
729 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
730 let config: Config = sourced.into();
731
732 assert_eq!(config.global.include.len(), 2);
733 assert_eq!(config.global.exclude.len(), 2);
734 assert!(config.global.include[0].contains("文档"));
735 assert!(config.global.exclude[1].contains("🚀"));
736
737 let rule_config = config.rules.get("MD013").unwrap();
738 let message = rule_config.values.get("message").unwrap();
739 if let toml::Value::String(s) = message {
740 assert!(s.contains("行太长了"));
741 assert!(s.contains("🚨"));
742 }
743 }
744
745 #[test]
746 fn test_extremely_long_values() {
747 let temp_dir = tempdir().unwrap();
748 let config_path = temp_dir.path().join(".rumdl.toml");
749
750 let long_string = "a".repeat(10000);
751 let config_content = format!(
752 r#"
753[global]
754exclude = ["{long_string}"]
755
756[MD013]
757line-length = 999999999
758"#
759 );
760
761 fs::write(&config_path, config_content).unwrap();
762
763 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
764 let config: Config = sourced.into();
765
766 assert_eq!(config.global.exclude[0].len(), 10000);
767 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
768 assert_eq!(line_length, Some(999999999));
769 }
770
771 #[test]
772 fn test_config_with_comments() {
773 let temp_dir = tempdir().unwrap();
774 let config_path = temp_dir.path().join(".rumdl.toml");
775
776 let config_content = r#"
777[global]
778# This is a comment
779enable = ["MD001"] # Enable MD001
780# disable = ["MD002"] # This is commented out
781
782[MD013] # Line length rule
783line-length = 100 # Set to 100 characters
784# ignored = true # This setting is commented out
785"#;
786 fs::write(&config_path, config_content).unwrap();
787
788 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
789 let config: Config = sourced.into();
790
791 assert_eq!(config.global.enable, vec!["MD001"]);
792 assert!(config.global.disable.is_empty()); let rule_config = config.rules.get("MD013").unwrap();
795 assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
797 }
798
799 #[test]
800 fn test_arrays_in_rule_config() {
801 let temp_dir = tempdir().unwrap();
802 let config_path = temp_dir.path().join(".rumdl.toml");
803
804 let config_content = r#"
805[MD002]
806levels = [1, 2, 3]
807tags = ["important", "critical"]
808mixed = [1, "two", true]
809"#;
810 fs::write(&config_path, config_content).unwrap();
811
812 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
813 let config: Config = sourced.into();
814
815 let rule_config = config.rules.get("MD002").expect("MD002 config should exist");
817
818 assert!(rule_config.values.contains_key("levels"));
820 assert!(rule_config.values.contains_key("tags"));
821 assert!(rule_config.values.contains_key("mixed"));
822
823 if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
825 assert_eq!(levels.len(), 3);
826 assert_eq!(levels[0], toml::Value::Integer(1));
827 assert_eq!(levels[1], toml::Value::Integer(2));
828 assert_eq!(levels[2], toml::Value::Integer(3));
829 } else {
830 panic!("levels should be an array");
831 }
832
833 if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
834 assert_eq!(tags.len(), 2);
835 assert_eq!(tags[0], toml::Value::String("important".to_string()));
836 assert_eq!(tags[1], toml::Value::String("critical".to_string()));
837 } else {
838 panic!("tags should be an array");
839 }
840
841 if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
842 assert_eq!(mixed.len(), 3);
843 assert_eq!(mixed[0], toml::Value::Integer(1));
844 assert_eq!(mixed[1], toml::Value::String("two".to_string()));
845 assert_eq!(mixed[2], toml::Value::Boolean(true));
846 } else {
847 panic!("mixed should be an array");
848 }
849 }
850
851 #[test]
852 fn test_normalize_key_edge_cases() {
853 assert_eq!(normalize_key("MD001"), "MD001");
855 assert_eq!(normalize_key("md001"), "MD001");
856 assert_eq!(normalize_key("Md001"), "MD001");
857 assert_eq!(normalize_key("mD001"), "MD001");
858
859 assert_eq!(normalize_key("line_length"), "line-length");
861 assert_eq!(normalize_key("line-length"), "line-length");
862 assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
863 assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
864
865 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(""), "");
872 assert_eq!(normalize_key("_"), "-");
873 assert_eq!(normalize_key("___"), "---");
874 }
875
876 #[test]
877 fn test_missing_config_file() {
878 let temp_dir = tempdir().unwrap();
879 let config_path = temp_dir.path().join("nonexistent.toml");
880
881 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
882 assert!(result.is_err());
883 match result.unwrap_err() {
884 ConfigError::IoError { .. } => {}
885 _ => panic!("Expected IoError for missing file"),
886 }
887 }
888
889 #[test]
890 #[cfg(unix)]
891 fn test_permission_denied_config() {
892 use std::os::unix::fs::PermissionsExt;
893
894 let temp_dir = tempdir().unwrap();
895 let config_path = temp_dir.path().join(".rumdl.toml");
896
897 fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
898
899 let mut perms = fs::metadata(&config_path).unwrap().permissions();
901 perms.set_mode(0o000);
902 fs::set_permissions(&config_path, perms).unwrap();
903
904 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
905
906 let mut perms = fs::metadata(&config_path).unwrap().permissions();
908 perms.set_mode(0o644);
909 fs::set_permissions(&config_path, perms).unwrap();
910
911 assert!(result.is_err());
912 match result.unwrap_err() {
913 ConfigError::IoError { .. } => {}
914 _ => panic!("Expected IoError for permission denied"),
915 }
916 }
917
918 #[test]
919 fn test_circular_reference_detection() {
920 let temp_dir = tempdir().unwrap();
923 let config_path = temp_dir.path().join(".rumdl.toml");
924
925 let mut config_content = String::from("[MD001]\n");
926 for i in 0..100 {
927 config_content.push_str(&format!("key{i} = {i}\n"));
928 }
929
930 fs::write(&config_path, config_content).unwrap();
931
932 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
933 let config: Config = sourced.into();
934
935 let rule_config = config.rules.get("MD001").unwrap();
936 assert_eq!(rule_config.values.len(), 100);
937 }
938
939 #[test]
940 fn test_special_toml_values() {
941 let temp_dir = tempdir().unwrap();
942 let config_path = temp_dir.path().join(".rumdl.toml");
943
944 let config_content = r#"
945[MD001]
946infinity = inf
947neg_infinity = -inf
948not_a_number = nan
949datetime = 1979-05-27T07:32:00Z
950local_date = 1979-05-27
951local_time = 07:32:00
952"#;
953 fs::write(&config_path, config_content).unwrap();
954
955 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
956 let config: Config = sourced.into();
957
958 if let Some(rule_config) = config.rules.get("MD001") {
960 if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
962 assert!(f.is_infinite() && f.is_sign_positive());
963 }
964 if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
965 assert!(f.is_infinite() && f.is_sign_negative());
966 }
967 if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
968 assert!(f.is_nan());
969 }
970
971 if let Some(val) = rule_config.values.get("datetime") {
973 assert!(matches!(val, toml::Value::Datetime(_)));
974 }
975 }
977 }
978
979 #[test]
980 fn test_default_config_passes_validation() {
981 use crate::rules;
982
983 let temp_dir = tempdir().unwrap();
984 let config_path = temp_dir.path().join(".rumdl.toml");
985 let config_path_str = config_path.to_str().unwrap();
986
987 create_default_config(config_path_str).unwrap();
989
990 let sourced =
992 SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
993
994 let all_rules = rules::all_rules(&Config::default());
996 let registry = RuleRegistry::from_rules(&all_rules);
997
998 let warnings = validate_config_sourced(&sourced, ®istry);
1000
1001 if !warnings.is_empty() {
1003 for warning in &warnings {
1004 eprintln!("Config validation warning: {}", warning.message);
1005 if let Some(rule) = &warning.rule {
1006 eprintln!(" Rule: {rule}");
1007 }
1008 if let Some(key) = &warning.key {
1009 eprintln!(" Key: {key}");
1010 }
1011 }
1012 }
1013 assert!(
1014 warnings.is_empty(),
1015 "Default config from rumdl init should pass validation without warnings"
1016 );
1017 }
1018}
1019
1020#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1021pub enum ConfigSource {
1022 Default,
1023 RumdlToml,
1024 PyprojectToml,
1025 Cli,
1026 Markdownlint,
1028}
1029
1030#[derive(Debug, Clone)]
1031pub struct ConfigOverride<T> {
1032 pub value: T,
1033 pub source: ConfigSource,
1034 pub file: Option<String>,
1035 pub line: Option<usize>,
1036}
1037
1038#[derive(Debug, Clone)]
1039pub struct SourcedValue<T> {
1040 pub value: T,
1041 pub source: ConfigSource,
1042 pub overrides: Vec<ConfigOverride<T>>,
1043}
1044
1045impl<T: Clone> SourcedValue<T> {
1046 pub fn new(value: T, source: ConfigSource) -> Self {
1047 Self {
1048 value: value.clone(),
1049 source,
1050 overrides: vec![ConfigOverride {
1051 value,
1052 source,
1053 file: None,
1054 line: None,
1055 }],
1056 }
1057 }
1058
1059 pub fn merge_override(
1063 &mut self,
1064 new_value: T,
1065 new_source: ConfigSource,
1066 new_file: Option<String>,
1067 new_line: Option<usize>,
1068 ) {
1069 fn source_precedence(src: ConfigSource) -> u8 {
1071 match src {
1072 ConfigSource::Default => 0,
1073 ConfigSource::PyprojectToml => 1,
1074 ConfigSource::Markdownlint => 2,
1075 ConfigSource::RumdlToml => 3,
1076 ConfigSource::Cli => 4,
1077 }
1078 }
1079
1080 if source_precedence(new_source) >= source_precedence(self.source) {
1081 self.value = new_value.clone();
1082 self.source = new_source;
1083 self.overrides.push(ConfigOverride {
1084 value: new_value,
1085 source: new_source,
1086 file: new_file,
1087 line: new_line,
1088 });
1089 }
1090 }
1091
1092 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
1093 self.value = value.clone();
1096 self.source = source;
1097 self.overrides.push(ConfigOverride {
1098 value,
1099 source,
1100 file,
1101 line,
1102 });
1103 }
1104}
1105
1106#[derive(Debug, Clone)]
1107pub struct SourcedGlobalConfig {
1108 pub enable: SourcedValue<Vec<String>>,
1109 pub disable: SourcedValue<Vec<String>>,
1110 pub exclude: SourcedValue<Vec<String>>,
1111 pub include: SourcedValue<Vec<String>>,
1112 pub respect_gitignore: SourcedValue<bool>,
1113 pub line_length: SourcedValue<u64>,
1114 pub output_format: Option<SourcedValue<String>>,
1115 pub fixable: SourcedValue<Vec<String>>,
1116 pub unfixable: SourcedValue<Vec<String>>,
1117 pub flavor: SourcedValue<MarkdownFlavor>,
1118}
1119
1120impl Default for SourcedGlobalConfig {
1121 fn default() -> Self {
1122 SourcedGlobalConfig {
1123 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1124 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1125 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
1126 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
1127 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
1128 line_length: SourcedValue::new(80, ConfigSource::Default),
1129 output_format: None,
1130 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1131 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1132 flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
1133 }
1134 }
1135}
1136
1137#[derive(Debug, Default, Clone)]
1138pub struct SourcedRuleConfig {
1139 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
1140}
1141
1142#[derive(Debug, Default, Clone)]
1145pub struct SourcedConfigFragment {
1146 pub global: SourcedGlobalConfig,
1147 pub rules: BTreeMap<String, SourcedRuleConfig>,
1148 }
1150
1151#[derive(Debug, Default, Clone)]
1152pub struct SourcedConfig {
1153 pub global: SourcedGlobalConfig,
1154 pub rules: BTreeMap<String, SourcedRuleConfig>,
1155 pub loaded_files: Vec<String>,
1156 pub unknown_keys: Vec<(String, String)>, }
1158
1159impl SourcedConfig {
1160 fn merge(&mut self, fragment: SourcedConfigFragment) {
1163 self.global.enable.merge_override(
1165 fragment.global.enable.value,
1166 fragment.global.enable.source,
1167 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
1168 fragment.global.enable.overrides.first().and_then(|o| o.line),
1169 );
1170 self.global.disable.merge_override(
1171 fragment.global.disable.value,
1172 fragment.global.disable.source,
1173 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
1174 fragment.global.disable.overrides.first().and_then(|o| o.line),
1175 );
1176 self.global.include.merge_override(
1177 fragment.global.include.value,
1178 fragment.global.include.source,
1179 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
1180 fragment.global.include.overrides.first().and_then(|o| o.line),
1181 );
1182 self.global.exclude.merge_override(
1183 fragment.global.exclude.value,
1184 fragment.global.exclude.source,
1185 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
1186 fragment.global.exclude.overrides.first().and_then(|o| o.line),
1187 );
1188 self.global.respect_gitignore.merge_override(
1189 fragment.global.respect_gitignore.value,
1190 fragment.global.respect_gitignore.source,
1191 fragment
1192 .global
1193 .respect_gitignore
1194 .overrides
1195 .first()
1196 .and_then(|o| o.file.clone()),
1197 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
1198 );
1199 self.global.line_length.merge_override(
1200 fragment.global.line_length.value,
1201 fragment.global.line_length.source,
1202 fragment
1203 .global
1204 .line_length
1205 .overrides
1206 .first()
1207 .and_then(|o| o.file.clone()),
1208 fragment.global.line_length.overrides.first().and_then(|o| o.line),
1209 );
1210 self.global.fixable.merge_override(
1211 fragment.global.fixable.value,
1212 fragment.global.fixable.source,
1213 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
1214 fragment.global.fixable.overrides.first().and_then(|o| o.line),
1215 );
1216 self.global.unfixable.merge_override(
1217 fragment.global.unfixable.value,
1218 fragment.global.unfixable.source,
1219 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
1220 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
1221 );
1222
1223 self.global.flavor.merge_override(
1225 fragment.global.flavor.value,
1226 fragment.global.flavor.source,
1227 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
1228 fragment.global.flavor.overrides.first().and_then(|o| o.line),
1229 );
1230
1231 if let Some(output_format_fragment) = fragment.global.output_format {
1233 if let Some(ref mut output_format) = self.global.output_format {
1234 output_format.merge_override(
1235 output_format_fragment.value,
1236 output_format_fragment.source,
1237 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
1238 output_format_fragment.overrides.first().and_then(|o| o.line),
1239 );
1240 } else {
1241 self.global.output_format = Some(output_format_fragment);
1242 }
1243 }
1244
1245 for (rule_name, rule_fragment) in fragment.rules {
1247 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
1249 for (key, sourced_value_fragment) in rule_fragment.values {
1250 let sv_entry = rule_entry
1251 .values
1252 .entry(key.clone())
1253 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
1254 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
1255 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
1256 sv_entry.merge_override(
1257 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
1262 }
1263 }
1264 }
1265
1266 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
1268 Self::load_with_discovery(config_path, cli_overrides, false)
1269 }
1270
1271 fn discover_config_upward() -> Option<std::path::PathBuf> {
1274 use std::env;
1275
1276 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1277 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
1280 Ok(dir) => dir,
1281 Err(e) => {
1282 log::debug!("[rumdl-config] Failed to get current directory: {e}");
1283 return None;
1284 }
1285 };
1286
1287 let mut current_dir = start_dir.clone();
1288 let mut depth = 0;
1289
1290 loop {
1291 if depth >= MAX_DEPTH {
1292 log::debug!("[rumdl-config] Maximum traversal depth reached");
1293 break;
1294 }
1295
1296 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
1297
1298 for config_name in CONFIG_FILES {
1300 let config_path = current_dir.join(config_name);
1301
1302 if config_path.exists() {
1303 if *config_name == "pyproject.toml" {
1305 if let Ok(content) = std::fs::read_to_string(&config_path) {
1306 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1307 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1308 return Some(config_path);
1309 }
1310 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
1311 continue;
1312 }
1313 } else {
1314 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1315 return Some(config_path);
1316 }
1317 }
1318 }
1319
1320 if current_dir.join(".git").exists() {
1322 log::debug!("[rumdl-config] Stopping at .git directory");
1323 break;
1324 }
1325
1326 match current_dir.parent() {
1328 Some(parent) => {
1329 current_dir = parent.to_owned();
1330 depth += 1;
1331 }
1332 None => {
1333 log::debug!("[rumdl-config] Reached filesystem root");
1334 break;
1335 }
1336 }
1337 }
1338
1339 None
1340 }
1341
1342 pub fn load_with_discovery(
1345 config_path: Option<&str>,
1346 cli_overrides: Option<&SourcedGlobalConfig>,
1347 skip_auto_discovery: bool,
1348 ) -> Result<Self, ConfigError> {
1349 use std::env;
1350 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1351 if config_path.is_none() {
1352 if skip_auto_discovery {
1353 log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
1354 } else {
1355 log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
1356 }
1357 } else {
1358 log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
1359 }
1360 let mut sourced_config = SourcedConfig::default();
1361
1362 if let Some(path) = config_path {
1364 let path_obj = Path::new(path);
1365 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
1366 log::debug!("[rumdl-config] Trying to load config file: {filename}");
1367 let path_str = path.to_string();
1368
1369 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
1371
1372 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
1373 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1374 source: e,
1375 path: path_str.clone(),
1376 })?;
1377 if filename == "pyproject.toml" {
1378 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1379 sourced_config.merge(fragment);
1380 sourced_config.loaded_files.push(path_str.clone());
1381 }
1382 } else {
1383 let fragment = parse_rumdl_toml(&content, &path_str)?;
1384 sourced_config.merge(fragment);
1385 sourced_config.loaded_files.push(path_str.clone());
1386 }
1387 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
1388 || path_str.ends_with(".json")
1389 || path_str.ends_with(".jsonc")
1390 || path_str.ends_with(".yaml")
1391 || path_str.ends_with(".yml")
1392 {
1393 let fragment = load_from_markdownlint(&path_str)?;
1395 sourced_config.merge(fragment);
1396 sourced_config.loaded_files.push(path_str.clone());
1397 } else {
1399 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1401 source: e,
1402 path: path_str.clone(),
1403 })?;
1404 let fragment = parse_rumdl_toml(&content, &path_str)?;
1405 sourced_config.merge(fragment);
1406 sourced_config.loaded_files.push(path_str.clone());
1407 }
1408 }
1409
1410 if !skip_auto_discovery && config_path.is_none() {
1412 if let Some(config_file) = Self::discover_config_upward() {
1414 let path_str = config_file.display().to_string();
1415 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
1416
1417 log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
1418
1419 if filename == "pyproject.toml" {
1420 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1421 source: e,
1422 path: path_str.clone(),
1423 })?;
1424 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1425 sourced_config.merge(fragment);
1426 sourced_config.loaded_files.push(path_str);
1427 }
1428 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
1429 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1430 source: e,
1431 path: path_str.clone(),
1432 })?;
1433 let fragment = parse_rumdl_toml(&content, &path_str)?;
1434 sourced_config.merge(fragment);
1435 sourced_config.loaded_files.push(path_str);
1436 }
1437 } else {
1438 log::debug!("[rumdl-config] No configuration file found via upward traversal");
1439
1440 for filename in MARKDOWNLINT_CONFIG_FILES {
1442 if std::path::Path::new(filename).exists() {
1443 match load_from_markdownlint(filename) {
1444 Ok(fragment) => {
1445 sourced_config.merge(fragment);
1446 sourced_config.loaded_files.push(filename.to_string());
1447 break; }
1449 Err(_e) => {
1450 }
1452 }
1453 }
1454 }
1455 }
1456 }
1457
1458 if let Some(cli) = cli_overrides {
1460 sourced_config
1461 .global
1462 .enable
1463 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
1464 sourced_config
1465 .global
1466 .disable
1467 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
1468 sourced_config
1469 .global
1470 .exclude
1471 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
1472 sourced_config
1473 .global
1474 .include
1475 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
1476 sourced_config.global.respect_gitignore.merge_override(
1477 cli.respect_gitignore.value,
1478 ConfigSource::Cli,
1479 None,
1480 None,
1481 );
1482 sourced_config
1483 .global
1484 .fixable
1485 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
1486 sourced_config
1487 .global
1488 .unfixable
1489 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
1490 }
1492
1493 Ok(sourced_config)
1496 }
1497}
1498
1499impl From<SourcedConfig> for Config {
1500 fn from(sourced: SourcedConfig) -> Self {
1501 let mut rules = BTreeMap::new();
1502 for (rule_name, sourced_rule_cfg) in sourced.rules {
1503 let normalized_rule_name = rule_name.to_ascii_uppercase();
1505 let mut values = BTreeMap::new();
1506 for (key, sourced_val) in sourced_rule_cfg.values {
1507 values.insert(key, sourced_val.value);
1508 }
1509 rules.insert(normalized_rule_name, RuleConfig { values });
1510 }
1511 let global = GlobalConfig {
1512 enable: sourced.global.enable.value,
1513 disable: sourced.global.disable.value,
1514 exclude: sourced.global.exclude.value,
1515 include: sourced.global.include.value,
1516 respect_gitignore: sourced.global.respect_gitignore.value,
1517 line_length: sourced.global.line_length.value,
1518 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1519 fixable: sourced.global.fixable.value,
1520 unfixable: sourced.global.unfixable.value,
1521 flavor: sourced.global.flavor.value,
1522 };
1523 Config { global, rules }
1524 }
1525}
1526
1527pub struct RuleRegistry {
1529 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
1531 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
1533}
1534
1535impl RuleRegistry {
1536 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
1538 let mut rule_schemas = std::collections::BTreeMap::new();
1539 let mut rule_aliases = std::collections::BTreeMap::new();
1540
1541 for rule in rules {
1542 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
1543 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
1545 norm_name
1546 } else {
1547 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
1549 norm_name
1550 };
1551
1552 if let Some(aliases) = rule.config_aliases() {
1554 rule_aliases.insert(norm_name, aliases);
1555 }
1556 }
1557
1558 RuleRegistry {
1559 rule_schemas,
1560 rule_aliases,
1561 }
1562 }
1563
1564 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
1566 self.rule_schemas.keys().cloned().collect()
1567 }
1568
1569 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
1571 self.rule_schemas.get(rule).map(|schema| {
1572 let mut all_keys = std::collections::BTreeSet::new();
1573
1574 for key in schema.keys() {
1576 all_keys.insert(key.clone());
1577 }
1578
1579 for key in schema.keys() {
1581 all_keys.insert(key.replace('_', "-"));
1583 all_keys.insert(key.replace('-', "_"));
1585 all_keys.insert(normalize_key(key));
1587 }
1588
1589 if let Some(aliases) = self.rule_aliases.get(rule) {
1591 for alias_key in aliases.keys() {
1592 all_keys.insert(alias_key.clone());
1593 all_keys.insert(alias_key.replace('_', "-"));
1595 all_keys.insert(alias_key.replace('-', "_"));
1596 all_keys.insert(normalize_key(alias_key));
1597 }
1598 }
1599
1600 all_keys
1601 })
1602 }
1603
1604 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
1606 if let Some(schema) = self.rule_schemas.get(rule) {
1607 if let Some(aliases) = self.rule_aliases.get(rule)
1609 && let Some(canonical_key) = aliases.get(key)
1610 {
1611 if let Some(value) = schema.get(canonical_key) {
1613 return Some(value);
1614 }
1615 }
1616
1617 if let Some(value) = schema.get(key) {
1619 return Some(value);
1620 }
1621
1622 let key_variants = [
1624 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
1628
1629 for variant in &key_variants {
1630 if let Some(value) = schema.get(variant) {
1631 return Some(value);
1632 }
1633 }
1634 }
1635 None
1636 }
1637}
1638
1639#[derive(Debug, Clone)]
1641pub struct ConfigValidationWarning {
1642 pub message: String,
1643 pub rule: Option<String>,
1644 pub key: Option<String>,
1645}
1646
1647pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
1649 let mut warnings = Vec::new();
1650 let known_rules = registry.rule_names();
1651 for rule in sourced.rules.keys() {
1653 if !known_rules.contains(rule) {
1654 warnings.push(ConfigValidationWarning {
1655 message: format!("Unknown rule in config: {rule}"),
1656 rule: Some(rule.clone()),
1657 key: None,
1658 });
1659 }
1660 }
1661 for (rule, rule_cfg) in &sourced.rules {
1663 if let Some(valid_keys) = registry.config_keys_for(rule) {
1664 for key in rule_cfg.values.keys() {
1665 if !valid_keys.contains(key) {
1666 warnings.push(ConfigValidationWarning {
1667 message: format!("Unknown option for rule {rule}: {key}"),
1668 rule: Some(rule.clone()),
1669 key: Some(key.clone()),
1670 });
1671 } else {
1672 if let Some(expected) = registry.expected_value_for(rule, key) {
1674 let actual = &rule_cfg.values[key].value;
1675 if !toml_value_type_matches(expected, actual) {
1676 warnings.push(ConfigValidationWarning {
1677 message: format!(
1678 "Type mismatch for {}.{}: expected {}, got {}",
1679 rule,
1680 key,
1681 toml_type_name(expected),
1682 toml_type_name(actual)
1683 ),
1684 rule: Some(rule.clone()),
1685 key: Some(key.clone()),
1686 });
1687 }
1688 }
1689 }
1690 }
1691 }
1692 }
1693 for (section, key) in &sourced.unknown_keys {
1695 if section.contains("[global]") {
1696 warnings.push(ConfigValidationWarning {
1697 message: format!("Unknown global option: {key}"),
1698 rule: None,
1699 key: Some(key.clone()),
1700 });
1701 }
1702 }
1703 warnings
1704}
1705
1706fn toml_type_name(val: &toml::Value) -> &'static str {
1707 match val {
1708 toml::Value::String(_) => "string",
1709 toml::Value::Integer(_) => "integer",
1710 toml::Value::Float(_) => "float",
1711 toml::Value::Boolean(_) => "boolean",
1712 toml::Value::Array(_) => "array",
1713 toml::Value::Table(_) => "table",
1714 toml::Value::Datetime(_) => "datetime",
1715 }
1716}
1717
1718fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
1719 use toml::Value::*;
1720 match (expected, actual) {
1721 (String(_), String(_)) => true,
1722 (Integer(_), Integer(_)) => true,
1723 (Float(_), Float(_)) => true,
1724 (Boolean(_), Boolean(_)) => true,
1725 (Array(_), Array(_)) => true,
1726 (Table(_), Table(_)) => true,
1727 (Datetime(_), Datetime(_)) => true,
1728 (Float(_), Integer(_)) => true,
1730 _ => false,
1731 }
1732}
1733
1734fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
1736 let doc: toml::Value =
1737 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
1738 let mut fragment = SourcedConfigFragment::default();
1739 let source = ConfigSource::PyprojectToml;
1740 let file = Some(path.to_string());
1741
1742 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
1744 && let Some(rumdl_table) = rumdl_config.as_table()
1745 {
1746 if let Some(enable) = rumdl_table.get("enable")
1748 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
1749 {
1750 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1752 fragment
1753 .global
1754 .enable
1755 .push_override(normalized_values, source, file.clone(), None);
1756 }
1757 if let Some(disable) = rumdl_table.get("disable")
1758 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
1759 {
1760 let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
1762 fragment
1763 .global
1764 .disable
1765 .push_override(normalized_values, source, file.clone(), None);
1766 }
1767 if let Some(include) = rumdl_table.get("include")
1768 && let Ok(values) = Vec::<String>::deserialize(include.clone())
1769 {
1770 fragment
1771 .global
1772 .include
1773 .push_override(values, source, file.clone(), None);
1774 }
1775 if let Some(exclude) = rumdl_table.get("exclude")
1776 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
1777 {
1778 fragment
1779 .global
1780 .exclude
1781 .push_override(values, source, file.clone(), None);
1782 }
1783 if let Some(respect_gitignore) = rumdl_table
1784 .get("respect-gitignore")
1785 .or_else(|| rumdl_table.get("respect_gitignore"))
1786 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
1787 {
1788 fragment
1789 .global
1790 .respect_gitignore
1791 .push_override(value, source, file.clone(), None);
1792 }
1793 if let Some(output_format) = rumdl_table
1794 .get("output-format")
1795 .or_else(|| rumdl_table.get("output_format"))
1796 && let Ok(value) = String::deserialize(output_format.clone())
1797 {
1798 if fragment.global.output_format.is_none() {
1799 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
1800 } else {
1801 fragment
1802 .global
1803 .output_format
1804 .as_mut()
1805 .unwrap()
1806 .push_override(value, source, file.clone(), None);
1807 }
1808 }
1809 if let Some(fixable) = rumdl_table.get("fixable")
1810 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
1811 {
1812 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1813 fragment
1814 .global
1815 .fixable
1816 .push_override(normalized_values, source, file.clone(), None);
1817 }
1818 if let Some(unfixable) = rumdl_table.get("unfixable")
1819 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
1820 {
1821 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1822 fragment
1823 .global
1824 .unfixable
1825 .push_override(normalized_values, source, file.clone(), None);
1826 }
1827 if let Some(flavor) = rumdl_table.get("flavor")
1828 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
1829 {
1830 fragment.global.flavor.push_override(value, source, file.clone(), None);
1831 }
1832
1833 let mut found_line_length_val: Option<toml::Value> = None;
1835 for key in ["line-length", "line_length"].iter() {
1836 if let Some(val) = rumdl_table.get(*key) {
1837 if val.is_integer() {
1839 found_line_length_val = Some(val.clone());
1840 break;
1841 } else {
1842 }
1844 }
1845 }
1846 if let Some(line_length_val) = found_line_length_val {
1847 let norm_md013_key = normalize_key("MD013"); let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
1849 let norm_line_length_key = normalize_key("line-length"); let sv = rule_entry
1851 .values
1852 .entry(norm_line_length_key)
1853 .or_insert_with(|| SourcedValue::new(line_length_val.clone(), ConfigSource::Default));
1854 sv.push_override(line_length_val, source, file.clone(), None);
1855 }
1856
1857 for (key, value) in rumdl_table {
1859 let norm_rule_key = normalize_key(key);
1860
1861 if [
1863 "enable",
1864 "disable",
1865 "include",
1866 "exclude",
1867 "respect_gitignore",
1868 "respect-gitignore", "line_length",
1870 "line-length",
1871 "output_format",
1872 "output-format",
1873 "fixable",
1874 "unfixable",
1875 ]
1876 .contains(&norm_rule_key.as_str())
1877 {
1878 continue;
1879 }
1880
1881 let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
1885 if norm_rule_key_upper.len() == 5
1886 && norm_rule_key_upper.starts_with("MD")
1887 && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
1888 && value.is_table()
1889 {
1890 if let Some(rule_config_table) = value.as_table() {
1891 let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
1893 for (rk, rv) in rule_config_table {
1894 let norm_rk = normalize_key(rk); let toml_val = rv.clone();
1897
1898 let sv = rule_entry
1899 .values
1900 .entry(norm_rk.clone())
1901 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
1902 sv.push_override(toml_val, source, file.clone(), None);
1903 }
1904 }
1905 } else {
1906 }
1910 }
1911 }
1912
1913 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
1915 for (key, value) in tool_table.iter() {
1916 if let Some(rule_name) = key.strip_prefix("rumdl.") {
1917 let norm_rule_name = normalize_key(rule_name);
1918 if norm_rule_name.len() == 5
1919 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
1920 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
1921 && let Some(rule_table) = value.as_table()
1922 {
1923 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
1924 for (rk, rv) in rule_table {
1925 let norm_rk = normalize_key(rk);
1926 let toml_val = rv.clone();
1927 let sv = rule_entry
1928 .values
1929 .entry(norm_rk.clone())
1930 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
1931 sv.push_override(toml_val, source, file.clone(), None);
1932 }
1933 }
1934 }
1935 }
1936 }
1937
1938 if let Some(doc_table) = doc.as_table() {
1940 for (key, value) in doc_table.iter() {
1941 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
1942 let norm_rule_name = normalize_key(rule_name);
1943 if norm_rule_name.len() == 5
1944 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
1945 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
1946 && let Some(rule_table) = value.as_table()
1947 {
1948 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
1949 for (rk, rv) in rule_table {
1950 let norm_rk = normalize_key(rk);
1951 let toml_val = rv.clone();
1952 let sv = rule_entry
1953 .values
1954 .entry(norm_rk.clone())
1955 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
1956 sv.push_override(toml_val, source, file.clone(), None);
1957 }
1958 }
1959 }
1960 }
1961 }
1962
1963 let has_any = !fragment.global.enable.value.is_empty()
1965 || !fragment.global.disable.value.is_empty()
1966 || !fragment.global.include.value.is_empty()
1967 || !fragment.global.exclude.value.is_empty()
1968 || !fragment.global.fixable.value.is_empty()
1969 || !fragment.global.unfixable.value.is_empty()
1970 || fragment.global.output_format.is_some()
1971 || !fragment.rules.is_empty();
1972 if has_any { Ok(Some(fragment)) } else { Ok(None) }
1973}
1974
1975fn parse_rumdl_toml(content: &str, path: &str) -> Result<SourcedConfigFragment, ConfigError> {
1977 let doc = content
1978 .parse::<DocumentMut>()
1979 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
1980 let mut fragment = SourcedConfigFragment::default();
1981 let source = ConfigSource::RumdlToml;
1982 let file = Some(path.to_string());
1983
1984 let all_rules = rules::all_rules(&Config::default());
1986 let registry = RuleRegistry::from_rules(&all_rules);
1987 let known_rule_names: BTreeSet<String> = registry
1988 .rule_names()
1989 .into_iter()
1990 .map(|s| s.to_ascii_uppercase())
1991 .collect();
1992
1993 if let Some(global_item) = doc.get("global")
1995 && let Some(global_table) = global_item.as_table()
1996 {
1997 for (key, value_item) in global_table.iter() {
1998 let norm_key = normalize_key(key);
1999 match norm_key.as_str() {
2000 "enable" | "disable" | "include" | "exclude" => {
2001 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2002 let values: Vec<String> = formatted_array
2004 .iter()
2005 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
2007 .collect();
2008
2009 let final_values = if norm_key == "enable" || norm_key == "disable" {
2011 values.into_iter().map(|s| normalize_key(&s)).collect()
2013 } else {
2014 values
2015 };
2016
2017 match norm_key.as_str() {
2018 "enable" => fragment
2019 .global
2020 .enable
2021 .push_override(final_values, source, file.clone(), None),
2022 "disable" => {
2023 fragment
2024 .global
2025 .disable
2026 .push_override(final_values, source, file.clone(), None)
2027 }
2028 "include" => {
2029 fragment
2030 .global
2031 .include
2032 .push_override(final_values, source, file.clone(), None)
2033 }
2034 "exclude" => {
2035 fragment
2036 .global
2037 .exclude
2038 .push_override(final_values, source, file.clone(), None)
2039 }
2040 _ => unreachable!(), }
2042 } else {
2043 log::warn!(
2044 "[WARN] Expected array for global key '{}' in {}, found {}",
2045 key,
2046 path,
2047 value_item.type_name()
2048 );
2049 }
2050 }
2051 "respect_gitignore" | "respect-gitignore" => {
2052 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2054 let val = *formatted_bool.value();
2055 fragment
2056 .global
2057 .respect_gitignore
2058 .push_override(val, source, file.clone(), None);
2059 } else {
2060 log::warn!(
2061 "[WARN] Expected boolean for global key '{}' in {}, found {}",
2062 key,
2063 path,
2064 value_item.type_name()
2065 );
2066 }
2067 }
2068 "line_length" | "line-length" => {
2069 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
2071 let val = *formatted_int.value() as u64;
2072 fragment
2073 .global
2074 .line_length
2075 .push_override(val, source, file.clone(), None);
2076 } else {
2077 log::warn!(
2078 "[WARN] Expected integer for global key '{}' in {}, found {}",
2079 key,
2080 path,
2081 value_item.type_name()
2082 );
2083 }
2084 }
2085 "output_format" | "output-format" => {
2086 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2088 let val = formatted_string.value().clone();
2089 if fragment.global.output_format.is_none() {
2090 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
2091 } else {
2092 fragment.global.output_format.as_mut().unwrap().push_override(
2093 val,
2094 source,
2095 file.clone(),
2096 None,
2097 );
2098 }
2099 } else {
2100 log::warn!(
2101 "[WARN] Expected string for global key '{}' in {}, found {}",
2102 key,
2103 path,
2104 value_item.type_name()
2105 );
2106 }
2107 }
2108 "fixable" => {
2109 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2110 let values: Vec<String> = formatted_array
2111 .iter()
2112 .filter_map(|item| item.as_str())
2113 .map(normalize_key)
2114 .collect();
2115 fragment
2116 .global
2117 .fixable
2118 .push_override(values, source, file.clone(), None);
2119 } else {
2120 log::warn!(
2121 "[WARN] Expected array for global key '{}' in {}, found {}",
2122 key,
2123 path,
2124 value_item.type_name()
2125 );
2126 }
2127 }
2128 "unfixable" => {
2129 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2130 let values: Vec<String> = formatted_array
2131 .iter()
2132 .filter_map(|item| item.as_str())
2133 .map(normalize_key)
2134 .collect();
2135 fragment
2136 .global
2137 .unfixable
2138 .push_override(values, source, file.clone(), None);
2139 } else {
2140 log::warn!(
2141 "[WARN] Expected array for global key '{}' in {}, found {}",
2142 key,
2143 path,
2144 value_item.type_name()
2145 );
2146 }
2147 }
2148 "flavor" => {
2149 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2150 let val = formatted_string.value();
2151 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
2152 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
2153 } else {
2154 log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
2155 }
2156 } else {
2157 log::warn!(
2158 "[WARN] Expected string for global key '{}' in {}, found {}",
2159 key,
2160 path,
2161 value_item.type_name()
2162 );
2163 }
2164 }
2165 _ => {
2166 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
2169 }
2170 }
2171 }
2172 }
2173
2174 for (key, item) in doc.iter() {
2176 let norm_rule_name = key.to_ascii_uppercase();
2177 if !known_rule_names.contains(&norm_rule_name) {
2178 continue;
2179 }
2180 if let Some(tbl) = item.as_table() {
2181 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
2182 for (rk, rv_item) in tbl.iter() {
2183 let norm_rk = normalize_key(rk);
2184 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
2185 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
2186 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
2187 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
2188 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
2189 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
2190 Some(toml_edit::Value::Array(formatted_array)) => {
2191 let mut values = Vec::new();
2193 for item in formatted_array.iter() {
2194 match item {
2195 toml_edit::Value::String(formatted) => {
2196 values.push(toml::Value::String(formatted.value().clone()))
2197 }
2198 toml_edit::Value::Integer(formatted) => {
2199 values.push(toml::Value::Integer(*formatted.value()))
2200 }
2201 toml_edit::Value::Float(formatted) => {
2202 values.push(toml::Value::Float(*formatted.value()))
2203 }
2204 toml_edit::Value::Boolean(formatted) => {
2205 values.push(toml::Value::Boolean(*formatted.value()))
2206 }
2207 toml_edit::Value::Datetime(formatted) => {
2208 values.push(toml::Value::Datetime(*formatted.value()))
2209 }
2210 _ => {
2211 log::warn!(
2212 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
2213 );
2214 }
2215 }
2216 }
2217 Some(toml::Value::Array(values))
2218 }
2219 Some(toml_edit::Value::InlineTable(_)) => {
2220 log::warn!(
2221 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
2222 );
2223 None
2224 }
2225 None => {
2226 log::warn!(
2227 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
2228 );
2229 None
2230 }
2231 };
2232 if let Some(toml_val) = maybe_toml_val {
2233 let sv = rule_entry
2234 .values
2235 .entry(norm_rk.clone())
2236 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2237 sv.push_override(toml_val, source, file.clone(), None);
2238 }
2239 }
2240 } else if item.is_value() {
2241 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
2242 }
2243 }
2244
2245 Ok(fragment)
2246}
2247
2248fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2250 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
2252 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
2253 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
2254}