1use crate::rule::Rule;
6use crate::rules;
7use crate::types::LineLength;
8use log;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::collections::{BTreeSet, HashMap, HashSet};
12use std::fmt;
13use std::fs;
14use std::io;
15use std::marker::PhantomData;
16use std::path::Path;
17use std::str::FromStr;
18use toml_edit::DocumentMut;
19
20#[derive(Debug, Clone, Copy, Default)]
27pub struct ConfigLoaded;
28
29#[derive(Debug, Clone, Copy, Default)]
32pub struct ConfigValidated;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema)]
36#[serde(rename_all = "lowercase")]
37pub enum MarkdownFlavor {
38 #[serde(rename = "standard", alias = "none", alias = "")]
40 #[default]
41 Standard,
42 #[serde(rename = "mkdocs")]
44 MkDocs,
45 #[serde(rename = "mdx")]
47 MDX,
48 #[serde(rename = "quarto")]
50 Quarto,
51 }
55
56impl fmt::Display for MarkdownFlavor {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 match self {
59 MarkdownFlavor::Standard => write!(f, "standard"),
60 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
61 MarkdownFlavor::MDX => write!(f, "mdx"),
62 MarkdownFlavor::Quarto => write!(f, "quarto"),
63 }
64 }
65}
66
67impl FromStr for MarkdownFlavor {
68 type Err = String;
69
70 fn from_str(s: &str) -> Result<Self, Self::Err> {
71 match s.to_lowercase().as_str() {
72 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
73 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
74 "mdx" => Ok(MarkdownFlavor::MDX),
75 "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
76 "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
80 _ => Err(format!("Unknown markdown flavor: {s}")),
81 }
82 }
83}
84
85impl MarkdownFlavor {
86 pub fn from_extension(ext: &str) -> Self {
88 match ext.to_lowercase().as_str() {
89 "mdx" => Self::MDX,
90 "qmd" => Self::Quarto,
91 "rmd" => Self::Quarto,
92 _ => Self::Standard,
93 }
94 }
95
96 pub fn from_path(path: &std::path::Path) -> Self {
98 path.extension()
99 .and_then(|e| e.to_str())
100 .map(Self::from_extension)
101 .unwrap_or(Self::Standard)
102 }
103
104 pub fn supports_esm_blocks(self) -> bool {
106 matches!(self, Self::MDX)
107 }
108
109 pub fn supports_jsx(self) -> bool {
111 matches!(self, Self::MDX)
112 }
113
114 pub fn supports_auto_references(self) -> bool {
116 matches!(self, Self::MkDocs)
117 }
118
119 pub fn name(self) -> &'static str {
121 match self {
122 Self::Standard => "Standard",
123 Self::MkDocs => "MkDocs",
124 Self::MDX => "MDX",
125 Self::Quarto => "Quarto",
126 }
127 }
128}
129
130pub fn normalize_key(key: &str) -> String {
132 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
134 key.to_ascii_uppercase()
135 } else {
136 key.replace('_', "-").to_ascii_lowercase()
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
142pub struct RuleConfig {
143 #[serde(flatten)]
145 #[schemars(schema_with = "arbitrary_value_schema")]
146 pub values: BTreeMap<String, toml::Value>,
147}
148
149fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
151 schemars::json_schema!({
152 "type": "object",
153 "additionalProperties": true
154 })
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
159#[schemars(
160 description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
161)]
162pub struct Config {
163 #[serde(default)]
165 pub global: GlobalConfig,
166
167 #[serde(default, rename = "per-file-ignores")]
170 pub per_file_ignores: HashMap<String, Vec<String>>,
171
172 #[serde(flatten)]
183 pub rules: BTreeMap<String, RuleConfig>,
184
185 #[serde(skip)]
187 pub project_root: Option<std::path::PathBuf>,
188}
189
190impl Config {
191 pub fn is_mkdocs_flavor(&self) -> bool {
193 self.global.flavor == MarkdownFlavor::MkDocs
194 }
195
196 pub fn markdown_flavor(&self) -> MarkdownFlavor {
202 self.global.flavor
203 }
204
205 pub fn is_mkdocs_project(&self) -> bool {
207 self.is_mkdocs_flavor()
208 }
209
210 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
213 use globset::{Glob, GlobSetBuilder};
214
215 let mut ignored_rules = HashSet::new();
216
217 if self.per_file_ignores.is_empty() {
218 return ignored_rules;
219 }
220
221 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
224 if let Ok(canonical_path) = file_path.canonicalize() {
225 if let Ok(canonical_root) = root.canonicalize() {
226 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
227 std::borrow::Cow::Owned(relative.to_path_buf())
228 } else {
229 std::borrow::Cow::Borrowed(file_path)
230 }
231 } else {
232 std::borrow::Cow::Borrowed(file_path)
233 }
234 } else {
235 std::borrow::Cow::Borrowed(file_path)
236 }
237 } else {
238 std::borrow::Cow::Borrowed(file_path)
239 };
240
241 let mut builder = GlobSetBuilder::new();
243 let mut pattern_to_rules: Vec<(usize, &Vec<String>)> = Vec::new();
244
245 for (idx, (pattern, rules)) in self.per_file_ignores.iter().enumerate() {
246 if let Ok(glob) = Glob::new(pattern) {
247 builder.add(glob);
248 pattern_to_rules.push((idx, rules));
249 } else {
250 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
251 }
252 }
253
254 let globset = match builder.build() {
255 Ok(gs) => gs,
256 Err(e) => {
257 log::error!("Failed to build globset for per-file-ignores: {e}");
258 return ignored_rules;
259 }
260 };
261
262 for match_idx in globset.matches(path_for_matching.as_ref()) {
264 if let Some((_, rules)) = pattern_to_rules.get(match_idx) {
265 for rule in rules.iter() {
266 ignored_rules.insert(normalize_key(rule));
268 }
269 }
270 }
271
272 ignored_rules
273 }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
278#[serde(default, rename_all = "kebab-case")]
279pub struct GlobalConfig {
280 #[serde(default)]
282 pub enable: Vec<String>,
283
284 #[serde(default)]
286 pub disable: Vec<String>,
287
288 #[serde(default)]
290 pub exclude: Vec<String>,
291
292 #[serde(default)]
294 pub include: Vec<String>,
295
296 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
298 pub respect_gitignore: bool,
299
300 #[serde(default, alias = "line_length")]
302 pub line_length: LineLength,
303
304 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
306 pub output_format: Option<String>,
307
308 #[serde(default)]
311 pub fixable: Vec<String>,
312
313 #[serde(default)]
316 pub unfixable: Vec<String>,
317
318 #[serde(default)]
321 pub flavor: MarkdownFlavor,
322
323 #[serde(default, alias = "force_exclude")]
328 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
329 pub force_exclude: bool,
330
331 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
334 pub cache_dir: Option<String>,
335
336 #[serde(default = "default_true")]
339 pub cache: bool,
340}
341
342fn default_respect_gitignore() -> bool {
343 true
344}
345
346fn default_true() -> bool {
347 true
348}
349
350impl Default for GlobalConfig {
352 #[allow(deprecated)]
353 fn default() -> Self {
354 Self {
355 enable: Vec::new(),
356 disable: Vec::new(),
357 exclude: Vec::new(),
358 include: Vec::new(),
359 respect_gitignore: true,
360 line_length: LineLength::default(),
361 output_format: None,
362 fixable: Vec::new(),
363 unfixable: Vec::new(),
364 flavor: MarkdownFlavor::default(),
365 force_exclude: false,
366 cache_dir: None,
367 cache: true,
368 }
369 }
370}
371
372const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
373 ".markdownlint.json",
374 ".markdownlint.jsonc",
375 ".markdownlint.yaml",
376 ".markdownlint.yml",
377 "markdownlint.json",
378 "markdownlint.jsonc",
379 "markdownlint.yaml",
380 "markdownlint.yml",
381];
382
383pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
385 if Path::new(path).exists() {
387 return Err(ConfigError::FileExists { path: path.to_string() });
388 }
389
390 let default_config = r#"# rumdl configuration file
392
393# Global configuration options
394[global]
395# List of rules to disable (uncomment and modify as needed)
396# disable = ["MD013", "MD033"]
397
398# List of rules to enable exclusively (if provided, only these rules will run)
399# enable = ["MD001", "MD003", "MD004"]
400
401# List of file/directory patterns to include for linting (if provided, only these will be linted)
402# include = [
403# "docs/*.md",
404# "src/**/*.md",
405# "README.md"
406# ]
407
408# List of file/directory patterns to exclude from linting
409exclude = [
410 # Common directories to exclude
411 ".git",
412 ".github",
413 "node_modules",
414 "vendor",
415 "dist",
416 "build",
417
418 # Specific files or patterns
419 "CHANGELOG.md",
420 "LICENSE.md",
421]
422
423# Respect .gitignore files when scanning directories (default: true)
424respect-gitignore = true
425
426# Markdown flavor/dialect (uncomment to enable)
427# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
428# flavor = "mkdocs"
429
430# Rule-specific configurations (uncomment and modify as needed)
431
432# [MD003]
433# style = "atx" # Heading style (atx, atx_closed, setext)
434
435# [MD004]
436# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
437
438# [MD007]
439# indent = 4 # Unordered list indentation
440
441# [MD013]
442# line-length = 100 # Line length
443# code-blocks = false # Exclude code blocks from line length check
444# tables = false # Exclude tables from line length check
445# headings = true # Include headings in line length check
446
447# [MD044]
448# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
449# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
450"#;
451
452 match fs::write(path, default_config) {
454 Ok(_) => Ok(()),
455 Err(err) => Err(ConfigError::IoError {
456 source: err,
457 path: path.to_string(),
458 }),
459 }
460}
461
462#[derive(Debug, thiserror::Error)]
464pub enum ConfigError {
465 #[error("Failed to read config file at {path}: {source}")]
467 IoError { source: io::Error, path: String },
468
469 #[error("Failed to parse config: {0}")]
471 ParseError(String),
472
473 #[error("Configuration file already exists at {path}")]
475 FileExists { path: String },
476}
477
478pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
482 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
485
486 let key_variants = [
488 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
493
494 for variant in &key_variants {
496 if let Some(value) = rule_config.values.get(variant)
497 && let Ok(result) = T::deserialize(value.clone())
498 {
499 return Some(result);
500 }
501 }
502
503 None
504}
505
506pub fn generate_pyproject_config() -> String {
508 let config_content = r#"
509[tool.rumdl]
510# Global configuration options
511line-length = 100
512disable = []
513exclude = [
514 # Common directories to exclude
515 ".git",
516 ".github",
517 "node_modules",
518 "vendor",
519 "dist",
520 "build",
521]
522respect-gitignore = true
523
524# Rule-specific configurations (uncomment and modify as needed)
525
526# [tool.rumdl.MD003]
527# style = "atx" # Heading style (atx, atx_closed, setext)
528
529# [tool.rumdl.MD004]
530# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
531
532# [tool.rumdl.MD007]
533# indent = 4 # Unordered list indentation
534
535# [tool.rumdl.MD013]
536# line-length = 100 # Line length
537# code-blocks = false # Exclude code blocks from line length check
538# tables = false # Exclude tables from line length check
539# headings = true # Include headings in line length check
540
541# [tool.rumdl.MD044]
542# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
543# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
544"#;
545
546 config_content.to_string()
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use std::fs;
553 use tempfile::tempdir;
554
555 #[test]
556 fn test_flavor_loading() {
557 let temp_dir = tempdir().unwrap();
558 let config_path = temp_dir.path().join(".rumdl.toml");
559 let config_content = r#"
560[global]
561flavor = "mkdocs"
562disable = ["MD001"]
563"#;
564 fs::write(&config_path, config_content).unwrap();
565
566 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
568 let config: Config = sourced.into_validated_unchecked().into();
569
570 assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
572 assert!(config.is_mkdocs_flavor());
573 assert!(config.is_mkdocs_project()); assert_eq!(config.global.disable, vec!["MD001".to_string()]);
575 }
576
577 #[test]
578 fn test_pyproject_toml_root_level_config() {
579 let temp_dir = tempdir().unwrap();
580 let config_path = temp_dir.path().join("pyproject.toml");
581
582 let content = r#"
584[tool.rumdl]
585line-length = 120
586disable = ["MD033"]
587enable = ["MD001", "MD004"]
588include = ["docs/*.md"]
589exclude = ["node_modules"]
590respect-gitignore = true
591 "#;
592
593 fs::write(&config_path, content).unwrap();
594
595 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
597 let config: Config = sourced.into_validated_unchecked().into(); assert_eq!(config.global.disable, vec!["MD033".to_string()]);
601 assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
602 assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
604 assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
605 assert!(config.global.respect_gitignore);
606
607 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
609 assert_eq!(line_length, Some(120));
610 }
611
612 #[test]
613 fn test_pyproject_toml_snake_case_and_kebab_case() {
614 let temp_dir = tempdir().unwrap();
615 let config_path = temp_dir.path().join("pyproject.toml");
616
617 let content = r#"
619[tool.rumdl]
620line-length = 150
621respect_gitignore = true
622 "#;
623
624 fs::write(&config_path, content).unwrap();
625
626 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
628 let config: Config = sourced.into_validated_unchecked().into(); assert!(config.global.respect_gitignore);
632 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
633 assert_eq!(line_length, Some(150));
634 }
635
636 #[test]
637 fn test_md013_key_normalization_in_rumdl_toml() {
638 let temp_dir = tempdir().unwrap();
639 let config_path = temp_dir.path().join(".rumdl.toml");
640 let config_content = r#"
641[MD013]
642line_length = 111
643line-length = 222
644"#;
645 fs::write(&config_path, config_content).unwrap();
646 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
648 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
649 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
651 assert_eq!(keys, vec!["line-length"]);
652 let val = &rule_cfg.values["line-length"].value;
653 assert_eq!(val.as_integer(), Some(222));
654 let config: Config = sourced.clone().into_validated_unchecked().into();
656 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
657 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
658 assert_eq!(v1, Some(222));
659 assert_eq!(v2, Some(222));
660 }
661
662 #[test]
663 fn test_md013_section_case_insensitivity() {
664 let temp_dir = tempdir().unwrap();
665 let config_path = temp_dir.path().join(".rumdl.toml");
666 let config_content = r#"
667[md013]
668line-length = 101
669
670[Md013]
671line-length = 102
672
673[MD013]
674line-length = 103
675"#;
676 fs::write(&config_path, config_content).unwrap();
677 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
679 let config: Config = sourced.clone().into_validated_unchecked().into();
680 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
682 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
683 assert_eq!(keys, vec!["line-length"]);
684 let val = &rule_cfg.values["line-length"].value;
685 assert_eq!(val.as_integer(), Some(103));
686 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
687 assert_eq!(v, Some(103));
688 }
689
690 #[test]
691 fn test_md013_key_snake_and_kebab_case() {
692 let temp_dir = tempdir().unwrap();
693 let config_path = temp_dir.path().join(".rumdl.toml");
694 let config_content = r#"
695[MD013]
696line_length = 201
697line-length = 202
698"#;
699 fs::write(&config_path, config_content).unwrap();
700 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
702 let config: Config = sourced.clone().into_validated_unchecked().into();
703 let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
704 let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
705 assert_eq!(keys, vec!["line-length"]);
706 let val = &rule_cfg.values["line-length"].value;
707 assert_eq!(val.as_integer(), Some(202));
708 let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
709 let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
710 assert_eq!(v1, Some(202));
711 assert_eq!(v2, Some(202));
712 }
713
714 #[test]
715 fn test_unknown_rule_section_is_ignored() {
716 let temp_dir = tempdir().unwrap();
717 let config_path = temp_dir.path().join(".rumdl.toml");
718 let config_content = r#"
719[MD999]
720foo = 1
721bar = 2
722[MD013]
723line-length = 303
724"#;
725 fs::write(&config_path, config_content).unwrap();
726 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
728 let config: Config = sourced.clone().into_validated_unchecked().into();
729 assert!(!sourced.rules.contains_key("MD999"));
731 let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
733 assert_eq!(v, Some(303));
734 }
735
736 #[test]
737 fn test_invalid_toml_syntax() {
738 let temp_dir = tempdir().unwrap();
739 let config_path = temp_dir.path().join(".rumdl.toml");
740
741 let config_content = r#"
743[MD013]
744line-length = "unclosed string
745"#;
746 fs::write(&config_path, config_content).unwrap();
747
748 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
749 assert!(result.is_err());
750 match result.unwrap_err() {
751 ConfigError::ParseError(msg) => {
752 assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
754 }
755 _ => panic!("Expected ParseError"),
756 }
757 }
758
759 #[test]
760 fn test_wrong_type_for_config_value() {
761 let temp_dir = tempdir().unwrap();
762 let config_path = temp_dir.path().join(".rumdl.toml");
763
764 let config_content = r#"
766[MD013]
767line-length = "not a number"
768"#;
769 fs::write(&config_path, config_content).unwrap();
770
771 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
772 let config: Config = sourced.into_validated_unchecked().into();
773
774 let rule_config = config.rules.get("MD013").unwrap();
776 let value = rule_config.values.get("line-length").unwrap();
777 assert!(matches!(value, toml::Value::String(_)));
778 }
779
780 #[test]
781 fn test_empty_config_file() {
782 let temp_dir = tempdir().unwrap();
783 let config_path = temp_dir.path().join(".rumdl.toml");
784
785 fs::write(&config_path, "").unwrap();
787
788 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
789 let config: Config = sourced.into_validated_unchecked().into();
790
791 assert_eq!(config.global.line_length.get(), 80);
793 assert!(config.global.respect_gitignore);
794 assert!(config.rules.is_empty());
795 }
796
797 #[test]
798 fn test_malformed_pyproject_toml() {
799 let temp_dir = tempdir().unwrap();
800 let config_path = temp_dir.path().join("pyproject.toml");
801
802 let content = r#"
804[tool.rumdl
805line-length = 120
806"#;
807 fs::write(&config_path, content).unwrap();
808
809 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
810 assert!(result.is_err());
811 }
812
813 #[test]
814 fn test_conflicting_config_values() {
815 let temp_dir = tempdir().unwrap();
816 let config_path = temp_dir.path().join(".rumdl.toml");
817
818 let config_content = r#"
820[global]
821enable = ["MD013"]
822disable = ["MD013"]
823"#;
824 fs::write(&config_path, config_content).unwrap();
825
826 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
827 let config: Config = sourced.into_validated_unchecked().into();
828
829 assert!(config.global.enable.contains(&"MD013".to_string()));
831 assert!(!config.global.disable.contains(&"MD013".to_string()));
832 }
833
834 #[test]
835 fn test_invalid_rule_names() {
836 let temp_dir = tempdir().unwrap();
837 let config_path = temp_dir.path().join(".rumdl.toml");
838
839 let config_content = r#"
840[global]
841enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
842disable = ["MD-001", "MD_002"]
843"#;
844 fs::write(&config_path, config_content).unwrap();
845
846 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
847 let config: Config = sourced.into_validated_unchecked().into();
848
849 assert_eq!(config.global.enable.len(), 4);
851 assert_eq!(config.global.disable.len(), 2);
852 }
853
854 #[test]
855 fn test_deeply_nested_config() {
856 let temp_dir = tempdir().unwrap();
857 let config_path = temp_dir.path().join(".rumdl.toml");
858
859 let config_content = r#"
861[MD013]
862line-length = 100
863[MD013.nested]
864value = 42
865"#;
866 fs::write(&config_path, config_content).unwrap();
867
868 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
869 let config: Config = sourced.into_validated_unchecked().into();
870
871 let rule_config = config.rules.get("MD013").unwrap();
872 assert_eq!(
873 rule_config.values.get("line-length").unwrap(),
874 &toml::Value::Integer(100)
875 );
876 assert!(!rule_config.values.contains_key("nested"));
878 }
879
880 #[test]
881 fn test_unicode_in_config() {
882 let temp_dir = tempdir().unwrap();
883 let config_path = temp_dir.path().join(".rumdl.toml");
884
885 let config_content = r#"
886[global]
887include = ["文档/*.md", "ドã‚ュメント/*.md"]
888exclude = ["测试/*", "🚀/*"]
889
890[MD013]
891line-length = 80
892message = "行太长了 🚨"
893"#;
894 fs::write(&config_path, config_content).unwrap();
895
896 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
897 let config: Config = sourced.into_validated_unchecked().into();
898
899 assert_eq!(config.global.include.len(), 2);
900 assert_eq!(config.global.exclude.len(), 2);
901 assert!(config.global.include[0].contains("文档"));
902 assert!(config.global.exclude[1].contains("🚀"));
903
904 let rule_config = config.rules.get("MD013").unwrap();
905 let message = rule_config.values.get("message").unwrap();
906 if let toml::Value::String(s) = message {
907 assert!(s.contains("行太长了"));
908 assert!(s.contains("🚨"));
909 }
910 }
911
912 #[test]
913 fn test_extremely_long_values() {
914 let temp_dir = tempdir().unwrap();
915 let config_path = temp_dir.path().join(".rumdl.toml");
916
917 let long_string = "a".repeat(10000);
918 let config_content = format!(
919 r#"
920[global]
921exclude = ["{long_string}"]
922
923[MD013]
924line-length = 999999999
925"#
926 );
927
928 fs::write(&config_path, config_content).unwrap();
929
930 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
931 let config: Config = sourced.into_validated_unchecked().into();
932
933 assert_eq!(config.global.exclude[0].len(), 10000);
934 let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
935 assert_eq!(line_length, Some(999999999));
936 }
937
938 #[test]
939 fn test_config_with_comments() {
940 let temp_dir = tempdir().unwrap();
941 let config_path = temp_dir.path().join(".rumdl.toml");
942
943 let config_content = r#"
944[global]
945# This is a comment
946enable = ["MD001"] # Enable MD001
947# disable = ["MD002"] # This is commented out
948
949[MD013] # Line length rule
950line-length = 100 # Set to 100 characters
951# ignored = true # This setting is commented out
952"#;
953 fs::write(&config_path, config_content).unwrap();
954
955 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
956 let config: Config = sourced.into_validated_unchecked().into();
957
958 assert_eq!(config.global.enable, vec!["MD001"]);
959 assert!(config.global.disable.is_empty()); let rule_config = config.rules.get("MD013").unwrap();
962 assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
964 }
965
966 #[test]
967 fn test_arrays_in_rule_config() {
968 let temp_dir = tempdir().unwrap();
969 let config_path = temp_dir.path().join(".rumdl.toml");
970
971 let config_content = r#"
972[MD003]
973levels = [1, 2, 3]
974tags = ["important", "critical"]
975mixed = [1, "two", true]
976"#;
977 fs::write(&config_path, config_content).unwrap();
978
979 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
980 let config: Config = sourced.into_validated_unchecked().into();
981
982 let rule_config = config.rules.get("MD003").expect("MD003 config should exist");
984
985 assert!(rule_config.values.contains_key("levels"));
987 assert!(rule_config.values.contains_key("tags"));
988 assert!(rule_config.values.contains_key("mixed"));
989
990 if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
992 assert_eq!(levels.len(), 3);
993 assert_eq!(levels[0], toml::Value::Integer(1));
994 assert_eq!(levels[1], toml::Value::Integer(2));
995 assert_eq!(levels[2], toml::Value::Integer(3));
996 } else {
997 panic!("levels should be an array");
998 }
999
1000 if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
1001 assert_eq!(tags.len(), 2);
1002 assert_eq!(tags[0], toml::Value::String("important".to_string()));
1003 assert_eq!(tags[1], toml::Value::String("critical".to_string()));
1004 } else {
1005 panic!("tags should be an array");
1006 }
1007
1008 if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
1009 assert_eq!(mixed.len(), 3);
1010 assert_eq!(mixed[0], toml::Value::Integer(1));
1011 assert_eq!(mixed[1], toml::Value::String("two".to_string()));
1012 assert_eq!(mixed[2], toml::Value::Boolean(true));
1013 } else {
1014 panic!("mixed should be an array");
1015 }
1016 }
1017
1018 #[test]
1019 fn test_normalize_key_edge_cases() {
1020 assert_eq!(normalize_key("MD001"), "MD001");
1022 assert_eq!(normalize_key("md001"), "MD001");
1023 assert_eq!(normalize_key("Md001"), "MD001");
1024 assert_eq!(normalize_key("mD001"), "MD001");
1025
1026 assert_eq!(normalize_key("line_length"), "line-length");
1028 assert_eq!(normalize_key("line-length"), "line-length");
1029 assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
1030 assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
1031
1032 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(""), "");
1039 assert_eq!(normalize_key("_"), "-");
1040 assert_eq!(normalize_key("___"), "---");
1041 }
1042
1043 #[test]
1044 fn test_missing_config_file() {
1045 let temp_dir = tempdir().unwrap();
1046 let config_path = temp_dir.path().join("nonexistent.toml");
1047
1048 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1049 assert!(result.is_err());
1050 match result.unwrap_err() {
1051 ConfigError::IoError { .. } => {}
1052 _ => panic!("Expected IoError for missing file"),
1053 }
1054 }
1055
1056 #[test]
1057 #[cfg(unix)]
1058 fn test_permission_denied_config() {
1059 use std::os::unix::fs::PermissionsExt;
1060
1061 let temp_dir = tempdir().unwrap();
1062 let config_path = temp_dir.path().join(".rumdl.toml");
1063
1064 fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
1065
1066 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1068 perms.set_mode(0o000);
1069 fs::set_permissions(&config_path, perms).unwrap();
1070
1071 let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1072
1073 let mut perms = fs::metadata(&config_path).unwrap().permissions();
1075 perms.set_mode(0o644);
1076 fs::set_permissions(&config_path, perms).unwrap();
1077
1078 assert!(result.is_err());
1079 match result.unwrap_err() {
1080 ConfigError::IoError { .. } => {}
1081 _ => panic!("Expected IoError for permission denied"),
1082 }
1083 }
1084
1085 #[test]
1086 fn test_circular_reference_detection() {
1087 let temp_dir = tempdir().unwrap();
1090 let config_path = temp_dir.path().join(".rumdl.toml");
1091
1092 let mut config_content = String::from("[MD001]\n");
1093 for i in 0..100 {
1094 config_content.push_str(&format!("key{i} = {i}\n"));
1095 }
1096
1097 fs::write(&config_path, config_content).unwrap();
1098
1099 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1100 let config: Config = sourced.into_validated_unchecked().into();
1101
1102 let rule_config = config.rules.get("MD001").unwrap();
1103 assert_eq!(rule_config.values.len(), 100);
1104 }
1105
1106 #[test]
1107 fn test_special_toml_values() {
1108 let temp_dir = tempdir().unwrap();
1109 let config_path = temp_dir.path().join(".rumdl.toml");
1110
1111 let config_content = r#"
1112[MD001]
1113infinity = inf
1114neg_infinity = -inf
1115not_a_number = nan
1116datetime = 1979-05-27T07:32:00Z
1117local_date = 1979-05-27
1118local_time = 07:32:00
1119"#;
1120 fs::write(&config_path, config_content).unwrap();
1121
1122 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1123 let config: Config = sourced.into_validated_unchecked().into();
1124
1125 if let Some(rule_config) = config.rules.get("MD001") {
1127 if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
1129 assert!(f.is_infinite() && f.is_sign_positive());
1130 }
1131 if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
1132 assert!(f.is_infinite() && f.is_sign_negative());
1133 }
1134 if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
1135 assert!(f.is_nan());
1136 }
1137
1138 if let Some(val) = rule_config.values.get("datetime") {
1140 assert!(matches!(val, toml::Value::Datetime(_)));
1141 }
1142 }
1144 }
1145
1146 #[test]
1147 fn test_default_config_passes_validation() {
1148 use crate::rules;
1149
1150 let temp_dir = tempdir().unwrap();
1151 let config_path = temp_dir.path().join(".rumdl.toml");
1152 let config_path_str = config_path.to_str().unwrap();
1153
1154 create_default_config(config_path_str).unwrap();
1156
1157 let sourced =
1159 SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1160
1161 let all_rules = rules::all_rules(&Config::default());
1163 let registry = RuleRegistry::from_rules(&all_rules);
1164
1165 let warnings = validate_config_sourced(&sourced, ®istry);
1167
1168 if !warnings.is_empty() {
1170 for warning in &warnings {
1171 eprintln!("Config validation warning: {}", warning.message);
1172 if let Some(rule) = &warning.rule {
1173 eprintln!(" Rule: {rule}");
1174 }
1175 if let Some(key) = &warning.key {
1176 eprintln!(" Key: {key}");
1177 }
1178 }
1179 }
1180 assert!(
1181 warnings.is_empty(),
1182 "Default config from rumdl init should pass validation without warnings"
1183 );
1184 }
1185
1186 #[test]
1187 fn test_per_file_ignores_config_parsing() {
1188 let temp_dir = tempdir().unwrap();
1189 let config_path = temp_dir.path().join(".rumdl.toml");
1190 let config_content = r#"
1191[per-file-ignores]
1192"README.md" = ["MD033"]
1193"docs/**/*.md" = ["MD013", "MD033"]
1194"test/*.md" = ["MD041"]
1195"#;
1196 fs::write(&config_path, config_content).unwrap();
1197
1198 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1199 let config: Config = sourced.into_validated_unchecked().into();
1200
1201 assert_eq!(config.per_file_ignores.len(), 3);
1203 assert_eq!(
1204 config.per_file_ignores.get("README.md"),
1205 Some(&vec!["MD033".to_string()])
1206 );
1207 assert_eq!(
1208 config.per_file_ignores.get("docs/**/*.md"),
1209 Some(&vec!["MD013".to_string(), "MD033".to_string()])
1210 );
1211 assert_eq!(
1212 config.per_file_ignores.get("test/*.md"),
1213 Some(&vec!["MD041".to_string()])
1214 );
1215 }
1216
1217 #[test]
1218 fn test_per_file_ignores_glob_matching() {
1219 use std::path::PathBuf;
1220
1221 let temp_dir = tempdir().unwrap();
1222 let config_path = temp_dir.path().join(".rumdl.toml");
1223 let config_content = r#"
1224[per-file-ignores]
1225"README.md" = ["MD033"]
1226"docs/**/*.md" = ["MD013"]
1227"**/test_*.md" = ["MD041"]
1228"#;
1229 fs::write(&config_path, config_content).unwrap();
1230
1231 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1232 let config: Config = sourced.into_validated_unchecked().into();
1233
1234 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1236 assert!(ignored.contains("MD033"));
1237 assert_eq!(ignored.len(), 1);
1238
1239 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1241 assert!(ignored.contains("MD013"));
1242 assert_eq!(ignored.len(), 1);
1243
1244 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
1246 assert!(ignored.contains("MD041"));
1247 assert_eq!(ignored.len(), 1);
1248
1249 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
1251 assert!(ignored.is_empty());
1252 }
1253
1254 #[test]
1255 fn test_per_file_ignores_pyproject_toml() {
1256 let temp_dir = tempdir().unwrap();
1257 let config_path = temp_dir.path().join("pyproject.toml");
1258 let config_content = r#"
1259[tool.rumdl]
1260[tool.rumdl.per-file-ignores]
1261"README.md" = ["MD033", "MD013"]
1262"generated/*.md" = ["MD041"]
1263"#;
1264 fs::write(&config_path, config_content).unwrap();
1265
1266 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1267 let config: Config = sourced.into_validated_unchecked().into();
1268
1269 assert_eq!(config.per_file_ignores.len(), 2);
1271 assert_eq!(
1272 config.per_file_ignores.get("README.md"),
1273 Some(&vec!["MD033".to_string(), "MD013".to_string()])
1274 );
1275 assert_eq!(
1276 config.per_file_ignores.get("generated/*.md"),
1277 Some(&vec!["MD041".to_string()])
1278 );
1279 }
1280
1281 #[test]
1282 fn test_per_file_ignores_multiple_patterns_match() {
1283 use std::path::PathBuf;
1284
1285 let temp_dir = tempdir().unwrap();
1286 let config_path = temp_dir.path().join(".rumdl.toml");
1287 let config_content = r#"
1288[per-file-ignores]
1289"docs/**/*.md" = ["MD013"]
1290"**/api/*.md" = ["MD033"]
1291"docs/api/overview.md" = ["MD041"]
1292"#;
1293 fs::write(&config_path, config_content).unwrap();
1294
1295 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1296 let config: Config = sourced.into_validated_unchecked().into();
1297
1298 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1300 assert_eq!(ignored.len(), 3);
1301 assert!(ignored.contains("MD013"));
1302 assert!(ignored.contains("MD033"));
1303 assert!(ignored.contains("MD041"));
1304 }
1305
1306 #[test]
1307 fn test_per_file_ignores_rule_name_normalization() {
1308 use std::path::PathBuf;
1309
1310 let temp_dir = tempdir().unwrap();
1311 let config_path = temp_dir.path().join(".rumdl.toml");
1312 let config_content = r#"
1313[per-file-ignores]
1314"README.md" = ["md033", "MD013", "Md041"]
1315"#;
1316 fs::write(&config_path, config_content).unwrap();
1317
1318 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1319 let config: Config = sourced.into_validated_unchecked().into();
1320
1321 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1323 assert_eq!(ignored.len(), 3);
1324 assert!(ignored.contains("MD033"));
1325 assert!(ignored.contains("MD013"));
1326 assert!(ignored.contains("MD041"));
1327 }
1328
1329 #[test]
1330 fn test_per_file_ignores_invalid_glob_pattern() {
1331 use std::path::PathBuf;
1332
1333 let temp_dir = tempdir().unwrap();
1334 let config_path = temp_dir.path().join(".rumdl.toml");
1335 let config_content = r#"
1336[per-file-ignores]
1337"[invalid" = ["MD033"]
1338"valid/*.md" = ["MD013"]
1339"#;
1340 fs::write(&config_path, config_content).unwrap();
1341
1342 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1343 let config: Config = sourced.into_validated_unchecked().into();
1344
1345 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
1347 assert!(ignored.contains("MD013"));
1348
1349 let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
1351 assert!(ignored2.is_empty());
1352 }
1353
1354 #[test]
1355 fn test_per_file_ignores_empty_section() {
1356 use std::path::PathBuf;
1357
1358 let temp_dir = tempdir().unwrap();
1359 let config_path = temp_dir.path().join(".rumdl.toml");
1360 let config_content = r#"
1361[global]
1362disable = ["MD001"]
1363
1364[per-file-ignores]
1365"#;
1366 fs::write(&config_path, config_content).unwrap();
1367
1368 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1369 let config: Config = sourced.into_validated_unchecked().into();
1370
1371 assert_eq!(config.per_file_ignores.len(), 0);
1373 let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1374 assert!(ignored.is_empty());
1375 }
1376
1377 #[test]
1378 fn test_per_file_ignores_with_underscores_in_pyproject() {
1379 let temp_dir = tempdir().unwrap();
1380 let config_path = temp_dir.path().join("pyproject.toml");
1381 let config_content = r#"
1382[tool.rumdl]
1383[tool.rumdl.per_file_ignores]
1384"README.md" = ["MD033"]
1385"#;
1386 fs::write(&config_path, config_content).unwrap();
1387
1388 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1389 let config: Config = sourced.into_validated_unchecked().into();
1390
1391 assert_eq!(config.per_file_ignores.len(), 1);
1393 assert_eq!(
1394 config.per_file_ignores.get("README.md"),
1395 Some(&vec!["MD033".to_string()])
1396 );
1397 }
1398
1399 #[test]
1400 fn test_per_file_ignores_absolute_path_matching() {
1401 use std::path::PathBuf;
1404
1405 let temp_dir = tempdir().unwrap();
1406 let config_path = temp_dir.path().join(".rumdl.toml");
1407
1408 let github_dir = temp_dir.path().join(".github");
1410 fs::create_dir_all(&github_dir).unwrap();
1411 let test_file = github_dir.join("pull_request_template.md");
1412 fs::write(&test_file, "Test content").unwrap();
1413
1414 let config_content = r#"
1415[per-file-ignores]
1416".github/pull_request_template.md" = ["MD041"]
1417"docs/**/*.md" = ["MD013"]
1418"#;
1419 fs::write(&config_path, config_content).unwrap();
1420
1421 let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1422 let config: Config = sourced.into_validated_unchecked().into();
1423
1424 let absolute_path = test_file.canonicalize().unwrap();
1426 let ignored = config.get_ignored_rules_for_file(&absolute_path);
1427 assert!(
1428 ignored.contains("MD041"),
1429 "Should match absolute path {absolute_path:?} against relative pattern"
1430 );
1431 assert_eq!(ignored.len(), 1);
1432
1433 let relative_path = PathBuf::from(".github/pull_request_template.md");
1435 let ignored = config.get_ignored_rules_for_file(&relative_path);
1436 assert!(ignored.contains("MD041"), "Should match relative path");
1437 }
1438
1439 #[test]
1440 fn test_generate_json_schema() {
1441 use schemars::schema_for;
1442 use std::env;
1443
1444 let schema = schema_for!(Config);
1445 let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
1446
1447 if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
1449 let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
1450 fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
1451 println!("Schema written to: {}", schema_path.display());
1452 }
1453
1454 assert!(schema_json.contains("\"title\": \"Config\""));
1456 assert!(schema_json.contains("\"global\""));
1457 assert!(schema_json.contains("\"per-file-ignores\""));
1458 }
1459
1460 #[test]
1461 fn test_user_config_loaded_with_explicit_project_config() {
1462 let temp_dir = tempdir().unwrap();
1465
1466 let user_config_dir = temp_dir.path().join("user_config");
1469 let rumdl_config_dir = user_config_dir.join("rumdl");
1470 fs::create_dir_all(&rumdl_config_dir).unwrap();
1471 let user_config_path = rumdl_config_dir.join("rumdl.toml");
1472
1473 let user_config_content = r#"
1475[global]
1476disable = ["MD013", "MD041"]
1477line-length = 100
1478"#;
1479 fs::write(&user_config_path, user_config_content).unwrap();
1480
1481 let project_config_path = temp_dir.path().join("project").join("pyproject.toml");
1483 fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1484 let project_config_content = r#"
1485[tool.rumdl]
1486enable = ["MD001"]
1487"#;
1488 fs::write(&project_config_path, project_config_content).unwrap();
1489
1490 let sourced = SourcedConfig::load_with_discovery_impl(
1492 Some(project_config_path.to_str().unwrap()),
1493 None,
1494 false,
1495 Some(&user_config_dir),
1496 )
1497 .unwrap();
1498
1499 let config: Config = sourced.into_validated_unchecked().into();
1500
1501 assert!(
1503 config.global.disable.contains(&"MD013".to_string()),
1504 "User config disabled rules should be preserved"
1505 );
1506 assert!(
1507 config.global.disable.contains(&"MD041".to_string()),
1508 "User config disabled rules should be preserved"
1509 );
1510
1511 assert!(
1513 config.global.enable.contains(&"MD001".to_string()),
1514 "Project config enabled rules should be applied"
1515 );
1516 }
1517
1518 #[test]
1519 fn test_typestate_validate_method() {
1520 use tempfile::tempdir;
1521
1522 let temp_dir = tempdir().expect("Failed to create temporary directory");
1523 let config_path = temp_dir.path().join("test.toml");
1524
1525 let config_content = r#"
1527[global]
1528enable = ["MD001"]
1529
1530[MD013]
1531line_length = 80
1532unknown_option = true
1533"#;
1534 std::fs::write(&config_path, config_content).expect("Failed to write config");
1535
1536 let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
1538 .expect("Should load config");
1539
1540 let default_config = Config::default();
1542 let all_rules = crate::rules::all_rules(&default_config);
1543 let registry = RuleRegistry::from_rules(&all_rules);
1544
1545 let validated = loaded.validate(®istry).expect("Should validate config");
1547
1548 let has_unknown_option_warning = validated
1551 .validation_warnings
1552 .iter()
1553 .any(|w| w.message.contains("unknown_option") || w.message.contains("Unknown option"));
1554
1555 if !has_unknown_option_warning {
1557 for w in &validated.validation_warnings {
1558 eprintln!("Warning: {}", w.message);
1559 }
1560 }
1561 assert!(
1562 has_unknown_option_warning,
1563 "Should have warning for unknown option. Got {} warnings: {:?}",
1564 validated.validation_warnings.len(),
1565 validated
1566 .validation_warnings
1567 .iter()
1568 .map(|w| &w.message)
1569 .collect::<Vec<_>>()
1570 );
1571
1572 let config: Config = validated.into();
1574
1575 assert!(config.global.enable.contains(&"MD001".to_string()));
1577 }
1578
1579 #[test]
1580 fn test_typestate_validate_into_convenience_method() {
1581 use tempfile::tempdir;
1582
1583 let temp_dir = tempdir().expect("Failed to create temporary directory");
1584 let config_path = temp_dir.path().join("test.toml");
1585
1586 let config_content = r#"
1587[global]
1588enable = ["MD022"]
1589
1590[MD022]
1591lines_above = 2
1592"#;
1593 std::fs::write(&config_path, config_content).expect("Failed to write config");
1594
1595 let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
1596 .expect("Should load config");
1597
1598 let default_config = Config::default();
1599 let all_rules = crate::rules::all_rules(&default_config);
1600 let registry = RuleRegistry::from_rules(&all_rules);
1601
1602 let (config, warnings) = loaded.validate_into(®istry).expect("Should validate and convert");
1604
1605 assert!(warnings.is_empty(), "Should have no warnings for valid config");
1607
1608 assert!(config.global.enable.contains(&"MD022".to_string()));
1610 }
1611}
1612
1613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1622pub enum ConfigSource {
1623 Default,
1625 UserConfig,
1627 PyprojectToml,
1629 ProjectConfig,
1631 Cli,
1633}
1634
1635#[derive(Debug, Clone)]
1636pub struct ConfigOverride<T> {
1637 pub value: T,
1638 pub source: ConfigSource,
1639 pub file: Option<String>,
1640 pub line: Option<usize>,
1641}
1642
1643#[derive(Debug, Clone)]
1644pub struct SourcedValue<T> {
1645 pub value: T,
1646 pub source: ConfigSource,
1647 pub overrides: Vec<ConfigOverride<T>>,
1648}
1649
1650impl<T: Clone> SourcedValue<T> {
1651 pub fn new(value: T, source: ConfigSource) -> Self {
1652 Self {
1653 value: value.clone(),
1654 source,
1655 overrides: vec![ConfigOverride {
1656 value,
1657 source,
1658 file: None,
1659 line: None,
1660 }],
1661 }
1662 }
1663
1664 pub fn merge_override(
1668 &mut self,
1669 new_value: T,
1670 new_source: ConfigSource,
1671 new_file: Option<String>,
1672 new_line: Option<usize>,
1673 ) {
1674 fn source_precedence(src: ConfigSource) -> u8 {
1676 match src {
1677 ConfigSource::Default => 0,
1678 ConfigSource::UserConfig => 1,
1679 ConfigSource::PyprojectToml => 2,
1680 ConfigSource::ProjectConfig => 3,
1681 ConfigSource::Cli => 4,
1682 }
1683 }
1684
1685 if source_precedence(new_source) >= source_precedence(self.source) {
1686 self.value = new_value.clone();
1687 self.source = new_source;
1688 self.overrides.push(ConfigOverride {
1689 value: new_value,
1690 source: new_source,
1691 file: new_file,
1692 line: new_line,
1693 });
1694 }
1695 }
1696
1697 pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
1698 self.value = value.clone();
1701 self.source = source;
1702 self.overrides.push(ConfigOverride {
1703 value,
1704 source,
1705 file,
1706 line,
1707 });
1708 }
1709}
1710
1711impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
1712 pub fn merge_union(
1715 &mut self,
1716 new_value: Vec<T>,
1717 new_source: ConfigSource,
1718 new_file: Option<String>,
1719 new_line: Option<usize>,
1720 ) {
1721 fn source_precedence(src: ConfigSource) -> u8 {
1722 match src {
1723 ConfigSource::Default => 0,
1724 ConfigSource::UserConfig => 1,
1725 ConfigSource::PyprojectToml => 2,
1726 ConfigSource::ProjectConfig => 3,
1727 ConfigSource::Cli => 4,
1728 }
1729 }
1730
1731 if source_precedence(new_source) >= source_precedence(self.source) {
1732 let mut combined = self.value.clone();
1734 for item in new_value.iter() {
1735 if !combined.contains(item) {
1736 combined.push(item.clone());
1737 }
1738 }
1739
1740 self.value = combined;
1741 self.source = new_source;
1742 self.overrides.push(ConfigOverride {
1743 value: new_value,
1744 source: new_source,
1745 file: new_file,
1746 line: new_line,
1747 });
1748 }
1749 }
1750}
1751
1752#[derive(Debug, Clone)]
1753pub struct SourcedGlobalConfig {
1754 pub enable: SourcedValue<Vec<String>>,
1755 pub disable: SourcedValue<Vec<String>>,
1756 pub exclude: SourcedValue<Vec<String>>,
1757 pub include: SourcedValue<Vec<String>>,
1758 pub respect_gitignore: SourcedValue<bool>,
1759 pub line_length: SourcedValue<LineLength>,
1760 pub output_format: Option<SourcedValue<String>>,
1761 pub fixable: SourcedValue<Vec<String>>,
1762 pub unfixable: SourcedValue<Vec<String>>,
1763 pub flavor: SourcedValue<MarkdownFlavor>,
1764 pub force_exclude: SourcedValue<bool>,
1765 pub cache_dir: Option<SourcedValue<String>>,
1766 pub cache: SourcedValue<bool>,
1767}
1768
1769impl Default for SourcedGlobalConfig {
1770 fn default() -> Self {
1771 SourcedGlobalConfig {
1772 enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1773 disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1774 exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
1775 include: SourcedValue::new(Vec::new(), ConfigSource::Default),
1776 respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
1777 line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
1778 output_format: None,
1779 fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1780 unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1781 flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
1782 force_exclude: SourcedValue::new(false, ConfigSource::Default),
1783 cache_dir: None,
1784 cache: SourcedValue::new(true, ConfigSource::Default),
1785 }
1786 }
1787}
1788
1789#[derive(Debug, Default, Clone)]
1790pub struct SourcedRuleConfig {
1791 pub values: BTreeMap<String, SourcedValue<toml::Value>>,
1792}
1793
1794#[derive(Debug, Clone)]
1797pub struct SourcedConfigFragment {
1798 pub global: SourcedGlobalConfig,
1799 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
1800 pub rules: BTreeMap<String, SourcedRuleConfig>,
1801 pub unknown_keys: Vec<(String, String, Option<String>)>, }
1804
1805impl Default for SourcedConfigFragment {
1806 fn default() -> Self {
1807 Self {
1808 global: SourcedGlobalConfig::default(),
1809 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
1810 rules: BTreeMap::new(),
1811 unknown_keys: Vec::new(),
1812 }
1813 }
1814}
1815
1816#[derive(Debug, Clone)]
1834pub struct SourcedConfig<State = ConfigLoaded> {
1835 pub global: SourcedGlobalConfig,
1836 pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
1837 pub rules: BTreeMap<String, SourcedRuleConfig>,
1838 pub loaded_files: Vec<String>,
1839 pub unknown_keys: Vec<(String, String, Option<String>)>, pub project_root: Option<std::path::PathBuf>,
1842 pub validation_warnings: Vec<ConfigValidationWarning>,
1844 _state: PhantomData<State>,
1846}
1847
1848impl Default for SourcedConfig<ConfigLoaded> {
1849 fn default() -> Self {
1850 Self {
1851 global: SourcedGlobalConfig::default(),
1852 per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
1853 rules: BTreeMap::new(),
1854 loaded_files: Vec::new(),
1855 unknown_keys: Vec::new(),
1856 project_root: None,
1857 validation_warnings: Vec::new(),
1858 _state: PhantomData,
1859 }
1860 }
1861}
1862
1863impl SourcedConfig<ConfigLoaded> {
1864 fn merge(&mut self, fragment: SourcedConfigFragment) {
1867 self.global.enable.merge_override(
1870 fragment.global.enable.value,
1871 fragment.global.enable.source,
1872 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
1873 fragment.global.enable.overrides.first().and_then(|o| o.line),
1874 );
1875
1876 self.global.disable.merge_union(
1878 fragment.global.disable.value,
1879 fragment.global.disable.source,
1880 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
1881 fragment.global.disable.overrides.first().and_then(|o| o.line),
1882 );
1883
1884 self.global
1887 .disable
1888 .value
1889 .retain(|rule| !self.global.enable.value.contains(rule));
1890 self.global.include.merge_override(
1891 fragment.global.include.value,
1892 fragment.global.include.source,
1893 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
1894 fragment.global.include.overrides.first().and_then(|o| o.line),
1895 );
1896 self.global.exclude.merge_override(
1897 fragment.global.exclude.value,
1898 fragment.global.exclude.source,
1899 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
1900 fragment.global.exclude.overrides.first().and_then(|o| o.line),
1901 );
1902 self.global.respect_gitignore.merge_override(
1903 fragment.global.respect_gitignore.value,
1904 fragment.global.respect_gitignore.source,
1905 fragment
1906 .global
1907 .respect_gitignore
1908 .overrides
1909 .first()
1910 .and_then(|o| o.file.clone()),
1911 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
1912 );
1913 self.global.line_length.merge_override(
1914 fragment.global.line_length.value,
1915 fragment.global.line_length.source,
1916 fragment
1917 .global
1918 .line_length
1919 .overrides
1920 .first()
1921 .and_then(|o| o.file.clone()),
1922 fragment.global.line_length.overrides.first().and_then(|o| o.line),
1923 );
1924 self.global.fixable.merge_override(
1925 fragment.global.fixable.value,
1926 fragment.global.fixable.source,
1927 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
1928 fragment.global.fixable.overrides.first().and_then(|o| o.line),
1929 );
1930 self.global.unfixable.merge_override(
1931 fragment.global.unfixable.value,
1932 fragment.global.unfixable.source,
1933 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
1934 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
1935 );
1936
1937 self.global.flavor.merge_override(
1939 fragment.global.flavor.value,
1940 fragment.global.flavor.source,
1941 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
1942 fragment.global.flavor.overrides.first().and_then(|o| o.line),
1943 );
1944
1945 self.global.force_exclude.merge_override(
1947 fragment.global.force_exclude.value,
1948 fragment.global.force_exclude.source,
1949 fragment
1950 .global
1951 .force_exclude
1952 .overrides
1953 .first()
1954 .and_then(|o| o.file.clone()),
1955 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
1956 );
1957
1958 if let Some(output_format_fragment) = fragment.global.output_format {
1960 if let Some(ref mut output_format) = self.global.output_format {
1961 output_format.merge_override(
1962 output_format_fragment.value,
1963 output_format_fragment.source,
1964 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
1965 output_format_fragment.overrides.first().and_then(|o| o.line),
1966 );
1967 } else {
1968 self.global.output_format = Some(output_format_fragment);
1969 }
1970 }
1971
1972 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
1974 if let Some(ref mut cache_dir) = self.global.cache_dir {
1975 cache_dir.merge_override(
1976 cache_dir_fragment.value,
1977 cache_dir_fragment.source,
1978 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
1979 cache_dir_fragment.overrides.first().and_then(|o| o.line),
1980 );
1981 } else {
1982 self.global.cache_dir = Some(cache_dir_fragment);
1983 }
1984 }
1985
1986 if fragment.global.cache.source != ConfigSource::Default {
1988 self.global.cache.merge_override(
1989 fragment.global.cache.value,
1990 fragment.global.cache.source,
1991 fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
1992 fragment.global.cache.overrides.first().and_then(|o| o.line),
1993 );
1994 }
1995
1996 self.per_file_ignores.merge_override(
1998 fragment.per_file_ignores.value,
1999 fragment.per_file_ignores.source,
2000 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
2001 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
2002 );
2003
2004 for (rule_name, rule_fragment) in fragment.rules {
2006 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
2008 for (key, sourced_value_fragment) in rule_fragment.values {
2009 let sv_entry = rule_entry
2010 .values
2011 .entry(key.clone())
2012 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
2013 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
2014 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
2015 sv_entry.merge_override(
2016 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
2021 }
2022 }
2023
2024 for (section, key, file_path) in fragment.unknown_keys {
2026 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
2028 self.unknown_keys.push((section, key, file_path));
2029 }
2030 }
2031 }
2032
2033 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
2035 Self::load_with_discovery(config_path, cli_overrides, false)
2036 }
2037
2038 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
2041 let mut current = start_dir.to_path_buf();
2042 const MAX_DEPTH: usize = 100;
2043
2044 for _ in 0..MAX_DEPTH {
2045 if current.join(".git").exists() {
2046 log::debug!("[rumdl-config] Found .git at: {}", current.display());
2047 return current;
2048 }
2049
2050 match current.parent() {
2051 Some(parent) => current = parent.to_path_buf(),
2052 None => break,
2053 }
2054 }
2055
2056 log::debug!(
2058 "[rumdl-config] No .git found, using config location as project root: {}",
2059 start_dir.display()
2060 );
2061 start_dir.to_path_buf()
2062 }
2063
2064 fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
2070 use std::env;
2071
2072 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
2073 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
2076 Ok(dir) => dir,
2077 Err(e) => {
2078 log::debug!("[rumdl-config] Failed to get current directory: {e}");
2079 return None;
2080 }
2081 };
2082
2083 let mut current_dir = start_dir.clone();
2084 let mut depth = 0;
2085 let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2086
2087 loop {
2088 if depth >= MAX_DEPTH {
2089 log::debug!("[rumdl-config] Maximum traversal depth reached");
2090 break;
2091 }
2092
2093 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
2094
2095 if found_config.is_none() {
2097 for config_name in CONFIG_FILES {
2098 let config_path = current_dir.join(config_name);
2099
2100 if config_path.exists() {
2101 if *config_name == "pyproject.toml" {
2103 if let Ok(content) = std::fs::read_to_string(&config_path) {
2104 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
2105 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
2106 found_config = Some((config_path.clone(), current_dir.clone()));
2108 break;
2109 }
2110 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
2111 continue;
2112 }
2113 } else {
2114 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
2115 found_config = Some((config_path.clone(), current_dir.clone()));
2117 break;
2118 }
2119 }
2120 }
2121 }
2122
2123 if current_dir.join(".git").exists() {
2125 log::debug!("[rumdl-config] Stopping at .git directory");
2126 break;
2127 }
2128
2129 match current_dir.parent() {
2131 Some(parent) => {
2132 current_dir = parent.to_owned();
2133 depth += 1;
2134 }
2135 None => {
2136 log::debug!("[rumdl-config] Reached filesystem root");
2137 break;
2138 }
2139 }
2140 }
2141
2142 if let Some((config_path, config_dir)) = found_config {
2144 let project_root = Self::find_project_root_from(&config_dir);
2145 return Some((config_path, project_root));
2146 }
2147
2148 None
2149 }
2150
2151 fn discover_markdownlint_config_upward() -> Option<std::path::PathBuf> {
2155 use std::env;
2156
2157 const MAX_DEPTH: usize = 100;
2158
2159 let start_dir = match env::current_dir() {
2160 Ok(dir) => dir,
2161 Err(e) => {
2162 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
2163 return None;
2164 }
2165 };
2166
2167 let mut current_dir = start_dir.clone();
2168 let mut depth = 0;
2169
2170 loop {
2171 if depth >= MAX_DEPTH {
2172 log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
2173 break;
2174 }
2175
2176 log::debug!(
2177 "[rumdl-config] Searching for markdownlint config in: {}",
2178 current_dir.display()
2179 );
2180
2181 for config_name in MARKDOWNLINT_CONFIG_FILES {
2183 let config_path = current_dir.join(config_name);
2184 if config_path.exists() {
2185 log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
2186 return Some(config_path);
2187 }
2188 }
2189
2190 if current_dir.join(".git").exists() {
2192 log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
2193 break;
2194 }
2195
2196 match current_dir.parent() {
2198 Some(parent) => {
2199 current_dir = parent.to_owned();
2200 depth += 1;
2201 }
2202 None => {
2203 log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
2204 break;
2205 }
2206 }
2207 }
2208
2209 None
2210 }
2211
2212 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
2214 let config_dir = config_dir.join("rumdl");
2215
2216 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
2218
2219 log::debug!(
2220 "[rumdl-config] Checking for user configuration in: {}",
2221 config_dir.display()
2222 );
2223
2224 for filename in USER_CONFIG_FILES {
2225 let config_path = config_dir.join(filename);
2226
2227 if config_path.exists() {
2228 if *filename == "pyproject.toml" {
2230 if let Ok(content) = std::fs::read_to_string(&config_path) {
2231 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
2232 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
2233 return Some(config_path);
2234 }
2235 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
2236 continue;
2237 }
2238 } else {
2239 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
2240 return Some(config_path);
2241 }
2242 }
2243 }
2244
2245 log::debug!(
2246 "[rumdl-config] No user configuration found in: {}",
2247 config_dir.display()
2248 );
2249 None
2250 }
2251
2252 #[cfg(feature = "native")]
2255 fn user_configuration_path() -> Option<std::path::PathBuf> {
2256 use etcetera::{BaseStrategy, choose_base_strategy};
2257
2258 match choose_base_strategy() {
2259 Ok(strategy) => {
2260 let config_dir = strategy.config_dir();
2261 Self::user_configuration_path_impl(&config_dir)
2262 }
2263 Err(e) => {
2264 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
2265 None
2266 }
2267 }
2268 }
2269
2270 #[cfg(not(feature = "native"))]
2272 fn user_configuration_path() -> Option<std::path::PathBuf> {
2273 None
2274 }
2275
2276 #[doc(hidden)]
2278 pub fn load_with_discovery_impl(
2279 config_path: Option<&str>,
2280 cli_overrides: Option<&SourcedGlobalConfig>,
2281 skip_auto_discovery: bool,
2282 user_config_dir: Option<&Path>,
2283 ) -> Result<Self, ConfigError> {
2284 use std::env;
2285 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
2286 if config_path.is_none() {
2287 if skip_auto_discovery {
2288 log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
2289 } else {
2290 log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
2291 }
2292 } else {
2293 log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
2294 }
2295 let mut sourced_config = SourcedConfig::default();
2296
2297 if !skip_auto_discovery {
2300 let user_config_path = if let Some(dir) = user_config_dir {
2301 Self::user_configuration_path_impl(dir)
2302 } else {
2303 Self::user_configuration_path()
2304 };
2305
2306 if let Some(user_config_path) = user_config_path {
2307 let path_str = user_config_path.display().to_string();
2308 let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2309
2310 log::debug!("[rumdl-config] Loading user configuration file: {path_str}");
2311
2312 if filename == "pyproject.toml" {
2313 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2314 source: e,
2315 path: path_str.clone(),
2316 })?;
2317 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2318 sourced_config.merge(fragment);
2319 sourced_config.loaded_files.push(path_str);
2320 }
2321 } else {
2322 let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2323 source: e,
2324 path: path_str.clone(),
2325 })?;
2326 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
2327 sourced_config.merge(fragment);
2328 sourced_config.loaded_files.push(path_str);
2329 }
2330 } else {
2331 log::debug!("[rumdl-config] No user configuration file found");
2332 }
2333 }
2334
2335 if let Some(path) = config_path {
2337 let path_obj = Path::new(path);
2338 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
2339 log::debug!("[rumdl-config] Trying to load config file: {filename}");
2340 let path_str = path.to_string();
2341
2342 if let Some(config_parent) = path_obj.parent() {
2344 let project_root = Self::find_project_root_from(config_parent);
2345 log::debug!(
2346 "[rumdl-config] Project root (from explicit config): {}",
2347 project_root.display()
2348 );
2349 sourced_config.project_root = Some(project_root);
2350 }
2351
2352 const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
2354
2355 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
2356 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2357 source: e,
2358 path: path_str.clone(),
2359 })?;
2360 if filename == "pyproject.toml" {
2361 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2362 sourced_config.merge(fragment);
2363 sourced_config.loaded_files.push(path_str.clone());
2364 }
2365 } else {
2366 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2367 sourced_config.merge(fragment);
2368 sourced_config.loaded_files.push(path_str.clone());
2369 }
2370 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
2371 || path_str.ends_with(".json")
2372 || path_str.ends_with(".jsonc")
2373 || path_str.ends_with(".yaml")
2374 || path_str.ends_with(".yml")
2375 {
2376 let fragment = load_from_markdownlint(&path_str)?;
2378 sourced_config.merge(fragment);
2379 sourced_config.loaded_files.push(path_str.clone());
2380 } else {
2382 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2384 source: e,
2385 path: path_str.clone(),
2386 })?;
2387 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2388 sourced_config.merge(fragment);
2389 sourced_config.loaded_files.push(path_str.clone());
2390 }
2391 }
2392
2393 if !skip_auto_discovery && config_path.is_none() {
2395 if let Some((config_file, project_root)) = Self::discover_config_upward() {
2397 let path_str = config_file.display().to_string();
2398 let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
2399
2400 log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
2401 log::debug!("[rumdl-config] Project root: {}", project_root.display());
2402
2403 sourced_config.project_root = Some(project_root);
2405
2406 if filename == "pyproject.toml" {
2407 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2408 source: e,
2409 path: path_str.clone(),
2410 })?;
2411 if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2412 sourced_config.merge(fragment);
2413 sourced_config.loaded_files.push(path_str);
2414 }
2415 } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
2416 let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2417 source: e,
2418 path: path_str.clone(),
2419 })?;
2420 let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2421 sourced_config.merge(fragment);
2422 sourced_config.loaded_files.push(path_str);
2423 }
2424 } else {
2425 log::debug!("[rumdl-config] No configuration file found via upward traversal");
2426
2427 if let Some(config_path) = Self::discover_markdownlint_config_upward() {
2429 let path_str = config_path.display().to_string();
2430 match load_from_markdownlint(&path_str) {
2431 Ok(fragment) => {
2432 sourced_config.merge(fragment);
2433 sourced_config.loaded_files.push(path_str);
2434 }
2435 Err(_e) => {
2436 log::debug!("[rumdl-config] Failed to load markdownlint config");
2437 }
2438 }
2439 } else {
2440 log::debug!("[rumdl-config] No markdownlint configuration file found");
2441 }
2442 }
2443 }
2444
2445 if let Some(cli) = cli_overrides {
2447 sourced_config
2448 .global
2449 .enable
2450 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
2451 sourced_config
2452 .global
2453 .disable
2454 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
2455 sourced_config
2456 .global
2457 .exclude
2458 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
2459 sourced_config
2460 .global
2461 .include
2462 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
2463 sourced_config.global.respect_gitignore.merge_override(
2464 cli.respect_gitignore.value,
2465 ConfigSource::Cli,
2466 None,
2467 None,
2468 );
2469 sourced_config
2470 .global
2471 .fixable
2472 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
2473 sourced_config
2474 .global
2475 .unfixable
2476 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
2477 }
2479
2480 Ok(sourced_config)
2483 }
2484
2485 pub fn load_with_discovery(
2488 config_path: Option<&str>,
2489 cli_overrides: Option<&SourcedGlobalConfig>,
2490 skip_auto_discovery: bool,
2491 ) -> Result<Self, ConfigError> {
2492 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
2493 }
2494
2495 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
2509 let warnings = validate_config_sourced_internal(&self, registry);
2510
2511 Ok(SourcedConfig {
2512 global: self.global,
2513 per_file_ignores: self.per_file_ignores,
2514 rules: self.rules,
2515 loaded_files: self.loaded_files,
2516 unknown_keys: self.unknown_keys,
2517 project_root: self.project_root,
2518 validation_warnings: warnings,
2519 _state: PhantomData,
2520 })
2521 }
2522
2523 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
2528 let validated = self.validate(registry)?;
2529 let warnings = validated.validation_warnings.clone();
2530 Ok((validated.into(), warnings))
2531 }
2532
2533 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
2544 SourcedConfig {
2545 global: self.global,
2546 per_file_ignores: self.per_file_ignores,
2547 rules: self.rules,
2548 loaded_files: self.loaded_files,
2549 unknown_keys: self.unknown_keys,
2550 project_root: self.project_root,
2551 validation_warnings: Vec::new(),
2552 _state: PhantomData,
2553 }
2554 }
2555}
2556
2557impl From<SourcedConfig<ConfigValidated>> for Config {
2562 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
2563 let mut rules = BTreeMap::new();
2564 for (rule_name, sourced_rule_cfg) in sourced.rules {
2565 let normalized_rule_name = rule_name.to_ascii_uppercase();
2567 let mut values = BTreeMap::new();
2568 for (key, sourced_val) in sourced_rule_cfg.values {
2569 values.insert(key, sourced_val.value);
2570 }
2571 rules.insert(normalized_rule_name, RuleConfig { values });
2572 }
2573 #[allow(deprecated)]
2574 let global = GlobalConfig {
2575 enable: sourced.global.enable.value,
2576 disable: sourced.global.disable.value,
2577 exclude: sourced.global.exclude.value,
2578 include: sourced.global.include.value,
2579 respect_gitignore: sourced.global.respect_gitignore.value,
2580 line_length: sourced.global.line_length.value,
2581 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
2582 fixable: sourced.global.fixable.value,
2583 unfixable: sourced.global.unfixable.value,
2584 flavor: sourced.global.flavor.value,
2585 force_exclude: sourced.global.force_exclude.value,
2586 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
2587 cache: sourced.global.cache.value,
2588 };
2589 Config {
2590 global,
2591 per_file_ignores: sourced.per_file_ignores.value,
2592 rules,
2593 project_root: sourced.project_root,
2594 }
2595 }
2596}
2597
2598pub struct RuleRegistry {
2600 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
2602 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
2604}
2605
2606impl RuleRegistry {
2607 pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
2609 let mut rule_schemas = std::collections::BTreeMap::new();
2610 let mut rule_aliases = std::collections::BTreeMap::new();
2611
2612 for rule in rules {
2613 let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
2614 let norm_name = normalize_key(&name); rule_schemas.insert(norm_name.clone(), table);
2616 norm_name
2617 } else {
2618 let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
2620 norm_name
2621 };
2622
2623 if let Some(aliases) = rule.config_aliases() {
2625 rule_aliases.insert(norm_name, aliases);
2626 }
2627 }
2628
2629 RuleRegistry {
2630 rule_schemas,
2631 rule_aliases,
2632 }
2633 }
2634
2635 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
2637 self.rule_schemas.keys().cloned().collect()
2638 }
2639
2640 pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
2642 self.rule_schemas.get(rule).map(|schema| {
2643 let mut all_keys = std::collections::BTreeSet::new();
2644
2645 for key in schema.keys() {
2647 all_keys.insert(key.clone());
2648 }
2649
2650 for key in schema.keys() {
2652 all_keys.insert(key.replace('_', "-"));
2654 all_keys.insert(key.replace('-', "_"));
2656 all_keys.insert(normalize_key(key));
2658 }
2659
2660 if let Some(aliases) = self.rule_aliases.get(rule) {
2662 for alias_key in aliases.keys() {
2663 all_keys.insert(alias_key.clone());
2664 all_keys.insert(alias_key.replace('_', "-"));
2666 all_keys.insert(alias_key.replace('-', "_"));
2667 all_keys.insert(normalize_key(alias_key));
2668 }
2669 }
2670
2671 all_keys
2672 })
2673 }
2674
2675 pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
2677 if let Some(schema) = self.rule_schemas.get(rule) {
2678 if let Some(aliases) = self.rule_aliases.get(rule)
2680 && let Some(canonical_key) = aliases.get(key)
2681 {
2682 if let Some(value) = schema.get(canonical_key) {
2684 return Some(value);
2685 }
2686 }
2687
2688 if let Some(value) = schema.get(key) {
2690 return Some(value);
2691 }
2692
2693 let key_variants = [
2695 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
2699
2700 for variant in &key_variants {
2701 if let Some(value) = schema.get(variant) {
2702 return Some(value);
2703 }
2704 }
2705 }
2706 None
2707 }
2708}
2709
2710#[derive(Debug, Clone)]
2712pub struct ConfigValidationWarning {
2713 pub message: String,
2714 pub rule: Option<String>,
2715 pub key: Option<String>,
2716}
2717
2718fn validate_config_sourced_internal<S>(
2721 sourced: &SourcedConfig<S>,
2722 registry: &RuleRegistry,
2723) -> Vec<ConfigValidationWarning> {
2724 validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry)
2725}
2726
2727fn validate_config_sourced_impl(
2729 rules: &BTreeMap<String, SourcedRuleConfig>,
2730 unknown_keys: &[(String, String, Option<String>)],
2731 registry: &RuleRegistry,
2732) -> Vec<ConfigValidationWarning> {
2733 let mut warnings = Vec::new();
2734 let known_rules = registry.rule_names();
2735 for rule in rules.keys() {
2737 if !known_rules.contains(rule) {
2738 warnings.push(ConfigValidationWarning {
2739 message: format!("Unknown rule in config: {rule}"),
2740 rule: Some(rule.clone()),
2741 key: None,
2742 });
2743 }
2744 }
2745 for (rule, rule_cfg) in rules {
2747 if let Some(valid_keys) = registry.config_keys_for(rule) {
2748 for key in rule_cfg.values.keys() {
2749 if !valid_keys.contains(key) {
2750 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
2751 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
2752 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
2753 } else {
2754 format!("Unknown option for rule {rule}: {key}")
2755 };
2756 warnings.push(ConfigValidationWarning {
2757 message,
2758 rule: Some(rule.clone()),
2759 key: Some(key.clone()),
2760 });
2761 } else {
2762 if let Some(expected) = registry.expected_value_for(rule, key) {
2764 let actual = &rule_cfg.values[key].value;
2765 if !toml_value_type_matches(expected, actual) {
2766 warnings.push(ConfigValidationWarning {
2767 message: format!(
2768 "Type mismatch for {}.{}: expected {}, got {}",
2769 rule,
2770 key,
2771 toml_type_name(expected),
2772 toml_type_name(actual)
2773 ),
2774 rule: Some(rule.clone()),
2775 key: Some(key.clone()),
2776 });
2777 }
2778 }
2779 }
2780 }
2781 }
2782 }
2783 let known_global_keys = vec![
2785 "enable".to_string(),
2786 "disable".to_string(),
2787 "include".to_string(),
2788 "exclude".to_string(),
2789 "respect-gitignore".to_string(),
2790 "line-length".to_string(),
2791 "fixable".to_string(),
2792 "unfixable".to_string(),
2793 "flavor".to_string(),
2794 "force-exclude".to_string(),
2795 "output-format".to_string(),
2796 "cache-dir".to_string(),
2797 "cache".to_string(),
2798 ];
2799
2800 for (section, key, file_path) in unknown_keys {
2801 if section.contains("[global]") || section.contains("[tool.rumdl]") {
2802 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
2803 if let Some(path) = file_path {
2804 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
2805 } else {
2806 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
2807 }
2808 } else if let Some(path) = file_path {
2809 format!("Unknown global option in {path}: {key}")
2810 } else {
2811 format!("Unknown global option: {key}")
2812 };
2813 warnings.push(ConfigValidationWarning {
2814 message,
2815 rule: None,
2816 key: Some(key.clone()),
2817 });
2818 } else if !key.is_empty() {
2819 continue;
2822 } else {
2823 let message = if let Some(path) = file_path {
2825 format!(
2826 "Unknown rule in {path}: {}",
2827 section.trim_matches(|c| c == '[' || c == ']')
2828 )
2829 } else {
2830 format!(
2831 "Unknown rule in config: {}",
2832 section.trim_matches(|c| c == '[' || c == ']')
2833 )
2834 };
2835 warnings.push(ConfigValidationWarning {
2836 message,
2837 rule: None,
2838 key: None,
2839 });
2840 }
2841 }
2842 warnings
2843}
2844
2845pub fn validate_config_sourced(
2851 sourced: &SourcedConfig<ConfigLoaded>,
2852 registry: &RuleRegistry,
2853) -> Vec<ConfigValidationWarning> {
2854 validate_config_sourced_internal(sourced, registry)
2855}
2856
2857pub fn validate_config_sourced_validated(
2861 sourced: &SourcedConfig<ConfigValidated>,
2862 _registry: &RuleRegistry,
2863) -> Vec<ConfigValidationWarning> {
2864 sourced.validation_warnings.clone()
2865}
2866
2867fn toml_type_name(val: &toml::Value) -> &'static str {
2868 match val {
2869 toml::Value::String(_) => "string",
2870 toml::Value::Integer(_) => "integer",
2871 toml::Value::Float(_) => "float",
2872 toml::Value::Boolean(_) => "boolean",
2873 toml::Value::Array(_) => "array",
2874 toml::Value::Table(_) => "table",
2875 toml::Value::Datetime(_) => "datetime",
2876 }
2877}
2878
2879fn levenshtein_distance(s1: &str, s2: &str) -> usize {
2881 let len1 = s1.len();
2882 let len2 = s2.len();
2883
2884 if len1 == 0 {
2885 return len2;
2886 }
2887 if len2 == 0 {
2888 return len1;
2889 }
2890
2891 let s1_chars: Vec<char> = s1.chars().collect();
2892 let s2_chars: Vec<char> = s2.chars().collect();
2893
2894 let mut prev_row: Vec<usize> = (0..=len2).collect();
2895 let mut curr_row = vec![0; len2 + 1];
2896
2897 for i in 1..=len1 {
2898 curr_row[0] = i;
2899 for j in 1..=len2 {
2900 let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
2901 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
2905 std::mem::swap(&mut prev_row, &mut curr_row);
2906 }
2907
2908 prev_row[len2]
2909}
2910
2911fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
2913 let unknown_lower = unknown.to_lowercase();
2914 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(String, usize)> = None;
2917
2918 for valid in valid_keys {
2919 let valid_lower = valid.to_lowercase();
2920 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
2921
2922 if distance <= max_distance {
2923 if let Some((_, best_dist)) = &best_match {
2924 if distance < *best_dist {
2925 best_match = Some((valid.clone(), distance));
2926 }
2927 } else {
2928 best_match = Some((valid.clone(), distance));
2929 }
2930 }
2931 }
2932
2933 best_match.map(|(key, _)| key)
2934}
2935
2936fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
2937 use toml::Value::*;
2938 match (expected, actual) {
2939 (String(_), String(_)) => true,
2940 (Integer(_), Integer(_)) => true,
2941 (Float(_), Float(_)) => true,
2942 (Boolean(_), Boolean(_)) => true,
2943 (Array(_), Array(_)) => true,
2944 (Table(_), Table(_)) => true,
2945 (Datetime(_), Datetime(_)) => true,
2946 (Float(_), Integer(_)) => true,
2948 _ => false,
2949 }
2950}
2951
2952fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
2954 let doc: toml::Value =
2955 toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2956 let mut fragment = SourcedConfigFragment::default();
2957 let source = ConfigSource::PyprojectToml;
2958 let file = Some(path.to_string());
2959
2960 if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
2962 && let Some(rumdl_table) = rumdl_config.as_table()
2963 {
2964 let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
2966 if let Some(enable) = table.get("enable")
2968 && let Ok(values) = Vec::<String>::deserialize(enable.clone())
2969 {
2970 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2972 fragment
2973 .global
2974 .enable
2975 .push_override(normalized_values, source, file.clone(), None);
2976 }
2977
2978 if let Some(disable) = table.get("disable")
2979 && let Ok(values) = Vec::<String>::deserialize(disable.clone())
2980 {
2981 let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
2983 fragment
2984 .global
2985 .disable
2986 .push_override(normalized_values, source, file.clone(), None);
2987 }
2988
2989 if let Some(include) = table.get("include")
2990 && let Ok(values) = Vec::<String>::deserialize(include.clone())
2991 {
2992 fragment
2993 .global
2994 .include
2995 .push_override(values, source, file.clone(), None);
2996 }
2997
2998 if let Some(exclude) = table.get("exclude")
2999 && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
3000 {
3001 fragment
3002 .global
3003 .exclude
3004 .push_override(values, source, file.clone(), None);
3005 }
3006
3007 if let Some(respect_gitignore) = table
3008 .get("respect-gitignore")
3009 .or_else(|| table.get("respect_gitignore"))
3010 && let Ok(value) = bool::deserialize(respect_gitignore.clone())
3011 {
3012 fragment
3013 .global
3014 .respect_gitignore
3015 .push_override(value, source, file.clone(), None);
3016 }
3017
3018 if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
3019 && let Ok(value) = bool::deserialize(force_exclude.clone())
3020 {
3021 fragment
3022 .global
3023 .force_exclude
3024 .push_override(value, source, file.clone(), None);
3025 }
3026
3027 if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
3028 && let Ok(value) = String::deserialize(output_format.clone())
3029 {
3030 if fragment.global.output_format.is_none() {
3031 fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
3032 } else {
3033 fragment
3034 .global
3035 .output_format
3036 .as_mut()
3037 .unwrap()
3038 .push_override(value, source, file.clone(), None);
3039 }
3040 }
3041
3042 if let Some(fixable) = table.get("fixable")
3043 && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
3044 {
3045 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
3046 fragment
3047 .global
3048 .fixable
3049 .push_override(normalized_values, source, file.clone(), None);
3050 }
3051
3052 if let Some(unfixable) = table.get("unfixable")
3053 && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
3054 {
3055 let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
3056 fragment
3057 .global
3058 .unfixable
3059 .push_override(normalized_values, source, file.clone(), None);
3060 }
3061
3062 if let Some(flavor) = table.get("flavor")
3063 && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
3064 {
3065 fragment.global.flavor.push_override(value, source, file.clone(), None);
3066 }
3067
3068 if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
3070 && let Ok(value) = u64::deserialize(line_length.clone())
3071 {
3072 fragment
3073 .global
3074 .line_length
3075 .push_override(LineLength::new(value as usize), source, file.clone(), None);
3076
3077 let norm_md013_key = normalize_key("MD013");
3079 let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
3080 let norm_line_length_key = normalize_key("line-length");
3081 let sv = rule_entry
3082 .values
3083 .entry(norm_line_length_key)
3084 .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
3085 sv.push_override(line_length.clone(), source, file.clone(), None);
3086 }
3087
3088 if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
3089 && let Ok(value) = String::deserialize(cache_dir.clone())
3090 {
3091 if fragment.global.cache_dir.is_none() {
3092 fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
3093 } else {
3094 fragment
3095 .global
3096 .cache_dir
3097 .as_mut()
3098 .unwrap()
3099 .push_override(value, source, file.clone(), None);
3100 }
3101 }
3102
3103 if let Some(cache) = table.get("cache")
3104 && let Ok(value) = bool::deserialize(cache.clone())
3105 {
3106 fragment.global.cache.push_override(value, source, file.clone(), None);
3107 }
3108 };
3109
3110 if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
3112 extract_global_config(&mut fragment, global_table);
3113 }
3114
3115 extract_global_config(&mut fragment, rumdl_table);
3117
3118 let per_file_ignores_key = rumdl_table
3121 .get("per-file-ignores")
3122 .or_else(|| rumdl_table.get("per_file_ignores"));
3123
3124 if let Some(per_file_ignores_value) = per_file_ignores_key
3125 && let Some(per_file_table) = per_file_ignores_value.as_table()
3126 {
3127 let mut per_file_map = HashMap::new();
3128 for (pattern, rules_value) in per_file_table {
3129 if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
3130 let normalized_rules = rules.into_iter().map(|s| normalize_key(&s)).collect();
3131 per_file_map.insert(pattern.clone(), normalized_rules);
3132 } else {
3133 log::warn!(
3134 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
3135 );
3136 }
3137 }
3138 fragment
3139 .per_file_ignores
3140 .push_override(per_file_map, source, file.clone(), None);
3141 }
3142
3143 for (key, value) in rumdl_table {
3145 let norm_rule_key = normalize_key(key);
3146
3147 if [
3149 "enable",
3150 "disable",
3151 "include",
3152 "exclude",
3153 "respect_gitignore",
3154 "respect-gitignore", "force_exclude",
3156 "force-exclude",
3157 "line_length",
3158 "line-length",
3159 "output_format",
3160 "output-format",
3161 "fixable",
3162 "unfixable",
3163 "per-file-ignores",
3164 "per_file_ignores",
3165 "global",
3166 "flavor",
3167 "cache_dir",
3168 "cache-dir",
3169 "cache",
3170 ]
3171 .contains(&norm_rule_key.as_str())
3172 {
3173 continue;
3174 }
3175
3176 let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
3180 if norm_rule_key_upper.len() == 5
3181 && norm_rule_key_upper.starts_with("MD")
3182 && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
3183 && value.is_table()
3184 {
3185 if let Some(rule_config_table) = value.as_table() {
3186 let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
3188 for (rk, rv) in rule_config_table {
3189 let norm_rk = normalize_key(rk); let toml_val = rv.clone();
3192
3193 let sv = rule_entry
3194 .values
3195 .entry(norm_rk.clone())
3196 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
3197 sv.push_override(toml_val, source, file.clone(), None);
3198 }
3199 }
3200 } else {
3201 fragment
3204 .unknown_keys
3205 .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
3206 }
3207 }
3208 }
3209
3210 if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
3212 for (key, value) in tool_table.iter() {
3213 if let Some(rule_name) = key.strip_prefix("rumdl.") {
3214 let norm_rule_name = normalize_key(rule_name);
3215 if norm_rule_name.len() == 5
3216 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
3217 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
3218 && let Some(rule_table) = value.as_table()
3219 {
3220 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
3221 for (rk, rv) in rule_table {
3222 let norm_rk = normalize_key(rk);
3223 let toml_val = rv.clone();
3224 let sv = rule_entry
3225 .values
3226 .entry(norm_rk.clone())
3227 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
3228 sv.push_override(toml_val, source, file.clone(), None);
3229 }
3230 } else if rule_name.to_ascii_uppercase().starts_with("MD") {
3231 fragment.unknown_keys.push((
3233 format!("[tool.rumdl.{rule_name}]"),
3234 String::new(),
3235 Some(path.to_string()),
3236 ));
3237 }
3238 }
3239 }
3240 }
3241
3242 if let Some(doc_table) = doc.as_table() {
3244 for (key, value) in doc_table.iter() {
3245 if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
3246 let norm_rule_name = normalize_key(rule_name);
3247 if norm_rule_name.len() == 5
3248 && norm_rule_name.to_ascii_uppercase().starts_with("MD")
3249 && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
3250 && let Some(rule_table) = value.as_table()
3251 {
3252 let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
3253 for (rk, rv) in rule_table {
3254 let norm_rk = normalize_key(rk);
3255 let toml_val = rv.clone();
3256 let sv = rule_entry
3257 .values
3258 .entry(norm_rk.clone())
3259 .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
3260 sv.push_override(toml_val, source, file.clone(), None);
3261 }
3262 } else if rule_name.to_ascii_uppercase().starts_with("MD") {
3263 fragment.unknown_keys.push((
3265 format!("[tool.rumdl.{rule_name}]"),
3266 String::new(),
3267 Some(path.to_string()),
3268 ));
3269 }
3270 }
3271 }
3272 }
3273
3274 let has_any = !fragment.global.enable.value.is_empty()
3276 || !fragment.global.disable.value.is_empty()
3277 || !fragment.global.include.value.is_empty()
3278 || !fragment.global.exclude.value.is_empty()
3279 || !fragment.global.fixable.value.is_empty()
3280 || !fragment.global.unfixable.value.is_empty()
3281 || fragment.global.output_format.is_some()
3282 || fragment.global.cache_dir.is_some()
3283 || !fragment.global.cache.value
3284 || !fragment.per_file_ignores.value.is_empty()
3285 || !fragment.rules.is_empty();
3286 if has_any { Ok(Some(fragment)) } else { Ok(None) }
3287}
3288
3289fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
3291 let doc = content
3292 .parse::<DocumentMut>()
3293 .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
3294 let mut fragment = SourcedConfigFragment::default();
3295 let file = Some(path.to_string());
3297
3298 let all_rules = rules::all_rules(&Config::default());
3300 let registry = RuleRegistry::from_rules(&all_rules);
3301 let known_rule_names: BTreeSet<String> = registry
3302 .rule_names()
3303 .into_iter()
3304 .map(|s| s.to_ascii_uppercase())
3305 .collect();
3306
3307 if let Some(global_item) = doc.get("global")
3309 && let Some(global_table) = global_item.as_table()
3310 {
3311 for (key, value_item) in global_table.iter() {
3312 let norm_key = normalize_key(key);
3313 match norm_key.as_str() {
3314 "enable" | "disable" | "include" | "exclude" => {
3315 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3316 let values: Vec<String> = formatted_array
3318 .iter()
3319 .filter_map(|item| item.as_str()) .map(|s| s.to_string())
3321 .collect();
3322
3323 let final_values = if norm_key == "enable" || norm_key == "disable" {
3325 values.into_iter().map(|s| normalize_key(&s)).collect()
3327 } else {
3328 values
3329 };
3330
3331 match norm_key.as_str() {
3332 "enable" => fragment
3333 .global
3334 .enable
3335 .push_override(final_values, source, file.clone(), None),
3336 "disable" => {
3337 fragment
3338 .global
3339 .disable
3340 .push_override(final_values, source, file.clone(), None)
3341 }
3342 "include" => {
3343 fragment
3344 .global
3345 .include
3346 .push_override(final_values, source, file.clone(), None)
3347 }
3348 "exclude" => {
3349 fragment
3350 .global
3351 .exclude
3352 .push_override(final_values, source, file.clone(), None)
3353 }
3354 _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
3355 }
3356 } else {
3357 log::warn!(
3358 "[WARN] Expected array for global key '{}' in {}, found {}",
3359 key,
3360 path,
3361 value_item.type_name()
3362 );
3363 }
3364 }
3365 "respect_gitignore" | "respect-gitignore" => {
3366 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
3368 let val = *formatted_bool.value();
3369 fragment
3370 .global
3371 .respect_gitignore
3372 .push_override(val, source, file.clone(), None);
3373 } else {
3374 log::warn!(
3375 "[WARN] Expected boolean for global key '{}' in {}, found {}",
3376 key,
3377 path,
3378 value_item.type_name()
3379 );
3380 }
3381 }
3382 "force_exclude" | "force-exclude" => {
3383 if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
3385 let val = *formatted_bool.value();
3386 fragment
3387 .global
3388 .force_exclude
3389 .push_override(val, source, file.clone(), None);
3390 } else {
3391 log::warn!(
3392 "[WARN] Expected boolean for global key '{}' in {}, found {}",
3393 key,
3394 path,
3395 value_item.type_name()
3396 );
3397 }
3398 }
3399 "line_length" | "line-length" => {
3400 if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
3402 let val = LineLength::new(*formatted_int.value() as usize);
3403 fragment
3404 .global
3405 .line_length
3406 .push_override(val, source, file.clone(), None);
3407 } else {
3408 log::warn!(
3409 "[WARN] Expected integer for global key '{}' in {}, found {}",
3410 key,
3411 path,
3412 value_item.type_name()
3413 );
3414 }
3415 }
3416 "output_format" | "output-format" => {
3417 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3419 let val = formatted_string.value().clone();
3420 if fragment.global.output_format.is_none() {
3421 fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
3422 } else {
3423 fragment.global.output_format.as_mut().unwrap().push_override(
3424 val,
3425 source,
3426 file.clone(),
3427 None,
3428 );
3429 }
3430 } else {
3431 log::warn!(
3432 "[WARN] Expected string for global key '{}' in {}, found {}",
3433 key,
3434 path,
3435 value_item.type_name()
3436 );
3437 }
3438 }
3439 "cache_dir" | "cache-dir" => {
3440 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3442 let val = formatted_string.value().clone();
3443 if fragment.global.cache_dir.is_none() {
3444 fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
3445 } else {
3446 fragment
3447 .global
3448 .cache_dir
3449 .as_mut()
3450 .unwrap()
3451 .push_override(val, source, file.clone(), None);
3452 }
3453 } else {
3454 log::warn!(
3455 "[WARN] Expected string for global key '{}' in {}, found {}",
3456 key,
3457 path,
3458 value_item.type_name()
3459 );
3460 }
3461 }
3462 "cache" => {
3463 if let Some(toml_edit::Value::Boolean(b)) = value_item.as_value() {
3464 let val = *b.value();
3465 fragment.global.cache.push_override(val, source, file.clone(), None);
3466 } else {
3467 log::warn!(
3468 "[WARN] Expected boolean for global key '{}' in {}, found {}",
3469 key,
3470 path,
3471 value_item.type_name()
3472 );
3473 }
3474 }
3475 "fixable" => {
3476 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3477 let values: Vec<String> = formatted_array
3478 .iter()
3479 .filter_map(|item| item.as_str())
3480 .map(normalize_key)
3481 .collect();
3482 fragment
3483 .global
3484 .fixable
3485 .push_override(values, source, file.clone(), None);
3486 } else {
3487 log::warn!(
3488 "[WARN] Expected array for global key '{}' in {}, found {}",
3489 key,
3490 path,
3491 value_item.type_name()
3492 );
3493 }
3494 }
3495 "unfixable" => {
3496 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3497 let values: Vec<String> = formatted_array
3498 .iter()
3499 .filter_map(|item| item.as_str())
3500 .map(normalize_key)
3501 .collect();
3502 fragment
3503 .global
3504 .unfixable
3505 .push_override(values, source, file.clone(), None);
3506 } else {
3507 log::warn!(
3508 "[WARN] Expected array for global key '{}' in {}, found {}",
3509 key,
3510 path,
3511 value_item.type_name()
3512 );
3513 }
3514 }
3515 "flavor" => {
3516 if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3517 let val = formatted_string.value();
3518 if let Ok(flavor) = MarkdownFlavor::from_str(val) {
3519 fragment.global.flavor.push_override(flavor, source, file.clone(), None);
3520 } else {
3521 log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
3522 }
3523 } else {
3524 log::warn!(
3525 "[WARN] Expected string for global key '{}' in {}, found {}",
3526 key,
3527 path,
3528 value_item.type_name()
3529 );
3530 }
3531 }
3532 _ => {
3533 fragment
3535 .unknown_keys
3536 .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
3537 log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
3538 }
3539 }
3540 }
3541 }
3542
3543 if let Some(per_file_item) = doc.get("per-file-ignores")
3545 && let Some(per_file_table) = per_file_item.as_table()
3546 {
3547 let mut per_file_map = HashMap::new();
3548 for (pattern, value_item) in per_file_table.iter() {
3549 if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3550 let rules: Vec<String> = formatted_array
3551 .iter()
3552 .filter_map(|item| item.as_str())
3553 .map(normalize_key)
3554 .collect();
3555 per_file_map.insert(pattern.to_string(), rules);
3556 } else {
3557 let type_name = value_item.type_name();
3558 log::warn!(
3559 "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
3560 );
3561 }
3562 }
3563 fragment
3564 .per_file_ignores
3565 .push_override(per_file_map, source, file.clone(), None);
3566 }
3567
3568 for (key, item) in doc.iter() {
3570 let norm_rule_name = key.to_ascii_uppercase();
3571
3572 if key == "global" || key == "per-file-ignores" {
3574 continue;
3575 }
3576
3577 if !known_rule_names.contains(&norm_rule_name) {
3579 if norm_rule_name.starts_with("MD") || key.chars().all(|c| c.is_uppercase() || c.is_numeric()) {
3581 fragment
3582 .unknown_keys
3583 .push((format!("[{key}]"), String::new(), Some(path.to_string())));
3584 }
3585 continue;
3586 }
3587
3588 if let Some(tbl) = item.as_table() {
3589 let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
3590 for (rk, rv_item) in tbl.iter() {
3591 let norm_rk = normalize_key(rk);
3592 let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
3593 Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
3594 Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
3595 Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
3596 Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
3597 Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
3598 Some(toml_edit::Value::Array(formatted_array)) => {
3599 let mut values = Vec::new();
3601 for item in formatted_array.iter() {
3602 match item {
3603 toml_edit::Value::String(formatted) => {
3604 values.push(toml::Value::String(formatted.value().clone()))
3605 }
3606 toml_edit::Value::Integer(formatted) => {
3607 values.push(toml::Value::Integer(*formatted.value()))
3608 }
3609 toml_edit::Value::Float(formatted) => {
3610 values.push(toml::Value::Float(*formatted.value()))
3611 }
3612 toml_edit::Value::Boolean(formatted) => {
3613 values.push(toml::Value::Boolean(*formatted.value()))
3614 }
3615 toml_edit::Value::Datetime(formatted) => {
3616 values.push(toml::Value::Datetime(*formatted.value()))
3617 }
3618 _ => {
3619 log::warn!(
3620 "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
3621 );
3622 }
3623 }
3624 }
3625 Some(toml::Value::Array(values))
3626 }
3627 Some(toml_edit::Value::InlineTable(_)) => {
3628 log::warn!(
3629 "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
3630 );
3631 None
3632 }
3633 None => {
3634 log::warn!(
3635 "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
3636 );
3637 None
3638 }
3639 };
3640 if let Some(toml_val) = maybe_toml_val {
3641 let sv = rule_entry
3642 .values
3643 .entry(norm_rk.clone())
3644 .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
3645 sv.push_override(toml_val, source, file.clone(), None);
3646 }
3647 }
3648 } else if item.is_value() {
3649 log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
3650 }
3651 }
3652
3653 Ok(fragment)
3654}
3655
3656fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
3658 let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
3660 .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
3661 Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
3662}
3663
3664#[cfg(test)]
3665#[path = "config_intelligent_merge_tests.rs"]
3666mod config_intelligent_merge_tests;