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 fn user_configuration_path() -> Option<std::path::PathBuf> {
1345 use etcetera::{BaseStrategy, choose_base_strategy};
1346
1347 match choose_base_strategy() {
1348 Ok(strategy) => {
1349 let config_dir = strategy.config_dir().join("rumdl");
1350
1351 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1353
1354 log::debug!(
1355 "[rumdl-config] Checking for user configuration in: {}",
1356 config_dir.display()
1357 );
1358
1359 for filename in USER_CONFIG_FILES {
1360 let config_path = config_dir.join(filename);
1361
1362 if config_path.exists() {
1363 if *filename == "pyproject.toml" {
1365 if let Ok(content) = std::fs::read_to_string(&config_path) {
1366 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1367 log::debug!(
1368 "[rumdl-config] Found user configuration at: {}",
1369 config_path.display()
1370 );
1371 return Some(config_path);
1372 }
1373 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
1374 continue;
1375 }
1376 } else {
1377 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
1378 return Some(config_path);
1379 }
1380 }
1381 }
1382
1383 log::debug!(
1384 "[rumdl-config] No user configuration found in: {}",
1385 config_dir.display()
1386 );
1387 None
1388 }
1389 Err(e) => {
1390 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
1391 None
1392 }
1393 }
1394 }
1395
1396 pub fn load_with_discovery(
1399 config_path: Option<&str>,
1400 cli_overrides: Option<&SourcedGlobalConfig>,
1401 skip_auto_discovery: bool,
1402 ) -> Result<Self, ConfigError> {
1403 use std::env;
1404 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1405 if config_path.is_none() {
1406 if skip_auto_discovery {
1407 log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
1408 } else {
1409 log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
1410 }
1411 } else {
1412 log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
1413 }
1414 let mut sourced_config = SourcedConfig::default();
1415
1416 if let Some(path) = config_path {
1418 let path_obj = Path::new(path);
1419 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
1420 log::debug!("[rumdl-config] Trying to load config file: {filename}");
1421 let path_str = path.to_string();
1422
1423 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
1425
1426 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
1427 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1428 source: e,
1429 path: path_str.clone(),
1430 })?;
1431 if filename == "pyproject.toml" {
1432 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1433 sourced_config.merge(fragment);
1434 sourced_config.loaded_files.push(path_str.clone());
1435 }
1436 } else {
1437 let fragment = parse_rumdl_toml(&content, &path_str)?;
1438 sourced_config.merge(fragment);
1439 sourced_config.loaded_files.push(path_str.clone());
1440 }
1441 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
1442 || path_str.ends_with(".json")
1443 || path_str.ends_with(".jsonc")
1444 || path_str.ends_with(".yaml")
1445 || path_str.ends_with(".yml")
1446 {
1447 let fragment = load_from_markdownlint(&path_str)?;
1449 sourced_config.merge(fragment);
1450 sourced_config.loaded_files.push(path_str.clone());
1451 } else {
1453 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1455 source: e,
1456 path: path_str.clone(),
1457 })?;
1458 let fragment = parse_rumdl_toml(&content, &path_str)?;
1459 sourced_config.merge(fragment);
1460 sourced_config.loaded_files.push(path_str.clone());
1461 }
1462 }
1463
1464 if !skip_auto_discovery && config_path.is_none() {
1466 if let Some(user_config_path) = Self::user_configuration_path() {
1468 let path_str = user_config_path.display().to_string();
1469 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1470
1471 log::debug!("[rumdl-config] Loading user configuration file: {path_str}");
1472
1473 if filename == "pyproject.toml" {
1474 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
1475 source: e,
1476 path: path_str.clone(),
1477 })?;
1478 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1479 sourced_config.merge(fragment);
1480 sourced_config.loaded_files.push(path_str);
1481 }
1482 } else {
1483 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
1484 source: e,
1485 path: path_str.clone(),
1486 })?;
1487 let fragment = parse_rumdl_toml(&content, &path_str)?;
1488 sourced_config.merge(fragment);
1489 sourced_config.loaded_files.push(path_str);
1490 }
1491 } else {
1492 log::debug!("[rumdl-config] No user configuration file found");
1493 }
1494
1495 if let Some(config_file) = Self::discover_config_upward() {
1497 let path_str = config_file.display().to_string();
1498 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
1499
1500 log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
1501
1502 if filename == "pyproject.toml" {
1503 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1504 source: e,
1505 path: path_str.clone(),
1506 })?;
1507 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1508 sourced_config.merge(fragment);
1509 sourced_config.loaded_files.push(path_str);
1510 }
1511 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
1512 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1513 source: e,
1514 path: path_str.clone(),
1515 })?;
1516 let fragment = parse_rumdl_toml(&content, &path_str)?;
1517 sourced_config.merge(fragment);
1518 sourced_config.loaded_files.push(path_str);
1519 }
1520 } else {
1521 log::debug!("[rumdl-config] No configuration file found via upward traversal");
1522
1523 let mut found_markdownlint = false;
1525 for filename in MARKDOWNLINT_CONFIG_FILES {
1526 if std::path::Path::new(filename).exists() {
1527 match load_from_markdownlint(filename) {
1528 Ok(fragment) => {
1529 sourced_config.merge(fragment);
1530 sourced_config.loaded_files.push(filename.to_string());
1531 found_markdownlint = true;
1532 break; }
1534 Err(_e) => {
1535 }
1537 }
1538 }
1539 }
1540
1541 if !found_markdownlint {
1542 log::debug!("[rumdl-config] No markdownlint configuration file found");
1543 }
1544 }
1545 }
1546
1547 if let Some(cli) = cli_overrides {
1549 sourced_config
1550 .global
1551 .enable
1552 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
1553 sourced_config
1554 .global
1555 .disable
1556 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
1557 sourced_config
1558 .global
1559 .exclude
1560 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
1561 sourced_config
1562 .global
1563 .include
1564 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
1565 sourced_config.global.respect_gitignore.merge_override(
1566 cli.respect_gitignore.value,
1567 ConfigSource::Cli,
1568 None,
1569 None,
1570 );
1571 sourced_config
1572 .global
1573 .fixable
1574 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
1575 sourced_config
1576 .global
1577 .unfixable
1578 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
1579 }
1581
1582 Ok(sourced_config)
1585 }
1586}
1587
1588impl From<SourcedConfig> for Config {
1589 fn from(sourced: SourcedConfig) -> Self {
1590 let mut rules = BTreeMap::new();
1591 for (rule_name, sourced_rule_cfg) in sourced.rules {
1592 let normalized_rule_name = rule_name.to_ascii_uppercase();
1594 let mut values = BTreeMap::new();
1595 for (key, sourced_val) in sourced_rule_cfg.values {
1596 values.insert(key, sourced_val.value);
1597 }
1598 rules.insert(normalized_rule_name, RuleConfig { values });
1599 }
1600 let global = GlobalConfig {
1601 enable: sourced.global.enable.value,
1602 disable: sourced.global.disable.value,
1603 exclude: sourced.global.exclude.value,
1604 include: sourced.global.include.value,
1605 respect_gitignore: sourced.global.respect_gitignore.value,
1606 line_length: sourced.global.line_length.value,
1607 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1608 fixable: sourced.global.fixable.value,
1609 unfixable: sourced.global.unfixable.value,
1610 flavor: sourced.global.flavor.value,
1611 };
1612 Config { global, rules }
1613 }
1614}
1615
1616pub struct RuleRegistry {
1618 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
1620 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
1622}
1623
1624impl RuleRegistry {
1625 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
1627 let mut rule_schemas = std::collections::BTreeMap::new();
1628 let mut rule_aliases = std::collections::BTreeMap::new();
1629
1630 for rule in rules {
1631 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
1632 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
1634 norm_name
1635 } else {
1636 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
1638 norm_name
1639 };
1640
1641 if let Some(aliases) = rule.config_aliases() {
1643 rule_aliases.insert(norm_name, aliases);
1644 }
1645 }
1646
1647 RuleRegistry {
1648 rule_schemas,
1649 rule_aliases,
1650 }
1651 }
1652
1653 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
1655 self.rule_schemas.keys().cloned().collect()
1656 }
1657
1658 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
1660 self.rule_schemas.get(rule).map(|schema| {
1661 let mut all_keys = std::collections::BTreeSet::new();
1662
1663 for key in schema.keys() {
1665 all_keys.insert(key.clone());
1666 }
1667
1668 for key in schema.keys() {
1670 all_keys.insert(key.replace('_', "-"));
1672 all_keys.insert(key.replace('-', "_"));
1674 all_keys.insert(normalize_key(key));
1676 }
1677
1678 if let Some(aliases) = self.rule_aliases.get(rule) {
1680 for alias_key in aliases.keys() {
1681 all_keys.insert(alias_key.clone());
1682 all_keys.insert(alias_key.replace('_', "-"));
1684 all_keys.insert(alias_key.replace('-', "_"));
1685 all_keys.insert(normalize_key(alias_key));
1686 }
1687 }
1688
1689 all_keys
1690 })
1691 }
1692
1693 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
1695 if let Some(schema) = self.rule_schemas.get(rule) {
1696 if let Some(aliases) = self.rule_aliases.get(rule)
1698 && let Some(canonical_key) = aliases.get(key)
1699 {
1700 if let Some(value) = schema.get(canonical_key) {
1702 return Some(value);
1703 }
1704 }
1705
1706 if let Some(value) = schema.get(key) {
1708 return Some(value);
1709 }
1710
1711 let key_variants = [
1713 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
1717
1718 for variant in &key_variants {
1719 if let Some(value) = schema.get(variant) {
1720 return Some(value);
1721 }
1722 }
1723 }
1724 None
1725 }
1726}
1727
1728#[derive(Debug, Clone)]
1730pub struct ConfigValidationWarning {
1731 pub message: String,
1732 pub rule: Option<String>,
1733 pub key: Option<String>,
1734}
1735
1736pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
1738 let mut warnings = Vec::new();
1739 let known_rules = registry.rule_names();
1740 for rule in sourced.rules.keys() {
1742 if !known_rules.contains(rule) {
1743 warnings.push(ConfigValidationWarning {
1744 message: format!("Unknown rule in config: {rule}"),
1745 rule: Some(rule.clone()),
1746 key: None,
1747 });
1748 }
1749 }
1750 for (rule, rule_cfg) in &sourced.rules {
1752 if let Some(valid_keys) = registry.config_keys_for(rule) {
1753 for key in rule_cfg.values.keys() {
1754 if !valid_keys.contains(key) {
1755 warnings.push(ConfigValidationWarning {
1756 message: format!("Unknown option for rule {rule}: {key}"),
1757 rule: Some(rule.clone()),
1758 key: Some(key.clone()),
1759 });
1760 } else {
1761 if let Some(expected) = registry.expected_value_for(rule, key) {
1763 let actual = &rule_cfg.values[key].value;
1764 if !toml_value_type_matches(expected, actual) {
1765 warnings.push(ConfigValidationWarning {
1766 message: format!(
1767 "Type mismatch for {}.{}: expected {}, got {}",
1768 rule,
1769 key,
1770 toml_type_name(expected),
1771 toml_type_name(actual)
1772 ),
1773 rule: Some(rule.clone()),
1774 key: Some(key.clone()),
1775 });
1776 }
1777 }
1778 }
1779 }
1780 }
1781 }
1782 for (section, key) in &sourced.unknown_keys {
1784 if section.contains("[global]") {
1785 warnings.push(ConfigValidationWarning {
1786 message: format!("Unknown global option: {key}"),
1787 rule: None,
1788 key: Some(key.clone()),
1789 });
1790 }
1791 }
1792 warnings
1793}
1794
1795fn toml_type_name(val: &toml::Value) -> &'static str {
1796 match val {
1797 toml::Value::String(_) => "string",
1798 toml::Value::Integer(_) => "integer",
1799 toml::Value::Float(_) => "float",
1800 toml::Value::Boolean(_) => "boolean",
1801 toml::Value::Array(_) => "array",
1802 toml::Value::Table(_) => "table",
1803 toml::Value::Datetime(_) => "datetime",
1804 }
1805}
1806
1807fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
1808 use toml::Value::*;
1809 match (expected, actual) {
1810 (String(_), String(_)) => true,
1811 (Integer(_), Integer(_)) => true,
1812 (Float(_), Float(_)) => true,
1813 (Boolean(_), Boolean(_)) => true,
1814 (Array(_), Array(_)) => true,
1815 (Table(_), Table(_)) => true,
1816 (Datetime(_), Datetime(_)) => true,
1817 (Float(_), Integer(_)) => true,
1819 _ => false,
1820 }
1821}
1822
1823fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
1825 let doc: toml::Value =
1826 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
1827 let mut fragment = SourcedConfigFragment::default();
1828 let source = ConfigSource::PyprojectToml;
1829 let file = Some(path.to_string());
1830
1831 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
1833 && let Some(rumdl_table) = rumdl_config.as_table()
1834 {
1835 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
1837 if let Some(enable) = table.get("enable")
1839 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
1840 {
1841 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1843 fragment
1844 .global
1845 .enable
1846 .push_override(normalized_values, source, file.clone(), None);
1847 }
1848
1849 if let Some(disable) = table.get("disable")
1850 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
1851 {
1852 let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
1854 fragment
1855 .global
1856 .disable
1857 .push_override(normalized_values, source, file.clone(), None);
1858 }
1859
1860 if let Some(include) = table.get("include")
1861 && let Ok(values) = Vec::<String>::deserialize(include.clone())
1862 {
1863 fragment
1864 .global
1865 .include
1866 .push_override(values, source, file.clone(), None);
1867 }
1868
1869 if let Some(exclude) = table.get("exclude")
1870 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
1871 {
1872 fragment
1873 .global
1874 .exclude
1875 .push_override(values, source, file.clone(), None);
1876 }
1877
1878 if let Some(respect_gitignore) = table
1879 .get("respect-gitignore")
1880 .or_else(|| table.get("respect_gitignore"))
1881 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
1882 {
1883 fragment
1884 .global
1885 .respect_gitignore
1886 .push_override(value, source, file.clone(), None);
1887 }
1888
1889 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
1890 && let Ok(value) = String::deserialize(output_format.clone())
1891 {
1892 if fragment.global.output_format.is_none() {
1893 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
1894 } else {
1895 fragment
1896 .global
1897 .output_format
1898 .as_mut()
1899 .unwrap()
1900 .push_override(value, source, file.clone(), None);
1901 }
1902 }
1903
1904 if let Some(fixable) = table.get("fixable")
1905 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
1906 {
1907 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1908 fragment
1909 .global
1910 .fixable
1911 .push_override(normalized_values, source, file.clone(), None);
1912 }
1913
1914 if let Some(unfixable) = table.get("unfixable")
1915 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
1916 {
1917 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1918 fragment
1919 .global
1920 .unfixable
1921 .push_override(normalized_values, source, file.clone(), None);
1922 }
1923
1924 if let Some(flavor) = table.get("flavor")
1925 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
1926 {
1927 fragment.global.flavor.push_override(value, source, file.clone(), None);
1928 }
1929
1930 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
1932 && let Ok(value) = u64::deserialize(line_length.clone())
1933 {
1934 fragment
1935 .global
1936 .line_length
1937 .push_override(value, source, file.clone(), None);
1938
1939 let norm_md013_key = normalize_key("MD013");
1941 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
1942 let norm_line_length_key = normalize_key("line-length");
1943 let sv = rule_entry
1944 .values
1945 .entry(norm_line_length_key)
1946 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
1947 sv.push_override(line_length.clone(), source, file.clone(), None);
1948 }
1949 };
1950
1951 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
1953 extract_global_config(&mut fragment, global_table);
1954 }
1955
1956 extract_global_config(&mut fragment, rumdl_table);
1958
1959 for (key, value) in rumdl_table {
1961 let norm_rule_key = normalize_key(key);
1962
1963 if [
1965 "enable",
1966 "disable",
1967 "include",
1968 "exclude",
1969 "respect_gitignore",
1970 "respect-gitignore", "line_length",
1972 "line-length",
1973 "output_format",
1974 "output-format",
1975 "fixable",
1976 "unfixable",
1977 ]
1978 .contains(&norm_rule_key.as_str())
1979 {
1980 continue;
1981 }
1982
1983 let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
1987 if norm_rule_key_upper.len() == 5
1988 && norm_rule_key_upper.starts_with("MD")
1989 && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
1990 && value.is_table()
1991 {
1992 if let Some(rule_config_table) = value.as_table() {
1993 let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
1995 for (rk, rv) in rule_config_table {
1996 let norm_rk = normalize_key(rk); let toml_val = rv.clone();
1999
2000 let sv = rule_entry
2001 .values
2002 .entry(norm_rk.clone())
2003 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2004 sv.push_override(toml_val, source, file.clone(), None);
2005 }
2006 }
2007 } else {
2008 }
2012 }
2013 }
2014
2015 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
2017 for (key, value) in tool_table.iter() {
2018 if let Some(rule_name) = key.strip_prefix("rumdl.") {
2019 let norm_rule_name = normalize_key(rule_name);
2020 if norm_rule_name.len() == 5
2021 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2022 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2023 && let Some(rule_table) = value.as_table()
2024 {
2025 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2026 for (rk, rv) in rule_table {
2027 let norm_rk = normalize_key(rk);
2028 let toml_val = rv.clone();
2029 let sv = rule_entry
2030 .values
2031 .entry(norm_rk.clone())
2032 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2033 sv.push_override(toml_val, source, file.clone(), None);
2034 }
2035 }
2036 }
2037 }
2038 }
2039
2040 if let Some(doc_table) = doc.as_table() {
2042 for (key, value) in doc_table.iter() {
2043 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
2044 let norm_rule_name = normalize_key(rule_name);
2045 if norm_rule_name.len() == 5
2046 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2047 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2048 && let Some(rule_table) = value.as_table()
2049 {
2050 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2051 for (rk, rv) in rule_table {
2052 let norm_rk = normalize_key(rk);
2053 let toml_val = rv.clone();
2054 let sv = rule_entry
2055 .values
2056 .entry(norm_rk.clone())
2057 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2058 sv.push_override(toml_val, source, file.clone(), None);
2059 }
2060 }
2061 }
2062 }
2063 }
2064
2065 let has_any = !fragment.global.enable.value.is_empty()
2067 || !fragment.global.disable.value.is_empty()
2068 || !fragment.global.include.value.is_empty()
2069 || !fragment.global.exclude.value.is_empty()
2070 || !fragment.global.fixable.value.is_empty()
2071 || !fragment.global.unfixable.value.is_empty()
2072 || fragment.global.output_format.is_some()
2073 || !fragment.rules.is_empty();
2074 if has_any { Ok(Some(fragment)) } else { Ok(None) }
2075}
2076
2077fn parse_rumdl_toml(content: &str, path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2079 let doc = content
2080 .parse::<DocumentMut>()
2081 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2082 let mut fragment = SourcedConfigFragment::default();
2083 let source = ConfigSource::RumdlToml;
2084 let file = Some(path.to_string());
2085
2086 let all_rules = rules::all_rules(&Config::default());
2088 let registry = RuleRegistry::from_rules(&all_rules);
2089 let known_rule_names: BTreeSet<String> = registry
2090 .rule_names()
2091 .into_iter()
2092 .map(|s| s.to_ascii_uppercase())
2093 .collect();
2094
2095 if let Some(global_item) = doc.get("global")
2097 && let Some(global_table) = global_item.as_table()
2098 {
2099 for (key, value_item) in global_table.iter() {
2100 let norm_key = normalize_key(key);
2101 match norm_key.as_str() {
2102 "enable" | "disable" | "include" | "exclude" => {
2103 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2104 let values: Vec<String> = formatted_array
2106 .iter()
2107 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
2109 .collect();
2110
2111 let final_values = if norm_key == "enable" || norm_key == "disable" {
2113 values.into_iter().map(|s| normalize_key(&s)).collect()
2115 } else {
2116 values
2117 };
2118
2119 match norm_key.as_str() {
2120 "enable" => fragment
2121 .global
2122 .enable
2123 .push_override(final_values, source, file.clone(), None),
2124 "disable" => {
2125 fragment
2126 .global
2127 .disable
2128 .push_override(final_values, source, file.clone(), None)
2129 }
2130 "include" => {
2131 fragment
2132 .global
2133 .include
2134 .push_override(final_values, source, file.clone(), None)
2135 }
2136 "exclude" => {
2137 fragment
2138 .global
2139 .exclude
2140 .push_override(final_values, source, file.clone(), None)
2141 }
2142 _ => unreachable!(), }
2144 } else {
2145 log::warn!(
2146 "[WARN] Expected array for global key '{}' in {}, found {}",
2147 key,
2148 path,
2149 value_item.type_name()
2150 );
2151 }
2152 }
2153 "respect_gitignore" | "respect-gitignore" => {
2154 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2156 let val = *formatted_bool.value();
2157 fragment
2158 .global
2159 .respect_gitignore
2160 .push_override(val, source, file.clone(), None);
2161 } else {
2162 log::warn!(
2163 "[WARN] Expected boolean for global key '{}' in {}, found {}",
2164 key,
2165 path,
2166 value_item.type_name()
2167 );
2168 }
2169 }
2170 "line_length" | "line-length" => {
2171 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
2173 let val = *formatted_int.value() as u64;
2174 fragment
2175 .global
2176 .line_length
2177 .push_override(val, source, file.clone(), None);
2178 } else {
2179 log::warn!(
2180 "[WARN] Expected integer for global key '{}' in {}, found {}",
2181 key,
2182 path,
2183 value_item.type_name()
2184 );
2185 }
2186 }
2187 "output_format" | "output-format" => {
2188 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2190 let val = formatted_string.value().clone();
2191 if fragment.global.output_format.is_none() {
2192 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
2193 } else {
2194 fragment.global.output_format.as_mut().unwrap().push_override(
2195 val,
2196 source,
2197 file.clone(),
2198 None,
2199 );
2200 }
2201 } else {
2202 log::warn!(
2203 "[WARN] Expected string for global key '{}' in {}, found {}",
2204 key,
2205 path,
2206 value_item.type_name()
2207 );
2208 }
2209 }
2210 "fixable" => {
2211 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2212 let values: Vec<String> = formatted_array
2213 .iter()
2214 .filter_map(|item| item.as_str())
2215 .map(normalize_key)
2216 .collect();
2217 fragment
2218 .global
2219 .fixable
2220 .push_override(values, source, file.clone(), None);
2221 } else {
2222 log::warn!(
2223 "[WARN] Expected array for global key '{}' in {}, found {}",
2224 key,
2225 path,
2226 value_item.type_name()
2227 );
2228 }
2229 }
2230 "unfixable" => {
2231 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2232 let values: Vec<String> = formatted_array
2233 .iter()
2234 .filter_map(|item| item.as_str())
2235 .map(normalize_key)
2236 .collect();
2237 fragment
2238 .global
2239 .unfixable
2240 .push_override(values, source, file.clone(), None);
2241 } else {
2242 log::warn!(
2243 "[WARN] Expected array for global key '{}' in {}, found {}",
2244 key,
2245 path,
2246 value_item.type_name()
2247 );
2248 }
2249 }
2250 "flavor" => {
2251 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2252 let val = formatted_string.value();
2253 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
2254 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
2255 } else {
2256 log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
2257 }
2258 } else {
2259 log::warn!(
2260 "[WARN] Expected string for global key '{}' in {}, found {}",
2261 key,
2262 path,
2263 value_item.type_name()
2264 );
2265 }
2266 }
2267 _ => {
2268 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
2271 }
2272 }
2273 }
2274 }
2275
2276 for (key, item) in doc.iter() {
2278 let norm_rule_name = key.to_ascii_uppercase();
2279 if !known_rule_names.contains(&norm_rule_name) {
2280 continue;
2281 }
2282 if let Some(tbl) = item.as_table() {
2283 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
2284 for (rk, rv_item) in tbl.iter() {
2285 let norm_rk = normalize_key(rk);
2286 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
2287 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
2288 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
2289 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
2290 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
2291 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
2292 Some(toml_edit::Value::Array(formatted_array)) => {
2293 let mut values = Vec::new();
2295 for item in formatted_array.iter() {
2296 match item {
2297 toml_edit::Value::String(formatted) => {
2298 values.push(toml::Value::String(formatted.value().clone()))
2299 }
2300 toml_edit::Value::Integer(formatted) => {
2301 values.push(toml::Value::Integer(*formatted.value()))
2302 }
2303 toml_edit::Value::Float(formatted) => {
2304 values.push(toml::Value::Float(*formatted.value()))
2305 }
2306 toml_edit::Value::Boolean(formatted) => {
2307 values.push(toml::Value::Boolean(*formatted.value()))
2308 }
2309 toml_edit::Value::Datetime(formatted) => {
2310 values.push(toml::Value::Datetime(*formatted.value()))
2311 }
2312 _ => {
2313 log::warn!(
2314 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
2315 );
2316 }
2317 }
2318 }
2319 Some(toml::Value::Array(values))
2320 }
2321 Some(toml_edit::Value::InlineTable(_)) => {
2322 log::warn!(
2323 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
2324 );
2325 None
2326 }
2327 None => {
2328 log::warn!(
2329 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
2330 );
2331 None
2332 }
2333 };
2334 if let Some(toml_val) = maybe_toml_val {
2335 let sv = rule_entry
2336 .values
2337 .entry(norm_rk.clone())
2338 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2339 sv.push_override(toml_val, source, file.clone(), None);
2340 }
2341 }
2342 } else if item.is_value() {
2343 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
2344 }
2345 }
2346
2347 Ok(fragment)
2348}
2349
2350fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2352 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
2354 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
2355 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
2356}