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 #[test]
2549 fn test_relative_path_in_cwd() {
2550 let cwd = std::env::current_dir().unwrap();
2552 let test_path = cwd.join("test_file.md");
2553 fs::write(&test_path, "test").unwrap();
2554
2555 let result = super::to_relative_display_path(test_path.to_str().unwrap());
2556
2557 assert_eq!(result, "test_file.md");
2559
2560 fs::remove_file(&test_path).unwrap();
2562 }
2563
2564 #[test]
2565 fn test_relative_path_in_subdirectory() {
2566 let cwd = std::env::current_dir().unwrap();
2568 let subdir = cwd.join("test_subdir_for_relative_path");
2569 fs::create_dir_all(&subdir).unwrap();
2570 let test_path = subdir.join("test_file.md");
2571 fs::write(&test_path, "test").unwrap();
2572
2573 let result = super::to_relative_display_path(test_path.to_str().unwrap());
2574
2575 assert_eq!(result, "test_subdir_for_relative_path/test_file.md");
2577
2578 fs::remove_file(&test_path).unwrap();
2580 fs::remove_dir(&subdir).unwrap();
2581 }
2582
2583 #[test]
2584 fn test_relative_path_outside_cwd_returns_original() {
2585 let outside_path = "/tmp/definitely_not_in_cwd_test.md";
2587
2588 let result = super::to_relative_display_path(outside_path);
2589
2590 let cwd = std::env::current_dir().unwrap();
2593 if !cwd.starts_with("/tmp") {
2594 assert_eq!(result, outside_path);
2595 }
2596 }
2597
2598 #[test]
2599 fn test_relative_path_already_relative() {
2600 let relative_path = "some/relative/path.md";
2602
2603 let result = super::to_relative_display_path(relative_path);
2604
2605 assert_eq!(result, relative_path);
2607 }
2608
2609 #[test]
2610 fn test_relative_path_with_dot_components() {
2611 let cwd = std::env::current_dir().unwrap();
2613 let test_path = cwd.join("test_dot_component.md");
2614 fs::write(&test_path, "test").unwrap();
2615
2616 let dotted_path = cwd.join(".").join("test_dot_component.md");
2618 let result = super::to_relative_display_path(dotted_path.to_str().unwrap());
2619
2620 assert_eq!(result, "test_dot_component.md");
2622
2623 fs::remove_file(&test_path).unwrap();
2625 }
2626
2627 #[test]
2628 fn test_relative_path_empty_string() {
2629 let result = super::to_relative_display_path("");
2630
2631 assert_eq!(result, "");
2633 }
2634}
2635
2636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2645pub enum ConfigSource {
2646 Default,
2648 UserConfig,
2650 PyprojectToml,
2652 ProjectConfig,
2654 Cli,
2656}
2657
2658#[derive(Debug, Clone)]
2659pub struct ConfigOverride<T> {
2660 pub value: T,
2661 pub source: ConfigSource,
2662 pub file: Option<String>,
2663 pub line: Option<usize>,
2664}
2665
2666#[derive(Debug, Clone)]
2667pub struct SourcedValue<T> {
2668 pub value: T,
2669 pub source: ConfigSource,
2670 pub overrides: Vec<ConfigOverride<T>>,
2671}
2672
2673impl<T: Clone> SourcedValue<T> {
2674 pub fn new(value: T, source: ConfigSource) -> Self {
2675 Self {
2676 value: value.clone(),
2677 source,
2678 overrides: vec![ConfigOverride {
2679 value,
2680 source,
2681 file: None,
2682 line: None,
2683 }],
2684 }
2685 }
2686
2687 pub fn merge_override(
2691 &mut self,
2692 new_value: T,
2693 new_source: ConfigSource,
2694 new_file: Option<String>,
2695 new_line: Option<usize>,
2696 ) {
2697 fn source_precedence(src: ConfigSource) -> u8 {
2699 match src {
2700 ConfigSource::Default => 0,
2701 ConfigSource::UserConfig => 1,
2702 ConfigSource::PyprojectToml => 2,
2703 ConfigSource::ProjectConfig => 3,
2704 ConfigSource::Cli => 4,
2705 }
2706 }
2707
2708 if source_precedence(new_source) >= source_precedence(self.source) {
2709 self.value = new_value.clone();
2710 self.source = new_source;
2711 self.overrides.push(ConfigOverride {
2712 value: new_value,
2713 source: new_source,
2714 file: new_file,
2715 line: new_line,
2716 });
2717 }
2718 }
2719
2720 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
2721 self.value = value.clone();
2724 self.source = source;
2725 self.overrides.push(ConfigOverride {
2726 value,
2727 source,
2728 file,
2729 line,
2730 });
2731 }
2732}
2733
2734impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
2735 pub fn merge_union(
2738 &mut self,
2739 new_value: Vec<T>,
2740 new_source: ConfigSource,
2741 new_file: Option<String>,
2742 new_line: Option<usize>,
2743 ) {
2744 fn source_precedence(src: ConfigSource) -> u8 {
2745 match src {
2746 ConfigSource::Default => 0,
2747 ConfigSource::UserConfig => 1,
2748 ConfigSource::PyprojectToml => 2,
2749 ConfigSource::ProjectConfig => 3,
2750 ConfigSource::Cli => 4,
2751 }
2752 }
2753
2754 if source_precedence(new_source) >= source_precedence(self.source) {
2755 let mut combined = self.value.clone();
2757 for item in new_value.iter() {
2758 if !combined.contains(item) {
2759 combined.push(item.clone());
2760 }
2761 }
2762
2763 self.value = combined;
2764 self.source = new_source;
2765 self.overrides.push(ConfigOverride {
2766 value: new_value,
2767 source: new_source,
2768 file: new_file,
2769 line: new_line,
2770 });
2771 }
2772 }
2773}
2774
2775#[derive(Debug, Clone)]
2776pub struct SourcedGlobalConfig {
2777 pub enable: SourcedValue<Vec<String>>,
2778 pub disable: SourcedValue<Vec<String>>,
2779 pub exclude: SourcedValue<Vec<String>>,
2780 pub include: SourcedValue<Vec<String>>,
2781 pub respect_gitignore: SourcedValue<bool>,
2782 pub line_length: SourcedValue<LineLength>,
2783 pub output_format: Option<SourcedValue<String>>,
2784 pub fixable: SourcedValue<Vec<String>>,
2785 pub unfixable: SourcedValue<Vec<String>>,
2786 pub flavor: SourcedValue<MarkdownFlavor>,
2787 pub force_exclude: SourcedValue<bool>,
2788 pub cache_dir: Option<SourcedValue<String>>,
2789 pub cache: SourcedValue<bool>,
2790}
2791
2792impl Default for SourcedGlobalConfig {
2793 fn default() -> Self {
2794 SourcedGlobalConfig {
2795 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2796 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2797 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
2798 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
2799 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
2800 line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
2801 output_format: None,
2802 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2803 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2804 flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
2805 force_exclude: SourcedValue::new(false, ConfigSource::Default),
2806 cache_dir: None,
2807 cache: SourcedValue::new(true, ConfigSource::Default),
2808 }
2809 }
2810}
2811
2812#[derive(Debug, Default, Clone)]
2813pub struct SourcedRuleConfig {
2814 pub severity: Option<SourcedValue<crate::rule::Severity>>,
2815 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
2816}
2817
2818#[derive(Debug, Clone)]
2821pub struct SourcedConfigFragment {
2822 pub global: SourcedGlobalConfig,
2823 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2824 pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
2825 pub rules: BTreeMap<String, SourcedRuleConfig>,
2826 pub unknown_keys: Vec<(String, String, Option<String>)>, }
2829
2830impl Default for SourcedConfigFragment {
2831 fn default() -> Self {
2832 Self {
2833 global: SourcedGlobalConfig::default(),
2834 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2835 per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
2836 rules: BTreeMap::new(),
2837 unknown_keys: Vec::new(),
2838 }
2839 }
2840}
2841
2842#[derive(Debug, Clone)]
2860pub struct SourcedConfig<State = ConfigLoaded> {
2861 pub global: SourcedGlobalConfig,
2862 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2863 pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
2864 pub rules: BTreeMap<String, SourcedRuleConfig>,
2865 pub loaded_files: Vec<String>,
2866 pub unknown_keys: Vec<(String, String, Option<String>)>, pub project_root: Option<std::path::PathBuf>,
2869 pub validation_warnings: Vec<ConfigValidationWarning>,
2871 _state: PhantomData<State>,
2873}
2874
2875impl Default for SourcedConfig<ConfigLoaded> {
2876 fn default() -> Self {
2877 Self {
2878 global: SourcedGlobalConfig::default(),
2879 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2880 per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
2881 rules: BTreeMap::new(),
2882 loaded_files: Vec::new(),
2883 unknown_keys: Vec::new(),
2884 project_root: None,
2885 validation_warnings: Vec::new(),
2886 _state: PhantomData,
2887 }
2888 }
2889}
2890
2891impl SourcedConfig<ConfigLoaded> {
2892 fn merge(&mut self, fragment: SourcedConfigFragment) {
2895 self.global.enable.merge_override(
2898 fragment.global.enable.value,
2899 fragment.global.enable.source,
2900 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
2901 fragment.global.enable.overrides.first().and_then(|o| o.line),
2902 );
2903
2904 self.global.disable.merge_union(
2906 fragment.global.disable.value,
2907 fragment.global.disable.source,
2908 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
2909 fragment.global.disable.overrides.first().and_then(|o| o.line),
2910 );
2911
2912 self.global
2915 .disable
2916 .value
2917 .retain(|rule| !self.global.enable.value.contains(rule));
2918 self.global.include.merge_override(
2919 fragment.global.include.value,
2920 fragment.global.include.source,
2921 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
2922 fragment.global.include.overrides.first().and_then(|o| o.line),
2923 );
2924 self.global.exclude.merge_override(
2925 fragment.global.exclude.value,
2926 fragment.global.exclude.source,
2927 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
2928 fragment.global.exclude.overrides.first().and_then(|o| o.line),
2929 );
2930 self.global.respect_gitignore.merge_override(
2931 fragment.global.respect_gitignore.value,
2932 fragment.global.respect_gitignore.source,
2933 fragment
2934 .global
2935 .respect_gitignore
2936 .overrides
2937 .first()
2938 .and_then(|o| o.file.clone()),
2939 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
2940 );
2941 self.global.line_length.merge_override(
2942 fragment.global.line_length.value,
2943 fragment.global.line_length.source,
2944 fragment
2945 .global
2946 .line_length
2947 .overrides
2948 .first()
2949 .and_then(|o| o.file.clone()),
2950 fragment.global.line_length.overrides.first().and_then(|o| o.line),
2951 );
2952 self.global.fixable.merge_override(
2953 fragment.global.fixable.value,
2954 fragment.global.fixable.source,
2955 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
2956 fragment.global.fixable.overrides.first().and_then(|o| o.line),
2957 );
2958 self.global.unfixable.merge_override(
2959 fragment.global.unfixable.value,
2960 fragment.global.unfixable.source,
2961 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
2962 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
2963 );
2964
2965 self.global.flavor.merge_override(
2967 fragment.global.flavor.value,
2968 fragment.global.flavor.source,
2969 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
2970 fragment.global.flavor.overrides.first().and_then(|o| o.line),
2971 );
2972
2973 self.global.force_exclude.merge_override(
2975 fragment.global.force_exclude.value,
2976 fragment.global.force_exclude.source,
2977 fragment
2978 .global
2979 .force_exclude
2980 .overrides
2981 .first()
2982 .and_then(|o| o.file.clone()),
2983 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
2984 );
2985
2986 if let Some(output_format_fragment) = fragment.global.output_format {
2988 if let Some(ref mut output_format) = self.global.output_format {
2989 output_format.merge_override(
2990 output_format_fragment.value,
2991 output_format_fragment.source,
2992 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
2993 output_format_fragment.overrides.first().and_then(|o| o.line),
2994 );
2995 } else {
2996 self.global.output_format = Some(output_format_fragment);
2997 }
2998 }
2999
3000 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
3002 if let Some(ref mut cache_dir) = self.global.cache_dir {
3003 cache_dir.merge_override(
3004 cache_dir_fragment.value,
3005 cache_dir_fragment.source,
3006 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
3007 cache_dir_fragment.overrides.first().and_then(|o| o.line),
3008 );
3009 } else {
3010 self.global.cache_dir = Some(cache_dir_fragment);
3011 }
3012 }
3013
3014 if fragment.global.cache.source != ConfigSource::Default {
3016 self.global.cache.merge_override(
3017 fragment.global.cache.value,
3018 fragment.global.cache.source,
3019 fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
3020 fragment.global.cache.overrides.first().and_then(|o| o.line),
3021 );
3022 }
3023
3024 self.per_file_ignores.merge_override(
3026 fragment.per_file_ignores.value,
3027 fragment.per_file_ignores.source,
3028 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
3029 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
3030 );
3031
3032 self.per_file_flavor.merge_override(
3034 fragment.per_file_flavor.value,
3035 fragment.per_file_flavor.source,
3036 fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
3037 fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
3038 );
3039
3040 for (rule_name, rule_fragment) in fragment.rules {
3042 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
3044
3045 if let Some(severity_fragment) = rule_fragment.severity {
3047 if let Some(ref mut existing_severity) = rule_entry.severity {
3048 existing_severity.merge_override(
3049 severity_fragment.value,
3050 severity_fragment.source,
3051 severity_fragment.overrides.first().and_then(|o| o.file.clone()),
3052 severity_fragment.overrides.first().and_then(|o| o.line),
3053 );
3054 } else {
3055 rule_entry.severity = Some(severity_fragment);
3056 }
3057 }
3058
3059 for (key, sourced_value_fragment) in rule_fragment.values {
3061 let sv_entry = rule_entry
3062 .values
3063 .entry(key.clone())
3064 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
3065 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
3066 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
3067 sv_entry.merge_override(
3068 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
3073 }
3074 }
3075
3076 for (section, key, file_path) in fragment.unknown_keys {
3078 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
3080 self.unknown_keys.push((section, key, file_path));
3081 }
3082 }
3083 }
3084
3085 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
3087 Self::load_with_discovery(config_path, cli_overrides, false)
3088 }
3089
3090 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
3093 let mut current = if start_dir.is_relative() {
3095 std::env::current_dir()
3096 .map(|cwd| cwd.join(start_dir))
3097 .unwrap_or_else(|_| start_dir.to_path_buf())
3098 } else {
3099 start_dir.to_path_buf()
3100 };
3101 const MAX_DEPTH: usize = 100;
3102
3103 for _ in 0..MAX_DEPTH {
3104 if current.join(".git").exists() {
3105 log::debug!("[rumdl-config] Found .git at: {}", current.display());
3106 return current;
3107 }
3108
3109 match current.parent() {
3110 Some(parent) => current = parent.to_path_buf(),
3111 None => break,
3112 }
3113 }
3114
3115 log::debug!(
3117 "[rumdl-config] No .git found, using config location as project root: {}",
3118 start_dir.display()
3119 );
3120 start_dir.to_path_buf()
3121 }
3122
3123 fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
3129 use std::env;
3130
3131 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
3132 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
3135 Ok(dir) => dir,
3136 Err(e) => {
3137 log::debug!("[rumdl-config] Failed to get current directory: {e}");
3138 return None;
3139 }
3140 };
3141
3142 let mut current_dir = start_dir.clone();
3143 let mut depth = 0;
3144 let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3145
3146 loop {
3147 if depth >= MAX_DEPTH {
3148 log::debug!("[rumdl-config] Maximum traversal depth reached");
3149 break;
3150 }
3151
3152 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
3153
3154 if found_config.is_none() {
3156 for config_name in CONFIG_FILES {
3157 let config_path = current_dir.join(config_name);
3158
3159 if config_path.exists() {
3160 if *config_name == "pyproject.toml" {
3162 if let Ok(content) = std::fs::read_to_string(&config_path) {
3163 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
3164 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
3165 found_config = Some((config_path.clone(), current_dir.clone()));
3167 break;
3168 }
3169 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
3170 continue;
3171 }
3172 } else {
3173 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
3174 found_config = Some((config_path.clone(), current_dir.clone()));
3176 break;
3177 }
3178 }
3179 }
3180 }
3181
3182 if current_dir.join(".git").exists() {
3184 log::debug!("[rumdl-config] Stopping at .git directory");
3185 break;
3186 }
3187
3188 match current_dir.parent() {
3190 Some(parent) => {
3191 current_dir = parent.to_owned();
3192 depth += 1;
3193 }
3194 None => {
3195 log::debug!("[rumdl-config] Reached filesystem root");
3196 break;
3197 }
3198 }
3199 }
3200
3201 if let Some((config_path, config_dir)) = found_config {
3203 let project_root = Self::find_project_root_from(&config_dir);
3204 return Some((config_path, project_root));
3205 }
3206
3207 None
3208 }
3209
3210 fn discover_markdownlint_config_upward() -> Option<std::path::PathBuf> {
3214 use std::env;
3215
3216 const MAX_DEPTH: usize = 100;
3217
3218 let start_dir = match env::current_dir() {
3219 Ok(dir) => dir,
3220 Err(e) => {
3221 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
3222 return None;
3223 }
3224 };
3225
3226 let mut current_dir = start_dir.clone();
3227 let mut depth = 0;
3228
3229 loop {
3230 if depth >= MAX_DEPTH {
3231 log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
3232 break;
3233 }
3234
3235 log::debug!(
3236 "[rumdl-config] Searching for markdownlint config in: {}",
3237 current_dir.display()
3238 );
3239
3240 for config_name in MARKDOWNLINT_CONFIG_FILES {
3242 let config_path = current_dir.join(config_name);
3243 if config_path.exists() {
3244 log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
3245 return Some(config_path);
3246 }
3247 }
3248
3249 if current_dir.join(".git").exists() {
3251 log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
3252 break;
3253 }
3254
3255 match current_dir.parent() {
3257 Some(parent) => {
3258 current_dir = parent.to_owned();
3259 depth += 1;
3260 }
3261 None => {
3262 log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
3263 break;
3264 }
3265 }
3266 }
3267
3268 None
3269 }
3270
3271 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
3273 let config_dir = config_dir.join("rumdl");
3274
3275 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
3277
3278 log::debug!(
3279 "[rumdl-config] Checking for user configuration in: {}",
3280 config_dir.display()
3281 );
3282
3283 for filename in USER_CONFIG_FILES {
3284 let config_path = config_dir.join(filename);
3285
3286 if config_path.exists() {
3287 if *filename == "pyproject.toml" {
3289 if let Ok(content) = std::fs::read_to_string(&config_path) {
3290 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
3291 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
3292 return Some(config_path);
3293 }
3294 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
3295 continue;
3296 }
3297 } else {
3298 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
3299 return Some(config_path);
3300 }
3301 }
3302 }
3303
3304 log::debug!(
3305 "[rumdl-config] No user configuration found in: {}",
3306 config_dir.display()
3307 );
3308 None
3309 }
3310
3311 #[cfg(feature = "native")]
3314 fn user_configuration_path() -> Option<std::path::PathBuf> {
3315 use etcetera::{BaseStrategy, choose_base_strategy};
3316
3317 match choose_base_strategy() {
3318 Ok(strategy) => {
3319 let config_dir = strategy.config_dir();
3320 Self::user_configuration_path_impl(&config_dir)
3321 }
3322 Err(e) => {
3323 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
3324 None
3325 }
3326 }
3327 }
3328
3329 #[cfg(not(feature = "native"))]
3331 fn user_configuration_path() -> Option<std::path::PathBuf> {
3332 None
3333 }
3334
3335 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
3337 let path_obj = Path::new(path);
3338 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
3339 let path_str = path.to_string();
3340
3341 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
3342
3343 if let Some(config_parent) = path_obj.parent() {
3345 let project_root = Self::find_project_root_from(config_parent);
3346 log::debug!(
3347 "[rumdl-config] Project root (from explicit config): {}",
3348 project_root.display()
3349 );
3350 sourced_config.project_root = Some(project_root);
3351 }
3352
3353 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
3355
3356 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
3357 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
3358 source: e,
3359 path: path_str.clone(),
3360 })?;
3361 if filename == "pyproject.toml" {
3362 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3363 sourced_config.merge(fragment);
3364 sourced_config.loaded_files.push(path_str);
3365 }
3366 } else {
3367 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3368 sourced_config.merge(fragment);
3369 sourced_config.loaded_files.push(path_str);
3370 }
3371 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
3372 || path_str.ends_with(".json")
3373 || path_str.ends_with(".jsonc")
3374 || path_str.ends_with(".yaml")
3375 || path_str.ends_with(".yml")
3376 {
3377 let fragment = load_from_markdownlint(&path_str)?;
3379 sourced_config.merge(fragment);
3380 sourced_config.loaded_files.push(path_str);
3381 } else {
3382 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
3384 source: e,
3385 path: path_str.clone(),
3386 })?;
3387 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3388 sourced_config.merge(fragment);
3389 sourced_config.loaded_files.push(path_str);
3390 }
3391
3392 Ok(())
3393 }
3394
3395 fn load_user_config_as_fallback(
3397 sourced_config: &mut Self,
3398 user_config_dir: Option<&Path>,
3399 ) -> Result<(), ConfigError> {
3400 let user_config_path = if let Some(dir) = user_config_dir {
3401 Self::user_configuration_path_impl(dir)
3402 } else {
3403 Self::user_configuration_path()
3404 };
3405
3406 if let Some(user_config_path) = user_config_path {
3407 let path_str = user_config_path.display().to_string();
3408 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
3409
3410 log::debug!("[rumdl-config] Loading user config as fallback: {path_str}");
3411
3412 if filename == "pyproject.toml" {
3413 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
3414 source: e,
3415 path: path_str.clone(),
3416 })?;
3417 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3418 sourced_config.merge(fragment);
3419 sourced_config.loaded_files.push(path_str);
3420 }
3421 } else {
3422 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
3423 source: e,
3424 path: path_str.clone(),
3425 })?;
3426 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
3427 sourced_config.merge(fragment);
3428 sourced_config.loaded_files.push(path_str);
3429 }
3430 } else {
3431 log::debug!("[rumdl-config] No user configuration file found");
3432 }
3433
3434 Ok(())
3435 }
3436
3437 #[doc(hidden)]
3439 pub fn load_with_discovery_impl(
3440 config_path: Option<&str>,
3441 cli_overrides: Option<&SourcedGlobalConfig>,
3442 skip_auto_discovery: bool,
3443 user_config_dir: Option<&Path>,
3444 ) -> Result<Self, ConfigError> {
3445 use std::env;
3446 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
3447
3448 let mut sourced_config = SourcedConfig::default();
3449
3450 if let Some(path) = config_path {
3463 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
3465 Self::load_explicit_config(&mut sourced_config, path)?;
3466 } else if skip_auto_discovery {
3467 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
3468 } else {
3470 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
3472
3473 if let Some((config_file, project_root)) = Self::discover_config_upward() {
3475 let path_str = config_file.display().to_string();
3477 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
3478
3479 log::debug!("[rumdl-config] Found project config: {path_str}");
3480 log::debug!("[rumdl-config] Project root: {}", project_root.display());
3481
3482 sourced_config.project_root = Some(project_root);
3483
3484 if filename == "pyproject.toml" {
3485 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
3486 source: e,
3487 path: path_str.clone(),
3488 })?;
3489 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3490 sourced_config.merge(fragment);
3491 sourced_config.loaded_files.push(path_str);
3492 }
3493 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
3494 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
3495 source: e,
3496 path: path_str.clone(),
3497 })?;
3498 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3499 sourced_config.merge(fragment);
3500 sourced_config.loaded_files.push(path_str);
3501 }
3502 } else {
3503 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
3505
3506 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward() {
3507 let path_str = markdownlint_path.display().to_string();
3508 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
3509 match load_from_markdownlint(&path_str) {
3510 Ok(fragment) => {
3511 sourced_config.merge(fragment);
3512 sourced_config.loaded_files.push(path_str);
3513 }
3514 Err(_e) => {
3515 log::debug!("[rumdl-config] Failed to load markdownlint config, trying user config");
3516 Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
3517 }
3518 }
3519 } else {
3520 log::debug!("[rumdl-config] No project config found, using user config as fallback");
3522 Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
3523 }
3524 }
3525 }
3526
3527 if let Some(cli) = cli_overrides {
3529 sourced_config
3530 .global
3531 .enable
3532 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
3533 sourced_config
3534 .global
3535 .disable
3536 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
3537 sourced_config
3538 .global
3539 .exclude
3540 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
3541 sourced_config
3542 .global
3543 .include
3544 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
3545 sourced_config.global.respect_gitignore.merge_override(
3546 cli.respect_gitignore.value,
3547 ConfigSource::Cli,
3548 None,
3549 None,
3550 );
3551 sourced_config
3552 .global
3553 .fixable
3554 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
3555 sourced_config
3556 .global
3557 .unfixable
3558 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
3559 }
3561
3562 Ok(sourced_config)
3565 }
3566
3567 pub fn load_with_discovery(
3570 config_path: Option<&str>,
3571 cli_overrides: Option<&SourcedGlobalConfig>,
3572 skip_auto_discovery: bool,
3573 ) -> Result<Self, ConfigError> {
3574 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
3575 }
3576
3577 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
3591 let warnings = validate_config_sourced_internal(&self, registry);
3592
3593 Ok(SourcedConfig {
3594 global: self.global,
3595 per_file_ignores: self.per_file_ignores,
3596 per_file_flavor: self.per_file_flavor,
3597 rules: self.rules,
3598 loaded_files: self.loaded_files,
3599 unknown_keys: self.unknown_keys,
3600 project_root: self.project_root,
3601 validation_warnings: warnings,
3602 _state: PhantomData,
3603 })
3604 }
3605
3606 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
3611 let validated = self.validate(registry)?;
3612 let warnings = validated.validation_warnings.clone();
3613 Ok((validated.into(), warnings))
3614 }
3615
3616 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
3627 SourcedConfig {
3628 global: self.global,
3629 per_file_ignores: self.per_file_ignores,
3630 per_file_flavor: self.per_file_flavor,
3631 rules: self.rules,
3632 loaded_files: self.loaded_files,
3633 unknown_keys: self.unknown_keys,
3634 project_root: self.project_root,
3635 validation_warnings: Vec::new(),
3636 _state: PhantomData,
3637 }
3638 }
3639}
3640
3641impl From<SourcedConfig<ConfigValidated>> for Config {
3646 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
3647 let mut rules = BTreeMap::new();
3648 for (rule_name, sourced_rule_cfg) in sourced.rules {
3649 let normalized_rule_name = rule_name.to_ascii_uppercase();
3651 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
3652 let mut values = BTreeMap::new();
3653 for (key, sourced_val) in sourced_rule_cfg.values {
3654 values.insert(key, sourced_val.value);
3655 }
3656 rules.insert(normalized_rule_name, RuleConfig { severity, values });
3657 }
3658 #[allow(deprecated)]
3659 let global = GlobalConfig {
3660 enable: sourced.global.enable.value,
3661 disable: sourced.global.disable.value,
3662 exclude: sourced.global.exclude.value,
3663 include: sourced.global.include.value,
3664 respect_gitignore: sourced.global.respect_gitignore.value,
3665 line_length: sourced.global.line_length.value,
3666 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
3667 fixable: sourced.global.fixable.value,
3668 unfixable: sourced.global.unfixable.value,
3669 flavor: sourced.global.flavor.value,
3670 force_exclude: sourced.global.force_exclude.value,
3671 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
3672 cache: sourced.global.cache.value,
3673 };
3674 Config {
3675 global,
3676 per_file_ignores: sourced.per_file_ignores.value,
3677 per_file_flavor: sourced.per_file_flavor.value,
3678 rules,
3679 project_root: sourced.project_root,
3680 }
3681 }
3682}
3683
3684pub struct RuleRegistry {
3686 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
3688 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
3690}
3691
3692impl RuleRegistry {
3693 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
3695 let mut rule_schemas = std::collections::BTreeMap::new();
3696 let mut rule_aliases = std::collections::BTreeMap::new();
3697
3698 for rule in rules {
3699 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
3700 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
3702 norm_name
3703 } else {
3704 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
3706 norm_name
3707 };
3708
3709 if let Some(aliases) = rule.config_aliases() {
3711 rule_aliases.insert(norm_name, aliases);
3712 }
3713 }
3714
3715 RuleRegistry {
3716 rule_schemas,
3717 rule_aliases,
3718 }
3719 }
3720
3721 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
3723 self.rule_schemas.keys().cloned().collect()
3724 }
3725
3726 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
3728 self.rule_schemas.get(rule).map(|schema| {
3729 let mut all_keys = std::collections::BTreeSet::new();
3730
3731 all_keys.insert("severity".to_string());
3733
3734 for key in schema.keys() {
3736 all_keys.insert(key.clone());
3737 }
3738
3739 for key in schema.keys() {
3741 all_keys.insert(key.replace('_', "-"));
3743 all_keys.insert(key.replace('-', "_"));
3745 all_keys.insert(normalize_key(key));
3747 }
3748
3749 if let Some(aliases) = self.rule_aliases.get(rule) {
3751 for alias_key in aliases.keys() {
3752 all_keys.insert(alias_key.clone());
3753 all_keys.insert(alias_key.replace('_', "-"));
3755 all_keys.insert(alias_key.replace('-', "_"));
3756 all_keys.insert(normalize_key(alias_key));
3757 }
3758 }
3759
3760 all_keys
3761 })
3762 }
3763
3764 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
3766 if let Some(schema) = self.rule_schemas.get(rule) {
3767 if let Some(aliases) = self.rule_aliases.get(rule)
3769 && let Some(canonical_key) = aliases.get(key)
3770 {
3771 if let Some(value) = schema.get(canonical_key) {
3773 return Some(value);
3774 }
3775 }
3776
3777 if let Some(value) = schema.get(key) {
3779 return Some(value);
3780 }
3781
3782 let key_variants = [
3784 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
3788
3789 for variant in &key_variants {
3790 if let Some(value) = schema.get(variant) {
3791 return Some(value);
3792 }
3793 }
3794 }
3795 None
3796 }
3797
3798 pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
3805 let normalized = normalize_key(name);
3807 if self.rule_schemas.contains_key(&normalized) {
3808 return Some(normalized);
3809 }
3810
3811 resolve_rule_name_alias(name).map(|s| s.to_string())
3813 }
3814}
3815
3816pub static RULE_ALIAS_MAP: phf::Map<&'static str, &'static str> = phf::phf_map! {
3819 "MD001" => "MD001",
3821 "MD003" => "MD003",
3822 "MD004" => "MD004",
3823 "MD005" => "MD005",
3824 "MD007" => "MD007",
3825 "MD009" => "MD009",
3826 "MD010" => "MD010",
3827 "MD011" => "MD011",
3828 "MD012" => "MD012",
3829 "MD013" => "MD013",
3830 "MD014" => "MD014",
3831 "MD018" => "MD018",
3832 "MD019" => "MD019",
3833 "MD020" => "MD020",
3834 "MD021" => "MD021",
3835 "MD022" => "MD022",
3836 "MD023" => "MD023",
3837 "MD024" => "MD024",
3838 "MD025" => "MD025",
3839 "MD026" => "MD026",
3840 "MD027" => "MD027",
3841 "MD028" => "MD028",
3842 "MD029" => "MD029",
3843 "MD030" => "MD030",
3844 "MD031" => "MD031",
3845 "MD032" => "MD032",
3846 "MD033" => "MD033",
3847 "MD034" => "MD034",
3848 "MD035" => "MD035",
3849 "MD036" => "MD036",
3850 "MD037" => "MD037",
3851 "MD038" => "MD038",
3852 "MD039" => "MD039",
3853 "MD040" => "MD040",
3854 "MD041" => "MD041",
3855 "MD042" => "MD042",
3856 "MD043" => "MD043",
3857 "MD044" => "MD044",
3858 "MD045" => "MD045",
3859 "MD046" => "MD046",
3860 "MD047" => "MD047",
3861 "MD048" => "MD048",
3862 "MD049" => "MD049",
3863 "MD050" => "MD050",
3864 "MD051" => "MD051",
3865 "MD052" => "MD052",
3866 "MD053" => "MD053",
3867 "MD054" => "MD054",
3868 "MD055" => "MD055",
3869 "MD056" => "MD056",
3870 "MD057" => "MD057",
3871 "MD058" => "MD058",
3872 "MD059" => "MD059",
3873 "MD060" => "MD060",
3874 "MD061" => "MD061",
3875 "MD062" => "MD062",
3876 "MD063" => "MD063",
3877 "MD064" => "MD064",
3878 "MD065" => "MD065",
3879 "MD066" => "MD066",
3880 "MD067" => "MD067",
3881 "MD068" => "MD068",
3882 "MD069" => "MD069",
3883 "MD070" => "MD070",
3884 "MD071" => "MD071",
3885 "MD072" => "MD072",
3886
3887 "HEADING-INCREMENT" => "MD001",
3889 "HEADING-STYLE" => "MD003",
3890 "UL-STYLE" => "MD004",
3891 "LIST-INDENT" => "MD005",
3892 "UL-INDENT" => "MD007",
3893 "NO-TRAILING-SPACES" => "MD009",
3894 "NO-HARD-TABS" => "MD010",
3895 "NO-REVERSED-LINKS" => "MD011",
3896 "NO-MULTIPLE-BLANKS" => "MD012",
3897 "LINE-LENGTH" => "MD013",
3898 "COMMANDS-SHOW-OUTPUT" => "MD014",
3899 "NO-MISSING-SPACE-ATX" => "MD018",
3900 "NO-MULTIPLE-SPACE-ATX" => "MD019",
3901 "NO-MISSING-SPACE-CLOSED-ATX" => "MD020",
3902 "NO-MULTIPLE-SPACE-CLOSED-ATX" => "MD021",
3903 "BLANKS-AROUND-HEADINGS" => "MD022",
3904 "HEADING-START-LEFT" => "MD023",
3905 "NO-DUPLICATE-HEADING" => "MD024",
3906 "SINGLE-TITLE" => "MD025",
3907 "SINGLE-H1" => "MD025",
3908 "NO-TRAILING-PUNCTUATION" => "MD026",
3909 "NO-MULTIPLE-SPACE-BLOCKQUOTE" => "MD027",
3910 "NO-BLANKS-BLOCKQUOTE" => "MD028",
3911 "OL-PREFIX" => "MD029",
3912 "LIST-MARKER-SPACE" => "MD030",
3913 "BLANKS-AROUND-FENCES" => "MD031",
3914 "BLANKS-AROUND-LISTS" => "MD032",
3915 "NO-INLINE-HTML" => "MD033",
3916 "NO-BARE-URLS" => "MD034",
3917 "HR-STYLE" => "MD035",
3918 "NO-EMPHASIS-AS-HEADING" => "MD036",
3919 "NO-SPACE-IN-EMPHASIS" => "MD037",
3920 "NO-SPACE-IN-CODE" => "MD038",
3921 "NO-SPACE-IN-LINKS" => "MD039",
3922 "FENCED-CODE-LANGUAGE" => "MD040",
3923 "FIRST-LINE-HEADING" => "MD041",
3924 "FIRST-LINE-H1" => "MD041",
3925 "NO-EMPTY-LINKS" => "MD042",
3926 "REQUIRED-HEADINGS" => "MD043",
3927 "PROPER-NAMES" => "MD044",
3928 "NO-ALT-TEXT" => "MD045",
3929 "CODE-BLOCK-STYLE" => "MD046",
3930 "SINGLE-TRAILING-NEWLINE" => "MD047",
3931 "CODE-FENCE-STYLE" => "MD048",
3932 "EMPHASIS-STYLE" => "MD049",
3933 "STRONG-STYLE" => "MD050",
3934 "LINK-FRAGMENTS" => "MD051",
3935 "REFERENCE-LINKS-IMAGES" => "MD052",
3936 "LINK-IMAGE-REFERENCE-DEFINITIONS" => "MD053",
3937 "LINK-IMAGE-STYLE" => "MD054",
3938 "TABLE-PIPE-STYLE" => "MD055",
3939 "TABLE-COLUMN-COUNT" => "MD056",
3940 "EXISTING-RELATIVE-LINKS" => "MD057",
3941 "BLANKS-AROUND-TABLES" => "MD058",
3942 "TABLE-CELL-ALIGNMENT" => "MD059",
3943 "TABLE-FORMAT" => "MD060",
3944 "FORBIDDEN-TERMS" => "MD061",
3945 "LINK-DESTINATION-WHITESPACE" => "MD062",
3946 "HEADING-CAPITALIZATION" => "MD063",
3947 "NO-MULTIPLE-CONSECUTIVE-SPACES" => "MD064",
3948 "BLANKS-AROUND-HORIZONTAL-RULES" => "MD065",
3949 "FOOTNOTE-VALIDATION" => "MD066",
3950 "FOOTNOTE-DEFINITION-ORDER" => "MD067",
3951 "EMPTY-FOOTNOTE-DEFINITION" => "MD068",
3952 "NO-DUPLICATE-LIST-MARKERS" => "MD069",
3953 "NESTED-CODE-FENCE" => "MD070",
3954 "BLANK-LINE-AFTER-FRONTMATTER" => "MD071",
3955 "FRONTMATTER-KEY-SORT" => "MD072",
3956};
3957
3958pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
3962 let normalized_key = key.to_ascii_uppercase().replace('_', "-");
3964
3965 RULE_ALIAS_MAP.get(normalized_key.as_str()).copied()
3967}
3968
3969pub fn resolve_rule_name(name: &str) -> String {
3977 resolve_rule_name_alias(name)
3978 .map(|s| s.to_string())
3979 .unwrap_or_else(|| normalize_key(name))
3980}
3981
3982pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
3986 input
3987 .split(',')
3988 .map(|s| s.trim())
3989 .filter(|s| !s.is_empty())
3990 .map(resolve_rule_name)
3991 .collect()
3992}
3993
3994pub fn validate_cli_rule_names(
4000 enable: Option<&str>,
4001 disable: Option<&str>,
4002 extend_enable: Option<&str>,
4003 extend_disable: Option<&str>,
4004) -> Vec<ConfigValidationWarning> {
4005 let mut warnings = Vec::new();
4006 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4007
4008 let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
4009 for name in input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
4010 if name.eq_ignore_ascii_case("all") {
4012 continue;
4013 }
4014 if resolve_rule_name_alias(name).is_none() {
4015 let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
4016 let formatted = if suggestion.starts_with("MD") {
4017 suggestion
4018 } else {
4019 suggestion.to_lowercase()
4020 };
4021 format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
4022 } else {
4023 format!("Unknown rule in {flag_name}: {name}")
4024 };
4025 warnings.push(ConfigValidationWarning {
4026 message,
4027 rule: Some(name.to_string()),
4028 key: None,
4029 });
4030 }
4031 }
4032 };
4033
4034 if let Some(e) = enable {
4035 validate_list(e, "--enable", &mut warnings);
4036 }
4037 if let Some(d) = disable {
4038 validate_list(d, "--disable", &mut warnings);
4039 }
4040 if let Some(ee) = extend_enable {
4041 validate_list(ee, "--extend-enable", &mut warnings);
4042 }
4043 if let Some(ed) = extend_disable {
4044 validate_list(ed, "--extend-disable", &mut warnings);
4045 }
4046
4047 warnings
4048}
4049
4050pub fn is_valid_rule_name(name: &str) -> bool {
4054 if name.eq_ignore_ascii_case("all") {
4056 return true;
4057 }
4058 resolve_rule_name_alias(name).is_some()
4059}
4060
4061#[derive(Debug, Clone)]
4063pub struct ConfigValidationWarning {
4064 pub message: String,
4065 pub rule: Option<String>,
4066 pub key: Option<String>,
4067}
4068
4069fn validate_config_sourced_internal<S>(
4072 sourced: &SourcedConfig<S>,
4073 registry: &RuleRegistry,
4074) -> Vec<ConfigValidationWarning> {
4075 let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
4076
4077 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4079
4080 for rule_name in &sourced.global.enable.value {
4081 if !is_valid_rule_name(rule_name) {
4082 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4083 let formatted = if suggestion.starts_with("MD") {
4084 suggestion
4085 } else {
4086 suggestion.to_lowercase()
4087 };
4088 format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
4089 } else {
4090 format!("Unknown rule in global.enable: {rule_name}")
4091 };
4092 warnings.push(ConfigValidationWarning {
4093 message,
4094 rule: Some(rule_name.clone()),
4095 key: None,
4096 });
4097 }
4098 }
4099
4100 for rule_name in &sourced.global.disable.value {
4101 if !is_valid_rule_name(rule_name) {
4102 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4103 let formatted = if suggestion.starts_with("MD") {
4104 suggestion
4105 } else {
4106 suggestion.to_lowercase()
4107 };
4108 format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
4109 } else {
4110 format!("Unknown rule in global.disable: {rule_name}")
4111 };
4112 warnings.push(ConfigValidationWarning {
4113 message,
4114 rule: Some(rule_name.clone()),
4115 key: None,
4116 });
4117 }
4118 }
4119
4120 warnings
4121}
4122
4123fn validate_config_sourced_impl(
4125 rules: &BTreeMap<String, SourcedRuleConfig>,
4126 unknown_keys: &[(String, String, Option<String>)],
4127 registry: &RuleRegistry,
4128) -> Vec<ConfigValidationWarning> {
4129 let mut warnings = Vec::new();
4130 let known_rules = registry.rule_names();
4131 for rule in rules.keys() {
4133 if !known_rules.contains(rule) {
4134 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4136 let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
4137 let formatted_suggestion = if suggestion.starts_with("MD") {
4139 suggestion
4140 } else {
4141 suggestion.to_lowercase()
4142 };
4143 format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
4144 } else {
4145 format!("Unknown rule in config: {rule}")
4146 };
4147 warnings.push(ConfigValidationWarning {
4148 message,
4149 rule: Some(rule.clone()),
4150 key: None,
4151 });
4152 }
4153 }
4154 for (rule, rule_cfg) in rules {
4156 if let Some(valid_keys) = registry.config_keys_for(rule) {
4157 for key in rule_cfg.values.keys() {
4158 if !valid_keys.contains(key) {
4159 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
4160 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
4161 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
4162 } else {
4163 format!("Unknown option for rule {rule}: {key}")
4164 };
4165 warnings.push(ConfigValidationWarning {
4166 message,
4167 rule: Some(rule.clone()),
4168 key: Some(key.clone()),
4169 });
4170 } else {
4171 if let Some(expected) = registry.expected_value_for(rule, key) {
4173 let actual = &rule_cfg.values[key].value;
4174 if !toml_value_type_matches(expected, actual) {
4175 warnings.push(ConfigValidationWarning {
4176 message: format!(
4177 "Type mismatch for {}.{}: expected {}, got {}",
4178 rule,
4179 key,
4180 toml_type_name(expected),
4181 toml_type_name(actual)
4182 ),
4183 rule: Some(rule.clone()),
4184 key: Some(key.clone()),
4185 });
4186 }
4187 }
4188 }
4189 }
4190 }
4191 }
4192 let known_global_keys = vec![
4194 "enable".to_string(),
4195 "disable".to_string(),
4196 "include".to_string(),
4197 "exclude".to_string(),
4198 "respect-gitignore".to_string(),
4199 "line-length".to_string(),
4200 "fixable".to_string(),
4201 "unfixable".to_string(),
4202 "flavor".to_string(),
4203 "force-exclude".to_string(),
4204 "output-format".to_string(),
4205 "cache-dir".to_string(),
4206 "cache".to_string(),
4207 ];
4208
4209 for (section, key, file_path) in unknown_keys {
4210 let display_path = file_path.as_ref().map(|p| to_relative_display_path(p));
4212
4213 if section.contains("[global]") || section.contains("[tool.rumdl]") {
4214 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
4215 if let Some(ref path) = display_path {
4216 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
4217 } else {
4218 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
4219 }
4220 } else if let Some(ref path) = display_path {
4221 format!("Unknown global option in {path}: {key}")
4222 } else {
4223 format!("Unknown global option: {key}")
4224 };
4225 warnings.push(ConfigValidationWarning {
4226 message,
4227 rule: None,
4228 key: Some(key.clone()),
4229 });
4230 } else if !key.is_empty() {
4231 continue;
4233 } else {
4234 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
4236 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4237 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4238 let formatted_suggestion = if suggestion.starts_with("MD") {
4240 suggestion
4241 } else {
4242 suggestion.to_lowercase()
4243 };
4244 if let Some(ref path) = display_path {
4245 format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
4246 } else {
4247 format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
4248 }
4249 } else if let Some(ref path) = display_path {
4250 format!("Unknown rule in {path}: {rule_name}")
4251 } else {
4252 format!("Unknown rule in config: {rule_name}")
4253 };
4254 warnings.push(ConfigValidationWarning {
4255 message,
4256 rule: None,
4257 key: None,
4258 });
4259 }
4260 }
4261 warnings
4262}
4263
4264fn to_relative_display_path(path: &str) -> String {
4269 let file_path = Path::new(path);
4270
4271 if let Ok(cwd) = std::env::current_dir() {
4273 if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
4275 && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
4276 {
4277 return relative.to_string_lossy().to_string();
4278 }
4279
4280 if let Ok(relative) = file_path.strip_prefix(&cwd) {
4282 return relative.to_string_lossy().to_string();
4283 }
4284 }
4285
4286 path.to_string()
4288}
4289
4290pub fn validate_config_sourced(
4296 sourced: &SourcedConfig<ConfigLoaded>,
4297 registry: &RuleRegistry,
4298) -> Vec<ConfigValidationWarning> {
4299 validate_config_sourced_internal(sourced, registry)
4300}
4301
4302pub fn validate_config_sourced_validated(
4306 sourced: &SourcedConfig<ConfigValidated>,
4307 _registry: &RuleRegistry,
4308) -> Vec<ConfigValidationWarning> {
4309 sourced.validation_warnings.clone()
4310}
4311
4312fn toml_type_name(val: &toml::Value) -> &'static str {
4313 match val {
4314 toml::Value::String(_) => "string",
4315 toml::Value::Integer(_) => "integer",
4316 toml::Value::Float(_) => "float",
4317 toml::Value::Boolean(_) => "boolean",
4318 toml::Value::Array(_) => "array",
4319 toml::Value::Table(_) => "table",
4320 toml::Value::Datetime(_) => "datetime",
4321 }
4322}
4323
4324fn levenshtein_distance(s1: &str, s2: &str) -> usize {
4326 let len1 = s1.len();
4327 let len2 = s2.len();
4328
4329 if len1 == 0 {
4330 return len2;
4331 }
4332 if len2 == 0 {
4333 return len1;
4334 }
4335
4336 let s1_chars: Vec<char> = s1.chars().collect();
4337 let s2_chars: Vec<char> = s2.chars().collect();
4338
4339 let mut prev_row: Vec<usize> = (0..=len2).collect();
4340 let mut curr_row = vec![0; len2 + 1];
4341
4342 for i in 1..=len1 {
4343 curr_row[0] = i;
4344 for j in 1..=len2 {
4345 let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
4346 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
4350 std::mem::swap(&mut prev_row, &mut curr_row);
4351 }
4352
4353 prev_row[len2]
4354}
4355
4356pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
4358 let unknown_lower = unknown.to_lowercase();
4359 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
4362
4363 for valid in valid_keys {
4364 let valid_lower = valid.to_lowercase();
4365 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
4366
4367 if distance <= max_distance {
4368 if let Some((_, best_dist)) = &best_match {
4369 if distance < *best_dist {
4370 best_match = Some((valid.clone(), distance));
4371 }
4372 } else {
4373 best_match = Some((valid.clone(), distance));
4374 }
4375 }
4376 }
4377
4378 best_match.map(|(key, _)| key)
4379}
4380
4381fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
4382 use toml::Value::*;
4383 match (expected, actual) {
4384 (String(_), String(_)) => true,
4385 (Integer(_), Integer(_)) => true,
4386 (Float(_), Float(_)) => true,
4387 (Boolean(_), Boolean(_)) => true,
4388 (Array(_), Array(_)) => true,
4389 (Table(_), Table(_)) => true,
4390 (Datetime(_), Datetime(_)) => true,
4391 (Float(_), Integer(_)) => true,
4393 _ => false,
4394 }
4395}
4396
4397fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
4399 let display_path = to_relative_display_path(path);
4400 let doc: toml::Value = toml::from_str(content)
4401 .map_err(|e| ConfigError::ParseError(format!("{display_path}: Failed to parse TOML: {e}")))?;
4402 let mut fragment = SourcedConfigFragment::default();
4403 let source = ConfigSource::PyprojectToml;
4404 let file = Some(path.to_string());
4405
4406 let all_rules = rules::all_rules(&Config::default());
4408 let registry = RuleRegistry::from_rules(&all_rules);
4409
4410 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
4412 && let Some(rumdl_table) = rumdl_config.as_table()
4413 {
4414 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
4416 if let Some(enable) = table.get("enable")
4418 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
4419 {
4420 let normalized_values = values
4422 .into_iter()
4423 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4424 .collect();
4425 fragment
4426 .global
4427 .enable
4428 .push_override(normalized_values, source, file.clone(), None);
4429 }
4430
4431 if let Some(disable) = table.get("disable")
4432 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
4433 {
4434 let normalized_values: Vec<String> = values
4436 .into_iter()
4437 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4438 .collect();
4439 fragment
4440 .global
4441 .disable
4442 .push_override(normalized_values, source, file.clone(), None);
4443 }
4444
4445 if let Some(include) = table.get("include")
4446 && let Ok(values) = Vec::<String>::deserialize(include.clone())
4447 {
4448 fragment
4449 .global
4450 .include
4451 .push_override(values, source, file.clone(), None);
4452 }
4453
4454 if let Some(exclude) = table.get("exclude")
4455 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
4456 {
4457 fragment
4458 .global
4459 .exclude
4460 .push_override(values, source, file.clone(), None);
4461 }
4462
4463 if let Some(respect_gitignore) = table
4464 .get("respect-gitignore")
4465 .or_else(|| table.get("respect_gitignore"))
4466 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
4467 {
4468 fragment
4469 .global
4470 .respect_gitignore
4471 .push_override(value, source, file.clone(), None);
4472 }
4473
4474 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
4475 && let Ok(value) = bool::deserialize(force_exclude.clone())
4476 {
4477 fragment
4478 .global
4479 .force_exclude
4480 .push_override(value, source, file.clone(), None);
4481 }
4482
4483 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
4484 && let Ok(value) = String::deserialize(output_format.clone())
4485 {
4486 if fragment.global.output_format.is_none() {
4487 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
4488 } else {
4489 fragment
4490 .global
4491 .output_format
4492 .as_mut()
4493 .unwrap()
4494 .push_override(value, source, file.clone(), None);
4495 }
4496 }
4497
4498 if let Some(fixable) = table.get("fixable")
4499 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
4500 {
4501 let normalized_values = values
4502 .into_iter()
4503 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4504 .collect();
4505 fragment
4506 .global
4507 .fixable
4508 .push_override(normalized_values, source, file.clone(), None);
4509 }
4510
4511 if let Some(unfixable) = table.get("unfixable")
4512 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
4513 {
4514 let normalized_values = values
4515 .into_iter()
4516 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4517 .collect();
4518 fragment
4519 .global
4520 .unfixable
4521 .push_override(normalized_values, source, file.clone(), None);
4522 }
4523
4524 if let Some(flavor) = table.get("flavor")
4525 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
4526 {
4527 fragment.global.flavor.push_override(value, source, file.clone(), None);
4528 }
4529
4530 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
4532 && let Ok(value) = u64::deserialize(line_length.clone())
4533 {
4534 fragment
4535 .global
4536 .line_length
4537 .push_override(LineLength::new(value as usize), source, file.clone(), None);
4538
4539 let norm_md013_key = normalize_key("MD013");
4541 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
4542 let norm_line_length_key = normalize_key("line-length");
4543 let sv = rule_entry
4544 .values
4545 .entry(norm_line_length_key)
4546 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
4547 sv.push_override(line_length.clone(), source, file.clone(), None);
4548 }
4549
4550 if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
4551 && let Ok(value) = String::deserialize(cache_dir.clone())
4552 {
4553 if fragment.global.cache_dir.is_none() {
4554 fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
4555 } else {
4556 fragment
4557 .global
4558 .cache_dir
4559 .as_mut()
4560 .unwrap()
4561 .push_override(value, source, file.clone(), None);
4562 }
4563 }
4564
4565 if let Some(cache) = table.get("cache")
4566 && let Ok(value) = bool::deserialize(cache.clone())
4567 {
4568 fragment.global.cache.push_override(value, source, file.clone(), None);
4569 }
4570 };
4571
4572 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
4574 extract_global_config(&mut fragment, global_table);
4575 }
4576
4577 extract_global_config(&mut fragment, rumdl_table);
4579
4580 let per_file_ignores_key = rumdl_table
4583 .get("per-file-ignores")
4584 .or_else(|| rumdl_table.get("per_file_ignores"));
4585
4586 if let Some(per_file_ignores_value) = per_file_ignores_key
4587 && let Some(per_file_table) = per_file_ignores_value.as_table()
4588 {
4589 let mut per_file_map = HashMap::new();
4590 for (pattern, rules_value) in per_file_table {
4591 if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
4592 let normalized_rules = rules
4593 .into_iter()
4594 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4595 .collect();
4596 per_file_map.insert(pattern.clone(), normalized_rules);
4597 } else {
4598 log::warn!(
4599 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {display_path}, found {rules_value:?}"
4600 );
4601 }
4602 }
4603 fragment
4604 .per_file_ignores
4605 .push_override(per_file_map, source, file.clone(), None);
4606 }
4607
4608 let per_file_flavor_key = rumdl_table
4611 .get("per-file-flavor")
4612 .or_else(|| rumdl_table.get("per_file_flavor"));
4613
4614 if let Some(per_file_flavor_value) = per_file_flavor_key
4615 && let Some(per_file_table) = per_file_flavor_value.as_table()
4616 {
4617 let mut per_file_map = IndexMap::new();
4618 for (pattern, flavor_value) in per_file_table {
4619 if let Ok(flavor) = MarkdownFlavor::deserialize(flavor_value.clone()) {
4620 per_file_map.insert(pattern.clone(), flavor);
4621 } else {
4622 log::warn!(
4623 "[WARN] Invalid flavor for per-file-flavor pattern '{pattern}' in {display_path}, found {flavor_value:?}. Valid values: standard, mkdocs, mdx, quarto"
4624 );
4625 }
4626 }
4627 fragment
4628 .per_file_flavor
4629 .push_override(per_file_map, source, file.clone(), None);
4630 }
4631
4632 for (key, value) in rumdl_table {
4634 let norm_rule_key = normalize_key(key);
4635
4636 let is_global_key = [
4639 "enable",
4640 "disable",
4641 "include",
4642 "exclude",
4643 "respect_gitignore",
4644 "respect-gitignore",
4645 "force_exclude",
4646 "force-exclude",
4647 "output_format",
4648 "output-format",
4649 "fixable",
4650 "unfixable",
4651 "per-file-ignores",
4652 "per_file_ignores",
4653 "per-file-flavor",
4654 "per_file_flavor",
4655 "global",
4656 "flavor",
4657 "cache_dir",
4658 "cache-dir",
4659 "cache",
4660 ]
4661 .contains(&norm_rule_key.as_str());
4662
4663 let is_line_length_global =
4665 (norm_rule_key == "line-length" || norm_rule_key == "line_length") && !value.is_table();
4666
4667 if is_global_key || is_line_length_global {
4668 continue;
4669 }
4670
4671 if let Some(resolved_rule_name) = registry.resolve_rule_name(key)
4673 && value.is_table()
4674 && let Some(rule_config_table) = value.as_table()
4675 {
4676 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4677 for (rk, rv) in rule_config_table {
4678 let norm_rk = normalize_key(rk);
4679
4680 if norm_rk == "severity" {
4682 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4683 if rule_entry.severity.is_none() {
4684 rule_entry.severity = Some(SourcedValue::new(severity, source));
4685 } else {
4686 rule_entry.severity.as_mut().unwrap().push_override(
4687 severity,
4688 source,
4689 file.clone(),
4690 None,
4691 );
4692 }
4693 }
4694 continue; }
4696
4697 let toml_val = rv.clone();
4698
4699 let sv = rule_entry
4700 .values
4701 .entry(norm_rk.clone())
4702 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
4703 sv.push_override(toml_val, source, file.clone(), None);
4704 }
4705 } else if registry.resolve_rule_name(key).is_none() {
4706 fragment
4709 .unknown_keys
4710 .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
4711 }
4712 }
4713 }
4714
4715 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
4717 for (key, value) in tool_table.iter() {
4718 if let Some(rule_name) = key.strip_prefix("rumdl.") {
4719 if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4721 if let Some(rule_table) = value.as_table() {
4722 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4723 for (rk, rv) in rule_table {
4724 let norm_rk = normalize_key(rk);
4725
4726 if norm_rk == "severity" {
4728 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4729 if rule_entry.severity.is_none() {
4730 rule_entry.severity = Some(SourcedValue::new(severity, source));
4731 } else {
4732 rule_entry.severity.as_mut().unwrap().push_override(
4733 severity,
4734 source,
4735 file.clone(),
4736 None,
4737 );
4738 }
4739 }
4740 continue; }
4742
4743 let toml_val = rv.clone();
4744 let sv = rule_entry
4745 .values
4746 .entry(norm_rk.clone())
4747 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4748 sv.push_override(toml_val, source, file.clone(), None);
4749 }
4750 }
4751 } else if rule_name.to_ascii_uppercase().starts_with("MD")
4752 || rule_name.chars().any(|c| c.is_alphabetic())
4753 {
4754 fragment.unknown_keys.push((
4756 format!("[tool.rumdl.{rule_name}]"),
4757 String::new(),
4758 Some(path.to_string()),
4759 ));
4760 }
4761 }
4762 }
4763 }
4764
4765 if let Some(doc_table) = doc.as_table() {
4767 for (key, value) in doc_table.iter() {
4768 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
4769 if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4771 if let Some(rule_table) = value.as_table() {
4772 let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4773 for (rk, rv) in rule_table {
4774 let norm_rk = normalize_key(rk);
4775
4776 if norm_rk == "severity" {
4778 if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4779 if rule_entry.severity.is_none() {
4780 rule_entry.severity = Some(SourcedValue::new(severity, source));
4781 } else {
4782 rule_entry.severity.as_mut().unwrap().push_override(
4783 severity,
4784 source,
4785 file.clone(),
4786 None,
4787 );
4788 }
4789 }
4790 continue; }
4792
4793 let toml_val = rv.clone();
4794 let sv = rule_entry
4795 .values
4796 .entry(norm_rk.clone())
4797 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4798 sv.push_override(toml_val, source, file.clone(), None);
4799 }
4800 }
4801 } else if rule_name.to_ascii_uppercase().starts_with("MD")
4802 || rule_name.chars().any(|c| c.is_alphabetic())
4803 {
4804 fragment.unknown_keys.push((
4806 format!("[tool.rumdl.{rule_name}]"),
4807 String::new(),
4808 Some(path.to_string()),
4809 ));
4810 }
4811 }
4812 }
4813 }
4814
4815 let has_any = !fragment.global.enable.value.is_empty()
4817 || !fragment.global.disable.value.is_empty()
4818 || !fragment.global.include.value.is_empty()
4819 || !fragment.global.exclude.value.is_empty()
4820 || !fragment.global.fixable.value.is_empty()
4821 || !fragment.global.unfixable.value.is_empty()
4822 || fragment.global.output_format.is_some()
4823 || fragment.global.cache_dir.is_some()
4824 || !fragment.global.cache.value
4825 || !fragment.per_file_ignores.value.is_empty()
4826 || !fragment.per_file_flavor.value.is_empty()
4827 || !fragment.rules.is_empty();
4828 if has_any { Ok(Some(fragment)) } else { Ok(None) }
4829}
4830
4831fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
4833 let display_path = to_relative_display_path(path);
4834 let doc = content
4835 .parse::<DocumentMut>()
4836 .map_err(|e| ConfigError::ParseError(format!("{display_path}: Failed to parse TOML: {e}")))?;
4837 let mut fragment = SourcedConfigFragment::default();
4838 let file = Some(path.to_string());
4840
4841 let all_rules = rules::all_rules(&Config::default());
4843 let registry = RuleRegistry::from_rules(&all_rules);
4844
4845 if let Some(global_item) = doc.get("global")
4847 && let Some(global_table) = global_item.as_table()
4848 {
4849 for (key, value_item) in global_table.iter() {
4850 let norm_key = normalize_key(key);
4851 match norm_key.as_str() {
4852 "enable" | "disable" | "include" | "exclude" => {
4853 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4854 let values: Vec<String> = formatted_array
4856 .iter()
4857 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
4859 .collect();
4860
4861 let final_values = if norm_key == "enable" || norm_key == "disable" {
4863 values
4864 .into_iter()
4865 .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4866 .collect()
4867 } else {
4868 values
4869 };
4870
4871 match norm_key.as_str() {
4872 "enable" => fragment
4873 .global
4874 .enable
4875 .push_override(final_values, source, file.clone(), None),
4876 "disable" => {
4877 fragment
4878 .global
4879 .disable
4880 .push_override(final_values, source, file.clone(), None)
4881 }
4882 "include" => {
4883 fragment
4884 .global
4885 .include
4886 .push_override(final_values, source, file.clone(), None)
4887 }
4888 "exclude" => {
4889 fragment
4890 .global
4891 .exclude
4892 .push_override(final_values, source, file.clone(), None)
4893 }
4894 _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
4895 }
4896 } else {
4897 log::warn!(
4898 "[WARN] Expected array for global key '{}' in {}, found {}",
4899 key,
4900 display_path,
4901 value_item.type_name()
4902 );
4903 }
4904 }
4905 "respect_gitignore" | "respect-gitignore" => {
4906 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
4908 let val = *formatted_bool.value();
4909 fragment
4910 .global
4911 .respect_gitignore
4912 .push_override(val, source, file.clone(), None);
4913 } else {
4914 log::warn!(
4915 "[WARN] Expected boolean for global key '{}' in {}, found {}",
4916 key,
4917 display_path,
4918 value_item.type_name()
4919 );
4920 }
4921 }
4922 "force_exclude" | "force-exclude" => {
4923 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
4925 let val = *formatted_bool.value();
4926 fragment
4927 .global
4928 .force_exclude
4929 .push_override(val, source, file.clone(), None);
4930 } else {
4931 log::warn!(
4932 "[WARN] Expected boolean for global key '{}' in {}, found {}",
4933 key,
4934 display_path,
4935 value_item.type_name()
4936 );
4937 }
4938 }
4939 "line_length" | "line-length" => {
4940 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
4942 let val = LineLength::new(*formatted_int.value() as usize);
4943 fragment
4944 .global
4945 .line_length
4946 .push_override(val, source, file.clone(), None);
4947 } else {
4948 log::warn!(
4949 "[WARN] Expected integer for global key '{}' in {}, found {}",
4950 key,
4951 display_path,
4952 value_item.type_name()
4953 );
4954 }
4955 }
4956 "output_format" | "output-format" => {
4957 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4959 let val = formatted_string.value().clone();
4960 if fragment.global.output_format.is_none() {
4961 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
4962 } else {
4963 fragment.global.output_format.as_mut().unwrap().push_override(
4964 val,
4965 source,
4966 file.clone(),
4967 None,
4968 );
4969 }
4970 } else {
4971 log::warn!(
4972 "[WARN] Expected string for global key '{}' in {}, found {}",
4973 key,
4974 display_path,
4975 value_item.type_name()
4976 );
4977 }
4978 }
4979 "cache_dir" | "cache-dir" => {
4980 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4982 let val = formatted_string.value().clone();
4983 if fragment.global.cache_dir.is_none() {
4984 fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
4985 } else {
4986 fragment
4987 .global
4988 .cache_dir
4989 .as_mut()
4990 .unwrap()
4991 .push_override(val, source, file.clone(), None);
4992 }
4993 } else {
4994 log::warn!(
4995 "[WARN] Expected string for global key '{}' in {}, found {}",
4996 key,
4997 display_path,
4998 value_item.type_name()
4999 );
5000 }
5001 }
5002 "cache" => {
5003 if let Some(toml_edit::Value::Boolean(b)) = value_item.as_value() {
5004 let val = *b.value();
5005 fragment.global.cache.push_override(val, source, file.clone(), None);
5006 } else {
5007 log::warn!(
5008 "[WARN] Expected boolean for global key '{}' in {}, found {}",
5009 key,
5010 display_path,
5011 value_item.type_name()
5012 );
5013 }
5014 }
5015 "fixable" => {
5016 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5017 let values: Vec<String> = formatted_array
5018 .iter()
5019 .filter_map(|item| item.as_str())
5020 .map(normalize_key)
5021 .collect();
5022 fragment
5023 .global
5024 .fixable
5025 .push_override(values, source, file.clone(), None);
5026 } else {
5027 log::warn!(
5028 "[WARN] Expected array for global key '{}' in {}, found {}",
5029 key,
5030 display_path,
5031 value_item.type_name()
5032 );
5033 }
5034 }
5035 "unfixable" => {
5036 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5037 let values: Vec<String> = formatted_array
5038 .iter()
5039 .filter_map(|item| item.as_str())
5040 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
5041 .collect();
5042 fragment
5043 .global
5044 .unfixable
5045 .push_override(values, source, file.clone(), None);
5046 } else {
5047 log::warn!(
5048 "[WARN] Expected array for global key '{}' in {}, found {}",
5049 key,
5050 display_path,
5051 value_item.type_name()
5052 );
5053 }
5054 }
5055 "flavor" => {
5056 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5057 let val = formatted_string.value();
5058 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
5059 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
5060 } else {
5061 log::warn!("[WARN] Unknown markdown flavor '{val}' in {display_path}");
5062 }
5063 } else {
5064 log::warn!(
5065 "[WARN] Expected string for global key '{}' in {}, found {}",
5066 key,
5067 display_path,
5068 value_item.type_name()
5069 );
5070 }
5071 }
5072 _ => {
5073 fragment
5075 .unknown_keys
5076 .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
5077 log::warn!("[WARN] Unknown key in [global] section of {display_path}: {key}");
5078 }
5079 }
5080 }
5081 }
5082
5083 if let Some(per_file_item) = doc.get("per-file-ignores")
5085 && let Some(per_file_table) = per_file_item.as_table()
5086 {
5087 let mut per_file_map = HashMap::new();
5088 for (pattern, value_item) in per_file_table.iter() {
5089 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5090 let rules: Vec<String> = formatted_array
5091 .iter()
5092 .filter_map(|item| item.as_str())
5093 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
5094 .collect();
5095 per_file_map.insert(pattern.to_string(), rules);
5096 } else {
5097 let type_name = value_item.type_name();
5098 log::warn!(
5099 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {display_path}, found {type_name}"
5100 );
5101 }
5102 }
5103 fragment
5104 .per_file_ignores
5105 .push_override(per_file_map, source, file.clone(), None);
5106 }
5107
5108 if let Some(per_file_item) = doc.get("per-file-flavor")
5110 && let Some(per_file_table) = per_file_item.as_table()
5111 {
5112 let mut per_file_map = IndexMap::new();
5113 for (pattern, value_item) in per_file_table.iter() {
5114 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5115 let flavor_str = formatted_string.value();
5116 match MarkdownFlavor::deserialize(toml::Value::String(flavor_str.to_string())) {
5117 Ok(flavor) => {
5118 per_file_map.insert(pattern.to_string(), flavor);
5119 }
5120 Err(_) => {
5121 log::warn!(
5122 "[WARN] Invalid flavor '{flavor_str}' for pattern '{pattern}' in {display_path}. Valid values: standard, mkdocs, mdx, quarto"
5123 );
5124 }
5125 }
5126 } else {
5127 let type_name = value_item.type_name();
5128 log::warn!(
5129 "[WARN] Expected string for per-file-flavor pattern '{pattern}' in {display_path}, found {type_name}"
5130 );
5131 }
5132 }
5133 fragment
5134 .per_file_flavor
5135 .push_override(per_file_map, source, file.clone(), None);
5136 }
5137
5138 for (key, item) in doc.iter() {
5140 if key == "global" || key == "per-file-ignores" || key == "per-file-flavor" {
5142 continue;
5143 }
5144
5145 let norm_rule_name = if let Some(resolved) = registry.resolve_rule_name(key) {
5147 resolved
5148 } else {
5149 fragment
5151 .unknown_keys
5152 .push((format!("[{key}]"), String::new(), Some(path.to_string())));
5153 continue;
5154 };
5155
5156 if let Some(tbl) = item.as_table() {
5157 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
5158 for (rk, rv_item) in tbl.iter() {
5159 let norm_rk = normalize_key(rk);
5160
5161 if norm_rk == "severity" {
5163 if let Some(toml_edit::Value::String(formatted_string)) = rv_item.as_value() {
5164 let severity_str = formatted_string.value();
5165 match crate::rule::Severity::deserialize(toml::Value::String(severity_str.to_string())) {
5166 Ok(severity) => {
5167 if rule_entry.severity.is_none() {
5168 rule_entry.severity = Some(SourcedValue::new(severity, source));
5169 } else {
5170 rule_entry.severity.as_mut().unwrap().push_override(
5171 severity,
5172 source,
5173 file.clone(),
5174 None,
5175 );
5176 }
5177 }
5178 Err(_) => {
5179 log::warn!(
5180 "[WARN] Invalid severity '{severity_str}' for rule {norm_rule_name} in {display_path}. Valid values: error, warning"
5181 );
5182 }
5183 }
5184 }
5185 continue; }
5187
5188 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
5189 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
5190 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
5191 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
5192 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
5193 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
5194 Some(toml_edit::Value::Array(formatted_array)) => {
5195 let mut values = Vec::new();
5197 for item in formatted_array.iter() {
5198 match item {
5199 toml_edit::Value::String(formatted) => {
5200 values.push(toml::Value::String(formatted.value().clone()))
5201 }
5202 toml_edit::Value::Integer(formatted) => {
5203 values.push(toml::Value::Integer(*formatted.value()))
5204 }
5205 toml_edit::Value::Float(formatted) => {
5206 values.push(toml::Value::Float(*formatted.value()))
5207 }
5208 toml_edit::Value::Boolean(formatted) => {
5209 values.push(toml::Value::Boolean(*formatted.value()))
5210 }
5211 toml_edit::Value::Datetime(formatted) => {
5212 values.push(toml::Value::Datetime(*formatted.value()))
5213 }
5214 _ => {
5215 log::warn!(
5216 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {display_path}"
5217 );
5218 }
5219 }
5220 }
5221 Some(toml::Value::Array(values))
5222 }
5223 Some(toml_edit::Value::InlineTable(_)) => {
5224 log::warn!(
5225 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {display_path}. Table conversion not yet fully implemented in parser."
5226 );
5227 None
5228 }
5229 None => {
5230 log::warn!(
5231 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {display_path}. Expected simple value."
5232 );
5233 None
5234 }
5235 };
5236 if let Some(toml_val) = maybe_toml_val {
5237 let sv = rule_entry
5238 .values
5239 .entry(norm_rk.clone())
5240 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
5241 sv.push_override(toml_val, source, file.clone(), None);
5242 }
5243 }
5244 } else if item.is_value() {
5245 log::warn!(
5246 "[WARN] Ignoring top-level value key in {display_path}: '{key}'. Expected a table like [{key}]."
5247 );
5248 }
5249 }
5250
5251 Ok(fragment)
5252}
5253
5254fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
5256 let display_path = to_relative_display_path(path);
5257 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
5259 .map_err(|e| ConfigError::ParseError(format!("{display_path}: {e}")))?;
5260 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
5261}
5262
5263#[cfg(test)]
5264#[path = "config_intelligent_merge_tests.rs"]
5265mod config_intelligent_merge_tests;