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 #[serde(default)]
183 pub force_exclude: bool,
184}
185
186fn default_respect_gitignore() -> bool {
187 true
188}
189
190fn default_line_length() -> u64 {
191 80
192}
193
194impl Default for GlobalConfig {
196 fn default() -> Self {
197 Self {
198 enable: Vec::new(),
199 disable: Vec::new(),
200 exclude: Vec::new(),
201 include: Vec::new(),
202 respect_gitignore: true,
203 line_length: 80,
204 output_format: None,
205 fixable: Vec::new(),
206 unfixable: Vec::new(),
207 flavor: MarkdownFlavor::default(),
208 force_exclude: false,
209 }
210 }
211}
212
213const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
214 ".markdownlint.json",
215 ".markdownlint.jsonc",
216 ".markdownlint.yaml",
217 ".markdownlint.yml",
218 "markdownlint.json",
219 "markdownlint.jsonc",
220 "markdownlint.yaml",
221 "markdownlint.yml",
222];
223
224pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
226 if Path::new(path).exists() {
228 return Err(ConfigError::FileExists { path: path.to_string() });
229 }
230
231 let default_config = r#"# rumdl configuration file
233
234# Global configuration options
235[global]
236# List of rules to disable (uncomment and modify as needed)
237# disable = ["MD013", "MD033"]
238
239# List of rules to enable exclusively (if provided, only these rules will run)
240# enable = ["MD001", "MD003", "MD004"]
241
242# List of file/directory patterns to include for linting (if provided, only these will be linted)
243# include = [
244# "docs/*.md",
245# "src/**/*.md",
246# "README.md"
247# ]
248
249# List of file/directory patterns to exclude from linting
250exclude = [
251 # Common directories to exclude
252 ".git",
253 ".github",
254 "node_modules",
255 "vendor",
256 "dist",
257 "build",
258
259 # Specific files or patterns
260 "CHANGELOG.md",
261 "LICENSE.md",
262]
263
264# Respect .gitignore files when scanning directories (default: true)
265respect_gitignore = true
266
267# Markdown flavor/dialect (uncomment to enable)
268# Options: mkdocs, gfm, commonmark
269# flavor = "mkdocs"
270
271# Rule-specific configurations (uncomment and modify as needed)
272
273# [MD003]
274# style = "atx" # Heading style (atx, atx_closed, setext)
275
276# [MD004]
277# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
278
279# [MD007]
280# indent = 4 # Unordered list indentation
281
282# [MD013]
283# line_length = 100 # Line length
284# code_blocks = false # Exclude code blocks from line length check
285# tables = false # Exclude tables from line length check
286# headings = true # Include headings in line length check
287
288# [MD044]
289# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
290# code_blocks_excluded = true # Exclude code blocks from proper name check
291"#;
292
293 match fs::write(path, default_config) {
295 Ok(_) => Ok(()),
296 Err(err) => Err(ConfigError::IoError {
297 source: err,
298 path: path.to_string(),
299 }),
300 }
301}
302
303#[derive(Debug, thiserror::Error)]
305pub enum ConfigError {
306 #[error("Failed to read config file at {path}: {source}")]
308 IoError { source: io::Error, path: String },
309
310 #[error("Failed to parse config: {0}")]
312 ParseError(String),
313
314 #[error("Configuration file already exists at {path}")]
316 FileExists { path: String },
317}
318
319pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
323 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
326
327 let key_variants = [
329 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
334
335 for variant in &key_variants {
337 if let Some(value) = rule_config.values.get(variant)
338 && let Ok(result) = T::deserialize(value.clone())
339 {
340 return Some(result);
341 }
342 }
343
344 None
345}
346
347pub fn generate_pyproject_config() -> String {
349 let config_content = r#"
350[tool.rumdl]
351# Global configuration options
352line-length = 100
353disable = []
354exclude = [
355 # Common directories to exclude
356 ".git",
357 ".github",
358 "node_modules",
359 "vendor",
360 "dist",
361 "build",
362]
363respect-gitignore = true
364
365# Rule-specific configurations (uncomment and modify as needed)
366
367# [tool.rumdl.MD003]
368# style = "atx" # Heading style (atx, atx_closed, setext)
369
370# [tool.rumdl.MD004]
371# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
372
373# [tool.rumdl.MD007]
374# indent = 4 # Unordered list indentation
375
376# [tool.rumdl.MD013]
377# line_length = 100 # Line length
378# code_blocks = false # Exclude code blocks from line length check
379# tables = false # Exclude tables from line length check
380# headings = true # Include headings in line length check
381
382# [tool.rumdl.MD044]
383# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
384# code_blocks_excluded = true # Exclude code blocks from proper name check
385"#;
386
387 config_content.to_string()
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use std::fs;
394 use tempfile::tempdir;
395
396 #[test]
397 fn test_flavor_loading() {
398 let temp_dir = tempdir().unwrap();
399 let config_path = temp_dir.path().join(".rumdl.toml");
400 let config_content = r#"
401[global]
402flavor = "mkdocs"
403disable = ["MD001"]
404"#;
405 fs::write(&config_path, config_content).unwrap();
406
407 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
409 let config: Config = sourced.into();
410
411 assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
413 assert!(config.is_mkdocs_flavor());
414 assert!(config.is_mkdocs_project()); assert_eq!(config.global.disable, vec!["MD001".to_string()]);
416 }
417
418 #[test]
419 fn test_pyproject_toml_root_level_config() {
420 let temp_dir = tempdir().unwrap();
421 let config_path = temp_dir.path().join("pyproject.toml");
422
423 let content = r#"
425[tool.rumdl]
426line-length = 120
427disable = ["MD033"]
428enable = ["MD001", "MD004"]
429include = ["docs/*.md"]
430exclude = ["node_modules"]
431respect-gitignore = true
432 "#;
433
434 fs::write(&config_path, content).unwrap();
435
436 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
438 let config: Config = sourced.into(); assert_eq!(config.global.disable, vec!["MD033".to_string()]);
442 assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
443 assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
445 assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
446 assert!(config.global.respect_gitignore);
447
448 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
450 assert_eq!(line_length, Some(120));
451 }
452
453 #[test]
454 fn test_pyproject_toml_snake_case_and_kebab_case() {
455 let temp_dir = tempdir().unwrap();
456 let config_path = temp_dir.path().join("pyproject.toml");
457
458 let content = r#"
460[tool.rumdl]
461line-length = 150
462respect_gitignore = true
463 "#;
464
465 fs::write(&config_path, content).unwrap();
466
467 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
469 let config: Config = sourced.into(); assert!(config.global.respect_gitignore);
473 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
474 assert_eq!(line_length, Some(150));
475 }
476
477 #[test]
478 fn test_md013_key_normalization_in_rumdl_toml() {
479 let temp_dir = tempdir().unwrap();
480 let config_path = temp_dir.path().join(".rumdl.toml");
481 let config_content = r#"
482[MD013]
483line_length = 111
484line-length = 222
485"#;
486 fs::write(&config_path, config_content).unwrap();
487 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
489 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
490 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
492 assert_eq!(keys, vec!["line-length"]);
493 let val = &rule_cfg.values["line-length"].value;
494 assert_eq!(val.as_integer(), Some(222));
495 let config: Config = sourced.clone().into();
497 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
498 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
499 assert_eq!(v1, Some(222));
500 assert_eq!(v2, Some(222));
501 }
502
503 #[test]
504 fn test_md013_section_case_insensitivity() {
505 let temp_dir = tempdir().unwrap();
506 let config_path = temp_dir.path().join(".rumdl.toml");
507 let config_content = r#"
508[md013]
509line-length = 101
510
511[Md013]
512line-length = 102
513
514[MD013]
515line-length = 103
516"#;
517 fs::write(&config_path, config_content).unwrap();
518 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
520 let config: Config = sourced.clone().into();
521 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
523 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
524 assert_eq!(keys, vec!["line-length"]);
525 let val = &rule_cfg.values["line-length"].value;
526 assert_eq!(val.as_integer(), Some(103));
527 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
528 assert_eq!(v, Some(103));
529 }
530
531 #[test]
532 fn test_md013_key_snake_and_kebab_case() {
533 let temp_dir = tempdir().unwrap();
534 let config_path = temp_dir.path().join(".rumdl.toml");
535 let config_content = r#"
536[MD013]
537line_length = 201
538line-length = 202
539"#;
540 fs::write(&config_path, config_content).unwrap();
541 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
543 let config: Config = sourced.clone().into();
544 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
545 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
546 assert_eq!(keys, vec!["line-length"]);
547 let val = &rule_cfg.values["line-length"].value;
548 assert_eq!(val.as_integer(), Some(202));
549 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
550 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
551 assert_eq!(v1, Some(202));
552 assert_eq!(v2, Some(202));
553 }
554
555 #[test]
556 fn test_unknown_rule_section_is_ignored() {
557 let temp_dir = tempdir().unwrap();
558 let config_path = temp_dir.path().join(".rumdl.toml");
559 let config_content = r#"
560[MD999]
561foo = 1
562bar = 2
563[MD013]
564line-length = 303
565"#;
566 fs::write(&config_path, config_content).unwrap();
567 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
569 let config: Config = sourced.clone().into();
570 assert!(!sourced.rules.contains_key("MD999"));
572 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
574 assert_eq!(v, Some(303));
575 }
576
577 #[test]
578 fn test_invalid_toml_syntax() {
579 let temp_dir = tempdir().unwrap();
580 let config_path = temp_dir.path().join(".rumdl.toml");
581
582 let config_content = r#"
584[MD013]
585line-length = "unclosed string
586"#;
587 fs::write(&config_path, config_content).unwrap();
588
589 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
590 assert!(result.is_err());
591 match result.unwrap_err() {
592 ConfigError::ParseError(msg) => {
593 assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
595 }
596 _ => panic!("Expected ParseError"),
597 }
598 }
599
600 #[test]
601 fn test_wrong_type_for_config_value() {
602 let temp_dir = tempdir().unwrap();
603 let config_path = temp_dir.path().join(".rumdl.toml");
604
605 let config_content = r#"
607[MD013]
608line-length = "not a number"
609"#;
610 fs::write(&config_path, config_content).unwrap();
611
612 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
613 let config: Config = sourced.into();
614
615 let rule_config = config.rules.get("MD013").unwrap();
617 let value = rule_config.values.get("line-length").unwrap();
618 assert!(matches!(value, toml::Value::String(_)));
619 }
620
621 #[test]
622 fn test_empty_config_file() {
623 let temp_dir = tempdir().unwrap();
624 let config_path = temp_dir.path().join(".rumdl.toml");
625
626 fs::write(&config_path, "").unwrap();
628
629 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
630 let config: Config = sourced.into();
631
632 assert_eq!(config.global.line_length, 80);
634 assert!(config.global.respect_gitignore);
635 assert!(config.rules.is_empty());
636 }
637
638 #[test]
639 fn test_malformed_pyproject_toml() {
640 let temp_dir = tempdir().unwrap();
641 let config_path = temp_dir.path().join("pyproject.toml");
642
643 let content = r#"
645[tool.rumdl
646line-length = 120
647"#;
648 fs::write(&config_path, content).unwrap();
649
650 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
651 assert!(result.is_err());
652 }
653
654 #[test]
655 fn test_conflicting_config_values() {
656 let temp_dir = tempdir().unwrap();
657 let config_path = temp_dir.path().join(".rumdl.toml");
658
659 let config_content = r#"
661[global]
662enable = ["MD013"]
663disable = ["MD013"]
664"#;
665 fs::write(&config_path, config_content).unwrap();
666
667 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
668 let config: Config = sourced.into();
669
670 assert!(config.global.enable.contains(&"MD013".to_string()));
672 assert!(config.global.disable.contains(&"MD013".to_string()));
673 }
674
675 #[test]
676 fn test_invalid_rule_names() {
677 let temp_dir = tempdir().unwrap();
678 let config_path = temp_dir.path().join(".rumdl.toml");
679
680 let config_content = r#"
681[global]
682enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
683disable = ["MD-001", "MD_002"]
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.len(), 4);
692 assert_eq!(config.global.disable.len(), 2);
693 }
694
695 #[test]
696 fn test_deeply_nested_config() {
697 let temp_dir = tempdir().unwrap();
698 let config_path = temp_dir.path().join(".rumdl.toml");
699
700 let config_content = r#"
702[MD013]
703line-length = 100
704[MD013.nested]
705value = 42
706"#;
707 fs::write(&config_path, config_content).unwrap();
708
709 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
710 let config: Config = sourced.into();
711
712 let rule_config = config.rules.get("MD013").unwrap();
713 assert_eq!(
714 rule_config.values.get("line-length").unwrap(),
715 &toml::Value::Integer(100)
716 );
717 assert!(!rule_config.values.contains_key("nested"));
719 }
720
721 #[test]
722 fn test_unicode_in_config() {
723 let temp_dir = tempdir().unwrap();
724 let config_path = temp_dir.path().join(".rumdl.toml");
725
726 let config_content = r#"
727[global]
728include = ["文档/*.md", "ドキュメント/*.md"]
729exclude = ["测试/*", "🚀/*"]
730
731[MD013]
732line-length = 80
733message = "行太长了 🚨"
734"#;
735 fs::write(&config_path, config_content).unwrap();
736
737 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
738 let config: Config = sourced.into();
739
740 assert_eq!(config.global.include.len(), 2);
741 assert_eq!(config.global.exclude.len(), 2);
742 assert!(config.global.include[0].contains("文档"));
743 assert!(config.global.exclude[1].contains("🚀"));
744
745 let rule_config = config.rules.get("MD013").unwrap();
746 let message = rule_config.values.get("message").unwrap();
747 if let toml::Value::String(s) = message {
748 assert!(s.contains("行太长了"));
749 assert!(s.contains("🚨"));
750 }
751 }
752
753 #[test]
754 fn test_extremely_long_values() {
755 let temp_dir = tempdir().unwrap();
756 let config_path = temp_dir.path().join(".rumdl.toml");
757
758 let long_string = "a".repeat(10000);
759 let config_content = format!(
760 r#"
761[global]
762exclude = ["{long_string}"]
763
764[MD013]
765line-length = 999999999
766"#
767 );
768
769 fs::write(&config_path, config_content).unwrap();
770
771 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
772 let config: Config = sourced.into();
773
774 assert_eq!(config.global.exclude[0].len(), 10000);
775 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
776 assert_eq!(line_length, Some(999999999));
777 }
778
779 #[test]
780 fn test_config_with_comments() {
781 let temp_dir = tempdir().unwrap();
782 let config_path = temp_dir.path().join(".rumdl.toml");
783
784 let config_content = r#"
785[global]
786# This is a comment
787enable = ["MD001"] # Enable MD001
788# disable = ["MD002"] # This is commented out
789
790[MD013] # Line length rule
791line-length = 100 # Set to 100 characters
792# ignored = true # This setting is commented out
793"#;
794 fs::write(&config_path, config_content).unwrap();
795
796 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
797 let config: Config = sourced.into();
798
799 assert_eq!(config.global.enable, vec!["MD001"]);
800 assert!(config.global.disable.is_empty()); let rule_config = config.rules.get("MD013").unwrap();
803 assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
805 }
806
807 #[test]
808 fn test_arrays_in_rule_config() {
809 let temp_dir = tempdir().unwrap();
810 let config_path = temp_dir.path().join(".rumdl.toml");
811
812 let config_content = r#"
813[MD002]
814levels = [1, 2, 3]
815tags = ["important", "critical"]
816mixed = [1, "two", true]
817"#;
818 fs::write(&config_path, config_content).unwrap();
819
820 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
821 let config: Config = sourced.into();
822
823 let rule_config = config.rules.get("MD002").expect("MD002 config should exist");
825
826 assert!(rule_config.values.contains_key("levels"));
828 assert!(rule_config.values.contains_key("tags"));
829 assert!(rule_config.values.contains_key("mixed"));
830
831 if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
833 assert_eq!(levels.len(), 3);
834 assert_eq!(levels[0], toml::Value::Integer(1));
835 assert_eq!(levels[1], toml::Value::Integer(2));
836 assert_eq!(levels[2], toml::Value::Integer(3));
837 } else {
838 panic!("levels should be an array");
839 }
840
841 if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
842 assert_eq!(tags.len(), 2);
843 assert_eq!(tags[0], toml::Value::String("important".to_string()));
844 assert_eq!(tags[1], toml::Value::String("critical".to_string()));
845 } else {
846 panic!("tags should be an array");
847 }
848
849 if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
850 assert_eq!(mixed.len(), 3);
851 assert_eq!(mixed[0], toml::Value::Integer(1));
852 assert_eq!(mixed[1], toml::Value::String("two".to_string()));
853 assert_eq!(mixed[2], toml::Value::Boolean(true));
854 } else {
855 panic!("mixed should be an array");
856 }
857 }
858
859 #[test]
860 fn test_normalize_key_edge_cases() {
861 assert_eq!(normalize_key("MD001"), "MD001");
863 assert_eq!(normalize_key("md001"), "MD001");
864 assert_eq!(normalize_key("Md001"), "MD001");
865 assert_eq!(normalize_key("mD001"), "MD001");
866
867 assert_eq!(normalize_key("line_length"), "line-length");
869 assert_eq!(normalize_key("line-length"), "line-length");
870 assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
871 assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
872
873 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(""), "");
880 assert_eq!(normalize_key("_"), "-");
881 assert_eq!(normalize_key("___"), "---");
882 }
883
884 #[test]
885 fn test_missing_config_file() {
886 let temp_dir = tempdir().unwrap();
887 let config_path = temp_dir.path().join("nonexistent.toml");
888
889 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
890 assert!(result.is_err());
891 match result.unwrap_err() {
892 ConfigError::IoError { .. } => {}
893 _ => panic!("Expected IoError for missing file"),
894 }
895 }
896
897 #[test]
898 #[cfg(unix)]
899 fn test_permission_denied_config() {
900 use std::os::unix::fs::PermissionsExt;
901
902 let temp_dir = tempdir().unwrap();
903 let config_path = temp_dir.path().join(".rumdl.toml");
904
905 fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
906
907 let mut perms = fs::metadata(&config_path).unwrap().permissions();
909 perms.set_mode(0o000);
910 fs::set_permissions(&config_path, perms).unwrap();
911
912 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
913
914 let mut perms = fs::metadata(&config_path).unwrap().permissions();
916 perms.set_mode(0o644);
917 fs::set_permissions(&config_path, perms).unwrap();
918
919 assert!(result.is_err());
920 match result.unwrap_err() {
921 ConfigError::IoError { .. } => {}
922 _ => panic!("Expected IoError for permission denied"),
923 }
924 }
925
926 #[test]
927 fn test_circular_reference_detection() {
928 let temp_dir = tempdir().unwrap();
931 let config_path = temp_dir.path().join(".rumdl.toml");
932
933 let mut config_content = String::from("[MD001]\n");
934 for i in 0..100 {
935 config_content.push_str(&format!("key{i} = {i}\n"));
936 }
937
938 fs::write(&config_path, config_content).unwrap();
939
940 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
941 let config: Config = sourced.into();
942
943 let rule_config = config.rules.get("MD001").unwrap();
944 assert_eq!(rule_config.values.len(), 100);
945 }
946
947 #[test]
948 fn test_special_toml_values() {
949 let temp_dir = tempdir().unwrap();
950 let config_path = temp_dir.path().join(".rumdl.toml");
951
952 let config_content = r#"
953[MD001]
954infinity = inf
955neg_infinity = -inf
956not_a_number = nan
957datetime = 1979-05-27T07:32:00Z
958local_date = 1979-05-27
959local_time = 07:32:00
960"#;
961 fs::write(&config_path, config_content).unwrap();
962
963 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
964 let config: Config = sourced.into();
965
966 if let Some(rule_config) = config.rules.get("MD001") {
968 if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
970 assert!(f.is_infinite() && f.is_sign_positive());
971 }
972 if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
973 assert!(f.is_infinite() && f.is_sign_negative());
974 }
975 if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
976 assert!(f.is_nan());
977 }
978
979 if let Some(val) = rule_config.values.get("datetime") {
981 assert!(matches!(val, toml::Value::Datetime(_)));
982 }
983 }
985 }
986
987 #[test]
988 fn test_default_config_passes_validation() {
989 use crate::rules;
990
991 let temp_dir = tempdir().unwrap();
992 let config_path = temp_dir.path().join(".rumdl.toml");
993 let config_path_str = config_path.to_str().unwrap();
994
995 create_default_config(config_path_str).unwrap();
997
998 let sourced =
1000 SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1001
1002 let all_rules = rules::all_rules(&Config::default());
1004 let registry = RuleRegistry::from_rules(&all_rules);
1005
1006 let warnings = validate_config_sourced(&sourced, ®istry);
1008
1009 if !warnings.is_empty() {
1011 for warning in &warnings {
1012 eprintln!("Config validation warning: {}", warning.message);
1013 if let Some(rule) = &warning.rule {
1014 eprintln!(" Rule: {rule}");
1015 }
1016 if let Some(key) = &warning.key {
1017 eprintln!(" Key: {key}");
1018 }
1019 }
1020 }
1021 assert!(
1022 warnings.is_empty(),
1023 "Default config from rumdl init should pass validation without warnings"
1024 );
1025 }
1026}
1027
1028#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1029pub enum ConfigSource {
1030 Default,
1031 RumdlToml,
1032 PyprojectToml,
1033 Cli,
1034 Markdownlint,
1036}
1037
1038#[derive(Debug, Clone)]
1039pub struct ConfigOverride<T> {
1040 pub value: T,
1041 pub source: ConfigSource,
1042 pub file: Option<String>,
1043 pub line: Option<usize>,
1044}
1045
1046#[derive(Debug, Clone)]
1047pub struct SourcedValue<T> {
1048 pub value: T,
1049 pub source: ConfigSource,
1050 pub overrides: Vec<ConfigOverride<T>>,
1051}
1052
1053impl<T: Clone> SourcedValue<T> {
1054 pub fn new(value: T, source: ConfigSource) -> Self {
1055 Self {
1056 value: value.clone(),
1057 source,
1058 overrides: vec![ConfigOverride {
1059 value,
1060 source,
1061 file: None,
1062 line: None,
1063 }],
1064 }
1065 }
1066
1067 pub fn merge_override(
1071 &mut self,
1072 new_value: T,
1073 new_source: ConfigSource,
1074 new_file: Option<String>,
1075 new_line: Option<usize>,
1076 ) {
1077 fn source_precedence(src: ConfigSource) -> u8 {
1079 match src {
1080 ConfigSource::Default => 0,
1081 ConfigSource::PyprojectToml => 1,
1082 ConfigSource::Markdownlint => 2,
1083 ConfigSource::RumdlToml => 3,
1084 ConfigSource::Cli => 4,
1085 }
1086 }
1087
1088 if source_precedence(new_source) >= source_precedence(self.source) {
1089 self.value = new_value.clone();
1090 self.source = new_source;
1091 self.overrides.push(ConfigOverride {
1092 value: new_value,
1093 source: new_source,
1094 file: new_file,
1095 line: new_line,
1096 });
1097 }
1098 }
1099
1100 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
1101 self.value = value.clone();
1104 self.source = source;
1105 self.overrides.push(ConfigOverride {
1106 value,
1107 source,
1108 file,
1109 line,
1110 });
1111 }
1112}
1113
1114#[derive(Debug, Clone)]
1115pub struct SourcedGlobalConfig {
1116 pub enable: SourcedValue<Vec<String>>,
1117 pub disable: SourcedValue<Vec<String>>,
1118 pub exclude: SourcedValue<Vec<String>>,
1119 pub include: SourcedValue<Vec<String>>,
1120 pub respect_gitignore: SourcedValue<bool>,
1121 pub line_length: SourcedValue<u64>,
1122 pub output_format: Option<SourcedValue<String>>,
1123 pub fixable: SourcedValue<Vec<String>>,
1124 pub unfixable: SourcedValue<Vec<String>>,
1125 pub flavor: SourcedValue<MarkdownFlavor>,
1126 pub force_exclude: SourcedValue<bool>,
1127}
1128
1129impl Default for SourcedGlobalConfig {
1130 fn default() -> Self {
1131 SourcedGlobalConfig {
1132 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1133 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1134 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
1135 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
1136 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
1137 line_length: SourcedValue::new(80, ConfigSource::Default),
1138 output_format: None,
1139 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1140 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1141 flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
1142 force_exclude: SourcedValue::new(false, ConfigSource::Default),
1143 }
1144 }
1145}
1146
1147#[derive(Debug, Default, Clone)]
1148pub struct SourcedRuleConfig {
1149 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
1150}
1151
1152#[derive(Debug, Default, Clone)]
1155pub struct SourcedConfigFragment {
1156 pub global: SourcedGlobalConfig,
1157 pub rules: BTreeMap<String, SourcedRuleConfig>,
1158 }
1160
1161#[derive(Debug, Default, Clone)]
1162pub struct SourcedConfig {
1163 pub global: SourcedGlobalConfig,
1164 pub rules: BTreeMap<String, SourcedRuleConfig>,
1165 pub loaded_files: Vec<String>,
1166 pub unknown_keys: Vec<(String, String)>, }
1168
1169impl SourcedConfig {
1170 fn merge(&mut self, fragment: SourcedConfigFragment) {
1173 self.global.enable.merge_override(
1175 fragment.global.enable.value,
1176 fragment.global.enable.source,
1177 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
1178 fragment.global.enable.overrides.first().and_then(|o| o.line),
1179 );
1180 self.global.disable.merge_override(
1181 fragment.global.disable.value,
1182 fragment.global.disable.source,
1183 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
1184 fragment.global.disable.overrides.first().and_then(|o| o.line),
1185 );
1186 self.global.include.merge_override(
1187 fragment.global.include.value,
1188 fragment.global.include.source,
1189 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
1190 fragment.global.include.overrides.first().and_then(|o| o.line),
1191 );
1192 self.global.exclude.merge_override(
1193 fragment.global.exclude.value,
1194 fragment.global.exclude.source,
1195 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
1196 fragment.global.exclude.overrides.first().and_then(|o| o.line),
1197 );
1198 self.global.respect_gitignore.merge_override(
1199 fragment.global.respect_gitignore.value,
1200 fragment.global.respect_gitignore.source,
1201 fragment
1202 .global
1203 .respect_gitignore
1204 .overrides
1205 .first()
1206 .and_then(|o| o.file.clone()),
1207 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
1208 );
1209 self.global.line_length.merge_override(
1210 fragment.global.line_length.value,
1211 fragment.global.line_length.source,
1212 fragment
1213 .global
1214 .line_length
1215 .overrides
1216 .first()
1217 .and_then(|o| o.file.clone()),
1218 fragment.global.line_length.overrides.first().and_then(|o| o.line),
1219 );
1220 self.global.fixable.merge_override(
1221 fragment.global.fixable.value,
1222 fragment.global.fixable.source,
1223 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
1224 fragment.global.fixable.overrides.first().and_then(|o| o.line),
1225 );
1226 self.global.unfixable.merge_override(
1227 fragment.global.unfixable.value,
1228 fragment.global.unfixable.source,
1229 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
1230 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
1231 );
1232
1233 self.global.flavor.merge_override(
1235 fragment.global.flavor.value,
1236 fragment.global.flavor.source,
1237 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
1238 fragment.global.flavor.overrides.first().and_then(|o| o.line),
1239 );
1240
1241 self.global.force_exclude.merge_override(
1243 fragment.global.force_exclude.value,
1244 fragment.global.force_exclude.source,
1245 fragment
1246 .global
1247 .force_exclude
1248 .overrides
1249 .first()
1250 .and_then(|o| o.file.clone()),
1251 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
1252 );
1253
1254 if let Some(output_format_fragment) = fragment.global.output_format {
1256 if let Some(ref mut output_format) = self.global.output_format {
1257 output_format.merge_override(
1258 output_format_fragment.value,
1259 output_format_fragment.source,
1260 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
1261 output_format_fragment.overrides.first().and_then(|o| o.line),
1262 );
1263 } else {
1264 self.global.output_format = Some(output_format_fragment);
1265 }
1266 }
1267
1268 for (rule_name, rule_fragment) in fragment.rules {
1270 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
1272 for (key, sourced_value_fragment) in rule_fragment.values {
1273 let sv_entry = rule_entry
1274 .values
1275 .entry(key.clone())
1276 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
1277 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
1278 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
1279 sv_entry.merge_override(
1280 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
1285 }
1286 }
1287 }
1288
1289 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
1291 Self::load_with_discovery(config_path, cli_overrides, false)
1292 }
1293
1294 fn discover_config_upward() -> Option<std::path::PathBuf> {
1297 use std::env;
1298
1299 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1300 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
1303 Ok(dir) => dir,
1304 Err(e) => {
1305 log::debug!("[rumdl-config] Failed to get current directory: {e}");
1306 return None;
1307 }
1308 };
1309
1310 let mut current_dir = start_dir.clone();
1311 let mut depth = 0;
1312
1313 loop {
1314 if depth >= MAX_DEPTH {
1315 log::debug!("[rumdl-config] Maximum traversal depth reached");
1316 break;
1317 }
1318
1319 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
1320
1321 for config_name in CONFIG_FILES {
1323 let config_path = current_dir.join(config_name);
1324
1325 if config_path.exists() {
1326 if *config_name == "pyproject.toml" {
1328 if let Ok(content) = std::fs::read_to_string(&config_path) {
1329 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1330 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1331 return Some(config_path);
1332 }
1333 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
1334 continue;
1335 }
1336 } else {
1337 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1338 return Some(config_path);
1339 }
1340 }
1341 }
1342
1343 if current_dir.join(".git").exists() {
1345 log::debug!("[rumdl-config] Stopping at .git directory");
1346 break;
1347 }
1348
1349 match current_dir.parent() {
1351 Some(parent) => {
1352 current_dir = parent.to_owned();
1353 depth += 1;
1354 }
1355 None => {
1356 log::debug!("[rumdl-config] Reached filesystem root");
1357 break;
1358 }
1359 }
1360 }
1361
1362 None
1363 }
1364
1365 fn user_configuration_path() -> Option<std::path::PathBuf> {
1368 use etcetera::{BaseStrategy, choose_base_strategy};
1369
1370 match choose_base_strategy() {
1371 Ok(strategy) => {
1372 let config_dir = strategy.config_dir().join("rumdl");
1373
1374 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1376
1377 log::debug!(
1378 "[rumdl-config] Checking for user configuration in: {}",
1379 config_dir.display()
1380 );
1381
1382 for filename in USER_CONFIG_FILES {
1383 let config_path = config_dir.join(filename);
1384
1385 if config_path.exists() {
1386 if *filename == "pyproject.toml" {
1388 if let Ok(content) = std::fs::read_to_string(&config_path) {
1389 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1390 log::debug!(
1391 "[rumdl-config] Found user configuration at: {}",
1392 config_path.display()
1393 );
1394 return Some(config_path);
1395 }
1396 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
1397 continue;
1398 }
1399 } else {
1400 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
1401 return Some(config_path);
1402 }
1403 }
1404 }
1405
1406 log::debug!(
1407 "[rumdl-config] No user configuration found in: {}",
1408 config_dir.display()
1409 );
1410 None
1411 }
1412 Err(e) => {
1413 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
1414 None
1415 }
1416 }
1417 }
1418
1419 pub fn load_with_discovery(
1422 config_path: Option<&str>,
1423 cli_overrides: Option<&SourcedGlobalConfig>,
1424 skip_auto_discovery: bool,
1425 ) -> Result<Self, ConfigError> {
1426 use std::env;
1427 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1428 if config_path.is_none() {
1429 if skip_auto_discovery {
1430 log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
1431 } else {
1432 log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
1433 }
1434 } else {
1435 log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
1436 }
1437 let mut sourced_config = SourcedConfig::default();
1438
1439 if let Some(path) = config_path {
1441 let path_obj = Path::new(path);
1442 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
1443 log::debug!("[rumdl-config] Trying to load config file: {filename}");
1444 let path_str = path.to_string();
1445
1446 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
1448
1449 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
1450 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1451 source: e,
1452 path: path_str.clone(),
1453 })?;
1454 if filename == "pyproject.toml" {
1455 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1456 sourced_config.merge(fragment);
1457 sourced_config.loaded_files.push(path_str.clone());
1458 }
1459 } else {
1460 let fragment = parse_rumdl_toml(&content, &path_str)?;
1461 sourced_config.merge(fragment);
1462 sourced_config.loaded_files.push(path_str.clone());
1463 }
1464 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
1465 || path_str.ends_with(".json")
1466 || path_str.ends_with(".jsonc")
1467 || path_str.ends_with(".yaml")
1468 || path_str.ends_with(".yml")
1469 {
1470 let fragment = load_from_markdownlint(&path_str)?;
1472 sourced_config.merge(fragment);
1473 sourced_config.loaded_files.push(path_str.clone());
1474 } else {
1476 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1478 source: e,
1479 path: path_str.clone(),
1480 })?;
1481 let fragment = parse_rumdl_toml(&content, &path_str)?;
1482 sourced_config.merge(fragment);
1483 sourced_config.loaded_files.push(path_str.clone());
1484 }
1485 }
1486
1487 if !skip_auto_discovery && config_path.is_none() {
1489 if let Some(user_config_path) = Self::user_configuration_path() {
1491 let path_str = user_config_path.display().to_string();
1492 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1493
1494 log::debug!("[rumdl-config] Loading user configuration file: {path_str}");
1495
1496 if filename == "pyproject.toml" {
1497 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
1498 source: e,
1499 path: path_str.clone(),
1500 })?;
1501 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1502 sourced_config.merge(fragment);
1503 sourced_config.loaded_files.push(path_str);
1504 }
1505 } else {
1506 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
1507 source: e,
1508 path: path_str.clone(),
1509 })?;
1510 let fragment = parse_rumdl_toml(&content, &path_str)?;
1511 sourced_config.merge(fragment);
1512 sourced_config.loaded_files.push(path_str);
1513 }
1514 } else {
1515 log::debug!("[rumdl-config] No user configuration file found");
1516 }
1517
1518 if let Some(config_file) = Self::discover_config_upward() {
1520 let path_str = config_file.display().to_string();
1521 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
1522
1523 log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
1524
1525 if filename == "pyproject.toml" {
1526 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1527 source: e,
1528 path: path_str.clone(),
1529 })?;
1530 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1531 sourced_config.merge(fragment);
1532 sourced_config.loaded_files.push(path_str);
1533 }
1534 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
1535 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1536 source: e,
1537 path: path_str.clone(),
1538 })?;
1539 let fragment = parse_rumdl_toml(&content, &path_str)?;
1540 sourced_config.merge(fragment);
1541 sourced_config.loaded_files.push(path_str);
1542 }
1543 } else {
1544 log::debug!("[rumdl-config] No configuration file found via upward traversal");
1545
1546 let mut found_markdownlint = false;
1548 for filename in MARKDOWNLINT_CONFIG_FILES {
1549 if std::path::Path::new(filename).exists() {
1550 match load_from_markdownlint(filename) {
1551 Ok(fragment) => {
1552 sourced_config.merge(fragment);
1553 sourced_config.loaded_files.push(filename.to_string());
1554 found_markdownlint = true;
1555 break; }
1557 Err(_e) => {
1558 }
1560 }
1561 }
1562 }
1563
1564 if !found_markdownlint {
1565 log::debug!("[rumdl-config] No markdownlint configuration file found");
1566 }
1567 }
1568 }
1569
1570 if let Some(cli) = cli_overrides {
1572 sourced_config
1573 .global
1574 .enable
1575 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
1576 sourced_config
1577 .global
1578 .disable
1579 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
1580 sourced_config
1581 .global
1582 .exclude
1583 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
1584 sourced_config
1585 .global
1586 .include
1587 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
1588 sourced_config.global.respect_gitignore.merge_override(
1589 cli.respect_gitignore.value,
1590 ConfigSource::Cli,
1591 None,
1592 None,
1593 );
1594 sourced_config
1595 .global
1596 .fixable
1597 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
1598 sourced_config
1599 .global
1600 .unfixable
1601 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
1602 }
1604
1605 Ok(sourced_config)
1608 }
1609}
1610
1611impl From<SourcedConfig> for Config {
1612 fn from(sourced: SourcedConfig) -> Self {
1613 let mut rules = BTreeMap::new();
1614 for (rule_name, sourced_rule_cfg) in sourced.rules {
1615 let normalized_rule_name = rule_name.to_ascii_uppercase();
1617 let mut values = BTreeMap::new();
1618 for (key, sourced_val) in sourced_rule_cfg.values {
1619 values.insert(key, sourced_val.value);
1620 }
1621 rules.insert(normalized_rule_name, RuleConfig { values });
1622 }
1623 let global = GlobalConfig {
1624 enable: sourced.global.enable.value,
1625 disable: sourced.global.disable.value,
1626 exclude: sourced.global.exclude.value,
1627 include: sourced.global.include.value,
1628 respect_gitignore: sourced.global.respect_gitignore.value,
1629 line_length: sourced.global.line_length.value,
1630 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1631 fixable: sourced.global.fixable.value,
1632 unfixable: sourced.global.unfixable.value,
1633 flavor: sourced.global.flavor.value,
1634 force_exclude: sourced.global.force_exclude.value,
1635 };
1636 Config { global, rules }
1637 }
1638}
1639
1640pub struct RuleRegistry {
1642 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
1644 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
1646}
1647
1648impl RuleRegistry {
1649 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
1651 let mut rule_schemas = std::collections::BTreeMap::new();
1652 let mut rule_aliases = std::collections::BTreeMap::new();
1653
1654 for rule in rules {
1655 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
1656 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
1658 norm_name
1659 } else {
1660 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
1662 norm_name
1663 };
1664
1665 if let Some(aliases) = rule.config_aliases() {
1667 rule_aliases.insert(norm_name, aliases);
1668 }
1669 }
1670
1671 RuleRegistry {
1672 rule_schemas,
1673 rule_aliases,
1674 }
1675 }
1676
1677 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
1679 self.rule_schemas.keys().cloned().collect()
1680 }
1681
1682 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
1684 self.rule_schemas.get(rule).map(|schema| {
1685 let mut all_keys = std::collections::BTreeSet::new();
1686
1687 for key in schema.keys() {
1689 all_keys.insert(key.clone());
1690 }
1691
1692 for key in schema.keys() {
1694 all_keys.insert(key.replace('_', "-"));
1696 all_keys.insert(key.replace('-', "_"));
1698 all_keys.insert(normalize_key(key));
1700 }
1701
1702 if let Some(aliases) = self.rule_aliases.get(rule) {
1704 for alias_key in aliases.keys() {
1705 all_keys.insert(alias_key.clone());
1706 all_keys.insert(alias_key.replace('_', "-"));
1708 all_keys.insert(alias_key.replace('-', "_"));
1709 all_keys.insert(normalize_key(alias_key));
1710 }
1711 }
1712
1713 all_keys
1714 })
1715 }
1716
1717 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
1719 if let Some(schema) = self.rule_schemas.get(rule) {
1720 if let Some(aliases) = self.rule_aliases.get(rule)
1722 && let Some(canonical_key) = aliases.get(key)
1723 {
1724 if let Some(value) = schema.get(canonical_key) {
1726 return Some(value);
1727 }
1728 }
1729
1730 if let Some(value) = schema.get(key) {
1732 return Some(value);
1733 }
1734
1735 let key_variants = [
1737 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
1741
1742 for variant in &key_variants {
1743 if let Some(value) = schema.get(variant) {
1744 return Some(value);
1745 }
1746 }
1747 }
1748 None
1749 }
1750}
1751
1752#[derive(Debug, Clone)]
1754pub struct ConfigValidationWarning {
1755 pub message: String,
1756 pub rule: Option<String>,
1757 pub key: Option<String>,
1758}
1759
1760pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
1762 let mut warnings = Vec::new();
1763 let known_rules = registry.rule_names();
1764 for rule in sourced.rules.keys() {
1766 if !known_rules.contains(rule) {
1767 warnings.push(ConfigValidationWarning {
1768 message: format!("Unknown rule in config: {rule}"),
1769 rule: Some(rule.clone()),
1770 key: None,
1771 });
1772 }
1773 }
1774 for (rule, rule_cfg) in &sourced.rules {
1776 if let Some(valid_keys) = registry.config_keys_for(rule) {
1777 for key in rule_cfg.values.keys() {
1778 if !valid_keys.contains(key) {
1779 warnings.push(ConfigValidationWarning {
1780 message: format!("Unknown option for rule {rule}: {key}"),
1781 rule: Some(rule.clone()),
1782 key: Some(key.clone()),
1783 });
1784 } else {
1785 if let Some(expected) = registry.expected_value_for(rule, key) {
1787 let actual = &rule_cfg.values[key].value;
1788 if !toml_value_type_matches(expected, actual) {
1789 warnings.push(ConfigValidationWarning {
1790 message: format!(
1791 "Type mismatch for {}.{}: expected {}, got {}",
1792 rule,
1793 key,
1794 toml_type_name(expected),
1795 toml_type_name(actual)
1796 ),
1797 rule: Some(rule.clone()),
1798 key: Some(key.clone()),
1799 });
1800 }
1801 }
1802 }
1803 }
1804 }
1805 }
1806 for (section, key) in &sourced.unknown_keys {
1808 if section.contains("[global]") {
1809 warnings.push(ConfigValidationWarning {
1810 message: format!("Unknown global option: {key}"),
1811 rule: None,
1812 key: Some(key.clone()),
1813 });
1814 }
1815 }
1816 warnings
1817}
1818
1819fn toml_type_name(val: &toml::Value) -> &'static str {
1820 match val {
1821 toml::Value::String(_) => "string",
1822 toml::Value::Integer(_) => "integer",
1823 toml::Value::Float(_) => "float",
1824 toml::Value::Boolean(_) => "boolean",
1825 toml::Value::Array(_) => "array",
1826 toml::Value::Table(_) => "table",
1827 toml::Value::Datetime(_) => "datetime",
1828 }
1829}
1830
1831fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
1832 use toml::Value::*;
1833 match (expected, actual) {
1834 (String(_), String(_)) => true,
1835 (Integer(_), Integer(_)) => true,
1836 (Float(_), Float(_)) => true,
1837 (Boolean(_), Boolean(_)) => true,
1838 (Array(_), Array(_)) => true,
1839 (Table(_), Table(_)) => true,
1840 (Datetime(_), Datetime(_)) => true,
1841 (Float(_), Integer(_)) => true,
1843 _ => false,
1844 }
1845}
1846
1847fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
1849 let doc: toml::Value =
1850 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
1851 let mut fragment = SourcedConfigFragment::default();
1852 let source = ConfigSource::PyprojectToml;
1853 let file = Some(path.to_string());
1854
1855 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
1857 && let Some(rumdl_table) = rumdl_config.as_table()
1858 {
1859 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
1861 if let Some(enable) = table.get("enable")
1863 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
1864 {
1865 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1867 fragment
1868 .global
1869 .enable
1870 .push_override(normalized_values, source, file.clone(), None);
1871 }
1872
1873 if let Some(disable) = table.get("disable")
1874 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
1875 {
1876 let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
1878 fragment
1879 .global
1880 .disable
1881 .push_override(normalized_values, source, file.clone(), None);
1882 }
1883
1884 if let Some(include) = table.get("include")
1885 && let Ok(values) = Vec::<String>::deserialize(include.clone())
1886 {
1887 fragment
1888 .global
1889 .include
1890 .push_override(values, source, file.clone(), None);
1891 }
1892
1893 if let Some(exclude) = table.get("exclude")
1894 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
1895 {
1896 fragment
1897 .global
1898 .exclude
1899 .push_override(values, source, file.clone(), None);
1900 }
1901
1902 if let Some(respect_gitignore) = table
1903 .get("respect-gitignore")
1904 .or_else(|| table.get("respect_gitignore"))
1905 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
1906 {
1907 fragment
1908 .global
1909 .respect_gitignore
1910 .push_override(value, source, file.clone(), None);
1911 }
1912
1913 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
1914 && let Ok(value) = bool::deserialize(force_exclude.clone())
1915 {
1916 fragment
1917 .global
1918 .force_exclude
1919 .push_override(value, source, file.clone(), None);
1920 }
1921
1922 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
1923 && let Ok(value) = String::deserialize(output_format.clone())
1924 {
1925 if fragment.global.output_format.is_none() {
1926 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
1927 } else {
1928 fragment
1929 .global
1930 .output_format
1931 .as_mut()
1932 .unwrap()
1933 .push_override(value, source, file.clone(), None);
1934 }
1935 }
1936
1937 if let Some(fixable) = table.get("fixable")
1938 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
1939 {
1940 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1941 fragment
1942 .global
1943 .fixable
1944 .push_override(normalized_values, source, file.clone(), None);
1945 }
1946
1947 if let Some(unfixable) = table.get("unfixable")
1948 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
1949 {
1950 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1951 fragment
1952 .global
1953 .unfixable
1954 .push_override(normalized_values, source, file.clone(), None);
1955 }
1956
1957 if let Some(flavor) = table.get("flavor")
1958 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
1959 {
1960 fragment.global.flavor.push_override(value, source, file.clone(), None);
1961 }
1962
1963 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
1965 && let Ok(value) = u64::deserialize(line_length.clone())
1966 {
1967 fragment
1968 .global
1969 .line_length
1970 .push_override(value, source, file.clone(), None);
1971
1972 let norm_md013_key = normalize_key("MD013");
1974 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
1975 let norm_line_length_key = normalize_key("line-length");
1976 let sv = rule_entry
1977 .values
1978 .entry(norm_line_length_key)
1979 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
1980 sv.push_override(line_length.clone(), source, file.clone(), None);
1981 }
1982 };
1983
1984 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
1986 extract_global_config(&mut fragment, global_table);
1987 }
1988
1989 extract_global_config(&mut fragment, rumdl_table);
1991
1992 for (key, value) in rumdl_table {
1994 let norm_rule_key = normalize_key(key);
1995
1996 if [
1998 "enable",
1999 "disable",
2000 "include",
2001 "exclude",
2002 "respect_gitignore",
2003 "respect-gitignore", "force_exclude",
2005 "force-exclude",
2006 "line_length",
2007 "line-length",
2008 "output_format",
2009 "output-format",
2010 "fixable",
2011 "unfixable",
2012 ]
2013 .contains(&norm_rule_key.as_str())
2014 {
2015 continue;
2016 }
2017
2018 let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
2022 if norm_rule_key_upper.len() == 5
2023 && norm_rule_key_upper.starts_with("MD")
2024 && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
2025 && value.is_table()
2026 {
2027 if let Some(rule_config_table) = value.as_table() {
2028 let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
2030 for (rk, rv) in rule_config_table {
2031 let norm_rk = normalize_key(rk); let toml_val = rv.clone();
2034
2035 let sv = rule_entry
2036 .values
2037 .entry(norm_rk.clone())
2038 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2039 sv.push_override(toml_val, source, file.clone(), None);
2040 }
2041 }
2042 } else {
2043 }
2047 }
2048 }
2049
2050 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
2052 for (key, value) in tool_table.iter() {
2053 if let Some(rule_name) = key.strip_prefix("rumdl.") {
2054 let norm_rule_name = normalize_key(rule_name);
2055 if norm_rule_name.len() == 5
2056 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2057 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2058 && let Some(rule_table) = value.as_table()
2059 {
2060 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2061 for (rk, rv) in rule_table {
2062 let norm_rk = normalize_key(rk);
2063 let toml_val = rv.clone();
2064 let sv = rule_entry
2065 .values
2066 .entry(norm_rk.clone())
2067 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2068 sv.push_override(toml_val, source, file.clone(), None);
2069 }
2070 }
2071 }
2072 }
2073 }
2074
2075 if let Some(doc_table) = doc.as_table() {
2077 for (key, value) in doc_table.iter() {
2078 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
2079 let norm_rule_name = normalize_key(rule_name);
2080 if norm_rule_name.len() == 5
2081 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2082 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2083 && let Some(rule_table) = value.as_table()
2084 {
2085 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2086 for (rk, rv) in rule_table {
2087 let norm_rk = normalize_key(rk);
2088 let toml_val = rv.clone();
2089 let sv = rule_entry
2090 .values
2091 .entry(norm_rk.clone())
2092 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2093 sv.push_override(toml_val, source, file.clone(), None);
2094 }
2095 }
2096 }
2097 }
2098 }
2099
2100 let has_any = !fragment.global.enable.value.is_empty()
2102 || !fragment.global.disable.value.is_empty()
2103 || !fragment.global.include.value.is_empty()
2104 || !fragment.global.exclude.value.is_empty()
2105 || !fragment.global.fixable.value.is_empty()
2106 || !fragment.global.unfixable.value.is_empty()
2107 || fragment.global.output_format.is_some()
2108 || !fragment.rules.is_empty();
2109 if has_any { Ok(Some(fragment)) } else { Ok(None) }
2110}
2111
2112fn parse_rumdl_toml(content: &str, path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2114 let doc = content
2115 .parse::<DocumentMut>()
2116 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2117 let mut fragment = SourcedConfigFragment::default();
2118 let source = ConfigSource::RumdlToml;
2119 let file = Some(path.to_string());
2120
2121 let all_rules = rules::all_rules(&Config::default());
2123 let registry = RuleRegistry::from_rules(&all_rules);
2124 let known_rule_names: BTreeSet<String> = registry
2125 .rule_names()
2126 .into_iter()
2127 .map(|s| s.to_ascii_uppercase())
2128 .collect();
2129
2130 if let Some(global_item) = doc.get("global")
2132 && let Some(global_table) = global_item.as_table()
2133 {
2134 for (key, value_item) in global_table.iter() {
2135 let norm_key = normalize_key(key);
2136 match norm_key.as_str() {
2137 "enable" | "disable" | "include" | "exclude" => {
2138 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2139 let values: Vec<String> = formatted_array
2141 .iter()
2142 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
2144 .collect();
2145
2146 let final_values = if norm_key == "enable" || norm_key == "disable" {
2148 values.into_iter().map(|s| normalize_key(&s)).collect()
2150 } else {
2151 values
2152 };
2153
2154 match norm_key.as_str() {
2155 "enable" => fragment
2156 .global
2157 .enable
2158 .push_override(final_values, source, file.clone(), None),
2159 "disable" => {
2160 fragment
2161 .global
2162 .disable
2163 .push_override(final_values, source, file.clone(), None)
2164 }
2165 "include" => {
2166 fragment
2167 .global
2168 .include
2169 .push_override(final_values, source, file.clone(), None)
2170 }
2171 "exclude" => {
2172 fragment
2173 .global
2174 .exclude
2175 .push_override(final_values, source, file.clone(), None)
2176 }
2177 _ => unreachable!(), }
2179 } else {
2180 log::warn!(
2181 "[WARN] Expected array for global key '{}' in {}, found {}",
2182 key,
2183 path,
2184 value_item.type_name()
2185 );
2186 }
2187 }
2188 "respect_gitignore" | "respect-gitignore" => {
2189 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2191 let val = *formatted_bool.value();
2192 fragment
2193 .global
2194 .respect_gitignore
2195 .push_override(val, source, file.clone(), None);
2196 } else {
2197 log::warn!(
2198 "[WARN] Expected boolean for global key '{}' in {}, found {}",
2199 key,
2200 path,
2201 value_item.type_name()
2202 );
2203 }
2204 }
2205 "force_exclude" | "force-exclude" => {
2206 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2208 let val = *formatted_bool.value();
2209 fragment
2210 .global
2211 .force_exclude
2212 .push_override(val, source, file.clone(), None);
2213 } else {
2214 log::warn!(
2215 "[WARN] Expected boolean for global key '{}' in {}, found {}",
2216 key,
2217 path,
2218 value_item.type_name()
2219 );
2220 }
2221 }
2222 "line_length" | "line-length" => {
2223 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
2225 let val = *formatted_int.value() as u64;
2226 fragment
2227 .global
2228 .line_length
2229 .push_override(val, source, file.clone(), None);
2230 } else {
2231 log::warn!(
2232 "[WARN] Expected integer for global key '{}' in {}, found {}",
2233 key,
2234 path,
2235 value_item.type_name()
2236 );
2237 }
2238 }
2239 "output_format" | "output-format" => {
2240 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2242 let val = formatted_string.value().clone();
2243 if fragment.global.output_format.is_none() {
2244 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
2245 } else {
2246 fragment.global.output_format.as_mut().unwrap().push_override(
2247 val,
2248 source,
2249 file.clone(),
2250 None,
2251 );
2252 }
2253 } else {
2254 log::warn!(
2255 "[WARN] Expected string for global key '{}' in {}, found {}",
2256 key,
2257 path,
2258 value_item.type_name()
2259 );
2260 }
2261 }
2262 "fixable" => {
2263 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2264 let values: Vec<String> = formatted_array
2265 .iter()
2266 .filter_map(|item| item.as_str())
2267 .map(normalize_key)
2268 .collect();
2269 fragment
2270 .global
2271 .fixable
2272 .push_override(values, source, file.clone(), None);
2273 } else {
2274 log::warn!(
2275 "[WARN] Expected array for global key '{}' in {}, found {}",
2276 key,
2277 path,
2278 value_item.type_name()
2279 );
2280 }
2281 }
2282 "unfixable" => {
2283 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2284 let values: Vec<String> = formatted_array
2285 .iter()
2286 .filter_map(|item| item.as_str())
2287 .map(normalize_key)
2288 .collect();
2289 fragment
2290 .global
2291 .unfixable
2292 .push_override(values, source, file.clone(), None);
2293 } else {
2294 log::warn!(
2295 "[WARN] Expected array for global key '{}' in {}, found {}",
2296 key,
2297 path,
2298 value_item.type_name()
2299 );
2300 }
2301 }
2302 "flavor" => {
2303 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2304 let val = formatted_string.value();
2305 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
2306 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
2307 } else {
2308 log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
2309 }
2310 } else {
2311 log::warn!(
2312 "[WARN] Expected string for global key '{}' in {}, found {}",
2313 key,
2314 path,
2315 value_item.type_name()
2316 );
2317 }
2318 }
2319 _ => {
2320 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
2323 }
2324 }
2325 }
2326 }
2327
2328 for (key, item) in doc.iter() {
2330 let norm_rule_name = key.to_ascii_uppercase();
2331 if !known_rule_names.contains(&norm_rule_name) {
2332 continue;
2333 }
2334 if let Some(tbl) = item.as_table() {
2335 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
2336 for (rk, rv_item) in tbl.iter() {
2337 let norm_rk = normalize_key(rk);
2338 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
2339 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
2340 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
2341 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
2342 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
2343 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
2344 Some(toml_edit::Value::Array(formatted_array)) => {
2345 let mut values = Vec::new();
2347 for item in formatted_array.iter() {
2348 match item {
2349 toml_edit::Value::String(formatted) => {
2350 values.push(toml::Value::String(formatted.value().clone()))
2351 }
2352 toml_edit::Value::Integer(formatted) => {
2353 values.push(toml::Value::Integer(*formatted.value()))
2354 }
2355 toml_edit::Value::Float(formatted) => {
2356 values.push(toml::Value::Float(*formatted.value()))
2357 }
2358 toml_edit::Value::Boolean(formatted) => {
2359 values.push(toml::Value::Boolean(*formatted.value()))
2360 }
2361 toml_edit::Value::Datetime(formatted) => {
2362 values.push(toml::Value::Datetime(*formatted.value()))
2363 }
2364 _ => {
2365 log::warn!(
2366 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
2367 );
2368 }
2369 }
2370 }
2371 Some(toml::Value::Array(values))
2372 }
2373 Some(toml_edit::Value::InlineTable(_)) => {
2374 log::warn!(
2375 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
2376 );
2377 None
2378 }
2379 None => {
2380 log::warn!(
2381 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
2382 );
2383 None
2384 }
2385 };
2386 if let Some(toml_val) = maybe_toml_val {
2387 let sv = rule_entry
2388 .values
2389 .entry(norm_rk.clone())
2390 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2391 sv.push_override(toml_val, source, file.clone(), None);
2392 }
2393 }
2394 } else if item.is_value() {
2395 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
2396 }
2397 }
2398
2399 Ok(fragment)
2400}
2401
2402fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2404 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
2406 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
2407 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
2408}