1use crate::rule::Rule;
6use crate::rules;
7use crate::types::LineLength;
8use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
9use indexmap::IndexMap;
10use log;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::collections::{HashMap, HashSet};
14use std::fmt;
15use std::fs;
16use std::io;
17use std::marker::PhantomData;
18use std::path::Path;
19use std::str::FromStr;
20use std::sync::{Arc, OnceLock};
21use toml_edit::DocumentMut;
22
23#[derive(Debug, Clone, Copy, Default)]
30pub struct ConfigLoaded;
31
32#[derive(Debug, Clone, Copy, Default)]
35pub struct ConfigValidated;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39#[serde(rename_all = "lowercase")]
40pub enum MarkdownFlavor {
41 #[serde(rename = "standard", alias = "none", alias = "")]
43 #[default]
44 Standard,
45 #[serde(rename = "mkdocs")]
47 MkDocs,
48 #[serde(rename = "mdx")]
50 MDX,
51 #[serde(rename = "quarto")]
53 Quarto,
54}
55
56fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
58 schemars::json_schema!({
59 "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, quarto. Aliases: commonmark/github map to standard, qmd/rmd/rmarkdown map to quarto.",
60 "type": "string",
61 "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "quarto", "qmd", "rmd", "rmarkdown"]
62 })
63}
64
65impl schemars::JsonSchema for MarkdownFlavor {
66 fn schema_name() -> std::borrow::Cow<'static, str> {
67 std::borrow::Cow::Borrowed("MarkdownFlavor")
68 }
69
70 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
71 markdown_flavor_schema(generator)
72 }
73}
74
75impl fmt::Display for MarkdownFlavor {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 match self {
78 MarkdownFlavor::Standard => write!(f, "standard"),
79 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
80 MarkdownFlavor::MDX => write!(f, "mdx"),
81 MarkdownFlavor::Quarto => write!(f, "quarto"),
82 }
83 }
84}
85
86impl FromStr for MarkdownFlavor {
87 type Err = String;
88
89 fn from_str(s: &str) -> Result<Self, Self::Err> {
90 match s.to_lowercase().as_str() {
91 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
92 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
93 "mdx" => Ok(MarkdownFlavor::MDX),
94 "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
95 "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
99 _ => Err(format!("Unknown markdown flavor: {s}")),
100 }
101 }
102}
103
104impl MarkdownFlavor {
105 pub fn from_extension(ext: &str) -> Self {
107 match ext.to_lowercase().as_str() {
108 "mdx" => Self::MDX,
109 "qmd" => Self::Quarto,
110 "rmd" => Self::Quarto,
111 _ => Self::Standard,
112 }
113 }
114
115 pub fn from_path(path: &std::path::Path) -> Self {
117 path.extension()
118 .and_then(|e| e.to_str())
119 .map(Self::from_extension)
120 .unwrap_or(Self::Standard)
121 }
122
123 pub fn supports_esm_blocks(self) -> bool {
125 matches!(self, Self::MDX)
126 }
127
128 pub fn supports_jsx(self) -> bool {
130 matches!(self, Self::MDX)
131 }
132
133 pub fn supports_auto_references(self) -> bool {
135 matches!(self, Self::MkDocs)
136 }
137
138 pub fn name(self) -> &'static str {
140 match self {
141 Self::Standard => "Standard",
142 Self::MkDocs => "MkDocs",
143 Self::MDX => "MDX",
144 Self::Quarto => "Quarto",
145 }
146 }
147}
148
149pub fn normalize_key(key: &str) -> String {
151 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
153 key.to_ascii_uppercase()
154 } else {
155 key.replace('_', "-").to_ascii_lowercase()
156 }
157}
158
159fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: &str) {
163 if pattern.contains(',') && !pattern.contains('{') {
164 eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
165 eprintln!(" To match multiple files, use brace expansion: \"{{{pattern}}}\"");
166 eprintln!(" Or use separate entries for each file.");
167 }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
172pub struct RuleConfig {
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub severity: Option<crate::rule::Severity>,
176
177 #[serde(flatten)]
179 #[schemars(schema_with = "arbitrary_value_schema")]
180 pub values: BTreeMap<String, toml::Value>,
181}
182
183fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
185 schemars::json_schema!({
186 "type": "object",
187 "additionalProperties": true
188 })
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
193#[schemars(
194 description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
195)]
196pub struct Config {
197 #[serde(default)]
199 pub global: GlobalConfig,
200
201 #[serde(default, rename = "per-file-ignores")]
204 pub per_file_ignores: HashMap<String, Vec<String>>,
205
206 #[serde(default, rename = "per-file-flavor")]
210 #[schemars(with = "HashMap<String, MarkdownFlavor>")]
211 pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
212
213 #[serde(flatten)]
224 pub rules: BTreeMap<String, RuleConfig>,
225
226 #[serde(skip)]
228 pub project_root: Option<std::path::PathBuf>,
229
230 #[serde(skip)]
231 #[schemars(skip)]
232 per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
233
234 #[serde(skip)]
235 #[schemars(skip)]
236 per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
237}
238
239impl PartialEq for Config {
240 fn eq(&self, other: &Self) -> bool {
241 self.global == other.global
242 && self.per_file_ignores == other.per_file_ignores
243 && self.per_file_flavor == other.per_file_flavor
244 && self.rules == other.rules
245 && self.project_root == other.project_root
246 }
247}
248
249#[derive(Debug)]
250struct PerFileIgnoreCache {
251 globset: GlobSet,
252 rules: Vec<Vec<String>>,
253}
254
255#[derive(Debug)]
256struct PerFileFlavorCache {
257 matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
258}
259
260impl Config {
261 pub fn is_mkdocs_flavor(&self) -> bool {
263 self.global.flavor == MarkdownFlavor::MkDocs
264 }
265
266 pub fn markdown_flavor(&self) -> MarkdownFlavor {
272 self.global.flavor
273 }
274
275 pub fn is_mkdocs_project(&self) -> bool {
277 self.is_mkdocs_flavor()
278 }
279
280 pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
282 self.rules.get(rule_name).and_then(|r| r.severity)
283 }
284
285 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
288 let mut ignored_rules = HashSet::new();
289
290 if self.per_file_ignores.is_empty() {
291 return ignored_rules;
292 }
293
294 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
297 if let Ok(canonical_path) = file_path.canonicalize() {
298 if let Ok(canonical_root) = root.canonicalize() {
299 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
300 std::borrow::Cow::Owned(relative.to_path_buf())
301 } else {
302 std::borrow::Cow::Borrowed(file_path)
303 }
304 } else {
305 std::borrow::Cow::Borrowed(file_path)
306 }
307 } else {
308 std::borrow::Cow::Borrowed(file_path)
309 }
310 } else {
311 std::borrow::Cow::Borrowed(file_path)
312 };
313
314 let cache = self
315 .per_file_ignores_cache
316 .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
317
318 for match_idx in cache.globset.matches(path_for_matching.as_ref()) {
320 if let Some(rules) = cache.rules.get(match_idx) {
321 for rule in rules.iter() {
322 ignored_rules.insert(rule.clone());
324 }
325 }
326 }
327
328 ignored_rules
329 }
330
331 pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
335 if self.per_file_flavor.is_empty() {
337 return self.resolve_flavor_fallback(file_path);
338 }
339
340 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
342 if let Ok(canonical_path) = file_path.canonicalize() {
343 if let Ok(canonical_root) = root.canonicalize() {
344 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
345 std::borrow::Cow::Owned(relative.to_path_buf())
346 } else {
347 std::borrow::Cow::Borrowed(file_path)
348 }
349 } else {
350 std::borrow::Cow::Borrowed(file_path)
351 }
352 } else {
353 std::borrow::Cow::Borrowed(file_path)
354 }
355 } else {
356 std::borrow::Cow::Borrowed(file_path)
357 };
358
359 let cache = self
360 .per_file_flavor_cache
361 .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
362
363 for (matcher, flavor) in &cache.matchers {
365 if matcher.is_match(path_for_matching.as_ref()) {
366 return *flavor;
367 }
368 }
369
370 self.resolve_flavor_fallback(file_path)
372 }
373
374 fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
376 if self.global.flavor != MarkdownFlavor::Standard {
378 return self.global.flavor;
379 }
380 MarkdownFlavor::from_path(file_path)
382 }
383}
384
385impl PerFileIgnoreCache {
386 fn new(per_file_ignores: &HashMap<String, Vec<String>>) -> Self {
387 let mut builder = GlobSetBuilder::new();
388 let mut rules = Vec::new();
389
390 for (pattern, rules_list) in per_file_ignores {
391 if let Ok(glob) = Glob::new(pattern) {
392 builder.add(glob);
393 rules.push(rules_list.iter().map(|rule| normalize_key(rule)).collect());
394 } else {
395 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
396 }
397 }
398
399 let globset = builder.build().unwrap_or_else(|e| {
400 log::error!("Failed to build globset for per-file-ignores: {e}");
401 GlobSetBuilder::new().build().unwrap()
402 });
403
404 Self { globset, rules }
405 }
406}
407
408impl PerFileFlavorCache {
409 fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
410 let mut matchers = Vec::new();
411
412 for (pattern, flavor) in per_file_flavor {
413 if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
414 matchers.push((glob.compile_matcher(), *flavor));
415 } else {
416 log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
417 }
418 }
419
420 Self { matchers }
421 }
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
426#[serde(default, rename_all = "kebab-case")]
427pub struct GlobalConfig {
428 #[serde(default)]
430 pub enable: Vec<String>,
431
432 #[serde(default)]
434 pub disable: Vec<String>,
435
436 #[serde(default)]
438 pub exclude: Vec<String>,
439
440 #[serde(default)]
442 pub include: Vec<String>,
443
444 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
446 pub respect_gitignore: bool,
447
448 #[serde(default, alias = "line_length")]
450 pub line_length: LineLength,
451
452 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
454 pub output_format: Option<String>,
455
456 #[serde(default)]
459 pub fixable: Vec<String>,
460
461 #[serde(default)]
464 pub unfixable: Vec<String>,
465
466 #[serde(default)]
469 pub flavor: MarkdownFlavor,
470
471 #[serde(default, alias = "force_exclude")]
476 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
477 pub force_exclude: bool,
478
479 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
482 pub cache_dir: Option<String>,
483
484 #[serde(default = "default_true")]
487 pub cache: bool,
488}
489
490fn default_respect_gitignore() -> bool {
491 true
492}
493
494fn default_true() -> bool {
495 true
496}
497
498impl Default for GlobalConfig {
500 #[allow(deprecated)]
501 fn default() -> Self {
502 Self {
503 enable: Vec::new(),
504 disable: Vec::new(),
505 exclude: Vec::new(),
506 include: Vec::new(),
507 respect_gitignore: true,
508 line_length: LineLength::default(),
509 output_format: None,
510 fixable: Vec::new(),
511 unfixable: Vec::new(),
512 flavor: MarkdownFlavor::default(),
513 force_exclude: false,
514 cache_dir: None,
515 cache: true,
516 }
517 }
518}
519
520const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
521 ".markdownlint.json",
522 ".markdownlint.jsonc",
523 ".markdownlint.yaml",
524 ".markdownlint.yml",
525 "markdownlint.json",
526 "markdownlint.jsonc",
527 "markdownlint.yaml",
528 "markdownlint.yml",
529];
530
531pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
533 if Path::new(path).exists() {
535 return Err(ConfigError::FileExists { path: path.to_string() });
536 }
537
538 let default_config = r#"# rumdl configuration file
540
541# Global configuration options
542[global]
543# List of rules to disable (uncomment and modify as needed)
544# disable = ["MD013", "MD033"]
545
546# List of rules to enable exclusively (if provided, only these rules will run)
547# enable = ["MD001", "MD003", "MD004"]
548
549# List of file/directory patterns to include for linting (if provided, only these will be linted)
550# include = [
551# "docs/*.md",
552# "src/**/*.md",
553# "README.md"
554# ]
555
556# List of file/directory patterns to exclude from linting
557exclude = [
558 # Common directories to exclude
559 ".git",
560 ".github",
561 "node_modules",
562 "vendor",
563 "dist",
564 "build",
565
566 # Specific files or patterns
567 "CHANGELOG.md",
568 "LICENSE.md",
569]
570
571# Respect .gitignore files when scanning directories (default: true)
572respect-gitignore = true
573
574# Markdown flavor/dialect (uncomment to enable)
575# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
576# flavor = "mkdocs"
577
578# Rule-specific configurations (uncomment and modify as needed)
579
580# [MD003]
581# style = "atx" # Heading style (atx, atx_closed, setext)
582
583# [MD004]
584# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
585
586# [MD007]
587# indent = 4 # Unordered list indentation
588
589# [MD013]
590# line-length = 100 # Line length
591# code-blocks = false # Exclude code blocks from line length check
592# tables = false # Exclude tables from line length check
593# headings = true # Include headings in line length check
594
595# [MD044]
596# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
597# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
598"#;
599
600 match fs::write(path, default_config) {
602 Ok(_) => Ok(()),
603 Err(err) => Err(ConfigError::IoError {
604 source: err,
605 path: path.to_string(),
606 }),
607 }
608}
609
610#[derive(Debug, thiserror::Error)]
612pub enum ConfigError {
613 #[error("Failed to read config file at {path}: {source}")]
615 IoError { source: io::Error, path: String },
616
617 #[error("Failed to parse config: {0}")]
619 ParseError(String),
620
621 #[error("Configuration file already exists at {path}")]
623 FileExists { path: String },
624}
625
626pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
630 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
633
634 let key_variants = [
636 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
641
642 for variant in &key_variants {
644 if let Some(value) = rule_config.values.get(variant)
645 && let Ok(result) = T::deserialize(value.clone())
646 {
647 return Some(result);
648 }
649 }
650
651 None
652}
653
654pub fn generate_pyproject_config() -> String {
656 let config_content = r#"
657[tool.rumdl]
658# Global configuration options
659line-length = 100
660disable = []
661exclude = [
662 # Common directories to exclude
663 ".git",
664 ".github",
665 "node_modules",
666 "vendor",
667 "dist",
668 "build",
669]
670respect-gitignore = true
671
672# Rule-specific configurations (uncomment and modify as needed)
673
674# [tool.rumdl.MD003]
675# style = "atx" # Heading style (atx, atx_closed, setext)
676
677# [tool.rumdl.MD004]
678# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
679
680# [tool.rumdl.MD007]
681# indent = 4 # Unordered list indentation
682
683# [tool.rumdl.MD013]
684# line-length = 100 # Line length
685# code-blocks = false # Exclude code blocks from line length check
686# tables = false # Exclude tables from line length check
687# headings = true # Include headings in line length check
688
689# [tool.rumdl.MD044]
690# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
691# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
692"#;
693
694 config_content.to_string()
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use std::fs;
701 use tempfile::tempdir;
702
703 #[test]
704 fn test_flavor_loading() {
705 let temp_dir = tempdir().unwrap();
706 let config_path = temp_dir.path().join(".rumdl.toml");
707 let config_content = r#"
708[global]
709flavor = "mkdocs"
710disable = ["MD001"]
711"#;
712 fs::write(&config_path, config_content).unwrap();
713
714 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
716 let config: Config = sourced.into_validated_unchecked().into();
717
718 assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
720 assert!(config.is_mkdocs_flavor());
721 assert!(config.is_mkdocs_project()); assert_eq!(config.global.disable, vec!["MD001".to_string()]);
723 }
724
725 #[test]
726 fn test_pyproject_toml_root_level_config() {
727 let temp_dir = tempdir().unwrap();
728 let config_path = temp_dir.path().join("pyproject.toml");
729
730 let content = r#"
732[tool.rumdl]
733line-length = 120
734disable = ["MD033"]
735enable = ["MD001", "MD004"]
736include = ["docs/*.md"]
737exclude = ["node_modules"]
738respect-gitignore = true
739 "#;
740
741 fs::write(&config_path, content).unwrap();
742
743 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
745 let config: Config = sourced.into_validated_unchecked().into(); assert_eq!(config.global.disable, vec!["MD033".to_string()]);
749 assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
750 assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
752 assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
753 assert!(config.global.respect_gitignore);
754
755 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
757 assert_eq!(line_length, Some(120));
758 }
759
760 #[test]
761 fn test_pyproject_toml_snake_case_and_kebab_case() {
762 let temp_dir = tempdir().unwrap();
763 let config_path = temp_dir.path().join("pyproject.toml");
764
765 let content = r#"
767[tool.rumdl]
768line-length = 150
769respect_gitignore = true
770 "#;
771
772 fs::write(&config_path, content).unwrap();
773
774 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
776 let config: Config = sourced.into_validated_unchecked().into(); assert!(config.global.respect_gitignore);
780 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
781 assert_eq!(line_length, Some(150));
782 }
783
784 #[test]
785 fn test_md013_key_normalization_in_rumdl_toml() {
786 let temp_dir = tempdir().unwrap();
787 let config_path = temp_dir.path().join(".rumdl.toml");
788 let config_content = r#"
789[MD013]
790line_length = 111
791line-length = 222
792"#;
793 fs::write(&config_path, config_content).unwrap();
794 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
796 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
797 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
799 assert_eq!(keys, vec!["line-length"]);
800 let val = &rule_cfg.values["line-length"].value;
801 assert_eq!(val.as_integer(), Some(222));
802 let config: Config = sourced.clone().into_validated_unchecked().into();
804 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
805 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
806 assert_eq!(v1, Some(222));
807 assert_eq!(v2, Some(222));
808 }
809
810 #[test]
811 fn test_md013_section_case_insensitivity() {
812 let temp_dir = tempdir().unwrap();
813 let config_path = temp_dir.path().join(".rumdl.toml");
814 let config_content = r#"
815[md013]
816line-length = 101
817
818[Md013]
819line-length = 102
820
821[MD013]
822line-length = 103
823"#;
824 fs::write(&config_path, config_content).unwrap();
825 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
827 let config: Config = sourced.clone().into_validated_unchecked().into();
828 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
830 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
831 assert_eq!(keys, vec!["line-length"]);
832 let val = &rule_cfg.values["line-length"].value;
833 assert_eq!(val.as_integer(), Some(103));
834 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
835 assert_eq!(v, Some(103));
836 }
837
838 #[test]
839 fn test_md013_key_snake_and_kebab_case() {
840 let temp_dir = tempdir().unwrap();
841 let config_path = temp_dir.path().join(".rumdl.toml");
842 let config_content = r#"
843[MD013]
844line_length = 201
845line-length = 202
846"#;
847 fs::write(&config_path, config_content).unwrap();
848 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
850 let config: Config = sourced.clone().into_validated_unchecked().into();
851 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
852 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
853 assert_eq!(keys, vec!["line-length"]);
854 let val = &rule_cfg.values["line-length"].value;
855 assert_eq!(val.as_integer(), Some(202));
856 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
857 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
858 assert_eq!(v1, Some(202));
859 assert_eq!(v2, Some(202));
860 }
861
862 #[test]
863 fn test_unknown_rule_section_is_ignored() {
864 let temp_dir = tempdir().unwrap();
865 let config_path = temp_dir.path().join(".rumdl.toml");
866 let config_content = r#"
867[MD999]
868foo = 1
869bar = 2
870[MD013]
871line-length = 303
872"#;
873 fs::write(&config_path, config_content).unwrap();
874 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
876 let config: Config = sourced.clone().into_validated_unchecked().into();
877 assert!(!sourced.rules.contains_key("MD999"));
879 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
881 assert_eq!(v, Some(303));
882 }
883
884 #[test]
885 fn test_invalid_toml_syntax() {
886 let temp_dir = tempdir().unwrap();
887 let config_path = temp_dir.path().join(".rumdl.toml");
888
889 let config_content = r#"
891[MD013]
892line-length = "unclosed string
893"#;
894 fs::write(&config_path, config_content).unwrap();
895
896 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
897 assert!(result.is_err());
898 match result.unwrap_err() {
899 ConfigError::ParseError(msg) => {
900 assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
902 }
903 _ => panic!("Expected ParseError"),
904 }
905 }
906
907 #[test]
908 fn test_wrong_type_for_config_value() {
909 let temp_dir = tempdir().unwrap();
910 let config_path = temp_dir.path().join(".rumdl.toml");
911
912 let config_content = r#"
914[MD013]
915line-length = "not a number"
916"#;
917 fs::write(&config_path, config_content).unwrap();
918
919 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
920 let config: Config = sourced.into_validated_unchecked().into();
921
922 let rule_config = config.rules.get("MD013").unwrap();
924 let value = rule_config.values.get("line-length").unwrap();
925 assert!(matches!(value, toml::Value::String(_)));
926 }
927
928 #[test]
929 fn test_empty_config_file() {
930 let temp_dir = tempdir().unwrap();
931 let config_path = temp_dir.path().join(".rumdl.toml");
932
933 fs::write(&config_path, "").unwrap();
935
936 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
937 let config: Config = sourced.into_validated_unchecked().into();
938
939 assert_eq!(config.global.line_length.get(), 80);
941 assert!(config.global.respect_gitignore);
942 assert!(config.rules.is_empty());
943 }
944
945 #[test]
946 fn test_malformed_pyproject_toml() {
947 let temp_dir = tempdir().unwrap();
948 let config_path = temp_dir.path().join("pyproject.toml");
949
950 let content = r#"
952[tool.rumdl
953line-length = 120
954"#;
955 fs::write(&config_path, content).unwrap();
956
957 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
958 assert!(result.is_err());
959 }
960
961 #[test]
962 fn test_conflicting_config_values() {
963 let temp_dir = tempdir().unwrap();
964 let config_path = temp_dir.path().join(".rumdl.toml");
965
966 let config_content = r#"
968[global]
969enable = ["MD013"]
970disable = ["MD013"]
971"#;
972 fs::write(&config_path, config_content).unwrap();
973
974 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
975 let config: Config = sourced.into_validated_unchecked().into();
976
977 assert!(config.global.enable.contains(&"MD013".to_string()));
979 assert!(!config.global.disable.contains(&"MD013".to_string()));
980 }
981
982 #[test]
983 fn test_invalid_rule_names() {
984 let temp_dir = tempdir().unwrap();
985 let config_path = temp_dir.path().join(".rumdl.toml");
986
987 let config_content = r#"
988[global]
989enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
990disable = ["MD-001", "MD_002"]
991"#;
992 fs::write(&config_path, config_content).unwrap();
993
994 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
995 let config: Config = sourced.into_validated_unchecked().into();
996
997 assert_eq!(config.global.enable.len(), 4);
999 assert_eq!(config.global.disable.len(), 2);
1000 }
1001
1002 #[test]
1003 fn test_deeply_nested_config() {
1004 let temp_dir = tempdir().unwrap();
1005 let config_path = temp_dir.path().join(".rumdl.toml");
1006
1007 let config_content = r#"
1009[MD013]
1010line-length = 100
1011[MD013.nested]
1012value = 42
1013"#;
1014 fs::write(&config_path, config_content).unwrap();
1015
1016 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1017 let config: Config = sourced.into_validated_unchecked().into();
1018
1019 let rule_config = config.rules.get("MD013").unwrap();
1020 assert_eq!(
1021 rule_config.values.get("line-length").unwrap(),
1022 &toml::Value::Integer(100)
1023 );
1024 assert!(!rule_config.values.contains_key("nested"));
1026 }
1027
1028 #[test]
1029 fn test_unicode_in_config() {
1030 let temp_dir = tempdir().unwrap();
1031 let config_path = temp_dir.path().join(".rumdl.toml");
1032
1033 let config_content = r#"
1034[global]
1035include = ["文档/*.md", "ドã‚ュメント/*.md"]
1036exclude = ["测试/*", "🚀/*"]
1037
1038[MD013]
1039line-length = 80
1040message = "行太长了 🚨"
1041"#;
1042 fs::write(&config_path, config_content).unwrap();
1043
1044 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1045 let config: Config = sourced.into_validated_unchecked().into();
1046
1047 assert_eq!(config.global.include.len(), 2);
1048 assert_eq!(config.global.exclude.len(), 2);
1049 assert!(config.global.include[0].contains("文档"));
1050 assert!(config.global.exclude[1].contains("🚀"));
1051
1052 let rule_config = config.rules.get("MD013").unwrap();
1053 let message = rule_config.values.get("message").unwrap();
1054 if let toml::Value::String(s) = message {
1055 assert!(s.contains("行太长了"));
1056 assert!(s.contains("🚨"));
1057 }
1058 }
1059
1060 #[test]
1061 fn test_extremely_long_values() {
1062 let temp_dir = tempdir().unwrap();
1063 let config_path = temp_dir.path().join(".rumdl.toml");
1064
1065 let long_string = "a".repeat(10000);
1066 let config_content = format!(
1067 r#"
1068[global]
1069exclude = ["{long_string}"]
1070
1071[MD013]
1072line-length = 999999999
1073"#
1074 );
1075
1076 fs::write(&config_path, config_content).unwrap();
1077
1078 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1079 let config: Config = sourced.into_validated_unchecked().into();
1080
1081 assert_eq!(config.global.exclude[0].len(), 10000);
1082 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
1083 assert_eq!(line_length, Some(999999999));
1084 }
1085
1086 #[test]
1087 fn test_config_with_comments() {
1088 let temp_dir = tempdir().unwrap();
1089 let config_path = temp_dir.path().join(".rumdl.toml");
1090
1091 let config_content = r#"
1092[global]
1093# This is a comment
1094enable = ["MD001"] # Enable MD001
1095# disable = ["MD002"] # This is commented out
1096
1097[MD013] # Line length rule
1098line-length = 100 # Set to 100 characters
1099# ignored = true # This setting is commented out
1100"#;
1101 fs::write(&config_path, config_content).unwrap();
1102
1103 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1104 let config: Config = sourced.into_validated_unchecked().into();
1105
1106 assert_eq!(config.global.enable, vec!["MD001"]);
1107 assert!(config.global.disable.is_empty()); let rule_config = config.rules.get("MD013").unwrap();
1110 assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
1112 }
1113
1114 #[test]
1115 fn test_arrays_in_rule_config() {
1116 let temp_dir = tempdir().unwrap();
1117 let config_path = temp_dir.path().join(".rumdl.toml");
1118
1119 let config_content = r#"
1120[MD003]
1121levels = [1, 2, 3]
1122tags = ["important", "critical"]
1123mixed = [1, "two", true]
1124"#;
1125 fs::write(&config_path, config_content).unwrap();
1126
1127 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1128 let config: Config = sourced.into_validated_unchecked().into();
1129
1130 let rule_config = config.rules.get("MD003").expect("MD003 config should exist");
1132
1133 assert!(rule_config.values.contains_key("levels"));
1135 assert!(rule_config.values.contains_key("tags"));
1136 assert!(rule_config.values.contains_key("mixed"));
1137
1138 if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
1140 assert_eq!(levels.len(), 3);
1141 assert_eq!(levels[0], toml::Value::Integer(1));
1142 assert_eq!(levels[1], toml::Value::Integer(2));
1143 assert_eq!(levels[2], toml::Value::Integer(3));
1144 } else {
1145 panic!("levels should be an array");
1146 }
1147
1148 if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
1149 assert_eq!(tags.len(), 2);
1150 assert_eq!(tags[0], toml::Value::String("important".to_string()));
1151 assert_eq!(tags[1], toml::Value::String("critical".to_string()));
1152 } else {
1153 panic!("tags should be an array");
1154 }
1155
1156 if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
1157 assert_eq!(mixed.len(), 3);
1158 assert_eq!(mixed[0], toml::Value::Integer(1));
1159 assert_eq!(mixed[1], toml::Value::String("two".to_string()));
1160 assert_eq!(mixed[2], toml::Value::Boolean(true));
1161 } else {
1162 panic!("mixed should be an array");
1163 }
1164 }
1165
1166 #[test]
1167 fn test_normalize_key_edge_cases() {
1168 assert_eq!(normalize_key("MD001"), "MD001");
1170 assert_eq!(normalize_key("md001"), "MD001");
1171 assert_eq!(normalize_key("Md001"), "MD001");
1172 assert_eq!(normalize_key("mD001"), "MD001");
1173
1174 assert_eq!(normalize_key("line_length"), "line-length");
1176 assert_eq!(normalize_key("line-length"), "line-length");
1177 assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
1178 assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
1179
1180 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(""), "");
1187 assert_eq!(normalize_key("_"), "-");
1188 assert_eq!(normalize_key("___"), "---");
1189 }
1190
1191 #[test]
1192 fn test_missing_config_file() {
1193 let temp_dir = tempdir().unwrap();
1194 let config_path = temp_dir.path().join("nonexistent.toml");
1195
1196 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1197 assert!(result.is_err());
1198 match result.unwrap_err() {
1199 ConfigError::IoError { .. } => {}
1200 _ => panic!("Expected IoError for missing file"),
1201 }
1202 }
1203
1204 #[test]
1205 #[cfg(unix)]
1206 fn test_permission_denied_config() {
1207 use std::os::unix::fs::PermissionsExt;
1208
1209 let temp_dir = tempdir().unwrap();
1210 let config_path = temp_dir.path().join(".rumdl.toml");
1211
1212 fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
1213
1214 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1216 perms.set_mode(0o000);
1217 fs::set_permissions(&config_path, perms).unwrap();
1218
1219 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1220
1221 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1223 perms.set_mode(0o644);
1224 fs::set_permissions(&config_path, perms).unwrap();
1225
1226 assert!(result.is_err());
1227 match result.unwrap_err() {
1228 ConfigError::IoError { .. } => {}
1229 _ => panic!("Expected IoError for permission denied"),
1230 }
1231 }
1232
1233 #[test]
1234 fn test_circular_reference_detection() {
1235 let temp_dir = tempdir().unwrap();
1238 let config_path = temp_dir.path().join(".rumdl.toml");
1239
1240 let mut config_content = String::from("[MD001]\n");
1241 for i in 0..100 {
1242 config_content.push_str(&format!("key{i} = {i}\n"));
1243 }
1244
1245 fs::write(&config_path, config_content).unwrap();
1246
1247 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1248 let config: Config = sourced.into_validated_unchecked().into();
1249
1250 let rule_config = config.rules.get("MD001").unwrap();
1251 assert_eq!(rule_config.values.len(), 100);
1252 }
1253
1254 #[test]
1255 fn test_special_toml_values() {
1256 let temp_dir = tempdir().unwrap();
1257 let config_path = temp_dir.path().join(".rumdl.toml");
1258
1259 let config_content = r#"
1260[MD001]
1261infinity = inf
1262neg_infinity = -inf
1263not_a_number = nan
1264datetime = 1979-05-27T07:32:00Z
1265local_date = 1979-05-27
1266local_time = 07:32:00
1267"#;
1268 fs::write(&config_path, config_content).unwrap();
1269
1270 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1271 let config: Config = sourced.into_validated_unchecked().into();
1272
1273 if let Some(rule_config) = config.rules.get("MD001") {
1275 if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
1277 assert!(f.is_infinite() && f.is_sign_positive());
1278 }
1279 if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
1280 assert!(f.is_infinite() && f.is_sign_negative());
1281 }
1282 if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
1283 assert!(f.is_nan());
1284 }
1285
1286 if let Some(val) = rule_config.values.get("datetime") {
1288 assert!(matches!(val, toml::Value::Datetime(_)));
1289 }
1290 }
1292 }
1293
1294 #[test]
1295 fn test_default_config_passes_validation() {
1296 use crate::rules;
1297
1298 let temp_dir = tempdir().unwrap();
1299 let config_path = temp_dir.path().join(".rumdl.toml");
1300 let config_path_str = config_path.to_str().unwrap();
1301
1302 create_default_config(config_path_str).unwrap();
1304
1305 let sourced =
1307 SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1308
1309 let all_rules = rules::all_rules(&Config::default());
1311 let registry = RuleRegistry::from_rules(&all_rules);
1312
1313 let warnings = validate_config_sourced(&sourced, ®istry);
1315
1316 if !warnings.is_empty() {
1318 for warning in &warnings {
1319 eprintln!("Config validation warning: {}", warning.message);
1320 if let Some(rule) = &warning.rule {
1321 eprintln!(" Rule: {rule}");
1322 }
1323 if let Some(key) = &warning.key {
1324 eprintln!(" Key: {key}");
1325 }
1326 }
1327 }
1328 assert!(
1329 warnings.is_empty(),
1330 "Default config from rumdl init should pass validation without warnings"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_per_file_ignores_config_parsing() {
1336 let temp_dir = tempdir().unwrap();
1337 let config_path = temp_dir.path().join(".rumdl.toml");
1338 let config_content = r#"
1339[per-file-ignores]
1340"README.md" = ["MD033"]
1341"docs/**/*.md" = ["MD013", "MD033"]
1342"test/*.md" = ["MD041"]
1343"#;
1344 fs::write(&config_path, config_content).unwrap();
1345
1346 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1347 let config: Config = sourced.into_validated_unchecked().into();
1348
1349 assert_eq!(config.per_file_ignores.len(), 3);
1351 assert_eq!(
1352 config.per_file_ignores.get("README.md"),
1353 Some(&vec!["MD033".to_string()])
1354 );
1355 assert_eq!(
1356 config.per_file_ignores.get("docs/**/*.md"),
1357 Some(&vec!["MD013".to_string(), "MD033".to_string()])
1358 );
1359 assert_eq!(
1360 config.per_file_ignores.get("test/*.md"),
1361 Some(&vec!["MD041".to_string()])
1362 );
1363 }
1364
1365 #[test]
1366 fn test_per_file_ignores_glob_matching() {
1367 use std::path::PathBuf;
1368
1369 let temp_dir = tempdir().unwrap();
1370 let config_path = temp_dir.path().join(".rumdl.toml");
1371 let config_content = r#"
1372[per-file-ignores]
1373"README.md" = ["MD033"]
1374"docs/**/*.md" = ["MD013"]
1375"**/test_*.md" = ["MD041"]
1376"#;
1377 fs::write(&config_path, config_content).unwrap();
1378
1379 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1380 let config: Config = sourced.into_validated_unchecked().into();
1381
1382 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1384 assert!(ignored.contains("MD033"));
1385 assert_eq!(ignored.len(), 1);
1386
1387 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1389 assert!(ignored.contains("MD013"));
1390 assert_eq!(ignored.len(), 1);
1391
1392 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
1394 assert!(ignored.contains("MD041"));
1395 assert_eq!(ignored.len(), 1);
1396
1397 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
1399 assert!(ignored.is_empty());
1400 }
1401
1402 #[test]
1403 fn test_per_file_ignores_pyproject_toml() {
1404 let temp_dir = tempdir().unwrap();
1405 let config_path = temp_dir.path().join("pyproject.toml");
1406 let config_content = r#"
1407[tool.rumdl]
1408[tool.rumdl.per-file-ignores]
1409"README.md" = ["MD033", "MD013"]
1410"generated/*.md" = ["MD041"]
1411"#;
1412 fs::write(&config_path, config_content).unwrap();
1413
1414 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1415 let config: Config = sourced.into_validated_unchecked().into();
1416
1417 assert_eq!(config.per_file_ignores.len(), 2);
1419 assert_eq!(
1420 config.per_file_ignores.get("README.md"),
1421 Some(&vec!["MD033".to_string(), "MD013".to_string()])
1422 );
1423 assert_eq!(
1424 config.per_file_ignores.get("generated/*.md"),
1425 Some(&vec!["MD041".to_string()])
1426 );
1427 }
1428
1429 #[test]
1430 fn test_per_file_ignores_multiple_patterns_match() {
1431 use std::path::PathBuf;
1432
1433 let temp_dir = tempdir().unwrap();
1434 let config_path = temp_dir.path().join(".rumdl.toml");
1435 let config_content = r#"
1436[per-file-ignores]
1437"docs/**/*.md" = ["MD013"]
1438"**/api/*.md" = ["MD033"]
1439"docs/api/overview.md" = ["MD041"]
1440"#;
1441 fs::write(&config_path, config_content).unwrap();
1442
1443 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1444 let config: Config = sourced.into_validated_unchecked().into();
1445
1446 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1448 assert_eq!(ignored.len(), 3);
1449 assert!(ignored.contains("MD013"));
1450 assert!(ignored.contains("MD033"));
1451 assert!(ignored.contains("MD041"));
1452 }
1453
1454 #[test]
1455 fn test_per_file_ignores_rule_name_normalization() {
1456 use std::path::PathBuf;
1457
1458 let temp_dir = tempdir().unwrap();
1459 let config_path = temp_dir.path().join(".rumdl.toml");
1460 let config_content = r#"
1461[per-file-ignores]
1462"README.md" = ["md033", "MD013", "Md041"]
1463"#;
1464 fs::write(&config_path, config_content).unwrap();
1465
1466 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1467 let config: Config = sourced.into_validated_unchecked().into();
1468
1469 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1471 assert_eq!(ignored.len(), 3);
1472 assert!(ignored.contains("MD033"));
1473 assert!(ignored.contains("MD013"));
1474 assert!(ignored.contains("MD041"));
1475 }
1476
1477 #[test]
1478 fn test_per_file_ignores_invalid_glob_pattern() {
1479 use std::path::PathBuf;
1480
1481 let temp_dir = tempdir().unwrap();
1482 let config_path = temp_dir.path().join(".rumdl.toml");
1483 let config_content = r#"
1484[per-file-ignores]
1485"[invalid" = ["MD033"]
1486"valid/*.md" = ["MD013"]
1487"#;
1488 fs::write(&config_path, config_content).unwrap();
1489
1490 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1491 let config: Config = sourced.into_validated_unchecked().into();
1492
1493 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
1495 assert!(ignored.contains("MD013"));
1496
1497 let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
1499 assert!(ignored2.is_empty());
1500 }
1501
1502 #[test]
1503 fn test_per_file_ignores_empty_section() {
1504 use std::path::PathBuf;
1505
1506 let temp_dir = tempdir().unwrap();
1507 let config_path = temp_dir.path().join(".rumdl.toml");
1508 let config_content = r#"
1509[global]
1510disable = ["MD001"]
1511
1512[per-file-ignores]
1513"#;
1514 fs::write(&config_path, config_content).unwrap();
1515
1516 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1517 let config: Config = sourced.into_validated_unchecked().into();
1518
1519 assert_eq!(config.per_file_ignores.len(), 0);
1521 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1522 assert!(ignored.is_empty());
1523 }
1524
1525 #[test]
1526 fn test_per_file_ignores_with_underscores_in_pyproject() {
1527 let temp_dir = tempdir().unwrap();
1528 let config_path = temp_dir.path().join("pyproject.toml");
1529 let config_content = r#"
1530[tool.rumdl]
1531[tool.rumdl.per_file_ignores]
1532"README.md" = ["MD033"]
1533"#;
1534 fs::write(&config_path, config_content).unwrap();
1535
1536 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1537 let config: Config = sourced.into_validated_unchecked().into();
1538
1539 assert_eq!(config.per_file_ignores.len(), 1);
1541 assert_eq!(
1542 config.per_file_ignores.get("README.md"),
1543 Some(&vec!["MD033".to_string()])
1544 );
1545 }
1546
1547 #[test]
1548 fn test_per_file_ignores_absolute_path_matching() {
1549 use std::path::PathBuf;
1552
1553 let temp_dir = tempdir().unwrap();
1554 let config_path = temp_dir.path().join(".rumdl.toml");
1555
1556 let github_dir = temp_dir.path().join(".github");
1558 fs::create_dir_all(&github_dir).unwrap();
1559 let test_file = github_dir.join("pull_request_template.md");
1560 fs::write(&test_file, "Test content").unwrap();
1561
1562 let config_content = r#"
1563[per-file-ignores]
1564".github/pull_request_template.md" = ["MD041"]
1565"docs/**/*.md" = ["MD013"]
1566"#;
1567 fs::write(&config_path, config_content).unwrap();
1568
1569 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1570 let config: Config = sourced.into_validated_unchecked().into();
1571
1572 let absolute_path = test_file.canonicalize().unwrap();
1574 let ignored = config.get_ignored_rules_for_file(&absolute_path);
1575 assert!(
1576 ignored.contains("MD041"),
1577 "Should match absolute path {absolute_path:?} against relative pattern"
1578 );
1579 assert_eq!(ignored.len(), 1);
1580
1581 let relative_path = PathBuf::from(".github/pull_request_template.md");
1583 let ignored = config.get_ignored_rules_for_file(&relative_path);
1584 assert!(ignored.contains("MD041"), "Should match relative path");
1585 }
1586
1587 #[test]
1592 fn test_per_file_flavor_config_parsing() {
1593 let temp_dir = tempdir().unwrap();
1594 let config_path = temp_dir.path().join(".rumdl.toml");
1595 let config_content = r#"
1596[per-file-flavor]
1597"docs/**/*.md" = "mkdocs"
1598"**/*.mdx" = "mdx"
1599"**/*.qmd" = "quarto"
1600"#;
1601 fs::write(&config_path, config_content).unwrap();
1602
1603 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1604 let config: Config = sourced.into_validated_unchecked().into();
1605
1606 assert_eq!(config.per_file_flavor.len(), 3);
1608 assert_eq!(
1609 config.per_file_flavor.get("docs/**/*.md"),
1610 Some(&MarkdownFlavor::MkDocs)
1611 );
1612 assert_eq!(config.per_file_flavor.get("**/*.mdx"), Some(&MarkdownFlavor::MDX));
1613 assert_eq!(config.per_file_flavor.get("**/*.qmd"), Some(&MarkdownFlavor::Quarto));
1614 }
1615
1616 #[test]
1617 fn test_per_file_flavor_glob_matching() {
1618 use std::path::PathBuf;
1619
1620 let temp_dir = tempdir().unwrap();
1621 let config_path = temp_dir.path().join(".rumdl.toml");
1622 let config_content = r#"
1623[per-file-flavor]
1624"docs/**/*.md" = "mkdocs"
1625"**/*.mdx" = "mdx"
1626"components/**/*.md" = "mdx"
1627"#;
1628 fs::write(&config_path, config_content).unwrap();
1629
1630 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1631 let config: Config = sourced.into_validated_unchecked().into();
1632
1633 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/overview.md"));
1635 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1636
1637 let flavor = config.get_flavor_for_file(&PathBuf::from("src/components/Button.mdx"));
1639 assert_eq!(flavor, MarkdownFlavor::MDX);
1640
1641 let flavor = config.get_flavor_for_file(&PathBuf::from("components/Button/README.md"));
1643 assert_eq!(flavor, MarkdownFlavor::MDX);
1644
1645 let flavor = config.get_flavor_for_file(&PathBuf::from("README.md"));
1647 assert_eq!(flavor, MarkdownFlavor::Standard);
1648 }
1649
1650 #[test]
1651 fn test_per_file_flavor_pyproject_toml() {
1652 let temp_dir = tempdir().unwrap();
1653 let config_path = temp_dir.path().join("pyproject.toml");
1654 let config_content = r#"
1655[tool.rumdl]
1656[tool.rumdl.per-file-flavor]
1657"docs/**/*.md" = "mkdocs"
1658"**/*.mdx" = "mdx"
1659"#;
1660 fs::write(&config_path, config_content).unwrap();
1661
1662 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1663 let config: Config = sourced.into_validated_unchecked().into();
1664
1665 assert_eq!(config.per_file_flavor.len(), 2);
1667 assert_eq!(
1668 config.per_file_flavor.get("docs/**/*.md"),
1669 Some(&MarkdownFlavor::MkDocs)
1670 );
1671 assert_eq!(config.per_file_flavor.get("**/*.mdx"), Some(&MarkdownFlavor::MDX));
1672 }
1673
1674 #[test]
1675 fn test_per_file_flavor_first_match_wins() {
1676 use std::path::PathBuf;
1677
1678 let temp_dir = tempdir().unwrap();
1679 let config_path = temp_dir.path().join(".rumdl.toml");
1680 let config_content = r#"
1682[per-file-flavor]
1683"docs/internal/**/*.md" = "quarto"
1684"docs/**/*.md" = "mkdocs"
1685"**/*.md" = "standard"
1686"#;
1687 fs::write(&config_path, config_content).unwrap();
1688
1689 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1690 let config: Config = sourced.into_validated_unchecked().into();
1691
1692 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/internal/secret.md"));
1694 assert_eq!(flavor, MarkdownFlavor::Quarto);
1695
1696 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/public/readme.md"));
1698 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1699
1700 let flavor = config.get_flavor_for_file(&PathBuf::from("other/file.md"));
1702 assert_eq!(flavor, MarkdownFlavor::Standard);
1703 }
1704
1705 #[test]
1706 fn test_per_file_flavor_overrides_global_flavor() {
1707 use std::path::PathBuf;
1708
1709 let temp_dir = tempdir().unwrap();
1710 let config_path = temp_dir.path().join(".rumdl.toml");
1711 let config_content = r#"
1712[global]
1713flavor = "mkdocs"
1714
1715[per-file-flavor]
1716"**/*.mdx" = "mdx"
1717"#;
1718 fs::write(&config_path, config_content).unwrap();
1719
1720 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1721 let config: Config = sourced.into_validated_unchecked().into();
1722
1723 let flavor = config.get_flavor_for_file(&PathBuf::from("components/Button.mdx"));
1725 assert_eq!(flavor, MarkdownFlavor::MDX);
1726
1727 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/readme.md"));
1729 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1730 }
1731
1732 #[test]
1733 fn test_per_file_flavor_empty_map() {
1734 use std::path::PathBuf;
1735
1736 let temp_dir = tempdir().unwrap();
1737 let config_path = temp_dir.path().join(".rumdl.toml");
1738 let config_content = r#"
1739[global]
1740disable = ["MD001"]
1741
1742[per-file-flavor]
1743"#;
1744 fs::write(&config_path, config_content).unwrap();
1745
1746 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1747 let config: Config = sourced.into_validated_unchecked().into();
1748
1749 let flavor = config.get_flavor_for_file(&PathBuf::from("README.md"));
1751 assert_eq!(flavor, MarkdownFlavor::Standard);
1752
1753 let flavor = config.get_flavor_for_file(&PathBuf::from("test.mdx"));
1755 assert_eq!(flavor, MarkdownFlavor::MDX);
1756 }
1757
1758 #[test]
1759 fn test_per_file_flavor_with_underscores() {
1760 let temp_dir = tempdir().unwrap();
1761 let config_path = temp_dir.path().join("pyproject.toml");
1762 let config_content = r#"
1763[tool.rumdl]
1764[tool.rumdl.per_file_flavor]
1765"docs/**/*.md" = "mkdocs"
1766"#;
1767 fs::write(&config_path, config_content).unwrap();
1768
1769 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1770 let config: Config = sourced.into_validated_unchecked().into();
1771
1772 assert_eq!(config.per_file_flavor.len(), 1);
1774 assert_eq!(
1775 config.per_file_flavor.get("docs/**/*.md"),
1776 Some(&MarkdownFlavor::MkDocs)
1777 );
1778 }
1779
1780 #[test]
1781 fn test_per_file_flavor_absolute_path_matching() {
1782 use std::path::PathBuf;
1783
1784 let temp_dir = tempdir().unwrap();
1785 let config_path = temp_dir.path().join(".rumdl.toml");
1786
1787 let docs_dir = temp_dir.path().join("docs");
1789 fs::create_dir_all(&docs_dir).unwrap();
1790 let test_file = docs_dir.join("guide.md");
1791 fs::write(&test_file, "Test content").unwrap();
1792
1793 let config_content = r#"
1794[per-file-flavor]
1795"docs/**/*.md" = "mkdocs"
1796"#;
1797 fs::write(&config_path, config_content).unwrap();
1798
1799 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1800 let config: Config = sourced.into_validated_unchecked().into();
1801
1802 let absolute_path = test_file.canonicalize().unwrap();
1804 let flavor = config.get_flavor_for_file(&absolute_path);
1805 assert_eq!(
1806 flavor,
1807 MarkdownFlavor::MkDocs,
1808 "Should match absolute path {absolute_path:?} against relative pattern"
1809 );
1810
1811 let relative_path = PathBuf::from("docs/guide.md");
1813 let flavor = config.get_flavor_for_file(&relative_path);
1814 assert_eq!(flavor, MarkdownFlavor::MkDocs, "Should match relative path");
1815 }
1816
1817 #[test]
1818 fn test_per_file_flavor_all_flavors() {
1819 let temp_dir = tempdir().unwrap();
1820 let config_path = temp_dir.path().join(".rumdl.toml");
1821 let config_content = r#"
1822[per-file-flavor]
1823"standard/**/*.md" = "standard"
1824"mkdocs/**/*.md" = "mkdocs"
1825"mdx/**/*.md" = "mdx"
1826"quarto/**/*.md" = "quarto"
1827"#;
1828 fs::write(&config_path, config_content).unwrap();
1829
1830 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1831 let config: Config = sourced.into_validated_unchecked().into();
1832
1833 assert_eq!(config.per_file_flavor.len(), 4);
1835 assert_eq!(
1836 config.per_file_flavor.get("standard/**/*.md"),
1837 Some(&MarkdownFlavor::Standard)
1838 );
1839 assert_eq!(
1840 config.per_file_flavor.get("mkdocs/**/*.md"),
1841 Some(&MarkdownFlavor::MkDocs)
1842 );
1843 assert_eq!(config.per_file_flavor.get("mdx/**/*.md"), Some(&MarkdownFlavor::MDX));
1844 assert_eq!(
1845 config.per_file_flavor.get("quarto/**/*.md"),
1846 Some(&MarkdownFlavor::Quarto)
1847 );
1848 }
1849
1850 #[test]
1851 fn test_per_file_flavor_invalid_glob_pattern() {
1852 use std::path::PathBuf;
1853
1854 let temp_dir = tempdir().unwrap();
1855 let config_path = temp_dir.path().join(".rumdl.toml");
1856 let config_content = r#"
1858[per-file-flavor]
1859"[invalid" = "mkdocs"
1860"valid/**/*.md" = "mdx"
1861"#;
1862 fs::write(&config_path, config_content).unwrap();
1863
1864 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1865 let config: Config = sourced.into_validated_unchecked().into();
1866
1867 let flavor = config.get_flavor_for_file(&PathBuf::from("valid/test.md"));
1869 assert_eq!(flavor, MarkdownFlavor::MDX);
1870
1871 let flavor = config.get_flavor_for_file(&PathBuf::from("other/test.md"));
1873 assert_eq!(flavor, MarkdownFlavor::Standard);
1874 }
1875
1876 #[test]
1877 fn test_per_file_flavor_paths_with_spaces() {
1878 use std::path::PathBuf;
1879
1880 let temp_dir = tempdir().unwrap();
1881 let config_path = temp_dir.path().join(".rumdl.toml");
1882 let config_content = r#"
1883[per-file-flavor]
1884"my docs/**/*.md" = "mkdocs"
1885"src/**/*.md" = "mdx"
1886"#;
1887 fs::write(&config_path, config_content).unwrap();
1888
1889 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1890 let config: Config = sourced.into_validated_unchecked().into();
1891
1892 let flavor = config.get_flavor_for_file(&PathBuf::from("my docs/guide.md"));
1894 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1895
1896 let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
1898 assert_eq!(flavor, MarkdownFlavor::MDX);
1899 }
1900
1901 #[test]
1902 fn test_per_file_flavor_deeply_nested_paths() {
1903 use std::path::PathBuf;
1904
1905 let temp_dir = tempdir().unwrap();
1906 let config_path = temp_dir.path().join(".rumdl.toml");
1907 let config_content = r#"
1908[per-file-flavor]
1909"a/b/c/d/e/**/*.md" = "quarto"
1910"a/b/**/*.md" = "mkdocs"
1911"**/*.md" = "standard"
1912"#;
1913 fs::write(&config_path, config_content).unwrap();
1914
1915 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1916 let config: Config = sourced.into_validated_unchecked().into();
1917
1918 let flavor = config.get_flavor_for_file(&PathBuf::from("a/b/c/d/e/f/deep.md"));
1920 assert_eq!(flavor, MarkdownFlavor::Quarto);
1921
1922 let flavor = config.get_flavor_for_file(&PathBuf::from("a/b/c/test.md"));
1924 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1925
1926 let flavor = config.get_flavor_for_file(&PathBuf::from("root.md"));
1928 assert_eq!(flavor, MarkdownFlavor::Standard);
1929 }
1930
1931 #[test]
1932 fn test_per_file_flavor_complex_overlapping_patterns() {
1933 use std::path::PathBuf;
1934
1935 let temp_dir = tempdir().unwrap();
1936 let config_path = temp_dir.path().join(".rumdl.toml");
1937 let config_content = r#"
1939[per-file-flavor]
1940"docs/api/*.md" = "mkdocs"
1941"docs/**/*.mdx" = "mdx"
1942"docs/**/*.md" = "quarto"
1943"**/*.md" = "standard"
1944"#;
1945 fs::write(&config_path, config_content).unwrap();
1946
1947 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1948 let config: Config = sourced.into_validated_unchecked().into();
1949
1950 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/reference.md"));
1952 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1953
1954 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/nested/file.md"));
1956 assert_eq!(flavor, MarkdownFlavor::Quarto);
1957
1958 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/components/Button.mdx"));
1960 assert_eq!(flavor, MarkdownFlavor::MDX);
1961
1962 let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
1964 assert_eq!(flavor, MarkdownFlavor::Standard);
1965 }
1966
1967 #[test]
1968 fn test_per_file_flavor_extension_detection_interaction() {
1969 use std::path::PathBuf;
1970
1971 let temp_dir = tempdir().unwrap();
1972 let config_path = temp_dir.path().join(".rumdl.toml");
1973 let config_content = r#"
1975[per-file-flavor]
1976"legacy/**/*.mdx" = "standard"
1977"#;
1978 fs::write(&config_path, config_content).unwrap();
1979
1980 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1981 let config: Config = sourced.into_validated_unchecked().into();
1982
1983 let flavor = config.get_flavor_for_file(&PathBuf::from("legacy/old.mdx"));
1985 assert_eq!(flavor, MarkdownFlavor::Standard);
1986
1987 let flavor = config.get_flavor_for_file(&PathBuf::from("src/component.mdx"));
1989 assert_eq!(flavor, MarkdownFlavor::MDX);
1990 }
1991
1992 #[test]
1993 fn test_per_file_flavor_standard_alias_none() {
1994 use std::path::PathBuf;
1995
1996 let temp_dir = tempdir().unwrap();
1997 let config_path = temp_dir.path().join(".rumdl.toml");
1998 let config_content = r#"
2000[per-file-flavor]
2001"plain/**/*.md" = "none"
2002"#;
2003 fs::write(&config_path, config_content).unwrap();
2004
2005 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2006 let config: Config = sourced.into_validated_unchecked().into();
2007
2008 let flavor = config.get_flavor_for_file(&PathBuf::from("plain/test.md"));
2010 assert_eq!(flavor, MarkdownFlavor::Standard);
2011 }
2012
2013 #[test]
2014 fn test_per_file_flavor_brace_expansion() {
2015 use std::path::PathBuf;
2016
2017 let temp_dir = tempdir().unwrap();
2018 let config_path = temp_dir.path().join(".rumdl.toml");
2019 let config_content = r#"
2021[per-file-flavor]
2022"docs/**/*.{md,mdx}" = "mkdocs"
2023"#;
2024 fs::write(&config_path, config_content).unwrap();
2025
2026 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2027 let config: Config = sourced.into_validated_unchecked().into();
2028
2029 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/guide.md"));
2031 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2032
2033 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/component.mdx"));
2035 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2036 }
2037
2038 #[test]
2039 fn test_per_file_flavor_single_star_vs_double_star() {
2040 use std::path::PathBuf;
2041
2042 let temp_dir = tempdir().unwrap();
2043 let config_path = temp_dir.path().join(".rumdl.toml");
2044 let config_content = r#"
2046[per-file-flavor]
2047"docs/*.md" = "mkdocs"
2048"src/**/*.md" = "mdx"
2049"#;
2050 fs::write(&config_path, config_content).unwrap();
2051
2052 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2053 let config: Config = sourced.into_validated_unchecked().into();
2054
2055 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/README.md"));
2057 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2058
2059 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/index.md"));
2061 assert_eq!(flavor, MarkdownFlavor::Standard); let flavor = config.get_flavor_for_file(&PathBuf::from("src/components/Button.md"));
2065 assert_eq!(flavor, MarkdownFlavor::MDX);
2066
2067 let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
2068 assert_eq!(flavor, MarkdownFlavor::MDX);
2069 }
2070
2071 #[test]
2072 fn test_per_file_flavor_question_mark_wildcard() {
2073 use std::path::PathBuf;
2074
2075 let temp_dir = tempdir().unwrap();
2076 let config_path = temp_dir.path().join(".rumdl.toml");
2077 let config_content = r#"
2079[per-file-flavor]
2080"docs/v?.md" = "mkdocs"
2081"#;
2082 fs::write(&config_path, config_content).unwrap();
2083
2084 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2085 let config: Config = sourced.into_validated_unchecked().into();
2086
2087 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v1.md"));
2089 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2090
2091 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v2.md"));
2092 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2093
2094 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v10.md"));
2096 assert_eq!(flavor, MarkdownFlavor::Standard);
2097
2098 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v.md"));
2100 assert_eq!(flavor, MarkdownFlavor::Standard);
2101 }
2102
2103 #[test]
2104 fn test_per_file_flavor_character_class() {
2105 use std::path::PathBuf;
2106
2107 let temp_dir = tempdir().unwrap();
2108 let config_path = temp_dir.path().join(".rumdl.toml");
2109 let config_content = r#"
2111[per-file-flavor]
2112"docs/[abc].md" = "mkdocs"
2113"#;
2114 fs::write(&config_path, config_content).unwrap();
2115
2116 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2117 let config: Config = sourced.into_validated_unchecked().into();
2118
2119 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/a.md"));
2121 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2122
2123 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/b.md"));
2124 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2125
2126 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/d.md"));
2128 assert_eq!(flavor, MarkdownFlavor::Standard);
2129 }
2130
2131 #[test]
2132 fn test_generate_json_schema() {
2133 use schemars::schema_for;
2134 use std::env;
2135
2136 let schema = schema_for!(Config);
2137 let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
2138
2139 if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
2141 let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
2142 fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
2143 println!("Schema written to: {}", schema_path.display());
2144 }
2145
2146 assert!(schema_json.contains("\"title\": \"Config\""));
2148 assert!(schema_json.contains("\"global\""));
2149 assert!(schema_json.contains("\"per-file-ignores\""));
2150 }
2151
2152 #[test]
2153 fn test_markdown_flavor_schema_matches_fromstr() {
2154 use schemars::schema_for;
2157
2158 let schema = schema_for!(MarkdownFlavor);
2159 let schema_json = serde_json::to_value(&schema).expect("Failed to serialize schema");
2160
2161 let enum_values = schema_json
2163 .get("enum")
2164 .expect("Schema should have 'enum' field")
2165 .as_array()
2166 .expect("enum should be an array");
2167
2168 assert!(!enum_values.is_empty(), "Schema enum should not be empty");
2169
2170 for value in enum_values {
2172 let str_value = value.as_str().expect("enum value should be a string");
2173 let result = str_value.parse::<MarkdownFlavor>();
2174 assert!(
2175 result.is_ok(),
2176 "Schema value '{str_value}' should be parseable by FromStr but got: {:?}",
2177 result.err()
2178 );
2179 }
2180
2181 for alias in ["", "none"] {
2183 let result = alias.parse::<MarkdownFlavor>();
2184 assert!(result.is_ok(), "FromStr alias '{alias}' should be parseable");
2185 }
2186 }
2187
2188 #[test]
2189 fn test_project_config_is_standalone() {
2190 let temp_dir = tempdir().unwrap();
2193
2194 let user_config_dir = temp_dir.path().join("user_config");
2197 let rumdl_config_dir = user_config_dir.join("rumdl");
2198 fs::create_dir_all(&rumdl_config_dir).unwrap();
2199 let user_config_path = rumdl_config_dir.join("rumdl.toml");
2200
2201 let user_config_content = r#"
2203[global]
2204disable = ["MD013", "MD041"]
2205line-length = 100
2206"#;
2207 fs::write(&user_config_path, user_config_content).unwrap();
2208
2209 let project_config_path = temp_dir.path().join("project").join("pyproject.toml");
2211 fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
2212 let project_config_content = r#"
2213[tool.rumdl]
2214enable = ["MD001"]
2215"#;
2216 fs::write(&project_config_path, project_config_content).unwrap();
2217
2218 let sourced = SourcedConfig::load_with_discovery_impl(
2220 Some(project_config_path.to_str().unwrap()),
2221 None,
2222 false,
2223 Some(&user_config_dir),
2224 )
2225 .unwrap();
2226
2227 let config: Config = sourced.into_validated_unchecked().into();
2228
2229 assert!(
2231 !config.global.disable.contains(&"MD013".to_string()),
2232 "User config should NOT be merged with project config"
2233 );
2234 assert!(
2235 !config.global.disable.contains(&"MD041".to_string()),
2236 "User config should NOT be merged with project config"
2237 );
2238
2239 assert!(
2241 config.global.enable.contains(&"MD001".to_string()),
2242 "Project config enabled rules should be applied"
2243 );
2244 }
2245
2246 #[test]
2247 fn test_user_config_as_fallback_when_no_project_config() {
2248 use std::env;
2250
2251 let temp_dir = tempdir().unwrap();
2252 let original_dir = env::current_dir().unwrap();
2253
2254 let user_config_dir = temp_dir.path().join("user_config");
2256 let rumdl_config_dir = user_config_dir.join("rumdl");
2257 fs::create_dir_all(&rumdl_config_dir).unwrap();
2258 let user_config_path = rumdl_config_dir.join("rumdl.toml");
2259
2260 let user_config_content = r#"
2262[global]
2263disable = ["MD013", "MD041"]
2264line-length = 88
2265"#;
2266 fs::write(&user_config_path, user_config_content).unwrap();
2267
2268 let project_dir = temp_dir.path().join("project_no_config");
2270 fs::create_dir_all(&project_dir).unwrap();
2271
2272 env::set_current_dir(&project_dir).unwrap();
2274
2275 let sourced = SourcedConfig::load_with_discovery_impl(None, None, false, Some(&user_config_dir)).unwrap();
2277
2278 let config: Config = sourced.into_validated_unchecked().into();
2279
2280 assert!(
2282 config.global.disable.contains(&"MD013".to_string()),
2283 "User config should be loaded as fallback when no project config"
2284 );
2285 assert!(
2286 config.global.disable.contains(&"MD041".to_string()),
2287 "User config should be loaded as fallback when no project config"
2288 );
2289 assert_eq!(
2290 config.global.line_length.get(),
2291 88,
2292 "User config line-length should be loaded as fallback"
2293 );
2294
2295 env::set_current_dir(original_dir).unwrap();
2296 }
2297
2298 #[test]
2299 fn test_typestate_validate_method() {
2300 use tempfile::tempdir;
2301
2302 let temp_dir = tempdir().expect("Failed to create temporary directory");
2303 let config_path = temp_dir.path().join("test.toml");
2304
2305 let config_content = r#"
2307[global]
2308enable = ["MD001"]
2309
2310[MD013]
2311line_length = 80
2312unknown_option = true
2313"#;
2314 std::fs::write(&config_path, config_content).expect("Failed to write config");
2315
2316 let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
2318 .expect("Should load config");
2319
2320 let default_config = Config::default();
2322 let all_rules = crate::rules::all_rules(&default_config);
2323 let registry = RuleRegistry::from_rules(&all_rules);
2324
2325 let validated = loaded.validate(®istry).expect("Should validate config");
2327
2328 let has_unknown_option_warning = validated
2331 .validation_warnings
2332 .iter()
2333 .any(|w| w.message.contains("unknown_option") || w.message.contains("Unknown option"));
2334
2335 if !has_unknown_option_warning {
2337 for w in &validated.validation_warnings {
2338 eprintln!("Warning: {}", w.message);
2339 }
2340 }
2341 assert!(
2342 has_unknown_option_warning,
2343 "Should have warning for unknown option. Got {} warnings: {:?}",
2344 validated.validation_warnings.len(),
2345 validated
2346 .validation_warnings
2347 .iter()
2348 .map(|w| &w.message)
2349 .collect::<Vec<_>>()
2350 );
2351
2352 let config: Config = validated.into();
2354
2355 assert!(config.global.enable.contains(&"MD001".to_string()));
2357 }
2358
2359 #[test]
2360 fn test_typestate_validate_into_convenience_method() {
2361 use tempfile::tempdir;
2362
2363 let temp_dir = tempdir().expect("Failed to create temporary directory");
2364 let config_path = temp_dir.path().join("test.toml");
2365
2366 let config_content = r#"
2367[global]
2368enable = ["MD022"]
2369
2370[MD022]
2371lines_above = 2
2372"#;
2373 std::fs::write(&config_path, config_content).expect("Failed to write config");
2374
2375 let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
2376 .expect("Should load config");
2377
2378 let default_config = Config::default();
2379 let all_rules = crate::rules::all_rules(&default_config);
2380 let registry = RuleRegistry::from_rules(&all_rules);
2381
2382 let (config, warnings) = loaded.validate_into(®istry).expect("Should validate and convert");
2384
2385 assert!(warnings.is_empty(), "Should have no warnings for valid config");
2387
2388 assert!(config.global.enable.contains(&"MD022".to_string()));
2390 }
2391
2392 #[test]
2393 fn test_resolve_rule_name_canonical() {
2394 assert_eq!(resolve_rule_name("MD001"), "MD001");
2396 assert_eq!(resolve_rule_name("MD013"), "MD013");
2397 assert_eq!(resolve_rule_name("MD069"), "MD069");
2398 }
2399
2400 #[test]
2401 fn test_resolve_rule_name_aliases() {
2402 assert_eq!(resolve_rule_name("heading-increment"), "MD001");
2404 assert_eq!(resolve_rule_name("line-length"), "MD013");
2405 assert_eq!(resolve_rule_name("no-bare-urls"), "MD034");
2406 assert_eq!(resolve_rule_name("ul-style"), "MD004");
2407 }
2408
2409 #[test]
2410 fn test_resolve_rule_name_case_insensitive() {
2411 assert_eq!(resolve_rule_name("HEADING-INCREMENT"), "MD001");
2413 assert_eq!(resolve_rule_name("Heading-Increment"), "MD001");
2414 assert_eq!(resolve_rule_name("md001"), "MD001");
2415 assert_eq!(resolve_rule_name("MD001"), "MD001");
2416 }
2417
2418 #[test]
2419 fn test_resolve_rule_name_underscore_to_hyphen() {
2420 assert_eq!(resolve_rule_name("heading_increment"), "MD001");
2422 assert_eq!(resolve_rule_name("line_length"), "MD013");
2423 assert_eq!(resolve_rule_name("no_bare_urls"), "MD034");
2424 }
2425
2426 #[test]
2427 fn test_resolve_rule_name_unknown() {
2428 assert_eq!(resolve_rule_name("custom-rule"), "custom-rule");
2430 assert_eq!(resolve_rule_name("CUSTOM_RULE"), "custom-rule");
2431 assert_eq!(resolve_rule_name("md999"), "MD999"); }
2433
2434 #[test]
2435 fn test_resolve_rule_names_basic() {
2436 let result = resolve_rule_names("MD001,line-length,heading-increment");
2437 assert!(result.contains("MD001"));
2438 assert!(result.contains("MD013")); assert_eq!(result.len(), 2);
2441 }
2442
2443 #[test]
2444 fn test_resolve_rule_names_with_whitespace() {
2445 let result = resolve_rule_names(" MD001 , line-length , MD034 ");
2446 assert!(result.contains("MD001"));
2447 assert!(result.contains("MD013"));
2448 assert!(result.contains("MD034"));
2449 assert_eq!(result.len(), 3);
2450 }
2451
2452 #[test]
2453 fn test_resolve_rule_names_empty_entries() {
2454 let result = resolve_rule_names("MD001,,MD013,");
2455 assert!(result.contains("MD001"));
2456 assert!(result.contains("MD013"));
2457 assert_eq!(result.len(), 2);
2458 }
2459
2460 #[test]
2461 fn test_resolve_rule_names_empty_string() {
2462 let result = resolve_rule_names("");
2463 assert!(result.is_empty());
2464 }
2465
2466 #[test]
2467 fn test_resolve_rule_names_mixed() {
2468 let result = resolve_rule_names("MD001,line-length,custom-rule");
2470 assert!(result.contains("MD001"));
2471 assert!(result.contains("MD013"));
2472 assert!(result.contains("custom-rule"));
2473 assert_eq!(result.len(), 3);
2474 }
2475
2476 #[test]
2481 fn test_is_valid_rule_name_canonical() {
2482 assert!(is_valid_rule_name("MD001"));
2484 assert!(is_valid_rule_name("MD013"));
2485 assert!(is_valid_rule_name("MD041"));
2486 assert!(is_valid_rule_name("MD069"));
2487
2488 assert!(is_valid_rule_name("md001"));
2490 assert!(is_valid_rule_name("Md001"));
2491 assert!(is_valid_rule_name("mD001"));
2492 }
2493
2494 #[test]
2495 fn test_is_valid_rule_name_aliases() {
2496 assert!(is_valid_rule_name("line-length"));
2498 assert!(is_valid_rule_name("heading-increment"));
2499 assert!(is_valid_rule_name("no-bare-urls"));
2500 assert!(is_valid_rule_name("ul-style"));
2501
2502 assert!(is_valid_rule_name("LINE-LENGTH"));
2504 assert!(is_valid_rule_name("Line-Length"));
2505
2506 assert!(is_valid_rule_name("line_length"));
2508 assert!(is_valid_rule_name("ul_style"));
2509 }
2510
2511 #[test]
2512 fn test_is_valid_rule_name_special_all() {
2513 assert!(is_valid_rule_name("all"));
2514 assert!(is_valid_rule_name("ALL"));
2515 assert!(is_valid_rule_name("All"));
2516 assert!(is_valid_rule_name("aLl"));
2517 }
2518
2519 #[test]
2520 fn test_is_valid_rule_name_invalid() {
2521 assert!(!is_valid_rule_name("MD000"));
2523 assert!(!is_valid_rule_name("MD002")); assert!(!is_valid_rule_name("MD006")); assert!(!is_valid_rule_name("MD999"));
2526 assert!(!is_valid_rule_name("MD100"));
2527
2528 assert!(!is_valid_rule_name(""));
2530 assert!(!is_valid_rule_name("INVALID"));
2531 assert!(!is_valid_rule_name("not-a-rule"));
2532 assert!(!is_valid_rule_name("random-text"));
2533 assert!(!is_valid_rule_name("abc"));
2534
2535 assert!(!is_valid_rule_name("MD"));
2537 assert!(!is_valid_rule_name("MD1"));
2538 assert!(!is_valid_rule_name("MD12"));
2539 }
2540
2541 #[test]
2542 fn test_validate_cli_rule_names_valid() {
2543 let warnings = validate_cli_rule_names(
2545 Some("MD001,MD013"),
2546 Some("line-length"),
2547 Some("heading-increment"),
2548 Some("all"),
2549 );
2550 assert!(warnings.is_empty(), "Expected no warnings for valid rules");
2551 }
2552
2553 #[test]
2554 fn test_validate_cli_rule_names_invalid() {
2555 let warnings = validate_cli_rule_names(Some("abc"), None, None, None);
2557 assert_eq!(warnings.len(), 1);
2558 assert!(warnings[0].message.contains("Unknown rule in --enable: abc"));
2559
2560 let warnings = validate_cli_rule_names(None, Some("xyz"), None, None);
2562 assert_eq!(warnings.len(), 1);
2563 assert!(warnings[0].message.contains("Unknown rule in --disable: xyz"));
2564
2565 let warnings = validate_cli_rule_names(None, None, Some("nonexistent"), None);
2567 assert_eq!(warnings.len(), 1);
2568 assert!(
2569 warnings[0]
2570 .message
2571 .contains("Unknown rule in --extend-enable: nonexistent")
2572 );
2573
2574 let warnings = validate_cli_rule_names(None, None, None, Some("fake-rule"));
2576 assert_eq!(warnings.len(), 1);
2577 assert!(
2578 warnings[0]
2579 .message
2580 .contains("Unknown rule in --extend-disable: fake-rule")
2581 );
2582 }
2583
2584 #[test]
2585 fn test_validate_cli_rule_names_mixed() {
2586 let warnings = validate_cli_rule_names(Some("MD001,abc,MD003"), None, None, None);
2588 assert_eq!(warnings.len(), 1);
2589 assert!(warnings[0].message.contains("abc"));
2590 }
2591
2592 #[test]
2593 fn test_validate_cli_rule_names_suggestions() {
2594 let warnings = validate_cli_rule_names(Some("line-lenght"), None, None, None);
2596 assert_eq!(warnings.len(), 1);
2597 assert!(warnings[0].message.contains("did you mean"));
2598 assert!(warnings[0].message.contains("line-length"));
2599 }
2600
2601 #[test]
2602 fn test_validate_cli_rule_names_none() {
2603 let warnings = validate_cli_rule_names(None, None, None, None);
2605 assert!(warnings.is_empty());
2606 }
2607
2608 #[test]
2609 fn test_validate_cli_rule_names_empty_string() {
2610 let warnings = validate_cli_rule_names(Some(""), Some(""), Some(""), Some(""));
2612 assert!(warnings.is_empty());
2613 }
2614
2615 #[test]
2616 fn test_validate_cli_rule_names_whitespace() {
2617 let warnings = validate_cli_rule_names(Some(" MD001 , MD013 "), None, None, None);
2619 assert!(warnings.is_empty(), "Whitespace should be trimmed");
2620 }
2621
2622 #[test]
2623 fn test_all_implemented_rules_have_aliases() {
2624 let config = crate::config::Config::default();
2631 let all_rules = crate::rules::all_rules(&config);
2632
2633 let mut missing_rules = Vec::new();
2634 for rule in &all_rules {
2635 let rule_name = rule.name();
2636 if resolve_rule_name_alias(rule_name).is_none() {
2638 missing_rules.push(rule_name.to_string());
2639 }
2640 }
2641
2642 assert!(
2643 missing_rules.is_empty(),
2644 "The following rules are missing from RULE_ALIAS_MAP: {:?}\n\
2645 Add entries like:\n\
2646 - Canonical: \"{}\" => \"{}\"\n\
2647 - Alias: \"RULE-NAME-HERE\" => \"{}\"",
2648 missing_rules,
2649 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2650 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2651 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2652 );
2653 }
2654
2655 #[test]
2658 fn test_relative_path_in_cwd() {
2659 let cwd = std::env::current_dir().unwrap();
2661 let test_path = cwd.join("test_file.md");
2662 fs::write(&test_path, "test").unwrap();
2663
2664 let result = super::to_relative_display_path(test_path.to_str().unwrap());
2665
2666 assert_eq!(result, "test_file.md");
2668
2669 fs::remove_file(&test_path).unwrap();
2671 }
2672
2673 #[test]
2674 fn test_relative_path_in_subdirectory() {
2675 let cwd = std::env::current_dir().unwrap();
2677 let subdir = cwd.join("test_subdir_for_relative_path");
2678 fs::create_dir_all(&subdir).unwrap();
2679 let test_path = subdir.join("test_file.md");
2680 fs::write(&test_path, "test").unwrap();
2681
2682 let result = super::to_relative_display_path(test_path.to_str().unwrap());
2683
2684 assert_eq!(result, "test_subdir_for_relative_path/test_file.md");
2686
2687 fs::remove_file(&test_path).unwrap();
2689 fs::remove_dir(&subdir).unwrap();
2690 }
2691
2692 #[test]
2693 fn test_relative_path_outside_cwd_returns_original() {
2694 let outside_path = "/tmp/definitely_not_in_cwd_test.md";
2696
2697 let result = super::to_relative_display_path(outside_path);
2698
2699 let cwd = std::env::current_dir().unwrap();
2702 if !cwd.starts_with("/tmp") {
2703 assert_eq!(result, outside_path);
2704 }
2705 }
2706
2707 #[test]
2708 fn test_relative_path_already_relative() {
2709 let relative_path = "some/relative/path.md";
2711
2712 let result = super::to_relative_display_path(relative_path);
2713
2714 assert_eq!(result, relative_path);
2716 }
2717
2718 #[test]
2719 fn test_relative_path_with_dot_components() {
2720 let cwd = std::env::current_dir().unwrap();
2722 let test_path = cwd.join("test_dot_component.md");
2723 fs::write(&test_path, "test").unwrap();
2724
2725 let dotted_path = cwd.join(".").join("test_dot_component.md");
2727 let result = super::to_relative_display_path(dotted_path.to_str().unwrap());
2728
2729 assert_eq!(result, "test_dot_component.md");
2731
2732 fs::remove_file(&test_path).unwrap();
2734 }
2735
2736 #[test]
2737 fn test_relative_path_empty_string() {
2738 let result = super::to_relative_display_path("");
2739
2740 assert_eq!(result, "");
2742 }
2743}
2744
2745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2754pub enum ConfigSource {
2755 Default,
2757 UserConfig,
2759 PyprojectToml,
2761 ProjectConfig,
2763 Cli,
2765}
2766
2767#[derive(Debug, Clone)]
2768pub struct ConfigOverride<T> {
2769 pub value: T,
2770 pub source: ConfigSource,
2771 pub file: Option<String>,
2772 pub line: Option<usize>,
2773}
2774
2775#[derive(Debug, Clone)]
2776pub struct SourcedValue<T> {
2777 pub value: T,
2778 pub source: ConfigSource,
2779 pub overrides: Vec<ConfigOverride<T>>,
2780}
2781
2782impl<T: Clone> SourcedValue<T> {
2783 pub fn new(value: T, source: ConfigSource) -> Self {
2784 Self {
2785 value: value.clone(),
2786 source,
2787 overrides: vec![ConfigOverride {
2788 value,
2789 source,
2790 file: None,
2791 line: None,
2792 }],
2793 }
2794 }
2795
2796 pub fn merge_override(
2800 &mut self,
2801 new_value: T,
2802 new_source: ConfigSource,
2803 new_file: Option<String>,
2804 new_line: Option<usize>,
2805 ) {
2806 fn source_precedence(src: ConfigSource) -> u8 {
2808 match src {
2809 ConfigSource::Default => 0,
2810 ConfigSource::UserConfig => 1,
2811 ConfigSource::PyprojectToml => 2,
2812 ConfigSource::ProjectConfig => 3,
2813 ConfigSource::Cli => 4,
2814 }
2815 }
2816
2817 if source_precedence(new_source) >= source_precedence(self.source) {
2818 self.value = new_value.clone();
2819 self.source = new_source;
2820 self.overrides.push(ConfigOverride {
2821 value: new_value,
2822 source: new_source,
2823 file: new_file,
2824 line: new_line,
2825 });
2826 }
2827 }
2828
2829 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
2830 self.value = value.clone();
2833 self.source = source;
2834 self.overrides.push(ConfigOverride {
2835 value,
2836 source,
2837 file,
2838 line,
2839 });
2840 }
2841}
2842
2843impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
2844 pub fn merge_union(
2847 &mut self,
2848 new_value: Vec<T>,
2849 new_source: ConfigSource,
2850 new_file: Option<String>,
2851 new_line: Option<usize>,
2852 ) {
2853 fn source_precedence(src: ConfigSource) -> u8 {
2854 match src {
2855 ConfigSource::Default => 0,
2856 ConfigSource::UserConfig => 1,
2857 ConfigSource::PyprojectToml => 2,
2858 ConfigSource::ProjectConfig => 3,
2859 ConfigSource::Cli => 4,
2860 }
2861 }
2862
2863 if source_precedence(new_source) >= source_precedence(self.source) {
2864 let mut combined = self.value.clone();
2866 for item in new_value.iter() {
2867 if !combined.contains(item) {
2868 combined.push(item.clone());
2869 }
2870 }
2871
2872 self.value = combined;
2873 self.source = new_source;
2874 self.overrides.push(ConfigOverride {
2875 value: new_value,
2876 source: new_source,
2877 file: new_file,
2878 line: new_line,
2879 });
2880 }
2881 }
2882}
2883
2884#[derive(Debug, Clone)]
2885pub struct SourcedGlobalConfig {
2886 pub enable: SourcedValue<Vec<String>>,
2887 pub disable: SourcedValue<Vec<String>>,
2888 pub exclude: SourcedValue<Vec<String>>,
2889 pub include: SourcedValue<Vec<String>>,
2890 pub respect_gitignore: SourcedValue<bool>,
2891 pub line_length: SourcedValue<LineLength>,
2892 pub output_format: Option<SourcedValue<String>>,
2893 pub fixable: SourcedValue<Vec<String>>,
2894 pub unfixable: SourcedValue<Vec<String>>,
2895 pub flavor: SourcedValue<MarkdownFlavor>,
2896 pub force_exclude: SourcedValue<bool>,
2897 pub cache_dir: Option<SourcedValue<String>>,
2898 pub cache: SourcedValue<bool>,
2899}
2900
2901impl Default for SourcedGlobalConfig {
2902 fn default() -> Self {
2903 SourcedGlobalConfig {
2904 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2905 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2906 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
2907 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
2908 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
2909 line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
2910 output_format: None,
2911 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2912 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2913 flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
2914 force_exclude: SourcedValue::new(false, ConfigSource::Default),
2915 cache_dir: None,
2916 cache: SourcedValue::new(true, ConfigSource::Default),
2917 }
2918 }
2919}
2920
2921#[derive(Debug, Default, Clone)]
2922pub struct SourcedRuleConfig {
2923 pub severity: Option<SourcedValue<crate::rule::Severity>>,
2924 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
2925}
2926
2927#[derive(Debug, Clone)]
2930pub struct SourcedConfigFragment {
2931 pub global: SourcedGlobalConfig,
2932 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2933 pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
2934 pub rules: BTreeMap<String, SourcedRuleConfig>,
2935 pub unknown_keys: Vec<(String, String, Option<String>)>, }
2938
2939impl Default for SourcedConfigFragment {
2940 fn default() -> Self {
2941 Self {
2942 global: SourcedGlobalConfig::default(),
2943 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2944 per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
2945 rules: BTreeMap::new(),
2946 unknown_keys: Vec::new(),
2947 }
2948 }
2949}
2950
2951#[derive(Debug, Clone)]
2969pub struct SourcedConfig<State = ConfigLoaded> {
2970 pub global: SourcedGlobalConfig,
2971 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2972 pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
2973 pub rules: BTreeMap<String, SourcedRuleConfig>,
2974 pub loaded_files: Vec<String>,
2975 pub unknown_keys: Vec<(String, String, Option<String>)>, pub project_root: Option<std::path::PathBuf>,
2978 pub validation_warnings: Vec<ConfigValidationWarning>,
2980 _state: PhantomData<State>,
2982}
2983
2984impl Default for SourcedConfig<ConfigLoaded> {
2985 fn default() -> Self {
2986 Self {
2987 global: SourcedGlobalConfig::default(),
2988 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2989 per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
2990 rules: BTreeMap::new(),
2991 loaded_files: Vec::new(),
2992 unknown_keys: Vec::new(),
2993 project_root: None,
2994 validation_warnings: Vec::new(),
2995 _state: PhantomData,
2996 }
2997 }
2998}
2999
3000impl SourcedConfig<ConfigLoaded> {
3001 fn merge(&mut self, fragment: SourcedConfigFragment) {
3004 self.global.enable.merge_override(
3007 fragment.global.enable.value,
3008 fragment.global.enable.source,
3009 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
3010 fragment.global.enable.overrides.first().and_then(|o| o.line),
3011 );
3012
3013 self.global.disable.merge_union(
3015 fragment.global.disable.value,
3016 fragment.global.disable.source,
3017 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
3018 fragment.global.disable.overrides.first().and_then(|o| o.line),
3019 );
3020
3021 self.global
3024 .disable
3025 .value
3026 .retain(|rule| !self.global.enable.value.contains(rule));
3027 self.global.include.merge_override(
3028 fragment.global.include.value,
3029 fragment.global.include.source,
3030 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
3031 fragment.global.include.overrides.first().and_then(|o| o.line),
3032 );
3033 self.global.exclude.merge_override(
3034 fragment.global.exclude.value,
3035 fragment.global.exclude.source,
3036 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
3037 fragment.global.exclude.overrides.first().and_then(|o| o.line),
3038 );
3039 self.global.respect_gitignore.merge_override(
3040 fragment.global.respect_gitignore.value,
3041 fragment.global.respect_gitignore.source,
3042 fragment
3043 .global
3044 .respect_gitignore
3045 .overrides
3046 .first()
3047 .and_then(|o| o.file.clone()),
3048 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
3049 );
3050 self.global.line_length.merge_override(
3051 fragment.global.line_length.value,
3052 fragment.global.line_length.source,
3053 fragment
3054 .global
3055 .line_length
3056 .overrides
3057 .first()
3058 .and_then(|o| o.file.clone()),
3059 fragment.global.line_length.overrides.first().and_then(|o| o.line),
3060 );
3061 self.global.fixable.merge_override(
3062 fragment.global.fixable.value,
3063 fragment.global.fixable.source,
3064 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
3065 fragment.global.fixable.overrides.first().and_then(|o| o.line),
3066 );
3067 self.global.unfixable.merge_override(
3068 fragment.global.unfixable.value,
3069 fragment.global.unfixable.source,
3070 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
3071 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
3072 );
3073
3074 self.global.flavor.merge_override(
3076 fragment.global.flavor.value,
3077 fragment.global.flavor.source,
3078 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
3079 fragment.global.flavor.overrides.first().and_then(|o| o.line),
3080 );
3081
3082 self.global.force_exclude.merge_override(
3084 fragment.global.force_exclude.value,
3085 fragment.global.force_exclude.source,
3086 fragment
3087 .global
3088 .force_exclude
3089 .overrides
3090 .first()
3091 .and_then(|o| o.file.clone()),
3092 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
3093 );
3094
3095 if let Some(output_format_fragment) = fragment.global.output_format {
3097 if let Some(ref mut output_format) = self.global.output_format {
3098 output_format.merge_override(
3099 output_format_fragment.value,
3100 output_format_fragment.source,
3101 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
3102 output_format_fragment.overrides.first().and_then(|o| o.line),
3103 );
3104 } else {
3105 self.global.output_format = Some(output_format_fragment);
3106 }
3107 }
3108
3109 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
3111 if let Some(ref mut cache_dir) = self.global.cache_dir {
3112 cache_dir.merge_override(
3113 cache_dir_fragment.value,
3114 cache_dir_fragment.source,
3115 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
3116 cache_dir_fragment.overrides.first().and_then(|o| o.line),
3117 );
3118 } else {
3119 self.global.cache_dir = Some(cache_dir_fragment);
3120 }
3121 }
3122
3123 if fragment.global.cache.source != ConfigSource::Default {
3125 self.global.cache.merge_override(
3126 fragment.global.cache.value,
3127 fragment.global.cache.source,
3128 fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
3129 fragment.global.cache.overrides.first().and_then(|o| o.line),
3130 );
3131 }
3132
3133 self.per_file_ignores.merge_override(
3135 fragment.per_file_ignores.value,
3136 fragment.per_file_ignores.source,
3137 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
3138 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
3139 );
3140
3141 self.per_file_flavor.merge_override(
3143 fragment.per_file_flavor.value,
3144 fragment.per_file_flavor.source,
3145 fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
3146 fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
3147 );
3148
3149 for (rule_name, rule_fragment) in fragment.rules {
3151 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
3153
3154 if let Some(severity_fragment) = rule_fragment.severity {
3156 if let Some(ref mut existing_severity) = rule_entry.severity {
3157 existing_severity.merge_override(
3158 severity_fragment.value,
3159 severity_fragment.source,
3160 severity_fragment.overrides.first().and_then(|o| o.file.clone()),
3161 severity_fragment.overrides.first().and_then(|o| o.line),
3162 );
3163 } else {
3164 rule_entry.severity = Some(severity_fragment);
3165 }
3166 }
3167
3168 for (key, sourced_value_fragment) in rule_fragment.values {
3170 let sv_entry = rule_entry
3171 .values
3172 .entry(key.clone())
3173 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
3174 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
3175 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
3176 sv_entry.merge_override(
3177 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
3182 }
3183 }
3184
3185 for (section, key, file_path) in fragment.unknown_keys {
3187 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
3189 self.unknown_keys.push((section, key, file_path));
3190 }
3191 }
3192 }
3193
3194 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
3196 Self::load_with_discovery(config_path, cli_overrides, false)
3197 }
3198
3199 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
3202 let mut current = if start_dir.is_relative() {
3204 std::env::current_dir()
3205 .map(|cwd| cwd.join(start_dir))
3206 .unwrap_or_else(|_| start_dir.to_path_buf())
3207 } else {
3208 start_dir.to_path_buf()
3209 };
3210 const MAX_DEPTH: usize = 100;
3211
3212 for _ in 0..MAX_DEPTH {
3213 if current.join(".git").exists() {
3214 log::debug!("[rumdl-config] Found .git at: {}", current.display());
3215 return current;
3216 }
3217
3218 match current.parent() {
3219 Some(parent) => current = parent.to_path_buf(),
3220 None => break,
3221 }
3222 }
3223
3224 log::debug!(
3226 "[rumdl-config] No .git found, using config location as project root: {}",
3227 start_dir.display()
3228 );
3229 start_dir.to_path_buf()
3230 }
3231
3232 fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
3238 use std::env;
3239
3240 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
3241 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
3244 Ok(dir) => dir,
3245 Err(e) => {
3246 log::debug!("[rumdl-config] Failed to get current directory: {e}");
3247 return None;
3248 }
3249 };
3250
3251 let mut current_dir = start_dir.clone();
3252 let mut depth = 0;
3253 let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3254
3255 loop {
3256 if depth >= MAX_DEPTH {
3257 log::debug!("[rumdl-config] Maximum traversal depth reached");
3258 break;
3259 }
3260
3261 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
3262
3263 if found_config.is_none() {
3265 for config_name in CONFIG_FILES {
3266 let config_path = current_dir.join(config_name);
3267
3268 if config_path.exists() {
3269 if *config_name == "pyproject.toml" {
3271 if let Ok(content) = std::fs::read_to_string(&config_path) {
3272 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
3273 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
3274 found_config = Some((config_path.clone(), current_dir.clone()));
3276 break;
3277 }
3278 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
3279 continue;
3280 }
3281 } else {
3282 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
3283 found_config = Some((config_path.clone(), current_dir.clone()));
3285 break;
3286 }
3287 }
3288 }
3289 }
3290
3291 if current_dir.join(".git").exists() {
3293 log::debug!("[rumdl-config] Stopping at .git directory");
3294 break;
3295 }
3296
3297 match current_dir.parent() {
3299 Some(parent) => {
3300 current_dir = parent.to_owned();
3301 depth += 1;
3302 }
3303 None => {
3304 log::debug!("[rumdl-config] Reached filesystem root");
3305 break;
3306 }
3307 }
3308 }
3309
3310 if let Some((config_path, config_dir)) = found_config {
3312 let project_root = Self::find_project_root_from(&config_dir);
3313 return Some((config_path, project_root));
3314 }
3315
3316 None
3317 }
3318
3319 fn discover_markdownlint_config_upward() -> Option<std::path::PathBuf> {
3323 use std::env;
3324
3325 const MAX_DEPTH: usize = 100;
3326
3327 let start_dir = match env::current_dir() {
3328 Ok(dir) => dir,
3329 Err(e) => {
3330 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
3331 return None;
3332 }
3333 };
3334
3335 let mut current_dir = start_dir.clone();
3336 let mut depth = 0;
3337
3338 loop {
3339 if depth >= MAX_DEPTH {
3340 log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
3341 break;
3342 }
3343
3344 log::debug!(
3345 "[rumdl-config] Searching for markdownlint config in: {}",
3346 current_dir.display()
3347 );
3348
3349 for config_name in MARKDOWNLINT_CONFIG_FILES {
3351 let config_path = current_dir.join(config_name);
3352 if config_path.exists() {
3353 log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
3354 return Some(config_path);
3355 }
3356 }
3357
3358 if current_dir.join(".git").exists() {
3360 log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
3361 break;
3362 }
3363
3364 match current_dir.parent() {
3366 Some(parent) => {
3367 current_dir = parent.to_owned();
3368 depth += 1;
3369 }
3370 None => {
3371 log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
3372 break;
3373 }
3374 }
3375 }
3376
3377 None
3378 }
3379
3380 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
3382 let config_dir = config_dir.join("rumdl");
3383
3384 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
3386
3387 log::debug!(
3388 "[rumdl-config] Checking for user configuration in: {}",
3389 config_dir.display()
3390 );
3391
3392 for filename in USER_CONFIG_FILES {
3393 let config_path = config_dir.join(filename);
3394
3395 if config_path.exists() {
3396 if *filename == "pyproject.toml" {
3398 if let Ok(content) = std::fs::read_to_string(&config_path) {
3399 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
3400 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
3401 return Some(config_path);
3402 }
3403 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
3404 continue;
3405 }
3406 } else {
3407 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
3408 return Some(config_path);
3409 }
3410 }
3411 }
3412
3413 log::debug!(
3414 "[rumdl-config] No user configuration found in: {}",
3415 config_dir.display()
3416 );
3417 None
3418 }
3419
3420 #[cfg(feature = "native")]
3423 fn user_configuration_path() -> Option<std::path::PathBuf> {
3424 use etcetera::{BaseStrategy, choose_base_strategy};
3425
3426 match choose_base_strategy() {
3427 Ok(strategy) => {
3428 let config_dir = strategy.config_dir();
3429 Self::user_configuration_path_impl(&config_dir)
3430 }
3431 Err(e) => {
3432 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
3433 None
3434 }
3435 }
3436 }
3437
3438 #[cfg(not(feature = "native"))]
3440 fn user_configuration_path() -> Option<std::path::PathBuf> {
3441 None
3442 }
3443
3444 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
3446 let path_obj = Path::new(path);
3447 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
3448 let path_str = path.to_string();
3449
3450 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
3451
3452 if let Some(config_parent) = path_obj.parent() {
3454 let project_root = Self::find_project_root_from(config_parent);
3455 log::debug!(
3456 "[rumdl-config] Project root (from explicit config): {}",
3457 project_root.display()
3458 );
3459 sourced_config.project_root = Some(project_root);
3460 }
3461
3462 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
3464
3465 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
3466 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
3467 source: e,
3468 path: path_str.clone(),
3469 })?;
3470 if filename == "pyproject.toml" {
3471 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3472 sourced_config.merge(fragment);
3473 sourced_config.loaded_files.push(path_str);
3474 }
3475 } else {
3476 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3477 sourced_config.merge(fragment);
3478 sourced_config.loaded_files.push(path_str);
3479 }
3480 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
3481 || path_str.ends_with(".json")
3482 || path_str.ends_with(".jsonc")
3483 || path_str.ends_with(".yaml")
3484 || path_str.ends_with(".yml")
3485 {
3486 let fragment = load_from_markdownlint(&path_str)?;
3488 sourced_config.merge(fragment);
3489 sourced_config.loaded_files.push(path_str);
3490 } else {
3491 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
3493 source: e,
3494 path: path_str.clone(),
3495 })?;
3496 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3497 sourced_config.merge(fragment);
3498 sourced_config.loaded_files.push(path_str);
3499 }
3500
3501 Ok(())
3502 }
3503
3504 fn load_user_config_as_fallback(
3506 sourced_config: &mut Self,
3507 user_config_dir: Option<&Path>,
3508 ) -> Result<(), ConfigError> {
3509 let user_config_path = if let Some(dir) = user_config_dir {
3510 Self::user_configuration_path_impl(dir)
3511 } else {
3512 Self::user_configuration_path()
3513 };
3514
3515 if let Some(user_config_path) = user_config_path {
3516 let path_str = user_config_path.display().to_string();
3517 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
3518
3519 log::debug!("[rumdl-config] Loading user config as fallback: {path_str}");
3520
3521 if filename == "pyproject.toml" {
3522 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
3523 source: e,
3524 path: path_str.clone(),
3525 })?;
3526 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3527 sourced_config.merge(fragment);
3528 sourced_config.loaded_files.push(path_str);
3529 }
3530 } else {
3531 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
3532 source: e,
3533 path: path_str.clone(),
3534 })?;
3535 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
3536 sourced_config.merge(fragment);
3537 sourced_config.loaded_files.push(path_str);
3538 }
3539 } else {
3540 log::debug!("[rumdl-config] No user configuration file found");
3541 }
3542
3543 Ok(())
3544 }
3545
3546 #[doc(hidden)]
3548 pub fn load_with_discovery_impl(
3549 config_path: Option<&str>,
3550 cli_overrides: Option<&SourcedGlobalConfig>,
3551 skip_auto_discovery: bool,
3552 user_config_dir: Option<&Path>,
3553 ) -> Result<Self, ConfigError> {
3554 use std::env;
3555 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
3556
3557 let mut sourced_config = SourcedConfig::default();
3558
3559 if let Some(path) = config_path {
3572 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
3574 Self::load_explicit_config(&mut sourced_config, path)?;
3575 } else if skip_auto_discovery {
3576 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
3577 } else {
3579 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
3581
3582 if let Some((config_file, project_root)) = Self::discover_config_upward() {
3584 let path_str = config_file.display().to_string();
3586 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
3587
3588 log::debug!("[rumdl-config] Found project config: {path_str}");
3589 log::debug!("[rumdl-config] Project root: {}", project_root.display());
3590
3591 sourced_config.project_root = Some(project_root);
3592
3593 if filename == "pyproject.toml" {
3594 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
3595 source: e,
3596 path: path_str.clone(),
3597 })?;
3598 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3599 sourced_config.merge(fragment);
3600 sourced_config.loaded_files.push(path_str);
3601 }
3602 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
3603 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
3604 source: e,
3605 path: path_str.clone(),
3606 })?;
3607 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3608 sourced_config.merge(fragment);
3609 sourced_config.loaded_files.push(path_str);
3610 }
3611 } else {
3612 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
3614
3615 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward() {
3616 let path_str = markdownlint_path.display().to_string();
3617 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
3618 match load_from_markdownlint(&path_str) {
3619 Ok(fragment) => {
3620 sourced_config.merge(fragment);
3621 sourced_config.loaded_files.push(path_str);
3622 }
3623 Err(_e) => {
3624 log::debug!("[rumdl-config] Failed to load markdownlint config, trying user config");
3625 Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
3626 }
3627 }
3628 } else {
3629 log::debug!("[rumdl-config] No project config found, using user config as fallback");
3631 Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
3632 }
3633 }
3634 }
3635
3636 if let Some(cli) = cli_overrides {
3638 sourced_config
3639 .global
3640 .enable
3641 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
3642 sourced_config
3643 .global
3644 .disable
3645 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
3646 sourced_config
3647 .global
3648 .exclude
3649 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
3650 sourced_config
3651 .global
3652 .include
3653 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
3654 sourced_config.global.respect_gitignore.merge_override(
3655 cli.respect_gitignore.value,
3656 ConfigSource::Cli,
3657 None,
3658 None,
3659 );
3660 sourced_config
3661 .global
3662 .fixable
3663 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
3664 sourced_config
3665 .global
3666 .unfixable
3667 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
3668 }
3670
3671 Ok(sourced_config)
3674 }
3675
3676 pub fn load_with_discovery(
3679 config_path: Option<&str>,
3680 cli_overrides: Option<&SourcedGlobalConfig>,
3681 skip_auto_discovery: bool,
3682 ) -> Result<Self, ConfigError> {
3683 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
3684 }
3685
3686 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
3700 let warnings = validate_config_sourced_internal(&self, registry);
3701
3702 Ok(SourcedConfig {
3703 global: self.global,
3704 per_file_ignores: self.per_file_ignores,
3705 per_file_flavor: self.per_file_flavor,
3706 rules: self.rules,
3707 loaded_files: self.loaded_files,
3708 unknown_keys: self.unknown_keys,
3709 project_root: self.project_root,
3710 validation_warnings: warnings,
3711 _state: PhantomData,
3712 })
3713 }
3714
3715 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
3720 let validated = self.validate(registry)?;
3721 let warnings = validated.validation_warnings.clone();
3722 Ok((validated.into(), warnings))
3723 }
3724
3725 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
3736 SourcedConfig {
3737 global: self.global,
3738 per_file_ignores: self.per_file_ignores,
3739 per_file_flavor: self.per_file_flavor,
3740 rules: self.rules,
3741 loaded_files: self.loaded_files,
3742 unknown_keys: self.unknown_keys,
3743 project_root: self.project_root,
3744 validation_warnings: Vec::new(),
3745 _state: PhantomData,
3746 }
3747 }
3748}
3749
3750impl From<SourcedConfig<ConfigValidated>> for Config {
3755 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
3756 let mut rules = BTreeMap::new();
3757 for (rule_name, sourced_rule_cfg) in sourced.rules {
3758 let normalized_rule_name = rule_name.to_ascii_uppercase();
3760 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
3761 let mut values = BTreeMap::new();
3762 for (key, sourced_val) in sourced_rule_cfg.values {
3763 values.insert(key, sourced_val.value);
3764 }
3765 rules.insert(normalized_rule_name, RuleConfig { severity, values });
3766 }
3767 #[allow(deprecated)]
3768 let global = GlobalConfig {
3769 enable: sourced.global.enable.value,
3770 disable: sourced.global.disable.value,
3771 exclude: sourced.global.exclude.value,
3772 include: sourced.global.include.value,
3773 respect_gitignore: sourced.global.respect_gitignore.value,
3774 line_length: sourced.global.line_length.value,
3775 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
3776 fixable: sourced.global.fixable.value,
3777 unfixable: sourced.global.unfixable.value,
3778 flavor: sourced.global.flavor.value,
3779 force_exclude: sourced.global.force_exclude.value,
3780 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
3781 cache: sourced.global.cache.value,
3782 };
3783 Config {
3784 global,
3785 per_file_ignores: sourced.per_file_ignores.value,
3786 per_file_flavor: sourced.per_file_flavor.value,
3787 rules,
3788 project_root: sourced.project_root,
3789 per_file_ignores_cache: Arc::new(OnceLock::new()),
3790 per_file_flavor_cache: Arc::new(OnceLock::new()),
3791 }
3792 }
3793}
3794
3795pub struct RuleRegistry {
3797 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
3799 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
3801}
3802
3803impl RuleRegistry {
3804 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
3806 let mut rule_schemas = std::collections::BTreeMap::new();
3807 let mut rule_aliases = std::collections::BTreeMap::new();
3808
3809 for rule in rules {
3810 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
3811 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
3813 norm_name
3814 } else {
3815 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
3817 norm_name
3818 };
3819
3820 if let Some(aliases) = rule.config_aliases() {
3822 rule_aliases.insert(norm_name, aliases);
3823 }
3824 }
3825
3826 RuleRegistry {
3827 rule_schemas,
3828 rule_aliases,
3829 }
3830 }
3831
3832 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
3834 self.rule_schemas.keys().cloned().collect()
3835 }
3836
3837 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
3839 self.rule_schemas.get(rule).map(|schema| {
3840 let mut all_keys = std::collections::BTreeSet::new();
3841
3842 all_keys.insert("severity".to_string());
3844
3845 for key in schema.keys() {
3847 all_keys.insert(key.clone());
3848 }
3849
3850 for key in schema.keys() {
3852 all_keys.insert(key.replace('_', "-"));
3854 all_keys.insert(key.replace('-', "_"));
3856 all_keys.insert(normalize_key(key));
3858 }
3859
3860 if let Some(aliases) = self.rule_aliases.get(rule) {
3862 for alias_key in aliases.keys() {
3863 all_keys.insert(alias_key.clone());
3864 all_keys.insert(alias_key.replace('_', "-"));
3866 all_keys.insert(alias_key.replace('-', "_"));
3867 all_keys.insert(normalize_key(alias_key));
3868 }
3869 }
3870
3871 all_keys
3872 })
3873 }
3874
3875 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
3877 if let Some(schema) = self.rule_schemas.get(rule) {
3878 if let Some(aliases) = self.rule_aliases.get(rule)
3880 && let Some(canonical_key) = aliases.get(key)
3881 {
3882 if let Some(value) = schema.get(canonical_key) {
3884 return Some(value);
3885 }
3886 }
3887
3888 if let Some(value) = schema.get(key) {
3890 return Some(value);
3891 }
3892
3893 let key_variants = [
3895 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
3899
3900 for variant in &key_variants {
3901 if let Some(value) = schema.get(variant) {
3902 return Some(value);
3903 }
3904 }
3905 }
3906 None
3907 }
3908
3909 pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
3916 let normalized = normalize_key(name);
3918 if self.rule_schemas.contains_key(&normalized) {
3919 return Some(normalized);
3920 }
3921
3922 resolve_rule_name_alias(name).map(|s| s.to_string())
3924 }
3925}
3926
3927pub static RULE_ALIAS_MAP: phf::Map<&'static str, &'static str> = phf::phf_map! {
3930 "MD001" => "MD001",
3932 "MD003" => "MD003",
3933 "MD004" => "MD004",
3934 "MD005" => "MD005",
3935 "MD007" => "MD007",
3936 "MD009" => "MD009",
3937 "MD010" => "MD010",
3938 "MD011" => "MD011",
3939 "MD012" => "MD012",
3940 "MD013" => "MD013",
3941 "MD014" => "MD014",
3942 "MD018" => "MD018",
3943 "MD019" => "MD019",
3944 "MD020" => "MD020",
3945 "MD021" => "MD021",
3946 "MD022" => "MD022",
3947 "MD023" => "MD023",
3948 "MD024" => "MD024",
3949 "MD025" => "MD025",
3950 "MD026" => "MD026",
3951 "MD027" => "MD027",
3952 "MD028" => "MD028",
3953 "MD029" => "MD029",
3954 "MD030" => "MD030",
3955 "MD031" => "MD031",
3956 "MD032" => "MD032",
3957 "MD033" => "MD033",
3958 "MD034" => "MD034",
3959 "MD035" => "MD035",
3960 "MD036" => "MD036",
3961 "MD037" => "MD037",
3962 "MD038" => "MD038",
3963 "MD039" => "MD039",
3964 "MD040" => "MD040",
3965 "MD041" => "MD041",
3966 "MD042" => "MD042",
3967 "MD043" => "MD043",
3968 "MD044" => "MD044",
3969 "MD045" => "MD045",
3970 "MD046" => "MD046",
3971 "MD047" => "MD047",
3972 "MD048" => "MD048",
3973 "MD049" => "MD049",
3974 "MD050" => "MD050",
3975 "MD051" => "MD051",
3976 "MD052" => "MD052",
3977 "MD053" => "MD053",
3978 "MD054" => "MD054",
3979 "MD055" => "MD055",
3980 "MD056" => "MD056",
3981 "MD057" => "MD057",
3982 "MD058" => "MD058",
3983 "MD059" => "MD059",
3984 "MD060" => "MD060",
3985 "MD061" => "MD061",
3986 "MD062" => "MD062",
3987 "MD063" => "MD063",
3988 "MD064" => "MD064",
3989 "MD065" => "MD065",
3990 "MD066" => "MD066",
3991 "MD067" => "MD067",
3992 "MD068" => "MD068",
3993 "MD069" => "MD069",
3994 "MD070" => "MD070",
3995 "MD071" => "MD071",
3996 "MD072" => "MD072",
3997
3998 "HEADING-INCREMENT" => "MD001",
4000 "HEADING-STYLE" => "MD003",
4001 "UL-STYLE" => "MD004",
4002 "LIST-INDENT" => "MD005",
4003 "UL-INDENT" => "MD007",
4004 "NO-TRAILING-SPACES" => "MD009",
4005 "NO-HARD-TABS" => "MD010",
4006 "NO-REVERSED-LINKS" => "MD011",
4007 "NO-MULTIPLE-BLANKS" => "MD012",
4008 "LINE-LENGTH" => "MD013",
4009 "COMMANDS-SHOW-OUTPUT" => "MD014",
4010 "NO-MISSING-SPACE-ATX" => "MD018",
4011 "NO-MULTIPLE-SPACE-ATX" => "MD019",
4012 "NO-MISSING-SPACE-CLOSED-ATX" => "MD020",
4013 "NO-MULTIPLE-SPACE-CLOSED-ATX" => "MD021",
4014 "BLANKS-AROUND-HEADINGS" => "MD022",
4015 "HEADING-START-LEFT" => "MD023",
4016 "NO-DUPLICATE-HEADING" => "MD024",
4017 "SINGLE-TITLE" => "MD025",
4018 "SINGLE-H1" => "MD025",
4019 "NO-TRAILING-PUNCTUATION" => "MD026",
4020 "NO-MULTIPLE-SPACE-BLOCKQUOTE" => "MD027",
4021 "NO-BLANKS-BLOCKQUOTE" => "MD028",
4022 "OL-PREFIX" => "MD029",
4023 "LIST-MARKER-SPACE" => "MD030",
4024 "BLANKS-AROUND-FENCES" => "MD031",
4025 "BLANKS-AROUND-LISTS" => "MD032",
4026 "NO-INLINE-HTML" => "MD033",
4027 "NO-BARE-URLS" => "MD034",
4028 "HR-STYLE" => "MD035",
4029 "NO-EMPHASIS-AS-HEADING" => "MD036",
4030 "NO-SPACE-IN-EMPHASIS" => "MD037",
4031 "NO-SPACE-IN-CODE" => "MD038",
4032 "NO-SPACE-IN-LINKS" => "MD039",
4033 "FENCED-CODE-LANGUAGE" => "MD040",
4034 "FIRST-LINE-HEADING" => "MD041",
4035 "FIRST-LINE-H1" => "MD041",
4036 "NO-EMPTY-LINKS" => "MD042",
4037 "REQUIRED-HEADINGS" => "MD043",
4038 "PROPER-NAMES" => "MD044",
4039 "NO-ALT-TEXT" => "MD045",
4040 "CODE-BLOCK-STYLE" => "MD046",
4041 "SINGLE-TRAILING-NEWLINE" => "MD047",
4042 "CODE-FENCE-STYLE" => "MD048",
4043 "EMPHASIS-STYLE" => "MD049",
4044 "STRONG-STYLE" => "MD050",
4045 "LINK-FRAGMENTS" => "MD051",
4046 "REFERENCE-LINKS-IMAGES" => "MD052",
4047 "LINK-IMAGE-REFERENCE-DEFINITIONS" => "MD053",
4048 "LINK-IMAGE-STYLE" => "MD054",
4049 "TABLE-PIPE-STYLE" => "MD055",
4050 "TABLE-COLUMN-COUNT" => "MD056",
4051 "EXISTING-RELATIVE-LINKS" => "MD057",
4052 "BLANKS-AROUND-TABLES" => "MD058",
4053 "DESCRIPTIVE-LINK-TEXT" => "MD059",
4054 "TABLE-CELL-ALIGNMENT" => "MD060",
4055 "TABLE-FORMAT" => "MD060",
4056 "FORBIDDEN-TERMS" => "MD061",
4057 "LINK-DESTINATION-WHITESPACE" => "MD062",
4058 "HEADING-CAPITALIZATION" => "MD063",
4059 "NO-MULTIPLE-CONSECUTIVE-SPACES" => "MD064",
4060 "BLANKS-AROUND-HORIZONTAL-RULES" => "MD065",
4061 "FOOTNOTE-VALIDATION" => "MD066",
4062 "FOOTNOTE-DEFINITION-ORDER" => "MD067",
4063 "EMPTY-FOOTNOTE-DEFINITION" => "MD068",
4064 "NO-DUPLICATE-LIST-MARKERS" => "MD069",
4065 "NESTED-CODE-FENCE" => "MD070",
4066 "BLANK-LINE-AFTER-FRONTMATTER" => "MD071",
4067 "FRONTMATTER-KEY-SORT" => "MD072",
4068};
4069
4070pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
4074 let normalized_key = key.to_ascii_uppercase().replace('_', "-");
4076
4077 RULE_ALIAS_MAP.get(normalized_key.as_str()).copied()
4079}
4080
4081pub fn resolve_rule_name(name: &str) -> String {
4089 resolve_rule_name_alias(name)
4090 .map(|s| s.to_string())
4091 .unwrap_or_else(|| normalize_key(name))
4092}
4093
4094pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
4098 input
4099 .split(',')
4100 .map(|s| s.trim())
4101 .filter(|s| !s.is_empty())
4102 .map(resolve_rule_name)
4103 .collect()
4104}
4105
4106pub fn validate_cli_rule_names(
4112 enable: Option<&str>,
4113 disable: Option<&str>,
4114 extend_enable: Option<&str>,
4115 extend_disable: Option<&str>,
4116) -> Vec<ConfigValidationWarning> {
4117 let mut warnings = Vec::new();
4118 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4119
4120 let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
4121 for name in input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
4122 if name.eq_ignore_ascii_case("all") {
4124 continue;
4125 }
4126 if resolve_rule_name_alias(name).is_none() {
4127 let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
4128 let formatted = if suggestion.starts_with("MD") {
4129 suggestion
4130 } else {
4131 suggestion.to_lowercase()
4132 };
4133 format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
4134 } else {
4135 format!("Unknown rule in {flag_name}: {name}")
4136 };
4137 warnings.push(ConfigValidationWarning {
4138 message,
4139 rule: Some(name.to_string()),
4140 key: None,
4141 });
4142 }
4143 }
4144 };
4145
4146 if let Some(e) = enable {
4147 validate_list(e, "--enable", &mut warnings);
4148 }
4149 if let Some(d) = disable {
4150 validate_list(d, "--disable", &mut warnings);
4151 }
4152 if let Some(ee) = extend_enable {
4153 validate_list(ee, "--extend-enable", &mut warnings);
4154 }
4155 if let Some(ed) = extend_disable {
4156 validate_list(ed, "--extend-disable", &mut warnings);
4157 }
4158
4159 warnings
4160}
4161
4162pub fn is_valid_rule_name(name: &str) -> bool {
4166 if name.eq_ignore_ascii_case("all") {
4168 return true;
4169 }
4170 resolve_rule_name_alias(name).is_some()
4171}
4172
4173#[derive(Debug, Clone)]
4175pub struct ConfigValidationWarning {
4176 pub message: String,
4177 pub rule: Option<String>,
4178 pub key: Option<String>,
4179}
4180
4181fn validate_config_sourced_internal<S>(
4184 sourced: &SourcedConfig<S>,
4185 registry: &RuleRegistry,
4186) -> Vec<ConfigValidationWarning> {
4187 let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
4188
4189 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4191
4192 for rule_name in &sourced.global.enable.value {
4193 if !is_valid_rule_name(rule_name) {
4194 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4195 let formatted = if suggestion.starts_with("MD") {
4196 suggestion
4197 } else {
4198 suggestion.to_lowercase()
4199 };
4200 format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
4201 } else {
4202 format!("Unknown rule in global.enable: {rule_name}")
4203 };
4204 warnings.push(ConfigValidationWarning {
4205 message,
4206 rule: Some(rule_name.clone()),
4207 key: None,
4208 });
4209 }
4210 }
4211
4212 for rule_name in &sourced.global.disable.value {
4213 if !is_valid_rule_name(rule_name) {
4214 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4215 let formatted = if suggestion.starts_with("MD") {
4216 suggestion
4217 } else {
4218 suggestion.to_lowercase()
4219 };
4220 format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
4221 } else {
4222 format!("Unknown rule in global.disable: {rule_name}")
4223 };
4224 warnings.push(ConfigValidationWarning {
4225 message,
4226 rule: Some(rule_name.clone()),
4227 key: None,
4228 });
4229 }
4230 }
4231
4232 warnings
4233}
4234
4235fn validate_config_sourced_impl(
4237 rules: &BTreeMap<String, SourcedRuleConfig>,
4238 unknown_keys: &[(String, String, Option<String>)],
4239 registry: &RuleRegistry,
4240) -> Vec<ConfigValidationWarning> {
4241 let mut warnings = Vec::new();
4242 let known_rules = registry.rule_names();
4243 for rule in rules.keys() {
4245 if !known_rules.contains(rule) {
4246 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4248 let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
4249 let formatted_suggestion = if suggestion.starts_with("MD") {
4251 suggestion
4252 } else {
4253 suggestion.to_lowercase()
4254 };
4255 format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
4256 } else {
4257 format!("Unknown rule in config: {rule}")
4258 };
4259 warnings.push(ConfigValidationWarning {
4260 message,
4261 rule: Some(rule.clone()),
4262 key: None,
4263 });
4264 }
4265 }
4266 for (rule, rule_cfg) in rules {
4268 if let Some(valid_keys) = registry.config_keys_for(rule) {
4269 for key in rule_cfg.values.keys() {
4270 if !valid_keys.contains(key) {
4271 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
4272 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
4273 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
4274 } else {
4275 format!("Unknown option for rule {rule}: {key}")
4276 };
4277 warnings.push(ConfigValidationWarning {
4278 message,
4279 rule: Some(rule.clone()),
4280 key: Some(key.clone()),
4281 });
4282 } else {
4283 if let Some(expected) = registry.expected_value_for(rule, key) {
4285 let actual = &rule_cfg.values[key].value;
4286 if !toml_value_type_matches(expected, actual) {
4287 warnings.push(ConfigValidationWarning {
4288 message: format!(
4289 "Type mismatch for {}.{}: expected {}, got {}",
4290 rule,
4291 key,
4292 toml_type_name(expected),
4293 toml_type_name(actual)
4294 ),
4295 rule: Some(rule.clone()),
4296 key: Some(key.clone()),
4297 });
4298 }
4299 }
4300 }
4301 }
4302 }
4303 }
4304 let known_global_keys = vec![
4306 "enable".to_string(),
4307 "disable".to_string(),
4308 "include".to_string(),
4309 "exclude".to_string(),
4310 "respect-gitignore".to_string(),
4311 "line-length".to_string(),
4312 "fixable".to_string(),
4313 "unfixable".to_string(),
4314 "flavor".to_string(),
4315 "force-exclude".to_string(),
4316 "output-format".to_string(),
4317 "cache-dir".to_string(),
4318 "cache".to_string(),
4319 ];
4320
4321 for (section, key, file_path) in unknown_keys {
4322 let display_path = file_path.as_ref().map(|p| to_relative_display_path(p));
4324
4325 if section.contains("[global]") || section.contains("[tool.rumdl]") {
4326 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
4327 if let Some(ref path) = display_path {
4328 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
4329 } else {
4330 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
4331 }
4332 } else if let Some(ref path) = display_path {
4333 format!("Unknown global option in {path}: {key}")
4334 } else {
4335 format!("Unknown global option: {key}")
4336 };
4337 warnings.push(ConfigValidationWarning {
4338 message,
4339 rule: None,
4340 key: Some(key.clone()),
4341 });
4342 } else if !key.is_empty() {
4343 continue;
4345 } else {
4346 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
4348 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4349 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4350 let formatted_suggestion = if suggestion.starts_with("MD") {
4352 suggestion
4353 } else {
4354 suggestion.to_lowercase()
4355 };
4356 if let Some(ref path) = display_path {
4357 format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
4358 } else {
4359 format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
4360 }
4361 } else if let Some(ref path) = display_path {
4362 format!("Unknown rule in {path}: {rule_name}")
4363 } else {
4364 format!("Unknown rule in config: {rule_name}")
4365 };
4366 warnings.push(ConfigValidationWarning {
4367 message,
4368 rule: None,
4369 key: None,
4370 });
4371 }
4372 }
4373 warnings
4374}
4375
4376fn to_relative_display_path(path: &str) -> String {
4381 let file_path = Path::new(path);
4382
4383 if let Ok(cwd) = std::env::current_dir() {
4385 if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
4387 && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
4388 {
4389 return relative.to_string_lossy().to_string();
4390 }
4391
4392 if let Ok(relative) = file_path.strip_prefix(&cwd) {
4394 return relative.to_string_lossy().to_string();
4395 }
4396 }
4397
4398 path.to_string()
4400}
4401
4402pub fn validate_config_sourced(
4408 sourced: &SourcedConfig<ConfigLoaded>,
4409 registry: &RuleRegistry,
4410) -> Vec<ConfigValidationWarning> {
4411 validate_config_sourced_internal(sourced, registry)
4412}
4413
4414pub fn validate_config_sourced_validated(
4418 sourced: &SourcedConfig<ConfigValidated>,
4419 _registry: &RuleRegistry,
4420) -> Vec<ConfigValidationWarning> {
4421 sourced.validation_warnings.clone()
4422}
4423
4424fn toml_type_name(val: &toml::Value) -> &'static str {
4425 match val {
4426 toml::Value::String(_) => "string",
4427 toml::Value::Integer(_) => "integer",
4428 toml::Value::Float(_) => "float",
4429 toml::Value::Boolean(_) => "boolean",
4430 toml::Value::Array(_) => "array",
4431 toml::Value::Table(_) => "table",
4432 toml::Value::Datetime(_) => "datetime",
4433 }
4434}
4435
4436fn levenshtein_distance(s1: &str, s2: &str) -> usize {
4438 let len1 = s1.len();
4439 let len2 = s2.len();
4440
4441 if len1 == 0 {
4442 return len2;
4443 }
4444 if len2 == 0 {
4445 return len1;
4446 }
4447
4448 let s1_chars: Vec<char> = s1.chars().collect();
4449 let s2_chars: Vec<char> = s2.chars().collect();
4450
4451 let mut prev_row: Vec<usize> = (0..=len2).collect();
4452 let mut curr_row = vec![0; len2 + 1];
4453
4454 for i in 1..=len1 {
4455 curr_row[0] = i;
4456 for j in 1..=len2 {
4457 let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
4458 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
4462 std::mem::swap(&mut prev_row, &mut curr_row);
4463 }
4464
4465 prev_row[len2]
4466}
4467
4468pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
4470 let unknown_lower = unknown.to_lowercase();
4471 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
4474
4475 for valid in valid_keys {
4476 let valid_lower = valid.to_lowercase();
4477 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
4478
4479 if distance <= max_distance {
4480 if let Some((_, best_dist)) = &best_match {
4481 if distance < *best_dist {
4482 best_match = Some((valid.clone(), distance));
4483 }
4484 } else {
4485 best_match = Some((valid.clone(), distance));
4486 }
4487 }
4488 }
4489
4490 best_match.map(|(key, _)| key)
4491}
4492
4493fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
4494 use toml::Value::*;
4495 match (expected, actual) {
4496 (String(_), String(_)) => true,
4497 (Integer(_), Integer(_)) => true,
4498 (Float(_), Float(_)) => true,
4499 (Boolean(_), Boolean(_)) => true,
4500 (Array(_), Array(_)) => true,
4501 (Table(_), Table(_)) => true,
4502 (Datetime(_), Datetime(_)) => true,
4503 (Float(_), Integer(_)) => true,
4505 _ => false,
4506 }
4507}
4508
4509fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
4511 let display_path = to_relative_display_path(path);
4512 let doc: toml::Value = toml::from_str(content)
4513 .map_err(|e| ConfigError::ParseError(format!("{display_path}: Failed to parse TOML: {e}")))?;
4514 let mut fragment = SourcedConfigFragment::default();
4515 let source = ConfigSource::PyprojectToml;
4516 let file = Some(path.to_string());
4517
4518 let all_rules = rules::all_rules(&Config::default());
4520 let registry = RuleRegistry::from_rules(&all_rules);
4521
4522 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
4524 && let Some(rumdl_table) = rumdl_config.as_table()
4525 {
4526 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
4528 if let Some(enable) = table.get("enable")
4530 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
4531 {
4532 let normalized_values = values
4534 .into_iter()
4535 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4536 .collect();
4537 fragment
4538 .global
4539 .enable
4540 .push_override(normalized_values, source, file.clone(), None);
4541 }
4542
4543 if let Some(disable) = table.get("disable")
4544 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
4545 {
4546 let normalized_values: Vec<String> = values
4548 .into_iter()
4549 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4550 .collect();
4551 fragment
4552 .global
4553 .disable
4554 .push_override(normalized_values, source, file.clone(), None);
4555 }
4556
4557 if let Some(include) = table.get("include")
4558 && let Ok(values) = Vec::<String>::deserialize(include.clone())
4559 {
4560 fragment
4561 .global
4562 .include
4563 .push_override(values, source, file.clone(), None);
4564 }
4565
4566 if let Some(exclude) = table.get("exclude")
4567 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
4568 {
4569 fragment
4570 .global
4571 .exclude
4572 .push_override(values, source, file.clone(), None);
4573 }
4574
4575 if let Some(respect_gitignore) = table
4576 .get("respect-gitignore")
4577 .or_else(|| table.get("respect_gitignore"))
4578 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
4579 {
4580 fragment
4581 .global
4582 .respect_gitignore
4583 .push_override(value, source, file.clone(), None);
4584 }
4585
4586 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
4587 && let Ok(value) = bool::deserialize(force_exclude.clone())
4588 {
4589 fragment
4590 .global
4591 .force_exclude
4592 .push_override(value, source, file.clone(), None);
4593 }
4594
4595 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
4596 && let Ok(value) = String::deserialize(output_format.clone())
4597 {
4598 if fragment.global.output_format.is_none() {
4599 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
4600 } else {
4601 fragment
4602 .global
4603 .output_format
4604 .as_mut()
4605 .unwrap()
4606 .push_override(value, source, file.clone(), None);
4607 }
4608 }
4609
4610 if let Some(fixable) = table.get("fixable")
4611 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
4612 {
4613 let normalized_values = values
4614 .into_iter()
4615 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4616 .collect();
4617 fragment
4618 .global
4619 .fixable
4620 .push_override(normalized_values, source, file.clone(), None);
4621 }
4622
4623 if let Some(unfixable) = table.get("unfixable")
4624 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
4625 {
4626 let normalized_values = values
4627 .into_iter()
4628 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4629 .collect();
4630 fragment
4631 .global
4632 .unfixable
4633 .push_override(normalized_values, source, file.clone(), None);
4634 }
4635
4636 if let Some(flavor) = table.get("flavor")
4637 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
4638 {
4639 fragment.global.flavor.push_override(value, source, file.clone(), None);
4640 }
4641
4642 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
4644 && let Ok(value) = u64::deserialize(line_length.clone())
4645 {
4646 fragment
4647 .global
4648 .line_length
4649 .push_override(LineLength::new(value as usize), source, file.clone(), None);
4650
4651 let norm_md013_key = normalize_key("MD013");
4653 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
4654 let norm_line_length_key = normalize_key("line-length");
4655 let sv = rule_entry
4656 .values
4657 .entry(norm_line_length_key)
4658 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
4659 sv.push_override(line_length.clone(), source, file.clone(), None);
4660 }
4661
4662 if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
4663 && let Ok(value) = String::deserialize(cache_dir.clone())
4664 {
4665 if fragment.global.cache_dir.is_none() {
4666 fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
4667 } else {
4668 fragment
4669 .global
4670 .cache_dir
4671 .as_mut()
4672 .unwrap()
4673 .push_override(value, source, file.clone(), None);
4674 }
4675 }
4676
4677 if let Some(cache) = table.get("cache")
4678 && let Ok(value) = bool::deserialize(cache.clone())
4679 {
4680 fragment.global.cache.push_override(value, source, file.clone(), None);
4681 }
4682 };
4683
4684 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
4686 extract_global_config(&mut fragment, global_table);
4687 }
4688
4689 extract_global_config(&mut fragment, rumdl_table);
4691
4692 let per_file_ignores_key = rumdl_table
4695 .get("per-file-ignores")
4696 .or_else(|| rumdl_table.get("per_file_ignores"));
4697
4698 if let Some(per_file_ignores_value) = per_file_ignores_key
4699 && let Some(per_file_table) = per_file_ignores_value.as_table()
4700 {
4701 let mut per_file_map = HashMap::new();
4702 for (pattern, rules_value) in per_file_table {
4703 warn_comma_without_brace_in_pattern(pattern, &display_path);
4704 if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
4705 let normalized_rules = rules
4706 .into_iter()
4707 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4708 .collect();
4709 per_file_map.insert(pattern.clone(), normalized_rules);
4710 } else {
4711 log::warn!(
4712 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {display_path}, found {rules_value:?}"
4713 );
4714 }
4715 }
4716 fragment
4717 .per_file_ignores
4718 .push_override(per_file_map, source, file.clone(), None);
4719 }
4720
4721 let per_file_flavor_key = rumdl_table
4724 .get("per-file-flavor")
4725 .or_else(|| rumdl_table.get("per_file_flavor"));
4726
4727 if let Some(per_file_flavor_value) = per_file_flavor_key
4728 && let Some(per_file_table) = per_file_flavor_value.as_table()
4729 {
4730 let mut per_file_map = IndexMap::new();
4731 for (pattern, flavor_value) in per_file_table {
4732 if let Ok(flavor) = MarkdownFlavor::deserialize(flavor_value.clone()) {
4733 per_file_map.insert(pattern.clone(), flavor);
4734 } else {
4735 log::warn!(
4736 "[WARN] Invalid flavor for per-file-flavor pattern '{pattern}' in {display_path}, found {flavor_value:?}. Valid values: standard, mkdocs, mdx, quarto"
4737 );
4738 }
4739 }
4740 fragment
4741 .per_file_flavor
4742 .push_override(per_file_map, source, file.clone(), None);
4743 }
4744
4745 for (key, value) in rumdl_table {
4747 let norm_rule_key = normalize_key(key);
4748
4749 let is_global_key = [
4752 "enable",
4753 "disable",
4754 "include",
4755 "exclude",
4756 "respect_gitignore",
4757 "respect-gitignore",
4758 "force_exclude",
4759 "force-exclude",
4760 "output_format",
4761 "output-format",
4762 "fixable",
4763 "unfixable",
4764 "per-file-ignores",
4765 "per_file_ignores",
4766 "per-file-flavor",
4767 "per_file_flavor",
4768 "global",
4769 "flavor",
4770 "cache_dir",
4771 "cache-dir",
4772 "cache",
4773 ]
4774 .contains(&norm_rule_key.as_str());
4775
4776 let is_line_length_global =
4778 (norm_rule_key == "line-length" || norm_rule_key == "line_length") && !value.is_table();
4779
4780 if is_global_key || is_line_length_global {
4781 continue;
4782 }
4783
4784 if let Some(resolved_rule_name) = registry.resolve_rule_name(key)
4786 && value.is_table()
4787 && let Some(rule_config_table) = value.as_table()
4788 {
4789 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4790 for (rk, rv) in rule_config_table {
4791 let norm_rk = normalize_key(rk);
4792
4793 if norm_rk == "severity" {
4795 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4796 if rule_entry.severity.is_none() {
4797 rule_entry.severity = Some(SourcedValue::new(severity, source));
4798 } else {
4799 rule_entry.severity.as_mut().unwrap().push_override(
4800 severity,
4801 source,
4802 file.clone(),
4803 None,
4804 );
4805 }
4806 }
4807 continue; }
4809
4810 let toml_val = rv.clone();
4811
4812 let sv = rule_entry
4813 .values
4814 .entry(norm_rk.clone())
4815 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
4816 sv.push_override(toml_val, source, file.clone(), None);
4817 }
4818 } else if registry.resolve_rule_name(key).is_none() {
4819 fragment
4822 .unknown_keys
4823 .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
4824 }
4825 }
4826 }
4827
4828 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
4830 for (key, value) in tool_table.iter() {
4831 if let Some(rule_name) = key.strip_prefix("rumdl.") {
4832 if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4834 if let Some(rule_table) = value.as_table() {
4835 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4836 for (rk, rv) in rule_table {
4837 let norm_rk = normalize_key(rk);
4838
4839 if norm_rk == "severity" {
4841 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4842 if rule_entry.severity.is_none() {
4843 rule_entry.severity = Some(SourcedValue::new(severity, source));
4844 } else {
4845 rule_entry.severity.as_mut().unwrap().push_override(
4846 severity,
4847 source,
4848 file.clone(),
4849 None,
4850 );
4851 }
4852 }
4853 continue; }
4855
4856 let toml_val = rv.clone();
4857 let sv = rule_entry
4858 .values
4859 .entry(norm_rk.clone())
4860 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4861 sv.push_override(toml_val, source, file.clone(), None);
4862 }
4863 }
4864 } else if rule_name.to_ascii_uppercase().starts_with("MD")
4865 || rule_name.chars().any(|c| c.is_alphabetic())
4866 {
4867 fragment.unknown_keys.push((
4869 format!("[tool.rumdl.{rule_name}]"),
4870 String::new(),
4871 Some(path.to_string()),
4872 ));
4873 }
4874 }
4875 }
4876 }
4877
4878 if let Some(doc_table) = doc.as_table() {
4880 for (key, value) in doc_table.iter() {
4881 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
4882 if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4884 if let Some(rule_table) = value.as_table() {
4885 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4886 for (rk, rv) in rule_table {
4887 let norm_rk = normalize_key(rk);
4888
4889 if norm_rk == "severity" {
4891 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4892 if rule_entry.severity.is_none() {
4893 rule_entry.severity = Some(SourcedValue::new(severity, source));
4894 } else {
4895 rule_entry.severity.as_mut().unwrap().push_override(
4896 severity,
4897 source,
4898 file.clone(),
4899 None,
4900 );
4901 }
4902 }
4903 continue; }
4905
4906 let toml_val = rv.clone();
4907 let sv = rule_entry
4908 .values
4909 .entry(norm_rk.clone())
4910 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4911 sv.push_override(toml_val, source, file.clone(), None);
4912 }
4913 }
4914 } else if rule_name.to_ascii_uppercase().starts_with("MD")
4915 || rule_name.chars().any(|c| c.is_alphabetic())
4916 {
4917 fragment.unknown_keys.push((
4919 format!("[tool.rumdl.{rule_name}]"),
4920 String::new(),
4921 Some(path.to_string()),
4922 ));
4923 }
4924 }
4925 }
4926 }
4927
4928 let has_any = !fragment.global.enable.value.is_empty()
4930 || !fragment.global.disable.value.is_empty()
4931 || !fragment.global.include.value.is_empty()
4932 || !fragment.global.exclude.value.is_empty()
4933 || !fragment.global.fixable.value.is_empty()
4934 || !fragment.global.unfixable.value.is_empty()
4935 || fragment.global.output_format.is_some()
4936 || fragment.global.cache_dir.is_some()
4937 || !fragment.global.cache.value
4938 || !fragment.per_file_ignores.value.is_empty()
4939 || !fragment.per_file_flavor.value.is_empty()
4940 || !fragment.rules.is_empty();
4941 if has_any { Ok(Some(fragment)) } else { Ok(None) }
4942}
4943
4944fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
4946 let display_path = to_relative_display_path(path);
4947 let doc = content
4948 .parse::<DocumentMut>()
4949 .map_err(|e| ConfigError::ParseError(format!("{display_path}: Failed to parse TOML: {e}")))?;
4950 let mut fragment = SourcedConfigFragment::default();
4951 let file = Some(path.to_string());
4953
4954 let all_rules = rules::all_rules(&Config::default());
4956 let registry = RuleRegistry::from_rules(&all_rules);
4957
4958 if let Some(global_item) = doc.get("global")
4960 && let Some(global_table) = global_item.as_table()
4961 {
4962 for (key, value_item) in global_table.iter() {
4963 let norm_key = normalize_key(key);
4964 match norm_key.as_str() {
4965 "enable" | "disable" | "include" | "exclude" => {
4966 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4967 let values: Vec<String> = formatted_array
4969 .iter()
4970 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
4972 .collect();
4973
4974 let final_values = if norm_key == "enable" || norm_key == "disable" {
4976 values
4977 .into_iter()
4978 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4979 .collect()
4980 } else {
4981 values
4982 };
4983
4984 match norm_key.as_str() {
4985 "enable" => fragment
4986 .global
4987 .enable
4988 .push_override(final_values, source, file.clone(), None),
4989 "disable" => {
4990 fragment
4991 .global
4992 .disable
4993 .push_override(final_values, source, file.clone(), None)
4994 }
4995 "include" => {
4996 fragment
4997 .global
4998 .include
4999 .push_override(final_values, source, file.clone(), None)
5000 }
5001 "exclude" => {
5002 fragment
5003 .global
5004 .exclude
5005 .push_override(final_values, source, file.clone(), None)
5006 }
5007 _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
5008 }
5009 } else {
5010 log::warn!(
5011 "[WARN] Expected array for global key '{}' in {}, found {}",
5012 key,
5013 display_path,
5014 value_item.type_name()
5015 );
5016 }
5017 }
5018 "respect_gitignore" | "respect-gitignore" => {
5019 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
5021 let val = *formatted_bool.value();
5022 fragment
5023 .global
5024 .respect_gitignore
5025 .push_override(val, source, file.clone(), None);
5026 } else {
5027 log::warn!(
5028 "[WARN] Expected boolean for global key '{}' in {}, found {}",
5029 key,
5030 display_path,
5031 value_item.type_name()
5032 );
5033 }
5034 }
5035 "force_exclude" | "force-exclude" => {
5036 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
5038 let val = *formatted_bool.value();
5039 fragment
5040 .global
5041 .force_exclude
5042 .push_override(val, source, file.clone(), None);
5043 } else {
5044 log::warn!(
5045 "[WARN] Expected boolean for global key '{}' in {}, found {}",
5046 key,
5047 display_path,
5048 value_item.type_name()
5049 );
5050 }
5051 }
5052 "line_length" | "line-length" => {
5053 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
5055 let val = LineLength::new(*formatted_int.value() as usize);
5056 fragment
5057 .global
5058 .line_length
5059 .push_override(val, source, file.clone(), None);
5060 } else {
5061 log::warn!(
5062 "[WARN] Expected integer for global key '{}' in {}, found {}",
5063 key,
5064 display_path,
5065 value_item.type_name()
5066 );
5067 }
5068 }
5069 "output_format" | "output-format" => {
5070 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5072 let val = formatted_string.value().clone();
5073 if fragment.global.output_format.is_none() {
5074 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
5075 } else {
5076 fragment.global.output_format.as_mut().unwrap().push_override(
5077 val,
5078 source,
5079 file.clone(),
5080 None,
5081 );
5082 }
5083 } else {
5084 log::warn!(
5085 "[WARN] Expected string for global key '{}' in {}, found {}",
5086 key,
5087 display_path,
5088 value_item.type_name()
5089 );
5090 }
5091 }
5092 "cache_dir" | "cache-dir" => {
5093 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5095 let val = formatted_string.value().clone();
5096 if fragment.global.cache_dir.is_none() {
5097 fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
5098 } else {
5099 fragment
5100 .global
5101 .cache_dir
5102 .as_mut()
5103 .unwrap()
5104 .push_override(val, source, file.clone(), None);
5105 }
5106 } else {
5107 log::warn!(
5108 "[WARN] Expected string for global key '{}' in {}, found {}",
5109 key,
5110 display_path,
5111 value_item.type_name()
5112 );
5113 }
5114 }
5115 "cache" => {
5116 if let Some(toml_edit::Value::Boolean(b)) = value_item.as_value() {
5117 let val = *b.value();
5118 fragment.global.cache.push_override(val, source, file.clone(), None);
5119 } else {
5120 log::warn!(
5121 "[WARN] Expected boolean for global key '{}' in {}, found {}",
5122 key,
5123 display_path,
5124 value_item.type_name()
5125 );
5126 }
5127 }
5128 "fixable" => {
5129 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5130 let values: Vec<String> = formatted_array
5131 .iter()
5132 .filter_map(|item| item.as_str())
5133 .map(normalize_key)
5134 .collect();
5135 fragment
5136 .global
5137 .fixable
5138 .push_override(values, source, file.clone(), None);
5139 } else {
5140 log::warn!(
5141 "[WARN] Expected array for global key '{}' in {}, found {}",
5142 key,
5143 display_path,
5144 value_item.type_name()
5145 );
5146 }
5147 }
5148 "unfixable" => {
5149 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5150 let values: Vec<String> = formatted_array
5151 .iter()
5152 .filter_map(|item| item.as_str())
5153 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
5154 .collect();
5155 fragment
5156 .global
5157 .unfixable
5158 .push_override(values, source, file.clone(), None);
5159 } else {
5160 log::warn!(
5161 "[WARN] Expected array for global key '{}' in {}, found {}",
5162 key,
5163 display_path,
5164 value_item.type_name()
5165 );
5166 }
5167 }
5168 "flavor" => {
5169 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5170 let val = formatted_string.value();
5171 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
5172 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
5173 } else {
5174 log::warn!("[WARN] Unknown markdown flavor '{val}' in {display_path}");
5175 }
5176 } else {
5177 log::warn!(
5178 "[WARN] Expected string for global key '{}' in {}, found {}",
5179 key,
5180 display_path,
5181 value_item.type_name()
5182 );
5183 }
5184 }
5185 _ => {
5186 fragment
5188 .unknown_keys
5189 .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
5190 log::warn!("[WARN] Unknown key in [global] section of {display_path}: {key}");
5191 }
5192 }
5193 }
5194 }
5195
5196 if let Some(per_file_item) = doc.get("per-file-ignores")
5198 && let Some(per_file_table) = per_file_item.as_table()
5199 {
5200 let mut per_file_map = HashMap::new();
5201 for (pattern, value_item) in per_file_table.iter() {
5202 warn_comma_without_brace_in_pattern(pattern, &display_path);
5203 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5204 let rules: Vec<String> = formatted_array
5205 .iter()
5206 .filter_map(|item| item.as_str())
5207 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
5208 .collect();
5209 per_file_map.insert(pattern.to_string(), rules);
5210 } else {
5211 let type_name = value_item.type_name();
5212 log::warn!(
5213 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {display_path}, found {type_name}"
5214 );
5215 }
5216 }
5217 fragment
5218 .per_file_ignores
5219 .push_override(per_file_map, source, file.clone(), None);
5220 }
5221
5222 if let Some(per_file_item) = doc.get("per-file-flavor")
5224 && let Some(per_file_table) = per_file_item.as_table()
5225 {
5226 let mut per_file_map = IndexMap::new();
5227 for (pattern, value_item) in per_file_table.iter() {
5228 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5229 let flavor_str = formatted_string.value();
5230 match MarkdownFlavor::deserialize(toml::Value::String(flavor_str.to_string())) {
5231 Ok(flavor) => {
5232 per_file_map.insert(pattern.to_string(), flavor);
5233 }
5234 Err(_) => {
5235 log::warn!(
5236 "[WARN] Invalid flavor '{flavor_str}' for pattern '{pattern}' in {display_path}. Valid values: standard, mkdocs, mdx, quarto"
5237 );
5238 }
5239 }
5240 } else {
5241 let type_name = value_item.type_name();
5242 log::warn!(
5243 "[WARN] Expected string for per-file-flavor pattern '{pattern}' in {display_path}, found {type_name}"
5244 );
5245 }
5246 }
5247 fragment
5248 .per_file_flavor
5249 .push_override(per_file_map, source, file.clone(), None);
5250 }
5251
5252 for (key, item) in doc.iter() {
5254 if key == "global" || key == "per-file-ignores" || key == "per-file-flavor" {
5256 continue;
5257 }
5258
5259 let norm_rule_name = if let Some(resolved) = registry.resolve_rule_name(key) {
5261 resolved
5262 } else {
5263 fragment
5265 .unknown_keys
5266 .push((format!("[{key}]"), String::new(), Some(path.to_string())));
5267 continue;
5268 };
5269
5270 if let Some(tbl) = item.as_table() {
5271 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
5272 for (rk, rv_item) in tbl.iter() {
5273 let norm_rk = normalize_key(rk);
5274
5275 if norm_rk == "severity" {
5277 if let Some(toml_edit::Value::String(formatted_string)) = rv_item.as_value() {
5278 let severity_str = formatted_string.value();
5279 match crate::rule::Severity::deserialize(toml::Value::String(severity_str.to_string())) {
5280 Ok(severity) => {
5281 if rule_entry.severity.is_none() {
5282 rule_entry.severity = Some(SourcedValue::new(severity, source));
5283 } else {
5284 rule_entry.severity.as_mut().unwrap().push_override(
5285 severity,
5286 source,
5287 file.clone(),
5288 None,
5289 );
5290 }
5291 }
5292 Err(_) => {
5293 log::warn!(
5294 "[WARN] Invalid severity '{severity_str}' for rule {norm_rule_name} in {display_path}. Valid values: error, warning"
5295 );
5296 }
5297 }
5298 }
5299 continue; }
5301
5302 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
5303 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
5304 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
5305 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
5306 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
5307 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
5308 Some(toml_edit::Value::Array(formatted_array)) => {
5309 let mut values = Vec::new();
5311 for item in formatted_array.iter() {
5312 match item {
5313 toml_edit::Value::String(formatted) => {
5314 values.push(toml::Value::String(formatted.value().clone()))
5315 }
5316 toml_edit::Value::Integer(formatted) => {
5317 values.push(toml::Value::Integer(*formatted.value()))
5318 }
5319 toml_edit::Value::Float(formatted) => {
5320 values.push(toml::Value::Float(*formatted.value()))
5321 }
5322 toml_edit::Value::Boolean(formatted) => {
5323 values.push(toml::Value::Boolean(*formatted.value()))
5324 }
5325 toml_edit::Value::Datetime(formatted) => {
5326 values.push(toml::Value::Datetime(*formatted.value()))
5327 }
5328 _ => {
5329 log::warn!(
5330 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {display_path}"
5331 );
5332 }
5333 }
5334 }
5335 Some(toml::Value::Array(values))
5336 }
5337 Some(toml_edit::Value::InlineTable(_)) => {
5338 log::warn!(
5339 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {display_path}. Table conversion not yet fully implemented in parser."
5340 );
5341 None
5342 }
5343 None => {
5344 log::warn!(
5345 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {display_path}. Expected simple value."
5346 );
5347 None
5348 }
5349 };
5350 if let Some(toml_val) = maybe_toml_val {
5351 let sv = rule_entry
5352 .values
5353 .entry(norm_rk.clone())
5354 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
5355 sv.push_override(toml_val, source, file.clone(), None);
5356 }
5357 }
5358 } else if item.is_value() {
5359 log::warn!(
5360 "[WARN] Ignoring top-level value key in {display_path}: '{key}'. Expected a table like [{key}]."
5361 );
5362 }
5363 }
5364
5365 Ok(fragment)
5366}
5367
5368fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
5370 let display_path = to_relative_display_path(path);
5371 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
5373 .map_err(|e| ConfigError::ParseError(format!("{display_path}: {e}")))?;
5374 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
5375}
5376
5377#[cfg(test)]
5378#[path = "config_intelligent_merge_tests.rs"]
5379mod config_intelligent_merge_tests;