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