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 fn user_configuration_path() -> Option<std::path::PathBuf> {
1984 use etcetera::{BaseStrategy, choose_base_strategy};
1985
1986 match choose_base_strategy() {
1987 Ok(strategy) => {
1988 let config_dir = strategy.config_dir();
1989 Self::user_configuration_path_impl(&config_dir)
1990 }
1991 Err(e) => {
1992 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
1993 None
1994 }
1995 }
1996 }
1997
1998 #[doc(hidden)]
2000 pub fn load_with_discovery_impl(
2001 config_path: Option<&str>,
2002 cli_overrides: Option<&SourcedGlobalConfig>,
2003 skip_auto_discovery: bool,
2004 user_config_dir: Option<&Path>,
2005 ) -> Result<Self, ConfigError> {
2006 use std::env;
2007 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
2008 if config_path.is_none() {
2009 if skip_auto_discovery {
2010 log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
2011 } else {
2012 log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
2013 }
2014 } else {
2015 log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
2016 }
2017 let mut sourced_config = SourcedConfig::default();
2018
2019 if !skip_auto_discovery {
2022 let user_config_path = if let Some(dir) = user_config_dir {
2023 Self::user_configuration_path_impl(dir)
2024 } else {
2025 Self::user_configuration_path()
2026 };
2027
2028 if let Some(user_config_path) = user_config_path {
2029 let path_str = user_config_path.display().to_string();
2030 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2031
2032 log::debug!("[rumdl-config] Loading user configuration file: {path_str}");
2033
2034 if filename == "pyproject.toml" {
2035 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2036 source: e,
2037 path: path_str.clone(),
2038 })?;
2039 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2040 sourced_config.merge(fragment);
2041 sourced_config.loaded_files.push(path_str);
2042 }
2043 } else {
2044 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2045 source: e,
2046 path: path_str.clone(),
2047 })?;
2048 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
2049 sourced_config.merge(fragment);
2050 sourced_config.loaded_files.push(path_str);
2051 }
2052 } else {
2053 log::debug!("[rumdl-config] No user configuration file found");
2054 }
2055 }
2056
2057 if let Some(path) = config_path {
2059 let path_obj = Path::new(path);
2060 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
2061 log::debug!("[rumdl-config] Trying to load config file: {filename}");
2062 let path_str = path.to_string();
2063
2064 if let Some(config_parent) = path_obj.parent() {
2066 let project_root = Self::find_project_root_from(config_parent);
2067 log::debug!(
2068 "[rumdl-config] Project root (from explicit config): {}",
2069 project_root.display()
2070 );
2071 sourced_config.project_root = Some(project_root);
2072 }
2073
2074 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
2076
2077 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
2078 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2079 source: e,
2080 path: path_str.clone(),
2081 })?;
2082 if filename == "pyproject.toml" {
2083 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2084 sourced_config.merge(fragment);
2085 sourced_config.loaded_files.push(path_str.clone());
2086 }
2087 } else {
2088 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2089 sourced_config.merge(fragment);
2090 sourced_config.loaded_files.push(path_str.clone());
2091 }
2092 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
2093 || path_str.ends_with(".json")
2094 || path_str.ends_with(".jsonc")
2095 || path_str.ends_with(".yaml")
2096 || path_str.ends_with(".yml")
2097 {
2098 let fragment = load_from_markdownlint(&path_str)?;
2100 sourced_config.merge(fragment);
2101 sourced_config.loaded_files.push(path_str.clone());
2102 } else {
2104 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2106 source: e,
2107 path: path_str.clone(),
2108 })?;
2109 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2110 sourced_config.merge(fragment);
2111 sourced_config.loaded_files.push(path_str.clone());
2112 }
2113 }
2114
2115 if !skip_auto_discovery && config_path.is_none() {
2117 if let Some((config_file, project_root)) = Self::discover_config_upward() {
2119 let path_str = config_file.display().to_string();
2120 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
2121
2122 log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
2123 log::debug!("[rumdl-config] Project root: {}", project_root.display());
2124
2125 sourced_config.project_root = Some(project_root);
2127
2128 if filename == "pyproject.toml" {
2129 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2130 source: e,
2131 path: path_str.clone(),
2132 })?;
2133 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2134 sourced_config.merge(fragment);
2135 sourced_config.loaded_files.push(path_str);
2136 }
2137 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
2138 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2139 source: e,
2140 path: path_str.clone(),
2141 })?;
2142 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2143 sourced_config.merge(fragment);
2144 sourced_config.loaded_files.push(path_str);
2145 }
2146 } else {
2147 log::debug!("[rumdl-config] No configuration file found via upward traversal");
2148
2149 let mut found_markdownlint = false;
2151 for filename in MARKDOWNLINT_CONFIG_FILES {
2152 if std::path::Path::new(filename).exists() {
2153 match load_from_markdownlint(filename) {
2154 Ok(fragment) => {
2155 sourced_config.merge(fragment);
2156 sourced_config.loaded_files.push(filename.to_string());
2157 found_markdownlint = true;
2158 break; }
2160 Err(_e) => {
2161 }
2163 }
2164 }
2165 }
2166
2167 if !found_markdownlint {
2168 log::debug!("[rumdl-config] No markdownlint configuration file found");
2169 }
2170 }
2171 }
2172
2173 if let Some(cli) = cli_overrides {
2175 sourced_config
2176 .global
2177 .enable
2178 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
2179 sourced_config
2180 .global
2181 .disable
2182 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
2183 sourced_config
2184 .global
2185 .exclude
2186 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
2187 sourced_config
2188 .global
2189 .include
2190 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
2191 sourced_config.global.respect_gitignore.merge_override(
2192 cli.respect_gitignore.value,
2193 ConfigSource::Cli,
2194 None,
2195 None,
2196 );
2197 sourced_config
2198 .global
2199 .fixable
2200 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
2201 sourced_config
2202 .global
2203 .unfixable
2204 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
2205 }
2207
2208 Ok(sourced_config)
2211 }
2212
2213 pub fn load_with_discovery(
2216 config_path: Option<&str>,
2217 cli_overrides: Option<&SourcedGlobalConfig>,
2218 skip_auto_discovery: bool,
2219 ) -> Result<Self, ConfigError> {
2220 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
2221 }
2222}
2223
2224impl From<SourcedConfig> for Config {
2225 fn from(sourced: SourcedConfig) -> Self {
2226 let mut rules = BTreeMap::new();
2227 for (rule_name, sourced_rule_cfg) in sourced.rules {
2228 let normalized_rule_name = rule_name.to_ascii_uppercase();
2230 let mut values = BTreeMap::new();
2231 for (key, sourced_val) in sourced_rule_cfg.values {
2232 values.insert(key, sourced_val.value);
2233 }
2234 rules.insert(normalized_rule_name, RuleConfig { values });
2235 }
2236 #[allow(deprecated)]
2237 let global = GlobalConfig {
2238 enable: sourced.global.enable.value,
2239 disable: sourced.global.disable.value,
2240 exclude: sourced.global.exclude.value,
2241 include: sourced.global.include.value,
2242 respect_gitignore: sourced.global.respect_gitignore.value,
2243 line_length: sourced.global.line_length.value,
2244 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
2245 fixable: sourced.global.fixable.value,
2246 unfixable: sourced.global.unfixable.value,
2247 flavor: sourced.global.flavor.value,
2248 force_exclude: sourced.global.force_exclude.value,
2249 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
2250 };
2251 Config {
2252 global,
2253 per_file_ignores: sourced.per_file_ignores.value,
2254 rules,
2255 }
2256 }
2257}
2258
2259pub struct RuleRegistry {
2261 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
2263 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
2265}
2266
2267impl RuleRegistry {
2268 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
2270 let mut rule_schemas = std::collections::BTreeMap::new();
2271 let mut rule_aliases = std::collections::BTreeMap::new();
2272
2273 for rule in rules {
2274 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
2275 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
2277 norm_name
2278 } else {
2279 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
2281 norm_name
2282 };
2283
2284 if let Some(aliases) = rule.config_aliases() {
2286 rule_aliases.insert(norm_name, aliases);
2287 }
2288 }
2289
2290 RuleRegistry {
2291 rule_schemas,
2292 rule_aliases,
2293 }
2294 }
2295
2296 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
2298 self.rule_schemas.keys().cloned().collect()
2299 }
2300
2301 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
2303 self.rule_schemas.get(rule).map(|schema| {
2304 let mut all_keys = std::collections::BTreeSet::new();
2305
2306 for key in schema.keys() {
2308 all_keys.insert(key.clone());
2309 }
2310
2311 for key in schema.keys() {
2313 all_keys.insert(key.replace('_', "-"));
2315 all_keys.insert(key.replace('-', "_"));
2317 all_keys.insert(normalize_key(key));
2319 }
2320
2321 if let Some(aliases) = self.rule_aliases.get(rule) {
2323 for alias_key in aliases.keys() {
2324 all_keys.insert(alias_key.clone());
2325 all_keys.insert(alias_key.replace('_', "-"));
2327 all_keys.insert(alias_key.replace('-', "_"));
2328 all_keys.insert(normalize_key(alias_key));
2329 }
2330 }
2331
2332 all_keys
2333 })
2334 }
2335
2336 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
2338 if let Some(schema) = self.rule_schemas.get(rule) {
2339 if let Some(aliases) = self.rule_aliases.get(rule)
2341 && let Some(canonical_key) = aliases.get(key)
2342 {
2343 if let Some(value) = schema.get(canonical_key) {
2345 return Some(value);
2346 }
2347 }
2348
2349 if let Some(value) = schema.get(key) {
2351 return Some(value);
2352 }
2353
2354 let key_variants = [
2356 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
2360
2361 for variant in &key_variants {
2362 if let Some(value) = schema.get(variant) {
2363 return Some(value);
2364 }
2365 }
2366 }
2367 None
2368 }
2369}
2370
2371#[derive(Debug, Clone)]
2373pub struct ConfigValidationWarning {
2374 pub message: String,
2375 pub rule: Option<String>,
2376 pub key: Option<String>,
2377}
2378
2379pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
2381 let mut warnings = Vec::new();
2382 let known_rules = registry.rule_names();
2383 for rule in sourced.rules.keys() {
2385 if !known_rules.contains(rule) {
2386 warnings.push(ConfigValidationWarning {
2387 message: format!("Unknown rule in config: {rule}"),
2388 rule: Some(rule.clone()),
2389 key: None,
2390 });
2391 }
2392 }
2393 for (rule, rule_cfg) in &sourced.rules {
2395 if let Some(valid_keys) = registry.config_keys_for(rule) {
2396 for key in rule_cfg.values.keys() {
2397 if !valid_keys.contains(key) {
2398 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
2399 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
2400 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
2401 } else {
2402 format!("Unknown option for rule {rule}: {key}")
2403 };
2404 warnings.push(ConfigValidationWarning {
2405 message,
2406 rule: Some(rule.clone()),
2407 key: Some(key.clone()),
2408 });
2409 } else {
2410 if let Some(expected) = registry.expected_value_for(rule, key) {
2412 let actual = &rule_cfg.values[key].value;
2413 if !toml_value_type_matches(expected, actual) {
2414 warnings.push(ConfigValidationWarning {
2415 message: format!(
2416 "Type mismatch for {}.{}: expected {}, got {}",
2417 rule,
2418 key,
2419 toml_type_name(expected),
2420 toml_type_name(actual)
2421 ),
2422 rule: Some(rule.clone()),
2423 key: Some(key.clone()),
2424 });
2425 }
2426 }
2427 }
2428 }
2429 }
2430 }
2431 let known_global_keys = vec![
2433 "enable".to_string(),
2434 "disable".to_string(),
2435 "include".to_string(),
2436 "exclude".to_string(),
2437 "respect-gitignore".to_string(),
2438 "line-length".to_string(),
2439 "fixable".to_string(),
2440 "unfixable".to_string(),
2441 "flavor".to_string(),
2442 "force-exclude".to_string(),
2443 "output-format".to_string(),
2444 "cache-dir".to_string(),
2445 ];
2446
2447 for (section, key, file_path) in &sourced.unknown_keys {
2448 if section.contains("[global]") || section.contains("[tool.rumdl]") {
2449 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
2450 if let Some(path) = file_path {
2451 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
2452 } else {
2453 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
2454 }
2455 } else if let Some(path) = file_path {
2456 format!("Unknown global option in {path}: {key}")
2457 } else {
2458 format!("Unknown global option: {key}")
2459 };
2460 warnings.push(ConfigValidationWarning {
2461 message,
2462 rule: None,
2463 key: Some(key.clone()),
2464 });
2465 } else if !key.is_empty() {
2466 continue;
2469 } else {
2470 let message = if let Some(path) = file_path {
2472 format!(
2473 "Unknown rule in {path}: {}",
2474 section.trim_matches(|c| c == '[' || c == ']')
2475 )
2476 } else {
2477 format!(
2478 "Unknown rule in config: {}",
2479 section.trim_matches(|c| c == '[' || c == ']')
2480 )
2481 };
2482 warnings.push(ConfigValidationWarning {
2483 message,
2484 rule: None,
2485 key: None,
2486 });
2487 }
2488 }
2489 warnings
2490}
2491
2492fn toml_type_name(val: &toml::Value) -> &'static str {
2493 match val {
2494 toml::Value::String(_) => "string",
2495 toml::Value::Integer(_) => "integer",
2496 toml::Value::Float(_) => "float",
2497 toml::Value::Boolean(_) => "boolean",
2498 toml::Value::Array(_) => "array",
2499 toml::Value::Table(_) => "table",
2500 toml::Value::Datetime(_) => "datetime",
2501 }
2502}
2503
2504fn levenshtein_distance(s1: &str, s2: &str) -> usize {
2506 let len1 = s1.len();
2507 let len2 = s2.len();
2508
2509 if len1 == 0 {
2510 return len2;
2511 }
2512 if len2 == 0 {
2513 return len1;
2514 }
2515
2516 let s1_chars: Vec<char> = s1.chars().collect();
2517 let s2_chars: Vec<char> = s2.chars().collect();
2518
2519 let mut prev_row: Vec<usize> = (0..=len2).collect();
2520 let mut curr_row = vec![0; len2 + 1];
2521
2522 for i in 1..=len1 {
2523 curr_row[0] = i;
2524 for j in 1..=len2 {
2525 let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
2526 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
2530 std::mem::swap(&mut prev_row, &mut curr_row);
2531 }
2532
2533 prev_row[len2]
2534}
2535
2536fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
2538 let unknown_lower = unknown.to_lowercase();
2539 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
2542
2543 for valid in valid_keys {
2544 let valid_lower = valid.to_lowercase();
2545 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
2546
2547 if distance <= max_distance {
2548 if let Some((_, best_dist)) = &best_match {
2549 if distance < *best_dist {
2550 best_match = Some((valid.clone(), distance));
2551 }
2552 } else {
2553 best_match = Some((valid.clone(), distance));
2554 }
2555 }
2556 }
2557
2558 best_match.map(|(key, _)| key)
2559}
2560
2561fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
2562 use toml::Value::*;
2563 match (expected, actual) {
2564 (String(_), String(_)) => true,
2565 (Integer(_), Integer(_)) => true,
2566 (Float(_), Float(_)) => true,
2567 (Boolean(_), Boolean(_)) => true,
2568 (Array(_), Array(_)) => true,
2569 (Table(_), Table(_)) => true,
2570 (Datetime(_), Datetime(_)) => true,
2571 (Float(_), Integer(_)) => true,
2573 _ => false,
2574 }
2575}
2576
2577fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
2579 let doc: toml::Value =
2580 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2581 let mut fragment = SourcedConfigFragment::default();
2582 let source = ConfigSource::PyprojectToml;
2583 let file = Some(path.to_string());
2584
2585 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
2587 && let Some(rumdl_table) = rumdl_config.as_table()
2588 {
2589 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
2591 if let Some(enable) = table.get("enable")
2593 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
2594 {
2595 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2597 fragment
2598 .global
2599 .enable
2600 .push_override(normalized_values, source, file.clone(), None);
2601 }
2602
2603 if let Some(disable) = table.get("disable")
2604 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
2605 {
2606 let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
2608 fragment
2609 .global
2610 .disable
2611 .push_override(normalized_values, source, file.clone(), None);
2612 }
2613
2614 if let Some(include) = table.get("include")
2615 && let Ok(values) = Vec::<String>::deserialize(include.clone())
2616 {
2617 fragment
2618 .global
2619 .include
2620 .push_override(values, source, file.clone(), None);
2621 }
2622
2623 if let Some(exclude) = table.get("exclude")
2624 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
2625 {
2626 fragment
2627 .global
2628 .exclude
2629 .push_override(values, source, file.clone(), None);
2630 }
2631
2632 if let Some(respect_gitignore) = table
2633 .get("respect-gitignore")
2634 .or_else(|| table.get("respect_gitignore"))
2635 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
2636 {
2637 fragment
2638 .global
2639 .respect_gitignore
2640 .push_override(value, source, file.clone(), None);
2641 }
2642
2643 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
2644 && let Ok(value) = bool::deserialize(force_exclude.clone())
2645 {
2646 fragment
2647 .global
2648 .force_exclude
2649 .push_override(value, source, file.clone(), None);
2650 }
2651
2652 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
2653 && let Ok(value) = String::deserialize(output_format.clone())
2654 {
2655 if fragment.global.output_format.is_none() {
2656 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
2657 } else {
2658 fragment
2659 .global
2660 .output_format
2661 .as_mut()
2662 .unwrap()
2663 .push_override(value, source, file.clone(), None);
2664 }
2665 }
2666
2667 if let Some(fixable) = table.get("fixable")
2668 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
2669 {
2670 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2671 fragment
2672 .global
2673 .fixable
2674 .push_override(normalized_values, source, file.clone(), None);
2675 }
2676
2677 if let Some(unfixable) = table.get("unfixable")
2678 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
2679 {
2680 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2681 fragment
2682 .global
2683 .unfixable
2684 .push_override(normalized_values, source, file.clone(), None);
2685 }
2686
2687 if let Some(flavor) = table.get("flavor")
2688 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
2689 {
2690 fragment.global.flavor.push_override(value, source, file.clone(), None);
2691 }
2692
2693 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
2695 && let Ok(value) = u64::deserialize(line_length.clone())
2696 {
2697 fragment
2698 .global
2699 .line_length
2700 .push_override(value, source, file.clone(), None);
2701
2702 let norm_md013_key = normalize_key("MD013");
2704 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
2705 let norm_line_length_key = normalize_key("line-length");
2706 let sv = rule_entry
2707 .values
2708 .entry(norm_line_length_key)
2709 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
2710 sv.push_override(line_length.clone(), source, file.clone(), None);
2711 }
2712
2713 if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
2714 && let Ok(value) = String::deserialize(cache_dir.clone())
2715 {
2716 if fragment.global.cache_dir.is_none() {
2717 fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
2718 } else {
2719 fragment
2720 .global
2721 .cache_dir
2722 .as_mut()
2723 .unwrap()
2724 .push_override(value, source, file.clone(), None);
2725 }
2726 }
2727 };
2728
2729 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
2731 extract_global_config(&mut fragment, global_table);
2732 }
2733
2734 extract_global_config(&mut fragment, rumdl_table);
2736
2737 let per_file_ignores_key = rumdl_table
2740 .get("per-file-ignores")
2741 .or_else(|| rumdl_table.get("per_file_ignores"));
2742
2743 if let Some(per_file_ignores_value) = per_file_ignores_key
2744 && let Some(per_file_table) = per_file_ignores_value.as_table()
2745 {
2746 let mut per_file_map = HashMap::new();
2747 for (pattern, rules_value) in per_file_table {
2748 if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
2749 let normalized_rules = rules.into_iter().map(|s| normalize_key(&s)).collect();
2750 per_file_map.insert(pattern.clone(), normalized_rules);
2751 } else {
2752 log::warn!(
2753 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
2754 );
2755 }
2756 }
2757 fragment
2758 .per_file_ignores
2759 .push_override(per_file_map, source, file.clone(), None);
2760 }
2761
2762 for (key, value) in rumdl_table {
2764 let norm_rule_key = normalize_key(key);
2765
2766 if [
2768 "enable",
2769 "disable",
2770 "include",
2771 "exclude",
2772 "respect_gitignore",
2773 "respect-gitignore", "force_exclude",
2775 "force-exclude",
2776 "line_length",
2777 "line-length",
2778 "output_format",
2779 "output-format",
2780 "fixable",
2781 "unfixable",
2782 "per-file-ignores",
2783 "per_file_ignores",
2784 "global",
2785 "flavor",
2786 "cache_dir",
2787 "cache-dir",
2788 ]
2789 .contains(&norm_rule_key.as_str())
2790 {
2791 continue;
2792 }
2793
2794 let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
2798 if norm_rule_key_upper.len() == 5
2799 && norm_rule_key_upper.starts_with("MD")
2800 && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
2801 && value.is_table()
2802 {
2803 if let Some(rule_config_table) = value.as_table() {
2804 let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
2806 for (rk, rv) in rule_config_table {
2807 let norm_rk = normalize_key(rk); let toml_val = rv.clone();
2810
2811 let sv = rule_entry
2812 .values
2813 .entry(norm_rk.clone())
2814 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2815 sv.push_override(toml_val, source, file.clone(), None);
2816 }
2817 }
2818 } else {
2819 fragment
2822 .unknown_keys
2823 .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
2824 }
2825 }
2826 }
2827
2828 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
2830 for (key, value) in tool_table.iter() {
2831 if let Some(rule_name) = key.strip_prefix("rumdl.") {
2832 let norm_rule_name = normalize_key(rule_name);
2833 if norm_rule_name.len() == 5
2834 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2835 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2836 && let Some(rule_table) = value.as_table()
2837 {
2838 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2839 for (rk, rv) in rule_table {
2840 let norm_rk = normalize_key(rk);
2841 let toml_val = rv.clone();
2842 let sv = rule_entry
2843 .values
2844 .entry(norm_rk.clone())
2845 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2846 sv.push_override(toml_val, source, file.clone(), None);
2847 }
2848 } else if rule_name.to_ascii_uppercase().starts_with("MD") {
2849 fragment.unknown_keys.push((
2851 format!("[tool.rumdl.{rule_name}]"),
2852 String::new(),
2853 Some(path.to_string()),
2854 ));
2855 }
2856 }
2857 }
2858 }
2859
2860 if let Some(doc_table) = doc.as_table() {
2862 for (key, value) in doc_table.iter() {
2863 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
2864 let norm_rule_name = normalize_key(rule_name);
2865 if norm_rule_name.len() == 5
2866 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2867 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2868 && let Some(rule_table) = value.as_table()
2869 {
2870 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2871 for (rk, rv) in rule_table {
2872 let norm_rk = normalize_key(rk);
2873 let toml_val = rv.clone();
2874 let sv = rule_entry
2875 .values
2876 .entry(norm_rk.clone())
2877 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2878 sv.push_override(toml_val, source, file.clone(), None);
2879 }
2880 } else if rule_name.to_ascii_uppercase().starts_with("MD") {
2881 fragment.unknown_keys.push((
2883 format!("[tool.rumdl.{rule_name}]"),
2884 String::new(),
2885 Some(path.to_string()),
2886 ));
2887 }
2888 }
2889 }
2890 }
2891
2892 let has_any = !fragment.global.enable.value.is_empty()
2894 || !fragment.global.disable.value.is_empty()
2895 || !fragment.global.include.value.is_empty()
2896 || !fragment.global.exclude.value.is_empty()
2897 || !fragment.global.fixable.value.is_empty()
2898 || !fragment.global.unfixable.value.is_empty()
2899 || fragment.global.output_format.is_some()
2900 || fragment.global.cache_dir.is_some()
2901 || !fragment.per_file_ignores.value.is_empty()
2902 || !fragment.rules.is_empty();
2903 if has_any { Ok(Some(fragment)) } else { Ok(None) }
2904}
2905
2906fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
2908 let doc = content
2909 .parse::<DocumentMut>()
2910 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2911 let mut fragment = SourcedConfigFragment::default();
2912 let file = Some(path.to_string());
2914
2915 let all_rules = rules::all_rules(&Config::default());
2917 let registry = RuleRegistry::from_rules(&all_rules);
2918 let known_rule_names: BTreeSet<String> = registry
2919 .rule_names()
2920 .into_iter()
2921 .map(|s| s.to_ascii_uppercase())
2922 .collect();
2923
2924 if let Some(global_item) = doc.get("global")
2926 && let Some(global_table) = global_item.as_table()
2927 {
2928 for (key, value_item) in global_table.iter() {
2929 let norm_key = normalize_key(key);
2930 match norm_key.as_str() {
2931 "enable" | "disable" | "include" | "exclude" => {
2932 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2933 let values: Vec<String> = formatted_array
2935 .iter()
2936 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
2938 .collect();
2939
2940 let final_values = if norm_key == "enable" || norm_key == "disable" {
2942 values.into_iter().map(|s| normalize_key(&s)).collect()
2944 } else {
2945 values
2946 };
2947
2948 match norm_key.as_str() {
2949 "enable" => fragment
2950 .global
2951 .enable
2952 .push_override(final_values, source, file.clone(), None),
2953 "disable" => {
2954 fragment
2955 .global
2956 .disable
2957 .push_override(final_values, source, file.clone(), None)
2958 }
2959 "include" => {
2960 fragment
2961 .global
2962 .include
2963 .push_override(final_values, source, file.clone(), None)
2964 }
2965 "exclude" => {
2966 fragment
2967 .global
2968 .exclude
2969 .push_override(final_values, source, file.clone(), None)
2970 }
2971 _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
2972 }
2973 } else {
2974 log::warn!(
2975 "[WARN] Expected array for global key '{}' in {}, found {}",
2976 key,
2977 path,
2978 value_item.type_name()
2979 );
2980 }
2981 }
2982 "respect_gitignore" | "respect-gitignore" => {
2983 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2985 let val = *formatted_bool.value();
2986 fragment
2987 .global
2988 .respect_gitignore
2989 .push_override(val, source, file.clone(), None);
2990 } else {
2991 log::warn!(
2992 "[WARN] Expected boolean for global key '{}' in {}, found {}",
2993 key,
2994 path,
2995 value_item.type_name()
2996 );
2997 }
2998 }
2999 "force_exclude" | "force-exclude" => {
3000 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
3002 let val = *formatted_bool.value();
3003 fragment
3004 .global
3005 .force_exclude
3006 .push_override(val, source, file.clone(), None);
3007 } else {
3008 log::warn!(
3009 "[WARN] Expected boolean for global key '{}' in {}, found {}",
3010 key,
3011 path,
3012 value_item.type_name()
3013 );
3014 }
3015 }
3016 "line_length" | "line-length" => {
3017 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
3019 let val = *formatted_int.value() as u64;
3020 fragment
3021 .global
3022 .line_length
3023 .push_override(val, source, file.clone(), None);
3024 } else {
3025 log::warn!(
3026 "[WARN] Expected integer for global key '{}' in {}, found {}",
3027 key,
3028 path,
3029 value_item.type_name()
3030 );
3031 }
3032 }
3033 "output_format" | "output-format" => {
3034 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3036 let val = formatted_string.value().clone();
3037 if fragment.global.output_format.is_none() {
3038 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
3039 } else {
3040 fragment.global.output_format.as_mut().unwrap().push_override(
3041 val,
3042 source,
3043 file.clone(),
3044 None,
3045 );
3046 }
3047 } else {
3048 log::warn!(
3049 "[WARN] Expected string for global key '{}' in {}, found {}",
3050 key,
3051 path,
3052 value_item.type_name()
3053 );
3054 }
3055 }
3056 "cache_dir" | "cache-dir" => {
3057 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3059 let val = formatted_string.value().clone();
3060 if fragment.global.cache_dir.is_none() {
3061 fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
3062 } else {
3063 fragment
3064 .global
3065 .cache_dir
3066 .as_mut()
3067 .unwrap()
3068 .push_override(val, source, file.clone(), None);
3069 }
3070 } else {
3071 log::warn!(
3072 "[WARN] Expected string for global key '{}' in {}, found {}",
3073 key,
3074 path,
3075 value_item.type_name()
3076 );
3077 }
3078 }
3079 "fixable" => {
3080 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3081 let values: Vec<String> = formatted_array
3082 .iter()
3083 .filter_map(|item| item.as_str())
3084 .map(normalize_key)
3085 .collect();
3086 fragment
3087 .global
3088 .fixable
3089 .push_override(values, source, file.clone(), None);
3090 } else {
3091 log::warn!(
3092 "[WARN] Expected array for global key '{}' in {}, found {}",
3093 key,
3094 path,
3095 value_item.type_name()
3096 );
3097 }
3098 }
3099 "unfixable" => {
3100 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3101 let values: Vec<String> = formatted_array
3102 .iter()
3103 .filter_map(|item| item.as_str())
3104 .map(normalize_key)
3105 .collect();
3106 fragment
3107 .global
3108 .unfixable
3109 .push_override(values, source, file.clone(), None);
3110 } else {
3111 log::warn!(
3112 "[WARN] Expected array for global key '{}' in {}, found {}",
3113 key,
3114 path,
3115 value_item.type_name()
3116 );
3117 }
3118 }
3119 "flavor" => {
3120 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3121 let val = formatted_string.value();
3122 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
3123 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
3124 } else {
3125 log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
3126 }
3127 } else {
3128 log::warn!(
3129 "[WARN] Expected string for global key '{}' in {}, found {}",
3130 key,
3131 path,
3132 value_item.type_name()
3133 );
3134 }
3135 }
3136 _ => {
3137 fragment
3139 .unknown_keys
3140 .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
3141 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
3142 }
3143 }
3144 }
3145 }
3146
3147 if let Some(per_file_item) = doc.get("per-file-ignores")
3149 && let Some(per_file_table) = per_file_item.as_table()
3150 {
3151 let mut per_file_map = HashMap::new();
3152 for (pattern, value_item) in per_file_table.iter() {
3153 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3154 let rules: Vec<String> = formatted_array
3155 .iter()
3156 .filter_map(|item| item.as_str())
3157 .map(normalize_key)
3158 .collect();
3159 per_file_map.insert(pattern.to_string(), rules);
3160 } else {
3161 let type_name = value_item.type_name();
3162 log::warn!(
3163 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
3164 );
3165 }
3166 }
3167 fragment
3168 .per_file_ignores
3169 .push_override(per_file_map, source, file.clone(), None);
3170 }
3171
3172 for (key, item) in doc.iter() {
3174 let norm_rule_name = key.to_ascii_uppercase();
3175
3176 if key == "global" || key == "per-file-ignores" {
3178 continue;
3179 }
3180
3181 if !known_rule_names.contains(&norm_rule_name) {
3183 if norm_rule_name.starts_with("MD") || key.chars().all(|c| c.is_uppercase() || c.is_numeric()) {
3185 fragment
3186 .unknown_keys
3187 .push((format!("[{key}]"), String::new(), Some(path.to_string())));
3188 }
3189 continue;
3190 }
3191
3192 if let Some(tbl) = item.as_table() {
3193 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
3194 for (rk, rv_item) in tbl.iter() {
3195 let norm_rk = normalize_key(rk);
3196 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
3197 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
3198 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
3199 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
3200 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
3201 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
3202 Some(toml_edit::Value::Array(formatted_array)) => {
3203 let mut values = Vec::new();
3205 for item in formatted_array.iter() {
3206 match item {
3207 toml_edit::Value::String(formatted) => {
3208 values.push(toml::Value::String(formatted.value().clone()))
3209 }
3210 toml_edit::Value::Integer(formatted) => {
3211 values.push(toml::Value::Integer(*formatted.value()))
3212 }
3213 toml_edit::Value::Float(formatted) => {
3214 values.push(toml::Value::Float(*formatted.value()))
3215 }
3216 toml_edit::Value::Boolean(formatted) => {
3217 values.push(toml::Value::Boolean(*formatted.value()))
3218 }
3219 toml_edit::Value::Datetime(formatted) => {
3220 values.push(toml::Value::Datetime(*formatted.value()))
3221 }
3222 _ => {
3223 log::warn!(
3224 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
3225 );
3226 }
3227 }
3228 }
3229 Some(toml::Value::Array(values))
3230 }
3231 Some(toml_edit::Value::InlineTable(_)) => {
3232 log::warn!(
3233 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
3234 );
3235 None
3236 }
3237 None => {
3238 log::warn!(
3239 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
3240 );
3241 None
3242 }
3243 };
3244 if let Some(toml_val) = maybe_toml_val {
3245 let sv = rule_entry
3246 .values
3247 .entry(norm_rk.clone())
3248 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
3249 sv.push_override(toml_val, source, file.clone(), None);
3250 }
3251 }
3252 } else if item.is_value() {
3253 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
3254 }
3255 }
3256
3257 Ok(fragment)
3258}
3259
3260fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
3262 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
3264 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
3265 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
3266}
3267
3268#[cfg(test)]
3269#[path = "config_intelligent_merge_tests.rs"]
3270mod config_intelligent_merge_tests;