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