1use crate::rule::Rule;
6use crate::rules;
7use log;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::collections::{BTreeSet, HashMap, HashSet};
11use std::fmt;
12use std::fs;
13use std::io;
14use std::path::Path;
15use std::str::FromStr;
16use toml_edit::DocumentMut;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema)]
20#[serde(rename_all = "lowercase")]
21pub enum MarkdownFlavor {
22 #[serde(rename = "standard", alias = "none", alias = "")]
24 #[default]
25 Standard,
26 #[serde(rename = "mkdocs")]
28 MkDocs,
29 #[serde(rename = "mdx")]
31 MDX,
32 #[serde(rename = "quarto")]
34 Quarto,
35 }
39
40impl fmt::Display for MarkdownFlavor {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 match self {
43 MarkdownFlavor::Standard => write!(f, "standard"),
44 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
45 MarkdownFlavor::MDX => write!(f, "mdx"),
46 MarkdownFlavor::Quarto => write!(f, "quarto"),
47 }
48 }
49}
50
51impl FromStr for MarkdownFlavor {
52 type Err = String;
53
54 fn from_str(s: &str) -> Result<Self, Self::Err> {
55 match s.to_lowercase().as_str() {
56 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
57 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
58 "mdx" => Ok(MarkdownFlavor::MDX),
59 "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
60 "gfm" | "github" => {
62 eprintln!("Warning: GFM flavor not yet implemented, using standard");
63 Ok(MarkdownFlavor::Standard)
64 }
65 "commonmark" => {
66 eprintln!("Warning: CommonMark flavor not yet implemented, using standard");
67 Ok(MarkdownFlavor::Standard)
68 }
69 _ => Err(format!("Unknown markdown flavor: {s}")),
70 }
71 }
72}
73
74impl MarkdownFlavor {
75 pub fn from_extension(ext: &str) -> Self {
77 match ext.to_lowercase().as_str() {
78 "mdx" => Self::MDX,
79 "qmd" => Self::Quarto,
80 "rmd" => Self::Quarto,
81 _ => Self::Standard,
82 }
83 }
84
85 pub fn from_path(path: &std::path::Path) -> Self {
87 path.extension()
88 .and_then(|e| e.to_str())
89 .map(Self::from_extension)
90 .unwrap_or(Self::Standard)
91 }
92
93 pub fn supports_esm_blocks(self) -> bool {
95 matches!(self, Self::MDX)
96 }
97
98 pub fn supports_jsx(self) -> bool {
100 matches!(self, Self::MDX)
101 }
102
103 pub fn supports_auto_references(self) -> bool {
105 matches!(self, Self::MkDocs)
106 }
107
108 pub fn name(self) -> &'static str {
110 match self {
111 Self::Standard => "Standard",
112 Self::MkDocs => "MkDocs",
113 Self::MDX => "MDX",
114 Self::Quarto => "Quarto",
115 }
116 }
117}
118
119pub fn normalize_key(key: &str) -> String {
121 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
123 key.to_ascii_uppercase()
124 } else {
125 key.replace('_', "-").to_ascii_lowercase()
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
131pub struct RuleConfig {
132 #[serde(flatten)]
134 #[schemars(schema_with = "arbitrary_value_schema")]
135 pub values: BTreeMap<String, toml::Value>,
136}
137
138fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
140 schemars::json_schema!({
141 "type": "object",
142 "additionalProperties": true
143 })
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
148#[schemars(
149 description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
150)]
151pub struct Config {
152 #[serde(default)]
154 pub global: GlobalConfig,
155
156 #[serde(default, rename = "per-file-ignores")]
159 pub per_file_ignores: HashMap<String, Vec<String>>,
160
161 #[serde(flatten)]
172 pub rules: BTreeMap<String, RuleConfig>,
173}
174
175impl Config {
176 pub fn is_mkdocs_flavor(&self) -> bool {
178 self.global.flavor == MarkdownFlavor::MkDocs
179 }
180
181 pub fn markdown_flavor(&self) -> MarkdownFlavor {
187 self.global.flavor
188 }
189
190 pub fn is_mkdocs_project(&self) -> bool {
192 self.is_mkdocs_flavor()
193 }
194
195 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
198 use globset::{Glob, GlobSetBuilder};
199
200 let mut ignored_rules = HashSet::new();
201
202 if self.per_file_ignores.is_empty() {
203 return ignored_rules;
204 }
205
206 let mut builder = GlobSetBuilder::new();
208 let mut pattern_to_rules: Vec<(usize, &Vec<String>)> = Vec::new();
209
210 for (idx, (pattern, rules)) in self.per_file_ignores.iter().enumerate() {
211 if let Ok(glob) = Glob::new(pattern) {
212 builder.add(glob);
213 pattern_to_rules.push((idx, rules));
214 } else {
215 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
216 }
217 }
218
219 let globset = match builder.build() {
220 Ok(gs) => gs,
221 Err(e) => {
222 log::error!("Failed to build globset for per-file-ignores: {e}");
223 return ignored_rules;
224 }
225 };
226
227 for match_idx in globset.matches(file_path) {
229 if let Some((_, rules)) = pattern_to_rules.get(match_idx) {
230 for rule in rules.iter() {
231 ignored_rules.insert(normalize_key(rule));
233 }
234 }
235 }
236
237 ignored_rules
238 }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
243#[serde(default, rename_all = "kebab-case")]
244pub struct GlobalConfig {
245 #[serde(default)]
247 pub enable: Vec<String>,
248
249 #[serde(default)]
251 pub disable: Vec<String>,
252
253 #[serde(default)]
255 pub exclude: Vec<String>,
256
257 #[serde(default)]
259 pub include: Vec<String>,
260
261 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
263 pub respect_gitignore: bool,
264
265 #[serde(default = "default_line_length", alias = "line_length")]
267 pub line_length: u64,
268
269 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
271 pub output_format: Option<String>,
272
273 #[serde(default)]
276 pub fixable: Vec<String>,
277
278 #[serde(default)]
281 pub unfixable: Vec<String>,
282
283 #[serde(default)]
286 pub flavor: MarkdownFlavor,
287
288 #[serde(default, alias = "force_exclude")]
293 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
294 pub force_exclude: bool,
295
296 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
299 pub cache_dir: Option<String>,
300}
301
302fn default_respect_gitignore() -> bool {
303 true
304}
305
306fn default_line_length() -> u64 {
307 80
308}
309
310impl Default for GlobalConfig {
312 #[allow(deprecated)]
313 fn default() -> Self {
314 Self {
315 enable: Vec::new(),
316 disable: Vec::new(),
317 exclude: Vec::new(),
318 include: Vec::new(),
319 respect_gitignore: true,
320 line_length: 80,
321 output_format: None,
322 fixable: Vec::new(),
323 unfixable: Vec::new(),
324 flavor: MarkdownFlavor::default(),
325 force_exclude: false,
326 cache_dir: None,
327 }
328 }
329}
330
331const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
332 ".markdownlint.json",
333 ".markdownlint.jsonc",
334 ".markdownlint.yaml",
335 ".markdownlint.yml",
336 "markdownlint.json",
337 "markdownlint.jsonc",
338 "markdownlint.yaml",
339 "markdownlint.yml",
340];
341
342pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
344 if Path::new(path).exists() {
346 return Err(ConfigError::FileExists { path: path.to_string() });
347 }
348
349 let default_config = r#"# rumdl configuration file
351
352# Global configuration options
353[global]
354# List of rules to disable (uncomment and modify as needed)
355# disable = ["MD013", "MD033"]
356
357# List of rules to enable exclusively (if provided, only these rules will run)
358# enable = ["MD001", "MD003", "MD004"]
359
360# List of file/directory patterns to include for linting (if provided, only these will be linted)
361# include = [
362# "docs/*.md",
363# "src/**/*.md",
364# "README.md"
365# ]
366
367# List of file/directory patterns to exclude from linting
368exclude = [
369 # Common directories to exclude
370 ".git",
371 ".github",
372 "node_modules",
373 "vendor",
374 "dist",
375 "build",
376
377 # Specific files or patterns
378 "CHANGELOG.md",
379 "LICENSE.md",
380]
381
382# Respect .gitignore files when scanning directories (default: true)
383respect-gitignore = true
384
385# Markdown flavor/dialect (uncomment to enable)
386# Options: mkdocs, gfm, commonmark
387# flavor = "mkdocs"
388
389# Rule-specific configurations (uncomment and modify as needed)
390
391# [MD003]
392# style = "atx" # Heading style (atx, atx_closed, setext)
393
394# [MD004]
395# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
396
397# [MD007]
398# indent = 4 # Unordered list indentation
399
400# [MD013]
401# line-length = 100 # Line length
402# code-blocks = false # Exclude code blocks from line length check
403# tables = false # Exclude tables from line length check
404# headings = true # Include headings in line length check
405
406# [MD044]
407# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
408# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
409"#;
410
411 match fs::write(path, default_config) {
413 Ok(_) => Ok(()),
414 Err(err) => Err(ConfigError::IoError {
415 source: err,
416 path: path.to_string(),
417 }),
418 }
419}
420
421#[derive(Debug, thiserror::Error)]
423pub enum ConfigError {
424 #[error("Failed to read config file at {path}: {source}")]
426 IoError { source: io::Error, path: String },
427
428 #[error("Failed to parse config: {0}")]
430 ParseError(String),
431
432 #[error("Configuration file already exists at {path}")]
434 FileExists { path: String },
435}
436
437pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
441 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
444
445 let key_variants = [
447 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
452
453 for variant in &key_variants {
455 if let Some(value) = rule_config.values.get(variant)
456 && let Ok(result) = T::deserialize(value.clone())
457 {
458 return Some(result);
459 }
460 }
461
462 None
463}
464
465pub fn generate_pyproject_config() -> String {
467 let config_content = r#"
468[tool.rumdl]
469# Global configuration options
470line-length = 100
471disable = []
472exclude = [
473 # Common directories to exclude
474 ".git",
475 ".github",
476 "node_modules",
477 "vendor",
478 "dist",
479 "build",
480]
481respect-gitignore = true
482
483# Rule-specific configurations (uncomment and modify as needed)
484
485# [tool.rumdl.MD003]
486# style = "atx" # Heading style (atx, atx_closed, setext)
487
488# [tool.rumdl.MD004]
489# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
490
491# [tool.rumdl.MD007]
492# indent = 4 # Unordered list indentation
493
494# [tool.rumdl.MD013]
495# line-length = 100 # Line length
496# code-blocks = false # Exclude code blocks from line length check
497# tables = false # Exclude tables from line length check
498# headings = true # Include headings in line length check
499
500# [tool.rumdl.MD044]
501# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
502# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
503"#;
504
505 config_content.to_string()
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use std::fs;
512 use tempfile::tempdir;
513
514 #[test]
515 fn test_flavor_loading() {
516 let temp_dir = tempdir().unwrap();
517 let config_path = temp_dir.path().join(".rumdl.toml");
518 let config_content = r#"
519[global]
520flavor = "mkdocs"
521disable = ["MD001"]
522"#;
523 fs::write(&config_path, config_content).unwrap();
524
525 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
527 let config: Config = sourced.into();
528
529 assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
531 assert!(config.is_mkdocs_flavor());
532 assert!(config.is_mkdocs_project()); assert_eq!(config.global.disable, vec!["MD001".to_string()]);
534 }
535
536 #[test]
537 fn test_pyproject_toml_root_level_config() {
538 let temp_dir = tempdir().unwrap();
539 let config_path = temp_dir.path().join("pyproject.toml");
540
541 let content = r#"
543[tool.rumdl]
544line-length = 120
545disable = ["MD033"]
546enable = ["MD001", "MD004"]
547include = ["docs/*.md"]
548exclude = ["node_modules"]
549respect-gitignore = true
550 "#;
551
552 fs::write(&config_path, content).unwrap();
553
554 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
556 let config: Config = sourced.into(); assert_eq!(config.global.disable, vec!["MD033".to_string()]);
560 assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
561 assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
563 assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
564 assert!(config.global.respect_gitignore);
565
566 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
568 assert_eq!(line_length, Some(120));
569 }
570
571 #[test]
572 fn test_pyproject_toml_snake_case_and_kebab_case() {
573 let temp_dir = tempdir().unwrap();
574 let config_path = temp_dir.path().join("pyproject.toml");
575
576 let content = r#"
578[tool.rumdl]
579line-length = 150
580respect_gitignore = true
581 "#;
582
583 fs::write(&config_path, content).unwrap();
584
585 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
587 let config: Config = sourced.into(); assert!(config.global.respect_gitignore);
591 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
592 assert_eq!(line_length, Some(150));
593 }
594
595 #[test]
596 fn test_md013_key_normalization_in_rumdl_toml() {
597 let temp_dir = tempdir().unwrap();
598 let config_path = temp_dir.path().join(".rumdl.toml");
599 let config_content = r#"
600[MD013]
601line_length = 111
602line-length = 222
603"#;
604 fs::write(&config_path, config_content).unwrap();
605 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
607 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
608 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
610 assert_eq!(keys, vec!["line-length"]);
611 let val = &rule_cfg.values["line-length"].value;
612 assert_eq!(val.as_integer(), Some(222));
613 let config: Config = sourced.clone().into();
615 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
616 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
617 assert_eq!(v1, Some(222));
618 assert_eq!(v2, Some(222));
619 }
620
621 #[test]
622 fn test_md013_section_case_insensitivity() {
623 let temp_dir = tempdir().unwrap();
624 let config_path = temp_dir.path().join(".rumdl.toml");
625 let config_content = r#"
626[md013]
627line-length = 101
628
629[Md013]
630line-length = 102
631
632[MD013]
633line-length = 103
634"#;
635 fs::write(&config_path, config_content).unwrap();
636 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
638 let config: Config = sourced.clone().into();
639 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
641 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
642 assert_eq!(keys, vec!["line-length"]);
643 let val = &rule_cfg.values["line-length"].value;
644 assert_eq!(val.as_integer(), Some(103));
645 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
646 assert_eq!(v, Some(103));
647 }
648
649 #[test]
650 fn test_md013_key_snake_and_kebab_case() {
651 let temp_dir = tempdir().unwrap();
652 let config_path = temp_dir.path().join(".rumdl.toml");
653 let config_content = r#"
654[MD013]
655line_length = 201
656line-length = 202
657"#;
658 fs::write(&config_path, config_content).unwrap();
659 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
661 let config: Config = sourced.clone().into();
662 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
663 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
664 assert_eq!(keys, vec!["line-length"]);
665 let val = &rule_cfg.values["line-length"].value;
666 assert_eq!(val.as_integer(), Some(202));
667 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
668 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
669 assert_eq!(v1, Some(202));
670 assert_eq!(v2, Some(202));
671 }
672
673 #[test]
674 fn test_unknown_rule_section_is_ignored() {
675 let temp_dir = tempdir().unwrap();
676 let config_path = temp_dir.path().join(".rumdl.toml");
677 let config_content = r#"
678[MD999]
679foo = 1
680bar = 2
681[MD013]
682line-length = 303
683"#;
684 fs::write(&config_path, config_content).unwrap();
685 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
687 let config: Config = sourced.clone().into();
688 assert!(!sourced.rules.contains_key("MD999"));
690 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
692 assert_eq!(v, Some(303));
693 }
694
695 #[test]
696 fn test_invalid_toml_syntax() {
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 = "unclosed string
704"#;
705 fs::write(&config_path, config_content).unwrap();
706
707 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
708 assert!(result.is_err());
709 match result.unwrap_err() {
710 ConfigError::ParseError(msg) => {
711 assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
713 }
714 _ => panic!("Expected ParseError"),
715 }
716 }
717
718 #[test]
719 fn test_wrong_type_for_config_value() {
720 let temp_dir = tempdir().unwrap();
721 let config_path = temp_dir.path().join(".rumdl.toml");
722
723 let config_content = r#"
725[MD013]
726line-length = "not a number"
727"#;
728 fs::write(&config_path, config_content).unwrap();
729
730 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
731 let config: Config = sourced.into();
732
733 let rule_config = config.rules.get("MD013").unwrap();
735 let value = rule_config.values.get("line-length").unwrap();
736 assert!(matches!(value, toml::Value::String(_)));
737 }
738
739 #[test]
740 fn test_empty_config_file() {
741 let temp_dir = tempdir().unwrap();
742 let config_path = temp_dir.path().join(".rumdl.toml");
743
744 fs::write(&config_path, "").unwrap();
746
747 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
748 let config: Config = sourced.into();
749
750 assert_eq!(config.global.line_length, 80);
752 assert!(config.global.respect_gitignore);
753 assert!(config.rules.is_empty());
754 }
755
756 #[test]
757 fn test_malformed_pyproject_toml() {
758 let temp_dir = tempdir().unwrap();
759 let config_path = temp_dir.path().join("pyproject.toml");
760
761 let content = r#"
763[tool.rumdl
764line-length = 120
765"#;
766 fs::write(&config_path, content).unwrap();
767
768 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
769 assert!(result.is_err());
770 }
771
772 #[test]
773 fn test_conflicting_config_values() {
774 let temp_dir = tempdir().unwrap();
775 let config_path = temp_dir.path().join(".rumdl.toml");
776
777 let config_content = r#"
779[global]
780enable = ["MD013"]
781disable = ["MD013"]
782"#;
783 fs::write(&config_path, config_content).unwrap();
784
785 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
786 let config: Config = sourced.into();
787
788 assert!(config.global.enable.contains(&"MD013".to_string()));
790 assert!(!config.global.disable.contains(&"MD013".to_string()));
791 }
792
793 #[test]
794 fn test_invalid_rule_names() {
795 let temp_dir = tempdir().unwrap();
796 let config_path = temp_dir.path().join(".rumdl.toml");
797
798 let config_content = r#"
799[global]
800enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
801disable = ["MD-001", "MD_002"]
802"#;
803 fs::write(&config_path, config_content).unwrap();
804
805 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
806 let config: Config = sourced.into();
807
808 assert_eq!(config.global.enable.len(), 4);
810 assert_eq!(config.global.disable.len(), 2);
811 }
812
813 #[test]
814 fn test_deeply_nested_config() {
815 let temp_dir = tempdir().unwrap();
816 let config_path = temp_dir.path().join(".rumdl.toml");
817
818 let config_content = r#"
820[MD013]
821line-length = 100
822[MD013.nested]
823value = 42
824"#;
825 fs::write(&config_path, config_content).unwrap();
826
827 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
828 let config: Config = sourced.into();
829
830 let rule_config = config.rules.get("MD013").unwrap();
831 assert_eq!(
832 rule_config.values.get("line-length").unwrap(),
833 &toml::Value::Integer(100)
834 );
835 assert!(!rule_config.values.contains_key("nested"));
837 }
838
839 #[test]
840 fn test_unicode_in_config() {
841 let temp_dir = tempdir().unwrap();
842 let config_path = temp_dir.path().join(".rumdl.toml");
843
844 let config_content = r#"
845[global]
846include = ["文档/*.md", "ドã‚ュメント/*.md"]
847exclude = ["测试/*", "🚀/*"]
848
849[MD013]
850line-length = 80
851message = "行太长了 🚨"
852"#;
853 fs::write(&config_path, config_content).unwrap();
854
855 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
856 let config: Config = sourced.into();
857
858 assert_eq!(config.global.include.len(), 2);
859 assert_eq!(config.global.exclude.len(), 2);
860 assert!(config.global.include[0].contains("文档"));
861 assert!(config.global.exclude[1].contains("🚀"));
862
863 let rule_config = config.rules.get("MD013").unwrap();
864 let message = rule_config.values.get("message").unwrap();
865 if let toml::Value::String(s) = message {
866 assert!(s.contains("行太长了"));
867 assert!(s.contains("🚨"));
868 }
869 }
870
871 #[test]
872 fn test_extremely_long_values() {
873 let temp_dir = tempdir().unwrap();
874 let config_path = temp_dir.path().join(".rumdl.toml");
875
876 let long_string = "a".repeat(10000);
877 let config_content = format!(
878 r#"
879[global]
880exclude = ["{long_string}"]
881
882[MD013]
883line-length = 999999999
884"#
885 );
886
887 fs::write(&config_path, config_content).unwrap();
888
889 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
890 let config: Config = sourced.into();
891
892 assert_eq!(config.global.exclude[0].len(), 10000);
893 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
894 assert_eq!(line_length, Some(999999999));
895 }
896
897 #[test]
898 fn test_config_with_comments() {
899 let temp_dir = tempdir().unwrap();
900 let config_path = temp_dir.path().join(".rumdl.toml");
901
902 let config_content = r#"
903[global]
904# This is a comment
905enable = ["MD001"] # Enable MD001
906# disable = ["MD002"] # This is commented out
907
908[MD013] # Line length rule
909line-length = 100 # Set to 100 characters
910# ignored = true # This setting is commented out
911"#;
912 fs::write(&config_path, config_content).unwrap();
913
914 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
915 let config: Config = sourced.into();
916
917 assert_eq!(config.global.enable, vec!["MD001"]);
918 assert!(config.global.disable.is_empty()); let rule_config = config.rules.get("MD013").unwrap();
921 assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
923 }
924
925 #[test]
926 fn test_arrays_in_rule_config() {
927 let temp_dir = tempdir().unwrap();
928 let config_path = temp_dir.path().join(".rumdl.toml");
929
930 let config_content = r#"
931[MD003]
932levels = [1, 2, 3]
933tags = ["important", "critical"]
934mixed = [1, "two", true]
935"#;
936 fs::write(&config_path, config_content).unwrap();
937
938 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
939 let config: Config = sourced.into();
940
941 let rule_config = config.rules.get("MD003").expect("MD003 config should exist");
943
944 assert!(rule_config.values.contains_key("levels"));
946 assert!(rule_config.values.contains_key("tags"));
947 assert!(rule_config.values.contains_key("mixed"));
948
949 if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
951 assert_eq!(levels.len(), 3);
952 assert_eq!(levels[0], toml::Value::Integer(1));
953 assert_eq!(levels[1], toml::Value::Integer(2));
954 assert_eq!(levels[2], toml::Value::Integer(3));
955 } else {
956 panic!("levels should be an array");
957 }
958
959 if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
960 assert_eq!(tags.len(), 2);
961 assert_eq!(tags[0], toml::Value::String("important".to_string()));
962 assert_eq!(tags[1], toml::Value::String("critical".to_string()));
963 } else {
964 panic!("tags should be an array");
965 }
966
967 if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
968 assert_eq!(mixed.len(), 3);
969 assert_eq!(mixed[0], toml::Value::Integer(1));
970 assert_eq!(mixed[1], toml::Value::String("two".to_string()));
971 assert_eq!(mixed[2], toml::Value::Boolean(true));
972 } else {
973 panic!("mixed should be an array");
974 }
975 }
976
977 #[test]
978 fn test_normalize_key_edge_cases() {
979 assert_eq!(normalize_key("MD001"), "MD001");
981 assert_eq!(normalize_key("md001"), "MD001");
982 assert_eq!(normalize_key("Md001"), "MD001");
983 assert_eq!(normalize_key("mD001"), "MD001");
984
985 assert_eq!(normalize_key("line_length"), "line-length");
987 assert_eq!(normalize_key("line-length"), "line-length");
988 assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
989 assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
990
991 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(""), "");
998 assert_eq!(normalize_key("_"), "-");
999 assert_eq!(normalize_key("___"), "---");
1000 }
1001
1002 #[test]
1003 fn test_missing_config_file() {
1004 let temp_dir = tempdir().unwrap();
1005 let config_path = temp_dir.path().join("nonexistent.toml");
1006
1007 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1008 assert!(result.is_err());
1009 match result.unwrap_err() {
1010 ConfigError::IoError { .. } => {}
1011 _ => panic!("Expected IoError for missing file"),
1012 }
1013 }
1014
1015 #[test]
1016 #[cfg(unix)]
1017 fn test_permission_denied_config() {
1018 use std::os::unix::fs::PermissionsExt;
1019
1020 let temp_dir = tempdir().unwrap();
1021 let config_path = temp_dir.path().join(".rumdl.toml");
1022
1023 fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
1024
1025 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1027 perms.set_mode(0o000);
1028 fs::set_permissions(&config_path, perms).unwrap();
1029
1030 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1031
1032 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1034 perms.set_mode(0o644);
1035 fs::set_permissions(&config_path, perms).unwrap();
1036
1037 assert!(result.is_err());
1038 match result.unwrap_err() {
1039 ConfigError::IoError { .. } => {}
1040 _ => panic!("Expected IoError for permission denied"),
1041 }
1042 }
1043
1044 #[test]
1045 fn test_circular_reference_detection() {
1046 let temp_dir = tempdir().unwrap();
1049 let config_path = temp_dir.path().join(".rumdl.toml");
1050
1051 let mut config_content = String::from("[MD001]\n");
1052 for i in 0..100 {
1053 config_content.push_str(&format!("key{i} = {i}\n"));
1054 }
1055
1056 fs::write(&config_path, config_content).unwrap();
1057
1058 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1059 let config: Config = sourced.into();
1060
1061 let rule_config = config.rules.get("MD001").unwrap();
1062 assert_eq!(rule_config.values.len(), 100);
1063 }
1064
1065 #[test]
1066 fn test_special_toml_values() {
1067 let temp_dir = tempdir().unwrap();
1068 let config_path = temp_dir.path().join(".rumdl.toml");
1069
1070 let config_content = r#"
1071[MD001]
1072infinity = inf
1073neg_infinity = -inf
1074not_a_number = nan
1075datetime = 1979-05-27T07:32:00Z
1076local_date = 1979-05-27
1077local_time = 07:32:00
1078"#;
1079 fs::write(&config_path, config_content).unwrap();
1080
1081 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1082 let config: Config = sourced.into();
1083
1084 if let Some(rule_config) = config.rules.get("MD001") {
1086 if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
1088 assert!(f.is_infinite() && f.is_sign_positive());
1089 }
1090 if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
1091 assert!(f.is_infinite() && f.is_sign_negative());
1092 }
1093 if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
1094 assert!(f.is_nan());
1095 }
1096
1097 if let Some(val) = rule_config.values.get("datetime") {
1099 assert!(matches!(val, toml::Value::Datetime(_)));
1100 }
1101 }
1103 }
1104
1105 #[test]
1106 fn test_default_config_passes_validation() {
1107 use crate::rules;
1108
1109 let temp_dir = tempdir().unwrap();
1110 let config_path = temp_dir.path().join(".rumdl.toml");
1111 let config_path_str = config_path.to_str().unwrap();
1112
1113 create_default_config(config_path_str).unwrap();
1115
1116 let sourced =
1118 SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1119
1120 let all_rules = rules::all_rules(&Config::default());
1122 let registry = RuleRegistry::from_rules(&all_rules);
1123
1124 let warnings = validate_config_sourced(&sourced, ®istry);
1126
1127 if !warnings.is_empty() {
1129 for warning in &warnings {
1130 eprintln!("Config validation warning: {}", warning.message);
1131 if let Some(rule) = &warning.rule {
1132 eprintln!(" Rule: {rule}");
1133 }
1134 if let Some(key) = &warning.key {
1135 eprintln!(" Key: {key}");
1136 }
1137 }
1138 }
1139 assert!(
1140 warnings.is_empty(),
1141 "Default config from rumdl init should pass validation without warnings"
1142 );
1143 }
1144
1145 #[test]
1146 fn test_per_file_ignores_config_parsing() {
1147 let temp_dir = tempdir().unwrap();
1148 let config_path = temp_dir.path().join(".rumdl.toml");
1149 let config_content = r#"
1150[per-file-ignores]
1151"README.md" = ["MD033"]
1152"docs/**/*.md" = ["MD013", "MD033"]
1153"test/*.md" = ["MD041"]
1154"#;
1155 fs::write(&config_path, config_content).unwrap();
1156
1157 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1158 let config: Config = sourced.into();
1159
1160 assert_eq!(config.per_file_ignores.len(), 3);
1162 assert_eq!(
1163 config.per_file_ignores.get("README.md"),
1164 Some(&vec!["MD033".to_string()])
1165 );
1166 assert_eq!(
1167 config.per_file_ignores.get("docs/**/*.md"),
1168 Some(&vec!["MD013".to_string(), "MD033".to_string()])
1169 );
1170 assert_eq!(
1171 config.per_file_ignores.get("test/*.md"),
1172 Some(&vec!["MD041".to_string()])
1173 );
1174 }
1175
1176 #[test]
1177 fn test_per_file_ignores_glob_matching() {
1178 use std::path::PathBuf;
1179
1180 let temp_dir = tempdir().unwrap();
1181 let config_path = temp_dir.path().join(".rumdl.toml");
1182 let config_content = r#"
1183[per-file-ignores]
1184"README.md" = ["MD033"]
1185"docs/**/*.md" = ["MD013"]
1186"**/test_*.md" = ["MD041"]
1187"#;
1188 fs::write(&config_path, config_content).unwrap();
1189
1190 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1191 let config: Config = sourced.into();
1192
1193 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1195 assert!(ignored.contains("MD033"));
1196 assert_eq!(ignored.len(), 1);
1197
1198 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1200 assert!(ignored.contains("MD013"));
1201 assert_eq!(ignored.len(), 1);
1202
1203 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
1205 assert!(ignored.contains("MD041"));
1206 assert_eq!(ignored.len(), 1);
1207
1208 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
1210 assert!(ignored.is_empty());
1211 }
1212
1213 #[test]
1214 fn test_per_file_ignores_pyproject_toml() {
1215 let temp_dir = tempdir().unwrap();
1216 let config_path = temp_dir.path().join("pyproject.toml");
1217 let config_content = r#"
1218[tool.rumdl]
1219[tool.rumdl.per-file-ignores]
1220"README.md" = ["MD033", "MD013"]
1221"generated/*.md" = ["MD041"]
1222"#;
1223 fs::write(&config_path, config_content).unwrap();
1224
1225 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1226 let config: Config = sourced.into();
1227
1228 assert_eq!(config.per_file_ignores.len(), 2);
1230 assert_eq!(
1231 config.per_file_ignores.get("README.md"),
1232 Some(&vec!["MD033".to_string(), "MD013".to_string()])
1233 );
1234 assert_eq!(
1235 config.per_file_ignores.get("generated/*.md"),
1236 Some(&vec!["MD041".to_string()])
1237 );
1238 }
1239
1240 #[test]
1241 fn test_per_file_ignores_multiple_patterns_match() {
1242 use std::path::PathBuf;
1243
1244 let temp_dir = tempdir().unwrap();
1245 let config_path = temp_dir.path().join(".rumdl.toml");
1246 let config_content = r#"
1247[per-file-ignores]
1248"docs/**/*.md" = ["MD013"]
1249"**/api/*.md" = ["MD033"]
1250"docs/api/overview.md" = ["MD041"]
1251"#;
1252 fs::write(&config_path, config_content).unwrap();
1253
1254 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1255 let config: Config = sourced.into();
1256
1257 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1259 assert_eq!(ignored.len(), 3);
1260 assert!(ignored.contains("MD013"));
1261 assert!(ignored.contains("MD033"));
1262 assert!(ignored.contains("MD041"));
1263 }
1264
1265 #[test]
1266 fn test_per_file_ignores_rule_name_normalization() {
1267 use std::path::PathBuf;
1268
1269 let temp_dir = tempdir().unwrap();
1270 let config_path = temp_dir.path().join(".rumdl.toml");
1271 let config_content = r#"
1272[per-file-ignores]
1273"README.md" = ["md033", "MD013", "Md041"]
1274"#;
1275 fs::write(&config_path, config_content).unwrap();
1276
1277 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1278 let config: Config = sourced.into();
1279
1280 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1282 assert_eq!(ignored.len(), 3);
1283 assert!(ignored.contains("MD033"));
1284 assert!(ignored.contains("MD013"));
1285 assert!(ignored.contains("MD041"));
1286 }
1287
1288 #[test]
1289 fn test_per_file_ignores_invalid_glob_pattern() {
1290 use std::path::PathBuf;
1291
1292 let temp_dir = tempdir().unwrap();
1293 let config_path = temp_dir.path().join(".rumdl.toml");
1294 let config_content = r#"
1295[per-file-ignores]
1296"[invalid" = ["MD033"]
1297"valid/*.md" = ["MD013"]
1298"#;
1299 fs::write(&config_path, config_content).unwrap();
1300
1301 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1302 let config: Config = sourced.into();
1303
1304 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
1306 assert!(ignored.contains("MD013"));
1307
1308 let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
1310 assert!(ignored2.is_empty());
1311 }
1312
1313 #[test]
1314 fn test_per_file_ignores_empty_section() {
1315 use std::path::PathBuf;
1316
1317 let temp_dir = tempdir().unwrap();
1318 let config_path = temp_dir.path().join(".rumdl.toml");
1319 let config_content = r#"
1320[global]
1321disable = ["MD001"]
1322
1323[per-file-ignores]
1324"#;
1325 fs::write(&config_path, config_content).unwrap();
1326
1327 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1328 let config: Config = sourced.into();
1329
1330 assert_eq!(config.per_file_ignores.len(), 0);
1332 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1333 assert!(ignored.is_empty());
1334 }
1335
1336 #[test]
1337 fn test_per_file_ignores_with_underscores_in_pyproject() {
1338 let temp_dir = tempdir().unwrap();
1339 let config_path = temp_dir.path().join("pyproject.toml");
1340 let config_content = r#"
1341[tool.rumdl]
1342[tool.rumdl.per_file_ignores]
1343"README.md" = ["MD033"]
1344"#;
1345 fs::write(&config_path, config_content).unwrap();
1346
1347 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1348 let config: Config = sourced.into();
1349
1350 assert_eq!(config.per_file_ignores.len(), 1);
1352 assert_eq!(
1353 config.per_file_ignores.get("README.md"),
1354 Some(&vec!["MD033".to_string()])
1355 );
1356 }
1357
1358 #[test]
1359 fn test_generate_json_schema() {
1360 use schemars::schema_for;
1361 use std::env;
1362
1363 let schema = schema_for!(Config);
1364 let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
1365
1366 if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
1368 let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
1369 fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
1370 println!("Schema written to: {}", schema_path.display());
1371 }
1372
1373 assert!(schema_json.contains("\"title\": \"Config\""));
1375 assert!(schema_json.contains("\"global\""));
1376 assert!(schema_json.contains("\"per-file-ignores\""));
1377 }
1378
1379 #[test]
1380 fn test_user_config_loaded_with_explicit_project_config() {
1381 let temp_dir = tempdir().unwrap();
1384
1385 let user_config_dir = temp_dir.path().join("user_config");
1388 let rumdl_config_dir = user_config_dir.join("rumdl");
1389 fs::create_dir_all(&rumdl_config_dir).unwrap();
1390 let user_config_path = rumdl_config_dir.join("rumdl.toml");
1391
1392 let user_config_content = r#"
1394[global]
1395disable = ["MD013", "MD041"]
1396line-length = 100
1397"#;
1398 fs::write(&user_config_path, user_config_content).unwrap();
1399
1400 let project_config_path = temp_dir.path().join("project").join("pyproject.toml");
1402 fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1403 let project_config_content = r#"
1404[tool.rumdl]
1405enable = ["MD001"]
1406"#;
1407 fs::write(&project_config_path, project_config_content).unwrap();
1408
1409 let sourced = SourcedConfig::load_with_discovery_impl(
1411 Some(project_config_path.to_str().unwrap()),
1412 None,
1413 false,
1414 Some(&user_config_dir),
1415 )
1416 .unwrap();
1417
1418 let config: Config = sourced.into();
1419
1420 assert!(
1422 config.global.disable.contains(&"MD013".to_string()),
1423 "User config disabled rules should be preserved"
1424 );
1425 assert!(
1426 config.global.disable.contains(&"MD041".to_string()),
1427 "User config disabled rules should be preserved"
1428 );
1429
1430 assert!(
1432 config.global.enable.contains(&"MD001".to_string()),
1433 "Project config enabled rules should be applied"
1434 );
1435 }
1436}
1437
1438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1447pub enum ConfigSource {
1448 Default,
1450 UserConfig,
1452 PyprojectToml,
1454 ProjectConfig,
1456 Cli,
1458}
1459
1460#[derive(Debug, Clone)]
1461pub struct ConfigOverride<T> {
1462 pub value: T,
1463 pub source: ConfigSource,
1464 pub file: Option<String>,
1465 pub line: Option<usize>,
1466}
1467
1468#[derive(Debug, Clone)]
1469pub struct SourcedValue<T> {
1470 pub value: T,
1471 pub source: ConfigSource,
1472 pub overrides: Vec<ConfigOverride<T>>,
1473}
1474
1475impl<T: Clone> SourcedValue<T> {
1476 pub fn new(value: T, source: ConfigSource) -> Self {
1477 Self {
1478 value: value.clone(),
1479 source,
1480 overrides: vec![ConfigOverride {
1481 value,
1482 source,
1483 file: None,
1484 line: None,
1485 }],
1486 }
1487 }
1488
1489 pub fn merge_override(
1493 &mut self,
1494 new_value: T,
1495 new_source: ConfigSource,
1496 new_file: Option<String>,
1497 new_line: Option<usize>,
1498 ) {
1499 fn source_precedence(src: ConfigSource) -> u8 {
1501 match src {
1502 ConfigSource::Default => 0,
1503 ConfigSource::UserConfig => 1,
1504 ConfigSource::PyprojectToml => 2,
1505 ConfigSource::ProjectConfig => 3,
1506 ConfigSource::Cli => 4,
1507 }
1508 }
1509
1510 if source_precedence(new_source) >= source_precedence(self.source) {
1511 self.value = new_value.clone();
1512 self.source = new_source;
1513 self.overrides.push(ConfigOverride {
1514 value: new_value,
1515 source: new_source,
1516 file: new_file,
1517 line: new_line,
1518 });
1519 }
1520 }
1521
1522 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
1523 self.value = value.clone();
1526 self.source = source;
1527 self.overrides.push(ConfigOverride {
1528 value,
1529 source,
1530 file,
1531 line,
1532 });
1533 }
1534}
1535
1536impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
1537 pub fn merge_union(
1540 &mut self,
1541 new_value: Vec<T>,
1542 new_source: ConfigSource,
1543 new_file: Option<String>,
1544 new_line: Option<usize>,
1545 ) {
1546 fn source_precedence(src: ConfigSource) -> u8 {
1547 match src {
1548 ConfigSource::Default => 0,
1549 ConfigSource::UserConfig => 1,
1550 ConfigSource::PyprojectToml => 2,
1551 ConfigSource::ProjectConfig => 3,
1552 ConfigSource::Cli => 4,
1553 }
1554 }
1555
1556 if source_precedence(new_source) >= source_precedence(self.source) {
1557 let mut combined = self.value.clone();
1559 for item in new_value.iter() {
1560 if !combined.contains(item) {
1561 combined.push(item.clone());
1562 }
1563 }
1564
1565 self.value = combined;
1566 self.source = new_source;
1567 self.overrides.push(ConfigOverride {
1568 value: new_value,
1569 source: new_source,
1570 file: new_file,
1571 line: new_line,
1572 });
1573 }
1574 }
1575}
1576
1577#[derive(Debug, Clone)]
1578pub struct SourcedGlobalConfig {
1579 pub enable: SourcedValue<Vec<String>>,
1580 pub disable: SourcedValue<Vec<String>>,
1581 pub exclude: SourcedValue<Vec<String>>,
1582 pub include: SourcedValue<Vec<String>>,
1583 pub respect_gitignore: SourcedValue<bool>,
1584 pub line_length: SourcedValue<u64>,
1585 pub output_format: Option<SourcedValue<String>>,
1586 pub fixable: SourcedValue<Vec<String>>,
1587 pub unfixable: SourcedValue<Vec<String>>,
1588 pub flavor: SourcedValue<MarkdownFlavor>,
1589 pub force_exclude: SourcedValue<bool>,
1590 pub cache_dir: Option<SourcedValue<String>>,
1591}
1592
1593impl Default for SourcedGlobalConfig {
1594 fn default() -> Self {
1595 SourcedGlobalConfig {
1596 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1597 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1598 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
1599 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
1600 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
1601 line_length: SourcedValue::new(80, ConfigSource::Default),
1602 output_format: None,
1603 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1604 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1605 flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
1606 force_exclude: SourcedValue::new(false, ConfigSource::Default),
1607 cache_dir: None,
1608 }
1609 }
1610}
1611
1612#[derive(Debug, Default, Clone)]
1613pub struct SourcedRuleConfig {
1614 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
1615}
1616
1617#[derive(Debug, Clone)]
1620pub struct SourcedConfigFragment {
1621 pub global: SourcedGlobalConfig,
1622 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
1623 pub rules: BTreeMap<String, SourcedRuleConfig>,
1624 pub unknown_keys: Vec<(String, String, Option<String>)>, }
1627
1628impl Default for SourcedConfigFragment {
1629 fn default() -> Self {
1630 Self {
1631 global: SourcedGlobalConfig::default(),
1632 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
1633 rules: BTreeMap::new(),
1634 unknown_keys: Vec::new(),
1635 }
1636 }
1637}
1638
1639#[derive(Debug, Clone)]
1640pub struct SourcedConfig {
1641 pub global: SourcedGlobalConfig,
1642 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
1643 pub rules: BTreeMap<String, SourcedRuleConfig>,
1644 pub loaded_files: Vec<String>,
1645 pub unknown_keys: Vec<(String, String, Option<String>)>, pub project_root: Option<std::path::PathBuf>,
1648}
1649
1650impl Default for SourcedConfig {
1651 fn default() -> Self {
1652 Self {
1653 global: SourcedGlobalConfig::default(),
1654 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
1655 rules: BTreeMap::new(),
1656 loaded_files: Vec::new(),
1657 unknown_keys: Vec::new(),
1658 project_root: None,
1659 }
1660 }
1661}
1662
1663impl SourcedConfig {
1664 fn merge(&mut self, fragment: SourcedConfigFragment) {
1667 self.global.enable.merge_override(
1670 fragment.global.enable.value,
1671 fragment.global.enable.source,
1672 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
1673 fragment.global.enable.overrides.first().and_then(|o| o.line),
1674 );
1675
1676 self.global.disable.merge_union(
1678 fragment.global.disable.value,
1679 fragment.global.disable.source,
1680 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
1681 fragment.global.disable.overrides.first().and_then(|o| o.line),
1682 );
1683
1684 self.global
1687 .disable
1688 .value
1689 .retain(|rule| !self.global.enable.value.contains(rule));
1690 self.global.include.merge_override(
1691 fragment.global.include.value,
1692 fragment.global.include.source,
1693 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
1694 fragment.global.include.overrides.first().and_then(|o| o.line),
1695 );
1696 self.global.exclude.merge_override(
1697 fragment.global.exclude.value,
1698 fragment.global.exclude.source,
1699 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
1700 fragment.global.exclude.overrides.first().and_then(|o| o.line),
1701 );
1702 self.global.respect_gitignore.merge_override(
1703 fragment.global.respect_gitignore.value,
1704 fragment.global.respect_gitignore.source,
1705 fragment
1706 .global
1707 .respect_gitignore
1708 .overrides
1709 .first()
1710 .and_then(|o| o.file.clone()),
1711 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
1712 );
1713 self.global.line_length.merge_override(
1714 fragment.global.line_length.value,
1715 fragment.global.line_length.source,
1716 fragment
1717 .global
1718 .line_length
1719 .overrides
1720 .first()
1721 .and_then(|o| o.file.clone()),
1722 fragment.global.line_length.overrides.first().and_then(|o| o.line),
1723 );
1724 self.global.fixable.merge_override(
1725 fragment.global.fixable.value,
1726 fragment.global.fixable.source,
1727 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
1728 fragment.global.fixable.overrides.first().and_then(|o| o.line),
1729 );
1730 self.global.unfixable.merge_override(
1731 fragment.global.unfixable.value,
1732 fragment.global.unfixable.source,
1733 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
1734 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
1735 );
1736
1737 self.global.flavor.merge_override(
1739 fragment.global.flavor.value,
1740 fragment.global.flavor.source,
1741 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
1742 fragment.global.flavor.overrides.first().and_then(|o| o.line),
1743 );
1744
1745 self.global.force_exclude.merge_override(
1747 fragment.global.force_exclude.value,
1748 fragment.global.force_exclude.source,
1749 fragment
1750 .global
1751 .force_exclude
1752 .overrides
1753 .first()
1754 .and_then(|o| o.file.clone()),
1755 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
1756 );
1757
1758 if let Some(output_format_fragment) = fragment.global.output_format {
1760 if let Some(ref mut output_format) = self.global.output_format {
1761 output_format.merge_override(
1762 output_format_fragment.value,
1763 output_format_fragment.source,
1764 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
1765 output_format_fragment.overrides.first().and_then(|o| o.line),
1766 );
1767 } else {
1768 self.global.output_format = Some(output_format_fragment);
1769 }
1770 }
1771
1772 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
1774 if let Some(ref mut cache_dir) = self.global.cache_dir {
1775 cache_dir.merge_override(
1776 cache_dir_fragment.value,
1777 cache_dir_fragment.source,
1778 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
1779 cache_dir_fragment.overrides.first().and_then(|o| o.line),
1780 );
1781 } else {
1782 self.global.cache_dir = Some(cache_dir_fragment);
1783 }
1784 }
1785
1786 self.per_file_ignores.merge_override(
1788 fragment.per_file_ignores.value,
1789 fragment.per_file_ignores.source,
1790 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
1791 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
1792 );
1793
1794 for (rule_name, rule_fragment) in fragment.rules {
1796 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
1798 for (key, sourced_value_fragment) in rule_fragment.values {
1799 let sv_entry = rule_entry
1800 .values
1801 .entry(key.clone())
1802 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
1803 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
1804 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
1805 sv_entry.merge_override(
1806 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
1811 }
1812 }
1813
1814 for (section, key, file_path) in fragment.unknown_keys {
1816 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
1818 self.unknown_keys.push((section, key, file_path));
1819 }
1820 }
1821 }
1822
1823 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
1825 Self::load_with_discovery(config_path, cli_overrides, false)
1826 }
1827
1828 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
1831 let mut current = start_dir.to_path_buf();
1832 const MAX_DEPTH: usize = 100;
1833
1834 for _ in 0..MAX_DEPTH {
1835 if current.join(".git").exists() {
1836 log::debug!("[rumdl-config] Found .git at: {}", current.display());
1837 return current;
1838 }
1839
1840 match current.parent() {
1841 Some(parent) => current = parent.to_path_buf(),
1842 None => break,
1843 }
1844 }
1845
1846 log::debug!(
1848 "[rumdl-config] No .git found, using config location as project root: {}",
1849 start_dir.display()
1850 );
1851 start_dir.to_path_buf()
1852 }
1853
1854 fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
1860 use std::env;
1861
1862 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
1863 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
1866 Ok(dir) => dir,
1867 Err(e) => {
1868 log::debug!("[rumdl-config] Failed to get current directory: {e}");
1869 return None;
1870 }
1871 };
1872
1873 let mut current_dir = start_dir.clone();
1874 let mut depth = 0;
1875 let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
1876
1877 loop {
1878 if depth >= MAX_DEPTH {
1879 log::debug!("[rumdl-config] Maximum traversal depth reached");
1880 break;
1881 }
1882
1883 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
1884
1885 if found_config.is_none() {
1887 for config_name in CONFIG_FILES {
1888 let config_path = current_dir.join(config_name);
1889
1890 if config_path.exists() {
1891 if *config_name == "pyproject.toml" {
1893 if let Ok(content) = std::fs::read_to_string(&config_path) {
1894 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1895 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1896 found_config = Some((config_path.clone(), current_dir.clone()));
1898 break;
1899 }
1900 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
1901 continue;
1902 }
1903 } else {
1904 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1905 found_config = Some((config_path.clone(), current_dir.clone()));
1907 break;
1908 }
1909 }
1910 }
1911 }
1912
1913 if current_dir.join(".git").exists() {
1915 log::debug!("[rumdl-config] Stopping at .git directory");
1916 break;
1917 }
1918
1919 match current_dir.parent() {
1921 Some(parent) => {
1922 current_dir = parent.to_owned();
1923 depth += 1;
1924 }
1925 None => {
1926 log::debug!("[rumdl-config] Reached filesystem root");
1927 break;
1928 }
1929 }
1930 }
1931
1932 if let Some((config_path, config_dir)) = found_config {
1934 let project_root = Self::find_project_root_from(&config_dir);
1935 return Some((config_path, project_root));
1936 }
1937
1938 None
1939 }
1940
1941 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
1943 let config_dir = config_dir.join("rumdl");
1944
1945 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1947
1948 log::debug!(
1949 "[rumdl-config] Checking for user configuration in: {}",
1950 config_dir.display()
1951 );
1952
1953 for filename in USER_CONFIG_FILES {
1954 let config_path = config_dir.join(filename);
1955
1956 if config_path.exists() {
1957 if *filename == "pyproject.toml" {
1959 if let Ok(content) = std::fs::read_to_string(&config_path) {
1960 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1961 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
1962 return Some(config_path);
1963 }
1964 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
1965 continue;
1966 }
1967 } else {
1968 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
1969 return Some(config_path);
1970 }
1971 }
1972 }
1973
1974 log::debug!(
1975 "[rumdl-config] No user configuration found in: {}",
1976 config_dir.display()
1977 );
1978 None
1979 }
1980
1981 #[cfg(feature = "native")]
1984 fn user_configuration_path() -> Option<std::path::PathBuf> {
1985 use etcetera::{BaseStrategy, choose_base_strategy};
1986
1987 match choose_base_strategy() {
1988 Ok(strategy) => {
1989 let config_dir = strategy.config_dir();
1990 Self::user_configuration_path_impl(&config_dir)
1991 }
1992 Err(e) => {
1993 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
1994 None
1995 }
1996 }
1997 }
1998
1999 #[cfg(not(feature = "native"))]
2001 fn user_configuration_path() -> Option<std::path::PathBuf> {
2002 None
2003 }
2004
2005 #[doc(hidden)]
2007 pub fn load_with_discovery_impl(
2008 config_path: Option<&str>,
2009 cli_overrides: Option<&SourcedGlobalConfig>,
2010 skip_auto_discovery: bool,
2011 user_config_dir: Option<&Path>,
2012 ) -> Result<Self, ConfigError> {
2013 use std::env;
2014 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
2015 if config_path.is_none() {
2016 if skip_auto_discovery {
2017 log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
2018 } else {
2019 log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
2020 }
2021 } else {
2022 log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
2023 }
2024 let mut sourced_config = SourcedConfig::default();
2025
2026 if !skip_auto_discovery {
2029 let user_config_path = if let Some(dir) = user_config_dir {
2030 Self::user_configuration_path_impl(dir)
2031 } else {
2032 Self::user_configuration_path()
2033 };
2034
2035 if let Some(user_config_path) = user_config_path {
2036 let path_str = user_config_path.display().to_string();
2037 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2038
2039 log::debug!("[rumdl-config] Loading user configuration file: {path_str}");
2040
2041 if filename == "pyproject.toml" {
2042 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2043 source: e,
2044 path: path_str.clone(),
2045 })?;
2046 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2047 sourced_config.merge(fragment);
2048 sourced_config.loaded_files.push(path_str);
2049 }
2050 } else {
2051 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2052 source: e,
2053 path: path_str.clone(),
2054 })?;
2055 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
2056 sourced_config.merge(fragment);
2057 sourced_config.loaded_files.push(path_str);
2058 }
2059 } else {
2060 log::debug!("[rumdl-config] No user configuration file found");
2061 }
2062 }
2063
2064 if let Some(path) = config_path {
2066 let path_obj = Path::new(path);
2067 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
2068 log::debug!("[rumdl-config] Trying to load config file: {filename}");
2069 let path_str = path.to_string();
2070
2071 if let Some(config_parent) = path_obj.parent() {
2073 let project_root = Self::find_project_root_from(config_parent);
2074 log::debug!(
2075 "[rumdl-config] Project root (from explicit config): {}",
2076 project_root.display()
2077 );
2078 sourced_config.project_root = Some(project_root);
2079 }
2080
2081 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
2083
2084 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
2085 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2086 source: e,
2087 path: path_str.clone(),
2088 })?;
2089 if filename == "pyproject.toml" {
2090 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2091 sourced_config.merge(fragment);
2092 sourced_config.loaded_files.push(path_str.clone());
2093 }
2094 } else {
2095 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2096 sourced_config.merge(fragment);
2097 sourced_config.loaded_files.push(path_str.clone());
2098 }
2099 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
2100 || path_str.ends_with(".json")
2101 || path_str.ends_with(".jsonc")
2102 || path_str.ends_with(".yaml")
2103 || path_str.ends_with(".yml")
2104 {
2105 let fragment = load_from_markdownlint(&path_str)?;
2107 sourced_config.merge(fragment);
2108 sourced_config.loaded_files.push(path_str.clone());
2109 } else {
2111 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2113 source: e,
2114 path: path_str.clone(),
2115 })?;
2116 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2117 sourced_config.merge(fragment);
2118 sourced_config.loaded_files.push(path_str.clone());
2119 }
2120 }
2121
2122 if !skip_auto_discovery && config_path.is_none() {
2124 if let Some((config_file, project_root)) = Self::discover_config_upward() {
2126 let path_str = config_file.display().to_string();
2127 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
2128
2129 log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
2130 log::debug!("[rumdl-config] Project root: {}", project_root.display());
2131
2132 sourced_config.project_root = Some(project_root);
2134
2135 if filename == "pyproject.toml" {
2136 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2137 source: e,
2138 path: path_str.clone(),
2139 })?;
2140 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2141 sourced_config.merge(fragment);
2142 sourced_config.loaded_files.push(path_str);
2143 }
2144 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
2145 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2146 source: e,
2147 path: path_str.clone(),
2148 })?;
2149 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2150 sourced_config.merge(fragment);
2151 sourced_config.loaded_files.push(path_str);
2152 }
2153 } else {
2154 log::debug!("[rumdl-config] No configuration file found via upward traversal");
2155
2156 let mut found_markdownlint = false;
2158 for filename in MARKDOWNLINT_CONFIG_FILES {
2159 if std::path::Path::new(filename).exists() {
2160 match load_from_markdownlint(filename) {
2161 Ok(fragment) => {
2162 sourced_config.merge(fragment);
2163 sourced_config.loaded_files.push(filename.to_string());
2164 found_markdownlint = true;
2165 break; }
2167 Err(_e) => {
2168 }
2170 }
2171 }
2172 }
2173
2174 if !found_markdownlint {
2175 log::debug!("[rumdl-config] No markdownlint configuration file found");
2176 }
2177 }
2178 }
2179
2180 if let Some(cli) = cli_overrides {
2182 sourced_config
2183 .global
2184 .enable
2185 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
2186 sourced_config
2187 .global
2188 .disable
2189 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
2190 sourced_config
2191 .global
2192 .exclude
2193 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
2194 sourced_config
2195 .global
2196 .include
2197 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
2198 sourced_config.global.respect_gitignore.merge_override(
2199 cli.respect_gitignore.value,
2200 ConfigSource::Cli,
2201 None,
2202 None,
2203 );
2204 sourced_config
2205 .global
2206 .fixable
2207 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
2208 sourced_config
2209 .global
2210 .unfixable
2211 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
2212 }
2214
2215 Ok(sourced_config)
2218 }
2219
2220 pub fn load_with_discovery(
2223 config_path: Option<&str>,
2224 cli_overrides: Option<&SourcedGlobalConfig>,
2225 skip_auto_discovery: bool,
2226 ) -> Result<Self, ConfigError> {
2227 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
2228 }
2229}
2230
2231impl From<SourcedConfig> for Config {
2232 fn from(sourced: SourcedConfig) -> Self {
2233 let mut rules = BTreeMap::new();
2234 for (rule_name, sourced_rule_cfg) in sourced.rules {
2235 let normalized_rule_name = rule_name.to_ascii_uppercase();
2237 let mut values = BTreeMap::new();
2238 for (key, sourced_val) in sourced_rule_cfg.values {
2239 values.insert(key, sourced_val.value);
2240 }
2241 rules.insert(normalized_rule_name, RuleConfig { values });
2242 }
2243 #[allow(deprecated)]
2244 let global = GlobalConfig {
2245 enable: sourced.global.enable.value,
2246 disable: sourced.global.disable.value,
2247 exclude: sourced.global.exclude.value,
2248 include: sourced.global.include.value,
2249 respect_gitignore: sourced.global.respect_gitignore.value,
2250 line_length: sourced.global.line_length.value,
2251 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
2252 fixable: sourced.global.fixable.value,
2253 unfixable: sourced.global.unfixable.value,
2254 flavor: sourced.global.flavor.value,
2255 force_exclude: sourced.global.force_exclude.value,
2256 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
2257 };
2258 Config {
2259 global,
2260 per_file_ignores: sourced.per_file_ignores.value,
2261 rules,
2262 }
2263 }
2264}
2265
2266pub struct RuleRegistry {
2268 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
2270 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
2272}
2273
2274impl RuleRegistry {
2275 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
2277 let mut rule_schemas = std::collections::BTreeMap::new();
2278 let mut rule_aliases = std::collections::BTreeMap::new();
2279
2280 for rule in rules {
2281 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
2282 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
2284 norm_name
2285 } else {
2286 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
2288 norm_name
2289 };
2290
2291 if let Some(aliases) = rule.config_aliases() {
2293 rule_aliases.insert(norm_name, aliases);
2294 }
2295 }
2296
2297 RuleRegistry {
2298 rule_schemas,
2299 rule_aliases,
2300 }
2301 }
2302
2303 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
2305 self.rule_schemas.keys().cloned().collect()
2306 }
2307
2308 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
2310 self.rule_schemas.get(rule).map(|schema| {
2311 let mut all_keys = std::collections::BTreeSet::new();
2312
2313 for key in schema.keys() {
2315 all_keys.insert(key.clone());
2316 }
2317
2318 for key in schema.keys() {
2320 all_keys.insert(key.replace('_', "-"));
2322 all_keys.insert(key.replace('-', "_"));
2324 all_keys.insert(normalize_key(key));
2326 }
2327
2328 if let Some(aliases) = self.rule_aliases.get(rule) {
2330 for alias_key in aliases.keys() {
2331 all_keys.insert(alias_key.clone());
2332 all_keys.insert(alias_key.replace('_', "-"));
2334 all_keys.insert(alias_key.replace('-', "_"));
2335 all_keys.insert(normalize_key(alias_key));
2336 }
2337 }
2338
2339 all_keys
2340 })
2341 }
2342
2343 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
2345 if let Some(schema) = self.rule_schemas.get(rule) {
2346 if let Some(aliases) = self.rule_aliases.get(rule)
2348 && let Some(canonical_key) = aliases.get(key)
2349 {
2350 if let Some(value) = schema.get(canonical_key) {
2352 return Some(value);
2353 }
2354 }
2355
2356 if let Some(value) = schema.get(key) {
2358 return Some(value);
2359 }
2360
2361 let key_variants = [
2363 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
2367
2368 for variant in &key_variants {
2369 if let Some(value) = schema.get(variant) {
2370 return Some(value);
2371 }
2372 }
2373 }
2374 None
2375 }
2376}
2377
2378#[derive(Debug, Clone)]
2380pub struct ConfigValidationWarning {
2381 pub message: String,
2382 pub rule: Option<String>,
2383 pub key: Option<String>,
2384}
2385
2386pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
2388 let mut warnings = Vec::new();
2389 let known_rules = registry.rule_names();
2390 for rule in sourced.rules.keys() {
2392 if !known_rules.contains(rule) {
2393 warnings.push(ConfigValidationWarning {
2394 message: format!("Unknown rule in config: {rule}"),
2395 rule: Some(rule.clone()),
2396 key: None,
2397 });
2398 }
2399 }
2400 for (rule, rule_cfg) in &sourced.rules {
2402 if let Some(valid_keys) = registry.config_keys_for(rule) {
2403 for key in rule_cfg.values.keys() {
2404 if !valid_keys.contains(key) {
2405 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
2406 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
2407 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
2408 } else {
2409 format!("Unknown option for rule {rule}: {key}")
2410 };
2411 warnings.push(ConfigValidationWarning {
2412 message,
2413 rule: Some(rule.clone()),
2414 key: Some(key.clone()),
2415 });
2416 } else {
2417 if let Some(expected) = registry.expected_value_for(rule, key) {
2419 let actual = &rule_cfg.values[key].value;
2420 if !toml_value_type_matches(expected, actual) {
2421 warnings.push(ConfigValidationWarning {
2422 message: format!(
2423 "Type mismatch for {}.{}: expected {}, got {}",
2424 rule,
2425 key,
2426 toml_type_name(expected),
2427 toml_type_name(actual)
2428 ),
2429 rule: Some(rule.clone()),
2430 key: Some(key.clone()),
2431 });
2432 }
2433 }
2434 }
2435 }
2436 }
2437 }
2438 let known_global_keys = vec![
2440 "enable".to_string(),
2441 "disable".to_string(),
2442 "include".to_string(),
2443 "exclude".to_string(),
2444 "respect-gitignore".to_string(),
2445 "line-length".to_string(),
2446 "fixable".to_string(),
2447 "unfixable".to_string(),
2448 "flavor".to_string(),
2449 "force-exclude".to_string(),
2450 "output-format".to_string(),
2451 "cache-dir".to_string(),
2452 ];
2453
2454 for (section, key, file_path) in &sourced.unknown_keys {
2455 if section.contains("[global]") || section.contains("[tool.rumdl]") {
2456 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
2457 if let Some(path) = file_path {
2458 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
2459 } else {
2460 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
2461 }
2462 } else if let Some(path) = file_path {
2463 format!("Unknown global option in {path}: {key}")
2464 } else {
2465 format!("Unknown global option: {key}")
2466 };
2467 warnings.push(ConfigValidationWarning {
2468 message,
2469 rule: None,
2470 key: Some(key.clone()),
2471 });
2472 } else if !key.is_empty() {
2473 continue;
2476 } else {
2477 let message = if let Some(path) = file_path {
2479 format!(
2480 "Unknown rule in {path}: {}",
2481 section.trim_matches(|c| c == '[' || c == ']')
2482 )
2483 } else {
2484 format!(
2485 "Unknown rule in config: {}",
2486 section.trim_matches(|c| c == '[' || c == ']')
2487 )
2488 };
2489 warnings.push(ConfigValidationWarning {
2490 message,
2491 rule: None,
2492 key: None,
2493 });
2494 }
2495 }
2496 warnings
2497}
2498
2499fn toml_type_name(val: &toml::Value) -> &'static str {
2500 match val {
2501 toml::Value::String(_) => "string",
2502 toml::Value::Integer(_) => "integer",
2503 toml::Value::Float(_) => "float",
2504 toml::Value::Boolean(_) => "boolean",
2505 toml::Value::Array(_) => "array",
2506 toml::Value::Table(_) => "table",
2507 toml::Value::Datetime(_) => "datetime",
2508 }
2509}
2510
2511fn levenshtein_distance(s1: &str, s2: &str) -> usize {
2513 let len1 = s1.len();
2514 let len2 = s2.len();
2515
2516 if len1 == 0 {
2517 return len2;
2518 }
2519 if len2 == 0 {
2520 return len1;
2521 }
2522
2523 let s1_chars: Vec<char> = s1.chars().collect();
2524 let s2_chars: Vec<char> = s2.chars().collect();
2525
2526 let mut prev_row: Vec<usize> = (0..=len2).collect();
2527 let mut curr_row = vec![0; len2 + 1];
2528
2529 for i in 1..=len1 {
2530 curr_row[0] = i;
2531 for j in 1..=len2 {
2532 let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
2533 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
2537 std::mem::swap(&mut prev_row, &mut curr_row);
2538 }
2539
2540 prev_row[len2]
2541}
2542
2543fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
2545 let unknown_lower = unknown.to_lowercase();
2546 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
2549
2550 for valid in valid_keys {
2551 let valid_lower = valid.to_lowercase();
2552 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
2553
2554 if distance <= max_distance {
2555 if let Some((_, best_dist)) = &best_match {
2556 if distance < *best_dist {
2557 best_match = Some((valid.clone(), distance));
2558 }
2559 } else {
2560 best_match = Some((valid.clone(), distance));
2561 }
2562 }
2563 }
2564
2565 best_match.map(|(key, _)| key)
2566}
2567
2568fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
2569 use toml::Value::*;
2570 match (expected, actual) {
2571 (String(_), String(_)) => true,
2572 (Integer(_), Integer(_)) => true,
2573 (Float(_), Float(_)) => true,
2574 (Boolean(_), Boolean(_)) => true,
2575 (Array(_), Array(_)) => true,
2576 (Table(_), Table(_)) => true,
2577 (Datetime(_), Datetime(_)) => true,
2578 (Float(_), Integer(_)) => true,
2580 _ => false,
2581 }
2582}
2583
2584fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
2586 let doc: toml::Value =
2587 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2588 let mut fragment = SourcedConfigFragment::default();
2589 let source = ConfigSource::PyprojectToml;
2590 let file = Some(path.to_string());
2591
2592 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
2594 && let Some(rumdl_table) = rumdl_config.as_table()
2595 {
2596 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
2598 if let Some(enable) = table.get("enable")
2600 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
2601 {
2602 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2604 fragment
2605 .global
2606 .enable
2607 .push_override(normalized_values, source, file.clone(), None);
2608 }
2609
2610 if let Some(disable) = table.get("disable")
2611 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
2612 {
2613 let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
2615 fragment
2616 .global
2617 .disable
2618 .push_override(normalized_values, source, file.clone(), None);
2619 }
2620
2621 if let Some(include) = table.get("include")
2622 && let Ok(values) = Vec::<String>::deserialize(include.clone())
2623 {
2624 fragment
2625 .global
2626 .include
2627 .push_override(values, source, file.clone(), None);
2628 }
2629
2630 if let Some(exclude) = table.get("exclude")
2631 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
2632 {
2633 fragment
2634 .global
2635 .exclude
2636 .push_override(values, source, file.clone(), None);
2637 }
2638
2639 if let Some(respect_gitignore) = table
2640 .get("respect-gitignore")
2641 .or_else(|| table.get("respect_gitignore"))
2642 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
2643 {
2644 fragment
2645 .global
2646 .respect_gitignore
2647 .push_override(value, source, file.clone(), None);
2648 }
2649
2650 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
2651 && let Ok(value) = bool::deserialize(force_exclude.clone())
2652 {
2653 fragment
2654 .global
2655 .force_exclude
2656 .push_override(value, source, file.clone(), None);
2657 }
2658
2659 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
2660 && let Ok(value) = String::deserialize(output_format.clone())
2661 {
2662 if fragment.global.output_format.is_none() {
2663 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
2664 } else {
2665 fragment
2666 .global
2667 .output_format
2668 .as_mut()
2669 .unwrap()
2670 .push_override(value, source, file.clone(), None);
2671 }
2672 }
2673
2674 if let Some(fixable) = table.get("fixable")
2675 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
2676 {
2677 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2678 fragment
2679 .global
2680 .fixable
2681 .push_override(normalized_values, source, file.clone(), None);
2682 }
2683
2684 if let Some(unfixable) = table.get("unfixable")
2685 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
2686 {
2687 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2688 fragment
2689 .global
2690 .unfixable
2691 .push_override(normalized_values, source, file.clone(), None);
2692 }
2693
2694 if let Some(flavor) = table.get("flavor")
2695 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
2696 {
2697 fragment.global.flavor.push_override(value, source, file.clone(), None);
2698 }
2699
2700 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
2702 && let Ok(value) = u64::deserialize(line_length.clone())
2703 {
2704 fragment
2705 .global
2706 .line_length
2707 .push_override(value, source, file.clone(), None);
2708
2709 let norm_md013_key = normalize_key("MD013");
2711 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
2712 let norm_line_length_key = normalize_key("line-length");
2713 let sv = rule_entry
2714 .values
2715 .entry(norm_line_length_key)
2716 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
2717 sv.push_override(line_length.clone(), source, file.clone(), None);
2718 }
2719
2720 if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
2721 && let Ok(value) = String::deserialize(cache_dir.clone())
2722 {
2723 if fragment.global.cache_dir.is_none() {
2724 fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
2725 } else {
2726 fragment
2727 .global
2728 .cache_dir
2729 .as_mut()
2730 .unwrap()
2731 .push_override(value, source, file.clone(), None);
2732 }
2733 }
2734 };
2735
2736 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
2738 extract_global_config(&mut fragment, global_table);
2739 }
2740
2741 extract_global_config(&mut fragment, rumdl_table);
2743
2744 let per_file_ignores_key = rumdl_table
2747 .get("per-file-ignores")
2748 .or_else(|| rumdl_table.get("per_file_ignores"));
2749
2750 if let Some(per_file_ignores_value) = per_file_ignores_key
2751 && let Some(per_file_table) = per_file_ignores_value.as_table()
2752 {
2753 let mut per_file_map = HashMap::new();
2754 for (pattern, rules_value) in per_file_table {
2755 if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
2756 let normalized_rules = rules.into_iter().map(|s| normalize_key(&s)).collect();
2757 per_file_map.insert(pattern.clone(), normalized_rules);
2758 } else {
2759 log::warn!(
2760 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
2761 );
2762 }
2763 }
2764 fragment
2765 .per_file_ignores
2766 .push_override(per_file_map, source, file.clone(), None);
2767 }
2768
2769 for (key, value) in rumdl_table {
2771 let norm_rule_key = normalize_key(key);
2772
2773 if [
2775 "enable",
2776 "disable",
2777 "include",
2778 "exclude",
2779 "respect_gitignore",
2780 "respect-gitignore", "force_exclude",
2782 "force-exclude",
2783 "line_length",
2784 "line-length",
2785 "output_format",
2786 "output-format",
2787 "fixable",
2788 "unfixable",
2789 "per-file-ignores",
2790 "per_file_ignores",
2791 "global",
2792 "flavor",
2793 "cache_dir",
2794 "cache-dir",
2795 ]
2796 .contains(&norm_rule_key.as_str())
2797 {
2798 continue;
2799 }
2800
2801 let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
2805 if norm_rule_key_upper.len() == 5
2806 && norm_rule_key_upper.starts_with("MD")
2807 && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
2808 && value.is_table()
2809 {
2810 if let Some(rule_config_table) = value.as_table() {
2811 let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
2813 for (rk, rv) in rule_config_table {
2814 let norm_rk = normalize_key(rk); let toml_val = rv.clone();
2817
2818 let sv = rule_entry
2819 .values
2820 .entry(norm_rk.clone())
2821 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2822 sv.push_override(toml_val, source, file.clone(), None);
2823 }
2824 }
2825 } else {
2826 fragment
2829 .unknown_keys
2830 .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
2831 }
2832 }
2833 }
2834
2835 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
2837 for (key, value) in tool_table.iter() {
2838 if let Some(rule_name) = key.strip_prefix("rumdl.") {
2839 let norm_rule_name = normalize_key(rule_name);
2840 if norm_rule_name.len() == 5
2841 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2842 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2843 && let Some(rule_table) = value.as_table()
2844 {
2845 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2846 for (rk, rv) in rule_table {
2847 let norm_rk = normalize_key(rk);
2848 let toml_val = rv.clone();
2849 let sv = rule_entry
2850 .values
2851 .entry(norm_rk.clone())
2852 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2853 sv.push_override(toml_val, source, file.clone(), None);
2854 }
2855 } else if rule_name.to_ascii_uppercase().starts_with("MD") {
2856 fragment.unknown_keys.push((
2858 format!("[tool.rumdl.{rule_name}]"),
2859 String::new(),
2860 Some(path.to_string()),
2861 ));
2862 }
2863 }
2864 }
2865 }
2866
2867 if let Some(doc_table) = doc.as_table() {
2869 for (key, value) in doc_table.iter() {
2870 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
2871 let norm_rule_name = normalize_key(rule_name);
2872 if norm_rule_name.len() == 5
2873 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2874 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2875 && let Some(rule_table) = value.as_table()
2876 {
2877 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2878 for (rk, rv) in rule_table {
2879 let norm_rk = normalize_key(rk);
2880 let toml_val = rv.clone();
2881 let sv = rule_entry
2882 .values
2883 .entry(norm_rk.clone())
2884 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2885 sv.push_override(toml_val, source, file.clone(), None);
2886 }
2887 } else if rule_name.to_ascii_uppercase().starts_with("MD") {
2888 fragment.unknown_keys.push((
2890 format!("[tool.rumdl.{rule_name}]"),
2891 String::new(),
2892 Some(path.to_string()),
2893 ));
2894 }
2895 }
2896 }
2897 }
2898
2899 let has_any = !fragment.global.enable.value.is_empty()
2901 || !fragment.global.disable.value.is_empty()
2902 || !fragment.global.include.value.is_empty()
2903 || !fragment.global.exclude.value.is_empty()
2904 || !fragment.global.fixable.value.is_empty()
2905 || !fragment.global.unfixable.value.is_empty()
2906 || fragment.global.output_format.is_some()
2907 || fragment.global.cache_dir.is_some()
2908 || !fragment.per_file_ignores.value.is_empty()
2909 || !fragment.rules.is_empty();
2910 if has_any { Ok(Some(fragment)) } else { Ok(None) }
2911}
2912
2913fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
2915 let doc = content
2916 .parse::<DocumentMut>()
2917 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2918 let mut fragment = SourcedConfigFragment::default();
2919 let file = Some(path.to_string());
2921
2922 let all_rules = rules::all_rules(&Config::default());
2924 let registry = RuleRegistry::from_rules(&all_rules);
2925 let known_rule_names: BTreeSet<String> = registry
2926 .rule_names()
2927 .into_iter()
2928 .map(|s| s.to_ascii_uppercase())
2929 .collect();
2930
2931 if let Some(global_item) = doc.get("global")
2933 && let Some(global_table) = global_item.as_table()
2934 {
2935 for (key, value_item) in global_table.iter() {
2936 let norm_key = normalize_key(key);
2937 match norm_key.as_str() {
2938 "enable" | "disable" | "include" | "exclude" => {
2939 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2940 let values: Vec<String> = formatted_array
2942 .iter()
2943 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
2945 .collect();
2946
2947 let final_values = if norm_key == "enable" || norm_key == "disable" {
2949 values.into_iter().map(|s| normalize_key(&s)).collect()
2951 } else {
2952 values
2953 };
2954
2955 match norm_key.as_str() {
2956 "enable" => fragment
2957 .global
2958 .enable
2959 .push_override(final_values, source, file.clone(), None),
2960 "disable" => {
2961 fragment
2962 .global
2963 .disable
2964 .push_override(final_values, source, file.clone(), None)
2965 }
2966 "include" => {
2967 fragment
2968 .global
2969 .include
2970 .push_override(final_values, source, file.clone(), None)
2971 }
2972 "exclude" => {
2973 fragment
2974 .global
2975 .exclude
2976 .push_override(final_values, source, file.clone(), None)
2977 }
2978 _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
2979 }
2980 } else {
2981 log::warn!(
2982 "[WARN] Expected array for global key '{}' in {}, found {}",
2983 key,
2984 path,
2985 value_item.type_name()
2986 );
2987 }
2988 }
2989 "respect_gitignore" | "respect-gitignore" => {
2990 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2992 let val = *formatted_bool.value();
2993 fragment
2994 .global
2995 .respect_gitignore
2996 .push_override(val, source, file.clone(), None);
2997 } else {
2998 log::warn!(
2999 "[WARN] Expected boolean for global key '{}' in {}, found {}",
3000 key,
3001 path,
3002 value_item.type_name()
3003 );
3004 }
3005 }
3006 "force_exclude" | "force-exclude" => {
3007 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
3009 let val = *formatted_bool.value();
3010 fragment
3011 .global
3012 .force_exclude
3013 .push_override(val, source, file.clone(), None);
3014 } else {
3015 log::warn!(
3016 "[WARN] Expected boolean for global key '{}' in {}, found {}",
3017 key,
3018 path,
3019 value_item.type_name()
3020 );
3021 }
3022 }
3023 "line_length" | "line-length" => {
3024 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
3026 let val = *formatted_int.value() as u64;
3027 fragment
3028 .global
3029 .line_length
3030 .push_override(val, source, file.clone(), None);
3031 } else {
3032 log::warn!(
3033 "[WARN] Expected integer for global key '{}' in {}, found {}",
3034 key,
3035 path,
3036 value_item.type_name()
3037 );
3038 }
3039 }
3040 "output_format" | "output-format" => {
3041 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3043 let val = formatted_string.value().clone();
3044 if fragment.global.output_format.is_none() {
3045 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
3046 } else {
3047 fragment.global.output_format.as_mut().unwrap().push_override(
3048 val,
3049 source,
3050 file.clone(),
3051 None,
3052 );
3053 }
3054 } else {
3055 log::warn!(
3056 "[WARN] Expected string for global key '{}' in {}, found {}",
3057 key,
3058 path,
3059 value_item.type_name()
3060 );
3061 }
3062 }
3063 "cache_dir" | "cache-dir" => {
3064 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3066 let val = formatted_string.value().clone();
3067 if fragment.global.cache_dir.is_none() {
3068 fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
3069 } else {
3070 fragment
3071 .global
3072 .cache_dir
3073 .as_mut()
3074 .unwrap()
3075 .push_override(val, source, file.clone(), None);
3076 }
3077 } else {
3078 log::warn!(
3079 "[WARN] Expected string for global key '{}' in {}, found {}",
3080 key,
3081 path,
3082 value_item.type_name()
3083 );
3084 }
3085 }
3086 "fixable" => {
3087 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3088 let values: Vec<String> = formatted_array
3089 .iter()
3090 .filter_map(|item| item.as_str())
3091 .map(normalize_key)
3092 .collect();
3093 fragment
3094 .global
3095 .fixable
3096 .push_override(values, source, file.clone(), None);
3097 } else {
3098 log::warn!(
3099 "[WARN] Expected array for global key '{}' in {}, found {}",
3100 key,
3101 path,
3102 value_item.type_name()
3103 );
3104 }
3105 }
3106 "unfixable" => {
3107 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3108 let values: Vec<String> = formatted_array
3109 .iter()
3110 .filter_map(|item| item.as_str())
3111 .map(normalize_key)
3112 .collect();
3113 fragment
3114 .global
3115 .unfixable
3116 .push_override(values, source, file.clone(), None);
3117 } else {
3118 log::warn!(
3119 "[WARN] Expected array for global key '{}' in {}, found {}",
3120 key,
3121 path,
3122 value_item.type_name()
3123 );
3124 }
3125 }
3126 "flavor" => {
3127 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3128 let val = formatted_string.value();
3129 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
3130 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
3131 } else {
3132 log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
3133 }
3134 } else {
3135 log::warn!(
3136 "[WARN] Expected string for global key '{}' in {}, found {}",
3137 key,
3138 path,
3139 value_item.type_name()
3140 );
3141 }
3142 }
3143 _ => {
3144 fragment
3146 .unknown_keys
3147 .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
3148 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
3149 }
3150 }
3151 }
3152 }
3153
3154 if let Some(per_file_item) = doc.get("per-file-ignores")
3156 && let Some(per_file_table) = per_file_item.as_table()
3157 {
3158 let mut per_file_map = HashMap::new();
3159 for (pattern, value_item) in per_file_table.iter() {
3160 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3161 let rules: Vec<String> = formatted_array
3162 .iter()
3163 .filter_map(|item| item.as_str())
3164 .map(normalize_key)
3165 .collect();
3166 per_file_map.insert(pattern.to_string(), rules);
3167 } else {
3168 let type_name = value_item.type_name();
3169 log::warn!(
3170 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
3171 );
3172 }
3173 }
3174 fragment
3175 .per_file_ignores
3176 .push_override(per_file_map, source, file.clone(), None);
3177 }
3178
3179 for (key, item) in doc.iter() {
3181 let norm_rule_name = key.to_ascii_uppercase();
3182
3183 if key == "global" || key == "per-file-ignores" {
3185 continue;
3186 }
3187
3188 if !known_rule_names.contains(&norm_rule_name) {
3190 if norm_rule_name.starts_with("MD") || key.chars().all(|c| c.is_uppercase() || c.is_numeric()) {
3192 fragment
3193 .unknown_keys
3194 .push((format!("[{key}]"), String::new(), Some(path.to_string())));
3195 }
3196 continue;
3197 }
3198
3199 if let Some(tbl) = item.as_table() {
3200 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
3201 for (rk, rv_item) in tbl.iter() {
3202 let norm_rk = normalize_key(rk);
3203 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
3204 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
3205 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
3206 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
3207 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
3208 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
3209 Some(toml_edit::Value::Array(formatted_array)) => {
3210 let mut values = Vec::new();
3212 for item in formatted_array.iter() {
3213 match item {
3214 toml_edit::Value::String(formatted) => {
3215 values.push(toml::Value::String(formatted.value().clone()))
3216 }
3217 toml_edit::Value::Integer(formatted) => {
3218 values.push(toml::Value::Integer(*formatted.value()))
3219 }
3220 toml_edit::Value::Float(formatted) => {
3221 values.push(toml::Value::Float(*formatted.value()))
3222 }
3223 toml_edit::Value::Boolean(formatted) => {
3224 values.push(toml::Value::Boolean(*formatted.value()))
3225 }
3226 toml_edit::Value::Datetime(formatted) => {
3227 values.push(toml::Value::Datetime(*formatted.value()))
3228 }
3229 _ => {
3230 log::warn!(
3231 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
3232 );
3233 }
3234 }
3235 }
3236 Some(toml::Value::Array(values))
3237 }
3238 Some(toml_edit::Value::InlineTable(_)) => {
3239 log::warn!(
3240 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
3241 );
3242 None
3243 }
3244 None => {
3245 log::warn!(
3246 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
3247 );
3248 None
3249 }
3250 };
3251 if let Some(toml_val) = maybe_toml_val {
3252 let sv = rule_entry
3253 .values
3254 .entry(norm_rk.clone())
3255 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
3256 sv.push_override(toml_val, source, file.clone(), None);
3257 }
3258 }
3259 } else if item.is_value() {
3260 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
3261 }
3262 }
3263
3264 Ok(fragment)
3265}
3266
3267fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
3269 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
3271 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
3272 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
3273}
3274
3275#[cfg(test)]
3276#[path = "config_intelligent_merge_tests.rs"]
3277mod config_intelligent_merge_tests;