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