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 #[serde(rename = "obsidian")]
56 Obsidian,
57}
58
59fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
61 schemars::json_schema!({
62 "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, quarto, obsidian. Aliases: commonmark/github map to standard, qmd/rmd/rmarkdown map to quarto.",
63 "type": "string",
64 "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "quarto", "qmd", "rmd", "rmarkdown", "obsidian"]
65 })
66}
67
68impl schemars::JsonSchema for MarkdownFlavor {
69 fn schema_name() -> std::borrow::Cow<'static, str> {
70 std::borrow::Cow::Borrowed("MarkdownFlavor")
71 }
72
73 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
74 markdown_flavor_schema(generator)
75 }
76}
77
78impl fmt::Display for MarkdownFlavor {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 match self {
81 MarkdownFlavor::Standard => write!(f, "standard"),
82 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
83 MarkdownFlavor::MDX => write!(f, "mdx"),
84 MarkdownFlavor::Quarto => write!(f, "quarto"),
85 MarkdownFlavor::Obsidian => write!(f, "obsidian"),
86 }
87 }
88}
89
90impl FromStr for MarkdownFlavor {
91 type Err = String;
92
93 fn from_str(s: &str) -> Result<Self, Self::Err> {
94 match s.to_lowercase().as_str() {
95 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
96 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
97 "mdx" => Ok(MarkdownFlavor::MDX),
98 "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
99 "obsidian" => Ok(MarkdownFlavor::Obsidian),
100 "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
104 _ => Err(format!("Unknown markdown flavor: {s}")),
105 }
106 }
107}
108
109impl MarkdownFlavor {
110 pub fn from_extension(ext: &str) -> Self {
112 match ext.to_lowercase().as_str() {
113 "mdx" => Self::MDX,
114 "qmd" => Self::Quarto,
115 "rmd" => Self::Quarto,
116 _ => Self::Standard,
117 }
118 }
119
120 pub fn from_path(path: &std::path::Path) -> Self {
122 path.extension()
123 .and_then(|e| e.to_str())
124 .map(Self::from_extension)
125 .unwrap_or(Self::Standard)
126 }
127
128 pub fn supports_esm_blocks(self) -> bool {
130 matches!(self, Self::MDX)
131 }
132
133 pub fn supports_jsx(self) -> bool {
135 matches!(self, Self::MDX)
136 }
137
138 pub fn supports_auto_references(self) -> bool {
140 matches!(self, Self::MkDocs)
141 }
142
143 pub fn name(self) -> &'static str {
145 match self {
146 Self::Standard => "Standard",
147 Self::MkDocs => "MkDocs",
148 Self::MDX => "MDX",
149 Self::Quarto => "Quarto",
150 Self::Obsidian => "Obsidian",
151 }
152 }
153}
154
155pub fn normalize_key(key: &str) -> String {
157 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
159 key.to_ascii_uppercase()
160 } else {
161 key.replace('_', "-").to_ascii_lowercase()
162 }
163}
164
165fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: &str) {
169 if pattern.contains(',') && !pattern.contains('{') {
170 eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
171 eprintln!(" To match multiple files, use brace expansion: \"{{{pattern}}}\"");
172 eprintln!(" Or use separate entries for each file.");
173 }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
178pub struct RuleConfig {
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub severity: Option<crate::rule::Severity>,
182
183 #[serde(flatten)]
185 #[schemars(schema_with = "arbitrary_value_schema")]
186 pub values: BTreeMap<String, toml::Value>,
187}
188
189fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
191 schemars::json_schema!({
192 "type": "object",
193 "additionalProperties": true
194 })
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
199#[schemars(
200 description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
201)]
202pub struct Config {
203 #[serde(default)]
205 pub global: GlobalConfig,
206
207 #[serde(default, rename = "per-file-ignores")]
210 pub per_file_ignores: HashMap<String, Vec<String>>,
211
212 #[serde(default, rename = "per-file-flavor")]
216 #[schemars(with = "HashMap<String, MarkdownFlavor>")]
217 pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
218
219 #[serde(default, rename = "code-block-tools")]
222 pub code_block_tools: crate::code_block_tools::CodeBlockToolsConfig,
223
224 #[serde(flatten)]
235 pub rules: BTreeMap<String, RuleConfig>,
236
237 #[serde(skip)]
239 pub project_root: Option<std::path::PathBuf>,
240
241 #[serde(skip)]
242 #[schemars(skip)]
243 per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
244
245 #[serde(skip)]
246 #[schemars(skip)]
247 per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
248}
249
250impl PartialEq for Config {
251 fn eq(&self, other: &Self) -> bool {
252 self.global == other.global
253 && self.per_file_ignores == other.per_file_ignores
254 && self.per_file_flavor == other.per_file_flavor
255 && self.code_block_tools == other.code_block_tools
256 && self.rules == other.rules
257 && self.project_root == other.project_root
258 }
259}
260
261#[derive(Debug)]
262struct PerFileIgnoreCache {
263 globset: GlobSet,
264 rules: Vec<Vec<String>>,
265}
266
267#[derive(Debug)]
268struct PerFileFlavorCache {
269 matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
270}
271
272impl Config {
273 pub fn is_mkdocs_flavor(&self) -> bool {
275 self.global.flavor == MarkdownFlavor::MkDocs
276 }
277
278 pub fn markdown_flavor(&self) -> MarkdownFlavor {
284 self.global.flavor
285 }
286
287 pub fn is_mkdocs_project(&self) -> bool {
289 self.is_mkdocs_flavor()
290 }
291
292 pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
294 self.rules.get(rule_name).and_then(|r| r.severity)
295 }
296
297 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
300 let mut ignored_rules = HashSet::new();
301
302 if self.per_file_ignores.is_empty() {
303 return ignored_rules;
304 }
305
306 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
309 if let Ok(canonical_path) = file_path.canonicalize() {
310 if let Ok(canonical_root) = root.canonicalize() {
311 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
312 std::borrow::Cow::Owned(relative.to_path_buf())
313 } else {
314 std::borrow::Cow::Borrowed(file_path)
315 }
316 } else {
317 std::borrow::Cow::Borrowed(file_path)
318 }
319 } else {
320 std::borrow::Cow::Borrowed(file_path)
321 }
322 } else {
323 std::borrow::Cow::Borrowed(file_path)
324 };
325
326 let cache = self
327 .per_file_ignores_cache
328 .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
329
330 for match_idx in cache.globset.matches(path_for_matching.as_ref()) {
332 if let Some(rules) = cache.rules.get(match_idx) {
333 for rule in rules.iter() {
334 ignored_rules.insert(rule.clone());
336 }
337 }
338 }
339
340 ignored_rules
341 }
342
343 pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
347 if self.per_file_flavor.is_empty() {
349 return self.resolve_flavor_fallback(file_path);
350 }
351
352 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
354 if let Ok(canonical_path) = file_path.canonicalize() {
355 if let Ok(canonical_root) = root.canonicalize() {
356 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
357 std::borrow::Cow::Owned(relative.to_path_buf())
358 } else {
359 std::borrow::Cow::Borrowed(file_path)
360 }
361 } else {
362 std::borrow::Cow::Borrowed(file_path)
363 }
364 } else {
365 std::borrow::Cow::Borrowed(file_path)
366 }
367 } else {
368 std::borrow::Cow::Borrowed(file_path)
369 };
370
371 let cache = self
372 .per_file_flavor_cache
373 .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
374
375 for (matcher, flavor) in &cache.matchers {
377 if matcher.is_match(path_for_matching.as_ref()) {
378 return *flavor;
379 }
380 }
381
382 self.resolve_flavor_fallback(file_path)
384 }
385
386 fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
388 if self.global.flavor != MarkdownFlavor::Standard {
390 return self.global.flavor;
391 }
392 MarkdownFlavor::from_path(file_path)
394 }
395
396 pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
404 let overrides = inline_config.get_all_rule_configs();
405 if overrides.is_empty() {
406 return self.clone();
407 }
408
409 let mut merged = self.clone();
410
411 for (rule_name, json_override) in overrides {
412 let rule_config = merged.rules.entry(rule_name.clone()).or_default();
414
415 if let Some(obj) = json_override.as_object() {
417 for (key, value) in obj {
418 let normalized_key = key.replace('_', "-");
420
421 if let Some(toml_value) = json_to_toml(value) {
423 rule_config.values.insert(normalized_key, toml_value);
424 }
425 }
426 }
427 }
428
429 merged
430 }
431}
432
433fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
435 match json {
436 serde_json::Value::Null => None,
437 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
438 serde_json::Value::Number(n) => n
439 .as_i64()
440 .map(toml::Value::Integer)
441 .or_else(|| n.as_f64().map(toml::Value::Float)),
442 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
443 serde_json::Value::Array(arr) => {
444 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
445 Some(toml::Value::Array(toml_arr))
446 }
447 serde_json::Value::Object(obj) => {
448 let mut table = toml::map::Map::new();
449 for (k, v) in obj {
450 if let Some(tv) = json_to_toml(v) {
451 table.insert(k.clone(), tv);
452 }
453 }
454 Some(toml::Value::Table(table))
455 }
456 }
457}
458
459impl PerFileIgnoreCache {
460 fn new(per_file_ignores: &HashMap<String, Vec<String>>) -> Self {
461 let mut builder = GlobSetBuilder::new();
462 let mut rules = Vec::new();
463
464 for (pattern, rules_list) in per_file_ignores {
465 if let Ok(glob) = Glob::new(pattern) {
466 builder.add(glob);
467 rules.push(rules_list.iter().map(|rule| normalize_key(rule)).collect());
468 } else {
469 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
470 }
471 }
472
473 let globset = builder.build().unwrap_or_else(|e| {
474 log::error!("Failed to build globset for per-file-ignores: {e}");
475 GlobSetBuilder::new().build().unwrap()
476 });
477
478 Self { globset, rules }
479 }
480}
481
482impl PerFileFlavorCache {
483 fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
484 let mut matchers = Vec::new();
485
486 for (pattern, flavor) in per_file_flavor {
487 if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
488 matchers.push((glob.compile_matcher(), *flavor));
489 } else {
490 log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
491 }
492 }
493
494 Self { matchers }
495 }
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
500#[serde(default, rename_all = "kebab-case")]
501pub struct GlobalConfig {
502 #[serde(default)]
504 pub enable: Vec<String>,
505
506 #[serde(default)]
508 pub disable: Vec<String>,
509
510 #[serde(default)]
512 pub exclude: Vec<String>,
513
514 #[serde(default)]
516 pub include: Vec<String>,
517
518 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
520 pub respect_gitignore: bool,
521
522 #[serde(default, alias = "line_length")]
524 pub line_length: LineLength,
525
526 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
528 pub output_format: Option<String>,
529
530 #[serde(default)]
533 pub fixable: Vec<String>,
534
535 #[serde(default)]
538 pub unfixable: Vec<String>,
539
540 #[serde(default)]
543 pub flavor: MarkdownFlavor,
544
545 #[serde(default, alias = "force_exclude")]
550 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
551 pub force_exclude: bool,
552
553 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
556 pub cache_dir: Option<String>,
557
558 #[serde(default = "default_true")]
561 pub cache: bool,
562}
563
564fn default_respect_gitignore() -> bool {
565 true
566}
567
568fn default_true() -> bool {
569 true
570}
571
572impl Default for GlobalConfig {
574 #[allow(deprecated)]
575 fn default() -> Self {
576 Self {
577 enable: Vec::new(),
578 disable: Vec::new(),
579 exclude: Vec::new(),
580 include: Vec::new(),
581 respect_gitignore: true,
582 line_length: LineLength::default(),
583 output_format: None,
584 fixable: Vec::new(),
585 unfixable: Vec::new(),
586 flavor: MarkdownFlavor::default(),
587 force_exclude: false,
588 cache_dir: None,
589 cache: true,
590 }
591 }
592}
593
594const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
595 ".markdownlint.json",
596 ".markdownlint.jsonc",
597 ".markdownlint.yaml",
598 ".markdownlint.yml",
599 "markdownlint.json",
600 "markdownlint.jsonc",
601 "markdownlint.yaml",
602 "markdownlint.yml",
603];
604
605pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
607 if Path::new(path).exists() {
609 return Err(ConfigError::FileExists { path: path.to_string() });
610 }
611
612 let default_config = r#"# rumdl configuration file
614
615# Global configuration options
616[global]
617# List of rules to disable (uncomment and modify as needed)
618# disable = ["MD013", "MD033"]
619
620# List of rules to enable exclusively (if provided, only these rules will run)
621# enable = ["MD001", "MD003", "MD004"]
622
623# List of file/directory patterns to include for linting (if provided, only these will be linted)
624# include = [
625# "docs/*.md",
626# "src/**/*.md",
627# "README.md"
628# ]
629
630# List of file/directory patterns to exclude from linting
631exclude = [
632 # Common directories to exclude
633 ".git",
634 ".github",
635 "node_modules",
636 "vendor",
637 "dist",
638 "build",
639
640 # Specific files or patterns
641 "CHANGELOG.md",
642 "LICENSE.md",
643]
644
645# Respect .gitignore files when scanning directories (default: true)
646respect-gitignore = true
647
648# Markdown flavor/dialect (uncomment to enable)
649# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
650# flavor = "mkdocs"
651
652# Rule-specific configurations (uncomment and modify as needed)
653
654# [MD003]
655# style = "atx" # Heading style (atx, atx_closed, setext)
656
657# [MD004]
658# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
659
660# [MD007]
661# indent = 4 # Unordered list indentation
662
663# [MD013]
664# line-length = 100 # Line length
665# code-blocks = false # Exclude code blocks from line length check
666# tables = false # Exclude tables from line length check
667# headings = true # Include headings in line length check
668
669# [MD044]
670# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
671# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
672"#;
673
674 match fs::write(path, default_config) {
676 Ok(_) => Ok(()),
677 Err(err) => Err(ConfigError::IoError {
678 source: err,
679 path: path.to_string(),
680 }),
681 }
682}
683
684#[derive(Debug, thiserror::Error)]
686pub enum ConfigError {
687 #[error("Failed to read config file at {path}: {source}")]
689 IoError { source: io::Error, path: String },
690
691 #[error("Failed to parse config: {0}")]
693 ParseError(String),
694
695 #[error("Configuration file already exists at {path}")]
697 FileExists { path: String },
698}
699
700pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
704 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
707
708 let key_variants = [
710 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
715
716 for variant in &key_variants {
718 if let Some(value) = rule_config.values.get(variant)
719 && let Ok(result) = T::deserialize(value.clone())
720 {
721 return Some(result);
722 }
723 }
724
725 None
726}
727
728pub fn generate_pyproject_config() -> String {
730 let config_content = r#"
731[tool.rumdl]
732# Global configuration options
733line-length = 100
734disable = []
735exclude = [
736 # Common directories to exclude
737 ".git",
738 ".github",
739 "node_modules",
740 "vendor",
741 "dist",
742 "build",
743]
744respect-gitignore = true
745
746# Rule-specific configurations (uncomment and modify as needed)
747
748# [tool.rumdl.MD003]
749# style = "atx" # Heading style (atx, atx_closed, setext)
750
751# [tool.rumdl.MD004]
752# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
753
754# [tool.rumdl.MD007]
755# indent = 4 # Unordered list indentation
756
757# [tool.rumdl.MD013]
758# line-length = 100 # Line length
759# code-blocks = false # Exclude code blocks from line length check
760# tables = false # Exclude tables from line length check
761# headings = true # Include headings in line length check
762
763# [tool.rumdl.MD044]
764# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
765# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
766"#;
767
768 config_content.to_string()
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774 use std::fs;
775 use tempfile::tempdir;
776
777 #[test]
778 fn test_flavor_loading() {
779 let temp_dir = tempdir().unwrap();
780 let config_path = temp_dir.path().join(".rumdl.toml");
781 let config_content = r#"
782[global]
783flavor = "mkdocs"
784disable = ["MD001"]
785"#;
786 fs::write(&config_path, config_content).unwrap();
787
788 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
790 let config: Config = sourced.into_validated_unchecked().into();
791
792 assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
794 assert!(config.is_mkdocs_flavor());
795 assert!(config.is_mkdocs_project()); assert_eq!(config.global.disable, vec!["MD001".to_string()]);
797 }
798
799 #[test]
800 fn test_pyproject_toml_root_level_config() {
801 let temp_dir = tempdir().unwrap();
802 let config_path = temp_dir.path().join("pyproject.toml");
803
804 let content = r#"
806[tool.rumdl]
807line-length = 120
808disable = ["MD033"]
809enable = ["MD001", "MD004"]
810include = ["docs/*.md"]
811exclude = ["node_modules"]
812respect-gitignore = true
813 "#;
814
815 fs::write(&config_path, content).unwrap();
816
817 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
819 let config: Config = sourced.into_validated_unchecked().into(); assert_eq!(config.global.disable, vec!["MD033".to_string()]);
823 assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
824 assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
826 assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
827 assert!(config.global.respect_gitignore);
828
829 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
831 assert_eq!(line_length, Some(120));
832 }
833
834 #[test]
835 fn test_pyproject_toml_snake_case_and_kebab_case() {
836 let temp_dir = tempdir().unwrap();
837 let config_path = temp_dir.path().join("pyproject.toml");
838
839 let content = r#"
841[tool.rumdl]
842line-length = 150
843respect_gitignore = true
844 "#;
845
846 fs::write(&config_path, content).unwrap();
847
848 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
850 let config: Config = sourced.into_validated_unchecked().into(); assert!(config.global.respect_gitignore);
854 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
855 assert_eq!(line_length, Some(150));
856 }
857
858 #[test]
859 fn test_md013_key_normalization_in_rumdl_toml() {
860 let temp_dir = tempdir().unwrap();
861 let config_path = temp_dir.path().join(".rumdl.toml");
862 let config_content = r#"
863[MD013]
864line_length = 111
865line-length = 222
866"#;
867 fs::write(&config_path, config_content).unwrap();
868 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
870 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
871 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
873 assert_eq!(keys, vec!["line-length"]);
874 let val = &rule_cfg.values["line-length"].value;
875 assert_eq!(val.as_integer(), Some(222));
876 let config: Config = sourced.clone().into_validated_unchecked().into();
878 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
879 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
880 assert_eq!(v1, Some(222));
881 assert_eq!(v2, Some(222));
882 }
883
884 #[test]
885 fn test_md013_section_case_insensitivity() {
886 let temp_dir = tempdir().unwrap();
887 let config_path = temp_dir.path().join(".rumdl.toml");
888 let config_content = r#"
889[md013]
890line-length = 101
891
892[Md013]
893line-length = 102
894
895[MD013]
896line-length = 103
897"#;
898 fs::write(&config_path, config_content).unwrap();
899 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
901 let config: Config = sourced.clone().into_validated_unchecked().into();
902 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
904 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
905 assert_eq!(keys, vec!["line-length"]);
906 let val = &rule_cfg.values["line-length"].value;
907 assert_eq!(val.as_integer(), Some(103));
908 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
909 assert_eq!(v, Some(103));
910 }
911
912 #[test]
913 fn test_md013_key_snake_and_kebab_case() {
914 let temp_dir = tempdir().unwrap();
915 let config_path = temp_dir.path().join(".rumdl.toml");
916 let config_content = r#"
917[MD013]
918line_length = 201
919line-length = 202
920"#;
921 fs::write(&config_path, config_content).unwrap();
922 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
924 let config: Config = sourced.clone().into_validated_unchecked().into();
925 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
926 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
927 assert_eq!(keys, vec!["line-length"]);
928 let val = &rule_cfg.values["line-length"].value;
929 assert_eq!(val.as_integer(), Some(202));
930 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
931 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
932 assert_eq!(v1, Some(202));
933 assert_eq!(v2, Some(202));
934 }
935
936 #[test]
937 fn test_unknown_rule_section_is_ignored() {
938 let temp_dir = tempdir().unwrap();
939 let config_path = temp_dir.path().join(".rumdl.toml");
940 let config_content = r#"
941[MD999]
942foo = 1
943bar = 2
944[MD013]
945line-length = 303
946"#;
947 fs::write(&config_path, config_content).unwrap();
948 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
950 let config: Config = sourced.clone().into_validated_unchecked().into();
951 assert!(!sourced.rules.contains_key("MD999"));
953 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
955 assert_eq!(v, Some(303));
956 }
957
958 #[test]
959 fn test_invalid_toml_syntax() {
960 let temp_dir = tempdir().unwrap();
961 let config_path = temp_dir.path().join(".rumdl.toml");
962
963 let config_content = r#"
965[MD013]
966line-length = "unclosed string
967"#;
968 fs::write(&config_path, config_content).unwrap();
969
970 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
971 assert!(result.is_err());
972 match result.unwrap_err() {
973 ConfigError::ParseError(msg) => {
974 assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
976 }
977 _ => panic!("Expected ParseError"),
978 }
979 }
980
981 #[test]
982 fn test_wrong_type_for_config_value() {
983 let temp_dir = tempdir().unwrap();
984 let config_path = temp_dir.path().join(".rumdl.toml");
985
986 let config_content = r#"
988[MD013]
989line-length = "not a number"
990"#;
991 fs::write(&config_path, config_content).unwrap();
992
993 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
994 let config: Config = sourced.into_validated_unchecked().into();
995
996 let rule_config = config.rules.get("MD013").unwrap();
998 let value = rule_config.values.get("line-length").unwrap();
999 assert!(matches!(value, toml::Value::String(_)));
1000 }
1001
1002 #[test]
1003 fn test_empty_config_file() {
1004 let temp_dir = tempdir().unwrap();
1005 let config_path = temp_dir.path().join(".rumdl.toml");
1006
1007 fs::write(&config_path, "").unwrap();
1009
1010 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1011 let config: Config = sourced.into_validated_unchecked().into();
1012
1013 assert_eq!(config.global.line_length.get(), 80);
1015 assert!(config.global.respect_gitignore);
1016 assert!(config.rules.is_empty());
1017 }
1018
1019 #[test]
1020 fn test_malformed_pyproject_toml() {
1021 let temp_dir = tempdir().unwrap();
1022 let config_path = temp_dir.path().join("pyproject.toml");
1023
1024 let content = r#"
1026[tool.rumdl
1027line-length = 120
1028"#;
1029 fs::write(&config_path, content).unwrap();
1030
1031 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1032 assert!(result.is_err());
1033 }
1034
1035 #[test]
1036 fn test_conflicting_config_values() {
1037 let temp_dir = tempdir().unwrap();
1038 let config_path = temp_dir.path().join(".rumdl.toml");
1039
1040 let config_content = r#"
1042[global]
1043enable = ["MD013"]
1044disable = ["MD013"]
1045"#;
1046 fs::write(&config_path, config_content).unwrap();
1047
1048 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1049 let config: Config = sourced.into_validated_unchecked().into();
1050
1051 assert!(config.global.enable.contains(&"MD013".to_string()));
1053 assert!(!config.global.disable.contains(&"MD013".to_string()));
1054 }
1055
1056 #[test]
1057 fn test_invalid_rule_names() {
1058 let temp_dir = tempdir().unwrap();
1059 let config_path = temp_dir.path().join(".rumdl.toml");
1060
1061 let config_content = r#"
1062[global]
1063enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
1064disable = ["MD-001", "MD_002"]
1065"#;
1066 fs::write(&config_path, config_content).unwrap();
1067
1068 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1069 let config: Config = sourced.into_validated_unchecked().into();
1070
1071 assert_eq!(config.global.enable.len(), 4);
1073 assert_eq!(config.global.disable.len(), 2);
1074 }
1075
1076 #[test]
1077 fn test_deeply_nested_config() {
1078 let temp_dir = tempdir().unwrap();
1079 let config_path = temp_dir.path().join(".rumdl.toml");
1080
1081 let config_content = r#"
1083[MD013]
1084line-length = 100
1085[MD013.nested]
1086value = 42
1087"#;
1088 fs::write(&config_path, config_content).unwrap();
1089
1090 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1091 let config: Config = sourced.into_validated_unchecked().into();
1092
1093 let rule_config = config.rules.get("MD013").unwrap();
1094 assert_eq!(
1095 rule_config.values.get("line-length").unwrap(),
1096 &toml::Value::Integer(100)
1097 );
1098 assert!(!rule_config.values.contains_key("nested"));
1100 }
1101
1102 #[test]
1103 fn test_unicode_in_config() {
1104 let temp_dir = tempdir().unwrap();
1105 let config_path = temp_dir.path().join(".rumdl.toml");
1106
1107 let config_content = r#"
1108[global]
1109include = ["文档/*.md", "ドã‚ュメント/*.md"]
1110exclude = ["测试/*", "🚀/*"]
1111
1112[MD013]
1113line-length = 80
1114message = "行太长了 🚨"
1115"#;
1116 fs::write(&config_path, config_content).unwrap();
1117
1118 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1119 let config: Config = sourced.into_validated_unchecked().into();
1120
1121 assert_eq!(config.global.include.len(), 2);
1122 assert_eq!(config.global.exclude.len(), 2);
1123 assert!(config.global.include[0].contains("文档"));
1124 assert!(config.global.exclude[1].contains("🚀"));
1125
1126 let rule_config = config.rules.get("MD013").unwrap();
1127 let message = rule_config.values.get("message").unwrap();
1128 if let toml::Value::String(s) = message {
1129 assert!(s.contains("行太长了"));
1130 assert!(s.contains("🚨"));
1131 }
1132 }
1133
1134 #[test]
1135 fn test_extremely_long_values() {
1136 let temp_dir = tempdir().unwrap();
1137 let config_path = temp_dir.path().join(".rumdl.toml");
1138
1139 let long_string = "a".repeat(10000);
1140 let config_content = format!(
1141 r#"
1142[global]
1143exclude = ["{long_string}"]
1144
1145[MD013]
1146line-length = 999999999
1147"#
1148 );
1149
1150 fs::write(&config_path, config_content).unwrap();
1151
1152 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1153 let config: Config = sourced.into_validated_unchecked().into();
1154
1155 assert_eq!(config.global.exclude[0].len(), 10000);
1156 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
1157 assert_eq!(line_length, Some(999999999));
1158 }
1159
1160 #[test]
1161 fn test_config_with_comments() {
1162 let temp_dir = tempdir().unwrap();
1163 let config_path = temp_dir.path().join(".rumdl.toml");
1164
1165 let config_content = r#"
1166[global]
1167# This is a comment
1168enable = ["MD001"] # Enable MD001
1169# disable = ["MD002"] # This is commented out
1170
1171[MD013] # Line length rule
1172line-length = 100 # Set to 100 characters
1173# ignored = true # This setting is commented out
1174"#;
1175 fs::write(&config_path, config_content).unwrap();
1176
1177 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1178 let config: Config = sourced.into_validated_unchecked().into();
1179
1180 assert_eq!(config.global.enable, vec!["MD001"]);
1181 assert!(config.global.disable.is_empty()); let rule_config = config.rules.get("MD013").unwrap();
1184 assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
1186 }
1187
1188 #[test]
1189 fn test_arrays_in_rule_config() {
1190 let temp_dir = tempdir().unwrap();
1191 let config_path = temp_dir.path().join(".rumdl.toml");
1192
1193 let config_content = r#"
1194[MD003]
1195levels = [1, 2, 3]
1196tags = ["important", "critical"]
1197mixed = [1, "two", true]
1198"#;
1199 fs::write(&config_path, config_content).unwrap();
1200
1201 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1202 let config: Config = sourced.into_validated_unchecked().into();
1203
1204 let rule_config = config.rules.get("MD003").expect("MD003 config should exist");
1206
1207 assert!(rule_config.values.contains_key("levels"));
1209 assert!(rule_config.values.contains_key("tags"));
1210 assert!(rule_config.values.contains_key("mixed"));
1211
1212 if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
1214 assert_eq!(levels.len(), 3);
1215 assert_eq!(levels[0], toml::Value::Integer(1));
1216 assert_eq!(levels[1], toml::Value::Integer(2));
1217 assert_eq!(levels[2], toml::Value::Integer(3));
1218 } else {
1219 panic!("levels should be an array");
1220 }
1221
1222 if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
1223 assert_eq!(tags.len(), 2);
1224 assert_eq!(tags[0], toml::Value::String("important".to_string()));
1225 assert_eq!(tags[1], toml::Value::String("critical".to_string()));
1226 } else {
1227 panic!("tags should be an array");
1228 }
1229
1230 if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
1231 assert_eq!(mixed.len(), 3);
1232 assert_eq!(mixed[0], toml::Value::Integer(1));
1233 assert_eq!(mixed[1], toml::Value::String("two".to_string()));
1234 assert_eq!(mixed[2], toml::Value::Boolean(true));
1235 } else {
1236 panic!("mixed should be an array");
1237 }
1238 }
1239
1240 #[test]
1241 fn test_normalize_key_edge_cases() {
1242 assert_eq!(normalize_key("MD001"), "MD001");
1244 assert_eq!(normalize_key("md001"), "MD001");
1245 assert_eq!(normalize_key("Md001"), "MD001");
1246 assert_eq!(normalize_key("mD001"), "MD001");
1247
1248 assert_eq!(normalize_key("line_length"), "line-length");
1250 assert_eq!(normalize_key("line-length"), "line-length");
1251 assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
1252 assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
1253
1254 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(""), "");
1261 assert_eq!(normalize_key("_"), "-");
1262 assert_eq!(normalize_key("___"), "---");
1263 }
1264
1265 #[test]
1266 fn test_missing_config_file() {
1267 let temp_dir = tempdir().unwrap();
1268 let config_path = temp_dir.path().join("nonexistent.toml");
1269
1270 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1271 assert!(result.is_err());
1272 match result.unwrap_err() {
1273 ConfigError::IoError { .. } => {}
1274 _ => panic!("Expected IoError for missing file"),
1275 }
1276 }
1277
1278 #[test]
1279 #[cfg(unix)]
1280 fn test_permission_denied_config() {
1281 use std::os::unix::fs::PermissionsExt;
1282
1283 let temp_dir = tempdir().unwrap();
1284 let config_path = temp_dir.path().join(".rumdl.toml");
1285
1286 fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
1287
1288 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1290 perms.set_mode(0o000);
1291 fs::set_permissions(&config_path, perms).unwrap();
1292
1293 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1294
1295 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1297 perms.set_mode(0o644);
1298 fs::set_permissions(&config_path, perms).unwrap();
1299
1300 assert!(result.is_err());
1301 match result.unwrap_err() {
1302 ConfigError::IoError { .. } => {}
1303 _ => panic!("Expected IoError for permission denied"),
1304 }
1305 }
1306
1307 #[test]
1308 fn test_circular_reference_detection() {
1309 let temp_dir = tempdir().unwrap();
1312 let config_path = temp_dir.path().join(".rumdl.toml");
1313
1314 let mut config_content = String::from("[MD001]\n");
1315 for i in 0..100 {
1316 config_content.push_str(&format!("key{i} = {i}\n"));
1317 }
1318
1319 fs::write(&config_path, config_content).unwrap();
1320
1321 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1322 let config: Config = sourced.into_validated_unchecked().into();
1323
1324 let rule_config = config.rules.get("MD001").unwrap();
1325 assert_eq!(rule_config.values.len(), 100);
1326 }
1327
1328 #[test]
1329 fn test_special_toml_values() {
1330 let temp_dir = tempdir().unwrap();
1331 let config_path = temp_dir.path().join(".rumdl.toml");
1332
1333 let config_content = r#"
1334[MD001]
1335infinity = inf
1336neg_infinity = -inf
1337not_a_number = nan
1338datetime = 1979-05-27T07:32:00Z
1339local_date = 1979-05-27
1340local_time = 07:32:00
1341"#;
1342 fs::write(&config_path, config_content).unwrap();
1343
1344 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1345 let config: Config = sourced.into_validated_unchecked().into();
1346
1347 if let Some(rule_config) = config.rules.get("MD001") {
1349 if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
1351 assert!(f.is_infinite() && f.is_sign_positive());
1352 }
1353 if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
1354 assert!(f.is_infinite() && f.is_sign_negative());
1355 }
1356 if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
1357 assert!(f.is_nan());
1358 }
1359
1360 if let Some(val) = rule_config.values.get("datetime") {
1362 assert!(matches!(val, toml::Value::Datetime(_)));
1363 }
1364 }
1366 }
1367
1368 #[test]
1369 fn test_default_config_passes_validation() {
1370 use crate::rules;
1371
1372 let temp_dir = tempdir().unwrap();
1373 let config_path = temp_dir.path().join(".rumdl.toml");
1374 let config_path_str = config_path.to_str().unwrap();
1375
1376 create_default_config(config_path_str).unwrap();
1378
1379 let sourced =
1381 SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1382
1383 let all_rules = rules::all_rules(&Config::default());
1385 let registry = RuleRegistry::from_rules(&all_rules);
1386
1387 let warnings = validate_config_sourced(&sourced, ®istry);
1389
1390 if !warnings.is_empty() {
1392 for warning in &warnings {
1393 eprintln!("Config validation warning: {}", warning.message);
1394 if let Some(rule) = &warning.rule {
1395 eprintln!(" Rule: {rule}");
1396 }
1397 if let Some(key) = &warning.key {
1398 eprintln!(" Key: {key}");
1399 }
1400 }
1401 }
1402 assert!(
1403 warnings.is_empty(),
1404 "Default config from rumdl init should pass validation without warnings"
1405 );
1406 }
1407
1408 #[test]
1409 fn test_per_file_ignores_config_parsing() {
1410 let temp_dir = tempdir().unwrap();
1411 let config_path = temp_dir.path().join(".rumdl.toml");
1412 let config_content = r#"
1413[per-file-ignores]
1414"README.md" = ["MD033"]
1415"docs/**/*.md" = ["MD013", "MD033"]
1416"test/*.md" = ["MD041"]
1417"#;
1418 fs::write(&config_path, config_content).unwrap();
1419
1420 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1421 let config: Config = sourced.into_validated_unchecked().into();
1422
1423 assert_eq!(config.per_file_ignores.len(), 3);
1425 assert_eq!(
1426 config.per_file_ignores.get("README.md"),
1427 Some(&vec!["MD033".to_string()])
1428 );
1429 assert_eq!(
1430 config.per_file_ignores.get("docs/**/*.md"),
1431 Some(&vec!["MD013".to_string(), "MD033".to_string()])
1432 );
1433 assert_eq!(
1434 config.per_file_ignores.get("test/*.md"),
1435 Some(&vec!["MD041".to_string()])
1436 );
1437 }
1438
1439 #[test]
1440 fn test_per_file_ignores_glob_matching() {
1441 use std::path::PathBuf;
1442
1443 let temp_dir = tempdir().unwrap();
1444 let config_path = temp_dir.path().join(".rumdl.toml");
1445 let config_content = r#"
1446[per-file-ignores]
1447"README.md" = ["MD033"]
1448"docs/**/*.md" = ["MD013"]
1449"**/test_*.md" = ["MD041"]
1450"#;
1451 fs::write(&config_path, config_content).unwrap();
1452
1453 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1454 let config: Config = sourced.into_validated_unchecked().into();
1455
1456 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1458 assert!(ignored.contains("MD033"));
1459 assert_eq!(ignored.len(), 1);
1460
1461 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1463 assert!(ignored.contains("MD013"));
1464 assert_eq!(ignored.len(), 1);
1465
1466 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
1468 assert!(ignored.contains("MD041"));
1469 assert_eq!(ignored.len(), 1);
1470
1471 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
1473 assert!(ignored.is_empty());
1474 }
1475
1476 #[test]
1477 fn test_per_file_ignores_pyproject_toml() {
1478 let temp_dir = tempdir().unwrap();
1479 let config_path = temp_dir.path().join("pyproject.toml");
1480 let config_content = r#"
1481[tool.rumdl]
1482[tool.rumdl.per-file-ignores]
1483"README.md" = ["MD033", "MD013"]
1484"generated/*.md" = ["MD041"]
1485"#;
1486 fs::write(&config_path, config_content).unwrap();
1487
1488 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1489 let config: Config = sourced.into_validated_unchecked().into();
1490
1491 assert_eq!(config.per_file_ignores.len(), 2);
1493 assert_eq!(
1494 config.per_file_ignores.get("README.md"),
1495 Some(&vec!["MD033".to_string(), "MD013".to_string()])
1496 );
1497 assert_eq!(
1498 config.per_file_ignores.get("generated/*.md"),
1499 Some(&vec!["MD041".to_string()])
1500 );
1501 }
1502
1503 #[test]
1504 fn test_per_file_ignores_multiple_patterns_match() {
1505 use std::path::PathBuf;
1506
1507 let temp_dir = tempdir().unwrap();
1508 let config_path = temp_dir.path().join(".rumdl.toml");
1509 let config_content = r#"
1510[per-file-ignores]
1511"docs/**/*.md" = ["MD013"]
1512"**/api/*.md" = ["MD033"]
1513"docs/api/overview.md" = ["MD041"]
1514"#;
1515 fs::write(&config_path, config_content).unwrap();
1516
1517 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1518 let config: Config = sourced.into_validated_unchecked().into();
1519
1520 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1522 assert_eq!(ignored.len(), 3);
1523 assert!(ignored.contains("MD013"));
1524 assert!(ignored.contains("MD033"));
1525 assert!(ignored.contains("MD041"));
1526 }
1527
1528 #[test]
1529 fn test_per_file_ignores_rule_name_normalization() {
1530 use std::path::PathBuf;
1531
1532 let temp_dir = tempdir().unwrap();
1533 let config_path = temp_dir.path().join(".rumdl.toml");
1534 let config_content = r#"
1535[per-file-ignores]
1536"README.md" = ["md033", "MD013", "Md041"]
1537"#;
1538 fs::write(&config_path, config_content).unwrap();
1539
1540 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1541 let config: Config = sourced.into_validated_unchecked().into();
1542
1543 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1545 assert_eq!(ignored.len(), 3);
1546 assert!(ignored.contains("MD033"));
1547 assert!(ignored.contains("MD013"));
1548 assert!(ignored.contains("MD041"));
1549 }
1550
1551 #[test]
1552 fn test_per_file_ignores_invalid_glob_pattern() {
1553 use std::path::PathBuf;
1554
1555 let temp_dir = tempdir().unwrap();
1556 let config_path = temp_dir.path().join(".rumdl.toml");
1557 let config_content = r#"
1558[per-file-ignores]
1559"[invalid" = ["MD033"]
1560"valid/*.md" = ["MD013"]
1561"#;
1562 fs::write(&config_path, config_content).unwrap();
1563
1564 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1565 let config: Config = sourced.into_validated_unchecked().into();
1566
1567 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
1569 assert!(ignored.contains("MD013"));
1570
1571 let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
1573 assert!(ignored2.is_empty());
1574 }
1575
1576 #[test]
1577 fn test_per_file_ignores_empty_section() {
1578 use std::path::PathBuf;
1579
1580 let temp_dir = tempdir().unwrap();
1581 let config_path = temp_dir.path().join(".rumdl.toml");
1582 let config_content = r#"
1583[global]
1584disable = ["MD001"]
1585
1586[per-file-ignores]
1587"#;
1588 fs::write(&config_path, config_content).unwrap();
1589
1590 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1591 let config: Config = sourced.into_validated_unchecked().into();
1592
1593 assert_eq!(config.per_file_ignores.len(), 0);
1595 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1596 assert!(ignored.is_empty());
1597 }
1598
1599 #[test]
1600 fn test_per_file_ignores_with_underscores_in_pyproject() {
1601 let temp_dir = tempdir().unwrap();
1602 let config_path = temp_dir.path().join("pyproject.toml");
1603 let config_content = r#"
1604[tool.rumdl]
1605[tool.rumdl.per_file_ignores]
1606"README.md" = ["MD033"]
1607"#;
1608 fs::write(&config_path, config_content).unwrap();
1609
1610 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1611 let config: Config = sourced.into_validated_unchecked().into();
1612
1613 assert_eq!(config.per_file_ignores.len(), 1);
1615 assert_eq!(
1616 config.per_file_ignores.get("README.md"),
1617 Some(&vec!["MD033".to_string()])
1618 );
1619 }
1620
1621 #[test]
1622 fn test_per_file_ignores_absolute_path_matching() {
1623 use std::path::PathBuf;
1626
1627 let temp_dir = tempdir().unwrap();
1628 let config_path = temp_dir.path().join(".rumdl.toml");
1629
1630 let github_dir = temp_dir.path().join(".github");
1632 fs::create_dir_all(&github_dir).unwrap();
1633 let test_file = github_dir.join("pull_request_template.md");
1634 fs::write(&test_file, "Test content").unwrap();
1635
1636 let config_content = r#"
1637[per-file-ignores]
1638".github/pull_request_template.md" = ["MD041"]
1639"docs/**/*.md" = ["MD013"]
1640"#;
1641 fs::write(&config_path, config_content).unwrap();
1642
1643 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1644 let config: Config = sourced.into_validated_unchecked().into();
1645
1646 let absolute_path = test_file.canonicalize().unwrap();
1648 let ignored = config.get_ignored_rules_for_file(&absolute_path);
1649 assert!(
1650 ignored.contains("MD041"),
1651 "Should match absolute path {absolute_path:?} against relative pattern"
1652 );
1653 assert_eq!(ignored.len(), 1);
1654
1655 let relative_path = PathBuf::from(".github/pull_request_template.md");
1657 let ignored = config.get_ignored_rules_for_file(&relative_path);
1658 assert!(ignored.contains("MD041"), "Should match relative path");
1659 }
1660
1661 #[test]
1666 fn test_per_file_flavor_config_parsing() {
1667 let temp_dir = tempdir().unwrap();
1668 let config_path = temp_dir.path().join(".rumdl.toml");
1669 let config_content = r#"
1670[per-file-flavor]
1671"docs/**/*.md" = "mkdocs"
1672"**/*.mdx" = "mdx"
1673"**/*.qmd" = "quarto"
1674"#;
1675 fs::write(&config_path, config_content).unwrap();
1676
1677 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1678 let config: Config = sourced.into_validated_unchecked().into();
1679
1680 assert_eq!(config.per_file_flavor.len(), 3);
1682 assert_eq!(
1683 config.per_file_flavor.get("docs/**/*.md"),
1684 Some(&MarkdownFlavor::MkDocs)
1685 );
1686 assert_eq!(config.per_file_flavor.get("**/*.mdx"), Some(&MarkdownFlavor::MDX));
1687 assert_eq!(config.per_file_flavor.get("**/*.qmd"), Some(&MarkdownFlavor::Quarto));
1688 }
1689
1690 #[test]
1691 fn test_per_file_flavor_glob_matching() {
1692 use std::path::PathBuf;
1693
1694 let temp_dir = tempdir().unwrap();
1695 let config_path = temp_dir.path().join(".rumdl.toml");
1696 let config_content = r#"
1697[per-file-flavor]
1698"docs/**/*.md" = "mkdocs"
1699"**/*.mdx" = "mdx"
1700"components/**/*.md" = "mdx"
1701"#;
1702 fs::write(&config_path, config_content).unwrap();
1703
1704 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1705 let config: Config = sourced.into_validated_unchecked().into();
1706
1707 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/overview.md"));
1709 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1710
1711 let flavor = config.get_flavor_for_file(&PathBuf::from("src/components/Button.mdx"));
1713 assert_eq!(flavor, MarkdownFlavor::MDX);
1714
1715 let flavor = config.get_flavor_for_file(&PathBuf::from("components/Button/README.md"));
1717 assert_eq!(flavor, MarkdownFlavor::MDX);
1718
1719 let flavor = config.get_flavor_for_file(&PathBuf::from("README.md"));
1721 assert_eq!(flavor, MarkdownFlavor::Standard);
1722 }
1723
1724 #[test]
1725 fn test_per_file_flavor_pyproject_toml() {
1726 let temp_dir = tempdir().unwrap();
1727 let config_path = temp_dir.path().join("pyproject.toml");
1728 let config_content = r#"
1729[tool.rumdl]
1730[tool.rumdl.per-file-flavor]
1731"docs/**/*.md" = "mkdocs"
1732"**/*.mdx" = "mdx"
1733"#;
1734 fs::write(&config_path, config_content).unwrap();
1735
1736 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1737 let config: Config = sourced.into_validated_unchecked().into();
1738
1739 assert_eq!(config.per_file_flavor.len(), 2);
1741 assert_eq!(
1742 config.per_file_flavor.get("docs/**/*.md"),
1743 Some(&MarkdownFlavor::MkDocs)
1744 );
1745 assert_eq!(config.per_file_flavor.get("**/*.mdx"), Some(&MarkdownFlavor::MDX));
1746 }
1747
1748 #[test]
1749 fn test_per_file_flavor_first_match_wins() {
1750 use std::path::PathBuf;
1751
1752 let temp_dir = tempdir().unwrap();
1753 let config_path = temp_dir.path().join(".rumdl.toml");
1754 let config_content = r#"
1756[per-file-flavor]
1757"docs/internal/**/*.md" = "quarto"
1758"docs/**/*.md" = "mkdocs"
1759"**/*.md" = "standard"
1760"#;
1761 fs::write(&config_path, config_content).unwrap();
1762
1763 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1764 let config: Config = sourced.into_validated_unchecked().into();
1765
1766 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/internal/secret.md"));
1768 assert_eq!(flavor, MarkdownFlavor::Quarto);
1769
1770 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/public/readme.md"));
1772 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1773
1774 let flavor = config.get_flavor_for_file(&PathBuf::from("other/file.md"));
1776 assert_eq!(flavor, MarkdownFlavor::Standard);
1777 }
1778
1779 #[test]
1780 fn test_per_file_flavor_overrides_global_flavor() {
1781 use std::path::PathBuf;
1782
1783 let temp_dir = tempdir().unwrap();
1784 let config_path = temp_dir.path().join(".rumdl.toml");
1785 let config_content = r#"
1786[global]
1787flavor = "mkdocs"
1788
1789[per-file-flavor]
1790"**/*.mdx" = "mdx"
1791"#;
1792 fs::write(&config_path, config_content).unwrap();
1793
1794 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1795 let config: Config = sourced.into_validated_unchecked().into();
1796
1797 let flavor = config.get_flavor_for_file(&PathBuf::from("components/Button.mdx"));
1799 assert_eq!(flavor, MarkdownFlavor::MDX);
1800
1801 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/readme.md"));
1803 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1804 }
1805
1806 #[test]
1807 fn test_per_file_flavor_empty_map() {
1808 use std::path::PathBuf;
1809
1810 let temp_dir = tempdir().unwrap();
1811 let config_path = temp_dir.path().join(".rumdl.toml");
1812 let config_content = r#"
1813[global]
1814disable = ["MD001"]
1815
1816[per-file-flavor]
1817"#;
1818 fs::write(&config_path, config_content).unwrap();
1819
1820 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1821 let config: Config = sourced.into_validated_unchecked().into();
1822
1823 let flavor = config.get_flavor_for_file(&PathBuf::from("README.md"));
1825 assert_eq!(flavor, MarkdownFlavor::Standard);
1826
1827 let flavor = config.get_flavor_for_file(&PathBuf::from("test.mdx"));
1829 assert_eq!(flavor, MarkdownFlavor::MDX);
1830 }
1831
1832 #[test]
1833 fn test_per_file_flavor_with_underscores() {
1834 let temp_dir = tempdir().unwrap();
1835 let config_path = temp_dir.path().join("pyproject.toml");
1836 let config_content = r#"
1837[tool.rumdl]
1838[tool.rumdl.per_file_flavor]
1839"docs/**/*.md" = "mkdocs"
1840"#;
1841 fs::write(&config_path, config_content).unwrap();
1842
1843 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1844 let config: Config = sourced.into_validated_unchecked().into();
1845
1846 assert_eq!(config.per_file_flavor.len(), 1);
1848 assert_eq!(
1849 config.per_file_flavor.get("docs/**/*.md"),
1850 Some(&MarkdownFlavor::MkDocs)
1851 );
1852 }
1853
1854 #[test]
1855 fn test_per_file_flavor_absolute_path_matching() {
1856 use std::path::PathBuf;
1857
1858 let temp_dir = tempdir().unwrap();
1859 let config_path = temp_dir.path().join(".rumdl.toml");
1860
1861 let docs_dir = temp_dir.path().join("docs");
1863 fs::create_dir_all(&docs_dir).unwrap();
1864 let test_file = docs_dir.join("guide.md");
1865 fs::write(&test_file, "Test content").unwrap();
1866
1867 let config_content = r#"
1868[per-file-flavor]
1869"docs/**/*.md" = "mkdocs"
1870"#;
1871 fs::write(&config_path, config_content).unwrap();
1872
1873 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1874 let config: Config = sourced.into_validated_unchecked().into();
1875
1876 let absolute_path = test_file.canonicalize().unwrap();
1878 let flavor = config.get_flavor_for_file(&absolute_path);
1879 assert_eq!(
1880 flavor,
1881 MarkdownFlavor::MkDocs,
1882 "Should match absolute path {absolute_path:?} against relative pattern"
1883 );
1884
1885 let relative_path = PathBuf::from("docs/guide.md");
1887 let flavor = config.get_flavor_for_file(&relative_path);
1888 assert_eq!(flavor, MarkdownFlavor::MkDocs, "Should match relative path");
1889 }
1890
1891 #[test]
1892 fn test_per_file_flavor_all_flavors() {
1893 let temp_dir = tempdir().unwrap();
1894 let config_path = temp_dir.path().join(".rumdl.toml");
1895 let config_content = r#"
1896[per-file-flavor]
1897"standard/**/*.md" = "standard"
1898"mkdocs/**/*.md" = "mkdocs"
1899"mdx/**/*.md" = "mdx"
1900"quarto/**/*.md" = "quarto"
1901"#;
1902 fs::write(&config_path, config_content).unwrap();
1903
1904 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1905 let config: Config = sourced.into_validated_unchecked().into();
1906
1907 assert_eq!(config.per_file_flavor.len(), 4);
1909 assert_eq!(
1910 config.per_file_flavor.get("standard/**/*.md"),
1911 Some(&MarkdownFlavor::Standard)
1912 );
1913 assert_eq!(
1914 config.per_file_flavor.get("mkdocs/**/*.md"),
1915 Some(&MarkdownFlavor::MkDocs)
1916 );
1917 assert_eq!(config.per_file_flavor.get("mdx/**/*.md"), Some(&MarkdownFlavor::MDX));
1918 assert_eq!(
1919 config.per_file_flavor.get("quarto/**/*.md"),
1920 Some(&MarkdownFlavor::Quarto)
1921 );
1922 }
1923
1924 #[test]
1925 fn test_per_file_flavor_invalid_glob_pattern() {
1926 use std::path::PathBuf;
1927
1928 let temp_dir = tempdir().unwrap();
1929 let config_path = temp_dir.path().join(".rumdl.toml");
1930 let config_content = r#"
1932[per-file-flavor]
1933"[invalid" = "mkdocs"
1934"valid/**/*.md" = "mdx"
1935"#;
1936 fs::write(&config_path, config_content).unwrap();
1937
1938 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1939 let config: Config = sourced.into_validated_unchecked().into();
1940
1941 let flavor = config.get_flavor_for_file(&PathBuf::from("valid/test.md"));
1943 assert_eq!(flavor, MarkdownFlavor::MDX);
1944
1945 let flavor = config.get_flavor_for_file(&PathBuf::from("other/test.md"));
1947 assert_eq!(flavor, MarkdownFlavor::Standard);
1948 }
1949
1950 #[test]
1951 fn test_per_file_flavor_paths_with_spaces() {
1952 use std::path::PathBuf;
1953
1954 let temp_dir = tempdir().unwrap();
1955 let config_path = temp_dir.path().join(".rumdl.toml");
1956 let config_content = r#"
1957[per-file-flavor]
1958"my docs/**/*.md" = "mkdocs"
1959"src/**/*.md" = "mdx"
1960"#;
1961 fs::write(&config_path, config_content).unwrap();
1962
1963 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1964 let config: Config = sourced.into_validated_unchecked().into();
1965
1966 let flavor = config.get_flavor_for_file(&PathBuf::from("my docs/guide.md"));
1968 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1969
1970 let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
1972 assert_eq!(flavor, MarkdownFlavor::MDX);
1973 }
1974
1975 #[test]
1976 fn test_per_file_flavor_deeply_nested_paths() {
1977 use std::path::PathBuf;
1978
1979 let temp_dir = tempdir().unwrap();
1980 let config_path = temp_dir.path().join(".rumdl.toml");
1981 let config_content = r#"
1982[per-file-flavor]
1983"a/b/c/d/e/**/*.md" = "quarto"
1984"a/b/**/*.md" = "mkdocs"
1985"**/*.md" = "standard"
1986"#;
1987 fs::write(&config_path, config_content).unwrap();
1988
1989 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1990 let config: Config = sourced.into_validated_unchecked().into();
1991
1992 let flavor = config.get_flavor_for_file(&PathBuf::from("a/b/c/d/e/f/deep.md"));
1994 assert_eq!(flavor, MarkdownFlavor::Quarto);
1995
1996 let flavor = config.get_flavor_for_file(&PathBuf::from("a/b/c/test.md"));
1998 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1999
2000 let flavor = config.get_flavor_for_file(&PathBuf::from("root.md"));
2002 assert_eq!(flavor, MarkdownFlavor::Standard);
2003 }
2004
2005 #[test]
2006 fn test_per_file_flavor_complex_overlapping_patterns() {
2007 use std::path::PathBuf;
2008
2009 let temp_dir = tempdir().unwrap();
2010 let config_path = temp_dir.path().join(".rumdl.toml");
2011 let config_content = r#"
2013[per-file-flavor]
2014"docs/api/*.md" = "mkdocs"
2015"docs/**/*.mdx" = "mdx"
2016"docs/**/*.md" = "quarto"
2017"**/*.md" = "standard"
2018"#;
2019 fs::write(&config_path, config_content).unwrap();
2020
2021 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2022 let config: Config = sourced.into_validated_unchecked().into();
2023
2024 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/reference.md"));
2026 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2027
2028 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/nested/file.md"));
2030 assert_eq!(flavor, MarkdownFlavor::Quarto);
2031
2032 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/components/Button.mdx"));
2034 assert_eq!(flavor, MarkdownFlavor::MDX);
2035
2036 let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
2038 assert_eq!(flavor, MarkdownFlavor::Standard);
2039 }
2040
2041 #[test]
2042 fn test_per_file_flavor_extension_detection_interaction() {
2043 use std::path::PathBuf;
2044
2045 let temp_dir = tempdir().unwrap();
2046 let config_path = temp_dir.path().join(".rumdl.toml");
2047 let config_content = r#"
2049[per-file-flavor]
2050"legacy/**/*.mdx" = "standard"
2051"#;
2052 fs::write(&config_path, config_content).unwrap();
2053
2054 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2055 let config: Config = sourced.into_validated_unchecked().into();
2056
2057 let flavor = config.get_flavor_for_file(&PathBuf::from("legacy/old.mdx"));
2059 assert_eq!(flavor, MarkdownFlavor::Standard);
2060
2061 let flavor = config.get_flavor_for_file(&PathBuf::from("src/component.mdx"));
2063 assert_eq!(flavor, MarkdownFlavor::MDX);
2064 }
2065
2066 #[test]
2067 fn test_per_file_flavor_standard_alias_none() {
2068 use std::path::PathBuf;
2069
2070 let temp_dir = tempdir().unwrap();
2071 let config_path = temp_dir.path().join(".rumdl.toml");
2072 let config_content = r#"
2074[per-file-flavor]
2075"plain/**/*.md" = "none"
2076"#;
2077 fs::write(&config_path, config_content).unwrap();
2078
2079 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2080 let config: Config = sourced.into_validated_unchecked().into();
2081
2082 let flavor = config.get_flavor_for_file(&PathBuf::from("plain/test.md"));
2084 assert_eq!(flavor, MarkdownFlavor::Standard);
2085 }
2086
2087 #[test]
2088 fn test_per_file_flavor_brace_expansion() {
2089 use std::path::PathBuf;
2090
2091 let temp_dir = tempdir().unwrap();
2092 let config_path = temp_dir.path().join(".rumdl.toml");
2093 let config_content = r#"
2095[per-file-flavor]
2096"docs/**/*.{md,mdx}" = "mkdocs"
2097"#;
2098 fs::write(&config_path, config_content).unwrap();
2099
2100 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2101 let config: Config = sourced.into_validated_unchecked().into();
2102
2103 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/guide.md"));
2105 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2106
2107 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/component.mdx"));
2109 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2110 }
2111
2112 #[test]
2113 fn test_per_file_flavor_single_star_vs_double_star() {
2114 use std::path::PathBuf;
2115
2116 let temp_dir = tempdir().unwrap();
2117 let config_path = temp_dir.path().join(".rumdl.toml");
2118 let config_content = r#"
2120[per-file-flavor]
2121"docs/*.md" = "mkdocs"
2122"src/**/*.md" = "mdx"
2123"#;
2124 fs::write(&config_path, config_content).unwrap();
2125
2126 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2127 let config: Config = sourced.into_validated_unchecked().into();
2128
2129 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/README.md"));
2131 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2132
2133 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/index.md"));
2135 assert_eq!(flavor, MarkdownFlavor::Standard); let flavor = config.get_flavor_for_file(&PathBuf::from("src/components/Button.md"));
2139 assert_eq!(flavor, MarkdownFlavor::MDX);
2140
2141 let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
2142 assert_eq!(flavor, MarkdownFlavor::MDX);
2143 }
2144
2145 #[test]
2146 fn test_per_file_flavor_question_mark_wildcard() {
2147 use std::path::PathBuf;
2148
2149 let temp_dir = tempdir().unwrap();
2150 let config_path = temp_dir.path().join(".rumdl.toml");
2151 let config_content = r#"
2153[per-file-flavor]
2154"docs/v?.md" = "mkdocs"
2155"#;
2156 fs::write(&config_path, config_content).unwrap();
2157
2158 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2159 let config: Config = sourced.into_validated_unchecked().into();
2160
2161 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v1.md"));
2163 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2164
2165 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v2.md"));
2166 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2167
2168 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v10.md"));
2170 assert_eq!(flavor, MarkdownFlavor::Standard);
2171
2172 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v.md"));
2174 assert_eq!(flavor, MarkdownFlavor::Standard);
2175 }
2176
2177 #[test]
2178 fn test_per_file_flavor_character_class() {
2179 use std::path::PathBuf;
2180
2181 let temp_dir = tempdir().unwrap();
2182 let config_path = temp_dir.path().join(".rumdl.toml");
2183 let config_content = r#"
2185[per-file-flavor]
2186"docs/[abc].md" = "mkdocs"
2187"#;
2188 fs::write(&config_path, config_content).unwrap();
2189
2190 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2191 let config: Config = sourced.into_validated_unchecked().into();
2192
2193 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/a.md"));
2195 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2196
2197 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/b.md"));
2198 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2199
2200 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/d.md"));
2202 assert_eq!(flavor, MarkdownFlavor::Standard);
2203 }
2204
2205 #[test]
2206 fn test_generate_json_schema() {
2207 use schemars::schema_for;
2208 use std::env;
2209
2210 let schema = schema_for!(Config);
2211 let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
2212
2213 if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
2215 let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
2216 fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
2217 println!("Schema written to: {}", schema_path.display());
2218 }
2219
2220 assert!(schema_json.contains("\"title\": \"Config\""));
2222 assert!(schema_json.contains("\"global\""));
2223 assert!(schema_json.contains("\"per-file-ignores\""));
2224 }
2225
2226 #[test]
2227 fn test_markdown_flavor_schema_matches_fromstr() {
2228 use schemars::schema_for;
2231
2232 let schema = schema_for!(MarkdownFlavor);
2233 let schema_json = serde_json::to_value(&schema).expect("Failed to serialize schema");
2234
2235 let enum_values = schema_json
2237 .get("enum")
2238 .expect("Schema should have 'enum' field")
2239 .as_array()
2240 .expect("enum should be an array");
2241
2242 assert!(!enum_values.is_empty(), "Schema enum should not be empty");
2243
2244 for value in enum_values {
2246 let str_value = value.as_str().expect("enum value should be a string");
2247 let result = str_value.parse::<MarkdownFlavor>();
2248 assert!(
2249 result.is_ok(),
2250 "Schema value '{str_value}' should be parseable by FromStr but got: {:?}",
2251 result.err()
2252 );
2253 }
2254
2255 for alias in ["", "none"] {
2257 let result = alias.parse::<MarkdownFlavor>();
2258 assert!(result.is_ok(), "FromStr alias '{alias}' should be parseable");
2259 }
2260 }
2261
2262 #[test]
2263 fn test_project_config_is_standalone() {
2264 let temp_dir = tempdir().unwrap();
2267
2268 let user_config_dir = temp_dir.path().join("user_config");
2271 let rumdl_config_dir = user_config_dir.join("rumdl");
2272 fs::create_dir_all(&rumdl_config_dir).unwrap();
2273 let user_config_path = rumdl_config_dir.join("rumdl.toml");
2274
2275 let user_config_content = r#"
2277[global]
2278disable = ["MD013", "MD041"]
2279line-length = 100
2280"#;
2281 fs::write(&user_config_path, user_config_content).unwrap();
2282
2283 let project_config_path = temp_dir.path().join("project").join("pyproject.toml");
2285 fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
2286 let project_config_content = r#"
2287[tool.rumdl]
2288enable = ["MD001"]
2289"#;
2290 fs::write(&project_config_path, project_config_content).unwrap();
2291
2292 let sourced = SourcedConfig::load_with_discovery_impl(
2294 Some(project_config_path.to_str().unwrap()),
2295 None,
2296 false,
2297 Some(&user_config_dir),
2298 )
2299 .unwrap();
2300
2301 let config: Config = sourced.into_validated_unchecked().into();
2302
2303 assert!(
2305 !config.global.disable.contains(&"MD013".to_string()),
2306 "User config should NOT be merged with project config"
2307 );
2308 assert!(
2309 !config.global.disable.contains(&"MD041".to_string()),
2310 "User config should NOT be merged with project config"
2311 );
2312
2313 assert!(
2315 config.global.enable.contains(&"MD001".to_string()),
2316 "Project config enabled rules should be applied"
2317 );
2318 }
2319
2320 #[test]
2321 fn test_user_config_as_fallback_when_no_project_config() {
2322 use std::env;
2324
2325 let temp_dir = tempdir().unwrap();
2326 let original_dir = env::current_dir().unwrap();
2327
2328 let user_config_dir = temp_dir.path().join("user_config");
2330 let rumdl_config_dir = user_config_dir.join("rumdl");
2331 fs::create_dir_all(&rumdl_config_dir).unwrap();
2332 let user_config_path = rumdl_config_dir.join("rumdl.toml");
2333
2334 let user_config_content = r#"
2336[global]
2337disable = ["MD013", "MD041"]
2338line-length = 88
2339"#;
2340 fs::write(&user_config_path, user_config_content).unwrap();
2341
2342 let project_dir = temp_dir.path().join("project_no_config");
2344 fs::create_dir_all(&project_dir).unwrap();
2345
2346 env::set_current_dir(&project_dir).unwrap();
2348
2349 let sourced = SourcedConfig::load_with_discovery_impl(None, None, false, Some(&user_config_dir)).unwrap();
2351
2352 let config: Config = sourced.into_validated_unchecked().into();
2353
2354 assert!(
2356 config.global.disable.contains(&"MD013".to_string()),
2357 "User config should be loaded as fallback when no project config"
2358 );
2359 assert!(
2360 config.global.disable.contains(&"MD041".to_string()),
2361 "User config should be loaded as fallback when no project config"
2362 );
2363 assert_eq!(
2364 config.global.line_length.get(),
2365 88,
2366 "User config line-length should be loaded as fallback"
2367 );
2368
2369 env::set_current_dir(original_dir).unwrap();
2370 }
2371
2372 #[test]
2373 fn test_typestate_validate_method() {
2374 use tempfile::tempdir;
2375
2376 let temp_dir = tempdir().expect("Failed to create temporary directory");
2377 let config_path = temp_dir.path().join("test.toml");
2378
2379 let config_content = r#"
2381[global]
2382enable = ["MD001"]
2383
2384[MD013]
2385line_length = 80
2386unknown_option = true
2387"#;
2388 std::fs::write(&config_path, config_content).expect("Failed to write config");
2389
2390 let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
2392 .expect("Should load config");
2393
2394 let default_config = Config::default();
2396 let all_rules = crate::rules::all_rules(&default_config);
2397 let registry = RuleRegistry::from_rules(&all_rules);
2398
2399 let validated = loaded.validate(®istry).expect("Should validate config");
2401
2402 let has_unknown_option_warning = validated
2405 .validation_warnings
2406 .iter()
2407 .any(|w| w.message.contains("unknown_option") || w.message.contains("Unknown option"));
2408
2409 if !has_unknown_option_warning {
2411 for w in &validated.validation_warnings {
2412 eprintln!("Warning: {}", w.message);
2413 }
2414 }
2415 assert!(
2416 has_unknown_option_warning,
2417 "Should have warning for unknown option. Got {} warnings: {:?}",
2418 validated.validation_warnings.len(),
2419 validated
2420 .validation_warnings
2421 .iter()
2422 .map(|w| &w.message)
2423 .collect::<Vec<_>>()
2424 );
2425
2426 let config: Config = validated.into();
2428
2429 assert!(config.global.enable.contains(&"MD001".to_string()));
2431 }
2432
2433 #[test]
2434 fn test_typestate_validate_into_convenience_method() {
2435 use tempfile::tempdir;
2436
2437 let temp_dir = tempdir().expect("Failed to create temporary directory");
2438 let config_path = temp_dir.path().join("test.toml");
2439
2440 let config_content = r#"
2441[global]
2442enable = ["MD022"]
2443
2444[MD022]
2445lines_above = 2
2446"#;
2447 std::fs::write(&config_path, config_content).expect("Failed to write config");
2448
2449 let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
2450 .expect("Should load config");
2451
2452 let default_config = Config::default();
2453 let all_rules = crate::rules::all_rules(&default_config);
2454 let registry = RuleRegistry::from_rules(&all_rules);
2455
2456 let (config, warnings) = loaded.validate_into(®istry).expect("Should validate and convert");
2458
2459 assert!(warnings.is_empty(), "Should have no warnings for valid config");
2461
2462 assert!(config.global.enable.contains(&"MD022".to_string()));
2464 }
2465
2466 #[test]
2467 fn test_resolve_rule_name_canonical() {
2468 assert_eq!(resolve_rule_name("MD001"), "MD001");
2470 assert_eq!(resolve_rule_name("MD013"), "MD013");
2471 assert_eq!(resolve_rule_name("MD069"), "MD069");
2472 }
2473
2474 #[test]
2475 fn test_resolve_rule_name_aliases() {
2476 assert_eq!(resolve_rule_name("heading-increment"), "MD001");
2478 assert_eq!(resolve_rule_name("line-length"), "MD013");
2479 assert_eq!(resolve_rule_name("no-bare-urls"), "MD034");
2480 assert_eq!(resolve_rule_name("ul-style"), "MD004");
2481 }
2482
2483 #[test]
2484 fn test_resolve_rule_name_case_insensitive() {
2485 assert_eq!(resolve_rule_name("HEADING-INCREMENT"), "MD001");
2487 assert_eq!(resolve_rule_name("Heading-Increment"), "MD001");
2488 assert_eq!(resolve_rule_name("md001"), "MD001");
2489 assert_eq!(resolve_rule_name("MD001"), "MD001");
2490 }
2491
2492 #[test]
2493 fn test_resolve_rule_name_underscore_to_hyphen() {
2494 assert_eq!(resolve_rule_name("heading_increment"), "MD001");
2496 assert_eq!(resolve_rule_name("line_length"), "MD013");
2497 assert_eq!(resolve_rule_name("no_bare_urls"), "MD034");
2498 }
2499
2500 #[test]
2501 fn test_resolve_rule_name_unknown() {
2502 assert_eq!(resolve_rule_name("custom-rule"), "custom-rule");
2504 assert_eq!(resolve_rule_name("CUSTOM_RULE"), "custom-rule");
2505 assert_eq!(resolve_rule_name("md999"), "MD999"); }
2507
2508 #[test]
2509 fn test_resolve_rule_names_basic() {
2510 let result = resolve_rule_names("MD001,line-length,heading-increment");
2511 assert!(result.contains("MD001"));
2512 assert!(result.contains("MD013")); assert_eq!(result.len(), 2);
2515 }
2516
2517 #[test]
2518 fn test_resolve_rule_names_with_whitespace() {
2519 let result = resolve_rule_names(" MD001 , line-length , MD034 ");
2520 assert!(result.contains("MD001"));
2521 assert!(result.contains("MD013"));
2522 assert!(result.contains("MD034"));
2523 assert_eq!(result.len(), 3);
2524 }
2525
2526 #[test]
2527 fn test_resolve_rule_names_empty_entries() {
2528 let result = resolve_rule_names("MD001,,MD013,");
2529 assert!(result.contains("MD001"));
2530 assert!(result.contains("MD013"));
2531 assert_eq!(result.len(), 2);
2532 }
2533
2534 #[test]
2535 fn test_resolve_rule_names_empty_string() {
2536 let result = resolve_rule_names("");
2537 assert!(result.is_empty());
2538 }
2539
2540 #[test]
2541 fn test_resolve_rule_names_mixed() {
2542 let result = resolve_rule_names("MD001,line-length,custom-rule");
2544 assert!(result.contains("MD001"));
2545 assert!(result.contains("MD013"));
2546 assert!(result.contains("custom-rule"));
2547 assert_eq!(result.len(), 3);
2548 }
2549
2550 #[test]
2555 fn test_is_valid_rule_name_canonical() {
2556 assert!(is_valid_rule_name("MD001"));
2558 assert!(is_valid_rule_name("MD013"));
2559 assert!(is_valid_rule_name("MD041"));
2560 assert!(is_valid_rule_name("MD069"));
2561
2562 assert!(is_valid_rule_name("md001"));
2564 assert!(is_valid_rule_name("Md001"));
2565 assert!(is_valid_rule_name("mD001"));
2566 }
2567
2568 #[test]
2569 fn test_is_valid_rule_name_aliases() {
2570 assert!(is_valid_rule_name("line-length"));
2572 assert!(is_valid_rule_name("heading-increment"));
2573 assert!(is_valid_rule_name("no-bare-urls"));
2574 assert!(is_valid_rule_name("ul-style"));
2575
2576 assert!(is_valid_rule_name("LINE-LENGTH"));
2578 assert!(is_valid_rule_name("Line-Length"));
2579
2580 assert!(is_valid_rule_name("line_length"));
2582 assert!(is_valid_rule_name("ul_style"));
2583 }
2584
2585 #[test]
2586 fn test_is_valid_rule_name_special_all() {
2587 assert!(is_valid_rule_name("all"));
2588 assert!(is_valid_rule_name("ALL"));
2589 assert!(is_valid_rule_name("All"));
2590 assert!(is_valid_rule_name("aLl"));
2591 }
2592
2593 #[test]
2594 fn test_is_valid_rule_name_invalid() {
2595 assert!(!is_valid_rule_name("MD000"));
2597 assert!(!is_valid_rule_name("MD002")); assert!(!is_valid_rule_name("MD006")); assert!(!is_valid_rule_name("MD999"));
2600 assert!(!is_valid_rule_name("MD100"));
2601
2602 assert!(!is_valid_rule_name(""));
2604 assert!(!is_valid_rule_name("INVALID"));
2605 assert!(!is_valid_rule_name("not-a-rule"));
2606 assert!(!is_valid_rule_name("random-text"));
2607 assert!(!is_valid_rule_name("abc"));
2608
2609 assert!(!is_valid_rule_name("MD"));
2611 assert!(!is_valid_rule_name("MD1"));
2612 assert!(!is_valid_rule_name("MD12"));
2613 }
2614
2615 #[test]
2616 fn test_validate_cli_rule_names_valid() {
2617 let warnings = validate_cli_rule_names(
2619 Some("MD001,MD013"),
2620 Some("line-length"),
2621 Some("heading-increment"),
2622 Some("all"),
2623 );
2624 assert!(warnings.is_empty(), "Expected no warnings for valid rules");
2625 }
2626
2627 #[test]
2628 fn test_validate_cli_rule_names_invalid() {
2629 let warnings = validate_cli_rule_names(Some("abc"), None, None, None);
2631 assert_eq!(warnings.len(), 1);
2632 assert!(warnings[0].message.contains("Unknown rule in --enable: abc"));
2633
2634 let warnings = validate_cli_rule_names(None, Some("xyz"), None, None);
2636 assert_eq!(warnings.len(), 1);
2637 assert!(warnings[0].message.contains("Unknown rule in --disable: xyz"));
2638
2639 let warnings = validate_cli_rule_names(None, None, Some("nonexistent"), None);
2641 assert_eq!(warnings.len(), 1);
2642 assert!(
2643 warnings[0]
2644 .message
2645 .contains("Unknown rule in --extend-enable: nonexistent")
2646 );
2647
2648 let warnings = validate_cli_rule_names(None, None, None, Some("fake-rule"));
2650 assert_eq!(warnings.len(), 1);
2651 assert!(
2652 warnings[0]
2653 .message
2654 .contains("Unknown rule in --extend-disable: fake-rule")
2655 );
2656 }
2657
2658 #[test]
2659 fn test_validate_cli_rule_names_mixed() {
2660 let warnings = validate_cli_rule_names(Some("MD001,abc,MD003"), None, None, None);
2662 assert_eq!(warnings.len(), 1);
2663 assert!(warnings[0].message.contains("abc"));
2664 }
2665
2666 #[test]
2667 fn test_validate_cli_rule_names_suggestions() {
2668 let warnings = validate_cli_rule_names(Some("line-lenght"), None, None, None);
2670 assert_eq!(warnings.len(), 1);
2671 assert!(warnings[0].message.contains("did you mean"));
2672 assert!(warnings[0].message.contains("line-length"));
2673 }
2674
2675 #[test]
2676 fn test_validate_cli_rule_names_none() {
2677 let warnings = validate_cli_rule_names(None, None, None, None);
2679 assert!(warnings.is_empty());
2680 }
2681
2682 #[test]
2683 fn test_validate_cli_rule_names_empty_string() {
2684 let warnings = validate_cli_rule_names(Some(""), Some(""), Some(""), Some(""));
2686 assert!(warnings.is_empty());
2687 }
2688
2689 #[test]
2690 fn test_validate_cli_rule_names_whitespace() {
2691 let warnings = validate_cli_rule_names(Some(" MD001 , MD013 "), None, None, None);
2693 assert!(warnings.is_empty(), "Whitespace should be trimmed");
2694 }
2695
2696 #[test]
2697 fn test_all_implemented_rules_have_aliases() {
2698 let config = crate::config::Config::default();
2705 let all_rules = crate::rules::all_rules(&config);
2706
2707 let mut missing_rules = Vec::new();
2708 for rule in &all_rules {
2709 let rule_name = rule.name();
2710 if resolve_rule_name_alias(rule_name).is_none() {
2712 missing_rules.push(rule_name.to_string());
2713 }
2714 }
2715
2716 assert!(
2717 missing_rules.is_empty(),
2718 "The following rules are missing from RULE_ALIAS_MAP: {:?}\n\
2719 Add entries like:\n\
2720 - Canonical: \"{}\" => \"{}\"\n\
2721 - Alias: \"RULE-NAME-HERE\" => \"{}\"",
2722 missing_rules,
2723 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2724 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2725 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2726 );
2727 }
2728
2729 #[test]
2732 fn test_relative_path_in_cwd() {
2733 let cwd = std::env::current_dir().unwrap();
2735 let test_path = cwd.join("test_file.md");
2736 fs::write(&test_path, "test").unwrap();
2737
2738 let result = super::to_relative_display_path(test_path.to_str().unwrap());
2739
2740 assert_eq!(result, "test_file.md");
2742
2743 fs::remove_file(&test_path).unwrap();
2745 }
2746
2747 #[test]
2748 fn test_relative_path_in_subdirectory() {
2749 let cwd = std::env::current_dir().unwrap();
2751 let subdir = cwd.join("test_subdir_for_relative_path");
2752 fs::create_dir_all(&subdir).unwrap();
2753 let test_path = subdir.join("test_file.md");
2754 fs::write(&test_path, "test").unwrap();
2755
2756 let result = super::to_relative_display_path(test_path.to_str().unwrap());
2757
2758 assert_eq!(result, "test_subdir_for_relative_path/test_file.md");
2760
2761 fs::remove_file(&test_path).unwrap();
2763 fs::remove_dir(&subdir).unwrap();
2764 }
2765
2766 #[test]
2767 fn test_relative_path_outside_cwd_returns_original() {
2768 let outside_path = "/tmp/definitely_not_in_cwd_test.md";
2770
2771 let result = super::to_relative_display_path(outside_path);
2772
2773 let cwd = std::env::current_dir().unwrap();
2776 if !cwd.starts_with("/tmp") {
2777 assert_eq!(result, outside_path);
2778 }
2779 }
2780
2781 #[test]
2782 fn test_relative_path_already_relative() {
2783 let relative_path = "some/relative/path.md";
2785
2786 let result = super::to_relative_display_path(relative_path);
2787
2788 assert_eq!(result, relative_path);
2790 }
2791
2792 #[test]
2793 fn test_relative_path_with_dot_components() {
2794 let cwd = std::env::current_dir().unwrap();
2796 let test_path = cwd.join("test_dot_component.md");
2797 fs::write(&test_path, "test").unwrap();
2798
2799 let dotted_path = cwd.join(".").join("test_dot_component.md");
2801 let result = super::to_relative_display_path(dotted_path.to_str().unwrap());
2802
2803 assert_eq!(result, "test_dot_component.md");
2805
2806 fs::remove_file(&test_path).unwrap();
2808 }
2809
2810 #[test]
2811 fn test_relative_path_empty_string() {
2812 let result = super::to_relative_display_path("");
2813
2814 assert_eq!(result, "");
2816 }
2817}
2818
2819#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2828pub enum ConfigSource {
2829 Default,
2831 UserConfig,
2833 PyprojectToml,
2835 ProjectConfig,
2837 Cli,
2839}
2840
2841#[derive(Debug, Clone)]
2842pub struct ConfigOverride<T> {
2843 pub value: T,
2844 pub source: ConfigSource,
2845 pub file: Option<String>,
2846 pub line: Option<usize>,
2847}
2848
2849#[derive(Debug, Clone)]
2850pub struct SourcedValue<T> {
2851 pub value: T,
2852 pub source: ConfigSource,
2853 pub overrides: Vec<ConfigOverride<T>>,
2854}
2855
2856impl<T: Clone> SourcedValue<T> {
2857 pub fn new(value: T, source: ConfigSource) -> Self {
2858 Self {
2859 value: value.clone(),
2860 source,
2861 overrides: vec![ConfigOverride {
2862 value,
2863 source,
2864 file: None,
2865 line: None,
2866 }],
2867 }
2868 }
2869
2870 pub fn merge_override(
2874 &mut self,
2875 new_value: T,
2876 new_source: ConfigSource,
2877 new_file: Option<String>,
2878 new_line: Option<usize>,
2879 ) {
2880 fn source_precedence(src: ConfigSource) -> u8 {
2882 match src {
2883 ConfigSource::Default => 0,
2884 ConfigSource::UserConfig => 1,
2885 ConfigSource::PyprojectToml => 2,
2886 ConfigSource::ProjectConfig => 3,
2887 ConfigSource::Cli => 4,
2888 }
2889 }
2890
2891 if source_precedence(new_source) >= source_precedence(self.source) {
2892 self.value = new_value.clone();
2893 self.source = new_source;
2894 self.overrides.push(ConfigOverride {
2895 value: new_value,
2896 source: new_source,
2897 file: new_file,
2898 line: new_line,
2899 });
2900 }
2901 }
2902
2903 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
2904 self.value = value.clone();
2907 self.source = source;
2908 self.overrides.push(ConfigOverride {
2909 value,
2910 source,
2911 file,
2912 line,
2913 });
2914 }
2915}
2916
2917impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
2918 pub fn merge_union(
2921 &mut self,
2922 new_value: Vec<T>,
2923 new_source: ConfigSource,
2924 new_file: Option<String>,
2925 new_line: Option<usize>,
2926 ) {
2927 fn source_precedence(src: ConfigSource) -> u8 {
2928 match src {
2929 ConfigSource::Default => 0,
2930 ConfigSource::UserConfig => 1,
2931 ConfigSource::PyprojectToml => 2,
2932 ConfigSource::ProjectConfig => 3,
2933 ConfigSource::Cli => 4,
2934 }
2935 }
2936
2937 if source_precedence(new_source) >= source_precedence(self.source) {
2938 let mut combined = self.value.clone();
2940 for item in new_value.iter() {
2941 if !combined.contains(item) {
2942 combined.push(item.clone());
2943 }
2944 }
2945
2946 self.value = combined;
2947 self.source = new_source;
2948 self.overrides.push(ConfigOverride {
2949 value: new_value,
2950 source: new_source,
2951 file: new_file,
2952 line: new_line,
2953 });
2954 }
2955 }
2956}
2957
2958#[derive(Debug, Clone)]
2959pub struct SourcedGlobalConfig {
2960 pub enable: SourcedValue<Vec<String>>,
2961 pub disable: SourcedValue<Vec<String>>,
2962 pub exclude: SourcedValue<Vec<String>>,
2963 pub include: SourcedValue<Vec<String>>,
2964 pub respect_gitignore: SourcedValue<bool>,
2965 pub line_length: SourcedValue<LineLength>,
2966 pub output_format: Option<SourcedValue<String>>,
2967 pub fixable: SourcedValue<Vec<String>>,
2968 pub unfixable: SourcedValue<Vec<String>>,
2969 pub flavor: SourcedValue<MarkdownFlavor>,
2970 pub force_exclude: SourcedValue<bool>,
2971 pub cache_dir: Option<SourcedValue<String>>,
2972 pub cache: SourcedValue<bool>,
2973}
2974
2975impl Default for SourcedGlobalConfig {
2976 fn default() -> Self {
2977 SourcedGlobalConfig {
2978 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2979 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2980 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
2981 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
2982 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
2983 line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
2984 output_format: None,
2985 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2986 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2987 flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
2988 force_exclude: SourcedValue::new(false, ConfigSource::Default),
2989 cache_dir: None,
2990 cache: SourcedValue::new(true, ConfigSource::Default),
2991 }
2992 }
2993}
2994
2995#[derive(Debug, Default, Clone)]
2996pub struct SourcedRuleConfig {
2997 pub severity: Option<SourcedValue<crate::rule::Severity>>,
2998 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
2999}
3000
3001#[derive(Debug, Clone)]
3004pub struct SourcedConfigFragment {
3005 pub global: SourcedGlobalConfig,
3006 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
3007 pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
3008 pub code_block_tools: SourcedValue<crate::code_block_tools::CodeBlockToolsConfig>,
3009 pub rules: BTreeMap<String, SourcedRuleConfig>,
3010 pub unknown_keys: Vec<(String, String, Option<String>)>, }
3013
3014impl Default for SourcedConfigFragment {
3015 fn default() -> Self {
3016 Self {
3017 global: SourcedGlobalConfig::default(),
3018 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
3019 per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
3020 code_block_tools: SourcedValue::new(
3021 crate::code_block_tools::CodeBlockToolsConfig::default(),
3022 ConfigSource::Default,
3023 ),
3024 rules: BTreeMap::new(),
3025 unknown_keys: Vec::new(),
3026 }
3027 }
3028}
3029
3030#[derive(Debug, Clone)]
3048pub struct SourcedConfig<State = ConfigLoaded> {
3049 pub global: SourcedGlobalConfig,
3050 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
3051 pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
3052 pub code_block_tools: SourcedValue<crate::code_block_tools::CodeBlockToolsConfig>,
3053 pub rules: BTreeMap<String, SourcedRuleConfig>,
3054 pub loaded_files: Vec<String>,
3055 pub unknown_keys: Vec<(String, String, Option<String>)>, pub project_root: Option<std::path::PathBuf>,
3058 pub validation_warnings: Vec<ConfigValidationWarning>,
3060 _state: PhantomData<State>,
3062}
3063
3064impl Default for SourcedConfig<ConfigLoaded> {
3065 fn default() -> Self {
3066 Self {
3067 global: SourcedGlobalConfig::default(),
3068 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
3069 per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
3070 code_block_tools: SourcedValue::new(
3071 crate::code_block_tools::CodeBlockToolsConfig::default(),
3072 ConfigSource::Default,
3073 ),
3074 rules: BTreeMap::new(),
3075 loaded_files: Vec::new(),
3076 unknown_keys: Vec::new(),
3077 project_root: None,
3078 validation_warnings: Vec::new(),
3079 _state: PhantomData,
3080 }
3081 }
3082}
3083
3084impl SourcedConfig<ConfigLoaded> {
3085 fn merge(&mut self, fragment: SourcedConfigFragment) {
3088 self.global.enable.merge_override(
3091 fragment.global.enable.value,
3092 fragment.global.enable.source,
3093 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
3094 fragment.global.enable.overrides.first().and_then(|o| o.line),
3095 );
3096
3097 self.global.disable.merge_union(
3099 fragment.global.disable.value,
3100 fragment.global.disable.source,
3101 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
3102 fragment.global.disable.overrides.first().and_then(|o| o.line),
3103 );
3104
3105 self.global
3108 .disable
3109 .value
3110 .retain(|rule| !self.global.enable.value.contains(rule));
3111 self.global.include.merge_override(
3112 fragment.global.include.value,
3113 fragment.global.include.source,
3114 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
3115 fragment.global.include.overrides.first().and_then(|o| o.line),
3116 );
3117 self.global.exclude.merge_override(
3118 fragment.global.exclude.value,
3119 fragment.global.exclude.source,
3120 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
3121 fragment.global.exclude.overrides.first().and_then(|o| o.line),
3122 );
3123 self.global.respect_gitignore.merge_override(
3124 fragment.global.respect_gitignore.value,
3125 fragment.global.respect_gitignore.source,
3126 fragment
3127 .global
3128 .respect_gitignore
3129 .overrides
3130 .first()
3131 .and_then(|o| o.file.clone()),
3132 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
3133 );
3134 self.global.line_length.merge_override(
3135 fragment.global.line_length.value,
3136 fragment.global.line_length.source,
3137 fragment
3138 .global
3139 .line_length
3140 .overrides
3141 .first()
3142 .and_then(|o| o.file.clone()),
3143 fragment.global.line_length.overrides.first().and_then(|o| o.line),
3144 );
3145 self.global.fixable.merge_override(
3146 fragment.global.fixable.value,
3147 fragment.global.fixable.source,
3148 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
3149 fragment.global.fixable.overrides.first().and_then(|o| o.line),
3150 );
3151 self.global.unfixable.merge_override(
3152 fragment.global.unfixable.value,
3153 fragment.global.unfixable.source,
3154 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
3155 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
3156 );
3157
3158 self.global.flavor.merge_override(
3160 fragment.global.flavor.value,
3161 fragment.global.flavor.source,
3162 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
3163 fragment.global.flavor.overrides.first().and_then(|o| o.line),
3164 );
3165
3166 self.global.force_exclude.merge_override(
3168 fragment.global.force_exclude.value,
3169 fragment.global.force_exclude.source,
3170 fragment
3171 .global
3172 .force_exclude
3173 .overrides
3174 .first()
3175 .and_then(|o| o.file.clone()),
3176 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
3177 );
3178
3179 if let Some(output_format_fragment) = fragment.global.output_format {
3181 if let Some(ref mut output_format) = self.global.output_format {
3182 output_format.merge_override(
3183 output_format_fragment.value,
3184 output_format_fragment.source,
3185 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
3186 output_format_fragment.overrides.first().and_then(|o| o.line),
3187 );
3188 } else {
3189 self.global.output_format = Some(output_format_fragment);
3190 }
3191 }
3192
3193 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
3195 if let Some(ref mut cache_dir) = self.global.cache_dir {
3196 cache_dir.merge_override(
3197 cache_dir_fragment.value,
3198 cache_dir_fragment.source,
3199 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
3200 cache_dir_fragment.overrides.first().and_then(|o| o.line),
3201 );
3202 } else {
3203 self.global.cache_dir = Some(cache_dir_fragment);
3204 }
3205 }
3206
3207 if fragment.global.cache.source != ConfigSource::Default {
3209 self.global.cache.merge_override(
3210 fragment.global.cache.value,
3211 fragment.global.cache.source,
3212 fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
3213 fragment.global.cache.overrides.first().and_then(|o| o.line),
3214 );
3215 }
3216
3217 self.per_file_ignores.merge_override(
3219 fragment.per_file_ignores.value,
3220 fragment.per_file_ignores.source,
3221 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
3222 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
3223 );
3224
3225 self.per_file_flavor.merge_override(
3227 fragment.per_file_flavor.value,
3228 fragment.per_file_flavor.source,
3229 fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
3230 fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
3231 );
3232
3233 self.code_block_tools.merge_override(
3235 fragment.code_block_tools.value,
3236 fragment.code_block_tools.source,
3237 fragment.code_block_tools.overrides.first().and_then(|o| o.file.clone()),
3238 fragment.code_block_tools.overrides.first().and_then(|o| o.line),
3239 );
3240
3241 for (rule_name, rule_fragment) in fragment.rules {
3243 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
3245
3246 if let Some(severity_fragment) = rule_fragment.severity {
3248 if let Some(ref mut existing_severity) = rule_entry.severity {
3249 existing_severity.merge_override(
3250 severity_fragment.value,
3251 severity_fragment.source,
3252 severity_fragment.overrides.first().and_then(|o| o.file.clone()),
3253 severity_fragment.overrides.first().and_then(|o| o.line),
3254 );
3255 } else {
3256 rule_entry.severity = Some(severity_fragment);
3257 }
3258 }
3259
3260 for (key, sourced_value_fragment) in rule_fragment.values {
3262 let sv_entry = rule_entry
3263 .values
3264 .entry(key.clone())
3265 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
3266 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
3267 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
3268 sv_entry.merge_override(
3269 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
3274 }
3275 }
3276
3277 for (section, key, file_path) in fragment.unknown_keys {
3279 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
3281 self.unknown_keys.push((section, key, file_path));
3282 }
3283 }
3284 }
3285
3286 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
3288 Self::load_with_discovery(config_path, cli_overrides, false)
3289 }
3290
3291 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
3294 let mut current = if start_dir.is_relative() {
3296 std::env::current_dir()
3297 .map(|cwd| cwd.join(start_dir))
3298 .unwrap_or_else(|_| start_dir.to_path_buf())
3299 } else {
3300 start_dir.to_path_buf()
3301 };
3302 const MAX_DEPTH: usize = 100;
3303
3304 for _ in 0..MAX_DEPTH {
3305 if current.join(".git").exists() {
3306 log::debug!("[rumdl-config] Found .git at: {}", current.display());
3307 return current;
3308 }
3309
3310 match current.parent() {
3311 Some(parent) => current = parent.to_path_buf(),
3312 None => break,
3313 }
3314 }
3315
3316 log::debug!(
3318 "[rumdl-config] No .git found, using config location as project root: {}",
3319 start_dir.display()
3320 );
3321 start_dir.to_path_buf()
3322 }
3323
3324 fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
3330 use std::env;
3331
3332 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
3333 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
3336 Ok(dir) => dir,
3337 Err(e) => {
3338 log::debug!("[rumdl-config] Failed to get current directory: {e}");
3339 return None;
3340 }
3341 };
3342
3343 let mut current_dir = start_dir.clone();
3344 let mut depth = 0;
3345 let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3346
3347 loop {
3348 if depth >= MAX_DEPTH {
3349 log::debug!("[rumdl-config] Maximum traversal depth reached");
3350 break;
3351 }
3352
3353 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
3354
3355 if found_config.is_none() {
3357 for config_name in CONFIG_FILES {
3358 let config_path = current_dir.join(config_name);
3359
3360 if config_path.exists() {
3361 if *config_name == "pyproject.toml" {
3363 if let Ok(content) = std::fs::read_to_string(&config_path) {
3364 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
3365 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
3366 found_config = Some((config_path.clone(), current_dir.clone()));
3368 break;
3369 }
3370 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
3371 continue;
3372 }
3373 } else {
3374 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
3375 found_config = Some((config_path.clone(), current_dir.clone()));
3377 break;
3378 }
3379 }
3380 }
3381 }
3382
3383 if current_dir.join(".git").exists() {
3385 log::debug!("[rumdl-config] Stopping at .git directory");
3386 break;
3387 }
3388
3389 match current_dir.parent() {
3391 Some(parent) => {
3392 current_dir = parent.to_owned();
3393 depth += 1;
3394 }
3395 None => {
3396 log::debug!("[rumdl-config] Reached filesystem root");
3397 break;
3398 }
3399 }
3400 }
3401
3402 if let Some((config_path, config_dir)) = found_config {
3404 let project_root = Self::find_project_root_from(&config_dir);
3405 return Some((config_path, project_root));
3406 }
3407
3408 None
3409 }
3410
3411 fn discover_markdownlint_config_upward() -> Option<std::path::PathBuf> {
3415 use std::env;
3416
3417 const MAX_DEPTH: usize = 100;
3418
3419 let start_dir = match env::current_dir() {
3420 Ok(dir) => dir,
3421 Err(e) => {
3422 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
3423 return None;
3424 }
3425 };
3426
3427 let mut current_dir = start_dir.clone();
3428 let mut depth = 0;
3429
3430 loop {
3431 if depth >= MAX_DEPTH {
3432 log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
3433 break;
3434 }
3435
3436 log::debug!(
3437 "[rumdl-config] Searching for markdownlint config in: {}",
3438 current_dir.display()
3439 );
3440
3441 for config_name in MARKDOWNLINT_CONFIG_FILES {
3443 let config_path = current_dir.join(config_name);
3444 if config_path.exists() {
3445 log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
3446 return Some(config_path);
3447 }
3448 }
3449
3450 if current_dir.join(".git").exists() {
3452 log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
3453 break;
3454 }
3455
3456 match current_dir.parent() {
3458 Some(parent) => {
3459 current_dir = parent.to_owned();
3460 depth += 1;
3461 }
3462 None => {
3463 log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
3464 break;
3465 }
3466 }
3467 }
3468
3469 None
3470 }
3471
3472 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
3474 let config_dir = config_dir.join("rumdl");
3475
3476 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
3478
3479 log::debug!(
3480 "[rumdl-config] Checking for user configuration in: {}",
3481 config_dir.display()
3482 );
3483
3484 for filename in USER_CONFIG_FILES {
3485 let config_path = config_dir.join(filename);
3486
3487 if config_path.exists() {
3488 if *filename == "pyproject.toml" {
3490 if let Ok(content) = std::fs::read_to_string(&config_path) {
3491 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
3492 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
3493 return Some(config_path);
3494 }
3495 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
3496 continue;
3497 }
3498 } else {
3499 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
3500 return Some(config_path);
3501 }
3502 }
3503 }
3504
3505 log::debug!(
3506 "[rumdl-config] No user configuration found in: {}",
3507 config_dir.display()
3508 );
3509 None
3510 }
3511
3512 #[cfg(feature = "native")]
3515 fn user_configuration_path() -> Option<std::path::PathBuf> {
3516 use etcetera::{BaseStrategy, choose_base_strategy};
3517
3518 match choose_base_strategy() {
3519 Ok(strategy) => {
3520 let config_dir = strategy.config_dir();
3521 Self::user_configuration_path_impl(&config_dir)
3522 }
3523 Err(e) => {
3524 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
3525 None
3526 }
3527 }
3528 }
3529
3530 #[cfg(not(feature = "native"))]
3532 fn user_configuration_path() -> Option<std::path::PathBuf> {
3533 None
3534 }
3535
3536 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
3538 let path_obj = Path::new(path);
3539 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
3540 let path_str = path.to_string();
3541
3542 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
3543
3544 if let Some(config_parent) = path_obj.parent() {
3546 let project_root = Self::find_project_root_from(config_parent);
3547 log::debug!(
3548 "[rumdl-config] Project root (from explicit config): {}",
3549 project_root.display()
3550 );
3551 sourced_config.project_root = Some(project_root);
3552 }
3553
3554 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
3556
3557 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
3558 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
3559 source: e,
3560 path: path_str.clone(),
3561 })?;
3562 if filename == "pyproject.toml" {
3563 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3564 sourced_config.merge(fragment);
3565 sourced_config.loaded_files.push(path_str);
3566 }
3567 } else {
3568 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3569 sourced_config.merge(fragment);
3570 sourced_config.loaded_files.push(path_str);
3571 }
3572 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
3573 || path_str.ends_with(".json")
3574 || path_str.ends_with(".jsonc")
3575 || path_str.ends_with(".yaml")
3576 || path_str.ends_with(".yml")
3577 {
3578 let fragment = load_from_markdownlint(&path_str)?;
3580 sourced_config.merge(fragment);
3581 sourced_config.loaded_files.push(path_str);
3582 } else {
3583 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
3585 source: e,
3586 path: path_str.clone(),
3587 })?;
3588 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3589 sourced_config.merge(fragment);
3590 sourced_config.loaded_files.push(path_str);
3591 }
3592
3593 Ok(())
3594 }
3595
3596 fn load_user_config_as_fallback(
3598 sourced_config: &mut Self,
3599 user_config_dir: Option<&Path>,
3600 ) -> Result<(), ConfigError> {
3601 let user_config_path = if let Some(dir) = user_config_dir {
3602 Self::user_configuration_path_impl(dir)
3603 } else {
3604 Self::user_configuration_path()
3605 };
3606
3607 if let Some(user_config_path) = user_config_path {
3608 let path_str = user_config_path.display().to_string();
3609 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
3610
3611 log::debug!("[rumdl-config] Loading user config as fallback: {path_str}");
3612
3613 if filename == "pyproject.toml" {
3614 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
3615 source: e,
3616 path: path_str.clone(),
3617 })?;
3618 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3619 sourced_config.merge(fragment);
3620 sourced_config.loaded_files.push(path_str);
3621 }
3622 } else {
3623 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
3624 source: e,
3625 path: path_str.clone(),
3626 })?;
3627 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
3628 sourced_config.merge(fragment);
3629 sourced_config.loaded_files.push(path_str);
3630 }
3631 } else {
3632 log::debug!("[rumdl-config] No user configuration file found");
3633 }
3634
3635 Ok(())
3636 }
3637
3638 #[doc(hidden)]
3640 pub fn load_with_discovery_impl(
3641 config_path: Option<&str>,
3642 cli_overrides: Option<&SourcedGlobalConfig>,
3643 skip_auto_discovery: bool,
3644 user_config_dir: Option<&Path>,
3645 ) -> Result<Self, ConfigError> {
3646 use std::env;
3647 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
3648
3649 let mut sourced_config = SourcedConfig::default();
3650
3651 if let Some(path) = config_path {
3664 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
3666 Self::load_explicit_config(&mut sourced_config, path)?;
3667 } else if skip_auto_discovery {
3668 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
3669 } else {
3671 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
3673
3674 if let Some((config_file, project_root)) = Self::discover_config_upward() {
3676 let path_str = config_file.display().to_string();
3678 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
3679
3680 log::debug!("[rumdl-config] Found project config: {path_str}");
3681 log::debug!("[rumdl-config] Project root: {}", project_root.display());
3682
3683 sourced_config.project_root = Some(project_root);
3684
3685 if filename == "pyproject.toml" {
3686 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
3687 source: e,
3688 path: path_str.clone(),
3689 })?;
3690 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3691 sourced_config.merge(fragment);
3692 sourced_config.loaded_files.push(path_str);
3693 }
3694 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
3695 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
3696 source: e,
3697 path: path_str.clone(),
3698 })?;
3699 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3700 sourced_config.merge(fragment);
3701 sourced_config.loaded_files.push(path_str);
3702 }
3703 } else {
3704 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
3706
3707 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward() {
3708 let path_str = markdownlint_path.display().to_string();
3709 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
3710 match load_from_markdownlint(&path_str) {
3711 Ok(fragment) => {
3712 sourced_config.merge(fragment);
3713 sourced_config.loaded_files.push(path_str);
3714 }
3715 Err(_e) => {
3716 log::debug!("[rumdl-config] Failed to load markdownlint config, trying user config");
3717 Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
3718 }
3719 }
3720 } else {
3721 log::debug!("[rumdl-config] No project config found, using user config as fallback");
3723 Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
3724 }
3725 }
3726 }
3727
3728 if let Some(cli) = cli_overrides {
3730 sourced_config
3731 .global
3732 .enable
3733 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
3734 sourced_config
3735 .global
3736 .disable
3737 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
3738 sourced_config
3739 .global
3740 .exclude
3741 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
3742 sourced_config
3743 .global
3744 .include
3745 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
3746 sourced_config.global.respect_gitignore.merge_override(
3747 cli.respect_gitignore.value,
3748 ConfigSource::Cli,
3749 None,
3750 None,
3751 );
3752 sourced_config
3753 .global
3754 .fixable
3755 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
3756 sourced_config
3757 .global
3758 .unfixable
3759 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
3760 }
3762
3763 Ok(sourced_config)
3766 }
3767
3768 pub fn load_with_discovery(
3771 config_path: Option<&str>,
3772 cli_overrides: Option<&SourcedGlobalConfig>,
3773 skip_auto_discovery: bool,
3774 ) -> Result<Self, ConfigError> {
3775 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
3776 }
3777
3778 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
3792 let warnings = validate_config_sourced_internal(&self, registry);
3793
3794 Ok(SourcedConfig {
3795 global: self.global,
3796 per_file_ignores: self.per_file_ignores,
3797 per_file_flavor: self.per_file_flavor,
3798 code_block_tools: self.code_block_tools,
3799 rules: self.rules,
3800 loaded_files: self.loaded_files,
3801 unknown_keys: self.unknown_keys,
3802 project_root: self.project_root,
3803 validation_warnings: warnings,
3804 _state: PhantomData,
3805 })
3806 }
3807
3808 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
3813 let validated = self.validate(registry)?;
3814 let warnings = validated.validation_warnings.clone();
3815 Ok((validated.into(), warnings))
3816 }
3817
3818 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
3829 SourcedConfig {
3830 global: self.global,
3831 per_file_ignores: self.per_file_ignores,
3832 per_file_flavor: self.per_file_flavor,
3833 code_block_tools: self.code_block_tools,
3834 rules: self.rules,
3835 loaded_files: self.loaded_files,
3836 unknown_keys: self.unknown_keys,
3837 project_root: self.project_root,
3838 validation_warnings: Vec::new(),
3839 _state: PhantomData,
3840 }
3841 }
3842}
3843
3844impl From<SourcedConfig<ConfigValidated>> for Config {
3849 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
3850 let mut rules = BTreeMap::new();
3851 for (rule_name, sourced_rule_cfg) in sourced.rules {
3852 let normalized_rule_name = rule_name.to_ascii_uppercase();
3854 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
3855 let mut values = BTreeMap::new();
3856 for (key, sourced_val) in sourced_rule_cfg.values {
3857 values.insert(key, sourced_val.value);
3858 }
3859 rules.insert(normalized_rule_name, RuleConfig { severity, values });
3860 }
3861 #[allow(deprecated)]
3862 let global = GlobalConfig {
3863 enable: sourced.global.enable.value,
3864 disable: sourced.global.disable.value,
3865 exclude: sourced.global.exclude.value,
3866 include: sourced.global.include.value,
3867 respect_gitignore: sourced.global.respect_gitignore.value,
3868 line_length: sourced.global.line_length.value,
3869 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
3870 fixable: sourced.global.fixable.value,
3871 unfixable: sourced.global.unfixable.value,
3872 flavor: sourced.global.flavor.value,
3873 force_exclude: sourced.global.force_exclude.value,
3874 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
3875 cache: sourced.global.cache.value,
3876 };
3877 Config {
3878 global,
3879 per_file_ignores: sourced.per_file_ignores.value,
3880 per_file_flavor: sourced.per_file_flavor.value,
3881 code_block_tools: sourced.code_block_tools.value,
3882 rules,
3883 project_root: sourced.project_root,
3884 per_file_ignores_cache: Arc::new(OnceLock::new()),
3885 per_file_flavor_cache: Arc::new(OnceLock::new()),
3886 }
3887 }
3888}
3889
3890pub struct RuleRegistry {
3892 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
3894 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
3896}
3897
3898impl RuleRegistry {
3899 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
3901 let mut rule_schemas = std::collections::BTreeMap::new();
3902 let mut rule_aliases = std::collections::BTreeMap::new();
3903
3904 for rule in rules {
3905 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
3906 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
3908 norm_name
3909 } else {
3910 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
3912 norm_name
3913 };
3914
3915 if let Some(aliases) = rule.config_aliases() {
3917 rule_aliases.insert(norm_name, aliases);
3918 }
3919 }
3920
3921 RuleRegistry {
3922 rule_schemas,
3923 rule_aliases,
3924 }
3925 }
3926
3927 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
3929 self.rule_schemas.keys().cloned().collect()
3930 }
3931
3932 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
3934 self.rule_schemas.get(rule).map(|schema| {
3935 let mut all_keys = std::collections::BTreeSet::new();
3936
3937 all_keys.insert("severity".to_string());
3939
3940 for key in schema.keys() {
3942 all_keys.insert(key.clone());
3943 }
3944
3945 for key in schema.keys() {
3947 all_keys.insert(key.replace('_', "-"));
3949 all_keys.insert(key.replace('-', "_"));
3951 all_keys.insert(normalize_key(key));
3953 }
3954
3955 if let Some(aliases) = self.rule_aliases.get(rule) {
3957 for alias_key in aliases.keys() {
3958 all_keys.insert(alias_key.clone());
3959 all_keys.insert(alias_key.replace('_', "-"));
3961 all_keys.insert(alias_key.replace('-', "_"));
3962 all_keys.insert(normalize_key(alias_key));
3963 }
3964 }
3965
3966 all_keys
3967 })
3968 }
3969
3970 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
3972 if let Some(schema) = self.rule_schemas.get(rule) {
3973 if let Some(aliases) = self.rule_aliases.get(rule)
3975 && let Some(canonical_key) = aliases.get(key)
3976 {
3977 if let Some(value) = schema.get(canonical_key) {
3979 return Some(value);
3980 }
3981 }
3982
3983 if let Some(value) = schema.get(key) {
3985 return Some(value);
3986 }
3987
3988 let key_variants = [
3990 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
3994
3995 for variant in &key_variants {
3996 if let Some(value) = schema.get(variant) {
3997 return Some(value);
3998 }
3999 }
4000 }
4001 None
4002 }
4003
4004 pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
4011 let normalized = normalize_key(name);
4013 if self.rule_schemas.contains_key(&normalized) {
4014 return Some(normalized);
4015 }
4016
4017 resolve_rule_name_alias(name).map(|s| s.to_string())
4019 }
4020}
4021
4022pub static RULE_ALIAS_MAP: phf::Map<&'static str, &'static str> = phf::phf_map! {
4025 "MD001" => "MD001",
4027 "MD003" => "MD003",
4028 "MD004" => "MD004",
4029 "MD005" => "MD005",
4030 "MD007" => "MD007",
4031 "MD009" => "MD009",
4032 "MD010" => "MD010",
4033 "MD011" => "MD011",
4034 "MD012" => "MD012",
4035 "MD013" => "MD013",
4036 "MD014" => "MD014",
4037 "MD018" => "MD018",
4038 "MD019" => "MD019",
4039 "MD020" => "MD020",
4040 "MD021" => "MD021",
4041 "MD022" => "MD022",
4042 "MD023" => "MD023",
4043 "MD024" => "MD024",
4044 "MD025" => "MD025",
4045 "MD026" => "MD026",
4046 "MD027" => "MD027",
4047 "MD028" => "MD028",
4048 "MD029" => "MD029",
4049 "MD030" => "MD030",
4050 "MD031" => "MD031",
4051 "MD032" => "MD032",
4052 "MD033" => "MD033",
4053 "MD034" => "MD034",
4054 "MD035" => "MD035",
4055 "MD036" => "MD036",
4056 "MD037" => "MD037",
4057 "MD038" => "MD038",
4058 "MD039" => "MD039",
4059 "MD040" => "MD040",
4060 "MD041" => "MD041",
4061 "MD042" => "MD042",
4062 "MD043" => "MD043",
4063 "MD044" => "MD044",
4064 "MD045" => "MD045",
4065 "MD046" => "MD046",
4066 "MD047" => "MD047",
4067 "MD048" => "MD048",
4068 "MD049" => "MD049",
4069 "MD050" => "MD050",
4070 "MD051" => "MD051",
4071 "MD052" => "MD052",
4072 "MD053" => "MD053",
4073 "MD054" => "MD054",
4074 "MD055" => "MD055",
4075 "MD056" => "MD056",
4076 "MD057" => "MD057",
4077 "MD058" => "MD058",
4078 "MD059" => "MD059",
4079 "MD060" => "MD060",
4080 "MD061" => "MD061",
4081 "MD062" => "MD062",
4082 "MD063" => "MD063",
4083 "MD064" => "MD064",
4084 "MD065" => "MD065",
4085 "MD066" => "MD066",
4086 "MD067" => "MD067",
4087 "MD068" => "MD068",
4088 "MD069" => "MD069",
4089 "MD070" => "MD070",
4090 "MD071" => "MD071",
4091 "MD072" => "MD072",
4092 "MD073" => "MD073",
4093
4094 "HEADING-INCREMENT" => "MD001",
4096 "HEADING-STYLE" => "MD003",
4097 "UL-STYLE" => "MD004",
4098 "LIST-INDENT" => "MD005",
4099 "UL-INDENT" => "MD007",
4100 "NO-TRAILING-SPACES" => "MD009",
4101 "NO-HARD-TABS" => "MD010",
4102 "NO-REVERSED-LINKS" => "MD011",
4103 "NO-MULTIPLE-BLANKS" => "MD012",
4104 "LINE-LENGTH" => "MD013",
4105 "COMMANDS-SHOW-OUTPUT" => "MD014",
4106 "NO-MISSING-SPACE-ATX" => "MD018",
4107 "NO-MULTIPLE-SPACE-ATX" => "MD019",
4108 "NO-MISSING-SPACE-CLOSED-ATX" => "MD020",
4109 "NO-MULTIPLE-SPACE-CLOSED-ATX" => "MD021",
4110 "BLANKS-AROUND-HEADINGS" => "MD022",
4111 "HEADING-START-LEFT" => "MD023",
4112 "NO-DUPLICATE-HEADING" => "MD024",
4113 "SINGLE-TITLE" => "MD025",
4114 "SINGLE-H1" => "MD025",
4115 "NO-TRAILING-PUNCTUATION" => "MD026",
4116 "NO-MULTIPLE-SPACE-BLOCKQUOTE" => "MD027",
4117 "NO-BLANKS-BLOCKQUOTE" => "MD028",
4118 "OL-PREFIX" => "MD029",
4119 "LIST-MARKER-SPACE" => "MD030",
4120 "BLANKS-AROUND-FENCES" => "MD031",
4121 "BLANKS-AROUND-LISTS" => "MD032",
4122 "NO-INLINE-HTML" => "MD033",
4123 "NO-BARE-URLS" => "MD034",
4124 "HR-STYLE" => "MD035",
4125 "NO-EMPHASIS-AS-HEADING" => "MD036",
4126 "NO-SPACE-IN-EMPHASIS" => "MD037",
4127 "NO-SPACE-IN-CODE" => "MD038",
4128 "NO-SPACE-IN-LINKS" => "MD039",
4129 "FENCED-CODE-LANGUAGE" => "MD040",
4130 "FIRST-LINE-HEADING" => "MD041",
4131 "FIRST-LINE-H1" => "MD041",
4132 "NO-EMPTY-LINKS" => "MD042",
4133 "REQUIRED-HEADINGS" => "MD043",
4134 "PROPER-NAMES" => "MD044",
4135 "NO-ALT-TEXT" => "MD045",
4136 "CODE-BLOCK-STYLE" => "MD046",
4137 "SINGLE-TRAILING-NEWLINE" => "MD047",
4138 "CODE-FENCE-STYLE" => "MD048",
4139 "EMPHASIS-STYLE" => "MD049",
4140 "STRONG-STYLE" => "MD050",
4141 "LINK-FRAGMENTS" => "MD051",
4142 "REFERENCE-LINKS-IMAGES" => "MD052",
4143 "LINK-IMAGE-REFERENCE-DEFINITIONS" => "MD053",
4144 "LINK-IMAGE-STYLE" => "MD054",
4145 "TABLE-PIPE-STYLE" => "MD055",
4146 "TABLE-COLUMN-COUNT" => "MD056",
4147 "EXISTING-RELATIVE-LINKS" => "MD057",
4148 "BLANKS-AROUND-TABLES" => "MD058",
4149 "DESCRIPTIVE-LINK-TEXT" => "MD059",
4150 "TABLE-CELL-ALIGNMENT" => "MD060",
4151 "TABLE-FORMAT" => "MD060",
4152 "FORBIDDEN-TERMS" => "MD061",
4153 "LINK-DESTINATION-WHITESPACE" => "MD062",
4154 "HEADING-CAPITALIZATION" => "MD063",
4155 "NO-MULTIPLE-CONSECUTIVE-SPACES" => "MD064",
4156 "BLANKS-AROUND-HORIZONTAL-RULES" => "MD065",
4157 "FOOTNOTE-VALIDATION" => "MD066",
4158 "FOOTNOTE-DEFINITION-ORDER" => "MD067",
4159 "EMPTY-FOOTNOTE-DEFINITION" => "MD068",
4160 "NO-DUPLICATE-LIST-MARKERS" => "MD069",
4161 "NESTED-CODE-FENCE" => "MD070",
4162 "BLANK-LINE-AFTER-FRONTMATTER" => "MD071",
4163 "FRONTMATTER-KEY-SORT" => "MD072",
4164 "TOC-VALIDATION" => "MD073",
4165};
4166
4167pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
4171 let normalized_key = key.to_ascii_uppercase().replace('_', "-");
4173
4174 RULE_ALIAS_MAP.get(normalized_key.as_str()).copied()
4176}
4177
4178pub fn resolve_rule_name(name: &str) -> String {
4186 resolve_rule_name_alias(name)
4187 .map(|s| s.to_string())
4188 .unwrap_or_else(|| normalize_key(name))
4189}
4190
4191pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
4195 input
4196 .split(',')
4197 .map(|s| s.trim())
4198 .filter(|s| !s.is_empty())
4199 .map(resolve_rule_name)
4200 .collect()
4201}
4202
4203pub fn validate_cli_rule_names(
4209 enable: Option<&str>,
4210 disable: Option<&str>,
4211 extend_enable: Option<&str>,
4212 extend_disable: Option<&str>,
4213) -> Vec<ConfigValidationWarning> {
4214 let mut warnings = Vec::new();
4215 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4216
4217 let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
4218 for name in input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
4219 if name.eq_ignore_ascii_case("all") {
4221 continue;
4222 }
4223 if resolve_rule_name_alias(name).is_none() {
4224 let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
4225 let formatted = if suggestion.starts_with("MD") {
4226 suggestion
4227 } else {
4228 suggestion.to_lowercase()
4229 };
4230 format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
4231 } else {
4232 format!("Unknown rule in {flag_name}: {name}")
4233 };
4234 warnings.push(ConfigValidationWarning {
4235 message,
4236 rule: Some(name.to_string()),
4237 key: None,
4238 });
4239 }
4240 }
4241 };
4242
4243 if let Some(e) = enable {
4244 validate_list(e, "--enable", &mut warnings);
4245 }
4246 if let Some(d) = disable {
4247 validate_list(d, "--disable", &mut warnings);
4248 }
4249 if let Some(ee) = extend_enable {
4250 validate_list(ee, "--extend-enable", &mut warnings);
4251 }
4252 if let Some(ed) = extend_disable {
4253 validate_list(ed, "--extend-disable", &mut warnings);
4254 }
4255
4256 warnings
4257}
4258
4259pub fn is_valid_rule_name(name: &str) -> bool {
4263 if name.eq_ignore_ascii_case("all") {
4265 return true;
4266 }
4267 resolve_rule_name_alias(name).is_some()
4268}
4269
4270#[derive(Debug, Clone)]
4272pub struct ConfigValidationWarning {
4273 pub message: String,
4274 pub rule: Option<String>,
4275 pub key: Option<String>,
4276}
4277
4278fn validate_config_sourced_internal<S>(
4281 sourced: &SourcedConfig<S>,
4282 registry: &RuleRegistry,
4283) -> Vec<ConfigValidationWarning> {
4284 let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
4285
4286 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4288
4289 for rule_name in &sourced.global.enable.value {
4290 if !is_valid_rule_name(rule_name) {
4291 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4292 let formatted = if suggestion.starts_with("MD") {
4293 suggestion
4294 } else {
4295 suggestion.to_lowercase()
4296 };
4297 format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
4298 } else {
4299 format!("Unknown rule in global.enable: {rule_name}")
4300 };
4301 warnings.push(ConfigValidationWarning {
4302 message,
4303 rule: Some(rule_name.clone()),
4304 key: None,
4305 });
4306 }
4307 }
4308
4309 for rule_name in &sourced.global.disable.value {
4310 if !is_valid_rule_name(rule_name) {
4311 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4312 let formatted = if suggestion.starts_with("MD") {
4313 suggestion
4314 } else {
4315 suggestion.to_lowercase()
4316 };
4317 format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
4318 } else {
4319 format!("Unknown rule in global.disable: {rule_name}")
4320 };
4321 warnings.push(ConfigValidationWarning {
4322 message,
4323 rule: Some(rule_name.clone()),
4324 key: None,
4325 });
4326 }
4327 }
4328
4329 warnings
4330}
4331
4332fn validate_config_sourced_impl(
4334 rules: &BTreeMap<String, SourcedRuleConfig>,
4335 unknown_keys: &[(String, String, Option<String>)],
4336 registry: &RuleRegistry,
4337) -> Vec<ConfigValidationWarning> {
4338 let mut warnings = Vec::new();
4339 let known_rules = registry.rule_names();
4340 for rule in rules.keys() {
4342 if !known_rules.contains(rule) {
4343 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4345 let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
4346 let formatted_suggestion = if suggestion.starts_with("MD") {
4348 suggestion
4349 } else {
4350 suggestion.to_lowercase()
4351 };
4352 format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
4353 } else {
4354 format!("Unknown rule in config: {rule}")
4355 };
4356 warnings.push(ConfigValidationWarning {
4357 message,
4358 rule: Some(rule.clone()),
4359 key: None,
4360 });
4361 }
4362 }
4363 for (rule, rule_cfg) in rules {
4365 if let Some(valid_keys) = registry.config_keys_for(rule) {
4366 for key in rule_cfg.values.keys() {
4367 if !valid_keys.contains(key) {
4368 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
4369 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
4370 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
4371 } else {
4372 format!("Unknown option for rule {rule}: {key}")
4373 };
4374 warnings.push(ConfigValidationWarning {
4375 message,
4376 rule: Some(rule.clone()),
4377 key: Some(key.clone()),
4378 });
4379 } else {
4380 if let Some(expected) = registry.expected_value_for(rule, key) {
4382 let actual = &rule_cfg.values[key].value;
4383 if !toml_value_type_matches(expected, actual) {
4384 warnings.push(ConfigValidationWarning {
4385 message: format!(
4386 "Type mismatch for {}.{}: expected {}, got {}",
4387 rule,
4388 key,
4389 toml_type_name(expected),
4390 toml_type_name(actual)
4391 ),
4392 rule: Some(rule.clone()),
4393 key: Some(key.clone()),
4394 });
4395 }
4396 }
4397 }
4398 }
4399 }
4400 }
4401 let known_global_keys = vec![
4403 "enable".to_string(),
4404 "disable".to_string(),
4405 "include".to_string(),
4406 "exclude".to_string(),
4407 "respect-gitignore".to_string(),
4408 "line-length".to_string(),
4409 "fixable".to_string(),
4410 "unfixable".to_string(),
4411 "flavor".to_string(),
4412 "force-exclude".to_string(),
4413 "output-format".to_string(),
4414 "cache-dir".to_string(),
4415 "cache".to_string(),
4416 ];
4417
4418 for (section, key, file_path) in unknown_keys {
4419 let display_path = file_path.as_ref().map(|p| to_relative_display_path(p));
4421
4422 if section.contains("[global]") || section.contains("[tool.rumdl]") {
4423 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
4424 if let Some(ref path) = display_path {
4425 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
4426 } else {
4427 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
4428 }
4429 } else if let Some(ref path) = display_path {
4430 format!("Unknown global option in {path}: {key}")
4431 } else {
4432 format!("Unknown global option: {key}")
4433 };
4434 warnings.push(ConfigValidationWarning {
4435 message,
4436 rule: None,
4437 key: Some(key.clone()),
4438 });
4439 } else if !key.is_empty() {
4440 continue;
4442 } else {
4443 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
4445 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4446 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4447 let formatted_suggestion = if suggestion.starts_with("MD") {
4449 suggestion
4450 } else {
4451 suggestion.to_lowercase()
4452 };
4453 if let Some(ref path) = display_path {
4454 format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
4455 } else {
4456 format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
4457 }
4458 } else if let Some(ref path) = display_path {
4459 format!("Unknown rule in {path}: {rule_name}")
4460 } else {
4461 format!("Unknown rule in config: {rule_name}")
4462 };
4463 warnings.push(ConfigValidationWarning {
4464 message,
4465 rule: None,
4466 key: None,
4467 });
4468 }
4469 }
4470 warnings
4471}
4472
4473fn to_relative_display_path(path: &str) -> String {
4478 let file_path = Path::new(path);
4479
4480 if let Ok(cwd) = std::env::current_dir() {
4482 if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
4484 && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
4485 {
4486 return relative.to_string_lossy().to_string();
4487 }
4488
4489 if let Ok(relative) = file_path.strip_prefix(&cwd) {
4491 return relative.to_string_lossy().to_string();
4492 }
4493 }
4494
4495 path.to_string()
4497}
4498
4499pub fn validate_config_sourced(
4505 sourced: &SourcedConfig<ConfigLoaded>,
4506 registry: &RuleRegistry,
4507) -> Vec<ConfigValidationWarning> {
4508 validate_config_sourced_internal(sourced, registry)
4509}
4510
4511pub fn validate_config_sourced_validated(
4515 sourced: &SourcedConfig<ConfigValidated>,
4516 _registry: &RuleRegistry,
4517) -> Vec<ConfigValidationWarning> {
4518 sourced.validation_warnings.clone()
4519}
4520
4521fn toml_type_name(val: &toml::Value) -> &'static str {
4522 match val {
4523 toml::Value::String(_) => "string",
4524 toml::Value::Integer(_) => "integer",
4525 toml::Value::Float(_) => "float",
4526 toml::Value::Boolean(_) => "boolean",
4527 toml::Value::Array(_) => "array",
4528 toml::Value::Table(_) => "table",
4529 toml::Value::Datetime(_) => "datetime",
4530 }
4531}
4532
4533fn levenshtein_distance(s1: &str, s2: &str) -> usize {
4535 let len1 = s1.len();
4536 let len2 = s2.len();
4537
4538 if len1 == 0 {
4539 return len2;
4540 }
4541 if len2 == 0 {
4542 return len1;
4543 }
4544
4545 let s1_chars: Vec<char> = s1.chars().collect();
4546 let s2_chars: Vec<char> = s2.chars().collect();
4547
4548 let mut prev_row: Vec<usize> = (0..=len2).collect();
4549 let mut curr_row = vec![0; len2 + 1];
4550
4551 for i in 1..=len1 {
4552 curr_row[0] = i;
4553 for j in 1..=len2 {
4554 let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
4555 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
4559 std::mem::swap(&mut prev_row, &mut curr_row);
4560 }
4561
4562 prev_row[len2]
4563}
4564
4565pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
4567 let unknown_lower = unknown.to_lowercase();
4568 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
4571
4572 for valid in valid_keys {
4573 let valid_lower = valid.to_lowercase();
4574 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
4575
4576 if distance <= max_distance {
4577 if let Some((_, best_dist)) = &best_match {
4578 if distance < *best_dist {
4579 best_match = Some((valid.clone(), distance));
4580 }
4581 } else {
4582 best_match = Some((valid.clone(), distance));
4583 }
4584 }
4585 }
4586
4587 best_match.map(|(key, _)| key)
4588}
4589
4590fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
4591 use toml::Value::*;
4592 match (expected, actual) {
4593 (String(_), String(_)) => true,
4594 (Integer(_), Integer(_)) => true,
4595 (Float(_), Float(_)) => true,
4596 (Boolean(_), Boolean(_)) => true,
4597 (Array(_), Array(_)) => true,
4598 (Table(_), Table(_)) => true,
4599 (Datetime(_), Datetime(_)) => true,
4600 (Float(_), Integer(_)) => true,
4602 _ => false,
4603 }
4604}
4605
4606fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
4608 let display_path = to_relative_display_path(path);
4609 let doc: toml::Value = toml::from_str(content)
4610 .map_err(|e| ConfigError::ParseError(format!("{display_path}: Failed to parse TOML: {e}")))?;
4611 let mut fragment = SourcedConfigFragment::default();
4612 let source = ConfigSource::PyprojectToml;
4613 let file = Some(path.to_string());
4614
4615 let all_rules = rules::all_rules(&Config::default());
4617 let registry = RuleRegistry::from_rules(&all_rules);
4618
4619 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
4621 && let Some(rumdl_table) = rumdl_config.as_table()
4622 {
4623 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
4625 if let Some(enable) = table.get("enable")
4627 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
4628 {
4629 let normalized_values = values
4631 .into_iter()
4632 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4633 .collect();
4634 fragment
4635 .global
4636 .enable
4637 .push_override(normalized_values, source, file.clone(), None);
4638 }
4639
4640 if let Some(disable) = table.get("disable")
4641 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
4642 {
4643 let normalized_values: Vec<String> = values
4645 .into_iter()
4646 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4647 .collect();
4648 fragment
4649 .global
4650 .disable
4651 .push_override(normalized_values, source, file.clone(), None);
4652 }
4653
4654 if let Some(include) = table.get("include")
4655 && let Ok(values) = Vec::<String>::deserialize(include.clone())
4656 {
4657 fragment
4658 .global
4659 .include
4660 .push_override(values, source, file.clone(), None);
4661 }
4662
4663 if let Some(exclude) = table.get("exclude")
4664 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
4665 {
4666 fragment
4667 .global
4668 .exclude
4669 .push_override(values, source, file.clone(), None);
4670 }
4671
4672 if let Some(respect_gitignore) = table
4673 .get("respect-gitignore")
4674 .or_else(|| table.get("respect_gitignore"))
4675 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
4676 {
4677 fragment
4678 .global
4679 .respect_gitignore
4680 .push_override(value, source, file.clone(), None);
4681 }
4682
4683 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
4684 && let Ok(value) = bool::deserialize(force_exclude.clone())
4685 {
4686 fragment
4687 .global
4688 .force_exclude
4689 .push_override(value, source, file.clone(), None);
4690 }
4691
4692 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
4693 && let Ok(value) = String::deserialize(output_format.clone())
4694 {
4695 if fragment.global.output_format.is_none() {
4696 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
4697 } else {
4698 fragment
4699 .global
4700 .output_format
4701 .as_mut()
4702 .unwrap()
4703 .push_override(value, source, file.clone(), None);
4704 }
4705 }
4706
4707 if let Some(fixable) = table.get("fixable")
4708 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
4709 {
4710 let normalized_values = values
4711 .into_iter()
4712 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4713 .collect();
4714 fragment
4715 .global
4716 .fixable
4717 .push_override(normalized_values, source, file.clone(), None);
4718 }
4719
4720 if let Some(unfixable) = table.get("unfixable")
4721 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
4722 {
4723 let normalized_values = values
4724 .into_iter()
4725 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4726 .collect();
4727 fragment
4728 .global
4729 .unfixable
4730 .push_override(normalized_values, source, file.clone(), None);
4731 }
4732
4733 if let Some(flavor) = table.get("flavor")
4734 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
4735 {
4736 fragment.global.flavor.push_override(value, source, file.clone(), None);
4737 }
4738
4739 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
4741 && let Ok(value) = u64::deserialize(line_length.clone())
4742 {
4743 fragment
4744 .global
4745 .line_length
4746 .push_override(LineLength::new(value as usize), source, file.clone(), None);
4747
4748 let norm_md013_key = normalize_key("MD013");
4750 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
4751 let norm_line_length_key = normalize_key("line-length");
4752 let sv = rule_entry
4753 .values
4754 .entry(norm_line_length_key)
4755 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
4756 sv.push_override(line_length.clone(), source, file.clone(), None);
4757 }
4758
4759 if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
4760 && let Ok(value) = String::deserialize(cache_dir.clone())
4761 {
4762 if fragment.global.cache_dir.is_none() {
4763 fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
4764 } else {
4765 fragment
4766 .global
4767 .cache_dir
4768 .as_mut()
4769 .unwrap()
4770 .push_override(value, source, file.clone(), None);
4771 }
4772 }
4773
4774 if let Some(cache) = table.get("cache")
4775 && let Ok(value) = bool::deserialize(cache.clone())
4776 {
4777 fragment.global.cache.push_override(value, source, file.clone(), None);
4778 }
4779 };
4780
4781 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
4783 extract_global_config(&mut fragment, global_table);
4784 }
4785
4786 extract_global_config(&mut fragment, rumdl_table);
4788
4789 let per_file_ignores_key = rumdl_table
4792 .get("per-file-ignores")
4793 .or_else(|| rumdl_table.get("per_file_ignores"));
4794
4795 if let Some(per_file_ignores_value) = per_file_ignores_key
4796 && let Some(per_file_table) = per_file_ignores_value.as_table()
4797 {
4798 let mut per_file_map = HashMap::new();
4799 for (pattern, rules_value) in per_file_table {
4800 warn_comma_without_brace_in_pattern(pattern, &display_path);
4801 if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
4802 let normalized_rules = rules
4803 .into_iter()
4804 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4805 .collect();
4806 per_file_map.insert(pattern.clone(), normalized_rules);
4807 } else {
4808 log::warn!(
4809 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {display_path}, found {rules_value:?}"
4810 );
4811 }
4812 }
4813 fragment
4814 .per_file_ignores
4815 .push_override(per_file_map, source, file.clone(), None);
4816 }
4817
4818 let per_file_flavor_key = rumdl_table
4821 .get("per-file-flavor")
4822 .or_else(|| rumdl_table.get("per_file_flavor"));
4823
4824 if let Some(per_file_flavor_value) = per_file_flavor_key
4825 && let Some(per_file_table) = per_file_flavor_value.as_table()
4826 {
4827 let mut per_file_map = IndexMap::new();
4828 for (pattern, flavor_value) in per_file_table {
4829 if let Ok(flavor) = MarkdownFlavor::deserialize(flavor_value.clone()) {
4830 per_file_map.insert(pattern.clone(), flavor);
4831 } else {
4832 log::warn!(
4833 "[WARN] Invalid flavor for per-file-flavor pattern '{pattern}' in {display_path}, found {flavor_value:?}. Valid values: standard, mkdocs, mdx, quarto"
4834 );
4835 }
4836 }
4837 fragment
4838 .per_file_flavor
4839 .push_override(per_file_map, source, file.clone(), None);
4840 }
4841
4842 for (key, value) in rumdl_table {
4844 let norm_rule_key = normalize_key(key);
4845
4846 let is_global_key = [
4849 "enable",
4850 "disable",
4851 "include",
4852 "exclude",
4853 "respect_gitignore",
4854 "respect-gitignore",
4855 "force_exclude",
4856 "force-exclude",
4857 "output_format",
4858 "output-format",
4859 "fixable",
4860 "unfixable",
4861 "per-file-ignores",
4862 "per_file_ignores",
4863 "per-file-flavor",
4864 "per_file_flavor",
4865 "global",
4866 "flavor",
4867 "cache_dir",
4868 "cache-dir",
4869 "cache",
4870 ]
4871 .contains(&norm_rule_key.as_str());
4872
4873 let is_line_length_global =
4875 (norm_rule_key == "line-length" || norm_rule_key == "line_length") && !value.is_table();
4876
4877 if is_global_key || is_line_length_global {
4878 continue;
4879 }
4880
4881 if let Some(resolved_rule_name) = registry.resolve_rule_name(key)
4883 && value.is_table()
4884 && let Some(rule_config_table) = value.as_table()
4885 {
4886 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4887 for (rk, rv) in rule_config_table {
4888 let norm_rk = normalize_key(rk);
4889
4890 if norm_rk == "severity" {
4892 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4893 if rule_entry.severity.is_none() {
4894 rule_entry.severity = Some(SourcedValue::new(severity, source));
4895 } else {
4896 rule_entry.severity.as_mut().unwrap().push_override(
4897 severity,
4898 source,
4899 file.clone(),
4900 None,
4901 );
4902 }
4903 }
4904 continue; }
4906
4907 let toml_val = rv.clone();
4908
4909 let sv = rule_entry
4910 .values
4911 .entry(norm_rk.clone())
4912 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
4913 sv.push_override(toml_val, source, file.clone(), None);
4914 }
4915 } else if registry.resolve_rule_name(key).is_none() {
4916 fragment
4919 .unknown_keys
4920 .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
4921 }
4922 }
4923 }
4924
4925 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
4927 for (key, value) in tool_table.iter() {
4928 if let Some(rule_name) = key.strip_prefix("rumdl.") {
4929 if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4931 if let Some(rule_table) = value.as_table() {
4932 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4933 for (rk, rv) in rule_table {
4934 let norm_rk = normalize_key(rk);
4935
4936 if norm_rk == "severity" {
4938 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4939 if rule_entry.severity.is_none() {
4940 rule_entry.severity = Some(SourcedValue::new(severity, source));
4941 } else {
4942 rule_entry.severity.as_mut().unwrap().push_override(
4943 severity,
4944 source,
4945 file.clone(),
4946 None,
4947 );
4948 }
4949 }
4950 continue; }
4952
4953 let toml_val = rv.clone();
4954 let sv = rule_entry
4955 .values
4956 .entry(norm_rk.clone())
4957 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4958 sv.push_override(toml_val, source, file.clone(), None);
4959 }
4960 }
4961 } else if rule_name.to_ascii_uppercase().starts_with("MD")
4962 || rule_name.chars().any(|c| c.is_alphabetic())
4963 {
4964 fragment.unknown_keys.push((
4966 format!("[tool.rumdl.{rule_name}]"),
4967 String::new(),
4968 Some(path.to_string()),
4969 ));
4970 }
4971 }
4972 }
4973 }
4974
4975 if let Some(doc_table) = doc.as_table() {
4977 for (key, value) in doc_table.iter() {
4978 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
4979 if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4981 if let Some(rule_table) = value.as_table() {
4982 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4983 for (rk, rv) in rule_table {
4984 let norm_rk = normalize_key(rk);
4985
4986 if norm_rk == "severity" {
4988 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4989 if rule_entry.severity.is_none() {
4990 rule_entry.severity = Some(SourcedValue::new(severity, source));
4991 } else {
4992 rule_entry.severity.as_mut().unwrap().push_override(
4993 severity,
4994 source,
4995 file.clone(),
4996 None,
4997 );
4998 }
4999 }
5000 continue; }
5002
5003 let toml_val = rv.clone();
5004 let sv = rule_entry
5005 .values
5006 .entry(norm_rk.clone())
5007 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
5008 sv.push_override(toml_val, source, file.clone(), None);
5009 }
5010 }
5011 } else if rule_name.to_ascii_uppercase().starts_with("MD")
5012 || rule_name.chars().any(|c| c.is_alphabetic())
5013 {
5014 fragment.unknown_keys.push((
5016 format!("[tool.rumdl.{rule_name}]"),
5017 String::new(),
5018 Some(path.to_string()),
5019 ));
5020 }
5021 }
5022 }
5023 }
5024
5025 let has_any = !fragment.global.enable.value.is_empty()
5027 || !fragment.global.disable.value.is_empty()
5028 || !fragment.global.include.value.is_empty()
5029 || !fragment.global.exclude.value.is_empty()
5030 || !fragment.global.fixable.value.is_empty()
5031 || !fragment.global.unfixable.value.is_empty()
5032 || fragment.global.output_format.is_some()
5033 || fragment.global.cache_dir.is_some()
5034 || !fragment.global.cache.value
5035 || !fragment.per_file_ignores.value.is_empty()
5036 || !fragment.per_file_flavor.value.is_empty()
5037 || !fragment.rules.is_empty();
5038 if has_any { Ok(Some(fragment)) } else { Ok(None) }
5039}
5040
5041fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
5043 let display_path = to_relative_display_path(path);
5044 let doc = content
5045 .parse::<DocumentMut>()
5046 .map_err(|e| ConfigError::ParseError(format!("{display_path}: Failed to parse TOML: {e}")))?;
5047 let mut fragment = SourcedConfigFragment::default();
5048 let file = Some(path.to_string());
5050
5051 let all_rules = rules::all_rules(&Config::default());
5053 let registry = RuleRegistry::from_rules(&all_rules);
5054
5055 if let Some(global_item) = doc.get("global")
5057 && let Some(global_table) = global_item.as_table()
5058 {
5059 for (key, value_item) in global_table.iter() {
5060 let norm_key = normalize_key(key);
5061 match norm_key.as_str() {
5062 "enable" | "disable" | "include" | "exclude" => {
5063 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5064 let values: Vec<String> = formatted_array
5066 .iter()
5067 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
5069 .collect();
5070
5071 let final_values = if norm_key == "enable" || norm_key == "disable" {
5073 values
5074 .into_iter()
5075 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
5076 .collect()
5077 } else {
5078 values
5079 };
5080
5081 match norm_key.as_str() {
5082 "enable" => fragment
5083 .global
5084 .enable
5085 .push_override(final_values, source, file.clone(), None),
5086 "disable" => {
5087 fragment
5088 .global
5089 .disable
5090 .push_override(final_values, source, file.clone(), None)
5091 }
5092 "include" => {
5093 fragment
5094 .global
5095 .include
5096 .push_override(final_values, source, file.clone(), None)
5097 }
5098 "exclude" => {
5099 fragment
5100 .global
5101 .exclude
5102 .push_override(final_values, source, file.clone(), None)
5103 }
5104 _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
5105 }
5106 } else {
5107 log::warn!(
5108 "[WARN] Expected array for global key '{}' in {}, found {}",
5109 key,
5110 display_path,
5111 value_item.type_name()
5112 );
5113 }
5114 }
5115 "respect_gitignore" | "respect-gitignore" => {
5116 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
5118 let val = *formatted_bool.value();
5119 fragment
5120 .global
5121 .respect_gitignore
5122 .push_override(val, source, file.clone(), None);
5123 } else {
5124 log::warn!(
5125 "[WARN] Expected boolean for global key '{}' in {}, found {}",
5126 key,
5127 display_path,
5128 value_item.type_name()
5129 );
5130 }
5131 }
5132 "force_exclude" | "force-exclude" => {
5133 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
5135 let val = *formatted_bool.value();
5136 fragment
5137 .global
5138 .force_exclude
5139 .push_override(val, source, file.clone(), None);
5140 } else {
5141 log::warn!(
5142 "[WARN] Expected boolean for global key '{}' in {}, found {}",
5143 key,
5144 display_path,
5145 value_item.type_name()
5146 );
5147 }
5148 }
5149 "line_length" | "line-length" => {
5150 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
5152 let val = LineLength::new(*formatted_int.value() as usize);
5153 fragment
5154 .global
5155 .line_length
5156 .push_override(val, source, file.clone(), None);
5157 } else {
5158 log::warn!(
5159 "[WARN] Expected integer for global key '{}' in {}, found {}",
5160 key,
5161 display_path,
5162 value_item.type_name()
5163 );
5164 }
5165 }
5166 "output_format" | "output-format" => {
5167 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5169 let val = formatted_string.value().clone();
5170 if fragment.global.output_format.is_none() {
5171 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
5172 } else {
5173 fragment.global.output_format.as_mut().unwrap().push_override(
5174 val,
5175 source,
5176 file.clone(),
5177 None,
5178 );
5179 }
5180 } else {
5181 log::warn!(
5182 "[WARN] Expected string for global key '{}' in {}, found {}",
5183 key,
5184 display_path,
5185 value_item.type_name()
5186 );
5187 }
5188 }
5189 "cache_dir" | "cache-dir" => {
5190 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5192 let val = formatted_string.value().clone();
5193 if fragment.global.cache_dir.is_none() {
5194 fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
5195 } else {
5196 fragment
5197 .global
5198 .cache_dir
5199 .as_mut()
5200 .unwrap()
5201 .push_override(val, source, file.clone(), None);
5202 }
5203 } else {
5204 log::warn!(
5205 "[WARN] Expected string for global key '{}' in {}, found {}",
5206 key,
5207 display_path,
5208 value_item.type_name()
5209 );
5210 }
5211 }
5212 "cache" => {
5213 if let Some(toml_edit::Value::Boolean(b)) = value_item.as_value() {
5214 let val = *b.value();
5215 fragment.global.cache.push_override(val, source, file.clone(), None);
5216 } else {
5217 log::warn!(
5218 "[WARN] Expected boolean for global key '{}' in {}, found {}",
5219 key,
5220 display_path,
5221 value_item.type_name()
5222 );
5223 }
5224 }
5225 "fixable" => {
5226 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5227 let values: Vec<String> = formatted_array
5228 .iter()
5229 .filter_map(|item| item.as_str())
5230 .map(normalize_key)
5231 .collect();
5232 fragment
5233 .global
5234 .fixable
5235 .push_override(values, source, file.clone(), None);
5236 } else {
5237 log::warn!(
5238 "[WARN] Expected array for global key '{}' in {}, found {}",
5239 key,
5240 display_path,
5241 value_item.type_name()
5242 );
5243 }
5244 }
5245 "unfixable" => {
5246 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5247 let values: Vec<String> = formatted_array
5248 .iter()
5249 .filter_map(|item| item.as_str())
5250 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
5251 .collect();
5252 fragment
5253 .global
5254 .unfixable
5255 .push_override(values, source, file.clone(), None);
5256 } else {
5257 log::warn!(
5258 "[WARN] Expected array for global key '{}' in {}, found {}",
5259 key,
5260 display_path,
5261 value_item.type_name()
5262 );
5263 }
5264 }
5265 "flavor" => {
5266 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5267 let val = formatted_string.value();
5268 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
5269 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
5270 } else {
5271 log::warn!("[WARN] Unknown markdown flavor '{val}' in {display_path}");
5272 }
5273 } else {
5274 log::warn!(
5275 "[WARN] Expected string for global key '{}' in {}, found {}",
5276 key,
5277 display_path,
5278 value_item.type_name()
5279 );
5280 }
5281 }
5282 _ => {
5283 fragment
5285 .unknown_keys
5286 .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
5287 log::warn!("[WARN] Unknown key in [global] section of {display_path}: {key}");
5288 }
5289 }
5290 }
5291 }
5292
5293 if let Some(per_file_item) = doc.get("per-file-ignores")
5295 && let Some(per_file_table) = per_file_item.as_table()
5296 {
5297 let mut per_file_map = HashMap::new();
5298 for (pattern, value_item) in per_file_table.iter() {
5299 warn_comma_without_brace_in_pattern(pattern, &display_path);
5300 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5301 let rules: Vec<String> = formatted_array
5302 .iter()
5303 .filter_map(|item| item.as_str())
5304 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
5305 .collect();
5306 per_file_map.insert(pattern.to_string(), rules);
5307 } else {
5308 let type_name = value_item.type_name();
5309 log::warn!(
5310 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {display_path}, found {type_name}"
5311 );
5312 }
5313 }
5314 fragment
5315 .per_file_ignores
5316 .push_override(per_file_map, source, file.clone(), None);
5317 }
5318
5319 if let Some(per_file_item) = doc.get("per-file-flavor")
5321 && let Some(per_file_table) = per_file_item.as_table()
5322 {
5323 let mut per_file_map = IndexMap::new();
5324 for (pattern, value_item) in per_file_table.iter() {
5325 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5326 let flavor_str = formatted_string.value();
5327 match MarkdownFlavor::deserialize(toml::Value::String(flavor_str.to_string())) {
5328 Ok(flavor) => {
5329 per_file_map.insert(pattern.to_string(), flavor);
5330 }
5331 Err(_) => {
5332 log::warn!(
5333 "[WARN] Invalid flavor '{flavor_str}' for pattern '{pattern}' in {display_path}. Valid values: standard, mkdocs, mdx, quarto"
5334 );
5335 }
5336 }
5337 } else {
5338 let type_name = value_item.type_name();
5339 log::warn!(
5340 "[WARN] Expected string for per-file-flavor pattern '{pattern}' in {display_path}, found {type_name}"
5341 );
5342 }
5343 }
5344 fragment
5345 .per_file_flavor
5346 .push_override(per_file_map, source, file.clone(), None);
5347 }
5348
5349 if let Some(cbt_item) = doc.get("code-block-tools")
5351 && let Some(cbt_table) = cbt_item.as_table()
5352 {
5353 let mut cbt_doc = toml_edit::DocumentMut::new();
5356 for (key, value) in cbt_table.iter() {
5357 cbt_doc[key] = value.clone();
5358 }
5359 let cbt_toml_str = cbt_doc.to_string();
5360 match toml::from_str::<crate::code_block_tools::CodeBlockToolsConfig>(&cbt_toml_str) {
5361 Ok(cbt_config) => {
5362 fragment
5363 .code_block_tools
5364 .push_override(cbt_config, source, file.clone(), None);
5365 }
5366 Err(e) => {
5367 log::warn!("[WARN] Failed to parse [code-block-tools] section in {display_path}: {e}");
5368 }
5369 }
5370 }
5371
5372 for (key, item) in doc.iter() {
5374 if key == "global" || key == "per-file-ignores" || key == "per-file-flavor" || key == "code-block-tools" {
5376 continue;
5377 }
5378
5379 let norm_rule_name = if let Some(resolved) = registry.resolve_rule_name(key) {
5381 resolved
5382 } else {
5383 fragment
5385 .unknown_keys
5386 .push((format!("[{key}]"), String::new(), Some(path.to_string())));
5387 continue;
5388 };
5389
5390 if let Some(tbl) = item.as_table() {
5391 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
5392 for (rk, rv_item) in tbl.iter() {
5393 let norm_rk = normalize_key(rk);
5394
5395 if norm_rk == "severity" {
5397 if let Some(toml_edit::Value::String(formatted_string)) = rv_item.as_value() {
5398 let severity_str = formatted_string.value();
5399 match crate::rule::Severity::deserialize(toml::Value::String(severity_str.to_string())) {
5400 Ok(severity) => {
5401 if rule_entry.severity.is_none() {
5402 rule_entry.severity = Some(SourcedValue::new(severity, source));
5403 } else {
5404 rule_entry.severity.as_mut().unwrap().push_override(
5405 severity,
5406 source,
5407 file.clone(),
5408 None,
5409 );
5410 }
5411 }
5412 Err(_) => {
5413 log::warn!(
5414 "[WARN] Invalid severity '{severity_str}' for rule {norm_rule_name} in {display_path}. Valid values: error, warning"
5415 );
5416 }
5417 }
5418 }
5419 continue; }
5421
5422 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
5423 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
5424 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
5425 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
5426 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
5427 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
5428 Some(toml_edit::Value::Array(formatted_array)) => {
5429 let mut values = Vec::new();
5431 for item in formatted_array.iter() {
5432 match item {
5433 toml_edit::Value::String(formatted) => {
5434 values.push(toml::Value::String(formatted.value().clone()))
5435 }
5436 toml_edit::Value::Integer(formatted) => {
5437 values.push(toml::Value::Integer(*formatted.value()))
5438 }
5439 toml_edit::Value::Float(formatted) => {
5440 values.push(toml::Value::Float(*formatted.value()))
5441 }
5442 toml_edit::Value::Boolean(formatted) => {
5443 values.push(toml::Value::Boolean(*formatted.value()))
5444 }
5445 toml_edit::Value::Datetime(formatted) => {
5446 values.push(toml::Value::Datetime(*formatted.value()))
5447 }
5448 _ => {
5449 log::warn!(
5450 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {display_path}"
5451 );
5452 }
5453 }
5454 }
5455 Some(toml::Value::Array(values))
5456 }
5457 Some(toml_edit::Value::InlineTable(_)) => {
5458 log::warn!(
5459 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {display_path}. Table conversion not yet fully implemented in parser."
5460 );
5461 None
5462 }
5463 None => {
5464 log::warn!(
5465 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {display_path}. Expected simple value."
5466 );
5467 None
5468 }
5469 };
5470 if let Some(toml_val) = maybe_toml_val {
5471 let sv = rule_entry
5472 .values
5473 .entry(norm_rk.clone())
5474 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
5475 sv.push_override(toml_val, source, file.clone(), None);
5476 }
5477 }
5478 } else if item.is_value() {
5479 log::warn!(
5480 "[WARN] Ignoring top-level value key in {display_path}: '{key}'. Expected a table like [{key}]."
5481 );
5482 }
5483 }
5484
5485 Ok(fragment)
5486}
5487
5488fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
5490 let display_path = to_relative_display_path(path);
5491 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
5493 .map_err(|e| ConfigError::ParseError(format!("{display_path}: {e}")))?;
5494 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
5495}
5496
5497#[cfg(test)]
5498#[path = "config_intelligent_merge_tests.rs"]
5499mod config_intelligent_merge_tests;