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
141#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
143pub struct RuleConfig {
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub severity: Option<crate::rule::Severity>,
147
148 #[serde(flatten)]
150 #[schemars(schema_with = "arbitrary_value_schema")]
151 pub values: BTreeMap<String, toml::Value>,
152}
153
154fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
156 schemars::json_schema!({
157 "type": "object",
158 "additionalProperties": true
159 })
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
164#[schemars(
165 description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
166)]
167pub struct Config {
168 #[serde(default)]
170 pub global: GlobalConfig,
171
172 #[serde(default, rename = "per-file-ignores")]
175 pub per_file_ignores: HashMap<String, Vec<String>>,
176
177 #[serde(default, rename = "per-file-flavor")]
181 #[schemars(with = "HashMap<String, MarkdownFlavor>")]
182 pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
183
184 #[serde(flatten)]
195 pub rules: BTreeMap<String, RuleConfig>,
196
197 #[serde(skip)]
199 pub project_root: Option<std::path::PathBuf>,
200}
201
202impl Config {
203 pub fn is_mkdocs_flavor(&self) -> bool {
205 self.global.flavor == MarkdownFlavor::MkDocs
206 }
207
208 pub fn markdown_flavor(&self) -> MarkdownFlavor {
214 self.global.flavor
215 }
216
217 pub fn is_mkdocs_project(&self) -> bool {
219 self.is_mkdocs_flavor()
220 }
221
222 pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
224 self.rules.get(rule_name).and_then(|r| r.severity)
225 }
226
227 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
230 use globset::{Glob, GlobSetBuilder};
231
232 let mut ignored_rules = HashSet::new();
233
234 if self.per_file_ignores.is_empty() {
235 return ignored_rules;
236 }
237
238 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
241 if let Ok(canonical_path) = file_path.canonicalize() {
242 if let Ok(canonical_root) = root.canonicalize() {
243 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
244 std::borrow::Cow::Owned(relative.to_path_buf())
245 } else {
246 std::borrow::Cow::Borrowed(file_path)
247 }
248 } else {
249 std::borrow::Cow::Borrowed(file_path)
250 }
251 } else {
252 std::borrow::Cow::Borrowed(file_path)
253 }
254 } else {
255 std::borrow::Cow::Borrowed(file_path)
256 };
257
258 let mut builder = GlobSetBuilder::new();
260 let mut pattern_to_rules: Vec<(usize, &Vec<String>)> = Vec::new();
261
262 for (idx, (pattern, rules)) in self.per_file_ignores.iter().enumerate() {
263 if let Ok(glob) = Glob::new(pattern) {
264 builder.add(glob);
265 pattern_to_rules.push((idx, rules));
266 } else {
267 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
268 }
269 }
270
271 let globset = match builder.build() {
272 Ok(gs) => gs,
273 Err(e) => {
274 log::error!("Failed to build globset for per-file-ignores: {e}");
275 return ignored_rules;
276 }
277 };
278
279 for match_idx in globset.matches(path_for_matching.as_ref()) {
281 if let Some((_, rules)) = pattern_to_rules.get(match_idx) {
282 for rule in rules.iter() {
283 ignored_rules.insert(normalize_key(rule));
285 }
286 }
287 }
288
289 ignored_rules
290 }
291
292 pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
296 use globset::GlobBuilder;
297
298 if self.per_file_flavor.is_empty() {
300 return self.resolve_flavor_fallback(file_path);
301 }
302
303 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
305 if let Ok(canonical_path) = file_path.canonicalize() {
306 if let Ok(canonical_root) = root.canonicalize() {
307 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
308 std::borrow::Cow::Owned(relative.to_path_buf())
309 } else {
310 std::borrow::Cow::Borrowed(file_path)
311 }
312 } else {
313 std::borrow::Cow::Borrowed(file_path)
314 }
315 } else {
316 std::borrow::Cow::Borrowed(file_path)
317 }
318 } else {
319 std::borrow::Cow::Borrowed(file_path)
320 };
321
322 for (pattern, flavor) in &self.per_file_flavor {
324 if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
327 let matcher = glob.compile_matcher();
328 if matcher.is_match(path_for_matching.as_ref()) {
329 return *flavor;
330 }
331 } else {
332 log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
333 }
334 }
335
336 self.resolve_flavor_fallback(file_path)
338 }
339
340 fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
342 if self.global.flavor != MarkdownFlavor::Standard {
344 return self.global.flavor;
345 }
346 MarkdownFlavor::from_path(file_path)
348 }
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
353#[serde(default, rename_all = "kebab-case")]
354pub struct GlobalConfig {
355 #[serde(default)]
357 pub enable: Vec<String>,
358
359 #[serde(default)]
361 pub disable: Vec<String>,
362
363 #[serde(default)]
365 pub exclude: Vec<String>,
366
367 #[serde(default)]
369 pub include: Vec<String>,
370
371 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
373 pub respect_gitignore: bool,
374
375 #[serde(default, alias = "line_length")]
377 pub line_length: LineLength,
378
379 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
381 pub output_format: Option<String>,
382
383 #[serde(default)]
386 pub fixable: Vec<String>,
387
388 #[serde(default)]
391 pub unfixable: Vec<String>,
392
393 #[serde(default)]
396 pub flavor: MarkdownFlavor,
397
398 #[serde(default, alias = "force_exclude")]
403 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
404 pub force_exclude: bool,
405
406 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
409 pub cache_dir: Option<String>,
410
411 #[serde(default = "default_true")]
414 pub cache: bool,
415}
416
417fn default_respect_gitignore() -> bool {
418 true
419}
420
421fn default_true() -> bool {
422 true
423}
424
425impl Default for GlobalConfig {
427 #[allow(deprecated)]
428 fn default() -> Self {
429 Self {
430 enable: Vec::new(),
431 disable: Vec::new(),
432 exclude: Vec::new(),
433 include: Vec::new(),
434 respect_gitignore: true,
435 line_length: LineLength::default(),
436 output_format: None,
437 fixable: Vec::new(),
438 unfixable: Vec::new(),
439 flavor: MarkdownFlavor::default(),
440 force_exclude: false,
441 cache_dir: None,
442 cache: true,
443 }
444 }
445}
446
447const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
448 ".markdownlint.json",
449 ".markdownlint.jsonc",
450 ".markdownlint.yaml",
451 ".markdownlint.yml",
452 "markdownlint.json",
453 "markdownlint.jsonc",
454 "markdownlint.yaml",
455 "markdownlint.yml",
456];
457
458pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
460 if Path::new(path).exists() {
462 return Err(ConfigError::FileExists { path: path.to_string() });
463 }
464
465 let default_config = r#"# rumdl configuration file
467
468# Global configuration options
469[global]
470# List of rules to disable (uncomment and modify as needed)
471# disable = ["MD013", "MD033"]
472
473# List of rules to enable exclusively (if provided, only these rules will run)
474# enable = ["MD001", "MD003", "MD004"]
475
476# List of file/directory patterns to include for linting (if provided, only these will be linted)
477# include = [
478# "docs/*.md",
479# "src/**/*.md",
480# "README.md"
481# ]
482
483# List of file/directory patterns to exclude from linting
484exclude = [
485 # Common directories to exclude
486 ".git",
487 ".github",
488 "node_modules",
489 "vendor",
490 "dist",
491 "build",
492
493 # Specific files or patterns
494 "CHANGELOG.md",
495 "LICENSE.md",
496]
497
498# Respect .gitignore files when scanning directories (default: true)
499respect-gitignore = true
500
501# Markdown flavor/dialect (uncomment to enable)
502# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
503# flavor = "mkdocs"
504
505# Rule-specific configurations (uncomment and modify as needed)
506
507# [MD003]
508# style = "atx" # Heading style (atx, atx_closed, setext)
509
510# [MD004]
511# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
512
513# [MD007]
514# indent = 4 # Unordered list indentation
515
516# [MD013]
517# line-length = 100 # Line length
518# code-blocks = false # Exclude code blocks from line length check
519# tables = false # Exclude tables from line length check
520# headings = true # Include headings in line length check
521
522# [MD044]
523# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
524# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
525"#;
526
527 match fs::write(path, default_config) {
529 Ok(_) => Ok(()),
530 Err(err) => Err(ConfigError::IoError {
531 source: err,
532 path: path.to_string(),
533 }),
534 }
535}
536
537#[derive(Debug, thiserror::Error)]
539pub enum ConfigError {
540 #[error("Failed to read config file at {path}: {source}")]
542 IoError { source: io::Error, path: String },
543
544 #[error("Failed to parse config: {0}")]
546 ParseError(String),
547
548 #[error("Configuration file already exists at {path}")]
550 FileExists { path: String },
551}
552
553pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
557 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
560
561 let key_variants = [
563 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
568
569 for variant in &key_variants {
571 if let Some(value) = rule_config.values.get(variant)
572 && let Ok(result) = T::deserialize(value.clone())
573 {
574 return Some(result);
575 }
576 }
577
578 None
579}
580
581pub fn generate_pyproject_config() -> String {
583 let config_content = r#"
584[tool.rumdl]
585# Global configuration options
586line-length = 100
587disable = []
588exclude = [
589 # Common directories to exclude
590 ".git",
591 ".github",
592 "node_modules",
593 "vendor",
594 "dist",
595 "build",
596]
597respect-gitignore = true
598
599# Rule-specific configurations (uncomment and modify as needed)
600
601# [tool.rumdl.MD003]
602# style = "atx" # Heading style (atx, atx_closed, setext)
603
604# [tool.rumdl.MD004]
605# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
606
607# [tool.rumdl.MD007]
608# indent = 4 # Unordered list indentation
609
610# [tool.rumdl.MD013]
611# line-length = 100 # Line length
612# code-blocks = false # Exclude code blocks from line length check
613# tables = false # Exclude tables from line length check
614# headings = true # Include headings in line length check
615
616# [tool.rumdl.MD044]
617# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
618# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
619"#;
620
621 config_content.to_string()
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627 use std::fs;
628 use tempfile::tempdir;
629
630 #[test]
631 fn test_flavor_loading() {
632 let temp_dir = tempdir().unwrap();
633 let config_path = temp_dir.path().join(".rumdl.toml");
634 let config_content = r#"
635[global]
636flavor = "mkdocs"
637disable = ["MD001"]
638"#;
639 fs::write(&config_path, config_content).unwrap();
640
641 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
643 let config: Config = sourced.into_validated_unchecked().into();
644
645 assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
647 assert!(config.is_mkdocs_flavor());
648 assert!(config.is_mkdocs_project()); assert_eq!(config.global.disable, vec!["MD001".to_string()]);
650 }
651
652 #[test]
653 fn test_pyproject_toml_root_level_config() {
654 let temp_dir = tempdir().unwrap();
655 let config_path = temp_dir.path().join("pyproject.toml");
656
657 let content = r#"
659[tool.rumdl]
660line-length = 120
661disable = ["MD033"]
662enable = ["MD001", "MD004"]
663include = ["docs/*.md"]
664exclude = ["node_modules"]
665respect-gitignore = true
666 "#;
667
668 fs::write(&config_path, content).unwrap();
669
670 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
672 let config: Config = sourced.into_validated_unchecked().into(); assert_eq!(config.global.disable, vec!["MD033".to_string()]);
676 assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
677 assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
679 assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
680 assert!(config.global.respect_gitignore);
681
682 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
684 assert_eq!(line_length, Some(120));
685 }
686
687 #[test]
688 fn test_pyproject_toml_snake_case_and_kebab_case() {
689 let temp_dir = tempdir().unwrap();
690 let config_path = temp_dir.path().join("pyproject.toml");
691
692 let content = r#"
694[tool.rumdl]
695line-length = 150
696respect_gitignore = true
697 "#;
698
699 fs::write(&config_path, content).unwrap();
700
701 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
703 let config: Config = sourced.into_validated_unchecked().into(); assert!(config.global.respect_gitignore);
707 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
708 assert_eq!(line_length, Some(150));
709 }
710
711 #[test]
712 fn test_md013_key_normalization_in_rumdl_toml() {
713 let temp_dir = tempdir().unwrap();
714 let config_path = temp_dir.path().join(".rumdl.toml");
715 let config_content = r#"
716[MD013]
717line_length = 111
718line-length = 222
719"#;
720 fs::write(&config_path, config_content).unwrap();
721 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
723 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
724 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
726 assert_eq!(keys, vec!["line-length"]);
727 let val = &rule_cfg.values["line-length"].value;
728 assert_eq!(val.as_integer(), Some(222));
729 let config: Config = sourced.clone().into_validated_unchecked().into();
731 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
732 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
733 assert_eq!(v1, Some(222));
734 assert_eq!(v2, Some(222));
735 }
736
737 #[test]
738 fn test_md013_section_case_insensitivity() {
739 let temp_dir = tempdir().unwrap();
740 let config_path = temp_dir.path().join(".rumdl.toml");
741 let config_content = r#"
742[md013]
743line-length = 101
744
745[Md013]
746line-length = 102
747
748[MD013]
749line-length = 103
750"#;
751 fs::write(&config_path, config_content).unwrap();
752 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
754 let config: Config = sourced.clone().into_validated_unchecked().into();
755 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
757 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
758 assert_eq!(keys, vec!["line-length"]);
759 let val = &rule_cfg.values["line-length"].value;
760 assert_eq!(val.as_integer(), Some(103));
761 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
762 assert_eq!(v, Some(103));
763 }
764
765 #[test]
766 fn test_md013_key_snake_and_kebab_case() {
767 let temp_dir = tempdir().unwrap();
768 let config_path = temp_dir.path().join(".rumdl.toml");
769 let config_content = r#"
770[MD013]
771line_length = 201
772line-length = 202
773"#;
774 fs::write(&config_path, config_content).unwrap();
775 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
777 let config: Config = sourced.clone().into_validated_unchecked().into();
778 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
779 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
780 assert_eq!(keys, vec!["line-length"]);
781 let val = &rule_cfg.values["line-length"].value;
782 assert_eq!(val.as_integer(), Some(202));
783 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
784 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
785 assert_eq!(v1, Some(202));
786 assert_eq!(v2, Some(202));
787 }
788
789 #[test]
790 fn test_unknown_rule_section_is_ignored() {
791 let temp_dir = tempdir().unwrap();
792 let config_path = temp_dir.path().join(".rumdl.toml");
793 let config_content = r#"
794[MD999]
795foo = 1
796bar = 2
797[MD013]
798line-length = 303
799"#;
800 fs::write(&config_path, config_content).unwrap();
801 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
803 let config: Config = sourced.clone().into_validated_unchecked().into();
804 assert!(!sourced.rules.contains_key("MD999"));
806 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
808 assert_eq!(v, Some(303));
809 }
810
811 #[test]
812 fn test_invalid_toml_syntax() {
813 let temp_dir = tempdir().unwrap();
814 let config_path = temp_dir.path().join(".rumdl.toml");
815
816 let config_content = r#"
818[MD013]
819line-length = "unclosed string
820"#;
821 fs::write(&config_path, config_content).unwrap();
822
823 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
824 assert!(result.is_err());
825 match result.unwrap_err() {
826 ConfigError::ParseError(msg) => {
827 assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
829 }
830 _ => panic!("Expected ParseError"),
831 }
832 }
833
834 #[test]
835 fn test_wrong_type_for_config_value() {
836 let temp_dir = tempdir().unwrap();
837 let config_path = temp_dir.path().join(".rumdl.toml");
838
839 let config_content = r#"
841[MD013]
842line-length = "not a number"
843"#;
844 fs::write(&config_path, config_content).unwrap();
845
846 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
847 let config: Config = sourced.into_validated_unchecked().into();
848
849 let rule_config = config.rules.get("MD013").unwrap();
851 let value = rule_config.values.get("line-length").unwrap();
852 assert!(matches!(value, toml::Value::String(_)));
853 }
854
855 #[test]
856 fn test_empty_config_file() {
857 let temp_dir = tempdir().unwrap();
858 let config_path = temp_dir.path().join(".rumdl.toml");
859
860 fs::write(&config_path, "").unwrap();
862
863 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
864 let config: Config = sourced.into_validated_unchecked().into();
865
866 assert_eq!(config.global.line_length.get(), 80);
868 assert!(config.global.respect_gitignore);
869 assert!(config.rules.is_empty());
870 }
871
872 #[test]
873 fn test_malformed_pyproject_toml() {
874 let temp_dir = tempdir().unwrap();
875 let config_path = temp_dir.path().join("pyproject.toml");
876
877 let content = r#"
879[tool.rumdl
880line-length = 120
881"#;
882 fs::write(&config_path, content).unwrap();
883
884 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
885 assert!(result.is_err());
886 }
887
888 #[test]
889 fn test_conflicting_config_values() {
890 let temp_dir = tempdir().unwrap();
891 let config_path = temp_dir.path().join(".rumdl.toml");
892
893 let config_content = r#"
895[global]
896enable = ["MD013"]
897disable = ["MD013"]
898"#;
899 fs::write(&config_path, config_content).unwrap();
900
901 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
902 let config: Config = sourced.into_validated_unchecked().into();
903
904 assert!(config.global.enable.contains(&"MD013".to_string()));
906 assert!(!config.global.disable.contains(&"MD013".to_string()));
907 }
908
909 #[test]
910 fn test_invalid_rule_names() {
911 let temp_dir = tempdir().unwrap();
912 let config_path = temp_dir.path().join(".rumdl.toml");
913
914 let config_content = r#"
915[global]
916enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
917disable = ["MD-001", "MD_002"]
918"#;
919 fs::write(&config_path, config_content).unwrap();
920
921 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
922 let config: Config = sourced.into_validated_unchecked().into();
923
924 assert_eq!(config.global.enable.len(), 4);
926 assert_eq!(config.global.disable.len(), 2);
927 }
928
929 #[test]
930 fn test_deeply_nested_config() {
931 let temp_dir = tempdir().unwrap();
932 let config_path = temp_dir.path().join(".rumdl.toml");
933
934 let config_content = r#"
936[MD013]
937line-length = 100
938[MD013.nested]
939value = 42
940"#;
941 fs::write(&config_path, config_content).unwrap();
942
943 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
944 let config: Config = sourced.into_validated_unchecked().into();
945
946 let rule_config = config.rules.get("MD013").unwrap();
947 assert_eq!(
948 rule_config.values.get("line-length").unwrap(),
949 &toml::Value::Integer(100)
950 );
951 assert!(!rule_config.values.contains_key("nested"));
953 }
954
955 #[test]
956 fn test_unicode_in_config() {
957 let temp_dir = tempdir().unwrap();
958 let config_path = temp_dir.path().join(".rumdl.toml");
959
960 let config_content = r#"
961[global]
962include = ["文档/*.md", "ドã‚ュメント/*.md"]
963exclude = ["测试/*", "🚀/*"]
964
965[MD013]
966line-length = 80
967message = "行太长了 🚨"
968"#;
969 fs::write(&config_path, config_content).unwrap();
970
971 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
972 let config: Config = sourced.into_validated_unchecked().into();
973
974 assert_eq!(config.global.include.len(), 2);
975 assert_eq!(config.global.exclude.len(), 2);
976 assert!(config.global.include[0].contains("文档"));
977 assert!(config.global.exclude[1].contains("🚀"));
978
979 let rule_config = config.rules.get("MD013").unwrap();
980 let message = rule_config.values.get("message").unwrap();
981 if let toml::Value::String(s) = message {
982 assert!(s.contains("行太长了"));
983 assert!(s.contains("🚨"));
984 }
985 }
986
987 #[test]
988 fn test_extremely_long_values() {
989 let temp_dir = tempdir().unwrap();
990 let config_path = temp_dir.path().join(".rumdl.toml");
991
992 let long_string = "a".repeat(10000);
993 let config_content = format!(
994 r#"
995[global]
996exclude = ["{long_string}"]
997
998[MD013]
999line-length = 999999999
1000"#
1001 );
1002
1003 fs::write(&config_path, config_content).unwrap();
1004
1005 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1006 let config: Config = sourced.into_validated_unchecked().into();
1007
1008 assert_eq!(config.global.exclude[0].len(), 10000);
1009 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
1010 assert_eq!(line_length, Some(999999999));
1011 }
1012
1013 #[test]
1014 fn test_config_with_comments() {
1015 let temp_dir = tempdir().unwrap();
1016 let config_path = temp_dir.path().join(".rumdl.toml");
1017
1018 let config_content = r#"
1019[global]
1020# This is a comment
1021enable = ["MD001"] # Enable MD001
1022# disable = ["MD002"] # This is commented out
1023
1024[MD013] # Line length rule
1025line-length = 100 # Set to 100 characters
1026# ignored = true # This setting is commented out
1027"#;
1028 fs::write(&config_path, config_content).unwrap();
1029
1030 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1031 let config: Config = sourced.into_validated_unchecked().into();
1032
1033 assert_eq!(config.global.enable, vec!["MD001"]);
1034 assert!(config.global.disable.is_empty()); let rule_config = config.rules.get("MD013").unwrap();
1037 assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
1039 }
1040
1041 #[test]
1042 fn test_arrays_in_rule_config() {
1043 let temp_dir = tempdir().unwrap();
1044 let config_path = temp_dir.path().join(".rumdl.toml");
1045
1046 let config_content = r#"
1047[MD003]
1048levels = [1, 2, 3]
1049tags = ["important", "critical"]
1050mixed = [1, "two", true]
1051"#;
1052 fs::write(&config_path, config_content).unwrap();
1053
1054 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1055 let config: Config = sourced.into_validated_unchecked().into();
1056
1057 let rule_config = config.rules.get("MD003").expect("MD003 config should exist");
1059
1060 assert!(rule_config.values.contains_key("levels"));
1062 assert!(rule_config.values.contains_key("tags"));
1063 assert!(rule_config.values.contains_key("mixed"));
1064
1065 if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
1067 assert_eq!(levels.len(), 3);
1068 assert_eq!(levels[0], toml::Value::Integer(1));
1069 assert_eq!(levels[1], toml::Value::Integer(2));
1070 assert_eq!(levels[2], toml::Value::Integer(3));
1071 } else {
1072 panic!("levels should be an array");
1073 }
1074
1075 if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
1076 assert_eq!(tags.len(), 2);
1077 assert_eq!(tags[0], toml::Value::String("important".to_string()));
1078 assert_eq!(tags[1], toml::Value::String("critical".to_string()));
1079 } else {
1080 panic!("tags should be an array");
1081 }
1082
1083 if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
1084 assert_eq!(mixed.len(), 3);
1085 assert_eq!(mixed[0], toml::Value::Integer(1));
1086 assert_eq!(mixed[1], toml::Value::String("two".to_string()));
1087 assert_eq!(mixed[2], toml::Value::Boolean(true));
1088 } else {
1089 panic!("mixed should be an array");
1090 }
1091 }
1092
1093 #[test]
1094 fn test_normalize_key_edge_cases() {
1095 assert_eq!(normalize_key("MD001"), "MD001");
1097 assert_eq!(normalize_key("md001"), "MD001");
1098 assert_eq!(normalize_key("Md001"), "MD001");
1099 assert_eq!(normalize_key("mD001"), "MD001");
1100
1101 assert_eq!(normalize_key("line_length"), "line-length");
1103 assert_eq!(normalize_key("line-length"), "line-length");
1104 assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
1105 assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
1106
1107 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(""), "");
1114 assert_eq!(normalize_key("_"), "-");
1115 assert_eq!(normalize_key("___"), "---");
1116 }
1117
1118 #[test]
1119 fn test_missing_config_file() {
1120 let temp_dir = tempdir().unwrap();
1121 let config_path = temp_dir.path().join("nonexistent.toml");
1122
1123 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1124 assert!(result.is_err());
1125 match result.unwrap_err() {
1126 ConfigError::IoError { .. } => {}
1127 _ => panic!("Expected IoError for missing file"),
1128 }
1129 }
1130
1131 #[test]
1132 #[cfg(unix)]
1133 fn test_permission_denied_config() {
1134 use std::os::unix::fs::PermissionsExt;
1135
1136 let temp_dir = tempdir().unwrap();
1137 let config_path = temp_dir.path().join(".rumdl.toml");
1138
1139 fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
1140
1141 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1143 perms.set_mode(0o000);
1144 fs::set_permissions(&config_path, perms).unwrap();
1145
1146 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1147
1148 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1150 perms.set_mode(0o644);
1151 fs::set_permissions(&config_path, perms).unwrap();
1152
1153 assert!(result.is_err());
1154 match result.unwrap_err() {
1155 ConfigError::IoError { .. } => {}
1156 _ => panic!("Expected IoError for permission denied"),
1157 }
1158 }
1159
1160 #[test]
1161 fn test_circular_reference_detection() {
1162 let temp_dir = tempdir().unwrap();
1165 let config_path = temp_dir.path().join(".rumdl.toml");
1166
1167 let mut config_content = String::from("[MD001]\n");
1168 for i in 0..100 {
1169 config_content.push_str(&format!("key{i} = {i}\n"));
1170 }
1171
1172 fs::write(&config_path, config_content).unwrap();
1173
1174 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1175 let config: Config = sourced.into_validated_unchecked().into();
1176
1177 let rule_config = config.rules.get("MD001").unwrap();
1178 assert_eq!(rule_config.values.len(), 100);
1179 }
1180
1181 #[test]
1182 fn test_special_toml_values() {
1183 let temp_dir = tempdir().unwrap();
1184 let config_path = temp_dir.path().join(".rumdl.toml");
1185
1186 let config_content = r#"
1187[MD001]
1188infinity = inf
1189neg_infinity = -inf
1190not_a_number = nan
1191datetime = 1979-05-27T07:32:00Z
1192local_date = 1979-05-27
1193local_time = 07:32:00
1194"#;
1195 fs::write(&config_path, config_content).unwrap();
1196
1197 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1198 let config: Config = sourced.into_validated_unchecked().into();
1199
1200 if let Some(rule_config) = config.rules.get("MD001") {
1202 if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
1204 assert!(f.is_infinite() && f.is_sign_positive());
1205 }
1206 if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
1207 assert!(f.is_infinite() && f.is_sign_negative());
1208 }
1209 if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
1210 assert!(f.is_nan());
1211 }
1212
1213 if let Some(val) = rule_config.values.get("datetime") {
1215 assert!(matches!(val, toml::Value::Datetime(_)));
1216 }
1217 }
1219 }
1220
1221 #[test]
1222 fn test_default_config_passes_validation() {
1223 use crate::rules;
1224
1225 let temp_dir = tempdir().unwrap();
1226 let config_path = temp_dir.path().join(".rumdl.toml");
1227 let config_path_str = config_path.to_str().unwrap();
1228
1229 create_default_config(config_path_str).unwrap();
1231
1232 let sourced =
1234 SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1235
1236 let all_rules = rules::all_rules(&Config::default());
1238 let registry = RuleRegistry::from_rules(&all_rules);
1239
1240 let warnings = validate_config_sourced(&sourced, ®istry);
1242
1243 if !warnings.is_empty() {
1245 for warning in &warnings {
1246 eprintln!("Config validation warning: {}", warning.message);
1247 if let Some(rule) = &warning.rule {
1248 eprintln!(" Rule: {rule}");
1249 }
1250 if let Some(key) = &warning.key {
1251 eprintln!(" Key: {key}");
1252 }
1253 }
1254 }
1255 assert!(
1256 warnings.is_empty(),
1257 "Default config from rumdl init should pass validation without warnings"
1258 );
1259 }
1260
1261 #[test]
1262 fn test_per_file_ignores_config_parsing() {
1263 let temp_dir = tempdir().unwrap();
1264 let config_path = temp_dir.path().join(".rumdl.toml");
1265 let config_content = r#"
1266[per-file-ignores]
1267"README.md" = ["MD033"]
1268"docs/**/*.md" = ["MD013", "MD033"]
1269"test/*.md" = ["MD041"]
1270"#;
1271 fs::write(&config_path, config_content).unwrap();
1272
1273 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1274 let config: Config = sourced.into_validated_unchecked().into();
1275
1276 assert_eq!(config.per_file_ignores.len(), 3);
1278 assert_eq!(
1279 config.per_file_ignores.get("README.md"),
1280 Some(&vec!["MD033".to_string()])
1281 );
1282 assert_eq!(
1283 config.per_file_ignores.get("docs/**/*.md"),
1284 Some(&vec!["MD013".to_string(), "MD033".to_string()])
1285 );
1286 assert_eq!(
1287 config.per_file_ignores.get("test/*.md"),
1288 Some(&vec!["MD041".to_string()])
1289 );
1290 }
1291
1292 #[test]
1293 fn test_per_file_ignores_glob_matching() {
1294 use std::path::PathBuf;
1295
1296 let temp_dir = tempdir().unwrap();
1297 let config_path = temp_dir.path().join(".rumdl.toml");
1298 let config_content = r#"
1299[per-file-ignores]
1300"README.md" = ["MD033"]
1301"docs/**/*.md" = ["MD013"]
1302"**/test_*.md" = ["MD041"]
1303"#;
1304 fs::write(&config_path, config_content).unwrap();
1305
1306 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1307 let config: Config = sourced.into_validated_unchecked().into();
1308
1309 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1311 assert!(ignored.contains("MD033"));
1312 assert_eq!(ignored.len(), 1);
1313
1314 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1316 assert!(ignored.contains("MD013"));
1317 assert_eq!(ignored.len(), 1);
1318
1319 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
1321 assert!(ignored.contains("MD041"));
1322 assert_eq!(ignored.len(), 1);
1323
1324 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
1326 assert!(ignored.is_empty());
1327 }
1328
1329 #[test]
1330 fn test_per_file_ignores_pyproject_toml() {
1331 let temp_dir = tempdir().unwrap();
1332 let config_path = temp_dir.path().join("pyproject.toml");
1333 let config_content = r#"
1334[tool.rumdl]
1335[tool.rumdl.per-file-ignores]
1336"README.md" = ["MD033", "MD013"]
1337"generated/*.md" = ["MD041"]
1338"#;
1339 fs::write(&config_path, config_content).unwrap();
1340
1341 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1342 let config: Config = sourced.into_validated_unchecked().into();
1343
1344 assert_eq!(config.per_file_ignores.len(), 2);
1346 assert_eq!(
1347 config.per_file_ignores.get("README.md"),
1348 Some(&vec!["MD033".to_string(), "MD013".to_string()])
1349 );
1350 assert_eq!(
1351 config.per_file_ignores.get("generated/*.md"),
1352 Some(&vec!["MD041".to_string()])
1353 );
1354 }
1355
1356 #[test]
1357 fn test_per_file_ignores_multiple_patterns_match() {
1358 use std::path::PathBuf;
1359
1360 let temp_dir = tempdir().unwrap();
1361 let config_path = temp_dir.path().join(".rumdl.toml");
1362 let config_content = r#"
1363[per-file-ignores]
1364"docs/**/*.md" = ["MD013"]
1365"**/api/*.md" = ["MD033"]
1366"docs/api/overview.md" = ["MD041"]
1367"#;
1368 fs::write(&config_path, config_content).unwrap();
1369
1370 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1371 let config: Config = sourced.into_validated_unchecked().into();
1372
1373 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1375 assert_eq!(ignored.len(), 3);
1376 assert!(ignored.contains("MD013"));
1377 assert!(ignored.contains("MD033"));
1378 assert!(ignored.contains("MD041"));
1379 }
1380
1381 #[test]
1382 fn test_per_file_ignores_rule_name_normalization() {
1383 use std::path::PathBuf;
1384
1385 let temp_dir = tempdir().unwrap();
1386 let config_path = temp_dir.path().join(".rumdl.toml");
1387 let config_content = r#"
1388[per-file-ignores]
1389"README.md" = ["md033", "MD013", "Md041"]
1390"#;
1391 fs::write(&config_path, config_content).unwrap();
1392
1393 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1394 let config: Config = sourced.into_validated_unchecked().into();
1395
1396 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1398 assert_eq!(ignored.len(), 3);
1399 assert!(ignored.contains("MD033"));
1400 assert!(ignored.contains("MD013"));
1401 assert!(ignored.contains("MD041"));
1402 }
1403
1404 #[test]
1405 fn test_per_file_ignores_invalid_glob_pattern() {
1406 use std::path::PathBuf;
1407
1408 let temp_dir = tempdir().unwrap();
1409 let config_path = temp_dir.path().join(".rumdl.toml");
1410 let config_content = r#"
1411[per-file-ignores]
1412"[invalid" = ["MD033"]
1413"valid/*.md" = ["MD013"]
1414"#;
1415 fs::write(&config_path, config_content).unwrap();
1416
1417 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1418 let config: Config = sourced.into_validated_unchecked().into();
1419
1420 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
1422 assert!(ignored.contains("MD013"));
1423
1424 let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
1426 assert!(ignored2.is_empty());
1427 }
1428
1429 #[test]
1430 fn test_per_file_ignores_empty_section() {
1431 use std::path::PathBuf;
1432
1433 let temp_dir = tempdir().unwrap();
1434 let config_path = temp_dir.path().join(".rumdl.toml");
1435 let config_content = r#"
1436[global]
1437disable = ["MD001"]
1438
1439[per-file-ignores]
1440"#;
1441 fs::write(&config_path, config_content).unwrap();
1442
1443 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1444 let config: Config = sourced.into_validated_unchecked().into();
1445
1446 assert_eq!(config.per_file_ignores.len(), 0);
1448 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1449 assert!(ignored.is_empty());
1450 }
1451
1452 #[test]
1453 fn test_per_file_ignores_with_underscores_in_pyproject() {
1454 let temp_dir = tempdir().unwrap();
1455 let config_path = temp_dir.path().join("pyproject.toml");
1456 let config_content = r#"
1457[tool.rumdl]
1458[tool.rumdl.per_file_ignores]
1459"README.md" = ["MD033"]
1460"#;
1461 fs::write(&config_path, config_content).unwrap();
1462
1463 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1464 let config: Config = sourced.into_validated_unchecked().into();
1465
1466 assert_eq!(config.per_file_ignores.len(), 1);
1468 assert_eq!(
1469 config.per_file_ignores.get("README.md"),
1470 Some(&vec!["MD033".to_string()])
1471 );
1472 }
1473
1474 #[test]
1475 fn test_per_file_ignores_absolute_path_matching() {
1476 use std::path::PathBuf;
1479
1480 let temp_dir = tempdir().unwrap();
1481 let config_path = temp_dir.path().join(".rumdl.toml");
1482
1483 let github_dir = temp_dir.path().join(".github");
1485 fs::create_dir_all(&github_dir).unwrap();
1486 let test_file = github_dir.join("pull_request_template.md");
1487 fs::write(&test_file, "Test content").unwrap();
1488
1489 let config_content = r#"
1490[per-file-ignores]
1491".github/pull_request_template.md" = ["MD041"]
1492"docs/**/*.md" = ["MD013"]
1493"#;
1494 fs::write(&config_path, config_content).unwrap();
1495
1496 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1497 let config: Config = sourced.into_validated_unchecked().into();
1498
1499 let absolute_path = test_file.canonicalize().unwrap();
1501 let ignored = config.get_ignored_rules_for_file(&absolute_path);
1502 assert!(
1503 ignored.contains("MD041"),
1504 "Should match absolute path {absolute_path:?} against relative pattern"
1505 );
1506 assert_eq!(ignored.len(), 1);
1507
1508 let relative_path = PathBuf::from(".github/pull_request_template.md");
1510 let ignored = config.get_ignored_rules_for_file(&relative_path);
1511 assert!(ignored.contains("MD041"), "Should match relative path");
1512 }
1513
1514 #[test]
1519 fn test_per_file_flavor_config_parsing() {
1520 let temp_dir = tempdir().unwrap();
1521 let config_path = temp_dir.path().join(".rumdl.toml");
1522 let config_content = r#"
1523[per-file-flavor]
1524"docs/**/*.md" = "mkdocs"
1525"**/*.mdx" = "mdx"
1526"**/*.qmd" = "quarto"
1527"#;
1528 fs::write(&config_path, config_content).unwrap();
1529
1530 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1531 let config: Config = sourced.into_validated_unchecked().into();
1532
1533 assert_eq!(config.per_file_flavor.len(), 3);
1535 assert_eq!(
1536 config.per_file_flavor.get("docs/**/*.md"),
1537 Some(&MarkdownFlavor::MkDocs)
1538 );
1539 assert_eq!(config.per_file_flavor.get("**/*.mdx"), Some(&MarkdownFlavor::MDX));
1540 assert_eq!(config.per_file_flavor.get("**/*.qmd"), Some(&MarkdownFlavor::Quarto));
1541 }
1542
1543 #[test]
1544 fn test_per_file_flavor_glob_matching() {
1545 use std::path::PathBuf;
1546
1547 let temp_dir = tempdir().unwrap();
1548 let config_path = temp_dir.path().join(".rumdl.toml");
1549 let config_content = r#"
1550[per-file-flavor]
1551"docs/**/*.md" = "mkdocs"
1552"**/*.mdx" = "mdx"
1553"components/**/*.md" = "mdx"
1554"#;
1555 fs::write(&config_path, config_content).unwrap();
1556
1557 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1558 let config: Config = sourced.into_validated_unchecked().into();
1559
1560 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/overview.md"));
1562 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1563
1564 let flavor = config.get_flavor_for_file(&PathBuf::from("src/components/Button.mdx"));
1566 assert_eq!(flavor, MarkdownFlavor::MDX);
1567
1568 let flavor = config.get_flavor_for_file(&PathBuf::from("components/Button/README.md"));
1570 assert_eq!(flavor, MarkdownFlavor::MDX);
1571
1572 let flavor = config.get_flavor_for_file(&PathBuf::from("README.md"));
1574 assert_eq!(flavor, MarkdownFlavor::Standard);
1575 }
1576
1577 #[test]
1578 fn test_per_file_flavor_pyproject_toml() {
1579 let temp_dir = tempdir().unwrap();
1580 let config_path = temp_dir.path().join("pyproject.toml");
1581 let config_content = r#"
1582[tool.rumdl]
1583[tool.rumdl.per-file-flavor]
1584"docs/**/*.md" = "mkdocs"
1585"**/*.mdx" = "mdx"
1586"#;
1587 fs::write(&config_path, config_content).unwrap();
1588
1589 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1590 let config: Config = sourced.into_validated_unchecked().into();
1591
1592 assert_eq!(config.per_file_flavor.len(), 2);
1594 assert_eq!(
1595 config.per_file_flavor.get("docs/**/*.md"),
1596 Some(&MarkdownFlavor::MkDocs)
1597 );
1598 assert_eq!(config.per_file_flavor.get("**/*.mdx"), Some(&MarkdownFlavor::MDX));
1599 }
1600
1601 #[test]
1602 fn test_per_file_flavor_first_match_wins() {
1603 use std::path::PathBuf;
1604
1605 let temp_dir = tempdir().unwrap();
1606 let config_path = temp_dir.path().join(".rumdl.toml");
1607 let config_content = r#"
1609[per-file-flavor]
1610"docs/internal/**/*.md" = "quarto"
1611"docs/**/*.md" = "mkdocs"
1612"**/*.md" = "standard"
1613"#;
1614 fs::write(&config_path, config_content).unwrap();
1615
1616 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1617 let config: Config = sourced.into_validated_unchecked().into();
1618
1619 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/internal/secret.md"));
1621 assert_eq!(flavor, MarkdownFlavor::Quarto);
1622
1623 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/public/readme.md"));
1625 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1626
1627 let flavor = config.get_flavor_for_file(&PathBuf::from("other/file.md"));
1629 assert_eq!(flavor, MarkdownFlavor::Standard);
1630 }
1631
1632 #[test]
1633 fn test_per_file_flavor_overrides_global_flavor() {
1634 use std::path::PathBuf;
1635
1636 let temp_dir = tempdir().unwrap();
1637 let config_path = temp_dir.path().join(".rumdl.toml");
1638 let config_content = r#"
1639[global]
1640flavor = "mkdocs"
1641
1642[per-file-flavor]
1643"**/*.mdx" = "mdx"
1644"#;
1645 fs::write(&config_path, config_content).unwrap();
1646
1647 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1648 let config: Config = sourced.into_validated_unchecked().into();
1649
1650 let flavor = config.get_flavor_for_file(&PathBuf::from("components/Button.mdx"));
1652 assert_eq!(flavor, MarkdownFlavor::MDX);
1653
1654 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/readme.md"));
1656 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1657 }
1658
1659 #[test]
1660 fn test_per_file_flavor_empty_map() {
1661 use std::path::PathBuf;
1662
1663 let temp_dir = tempdir().unwrap();
1664 let config_path = temp_dir.path().join(".rumdl.toml");
1665 let config_content = r#"
1666[global]
1667disable = ["MD001"]
1668
1669[per-file-flavor]
1670"#;
1671 fs::write(&config_path, config_content).unwrap();
1672
1673 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1674 let config: Config = sourced.into_validated_unchecked().into();
1675
1676 let flavor = config.get_flavor_for_file(&PathBuf::from("README.md"));
1678 assert_eq!(flavor, MarkdownFlavor::Standard);
1679
1680 let flavor = config.get_flavor_for_file(&PathBuf::from("test.mdx"));
1682 assert_eq!(flavor, MarkdownFlavor::MDX);
1683 }
1684
1685 #[test]
1686 fn test_per_file_flavor_with_underscores() {
1687 let temp_dir = tempdir().unwrap();
1688 let config_path = temp_dir.path().join("pyproject.toml");
1689 let config_content = r#"
1690[tool.rumdl]
1691[tool.rumdl.per_file_flavor]
1692"docs/**/*.md" = "mkdocs"
1693"#;
1694 fs::write(&config_path, config_content).unwrap();
1695
1696 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1697 let config: Config = sourced.into_validated_unchecked().into();
1698
1699 assert_eq!(config.per_file_flavor.len(), 1);
1701 assert_eq!(
1702 config.per_file_flavor.get("docs/**/*.md"),
1703 Some(&MarkdownFlavor::MkDocs)
1704 );
1705 }
1706
1707 #[test]
1708 fn test_per_file_flavor_absolute_path_matching() {
1709 use std::path::PathBuf;
1710
1711 let temp_dir = tempdir().unwrap();
1712 let config_path = temp_dir.path().join(".rumdl.toml");
1713
1714 let docs_dir = temp_dir.path().join("docs");
1716 fs::create_dir_all(&docs_dir).unwrap();
1717 let test_file = docs_dir.join("guide.md");
1718 fs::write(&test_file, "Test content").unwrap();
1719
1720 let config_content = r#"
1721[per-file-flavor]
1722"docs/**/*.md" = "mkdocs"
1723"#;
1724 fs::write(&config_path, config_content).unwrap();
1725
1726 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1727 let config: Config = sourced.into_validated_unchecked().into();
1728
1729 let absolute_path = test_file.canonicalize().unwrap();
1731 let flavor = config.get_flavor_for_file(&absolute_path);
1732 assert_eq!(
1733 flavor,
1734 MarkdownFlavor::MkDocs,
1735 "Should match absolute path {absolute_path:?} against relative pattern"
1736 );
1737
1738 let relative_path = PathBuf::from("docs/guide.md");
1740 let flavor = config.get_flavor_for_file(&relative_path);
1741 assert_eq!(flavor, MarkdownFlavor::MkDocs, "Should match relative path");
1742 }
1743
1744 #[test]
1745 fn test_per_file_flavor_all_flavors() {
1746 let temp_dir = tempdir().unwrap();
1747 let config_path = temp_dir.path().join(".rumdl.toml");
1748 let config_content = r#"
1749[per-file-flavor]
1750"standard/**/*.md" = "standard"
1751"mkdocs/**/*.md" = "mkdocs"
1752"mdx/**/*.md" = "mdx"
1753"quarto/**/*.md" = "quarto"
1754"#;
1755 fs::write(&config_path, config_content).unwrap();
1756
1757 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1758 let config: Config = sourced.into_validated_unchecked().into();
1759
1760 assert_eq!(config.per_file_flavor.len(), 4);
1762 assert_eq!(
1763 config.per_file_flavor.get("standard/**/*.md"),
1764 Some(&MarkdownFlavor::Standard)
1765 );
1766 assert_eq!(
1767 config.per_file_flavor.get("mkdocs/**/*.md"),
1768 Some(&MarkdownFlavor::MkDocs)
1769 );
1770 assert_eq!(config.per_file_flavor.get("mdx/**/*.md"), Some(&MarkdownFlavor::MDX));
1771 assert_eq!(
1772 config.per_file_flavor.get("quarto/**/*.md"),
1773 Some(&MarkdownFlavor::Quarto)
1774 );
1775 }
1776
1777 #[test]
1778 fn test_per_file_flavor_invalid_glob_pattern() {
1779 use std::path::PathBuf;
1780
1781 let temp_dir = tempdir().unwrap();
1782 let config_path = temp_dir.path().join(".rumdl.toml");
1783 let config_content = r#"
1785[per-file-flavor]
1786"[invalid" = "mkdocs"
1787"valid/**/*.md" = "mdx"
1788"#;
1789 fs::write(&config_path, config_content).unwrap();
1790
1791 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1792 let config: Config = sourced.into_validated_unchecked().into();
1793
1794 let flavor = config.get_flavor_for_file(&PathBuf::from("valid/test.md"));
1796 assert_eq!(flavor, MarkdownFlavor::MDX);
1797
1798 let flavor = config.get_flavor_for_file(&PathBuf::from("other/test.md"));
1800 assert_eq!(flavor, MarkdownFlavor::Standard);
1801 }
1802
1803 #[test]
1804 fn test_per_file_flavor_paths_with_spaces() {
1805 use std::path::PathBuf;
1806
1807 let temp_dir = tempdir().unwrap();
1808 let config_path = temp_dir.path().join(".rumdl.toml");
1809 let config_content = r#"
1810[per-file-flavor]
1811"my docs/**/*.md" = "mkdocs"
1812"src/**/*.md" = "mdx"
1813"#;
1814 fs::write(&config_path, config_content).unwrap();
1815
1816 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1817 let config: Config = sourced.into_validated_unchecked().into();
1818
1819 let flavor = config.get_flavor_for_file(&PathBuf::from("my docs/guide.md"));
1821 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1822
1823 let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
1825 assert_eq!(flavor, MarkdownFlavor::MDX);
1826 }
1827
1828 #[test]
1829 fn test_per_file_flavor_deeply_nested_paths() {
1830 use std::path::PathBuf;
1831
1832 let temp_dir = tempdir().unwrap();
1833 let config_path = temp_dir.path().join(".rumdl.toml");
1834 let config_content = r#"
1835[per-file-flavor]
1836"a/b/c/d/e/**/*.md" = "quarto"
1837"a/b/**/*.md" = "mkdocs"
1838"**/*.md" = "standard"
1839"#;
1840 fs::write(&config_path, config_content).unwrap();
1841
1842 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1843 let config: Config = sourced.into_validated_unchecked().into();
1844
1845 let flavor = config.get_flavor_for_file(&PathBuf::from("a/b/c/d/e/f/deep.md"));
1847 assert_eq!(flavor, MarkdownFlavor::Quarto);
1848
1849 let flavor = config.get_flavor_for_file(&PathBuf::from("a/b/c/test.md"));
1851 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1852
1853 let flavor = config.get_flavor_for_file(&PathBuf::from("root.md"));
1855 assert_eq!(flavor, MarkdownFlavor::Standard);
1856 }
1857
1858 #[test]
1859 fn test_per_file_flavor_complex_overlapping_patterns() {
1860 use std::path::PathBuf;
1861
1862 let temp_dir = tempdir().unwrap();
1863 let config_path = temp_dir.path().join(".rumdl.toml");
1864 let config_content = r#"
1866[per-file-flavor]
1867"docs/api/*.md" = "mkdocs"
1868"docs/**/*.mdx" = "mdx"
1869"docs/**/*.md" = "quarto"
1870"**/*.md" = "standard"
1871"#;
1872 fs::write(&config_path, config_content).unwrap();
1873
1874 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1875 let config: Config = sourced.into_validated_unchecked().into();
1876
1877 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/reference.md"));
1879 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1880
1881 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/nested/file.md"));
1883 assert_eq!(flavor, MarkdownFlavor::Quarto);
1884
1885 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/components/Button.mdx"));
1887 assert_eq!(flavor, MarkdownFlavor::MDX);
1888
1889 let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
1891 assert_eq!(flavor, MarkdownFlavor::Standard);
1892 }
1893
1894 #[test]
1895 fn test_per_file_flavor_extension_detection_interaction() {
1896 use std::path::PathBuf;
1897
1898 let temp_dir = tempdir().unwrap();
1899 let config_path = temp_dir.path().join(".rumdl.toml");
1900 let config_content = r#"
1902[per-file-flavor]
1903"legacy/**/*.mdx" = "standard"
1904"#;
1905 fs::write(&config_path, config_content).unwrap();
1906
1907 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1908 let config: Config = sourced.into_validated_unchecked().into();
1909
1910 let flavor = config.get_flavor_for_file(&PathBuf::from("legacy/old.mdx"));
1912 assert_eq!(flavor, MarkdownFlavor::Standard);
1913
1914 let flavor = config.get_flavor_for_file(&PathBuf::from("src/component.mdx"));
1916 assert_eq!(flavor, MarkdownFlavor::MDX);
1917 }
1918
1919 #[test]
1920 fn test_per_file_flavor_standard_alias_none() {
1921 use std::path::PathBuf;
1922
1923 let temp_dir = tempdir().unwrap();
1924 let config_path = temp_dir.path().join(".rumdl.toml");
1925 let config_content = r#"
1927[per-file-flavor]
1928"plain/**/*.md" = "none"
1929"#;
1930 fs::write(&config_path, config_content).unwrap();
1931
1932 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1933 let config: Config = sourced.into_validated_unchecked().into();
1934
1935 let flavor = config.get_flavor_for_file(&PathBuf::from("plain/test.md"));
1937 assert_eq!(flavor, MarkdownFlavor::Standard);
1938 }
1939
1940 #[test]
1941 fn test_per_file_flavor_brace_expansion() {
1942 use std::path::PathBuf;
1943
1944 let temp_dir = tempdir().unwrap();
1945 let config_path = temp_dir.path().join(".rumdl.toml");
1946 let config_content = r#"
1948[per-file-flavor]
1949"docs/**/*.{md,mdx}" = "mkdocs"
1950"#;
1951 fs::write(&config_path, config_content).unwrap();
1952
1953 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1954 let config: Config = sourced.into_validated_unchecked().into();
1955
1956 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/guide.md"));
1958 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1959
1960 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/component.mdx"));
1962 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1963 }
1964
1965 #[test]
1966 fn test_per_file_flavor_single_star_vs_double_star() {
1967 use std::path::PathBuf;
1968
1969 let temp_dir = tempdir().unwrap();
1970 let config_path = temp_dir.path().join(".rumdl.toml");
1971 let config_content = r#"
1973[per-file-flavor]
1974"docs/*.md" = "mkdocs"
1975"src/**/*.md" = "mdx"
1976"#;
1977 fs::write(&config_path, config_content).unwrap();
1978
1979 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1980 let config: Config = sourced.into_validated_unchecked().into();
1981
1982 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/README.md"));
1984 assert_eq!(flavor, MarkdownFlavor::MkDocs);
1985
1986 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/index.md"));
1988 assert_eq!(flavor, MarkdownFlavor::Standard); let flavor = config.get_flavor_for_file(&PathBuf::from("src/components/Button.md"));
1992 assert_eq!(flavor, MarkdownFlavor::MDX);
1993
1994 let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
1995 assert_eq!(flavor, MarkdownFlavor::MDX);
1996 }
1997
1998 #[test]
1999 fn test_per_file_flavor_question_mark_wildcard() {
2000 use std::path::PathBuf;
2001
2002 let temp_dir = tempdir().unwrap();
2003 let config_path = temp_dir.path().join(".rumdl.toml");
2004 let config_content = r#"
2006[per-file-flavor]
2007"docs/v?.md" = "mkdocs"
2008"#;
2009 fs::write(&config_path, config_content).unwrap();
2010
2011 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2012 let config: Config = sourced.into_validated_unchecked().into();
2013
2014 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v1.md"));
2016 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2017
2018 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v2.md"));
2019 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2020
2021 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v10.md"));
2023 assert_eq!(flavor, MarkdownFlavor::Standard);
2024
2025 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v.md"));
2027 assert_eq!(flavor, MarkdownFlavor::Standard);
2028 }
2029
2030 #[test]
2031 fn test_per_file_flavor_character_class() {
2032 use std::path::PathBuf;
2033
2034 let temp_dir = tempdir().unwrap();
2035 let config_path = temp_dir.path().join(".rumdl.toml");
2036 let config_content = r#"
2038[per-file-flavor]
2039"docs/[abc].md" = "mkdocs"
2040"#;
2041 fs::write(&config_path, config_content).unwrap();
2042
2043 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2044 let config: Config = sourced.into_validated_unchecked().into();
2045
2046 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/a.md"));
2048 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2049
2050 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/b.md"));
2051 assert_eq!(flavor, MarkdownFlavor::MkDocs);
2052
2053 let flavor = config.get_flavor_for_file(&PathBuf::from("docs/d.md"));
2055 assert_eq!(flavor, MarkdownFlavor::Standard);
2056 }
2057
2058 #[test]
2059 fn test_generate_json_schema() {
2060 use schemars::schema_for;
2061 use std::env;
2062
2063 let schema = schema_for!(Config);
2064 let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
2065
2066 if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
2068 let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
2069 fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
2070 println!("Schema written to: {}", schema_path.display());
2071 }
2072
2073 assert!(schema_json.contains("\"title\": \"Config\""));
2075 assert!(schema_json.contains("\"global\""));
2076 assert!(schema_json.contains("\"per-file-ignores\""));
2077 }
2078
2079 #[test]
2080 fn test_project_config_is_standalone() {
2081 let temp_dir = tempdir().unwrap();
2084
2085 let user_config_dir = temp_dir.path().join("user_config");
2088 let rumdl_config_dir = user_config_dir.join("rumdl");
2089 fs::create_dir_all(&rumdl_config_dir).unwrap();
2090 let user_config_path = rumdl_config_dir.join("rumdl.toml");
2091
2092 let user_config_content = r#"
2094[global]
2095disable = ["MD013", "MD041"]
2096line-length = 100
2097"#;
2098 fs::write(&user_config_path, user_config_content).unwrap();
2099
2100 let project_config_path = temp_dir.path().join("project").join("pyproject.toml");
2102 fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
2103 let project_config_content = r#"
2104[tool.rumdl]
2105enable = ["MD001"]
2106"#;
2107 fs::write(&project_config_path, project_config_content).unwrap();
2108
2109 let sourced = SourcedConfig::load_with_discovery_impl(
2111 Some(project_config_path.to_str().unwrap()),
2112 None,
2113 false,
2114 Some(&user_config_dir),
2115 )
2116 .unwrap();
2117
2118 let config: Config = sourced.into_validated_unchecked().into();
2119
2120 assert!(
2122 !config.global.disable.contains(&"MD013".to_string()),
2123 "User config should NOT be merged with project config"
2124 );
2125 assert!(
2126 !config.global.disable.contains(&"MD041".to_string()),
2127 "User config should NOT be merged with project config"
2128 );
2129
2130 assert!(
2132 config.global.enable.contains(&"MD001".to_string()),
2133 "Project config enabled rules should be applied"
2134 );
2135 }
2136
2137 #[test]
2138 fn test_user_config_as_fallback_when_no_project_config() {
2139 use std::env;
2141
2142 let temp_dir = tempdir().unwrap();
2143 let original_dir = env::current_dir().unwrap();
2144
2145 let user_config_dir = temp_dir.path().join("user_config");
2147 let rumdl_config_dir = user_config_dir.join("rumdl");
2148 fs::create_dir_all(&rumdl_config_dir).unwrap();
2149 let user_config_path = rumdl_config_dir.join("rumdl.toml");
2150
2151 let user_config_content = r#"
2153[global]
2154disable = ["MD013", "MD041"]
2155line-length = 88
2156"#;
2157 fs::write(&user_config_path, user_config_content).unwrap();
2158
2159 let project_dir = temp_dir.path().join("project_no_config");
2161 fs::create_dir_all(&project_dir).unwrap();
2162
2163 env::set_current_dir(&project_dir).unwrap();
2165
2166 let sourced = SourcedConfig::load_with_discovery_impl(None, None, false, Some(&user_config_dir)).unwrap();
2168
2169 let config: Config = sourced.into_validated_unchecked().into();
2170
2171 assert!(
2173 config.global.disable.contains(&"MD013".to_string()),
2174 "User config should be loaded as fallback when no project config"
2175 );
2176 assert!(
2177 config.global.disable.contains(&"MD041".to_string()),
2178 "User config should be loaded as fallback when no project config"
2179 );
2180 assert_eq!(
2181 config.global.line_length.get(),
2182 88,
2183 "User config line-length should be loaded as fallback"
2184 );
2185
2186 env::set_current_dir(original_dir).unwrap();
2187 }
2188
2189 #[test]
2190 fn test_typestate_validate_method() {
2191 use tempfile::tempdir;
2192
2193 let temp_dir = tempdir().expect("Failed to create temporary directory");
2194 let config_path = temp_dir.path().join("test.toml");
2195
2196 let config_content = r#"
2198[global]
2199enable = ["MD001"]
2200
2201[MD013]
2202line_length = 80
2203unknown_option = true
2204"#;
2205 std::fs::write(&config_path, config_content).expect("Failed to write config");
2206
2207 let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
2209 .expect("Should load config");
2210
2211 let default_config = Config::default();
2213 let all_rules = crate::rules::all_rules(&default_config);
2214 let registry = RuleRegistry::from_rules(&all_rules);
2215
2216 let validated = loaded.validate(®istry).expect("Should validate config");
2218
2219 let has_unknown_option_warning = validated
2222 .validation_warnings
2223 .iter()
2224 .any(|w| w.message.contains("unknown_option") || w.message.contains("Unknown option"));
2225
2226 if !has_unknown_option_warning {
2228 for w in &validated.validation_warnings {
2229 eprintln!("Warning: {}", w.message);
2230 }
2231 }
2232 assert!(
2233 has_unknown_option_warning,
2234 "Should have warning for unknown option. Got {} warnings: {:?}",
2235 validated.validation_warnings.len(),
2236 validated
2237 .validation_warnings
2238 .iter()
2239 .map(|w| &w.message)
2240 .collect::<Vec<_>>()
2241 );
2242
2243 let config: Config = validated.into();
2245
2246 assert!(config.global.enable.contains(&"MD001".to_string()));
2248 }
2249
2250 #[test]
2251 fn test_typestate_validate_into_convenience_method() {
2252 use tempfile::tempdir;
2253
2254 let temp_dir = tempdir().expect("Failed to create temporary directory");
2255 let config_path = temp_dir.path().join("test.toml");
2256
2257 let config_content = r#"
2258[global]
2259enable = ["MD022"]
2260
2261[MD022]
2262lines_above = 2
2263"#;
2264 std::fs::write(&config_path, config_content).expect("Failed to write config");
2265
2266 let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
2267 .expect("Should load config");
2268
2269 let default_config = Config::default();
2270 let all_rules = crate::rules::all_rules(&default_config);
2271 let registry = RuleRegistry::from_rules(&all_rules);
2272
2273 let (config, warnings) = loaded.validate_into(®istry).expect("Should validate and convert");
2275
2276 assert!(warnings.is_empty(), "Should have no warnings for valid config");
2278
2279 assert!(config.global.enable.contains(&"MD022".to_string()));
2281 }
2282
2283 #[test]
2284 fn test_resolve_rule_name_canonical() {
2285 assert_eq!(resolve_rule_name("MD001"), "MD001");
2287 assert_eq!(resolve_rule_name("MD013"), "MD013");
2288 assert_eq!(resolve_rule_name("MD069"), "MD069");
2289 }
2290
2291 #[test]
2292 fn test_resolve_rule_name_aliases() {
2293 assert_eq!(resolve_rule_name("heading-increment"), "MD001");
2295 assert_eq!(resolve_rule_name("line-length"), "MD013");
2296 assert_eq!(resolve_rule_name("no-bare-urls"), "MD034");
2297 assert_eq!(resolve_rule_name("ul-style"), "MD004");
2298 }
2299
2300 #[test]
2301 fn test_resolve_rule_name_case_insensitive() {
2302 assert_eq!(resolve_rule_name("HEADING-INCREMENT"), "MD001");
2304 assert_eq!(resolve_rule_name("Heading-Increment"), "MD001");
2305 assert_eq!(resolve_rule_name("md001"), "MD001");
2306 assert_eq!(resolve_rule_name("MD001"), "MD001");
2307 }
2308
2309 #[test]
2310 fn test_resolve_rule_name_underscore_to_hyphen() {
2311 assert_eq!(resolve_rule_name("heading_increment"), "MD001");
2313 assert_eq!(resolve_rule_name("line_length"), "MD013");
2314 assert_eq!(resolve_rule_name("no_bare_urls"), "MD034");
2315 }
2316
2317 #[test]
2318 fn test_resolve_rule_name_unknown() {
2319 assert_eq!(resolve_rule_name("custom-rule"), "custom-rule");
2321 assert_eq!(resolve_rule_name("CUSTOM_RULE"), "custom-rule");
2322 assert_eq!(resolve_rule_name("md999"), "MD999"); }
2324
2325 #[test]
2326 fn test_resolve_rule_names_basic() {
2327 let result = resolve_rule_names("MD001,line-length,heading-increment");
2328 assert!(result.contains("MD001"));
2329 assert!(result.contains("MD013")); assert_eq!(result.len(), 2);
2332 }
2333
2334 #[test]
2335 fn test_resolve_rule_names_with_whitespace() {
2336 let result = resolve_rule_names(" MD001 , line-length , MD034 ");
2337 assert!(result.contains("MD001"));
2338 assert!(result.contains("MD013"));
2339 assert!(result.contains("MD034"));
2340 assert_eq!(result.len(), 3);
2341 }
2342
2343 #[test]
2344 fn test_resolve_rule_names_empty_entries() {
2345 let result = resolve_rule_names("MD001,,MD013,");
2346 assert!(result.contains("MD001"));
2347 assert!(result.contains("MD013"));
2348 assert_eq!(result.len(), 2);
2349 }
2350
2351 #[test]
2352 fn test_resolve_rule_names_empty_string() {
2353 let result = resolve_rule_names("");
2354 assert!(result.is_empty());
2355 }
2356
2357 #[test]
2358 fn test_resolve_rule_names_mixed() {
2359 let result = resolve_rule_names("MD001,line-length,custom-rule");
2361 assert!(result.contains("MD001"));
2362 assert!(result.contains("MD013"));
2363 assert!(result.contains("custom-rule"));
2364 assert_eq!(result.len(), 3);
2365 }
2366
2367 #[test]
2372 fn test_is_valid_rule_name_canonical() {
2373 assert!(is_valid_rule_name("MD001"));
2375 assert!(is_valid_rule_name("MD013"));
2376 assert!(is_valid_rule_name("MD041"));
2377 assert!(is_valid_rule_name("MD069"));
2378
2379 assert!(is_valid_rule_name("md001"));
2381 assert!(is_valid_rule_name("Md001"));
2382 assert!(is_valid_rule_name("mD001"));
2383 }
2384
2385 #[test]
2386 fn test_is_valid_rule_name_aliases() {
2387 assert!(is_valid_rule_name("line-length"));
2389 assert!(is_valid_rule_name("heading-increment"));
2390 assert!(is_valid_rule_name("no-bare-urls"));
2391 assert!(is_valid_rule_name("ul-style"));
2392
2393 assert!(is_valid_rule_name("LINE-LENGTH"));
2395 assert!(is_valid_rule_name("Line-Length"));
2396
2397 assert!(is_valid_rule_name("line_length"));
2399 assert!(is_valid_rule_name("ul_style"));
2400 }
2401
2402 #[test]
2403 fn test_is_valid_rule_name_special_all() {
2404 assert!(is_valid_rule_name("all"));
2405 assert!(is_valid_rule_name("ALL"));
2406 assert!(is_valid_rule_name("All"));
2407 assert!(is_valid_rule_name("aLl"));
2408 }
2409
2410 #[test]
2411 fn test_is_valid_rule_name_invalid() {
2412 assert!(!is_valid_rule_name("MD000"));
2414 assert!(!is_valid_rule_name("MD002")); assert!(!is_valid_rule_name("MD006")); assert!(!is_valid_rule_name("MD999"));
2417 assert!(!is_valid_rule_name("MD100"));
2418
2419 assert!(!is_valid_rule_name(""));
2421 assert!(!is_valid_rule_name("INVALID"));
2422 assert!(!is_valid_rule_name("not-a-rule"));
2423 assert!(!is_valid_rule_name("random-text"));
2424 assert!(!is_valid_rule_name("abc"));
2425
2426 assert!(!is_valid_rule_name("MD"));
2428 assert!(!is_valid_rule_name("MD1"));
2429 assert!(!is_valid_rule_name("MD12"));
2430 }
2431
2432 #[test]
2433 fn test_validate_cli_rule_names_valid() {
2434 let warnings = validate_cli_rule_names(
2436 Some("MD001,MD013"),
2437 Some("line-length"),
2438 Some("heading-increment"),
2439 Some("all"),
2440 );
2441 assert!(warnings.is_empty(), "Expected no warnings for valid rules");
2442 }
2443
2444 #[test]
2445 fn test_validate_cli_rule_names_invalid() {
2446 let warnings = validate_cli_rule_names(Some("abc"), None, None, None);
2448 assert_eq!(warnings.len(), 1);
2449 assert!(warnings[0].message.contains("Unknown rule in --enable: abc"));
2450
2451 let warnings = validate_cli_rule_names(None, Some("xyz"), None, None);
2453 assert_eq!(warnings.len(), 1);
2454 assert!(warnings[0].message.contains("Unknown rule in --disable: xyz"));
2455
2456 let warnings = validate_cli_rule_names(None, None, Some("nonexistent"), None);
2458 assert_eq!(warnings.len(), 1);
2459 assert!(
2460 warnings[0]
2461 .message
2462 .contains("Unknown rule in --extend-enable: nonexistent")
2463 );
2464
2465 let warnings = validate_cli_rule_names(None, None, None, Some("fake-rule"));
2467 assert_eq!(warnings.len(), 1);
2468 assert!(
2469 warnings[0]
2470 .message
2471 .contains("Unknown rule in --extend-disable: fake-rule")
2472 );
2473 }
2474
2475 #[test]
2476 fn test_validate_cli_rule_names_mixed() {
2477 let warnings = validate_cli_rule_names(Some("MD001,abc,MD003"), None, None, None);
2479 assert_eq!(warnings.len(), 1);
2480 assert!(warnings[0].message.contains("abc"));
2481 }
2482
2483 #[test]
2484 fn test_validate_cli_rule_names_suggestions() {
2485 let warnings = validate_cli_rule_names(Some("line-lenght"), None, None, None);
2487 assert_eq!(warnings.len(), 1);
2488 assert!(warnings[0].message.contains("did you mean"));
2489 assert!(warnings[0].message.contains("line-length"));
2490 }
2491
2492 #[test]
2493 fn test_validate_cli_rule_names_none() {
2494 let warnings = validate_cli_rule_names(None, None, None, None);
2496 assert!(warnings.is_empty());
2497 }
2498
2499 #[test]
2500 fn test_validate_cli_rule_names_empty_string() {
2501 let warnings = validate_cli_rule_names(Some(""), Some(""), Some(""), Some(""));
2503 assert!(warnings.is_empty());
2504 }
2505
2506 #[test]
2507 fn test_validate_cli_rule_names_whitespace() {
2508 let warnings = validate_cli_rule_names(Some(" MD001 , MD013 "), None, None, None);
2510 assert!(warnings.is_empty(), "Whitespace should be trimmed");
2511 }
2512
2513 #[test]
2514 fn test_all_implemented_rules_have_aliases() {
2515 let config = crate::config::Config::default();
2522 let all_rules = crate::rules::all_rules(&config);
2523
2524 let mut missing_rules = Vec::new();
2525 for rule in &all_rules {
2526 let rule_name = rule.name();
2527 if resolve_rule_name_alias(rule_name).is_none() {
2529 missing_rules.push(rule_name.to_string());
2530 }
2531 }
2532
2533 assert!(
2534 missing_rules.is_empty(),
2535 "The following rules are missing from RULE_ALIAS_MAP: {:?}\n\
2536 Add entries like:\n\
2537 - Canonical: \"{}\" => \"{}\"\n\
2538 - Alias: \"RULE-NAME-HERE\" => \"{}\"",
2539 missing_rules,
2540 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2541 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2542 missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2543 );
2544 }
2545}
2546
2547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2556pub enum ConfigSource {
2557 Default,
2559 UserConfig,
2561 PyprojectToml,
2563 ProjectConfig,
2565 Cli,
2567}
2568
2569#[derive(Debug, Clone)]
2570pub struct ConfigOverride<T> {
2571 pub value: T,
2572 pub source: ConfigSource,
2573 pub file: Option<String>,
2574 pub line: Option<usize>,
2575}
2576
2577#[derive(Debug, Clone)]
2578pub struct SourcedValue<T> {
2579 pub value: T,
2580 pub source: ConfigSource,
2581 pub overrides: Vec<ConfigOverride<T>>,
2582}
2583
2584impl<T: Clone> SourcedValue<T> {
2585 pub fn new(value: T, source: ConfigSource) -> Self {
2586 Self {
2587 value: value.clone(),
2588 source,
2589 overrides: vec![ConfigOverride {
2590 value,
2591 source,
2592 file: None,
2593 line: None,
2594 }],
2595 }
2596 }
2597
2598 pub fn merge_override(
2602 &mut self,
2603 new_value: T,
2604 new_source: ConfigSource,
2605 new_file: Option<String>,
2606 new_line: Option<usize>,
2607 ) {
2608 fn source_precedence(src: ConfigSource) -> u8 {
2610 match src {
2611 ConfigSource::Default => 0,
2612 ConfigSource::UserConfig => 1,
2613 ConfigSource::PyprojectToml => 2,
2614 ConfigSource::ProjectConfig => 3,
2615 ConfigSource::Cli => 4,
2616 }
2617 }
2618
2619 if source_precedence(new_source) >= source_precedence(self.source) {
2620 self.value = new_value.clone();
2621 self.source = new_source;
2622 self.overrides.push(ConfigOverride {
2623 value: new_value,
2624 source: new_source,
2625 file: new_file,
2626 line: new_line,
2627 });
2628 }
2629 }
2630
2631 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
2632 self.value = value.clone();
2635 self.source = source;
2636 self.overrides.push(ConfigOverride {
2637 value,
2638 source,
2639 file,
2640 line,
2641 });
2642 }
2643}
2644
2645impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
2646 pub fn merge_union(
2649 &mut self,
2650 new_value: Vec<T>,
2651 new_source: ConfigSource,
2652 new_file: Option<String>,
2653 new_line: Option<usize>,
2654 ) {
2655 fn source_precedence(src: ConfigSource) -> u8 {
2656 match src {
2657 ConfigSource::Default => 0,
2658 ConfigSource::UserConfig => 1,
2659 ConfigSource::PyprojectToml => 2,
2660 ConfigSource::ProjectConfig => 3,
2661 ConfigSource::Cli => 4,
2662 }
2663 }
2664
2665 if source_precedence(new_source) >= source_precedence(self.source) {
2666 let mut combined = self.value.clone();
2668 for item in new_value.iter() {
2669 if !combined.contains(item) {
2670 combined.push(item.clone());
2671 }
2672 }
2673
2674 self.value = combined;
2675 self.source = new_source;
2676 self.overrides.push(ConfigOverride {
2677 value: new_value,
2678 source: new_source,
2679 file: new_file,
2680 line: new_line,
2681 });
2682 }
2683 }
2684}
2685
2686#[derive(Debug, Clone)]
2687pub struct SourcedGlobalConfig {
2688 pub enable: SourcedValue<Vec<String>>,
2689 pub disable: SourcedValue<Vec<String>>,
2690 pub exclude: SourcedValue<Vec<String>>,
2691 pub include: SourcedValue<Vec<String>>,
2692 pub respect_gitignore: SourcedValue<bool>,
2693 pub line_length: SourcedValue<LineLength>,
2694 pub output_format: Option<SourcedValue<String>>,
2695 pub fixable: SourcedValue<Vec<String>>,
2696 pub unfixable: SourcedValue<Vec<String>>,
2697 pub flavor: SourcedValue<MarkdownFlavor>,
2698 pub force_exclude: SourcedValue<bool>,
2699 pub cache_dir: Option<SourcedValue<String>>,
2700 pub cache: SourcedValue<bool>,
2701}
2702
2703impl Default for SourcedGlobalConfig {
2704 fn default() -> Self {
2705 SourcedGlobalConfig {
2706 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2707 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2708 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
2709 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
2710 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
2711 line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
2712 output_format: None,
2713 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2714 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2715 flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
2716 force_exclude: SourcedValue::new(false, ConfigSource::Default),
2717 cache_dir: None,
2718 cache: SourcedValue::new(true, ConfigSource::Default),
2719 }
2720 }
2721}
2722
2723#[derive(Debug, Default, Clone)]
2724pub struct SourcedRuleConfig {
2725 pub severity: Option<SourcedValue<crate::rule::Severity>>,
2726 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
2727}
2728
2729#[derive(Debug, Clone)]
2732pub struct SourcedConfigFragment {
2733 pub global: SourcedGlobalConfig,
2734 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2735 pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
2736 pub rules: BTreeMap<String, SourcedRuleConfig>,
2737 pub unknown_keys: Vec<(String, String, Option<String>)>, }
2740
2741impl Default for SourcedConfigFragment {
2742 fn default() -> Self {
2743 Self {
2744 global: SourcedGlobalConfig::default(),
2745 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2746 per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
2747 rules: BTreeMap::new(),
2748 unknown_keys: Vec::new(),
2749 }
2750 }
2751}
2752
2753#[derive(Debug, Clone)]
2771pub struct SourcedConfig<State = ConfigLoaded> {
2772 pub global: SourcedGlobalConfig,
2773 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2774 pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
2775 pub rules: BTreeMap<String, SourcedRuleConfig>,
2776 pub loaded_files: Vec<String>,
2777 pub unknown_keys: Vec<(String, String, Option<String>)>, pub project_root: Option<std::path::PathBuf>,
2780 pub validation_warnings: Vec<ConfigValidationWarning>,
2782 _state: PhantomData<State>,
2784}
2785
2786impl Default for SourcedConfig<ConfigLoaded> {
2787 fn default() -> Self {
2788 Self {
2789 global: SourcedGlobalConfig::default(),
2790 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2791 per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
2792 rules: BTreeMap::new(),
2793 loaded_files: Vec::new(),
2794 unknown_keys: Vec::new(),
2795 project_root: None,
2796 validation_warnings: Vec::new(),
2797 _state: PhantomData,
2798 }
2799 }
2800}
2801
2802impl SourcedConfig<ConfigLoaded> {
2803 fn merge(&mut self, fragment: SourcedConfigFragment) {
2806 self.global.enable.merge_override(
2809 fragment.global.enable.value,
2810 fragment.global.enable.source,
2811 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
2812 fragment.global.enable.overrides.first().and_then(|o| o.line),
2813 );
2814
2815 self.global.disable.merge_union(
2817 fragment.global.disable.value,
2818 fragment.global.disable.source,
2819 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
2820 fragment.global.disable.overrides.first().and_then(|o| o.line),
2821 );
2822
2823 self.global
2826 .disable
2827 .value
2828 .retain(|rule| !self.global.enable.value.contains(rule));
2829 self.global.include.merge_override(
2830 fragment.global.include.value,
2831 fragment.global.include.source,
2832 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
2833 fragment.global.include.overrides.first().and_then(|o| o.line),
2834 );
2835 self.global.exclude.merge_override(
2836 fragment.global.exclude.value,
2837 fragment.global.exclude.source,
2838 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
2839 fragment.global.exclude.overrides.first().and_then(|o| o.line),
2840 );
2841 self.global.respect_gitignore.merge_override(
2842 fragment.global.respect_gitignore.value,
2843 fragment.global.respect_gitignore.source,
2844 fragment
2845 .global
2846 .respect_gitignore
2847 .overrides
2848 .first()
2849 .and_then(|o| o.file.clone()),
2850 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
2851 );
2852 self.global.line_length.merge_override(
2853 fragment.global.line_length.value,
2854 fragment.global.line_length.source,
2855 fragment
2856 .global
2857 .line_length
2858 .overrides
2859 .first()
2860 .and_then(|o| o.file.clone()),
2861 fragment.global.line_length.overrides.first().and_then(|o| o.line),
2862 );
2863 self.global.fixable.merge_override(
2864 fragment.global.fixable.value,
2865 fragment.global.fixable.source,
2866 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
2867 fragment.global.fixable.overrides.first().and_then(|o| o.line),
2868 );
2869 self.global.unfixable.merge_override(
2870 fragment.global.unfixable.value,
2871 fragment.global.unfixable.source,
2872 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
2873 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
2874 );
2875
2876 self.global.flavor.merge_override(
2878 fragment.global.flavor.value,
2879 fragment.global.flavor.source,
2880 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
2881 fragment.global.flavor.overrides.first().and_then(|o| o.line),
2882 );
2883
2884 self.global.force_exclude.merge_override(
2886 fragment.global.force_exclude.value,
2887 fragment.global.force_exclude.source,
2888 fragment
2889 .global
2890 .force_exclude
2891 .overrides
2892 .first()
2893 .and_then(|o| o.file.clone()),
2894 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
2895 );
2896
2897 if let Some(output_format_fragment) = fragment.global.output_format {
2899 if let Some(ref mut output_format) = self.global.output_format {
2900 output_format.merge_override(
2901 output_format_fragment.value,
2902 output_format_fragment.source,
2903 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
2904 output_format_fragment.overrides.first().and_then(|o| o.line),
2905 );
2906 } else {
2907 self.global.output_format = Some(output_format_fragment);
2908 }
2909 }
2910
2911 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
2913 if let Some(ref mut cache_dir) = self.global.cache_dir {
2914 cache_dir.merge_override(
2915 cache_dir_fragment.value,
2916 cache_dir_fragment.source,
2917 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
2918 cache_dir_fragment.overrides.first().and_then(|o| o.line),
2919 );
2920 } else {
2921 self.global.cache_dir = Some(cache_dir_fragment);
2922 }
2923 }
2924
2925 if fragment.global.cache.source != ConfigSource::Default {
2927 self.global.cache.merge_override(
2928 fragment.global.cache.value,
2929 fragment.global.cache.source,
2930 fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
2931 fragment.global.cache.overrides.first().and_then(|o| o.line),
2932 );
2933 }
2934
2935 self.per_file_ignores.merge_override(
2937 fragment.per_file_ignores.value,
2938 fragment.per_file_ignores.source,
2939 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
2940 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
2941 );
2942
2943 self.per_file_flavor.merge_override(
2945 fragment.per_file_flavor.value,
2946 fragment.per_file_flavor.source,
2947 fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
2948 fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
2949 );
2950
2951 for (rule_name, rule_fragment) in fragment.rules {
2953 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
2955
2956 if let Some(severity_fragment) = rule_fragment.severity {
2958 if let Some(ref mut existing_severity) = rule_entry.severity {
2959 existing_severity.merge_override(
2960 severity_fragment.value,
2961 severity_fragment.source,
2962 severity_fragment.overrides.first().and_then(|o| o.file.clone()),
2963 severity_fragment.overrides.first().and_then(|o| o.line),
2964 );
2965 } else {
2966 rule_entry.severity = Some(severity_fragment);
2967 }
2968 }
2969
2970 for (key, sourced_value_fragment) in rule_fragment.values {
2972 let sv_entry = rule_entry
2973 .values
2974 .entry(key.clone())
2975 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
2976 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
2977 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
2978 sv_entry.merge_override(
2979 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
2984 }
2985 }
2986
2987 for (section, key, file_path) in fragment.unknown_keys {
2989 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
2991 self.unknown_keys.push((section, key, file_path));
2992 }
2993 }
2994 }
2995
2996 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
2998 Self::load_with_discovery(config_path, cli_overrides, false)
2999 }
3000
3001 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
3004 let mut current = if start_dir.is_relative() {
3006 std::env::current_dir()
3007 .map(|cwd| cwd.join(start_dir))
3008 .unwrap_or_else(|_| start_dir.to_path_buf())
3009 } else {
3010 start_dir.to_path_buf()
3011 };
3012 const MAX_DEPTH: usize = 100;
3013
3014 for _ in 0..MAX_DEPTH {
3015 if current.join(".git").exists() {
3016 log::debug!("[rumdl-config] Found .git at: {}", current.display());
3017 return current;
3018 }
3019
3020 match current.parent() {
3021 Some(parent) => current = parent.to_path_buf(),
3022 None => break,
3023 }
3024 }
3025
3026 log::debug!(
3028 "[rumdl-config] No .git found, using config location as project root: {}",
3029 start_dir.display()
3030 );
3031 start_dir.to_path_buf()
3032 }
3033
3034 fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
3040 use std::env;
3041
3042 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
3043 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
3046 Ok(dir) => dir,
3047 Err(e) => {
3048 log::debug!("[rumdl-config] Failed to get current directory: {e}");
3049 return None;
3050 }
3051 };
3052
3053 let mut current_dir = start_dir.clone();
3054 let mut depth = 0;
3055 let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3056
3057 loop {
3058 if depth >= MAX_DEPTH {
3059 log::debug!("[rumdl-config] Maximum traversal depth reached");
3060 break;
3061 }
3062
3063 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
3064
3065 if found_config.is_none() {
3067 for config_name in CONFIG_FILES {
3068 let config_path = current_dir.join(config_name);
3069
3070 if config_path.exists() {
3071 if *config_name == "pyproject.toml" {
3073 if let Ok(content) = std::fs::read_to_string(&config_path) {
3074 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
3075 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
3076 found_config = Some((config_path.clone(), current_dir.clone()));
3078 break;
3079 }
3080 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
3081 continue;
3082 }
3083 } else {
3084 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
3085 found_config = Some((config_path.clone(), current_dir.clone()));
3087 break;
3088 }
3089 }
3090 }
3091 }
3092
3093 if current_dir.join(".git").exists() {
3095 log::debug!("[rumdl-config] Stopping at .git directory");
3096 break;
3097 }
3098
3099 match current_dir.parent() {
3101 Some(parent) => {
3102 current_dir = parent.to_owned();
3103 depth += 1;
3104 }
3105 None => {
3106 log::debug!("[rumdl-config] Reached filesystem root");
3107 break;
3108 }
3109 }
3110 }
3111
3112 if let Some((config_path, config_dir)) = found_config {
3114 let project_root = Self::find_project_root_from(&config_dir);
3115 return Some((config_path, project_root));
3116 }
3117
3118 None
3119 }
3120
3121 fn discover_markdownlint_config_upward() -> Option<std::path::PathBuf> {
3125 use std::env;
3126
3127 const MAX_DEPTH: usize = 100;
3128
3129 let start_dir = match env::current_dir() {
3130 Ok(dir) => dir,
3131 Err(e) => {
3132 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
3133 return None;
3134 }
3135 };
3136
3137 let mut current_dir = start_dir.clone();
3138 let mut depth = 0;
3139
3140 loop {
3141 if depth >= MAX_DEPTH {
3142 log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
3143 break;
3144 }
3145
3146 log::debug!(
3147 "[rumdl-config] Searching for markdownlint config in: {}",
3148 current_dir.display()
3149 );
3150
3151 for config_name in MARKDOWNLINT_CONFIG_FILES {
3153 let config_path = current_dir.join(config_name);
3154 if config_path.exists() {
3155 log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
3156 return Some(config_path);
3157 }
3158 }
3159
3160 if current_dir.join(".git").exists() {
3162 log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
3163 break;
3164 }
3165
3166 match current_dir.parent() {
3168 Some(parent) => {
3169 current_dir = parent.to_owned();
3170 depth += 1;
3171 }
3172 None => {
3173 log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
3174 break;
3175 }
3176 }
3177 }
3178
3179 None
3180 }
3181
3182 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
3184 let config_dir = config_dir.join("rumdl");
3185
3186 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
3188
3189 log::debug!(
3190 "[rumdl-config] Checking for user configuration in: {}",
3191 config_dir.display()
3192 );
3193
3194 for filename in USER_CONFIG_FILES {
3195 let config_path = config_dir.join(filename);
3196
3197 if config_path.exists() {
3198 if *filename == "pyproject.toml" {
3200 if let Ok(content) = std::fs::read_to_string(&config_path) {
3201 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
3202 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
3203 return Some(config_path);
3204 }
3205 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
3206 continue;
3207 }
3208 } else {
3209 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
3210 return Some(config_path);
3211 }
3212 }
3213 }
3214
3215 log::debug!(
3216 "[rumdl-config] No user configuration found in: {}",
3217 config_dir.display()
3218 );
3219 None
3220 }
3221
3222 #[cfg(feature = "native")]
3225 fn user_configuration_path() -> Option<std::path::PathBuf> {
3226 use etcetera::{BaseStrategy, choose_base_strategy};
3227
3228 match choose_base_strategy() {
3229 Ok(strategy) => {
3230 let config_dir = strategy.config_dir();
3231 Self::user_configuration_path_impl(&config_dir)
3232 }
3233 Err(e) => {
3234 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
3235 None
3236 }
3237 }
3238 }
3239
3240 #[cfg(not(feature = "native"))]
3242 fn user_configuration_path() -> Option<std::path::PathBuf> {
3243 None
3244 }
3245
3246 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
3248 let path_obj = Path::new(path);
3249 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
3250 let path_str = path.to_string();
3251
3252 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
3253
3254 if let Some(config_parent) = path_obj.parent() {
3256 let project_root = Self::find_project_root_from(config_parent);
3257 log::debug!(
3258 "[rumdl-config] Project root (from explicit config): {}",
3259 project_root.display()
3260 );
3261 sourced_config.project_root = Some(project_root);
3262 }
3263
3264 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
3266
3267 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
3268 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
3269 source: e,
3270 path: path_str.clone(),
3271 })?;
3272 if filename == "pyproject.toml" {
3273 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3274 sourced_config.merge(fragment);
3275 sourced_config.loaded_files.push(path_str);
3276 }
3277 } else {
3278 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3279 sourced_config.merge(fragment);
3280 sourced_config.loaded_files.push(path_str);
3281 }
3282 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
3283 || path_str.ends_with(".json")
3284 || path_str.ends_with(".jsonc")
3285 || path_str.ends_with(".yaml")
3286 || path_str.ends_with(".yml")
3287 {
3288 let fragment = load_from_markdownlint(&path_str)?;
3290 sourced_config.merge(fragment);
3291 sourced_config.loaded_files.push(path_str);
3292 } else {
3293 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
3295 source: e,
3296 path: path_str.clone(),
3297 })?;
3298 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3299 sourced_config.merge(fragment);
3300 sourced_config.loaded_files.push(path_str);
3301 }
3302
3303 Ok(())
3304 }
3305
3306 fn load_user_config_as_fallback(
3308 sourced_config: &mut Self,
3309 user_config_dir: Option<&Path>,
3310 ) -> Result<(), ConfigError> {
3311 let user_config_path = if let Some(dir) = user_config_dir {
3312 Self::user_configuration_path_impl(dir)
3313 } else {
3314 Self::user_configuration_path()
3315 };
3316
3317 if let Some(user_config_path) = user_config_path {
3318 let path_str = user_config_path.display().to_string();
3319 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
3320
3321 log::debug!("[rumdl-config] Loading user config as fallback: {path_str}");
3322
3323 if filename == "pyproject.toml" {
3324 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
3325 source: e,
3326 path: path_str.clone(),
3327 })?;
3328 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3329 sourced_config.merge(fragment);
3330 sourced_config.loaded_files.push(path_str);
3331 }
3332 } else {
3333 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
3334 source: e,
3335 path: path_str.clone(),
3336 })?;
3337 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
3338 sourced_config.merge(fragment);
3339 sourced_config.loaded_files.push(path_str);
3340 }
3341 } else {
3342 log::debug!("[rumdl-config] No user configuration file found");
3343 }
3344
3345 Ok(())
3346 }
3347
3348 #[doc(hidden)]
3350 pub fn load_with_discovery_impl(
3351 config_path: Option<&str>,
3352 cli_overrides: Option<&SourcedGlobalConfig>,
3353 skip_auto_discovery: bool,
3354 user_config_dir: Option<&Path>,
3355 ) -> Result<Self, ConfigError> {
3356 use std::env;
3357 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
3358
3359 let mut sourced_config = SourcedConfig::default();
3360
3361 if let Some(path) = config_path {
3374 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
3376 Self::load_explicit_config(&mut sourced_config, path)?;
3377 } else if skip_auto_discovery {
3378 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
3379 } else {
3381 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
3383
3384 if let Some((config_file, project_root)) = Self::discover_config_upward() {
3386 let path_str = config_file.display().to_string();
3388 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
3389
3390 log::debug!("[rumdl-config] Found project config: {path_str}");
3391 log::debug!("[rumdl-config] Project root: {}", project_root.display());
3392
3393 sourced_config.project_root = Some(project_root);
3394
3395 if filename == "pyproject.toml" {
3396 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
3397 source: e,
3398 path: path_str.clone(),
3399 })?;
3400 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3401 sourced_config.merge(fragment);
3402 sourced_config.loaded_files.push(path_str);
3403 }
3404 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
3405 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
3406 source: e,
3407 path: path_str.clone(),
3408 })?;
3409 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3410 sourced_config.merge(fragment);
3411 sourced_config.loaded_files.push(path_str);
3412 }
3413 } else {
3414 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
3416
3417 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward() {
3418 let path_str = markdownlint_path.display().to_string();
3419 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
3420 match load_from_markdownlint(&path_str) {
3421 Ok(fragment) => {
3422 sourced_config.merge(fragment);
3423 sourced_config.loaded_files.push(path_str);
3424 }
3425 Err(_e) => {
3426 log::debug!("[rumdl-config] Failed to load markdownlint config, trying user config");
3427 Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
3428 }
3429 }
3430 } else {
3431 log::debug!("[rumdl-config] No project config found, using user config as fallback");
3433 Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
3434 }
3435 }
3436 }
3437
3438 if let Some(cli) = cli_overrides {
3440 sourced_config
3441 .global
3442 .enable
3443 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
3444 sourced_config
3445 .global
3446 .disable
3447 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
3448 sourced_config
3449 .global
3450 .exclude
3451 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
3452 sourced_config
3453 .global
3454 .include
3455 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
3456 sourced_config.global.respect_gitignore.merge_override(
3457 cli.respect_gitignore.value,
3458 ConfigSource::Cli,
3459 None,
3460 None,
3461 );
3462 sourced_config
3463 .global
3464 .fixable
3465 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
3466 sourced_config
3467 .global
3468 .unfixable
3469 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
3470 }
3472
3473 Ok(sourced_config)
3476 }
3477
3478 pub fn load_with_discovery(
3481 config_path: Option<&str>,
3482 cli_overrides: Option<&SourcedGlobalConfig>,
3483 skip_auto_discovery: bool,
3484 ) -> Result<Self, ConfigError> {
3485 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
3486 }
3487
3488 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
3502 let warnings = validate_config_sourced_internal(&self, registry);
3503
3504 Ok(SourcedConfig {
3505 global: self.global,
3506 per_file_ignores: self.per_file_ignores,
3507 per_file_flavor: self.per_file_flavor,
3508 rules: self.rules,
3509 loaded_files: self.loaded_files,
3510 unknown_keys: self.unknown_keys,
3511 project_root: self.project_root,
3512 validation_warnings: warnings,
3513 _state: PhantomData,
3514 })
3515 }
3516
3517 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
3522 let validated = self.validate(registry)?;
3523 let warnings = validated.validation_warnings.clone();
3524 Ok((validated.into(), warnings))
3525 }
3526
3527 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
3538 SourcedConfig {
3539 global: self.global,
3540 per_file_ignores: self.per_file_ignores,
3541 per_file_flavor: self.per_file_flavor,
3542 rules: self.rules,
3543 loaded_files: self.loaded_files,
3544 unknown_keys: self.unknown_keys,
3545 project_root: self.project_root,
3546 validation_warnings: Vec::new(),
3547 _state: PhantomData,
3548 }
3549 }
3550}
3551
3552impl From<SourcedConfig<ConfigValidated>> for Config {
3557 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
3558 let mut rules = BTreeMap::new();
3559 for (rule_name, sourced_rule_cfg) in sourced.rules {
3560 let normalized_rule_name = rule_name.to_ascii_uppercase();
3562 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
3563 let mut values = BTreeMap::new();
3564 for (key, sourced_val) in sourced_rule_cfg.values {
3565 values.insert(key, sourced_val.value);
3566 }
3567 rules.insert(normalized_rule_name, RuleConfig { severity, values });
3568 }
3569 #[allow(deprecated)]
3570 let global = GlobalConfig {
3571 enable: sourced.global.enable.value,
3572 disable: sourced.global.disable.value,
3573 exclude: sourced.global.exclude.value,
3574 include: sourced.global.include.value,
3575 respect_gitignore: sourced.global.respect_gitignore.value,
3576 line_length: sourced.global.line_length.value,
3577 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
3578 fixable: sourced.global.fixable.value,
3579 unfixable: sourced.global.unfixable.value,
3580 flavor: sourced.global.flavor.value,
3581 force_exclude: sourced.global.force_exclude.value,
3582 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
3583 cache: sourced.global.cache.value,
3584 };
3585 Config {
3586 global,
3587 per_file_ignores: sourced.per_file_ignores.value,
3588 per_file_flavor: sourced.per_file_flavor.value,
3589 rules,
3590 project_root: sourced.project_root,
3591 }
3592 }
3593}
3594
3595pub struct RuleRegistry {
3597 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
3599 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
3601}
3602
3603impl RuleRegistry {
3604 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
3606 let mut rule_schemas = std::collections::BTreeMap::new();
3607 let mut rule_aliases = std::collections::BTreeMap::new();
3608
3609 for rule in rules {
3610 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
3611 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
3613 norm_name
3614 } else {
3615 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
3617 norm_name
3618 };
3619
3620 if let Some(aliases) = rule.config_aliases() {
3622 rule_aliases.insert(norm_name, aliases);
3623 }
3624 }
3625
3626 RuleRegistry {
3627 rule_schemas,
3628 rule_aliases,
3629 }
3630 }
3631
3632 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
3634 self.rule_schemas.keys().cloned().collect()
3635 }
3636
3637 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
3639 self.rule_schemas.get(rule).map(|schema| {
3640 let mut all_keys = std::collections::BTreeSet::new();
3641
3642 all_keys.insert("severity".to_string());
3644
3645 for key in schema.keys() {
3647 all_keys.insert(key.clone());
3648 }
3649
3650 for key in schema.keys() {
3652 all_keys.insert(key.replace('_', "-"));
3654 all_keys.insert(key.replace('-', "_"));
3656 all_keys.insert(normalize_key(key));
3658 }
3659
3660 if let Some(aliases) = self.rule_aliases.get(rule) {
3662 for alias_key in aliases.keys() {
3663 all_keys.insert(alias_key.clone());
3664 all_keys.insert(alias_key.replace('_', "-"));
3666 all_keys.insert(alias_key.replace('-', "_"));
3667 all_keys.insert(normalize_key(alias_key));
3668 }
3669 }
3670
3671 all_keys
3672 })
3673 }
3674
3675 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
3677 if let Some(schema) = self.rule_schemas.get(rule) {
3678 if let Some(aliases) = self.rule_aliases.get(rule)
3680 && let Some(canonical_key) = aliases.get(key)
3681 {
3682 if let Some(value) = schema.get(canonical_key) {
3684 return Some(value);
3685 }
3686 }
3687
3688 if let Some(value) = schema.get(key) {
3690 return Some(value);
3691 }
3692
3693 let key_variants = [
3695 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
3699
3700 for variant in &key_variants {
3701 if let Some(value) = schema.get(variant) {
3702 return Some(value);
3703 }
3704 }
3705 }
3706 None
3707 }
3708
3709 pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
3716 let normalized = normalize_key(name);
3718 if self.rule_schemas.contains_key(&normalized) {
3719 return Some(normalized);
3720 }
3721
3722 resolve_rule_name_alias(name).map(|s| s.to_string())
3724 }
3725}
3726
3727pub static RULE_ALIAS_MAP: phf::Map<&'static str, &'static str> = phf::phf_map! {
3730 "MD001" => "MD001",
3732 "MD003" => "MD003",
3733 "MD004" => "MD004",
3734 "MD005" => "MD005",
3735 "MD007" => "MD007",
3736 "MD009" => "MD009",
3737 "MD010" => "MD010",
3738 "MD011" => "MD011",
3739 "MD012" => "MD012",
3740 "MD013" => "MD013",
3741 "MD014" => "MD014",
3742 "MD018" => "MD018",
3743 "MD019" => "MD019",
3744 "MD020" => "MD020",
3745 "MD021" => "MD021",
3746 "MD022" => "MD022",
3747 "MD023" => "MD023",
3748 "MD024" => "MD024",
3749 "MD025" => "MD025",
3750 "MD026" => "MD026",
3751 "MD027" => "MD027",
3752 "MD028" => "MD028",
3753 "MD029" => "MD029",
3754 "MD030" => "MD030",
3755 "MD031" => "MD031",
3756 "MD032" => "MD032",
3757 "MD033" => "MD033",
3758 "MD034" => "MD034",
3759 "MD035" => "MD035",
3760 "MD036" => "MD036",
3761 "MD037" => "MD037",
3762 "MD038" => "MD038",
3763 "MD039" => "MD039",
3764 "MD040" => "MD040",
3765 "MD041" => "MD041",
3766 "MD042" => "MD042",
3767 "MD043" => "MD043",
3768 "MD044" => "MD044",
3769 "MD045" => "MD045",
3770 "MD046" => "MD046",
3771 "MD047" => "MD047",
3772 "MD048" => "MD048",
3773 "MD049" => "MD049",
3774 "MD050" => "MD050",
3775 "MD051" => "MD051",
3776 "MD052" => "MD052",
3777 "MD053" => "MD053",
3778 "MD054" => "MD054",
3779 "MD055" => "MD055",
3780 "MD056" => "MD056",
3781 "MD057" => "MD057",
3782 "MD058" => "MD058",
3783 "MD059" => "MD059",
3784 "MD060" => "MD060",
3785 "MD061" => "MD061",
3786 "MD062" => "MD062",
3787 "MD063" => "MD063",
3788 "MD064" => "MD064",
3789 "MD065" => "MD065",
3790 "MD066" => "MD066",
3791 "MD067" => "MD067",
3792 "MD068" => "MD068",
3793 "MD069" => "MD069",
3794 "MD070" => "MD070",
3795 "MD071" => "MD071",
3796 "MD072" => "MD072",
3797
3798 "HEADING-INCREMENT" => "MD001",
3800 "HEADING-STYLE" => "MD003",
3801 "UL-STYLE" => "MD004",
3802 "LIST-INDENT" => "MD005",
3803 "UL-INDENT" => "MD007",
3804 "NO-TRAILING-SPACES" => "MD009",
3805 "NO-HARD-TABS" => "MD010",
3806 "NO-REVERSED-LINKS" => "MD011",
3807 "NO-MULTIPLE-BLANKS" => "MD012",
3808 "LINE-LENGTH" => "MD013",
3809 "COMMANDS-SHOW-OUTPUT" => "MD014",
3810 "NO-MISSING-SPACE-ATX" => "MD018",
3811 "NO-MULTIPLE-SPACE-ATX" => "MD019",
3812 "NO-MISSING-SPACE-CLOSED-ATX" => "MD020",
3813 "NO-MULTIPLE-SPACE-CLOSED-ATX" => "MD021",
3814 "BLANKS-AROUND-HEADINGS" => "MD022",
3815 "HEADING-START-LEFT" => "MD023",
3816 "NO-DUPLICATE-HEADING" => "MD024",
3817 "SINGLE-TITLE" => "MD025",
3818 "SINGLE-H1" => "MD025",
3819 "NO-TRAILING-PUNCTUATION" => "MD026",
3820 "NO-MULTIPLE-SPACE-BLOCKQUOTE" => "MD027",
3821 "NO-BLANKS-BLOCKQUOTE" => "MD028",
3822 "OL-PREFIX" => "MD029",
3823 "LIST-MARKER-SPACE" => "MD030",
3824 "BLANKS-AROUND-FENCES" => "MD031",
3825 "BLANKS-AROUND-LISTS" => "MD032",
3826 "NO-INLINE-HTML" => "MD033",
3827 "NO-BARE-URLS" => "MD034",
3828 "HR-STYLE" => "MD035",
3829 "NO-EMPHASIS-AS-HEADING" => "MD036",
3830 "NO-SPACE-IN-EMPHASIS" => "MD037",
3831 "NO-SPACE-IN-CODE" => "MD038",
3832 "NO-SPACE-IN-LINKS" => "MD039",
3833 "FENCED-CODE-LANGUAGE" => "MD040",
3834 "FIRST-LINE-HEADING" => "MD041",
3835 "FIRST-LINE-H1" => "MD041",
3836 "NO-EMPTY-LINKS" => "MD042",
3837 "REQUIRED-HEADINGS" => "MD043",
3838 "PROPER-NAMES" => "MD044",
3839 "NO-ALT-TEXT" => "MD045",
3840 "CODE-BLOCK-STYLE" => "MD046",
3841 "SINGLE-TRAILING-NEWLINE" => "MD047",
3842 "CODE-FENCE-STYLE" => "MD048",
3843 "EMPHASIS-STYLE" => "MD049",
3844 "STRONG-STYLE" => "MD050",
3845 "LINK-FRAGMENTS" => "MD051",
3846 "REFERENCE-LINKS-IMAGES" => "MD052",
3847 "LINK-IMAGE-REFERENCE-DEFINITIONS" => "MD053",
3848 "LINK-IMAGE-STYLE" => "MD054",
3849 "TABLE-PIPE-STYLE" => "MD055",
3850 "TABLE-COLUMN-COUNT" => "MD056",
3851 "EXISTING-RELATIVE-LINKS" => "MD057",
3852 "BLANKS-AROUND-TABLES" => "MD058",
3853 "TABLE-CELL-ALIGNMENT" => "MD059",
3854 "TABLE-FORMAT" => "MD060",
3855 "FORBIDDEN-TERMS" => "MD061",
3856 "LINK-DESTINATION-WHITESPACE" => "MD062",
3857 "HEADING-CAPITALIZATION" => "MD063",
3858 "NO-MULTIPLE-CONSECUTIVE-SPACES" => "MD064",
3859 "BLANKS-AROUND-HORIZONTAL-RULES" => "MD065",
3860 "FOOTNOTE-VALIDATION" => "MD066",
3861 "FOOTNOTE-DEFINITION-ORDER" => "MD067",
3862 "EMPTY-FOOTNOTE-DEFINITION" => "MD068",
3863 "NO-DUPLICATE-LIST-MARKERS" => "MD069",
3864 "NESTED-CODE-FENCE" => "MD070",
3865 "BLANK-LINE-AFTER-FRONTMATTER" => "MD071",
3866 "FRONTMATTER-KEY-SORT" => "MD072",
3867};
3868
3869pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
3873 let normalized_key = key.to_ascii_uppercase().replace('_', "-");
3875
3876 RULE_ALIAS_MAP.get(normalized_key.as_str()).copied()
3878}
3879
3880pub fn resolve_rule_name(name: &str) -> String {
3888 resolve_rule_name_alias(name)
3889 .map(|s| s.to_string())
3890 .unwrap_or_else(|| normalize_key(name))
3891}
3892
3893pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
3897 input
3898 .split(',')
3899 .map(|s| s.trim())
3900 .filter(|s| !s.is_empty())
3901 .map(resolve_rule_name)
3902 .collect()
3903}
3904
3905pub fn validate_cli_rule_names(
3911 enable: Option<&str>,
3912 disable: Option<&str>,
3913 extend_enable: Option<&str>,
3914 extend_disable: Option<&str>,
3915) -> Vec<ConfigValidationWarning> {
3916 let mut warnings = Vec::new();
3917 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
3918
3919 let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
3920 for name in input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
3921 if name.eq_ignore_ascii_case("all") {
3923 continue;
3924 }
3925 if resolve_rule_name_alias(name).is_none() {
3926 let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
3927 let formatted = if suggestion.starts_with("MD") {
3928 suggestion
3929 } else {
3930 suggestion.to_lowercase()
3931 };
3932 format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
3933 } else {
3934 format!("Unknown rule in {flag_name}: {name}")
3935 };
3936 warnings.push(ConfigValidationWarning {
3937 message,
3938 rule: Some(name.to_string()),
3939 key: None,
3940 });
3941 }
3942 }
3943 };
3944
3945 if let Some(e) = enable {
3946 validate_list(e, "--enable", &mut warnings);
3947 }
3948 if let Some(d) = disable {
3949 validate_list(d, "--disable", &mut warnings);
3950 }
3951 if let Some(ee) = extend_enable {
3952 validate_list(ee, "--extend-enable", &mut warnings);
3953 }
3954 if let Some(ed) = extend_disable {
3955 validate_list(ed, "--extend-disable", &mut warnings);
3956 }
3957
3958 warnings
3959}
3960
3961pub fn is_valid_rule_name(name: &str) -> bool {
3965 if name.eq_ignore_ascii_case("all") {
3967 return true;
3968 }
3969 resolve_rule_name_alias(name).is_some()
3970}
3971
3972#[derive(Debug, Clone)]
3974pub struct ConfigValidationWarning {
3975 pub message: String,
3976 pub rule: Option<String>,
3977 pub key: Option<String>,
3978}
3979
3980fn validate_config_sourced_internal<S>(
3983 sourced: &SourcedConfig<S>,
3984 registry: &RuleRegistry,
3985) -> Vec<ConfigValidationWarning> {
3986 let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
3987
3988 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
3990
3991 for rule_name in &sourced.global.enable.value {
3992 if !is_valid_rule_name(rule_name) {
3993 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
3994 let formatted = if suggestion.starts_with("MD") {
3995 suggestion
3996 } else {
3997 suggestion.to_lowercase()
3998 };
3999 format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
4000 } else {
4001 format!("Unknown rule in global.enable: {rule_name}")
4002 };
4003 warnings.push(ConfigValidationWarning {
4004 message,
4005 rule: Some(rule_name.clone()),
4006 key: None,
4007 });
4008 }
4009 }
4010
4011 for rule_name in &sourced.global.disable.value {
4012 if !is_valid_rule_name(rule_name) {
4013 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4014 let formatted = if suggestion.starts_with("MD") {
4015 suggestion
4016 } else {
4017 suggestion.to_lowercase()
4018 };
4019 format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
4020 } else {
4021 format!("Unknown rule in global.disable: {rule_name}")
4022 };
4023 warnings.push(ConfigValidationWarning {
4024 message,
4025 rule: Some(rule_name.clone()),
4026 key: None,
4027 });
4028 }
4029 }
4030
4031 warnings
4032}
4033
4034fn validate_config_sourced_impl(
4036 rules: &BTreeMap<String, SourcedRuleConfig>,
4037 unknown_keys: &[(String, String, Option<String>)],
4038 registry: &RuleRegistry,
4039) -> Vec<ConfigValidationWarning> {
4040 let mut warnings = Vec::new();
4041 let known_rules = registry.rule_names();
4042 for rule in rules.keys() {
4044 if !known_rules.contains(rule) {
4045 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4047 let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
4048 let formatted_suggestion = if suggestion.starts_with("MD") {
4050 suggestion
4051 } else {
4052 suggestion.to_lowercase()
4053 };
4054 format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
4055 } else {
4056 format!("Unknown rule in config: {rule}")
4057 };
4058 warnings.push(ConfigValidationWarning {
4059 message,
4060 rule: Some(rule.clone()),
4061 key: None,
4062 });
4063 }
4064 }
4065 for (rule, rule_cfg) in rules {
4067 if let Some(valid_keys) = registry.config_keys_for(rule) {
4068 for key in rule_cfg.values.keys() {
4069 if !valid_keys.contains(key) {
4070 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
4071 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
4072 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
4073 } else {
4074 format!("Unknown option for rule {rule}: {key}")
4075 };
4076 warnings.push(ConfigValidationWarning {
4077 message,
4078 rule: Some(rule.clone()),
4079 key: Some(key.clone()),
4080 });
4081 } else {
4082 if let Some(expected) = registry.expected_value_for(rule, key) {
4084 let actual = &rule_cfg.values[key].value;
4085 if !toml_value_type_matches(expected, actual) {
4086 warnings.push(ConfigValidationWarning {
4087 message: format!(
4088 "Type mismatch for {}.{}: expected {}, got {}",
4089 rule,
4090 key,
4091 toml_type_name(expected),
4092 toml_type_name(actual)
4093 ),
4094 rule: Some(rule.clone()),
4095 key: Some(key.clone()),
4096 });
4097 }
4098 }
4099 }
4100 }
4101 }
4102 }
4103 let known_global_keys = vec![
4105 "enable".to_string(),
4106 "disable".to_string(),
4107 "include".to_string(),
4108 "exclude".to_string(),
4109 "respect-gitignore".to_string(),
4110 "line-length".to_string(),
4111 "fixable".to_string(),
4112 "unfixable".to_string(),
4113 "flavor".to_string(),
4114 "force-exclude".to_string(),
4115 "output-format".to_string(),
4116 "cache-dir".to_string(),
4117 "cache".to_string(),
4118 ];
4119
4120 for (section, key, file_path) in unknown_keys {
4121 if section.contains("[global]") || section.contains("[tool.rumdl]") {
4122 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
4123 if let Some(path) = file_path {
4124 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
4125 } else {
4126 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
4127 }
4128 } else if let Some(path) = file_path {
4129 format!("Unknown global option in {path}: {key}")
4130 } else {
4131 format!("Unknown global option: {key}")
4132 };
4133 warnings.push(ConfigValidationWarning {
4134 message,
4135 rule: None,
4136 key: Some(key.clone()),
4137 });
4138 } else if !key.is_empty() {
4139 continue;
4141 } else {
4142 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
4144 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4145 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4146 let formatted_suggestion = if suggestion.starts_with("MD") {
4148 suggestion
4149 } else {
4150 suggestion.to_lowercase()
4151 };
4152 if let Some(path) = file_path {
4153 format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
4154 } else {
4155 format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
4156 }
4157 } else if let Some(path) = file_path {
4158 format!("Unknown rule in {path}: {rule_name}")
4159 } else {
4160 format!("Unknown rule in config: {rule_name}")
4161 };
4162 warnings.push(ConfigValidationWarning {
4163 message,
4164 rule: None,
4165 key: None,
4166 });
4167 }
4168 }
4169 warnings
4170}
4171
4172pub fn validate_config_sourced(
4178 sourced: &SourcedConfig<ConfigLoaded>,
4179 registry: &RuleRegistry,
4180) -> Vec<ConfigValidationWarning> {
4181 validate_config_sourced_internal(sourced, registry)
4182}
4183
4184pub fn validate_config_sourced_validated(
4188 sourced: &SourcedConfig<ConfigValidated>,
4189 _registry: &RuleRegistry,
4190) -> Vec<ConfigValidationWarning> {
4191 sourced.validation_warnings.clone()
4192}
4193
4194fn toml_type_name(val: &toml::Value) -> &'static str {
4195 match val {
4196 toml::Value::String(_) => "string",
4197 toml::Value::Integer(_) => "integer",
4198 toml::Value::Float(_) => "float",
4199 toml::Value::Boolean(_) => "boolean",
4200 toml::Value::Array(_) => "array",
4201 toml::Value::Table(_) => "table",
4202 toml::Value::Datetime(_) => "datetime",
4203 }
4204}
4205
4206fn levenshtein_distance(s1: &str, s2: &str) -> usize {
4208 let len1 = s1.len();
4209 let len2 = s2.len();
4210
4211 if len1 == 0 {
4212 return len2;
4213 }
4214 if len2 == 0 {
4215 return len1;
4216 }
4217
4218 let s1_chars: Vec<char> = s1.chars().collect();
4219 let s2_chars: Vec<char> = s2.chars().collect();
4220
4221 let mut prev_row: Vec<usize> = (0..=len2).collect();
4222 let mut curr_row = vec![0; len2 + 1];
4223
4224 for i in 1..=len1 {
4225 curr_row[0] = i;
4226 for j in 1..=len2 {
4227 let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
4228 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
4232 std::mem::swap(&mut prev_row, &mut curr_row);
4233 }
4234
4235 prev_row[len2]
4236}
4237
4238pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
4240 let unknown_lower = unknown.to_lowercase();
4241 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
4244
4245 for valid in valid_keys {
4246 let valid_lower = valid.to_lowercase();
4247 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
4248
4249 if distance <= max_distance {
4250 if let Some((_, best_dist)) = &best_match {
4251 if distance < *best_dist {
4252 best_match = Some((valid.clone(), distance));
4253 }
4254 } else {
4255 best_match = Some((valid.clone(), distance));
4256 }
4257 }
4258 }
4259
4260 best_match.map(|(key, _)| key)
4261}
4262
4263fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
4264 use toml::Value::*;
4265 match (expected, actual) {
4266 (String(_), String(_)) => true,
4267 (Integer(_), Integer(_)) => true,
4268 (Float(_), Float(_)) => true,
4269 (Boolean(_), Boolean(_)) => true,
4270 (Array(_), Array(_)) => true,
4271 (Table(_), Table(_)) => true,
4272 (Datetime(_), Datetime(_)) => true,
4273 (Float(_), Integer(_)) => true,
4275 _ => false,
4276 }
4277}
4278
4279fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
4281 let doc: toml::Value =
4282 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
4283 let mut fragment = SourcedConfigFragment::default();
4284 let source = ConfigSource::PyprojectToml;
4285 let file = Some(path.to_string());
4286
4287 let all_rules = rules::all_rules(&Config::default());
4289 let registry = RuleRegistry::from_rules(&all_rules);
4290
4291 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
4293 && let Some(rumdl_table) = rumdl_config.as_table()
4294 {
4295 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
4297 if let Some(enable) = table.get("enable")
4299 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
4300 {
4301 let normalized_values = values
4303 .into_iter()
4304 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4305 .collect();
4306 fragment
4307 .global
4308 .enable
4309 .push_override(normalized_values, source, file.clone(), None);
4310 }
4311
4312 if let Some(disable) = table.get("disable")
4313 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
4314 {
4315 let normalized_values: Vec<String> = values
4317 .into_iter()
4318 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4319 .collect();
4320 fragment
4321 .global
4322 .disable
4323 .push_override(normalized_values, source, file.clone(), None);
4324 }
4325
4326 if let Some(include) = table.get("include")
4327 && let Ok(values) = Vec::<String>::deserialize(include.clone())
4328 {
4329 fragment
4330 .global
4331 .include
4332 .push_override(values, source, file.clone(), None);
4333 }
4334
4335 if let Some(exclude) = table.get("exclude")
4336 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
4337 {
4338 fragment
4339 .global
4340 .exclude
4341 .push_override(values, source, file.clone(), None);
4342 }
4343
4344 if let Some(respect_gitignore) = table
4345 .get("respect-gitignore")
4346 .or_else(|| table.get("respect_gitignore"))
4347 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
4348 {
4349 fragment
4350 .global
4351 .respect_gitignore
4352 .push_override(value, source, file.clone(), None);
4353 }
4354
4355 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
4356 && let Ok(value) = bool::deserialize(force_exclude.clone())
4357 {
4358 fragment
4359 .global
4360 .force_exclude
4361 .push_override(value, source, file.clone(), None);
4362 }
4363
4364 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
4365 && let Ok(value) = String::deserialize(output_format.clone())
4366 {
4367 if fragment.global.output_format.is_none() {
4368 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
4369 } else {
4370 fragment
4371 .global
4372 .output_format
4373 .as_mut()
4374 .unwrap()
4375 .push_override(value, source, file.clone(), None);
4376 }
4377 }
4378
4379 if let Some(fixable) = table.get("fixable")
4380 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
4381 {
4382 let normalized_values = values
4383 .into_iter()
4384 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4385 .collect();
4386 fragment
4387 .global
4388 .fixable
4389 .push_override(normalized_values, source, file.clone(), None);
4390 }
4391
4392 if let Some(unfixable) = table.get("unfixable")
4393 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
4394 {
4395 let normalized_values = values
4396 .into_iter()
4397 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4398 .collect();
4399 fragment
4400 .global
4401 .unfixable
4402 .push_override(normalized_values, source, file.clone(), None);
4403 }
4404
4405 if let Some(flavor) = table.get("flavor")
4406 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
4407 {
4408 fragment.global.flavor.push_override(value, source, file.clone(), None);
4409 }
4410
4411 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
4413 && let Ok(value) = u64::deserialize(line_length.clone())
4414 {
4415 fragment
4416 .global
4417 .line_length
4418 .push_override(LineLength::new(value as usize), source, file.clone(), None);
4419
4420 let norm_md013_key = normalize_key("MD013");
4422 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
4423 let norm_line_length_key = normalize_key("line-length");
4424 let sv = rule_entry
4425 .values
4426 .entry(norm_line_length_key)
4427 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
4428 sv.push_override(line_length.clone(), source, file.clone(), None);
4429 }
4430
4431 if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
4432 && let Ok(value) = String::deserialize(cache_dir.clone())
4433 {
4434 if fragment.global.cache_dir.is_none() {
4435 fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
4436 } else {
4437 fragment
4438 .global
4439 .cache_dir
4440 .as_mut()
4441 .unwrap()
4442 .push_override(value, source, file.clone(), None);
4443 }
4444 }
4445
4446 if let Some(cache) = table.get("cache")
4447 && let Ok(value) = bool::deserialize(cache.clone())
4448 {
4449 fragment.global.cache.push_override(value, source, file.clone(), None);
4450 }
4451 };
4452
4453 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
4455 extract_global_config(&mut fragment, global_table);
4456 }
4457
4458 extract_global_config(&mut fragment, rumdl_table);
4460
4461 let per_file_ignores_key = rumdl_table
4464 .get("per-file-ignores")
4465 .or_else(|| rumdl_table.get("per_file_ignores"));
4466
4467 if let Some(per_file_ignores_value) = per_file_ignores_key
4468 && let Some(per_file_table) = per_file_ignores_value.as_table()
4469 {
4470 let mut per_file_map = HashMap::new();
4471 for (pattern, rules_value) in per_file_table {
4472 if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
4473 let normalized_rules = rules
4474 .into_iter()
4475 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4476 .collect();
4477 per_file_map.insert(pattern.clone(), normalized_rules);
4478 } else {
4479 log::warn!(
4480 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
4481 );
4482 }
4483 }
4484 fragment
4485 .per_file_ignores
4486 .push_override(per_file_map, source, file.clone(), None);
4487 }
4488
4489 let per_file_flavor_key = rumdl_table
4492 .get("per-file-flavor")
4493 .or_else(|| rumdl_table.get("per_file_flavor"));
4494
4495 if let Some(per_file_flavor_value) = per_file_flavor_key
4496 && let Some(per_file_table) = per_file_flavor_value.as_table()
4497 {
4498 let mut per_file_map = IndexMap::new();
4499 for (pattern, flavor_value) in per_file_table {
4500 if let Ok(flavor) = MarkdownFlavor::deserialize(flavor_value.clone()) {
4501 per_file_map.insert(pattern.clone(), flavor);
4502 } else {
4503 log::warn!(
4504 "[WARN] Invalid flavor for per-file-flavor pattern '{pattern}' in {path}, found {flavor_value:?}. Valid values: standard, mkdocs, mdx, quarto"
4505 );
4506 }
4507 }
4508 fragment
4509 .per_file_flavor
4510 .push_override(per_file_map, source, file.clone(), None);
4511 }
4512
4513 for (key, value) in rumdl_table {
4515 let norm_rule_key = normalize_key(key);
4516
4517 let is_global_key = [
4520 "enable",
4521 "disable",
4522 "include",
4523 "exclude",
4524 "respect_gitignore",
4525 "respect-gitignore",
4526 "force_exclude",
4527 "force-exclude",
4528 "output_format",
4529 "output-format",
4530 "fixable",
4531 "unfixable",
4532 "per-file-ignores",
4533 "per_file_ignores",
4534 "per-file-flavor",
4535 "per_file_flavor",
4536 "global",
4537 "flavor",
4538 "cache_dir",
4539 "cache-dir",
4540 "cache",
4541 ]
4542 .contains(&norm_rule_key.as_str());
4543
4544 let is_line_length_global =
4546 (norm_rule_key == "line-length" || norm_rule_key == "line_length") && !value.is_table();
4547
4548 if is_global_key || is_line_length_global {
4549 continue;
4550 }
4551
4552 if let Some(resolved_rule_name) = registry.resolve_rule_name(key)
4554 && value.is_table()
4555 && let Some(rule_config_table) = value.as_table()
4556 {
4557 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4558 for (rk, rv) in rule_config_table {
4559 let norm_rk = normalize_key(rk);
4560
4561 if norm_rk == "severity" {
4563 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4564 if rule_entry.severity.is_none() {
4565 rule_entry.severity = Some(SourcedValue::new(severity, source));
4566 } else {
4567 rule_entry.severity.as_mut().unwrap().push_override(
4568 severity,
4569 source,
4570 file.clone(),
4571 None,
4572 );
4573 }
4574 }
4575 continue; }
4577
4578 let toml_val = rv.clone();
4579
4580 let sv = rule_entry
4581 .values
4582 .entry(norm_rk.clone())
4583 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
4584 sv.push_override(toml_val, source, file.clone(), None);
4585 }
4586 } else if registry.resolve_rule_name(key).is_none() {
4587 fragment
4590 .unknown_keys
4591 .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
4592 }
4593 }
4594 }
4595
4596 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
4598 for (key, value) in tool_table.iter() {
4599 if let Some(rule_name) = key.strip_prefix("rumdl.") {
4600 if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4602 if let Some(rule_table) = value.as_table() {
4603 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4604 for (rk, rv) in rule_table {
4605 let norm_rk = normalize_key(rk);
4606
4607 if norm_rk == "severity" {
4609 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4610 if rule_entry.severity.is_none() {
4611 rule_entry.severity = Some(SourcedValue::new(severity, source));
4612 } else {
4613 rule_entry.severity.as_mut().unwrap().push_override(
4614 severity,
4615 source,
4616 file.clone(),
4617 None,
4618 );
4619 }
4620 }
4621 continue; }
4623
4624 let toml_val = rv.clone();
4625 let sv = rule_entry
4626 .values
4627 .entry(norm_rk.clone())
4628 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4629 sv.push_override(toml_val, source, file.clone(), None);
4630 }
4631 }
4632 } else if rule_name.to_ascii_uppercase().starts_with("MD")
4633 || rule_name.chars().any(|c| c.is_alphabetic())
4634 {
4635 fragment.unknown_keys.push((
4637 format!("[tool.rumdl.{rule_name}]"),
4638 String::new(),
4639 Some(path.to_string()),
4640 ));
4641 }
4642 }
4643 }
4644 }
4645
4646 if let Some(doc_table) = doc.as_table() {
4648 for (key, value) in doc_table.iter() {
4649 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
4650 if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4652 if let Some(rule_table) = value.as_table() {
4653 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4654 for (rk, rv) in rule_table {
4655 let norm_rk = normalize_key(rk);
4656
4657 if norm_rk == "severity" {
4659 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4660 if rule_entry.severity.is_none() {
4661 rule_entry.severity = Some(SourcedValue::new(severity, source));
4662 } else {
4663 rule_entry.severity.as_mut().unwrap().push_override(
4664 severity,
4665 source,
4666 file.clone(),
4667 None,
4668 );
4669 }
4670 }
4671 continue; }
4673
4674 let toml_val = rv.clone();
4675 let sv = rule_entry
4676 .values
4677 .entry(norm_rk.clone())
4678 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4679 sv.push_override(toml_val, source, file.clone(), None);
4680 }
4681 }
4682 } else if rule_name.to_ascii_uppercase().starts_with("MD")
4683 || rule_name.chars().any(|c| c.is_alphabetic())
4684 {
4685 fragment.unknown_keys.push((
4687 format!("[tool.rumdl.{rule_name}]"),
4688 String::new(),
4689 Some(path.to_string()),
4690 ));
4691 }
4692 }
4693 }
4694 }
4695
4696 let has_any = !fragment.global.enable.value.is_empty()
4698 || !fragment.global.disable.value.is_empty()
4699 || !fragment.global.include.value.is_empty()
4700 || !fragment.global.exclude.value.is_empty()
4701 || !fragment.global.fixable.value.is_empty()
4702 || !fragment.global.unfixable.value.is_empty()
4703 || fragment.global.output_format.is_some()
4704 || fragment.global.cache_dir.is_some()
4705 || !fragment.global.cache.value
4706 || !fragment.per_file_ignores.value.is_empty()
4707 || !fragment.per_file_flavor.value.is_empty()
4708 || !fragment.rules.is_empty();
4709 if has_any { Ok(Some(fragment)) } else { Ok(None) }
4710}
4711
4712fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
4714 let doc = content
4715 .parse::<DocumentMut>()
4716 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
4717 let mut fragment = SourcedConfigFragment::default();
4718 let file = Some(path.to_string());
4720
4721 let all_rules = rules::all_rules(&Config::default());
4723 let registry = RuleRegistry::from_rules(&all_rules);
4724
4725 if let Some(global_item) = doc.get("global")
4727 && let Some(global_table) = global_item.as_table()
4728 {
4729 for (key, value_item) in global_table.iter() {
4730 let norm_key = normalize_key(key);
4731 match norm_key.as_str() {
4732 "enable" | "disable" | "include" | "exclude" => {
4733 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4734 let values: Vec<String> = formatted_array
4736 .iter()
4737 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
4739 .collect();
4740
4741 let final_values = if norm_key == "enable" || norm_key == "disable" {
4743 values
4744 .into_iter()
4745 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4746 .collect()
4747 } else {
4748 values
4749 };
4750
4751 match norm_key.as_str() {
4752 "enable" => fragment
4753 .global
4754 .enable
4755 .push_override(final_values, source, file.clone(), None),
4756 "disable" => {
4757 fragment
4758 .global
4759 .disable
4760 .push_override(final_values, source, file.clone(), None)
4761 }
4762 "include" => {
4763 fragment
4764 .global
4765 .include
4766 .push_override(final_values, source, file.clone(), None)
4767 }
4768 "exclude" => {
4769 fragment
4770 .global
4771 .exclude
4772 .push_override(final_values, source, file.clone(), None)
4773 }
4774 _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
4775 }
4776 } else {
4777 log::warn!(
4778 "[WARN] Expected array for global key '{}' in {}, found {}",
4779 key,
4780 path,
4781 value_item.type_name()
4782 );
4783 }
4784 }
4785 "respect_gitignore" | "respect-gitignore" => {
4786 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
4788 let val = *formatted_bool.value();
4789 fragment
4790 .global
4791 .respect_gitignore
4792 .push_override(val, source, file.clone(), None);
4793 } else {
4794 log::warn!(
4795 "[WARN] Expected boolean for global key '{}' in {}, found {}",
4796 key,
4797 path,
4798 value_item.type_name()
4799 );
4800 }
4801 }
4802 "force_exclude" | "force-exclude" => {
4803 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
4805 let val = *formatted_bool.value();
4806 fragment
4807 .global
4808 .force_exclude
4809 .push_override(val, source, file.clone(), None);
4810 } else {
4811 log::warn!(
4812 "[WARN] Expected boolean for global key '{}' in {}, found {}",
4813 key,
4814 path,
4815 value_item.type_name()
4816 );
4817 }
4818 }
4819 "line_length" | "line-length" => {
4820 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
4822 let val = LineLength::new(*formatted_int.value() as usize);
4823 fragment
4824 .global
4825 .line_length
4826 .push_override(val, source, file.clone(), None);
4827 } else {
4828 log::warn!(
4829 "[WARN] Expected integer for global key '{}' in {}, found {}",
4830 key,
4831 path,
4832 value_item.type_name()
4833 );
4834 }
4835 }
4836 "output_format" | "output-format" => {
4837 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4839 let val = formatted_string.value().clone();
4840 if fragment.global.output_format.is_none() {
4841 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
4842 } else {
4843 fragment.global.output_format.as_mut().unwrap().push_override(
4844 val,
4845 source,
4846 file.clone(),
4847 None,
4848 );
4849 }
4850 } else {
4851 log::warn!(
4852 "[WARN] Expected string for global key '{}' in {}, found {}",
4853 key,
4854 path,
4855 value_item.type_name()
4856 );
4857 }
4858 }
4859 "cache_dir" | "cache-dir" => {
4860 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4862 let val = formatted_string.value().clone();
4863 if fragment.global.cache_dir.is_none() {
4864 fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
4865 } else {
4866 fragment
4867 .global
4868 .cache_dir
4869 .as_mut()
4870 .unwrap()
4871 .push_override(val, source, file.clone(), None);
4872 }
4873 } else {
4874 log::warn!(
4875 "[WARN] Expected string for global key '{}' in {}, found {}",
4876 key,
4877 path,
4878 value_item.type_name()
4879 );
4880 }
4881 }
4882 "cache" => {
4883 if let Some(toml_edit::Value::Boolean(b)) = value_item.as_value() {
4884 let val = *b.value();
4885 fragment.global.cache.push_override(val, source, file.clone(), None);
4886 } else {
4887 log::warn!(
4888 "[WARN] Expected boolean for global key '{}' in {}, found {}",
4889 key,
4890 path,
4891 value_item.type_name()
4892 );
4893 }
4894 }
4895 "fixable" => {
4896 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4897 let values: Vec<String> = formatted_array
4898 .iter()
4899 .filter_map(|item| item.as_str())
4900 .map(normalize_key)
4901 .collect();
4902 fragment
4903 .global
4904 .fixable
4905 .push_override(values, source, file.clone(), None);
4906 } else {
4907 log::warn!(
4908 "[WARN] Expected array for global key '{}' in {}, found {}",
4909 key,
4910 path,
4911 value_item.type_name()
4912 );
4913 }
4914 }
4915 "unfixable" => {
4916 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4917 let values: Vec<String> = formatted_array
4918 .iter()
4919 .filter_map(|item| item.as_str())
4920 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
4921 .collect();
4922 fragment
4923 .global
4924 .unfixable
4925 .push_override(values, source, file.clone(), None);
4926 } else {
4927 log::warn!(
4928 "[WARN] Expected array for global key '{}' in {}, found {}",
4929 key,
4930 path,
4931 value_item.type_name()
4932 );
4933 }
4934 }
4935 "flavor" => {
4936 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4937 let val = formatted_string.value();
4938 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
4939 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
4940 } else {
4941 log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
4942 }
4943 } else {
4944 log::warn!(
4945 "[WARN] Expected string for global key '{}' in {}, found {}",
4946 key,
4947 path,
4948 value_item.type_name()
4949 );
4950 }
4951 }
4952 _ => {
4953 fragment
4955 .unknown_keys
4956 .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
4957 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
4958 }
4959 }
4960 }
4961 }
4962
4963 if let Some(per_file_item) = doc.get("per-file-ignores")
4965 && let Some(per_file_table) = per_file_item.as_table()
4966 {
4967 let mut per_file_map = HashMap::new();
4968 for (pattern, value_item) in per_file_table.iter() {
4969 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4970 let rules: Vec<String> = formatted_array
4971 .iter()
4972 .filter_map(|item| item.as_str())
4973 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
4974 .collect();
4975 per_file_map.insert(pattern.to_string(), rules);
4976 } else {
4977 let type_name = value_item.type_name();
4978 log::warn!(
4979 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
4980 );
4981 }
4982 }
4983 fragment
4984 .per_file_ignores
4985 .push_override(per_file_map, source, file.clone(), None);
4986 }
4987
4988 if let Some(per_file_item) = doc.get("per-file-flavor")
4990 && let Some(per_file_table) = per_file_item.as_table()
4991 {
4992 let mut per_file_map = IndexMap::new();
4993 for (pattern, value_item) in per_file_table.iter() {
4994 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4995 let flavor_str = formatted_string.value();
4996 match MarkdownFlavor::deserialize(toml::Value::String(flavor_str.to_string())) {
4997 Ok(flavor) => {
4998 per_file_map.insert(pattern.to_string(), flavor);
4999 }
5000 Err(_) => {
5001 log::warn!(
5002 "[WARN] Invalid flavor '{flavor_str}' for pattern '{pattern}' in {path}. Valid values: standard, mkdocs, mdx, quarto"
5003 );
5004 }
5005 }
5006 } else {
5007 let type_name = value_item.type_name();
5008 log::warn!(
5009 "[WARN] Expected string for per-file-flavor pattern '{pattern}' in {path}, found {type_name}"
5010 );
5011 }
5012 }
5013 fragment
5014 .per_file_flavor
5015 .push_override(per_file_map, source, file.clone(), None);
5016 }
5017
5018 for (key, item) in doc.iter() {
5020 if key == "global" || key == "per-file-ignores" || key == "per-file-flavor" {
5022 continue;
5023 }
5024
5025 let norm_rule_name = if let Some(resolved) = registry.resolve_rule_name(key) {
5027 resolved
5028 } else {
5029 fragment
5031 .unknown_keys
5032 .push((format!("[{key}]"), String::new(), Some(path.to_string())));
5033 continue;
5034 };
5035
5036 if let Some(tbl) = item.as_table() {
5037 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
5038 for (rk, rv_item) in tbl.iter() {
5039 let norm_rk = normalize_key(rk);
5040
5041 if norm_rk == "severity" {
5043 if let Some(toml_edit::Value::String(formatted_string)) = rv_item.as_value() {
5044 let severity_str = formatted_string.value();
5045 match crate::rule::Severity::deserialize(toml::Value::String(severity_str.to_string())) {
5046 Ok(severity) => {
5047 if rule_entry.severity.is_none() {
5048 rule_entry.severity = Some(SourcedValue::new(severity, source));
5049 } else {
5050 rule_entry.severity.as_mut().unwrap().push_override(
5051 severity,
5052 source,
5053 file.clone(),
5054 None,
5055 );
5056 }
5057 }
5058 Err(_) => {
5059 log::warn!(
5060 "[WARN] Invalid severity '{severity_str}' for rule {norm_rule_name} in {path}. Valid values: error, warning"
5061 );
5062 }
5063 }
5064 }
5065 continue; }
5067
5068 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
5069 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
5070 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
5071 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
5072 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
5073 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
5074 Some(toml_edit::Value::Array(formatted_array)) => {
5075 let mut values = Vec::new();
5077 for item in formatted_array.iter() {
5078 match item {
5079 toml_edit::Value::String(formatted) => {
5080 values.push(toml::Value::String(formatted.value().clone()))
5081 }
5082 toml_edit::Value::Integer(formatted) => {
5083 values.push(toml::Value::Integer(*formatted.value()))
5084 }
5085 toml_edit::Value::Float(formatted) => {
5086 values.push(toml::Value::Float(*formatted.value()))
5087 }
5088 toml_edit::Value::Boolean(formatted) => {
5089 values.push(toml::Value::Boolean(*formatted.value()))
5090 }
5091 toml_edit::Value::Datetime(formatted) => {
5092 values.push(toml::Value::Datetime(*formatted.value()))
5093 }
5094 _ => {
5095 log::warn!(
5096 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
5097 );
5098 }
5099 }
5100 }
5101 Some(toml::Value::Array(values))
5102 }
5103 Some(toml_edit::Value::InlineTable(_)) => {
5104 log::warn!(
5105 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
5106 );
5107 None
5108 }
5109 None => {
5110 log::warn!(
5111 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
5112 );
5113 None
5114 }
5115 };
5116 if let Some(toml_val) = maybe_toml_val {
5117 let sv = rule_entry
5118 .values
5119 .entry(norm_rk.clone())
5120 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
5121 sv.push_override(toml_val, source, file.clone(), None);
5122 }
5123 }
5124 } else if item.is_value() {
5125 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
5126 }
5127 }
5128
5129 Ok(fragment)
5130}
5131
5132fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
5134 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
5136 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
5137 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
5138}
5139
5140#[cfg(test)]
5141#[path = "config_intelligent_merge_tests.rs"]
5142mod config_intelligent_merge_tests;