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