1use crate::rule::Rule;
6use crate::rules;
7use crate::types::LineLength;
8use log;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::collections::{HashMap, HashSet};
12use std::fmt;
13use std::fs;
14use std::io;
15use std::marker::PhantomData;
16use std::path::Path;
17use std::str::FromStr;
18use toml_edit::DocumentMut;
19
20#[derive(Debug, Clone, Copy, Default)]
27pub struct ConfigLoaded;
28
29#[derive(Debug, Clone, Copy, Default)]
32pub struct ConfigValidated;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema)]
36#[serde(rename_all = "lowercase")]
37pub enum MarkdownFlavor {
38 #[serde(rename = "standard", alias = "none", alias = "")]
40 #[default]
41 Standard,
42 #[serde(rename = "mkdocs")]
44 MkDocs,
45 #[serde(rename = "mdx")]
47 MDX,
48 #[serde(rename = "quarto")]
50 Quarto,
51 }
55
56impl fmt::Display for MarkdownFlavor {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 match self {
59 MarkdownFlavor::Standard => write!(f, "standard"),
60 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
61 MarkdownFlavor::MDX => write!(f, "mdx"),
62 MarkdownFlavor::Quarto => write!(f, "quarto"),
63 }
64 }
65}
66
67impl FromStr for MarkdownFlavor {
68 type Err = String;
69
70 fn from_str(s: &str) -> Result<Self, Self::Err> {
71 match s.to_lowercase().as_str() {
72 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
73 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
74 "mdx" => Ok(MarkdownFlavor::MDX),
75 "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
76 "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
80 _ => Err(format!("Unknown markdown flavor: {s}")),
81 }
82 }
83}
84
85impl MarkdownFlavor {
86 pub fn from_extension(ext: &str) -> Self {
88 match ext.to_lowercase().as_str() {
89 "mdx" => Self::MDX,
90 "qmd" => Self::Quarto,
91 "rmd" => Self::Quarto,
92 _ => Self::Standard,
93 }
94 }
95
96 pub fn from_path(path: &std::path::Path) -> Self {
98 path.extension()
99 .and_then(|e| e.to_str())
100 .map(Self::from_extension)
101 .unwrap_or(Self::Standard)
102 }
103
104 pub fn supports_esm_blocks(self) -> bool {
106 matches!(self, Self::MDX)
107 }
108
109 pub fn supports_jsx(self) -> bool {
111 matches!(self, Self::MDX)
112 }
113
114 pub fn supports_auto_references(self) -> bool {
116 matches!(self, Self::MkDocs)
117 }
118
119 pub fn name(self) -> &'static str {
121 match self {
122 Self::Standard => "Standard",
123 Self::MkDocs => "MkDocs",
124 Self::MDX => "MDX",
125 Self::Quarto => "Quarto",
126 }
127 }
128}
129
130pub fn normalize_key(key: &str) -> String {
132 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
134 key.to_ascii_uppercase()
135 } else {
136 key.replace('_', "-").to_ascii_lowercase()
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
142pub struct RuleConfig {
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub severity: Option<crate::rule::Severity>,
146
147 #[serde(flatten)]
149 #[schemars(schema_with = "arbitrary_value_schema")]
150 pub values: BTreeMap<String, toml::Value>,
151}
152
153fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
155 schemars::json_schema!({
156 "type": "object",
157 "additionalProperties": true
158 })
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
163#[schemars(
164 description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
165)]
166pub struct Config {
167 #[serde(default)]
169 pub global: GlobalConfig,
170
171 #[serde(default, rename = "per-file-ignores")]
174 pub per_file_ignores: HashMap<String, Vec<String>>,
175
176 #[serde(flatten)]
187 pub rules: BTreeMap<String, RuleConfig>,
188
189 #[serde(skip)]
191 pub project_root: Option<std::path::PathBuf>,
192}
193
194impl Config {
195 pub fn is_mkdocs_flavor(&self) -> bool {
197 self.global.flavor == MarkdownFlavor::MkDocs
198 }
199
200 pub fn markdown_flavor(&self) -> MarkdownFlavor {
206 self.global.flavor
207 }
208
209 pub fn is_mkdocs_project(&self) -> bool {
211 self.is_mkdocs_flavor()
212 }
213
214 pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
216 self.rules.get(rule_name).and_then(|r| r.severity)
217 }
218
219 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
222 use globset::{Glob, GlobSetBuilder};
223
224 let mut ignored_rules = HashSet::new();
225
226 if self.per_file_ignores.is_empty() {
227 return ignored_rules;
228 }
229
230 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
233 if let Ok(canonical_path) = file_path.canonicalize() {
234 if let Ok(canonical_root) = root.canonicalize() {
235 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
236 std::borrow::Cow::Owned(relative.to_path_buf())
237 } else {
238 std::borrow::Cow::Borrowed(file_path)
239 }
240 } else {
241 std::borrow::Cow::Borrowed(file_path)
242 }
243 } else {
244 std::borrow::Cow::Borrowed(file_path)
245 }
246 } else {
247 std::borrow::Cow::Borrowed(file_path)
248 };
249
250 let mut builder = GlobSetBuilder::new();
252 let mut pattern_to_rules: Vec<(usize, &Vec<String>)> = Vec::new();
253
254 for (idx, (pattern, rules)) in self.per_file_ignores.iter().enumerate() {
255 if let Ok(glob) = Glob::new(pattern) {
256 builder.add(glob);
257 pattern_to_rules.push((idx, rules));
258 } else {
259 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
260 }
261 }
262
263 let globset = match builder.build() {
264 Ok(gs) => gs,
265 Err(e) => {
266 log::error!("Failed to build globset for per-file-ignores: {e}");
267 return ignored_rules;
268 }
269 };
270
271 for match_idx in globset.matches(path_for_matching.as_ref()) {
273 if let Some((_, rules)) = pattern_to_rules.get(match_idx) {
274 for rule in rules.iter() {
275 ignored_rules.insert(normalize_key(rule));
277 }
278 }
279 }
280
281 ignored_rules
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
287#[serde(default, rename_all = "kebab-case")]
288pub struct GlobalConfig {
289 #[serde(default)]
291 pub enable: Vec<String>,
292
293 #[serde(default)]
295 pub disable: Vec<String>,
296
297 #[serde(default)]
299 pub exclude: Vec<String>,
300
301 #[serde(default)]
303 pub include: Vec<String>,
304
305 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
307 pub respect_gitignore: bool,
308
309 #[serde(default, alias = "line_length")]
311 pub line_length: LineLength,
312
313 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
315 pub output_format: Option<String>,
316
317 #[serde(default)]
320 pub fixable: Vec<String>,
321
322 #[serde(default)]
325 pub unfixable: Vec<String>,
326
327 #[serde(default)]
330 pub flavor: MarkdownFlavor,
331
332 #[serde(default, alias = "force_exclude")]
337 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
338 pub force_exclude: bool,
339
340 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
343 pub cache_dir: Option<String>,
344
345 #[serde(default = "default_true")]
348 pub cache: bool,
349}
350
351fn default_respect_gitignore() -> bool {
352 true
353}
354
355fn default_true() -> bool {
356 true
357}
358
359impl Default for GlobalConfig {
361 #[allow(deprecated)]
362 fn default() -> Self {
363 Self {
364 enable: Vec::new(),
365 disable: Vec::new(),
366 exclude: Vec::new(),
367 include: Vec::new(),
368 respect_gitignore: true,
369 line_length: LineLength::default(),
370 output_format: None,
371 fixable: Vec::new(),
372 unfixable: Vec::new(),
373 flavor: MarkdownFlavor::default(),
374 force_exclude: false,
375 cache_dir: None,
376 cache: true,
377 }
378 }
379}
380
381const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
382 ".markdownlint.json",
383 ".markdownlint.jsonc",
384 ".markdownlint.yaml",
385 ".markdownlint.yml",
386 "markdownlint.json",
387 "markdownlint.jsonc",
388 "markdownlint.yaml",
389 "markdownlint.yml",
390];
391
392pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
394 if Path::new(path).exists() {
396 return Err(ConfigError::FileExists { path: path.to_string() });
397 }
398
399 let default_config = r#"# rumdl configuration file
401
402# Global configuration options
403[global]
404# List of rules to disable (uncomment and modify as needed)
405# disable = ["MD013", "MD033"]
406
407# List of rules to enable exclusively (if provided, only these rules will run)
408# enable = ["MD001", "MD003", "MD004"]
409
410# List of file/directory patterns to include for linting (if provided, only these will be linted)
411# include = [
412# "docs/*.md",
413# "src/**/*.md",
414# "README.md"
415# ]
416
417# List of file/directory patterns to exclude from linting
418exclude = [
419 # Common directories to exclude
420 ".git",
421 ".github",
422 "node_modules",
423 "vendor",
424 "dist",
425 "build",
426
427 # Specific files or patterns
428 "CHANGELOG.md",
429 "LICENSE.md",
430]
431
432# Respect .gitignore files when scanning directories (default: true)
433respect-gitignore = true
434
435# Markdown flavor/dialect (uncomment to enable)
436# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
437# flavor = "mkdocs"
438
439# Rule-specific configurations (uncomment and modify as needed)
440
441# [MD003]
442# style = "atx" # Heading style (atx, atx_closed, setext)
443
444# [MD004]
445# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
446
447# [MD007]
448# indent = 4 # Unordered list indentation
449
450# [MD013]
451# line-length = 100 # Line length
452# code-blocks = false # Exclude code blocks from line length check
453# tables = false # Exclude tables from line length check
454# headings = true # Include headings in line length check
455
456# [MD044]
457# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
458# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
459"#;
460
461 match fs::write(path, default_config) {
463 Ok(_) => Ok(()),
464 Err(err) => Err(ConfigError::IoError {
465 source: err,
466 path: path.to_string(),
467 }),
468 }
469}
470
471#[derive(Debug, thiserror::Error)]
473pub enum ConfigError {
474 #[error("Failed to read config file at {path}: {source}")]
476 IoError { source: io::Error, path: String },
477
478 #[error("Failed to parse config: {0}")]
480 ParseError(String),
481
482 #[error("Configuration file already exists at {path}")]
484 FileExists { path: String },
485}
486
487pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
491 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
494
495 let key_variants = [
497 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
502
503 for variant in &key_variants {
505 if let Some(value) = rule_config.values.get(variant)
506 && let Ok(result) = T::deserialize(value.clone())
507 {
508 return Some(result);
509 }
510 }
511
512 None
513}
514
515pub fn generate_pyproject_config() -> String {
517 let config_content = r#"
518[tool.rumdl]
519# Global configuration options
520line-length = 100
521disable = []
522exclude = [
523 # Common directories to exclude
524 ".git",
525 ".github",
526 "node_modules",
527 "vendor",
528 "dist",
529 "build",
530]
531respect-gitignore = true
532
533# Rule-specific configurations (uncomment and modify as needed)
534
535# [tool.rumdl.MD003]
536# style = "atx" # Heading style (atx, atx_closed, setext)
537
538# [tool.rumdl.MD004]
539# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
540
541# [tool.rumdl.MD007]
542# indent = 4 # Unordered list indentation
543
544# [tool.rumdl.MD013]
545# line-length = 100 # Line length
546# code-blocks = false # Exclude code blocks from line length check
547# tables = false # Exclude tables from line length check
548# headings = true # Include headings in line length check
549
550# [tool.rumdl.MD044]
551# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
552# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
553"#;
554
555 config_content.to_string()
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use std::fs;
562 use tempfile::tempdir;
563
564 #[test]
565 fn test_flavor_loading() {
566 let temp_dir = tempdir().unwrap();
567 let config_path = temp_dir.path().join(".rumdl.toml");
568 let config_content = r#"
569[global]
570flavor = "mkdocs"
571disable = ["MD001"]
572"#;
573 fs::write(&config_path, config_content).unwrap();
574
575 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
577 let config: Config = sourced.into_validated_unchecked().into();
578
579 assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
581 assert!(config.is_mkdocs_flavor());
582 assert!(config.is_mkdocs_project()); assert_eq!(config.global.disable, vec!["MD001".to_string()]);
584 }
585
586 #[test]
587 fn test_pyproject_toml_root_level_config() {
588 let temp_dir = tempdir().unwrap();
589 let config_path = temp_dir.path().join("pyproject.toml");
590
591 let content = r#"
593[tool.rumdl]
594line-length = 120
595disable = ["MD033"]
596enable = ["MD001", "MD004"]
597include = ["docs/*.md"]
598exclude = ["node_modules"]
599respect-gitignore = true
600 "#;
601
602 fs::write(&config_path, content).unwrap();
603
604 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
606 let config: Config = sourced.into_validated_unchecked().into(); assert_eq!(config.global.disable, vec!["MD033".to_string()]);
610 assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
611 assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
613 assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
614 assert!(config.global.respect_gitignore);
615
616 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
618 assert_eq!(line_length, Some(120));
619 }
620
621 #[test]
622 fn test_pyproject_toml_snake_case_and_kebab_case() {
623 let temp_dir = tempdir().unwrap();
624 let config_path = temp_dir.path().join("pyproject.toml");
625
626 let content = r#"
628[tool.rumdl]
629line-length = 150
630respect_gitignore = true
631 "#;
632
633 fs::write(&config_path, content).unwrap();
634
635 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
637 let config: Config = sourced.into_validated_unchecked().into(); assert!(config.global.respect_gitignore);
641 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
642 assert_eq!(line_length, Some(150));
643 }
644
645 #[test]
646 fn test_md013_key_normalization_in_rumdl_toml() {
647 let temp_dir = tempdir().unwrap();
648 let config_path = temp_dir.path().join(".rumdl.toml");
649 let config_content = r#"
650[MD013]
651line_length = 111
652line-length = 222
653"#;
654 fs::write(&config_path, config_content).unwrap();
655 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
657 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
658 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
660 assert_eq!(keys, vec!["line-length"]);
661 let val = &rule_cfg.values["line-length"].value;
662 assert_eq!(val.as_integer(), Some(222));
663 let config: Config = sourced.clone().into_validated_unchecked().into();
665 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
666 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
667 assert_eq!(v1, Some(222));
668 assert_eq!(v2, Some(222));
669 }
670
671 #[test]
672 fn test_md013_section_case_insensitivity() {
673 let temp_dir = tempdir().unwrap();
674 let config_path = temp_dir.path().join(".rumdl.toml");
675 let config_content = r#"
676[md013]
677line-length = 101
678
679[Md013]
680line-length = 102
681
682[MD013]
683line-length = 103
684"#;
685 fs::write(&config_path, config_content).unwrap();
686 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
688 let config: Config = sourced.clone().into_validated_unchecked().into();
689 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
691 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
692 assert_eq!(keys, vec!["line-length"]);
693 let val = &rule_cfg.values["line-length"].value;
694 assert_eq!(val.as_integer(), Some(103));
695 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
696 assert_eq!(v, Some(103));
697 }
698
699 #[test]
700 fn test_md013_key_snake_and_kebab_case() {
701 let temp_dir = tempdir().unwrap();
702 let config_path = temp_dir.path().join(".rumdl.toml");
703 let config_content = r#"
704[MD013]
705line_length = 201
706line-length = 202
707"#;
708 fs::write(&config_path, config_content).unwrap();
709 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
711 let config: Config = sourced.clone().into_validated_unchecked().into();
712 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
713 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
714 assert_eq!(keys, vec!["line-length"]);
715 let val = &rule_cfg.values["line-length"].value;
716 assert_eq!(val.as_integer(), Some(202));
717 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
718 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
719 assert_eq!(v1, Some(202));
720 assert_eq!(v2, Some(202));
721 }
722
723 #[test]
724 fn test_unknown_rule_section_is_ignored() {
725 let temp_dir = tempdir().unwrap();
726 let config_path = temp_dir.path().join(".rumdl.toml");
727 let config_content = r#"
728[MD999]
729foo = 1
730bar = 2
731[MD013]
732line-length = 303
733"#;
734 fs::write(&config_path, config_content).unwrap();
735 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
737 let config: Config = sourced.clone().into_validated_unchecked().into();
738 assert!(!sourced.rules.contains_key("MD999"));
740 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
742 assert_eq!(v, Some(303));
743 }
744
745 #[test]
746 fn test_invalid_toml_syntax() {
747 let temp_dir = tempdir().unwrap();
748 let config_path = temp_dir.path().join(".rumdl.toml");
749
750 let config_content = r#"
752[MD013]
753line-length = "unclosed string
754"#;
755 fs::write(&config_path, config_content).unwrap();
756
757 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
758 assert!(result.is_err());
759 match result.unwrap_err() {
760 ConfigError::ParseError(msg) => {
761 assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
763 }
764 _ => panic!("Expected ParseError"),
765 }
766 }
767
768 #[test]
769 fn test_wrong_type_for_config_value() {
770 let temp_dir = tempdir().unwrap();
771 let config_path = temp_dir.path().join(".rumdl.toml");
772
773 let config_content = r#"
775[MD013]
776line-length = "not a number"
777"#;
778 fs::write(&config_path, config_content).unwrap();
779
780 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
781 let config: Config = sourced.into_validated_unchecked().into();
782
783 let rule_config = config.rules.get("MD013").unwrap();
785 let value = rule_config.values.get("line-length").unwrap();
786 assert!(matches!(value, toml::Value::String(_)));
787 }
788
789 #[test]
790 fn test_empty_config_file() {
791 let temp_dir = tempdir().unwrap();
792 let config_path = temp_dir.path().join(".rumdl.toml");
793
794 fs::write(&config_path, "").unwrap();
796
797 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
798 let config: Config = sourced.into_validated_unchecked().into();
799
800 assert_eq!(config.global.line_length.get(), 80);
802 assert!(config.global.respect_gitignore);
803 assert!(config.rules.is_empty());
804 }
805
806 #[test]
807 fn test_malformed_pyproject_toml() {
808 let temp_dir = tempdir().unwrap();
809 let config_path = temp_dir.path().join("pyproject.toml");
810
811 let content = r#"
813[tool.rumdl
814line-length = 120
815"#;
816 fs::write(&config_path, content).unwrap();
817
818 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
819 assert!(result.is_err());
820 }
821
822 #[test]
823 fn test_conflicting_config_values() {
824 let temp_dir = tempdir().unwrap();
825 let config_path = temp_dir.path().join(".rumdl.toml");
826
827 let config_content = r#"
829[global]
830enable = ["MD013"]
831disable = ["MD013"]
832"#;
833 fs::write(&config_path, config_content).unwrap();
834
835 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
836 let config: Config = sourced.into_validated_unchecked().into();
837
838 assert!(config.global.enable.contains(&"MD013".to_string()));
840 assert!(!config.global.disable.contains(&"MD013".to_string()));
841 }
842
843 #[test]
844 fn test_invalid_rule_names() {
845 let temp_dir = tempdir().unwrap();
846 let config_path = temp_dir.path().join(".rumdl.toml");
847
848 let config_content = r#"
849[global]
850enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
851disable = ["MD-001", "MD_002"]
852"#;
853 fs::write(&config_path, config_content).unwrap();
854
855 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
856 let config: Config = sourced.into_validated_unchecked().into();
857
858 assert_eq!(config.global.enable.len(), 4);
860 assert_eq!(config.global.disable.len(), 2);
861 }
862
863 #[test]
864 fn test_deeply_nested_config() {
865 let temp_dir = tempdir().unwrap();
866 let config_path = temp_dir.path().join(".rumdl.toml");
867
868 let config_content = r#"
870[MD013]
871line-length = 100
872[MD013.nested]
873value = 42
874"#;
875 fs::write(&config_path, config_content).unwrap();
876
877 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
878 let config: Config = sourced.into_validated_unchecked().into();
879
880 let rule_config = config.rules.get("MD013").unwrap();
881 assert_eq!(
882 rule_config.values.get("line-length").unwrap(),
883 &toml::Value::Integer(100)
884 );
885 assert!(!rule_config.values.contains_key("nested"));
887 }
888
889 #[test]
890 fn test_unicode_in_config() {
891 let temp_dir = tempdir().unwrap();
892 let config_path = temp_dir.path().join(".rumdl.toml");
893
894 let config_content = r#"
895[global]
896include = ["文档/*.md", "ドã‚ュメント/*.md"]
897exclude = ["测试/*", "🚀/*"]
898
899[MD013]
900line-length = 80
901message = "行太长了 🚨"
902"#;
903 fs::write(&config_path, config_content).unwrap();
904
905 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
906 let config: Config = sourced.into_validated_unchecked().into();
907
908 assert_eq!(config.global.include.len(), 2);
909 assert_eq!(config.global.exclude.len(), 2);
910 assert!(config.global.include[0].contains("文档"));
911 assert!(config.global.exclude[1].contains("🚀"));
912
913 let rule_config = config.rules.get("MD013").unwrap();
914 let message = rule_config.values.get("message").unwrap();
915 if let toml::Value::String(s) = message {
916 assert!(s.contains("行太长了"));
917 assert!(s.contains("🚨"));
918 }
919 }
920
921 #[test]
922 fn test_extremely_long_values() {
923 let temp_dir = tempdir().unwrap();
924 let config_path = temp_dir.path().join(".rumdl.toml");
925
926 let long_string = "a".repeat(10000);
927 let config_content = format!(
928 r#"
929[global]
930exclude = ["{long_string}"]
931
932[MD013]
933line-length = 999999999
934"#
935 );
936
937 fs::write(&config_path, config_content).unwrap();
938
939 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
940 let config: Config = sourced.into_validated_unchecked().into();
941
942 assert_eq!(config.global.exclude[0].len(), 10000);
943 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
944 assert_eq!(line_length, Some(999999999));
945 }
946
947 #[test]
948 fn test_config_with_comments() {
949 let temp_dir = tempdir().unwrap();
950 let config_path = temp_dir.path().join(".rumdl.toml");
951
952 let config_content = r#"
953[global]
954# This is a comment
955enable = ["MD001"] # Enable MD001
956# disable = ["MD002"] # This is commented out
957
958[MD013] # Line length rule
959line-length = 100 # Set to 100 characters
960# ignored = true # This setting is commented out
961"#;
962 fs::write(&config_path, config_content).unwrap();
963
964 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
965 let config: Config = sourced.into_validated_unchecked().into();
966
967 assert_eq!(config.global.enable, vec!["MD001"]);
968 assert!(config.global.disable.is_empty()); let rule_config = config.rules.get("MD013").unwrap();
971 assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
973 }
974
975 #[test]
976 fn test_arrays_in_rule_config() {
977 let temp_dir = tempdir().unwrap();
978 let config_path = temp_dir.path().join(".rumdl.toml");
979
980 let config_content = r#"
981[MD003]
982levels = [1, 2, 3]
983tags = ["important", "critical"]
984mixed = [1, "two", true]
985"#;
986 fs::write(&config_path, config_content).unwrap();
987
988 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
989 let config: Config = sourced.into_validated_unchecked().into();
990
991 let rule_config = config.rules.get("MD003").expect("MD003 config should exist");
993
994 assert!(rule_config.values.contains_key("levels"));
996 assert!(rule_config.values.contains_key("tags"));
997 assert!(rule_config.values.contains_key("mixed"));
998
999 if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
1001 assert_eq!(levels.len(), 3);
1002 assert_eq!(levels[0], toml::Value::Integer(1));
1003 assert_eq!(levels[1], toml::Value::Integer(2));
1004 assert_eq!(levels[2], toml::Value::Integer(3));
1005 } else {
1006 panic!("levels should be an array");
1007 }
1008
1009 if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
1010 assert_eq!(tags.len(), 2);
1011 assert_eq!(tags[0], toml::Value::String("important".to_string()));
1012 assert_eq!(tags[1], toml::Value::String("critical".to_string()));
1013 } else {
1014 panic!("tags should be an array");
1015 }
1016
1017 if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
1018 assert_eq!(mixed.len(), 3);
1019 assert_eq!(mixed[0], toml::Value::Integer(1));
1020 assert_eq!(mixed[1], toml::Value::String("two".to_string()));
1021 assert_eq!(mixed[2], toml::Value::Boolean(true));
1022 } else {
1023 panic!("mixed should be an array");
1024 }
1025 }
1026
1027 #[test]
1028 fn test_normalize_key_edge_cases() {
1029 assert_eq!(normalize_key("MD001"), "MD001");
1031 assert_eq!(normalize_key("md001"), "MD001");
1032 assert_eq!(normalize_key("Md001"), "MD001");
1033 assert_eq!(normalize_key("mD001"), "MD001");
1034
1035 assert_eq!(normalize_key("line_length"), "line-length");
1037 assert_eq!(normalize_key("line-length"), "line-length");
1038 assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
1039 assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
1040
1041 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(""), "");
1048 assert_eq!(normalize_key("_"), "-");
1049 assert_eq!(normalize_key("___"), "---");
1050 }
1051
1052 #[test]
1053 fn test_missing_config_file() {
1054 let temp_dir = tempdir().unwrap();
1055 let config_path = temp_dir.path().join("nonexistent.toml");
1056
1057 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1058 assert!(result.is_err());
1059 match result.unwrap_err() {
1060 ConfigError::IoError { .. } => {}
1061 _ => panic!("Expected IoError for missing file"),
1062 }
1063 }
1064
1065 #[test]
1066 #[cfg(unix)]
1067 fn test_permission_denied_config() {
1068 use std::os::unix::fs::PermissionsExt;
1069
1070 let temp_dir = tempdir().unwrap();
1071 let config_path = temp_dir.path().join(".rumdl.toml");
1072
1073 fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
1074
1075 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1077 perms.set_mode(0o000);
1078 fs::set_permissions(&config_path, perms).unwrap();
1079
1080 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1081
1082 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1084 perms.set_mode(0o644);
1085 fs::set_permissions(&config_path, perms).unwrap();
1086
1087 assert!(result.is_err());
1088 match result.unwrap_err() {
1089 ConfigError::IoError { .. } => {}
1090 _ => panic!("Expected IoError for permission denied"),
1091 }
1092 }
1093
1094 #[test]
1095 fn test_circular_reference_detection() {
1096 let temp_dir = tempdir().unwrap();
1099 let config_path = temp_dir.path().join(".rumdl.toml");
1100
1101 let mut config_content = String::from("[MD001]\n");
1102 for i in 0..100 {
1103 config_content.push_str(&format!("key{i} = {i}\n"));
1104 }
1105
1106 fs::write(&config_path, config_content).unwrap();
1107
1108 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1109 let config: Config = sourced.into_validated_unchecked().into();
1110
1111 let rule_config = config.rules.get("MD001").unwrap();
1112 assert_eq!(rule_config.values.len(), 100);
1113 }
1114
1115 #[test]
1116 fn test_special_toml_values() {
1117 let temp_dir = tempdir().unwrap();
1118 let config_path = temp_dir.path().join(".rumdl.toml");
1119
1120 let config_content = r#"
1121[MD001]
1122infinity = inf
1123neg_infinity = -inf
1124not_a_number = nan
1125datetime = 1979-05-27T07:32:00Z
1126local_date = 1979-05-27
1127local_time = 07:32:00
1128"#;
1129 fs::write(&config_path, config_content).unwrap();
1130
1131 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1132 let config: Config = sourced.into_validated_unchecked().into();
1133
1134 if let Some(rule_config) = config.rules.get("MD001") {
1136 if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
1138 assert!(f.is_infinite() && f.is_sign_positive());
1139 }
1140 if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
1141 assert!(f.is_infinite() && f.is_sign_negative());
1142 }
1143 if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
1144 assert!(f.is_nan());
1145 }
1146
1147 if let Some(val) = rule_config.values.get("datetime") {
1149 assert!(matches!(val, toml::Value::Datetime(_)));
1150 }
1151 }
1153 }
1154
1155 #[test]
1156 fn test_default_config_passes_validation() {
1157 use crate::rules;
1158
1159 let temp_dir = tempdir().unwrap();
1160 let config_path = temp_dir.path().join(".rumdl.toml");
1161 let config_path_str = config_path.to_str().unwrap();
1162
1163 create_default_config(config_path_str).unwrap();
1165
1166 let sourced =
1168 SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1169
1170 let all_rules = rules::all_rules(&Config::default());
1172 let registry = RuleRegistry::from_rules(&all_rules);
1173
1174 let warnings = validate_config_sourced(&sourced, ®istry);
1176
1177 if !warnings.is_empty() {
1179 for warning in &warnings {
1180 eprintln!("Config validation warning: {}", warning.message);
1181 if let Some(rule) = &warning.rule {
1182 eprintln!(" Rule: {rule}");
1183 }
1184 if let Some(key) = &warning.key {
1185 eprintln!(" Key: {key}");
1186 }
1187 }
1188 }
1189 assert!(
1190 warnings.is_empty(),
1191 "Default config from rumdl init should pass validation without warnings"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_per_file_ignores_config_parsing() {
1197 let temp_dir = tempdir().unwrap();
1198 let config_path = temp_dir.path().join(".rumdl.toml");
1199 let config_content = r#"
1200[per-file-ignores]
1201"README.md" = ["MD033"]
1202"docs/**/*.md" = ["MD013", "MD033"]
1203"test/*.md" = ["MD041"]
1204"#;
1205 fs::write(&config_path, config_content).unwrap();
1206
1207 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1208 let config: Config = sourced.into_validated_unchecked().into();
1209
1210 assert_eq!(config.per_file_ignores.len(), 3);
1212 assert_eq!(
1213 config.per_file_ignores.get("README.md"),
1214 Some(&vec!["MD033".to_string()])
1215 );
1216 assert_eq!(
1217 config.per_file_ignores.get("docs/**/*.md"),
1218 Some(&vec!["MD013".to_string(), "MD033".to_string()])
1219 );
1220 assert_eq!(
1221 config.per_file_ignores.get("test/*.md"),
1222 Some(&vec!["MD041".to_string()])
1223 );
1224 }
1225
1226 #[test]
1227 fn test_per_file_ignores_glob_matching() {
1228 use std::path::PathBuf;
1229
1230 let temp_dir = tempdir().unwrap();
1231 let config_path = temp_dir.path().join(".rumdl.toml");
1232 let config_content = r#"
1233[per-file-ignores]
1234"README.md" = ["MD033"]
1235"docs/**/*.md" = ["MD013"]
1236"**/test_*.md" = ["MD041"]
1237"#;
1238 fs::write(&config_path, config_content).unwrap();
1239
1240 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1241 let config: Config = sourced.into_validated_unchecked().into();
1242
1243 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1245 assert!(ignored.contains("MD033"));
1246 assert_eq!(ignored.len(), 1);
1247
1248 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1250 assert!(ignored.contains("MD013"));
1251 assert_eq!(ignored.len(), 1);
1252
1253 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
1255 assert!(ignored.contains("MD041"));
1256 assert_eq!(ignored.len(), 1);
1257
1258 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
1260 assert!(ignored.is_empty());
1261 }
1262
1263 #[test]
1264 fn test_per_file_ignores_pyproject_toml() {
1265 let temp_dir = tempdir().unwrap();
1266 let config_path = temp_dir.path().join("pyproject.toml");
1267 let config_content = r#"
1268[tool.rumdl]
1269[tool.rumdl.per-file-ignores]
1270"README.md" = ["MD033", "MD013"]
1271"generated/*.md" = ["MD041"]
1272"#;
1273 fs::write(&config_path, config_content).unwrap();
1274
1275 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1276 let config: Config = sourced.into_validated_unchecked().into();
1277
1278 assert_eq!(config.per_file_ignores.len(), 2);
1280 assert_eq!(
1281 config.per_file_ignores.get("README.md"),
1282 Some(&vec!["MD033".to_string(), "MD013".to_string()])
1283 );
1284 assert_eq!(
1285 config.per_file_ignores.get("generated/*.md"),
1286 Some(&vec!["MD041".to_string()])
1287 );
1288 }
1289
1290 #[test]
1291 fn test_per_file_ignores_multiple_patterns_match() {
1292 use std::path::PathBuf;
1293
1294 let temp_dir = tempdir().unwrap();
1295 let config_path = temp_dir.path().join(".rumdl.toml");
1296 let config_content = r#"
1297[per-file-ignores]
1298"docs/**/*.md" = ["MD013"]
1299"**/api/*.md" = ["MD033"]
1300"docs/api/overview.md" = ["MD041"]
1301"#;
1302 fs::write(&config_path, config_content).unwrap();
1303
1304 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1305 let config: Config = sourced.into_validated_unchecked().into();
1306
1307 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1309 assert_eq!(ignored.len(), 3);
1310 assert!(ignored.contains("MD013"));
1311 assert!(ignored.contains("MD033"));
1312 assert!(ignored.contains("MD041"));
1313 }
1314
1315 #[test]
1316 fn test_per_file_ignores_rule_name_normalization() {
1317 use std::path::PathBuf;
1318
1319 let temp_dir = tempdir().unwrap();
1320 let config_path = temp_dir.path().join(".rumdl.toml");
1321 let config_content = r#"
1322[per-file-ignores]
1323"README.md" = ["md033", "MD013", "Md041"]
1324"#;
1325 fs::write(&config_path, config_content).unwrap();
1326
1327 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1328 let config: Config = sourced.into_validated_unchecked().into();
1329
1330 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1332 assert_eq!(ignored.len(), 3);
1333 assert!(ignored.contains("MD033"));
1334 assert!(ignored.contains("MD013"));
1335 assert!(ignored.contains("MD041"));
1336 }
1337
1338 #[test]
1339 fn test_per_file_ignores_invalid_glob_pattern() {
1340 use std::path::PathBuf;
1341
1342 let temp_dir = tempdir().unwrap();
1343 let config_path = temp_dir.path().join(".rumdl.toml");
1344 let config_content = r#"
1345[per-file-ignores]
1346"[invalid" = ["MD033"]
1347"valid/*.md" = ["MD013"]
1348"#;
1349 fs::write(&config_path, config_content).unwrap();
1350
1351 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1352 let config: Config = sourced.into_validated_unchecked().into();
1353
1354 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
1356 assert!(ignored.contains("MD013"));
1357
1358 let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
1360 assert!(ignored2.is_empty());
1361 }
1362
1363 #[test]
1364 fn test_per_file_ignores_empty_section() {
1365 use std::path::PathBuf;
1366
1367 let temp_dir = tempdir().unwrap();
1368 let config_path = temp_dir.path().join(".rumdl.toml");
1369 let config_content = r#"
1370[global]
1371disable = ["MD001"]
1372
1373[per-file-ignores]
1374"#;
1375 fs::write(&config_path, config_content).unwrap();
1376
1377 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1378 let config: Config = sourced.into_validated_unchecked().into();
1379
1380 assert_eq!(config.per_file_ignores.len(), 0);
1382 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1383 assert!(ignored.is_empty());
1384 }
1385
1386 #[test]
1387 fn test_per_file_ignores_with_underscores_in_pyproject() {
1388 let temp_dir = tempdir().unwrap();
1389 let config_path = temp_dir.path().join("pyproject.toml");
1390 let config_content = r#"
1391[tool.rumdl]
1392[tool.rumdl.per_file_ignores]
1393"README.md" = ["MD033"]
1394"#;
1395 fs::write(&config_path, config_content).unwrap();
1396
1397 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1398 let config: Config = sourced.into_validated_unchecked().into();
1399
1400 assert_eq!(config.per_file_ignores.len(), 1);
1402 assert_eq!(
1403 config.per_file_ignores.get("README.md"),
1404 Some(&vec!["MD033".to_string()])
1405 );
1406 }
1407
1408 #[test]
1409 fn test_per_file_ignores_absolute_path_matching() {
1410 use std::path::PathBuf;
1413
1414 let temp_dir = tempdir().unwrap();
1415 let config_path = temp_dir.path().join(".rumdl.toml");
1416
1417 let github_dir = temp_dir.path().join(".github");
1419 fs::create_dir_all(&github_dir).unwrap();
1420 let test_file = github_dir.join("pull_request_template.md");
1421 fs::write(&test_file, "Test content").unwrap();
1422
1423 let config_content = r#"
1424[per-file-ignores]
1425".github/pull_request_template.md" = ["MD041"]
1426"docs/**/*.md" = ["MD013"]
1427"#;
1428 fs::write(&config_path, config_content).unwrap();
1429
1430 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1431 let config: Config = sourced.into_validated_unchecked().into();
1432
1433 let absolute_path = test_file.canonicalize().unwrap();
1435 let ignored = config.get_ignored_rules_for_file(&absolute_path);
1436 assert!(
1437 ignored.contains("MD041"),
1438 "Should match absolute path {absolute_path:?} against relative pattern"
1439 );
1440 assert_eq!(ignored.len(), 1);
1441
1442 let relative_path = PathBuf::from(".github/pull_request_template.md");
1444 let ignored = config.get_ignored_rules_for_file(&relative_path);
1445 assert!(ignored.contains("MD041"), "Should match relative path");
1446 }
1447
1448 #[test]
1449 fn test_generate_json_schema() {
1450 use schemars::schema_for;
1451 use std::env;
1452
1453 let schema = schema_for!(Config);
1454 let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
1455
1456 if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
1458 let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
1459 fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
1460 println!("Schema written to: {}", schema_path.display());
1461 }
1462
1463 assert!(schema_json.contains("\"title\": \"Config\""));
1465 assert!(schema_json.contains("\"global\""));
1466 assert!(schema_json.contains("\"per-file-ignores\""));
1467 }
1468
1469 #[test]
1470 fn test_project_config_is_standalone() {
1471 let temp_dir = tempdir().unwrap();
1474
1475 let user_config_dir = temp_dir.path().join("user_config");
1478 let rumdl_config_dir = user_config_dir.join("rumdl");
1479 fs::create_dir_all(&rumdl_config_dir).unwrap();
1480 let user_config_path = rumdl_config_dir.join("rumdl.toml");
1481
1482 let user_config_content = r#"
1484[global]
1485disable = ["MD013", "MD041"]
1486line-length = 100
1487"#;
1488 fs::write(&user_config_path, user_config_content).unwrap();
1489
1490 let project_config_path = temp_dir.path().join("project").join("pyproject.toml");
1492 fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1493 let project_config_content = r#"
1494[tool.rumdl]
1495enable = ["MD001"]
1496"#;
1497 fs::write(&project_config_path, project_config_content).unwrap();
1498
1499 let sourced = SourcedConfig::load_with_discovery_impl(
1501 Some(project_config_path.to_str().unwrap()),
1502 None,
1503 false,
1504 Some(&user_config_dir),
1505 )
1506 .unwrap();
1507
1508 let config: Config = sourced.into_validated_unchecked().into();
1509
1510 assert!(
1512 !config.global.disable.contains(&"MD013".to_string()),
1513 "User config should NOT be merged with project config"
1514 );
1515 assert!(
1516 !config.global.disable.contains(&"MD041".to_string()),
1517 "User config should NOT be merged with project config"
1518 );
1519
1520 assert!(
1522 config.global.enable.contains(&"MD001".to_string()),
1523 "Project config enabled rules should be applied"
1524 );
1525 }
1526
1527 #[test]
1528 fn test_user_config_as_fallback_when_no_project_config() {
1529 use std::env;
1531
1532 let temp_dir = tempdir().unwrap();
1533 let original_dir = env::current_dir().unwrap();
1534
1535 let user_config_dir = temp_dir.path().join("user_config");
1537 let rumdl_config_dir = user_config_dir.join("rumdl");
1538 fs::create_dir_all(&rumdl_config_dir).unwrap();
1539 let user_config_path = rumdl_config_dir.join("rumdl.toml");
1540
1541 let user_config_content = r#"
1543[global]
1544disable = ["MD013", "MD041"]
1545line-length = 88
1546"#;
1547 fs::write(&user_config_path, user_config_content).unwrap();
1548
1549 let project_dir = temp_dir.path().join("project_no_config");
1551 fs::create_dir_all(&project_dir).unwrap();
1552
1553 env::set_current_dir(&project_dir).unwrap();
1555
1556 let sourced = SourcedConfig::load_with_discovery_impl(None, None, false, Some(&user_config_dir)).unwrap();
1558
1559 let config: Config = sourced.into_validated_unchecked().into();
1560
1561 assert!(
1563 config.global.disable.contains(&"MD013".to_string()),
1564 "User config should be loaded as fallback when no project config"
1565 );
1566 assert!(
1567 config.global.disable.contains(&"MD041".to_string()),
1568 "User config should be loaded as fallback when no project config"
1569 );
1570 assert_eq!(
1571 config.global.line_length.get(),
1572 88,
1573 "User config line-length should be loaded as fallback"
1574 );
1575
1576 env::set_current_dir(original_dir).unwrap();
1577 }
1578
1579 #[test]
1580 fn test_typestate_validate_method() {
1581 use tempfile::tempdir;
1582
1583 let temp_dir = tempdir().expect("Failed to create temporary directory");
1584 let config_path = temp_dir.path().join("test.toml");
1585
1586 let config_content = r#"
1588[global]
1589enable = ["MD001"]
1590
1591[MD013]
1592line_length = 80
1593unknown_option = true
1594"#;
1595 std::fs::write(&config_path, config_content).expect("Failed to write config");
1596
1597 let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
1599 .expect("Should load config");
1600
1601 let default_config = Config::default();
1603 let all_rules = crate::rules::all_rules(&default_config);
1604 let registry = RuleRegistry::from_rules(&all_rules);
1605
1606 let validated = loaded.validate(®istry).expect("Should validate config");
1608
1609 let has_unknown_option_warning = validated
1612 .validation_warnings
1613 .iter()
1614 .any(|w| w.message.contains("unknown_option") || w.message.contains("Unknown option"));
1615
1616 if !has_unknown_option_warning {
1618 for w in &validated.validation_warnings {
1619 eprintln!("Warning: {}", w.message);
1620 }
1621 }
1622 assert!(
1623 has_unknown_option_warning,
1624 "Should have warning for unknown option. Got {} warnings: {:?}",
1625 validated.validation_warnings.len(),
1626 validated
1627 .validation_warnings
1628 .iter()
1629 .map(|w| &w.message)
1630 .collect::<Vec<_>>()
1631 );
1632
1633 let config: Config = validated.into();
1635
1636 assert!(config.global.enable.contains(&"MD001".to_string()));
1638 }
1639
1640 #[test]
1641 fn test_typestate_validate_into_convenience_method() {
1642 use tempfile::tempdir;
1643
1644 let temp_dir = tempdir().expect("Failed to create temporary directory");
1645 let config_path = temp_dir.path().join("test.toml");
1646
1647 let config_content = r#"
1648[global]
1649enable = ["MD022"]
1650
1651[MD022]
1652lines_above = 2
1653"#;
1654 std::fs::write(&config_path, config_content).expect("Failed to write config");
1655
1656 let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
1657 .expect("Should load config");
1658
1659 let default_config = Config::default();
1660 let all_rules = crate::rules::all_rules(&default_config);
1661 let registry = RuleRegistry::from_rules(&all_rules);
1662
1663 let (config, warnings) = loaded.validate_into(®istry).expect("Should validate and convert");
1665
1666 assert!(warnings.is_empty(), "Should have no warnings for valid config");
1668
1669 assert!(config.global.enable.contains(&"MD022".to_string()));
1671 }
1672
1673 #[test]
1674 fn test_resolve_rule_name_canonical() {
1675 assert_eq!(resolve_rule_name("MD001"), "MD001");
1677 assert_eq!(resolve_rule_name("MD013"), "MD013");
1678 assert_eq!(resolve_rule_name("MD069"), "MD069");
1679 }
1680
1681 #[test]
1682 fn test_resolve_rule_name_aliases() {
1683 assert_eq!(resolve_rule_name("heading-increment"), "MD001");
1685 assert_eq!(resolve_rule_name("line-length"), "MD013");
1686 assert_eq!(resolve_rule_name("no-bare-urls"), "MD034");
1687 assert_eq!(resolve_rule_name("ul-style"), "MD004");
1688 }
1689
1690 #[test]
1691 fn test_resolve_rule_name_case_insensitive() {
1692 assert_eq!(resolve_rule_name("HEADING-INCREMENT"), "MD001");
1694 assert_eq!(resolve_rule_name("Heading-Increment"), "MD001");
1695 assert_eq!(resolve_rule_name("md001"), "MD001");
1696 assert_eq!(resolve_rule_name("MD001"), "MD001");
1697 }
1698
1699 #[test]
1700 fn test_resolve_rule_name_underscore_to_hyphen() {
1701 assert_eq!(resolve_rule_name("heading_increment"), "MD001");
1703 assert_eq!(resolve_rule_name("line_length"), "MD013");
1704 assert_eq!(resolve_rule_name("no_bare_urls"), "MD034");
1705 }
1706
1707 #[test]
1708 fn test_resolve_rule_name_unknown() {
1709 assert_eq!(resolve_rule_name("custom-rule"), "custom-rule");
1711 assert_eq!(resolve_rule_name("CUSTOM_RULE"), "custom-rule");
1712 assert_eq!(resolve_rule_name("md999"), "MD999"); }
1714
1715 #[test]
1716 fn test_resolve_rule_names_basic() {
1717 let result = resolve_rule_names("MD001,line-length,heading-increment");
1718 assert!(result.contains("MD001"));
1719 assert!(result.contains("MD013")); assert_eq!(result.len(), 2);
1722 }
1723
1724 #[test]
1725 fn test_resolve_rule_names_with_whitespace() {
1726 let result = resolve_rule_names(" MD001 , line-length , MD034 ");
1727 assert!(result.contains("MD001"));
1728 assert!(result.contains("MD013"));
1729 assert!(result.contains("MD034"));
1730 assert_eq!(result.len(), 3);
1731 }
1732
1733 #[test]
1734 fn test_resolve_rule_names_empty_entries() {
1735 let result = resolve_rule_names("MD001,,MD013,");
1736 assert!(result.contains("MD001"));
1737 assert!(result.contains("MD013"));
1738 assert_eq!(result.len(), 2);
1739 }
1740
1741 #[test]
1742 fn test_resolve_rule_names_empty_string() {
1743 let result = resolve_rule_names("");
1744 assert!(result.is_empty());
1745 }
1746
1747 #[test]
1748 fn test_resolve_rule_names_mixed() {
1749 let result = resolve_rule_names("MD001,line-length,custom-rule");
1751 assert!(result.contains("MD001"));
1752 assert!(result.contains("MD013"));
1753 assert!(result.contains("custom-rule"));
1754 assert_eq!(result.len(), 3);
1755 }
1756
1757 #[test]
1762 fn test_is_valid_rule_name_canonical() {
1763 assert!(is_valid_rule_name("MD001"));
1765 assert!(is_valid_rule_name("MD013"));
1766 assert!(is_valid_rule_name("MD041"));
1767 assert!(is_valid_rule_name("MD069"));
1768
1769 assert!(is_valid_rule_name("md001"));
1771 assert!(is_valid_rule_name("Md001"));
1772 assert!(is_valid_rule_name("mD001"));
1773 }
1774
1775 #[test]
1776 fn test_is_valid_rule_name_aliases() {
1777 assert!(is_valid_rule_name("line-length"));
1779 assert!(is_valid_rule_name("heading-increment"));
1780 assert!(is_valid_rule_name("no-bare-urls"));
1781 assert!(is_valid_rule_name("ul-style"));
1782
1783 assert!(is_valid_rule_name("LINE-LENGTH"));
1785 assert!(is_valid_rule_name("Line-Length"));
1786
1787 assert!(is_valid_rule_name("line_length"));
1789 assert!(is_valid_rule_name("ul_style"));
1790 }
1791
1792 #[test]
1793 fn test_is_valid_rule_name_special_all() {
1794 assert!(is_valid_rule_name("all"));
1795 assert!(is_valid_rule_name("ALL"));
1796 assert!(is_valid_rule_name("All"));
1797 assert!(is_valid_rule_name("aLl"));
1798 }
1799
1800 #[test]
1801 fn test_is_valid_rule_name_invalid() {
1802 assert!(!is_valid_rule_name("MD000"));
1804 assert!(!is_valid_rule_name("MD002")); assert!(!is_valid_rule_name("MD006")); assert!(!is_valid_rule_name("MD999"));
1807 assert!(!is_valid_rule_name("MD100"));
1808
1809 assert!(!is_valid_rule_name(""));
1811 assert!(!is_valid_rule_name("INVALID"));
1812 assert!(!is_valid_rule_name("not-a-rule"));
1813 assert!(!is_valid_rule_name("random-text"));
1814 assert!(!is_valid_rule_name("abc"));
1815
1816 assert!(!is_valid_rule_name("MD"));
1818 assert!(!is_valid_rule_name("MD1"));
1819 assert!(!is_valid_rule_name("MD12"));
1820 }
1821
1822 #[test]
1823 fn test_validate_cli_rule_names_valid() {
1824 let warnings = validate_cli_rule_names(
1826 Some("MD001,MD013"),
1827 Some("line-length"),
1828 Some("heading-increment"),
1829 Some("all"),
1830 );
1831 assert!(warnings.is_empty(), "Expected no warnings for valid rules");
1832 }
1833
1834 #[test]
1835 fn test_validate_cli_rule_names_invalid() {
1836 let warnings = validate_cli_rule_names(Some("abc"), None, None, None);
1838 assert_eq!(warnings.len(), 1);
1839 assert!(warnings[0].message.contains("Unknown rule in --enable: abc"));
1840
1841 let warnings = validate_cli_rule_names(None, Some("xyz"), None, None);
1843 assert_eq!(warnings.len(), 1);
1844 assert!(warnings[0].message.contains("Unknown rule in --disable: xyz"));
1845
1846 let warnings = validate_cli_rule_names(None, None, Some("nonexistent"), None);
1848 assert_eq!(warnings.len(), 1);
1849 assert!(
1850 warnings[0]
1851 .message
1852 .contains("Unknown rule in --extend-enable: nonexistent")
1853 );
1854
1855 let warnings = validate_cli_rule_names(None, None, None, Some("fake-rule"));
1857 assert_eq!(warnings.len(), 1);
1858 assert!(
1859 warnings[0]
1860 .message
1861 .contains("Unknown rule in --extend-disable: fake-rule")
1862 );
1863 }
1864
1865 #[test]
1866 fn test_validate_cli_rule_names_mixed() {
1867 let warnings = validate_cli_rule_names(Some("MD001,abc,MD003"), None, None, None);
1869 assert_eq!(warnings.len(), 1);
1870 assert!(warnings[0].message.contains("abc"));
1871 }
1872
1873 #[test]
1874 fn test_validate_cli_rule_names_suggestions() {
1875 let warnings = validate_cli_rule_names(Some("line-lenght"), None, None, None);
1877 assert_eq!(warnings.len(), 1);
1878 assert!(warnings[0].message.contains("did you mean"));
1879 assert!(warnings[0].message.contains("line-length"));
1880 }
1881
1882 #[test]
1883 fn test_validate_cli_rule_names_none() {
1884 let warnings = validate_cli_rule_names(None, None, None, None);
1886 assert!(warnings.is_empty());
1887 }
1888
1889 #[test]
1890 fn test_validate_cli_rule_names_empty_string() {
1891 let warnings = validate_cli_rule_names(Some(""), Some(""), Some(""), Some(""));
1893 assert!(warnings.is_empty());
1894 }
1895
1896 #[test]
1897 fn test_validate_cli_rule_names_whitespace() {
1898 let warnings = validate_cli_rule_names(Some(" MD001 , MD013 "), None, None, None);
1900 assert!(warnings.is_empty(), "Whitespace should be trimmed");
1901 }
1902
1903 #[test]
1904 fn test_all_implemented_rules_have_aliases() {
1905 let config = crate::config::Config::default();
1912 let all_rules = crate::rules::all_rules(&config);
1913
1914 let mut missing_rules = Vec::new();
1915 for rule in &all_rules {
1916 let rule_name = rule.name();
1917 if resolve_rule_name_alias(rule_name).is_none() {
1919 missing_rules.push(rule_name.to_string());
1920 }
1921 }
1922
1923 assert!(
1924 missing_rules.is_empty(),
1925 "The following rules are missing from RULE_ALIAS_MAP: {:?}\n\
1926 Add entries like:\n\
1927 - Canonical: \"{}\" => \"{}\"\n\
1928 - Alias: \"RULE-NAME-HERE\" => \"{}\"",
1929 missing_rules,
1930 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
1931 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
1932 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
1933 );
1934 }
1935}
1936
1937#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1946pub enum ConfigSource {
1947 Default,
1949 UserConfig,
1951 PyprojectToml,
1953 ProjectConfig,
1955 Cli,
1957}
1958
1959#[derive(Debug, Clone)]
1960pub struct ConfigOverride<T> {
1961 pub value: T,
1962 pub source: ConfigSource,
1963 pub file: Option<String>,
1964 pub line: Option<usize>,
1965}
1966
1967#[derive(Debug, Clone)]
1968pub struct SourcedValue<T> {
1969 pub value: T,
1970 pub source: ConfigSource,
1971 pub overrides: Vec<ConfigOverride<T>>,
1972}
1973
1974impl<T: Clone> SourcedValue<T> {
1975 pub fn new(value: T, source: ConfigSource) -> Self {
1976 Self {
1977 value: value.clone(),
1978 source,
1979 overrides: vec![ConfigOverride {
1980 value,
1981 source,
1982 file: None,
1983 line: None,
1984 }],
1985 }
1986 }
1987
1988 pub fn merge_override(
1992 &mut self,
1993 new_value: T,
1994 new_source: ConfigSource,
1995 new_file: Option<String>,
1996 new_line: Option<usize>,
1997 ) {
1998 fn source_precedence(src: ConfigSource) -> u8 {
2000 match src {
2001 ConfigSource::Default => 0,
2002 ConfigSource::UserConfig => 1,
2003 ConfigSource::PyprojectToml => 2,
2004 ConfigSource::ProjectConfig => 3,
2005 ConfigSource::Cli => 4,
2006 }
2007 }
2008
2009 if source_precedence(new_source) >= source_precedence(self.source) {
2010 self.value = new_value.clone();
2011 self.source = new_source;
2012 self.overrides.push(ConfigOverride {
2013 value: new_value,
2014 source: new_source,
2015 file: new_file,
2016 line: new_line,
2017 });
2018 }
2019 }
2020
2021 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
2022 self.value = value.clone();
2025 self.source = source;
2026 self.overrides.push(ConfigOverride {
2027 value,
2028 source,
2029 file,
2030 line,
2031 });
2032 }
2033}
2034
2035impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
2036 pub fn merge_union(
2039 &mut self,
2040 new_value: Vec<T>,
2041 new_source: ConfigSource,
2042 new_file: Option<String>,
2043 new_line: Option<usize>,
2044 ) {
2045 fn source_precedence(src: ConfigSource) -> u8 {
2046 match src {
2047 ConfigSource::Default => 0,
2048 ConfigSource::UserConfig => 1,
2049 ConfigSource::PyprojectToml => 2,
2050 ConfigSource::ProjectConfig => 3,
2051 ConfigSource::Cli => 4,
2052 }
2053 }
2054
2055 if source_precedence(new_source) >= source_precedence(self.source) {
2056 let mut combined = self.value.clone();
2058 for item in new_value.iter() {
2059 if !combined.contains(item) {
2060 combined.push(item.clone());
2061 }
2062 }
2063
2064 self.value = combined;
2065 self.source = new_source;
2066 self.overrides.push(ConfigOverride {
2067 value: new_value,
2068 source: new_source,
2069 file: new_file,
2070 line: new_line,
2071 });
2072 }
2073 }
2074}
2075
2076#[derive(Debug, Clone)]
2077pub struct SourcedGlobalConfig {
2078 pub enable: SourcedValue<Vec<String>>,
2079 pub disable: SourcedValue<Vec<String>>,
2080 pub exclude: SourcedValue<Vec<String>>,
2081 pub include: SourcedValue<Vec<String>>,
2082 pub respect_gitignore: SourcedValue<bool>,
2083 pub line_length: SourcedValue<LineLength>,
2084 pub output_format: Option<SourcedValue<String>>,
2085 pub fixable: SourcedValue<Vec<String>>,
2086 pub unfixable: SourcedValue<Vec<String>>,
2087 pub flavor: SourcedValue<MarkdownFlavor>,
2088 pub force_exclude: SourcedValue<bool>,
2089 pub cache_dir: Option<SourcedValue<String>>,
2090 pub cache: SourcedValue<bool>,
2091}
2092
2093impl Default for SourcedGlobalConfig {
2094 fn default() -> Self {
2095 SourcedGlobalConfig {
2096 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2097 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2098 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
2099 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
2100 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
2101 line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
2102 output_format: None,
2103 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2104 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2105 flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
2106 force_exclude: SourcedValue::new(false, ConfigSource::Default),
2107 cache_dir: None,
2108 cache: SourcedValue::new(true, ConfigSource::Default),
2109 }
2110 }
2111}
2112
2113#[derive(Debug, Default, Clone)]
2114pub struct SourcedRuleConfig {
2115 pub severity: Option<SourcedValue<crate::rule::Severity>>,
2116 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
2117}
2118
2119#[derive(Debug, Clone)]
2122pub struct SourcedConfigFragment {
2123 pub global: SourcedGlobalConfig,
2124 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2125 pub rules: BTreeMap<String, SourcedRuleConfig>,
2126 pub unknown_keys: Vec<(String, String, Option<String>)>, }
2129
2130impl Default for SourcedConfigFragment {
2131 fn default() -> Self {
2132 Self {
2133 global: SourcedGlobalConfig::default(),
2134 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2135 rules: BTreeMap::new(),
2136 unknown_keys: Vec::new(),
2137 }
2138 }
2139}
2140
2141#[derive(Debug, Clone)]
2159pub struct SourcedConfig<State = ConfigLoaded> {
2160 pub global: SourcedGlobalConfig,
2161 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2162 pub rules: BTreeMap<String, SourcedRuleConfig>,
2163 pub loaded_files: Vec<String>,
2164 pub unknown_keys: Vec<(String, String, Option<String>)>, pub project_root: Option<std::path::PathBuf>,
2167 pub validation_warnings: Vec<ConfigValidationWarning>,
2169 _state: PhantomData<State>,
2171}
2172
2173impl Default for SourcedConfig<ConfigLoaded> {
2174 fn default() -> Self {
2175 Self {
2176 global: SourcedGlobalConfig::default(),
2177 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2178 rules: BTreeMap::new(),
2179 loaded_files: Vec::new(),
2180 unknown_keys: Vec::new(),
2181 project_root: None,
2182 validation_warnings: Vec::new(),
2183 _state: PhantomData,
2184 }
2185 }
2186}
2187
2188impl SourcedConfig<ConfigLoaded> {
2189 fn merge(&mut self, fragment: SourcedConfigFragment) {
2192 self.global.enable.merge_override(
2195 fragment.global.enable.value,
2196 fragment.global.enable.source,
2197 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
2198 fragment.global.enable.overrides.first().and_then(|o| o.line),
2199 );
2200
2201 self.global.disable.merge_union(
2203 fragment.global.disable.value,
2204 fragment.global.disable.source,
2205 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
2206 fragment.global.disable.overrides.first().and_then(|o| o.line),
2207 );
2208
2209 self.global
2212 .disable
2213 .value
2214 .retain(|rule| !self.global.enable.value.contains(rule));
2215 self.global.include.merge_override(
2216 fragment.global.include.value,
2217 fragment.global.include.source,
2218 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
2219 fragment.global.include.overrides.first().and_then(|o| o.line),
2220 );
2221 self.global.exclude.merge_override(
2222 fragment.global.exclude.value,
2223 fragment.global.exclude.source,
2224 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
2225 fragment.global.exclude.overrides.first().and_then(|o| o.line),
2226 );
2227 self.global.respect_gitignore.merge_override(
2228 fragment.global.respect_gitignore.value,
2229 fragment.global.respect_gitignore.source,
2230 fragment
2231 .global
2232 .respect_gitignore
2233 .overrides
2234 .first()
2235 .and_then(|o| o.file.clone()),
2236 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
2237 );
2238 self.global.line_length.merge_override(
2239 fragment.global.line_length.value,
2240 fragment.global.line_length.source,
2241 fragment
2242 .global
2243 .line_length
2244 .overrides
2245 .first()
2246 .and_then(|o| o.file.clone()),
2247 fragment.global.line_length.overrides.first().and_then(|o| o.line),
2248 );
2249 self.global.fixable.merge_override(
2250 fragment.global.fixable.value,
2251 fragment.global.fixable.source,
2252 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
2253 fragment.global.fixable.overrides.first().and_then(|o| o.line),
2254 );
2255 self.global.unfixable.merge_override(
2256 fragment.global.unfixable.value,
2257 fragment.global.unfixable.source,
2258 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
2259 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
2260 );
2261
2262 self.global.flavor.merge_override(
2264 fragment.global.flavor.value,
2265 fragment.global.flavor.source,
2266 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
2267 fragment.global.flavor.overrides.first().and_then(|o| o.line),
2268 );
2269
2270 self.global.force_exclude.merge_override(
2272 fragment.global.force_exclude.value,
2273 fragment.global.force_exclude.source,
2274 fragment
2275 .global
2276 .force_exclude
2277 .overrides
2278 .first()
2279 .and_then(|o| o.file.clone()),
2280 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
2281 );
2282
2283 if let Some(output_format_fragment) = fragment.global.output_format {
2285 if let Some(ref mut output_format) = self.global.output_format {
2286 output_format.merge_override(
2287 output_format_fragment.value,
2288 output_format_fragment.source,
2289 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
2290 output_format_fragment.overrides.first().and_then(|o| o.line),
2291 );
2292 } else {
2293 self.global.output_format = Some(output_format_fragment);
2294 }
2295 }
2296
2297 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
2299 if let Some(ref mut cache_dir) = self.global.cache_dir {
2300 cache_dir.merge_override(
2301 cache_dir_fragment.value,
2302 cache_dir_fragment.source,
2303 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
2304 cache_dir_fragment.overrides.first().and_then(|o| o.line),
2305 );
2306 } else {
2307 self.global.cache_dir = Some(cache_dir_fragment);
2308 }
2309 }
2310
2311 if fragment.global.cache.source != ConfigSource::Default {
2313 self.global.cache.merge_override(
2314 fragment.global.cache.value,
2315 fragment.global.cache.source,
2316 fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
2317 fragment.global.cache.overrides.first().and_then(|o| o.line),
2318 );
2319 }
2320
2321 self.per_file_ignores.merge_override(
2323 fragment.per_file_ignores.value,
2324 fragment.per_file_ignores.source,
2325 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
2326 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
2327 );
2328
2329 for (rule_name, rule_fragment) in fragment.rules {
2331 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
2333
2334 if let Some(severity_fragment) = rule_fragment.severity {
2336 if let Some(ref mut existing_severity) = rule_entry.severity {
2337 existing_severity.merge_override(
2338 severity_fragment.value,
2339 severity_fragment.source,
2340 severity_fragment.overrides.first().and_then(|o| o.file.clone()),
2341 severity_fragment.overrides.first().and_then(|o| o.line),
2342 );
2343 } else {
2344 rule_entry.severity = Some(severity_fragment);
2345 }
2346 }
2347
2348 for (key, sourced_value_fragment) in rule_fragment.values {
2350 let sv_entry = rule_entry
2351 .values
2352 .entry(key.clone())
2353 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
2354 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
2355 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
2356 sv_entry.merge_override(
2357 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
2362 }
2363 }
2364
2365 for (section, key, file_path) in fragment.unknown_keys {
2367 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
2369 self.unknown_keys.push((section, key, file_path));
2370 }
2371 }
2372 }
2373
2374 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
2376 Self::load_with_discovery(config_path, cli_overrides, false)
2377 }
2378
2379 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
2382 let mut current = if start_dir.is_relative() {
2384 std::env::current_dir()
2385 .map(|cwd| cwd.join(start_dir))
2386 .unwrap_or_else(|_| start_dir.to_path_buf())
2387 } else {
2388 start_dir.to_path_buf()
2389 };
2390 const MAX_DEPTH: usize = 100;
2391
2392 for _ in 0..MAX_DEPTH {
2393 if current.join(".git").exists() {
2394 log::debug!("[rumdl-config] Found .git at: {}", current.display());
2395 return current;
2396 }
2397
2398 match current.parent() {
2399 Some(parent) => current = parent.to_path_buf(),
2400 None => break,
2401 }
2402 }
2403
2404 log::debug!(
2406 "[rumdl-config] No .git found, using config location as project root: {}",
2407 start_dir.display()
2408 );
2409 start_dir.to_path_buf()
2410 }
2411
2412 fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
2418 use std::env;
2419
2420 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
2421 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
2424 Ok(dir) => dir,
2425 Err(e) => {
2426 log::debug!("[rumdl-config] Failed to get current directory: {e}");
2427 return None;
2428 }
2429 };
2430
2431 let mut current_dir = start_dir.clone();
2432 let mut depth = 0;
2433 let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2434
2435 loop {
2436 if depth >= MAX_DEPTH {
2437 log::debug!("[rumdl-config] Maximum traversal depth reached");
2438 break;
2439 }
2440
2441 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
2442
2443 if found_config.is_none() {
2445 for config_name in CONFIG_FILES {
2446 let config_path = current_dir.join(config_name);
2447
2448 if config_path.exists() {
2449 if *config_name == "pyproject.toml" {
2451 if let Ok(content) = std::fs::read_to_string(&config_path) {
2452 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
2453 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
2454 found_config = Some((config_path.clone(), current_dir.clone()));
2456 break;
2457 }
2458 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
2459 continue;
2460 }
2461 } else {
2462 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
2463 found_config = Some((config_path.clone(), current_dir.clone()));
2465 break;
2466 }
2467 }
2468 }
2469 }
2470
2471 if current_dir.join(".git").exists() {
2473 log::debug!("[rumdl-config] Stopping at .git directory");
2474 break;
2475 }
2476
2477 match current_dir.parent() {
2479 Some(parent) => {
2480 current_dir = parent.to_owned();
2481 depth += 1;
2482 }
2483 None => {
2484 log::debug!("[rumdl-config] Reached filesystem root");
2485 break;
2486 }
2487 }
2488 }
2489
2490 if let Some((config_path, config_dir)) = found_config {
2492 let project_root = Self::find_project_root_from(&config_dir);
2493 return Some((config_path, project_root));
2494 }
2495
2496 None
2497 }
2498
2499 fn discover_markdownlint_config_upward() -> Option<std::path::PathBuf> {
2503 use std::env;
2504
2505 const MAX_DEPTH: usize = 100;
2506
2507 let start_dir = match env::current_dir() {
2508 Ok(dir) => dir,
2509 Err(e) => {
2510 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
2511 return None;
2512 }
2513 };
2514
2515 let mut current_dir = start_dir.clone();
2516 let mut depth = 0;
2517
2518 loop {
2519 if depth >= MAX_DEPTH {
2520 log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
2521 break;
2522 }
2523
2524 log::debug!(
2525 "[rumdl-config] Searching for markdownlint config in: {}",
2526 current_dir.display()
2527 );
2528
2529 for config_name in MARKDOWNLINT_CONFIG_FILES {
2531 let config_path = current_dir.join(config_name);
2532 if config_path.exists() {
2533 log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
2534 return Some(config_path);
2535 }
2536 }
2537
2538 if current_dir.join(".git").exists() {
2540 log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
2541 break;
2542 }
2543
2544 match current_dir.parent() {
2546 Some(parent) => {
2547 current_dir = parent.to_owned();
2548 depth += 1;
2549 }
2550 None => {
2551 log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
2552 break;
2553 }
2554 }
2555 }
2556
2557 None
2558 }
2559
2560 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
2562 let config_dir = config_dir.join("rumdl");
2563
2564 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
2566
2567 log::debug!(
2568 "[rumdl-config] Checking for user configuration in: {}",
2569 config_dir.display()
2570 );
2571
2572 for filename in USER_CONFIG_FILES {
2573 let config_path = config_dir.join(filename);
2574
2575 if config_path.exists() {
2576 if *filename == "pyproject.toml" {
2578 if let Ok(content) = std::fs::read_to_string(&config_path) {
2579 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
2580 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
2581 return Some(config_path);
2582 }
2583 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
2584 continue;
2585 }
2586 } else {
2587 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
2588 return Some(config_path);
2589 }
2590 }
2591 }
2592
2593 log::debug!(
2594 "[rumdl-config] No user configuration found in: {}",
2595 config_dir.display()
2596 );
2597 None
2598 }
2599
2600 #[cfg(feature = "native")]
2603 fn user_configuration_path() -> Option<std::path::PathBuf> {
2604 use etcetera::{BaseStrategy, choose_base_strategy};
2605
2606 match choose_base_strategy() {
2607 Ok(strategy) => {
2608 let config_dir = strategy.config_dir();
2609 Self::user_configuration_path_impl(&config_dir)
2610 }
2611 Err(e) => {
2612 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
2613 None
2614 }
2615 }
2616 }
2617
2618 #[cfg(not(feature = "native"))]
2620 fn user_configuration_path() -> Option<std::path::PathBuf> {
2621 None
2622 }
2623
2624 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
2626 let path_obj = Path::new(path);
2627 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
2628 let path_str = path.to_string();
2629
2630 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
2631
2632 if let Some(config_parent) = path_obj.parent() {
2634 let project_root = Self::find_project_root_from(config_parent);
2635 log::debug!(
2636 "[rumdl-config] Project root (from explicit config): {}",
2637 project_root.display()
2638 );
2639 sourced_config.project_root = Some(project_root);
2640 }
2641
2642 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
2644
2645 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
2646 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2647 source: e,
2648 path: path_str.clone(),
2649 })?;
2650 if filename == "pyproject.toml" {
2651 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2652 sourced_config.merge(fragment);
2653 sourced_config.loaded_files.push(path_str);
2654 }
2655 } else {
2656 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2657 sourced_config.merge(fragment);
2658 sourced_config.loaded_files.push(path_str);
2659 }
2660 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
2661 || path_str.ends_with(".json")
2662 || path_str.ends_with(".jsonc")
2663 || path_str.ends_with(".yaml")
2664 || path_str.ends_with(".yml")
2665 {
2666 let fragment = load_from_markdownlint(&path_str)?;
2668 sourced_config.merge(fragment);
2669 sourced_config.loaded_files.push(path_str);
2670 } else {
2671 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2673 source: e,
2674 path: path_str.clone(),
2675 })?;
2676 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2677 sourced_config.merge(fragment);
2678 sourced_config.loaded_files.push(path_str);
2679 }
2680
2681 Ok(())
2682 }
2683
2684 fn load_user_config_as_fallback(
2686 sourced_config: &mut Self,
2687 user_config_dir: Option<&Path>,
2688 ) -> Result<(), ConfigError> {
2689 let user_config_path = if let Some(dir) = user_config_dir {
2690 Self::user_configuration_path_impl(dir)
2691 } else {
2692 Self::user_configuration_path()
2693 };
2694
2695 if let Some(user_config_path) = user_config_path {
2696 let path_str = user_config_path.display().to_string();
2697 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2698
2699 log::debug!("[rumdl-config] Loading user config as fallback: {path_str}");
2700
2701 if filename == "pyproject.toml" {
2702 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2703 source: e,
2704 path: path_str.clone(),
2705 })?;
2706 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2707 sourced_config.merge(fragment);
2708 sourced_config.loaded_files.push(path_str);
2709 }
2710 } else {
2711 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2712 source: e,
2713 path: path_str.clone(),
2714 })?;
2715 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
2716 sourced_config.merge(fragment);
2717 sourced_config.loaded_files.push(path_str);
2718 }
2719 } else {
2720 log::debug!("[rumdl-config] No user configuration file found");
2721 }
2722
2723 Ok(())
2724 }
2725
2726 #[doc(hidden)]
2728 pub fn load_with_discovery_impl(
2729 config_path: Option<&str>,
2730 cli_overrides: Option<&SourcedGlobalConfig>,
2731 skip_auto_discovery: bool,
2732 user_config_dir: Option<&Path>,
2733 ) -> Result<Self, ConfigError> {
2734 use std::env;
2735 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
2736
2737 let mut sourced_config = SourcedConfig::default();
2738
2739 if let Some(path) = config_path {
2752 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
2754 Self::load_explicit_config(&mut sourced_config, path)?;
2755 } else if skip_auto_discovery {
2756 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
2757 } else {
2759 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
2761
2762 if let Some((config_file, project_root)) = Self::discover_config_upward() {
2764 let path_str = config_file.display().to_string();
2766 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
2767
2768 log::debug!("[rumdl-config] Found project config: {path_str}");
2769 log::debug!("[rumdl-config] Project root: {}", project_root.display());
2770
2771 sourced_config.project_root = Some(project_root);
2772
2773 if filename == "pyproject.toml" {
2774 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2775 source: e,
2776 path: path_str.clone(),
2777 })?;
2778 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2779 sourced_config.merge(fragment);
2780 sourced_config.loaded_files.push(path_str);
2781 }
2782 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
2783 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2784 source: e,
2785 path: path_str.clone(),
2786 })?;
2787 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2788 sourced_config.merge(fragment);
2789 sourced_config.loaded_files.push(path_str);
2790 }
2791 } else {
2792 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
2794
2795 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward() {
2796 let path_str = markdownlint_path.display().to_string();
2797 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
2798 match load_from_markdownlint(&path_str) {
2799 Ok(fragment) => {
2800 sourced_config.merge(fragment);
2801 sourced_config.loaded_files.push(path_str);
2802 }
2803 Err(_e) => {
2804 log::debug!("[rumdl-config] Failed to load markdownlint config, trying user config");
2805 Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
2806 }
2807 }
2808 } else {
2809 log::debug!("[rumdl-config] No project config found, using user config as fallback");
2811 Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
2812 }
2813 }
2814 }
2815
2816 if let Some(cli) = cli_overrides {
2818 sourced_config
2819 .global
2820 .enable
2821 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
2822 sourced_config
2823 .global
2824 .disable
2825 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
2826 sourced_config
2827 .global
2828 .exclude
2829 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
2830 sourced_config
2831 .global
2832 .include
2833 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
2834 sourced_config.global.respect_gitignore.merge_override(
2835 cli.respect_gitignore.value,
2836 ConfigSource::Cli,
2837 None,
2838 None,
2839 );
2840 sourced_config
2841 .global
2842 .fixable
2843 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
2844 sourced_config
2845 .global
2846 .unfixable
2847 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
2848 }
2850
2851 Ok(sourced_config)
2854 }
2855
2856 pub fn load_with_discovery(
2859 config_path: Option<&str>,
2860 cli_overrides: Option<&SourcedGlobalConfig>,
2861 skip_auto_discovery: bool,
2862 ) -> Result<Self, ConfigError> {
2863 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
2864 }
2865
2866 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
2880 let warnings = validate_config_sourced_internal(&self, registry);
2881
2882 Ok(SourcedConfig {
2883 global: self.global,
2884 per_file_ignores: self.per_file_ignores,
2885 rules: self.rules,
2886 loaded_files: self.loaded_files,
2887 unknown_keys: self.unknown_keys,
2888 project_root: self.project_root,
2889 validation_warnings: warnings,
2890 _state: PhantomData,
2891 })
2892 }
2893
2894 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
2899 let validated = self.validate(registry)?;
2900 let warnings = validated.validation_warnings.clone();
2901 Ok((validated.into(), warnings))
2902 }
2903
2904 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
2915 SourcedConfig {
2916 global: self.global,
2917 per_file_ignores: self.per_file_ignores,
2918 rules: self.rules,
2919 loaded_files: self.loaded_files,
2920 unknown_keys: self.unknown_keys,
2921 project_root: self.project_root,
2922 validation_warnings: Vec::new(),
2923 _state: PhantomData,
2924 }
2925 }
2926}
2927
2928impl From<SourcedConfig<ConfigValidated>> for Config {
2933 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
2934 let mut rules = BTreeMap::new();
2935 for (rule_name, sourced_rule_cfg) in sourced.rules {
2936 let normalized_rule_name = rule_name.to_ascii_uppercase();
2938 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
2939 let mut values = BTreeMap::new();
2940 for (key, sourced_val) in sourced_rule_cfg.values {
2941 values.insert(key, sourced_val.value);
2942 }
2943 rules.insert(normalized_rule_name, RuleConfig { severity, values });
2944 }
2945 #[allow(deprecated)]
2946 let global = GlobalConfig {
2947 enable: sourced.global.enable.value,
2948 disable: sourced.global.disable.value,
2949 exclude: sourced.global.exclude.value,
2950 include: sourced.global.include.value,
2951 respect_gitignore: sourced.global.respect_gitignore.value,
2952 line_length: sourced.global.line_length.value,
2953 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
2954 fixable: sourced.global.fixable.value,
2955 unfixable: sourced.global.unfixable.value,
2956 flavor: sourced.global.flavor.value,
2957 force_exclude: sourced.global.force_exclude.value,
2958 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
2959 cache: sourced.global.cache.value,
2960 };
2961 Config {
2962 global,
2963 per_file_ignores: sourced.per_file_ignores.value,
2964 rules,
2965 project_root: sourced.project_root,
2966 }
2967 }
2968}
2969
2970pub struct RuleRegistry {
2972 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
2974 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
2976}
2977
2978impl RuleRegistry {
2979 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
2981 let mut rule_schemas = std::collections::BTreeMap::new();
2982 let mut rule_aliases = std::collections::BTreeMap::new();
2983
2984 for rule in rules {
2985 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
2986 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
2988 norm_name
2989 } else {
2990 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
2992 norm_name
2993 };
2994
2995 if let Some(aliases) = rule.config_aliases() {
2997 rule_aliases.insert(norm_name, aliases);
2998 }
2999 }
3000
3001 RuleRegistry {
3002 rule_schemas,
3003 rule_aliases,
3004 }
3005 }
3006
3007 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
3009 self.rule_schemas.keys().cloned().collect()
3010 }
3011
3012 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
3014 self.rule_schemas.get(rule).map(|schema| {
3015 let mut all_keys = std::collections::BTreeSet::new();
3016
3017 all_keys.insert("severity".to_string());
3019
3020 for key in schema.keys() {
3022 all_keys.insert(key.clone());
3023 }
3024
3025 for key in schema.keys() {
3027 all_keys.insert(key.replace('_', "-"));
3029 all_keys.insert(key.replace('-', "_"));
3031 all_keys.insert(normalize_key(key));
3033 }
3034
3035 if let Some(aliases) = self.rule_aliases.get(rule) {
3037 for alias_key in aliases.keys() {
3038 all_keys.insert(alias_key.clone());
3039 all_keys.insert(alias_key.replace('_', "-"));
3041 all_keys.insert(alias_key.replace('-', "_"));
3042 all_keys.insert(normalize_key(alias_key));
3043 }
3044 }
3045
3046 all_keys
3047 })
3048 }
3049
3050 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
3052 if let Some(schema) = self.rule_schemas.get(rule) {
3053 if let Some(aliases) = self.rule_aliases.get(rule)
3055 && let Some(canonical_key) = aliases.get(key)
3056 {
3057 if let Some(value) = schema.get(canonical_key) {
3059 return Some(value);
3060 }
3061 }
3062
3063 if let Some(value) = schema.get(key) {
3065 return Some(value);
3066 }
3067
3068 let key_variants = [
3070 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
3074
3075 for variant in &key_variants {
3076 if let Some(value) = schema.get(variant) {
3077 return Some(value);
3078 }
3079 }
3080 }
3081 None
3082 }
3083
3084 pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
3091 let normalized = normalize_key(name);
3093 if self.rule_schemas.contains_key(&normalized) {
3094 return Some(normalized);
3095 }
3096
3097 resolve_rule_name_alias(name).map(|s| s.to_string())
3099 }
3100}
3101
3102pub static RULE_ALIAS_MAP: phf::Map<&'static str, &'static str> = phf::phf_map! {
3105 "MD001" => "MD001",
3107 "MD003" => "MD003",
3108 "MD004" => "MD004",
3109 "MD005" => "MD005",
3110 "MD007" => "MD007",
3111 "MD009" => "MD009",
3112 "MD010" => "MD010",
3113 "MD011" => "MD011",
3114 "MD012" => "MD012",
3115 "MD013" => "MD013",
3116 "MD014" => "MD014",
3117 "MD018" => "MD018",
3118 "MD019" => "MD019",
3119 "MD020" => "MD020",
3120 "MD021" => "MD021",
3121 "MD022" => "MD022",
3122 "MD023" => "MD023",
3123 "MD024" => "MD024",
3124 "MD025" => "MD025",
3125 "MD026" => "MD026",
3126 "MD027" => "MD027",
3127 "MD028" => "MD028",
3128 "MD029" => "MD029",
3129 "MD030" => "MD030",
3130 "MD031" => "MD031",
3131 "MD032" => "MD032",
3132 "MD033" => "MD033",
3133 "MD034" => "MD034",
3134 "MD035" => "MD035",
3135 "MD036" => "MD036",
3136 "MD037" => "MD037",
3137 "MD038" => "MD038",
3138 "MD039" => "MD039",
3139 "MD040" => "MD040",
3140 "MD041" => "MD041",
3141 "MD042" => "MD042",
3142 "MD043" => "MD043",
3143 "MD044" => "MD044",
3144 "MD045" => "MD045",
3145 "MD046" => "MD046",
3146 "MD047" => "MD047",
3147 "MD048" => "MD048",
3148 "MD049" => "MD049",
3149 "MD050" => "MD050",
3150 "MD051" => "MD051",
3151 "MD052" => "MD052",
3152 "MD053" => "MD053",
3153 "MD054" => "MD054",
3154 "MD055" => "MD055",
3155 "MD056" => "MD056",
3156 "MD057" => "MD057",
3157 "MD058" => "MD058",
3158 "MD059" => "MD059",
3159 "MD060" => "MD060",
3160 "MD061" => "MD061",
3161 "MD062" => "MD062",
3162 "MD063" => "MD063",
3163 "MD064" => "MD064",
3164 "MD065" => "MD065",
3165 "MD066" => "MD066",
3166 "MD067" => "MD067",
3167 "MD068" => "MD068",
3168 "MD069" => "MD069",
3169 "MD070" => "MD070",
3170 "MD071" => "MD071",
3171 "MD072" => "MD072",
3172
3173 "HEADING-INCREMENT" => "MD001",
3175 "HEADING-STYLE" => "MD003",
3176 "UL-STYLE" => "MD004",
3177 "LIST-INDENT" => "MD005",
3178 "UL-INDENT" => "MD007",
3179 "NO-TRAILING-SPACES" => "MD009",
3180 "NO-HARD-TABS" => "MD010",
3181 "NO-REVERSED-LINKS" => "MD011",
3182 "NO-MULTIPLE-BLANKS" => "MD012",
3183 "LINE-LENGTH" => "MD013",
3184 "COMMANDS-SHOW-OUTPUT" => "MD014",
3185 "NO-MISSING-SPACE-ATX" => "MD018",
3186 "NO-MULTIPLE-SPACE-ATX" => "MD019",
3187 "NO-MISSING-SPACE-CLOSED-ATX" => "MD020",
3188 "NO-MULTIPLE-SPACE-CLOSED-ATX" => "MD021",
3189 "BLANKS-AROUND-HEADINGS" => "MD022",
3190 "HEADING-START-LEFT" => "MD023",
3191 "NO-DUPLICATE-HEADING" => "MD024",
3192 "SINGLE-TITLE" => "MD025",
3193 "SINGLE-H1" => "MD025",
3194 "NO-TRAILING-PUNCTUATION" => "MD026",
3195 "NO-MULTIPLE-SPACE-BLOCKQUOTE" => "MD027",
3196 "NO-BLANKS-BLOCKQUOTE" => "MD028",
3197 "OL-PREFIX" => "MD029",
3198 "LIST-MARKER-SPACE" => "MD030",
3199 "BLANKS-AROUND-FENCES" => "MD031",
3200 "BLANKS-AROUND-LISTS" => "MD032",
3201 "NO-INLINE-HTML" => "MD033",
3202 "NO-BARE-URLS" => "MD034",
3203 "HR-STYLE" => "MD035",
3204 "NO-EMPHASIS-AS-HEADING" => "MD036",
3205 "NO-SPACE-IN-EMPHASIS" => "MD037",
3206 "NO-SPACE-IN-CODE" => "MD038",
3207 "NO-SPACE-IN-LINKS" => "MD039",
3208 "FENCED-CODE-LANGUAGE" => "MD040",
3209 "FIRST-LINE-HEADING" => "MD041",
3210 "FIRST-LINE-H1" => "MD041",
3211 "NO-EMPTY-LINKS" => "MD042",
3212 "REQUIRED-HEADINGS" => "MD043",
3213 "PROPER-NAMES" => "MD044",
3214 "NO-ALT-TEXT" => "MD045",
3215 "CODE-BLOCK-STYLE" => "MD046",
3216 "SINGLE-TRAILING-NEWLINE" => "MD047",
3217 "CODE-FENCE-STYLE" => "MD048",
3218 "EMPHASIS-STYLE" => "MD049",
3219 "STRONG-STYLE" => "MD050",
3220 "LINK-FRAGMENTS" => "MD051",
3221 "REFERENCE-LINKS-IMAGES" => "MD052",
3222 "LINK-IMAGE-REFERENCE-DEFINITIONS" => "MD053",
3223 "LINK-IMAGE-STYLE" => "MD054",
3224 "TABLE-PIPE-STYLE" => "MD055",
3225 "TABLE-COLUMN-COUNT" => "MD056",
3226 "EXISTING-RELATIVE-LINKS" => "MD057",
3227 "BLANKS-AROUND-TABLES" => "MD058",
3228 "TABLE-CELL-ALIGNMENT" => "MD059",
3229 "TABLE-FORMAT" => "MD060",
3230 "FORBIDDEN-TERMS" => "MD061",
3231 "LINK-DESTINATION-WHITESPACE" => "MD062",
3232 "HEADING-CAPITALIZATION" => "MD063",
3233 "NO-MULTIPLE-CONSECUTIVE-SPACES" => "MD064",
3234 "BLANKS-AROUND-HORIZONTAL-RULES" => "MD065",
3235 "FOOTNOTE-VALIDATION" => "MD066",
3236 "FOOTNOTE-DEFINITION-ORDER" => "MD067",
3237 "EMPTY-FOOTNOTE-DEFINITION" => "MD068",
3238 "NO-DUPLICATE-LIST-MARKERS" => "MD069",
3239 "NESTED-CODE-FENCE" => "MD070",
3240 "BLANK-LINE-AFTER-FRONTMATTER" => "MD071",
3241 "FRONTMATTER-KEY-SORT" => "MD072",
3242};
3243
3244pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
3248 let normalized_key = key.to_ascii_uppercase().replace('_', "-");
3250
3251 RULE_ALIAS_MAP.get(normalized_key.as_str()).copied()
3253}
3254
3255pub fn resolve_rule_name(name: &str) -> String {
3263 resolve_rule_name_alias(name)
3264 .map(|s| s.to_string())
3265 .unwrap_or_else(|| normalize_key(name))
3266}
3267
3268pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
3272 input
3273 .split(',')
3274 .map(|s| s.trim())
3275 .filter(|s| !s.is_empty())
3276 .map(resolve_rule_name)
3277 .collect()
3278}
3279
3280pub fn validate_cli_rule_names(
3286 enable: Option<&str>,
3287 disable: Option<&str>,
3288 extend_enable: Option<&str>,
3289 extend_disable: Option<&str>,
3290) -> Vec<ConfigValidationWarning> {
3291 let mut warnings = Vec::new();
3292 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
3293
3294 let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
3295 for name in input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
3296 if name.eq_ignore_ascii_case("all") {
3298 continue;
3299 }
3300 if resolve_rule_name_alias(name).is_none() {
3301 let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
3302 let formatted = if suggestion.starts_with("MD") {
3303 suggestion
3304 } else {
3305 suggestion.to_lowercase()
3306 };
3307 format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
3308 } else {
3309 format!("Unknown rule in {flag_name}: {name}")
3310 };
3311 warnings.push(ConfigValidationWarning {
3312 message,
3313 rule: Some(name.to_string()),
3314 key: None,
3315 });
3316 }
3317 }
3318 };
3319
3320 if let Some(e) = enable {
3321 validate_list(e, "--enable", &mut warnings);
3322 }
3323 if let Some(d) = disable {
3324 validate_list(d, "--disable", &mut warnings);
3325 }
3326 if let Some(ee) = extend_enable {
3327 validate_list(ee, "--extend-enable", &mut warnings);
3328 }
3329 if let Some(ed) = extend_disable {
3330 validate_list(ed, "--extend-disable", &mut warnings);
3331 }
3332
3333 warnings
3334}
3335
3336pub fn is_valid_rule_name(name: &str) -> bool {
3340 if name.eq_ignore_ascii_case("all") {
3342 return true;
3343 }
3344 resolve_rule_name_alias(name).is_some()
3345}
3346
3347#[derive(Debug, Clone)]
3349pub struct ConfigValidationWarning {
3350 pub message: String,
3351 pub rule: Option<String>,
3352 pub key: Option<String>,
3353}
3354
3355fn validate_config_sourced_internal<S>(
3358 sourced: &SourcedConfig<S>,
3359 registry: &RuleRegistry,
3360) -> Vec<ConfigValidationWarning> {
3361 let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
3362
3363 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
3365
3366 for rule_name in &sourced.global.enable.value {
3367 if !is_valid_rule_name(rule_name) {
3368 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
3369 let formatted = if suggestion.starts_with("MD") {
3370 suggestion
3371 } else {
3372 suggestion.to_lowercase()
3373 };
3374 format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
3375 } else {
3376 format!("Unknown rule in global.enable: {rule_name}")
3377 };
3378 warnings.push(ConfigValidationWarning {
3379 message,
3380 rule: Some(rule_name.clone()),
3381 key: None,
3382 });
3383 }
3384 }
3385
3386 for rule_name in &sourced.global.disable.value {
3387 if !is_valid_rule_name(rule_name) {
3388 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
3389 let formatted = if suggestion.starts_with("MD") {
3390 suggestion
3391 } else {
3392 suggestion.to_lowercase()
3393 };
3394 format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
3395 } else {
3396 format!("Unknown rule in global.disable: {rule_name}")
3397 };
3398 warnings.push(ConfigValidationWarning {
3399 message,
3400 rule: Some(rule_name.clone()),
3401 key: None,
3402 });
3403 }
3404 }
3405
3406 warnings
3407}
3408
3409fn validate_config_sourced_impl(
3411 rules: &BTreeMap<String, SourcedRuleConfig>,
3412 unknown_keys: &[(String, String, Option<String>)],
3413 registry: &RuleRegistry,
3414) -> Vec<ConfigValidationWarning> {
3415 let mut warnings = Vec::new();
3416 let known_rules = registry.rule_names();
3417 for rule in rules.keys() {
3419 if !known_rules.contains(rule) {
3420 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
3422 let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
3423 let formatted_suggestion = if suggestion.starts_with("MD") {
3425 suggestion
3426 } else {
3427 suggestion.to_lowercase()
3428 };
3429 format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
3430 } else {
3431 format!("Unknown rule in config: {rule}")
3432 };
3433 warnings.push(ConfigValidationWarning {
3434 message,
3435 rule: Some(rule.clone()),
3436 key: None,
3437 });
3438 }
3439 }
3440 for (rule, rule_cfg) in rules {
3442 if let Some(valid_keys) = registry.config_keys_for(rule) {
3443 for key in rule_cfg.values.keys() {
3444 if !valid_keys.contains(key) {
3445 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
3446 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
3447 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
3448 } else {
3449 format!("Unknown option for rule {rule}: {key}")
3450 };
3451 warnings.push(ConfigValidationWarning {
3452 message,
3453 rule: Some(rule.clone()),
3454 key: Some(key.clone()),
3455 });
3456 } else {
3457 if let Some(expected) = registry.expected_value_for(rule, key) {
3459 let actual = &rule_cfg.values[key].value;
3460 if !toml_value_type_matches(expected, actual) {
3461 warnings.push(ConfigValidationWarning {
3462 message: format!(
3463 "Type mismatch for {}.{}: expected {}, got {}",
3464 rule,
3465 key,
3466 toml_type_name(expected),
3467 toml_type_name(actual)
3468 ),
3469 rule: Some(rule.clone()),
3470 key: Some(key.clone()),
3471 });
3472 }
3473 }
3474 }
3475 }
3476 }
3477 }
3478 let known_global_keys = vec![
3480 "enable".to_string(),
3481 "disable".to_string(),
3482 "include".to_string(),
3483 "exclude".to_string(),
3484 "respect-gitignore".to_string(),
3485 "line-length".to_string(),
3486 "fixable".to_string(),
3487 "unfixable".to_string(),
3488 "flavor".to_string(),
3489 "force-exclude".to_string(),
3490 "output-format".to_string(),
3491 "cache-dir".to_string(),
3492 "cache".to_string(),
3493 ];
3494
3495 for (section, key, file_path) in unknown_keys {
3496 if section.contains("[global]") || section.contains("[tool.rumdl]") {
3497 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
3498 if let Some(path) = file_path {
3499 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
3500 } else {
3501 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
3502 }
3503 } else if let Some(path) = file_path {
3504 format!("Unknown global option in {path}: {key}")
3505 } else {
3506 format!("Unknown global option: {key}")
3507 };
3508 warnings.push(ConfigValidationWarning {
3509 message,
3510 rule: None,
3511 key: Some(key.clone()),
3512 });
3513 } else if !key.is_empty() {
3514 continue;
3516 } else {
3517 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
3519 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
3520 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
3521 let formatted_suggestion = if suggestion.starts_with("MD") {
3523 suggestion
3524 } else {
3525 suggestion.to_lowercase()
3526 };
3527 if let Some(path) = file_path {
3528 format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
3529 } else {
3530 format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
3531 }
3532 } else if let Some(path) = file_path {
3533 format!("Unknown rule in {path}: {rule_name}")
3534 } else {
3535 format!("Unknown rule in config: {rule_name}")
3536 };
3537 warnings.push(ConfigValidationWarning {
3538 message,
3539 rule: None,
3540 key: None,
3541 });
3542 }
3543 }
3544 warnings
3545}
3546
3547pub fn validate_config_sourced(
3553 sourced: &SourcedConfig<ConfigLoaded>,
3554 registry: &RuleRegistry,
3555) -> Vec<ConfigValidationWarning> {
3556 validate_config_sourced_internal(sourced, registry)
3557}
3558
3559pub fn validate_config_sourced_validated(
3563 sourced: &SourcedConfig<ConfigValidated>,
3564 _registry: &RuleRegistry,
3565) -> Vec<ConfigValidationWarning> {
3566 sourced.validation_warnings.clone()
3567}
3568
3569fn toml_type_name(val: &toml::Value) -> &'static str {
3570 match val {
3571 toml::Value::String(_) => "string",
3572 toml::Value::Integer(_) => "integer",
3573 toml::Value::Float(_) => "float",
3574 toml::Value::Boolean(_) => "boolean",
3575 toml::Value::Array(_) => "array",
3576 toml::Value::Table(_) => "table",
3577 toml::Value::Datetime(_) => "datetime",
3578 }
3579}
3580
3581fn levenshtein_distance(s1: &str, s2: &str) -> usize {
3583 let len1 = s1.len();
3584 let len2 = s2.len();
3585
3586 if len1 == 0 {
3587 return len2;
3588 }
3589 if len2 == 0 {
3590 return len1;
3591 }
3592
3593 let s1_chars: Vec<char> = s1.chars().collect();
3594 let s2_chars: Vec<char> = s2.chars().collect();
3595
3596 let mut prev_row: Vec<usize> = (0..=len2).collect();
3597 let mut curr_row = vec![0; len2 + 1];
3598
3599 for i in 1..=len1 {
3600 curr_row[0] = i;
3601 for j in 1..=len2 {
3602 let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
3603 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
3607 std::mem::swap(&mut prev_row, &mut curr_row);
3608 }
3609
3610 prev_row[len2]
3611}
3612
3613pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
3615 let unknown_lower = unknown.to_lowercase();
3616 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
3619
3620 for valid in valid_keys {
3621 let valid_lower = valid.to_lowercase();
3622 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
3623
3624 if distance <= max_distance {
3625 if let Some((_, best_dist)) = &best_match {
3626 if distance < *best_dist {
3627 best_match = Some((valid.clone(), distance));
3628 }
3629 } else {
3630 best_match = Some((valid.clone(), distance));
3631 }
3632 }
3633 }
3634
3635 best_match.map(|(key, _)| key)
3636}
3637
3638fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
3639 use toml::Value::*;
3640 match (expected, actual) {
3641 (String(_), String(_)) => true,
3642 (Integer(_), Integer(_)) => true,
3643 (Float(_), Float(_)) => true,
3644 (Boolean(_), Boolean(_)) => true,
3645 (Array(_), Array(_)) => true,
3646 (Table(_), Table(_)) => true,
3647 (Datetime(_), Datetime(_)) => true,
3648 (Float(_), Integer(_)) => true,
3650 _ => false,
3651 }
3652}
3653
3654fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
3656 let doc: toml::Value =
3657 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
3658 let mut fragment = SourcedConfigFragment::default();
3659 let source = ConfigSource::PyprojectToml;
3660 let file = Some(path.to_string());
3661
3662 let all_rules = rules::all_rules(&Config::default());
3664 let registry = RuleRegistry::from_rules(&all_rules);
3665
3666 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
3668 && let Some(rumdl_table) = rumdl_config.as_table()
3669 {
3670 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
3672 if let Some(enable) = table.get("enable")
3674 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
3675 {
3676 let normalized_values = values
3678 .into_iter()
3679 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
3680 .collect();
3681 fragment
3682 .global
3683 .enable
3684 .push_override(normalized_values, source, file.clone(), None);
3685 }
3686
3687 if let Some(disable) = table.get("disable")
3688 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
3689 {
3690 let normalized_values: Vec<String> = values
3692 .into_iter()
3693 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
3694 .collect();
3695 fragment
3696 .global
3697 .disable
3698 .push_override(normalized_values, source, file.clone(), None);
3699 }
3700
3701 if let Some(include) = table.get("include")
3702 && let Ok(values) = Vec::<String>::deserialize(include.clone())
3703 {
3704 fragment
3705 .global
3706 .include
3707 .push_override(values, source, file.clone(), None);
3708 }
3709
3710 if let Some(exclude) = table.get("exclude")
3711 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
3712 {
3713 fragment
3714 .global
3715 .exclude
3716 .push_override(values, source, file.clone(), None);
3717 }
3718
3719 if let Some(respect_gitignore) = table
3720 .get("respect-gitignore")
3721 .or_else(|| table.get("respect_gitignore"))
3722 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
3723 {
3724 fragment
3725 .global
3726 .respect_gitignore
3727 .push_override(value, source, file.clone(), None);
3728 }
3729
3730 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
3731 && let Ok(value) = bool::deserialize(force_exclude.clone())
3732 {
3733 fragment
3734 .global
3735 .force_exclude
3736 .push_override(value, source, file.clone(), None);
3737 }
3738
3739 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
3740 && let Ok(value) = String::deserialize(output_format.clone())
3741 {
3742 if fragment.global.output_format.is_none() {
3743 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
3744 } else {
3745 fragment
3746 .global
3747 .output_format
3748 .as_mut()
3749 .unwrap()
3750 .push_override(value, source, file.clone(), None);
3751 }
3752 }
3753
3754 if let Some(fixable) = table.get("fixable")
3755 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
3756 {
3757 let normalized_values = values
3758 .into_iter()
3759 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
3760 .collect();
3761 fragment
3762 .global
3763 .fixable
3764 .push_override(normalized_values, source, file.clone(), None);
3765 }
3766
3767 if let Some(unfixable) = table.get("unfixable")
3768 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
3769 {
3770 let normalized_values = values
3771 .into_iter()
3772 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
3773 .collect();
3774 fragment
3775 .global
3776 .unfixable
3777 .push_override(normalized_values, source, file.clone(), None);
3778 }
3779
3780 if let Some(flavor) = table.get("flavor")
3781 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
3782 {
3783 fragment.global.flavor.push_override(value, source, file.clone(), None);
3784 }
3785
3786 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
3788 && let Ok(value) = u64::deserialize(line_length.clone())
3789 {
3790 fragment
3791 .global
3792 .line_length
3793 .push_override(LineLength::new(value as usize), source, file.clone(), None);
3794
3795 let norm_md013_key = normalize_key("MD013");
3797 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
3798 let norm_line_length_key = normalize_key("line-length");
3799 let sv = rule_entry
3800 .values
3801 .entry(norm_line_length_key)
3802 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
3803 sv.push_override(line_length.clone(), source, file.clone(), None);
3804 }
3805
3806 if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
3807 && let Ok(value) = String::deserialize(cache_dir.clone())
3808 {
3809 if fragment.global.cache_dir.is_none() {
3810 fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
3811 } else {
3812 fragment
3813 .global
3814 .cache_dir
3815 .as_mut()
3816 .unwrap()
3817 .push_override(value, source, file.clone(), None);
3818 }
3819 }
3820
3821 if let Some(cache) = table.get("cache")
3822 && let Ok(value) = bool::deserialize(cache.clone())
3823 {
3824 fragment.global.cache.push_override(value, source, file.clone(), None);
3825 }
3826 };
3827
3828 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
3830 extract_global_config(&mut fragment, global_table);
3831 }
3832
3833 extract_global_config(&mut fragment, rumdl_table);
3835
3836 let per_file_ignores_key = rumdl_table
3839 .get("per-file-ignores")
3840 .or_else(|| rumdl_table.get("per_file_ignores"));
3841
3842 if let Some(per_file_ignores_value) = per_file_ignores_key
3843 && let Some(per_file_table) = per_file_ignores_value.as_table()
3844 {
3845 let mut per_file_map = HashMap::new();
3846 for (pattern, rules_value) in per_file_table {
3847 if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
3848 let normalized_rules = rules
3849 .into_iter()
3850 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
3851 .collect();
3852 per_file_map.insert(pattern.clone(), normalized_rules);
3853 } else {
3854 log::warn!(
3855 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
3856 );
3857 }
3858 }
3859 fragment
3860 .per_file_ignores
3861 .push_override(per_file_map, source, file.clone(), None);
3862 }
3863
3864 for (key, value) in rumdl_table {
3866 let norm_rule_key = normalize_key(key);
3867
3868 let is_global_key = [
3871 "enable",
3872 "disable",
3873 "include",
3874 "exclude",
3875 "respect_gitignore",
3876 "respect-gitignore",
3877 "force_exclude",
3878 "force-exclude",
3879 "output_format",
3880 "output-format",
3881 "fixable",
3882 "unfixable",
3883 "per-file-ignores",
3884 "per_file_ignores",
3885 "global",
3886 "flavor",
3887 "cache_dir",
3888 "cache-dir",
3889 "cache",
3890 ]
3891 .contains(&norm_rule_key.as_str());
3892
3893 let is_line_length_global =
3895 (norm_rule_key == "line-length" || norm_rule_key == "line_length") && !value.is_table();
3896
3897 if is_global_key || is_line_length_global {
3898 continue;
3899 }
3900
3901 if let Some(resolved_rule_name) = registry.resolve_rule_name(key)
3903 && value.is_table()
3904 && let Some(rule_config_table) = value.as_table()
3905 {
3906 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
3907 for (rk, rv) in rule_config_table {
3908 let norm_rk = normalize_key(rk);
3909
3910 if norm_rk == "severity" {
3912 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
3913 if rule_entry.severity.is_none() {
3914 rule_entry.severity = Some(SourcedValue::new(severity, source));
3915 } else {
3916 rule_entry.severity.as_mut().unwrap().push_override(
3917 severity,
3918 source,
3919 file.clone(),
3920 None,
3921 );
3922 }
3923 }
3924 continue; }
3926
3927 let toml_val = rv.clone();
3928
3929 let sv = rule_entry
3930 .values
3931 .entry(norm_rk.clone())
3932 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
3933 sv.push_override(toml_val, source, file.clone(), None);
3934 }
3935 } else if registry.resolve_rule_name(key).is_none() {
3936 fragment
3939 .unknown_keys
3940 .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
3941 }
3942 }
3943 }
3944
3945 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
3947 for (key, value) in tool_table.iter() {
3948 if let Some(rule_name) = key.strip_prefix("rumdl.") {
3949 if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
3951 if let Some(rule_table) = value.as_table() {
3952 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
3953 for (rk, rv) in rule_table {
3954 let norm_rk = normalize_key(rk);
3955
3956 if norm_rk == "severity" {
3958 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
3959 if rule_entry.severity.is_none() {
3960 rule_entry.severity = Some(SourcedValue::new(severity, source));
3961 } else {
3962 rule_entry.severity.as_mut().unwrap().push_override(
3963 severity,
3964 source,
3965 file.clone(),
3966 None,
3967 );
3968 }
3969 }
3970 continue; }
3972
3973 let toml_val = rv.clone();
3974 let sv = rule_entry
3975 .values
3976 .entry(norm_rk.clone())
3977 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
3978 sv.push_override(toml_val, source, file.clone(), None);
3979 }
3980 }
3981 } else if rule_name.to_ascii_uppercase().starts_with("MD")
3982 || rule_name.chars().any(|c| c.is_alphabetic())
3983 {
3984 fragment.unknown_keys.push((
3986 format!("[tool.rumdl.{rule_name}]"),
3987 String::new(),
3988 Some(path.to_string()),
3989 ));
3990 }
3991 }
3992 }
3993 }
3994
3995 if let Some(doc_table) = doc.as_table() {
3997 for (key, value) in doc_table.iter() {
3998 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
3999 if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4001 if let Some(rule_table) = value.as_table() {
4002 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4003 for (rk, rv) in rule_table {
4004 let norm_rk = normalize_key(rk);
4005
4006 if norm_rk == "severity" {
4008 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4009 if rule_entry.severity.is_none() {
4010 rule_entry.severity = Some(SourcedValue::new(severity, source));
4011 } else {
4012 rule_entry.severity.as_mut().unwrap().push_override(
4013 severity,
4014 source,
4015 file.clone(),
4016 None,
4017 );
4018 }
4019 }
4020 continue; }
4022
4023 let toml_val = rv.clone();
4024 let sv = rule_entry
4025 .values
4026 .entry(norm_rk.clone())
4027 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4028 sv.push_override(toml_val, source, file.clone(), None);
4029 }
4030 }
4031 } else if rule_name.to_ascii_uppercase().starts_with("MD")
4032 || rule_name.chars().any(|c| c.is_alphabetic())
4033 {
4034 fragment.unknown_keys.push((
4036 format!("[tool.rumdl.{rule_name}]"),
4037 String::new(),
4038 Some(path.to_string()),
4039 ));
4040 }
4041 }
4042 }
4043 }
4044
4045 let has_any = !fragment.global.enable.value.is_empty()
4047 || !fragment.global.disable.value.is_empty()
4048 || !fragment.global.include.value.is_empty()
4049 || !fragment.global.exclude.value.is_empty()
4050 || !fragment.global.fixable.value.is_empty()
4051 || !fragment.global.unfixable.value.is_empty()
4052 || fragment.global.output_format.is_some()
4053 || fragment.global.cache_dir.is_some()
4054 || !fragment.global.cache.value
4055 || !fragment.per_file_ignores.value.is_empty()
4056 || !fragment.rules.is_empty();
4057 if has_any { Ok(Some(fragment)) } else { Ok(None) }
4058}
4059
4060fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
4062 let doc = content
4063 .parse::<DocumentMut>()
4064 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
4065 let mut fragment = SourcedConfigFragment::default();
4066 let file = Some(path.to_string());
4068
4069 let all_rules = rules::all_rules(&Config::default());
4071 let registry = RuleRegistry::from_rules(&all_rules);
4072
4073 if let Some(global_item) = doc.get("global")
4075 && let Some(global_table) = global_item.as_table()
4076 {
4077 for (key, value_item) in global_table.iter() {
4078 let norm_key = normalize_key(key);
4079 match norm_key.as_str() {
4080 "enable" | "disable" | "include" | "exclude" => {
4081 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4082 let values: Vec<String> = formatted_array
4084 .iter()
4085 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
4087 .collect();
4088
4089 let final_values = if norm_key == "enable" || norm_key == "disable" {
4091 values
4092 .into_iter()
4093 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4094 .collect()
4095 } else {
4096 values
4097 };
4098
4099 match norm_key.as_str() {
4100 "enable" => fragment
4101 .global
4102 .enable
4103 .push_override(final_values, source, file.clone(), None),
4104 "disable" => {
4105 fragment
4106 .global
4107 .disable
4108 .push_override(final_values, source, file.clone(), None)
4109 }
4110 "include" => {
4111 fragment
4112 .global
4113 .include
4114 .push_override(final_values, source, file.clone(), None)
4115 }
4116 "exclude" => {
4117 fragment
4118 .global
4119 .exclude
4120 .push_override(final_values, source, file.clone(), None)
4121 }
4122 _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
4123 }
4124 } else {
4125 log::warn!(
4126 "[WARN] Expected array for global key '{}' in {}, found {}",
4127 key,
4128 path,
4129 value_item.type_name()
4130 );
4131 }
4132 }
4133 "respect_gitignore" | "respect-gitignore" => {
4134 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
4136 let val = *formatted_bool.value();
4137 fragment
4138 .global
4139 .respect_gitignore
4140 .push_override(val, source, file.clone(), None);
4141 } else {
4142 log::warn!(
4143 "[WARN] Expected boolean for global key '{}' in {}, found {}",
4144 key,
4145 path,
4146 value_item.type_name()
4147 );
4148 }
4149 }
4150 "force_exclude" | "force-exclude" => {
4151 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
4153 let val = *formatted_bool.value();
4154 fragment
4155 .global
4156 .force_exclude
4157 .push_override(val, source, file.clone(), None);
4158 } else {
4159 log::warn!(
4160 "[WARN] Expected boolean for global key '{}' in {}, found {}",
4161 key,
4162 path,
4163 value_item.type_name()
4164 );
4165 }
4166 }
4167 "line_length" | "line-length" => {
4168 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
4170 let val = LineLength::new(*formatted_int.value() as usize);
4171 fragment
4172 .global
4173 .line_length
4174 .push_override(val, source, file.clone(), None);
4175 } else {
4176 log::warn!(
4177 "[WARN] Expected integer for global key '{}' in {}, found {}",
4178 key,
4179 path,
4180 value_item.type_name()
4181 );
4182 }
4183 }
4184 "output_format" | "output-format" => {
4185 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4187 let val = formatted_string.value().clone();
4188 if fragment.global.output_format.is_none() {
4189 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
4190 } else {
4191 fragment.global.output_format.as_mut().unwrap().push_override(
4192 val,
4193 source,
4194 file.clone(),
4195 None,
4196 );
4197 }
4198 } else {
4199 log::warn!(
4200 "[WARN] Expected string for global key '{}' in {}, found {}",
4201 key,
4202 path,
4203 value_item.type_name()
4204 );
4205 }
4206 }
4207 "cache_dir" | "cache-dir" => {
4208 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4210 let val = formatted_string.value().clone();
4211 if fragment.global.cache_dir.is_none() {
4212 fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
4213 } else {
4214 fragment
4215 .global
4216 .cache_dir
4217 .as_mut()
4218 .unwrap()
4219 .push_override(val, source, file.clone(), None);
4220 }
4221 } else {
4222 log::warn!(
4223 "[WARN] Expected string for global key '{}' in {}, found {}",
4224 key,
4225 path,
4226 value_item.type_name()
4227 );
4228 }
4229 }
4230 "cache" => {
4231 if let Some(toml_edit::Value::Boolean(b)) = value_item.as_value() {
4232 let val = *b.value();
4233 fragment.global.cache.push_override(val, source, file.clone(), None);
4234 } else {
4235 log::warn!(
4236 "[WARN] Expected boolean for global key '{}' in {}, found {}",
4237 key,
4238 path,
4239 value_item.type_name()
4240 );
4241 }
4242 }
4243 "fixable" => {
4244 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4245 let values: Vec<String> = formatted_array
4246 .iter()
4247 .filter_map(|item| item.as_str())
4248 .map(normalize_key)
4249 .collect();
4250 fragment
4251 .global
4252 .fixable
4253 .push_override(values, source, file.clone(), None);
4254 } else {
4255 log::warn!(
4256 "[WARN] Expected array for global key '{}' in {}, found {}",
4257 key,
4258 path,
4259 value_item.type_name()
4260 );
4261 }
4262 }
4263 "unfixable" => {
4264 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4265 let values: Vec<String> = formatted_array
4266 .iter()
4267 .filter_map(|item| item.as_str())
4268 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
4269 .collect();
4270 fragment
4271 .global
4272 .unfixable
4273 .push_override(values, source, file.clone(), None);
4274 } else {
4275 log::warn!(
4276 "[WARN] Expected array for global key '{}' in {}, found {}",
4277 key,
4278 path,
4279 value_item.type_name()
4280 );
4281 }
4282 }
4283 "flavor" => {
4284 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4285 let val = formatted_string.value();
4286 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
4287 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
4288 } else {
4289 log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
4290 }
4291 } else {
4292 log::warn!(
4293 "[WARN] Expected string for global key '{}' in {}, found {}",
4294 key,
4295 path,
4296 value_item.type_name()
4297 );
4298 }
4299 }
4300 _ => {
4301 fragment
4303 .unknown_keys
4304 .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
4305 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
4306 }
4307 }
4308 }
4309 }
4310
4311 if let Some(per_file_item) = doc.get("per-file-ignores")
4313 && let Some(per_file_table) = per_file_item.as_table()
4314 {
4315 let mut per_file_map = HashMap::new();
4316 for (pattern, value_item) in per_file_table.iter() {
4317 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4318 let rules: Vec<String> = formatted_array
4319 .iter()
4320 .filter_map(|item| item.as_str())
4321 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
4322 .collect();
4323 per_file_map.insert(pattern.to_string(), rules);
4324 } else {
4325 let type_name = value_item.type_name();
4326 log::warn!(
4327 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
4328 );
4329 }
4330 }
4331 fragment
4332 .per_file_ignores
4333 .push_override(per_file_map, source, file.clone(), None);
4334 }
4335
4336 for (key, item) in doc.iter() {
4338 if key == "global" || key == "per-file-ignores" {
4340 continue;
4341 }
4342
4343 let norm_rule_name = if let Some(resolved) = registry.resolve_rule_name(key) {
4345 resolved
4346 } else {
4347 fragment
4349 .unknown_keys
4350 .push((format!("[{key}]"), String::new(), Some(path.to_string())));
4351 continue;
4352 };
4353
4354 if let Some(tbl) = item.as_table() {
4355 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
4356 for (rk, rv_item) in tbl.iter() {
4357 let norm_rk = normalize_key(rk);
4358
4359 if norm_rk == "severity" {
4361 if let Some(toml_edit::Value::String(formatted_string)) = rv_item.as_value() {
4362 let severity_str = formatted_string.value();
4363 match crate::rule::Severity::deserialize(toml::Value::String(severity_str.to_string())) {
4364 Ok(severity) => {
4365 if rule_entry.severity.is_none() {
4366 rule_entry.severity = Some(SourcedValue::new(severity, source));
4367 } else {
4368 rule_entry.severity.as_mut().unwrap().push_override(
4369 severity,
4370 source,
4371 file.clone(),
4372 None,
4373 );
4374 }
4375 }
4376 Err(_) => {
4377 log::warn!(
4378 "[WARN] Invalid severity '{severity_str}' for rule {norm_rule_name} in {path}. Valid values: error, warning"
4379 );
4380 }
4381 }
4382 }
4383 continue; }
4385
4386 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
4387 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
4388 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
4389 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
4390 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
4391 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
4392 Some(toml_edit::Value::Array(formatted_array)) => {
4393 let mut values = Vec::new();
4395 for item in formatted_array.iter() {
4396 match item {
4397 toml_edit::Value::String(formatted) => {
4398 values.push(toml::Value::String(formatted.value().clone()))
4399 }
4400 toml_edit::Value::Integer(formatted) => {
4401 values.push(toml::Value::Integer(*formatted.value()))
4402 }
4403 toml_edit::Value::Float(formatted) => {
4404 values.push(toml::Value::Float(*formatted.value()))
4405 }
4406 toml_edit::Value::Boolean(formatted) => {
4407 values.push(toml::Value::Boolean(*formatted.value()))
4408 }
4409 toml_edit::Value::Datetime(formatted) => {
4410 values.push(toml::Value::Datetime(*formatted.value()))
4411 }
4412 _ => {
4413 log::warn!(
4414 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
4415 );
4416 }
4417 }
4418 }
4419 Some(toml::Value::Array(values))
4420 }
4421 Some(toml_edit::Value::InlineTable(_)) => {
4422 log::warn!(
4423 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
4424 );
4425 None
4426 }
4427 None => {
4428 log::warn!(
4429 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
4430 );
4431 None
4432 }
4433 };
4434 if let Some(toml_val) = maybe_toml_val {
4435 let sv = rule_entry
4436 .values
4437 .entry(norm_rk.clone())
4438 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
4439 sv.push_override(toml_val, source, file.clone(), None);
4440 }
4441 }
4442 } else if item.is_value() {
4443 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
4444 }
4445 }
4446
4447 Ok(fragment)
4448}
4449
4450fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
4452 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
4454 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
4455 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
4456}
4457
4458#[cfg(test)]
4459#[path = "config_intelligent_merge_tests.rs"]
4460mod config_intelligent_merge_tests;